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
6 changes: 6 additions & 0 deletions .changeset/contentless-tool-result.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions packages/core-internal/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ type Flatten<T> = T extends Primitive

type Infer<Schema extends z.ZodTypeAny> = Flatten<z.infer<Schema>>;

/**
* 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<Schema extends z.ZodTypeAny> = Flatten<z.input<Schema>>;

/**
* Wire-only members hidden from the public types.
*
Expand Down Expand Up @@ -421,6 +428,28 @@ export type ListToolsRequest = Infer<typeof ListToolsRequestSchema>;
export type ListToolsResult = StripWireOnly<Infer<typeof ListToolsResultSchema>>;
export type CallToolRequestParams = Infer<typeof CallToolRequestParamsSchema>;
export type CallToolResult = StripWireOnly<Infer<typeof CallToolResultSchema>>;
/**
* 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<InferInput<typeof CallToolResultSchema>>;
export type CompatibilityCallToolResult = StripWireOnly<Infer<typeof CompatibilityCallToolResultSchema>>;
export type CallToolRequest = Infer<typeof CallToolRequestSchema>;
export type ToolListChangedNotification = Infer<typeof ToolListChangedNotificationSchema>;
Expand Down
2 changes: 2 additions & 0 deletions packages/core-internal/test/types/wireOnlyHiding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type * as z from 'zod/v4';

import type {
CallToolResult,
CallToolResultInput,
CancelTaskResult,
CompleteResult,
CreateMessageResult,
Expand Down Expand Up @@ -59,6 +60,7 @@ describe('wire-only members are hidden from the public result types', () => {
expectTypeOf<DeclaresResultType<EmptyResult>>().toEqualTypeOf<false>();
expectTypeOf<DeclaresResultType<InitializeResult>>().toEqualTypeOf<false>();
expectTypeOf<DeclaresResultType<CallToolResult>>().toEqualTypeOf<false>();
expectTypeOf<DeclaresResultType<CallToolResultInput>>().toEqualTypeOf<false>();
expectTypeOf<DeclaresResultType<ListToolsResult>>().toEqualTypeOf<false>();
expectTypeOf<DeclaresResultType<ReadResourceResult>>().toEqualTypeOf<false>();
expectTypeOf<DeclaresResultType<CompleteResult>>().toEqualTypeOf<false>();
Expand Down
17 changes: 14 additions & 3 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
BaseMetadata,
CacheHint,
CallToolResult,
CallToolResultInput,
CompleteRequestPrompt,
CompleteRequestResourceTemplate,
CompleteResult,
Expand Down Expand Up @@ -1223,8 +1224,8 @@ export type LegacyToolCallback<Args extends ZodRawShape | undefined> = Args exte
? (
args: InferRawShape<Args>,
ctx: ServerContext
) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult>
: (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult>;
) => CallToolResultInput | InputRequiredResult | Promise<CallToolResultInput | InputRequiredResult>
: (ctx: ServerContext) => CallToolResultInput | InputRequiredResult | Promise<CallToolResultInput | InputRequiredResult>;

/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */
export type LegacyPromptCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
Expand All @@ -1246,7 +1247,7 @@ export type BaseToolCallback<
* Callback for a tool handler registered with {@linkcode McpServer.registerTool}.
*/
export type ToolCallback<Args extends StandardSchemaWithJSON | undefined = undefined> = BaseToolCallback<
CallToolResult | InputRequiredResult,
CallToolResultInput | InputRequiredResult,
ServerContext,
Args
>;
Expand All @@ -1259,6 +1260,16 @@ export type AnyToolHandler<Args extends StandardSchemaWithJSON | undefined = und
/**
* Internal executor type that encapsulates handler invocation with proper types.
*/
/**
* Invocation seam between a registered handler and the `tools/call` route.
*
* The declared result is the PARSED shape, while a handler may legally omit
* `content` (#2755) — `Server._wrapHandler` normalizes that to `content: []`
* before era validation, so the two agree by the time anything validates.
* In between they do not, which is why `appendTextFallbackForNonObject`
* reads `result.content ?? []`: that guard is load-bearing, not defensive
* habit, and must survive a reader who trusts this signature.
*/
type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise<CallToolResult | InputRequiredResult>;

export type RegisteredTool = {
Expand Down
138 changes: 136 additions & 2 deletions packages/server/test/server/mcp.compat.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -147,3 +163,121 @@ describe('SEP-2106: registerTool with non-object outputSchema (type-level)', ()
expectTypeOf<number[]>().toMatchTypeOf<z.infer<ReturnType<typeof z.array<z.ZodNumber>>>>();
});
});

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<string, unknown> })._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<string, unknown> })._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<string, unknown> })._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();
});
});
Loading