Skip to content

Commit d80e40e

Browse files
committed
fix: detect plain JSON Schema objects in tool() overload resolution
When a plain JSON Schema object (with type/properties) was passed to server.tool(), the overload resolver incorrectly treated it as ToolAnnotations, causing inputSchema to be silently dropped from the wire protocol. Fixes #1585
1 parent 4fbcfcd commit d80e40e

3 files changed

Lines changed: 330 additions & 2 deletions

File tree

packages/server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type {
1212
AnyToolHandler,
1313
BaseToolCallback,
1414
CompleteResourceTemplateCallback,
15+
DeprecatedVariadicToolCallback,
1516
ListResourcesCallback,
1617
PromptCallback,
1718
ReadResourceCallback,

packages/server/src/server/mcp.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
CreateTaskServerContext,
1010
GetPromptResult,
1111
Implementation,
12+
JsonSchemaType,
1213
ListPromptsResult,
1314
ListResourcesResult,
1415
ListToolsResult,
@@ -30,6 +31,8 @@ import type {
3031
import {
3132
assertCompleteRequestPrompt,
3233
assertCompleteRequestResourceTemplate,
34+
isStandardSchema,
35+
isZodRawShape,
3336
normalizeRawShapeSchema,
3437
promptArgumentsFromStandardSchema,
3538
ProtocolError,
@@ -43,6 +46,7 @@ import type * as z from 'zod/v4';
4346

4447
import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js';
4548
import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js';
49+
import { fromJsonSchema } from '../fromJsonSchema.js';
4650
import { getCompleter, isCompletable } from './completable.js';
4751
import type { ServerOptions } from './server.js';
4852
import { Server } from './server.js';
@@ -920,6 +924,105 @@ export class McpServer {
920924
);
921925
}
922926

927+
/**
928+
* Registers a tool using the legacy variadic overloads (v1-style).
929+
*
930+
* **Note:** Use {@linkcode registerTool} for new code.
931+
*
932+
* @deprecated Prefer {@linkcode registerTool} with an explicit config object.
933+
*/
934+
tool(
935+
name: string,
936+
paramsSchemaOrAnnotations: StandardSchemaWithJSON | ToolAnnotations | Record<string, unknown>,
937+
cb: DeprecatedVariadicToolCallback
938+
): RegisteredTool;
939+
940+
/**
941+
* @deprecated Prefer {@linkcode registerTool}.
942+
*/
943+
tool(
944+
name: string,
945+
description: string,
946+
paramsSchemaOrAnnotations: StandardSchemaWithJSON | ToolAnnotations | Record<string, unknown>,
947+
cb: DeprecatedVariadicToolCallback
948+
): RegisteredTool;
949+
950+
/**
951+
* Legacy `tool()` implementation. Parses arguments for the overloads declared above.
952+
*/
953+
tool(name: string, ...rest: unknown[]): RegisteredTool {
954+
if (this._registeredTools[name]) {
955+
throw new Error(`Tool ${name} is already registered`);
956+
}
957+
958+
let description: string | undefined;
959+
let inputSchema: StandardSchemaWithJSON | undefined;
960+
let annotations: ToolAnnotations | undefined;
961+
962+
if (typeof rest[0] === 'string') {
963+
description = rest.shift() as string;
964+
}
965+
966+
if (rest.length > 1) {
967+
const firstArg = rest[0];
968+
969+
if (typeof firstArg === 'object' && firstArg !== null && !Array.isArray(firstArg)) {
970+
const record = firstArg as Record<string, unknown>;
971+
972+
if (isZodRawShape(record) || isStandardSchema(record)) {
973+
inputSchema = normalizeRawShapeSchema(record as StandardSchemaWithJSON | ZodRawShape);
974+
rest.shift();
975+
976+
if (
977+
rest.length > 1 &&
978+
typeof rest[0] === 'object' &&
979+
rest[0] !== null &&
980+
!Array.isArray(rest[0]) &&
981+
isToolAnnotationsOnlyObject(rest[0] as Record<string, unknown>)
982+
) {
983+
annotations = rest.shift() as ToolAnnotations;
984+
}
985+
} else if (isLikelyPlainJsonSchemaObject(record)) {
986+
inputSchema = fromJsonSchema(record as JsonSchemaType);
987+
rest.shift();
988+
989+
if (
990+
rest.length > 1 &&
991+
typeof rest[0] === 'object' &&
992+
rest[0] !== null &&
993+
!Array.isArray(rest[0]) &&
994+
isToolAnnotationsOnlyObject(rest[0] as Record<string, unknown>)
995+
) {
996+
annotations = rest.shift() as ToolAnnotations;
997+
}
998+
} else if (isToolAnnotationsOnlyObject(record)) {
999+
annotations = rest.shift() as ToolAnnotations;
1000+
} else {
1001+
throw new TypeError(
1002+
`Tool "${name}": unrecognized third argument. Expected a Standard Schema, Zod raw shape ({ field: z.string() }), plain JSON Schema (e.g. { type: "object", properties: {...} }), or ToolAnnotations (${[...TOOL_ANNOTATION_KEYS].join(', ')}).`
1003+
);
1004+
}
1005+
}
1006+
}
1007+
1008+
const callback = rest[0];
1009+
if (typeof callback !== 'function') {
1010+
throw new TypeError(`Tool "${name}": last argument must be the handler callback`);
1011+
}
1012+
1013+
return this._createRegisteredTool(
1014+
name,
1015+
undefined,
1016+
description,
1017+
inputSchema,
1018+
undefined,
1019+
annotations,
1020+
{ taskSupport: 'forbidden' },
1021+
undefined,
1022+
callback as ToolCallback<StandardSchemaWithJSON | undefined>
1023+
);
1024+
}
1025+
9231026
/**
9241027
* Registers a prompt with a config object and callback.
9251028
*
@@ -1147,6 +1250,14 @@ export type ToolCallback<Args extends StandardSchemaWithJSON | undefined = undef
11471250
Args
11481251
>;
11491252

1253+
/**
1254+
* Callback type for deprecated {@linkcode McpServer.tool} positional overloads.
1255+
* Intentionally loose: plain JSON Schema (wrapped via {@linkcode fromJsonSchema}) does not participate in overload inference.
1256+
*/
1257+
export type DeprecatedVariadicToolCallback =
1258+
| ((args: unknown, ctx: ServerContext) => CallToolResult | Promise<CallToolResult>)
1259+
| ((ctx: ServerContext) => CallToolResult | Promise<CallToolResult>);
1260+
11501261
/**
11511262
* Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object).
11521263
*/
@@ -1185,6 +1296,69 @@ export type RegisteredTool = {
11851296
remove(): void;
11861297
};
11871298

1299+
const TOOL_ANNOTATION_KEYS = new Set(['title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']);
1300+
1301+
/** Structural hints that a plain object is JSON Schema (wire-shape) rather than {@linkcode ToolAnnotations}. */
1302+
const JSON_SCHEMA_SHAPE_KEYS = [
1303+
'type',
1304+
'properties',
1305+
'items',
1306+
'required',
1307+
'additionalProperties',
1308+
'$schema',
1309+
'$ref',
1310+
'$defs',
1311+
'definitions',
1312+
'oneOf',
1313+
'anyOf',
1314+
'allOf',
1315+
'not',
1316+
'enum',
1317+
'const',
1318+
'minimum',
1319+
'maximum',
1320+
'minLength',
1321+
'maxLength',
1322+
'pattern',
1323+
'format',
1324+
'minItems',
1325+
'maxItems',
1326+
'prefixItems',
1327+
'description'
1328+
] as const;
1329+
1330+
function isToolAnnotationsOnlyObject(obj: Record<string, unknown>): boolean {
1331+
const keys = Object.keys(obj);
1332+
if (keys.length === 0) {
1333+
return false;
1334+
}
1335+
for (const key of keys) {
1336+
if (!TOOL_ANNOTATION_KEYS.has(key)) {
1337+
return false;
1338+
}
1339+
}
1340+
for (const [k, v] of Object.entries(obj)) {
1341+
if (k === 'title') {
1342+
if (typeof v !== 'string') {
1343+
return false;
1344+
}
1345+
} else if (typeof v !== 'boolean') {
1346+
return false;
1347+
}
1348+
}
1349+
return true;
1350+
}
1351+
1352+
function isLikelyPlainJsonSchemaObject(obj: Record<string, unknown>): boolean {
1353+
if (Object.keys(obj).length === 0) {
1354+
return false;
1355+
}
1356+
if (isToolAnnotationsOnlyObject(obj)) {
1357+
return false;
1358+
}
1359+
return JSON_SCHEMA_SHAPE_KEYS.some(k => k in obj);
1360+
}
1361+
11881362
/**
11891363
* Creates an executor that invokes the handler with the appropriate arguments.
11901364
* When `inputSchema` is defined, the handler is called with `(args, ctx)`.

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

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
2-
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
1+
import type { JSONRPCMessage, StandardSchemaWithJSON } from '@modelcontextprotocol/core';
2+
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION, standardSchemaToJsonSchema } from '@modelcontextprotocol/core';
33
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
44
import * as z from 'zod/v4';
55
import { McpServer } from '../../src/index.js';
@@ -127,3 +127,156 @@ describe('InferRawShape', () => {
127127
expectTypeOf<S>().toEqualTypeOf<{ a: string; b?: string | undefined }>();
128128
});
129129
});
130+
131+
describe('McpServer.tool() legacy overload resolution', () => {
132+
it('treats a plain JSON Schema object as inputSchema (not ToolAnnotations)', () => {
133+
const server = new McpServer({ name: 't', version: '1.0.0' });
134+
135+
server.tool(
136+
'my.tool',
137+
'A tool that requires a directory_id',
138+
{
139+
type: 'object',
140+
properties: {
141+
directory_id: {
142+
type: 'string',
143+
format: 'uuid',
144+
description: 'The UUID of the directory'
145+
}
146+
},
147+
required: ['directory_id']
148+
},
149+
async (args: unknown) => ({
150+
content: [{ type: 'text' as const, text: JSON.stringify(args) }]
151+
})
152+
);
153+
154+
const tools = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown }> })._registeredTools;
155+
expect(isStandardSchema(tools['my.tool']?.inputSchema)).toBe(true);
156+
const json = standardSchemaToJsonSchema(tools['my.tool']!.inputSchema as StandardSchemaWithJSON, 'input') as {
157+
properties?: Record<string, unknown>;
158+
required?: string[];
159+
};
160+
expect(json.properties).toHaveProperty('directory_id');
161+
expect(json.required).toContain('directory_id');
162+
});
163+
164+
it('still treats ToolAnnotations-only objects as annotations (empty wire input schema)', async () => {
165+
const server = new McpServer({ name: 't', version: '1.0.0' });
166+
server.tool('annotated', 'desc', { title: 'Display title', readOnlyHint: true }, async () => ({
167+
content: [{ type: 'text' as const, text: 'ok' }]
168+
}));
169+
170+
const registered = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown; annotations?: unknown }> })
171+
._registeredTools;
172+
expect(registered['annotated']?.annotations).toMatchObject({ title: 'Display title', readOnlyHint: true });
173+
expect(registered['annotated']?.inputSchema).toBeUndefined();
174+
175+
const [client, srv] = InMemoryTransport.createLinkedPair();
176+
await server.connect(srv);
177+
await client.start();
178+
179+
const responses: JSONRPCMessage[] = [];
180+
client.onmessage = m => responses.push(m);
181+
182+
await client.send({
183+
jsonrpc: '2.0',
184+
id: 1,
185+
method: 'initialize',
186+
params: {
187+
protocolVersion: LATEST_PROTOCOL_VERSION,
188+
capabilities: {},
189+
clientInfo: { name: 'c', version: '1.0.0' }
190+
}
191+
} as JSONRPCMessage);
192+
await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage);
193+
await client.send({
194+
jsonrpc: '2.0',
195+
id: 2,
196+
method: 'tools/list',
197+
params: {}
198+
} as JSONRPCMessage);
199+
200+
await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true));
201+
202+
const listed = responses.find(r => 'id' in r && r.id === 2) as {
203+
result?: { tools: Array<{ annotations?: unknown; inputSchema?: unknown }> };
204+
};
205+
expect(listed.result?.tools).toHaveLength(1);
206+
expect(listed.result?.tools[0]?.annotations).toMatchObject({ title: 'Display title', readOnlyHint: true });
207+
expect(listed.result?.tools[0]?.inputSchema).toEqual({
208+
type: 'object',
209+
properties: {}
210+
});
211+
212+
await server.close();
213+
});
214+
215+
it('throws when the positional object matches neither schema nor ToolAnnotations', () => {
216+
const server = new McpServer({ name: 't', version: '1.0.0' });
217+
218+
expect(() =>
219+
server.tool('bad', 'desc', { notASchemaOrAnnotation: true }, async () => ({
220+
content: [{ type: 'text' as const, text: 'x' }]
221+
}))
222+
).toThrow(TypeError);
223+
224+
expect(() =>
225+
server.tool('bad2', 'desc', { title: 'x', extraKey: true }, async () => ({
226+
content: [{ type: 'text' as const, text: 'x' }]
227+
}))
228+
).toThrow(TypeError);
229+
});
230+
231+
it('passes validated arguments for plain JSON Schema tools end-to-end', async () => {
232+
const server = new McpServer({ name: 't', version: '1.0.0' });
233+
let received: unknown;
234+
server.tool(
235+
'js',
236+
'uses json schema',
237+
{
238+
type: 'object',
239+
properties: { n: { type: 'number' } },
240+
required: ['n']
241+
},
242+
async (args: unknown) => {
243+
received = args;
244+
const { n } = args as { n: number };
245+
return { content: [{ type: 'text' as const, text: String(n) }] };
246+
}
247+
);
248+
249+
const [client, srv] = InMemoryTransport.createLinkedPair();
250+
await server.connect(srv);
251+
await client.start();
252+
253+
const responses: JSONRPCMessage[] = [];
254+
client.onmessage = m => responses.push(m);
255+
256+
await client.send({
257+
jsonrpc: '2.0',
258+
id: 1,
259+
method: 'initialize',
260+
params: {
261+
protocolVersion: LATEST_PROTOCOL_VERSION,
262+
capabilities: {},
263+
clientInfo: { name: 'c', version: '1.0.0' }
264+
}
265+
} as JSONRPCMessage);
266+
await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage);
267+
await client.send({
268+
jsonrpc: '2.0',
269+
id: 2,
270+
method: 'tools/call',
271+
params: { name: 'js', arguments: { n: 42 } }
272+
} as JSONRPCMessage);
273+
274+
await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true));
275+
276+
expect(received).toEqual({ n: 42 });
277+
const result = responses.find(r => 'id' in r && r.id === 2) as { result?: { content: Array<{ text?: string }> } };
278+
expect(result.result?.content[0]?.text).toBe('42');
279+
280+
await server.close();
281+
});
282+
});

0 commit comments

Comments
 (0)