Skip to content

feat: update the connect tool based on connectivity status #118

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 28 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
public readonly session: Session;
private readonly mcpServer: McpServer;
private readonly telemetry: Telemetry;
private readonly userConfig: UserConfig;
public readonly userConfig: UserConfig;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still not super happy with where we are at wrt configs, but this was a stopgap - there were a bunch of places in the tests where we were updating fields in the global config expecting this to be reflected in the server config, which only worked by accident.


constructor({ session, mcpServer, userConfig }: ServerOptions) {
this.session = session;
Expand All @@ -32,6 +32,7 @@

async connect(transport: Transport) {
this.mcpServer.server.registerCapabilities({ logging: {} });
this.mcpServer.sendToolListChanged;

Check failure on line 35 in src/server.ts

View workflow job for this annotation

GitHub Actions / check-style

Expected an assignment or function call and instead saw an expression
this.registerTools();
this.registerResources();

Expand Down Expand Up @@ -86,6 +87,32 @@
}

private registerResources() {
this.mcpServer.resource(
"config",
"config://config",
{
description:
"Server configuration, supplied by the user either as environment variables or as startup arguments",
},
(uri) => {
const result = {
telemetry: this.userConfig.telemetry,
logPath: this.userConfig.logPath,
connectionString: this.userConfig.connectionString
? "set; no explicit connect needed, use switch-connection tool to connect to a different connection if necessary"
: "not set; before using any mongodb tool, you need to call the connect tool with a connection string",
connectOptions: this.userConfig.connectOptions,
Comment on lines +146 to +151
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the config options we're exposing as a resource

};
return {
contents: [
{
text: JSON.stringify(result),
uri: uri.href,
},
],
};
}
);
if (this.userConfig.connectionString) {
this.mcpServer.resource(
"connection-string",
Expand Down
9 changes: 8 additions & 1 deletion src/session.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
import { ApiClient, ApiClientCredentials } from "./common/atlas/apiClient.js";
import { Implementation } from "@modelcontextprotocol/sdk/types.js";
import EventEmitter from "events";

export interface SessionOptions {
apiBaseUrl?: string;
apiClientId?: string;
apiClientSecret?: string;
}

export class Session {
export class Session extends EventEmitter<{
close: [];
}> {
sessionId?: string;
serviceProvider?: NodeDriverServiceProvider;
apiClient: ApiClient;
Expand All @@ -18,6 +21,8 @@ export class Session {
};

constructor({ apiBaseUrl, apiClientId, apiClientSecret }: SessionOptions = {}) {
super();

const credentials: ApiClientCredentials | undefined =
apiClientId && apiClientSecret
? {
Expand Down Expand Up @@ -49,6 +54,8 @@ export class Session {
console.error("Error closing service provider:", error);
}
this.serviceProvider = undefined;

this.emit("close");
}
}
}
155 changes: 79 additions & 76 deletions src/tools/mongodb/metadata/connect.ts
Original file line number Diff line number Diff line change
@@ -1,93 +1,96 @@
import { z } from "zod";
import { AnyZodObject, z, ZodRawShape } from "zod";

Check failure on line 1 in src/tools/mongodb/metadata/connect.ts

View workflow job for this annotation

GitHub Actions / check-style

'AnyZodObject' is defined but never used

Check failure on line 1 in src/tools/mongodb/metadata/connect.ts

View workflow job for this annotation

GitHub Actions / check-style

'ZodRawShape' is defined but never used
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { MongoError as DriverError } from "mongodb";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import assert from "assert";
import { UserConfig } from "../../../config.js";
import { Telemetry } from "../../../telemetry/telemetry.js";
import { Session } from "../../../session.js";

const disconnectedSchema = z
.object({
connectionString: z.string().describe("MongoDB connection string (in the mongodb:// or mongodb+srv:// format)"),
})
.describe("Options for connecting to MongoDB.");

const connectedSchema = z
.object({
connectionString: z
.string()
.optional()
.describe("MongoDB connection string to switch to (in the mongodb:// or mongodb+srv:// format)"),
})
.describe(
"Options for switching the current MongoDB connection. If a connection string is not provided, the connection string from the config will be used."
);

const connectedName = "switch-connection" as const;
const disconnectedName = "connect" as const;

const connectedDescription =
"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.";
const disconnectedDescription = "Connect to a MongoDB instance";

export class ConnectTool extends MongoDBToolBase {
protected name = "connect";
protected description = "Connect to a MongoDB instance";
protected name: typeof connectedName | typeof disconnectedName = disconnectedName;
protected description: typeof connectedDescription | typeof disconnectedDescription = disconnectedDescription;

// Here the default is empty just to trigger registration, but we're going to override it with the correct
// schema in the register method.
protected argsShape = {
options: z
.array(
z
.union([
z.object({
connectionString: z
.string()
.describe("MongoDB connection string (in the mongodb:// or mongodb+srv:// format)"),
}),
z.object({
clusterName: z.string().describe("MongoDB cluster name"),
}),
])
.optional()
)
.optional()
.describe(
"Options for connecting to MongoDB. If not provided, the connection string from the config://connection-string resource will be used. If the user hasn't specified Atlas cluster name or a connection string explicitly and the `config://connection-string` resource is present, always invoke this with no arguments."
),
connectionString: z.string().optional(),
};

protected operationType: OperationType = "metadata";

protected async execute({ options: optionsArr }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const options = optionsArr?.[0];
let connectionString: string;
if (!options && !this.config.connectionString) {
return {
content: [
{ type: "text", text: "No connection details provided." },
{ type: "text", text: "Please provide either a connection string or a cluster name" },
],
};
}
constructor(session: Session, config: UserConfig, telemetry: Telemetry) {
super(session, config, telemetry);
session.on("close", () => {
this.updateMetadata();
});
}

if (!options) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
connectionString = this.config.connectionString!;
} else if ("connectionString" in options) {
connectionString = options.connectionString;
} else {
// TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/19
// We don't support connecting via cluster name since we'd need to obtain the user credentials
// and fill in the connection string.
return {
content: [
{
type: "text",
text: `Connecting via cluster name not supported yet. Please provide a connection string.`,
},
],
};
protected async execute({ connectionString }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
switch (this.name) {
case disconnectedName:
assert(connectionString, "Connection string is required");
break;
case connectedName:
connectionString ??= this.config.connectionString;
assert(
connectionString,
"Cannot switch to a new connection because no connection string was provided and no default connection string is configured."
);
break;
}

try {
await this.connectToMongoDB(connectionString);
return {
content: [{ type: "text", text: `Successfully connected to ${connectionString}.` }],
};
} catch (error) {
// Sometimes the model will supply an incorrect connection string. If the user has configured
// a different one as environment variable or a cli argument, suggest using that one instead.
if (
this.config.connectionString &&
error instanceof DriverError &&
this.config.connectionString !== connectionString
) {
return {
content: [
{
type: "text",
text:
`Failed to connect to MongoDB at '${connectionString}' due to error: '${error.message}.` +
`Your config lists a different connection string: '${this.config.connectionString}' - do you want to try connecting to it instead?`,
},
],
};
}
await this.connectToMongoDB(connectionString);
this.updateMetadata();
return {
content: [{ type: "text", text: "Successfully connected to MongoDB." }],
};
}

public register(server: McpServer): void {
super.register(server);

throw error;
this.updateMetadata();
}

private updateMetadata(): void {
if (this.config.connectionString || this.session.serviceProvider) {
this.update?.({
name: connectedName,
description: connectedDescription,
inputSchema: connectedSchema,
});
} else {
this.update?.({
name: disconnectedName,
description: disconnectedDescription,
inputSchema: disconnectedSchema,
});
}
}
}
32 changes: 30 additions & 2 deletions src/tools/tool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z, type ZodRawShape, type ZodNever } from "zod";
import type { McpServer, ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z, type ZodRawShape, type ZodNever, AnyZodObject } from "zod";
import type { McpServer, RegisteredTool, ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { Session } from "../session.js";
import logger from "../logger.js";
Expand Down Expand Up @@ -81,8 +81,36 @@ export abstract class ToolBase {
};

server.tool(this.name, this.description, this.argsShape, callback);

// This is very similar to RegisteredTool.update, but without the bugs around the name.
// In the upstream update method, the name is captured in the closure and not updated when
// the tool name changes. This means that you only get one name update before things end up
// in a broken state.
this.update = (updates: { name?: string; description?: string; inputSchema?: AnyZodObject }) => {
const tools = server["_registeredTools"] as { [toolName: string]: RegisteredTool };
const existingTool = tools[this.name];

if (updates.name && updates.name !== this.name) {
delete tools[this.name];
this.name = updates.name;
tools[this.name] = existingTool;
}

if (updates.description) {
existingTool.description = updates.description;
this.description = updates.description;
}

if (updates.inputSchema) {
existingTool.inputSchema = updates.inputSchema;
}

server.sendToolListChanged();
};
}

protected update?: (updates: { name?: string; description?: string; inputSchema?: AnyZodObject }) => void;

// Checks if a tool is allowed to run based on the config
protected verifyAllowed(): boolean {
let errorClarification: string | undefined;
Expand Down
17 changes: 14 additions & 3 deletions tests/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { InMemoryTransport } from "./inMemoryTransport.js";
import { Server } from "../../src/server.js";
import { ObjectId } from "mongodb";
import { config, UserConfig } from "../../src/config.js";
import { McpError } from "@modelcontextprotocol/sdk/types.js";
import { McpError, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Session } from "../../src/session.js";
import { toIncludeAllMembers } from "jest-extended";
Expand All @@ -22,13 +22,14 @@ export interface IntegrationTest {
mcpServer: () => Server;
}

export function setupIntegrationTest(userConfig: UserConfig = config): IntegrationTest {
export function setupIntegrationTest(userConfigGetter: () => UserConfig = () => config): IntegrationTest {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was necessary to allow the user config to be constructed as part of the test initialization rather than statically.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function setupIntegrationTest(userConfigGetter: () => UserConfig = () => config): IntegrationTest {
export function setupIntegrationTest(getUserConfig: () => UserConfig = () => config): IntegrationTest {

nit

let mcpClient: Client | undefined;
let mcpServer: Server | undefined;

let randomDbName: string;

beforeAll(async () => {
const userConfig = userConfigGetter();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const userConfig = userConfigGetter();
const userConfig = getUserConfig();

const clientTransport = new InMemoryTransport();
const serverTransport = new InMemoryTransport();

Expand All @@ -54,6 +55,7 @@ export function setupIntegrationTest(userConfig: UserConfig = config): Integrati
apiClientSecret: userConfig.apiClientSecret,
});

userConfig.telemetry = "disabled";
mcpServer = new Server({
session,
userConfig,
Expand All @@ -67,10 +69,19 @@ export function setupIntegrationTest(userConfig: UserConfig = config): Integrati
});

beforeEach(async () => {
config.telemetry = "disabled";
if (mcpServer) {
mcpServer.userConfig.telemetry = "disabled";
}
randomDbName = new ObjectId().toString();
});

afterEach(async () => {
if (mcpServer) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this from the mongodb helpers because the session cleanup seemed like something common.

await mcpServer.session.close();
mcpServer.userConfig.connectionString = undefined;
}
});

afterAll(async () => {
await mcpClient?.close();
mcpClient = undefined;
Expand Down
14 changes: 4 additions & 10 deletions tests/integration/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { config } from "../../src/config.js";

describe("Server integration test", () => {
describe("without atlas", () => {
const integration = setupIntegrationTest({
const integration = setupIntegrationTest(() => ({
...config,
apiClientId: undefined,
apiClientSecret: undefined,
});
}));

it("should return positive number of tools and have no atlas tools", async () => {
const tools = await integration.mcpClient().listTools();
Expand All @@ -19,11 +19,11 @@ describe("Server integration test", () => {
});
});
describe("with atlas", () => {
const integration = setupIntegrationTest({
const integration = setupIntegrationTest(() => ({
...config,
apiClientId: "test",
apiClientSecret: "test",
});
}));

describe("list capabilities", () => {
it("should return positive number of tools and have some atlas tools", async () => {
Expand All @@ -35,12 +35,6 @@ describe("Server integration test", () => {
expect(atlasTools.length).toBeGreaterThan(0);
});

it("should return no resources", async () => {
await expect(() => integration.mcpClient().listResources()).rejects.toMatchObject({
message: "MCP error -32601: Method not found",
});
});

it("should return no prompts", async () => {
await expect(() => integration.mcpClient().listPrompts()).rejects.toMatchObject({
message: "MCP error -32601: Method not found",
Expand Down
Loading
Loading