Skip to content

Commit dbefcb4

Browse files
committed
fix: type registerTool structured output
1 parent e6cc5dc commit dbefcb4

5 files changed

Lines changed: 100 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
---
4+
5+
Type `McpServer.registerTool` callbacks against `outputSchema` so returned `structuredContent` is checked when present.

packages/server/src/server/mcp.ts

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -942,7 +942,10 @@ export class McpServer {
942942
* );
943943
* ```
944944
*/
945-
registerTool<OutputArgs extends StandardSchemaWithJSON, InputArgs extends StandardSchemaWithJSON | undefined = undefined>(
945+
registerTool<
946+
InputArgs extends StandardSchemaWithJSON | ZodRawShape | undefined = undefined,
947+
OutputArgs extends StandardSchemaWithJSON | ZodRawShape | undefined = undefined
948+
>(
946949
name: string,
947950
config: {
948951
title?: string;
@@ -953,21 +956,7 @@ export class McpServer {
953956
icons?: Icon[];
954957
_meta?: Record<string, unknown>;
955958
},
956-
cb: ToolCallback<InputArgs>
957-
): RegisteredTool;
958-
/** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */
959-
registerTool<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined = undefined>(
960-
name: string,
961-
config: {
962-
title?: string;
963-
description?: string;
964-
inputSchema?: InputArgs;
965-
outputSchema?: OutputArgs;
966-
annotations?: ToolAnnotations;
967-
icons?: Icon[];
968-
_meta?: Record<string, unknown>;
969-
},
970-
cb: LegacyToolCallback<InputArgs>
959+
cb: ToolCallback<InputArgs, OutputArgs>
971960
): RegisteredTool;
972961
registerTool(
973962
name: string,
@@ -980,7 +969,9 @@ export class McpServer {
980969
icons?: Icon[];
981970
_meta?: Record<string, unknown>;
982971
},
983-
cb: ToolCallback<StandardSchemaWithJSON | undefined> | LegacyToolCallback<ZodRawShape>
972+
cb:
973+
| ToolCallback<StandardSchemaWithJSON | undefined, StandardSchemaWithJSON | undefined>
974+
| LegacyToolCallback<ZodRawShape, StandardSchemaWithJSON | ZodRawShape | undefined>
984975
): RegisteredTool {
985976
if (this._registeredTools[name]) {
986977
throw new Error(`Tool ${name} is already registered`);
@@ -1211,12 +1202,10 @@ export type ZodRawShape = Record<string, z.ZodType>;
12111202
export type InferRawShape<S extends ZodRawShape> = z.infer<z.ZodObject<S>>;
12121203

12131204
/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */
1214-
export type LegacyToolCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
1215-
? (
1216-
args: InferRawShape<Args>,
1217-
ctx: ServerContext
1218-
) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult>
1219-
: (ctx: ServerContext) => CallToolResult | InputRequiredResult | Promise<CallToolResult | InputRequiredResult>;
1205+
export type LegacyToolCallback<
1206+
Args extends ZodRawShape | undefined,
1207+
OutputArgs extends StandardSchemaWithJSON | ZodRawShape | undefined = undefined
1208+
> = ToolCallback<Args, OutputArgs>;
12201209

12211210
/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */
12221211
export type LegacyPromptCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
@@ -1229,16 +1218,30 @@ export type LegacyPromptCallback<Args extends ZodRawShape | undefined> = Args ex
12291218
export type BaseToolCallback<
12301219
SendResultT extends Result,
12311220
Ctx extends ServerContext,
1232-
Args extends StandardSchemaWithJSON | undefined
1221+
Args extends StandardSchemaWithJSON | ZodRawShape | undefined
12331222
> = Args extends StandardSchemaWithJSON
12341223
? (args: StandardSchemaWithJSON.InferOutput<Args>, ctx: Ctx) => SendResultT | Promise<SendResultT>
1235-
: (ctx: Ctx) => SendResultT | Promise<SendResultT>;
1224+
: Args extends ZodRawShape
1225+
? (args: InferRawShape<Args>, ctx: Ctx) => SendResultT | Promise<SendResultT>
1226+
: (ctx: Ctx) => SendResultT | Promise<SendResultT>;
12361227

12371228
/**
12381229
* Callback for a tool handler registered with {@linkcode McpServer.registerTool}.
12391230
*/
1240-
export type ToolCallback<Args extends StandardSchemaWithJSON | undefined = undefined> = BaseToolCallback<
1241-
CallToolResult | InputRequiredResult,
1231+
export type ToolCallback<
1232+
Args extends StandardSchemaWithJSON | ZodRawShape | undefined = undefined,
1233+
OutputArgs extends StandardSchemaWithJSON | ZodRawShape | undefined = undefined
1234+
> = BaseToolCallback<
1235+
| (OutputArgs extends StandardSchemaWithJSON
1236+
? Omit<CallToolResult, 'structuredContent'> & {
1237+
structuredContent?: StandardSchemaWithJSON.InferOutput<OutputArgs>;
1238+
}
1239+
: OutputArgs extends ZodRawShape
1240+
? Omit<CallToolResult, 'structuredContent'> & {
1241+
structuredContent?: InferRawShape<OutputArgs>;
1242+
}
1243+
: CallToolResult)
1244+
| InputRequiredResult,
12421245
ServerContext,
12431246
Args
12441247
>;

packages/server/test/server/mcp.compat.test.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,57 @@ describe('SEP-2106: registerTool with non-object outputSchema (type-level)', ()
135135
content: [],
136136
structuredContent: [n, n + 1] satisfies number[]
137137
}));
138-
// NOTE (SEP-2106 PR-B verification item): the OutputArgs generic on registerTool is
139-
// captured but does NOT currently flow into the callback's return type — ToolCallback's
140-
// SendResultT is `CallToolResult | InputRequiredResult` (structuredContent: unknown), so
141-
// a wrong-typed structuredContent ALSO compiles. Runtime validation (validateToolOutput)
142-
// is the guard. Tightening the generic is out of this commit's scope.
143-
server.registerTool('arr-loose', { outputSchema: z.array(z.number()) }, async () => ({
138+
server.registerTool('arr-loose', { outputSchema: z.array(z.number()) }, () => ({
144139
content: [],
145-
structuredContent: 'not-an-array' // compiles: structuredContent is `unknown`
140+
// @ts-expect-error structuredContent must match outputSchema when present
141+
structuredContent: 'not-an-array'
146142
}));
147143
expectTypeOf<number[]>().toMatchTypeOf<z.infer<ReturnType<typeof z.array<z.ZodNumber>>>>();
148144
});
149145
});
146+
147+
describe('registerTool outputSchema typing', () => {
148+
it('checks object structuredContent', () => {
149+
const server = new McpServer({ name: 's', version: '1' });
150+
const outputSchema = z.object({ id: z.string() });
151+
152+
server.registerTool('object-ok', { inputSchema: z.object({}), outputSchema }, async () => ({
153+
content: [{ type: 'text' as const, text: 'ok' }],
154+
structuredContent: { id: 'abc' }
155+
}));
156+
157+
server.registerTool('object-bad', { inputSchema: z.object({}), outputSchema }, () => ({
158+
content: [{ type: 'text' as const, text: 'bad' }],
159+
structuredContent: {
160+
// @ts-expect-error structuredContent must match outputSchema when present
161+
id: 123
162+
}
163+
}));
164+
});
165+
166+
it('checks raw-shape structuredContent', () => {
167+
const server = new McpServer({ name: 's', version: '1' });
168+
169+
server.registerTool(
170+
'raw-ok',
171+
{ inputSchema: { count: z.number() }, outputSchema: { id: z.string(), total: z.number().optional() } },
172+
async ({ count }) => ({
173+
content: [],
174+
structuredContent: { id: String(count), total: count }
175+
})
176+
);
177+
178+
server.registerTool(
179+
'raw-bad',
180+
{ inputSchema: { count: z.number() }, outputSchema: { id: z.string(), total: z.number().optional() } },
181+
() => ({
182+
content: [],
183+
structuredContent: {
184+
id: 'abc',
185+
// @ts-expect-error structuredContent must match raw-shape outputSchema when present
186+
total: 'nope'
187+
}
188+
})
189+
);
190+
});
191+
});

test/e2e/scenarios/standard-schema.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,15 @@ verifies('standardschema:tool:output-schema-validation', async ({ transport }: T
139139
'get-server-status-corrupt',
140140
{ inputSchema: type({}), outputSchema },
141141
// intentionally nonconforming structuredContent (server-side output validation must reject it)
142-
() => ({ structuredContent: { healthy: 'definitely', uptimeSeconds: 'a while' }, content: [] })
142+
() => ({
143+
structuredContent: {
144+
// @ts-expect-error intentionally nonconforming structuredContent
145+
healthy: 'definitely',
146+
// @ts-expect-error intentionally nonconforming structuredContent
147+
uptimeSeconds: 'a while'
148+
},
149+
content: []
150+
})
143151
);
144152
return s;
145153
};

test/e2e/scenarios/tools.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,13 @@ function schemaServer(): McpServer {
9292
'structured-mismatch',
9393
{ inputSchema: z.object({}), outputSchema: z.object({ value: z.number() }) },
9494
// intentionally invalid structuredContent (tests server-side validation rejects it)
95-
() => ({ structuredContent: { value: 'not-a-number' }, content: [] })
95+
() => ({
96+
structuredContent: {
97+
// @ts-expect-error intentionally nonconforming structuredContent
98+
value: 'not-a-number'
99+
},
100+
content: []
101+
})
96102
);
97103
s.registerTool('structured-missing', { inputSchema: z.object({}), outputSchema: z.object({ value: z.number() }) }, () => ({
98104
content: [{ type: 'text', text: 'handler-body-no-structured' }]

0 commit comments

Comments
 (0)