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. added
    domain:devxDomain lane — seat card indexed by label:pm:seat
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 18, 2026
  2. 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

  3. 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

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 18, 2026
  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 19, 2026
  6. 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

  7. 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

  8. 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

  9. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seat

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions