diff --git a/.changeset/tool-structured-content-text.md b/.changeset/tool-structured-content-text.md new file mode 100644 index 0000000000..81077932cc --- /dev/null +++ b/.changeset/tool-structured-content-text.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +A `McpServer.registerTool` result with `structuredContent` and no `content` now gets a text block holding the serialized JSON of `structuredContent`, as the tools spec recommends, instead of `content: []`. A result that supplies `content`, even an empty array, is unchanged. To match, successful results of a tool with an `outputSchema` may now omit `content` in TypeScript (`ToolResultWithStructuredContent['content']` is optional). diff --git a/docs/servers/tools.md b/docs/servers/tools.md index 26a666920c..ec3afdf77e 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -122,6 +122,8 @@ The SDK validates the value you return and sends it as is, without the parsed re The SDK also validates `structuredContent` against `outputSchema` at runtime before the result leaves your server, and advertises the derived JSON Schema in `tools/list` so clients can validate it too. The runtime check is still the one that counts: it covers JavaScript callers, values cast with `as`, and constraints TypeScript cannot express, such as `.min()`, `.email()` or `.refine()`. +When `outputSchema` is set you may leave out `content`; the server then sends the JSON of `structuredContent` as a single text block, so clients that ignore `structuredContent` still see the result. Supply your own `content` to control that text. + Calling `product-details` with `{ name: 'Travel mug' }` returns both renderings: ``` diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 6f38085330..38801ae65f 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -307,12 +307,13 @@ export class McpServer { const result = await this.executeToolHandler(tool, args, ctx); await this.validateToolOutput(tool, result, request.params.name); if (isInputRequiredResult(result)) return result; + const completed = withStructuredContentText(result); // SEP-2106 result-side projection (the era-agnostic TextContent auto-append; the // `{result:…}` wrap on the 2025 era) lives behind the wire codec's // `projectCallToolResult`. The codec receives the SAME advertised JSON Schema // `tools/list` emits (and that the codec's `encodeResult('tools/list', …)` may have // wrapped) so the listing and the call cannot diverge. - return this.server.projectCallToolResult(result, tool.outputSchemaJson); + return this.server.projectCallToolResult(completed, tool.outputSchemaJson); } catch (error) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { throw error; // Return the error to the caller without wrapping in CallToolResult @@ -1338,6 +1339,14 @@ export class ResourceTemplate { } } +/** Sends the JSON of `structuredContent` as text when a tool result omits `content` (spec: SHOULD mirror structured content as text). */ +function withStructuredContentText(result: CallToolResult): CallToolResult { + if ((result as { content?: unknown }).content !== undefined || result.structuredContent === undefined) { + return result; + } + return { ...result, content: [{ type: 'text', text: JSON.stringify(result.structuredContent) }] }; +} + /** * A plain record of Zod field schemas, e.g. `{ name: z.string() }`. Accepted by * `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`. @@ -1380,12 +1389,16 @@ export type ToolStructuredContent = { // `Omit` would collapse CallToolResult's known keys into its index signature; key remapping keeps them. [K in keyof CallToolResult as K extends 'content' | 'structuredContent' | 'isError' ? never : K]: CallToolResult[K]; } & { - content: CallToolResult['content']; + content?: CallToolResult['content']; structuredContent: T; isError?: false; }; diff --git a/packages/server/test/server/registerToolOutputTyping.test.ts b/packages/server/test/server/registerToolOutputTyping.test.ts index 24e4ac5bac..27163f6603 100644 --- a/packages/server/test/server/registerToolOutputTyping.test.ts +++ b/packages/server/test/server/registerToolOutputTyping.test.ts @@ -14,6 +14,7 @@ import * as z from 'zod/v4'; import type { CallToolResult, ServerContext, ToolCallback, ToolResultFor, ToolStructuredContent } from '../../src/index'; import { fromJsonSchema, inputRequired, McpServer } from '../../src/index'; +import { invoke } from '../../src/server/invoke'; const server = new McpServer({ name: 's', version: '1' }); @@ -125,14 +126,14 @@ describe('registerTool: structuredContent is typed from outputSchema', () => { server.registerTool('mixed-bad', { inputSchema, outputSchema }, ({ ok }) => (ok ? { content: [], structuredContent: { data: 1 } } : { content: [], isError: true })); }); - it('requires content on every result, typed or not', () => { + it('lets typed successes omit content (the server sends structuredContent as JSON text)', () => { const outputSchema = z.object({ data: z.string() }); - // @ts-expect-error content is required on typed successes too server.registerTool('no-content', { outputSchema }, () => ({ structuredContent: { data: 'x' } })); // The rest of CallToolResult keeps its types on the typed branch. - server.registerTool('meta', { outputSchema }, () => ({ content: [], _meta: { trace: 'abc' }, structuredContent: { data: 'x' } })); - // @ts-expect-error content blocks are still checked + server.registerTool('meta', { outputSchema }, () => ({ _meta: { trace: 'abc' }, structuredContent: { data: 'x' } })); + // @ts-expect-error content blocks are still checked when supplied server.registerTool('bad-block', { outputSchema }, () => ({ content: [{ type: 'bogus' }], structuredContent: { data: 'x' } })); + // Schema-less and error results keep the CallToolResult contract (content required). // @ts-expect-error content is required without an outputSchema server.registerTool('schemaless-no-content', {}, () => ({ structuredContent: 'x' })); // @ts-expect-error content is required on error results @@ -266,3 +267,35 @@ describe('registerTool: structuredContent is typed from outputSchema', () => { void broad; }); }); + +describe('registerTool: runtime content for typed successes', () => { + const call = async (s: McpServer, name: string): Promise => { + const response = await invoke( + s, + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: {} } }, + { classification: { era: 'legacy' } } + ); + return ((await response.json()) as { result: CallToolResult }).result; + }; + + it('sends structuredContent as a JSON text block when content is omitted', async () => { + const s = new McpServer({ name: 's', version: '1' }); + s.registerTool('obj', { outputSchema: z.object({ data: z.string() }) }, () => ({ structuredContent: { data: 'x' } })); + s.registerTool('arr', { outputSchema: z.array(z.number()) }, () => ({ structuredContent: [1, 2] })); + s.registerTool('empty', { outputSchema: z.object({ data: z.string() }) }, () => ({ + content: [], + structuredContent: { data: 'x' } + })); + + const obj = await call(s, 'obj'); + expect(obj.isError).toBeUndefined(); + expect(obj.content).toEqual([{ type: 'text', text: '{"data":"x"}' }]); + expect(obj.structuredContent).toEqual({ data: 'x' }); + + const arr = await call(s, 'arr'); + expect(arr.content).toEqual([{ type: 'text', text: '[1,2]' }]); + + // Explicit content, even empty, is left alone. + expect((await call(s, 'empty')).content).toEqual([]); + }); +}); diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md index e12d54a800..bb1c375b36 100644 --- a/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md @@ -197,12 +197,13 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see - **`CallToolResult.content` keeps the v1 parse tolerance on the legacy era.** An inbound result without `content` defaults to `[]` (deployed servers omit it alongside `structuredContent`); 2026-07-28 connections stay strict. Authoring is - unchanged and era-independent: the TypeScript surface requires `content` on handler - results, and a content-less handler result is normalized to `content: []` before it - reaches the wire. One sharpening remains: a content-less body carrying another - result family's vocabulary (a task handle or an `input_required` round) is still - rejected loudly — tolerance never turns a different result kind into a silent empty - success. A body whose only foreign key was `resultType` strips to an empty object + era-independent: the TypeScript surface requires `content` on handler results, except + successful results of a `registerTool` tool with an `outputSchema`, which may omit it. + `McpServer` gives such a result a JSON text block of its `structuredContent`; any other + content-less handler result is normalized to `content: []` before it reaches the + wire. One sharpening remains: a content-less body carrying another result family's + vocabulary (a task handle or an `input_required` round) is still rejected loudly — + tolerance never turns a different result kind into a silent empty success. A body whose only foreign key was `resultType` strips to an empty object and defaults, exactly as v1 parsed a payload-free body. - **`ElicitResult.content` values are typed and validated as `string | number | boolean | string[]`.** v1's TypeScript surface accepted diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md index c00fc4dbd6..4dc204cf53 100644 --- a/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md @@ -43,8 +43,10 @@ before can now fail to compile when it: - passes an `outputSchema` typed as possibly `undefined` (a registry forwarding an optional schema). TypeScript drops the `undefined` at the call site, so the callback is checked as if the schema were present. `isError: true` results, input-required results, and tools without an `outputSchema` -are unchanged. Runtime validation is unchanged and still authoritative. A test fixture -that deliberately returns invalid output can cast the result: `() => ({ ... }) as never`. +are unchanged. Successful typed results may leave out `content`; the server then sends +the JSON of `structuredContent` as a text block. Runtime validation is unchanged and +still authoritative. A test fixture that deliberately returns invalid output can cast +the result: `() => ({ ... }) as never`. #### Standard Schema objects (raw shapes deprecated) diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 2aa3d31601..0ec94a168c 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -745,6 +745,32 @@ function createMcpServer() { } ); + // Structured content (tools-call-structured-content scenario). The handlers + // omit `content`: McpServer sends the serialized JSON of structuredContent + // as a text block, which is what the scenario's text-mirror check verifies. + mcpServer.registerTool( + 'test_structured_content', + { + description: 'Tests structured content against an object outputSchema', + outputSchema: z.object({ temperature: z.number(), conditions: z.string() }) + }, + async () => ({ structuredContent: { temperature: 22.5, conditions: 'Partly cloudy' } }) + ); + // SEP-2106: array outputSchema and structuredContent. + mcpServer.registerTool( + 'test_structured_content_array', + { + description: 'Tests array structured content (SEP-2106)', + outputSchema: z.array(z.object({ hour: z.string(), temp: z.number() })) + }, + async () => ({ + structuredContent: [ + { hour: '09:00', temp: 68 }, + { hour: '10:00', temp: 72 } + ] + }) + ); + // SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool. // The scenario verifies that $schema/$defs/additionalProperties (SEP-1613) // and the broader 2020-12 vocabulary — $anchor, allOf/anyOf, if/then/else — diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index d26f6524d2..c80fac139f 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -420,6 +420,11 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#structured-content', behavior: 'A tool returning structured content also returns the serialized JSON as a text content block.' }, + 'server:tools:structured-content:text-when-content-omitted': { + source: 'https://modelcontextprotocol.io/specification/draft/server/tools#structured-content', + behavior: + 'A McpServer tool with an outputSchema whose handler returns structuredContent but omits content gets a single {type:"text", text: JSON.stringify(structuredContent)} block, so the result carries the serialized JSON the spec asks for. A handler that supplies content, even an empty array, is sent unchanged.' + }, 'tools:call:unknown-name': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling', behavior: 'tools/call for a name the server does not recognise returns a JSON-RPC error.' diff --git a/test/e2e/scenarios/tools.test.ts b/test/e2e/scenarios/tools.test.ts index 517f5daa5f..590ca01f36 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -670,6 +670,27 @@ verifies('tools:call:structured-content:text-mirror', async ({ transport }: Test expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify({ doubled: 42 }) }); }); +verifies('server:tools:structured-content:text-when-content-omitted', async ({ transport }: TestArgs) => { + const makeServer = (): McpServer => { + const s = new McpServer({ name: 's', version: '0' }); + const outputSchema = z.object({ data: z.string() }); + s.registerTool('omitted', { inputSchema: z.object({}), outputSchema }, () => ({ structuredContent: { data: 'x' } })); + s.registerTool('empty', { inputSchema: z.object({}), outputSchema }, () => ({ content: [], structuredContent: { data: 'x' } })); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + const omitted = await client.callTool({ name: 'omitted', arguments: {} }); + expect(omitted.isError).toBeFalsy(); + expect(omitted.structuredContent).toEqual({ data: 'x' }); + expect(omitted.content).toEqual([{ type: 'text', text: JSON.stringify({ data: 'x' }) }]); + + const empty = await client.callTool({ name: 'empty', arguments: {} }); + expect(empty.structuredContent).toEqual({ data: 'x' }); + expect(empty.content).toEqual([]); +}); + verifies(['client:output-schema:validate', 'client:output-schema:missing-structured'], async ({ transport }: TestArgs) => { // Client-side validation is only observable when the server does NOT // pre-validate (raw Server) — McpServer would intercept first.