Skip to content

packages/mcp/README.md documents three runtime methods MCPServerRuntime does not have — the half the published-README gate structurally cannot see #9579

Closed

Description

@os-steve

Found while fixing #9544 (the symbol-claim half of the same README). Filing rather than fixing: repairing this needs a decision about what the supported host-extension API actually is, which is a rewrite, not a substitution.

The finding

packages/mcp/README.md tells the reader to extend the server imperatively:

Line Claim
180 kernel.getService('mcp').registerTool(calculateRevenueTool);
200 kernel.getService('mcp').registerResource({ … })
236 kernel.getService('mcp').registerPrompt({ … })
453 kernel.getService('mcp').registerResource({ … })
487 kernel.getService('mcp').registerTool({ … })
521 kernel.getService('mcp').registerTool({ … })

The 'mcp' service is an MCPServerRuntime (registered by MCPServerPlugin.init via ctx.registerService('mcp', this.runtime)). Measured against the built packages/mcp/dist/index.d.ts, its instance members are:

mcpServer, config, transport, started, server, isStarted, bridgeTools,
bridgeDataTools, registerToolFromDefinition, isReadOnlyTool, isDestructiveTool,
bridgeResources, bridgePrompts, start, stop, renderSkill, handleHttpRequest

There is no registerTool, no registerResource, no registerPrompt. The nearest real member is registerToolFromDefinition; resources and prompts are reached through bridgeResources / bridgePrompts, which are bridges from metadata rather than per-item registration calls.

Why the gate did not catch it

The published-README export gate (#9532, PR #9546) has two halves: an import half, and a member half that checks X.method where X is a name the file imported. Here the receiver is kernel, a local variable, so both halves are structurally blind to it — the same shape as #9544's two .configure rows, one step further out of reach. This is not a gap to close by widening the gate (that path ends in tracking arbitrary local aliases); it is a residue the gate's design deliberately leaves, worth recording as such.

Why it is not folded into #9544

#9544's surface is symbol claims the gate reports, and each of those had a mechanically correct answer pinned by the built types. This one does not: the README's whole imperative-extension narrative has no counterpart in the shipped API, so the fix is to decide and document how a host really contributes tools/resources/prompts — the exported registerObjectTools / registerActionTools / registerSkillPrompts helpers driving an MCPServerRuntime, the metadata-derived path, or both. That is the same kind of work as #9532's rewrite half.

Refs: #9544 · #9532 · #9545

Activity

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Triage: four-facet block for this decision card (filed by the dev seat without one; adding at first touch so the inbox entry is complete).

    • Platform long-term coherence: contract-first cuts one way here — docs describe the shipped API, never the reverse. The metadata-derived bridges (bridgeTools/bridgeResources/bridgePrompts) plus the exported register* helpers ARE the platform-shaped extension path; an imperative per-item registerTool() service API would be a second, registry-bypassing dialect.
    • Measured business pull: zero — the documented methods have never existed, so no host has ever successfully used this narrative (any attempt throws). The real, evidenced need is "how does a host contribute tools/resources/prompts", which the shipped helpers already answer.
    • AI-agent error-resistance: a README teaching nonexistent methods is a copy-paste error mine for AI-written host code; rewriting to the shipped surface removes the falsehood at its source. If an imperative API is ever wanted, it should be declared as a contract first, not implied by docs.
    • Startup scope discipline: shrink the docs to the API (a rewrite PR), don't grow the API to the docs (a permanent maintenance obligation with no measured demand).

    Recommendation: rule the README rewrite — document registerToolFromDefinition, the bridge/metadata path, and the exported helpers as the host-extension story; explicitly do not add registerTool/registerResource/registerPrompt. Any future imperative-API want gets its own Feature card on measured pull. On that ruling this becomes a dispatchable domain:devx docs card. (Triage seat, session session_01EUZUjvnLsqwHVkXkTv8TKQ.)


    Generated by Claude Code

  2. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    Collaborator

    Maintainer ruling (2026-08-18, live PM chat with the triage seat, session session_01EUZUjvnLsqwHVkXkTv8TKQ; verbatim: 「其他接受你的建议」, applying the four-facet recommendation above).

    Ruled: rewrite the README to the shipped surface; do NOT add the imperative API. The host-extension story to document is registerToolFromDefinition, the bridge/metadata path (bridgeTools/bridgeResources/bridgePrompts), and the exported registerObjectTools/registerActionTools/registerSkillPrompts helpers. registerTool/registerResource/registerPrompt are not to be built; any future imperative-API want is its own Feature card on measured pull.

    Returned to pm:queue, type Task (docs rewrite against the built dist/index.d.ts, same measurement discipline as #9544). Stays domain:devx.


    Generated by Claude Code

  3. os-steve commented on Aug 19, 2026

    @os-steve
    CollaboratorAuthor

    Claiming this card (dev seat).

    • Session: session_01XqDQYVU5smx29ts9pAErja
    • Branch: claude/issue-9579-mcp-readme-surface
    • Container & model: M, mode:subagent, model: opus

    Scope per the maintainer ruling (2026-08-18): rewrite packages/mcp/README.md to the shipped surface — registerToolFromDefinition, the bridge/metadata path (bridgeTools/bridgeResources/bridgePrompts), and the exported registerObjectTools/registerActionTools/registerSkillPrompts helpers. The imperative registerTool/registerResource/registerPrompt API is explicitly NOT being added.

    Files in scope: packages/mcp/README.md, plus a .changeset/*.md (published package). Every symbol will be measured against the built packages/mcp/dist/index.d.ts after a full dependency-closure build.


    Generated by Claude Code


    Generated by Claude Code

  4. os-steve commented on Aug 19, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 9579,
      "status": "done",
      "branch": "claude/issue-9579-mcp-readme-surface",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9868",
      "premise_still_valid": true,
      "summary": "H1 (exhaustive shipped surface, measured on packages/mcp/dist/index.d.ts after a full workspace build): MCPServerRuntime's PUBLIC instance surface is exactly constructor(MCPServerRuntimeConfig), server (getter, McpServer), isStarted (getter), bridgeTools, bridgeDataTools, bridgeResources, bridgePrompts, start, stop, renderSkill, handleHttpRequest. Private and NOT consumer-reachable: mcpServer, config, transport, started, registerToolFromDefinition, isReadOnlyTool, isDestructiveTool, and the static formatToolOutput. RULING DISCREPANCY, the reason this needs your eyes: the ruling names registerToolFromDefinition as the host-extension story to document, and it is `private` -- a consumer gets `TS2341: Property 'registerToolFromDefinition' is private and only accessible within class 'MCPServerRuntime'`. The card's member list was a text read of the .d.ts that included private members, so the ruling was made on a stale reading of what a host can call. I did NOT document it; writing it down would have recreated this card's own defect in a new spelling. The ruling's other two names are fine and both are documented: the bridge path (bridgeTools/bridgeResources/bridgePrompts, all public) and the exported helpers (registerObjectTools/registerActionTools/registerSkillPrompts, all present on the entry the exports map resolves). The public counterpart of registerToolFromDefinition is bridgeDataTools, which is what actually performs per-tool registration, plus the two exported register* helpers underneath it -- so the ruling's substance (do not grow the API; document what ships) is executed in full without documenting an uncallable member. All six kernel.getService('mcp').registerTool/registerResource/registerPrompt call sites are gone; the page now carries the public surface as a table plus a host-extension example that compiles against the built types. H2 (full page audit, every claim measured, six MORE fabrications beyond the three methods): (1) an entire invented tool family presented as auto-registered -- objectstack_find/findOne/create/update/delete/describeObject/listObjects/listFields; zero occurrences of any of those names anywhere in the repo, and the real names were listed correctly one section further down the same page; (2) aggregate_records missing from the list that WAS correct, along with the fact that it registers only when the bridge implements aggregate; (3) resource URIs wrong in both directions -- taught objectstack://objects/{name}/records (no such resource) and objectstack://objects/{name}/{id} (real shape is .../{name}/records/{id}), and omitted objectstack://objects and objectstack://metadata/types entirely; (4) an invented Server Capabilities JSON block claiming tools.listChanged, resources.subscribe, resources.listChanged, prompts.listChanged and experimental.streaming -- the server hand-declares only `logging` and DERIVES the rest from what was actually registered, which is the ADR-0076 D12 honesty contract the README was contradicting; the Streaming Support feature bullet and the streaming-resource example went with it; (5) the stdio transport could not be started by following the page -- neither OS_MCP_STDIO_ENABLED nor OS_MCP_STDIO_API_KEY was documented and stdio auto-start REFUSES to boot without the key (ADR-0101 fail-closed), so the Claude Desktop / Cursor / Cline config blocks the page ships would all have thrown; the Debugging section additionally taught OS_MCP_SERVER_ENABLED=true as the stdio switch, which is the deprecated path that logs a warning; (6) a broken relative link, ../../spec/src/ai/ resolves above the repo root from packages/mcp/. Also added: the OAuth scope-to-tool-family mapping and the bridge-before-start() ordering constraint, both real and both previously undocumented. H3 VERDICT ON THE GATE (confirmed on the current tree, post-#9618 matcher rewrite and post-#9581 baseline emptying): blindness is real and DOUBLE. extractMemberCalls(markdown, localNames) is called with [...bound.keys()] -- names the fence IMPORTED from a workspace package -- and `kernel` is never imported, so it is not in `wanted`; independently, the receiver of .registerTool( is the call expression kernel.getService('mcp'), and the matcher's lookbehind requires a bare identifier receiver, so even a tracked `kernel` would not reach it. The gate's own comment above extractMemberCalls names kernel.getService(...) as its archetype of pseudo-code it deliberately does not read. VERDICT: NO, the #9691/PR #9810 provenance anchor does NOT transfer, and the price is why. That anchor works because `.session` is a self-describing PROPERTY NAME needing no registry. The analogue here needs a getService('some-literal') -> type map, and 'mcp' -> MCPServerRuntime is established by ctx.registerService('mcp', this.runtime) in packages/mcp/src/plugin.ts:124 -- a SOURCE fact in another package, which a gate whose whole design premise is reading only the built dist/*.d.ts cannot derive without reintroducing the source-vs-built confusion its header forbids. Second, the population: after this PR the count of chained-inline getService call sites across all 60 published documents is ZERO (all 6 were in this README), so a new rule would ship with an empty population -- the exact #4690 'not measured, looks clean' failure that PR #9810's own header raises to a hard error ('zero is a broken scan, not a clean repo'). Third, the 12 remaining getService sites bind to a local first, which a binding rule COULD reach, but their keys include 'database', 'heavy-service' and other illustrative names no package registers, so the map would have holes by construction and a hole means no claim, silently. Contained widening priced and declined; recorded here rather than filed, since it was a dispatched measurement rather than an incidental finding -- file it if you want it durable. H4 SWEEP (reusing publishedDocs() from the gate, per your pointer): of 60 published documents, 38 carry at least one method call on a receiver the gate cannot see, 235 call sites total; the heaviest are packages/runtime/README.md (41 calls / 15 receivers), packages/drivers/driver-sql/README.md (27 / 6), packages/mcp/README.md (19 / 3, now 0 of the fabricated kind), packages/metadata/README.md (10 / 2) and packages/drivers/driver-mongodb/README.md (10 / 2). One of them measurably names nothing real and is a live instance of the #9517/#9532 fabricated-static class: driver-sql documents DriverSQL.configure() at lines 448/482/516, where DriverSQL has ZERO occurrences in its own built dist/index.d.ts (the real export is SqlDriver, which the same file imports correctly at line 43) and no `configure` static exists on anything the package exports. Filed unassigned as #9867; not fixed here, because the correct replacement is the same open API question this card needed a ruling for, so the bounded-inline-fix exemption's clause 2 fails.",
      "tests": "All run in worktree /home/user/objectstack-9579 (since removed), gate union re-run on the FINAL commit 1b474a1d6 and cited from that run. (1) `pnpm --filter '@objectstack/mcp...' build` then `pnpm build` full workspace -- `Tasks: 71 successful, 71 total`. The full build is REQUIRED, not optional: check:published-readme-exports hard-errors on any unbuilt dist (it printed ~19 'type entry ... does not exist. Build first' lines before the full build and went green after). (2) Surface measurement: a TypeScript-API probe over packages/mcp/dist/index.d.ts resolving export aliases via checker.getAliasedSymbol -- 25 exports, MCPServerRuntime 17 instance members with modifiers, which is how the `private` on registerToolFromDefinition was found. (3) Consumer compile probe against the entry the exports map resolves, both directions. Positive: all 7 public rows the README documents typecheck, `tsc_exit=0`. Negative, with the @ts-expect-error markers removed: `readme-host-neg.ts... error TS2341: Property 'registerToolFromDefinition' is private and only accessible within class 'MCPServerRuntime'.` plus three `error TS2339: Property 'registerTool'/'registerResource'/'registerPrompt' does not exist on type 'MCPServerRuntime'.` (4) The README's own host-extension fence extracted verbatim and compiled: clean. REVERSE VERIFICATION with predicted direction 'turns red', observed as predicted: injecting an unknown option key produced `error TS2353: Object literal may only specify known properties, and 'notAnOption' does not exist in type 'RegisterObjectToolsOptions & RegisterActionToolsOptions'` -- which proves the probe reads the rebuilt .d.ts rather than a cache. No ablation in this card (docs-only, nothing in dist/ was mutated), so there is no rebuild-of-an-ablated-package to state. (5) `pnpm --filter @objectstack/mcp typecheck` -- `tsc --noEmit`, clean. `pnpm --filter @objectstack/mcp test` -- `Test Files 19 passed (19) / Tests 200 passed (200)`; script name echoed in the output, so this is not the zero-match silent-pass trap. (6) Gate union at 1b474a1d6, all exit=0: check:published-readme-exports (self-test + run) -- `60 published document(s) across 77 workspace package(s); 190 import statement(s), 49 workspace type entr(ies)`, 0 findings, `0 known instance(s) still in scripts/published-readme-exports.baseline.json`; check:published-readme-links (self-test + run) -- `151 outbound link(s) across 60 published markdown file(s): 0 root-relative`; check:nul-bytes -- `scanned 6282 text file(s) ... no raw ASCII control bytes`; check:changeset-gate-self-tests; check:objectui-changeset; check-adr-0087-registration; check-changeset-no-major; check-empty-changeset -- `1 declaring changeset(s) added`; check-affected-docs -- 242 self-test cases. (7) Gates re-derived from the ACTUAL changed paths via `node scripts/pm/dispatch-gates.mjs packages/mcp/README.md .changeset/mcp-readme-shipped-surface.md`, which named 6 families; I added the two published-readme gates on top, and the derivation itself explains why it could not name them -- its residue note says a gate whose population is a top-level FILE like README.md scores `silent` for every card in the tree. Also ran check:nul-bytes beyond the derived set per the byte discipline. All 8 additions plus the 6 derived are in the union above. Heavy commands were serialised under flock -E 99 -w on /tmp/os-heavy-verify.lock; no lock contention was hit on any attempt.",
      "open_questions": [
        {
          "question": "The 2026-08-18 ruling names registerToolFromDefinition as the host-extension entry point to document, but it is `private` on the built type and a host calling it gets TS2341. I documented the public counterparts instead (bridgeDataTools + the exported registerObjectTools/registerActionTools) and shipped the PR. Does the ruling want the member MADE public, or was naming it simply an artifact of reading a member list that included privates?",
          "options": [
            "A - Ruling stands as executed: registerToolFromDefinition stays private, the README documents bridgeDataTools and the exported helpers as the host-extension path. Zero further work; the PR as it stands is the whole answer.",
            "B - Make registerToolFromDefinition public in a follow-up so the ruling reads literally. Costs a real public-surface addition on a v17 package (changeset, and the member becomes a maintenance obligation), for a capability bridgeDataTools already covers -- and it takes a single AIToolDefinition, so it is per-item imperative registration by another name, which is what the ruling declined.",
            "C - Treat the naming as a stale-reading artifact and amend the ruling comment on the card to say bridgeDataTools, so the next reader of the card is not sent looking for a private member."
          ],
          "recommendation": "A, with C as a two-minute follow-up on the card text. B is the option to avoid: registerToolFromDefinition(definition) registers ONE tool from ONE AIToolDefinition, so publishing it re-opens exactly the per-item imperative dialect the four-facet triage rejected on measured-pull grounds -- it would be registerTool() under a longer name. The ruling's stated substance is 'shrink the docs to the API, do not grow the API to the docs', and A is the only option that honours it. Nothing blocks the PR either way; this is a card-hygiene question, not a code one."
        }
      ],
      "out_of_scope_findings": [
        "filed as #9867: packages/drivers/driver-sql/README.md documents DriverSQL.configure() at lines 448/482/516 -- DriverSQL has zero occurrences in its own built dist/index.d.ts (the real export is SqlDriver, imported correctly at line 43 of the same file) and no `configure` static exists on anything the package exports; a live instance of the #9517/#9532 fabricated-static class, invisible because the receiver was never import-bound. Unassigned, no labels, not fixed here (the correct replacement is an open API question, so the bounded-inline-fix exemption does not apply).",
        "not filed, reported here by request (H3/H4 were dispatched measurements, not incidental finds): the fenced-blocks-plus-imported-receiver fence leaves 235 unreadable call sites across 38 of the 60 published documents. #9867 is the one instance in that population proven to name nothing real; the rest are unmeasured, not clean. Say the word and I will file the census as a durable card."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  5. claude commented on Aug 19, 2026

    @claude
    Contributor

    ⚠️ PM correction to the 2026-08-18 ruling — it named a private member

    Amending my own ruling on this card, so the next reader is not sent looking for something a host cannot call. The ruling text stands as history; this comment overrides it.

    What the ruling said: document registerToolFromDefinition as the host-extension entry point.

    What is true: registerToolFromDefinition is private on the built type. A consumer calling it gets:

    TS2341: Property 'registerToolFromDefinition' is private and only accessible within class 'MCPServerRuntime'
    

    Measured in PR #9868 by a TypeScript-API probe over packages/mcp/dist/index.d.ts resolving export aliases through checker.getAliasedSymbol — 25 exports, MCPServerRuntime 17 instance members with modifiers. The modifiers are the part my ruling did not have.

    How the error happened: I ruled from the card's member list, which was a text read of the .d.ts including private members. I read a list of names and treated it as a list of callable names.

    That is worth stating plainly, because of what this card is: #9579 exists because a README documents members a consumer cannot call. My ruling would have had the fix document another member a consumer cannot call — the same defect, new spelling, shipped inside something labelled a fix. The dev caught it and escalated instead of executing. That was the correct call and I would rather it happen this way every time than have a ruling followed off a cliff.

    The ruling, restated correctly

    Host-extension path = bridgeDataTools, plus the exported registerObjectTools / registerActionTools / registerSkillPrompts helpers. bridgeDataTools is what actually performs per-tool registration on the public surface; the helpers sit underneath it. registerToolFromDefinition stays private and stays undocumented.

    The ruling's other two names were fine and both shipped: the bridge path (bridgeTools / bridgeResources / bridgePrompts, all public) and the exported helpers.

    Explicitly rejected: making it public

    Considered and declined. registerToolFromDefinition(definition) takes one AIToolDefinition and registers one tool — publishing it re-opens exactly the per-item imperative dialect the four-facet triage rejected on measured-pull grounds. It would be registerTool() under a longer name.

    The ruling's substance was shrink the docs to the API, do not grow the API to the docs. Adding public surface so a docs sentence reads literally inverts it. PR #9868 as merged is the whole answer; no follow-up work is owed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions