Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
190 changes: 190 additions & 0 deletions src/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,196 @@ describe("tool()", () => {
expect(result.tools[1].annotations).toEqual(result.tools[0].annotations);
});

/***
* Test: Internal Robustness - Tool Registration Parameter Parsing
* Tests that internal parsing logic correctly handles edge cases even when
* TypeScript types are bypassed (e.g., via loose tsconfig or type assertions)
*/
test("should correctly parse callback position when undefined is passed in annotations position", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});

// Testing internal robustness: even if someone bypasses TypeScript and passes undefined
// the callback should still be found at the correct position
// 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", 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");
expect(result.tools[0].description).toBe("A tool description");
expect(result.tools[0].inputSchema).toMatchObject({
type: "object",
properties: { name: { type: "string" } }
});
expect(result.tools[0].annotations).toBeUndefined();

// Verify that the callback was correctly identified and works
const callResult = await client.request(
{
method: "tools/call",
params: {
name: "test",
arguments: { name: "World" }
}
},
CallToolResultSchema,
);

expect(callResult.content).toHaveLength(1);
expect((callResult.content[0] as TextContent).text).toBe("Hello, World!");
});

/***
* Test: Internal Robustness - Description Undefined
*/
test("should correctly parse when description is undefined", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});

// Testing: tool(name, undefined, schema, callback)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mcpServer.tool as any)(
"test",
undefined, // description is undefined
{ name: z.string() },
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");
expect(result.tools[0].description).toBeUndefined();
expect(result.tools[0].inputSchema).toMatchObject({
type: "object",
properties: { name: { type: "string" } }
});

// Verify callback works
const callResult = await client.request(
{
method: "tools/call",
params: {
name: "test",
arguments: { name: "World" }
}
},
CallToolResultSchema,
);

expect(callResult.content).toHaveLength(1);
expect((callResult.content[0] as TextContent).text).toBe("Hello, World!");
});

/***
* Test: Internal Robustness - ParamsSchema Undefined
*/
test("should correctly parse when paramsSchema is undefined", async () => {
const mcpServer = new McpServer({
name: "test server",
version: "1.0",
});
const client = new Client({
name: "test client",
version: "1.0",
});

// Testing: tool(name, description, undefined, annotations, callback)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mcpServer.tool as any)(
"test",
"A tool description",
undefined, // paramsSchema is undefined
{ title: "Test Tool" },
async () => ({
content: [{ type: "text", text: "No params" }]
})
);

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");
expect(result.tools[0].description).toBe("A tool description");
expect(result.tools[0].annotations?.title).toBe("Test Tool");
expect(result.tools[0].inputSchema).toMatchObject({
type: "object",
properties: {}
});

// Verify callback works
const callResult = await client.request(
{
method: "tools/call",
params: {
name: "test",
arguments: {}
}
},
CallToolResultSchema,
);

expect(callResult.content).toHaveLength(1);
expect((callResult.content[0] as TextContent).text).toBe("No params");
});

/***
* Test: Tool Argument Validation
*/
Expand Down
68 changes: 45 additions & 23 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,33 +891,55 @@ 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