Skip to content

fix(server): let a tool handler omit content (#2755) - #2815

Open
po-et wants to merge 1 commit into
modelcontextprotocol:mainfrom
po-et:fix/2755-contentless-tool-result
Open

fix(server): let a tool handler omit content (#2755)#2815
po-et wants to merge 1 commit into
modelcontextprotocol:mainfrom
po-et:fix/2755-contentless-tool-result

Conversation

@po-et

@po-et po-et commented Sep 14, 2026

Copy link
Copy Markdown

Closes #2755

Bug

A tool that returns structured content cannot omit content:

server.registerTool('ping', { outputSchema: z.string() }, async () => ({
    // content: [{ type: 'text', text: str }],   // error without this line
    structuredContent: str
}));

The specification makes that block a SHOULD, not a MUST:

For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.

Root cause

CallToolResultSchema declares content: z.array(ContentBlockSchema).default([]). CallToolResult is derived with z.infer, the shape parsing produces, so content is required there — and ToolCallback returned that type. What an author may write is the z.input side, where the default makes it optional.

The runtime never needed it. normalizeContentlessToolResult (wire/resultFamilies.ts:13) turns a content-less handler result into content: [] before era validation, and its comment calls this a "v1-parity authoring affordance". appendTextFallbackForNonObject reads result.content ?? []. isSpecType.CallToolResult({}) is documented as true precisely because content has a default (specTypeSchema.ts:296-300). Only the callback's type disagreed.

Changes

  • packages/core-internal/src/types/types.ts: add InferInput and CallToolResultInput, derived from the same schema via z.input, so it tracks the schema rather than restating it. content is the only member the two differ on.
  • packages/server/src/server/mcp.ts: ToolCallback and LegacyToolCallback return it. Comment on ToolExecutor (see below).
  • Tests in mcp.compat.test.ts; CallToolResultInput added to the resultType pin in wireOnlyHiding.test.ts.

Type-only — no runtime change — and a widening, so a handler that writes content today is unaffected.

What this deliberately does not do. ToolExecutor keeps declaring the parsed shape. Widening it would have to widen setRequestHandler'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 typecheck clean; @modelcontextprotocol/core-internal 69 files / 1457 tests and @modelcontextprotocol/server 42 files / 487 tests pass; lint and Prettier clean. Pre-push Build / Typecheck / Lint green.

tsconfig.json has include: ['./'], so the test files are type-checked and the type-level assertions are real. Reverting the change makes mcp.compat.test.ts fail with the reporter's own error:

error TS2769: ... Property 'content' is missing in type '{ structuredContent: { a: number; }; }'

Mutation-tested, each mutation applied and both tsgo --noEmit and vitest re-run:

mutation caught by
ToolCallback reverted typecheck (modern-overload registration)
LegacyToolCallback reverted typecheck (raw-shape registration)
CallToolResultInput = CallToolResult typecheck, 4 sites
Omit<CallToolResult,'content'> & { content?: unknown } typecheck, via the two unused @ts-expect-error
normalizeContentlessToolResult → identity both wire tests, on each era

The first two needed dedicated registrations: registerTool is overloaded, and a registration that omits inputSchema binds 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:806 declares content: 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 _wrapHandler normalizes 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:370 reads "2026-07-28 connections require it explicitly". That is true of the wire schema, but a 2026-07-28 handler that omits content still ships content: [] — 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.

`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.
@po-et
po-et requested a review from a team as a code owner September 14, 2026 07:55
@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 61b0c93

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch

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

@pkg-pr-new

pkg-pr-new Bot commented Sep 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2815

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2815

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2815

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2815

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2815

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2815

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2815

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2815

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2815

commit: 61b0c93

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v2] Typescript: optional content when structuredContent is present

1 participant