Skip to content

Commit 3c0c002

Browse files
committed
feat: Add registerTools() bulk method for efficient tool registration
- Adds registerTools() method that accepts an array of tools - Registers all tools and sends only one list_changed notification - Prevents memory leak warnings when registering many tools - Uses same pattern as individual registerTool() method - Includes comprehensive test coverage This solves the GitHub issue where registering 80+ tools caused EventEmitter memory leak warnings due to multiple rapid notifications.
1 parent b37a1a9 commit 3c0c002

File tree

2 files changed

+158
-0
lines changed

2 files changed

+158
-0
lines changed

src/server/mcp.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4406,4 +4406,113 @@ describe("elicitInput()", () => {
44064406
expect(toolsResult.tools).toHaveLength(2);
44074407
expect(toolsResult.tools.map(t => t.name).sort()).toEqual(["test2", "tool1"]);
44084408
});
4409+
4410+
/***
4411+
* Test: registerTools() bulk method
4412+
*/
4413+
test("should register multiple tools with single notification using registerTools()", async () => {
4414+
const mcpServer = new McpServer({
4415+
name: "test server",
4416+
version: "1.0",
4417+
});
4418+
4419+
// Register first tool to establish capabilities
4420+
mcpServer.tool("initial", async () => ({
4421+
content: [{ type: "text", text: "Initial" }]
4422+
}));
4423+
4424+
const notifications: Notification[] = []
4425+
const client = new Client({
4426+
name: "test client",
4427+
version: "1.0",
4428+
});
4429+
client.fallbackNotificationHandler = async (notification) => {
4430+
notifications.push(notification)
4431+
}
4432+
4433+
const [clientTransport, serverTransport] =
4434+
InMemoryTransport.createLinkedPair();
4435+
4436+
await Promise.all([
4437+
client.connect(clientTransport),
4438+
mcpServer.connect(serverTransport),
4439+
]);
4440+
4441+
// Clear initial notifications
4442+
notifications.length = 0;
4443+
4444+
// Register multiple tools at once using the bulk method
4445+
const tools = mcpServer.registerTools([
4446+
{
4447+
name: "bulk1",
4448+
config: {
4449+
title: "Bulk Tool 1",
4450+
description: "First bulk tool"
4451+
},
4452+
callback: async () => ({
4453+
content: [{ type: "text" as const, text: "Bulk 1" }]
4454+
})
4455+
},
4456+
{
4457+
name: "bulk2",
4458+
config: {
4459+
title: "Bulk Tool 2",
4460+
description: "Second bulk tool"
4461+
},
4462+
callback: async () => ({
4463+
content: [{ type: "text" as const, text: "Bulk 2" }]
4464+
})
4465+
},
4466+
{
4467+
name: "bulk3",
4468+
config: {
4469+
description: "Third bulk tool"
4470+
},
4471+
callback: async () => ({
4472+
content: [{ type: "text" as const, text: "Bulk 3" }]
4473+
})
4474+
}
4475+
]);
4476+
4477+
// Yield event loop to let notifications process
4478+
await new Promise(process.nextTick);
4479+
4480+
// Should have sent exactly ONE notification for all three tools
4481+
expect(notifications).toHaveLength(1);
4482+
expect(notifications[0]).toMatchObject({
4483+
method: "notifications/tools/list_changed",
4484+
});
4485+
4486+
// Should return array of registered tools
4487+
expect(tools).toHaveLength(3);
4488+
expect(tools[0].title).toBe("Bulk Tool 1");
4489+
expect(tools[1].title).toBe("Bulk Tool 2");
4490+
expect(tools[2].title).toBeUndefined(); // No title provided
4491+
4492+
// Verify all tools are registered and functional
4493+
const toolsResult = await client.request(
4494+
{ method: "tools/list" },
4495+
ListToolsResultSchema,
4496+
);
4497+
expect(toolsResult.tools).toHaveLength(4); // initial + 3 bulk tools
4498+
4499+
const toolNames = toolsResult.tools.map(t => t.name).sort();
4500+
expect(toolNames).toEqual(["bulk1", "bulk2", "bulk3", "initial"]);
4501+
4502+
// Test that the tools actually work
4503+
const callResult = await client.request(
4504+
{
4505+
method: "tools/call",
4506+
params: {
4507+
name: "bulk1",
4508+
arguments: {}
4509+
}
4510+
},
4511+
CallToolResultSchema,
4512+
);
4513+
expect(callResult.content[0]).toMatchObject({
4514+
type: "text",
4515+
text: "Bulk 1"
4516+
});
4517+
});
44094518
});

src/server/mcp.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,55 @@ export class McpServer {
955955
return result;
956956
}
957957

958+
/**
959+
* Registers multiple tools at once with a single notification.
960+
* This is more efficient than calling registerTool() multiple times,
961+
* especially when registering many tools, as it sends only one list_changed notification.
962+
*/
963+
registerTools<T extends Array<{
964+
name: string;
965+
config: {
966+
title?: string;
967+
description?: string;
968+
inputSchema?: ZodRawShape;
969+
outputSchema?: ZodRawShape;
970+
annotations?: ToolAnnotations;
971+
};
972+
callback: ToolCallback<ZodRawShape | undefined>;
973+
}>>(tools: T): RegisteredTool[] {
974+
const results: RegisteredTool[] = [];
975+
976+
// First, validate that none of the tools are already registered
977+
for (const { name } of tools) {
978+
if (this._registeredTools[name]) {
979+
throw new Error(`Tool ${name} is already registered`);
980+
}
981+
}
982+
983+
// Register all tools without sending notifications
984+
for (const { name, config, callback } of tools) {
985+
const { title, description, inputSchema, outputSchema, annotations } = config;
986+
987+
const result = this._createRegisteredTool(
988+
name,
989+
title,
990+
description,
991+
inputSchema,
992+
outputSchema,
993+
annotations,
994+
callback as ToolCallback<ZodRawShape | undefined>
995+
);
996+
997+
results.push(result);
998+
}
999+
1000+
// Set up handlers and send single notification at the end
1001+
this.setToolRequestHandlers();
1002+
this.sendToolListChanged();
1003+
1004+
return results;
1005+
}
1006+
9581007
/**
9591008
* Registers a zero-argument prompt `name`, which will run the given function when the client calls it.
9601009
*/

0 commit comments

Comments
 (0)