Uh oh!
There was an error while loading. Please reload this page.
fix(mcp): register the object tools on the stdio transport instead of only advertising them - #8084
Conversation
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
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
PM review — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8034
The stdio MCP server advertised
capabilities.toolsin itsinitializeresult and answered-32601 Method not foundto everytools/listandtools/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/registerActionToolswere imported once and called from exactly one place — insidehandleHttpRequest(), on the throwaway per-request server. Nothing reached them for stdio. The long-lived server's entire tool surface wasbridgeTools(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
-32601beside an advertised capability follows mechanically:McpServer.registerToolis what installs thetools/listandtools/callhandlers, 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)inmcp-http-tools.tsis now the single composition — object CRUD always, the action pair when the bridge carries that seam.handleHttpRequest()calls it; the newMCPServerRuntime.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) implementsMcpDataBridgeover the ObjectQL engine, with theOS_MCP_STDIO_API_KEYidentity 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/promptsat 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.loggingstays hand-declared because it is honest: there is noregisterLogging, and the declaration is itself what wireslogging/setLevel.promptson the per-request HTTP server also stays declared, becauseregisterSkillPromptsinstalls 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
warnnaming 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 realStdioServerTransportoverPassThroughpipes 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 exercisedhandleHttpRequestandbridgeToolsseparately, and neither can see one transport serving a different surface from the other.tools/listover stdio returns the tool names (asserted as a set, not "no error").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.aggregateseam. Divergence, not absence, was the bug.tools/call query_recordsover stdio reaches the bridge and returns its rows; thesys_*fail-closed guard still refuses on this transport without consulting the bridge.MCPServerPlugin.start()on the stdio path registers the tools on the long-lived server and a stdiotools/callreachesql.findwithcontext.userIdresolved 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
bridgeDataToolsemptied 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: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 HEADempty), 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-debtdoes not rise. This package's tsconfig excludes*.test.ts, so the packagetypecheckscript 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-coverageand its self-test pass, and no tsconfig exclusion was added.node scripts/check-nul-bytes.mjsOK, plus a self-scan of the changed files for the wider control-byte range.eslintclean on the changed files.registerObjectTools/registerActionToolsare exported from the package index but imported by nothing outsidepackages/mcp(the only external hit is prose in a dogfood matrix), and thevoid→string[]return change is source-compatible for every caller.Deliberately not in this PR
apiEnabled/apiMethodsexposure gate that HTTP applies #8083 (filed): the two transports still run their data verbs through different seams — the HTTP bridge goes throughcallData, which prefers theprotocolservice and applies the ADR-0049apiEnabled/apiMethodsexposure gate; the stdio bridge is engine-only and applies neither. Not an authorization bypass — that gate is a surface-area control by its own ADR note and the engine's CRUD/FLS/RLS run on both paths — but the two should not differ, and unifying them is a design change in a second package.callDatacannot be reused as-is: its signature is bound toHttpProtocolContext, and a long-lived stdio session has no request. The divergence is recorded in the new module's docblock so the next reader is pointed at the issue rather than re-deriving it.EngineAggregateOptions.groupByis declaredstring[]but the engine also reads{ field, dateGranularity }buckets — every correct caller must lie to the type #8032 (commented, not duplicated): the typed seam in the new bridge is the first caller forced to cast aroundEngineAggregateOptions; the same call needs a second cast foraggregations(function: stringversus the closed enum), which that issue did not yet name.os servewrites its banner and kernel logs to the stdout the stdio MCP transport owns #7915 is fixing. These pins attach the transport to their own pipes rather than bootingos serve, so they never see it; the frame reader skips unparseable stdout lines rather than failing on them, which is deliberate — a strict parse here would turn another card's defect into a red build for something this file says nothing about.Generated by Claude Code