Repository navigation
feat(server): send structuredContent as JSON text when a tool omits content #3153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Output extends StandardSchemaWithJSON | ZodRaw | |
| /** | ||
| * The successful result of a tool that declares an `outputSchema`: `structuredContent` is | ||
| * required and typed by {@linkcode ToolStructuredContent}. | ||
| * | ||
| * `content` is optional here: when it is omitted, the server sends the JSON of | ||
| * `structuredContent` as a single text block. Supply your own `content` to control what | ||
| * clients that ignore `structuredContent` see. | ||
| */ | ||
| export type ToolResultWithStructuredContent<T> = { | ||
| // `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']; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Tool authors who swap a handler with Why this was flaggedTrigger: a handler typed with the documented annotation, e.g. Verification: nit — triggers when a handler whose return type is Finding ID: |
||
| structuredContent: T; | ||
| isError?: false; | ||
| }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+202
to
+203
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): Two new statements about content-less tool results say something the code does not do, so upgrade-skill readers get wrong expectations. Why this was flaggedA JavaScript or cast handler on a tool with no Verification: nit — triggers when a handler that TypeScript would not allow to omit Finding ID: |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Comment on lines
+748
to
+750
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): Two comments added here run against the repo's brief-comments principle. The inline comment at test/conformance/src/everythingServer.ts:748-750 spans three lines, and the module-private helper Why this was flaggedNo runtime input triggers this; it is a convention check on comments the diff adds. The comment at test/conformance/src/everythingServer.ts:748-750 is a three-line inline comment explaining why the handlers omit Verification: Triggering condition: none at runtime; this is a violation of a written repository instruction by comments the diff adds. test/conformance/src/everythingServer.ts:748-750 is a three-line Finding ID: |
||
| 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 — | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 (optional) A tool result that omits
contentbut carriesstructuredContentplus atask,inputRequestsorrequestStatekey is now sent as a normal success instead of being rejected.withStructuredContentTextaddscontentbeforenormalizeContentlessToolResultruns (server.ts:576), and that guard only fires whilecontent === undefined. Fix: leave the result untouched when any key inTOOL_RESULT_FOREIGN_FAMILY_KEYSis present, so the existing loud rejection still applies on both eras.Why this was flagged
Trigger: a
registerToolhandler returns{ structuredContent: {...}, task: {...} }with nocontent, arriving through thetools/callhandler at packages/server/src/server/mcp.ts:310.withStructuredContentTextonly checkscontent !== undefinedandstructuredContent === undefined, so it adds a text block. The foreign-family guards at packages/core-internal/src/wire/resultFamilies.ts:18-19 and packages/core-internal/src/wire/rev2025-11-25/buildSchemas.ts:2245-2259 both return early oncecontentis defined. On the base branch the same result stays content-less and fails with "Invalid tools/call result: content is required when the body carries 'task' ..."; after the change the client receives a success with a text block. The paragraph this PR edits at skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md:204-206 still says such a body "is still rejected loudly".Verification: Triggers when a
registerToolhandler returnsstructuredContenttogether with atask,inputRequestsorrequestStatekey and nocontent.packages/server/src/server/mcp.ts:1343-1347:withStructuredContentTextnever looks at foreign-family keys.normalizeContentlessToolResultis a no-op oncecontent !== undefined. A loud -32602 turns into a success that carries a stray key.Finding ID:
f-b052db8b