Adding read tools¶
Précis-MCP exposes your financial model to MCP clients through a small set of
read-only tools — run_statement, run_metric, inspect_rows, the
discovery tools, and the ingestion-status reads (the advertised set is
catalogued in the MCP tool reference). This
guide is the contract for adding a tool of your own: where the function
lives, how it gets registered and advertised, what the permission gate does
for you, and what your tool must still do itself.
Everything here stays inside the precis_mcp package. Précis-MCP is read-only
by design — a new tool may query ClickHouse, the catalogue, or the platform
database, but it must not write to or mutate any of them.
Architecture sketch of this slice¶
A tool call over the /mcp endpoint passes through four stations:
tools/list tools/call
│ │
[1] TOOL_CATALOGUE + build_descriptors │ precis_mcp/dispatch.py
│ (what exists, with what access) │
[2] mcp_tool_variants + descriptions │ precis_mcp/mcp_external/framing.py
│ (how it is advertised) │
└──────────────► [3] process_tool_call (coercion, context, permission gate)
│ precis_mcp/dispatch.py
[4] your tool function, then frame_tool_result
│ precis_mcp/tools/*, framing.py
▼
JSON-RPC result envelope
- Tool functions live in
precis_mcp/tools/(read_tools.py,ingestion_tools.py). Each module exposes aregister_*_tools(mcp, ref)factory that registers its functions with the@mcp.tool()decorator — or@register_mcp_tool(mcp)fromprecis_mcp/tools/_mcp_register.pywhen the function takes a_scopeparameter (see Traps). precis_mcp/dispatch.pyis the single source of truth for tool metadata._load_open_tools()calls the factories;TOOL_CATALOGUEholds one declarative entry per tool;build_descriptors()joins the two into a name →ToolDescriptorindex and raises at startup for any loaded tool without a catalogue entry.build_tool_json_schema()derives the JSON Schema a client sees, andprocess_tool_call()is the pre-execution pipeline every call runs through.precis_mcp/mcp_external/server.pyis the authenticated/mcpJSON-RPC transport (OIDC bearer tokens — see Remote access). It advertises themcp_read-flagged subset of the catalogue, resolves variant names, runs the gate, executes the tool, and frames the result.precis_mcp/server.pyis the single-user dev server (FastMCP over Streamable HTTP behindMCP_DEV_KEY— see the quickstart). It calls the sameregister_*_toolsfactories directly, so a new module must be registered there too if you want it reachable on the dev server.
There is exactly one execution path. The catalogue, the gate, and the framing are the three places a new tool touches.
Invariants¶
- Every loaded tool has a catalogue entry.
build_descriptorsfails closed at startup otherwise — an uncatalogued tool would silently inheritaccess="read"and bypass the scenario-scope gate. A tool needing only the defaults still gets an explicit empty entry:"my_tool": {}. - Tools return a dict, always. On failure return
{"error": "...", "error_type": "..."}— never raise. The calling model reads the return value as its next context and can correct itself from a good error message. - The docstring is the model's documentation. It is the fallback tool
description on
tools/listand the only thing the model knows about your parameters. Write it as if briefing a colleague who has never seen the code. - Never read identity inside the tool. Declare
user_id: str = ""and/or_scope: dict | None = Noneparameters; the wrapper injects them from the request'sAuthContextand the gate's per-call scope. Both are stripped from the advertised schema (_INJECTED_PARAMSindispatch.py), so a client can never supply them. - Enforce
_scopeagainst whatever you query. The gate decides whether the caller may touch a scenario; your tool must apply the per-scenario domain/dimension allow/deny to the query itself. The engine does this for you when you passscope=_scopeintoexecute_report(precis_mcp/engine/scope_enforcer.pyapplies it); a tool that queries ClickHouse directly must honour it explicitly. - Results must be JSON-clean. Datetimes,
Decimal,UUID, NaN/Inf from pandas-backed paths — convert at the tool boundary._json_safe_inspection_resultinread_tools.pyand_serialise_load_rowsiningestion_tools.pyare the canonical converters. - Never leak internals in error messages. Driver exceptions can embed SQL
or filesystem paths. Log the detail, return a generic message — the
_safe_executehelper inread_tools.pyis the pattern. mcp_readis opt-in. A tool is advertised and callable over/mcponly if its catalogue entry setsmcp_read: Trueand itsaccessisreadorgeneral(checked again at call time as defense-in-depth). The defaultFalsekeeps theaccess="read"default from silently publishing every tool.
The catalogue entry¶
TOOL_CATALOGUE in precis_mcp/dispatch.py maps tool name → metadata dict.
The fields, with their ToolDescriptor defaults:
| Field | Default | Purpose |
|---|---|---|
access |
"read" |
Gate class. For this package the meaningful values are read (scenario-scoped, analyst+), general (no role/scope check at all — for tools with no per-scenario data; no open tool currently uses it), and admin (requires the is_admin user flag; e.g. reload_integrations). write / plan_manager exist in the model but no tool here uses them, and the /mcp transport refuses to execute anything that isn't read or general. |
mcp_read |
False |
Publishes the tool over /mcp (tools/list and tools/call). Opt-in, independent of access. |
scenario_params |
() |
Names of parameters that carry scenario references. Each referenced scenario is checked against the caller's per-scenario permissions, and the per-scenario ScopeSpec map is injected as _scope. Handles string params ("scenario_id") and list-of-dict params ("scenarios" with [{"scenario": "..."}]). |
report_context |
() |
Session-default injection keys (filters, scenarios, scale, …). Each key must name a real parameter on your signature — the schema is extra='forbid', so injecting a key the function doesn't accept rejects the call. Unknown keys raise at startup. |
dimension_map_args |
{} |
Maps a dict-shaped arg to a catalogue resolver (e.g. {"filters": "report_domains"}) for validation in process_tool_call. |
skills |
set() |
Visibility tags consumed by the Précis platform's agent layer; inert over the /mcp transport. Leave it out. |
What process_tool_call does before your function runs, in order:
- Coercion — params named in
_LIST_PARAMS(columns,dimensions,metrics,scenarios) and_DICT_PARAMS(filters,chart_spec, …) are un-double-encoded when a model sends them as JSON strings, and list-of-str params are shape-checked with a corrective error. Name your parameters from these sets to inherit the coercion. - Report-context injection — per the
report_contextdeclaration. - Permission gate —
admin→is_adminflag;general→ pass; otherwise every scenario named inscenario_paramsis expanded to its underlying scenario ids (registry aliases, dependencies, fork targets) and checked against the caller'spermissions.scenarios[sid].tool_scopes. On success the per-scenario scope map is placed on the_call_scopecontextvar for injection. The gate runs before the catalogue-backed validations so a denied caller can't probe dimension members through validation errors. - Dimension-map and dimension validation against the metric catalogue.
Worked example¶
A tool answering "which periods have successfully landed for a dataset?" —
read-only, queries the load_history audit table, no scenario scoping.
1. Write the function. New module precis_mcp/tools/coverage_tools.py:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
def register_coverage_tools(mcp: "FastMCP", catalogue_ref=None) -> None:
"""Register data-coverage tools on the MCP server instance."""
@mcp.tool()
def list_dataset_periods(dataset_id: str, limit: int = 24) -> dict:
"""List the accounting periods that have successfully landed for a dataset.
Returns the distinct periods (YYYYMM, most recent first) with at least
one successful load in `load_history`, up to `limit` periods.
Use this to answer "is April in yet?" or "how far back does the GL go?"
Args:
dataset_id: e.g. 'gl' — the dataset to check.
limit: period cap (default 24, max 120).
Returns:
{"dataset_id": ..., "periods": ["202604", ...], "count": N}.
On error, {"error": "...", "error_type": "..."}.
"""
from precis_mcp.db import query_platform
if not dataset_id:
return {"error": "dataset_id is required.", "error_type": "validation"}
limit = max(1, min(int(limit), 120))
rows = query_platform(
"""
SELECT DISTINCT period FROM load_history
WHERE dataset_id = %s AND status = 'success' AND period IS NOT NULL
ORDER BY period DESC LIMIT %s
""",
(dataset_id, limit),
)
periods = [r["period"] for r in rows]
return {"dataset_id": dataset_id, "periods": periods, "count": len(periods)}
Plain values only — strings and ints survive json.dumps as-is. If you select
timestamp or numeric columns, convert them (see _serialise_load_rows).
2. Load it. Add the factory call to _load_open_tools() in
precis_mcp/dispatch.py:
from precis_mcp.tools.coverage_tools import register_coverage_tools
register_coverage_tools(reg, catalogue_ref)
And, if the tool should also be reachable on the single-user dev server, add
the same registration to precis_mcp/server.py.
3. Catalogue entry. In TOOL_CATALOGUE:
"list_dataset_periods": {
"mcp_read": True,
},
access stays at the read default; there are no scenario params, so the
gate passes the call through. Without mcp_read: True the tool would load but
never appear on /mcp.
4. Description (optional but recommended). tools/list falls back to the
docstring's first line. For a fuller, MCP-accurate description add an entry to
_MCP_TOOL_DESCRIPTIONS in precis_mcp/mcp_external/framing.py:
"list_dataset_periods": (
"List the accounting periods that have successfully landed for a "
"dataset — most recent first."
),
5. Done. The tool is not in _SPLIT_TOOLS and not widget-linked, so it is
advertised as a single plain data tool (out='agent'): the model gets the raw
dict in both content and structuredContent. Nothing else to wire.
A scenario-scoped tool differs in two ways. The signature carries the injected params and uses the registration shim:
from precis_mcp.tools._mcp_register import register_mcp_tool
@register_mcp_tool(mcp) # NOT @mcp.tool() — FastMCP rejects `_`-prefixed params
def my_scoped_tool(
scenario_id: str,
user_id: str = "", # injected from AuthContext — hidden from clients
_scope: dict | None = None, # injected per-call scope — hidden from clients
) -> dict:
...
and the catalogue entry declares the scenario param:
"my_scoped_tool": {
"scenario_params": ("scenario_id",),
"mcp_read": True,
},
_scope arrives as {scenario_id: ScopeSpec | None} (see
precis_mcp/auth.py — ScopeSpec carries domain and dimension allow/deny
axes; None means unrestricted). Pass it into execute_report or apply it to
your own query. run_statement / run_metric / inspect_rows in
read_tools.py are the live references for all of this.
How /mcp advertises a tool¶
precis_mcp/mcp_external/framing.py owns the shape of the advertised surface.
An MCP host binds a widget to a tool definition — there is no per-call
output toggle — so one underlying tool can be advertised as several MCP names
with the output mode pinned per name:
_SPLIT_TOOLS(run_statement,run_metric) are advertised twice: the bare name (out='render', widget-linked) and<name>_data(out='agent', raw figures).mcp_tool_variants()produces the list;resolve_mcp_tool()maps an advertised name back to(underlying_tool, pinned_out)at call time.- Widget-linked tools (
_WIDGET_URI: financial-table, inspection-grid, chart) carry_meta.ui.resourceUripointing at aui://precis/<component>resource, served from the built bundle inui/mcp-widgets/dist/.widget_uri_for()only advertises a URI whose bundle is actually built. A widget-linked tool not in_SPLIT_TOOLSis render-only; everything else is a single raw-data tool. - Descriptions come from
_MCP_TOOL_DESCRIPTIONS(falling back to the docstring's first line), with_variant_description()inmcp_external/server.pyappending the render-vs-data hint to split tools. The longer "how do I use this data model" guidance reaches the model through the syntheticprecis_orientationtool's result (mcp_external/instructions.py) — hosted clients drop theinitializeinstructions field, so don't put load-bearing guidance there. - Dead params (
_DEAD_MCP_PARAMS = {out, report_id, position}) are stripped from every advertisedinputSchema—outis pinned by the variant, and the other two belong to output paths the transport rejects. - Result framing —
frame_tool_result()builds the JSON-RPC envelope. Bothrenderandagentreturn the raw engine result oncontentandstructuredContent, so the model always reasons over stable figures; therendervariant additionally carries its rendered display block on the result's_meta(RENDER_BLOCK_META_KEY) for the host to bind its widget to — widget-only, not shown to the model.out='report'raisesFramingError.
For a plain data tool you touch none of this — the defaults advertise it as a
single raw-data tool. You only edit framing.py to add a description, and the
variant/widget machinery only matters if you build a widget for your tool.
Extension seams¶
Several registration hooks exist so a downstream product can extend this surface at startup without the open package importing it. In an open deployment they are simply unregistered, and the corresponding behaviour is absent:
dispatch.register_tool_loader(loader, catalogue_entries)— add a batch of tools plus their catalogue metadata without editingdispatch.py. Also useful for your own tools if you'd rather keep them out of the package tree.framing.register_mcp_tool_description(name, description)— description override for a registered tool.framing.register_mcp_output_mode(McpOutputMode)— an extra split-tool variant with its own result framer (e.g. an_exceldownload variant);is_available()gates its advertisement.server.register_mcp_render_builder(tool_name, builder)— the render-block builder for a tool'srendervariant. The open transport registers the finance-table (precis_mcp/table_builder.py) and inspection-grid (precis_mcp/inspection_grid_builder.py) builders natively.precis_mcp/read_tool_hooks.py— hooks inside the read tools themselves: an output renderer for non-coreoutmodes, a chart-result cache (mints thedata_ref), and an Excel dispatcher. Unregistered, the read path returns figures with nodata_refandout='excel'/out='report'are unsupported.
(The Précis platform uses these same seams to attach its SPA block emitters and richer output modes — that machinery is out of scope here.)
Auth and scope — what a tool author must and must not do¶
Must:
- Declare
user_id: str = ""if you need the caller's identity (audit rows, per-user filtering). It is injected fromget_auth_context().user_id. - Declare
_scope: dict | None = Noneandscenario_paramsin the catalogue if the tool reads scenario-bearing data, then enforce the scope against the query. Declaring the param without the catalogue entry means the gate never runs and nothing is injected — a silent authorization hole. - Filter visibility yourself when returning per-scenario metadata: the gate
only checks scenarios named in the arguments.
list_scenariosinread_tools.pyshows the pattern — it drops rows the caller'spermissions.scenarios[sid].tool_scopesdoesn't grantreadon. - Use
@register_mcp_tool(mcp)for any function with a_-prefixed parameter.
Must not:
- Call
get_auth_context()/get_call_scope()for things the injected params already give you — the contextvars are transport plumbing, set per request and cleared in the transport'sfinallyblock. (Readingget_auth_context().permissionsfor visibility filtering, aslist_scenariosdoes, is the one legitimate direct use.) - Cache scope or identity across calls in module state.
- Accept
user_idor_scopefrom the client — you can't; the schema derivation strips them. Don't try to re-add them under another name either. - Write. No INSERTs into anything except best-effort audit rows, no
filesystem output. (
security_audit_logandinspection_auditwrites follow the "log on failure, never block the response" pattern.)
Testing¶
Tests live in the same repo, split by class:
tests/unit/— pure logic, no database, no network. Helpers like serialisers and validators belong here (e.g.tests/unit/test_read_tools_json_safe.py).tests/component/— register your factory againsttests/fakes/mock_mcp.MockMCP(an in-memory FastMCP substitute that captures@mcp.tool()registrations into a dict) and substitute the backends with the shared fakes:tests/fakes/fake_clickhouse.pyandtests/fakes/fake_platform_db.py, plus the factories intests/factories/.tests/component/test_ingestion_read_tools.pyis a complete template — fake platform DB monkeypatched in, factory registered onMockMCP, tool invoked as a plain function.tests/open_tests.txt— the manifest of test files that belong to this package; it is what defines the suite. Add your new test file to it. The membership rule: a listed test imports only from theprecis_mcptree and the shared test infrastructure (tests/fakes/,tests/factories/); a collection-time guard intests/conftest.pyfails the run on violations.
A useful component test for a new tool covers at minimum: the happy path, the
{"error": ...} path for bad input, and (for scoped tools) that the scope
passed as _scope actually constrains the result.
Failure modes and traps¶
@mcp.tool()on a_scope-bearing function crashes the server at startup. FastMCP raisesInvalidSignaturefor_-prefixed parameters at decoration time.MockMCP-based tests will not catch this — they duck-type the decorator. Use@register_mcp_tool(mcp), which strips hidden params from the FastMCP-exposed signature, and smoke-test registration against a realFastMCPinstance if in doubt.- Missing catalogue entry → startup failure. Deliberate
(
build_descriptorsfails closed). The error message names the tool. - Forgotten
mcp_read: True→ tool invisible on/mcp. It loads, the dev server serves it, but the external transport answers "Tool not exposed over MCP". Conversely, flaggingmcp_readon anadmintool gets it advertised but every call rejected — the call-time gate requires read-class access. report_contextkeys that aren't real parameters reject every call. The advertised schema isextra='forbid'; an injected key the function doesn't accept fails validation. Unknown key names are caught earlier, at startup.- NaN/Inf in results. Pandas maps SQL NULL to
float('nan')viaDataFrame.to_dict("records"); strict JSON consumers downstream reject it. Sanitize at the tool boundary —_json_safe_inspection_resultis the canonical converter. - Raw exception text in errors. Driver exceptions embed SQL and paths.
Catch broadly, log with
logger.exception, return"...failed with an internal {type}; details were logged."— the_safe_executepattern. - Scenario params the gate can't see.
_extract_scenario_idsonly reads the parameter names listed inscenario_params. Rename a parameter without updating the catalogue and the permission check silently stops applying. - List/dict params outside the coercion sets. Models sometimes
double-encode structured args as JSON strings. Only params named in
_LIST_PARAMS/_DICT_PARAMS(indispatch.py) get un-mangled; a custom-named dict param receives the raw string and your tool must cope or reject.
Do / Don't¶
| Do | Don't |
|---|---|
Return {"error": "...", "error_type": "..."} on failure |
Raise exceptions out of the tool |
| Write a precise, complete docstring | Rely on the parameter names to explain themselves |
Add one TOOL_CATALOGUE entry per tool (empty dict is valid) |
Scatter metadata, or skip the entry (startup fails) |
Declare user_id / _scope params and let the wrapper inject them |
Read the auth contextvars for identity decisions inside the tool |
Pass scope=_scope into execute_report, or enforce it on your own query |
Treat the gate's scenario check as full enforcement |
Use @register_mcp_tool(mcp) for _-prefixed params |
Use @mcp.tool() and find out at server startup |
| Convert datetimes/Decimal/NaN at the tool boundary | Trust the backend's types to be JSON-clean |
Set mcp_read: True deliberately, per tool |
Assume access="read" publishes the tool |
| Keep error messages generic; log the detail | Echo driver exceptions to the client |
Pre-flight checklist¶
- [ ] Tool function in
precis_mcp/tools/<domain>_tools.py, registered by aregister_*_tools(mcp, ref)factory - [ ] Correct decorator:
@mcp.tool(), or@register_mcp_tool(mcp)if the signature has_-prefixed params - [ ] Returns a dict; error path returns
{"error", "error_type"}; output is JSON-clean - [ ] Factory wired into
_load_open_tools()inprecis_mcp/dispatch.py(andprecis_mcp/server.pyif the dev server should serve it) - [ ]
TOOL_CATALOGUEentry with the rightaccess,scenario_params, andmcp_read - [ ] Scoped tools:
_scopedeclared and enforced against the query - [ ] Description added to
_MCP_TOOL_DESCRIPTIONSinmcp_external/framing.py(or the docstring's first line is good enough) - [ ] Component test on
MockMCP+ fakes intests/component/; pure helpers intests/unit/ - [ ] New test files added to
tests/open_tests.txt
What this guide doesn't cover¶
- Defining metrics, statements, and dimensions — that's catalogue configuration, not code: Catalogue & semantic model.
- Loading data — Ingestion & data sources.
- Standing up the transports — quickstart
for the dev server, remote access for the
authenticated
/mcpendpoint. - Anything that writes. Précis-MCP is the read surface; there is no supported path for a write tool in this package.