From 61b0c93deb49f26c5f3a79c374d8655353b18fbb Mon Sep 17 00:00:00 2001 From: po-et <42566883+po-et@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:33:15 +0800 Subject: [PATCH] fix(server): let a tool handler omit content (#2755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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/contentless-tool-result.md | 6 + packages/core-internal/src/types/types.ts | 29 ++++ .../test/types/wireOnlyHiding.test.ts | 2 + packages/server/src/server/mcp.ts | 17 ++- .../server/test/server/mcp.compat.test.ts | 138 +++++++++++++++++- 5 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 .changeset/contentless-tool-result.md diff --git a/.changeset/contentless-tool-result.md b/.changeset/contentless-tool-result.md new file mode 100644 index 0000000000..85249d8a62 --- /dev/null +++ b/.changeset/contentless-tool-result.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/server': patch +--- + +A tool handler may now return a result without `content`. `ToolCallback` and `LegacyToolCallback` returned `CallToolResult`, the shape parsing produces, where `content` is always an array because `CallToolResultSchema` defaults it to `[]` — so a handler that returned only `structuredContent` failed to compile even though the server has always accepted it (`normalizeContentlessToolResult` fills `content: []` before validation, and `isSpecType.CallToolResult({})` is documented as true for that reason). The specification makes the serialized-JSON TextContent block a SHOULD for a tool returning structured content, not a MUST (#2755). The new `CallToolResultInput` is derived from the same schema through `z.input`, so it differs from `CallToolResult` in `content` alone; every other member keeps its type. Type-only, and a widening — a handler that writes `content` today is unaffected. diff --git a/packages/core-internal/src/types/types.ts b/packages/core-internal/src/types/types.ts index f2bc9d67fc..acd2963ecb 100644 --- a/packages/core-internal/src/types/types.ts +++ b/packages/core-internal/src/types/types.ts @@ -199,6 +199,13 @@ type Flatten = T extends Primitive type Infer = Flatten>; +/** + * The shape a schema ACCEPTS, before defaults are applied — as opposed to + * {@link Infer}, which is the shape parsing produces. The two differ only + * where a schema declares `.default()`. + */ +type InferInput = Flatten>; + /** * Wire-only members hidden from the public types. * @@ -421,6 +428,28 @@ export type ListToolsRequest = Infer; export type ListToolsResult = StripWireOnly>; export type CallToolRequestParams = Infer; export type CallToolResult = StripWireOnly>; +/** + * A `tools/call` result as a tool handler may WRITE it. + * + * {@link CallToolResult} is the parsed shape, where `content` is always an + * array because the schema defaults it to `[]`. An author does not have to + * supply it: the server normalizes a content-less handler result before + * era validation (`normalizeContentlessToolResult`), which is why an + * empty object is already a valid `CallToolResult` INPUT — see + * `isSpecType.CallToolResult({})`. + * + * This matters for a tool that returns `structuredContent`. The spec makes + * the serialized-JSON TextContent block a SHOULD, not a MUST, so requiring + * authors to hand-write `content` alongside it asks for something the + * protocol does not. + * + * The omission is only good for a plain tool result. A body that also + * carries another result family's key (`task`, `inputRequests`, + * `requestState`) is left alone by that normalization and is then refused + * with −32602, because defaulting one family's field into another's body + * would be a guess. + */ +export type CallToolResultInput = StripWireOnly>; export type CompatibilityCallToolResult = StripWireOnly>; export type CallToolRequest = Infer; export type ToolListChangedNotification = Infer; diff --git a/packages/core-internal/test/types/wireOnlyHiding.test.ts b/packages/core-internal/test/types/wireOnlyHiding.test.ts index 7f7ff357eb..cca24d0bcc 100644 --- a/packages/core-internal/test/types/wireOnlyHiding.test.ts +++ b/packages/core-internal/test/types/wireOnlyHiding.test.ts @@ -24,6 +24,7 @@ import type * as z from 'zod/v4'; import type { CallToolResult, + CallToolResultInput, CancelTaskResult, CompleteResult, CreateMessageResult, @@ -59,6 +60,7 @@ describe('wire-only members are hidden from the public result types', () => { expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index d2e40181e4..4345ac3f6d 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -2,6 +2,7 @@ import type { BaseMetadata, CacheHint, CallToolResult, + CallToolResultInput, CompleteRequestPrompt, CompleteRequestResourceTemplate, CompleteResult, @@ -1223,8 +1224,8 @@ export type LegacyToolCallback = Args exte ? ( args: InferRawShape, ctx: ServerContext - ) => CallToolResult | InputRequiredResult | Promise - : (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise; + ) => CallToolResultInput | InputRequiredResult | Promise + : (ctx: ServerContext) => CallToolResultInput | InputRequiredResult | Promise; /** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ export type LegacyPromptCallback = Args extends ZodRawShape @@ -1246,7 +1247,7 @@ export type BaseToolCallback< * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. */ export type ToolCallback = BaseToolCallback< - CallToolResult | InputRequiredResult, + CallToolResultInput | InputRequiredResult, ServerContext, Args >; @@ -1259,6 +1260,16 @@ export type AnyToolHandler Promise; export type RegisteredTool = { diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index ae7a0438b5..3622739cd4 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -1,8 +1,24 @@ -import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; -import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + isStandardSchema, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + setNegotiatedProtocolVersion +} from '@modelcontextprotocol/core-internal'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import * as z from 'zod/v4'; +import { invoke } from '../../src/server/invoke'; import { McpServer } from '../../src/index'; + +const MODERN_REVISION = '2026-07-28'; +const MODERN_ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'c', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; import type { InferRawShape } from '../../src/server/mcp'; import { completable } from '../../src/server/completable'; @@ -147,3 +163,121 @@ describe('SEP-2106: registerTool with non-object outputSchema (type-level)', () expectTypeOf().toMatchTypeOf>>>(); }); }); + +describe('a tool handler may omit content (#2755)', () => { + // The spec makes the serialized-JSON TextContent block a SHOULD for a tool + // that returns structured content, not a MUST. The runtime already agrees: + // `normalizeContentlessToolResult` turns a content-less handler result into + // `content: []` before era validation, and `isSpecType.CallToolResult({})` + // is documented as true because the schema defaults `content`. Only the + // callback's return type disagreed. + it('compiles without content, for object and non-object output schemas alike', () => { + const server = new McpServer({ name: 's', version: '1' }); + server.registerTool('obj', { outputSchema: z.object({ a: z.number() }) }, async () => ({ + structuredContent: { a: 1 } + })); + // The reporter's case: a string outputSchema, no hand-written content. + server.registerTool('str', { outputSchema: z.string() }, async () => ({ + structuredContent: `Pong at ${new Date().toISOString()}` + })); + // Nothing at all is a result too — a tool that only performs an effect. + server.registerTool('none', {}, async () => ({})); + expect(Object.keys((server as unknown as { _registeredTools: Record })._registeredTools)).toEqual([ + 'obj', + 'str', + 'none' + ]); + }); + + it('compiles on BOTH registerTool overloads', () => { + // registerTool is overloaded, and the three registrations above bind to + // whichever overload still accepts a content-less return — so reverting + // one callback type alone would fall through to the other and nothing + // would fail. These two can each bind only one: a Standard Schema + // inputSchema selects ToolCallback, a raw Zod shape selects + // LegacyToolCallback. + const server = new McpServer({ name: 's', version: '1' }); + server.registerTool( + 'modern', + { inputSchema: z.object({ x: z.number() }), outputSchema: z.object({ a: z.number() }) }, + async () => ({ structuredContent: { a: 1 } }) + ); + server.registerTool('legacy', { inputSchema: { x: z.number() }, outputSchema: { a: z.number() } }, async () => ({ + structuredContent: { a: 1 } + })); + expect(Object.keys((server as unknown as { _registeredTools: Record })._registeredTools)).toEqual([ + 'modern', + 'legacy' + ]); + }); + + it('still rejects a wrongly typed content or isError', () => { + const server = new McpServer({ name: 's', version: '1' }); + // @ts-expect-error content, when supplied, is still a ContentBlock array + server.registerTool('bad-content', {}, async () => ({ content: 'nope' })); + // @ts-expect-error isError is still a boolean + server.registerTool('bad-error', {}, async () => ({ isError: 'yes' })); + expect(Object.keys((server as unknown as { _registeredTools: Record })._registeredTools)).toEqual([ + 'bad-content', + 'bad-error' + ]); + }); + + it('puts content on the wire on the 2026-07-28 era, where the schema has no default', async () => { + // The era that matters most. On 2025-11-25 the wire schema still + // defaults `content`, so a regression there would be masked; the + // 2026-07-28 wire schema declares `content: z.array(ContentBlockSchema)` + // with no default and no wire-seam guard, which makes the server-side + // normalization the only thing supplying it. + const server = new McpServer({ name: 's', version: '1' }); + server.registerTool('obj', { outputSchema: z.object({ a: z.number() }) }, async () => ({ + structuredContent: { a: 1 } + })); + setNegotiatedProtocolVersion(server.server, MODERN_REVISION); + + const response = await invoke( + server, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'obj', arguments: {}, _meta: MODERN_ENVELOPE } + } as JSONRPCRequest, + { classification: { era: 'modern', revision: MODERN_REVISION } } + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { result?: { content?: unknown; structuredContent?: unknown } }; + expect(body.result?.content).toEqual([]); + expect(body.result?.structuredContent).toEqual({ a: 1 }); + }); + + it('puts content on the wire even though the handler wrote none', async () => { + // The type change alone would be worth nothing if the omission then + // shipped a result without `content`, which the wire schema requires. + const server = new McpServer({ name: 's', version: '1' }); + server.registerTool('obj', { outputSchema: z.object({ a: z.number() }) }, async () => ({ + structuredContent: { a: 1 } + })); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '1.0.0' } } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'obj', arguments: {} } } as JSONRPCMessage); + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + const message = responses.find(r => 'id' in r && r.id === 2) as { result?: { content?: unknown; structuredContent?: unknown } }; + expect(message.result?.content).toEqual([]); + expect(message.result?.structuredContent).toEqual({ a: 1 }); + + await server.close(); + }); +});