Skip to content

feat(mcp): a leaner public tool list, bare spec_id frames, relative years, one refusal envelope and export_data - #124

Merged
jm-rivera merged 68 commits into
devfrom
feat/public-mcp-surface
Oct 30, 2026
Merged

jm-rivera merged 68 commits into
devfrom
feat/public-mcp-surface

Conversation

@jm-rivera

@jm-rivera jm-rivera commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

The public MCP server at /mcp lists 16 tools at 18,018 o200k tokens, down from 19 tools at 27,054. It adds export_data, takes a bare spec_id wherever a frame goes, rolls windows over relative years, and gives every refusal one envelope whose fix names only arguments the tool accepts.

Surface

  • compare takes operation (difference or sum) and share_of takes operation (share or quotient). Each form runs today's sum_of or quotient_of path with the same frame id, digest and refusal codes. A share refused cross_frame_unit_mismatch between two plain units sets fix to operation: "quotient".
  • get_skill takes path, offset and digest, and with a path returns what get_skill_file returns.
  • sum_of, quotient_of, get_skill_file and resolve_anchor stay on the per-server mounts the Studio harness calls, and leave the public list. A call to one of the first three refuses tool_not_found with a fix naming the merged tool and the argument to set.
  • common.declaration applies the declaration rules to every tool at registration. It removes $comment, empty required and the null branch of a property that states default: null, inlines every definition, and keeps each sentence once. It refuses a schema whose references it cannot inline.
  • Each child server declares the public shape of the tools whose public form differs (PUBLIC_SHAPES): paragraphs the public description adds, properties it hides and arguments it forces. The combined server applies them to every tool alike. A public call that sends a hidden, unforced argument refuses it as unknown; a forced argument the caller sends is accepted and overwritten.
  • The public get_data forces place_types and asks the dfo-apis mint for enrichment: "default", so every public frame carries the type and party columns of the roles its version holds; dfo-ai reads the columns from the rendering the mint served. rank, aggregate and share_of take place_level, sent to dfo-apis as a filter step under the verb.
  • A test bounds the public list in o200k tokens (_PUBLIC_LIST_TOKEN_BOUND), and another holds every public schema to the declaration rules. A test holds every code the servers write to its mcp.tools mapping in the pinned registry.

Frames

  • Frame arguments are typed objects: spec_id required, content_version optional. A bare spec_ id reads the newest version, the one the dfo-apis analysis runner's Spec root reads: the verbs send it as a Spec root and record the pair it names; describe_frame, write_component and export_data send it to the dfo-apis routes that resolve it. The public server registers a bare id's stored definition first, so an id fresh from search works on all four paths. A bare id of any other kind refuses frame_ref_incomplete.
  • On the public server a verb rebuilds every lost input the runner names (a spec_ frame by fetching it again in the rendering its mint record states, an ops_ frame by replaying its recipe with latest: true) and retries; describe_frame and write_component create do the same for a 404 frame_unavailable whose cause.code is unknown, expired or ops_unsupported, and each reply names the pair asked for and the one read. common.materialisation.rendering answers a frame's rendering for all three. A frame whose rendering cannot be established, and in presentation-mcp a summed or cut frame, refuses rendering_not_recorded with a fix naming get_data, since dfo-apis has no route that reads a Spec at a rendering outside an analysis tree. A bare read records the pair it resolved. The harness mounts keep frame_not_held and frame_unavailable, so Studio runs its own replay. A hidden rebuild_lost_frames argument, forced true by PUBLIC_SHAPES, carries the difference; common.derived_replay holds the one replay call.

Rolling periods

  • growth and rank window bounds and the filter threshold value take {"anchor": "latest", "offset": n} beside a four-digit year. dfo-apis resolves the latest year from the tree, and the digest names it and the years it resolved.
  • A public call whose tree holds an ops_ input sends rolling: "tree", so dfo-apis rolls those inputs' periods over the whole tree; the digest names any input computed again. A derived input with no stored recipe refuses rolling_reanchor_unavailable. The harness sends no rolling field and keeps its own populations.

Refusals

  • presentation-mcp refusals carry code, retry_mode, message and fix in structuredContent and the same facts in content, with isError, as data-mcp and analysis-mcp do. Advice moves into the message; _meta keeps the assembler's refusal for the harness, so Studio's refusal is unchanged.
  • request_invalid carries fix {tool, problems, accepted} on every server, with dfo-apis pointers rebased onto the tool's own arguments. Tests sample every refusal code of data-mcp, analysis-mcp and presentation-mcp and check each fix against the tool's declared schema.

export_data

export_data(frame_ref, format, bundle) prepares the file through dfo-apis POST /api/datasets/exports, forwarding the caller, and returns https://developmentfinance.org/api/datasets/exports/{export_id}. That link answers 302 to a five-minute signed URL and does not expire. The result and its digest carry the file's name, size, rows, columns, SHA-256, citation and licences. A get_data frame exports the stored version its mint record names, and the result names both. A summed or broken-down get_data frame refuses capability_unavailable and names the aggregate route, because the export route takes no groupby. A frame with no mint record refuses rendering_not_recorded. A frame dfo-apis no longer holds refuses frame_unavailable with the call that registers it again; an export never moves to another version by itself.

Order

  1. Merge feat(registry): register the export and MCP refusal codes spec-kit#37 with a merge commit, which registers the new codes and keeps 2dd2400, the commit this PR and feat(datasets,presentation): export any frame to a lasting link, read frames by spec_id, and roll a pairing over both sides apis#194 pin, in spec-kit's dev history. A squash merge drops it, and both PRs then need re-pinning to the squash commit.
  2. Merge apis#194 and let it deploy to dev. Its analysis route accepts rolling: "tree", and its export and bare-spec_id routes are what export_data, describe_frame and write_component call, and its cause.code on frame_unavailable is what presentation-mcp's rebuild reads; dev dfo-apis refuses the rolling field until then.
  3. Merge this PR.

The nine analysis verbs send a one-node recipe tree to POST
/api/datasets/analysis, and resolve_anchor asks POST
/api/datasets/analysis/anchor for the latest period, so the MCP, the
website and the SDKs share one engine. Replies are built from the
runner's summary: on every recorded tool call the digest text and
structured content match the in-process tools. Runner refusals map to
the existing ToolRefusal codes, a pending root replies
service_unavailable with the runner's retry time, and any other code
replies upstream_error naming it. The website's caller digest is
forwarded with the caller principal.

presentation-mcp reads an ops_ frame from the platform when no publish
principal is configured. The in-process engine and tool bodies stay
until the production recipe diff passes.
…uted them

Studio's harness reads a verb result from the shared store when it
holds the frame and otherwise from dfo-apis: publish_result takes the
derived manifest's recipe and replay key and publishes nothing, chart
work reads the derived manifest and Parquet file, rolling cards and
recipe replay read stored recipes from dfo-apis, so Studio works on
frames the runner computed. rendering_not_recorded is a stop code.
Harness tests run verbs through the recorded runner replay; a strict
xfail pins the runner refusing a rolling call over one side of a paired
population.
A frame fetched with a breakdown or a stat_vars sum is a verb input
again: runner_tree copies the mint record's groupby into the root's
rendering, which dfo-apis reads through the same head rendering as a
definition read. Only mint options dfo-apis cannot read still refuse
rendering_not_recorded. The harness decodes a derived file as the flat
frame-layout Parquet dfo-apis now serves. Both packages' runner replies
are recorded again against dfo-apis.
A rolling growth, rank or filter sends the roots resolve_anchor was
asked about as anchor_roots, built as the tree's own roots are, so
dfo-apis takes the latest period over the same population; a key with
no stored anchor record refuses rolling_anchor_unresolved before any
request. recipe_replay takes a stored tree's period over its
anchor_roots. data_mcp's Parquet frame decoder is public as
frame_from_parquet_bytes. Runner replies are recorded again.
… exact

The harness decodes a derived file whose unit is composed, such as a
quotient of dollars by people, so a chart over a quotient_of result
reads its series; a decode failure is logged with the frame pair. A
retryable recipe read withholds a rolling card as pending instead of
fixing its periods, and describe_frame answers it as transient.

analysis-mcp sends a root's columns sorted and once each, as dfo-apis
writes them; refuses a stored anchor record naming a derived root; maps
a lost root behind a derived input to derived_input_unrecoverable on
that input; and refuses a failed mint record read as retryable.
presentation-mcp sends its first-party principal on card create and
revise, so harness conversations spend no caller's card allowance.
The recorder starts each recording from an empty fixture, and both
packages' runner replies are recorded again.
Comments and docstrings for the runner client, verbs, derived frames
and recipe replay read as plain statements, and the recorder names
the dfo-apis sync it needs.
Every package pins spec-kit 03d8f5a. The harness classes the new
presentation refusal codes, frame_codec reads the stored two-member
unit layout as spec-kit's Frame.from_table now does, and both runner
reply fixtures are recorded against dfo-apis 605f87bed.
…al dfo-apis

Runner reply fixtures in analysis-mcp and the harness are recorded against dfo-apis e152d0b2d, whose output digest names a computed variable by what was computed.
presentation-mcp tells Studio's card writes from outside MCP clients by
transport: a call through the harness's loopback mounts sends the
first-party principal, and any other sends the website-vouched caller
under DFO_CALLER_PRINCIPAL, so dfo-apis counts that client's card
allowance; with no named client, outside writes share one allowance.

Every rolling call checks its population and the roots its answer
names against the anchor record. A lost anchor root refuses
rolling_anchor_unresolved, a lost root names only the inputs read from
it, and two sides over one version recover in one refusal. Derived
reads take their own 15 s timeout, summaries ignore fields dfo-apis
adds and refuse malformed parameters as upstream_error, and join and
filter summaries keep only what the digest shows. make_charts reads
each derived frame once. The docs and .env.example state both
principals as they work.
CALLER_PRINCIPAL_HEADER and ADDRESS_CALLER live in common.caller, which
analysis-mcp's runner_client and presentation-mcp's client both import.
.env.example and the analysis-mcp README document
DERIVED_READ_TIMEOUT_S (15 s).

The runner recorder passes version_writer to DerivedReads, which dfo-apis
requires, and both runner_replies.json fixtures are re-recorded against
dfo-apis 1f28a9405. Only trace_id values changed.
data-mcp's frame_ref_for puts "without_party_totals": true in the cv_
preimage when the cut is set and sorts groupby.by, matching dfo-apis;
both repos pin cv_bb722d06259f357e6961a081f1c1f141 for one input.
mint_recovery tries each candidate with and without the cut, so refs
named before this change still recover. MintRecord sorts
options.groupby.by and get_data sends by sorted, so one breakdown has one
recipe normal form in both repos.

The runner recorder resolves the cut the way dfo-apis does, and both
runner_replies.json fixtures are re-recorded against dfo-apis b8b270ea9
with the same status mix: analysis-mcp 83 replies, harness 164.
b7a40ad names minted variables mv_ plus a fixed-size digest and admits
a derived ref root that carries the groupby DFO read it with.
recipe_replay checks a breakdown root against its record's cut and
against each order of groupby.by named_groupbys lists: sorted, as
recorded, then every order up to four axes. mint_recovery tries the same
orders, so pairs named before the sort recover. A mint record keeps its
options as written and refuses a malformed groupby; data-mcp sorts by
where it writes options, so a stored recipe's normal form does not
change when it is read back.
0cb55e3 adds a nested minted-name vector with name documents and records
the grouped-root ref vectors from a dfo-apis run, with by sorted.
# Conflicts:
#	services/harness/src/harness/orchestrator_tools.py
#	services/harness/tests/executor/test_wide_frame.py
…im the declared schemas

