Skip to content

Commit 56ba6cf

Browse files
chore: prevent against tool name collision
This commit ensures that when toolMetadataOverrides accidentally lead to tool name collision then we error out before even starting the server.
1 parent 6ea3167 commit 56ba6cf

File tree

4 files changed

+202
-2
lines changed

4 files changed

+202
-2
lines changed

src/common/config/argsParserOptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ export const OPTIONS = {
5555
"exportTimeoutMs",
5656
"exportCleanupIntervalMs",
5757
"voyageApiKey",
58+
// Note: toolMetadataOverrides expects a JSON object but we mention it
59+
// as a string only to let yargs-parser know that this key needs to be
60+
// parsed. The internal handling of the key as an object is done within
61+
// yargs-parser itself.
62+
"toolMetadataOverrides",
5863
],
5964
boolean: [
6065
"apiDeprecationErrors",

src/server.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,27 @@ export class Server {
241241
}
242242

243243
private registerTools(): void {
244+
const toolsToRegister: ToolBase[] = [];
245+
const usedNames = new Set<string>();
246+
244247
for (const toolConstructor of this.toolConstructors) {
245248
const tool = new toolConstructor({
246249
session: this.session,
247250
config: this.userConfig,
248251
telemetry: this.telemetry,
249252
elicitation: this.elicitation,
250253
});
254+
255+
if (usedNames.has(tool.name)) {
256+
throw new Error(
257+
`Tool name collision detected: '${tool.name}'. This might be due to toolMetadataOverrides configuration.`
258+
);
259+
}
260+
usedNames.add(tool.name);
261+
toolsToRegister.push(tool);
262+
}
263+
264+
for (const tool of toolsToRegister) {
251265
if (tool.register(this)) {
252266
this.tools.push(tool);
253267
}

tests/integration/server.test.ts

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,69 @@
1-
import { defaultTestConfig, expectDefined } from "./helpers.js";
1+
import { MCPConnectionManager } from "../../src/common/connectionManager.js";
2+
import { ExportsManager } from "../../src/common/exportsManager.js";
3+
import { CompositeLogger } from "../../src/common/logger.js";
4+
import { DeviceId } from "../../src/helpers/deviceId.js";
5+
import { Session } from "../../src/common/session.js";
6+
import {
7+
defaultTestConfig,
8+
expectDefined,
9+
validateThrowsForInvalidArguments,
10+
validateToolMetadata,
11+
} from "./helpers.js";
212
import { describeWithMongoDB } from "./tools/mongodb/mongodbHelpers.js";
313
import { describe, expect, it } from "vitest";
14+
import { Elicitation, Keychain, Telemetry } from "../../src/lib.js";
15+
import { VectorSearchEmbeddingsManager } from "../../src/common/search/vectorSearchEmbeddingsManager.js";
16+
import { defaultCreateAtlasLocalClient } from "../../src/common/atlasLocal.js";
17+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18+
import { Server } from "../../src/server.js";
19+
import { connectionErrorHandler } from "../../src/common/connectionErrorHandler.js";
20+
import { InMemoryTransport } from "./inMemoryTransport.js";
21+
import type { UserConfig } from "../../src/common/config/userConfig.js";
22+
import { type OperationType, ToolBase, type ToolCategory } from "../../src/tools/tool.js";
23+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
24+
import type { TelemetryToolMetadata } from "../../src/telemetry/types.js";
25+
import { AllTools } from "../../src/tools/index.js";
26+
class TestToolOne extends ToolBase {
27+
public internalName = "test-tool-one";
28+
public category: ToolCategory = "mongodb";
29+
public operationType: OperationType = "delete";
30+
protected internalDescription = "A test tool one for verification tests";
31+
protected argsShape = {};
32+
protected async execute(): Promise<CallToolResult> {
33+
return Promise.resolve({
34+
content: [
35+
{
36+
type: "text",
37+
text: "Test tool executed successfully",
38+
},
39+
],
40+
});
41+
}
42+
protected resolveTelemetryMetadata(): TelemetryToolMetadata {
43+
return {};
44+
}
45+
}
46+
47+
class TestToolTwo extends ToolBase {
48+
public internalName = "test-tool-two";
49+
public category: ToolCategory = "mongodb";
50+
public operationType: OperationType = "delete";
51+
protected internalDescription = "A test tool two for verification tests";
52+
protected argsShape = {};
53+
protected async execute(): Promise<CallToolResult> {
54+
return Promise.resolve({
55+
content: [
56+
{
57+
type: "text",
58+
text: "Test tool executed successfully",
59+
},
60+
],
61+
});
62+
}
63+
protected resolveTelemetryMetadata(): TelemetryToolMetadata {
64+
return {};
65+
}
66+
}
467

568
describe("Server integration test", () => {
669
describeWithMongoDB(
@@ -97,4 +160,122 @@ describe("Server integration test", () => {
97160
}),
98161
}
99162
);
163+
164+
describeWithMongoDB(
165+
"Tool with overridden metadata",
166+
(integration) => {
167+
validateToolMetadata(integration, "new-connect", "new connect tool description", [
168+
{
169+
name: "connectionString",
170+
description: "MongoDB connection string (in the mongodb:// or mongodb+srv:// format)",
171+
type: "string",
172+
required: true,
173+
},
174+
]);
175+
176+
validateThrowsForInvalidArguments(integration, "new-connect", [{}, { connectionString: 123 }]);
177+
178+
it("should not have overridden connect tool registered", async () => {
179+
const { tools } = await integration.mcpClient().listTools();
180+
const tool = tools.find((tool) => tool.name === "connect");
181+
expect(tool).toBeUndefined();
182+
});
183+
},
184+
{
185+
getUserConfig() {
186+
return {
187+
...defaultTestConfig,
188+
toolMetadataOverrides: {
189+
connect: {
190+
name: "new-connect",
191+
description: "new connect tool description",
192+
},
193+
},
194+
};
195+
},
196+
}
197+
);
198+
199+
describe("when toolMetadataOverrides leads to tool name collision", () => {
200+
it.each([
201+
{
202+
config: {
203+
toolMetadataOverrides: {
204+
"list-databases": { name: "my-tool" },
205+
"list-collections": { name: "my-tool" },
206+
},
207+
},
208+
additionalTools: [],
209+
},
210+
{
211+
config: {
212+
toolMetadataOverrides: {
213+
"list-databases": { name: "list-collections" },
214+
},
215+
},
216+
additionalTools: [],
217+
},
218+
{
219+
config: {
220+
toolMetadataOverrides: {
221+
"test-tool-one": {
222+
name: "connect",
223+
},
224+
},
225+
},
226+
additionalTools: [TestToolOne, TestToolTwo],
227+
},
228+
{
229+
config: {
230+
toolMetadataOverrides: {
231+
"test-tool-one": {
232+
name: "test-tool-two",
233+
},
234+
},
235+
},
236+
additionalTools: [TestToolOne, TestToolTwo],
237+
},
238+
] as { config: Partial<UserConfig>; additionalTools: (new () => ToolBase)[] }[])(
239+
"should throw an error when tool names collide due to overrides - %",
240+
async ({ config, additionalTools }) => {
241+
const userConfig: UserConfig = {
242+
...defaultTestConfig,
243+
...config,
244+
};
245+
const logger = new CompositeLogger();
246+
const deviceId = DeviceId.create(logger);
247+
const connectionManager = new MCPConnectionManager(userConfig, logger, deviceId);
248+
const exportsManager = ExportsManager.init(userConfig, logger);
249+
250+
const session = new Session({
251+
userConfig: userConfig,
252+
logger,
253+
exportsManager,
254+
connectionManager,
255+
keychain: Keychain.root,
256+
vectorSearchEmbeddingsManager: new VectorSearchEmbeddingsManager(userConfig, connectionManager),
257+
atlasLocalClient: await defaultCreateAtlasLocalClient(),
258+
});
259+
260+
const telemetry = Telemetry.create(session, userConfig, deviceId);
261+
const mcpServerInstance = new McpServer({ name: "test", version: "1.0" });
262+
const elicitation = new Elicitation({ server: mcpServerInstance.server });
263+
264+
const server = new Server({
265+
session,
266+
userConfig: userConfig,
267+
telemetry,
268+
mcpServer: mcpServerInstance,
269+
elicitation,
270+
connectionErrorHandler,
271+
tools: [...AllTools, ...additionalTools],
272+
});
273+
274+
const transport = new InMemoryTransport();
275+
276+
// We expect this to fail with our new guardrail
277+
await expect(server.connect(transport)).rejects.toThrow(/Tool name collision detected/);
278+
}
279+
);
280+
});
100281
});

tests/integration/tools/mongodb/create/createIndex.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ describeWithMongoDB(
510510
expect(indexes).toHaveLength(1);
511511
expect(indexes[0]?.name).toEqual("vector_1_vector");
512512
expect(indexes[0]?.type).toEqual("vectorSearch");
513-
expect(indexes[0]?.status).toEqual("PENDING");
513+
expect(indexes[0]?.status).toEqual(expect.stringMatching(/PENDING|BUILDING/));
514514
expect(indexes[0]?.queryable).toEqual(false);
515515
expect(indexes[0]?.latestDefinition).toEqual({
516516
fields: [

0 commit comments

Comments
 (0)