Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tool-structured-content-text.md
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).
2 changes: 2 additions & 0 deletions docs/servers/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down
17 changes: 15 additions & 2 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }] };
Comment on lines +1344 to +1347

Copy link
Copy Markdown
Contributor

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 content but carries structuredContent plus a task, inputRequests or requestState key is now sent as a normal success instead of being rejected. withStructuredContentText adds content before normalizeContentlessToolResult runs (server.ts:576), and that guard only fires while content === undefined. Fix: leave the result untouched when any key in TOOL_RESULT_FOREIGN_FAMILY_KEYS is present, so the existing loud rejection still applies on both eras.

Why this was flagged

Trigger: a registerTool handler returns { structuredContent: {...}, task: {...} } with no content, arriving through the tools/call handler at packages/server/src/server/mcp.ts:310. withStructuredContentText only checks content !== undefined and structuredContent === 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 once content is 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 registerTool handler returns structuredContent together with a task, inputRequests or requestState key and no content. packages/server/src/server/mcp.ts:1343-1347: withStructuredContentText never looks at foreign-family keys. normalizeContentlessToolResult is a no-op once content !== undefined. A loud -32602 turns into a success that carries a stray key.

Finding ID: f-b052db8b

}

/**
* A plain record of Zod field schemas, e.g. `{ name: z.string() }`. Accepted by
* `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`.
Expand Down Expand Up @@ -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'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Tool authors who swap a handler with RegisteredTool.update({ callback }) get a compile error for exactly the typed handlers this change tells them they can write. Making content optional at packages/server/src/server/mcp.ts:1401 widens the typed result, but update at mcp.ts:1526 still takes ToolCallback<StandardSchemaWithJSON>, whose result is plain CallToolResult with required content. Fix: make every place that accepts a tool callback accept the same result type, e.g. type update's callback to return ToolResultFor<...> with optional content, or state the exception in the docs.

Why this was flagged

Trigger: a handler typed with the documented annotation, e.g. const cb = (): ToolResultFor<typeof outputSchema> => ({ structuredContent: { data: 'x' } }), is passed to tool.update({ callback: cb }). update's callback is ToolCallback<StandardSchemaWithJSON> at packages/server/src/server/mcp.ts:1526; its Output parameter defaults to undefined, so ToolResultFor at mcp.ts:1421-1425 resolves to CallToolResult, where content is required. With content?: at mcp.ts:1401 the typed result is no longer assignable. On base the same annotated function had required content and was assignable, so the dismissal's "not touched by this diff" misses that the diff changes the source type. At runtime update stores the callback at mcp.ts:1002-1004 and the same tools/call path adds the text block, so only the type rejects it. docs/servers/tools.md:125 says "When outputSchema is set you may leave out content" with no exception for update. Remedy: widen the callback type at mcp.ts:1526.

Verification: nit — triggers when a handler whose return type is ToolResultFor<typeof outputSchema> is passed to RegisteredTool.update({ callback }). packages/server/src/server/mcp.ts:1526 still requires content, which line 1401 now makes optional, so the callback is rejected by update. It was assignable on base. No overload of update accepts the typed result.

Finding ID: f-4b14fb37

structuredContent: T;
isError?: false;
};
Expand Down
41 changes: 37 additions & 4 deletions packages/server/test/server/registerToolOutputTyping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<CallToolResult> => {
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([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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. withStructuredContentText (mcp.ts:1343) is not gated on outputSchema or isError, so a schema-less or error result with structuredContent also gets the text block, not content: []. Fix: make the words match the helper and the codec, or gate the helper on successful outputSchema results, which covers the 2 sites listed. Same instruction at 2 sites (behavioral-changes.md:203, test/e2e/requirements.ts:426). The second says content: [] is sent unchanged, but textFallback.ts:22 appends text for array values. [also at: skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md:204 - nit: Readers of the v2 upgrade guide are told a content-less result outside the typed outputSchema case goes out as content: [], which is no longer true.; +2 more]

Why this was flagged

A JavaScript or cast handler on a tool with no outputSchema returns { structuredContent: { a: 1 } } with no content, through tools/call at packages/server/src/server/mcp.ts:310. withStructuredContentText (mcp.ts:1343-1347) checks only content !== undefined and structuredContent === undefined, so it adds [{type:'text', text:'{"a":1}'}]; the same happens for an isError: true result carrying structuredContent. behavioral-changes.md:202-203 says only successful outputSchema results get the text block and "any other content-less handler result is normalized to content: []". Separately, test/e2e/requirements.ts:426 says a handler that supplies content, "even an empty array, is sent unchanged"; with an array or primitive structuredContent and content: [], appendTextFallbackForNonObject (packages/core-internal/src/wire/textFallback.ts:22) still appends a text block. On the base the skill text matched the code. No safeguard applies: these are prose claims, and the new tests only cover object values with content: [].

Verification: nit — triggers when a handler that TypeScript would not allow to omit content (JavaScript or a cast) returns structuredContent without content from a schema-less tool, or with isError: true. packages/server/src/server/mcp.ts:1343-1348 — withStructuredContentText never looks at outputSchema or isError.

Finding ID: f-05cb9ce6

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
26 changes: 26 additions & 0 deletions test/conformance/src/everythingServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 withStructuredContentText carries a JSDoc block at packages/server/src/server/mcp.ts:1342. Fix: cut the inline comment to one line and turn the private helper's JSDoc into a one-line // comment, which covers the 2 sites listed. Same instruction at 2 sites (test/conformance/src/everythingServer.ts:750, packages/server/src/server/mcp.ts:1342). [also at: test/conformance/src/everythingServer.ts:750 - nit: CLAUDE.md principle 8 (inline comments one line, JSDoc only on the public API): the added comment above test_structured_content runs three lines and explains why the handlers omit content, and packages/server/src/server/mcp.ts adds a JSDoc block to the non-exported helper withStructuredContentText.]

Why this was flagged

No 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 content. The /** ... */ block at packages/server/src/server/mcp.ts:1342 documents withStructuredContentText, which is declared with plain function and is not exported. The instruction asks for one-line inline comments and JSDoc only on the public API, with the reasoning kept in the commit message or PR description. Neither line existed on the base branch. No lint rule enforces this, so nothing else catches it.

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 // inline comment. packages/server/src/server/mcp.ts:1342 is a /** ... */ JSDoc block on withStructuredContentText, declared at line 1343 as a plain function with no export.

Finding ID: f-22701457

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 —
Expand Down
5 changes: 5 additions & 0 deletions test/e2e/requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,11 @@ export const REQUIREMENTS: Record<string, Requirement> = {
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.'
Expand Down
21 changes: 21 additions & 0 deletions test/e2e/scenarios/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading