Uh oh!
There was an error while loading. Please reload this page.
fix(mcp): the tool bridge forwards AIToolDefinition.parameters as the tool's input schema, and the docblock stops describing a workaround that was never implemented - #13317
Conversation
…p-bridge-input-schema
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7292896d10f1c3635a000528fe20bf312ceee5a2 && git checkout 7292896d10f1c3635a000528fe20bf312ceee5a2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 289cf91c84a7e0cb93e13217070b2c864285c737 a876ebe6fb3f9d0fee59e96cf55955b569470ee1 && git checkout -B drift-repro 289cf91c84a7e0cb93e13217070b2c864285c737 && git merge --no-ff a876ebe6fb3f9d0fee59e96cf55955b569470ee1
node scripts/docs-audit/affected-docs.mjs --json 289cf91c84a7e0cb93e13217070b2c864285c737 |
os-trump
commented
Aug 30, 2026
✅ PM review — ACCEPT once CI is green; ⛔ not while it is running (#13271)CI started 03:23Z and is still running. Undrafting now so Clause ②: does not attach. Path limb measured silent (change set is 3 files, none under
|
Uh oh!
There was an error while loading. Please reload this page.
The `_note` on ToolSchema's `parameters` entry asserted that registerToolFromDefinition registered every bridged tool with NO inputSchema -- "so this key never reaches an MCP client" -- and cited that as the asymmetry with `name` / `description` (filed as #13271). PR #13317 (e29fc21, merged 2026-08-30T04:42:09Z) fixed exactly that: mcp-server-runtime.ts#toolInputSchema now converts `parameters` through zod@4's fromJSONSchema and registerToolFromDefinition forwards the result as the SDK inputSchema, which the SDK converts straight back to JSON Schema for tools/list -- the key reaches MCP clients too. The note is corrected to record the fix while keeping the sharper nuance the original note called out: the pre-fix behaviour was not "no schema" but the SDK synthesising EMPTY_OBJECT_JSON_SCHEMA (`{"type":"object","properties":{}}`) for a schema-less registration -- a positive claim that the tool takes no arguments, not silence. The grade does not move: `parameters` was live before this change and stays live -- the cloud LLM path (vercel-adapter.ts#buildVercelOptions) has read it all along, and that is what the verdict has always rested on. This closes an asymmetry between two consumers, not a change in liveness status. Fixes#13345 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N
Fixes#13271
Route: PLUMB — the drop was not deliberate
Intent was established before any code moved, as the card requires. Five readings, all against the tree, none from memory:
bridgeTools' docblock: "Each registered tool becomes an MCP tool with the same name, description, and JSON Schema parameters." And the comment on the call: "pass the JSON Schema as annotations metadata." A deliberate limitation does not get documented twice as the opposite.annotationsis typedToolAnnotations— a closed five-key Zod object (title/readOnlyHint/destructiveHint/idempotentHint/openWorldHint), no passthrough — so an object literal carrying a JSON Schema would not have type-checked, and the client's own parse would strip it. The comment also names.tool(), which the code does not call. It describes an implementation that never existed.wireBridgeToolsinpackages/mcp/src/mcp-http-tools.ts— the object/action tools, registered on BOTH transports — passesinputSchemafor every tool it registers (list_objects,describe_object,validate_expression,query_records,aggregate_records,get_record,create_record,update_record,delete_record,list_actions,run_action). The package's own convention is to declare the input shape; this one call site is the outlier.extra.arguments, "the property the MCP SDK passes tool arguments via when registerTool is called without an inputSchema".RequestHandlerExtrahas noargumentsmember — not in the type, and not infullExtraasProtocol._onrequestbuilds it. A deliberate trade-off is not built on a member that does not exist.af58a6fb), and the docblock predates the boundary, sogit log -Lattributes the whole hunk to the graft. Recorded as NOT MEASURED rather than folded into the four readings above.⇒ accidental. The card's route (1) is therefore not the answer, and route (2) applies.
SDK question, measured against the installed version — not recalled
@modelcontextprotocol/sdk1.30.0 (pinned inpnpm-lock.yaml, resolved on disk atpackages/mcp/node_modules/@modelcontextprotocol/sdk).registerTool'sinputSchemais typedZodRawShapeCompat | AnySchema, andAnySchema = z3.ZodTypeAny | z4.$ZodType. A raw JSON Schema is not accepted: it reachesgetZodSchemaObject()and throwsinputSchema must be a Zod schema or raw shape, received an unrecognized object.zod@4.4.3is already a direct dependency ofpackages/mcpand shipsfromJSONSchema, so no new dependency. The SDK converts the result straight back to JSON Schema fortools/listviatoJsonSchemaCompat; properties, types, descriptions,required, enums, nested objects andanyOf/oneOfall survive the round trip (measured), which adds$schemaandadditionalProperties: {}.FROM to TO — this changes what a published integration surface emits
StdioServerTransportat74049254, for a bridgedquery_recordsdeclaringobjectName: string (required)andlimit: number:tools/listinputSchema{"type":"object","properties":{}}— the SDK'sEMPTY_OBJECT_JSON_SCHEMA, i.e. a positive claim that the tool takes no argumentstools/callreachingtoolRegistry.executeinput: {}— always, whatever the client sent{}isErrorresult naming the offending fieldThe card's phrasing was "no input schema at all"; on the wire it is more precise to say the SDK synthesises an empty one, which is a stronger misstatement than silence — a well-behaved client reads it as "this tool takes nothing".
The second row is not a widening, it is the same fix.
McpServer.executeToolHandler()branches ontool.inputSchema: a schema-less tool is invoked ashandler(extra), and there is noargumentson thatextra. Declaring the schema is what makes the SDK hand the call's arguments to the handler at all. The two halves cannot be separated, so the handler signature moves from(extra)to(args)in the same change.The one fence this approached
registerToolwith aninputSchemaalso turns onMcpServer.validateToolInput(), and this SDK offers no advertise-without-validate mode. So schema validation on the call path arrives as an inseparable consequence, not as a second change folded in — no validation of my own was added anywhere, andtoolRegistry.execute's contract is untouched. Flagged rather than silent, per the dispatch.The premise the fence was written against turned out to be false in a way that shrinks its blast radius: it reads "today the handler reads
extra.argumentsunvalidated", butextra.argumentsisundefinedin every SDK version this package has depended on, so no bridged tool has ever received arguments. There is therefore no working call that validation can break — a call that previously could not have succeeded now either succeeds or returns a structured error. A tool that genuinely declares no arguments is unaffected (control below).Docblock — before and after
The card's acceptance criterion is that the docblock and the code stop disagreeing. Both prose sites moved.
bridgeTools, before — "Each registered tool becomes an MCP tool with the same name, description, and JSON Schema parameters." True only of a bridge that forwards them.After — names
toolInputSchemaand says the JSON Schema is converted into the Zod schema the SDK requires.registerToolFromDefinition, before — "Since our tools use JSON Schema, we use the low-level.tool()with a raw callback and pass the JSON Schema as annotations metadata." Wrong on all three counts: not.tool(), nothing passed,annotationscould not have carried it.After — deleted, and replaced by prose that states what the call does and why declaring
inputSchemais what makes arguments arrive. The measured SDK facts (thegetZodSchemaObjectthrow, theEMPTY_OBJECT_JSON_SCHEMAsynthesis, the missingextra.arguments) now live intoolInputSchema's docblock, next to the code that depends on them.Verification
New pins in
packages/mcp/src/mcp-tool-bridge-input-schema.test.tsspeak newline-delimited JSON-RPC down a realStdioServerTransport— the wire a desktop MCP host uses. The suite that was green through the whole defect asserted the bridge's log line, which stays true of a bridge that forwards nothing.Reverse verification, direction predicted before running: the two schema/argument pins and the consequence pin go red without the fix; the two controls stay green, so a red is a statement about the bridge and not about the harness.
601198c9, implementation absent):Tests 3 failed | 2 passed (5)—expected undefined to match object { type: 'string', … },expected {} to deeply equal { objectName: 'task', limit: 5 }.Tests 5 passed (5).MUTATION PROVEN: on-disk blob=e9cb5269… == pre-fix blob(and in the mutated file:inputSchema: toolInputSchema0 hits,rawExtra.arguments1 hit)Tests 3 failed | 2 passed (5)— the same three red, the same two controls greenRESTORE PROVEN: on-disk blob=0c6b4d2c… == HEAD blob; git diff HEAD empty./mcp-server-runtime.js), which vitest resolves tosrc/, never throughexportstodist/. The positive control is the ablation itself — the mutation flipped the result with no build in between, which a dist-resolved subject could not have done.Controls that stay green in both directions:
name and description reach the client(the harness works), anda tool that declares no parameters still registers and still executes(inputSchema.propertiesdeep-equals{},inputdeep-equals{}— the no-argument path is byte-identical before and after).Gates, all at
a876ebe6pnpm --filter @objectstack/mcp test—Test Files 24 passed (24) · Tests 258 passed (258)pnpm --filter @objectstack/mcp typecheck— exit 0 (--listFilesconfirmsmcp-server-runtime.tsis in the program)pnpm lint(eslint . --no-inline-config, whole repo, no narrowing) — exit 0pnpm check:type-check-debt—check-type-check-coverage --re-measure: OK — 30 ledger entr(ies) re-measured in 292.0s, 1560 raw tsc error(s) total, none above its recorded number.pnpm check:dual-build-cjs-loads—102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree.pnpm check:nul-bytes—OK (scanned 7363 text file(s) … no raw ASCII control bytes)Plus the other 25 families
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderives from the real change set: all exit 0.node scripts/check-test-completeness.mjsexit 3 — "PREREQUISITE NOT MET — this gate grades a savedturbo run testlog, and no log was named … the local reading for this gate is NOT MEASURED."node scripts/pm/check-half-states.mjsexit 3 — "PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential."One gate went genuinely red and was repaired, not baselined.
check:type-check-debtfirst reported@objectstack/mcp: TEST_DEBT records 53 raw tsc error(s), tsc --noEmit now reports 54 (+1). The+1was mine —TS6133: 'session' is declared but its value is never readin the new pin file. Fixed at the source by moving transport teardown intoafterEach(which also closes the transport when an assertion fails). Re-measured with the exclusion lifted: 53, class-for-class matching the ledger note (TS18046 x51,TS6133 x1,TS2352 x1), with the new file contributing 0. The ledger entry is untouched.packages/mcp/tsconfig.jsonexcludes**/*.test.ts, sopnpm typechecksays nothing about the new test file — which is exactly why the TEST_DEBT ratchet above is the measurement that covers it.Changeset
.changeset/mcp-bridge-forwards-tool-input-schema.md,@objectstack/mcp: minor— this changes what MCP clients receive from a published integration surface, so it is user-visible.Scope
packages/spec/liveness/tool.jsonis untouched —tool.parametersstayslive, and this card is evidence of the opposite of dead: the cloud LLM path (service-ai/src/adapters/vercel-adapter.ts,buildVercelOptions) reads the same key, unevenly consumed. Neither serially-locked file (packages/rest/src/rest-server.ts,packages/cli/src/commands/migrate/plan.ts) is on this card's surface or in this diff.Generated by Claude Code