fix(server): let a tool handler omit content (#2755) - #2815
Open
po-et wants to merge 1 commit into
Open
Conversation
`ToolCallback` returned `CallToolResult`, the PARSED shape, where `content`
is always an array because `CallToolResultSchema` defaults it to `[]`. A
handler that returns only `structuredContent` therefore failed to compile,
even though nothing downstream needed it to supply `content`.
The runtime already treats a content-less result as valid authoring input:
`normalizeContentlessToolResult` (`wire/resultFamilies.ts`) turns it into
`content: []` before era validation, `appendTextFallbackForNonObject` reads
`result.content ?? []`, and `isSpecType.CallToolResult({})` is documented as
true for exactly this reason. The spec agrees — the serialized-JSON
TextContent block is a SHOULD for a tool returning structured content, not a
MUST.
Adds `CallToolResultInput`, derived from the same schema through `z.input`
rather than `z.infer`, so it tracks the schema instead of restating it.
`content` is the only member the two differ on; every other field keeps its
type. `ToolCallback` and `LegacyToolCallback` now return it.
Type-only: no runtime behaviour changes, and the widening is backward
compatible — a handler that writes `content` today still compiles.
`ToolExecutor` keeps declaring the parsed shape, because widening it would
have to widen `setRequestHandler`'s result type for a spec method, which is
a protocol-layer change this does not need. The seam is commented instead,
naming the `?? []` guard downstream as load-bearing so a reader who trusts
the signature does not retire it.
🦋 Changeset detectedLatest commit: 61b0c93 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2755
Bug
A tool that returns structured content cannot omit
content:The specification makes that block a SHOULD, not a MUST:
Root cause
CallToolResultSchemadeclarescontent: z.array(ContentBlockSchema).default([]).CallToolResultis derived withz.infer, the shape parsing produces, socontentis required there — andToolCallbackreturned that type. What an author may write is thez.inputside, where the default makes it optional.The runtime never needed it.
normalizeContentlessToolResult(wire/resultFamilies.ts:13) turns a content-less handler result intocontent: []before era validation, and its comment calls this a "v1-parity authoring affordance".appendTextFallbackForNonObjectreadsresult.content ?? [].isSpecType.CallToolResult({})is documented astrueprecisely becausecontenthas a default (specTypeSchema.ts:296-300). Only the callback's type disagreed.Changes
packages/core-internal/src/types/types.ts: addInferInputandCallToolResultInput, derived from the same schema viaz.input, so it tracks the schema rather than restating it.contentis the only member the two differ on.packages/server/src/server/mcp.ts:ToolCallbackandLegacyToolCallbackreturn it. Comment onToolExecutor(see below).mcp.compat.test.ts;CallToolResultInputadded to theresultTypepin inwireOnlyHiding.test.ts.Type-only — no runtime change — and a widening, so a handler that writes
contenttoday is unaffected.What this deliberately does not do.
ToolExecutorkeeps declaring the parsed shape. Widening it would have to widensetRequestHandler's result type for a spec method, which is a protocol-layer change this issue does not need. The seam carries a comment instead, naming the?? []guard downstream as load-bearing so a reader who trusts the signature does not retire it. Happy to do the wider version if you would rather the compiler carried it.Verification
pnpm -r typecheckclean;@modelcontextprotocol/core-internal69 files / 1457 tests and@modelcontextprotocol/server42 files / 487 tests pass; lint and Prettier clean. Pre-push Build / Typecheck / Lint green.tsconfig.jsonhasinclude: ['./'], so the test files are type-checked and the type-level assertions are real. Reverting the change makesmcp.compat.test.tsfail with the reporter's own error:Mutation-tested, each mutation applied and both
tsgo --noEmitandvitestre-run:ToolCallbackrevertedLegacyToolCallbackrevertedCallToolResultInput = CallToolResultOmit<CallToolResult,'content'> & { content?: unknown }@ts-expect-errornormalizeContentlessToolResult→ identityThe first two needed dedicated registrations:
registerToolis overloaded, and a registration that omitsinputSchemabinds to whichever overload still accepts the return, so reverting one callback type alone fell through to the other and nothing failed.Both eras are pinned on the wire, not just the type. 2026-07-28 is the one that matters:
rev2026-07-28/buildSchemas.ts:806declarescontent: z.array(ContentBlockSchema)with no default, so the server-side normalization is the only thing supplying it there.Also checked, in case the omission could ship something illegal: a hand-written low-level
setRequestHandler('tools/call', …)is covered, because_wrapHandlernormalizes for it too. A content-less body that carries another family's key (task,inputRequests,requestState) skips normalization by design and is refused with −32602 rather than shipping — I noted that in the new type's doc comment, since the widening now lets that shape compile.One thing I did not touch
docs/migration/support-2026-07-28.md:370reads "2026-07-28 connections require it explicitly". That is true of the wire schema, but a 2026-07-28 handler that omitscontentstill shipscontent: []— I verified it on that era. It predates this change and I did not want to rewrite the sentence on my own reading of it; flagging in case you want it reworded now that authoring is the documented path.