Skip to content

Commit 8b21cff

Browse files
chore: split connect and switch-connection tool MCP-301 (#749)
1 parent 717a651 commit 8b21cff

File tree

7 files changed

+119
-129
lines changed

7 files changed

+119
-129
lines changed

src/common/connectionErrorHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type ConnectionErrorHandled = { errorHandled: true; result: CallToolResul
1414

1515
export const connectionErrorHandler: ConnectionErrorHandler = (error, { availableTools, connectionState }) => {
1616
const connectTools = availableTools
17-
.filter((t) => t.operationType === "connect")
17+
.filter((t) => t.operationType === "connect" && t.isEnabled())
1818
.sort((a, b) => a.category.localeCompare(b.category)); // Sort Atlas tools before MongoDB tools
1919

2020
// Find what Atlas connect tools are available and suggest when the LLM should to use each. If no Atlas tools are found, return a suggestion for the MongoDB connect tool.

src/common/logger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const LogId = {
4040
toolExecute: mongoLogId(1_003_001),
4141
toolExecuteFailure: mongoLogId(1_003_002),
4242
toolDisabled: mongoLogId(1_003_003),
43+
toolMetadataChange: mongoLogId(1_003_004),
4344

4445
mongodbConnectFailure: mongoLogId(1_004_001),
4546
mongodbDisconnectFailure: mongoLogId(1_004_002),

src/tools/mongodb/connect/connect.ts

Lines changed: 19 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -2,114 +2,48 @@ import { z } from "zod";
22
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
33
import { MongoDBToolBase } from "../mongodbTool.js";
44
import type { ToolArgs, OperationType, ToolConstructorParams } from "../../tool.js";
5-
import assert from "assert";
65
import type { Server } from "../../../server.js";
7-
import { LogId } from "../../../common/logger.js";
8-
9-
const disconnectedSchema = z
10-
.object({
11-
connectionString: z.string().describe("MongoDB connection string (in the mongodb:// or mongodb+srv:// format)"),
12-
})
13-
.describe("Options for connecting to MongoDB.");
14-
15-
const connectedSchema = z
16-
.object({
17-
connectionString: z
18-
.string()
19-
.optional()
20-
.describe("MongoDB connection string to switch to (in the mongodb:// or mongodb+srv:// format)"),
21-
})
22-
.describe(
23-
"Options for switching the current MongoDB connection. If a connection string is not provided, the connection string from the config will be used."
24-
);
25-
26-
const connectedName = "switch-connection" as const;
27-
const disconnectedName = "connect" as const;
28-
29-
const connectedDescription =
30-
"Switch to a different MongoDB connection. If the user has configured a connection string or has previously called the connect tool, a connection is already established and there's no need to call this tool unless the user has explicitly requested to switch to a new instance.";
31-
const disconnectedDescription =
32-
"Connect to a MongoDB instance. The config resource captures if the server is already connected to a MongoDB cluster. If the user has configured a connection string or has previously called the connect tool, a connection is already established and there's no need to call this tool unless the user has explicitly requested to switch to a new MongoDB cluster.";
33-
346
export class ConnectTool extends MongoDBToolBase {
35-
public name: typeof connectedName | typeof disconnectedName = disconnectedName;
36-
protected description: typeof connectedDescription | typeof disconnectedDescription = disconnectedDescription;
7+
public override name = "connect";
8+
protected override description =
9+
"Connect to a MongoDB instance. The config resource captures if the server is already connected to a MongoDB cluster. If the user has configured a connection string or has previously called the connect tool, a connection is already established and there's no need to call this tool unless the user has explicitly requested to switch to a new MongoDB cluster.";
3710

3811
// Here the default is empty just to trigger registration, but we're going to override it with the correct
3912
// schema in the register method.
40-
protected argsShape = {
41-
connectionString: z.string().optional(),
13+
protected override argsShape = {
14+
connectionString: z.string().describe("MongoDB connection string (in the mongodb:// or mongodb+srv:// format)"),
4215
};
4316

44-
public operationType: OperationType = "connect";
17+
public override operationType: OperationType = "connect";
4518

4619
constructor({ session, config, telemetry, elicitation }: ToolConstructorParams) {
4720
super({ session, config, telemetry, elicitation });
4821
session.on("connect", () => {
49-
this.updateMetadata();
22+
this.disable();
5023
});
5124

5225
session.on("disconnect", () => {
53-
this.updateMetadata();
26+
this.enable();
5427
});
5528
}
5629

57-
protected async execute({ connectionString }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
58-
switch (this.name) {
59-
case disconnectedName:
60-
assert(connectionString, "Connection string is required");
61-
break;
62-
case connectedName:
63-
connectionString ??= this.config.connectionString;
64-
assert(
65-
connectionString,
66-
"Cannot switch to a new connection because no connection string was provided and no default connection string is configured."
67-
);
68-
break;
30+
public override register(server: Server): boolean {
31+
const registrationSuccessful = super.register(server);
32+
/**
33+
* When connected to mongodb we want to swap connect with
34+
* switch-connection tool.
35+
*/
36+
if (registrationSuccessful && this.session.isConnectedToMongoDB) {
37+
this.disable();
6938
}
39+
return registrationSuccessful;
40+
}
7041

42+
protected override async execute({ connectionString }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
7143
await this.session.connectToMongoDB({ connectionString });
72-
this.updateMetadata();
7344

7445
return {
7546
content: [{ type: "text", text: "Successfully connected to MongoDB." }],
7647
};
7748
}
78-
79-
public register(server: Server): boolean {
80-
if (super.register(server)) {
81-
this.updateMetadata();
82-
return true;
83-
}
84-
85-
return false;
86-
}
87-
88-
private updateMetadata(): void {
89-
let name: string;
90-
let description: string;
91-
let inputSchema: z.ZodObject<z.ZodRawShape>;
92-
93-
if (this.session.isConnectedToMongoDB) {
94-
name = connectedName;
95-
description = connectedDescription;
96-
inputSchema = connectedSchema;
97-
} else {
98-
name = disconnectedName;
99-
description = disconnectedDescription;
100-
inputSchema = disconnectedSchema;
101-
}
102-
103-
this.session.logger.info({
104-
id: LogId.updateToolMetadata,
105-
context: "tool",
106-
message: `Updating tool metadata to ${name}`,
107-
});
108-
109-
this.update?.({
110-
name,
111-
description,
112-
inputSchema,
113-
});
114-
}
11549
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import z from "zod";
2+
import { type CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3+
4+
import { MongoDBToolBase } from "../mongodbTool.js";
5+
import { type ToolArgs, type OperationType, type ToolConstructorParams } from "../../tool.js";
6+
import type { Server } from "../../../server.js";
7+
8+
export class SwitchConnectionTool extends MongoDBToolBase {
9+
public override name = "switch-connection";
10+
protected override description =
11+
"Switch to a different MongoDB connection. If the user has configured a connection string or has previously called the connect tool, a connection is already established and there's no need to call this tool unless the user has explicitly requested to switch to a new instance.";
12+
13+
protected override argsShape = {
14+
connectionString: z
15+
.string()
16+
.optional()
17+
.describe(
18+
"MongoDB connection string to switch to (in the mongodb:// or mongodb+srv:// format). If a connection string is not provided, the connection string from the config will be used."
19+
),
20+
};
21+
22+
public override operationType: OperationType = "connect";
23+
24+
constructor({ session, config, telemetry, elicitation }: ToolConstructorParams) {
25+
super({ session, config, telemetry, elicitation });
26+
session.on("connect", () => {
27+
this.enable();
28+
});
29+
30+
session.on("disconnect", () => {
31+
this.disable();
32+
});
33+
}
34+
35+
public override register(server: Server): boolean {
36+
const registrationSuccessful = super.register(server);
37+
/**
38+
* When connected to mongodb we want to swap connect with
39+
* switch-connection tool.
40+
*/
41+
if (registrationSuccessful && !this.session.isConnectedToMongoDB) {
42+
this.disable();
43+
}
44+
return registrationSuccessful;
45+
}
46+
47+
protected override async execute({ connectionString }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
48+
if (typeof connectionString !== "string") {
49+
await this.session.connectToConfiguredConnection();
50+
} else {
51+
await this.session.connectToMongoDB({ connectionString });
52+
}
53+
54+
return {
55+
content: [{ type: "text", text: "Successfully connected to MongoDB." }],
56+
};
57+
}
58+
}

src/tools/mongodb/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ import { CreateCollectionTool } from "./create/createCollection.js";
2020
import { LogsTool } from "./metadata/logs.js";
2121
import { ExportTool } from "./read/export.js";
2222
import { DropIndexTool } from "./delete/dropIndex.js";
23+
import { SwitchConnectionTool } from "./connect/switchConnection.js";
2324

2425
export const MongoDbTools = [
2526
ConnectTool,
27+
SwitchConnectionTool,
2628
ListCollectionsTool,
2729
ListDatabasesTool,
2830
CollectionIndexesTool,

src/tools/tool.ts

Lines changed: 36 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { z, AnyZodObject } from "zod";
1+
import type { z } from "zod";
22
import { type ZodRawShape, type ZodNever } from "zod";
33
import type { RegisteredTool, ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
44
import type { CallToolResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
@@ -57,6 +57,8 @@ export abstract class ToolBase {
5757

5858
protected abstract argsShape: ZodRawShape;
5959

60+
private registeredTool: RegisteredTool | undefined;
61+
6062
protected get annotations(): ToolAnnotations {
6163
const annotations: ToolAnnotations = {
6264
title: this.name,
@@ -168,52 +170,44 @@ export abstract class ToolBase {
168170
}
169171
};
170172

171-
server.mcpServer.tool(this.name, this.description, this.argsShape, this.annotations, callback);
172-
173-
// This is very similar to RegisteredTool.update, but without the bugs around the name.
174-
// In the upstream update method, the name is captured in the closure and not updated when
175-
// the tool name changes. This means that you only get one name update before things end up
176-
// in a broken state.
177-
// See https://github.com/modelcontextprotocol/typescript-sdk/issues/414 for more details.
178-
this.update = (updates: { name?: string; description?: string; inputSchema?: AnyZodObject }): void => {
179-
const tools = server.mcpServer["_registeredTools"] as { [toolName: string]: RegisteredTool };
180-
const existingTool = tools[this.name];
181-
182-
if (!existingTool) {
183-
this.session.logger.warning({
184-
id: LogId.toolUpdateFailure,
185-
context: "tool",
186-
message: `Tool ${this.name} not found in update`,
187-
noRedaction: true,
188-
});
189-
return;
190-
}
173+
this.registeredTool = server.mcpServer.tool(
174+
this.name,
175+
this.description,
176+
this.argsShape,
177+
this.annotations,
178+
callback
179+
);
191180

192-
existingTool.annotations = this.annotations;
193-
194-
if (updates.name && updates.name !== this.name) {
195-
existingTool.annotations.title = updates.name;
196-
delete tools[this.name];
197-
this.name = updates.name;
198-
tools[this.name] = existingTool;
199-
}
200-
201-
if (updates.description) {
202-
existingTool.description = updates.description;
203-
this.description = updates.description;
204-
}
205-
206-
if (updates.inputSchema) {
207-
existingTool.inputSchema = updates.inputSchema;
208-
}
181+
return true;
182+
}
209183

210-
server.mcpServer.sendToolListChanged();
211-
};
184+
public isEnabled(): boolean {
185+
return this.registeredTool?.enabled ?? false;
186+
}
212187

213-
return true;
188+
protected disable(): void {
189+
if (!this.registeredTool) {
190+
this.session.logger.warning({
191+
id: LogId.toolMetadataChange,
192+
context: `tool - ${this.name}`,
193+
message: "Requested disabling of tool but it was never registered",
194+
});
195+
return;
196+
}
197+
this.registeredTool.disable();
214198
}
215199

216-
protected update?: (updates: { name?: string; description?: string; inputSchema?: AnyZodObject }) => void;
200+
protected enable(): void {
201+
if (!this.registeredTool) {
202+
this.session.logger.warning({
203+
id: LogId.toolMetadataChange,
204+
context: `tool - ${this.name}`,
205+
message: "Requested enabling of tool but it was never registered",
206+
});
207+
return;
208+
}
209+
this.registeredTool.enable();
210+
}
217211

218212
// Checks if a tool is allowed to run based on the config
219213
protected verifyAllowed(): boolean {

tests/integration/tools/mongodb/connect/connect.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ describeWithMongoDB(
2626
[
2727
{
2828
name: "connectionString",
29-
description: "MongoDB connection string to switch to (in the mongodb:// or mongodb+srv:// format)",
29+
description:
30+
"MongoDB connection string to switch to (in the mongodb:// or mongodb+srv:// format). If a connection string is not provided, the connection string from the config will be used.",
3031
type: "string",
3132
required: false,
3233
},

0 commit comments

Comments
 (0)