presentation-mcp refusals carry code, retry_mode, message and fix in structuredContent and the same facts in content, with isError. common.declaration.declared_schema applies the declaration rules to each tool's schema at mount and refuses a release whose references it cannot inline. A create or describe_frame naming a bare spec_id refuses frame_ref_incomplete.
…ared schemas

get_skill takes path, offset and digest and, with a path, returns what get_skill_file returns, through one implementation; paging lines name get_skill. common.declaration applies the declaration rules to every data-mcp tool at registration.
…are_of, and shape the public list

compare takes operation difference or sum, and share_of takes share or quotient, each form keeping its own engine path. Frame arguments are typed objects. The combined server hides sum_of, quotient_of, get_skill_file and resolve_anchor and answers the removed names with the call to use, returns schema failures as request_invalid with problems and accepted, strips the rolling arguments from the public growth, rank and filter, and gives the public get_data its type columns. A ratchet test bounds the public list in o200k tokens.
Each child server exports PUBLIC_SHAPES: paragraphs the public description adds, properties it hides and arguments it forces. The combined server applies them to every tool alike, replacing sentence matching. Text about a hidden argument lives in that argument's own description. Re-pins search, get_data, growth, rank and filter, re-records the context baseline, and widens the schema and naming checks to the whole public list.
export_data prepares the file through POST /api/datasets/exports, forwarding the caller, and returns the public link on DFO_SITE_ORIGIN with the file's facts, citation and licences. Refusals map to the shared envelope.
# Conflicts:
#	services/data-mcp/src/data_mcp/server.py
# Conflicts:
#	services/analysis-mcp/src/analysis_mcp/server.py
#	services/analysis-mcp/src/analysis_mcp/tool_pipeline.py
#	services/analysis-mcp/src/analysis_mcp/verbs.py
#	services/harness/src/compiler/capabilities.py
#	services/harness/tests/test_combined_mcp.py
The pin adds the rebuild refusals on datasets.exports and spec-kit dev's presentation.shares import codes; the harness classes share_import_not_permitted as unavailable and share_id_taken as a system fault.
datasets.exports never answers not_found for a frame, so a 404 not_found from the prepare route means the route is absent.
…to calls that roll over the tree

recipe_not_found on a derived input becomes rolling_reanchor_unavailable, and inputs are read for the computed-again sentence, only when the request sent rolling "tree"; harness calls keep frame_not_held and make no manifest reads. An input whose manifest cannot be read is logged and left out. A wire test pins a public rolling rank with place_level.
…lic server

_PublicArguments raises the unexpected-keyword error for any argument a tool's PublicShape hides, forced or not, so the caller gets request_invalid with the public argument list and the child never validates its hidden schema.
… from

The public get_data names its frame with a content_version dfo-ai derives from the columns, which dfo-apis never stored. export_data maps a spec_ pair through its mint record to the dataset pair, sends that, and names both the caller's frame and the exported version.
…unforced ones

A public call that sends place_types or a verb flag keeps working, with the forced value written over it; rolling and anchor_key still refuse as unknown. The get_skill_file redirect names the path argument.
…n public calls

describe_frame and write_component create fall back to the bare spec_id route when dfo-apis no longer holds the pair, and name both the version asked for and the one read. A hidden rebuild_lost_frames argument, forced true by presentation-mcp's PUBLIC_SHAPES, limits this to the public server; the Studio mounts still refuse frame_unavailable.
The public get_data sends enrichment "default" and reads the columns from the one rendering the mint served, so a flow identity that binds no role gets its party columns and dfo-ai no longer derives columns from the identity. The party-kinds check reads the frame's coverage roles. Frame names for frames that worked before are unchanged.
…e helper

common.materialisation.dataset_pair holds the rule export_data and presentation-mcp's PlatformAssembler both applied.
A frame whose mint record carries a groupby can share its base version, so exporting the record's dataset pair would hand over unsummed rows. export_data maps only records that add columns or cut party totals, and refuses the rest with capability_unavailable naming the aggregate route.
…calls

