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
148 changes: 148 additions & 0 deletions src/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,154 @@ describe('tool()', () => {
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
});

/***
* Test: Tool with undefined in annotations position
*/
test('should correctly parse callback position when undefined is passed in annotations position', async () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mcpServer.tool as any)(
'test',
'A tool description',
{ name: z.string() },
undefined, // Not officially supported, but internal logic should handle it
async ({ name }: { name: string }) => ({
content: [{ type: 'text' as const, text: `Hello, ${name}!` }]
})
);

expect(mcpServer['_registeredTools']['test']).toBeDefined();
expect(mcpServer['_registeredTools']['test'].description).toBe('A tool description');
expect(mcpServer['_registeredTools']['test'].inputSchema).toBeDefined();

const client = new Client({
name: 'test client',
version: '1.0'
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

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

const result = await client.listTools();
expect(result.tools).toHaveLength(1);
expect(result.tools[0].name).toBe('test');
expect(result.tools[0].description).toBe('A tool description');

const callResult = await client.callTool({ name: 'test', arguments: { name: 'World' } });
expect(callResult.content).toHaveLength(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((callResult.content as any)[0]).toMatchObject({
type: 'text',
text: 'Hello, World!'
});
});

/***
* Test: Tool with undefined in description position
*/
test('should correctly parse callback position when undefined is passed in description position', async () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mcpServer.tool as any)(
'test',
undefined, // Not officially supported, but internal logic should handle it
{ name: z.string() },
async ({ name }: { name: string }) => ({
content: [{ type: 'text' as const, text: `Hello, ${name}!` }]
})
);

expect(mcpServer['_registeredTools']['test']).toBeDefined();
expect(mcpServer['_registeredTools']['test'].description).toBeUndefined();
expect(mcpServer['_registeredTools']['test'].inputSchema).toBeDefined();

const client = new Client({
name: 'test client',
version: '1.0'
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

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

const result = await client.listTools();
expect(result.tools).toHaveLength(1);
expect(result.tools[0].name).toBe('test');
expect(result.tools[0].description).toBeUndefined();

const callResult = await client.callTool({ name: 'test', arguments: { name: 'World' } });
expect(callResult.content).toHaveLength(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((callResult.content as any)[0]).toMatchObject({
type: 'text',
text: 'Hello, World!'
});
});

/***
* Test: Tool with undefined in paramsSchema position
*/
test('should correctly parse callback position when undefined is passed in paramsSchema position', async () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mcpServer.tool as any)(
'test',
'A tool description',
undefined, // Not officially supported, but internal logic should handle it
{ title: 'Test Tool', readOnlyHint: true },
async () => ({
content: [{ type: 'text' as const, text: 'Hello!' }]
})
);

expect(mcpServer['_registeredTools']['test']).toBeDefined();
expect(mcpServer['_registeredTools']['test'].description).toBe('A tool description');
expect(mcpServer['_registeredTools']['test'].inputSchema).toBeUndefined();
expect(mcpServer['_registeredTools']['test'].annotations).toEqual({
title: 'Test Tool',
readOnlyHint: true
});

const client = new Client({
name: 'test client',
version: '1.0'
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

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

const result = await client.listTools();
expect(result.tools).toHaveLength(1);
expect(result.tools[0].name).toBe('test');
expect(result.tools[0].description).toBe('A tool description');
expect(result.tools[0].annotations).toEqual({
title: 'Test Tool',
readOnlyHint: true
});

const callResult = await client.callTool({ name: 'test' });
expect(callResult.content).toHaveLength(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((callResult.content as any)[0]).toMatchObject({
type: 'text',
text: 'Hello!'
});
});

/***
* Test: Tool Argument Validation
*/
Expand Down
66 changes: 43 additions & 23 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,33 +752,53 @@ export class McpServer {
// Support for this style is frozen as of protocol version 2025-03-26. Future additions
// to tool definition should *NOT* be added.

if (typeof rest[0] === 'string') {
description = rest.shift() as string;
// Find the callback first (always the last function in rest)
let callbackIndex = rest.length - 1;
while (callbackIndex >= 0 && typeof rest[callbackIndex] !== 'function') {
callbackIndex--;
}

// Handle the different overload combinations
if (rest.length > 1) {
// We have at least one more arg before the callback
const firstArg = rest[0];

if (isZodRawShape(firstArg)) {
// We have a params schema as the first arg
inputSchema = rest.shift() as ZodRawShape;

// Check if the next arg is potentially annotations
if (rest.length > 1 && typeof rest[0] === 'object' && rest[0] !== null && !isZodRawShape(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 ZodRawShape, so must be annotations in this position
// Case: tool(name, annotations, cb)
// Or: tool(name, description, annotations, cb)
annotations = rest.shift() as ToolAnnotations;
if (callbackIndex < 0) {
throw new Error(`Tool ${name} registration requires a callback function`);
}

const callback = rest[callbackIndex] as ToolCallback<ZodRawShape | undefined>;
// Get all args before the callback
const args = rest.slice(0, callbackIndex);

// Parse args: [description?], [paramsSchema?], [annotations?]
// Skip undefined values and parse based on type
let argIndex = 0;

// Check for optional description (first string arg, skip if undefined)
if (argIndex < args.length) {
if (typeof args[argIndex] === 'string') {
description = args[argIndex] as string;
argIndex++;
} else if (args[argIndex] === undefined) {
argIndex++; // Skip undefined
}
}

// Check for optional paramsSchema (ZodRawShape, skip if undefined)
if (argIndex < args.length) {
if (isZodRawShape(args[argIndex])) {
inputSchema = args[argIndex] as ZodRawShape;
argIndex++;
} else if (args[argIndex] === undefined) {
argIndex++; // Skip undefined
}
}

// Check for optional annotations (non-null object that's not a ZodRawShape, skip if undefined)
if (argIndex < args.length) {
if (typeof args[argIndex] === 'object' && args[argIndex] !== null && !isZodRawShape(args[argIndex])) {
annotations = args[argIndex] as ToolAnnotations;
argIndex++;
} else if (args[argIndex] === undefined) {
argIndex++; // Skip undefined
}
}
const callback = rest[0] as ToolCallback<ZodRawShape | undefined>;

return this._createRegisteredTool(name, undefined, description, inputSchema, outputSchema, annotations, undefined, callback);
}
Expand Down