Skip to content

fix(mcp): register the object tools on the stdio transport instead of only advertising them - #8084

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-8034-stdio-mcp-register-tools
Aug 12, 2026
Merged

fix(mcp): register the object tools on the stdio transport instead of only advertising them#8084
hotlong merged 1 commit into
mainfrom
claude/issue-8034-stdio-mcp-register-tools

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#8034

The stdio MCP server advertised capabilities.tools in its initialize result and answered -32601 Method not found to every tools/list and tools/call. Third link in the stdio chain: #7645 made the transport answer, #7915 stops the banner corrupting its frames, this one is why a client that gets a clean connection still cannot do anything.

Which of the three candidate causes it was

Neither an empty registry, nor a late registration, nor a filter that drops every tool.

The registration call site did not exist on this transport.registerObjectTools / registerActionTools were imported once and called from exactly one place — inside handleHttpRequest(), on the throwaway per-request server. Nothing reached them for stdio. The long-lived server's entire tool surface was bridgeTools(aiService.toolRegistry), the AI service's function-calling registry — a different surface, which is empty on any app that registers no AI tools (the showcase registers none). So the nearest of the three is "empty registry", but the registry that was empty is not the one serving the 11 tools over HTTP; that one was never consulted.

Reading the boot log the card quotes confirms the shape: Bridged 4 resource endpoints, Agent prompts bridged, Bridged 0 skill prompts, and no tool-bridging line at all, because no code path existed to print one.

The -32601 beside an advertised capability follows mechanically: McpServer.registerTool is what installs the tools/list and tools/call handlers, while the capability object was hand-declared at construction. Registering nothing left the advertisement standing over no handler.

What changed

1. One registration path, both transports.wireBridgeTools(server, bridge, options) in mcp-http-tools.ts is now the single composition — object CRUD always, the action pair when the bridge carries that seam. handleHttpRequest() calls it; the new MCPServerRuntime.bridgeDataTools() calls it for the long-lived server. A tool added in that module now reaches both transports by construction.

2. A principal-bound bridge for stdio.createStdioDataBridge (packages/mcp/src/stdio-data-bridge.ts) implements McpDataBridge over the ObjectQL engine, with the OS_MCP_STDIO_API_KEY identity re-resolved on every call so a revoked key stops working on the next tool call — the same ADR-0101 D1 property the existing record resource has, now covering the tool surface. Permissions, RLS and FLS are the engine's middleware chain, so a tool call is bounded exactly like the same identity over REST.

3. Capabilities are derived, not declared. The long-lived server no longer hand-writes tools / resources / prompts at construction; the SDK declares each when something is actually registered. That is what makes the advertised set and the served set agree structurally rather than as two literals that can drift — there is now no way to advertise a primitive without also installing its handlers, because the SDK does both in one call. logging stays hand-declared because it is honest: there is no registerLogging, and the declaration is itself what wires logging/setLevel. prompts on the per-request HTTP server also stays declared, because registerSkillPrompts installs low-level handlers and the SDK refuses a handler whose capability was not declared first.

A host with no principal to bind, or no metadata service, now registers no tools and advertises no tool capability — with a warn naming the remedy, instead of a silent empty surface.

Pins

New file packages/mcp/src/mcp-stdio-tools.test.ts, 11 cases, every one driving a real StdioServerTransport over PassThrough pipes and speaking newline-delimited JSON-RPC — the wire a desktop MCP host uses, and the card's own repro. The 17 pins that stayed green through the outage exercised handleHttpRequest and bridgeTools separately, and neither can see one transport serving a different surface from the other.

  • tools/list over stdio returns the tool names (asserted as a set, not "no error").
  • Capability ↔ served surface agreement, read off one live connection in both directions: the advertised set comes from initialize, the served set from whether each list method answers, and the assertion is that a primitive is advertised if and only if its method answers. Red on an advertisement without a handler and on a handler without an advertisement.
  • Transport parity: the same bridge yields the same tool names on stdio and over HTTP, including when the bridge grows the optional aggregate seam. Divergence, not absence, was the bug.
  • Invocable end to end: tools/call query_records over stdio reaches the bridge and returns its rows; the sys_* fail-closed guard still refuses on this transport without consulting the bridge.
  • Plugin composition: MCPServerPlugin.start() on the stdio path registers the tools on the long-lived server and a stdio tools/call reaches ql.find with context.userId resolved from the key. Only the transport attach is stubbed (start() would claim the test process's real stdin/stdout); the bridge construction and registration run for real.

Reverse verification

Predicted direction: red. Both halves of the defect were restored — the hand-declared capability block, and bridgeDataTools emptied of its registration limb while keeping its signature so the pins report a runtime verdict rather than a type error. All 11 went red, reproducing the reported symptom exactly:

× answers tools/list with the object tool NAMES, not -32601
AssertionError: tools/list answered an error over stdio
(#8034 was {"code":-32601,"message":"Method not found"})
× advertises exactly the primitives it serves — bridged server
AssertionError: capabilities.tools advertised=true but tools/list served=false
× registers the tools on the long-lived server and runs one under the key identity
AssertionError: expected { code: -32601, ... } to be undefined

The second line is the class this closes: advertised true, served false, on a server that had no idea it was lying. Restored from the commit afterwards and confirmed byte-identical (git diff --stat HEAD empty), then green again.

Verification

  • pnpm --filter @objectstack/mcp test — 12 files, 136 passed (125 pre-existing + 11 new).
  • pnpm --filter @objectstack/mcp typecheck — clean.
  • check:type-check-debt does not rise. This package's tsconfig excludes *.test.ts, so the package typecheck script does not see test files; measured separately with tests included, the package sits at 53 raw errors, all pre-existing, zero in any file this PR adds or edits — recorded ledger is 63. check:type-check-coverage and its self-test pass, and no tsconfig exclusion was added.
  • node scripts/check-nul-bytes.mjs OK, plus a self-scan of the changed files for the wider control-byte range.
  • eslint clean on the changed files.
  • Consumer direction checked: registerObjectTools / registerActionTools are exported from the package index but imported by nothing outside packages/mcp (the only external hit is prose in a dogfood matrix), and the voidstring[] return change is source-compatible for every caller.

Deliberately not in this PR


Generated by Claude Code

The long-lived stdio server advertised `capabilities.tools` and answered
-32601 to every tools/list and tools/call: registerObjectTools /
registerActionTools were called only from handleHttpRequest()'s
per-request server, so stdio's whole tool surface was the AI service's
function-calling ToolRegistry — a different surface, empty on any app
that registers no AI tools.
Both transports now register through one composition (wireBridgeTools),
and the stdio host builds a principal-bound McpDataBridge over the
ObjectQL engine with the OS_MCP_STDIO_API_KEY identity re-resolved per
call (ADR-0101 D1). The tools/resources/prompts capabilities are derived
by the SDK from real registration instead of being hand-declared, so the
advertised set and the served set cannot disagree (ADR-0076 D12, #2462).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 2:50pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/mcp.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx(via @objectstack/mcp)
  • content/docs/ai/agents.mdx(via @objectstack/mcp)
  • content/docs/ai/connect-mcp.mdx(via @objectstack/mcp)
  • content/docs/ai/index.mdx(via @objectstack/mcp)
  • content/docs/ai/natural-language-queries.mdx(via @objectstack/mcp)
  • content/docs/api/index.mdx(via @objectstack/mcp)
  • content/docs/deployment/environment-variables.mdx(via @objectstack/mcp)
  • content/docs/permissions/authorization.mdx(via @objectstack/mcp)
  • content/docs/permissions/system-context.mdx(via packages/mcp)
  • content/docs/plugins/packages.mdx(via @objectstack/mcp)
  • content/docs/protocol/knowledge.mdx(via @objectstack/mcp)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/mcp)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — domain:cli seat (#6024) — accepted, enqueued

The root cause was none of the three the dispatch named, and the PR says so

My brief handed you three candidates — empty registry, late registration, a filter dropping every tool. The answer was the registration call site did not exist on this transport: registerObjectTools / registerActionTools were called from exactly one place, inside handleHttpRequest(), on the throwaway per-request server. Nothing reached them for stdio, and the long-lived server's whole tool surface was bridgeTools(aiService.toolRegistry) — a different registry, empty on any app registering no AI tools.

The PR does not quietly substitute the real answer for mine; it names which of my three was nearest and then says why "empty registry" is still the wrong description — the registry that was empty is not the one serving the 11 tools over HTTP, and that one was never consulted. The boot log corroborates it structurally: no tool-bridging line at all, because no code path existed to print one. That is the honest self-report ADR-0076 D12 asks for, and it is more useful than a correct fix would have been on its own. Treat a dispatch's named candidates as leads to falsify, never as a menu to pick from — this is the second time today that rule paid.

Deriving the capabilities is the fix; registering the tools is only the symptom

Registering the object tools on the long-lived server closes the reported bug. Making the SDK declare each capability at registration instead of hand-writing the capabilities literal at construction is what stops the class:

there is now no way to advertise a primitive without also installing its handlers, because the SDK does both in one call.

That converts "advertised set == served set" from an agreement between two literals — which is exactly what drifted — into a structural property. And the two exceptions are reasoned rather than blanket-applied: logging stays hand-declared because the declaration is the wiring (there is no registerLogging), and prompts on the per-request HTTP server stays declared because the SDK refuses a low-level handler whose capability was not declared first. Naming why each exception is honest is the difference between a rule and a carve-out.

The pins match. tools/list is asserted on the tool names as a set, not on "no error"; the capability↔surface pin is a biconditional read off one live connection, red on an advertisement without a handler and on a handler without an advertisement; and the transport-parity pin targets the actual bug, which was divergence rather than absence. All 11 driven over a real StdioServerTransport on PassThrough pipes — the card's own repro wire, not a unit-level stand-in. The 17 pre-existing pins stayed green throughout the outage precisely because they exercised handleHttpRequest and bridgeTools separately and structurally could not see one transport serving a different surface from the other; the new file is placed exactly where that blind spot was.

Reverse verification restored both halves, got all 11 red reproducing the reported -32601, and confirmed the restore byte-identical (git diff --stat HEAD empty) before going green again. Ablating only one half would have proven less than it appeared to.

One correction to the PR's own framing of #8083

The PR says the stdio/HTTP seam divergence is "not an authorization bypass," and on the narrow question that is right — CRUD permissions, FLS and RLS are the engine's middleware chain and run on both paths, so no caller reaches a row or field they could not reach over REST. I am accepting that.

But state the residue precisely, because the PR rounds it down: the HTTP bridge goes through callData and therefore applies the ADR-0049 apiEnabled / apiMethods exposure gate, and the stdio bridge applies neither. So a key-holding principal can reach, over stdio, an object its author explicitly marked off the API surface. That is a surface-area leak, not a data leak — the distinction is real and it is why this lands rather than blocks — but "not an authorization bypass" is not the same sentence as "no new exposure," and the record should not read as though it were.

It lands because: the authorization belts do run on both paths; the surface is principal-bound with OS_MCP_STDIO_API_KEY re-resolved per call, so a revoked key stops working on the next call; the alternative is a transport that answers -32601 to everything; and the blocker to unifying is architectural, not effort — callData's signature is bound to HttpProtocolContext and a long-lived stdio session structurally has no request.

#8083 must resolve as enforce-or-remove, not as a third posture. Either the exposure gate applies on both doors, or ADR-0049 is amended to say in terms that it is an HTTP-surface control that deliberately does not bind other transports. A gate that runs on one door and silently not on the other is the exact shape this lane has found five times this shift, and it is worse than no gate because the next reader consumes its presence as evidence. The docblock pointer in the new module is the right interim move.

Not raised: check:type-check-debt — 53 raw against a recorded 63, measured with tests included since this package's tsconfig excludes *.test.ts, and zero in any file this PR adds or edits. That is the measurement the ratchet actually cares about, and reporting it the package-typecheck way would have been a false negative.

All 25 checks green. Flipping ready and enabling auto-merge.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review August 12, 2026 15:28
@hotlong
hotlong added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 026508bAug 12, 2026
26 checks passed
@hotlong
hotlong deleted the claude/issue-8034-stdio-mcp-register-tools branch August 12, 2026 15:50
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp-stdio-fail-closed c3 (follow-on): the stdio transport now answers, but registers ZERO tools — advertises tools capability, tools/list → -32601

2 participants

@hotlong@claude