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/fix-tool-json-schema-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/server": patch
---

fix: detect plain JSON Schema objects in tool() overload resolution
1 change: 1 addition & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
AnyToolHandler,
BaseToolCallback,
CompleteResourceTemplateCallback,
DeprecatedVariadicToolCallback,
ListResourcesCallback,
PromptCallback,
ReadResourceCallback,
Expand Down
174 changes: 174 additions & 0 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
CreateTaskServerContext,
GetPromptResult,
Implementation,
JsonSchemaType,
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
Expand All @@ -30,6 +31,8 @@ import type {
import {
assertCompleteRequestPrompt,
assertCompleteRequestResourceTemplate,
isStandardSchema,
isZodRawShape,
normalizeRawShapeSchema,
promptArgumentsFromStandardSchema,
ProtocolError,
Expand All @@ -43,6 +46,7 @@ import type * as z from 'zod/v4';

import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js';
import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js';
import { fromJsonSchema } from '../fromJsonSchema.js';
import { getCompleter, isCompletable } from './completable.js';
import type { ServerOptions } from './server.js';
import { Server } from './server.js';
Expand Down Expand Up @@ -920,6 +924,105 @@ export class McpServer {
);
}

/**
* Registers a tool using the legacy variadic overloads (v1-style).
*
* **Note:** Use {@linkcode registerTool} for new code.
*
* @deprecated Prefer {@linkcode registerTool} with an explicit config object.
*/
tool(
name: string,
paramsSchemaOrAnnotations: StandardSchemaWithJSON | ToolAnnotations | Record<string, unknown>,
cb: DeprecatedVariadicToolCallback
): RegisteredTool;

/**
* @deprecated Prefer {@linkcode registerTool}.
*/
tool(
name: string,
description: string,
paramsSchemaOrAnnotations: StandardSchemaWithJSON | ToolAnnotations | Record<string, unknown>,
cb: DeprecatedVariadicToolCallback
): RegisteredTool;

/**
* Legacy `tool()` implementation. Parses arguments for the overloads declared above.
*/
tool(name: string, ...rest: unknown[]): RegisteredTool {
if (this._registeredTools[name]) {
throw new Error(`Tool ${name} is already registered`);
}

let description: string | undefined;
let inputSchema: StandardSchemaWithJSON | undefined;
let annotations: ToolAnnotations | undefined;

if (typeof rest[0] === 'string') {
description = rest.shift() as string;
}

if (rest.length > 1) {
const firstArg = rest[0];

if (typeof firstArg === 'object' && firstArg !== null && !Array.isArray(firstArg)) {
const record = firstArg as Record<string, unknown>;

if (isZodRawShape(record) || isStandardSchema(record)) {
inputSchema = normalizeRawShapeSchema(record as StandardSchemaWithJSON | ZodRawShape);
rest.shift();

if (
rest.length > 1 &&
typeof rest[0] === 'object' &&
rest[0] !== null &&
!Array.isArray(rest[0]) &&
isToolAnnotationsOnlyObject(rest[0] as Record<string, unknown>)
) {
annotations = rest.shift() as ToolAnnotations;
}
} else if (isLikelyPlainJsonSchemaObject(record)) {
inputSchema = fromJsonSchema(record as JsonSchemaType);
rest.shift();

if (
rest.length > 1 &&
typeof rest[0] === 'object' &&
rest[0] !== null &&
!Array.isArray(rest[0]) &&
isToolAnnotationsOnlyObject(rest[0] as Record<string, unknown>)
) {
annotations = rest.shift() as ToolAnnotations;
}
} else if (isToolAnnotationsOnlyObject(record)) {
annotations = rest.shift() as ToolAnnotations;
} else {
throw new TypeError(
`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(', ')}).`
);
}
}
}

const callback = rest[0];
if (typeof callback !== 'function') {
throw new TypeError(`Tool "${name}": last argument must be the handler callback`);
}

return this._createRegisteredTool(
name,
undefined,
description,
inputSchema,
undefined,
annotations,
{ taskSupport: 'forbidden' },
undefined,
callback as ToolCallback<StandardSchemaWithJSON | undefined>
);
}

/**
* Registers a prompt with a config object and callback.
*
Expand Down Expand Up @@ -1147,6 +1250,14 @@ export type ToolCallback<Args extends StandardSchemaWithJSON | undefined = undef
Args
>;

/**
* Callback type for deprecated {@linkcode McpServer.tool} positional overloads.
* Intentionally loose: plain JSON Schema (wrapped via {@linkcode fromJsonSchema}) does not participate in overload inference.
*/
export type DeprecatedVariadicToolCallback =
| ((args: unknown, ctx: ServerContext) => CallToolResult | Promise<CallToolResult>)
| ((ctx: ServerContext) => CallToolResult | Promise<CallToolResult>);

/**
* Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object).
*/
Expand Down Expand Up @@ -1185,6 +1296,69 @@ export type RegisteredTool = {
remove(): void;
};

const TOOL_ANNOTATION_KEYS = new Set(['title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']);

/** Structural hints that a plain object is JSON Schema (wire-shape) rather than {@linkcode ToolAnnotations}. */
const JSON_SCHEMA_SHAPE_KEYS = [
'type',
'properties',
'items',
'required',
'additionalProperties',
'$schema',
'$ref',
'$defs',
'definitions',
'oneOf',
'anyOf',
'allOf',
'not',
'enum',
'const',
'minimum',
'maximum',
'minLength',
'maxLength',
'pattern',
'format',
'minItems',
'maxItems',
'prefixItems',
'description'
] as const;

function isToolAnnotationsOnlyObject(obj: Record<string, unknown>): boolean {
const keys = Object.keys(obj);
if (keys.length === 0) {
return false;
}
for (const key of keys) {
if (!TOOL_ANNOTATION_KEYS.has(key)) {
return false;
}
}
for (const [k, v] of Object.entries(obj)) {
if (k === 'title') {
if (typeof v !== 'string') {
return false;
}
} else if (typeof v !== 'boolean') {
return false;
}
}
return true;
}

function isLikelyPlainJsonSchemaObject(obj: Record<string, unknown>): boolean {
if (Object.keys(obj).length === 0) {
return false;
}
if (isToolAnnotationsOnlyObject(obj)) {
return false;
}
return JSON_SCHEMA_SHAPE_KEYS.some(k => k in obj);
}

/**
* Creates an executor that invokes the handler with the appropriate arguments.
* When `inputSchema` is defined, the handler is called with `(args, ctx)`.
Expand Down
157 changes: 155 additions & 2 deletions packages/server/test/server/mcp.compat.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
import type { JSONRPCMessage, StandardSchemaWithJSON } from '@modelcontextprotocol/core';
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION, standardSchemaToJsonSchema } from '@modelcontextprotocol/core';
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
import * as z from 'zod/v4';
import { McpServer } from '../../src/index.js';
Expand Down Expand Up @@ -127,3 +127,156 @@ describe('InferRawShape', () => {
expectTypeOf<S>().toEqualTypeOf<{ a: string; b?: string | undefined }>();
});
});

describe('McpServer.tool() legacy overload resolution', () => {
it('treats a plain JSON Schema object as inputSchema (not ToolAnnotations)', () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

server.tool(
'my.tool',
'A tool that requires a directory_id',
{
type: 'object',
properties: {
directory_id: {
type: 'string',
format: 'uuid',
description: 'The UUID of the directory'
}
},
required: ['directory_id']
},
async (args: unknown) => ({
content: [{ type: 'text' as const, text: JSON.stringify(args) }]
})
);

const tools = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown }> })._registeredTools;
expect(isStandardSchema(tools['my.tool']?.inputSchema)).toBe(true);
const json = standardSchemaToJsonSchema(tools['my.tool']!.inputSchema as StandardSchemaWithJSON, 'input') as {
properties?: Record<string, unknown>;
required?: string[];
};
expect(json.properties).toHaveProperty('directory_id');
expect(json.required).toContain('directory_id');
});

it('still treats ToolAnnotations-only objects as annotations (empty wire input schema)', async () => {
const server = new McpServer({ name: 't', version: '1.0.0' });
server.tool('annotated', 'desc', { title: 'Display title', readOnlyHint: true }, async () => ({
content: [{ type: 'text' as const, text: 'ok' }]
}));

const registered = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown; annotations?: unknown }> })
._registeredTools;
expect(registered['annotated']?.annotations).toMatchObject({ title: 'Display title', readOnlyHint: true });
expect(registered['annotated']?.inputSchema).toBeUndefined();

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/list',
params: {}
} as JSONRPCMessage);

await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true));

const listed = responses.find(r => 'id' in r && r.id === 2) as {
result?: { tools: Array<{ annotations?: unknown; inputSchema?: unknown }> };
};
expect(listed.result?.tools).toHaveLength(1);
expect(listed.result?.tools[0]?.annotations).toMatchObject({ title: 'Display title', readOnlyHint: true });
expect(listed.result?.tools[0]?.inputSchema).toEqual({
type: 'object',
properties: {}
});

await server.close();
});

it('throws when the positional object matches neither schema nor ToolAnnotations', () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

expect(() =>
server.tool('bad', 'desc', { notASchemaOrAnnotation: true }, async () => ({
content: [{ type: 'text' as const, text: 'x' }]
}))
).toThrow(TypeError);

expect(() =>
server.tool('bad2', 'desc', { title: 'x', extraKey: true }, async () => ({
content: [{ type: 'text' as const, text: 'x' }]
}))
).toThrow(TypeError);
});

it('passes validated arguments for plain JSON Schema tools end-to-end', async () => {
const server = new McpServer({ name: 't', version: '1.0.0' });
let received: unknown;
server.tool(
'js',
'uses json schema',
{
type: 'object',
properties: { n: { type: 'number' } },
required: ['n']
},
async (args: unknown) => {
received = args;
const { n } = args as { n: number };
return { content: [{ type: 'text' as const, text: String(n) }] };
}
);

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: 'js', arguments: { n: 42 } }
} as JSONRPCMessage);

await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true));

expect(received).toEqual({ n: 42 });
const result = responses.find(r => 'id' in r && r.id === 2) as { result?: { content: Array<{ text?: string }> } };
expect(result.result?.content[0]?.text).toBe('42');

await server.close();
});
});
Loading