describe_frame and write_component create, with rebuild_lost_frames set, answer a 404 frame_unavailable by reading a spec_ id at its newest version or replaying an ops_ frame's recipe on current data, and name both versions. common.derived_replay holds the replay call analysis-mcp also uses.
frame_mint_record holds the spec_ guard and failure logging; dataset_pair and export_data's summed-frame check both read through it.
@jm-rivera jm-rivera changed the title feat(mcp): a leaner public tool list, one refusal envelope and export_data feat(mcp): a leaner public tool list, bare spec_id frames, relative years, one refusal envelope and export_data Oct 30, 2026
common.declaration collapses a nullable only where the property states default null, so a presentation patch can still clear a field with null. The public list measures 18,018 o200k tokens.

A malformed bare spec_id refuses as an argument error. request_invalid names the tool's own arguments on export_data and get_data. place_levels_mixed advises place_level only on tools that take it, and a public relative year with no latest year asks for fixed years in place of the hidden resolve_anchor. frame_ref_incomplete caps the id it echoes, through truncate_value, now in common.echoed_text.
common.materialisation.rendering answers a frame's rendering from its mint record. analysis-mcp, presentation-mcp and export_data all recover or export through it, so a lost frame keeps its groupby and party-total cut. A frame whose rendering cannot be established refuses rendering_not_recorded, and a record that cannot be read refuses retryably. A bare read records the pair it resolved, so a later call can name that pair.

presentation-mcp rebuilds only a 404 frame_unavailable whose cause.code is unknown, expired or ops_unsupported. A revoked, quarantined or corrupted frame stays a refusal.

A rebuilt frame's sentence names the whole pair, since a replay can mint a new ops_ id. A transient failure of the resent call keeps its delay. A pooled denominator keys by place in a place_level step. The export_data digest states the SHA-256.
# Conflicts:
#	services/presentation-mcp/src/presentation_mcp/client.py
test_every_refusal_code_a_server_writes_is_in_the_pinned_registry reads the codes of data-mcp, analysis-mcp, presentation-mcp and the combined server from their source and refuses any the registry lacks. The spec-kit pin moves to 52e4e42, which registers the codes those servers write.
…ilt inputs

A bare read's mint record holds enrichment default, so a later verb on the pair reads the type columns a get_data pair holds. The digest reads a rebuilt input at the pair the replay computed, and mint record writes share one bound, so a stalled store leaves a computed answer standing.
Skill text names get_skill_file on the mounts, and a hidden argument the public shape forces names get_skill. A two-unit share refusal is rewritten on public calls only and keeps its numerator and denominator. A call with no anchor key is told which derived inputs recompute. Echoed ids, arguments and tool names are capped everywhere, and a failed resolve_definition names that tool.
The spec-kit pin moves to 2dd2400, which maps every code the servers write onto mcp.tools, so the registry test now checks that mapping in place of plain membership.
… own refusal

The public server registers a bare spec_id's stored definition before the tool runs, so an id fresh from search works on the verbs, describe_frame, write_component and export_data.

A resend after a rebuild keeps its own refusal, adding not_rebuilt only when it names a frame still lost. A failed bare read refuses frame_not_held or frame_expired naming the bare frames. Spec reads map to the ids sent through the recipe's tree, so a served id under another name is recorded, and root records decode leniently. Public digests head compare and share_of by the public tool and its operation.
…ters

A bare read writes the plain default record, the same whichever of it and get_data writes first, and a public verb over a plain record sends a root that takes the default enrichment. A Studio call and a replay read as before.

Refusals keep their pointer in fix, and _problems caps the validator's message. A transient registration failure lets the tool run, a 4xx refuses naming the calling tool, and ids register together under a process memo. export_data's prepare deadline takes the time left in the call. The digest says when an input's latest year could not be confirmed.
@jm-rivera
jm-rivera merged commit 7f3624c into dev Oct 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant