Skip to content

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

Description

@baozhoutao

Follow-on to #7645, found by re-testing it. #7645 is genuinely fixed and this is not a regression of it — the stdio transport is no longer deaf: initialize now answers immediately (it used to time out with zero bytes on stdout), and resources/list, resources/templates/list and resources/read all answer. The five fail-closed clauses were each re-proved from scratch rather than carried over, and they all hold. What the fix made visible is a second, independent gap that was previously masked by the deafness.

This is not an RLS bypass. The one principal-bound read path stdio does expose (the record resource template) was driven on both sides and the gate held: a member key reads its own row in full and gets not found for another principal's row; an admin key reads both — matching REST row-for-row.

Symptom

The stdio server's initialize result advertises capabilities.tools: {}, but the tool surface is empty:

  • tools/list-32601 Method not found
  • every tools/call-32601 Method not found
  • across 4 fresh boots, and on retry at +3 s and +8 s after notifications/initialized (ruling out a registration race)

Decisive isolation: in the same process, with the sameosk_ key, the HTTP transport (POST /api/v1/mcp) returns all 11 tools. So the bridge, the key, and the principal are all fine — the tools are simply never registered on the stdio server.

Boot log corroborates: [MCP] Bridged 4 resource endpoints, Agent prompts bridged, [MCP] Bridged 0 skill prompts — and no tool-bridging line at all.

Root cause

The object/action tool surface is built per HTTP request, on a throwaway server, and never on the long-lived stdio server.

packages/mcp/src/mcp-server-runtime.tshandleHttpRequest() constructs a fresh McpServer per request and registers the tools onto that instance:

// mcp-server-runtime.ts:1157,1166 — inside handleHttpRequest()registerObjectTools(server,opts.bridge,opts.toolOptions);
...
registerActionTools(server,opts.bridgeasMcpActionBridge,opts.toolOptions);

The long-lived server used by stdio (this.mcpServer) only ever receives tools through bridgeTools(toolRegistry) (mcp-server-runtime.ts:663), which is a different surface — the AI service's function-calling ToolRegistry, wired in packages/mcp/src/plugin.ts:138:

constaiService=ctx.getService<IAIService&{toolRegistry?: ToolRegistry}>('ai');if(aiService?.toolRegistry)this.runtime.bridgeTools(aiService.toolRegistry);

The showcase registers no ToolRegistry tools, so zero tools land on the stdio server. registerObjectTools/registerActionTools are imported only at line 9 and called only from inside handleHttpRequest — there is no path that reaches them for stdio.

The -32601 alongside an advertised tools capability is consistent with this: the MCP SDK only wires the tools/list handler once at least one tool is registered, while the capability object is declared statically at construction.

Reproduction

  1. Boot the showcase with stdio MCP enabled: node packages/cli/bin/run.js serve -p <port> --dev with stdio: ['pipe','pipe','pipe'], OS_MCP_STDIO_ENABLED=true, and a valid osk_ key.
  2. Write JSON-RPC initialize, then notifications/initialized, then tools/list to the child's stdin.
  3. Against the same process, POST /api/v1/mcp with the same key and body {"jsonrpc":"2.0","id":1,"method":"tools/list"}.

Expected both transports expose the same tool surface (11 tools).
Actual HTTP returns 11 tools; stdio returns -32601 Method not found, while its own initialize result claims capabilities.tools: {}.

Blast radius

Every MCP client that speaks stdio (the transport most desktop MCP hosts default to) sees an ObjectStack server with resources and prompts but no tools — it cannot query or mutate a single object. The 17/17 unit pins stay green because they exercise handleHttpRequest and bridgeTools separately, never asserting the two transports agree on the tool surface.

Downstream in the checklist

ai.mcp-stdio-fail-closed clauses 4/5 are blocked by this (no aggregate to reconcile against on stdio). Clause 5 has a separate fixture gap worth recording: the factory showcase has no readable: false FLS-masked field anywhere (repo-wide grep is zero), runtime authoring of one is refused 403 NOT_OVERRIDABLE, and the scratch-artifact escape hatch fails because the connector provider factory comes from objectstack.config.ts.

Suggested shape (not prescriptive)

Register the object/action tools on the long-lived server too — either by hoisting the registerObjectTools/registerActionTools pair into a shared wireTools(server, bridge, opts) called from both start() (stdio branch) and handleHttpRequest(), or by having stdio route through the same per-request assembly. Whatever the shape, the gate that keeps this from coming back is a test that asserts the two transports expose the same tool names for the same bridge — the current pins can't see a divergence.

Source

Extracted from the checklist retest run (framework b602d536, console 6314e87f2d49), re-testing #7645.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions