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
66 changes: 61 additions & 5 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@
title: tool.title,
description: tool.description,
inputSchema: (() => {
if (isLikelyJsonSchema(tool.inputSchema)) {
return tool.inputSchema as Tool['inputSchema'];

Check failure on line 150 in src/server/mcp.ts

View workflow job for this annotation

GitHub Actions / pkg-publish

Conversion of type 'AnySchema | undefined' to type '{ [x: string]: unknown; type: "object"; properties?: { [x: string]: object; } | undefined; required?: string[] | undefined; }' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

Check failure on line 150 in src/server/mcp.ts

View workflow job for this annotation

GitHub Actions / server-conformance

Conversion of type 'AnySchema | undefined' to type '{ [x: string]: unknown; type: "object"; properties?: { [x: string]: object; } | undefined; required?: string[] | undefined; }' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

Check failure on line 150 in src/server/mcp.ts

View workflow job for this annotation

GitHub Actions / client-conformance

Conversion of type 'AnySchema | undefined' to type '{ [x: string]: unknown; type: "object"; properties?: { [x: string]: object; } | undefined; required?: string[] | undefined; }' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

Check failure on line 150 in src/server/mcp.ts

View workflow job for this annotation

GitHub Actions / build

Conversion of type 'AnySchema | undefined' to type '{ [x: string]: unknown; type: "object"; properties?: { [x: string]: object; } | undefined; required?: string[] | undefined; }' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
}

const obj = normalizeObjectSchema(tool.inputSchema);
return obj
? (toJsonSchemaCompat(obj, {
Expand Down Expand Up @@ -996,7 +1000,7 @@
}

let description: string | undefined;
let inputSchema: ZodRawShapeCompat | undefined;
let inputSchema: ZodRawShapeCompat | AnySchema | undefined;
let outputSchema: ZodRawShapeCompat | undefined;
let annotations: ToolAnnotations | undefined;

Expand All @@ -1013,20 +1017,25 @@
// We have at least one more arg before the callback
const firstArg = rest[0];

if (isZodRawShapeCompat(firstArg)) {
// We have a params schema as the first arg
if (isZodRawShapeCompat(firstArg) || isLikelyJsonSchema(firstArg)) {
// We have an input schema (Zod raw shape shorthand or plain JSON Schema) as the first arg
inputSchema = rest.shift() as ZodRawShapeCompat;

// Check if the next arg is potentially annotations
if (rest.length > 1 && typeof rest[0] === 'object' && rest[0] !== null && !isZodRawShapeCompat(rest[0])) {
if (rest.length > 1 && isToolAnnotations(rest[0])) {
// Case: tool(name, paramsSchema, annotations, cb)
// Or: tool(name, description, paramsSchema, annotations, cb)
annotations = rest.shift() as ToolAnnotations;
}
} else if (typeof firstArg === 'object' && firstArg !== null) {
// Not a ZodRawShapeCompat, so must be annotations in this position
// Non-schema object in this position must be ToolAnnotations.
// Case: tool(name, annotations, cb)
// Or: tool(name, description, annotations, cb)
if (!isToolAnnotations(firstArg)) {
throw new Error(
`Invalid third argument for tool '${name}': expected input schema (Zod raw shape or JSON Schema) or ToolAnnotations`
);
}
annotations = rest.shift() as ToolAnnotations;
}
}
Expand Down Expand Up @@ -1384,6 +1393,53 @@
return Object.values(obj).some(isZodTypeLike);
}

function isToolAnnotations(obj: unknown): obj is ToolAnnotations {
if (typeof obj !== 'object' || obj === null) {
return false;
}

const allowedKeys = ['title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'];
const keys = Object.keys(obj);

// Empty object is valid for backwards compatibility in old tool() API.
if (keys.length === 0) {
return true;
}

// ToolAnnotations only supports these optional hint keys.
if (!keys.every(key => allowedKeys.includes(key))) {
return false;
}

const record = obj as Record<string, unknown>;

if (record.title !== undefined && typeof record.title !== 'string') {
return false;
}

for (const key of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const) {
if (record[key] !== undefined && typeof record[key] !== 'boolean') {
return false;
}
}

return true;
}

function isLikelyJsonSchema(obj: unknown): boolean {
if (typeof obj !== 'object' || obj === null) {
return false;
}

// Exclude Zod schemas (including transformed/object schemas) first.
if (isZodSchemaInstance(obj)) {
return false;
}

const schema = obj as Record<string, unknown>;
return schema.type === 'object' || schema.properties !== undefined;
}

/**
* Converts a provided Zod schema to a Zod object if it is a ZodRawShapeCompat,
* otherwise returns the schema as is.
Expand Down
65 changes: 65 additions & 0 deletions test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,71 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
});

/***
* Test: Tool Registration with JSON Schema
*/
test('should treat plain JSON Schema as params schema instead of annotations', async () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});
const client = new Client({
name: 'test client',
version: '1.0'
});

(mcpServer.tool as any)(
'test-json-schema',
'JSON schema tool',
{
type: 'object',
properties: {
name: { type: 'string' }
},
required: ['name']
},
async ({ name }: { name: string }) => ({
content: [{ type: 'text', text: `Hello, ${name}!` }]
})
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);

const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema);

expect(result.tools).toHaveLength(1);
expect(result.tools[0].name).toBe('test-json-schema');
expect(result.tools[0].description).toBe('JSON schema tool');
expect(result.tools[0].inputSchema).toMatchObject({
type: 'object',
properties: {
name: { type: 'string' }
},
required: ['name']
});
expect(result.tools[0].annotations).toBeUndefined();
});

/***
* Test: Tool Registration with Invalid Object Argument
*/
test('should throw when non-schema object is not valid ToolAnnotations', () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});

expect(() =>
(mcpServer.tool as any)(
'invalid-arg',
'Invalid object arg',
{ foo: 'bar' },
async () => ({ content: [{ type: 'text', text: 'ok' }] })
)
).toThrow("Invalid third argument for tool 'invalid-arg': expected input schema (Zod raw shape or JSON Schema) or ToolAnnotations");
});

/***
* Test: Tool Argument Validation
*/
Expand Down
Loading