diff --git a/.github/workflows/code_health.yaml b/.github/workflows/code_health.yaml index 265d1050..55f485d8 100644 --- a/.github/workflows/code_health.yaml +++ b/.github/workflows/code_health.yaml @@ -63,7 +63,7 @@ jobs: path: coverage/lcov.info coverage: - name: Run MongoDB tests + name: Report Coverage if: always() && github.event.pull_request.user.login != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest needs: [run-tests, run-atlas-tests] diff --git a/src/common/atlas/apiClient.ts b/src/common/atlas/apiClient.ts index 2cda1ffc..34e1a0e7 100644 --- a/src/common/atlas/apiClient.ts +++ b/src/common/atlas/apiClient.ts @@ -15,7 +15,7 @@ export interface ApiClientCredentials { export interface ApiClientOptions { credentials?: ApiClientCredentials; - baseUrl?: string; + baseUrl: string; userAgent?: string; } @@ -63,12 +63,11 @@ export class ApiClient { }, }; - constructor(options?: ApiClientOptions) { + constructor(options: ApiClientOptions) { this.options = { ...options, - baseUrl: options?.baseUrl || "https://cloud.mongodb.com/", userAgent: - options?.userAgent || + options.userAgent || `AtlasMCP/${packageInfo.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`, }; diff --git a/src/config.ts b/src/config.ts index 676247a5..aa0be9db 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,26 +4,29 @@ import argv from "yargs-parser"; import { ReadConcernLevel, ReadPreferenceMode, W } from "mongodb"; +export interface ConnectOptions { + readConcern: ReadConcernLevel; + readPreference: ReadPreferenceMode; + writeConcern: W; + timeoutMS: number; +} + // If we decide to support non-string config options, we'll need to extend the mechanism for parsing // env variables. export interface UserConfig { - apiBaseUrl?: string; + apiBaseUrl: string; apiClientId?: string; apiClientSecret?: string; telemetry?: "enabled" | "disabled"; logPath: string; connectionString?: string; - connectOptions: { - readConcern: ReadConcernLevel; - readPreference: ReadPreferenceMode; - writeConcern: W; - timeoutMS: number; - }; + connectOptions: ConnectOptions; disabledTools: Array; readOnly?: boolean; } const defaults: UserConfig = { + apiBaseUrl: "https://cloud.mongodb.com/", logPath: getLogPath(), connectOptions: { readConcern: "local", diff --git a/src/index.ts b/src/index.ts index ef496e5d..9ab92038 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ try { name: packageInfo.mcpServerName, version: packageInfo.version, }); + const server = new Server({ mcpServer, session, diff --git a/src/server.ts b/src/server.ts index 3d4802f3..a105b33f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,7 +33,7 @@ export class Server { this.userConfig = userConfig; } - async connect(transport: Transport) { + async connect(transport: Transport): Promise { this.mcpServer.server.registerCapabilities({ logging: {} }); this.registerTools(); @@ -88,6 +88,8 @@ export class Server { const closeTime = Date.now(); this.emitServerEvent("stop", Date.now() - closeTime, error); }; + + await this.validateConfig(); } async close(): Promise { @@ -183,4 +185,29 @@ export class Server { ); } } + + private async validateConfig(): Promise { + const isAtlasConfigured = this.userConfig.apiClientId && this.userConfig.apiClientSecret; + const isMongoDbConfigured = this.userConfig.connectionString; + if (!isAtlasConfigured && !isMongoDbConfigured) { + console.error( + "Either Atlas Client Id or a MongoDB connection string must be configured - you can provide them as environment variables or as startup arguments. \n" + + "Provide the Atlas credentials as `MDB_MCP_API_CLIENT_ID` and `MDB_MCP_API_CLIENT_SECRET` environment variables or as `--apiClientId` and `--apiClientSecret` startup arguments. \n" + + "Provide the MongoDB connection string as `MDB_MCP_CONNECTION_STRING` environment variable or as `--connectionString` startup argument." + ); + throw new Error("Either Atlas Client Id or a MongoDB connection string must be configured"); + } + + if (this.userConfig.connectionString) { + try { + await this.session.connectToMongoDB(this.userConfig.connectionString, this.userConfig.connectOptions); + } catch (error) { + console.error( + "Failed to connect to MongoDB instance using the connection string from the config: ", + error + ); + throw new Error("Failed to connect to MongoDB instance using the connection string from the config"); + } + } + } } diff --git a/src/session.ts b/src/session.ts index 17357d6c..4b8c7faf 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,9 +2,10 @@ 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"; +import { ConnectOptions } from "./config.js"; export interface SessionOptions { - apiBaseUrl?: string; + apiBaseUrl: string; apiClientId?: string; apiClientSecret?: string; } @@ -20,7 +21,7 @@ export class Session extends EventEmitter<{ version: string; }; - constructor({ apiBaseUrl, apiClientId, apiClientSecret }: SessionOptions = {}) { + constructor({ apiBaseUrl, apiClientId, apiClientSecret }: SessionOptions) { super(); const credentials: ApiClientCredentials | undefined = @@ -58,4 +59,21 @@ export class Session extends EventEmitter<{ this.emit("close"); } } + + async connectToMongoDB(connectionString: string, connectOptions: ConnectOptions): Promise { + const provider = await NodeDriverServiceProvider.connect(connectionString, { + productDocsLink: "https://docs.mongodb.com/todo-mcp", + productName: "MongoDB MCP", + readConcern: { + level: connectOptions.readConcern, + }, + readPreference: connectOptions.readPreference, + writeConcern: { + w: connectOptions.writeConcern, + }, + timeoutMS: connectOptions.timeoutMS, + }); + + this.serviceProvider = provider; + } } diff --git a/src/tools/mongodb/mongodbTool.ts b/src/tools/mongodb/mongodbTool.ts index 7e067e0f..d0e59b8b 100644 --- a/src/tools/mongodb/mongodbTool.ts +++ b/src/tools/mongodb/mongodbTool.ts @@ -70,20 +70,7 @@ export abstract class MongoDBToolBase extends ToolBase { return super.handleError(error, args); } - protected async connectToMongoDB(connectionString: string): Promise { - const provider = await NodeDriverServiceProvider.connect(connectionString, { - productDocsLink: "https://docs.mongodb.com/todo-mcp", - productName: "MongoDB MCP", - readConcern: { - level: this.config.connectOptions.readConcern, - }, - readPreference: this.config.connectOptions.readPreference, - writeConcern: { - w: this.config.connectOptions.writeConcern, - }, - timeoutMS: this.config.connectOptions.timeoutMS, - }); - - this.session.serviceProvider = provider; + protected connectToMongoDB(connectionString: string): Promise { + return this.session.connectToMongoDB(connectionString, this.config.connectOptions); } } diff --git a/src/tools/mongodb/tools.ts b/src/tools/mongodb/tools.ts index d64d53ea..523f45ca 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -1,4 +1,5 @@ -import { ConnectTool } from "./metadata/connect.js"; +// TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/141 - reenable when the connect tool is reenabled +// import { ConnectTool } from "./metadata/connect.js"; import { ListCollectionsTool } from "./metadata/listCollections.js"; import { CollectionIndexesTool } from "./read/collectionIndexes.js"; import { ListDatabasesTool } from "./metadata/listDatabases.js"; @@ -20,7 +21,8 @@ import { CreateCollectionTool } from "./create/createCollection.js"; import { LogsTool } from "./metadata/logs.js"; export const MongoDbTools = [ - ConnectTool, + // TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/141 - reenable when the connect tool is reenabled + // ConnectTool, ListCollectionsTool, ListDatabasesTool, CollectionIndexesTool, diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 98c8b970..c57deda8 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -1,7 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "./inMemoryTransport.js"; import { Server } from "../../src/server.js"; -import { config, UserConfig } from "../../src/config.js"; +import { UserConfig } from "../../src/config.js"; import { McpError } from "@modelcontextprotocol/sdk/types.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Session } from "../../src/session.js"; @@ -20,7 +20,7 @@ export interface IntegrationTest { mcpServer: () => Server; } -export function setupIntegrationTest(getUserConfig: () => UserConfig = () => config): IntegrationTest { +export function setupIntegrationTest(getUserConfig: () => UserConfig): IntegrationTest { let mcpClient: Client | undefined; let mcpServer: Server | undefined; diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index b9072dca..aec15add 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,23 +1,27 @@ import { expectDefined, setupIntegrationTest } from "./helpers.js"; import { config } from "../../src/config.js"; +import { describeWithMongoDB } from "./tools/mongodb/mongodbHelpers.js"; describe("Server integration test", () => { - describe("without atlas", () => { - const integration = setupIntegrationTest(() => ({ + describeWithMongoDB( + "without atlas", + (integration) => { + it("should return positive number of tools and have no atlas tools", async () => { + const tools = await integration.mcpClient().listTools(); + expectDefined(tools); + expect(tools.tools.length).toBeGreaterThan(0); + + const atlasTools = tools.tools.filter((tool) => tool.name.startsWith("atlas-")); + expect(atlasTools.length).toBeLessThanOrEqual(0); + }); + }, + () => ({ ...config, apiClientId: undefined, apiClientSecret: undefined, - })); + }) + ); - it("should return positive number of tools and have no atlas tools", async () => { - const tools = await integration.mcpClient().listTools(); - expectDefined(tools); - expect(tools.tools.length).toBeGreaterThan(0); - - const atlasTools = tools.tools.filter((tool) => tool.name.startsWith("atlas-")); - expect(atlasTools.length).toBeLessThanOrEqual(0); - }); - }); describe("with atlas", () => { const integration = setupIntegrationTest(() => ({ ...config, diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index f015b2b2..76ba157e 100644 --- a/tests/integration/tools/atlas/atlasHelpers.ts +++ b/tests/integration/tools/atlas/atlasHelpers.ts @@ -2,6 +2,7 @@ import { ObjectId } from "mongodb"; import { Group } from "../../../../src/common/atlas/openapi.js"; import { ApiClient } from "../../../../src/common/atlas/apiClient.js"; import { setupIntegrationTest, IntegrationTest } from "../../helpers.js"; +import { config } from "../../../../src/config.js"; export type IntegrationTestFunction = (integration: IntegrationTest) => void; @@ -11,7 +12,12 @@ export function sleep(ms: number) { export function describeWithAtlas(name: string, fn: IntegrationTestFunction) { const testDefinition = () => { - const integration = setupIntegrationTest(); + const integration = setupIntegrationTest(() => ({ + ...config, + apiClientId: process.env.MDB_MCP_API_CLIENT_ID, + apiClientSecret: process.env.MDB_MCP_API_CLIENT_SECRET, + })); + describe(name, () => { fn(integration); }); diff --git a/tests/integration/tools/mongodb/metadata/connect.test.ts b/tests/integration/tools/mongodb/metadata/connect.test.ts index ca138944..d742e7e8 100644 --- a/tests/integration/tools/mongodb/metadata/connect.test.ts +++ b/tests/integration/tools/mongodb/metadata/connect.test.ts @@ -2,6 +2,8 @@ import { describeWithMongoDB } from "../mongodbHelpers.js"; import { getResponseContent, validateThrowsForInvalidArguments, validateToolMetadata } from "../../../helpers.js"; import { config } from "../../../../../src/config.js"; +// These tests are temporarily skipped because the connect tool is disabled for the initial release. +// TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/141 - reenable when the connect tool is reenabled describeWithMongoDB( "switchConnection tool", (integration) => { @@ -75,50 +77,56 @@ describeWithMongoDB( (mdbIntegration) => ({ ...config, connectionString: mdbIntegration.connectionString(), - }) + }), + describe.skip ); -describeWithMongoDB("Connect tool", (integration) => { - validateToolMetadata(integration, "connect", "Connect to a MongoDB instance", [ - { - name: "connectionString", - description: "MongoDB connection string (in the mongodb:// or mongodb+srv:// format)", - type: "string", - required: true, - }, - ]); +describeWithMongoDB( + "Connect tool", + (integration) => { + validateToolMetadata(integration, "connect", "Connect to a MongoDB instance", [ + { + name: "connectionString", + description: "MongoDB connection string (in the mongodb:// or mongodb+srv:// format)", + type: "string", + required: true, + }, + ]); - validateThrowsForInvalidArguments(integration, "connect", [{}, { connectionString: 123 }]); + validateThrowsForInvalidArguments(integration, "connect", [{}, { connectionString: 123 }]); - it("doesn't have the switch-connection tool registered", async () => { - const { tools } = await integration.mcpClient().listTools(); - const tool = tools.find((tool) => tool.name === "switch-connection"); - expect(tool).toBeUndefined(); - }); + it("doesn't have the switch-connection tool registered", async () => { + const { tools } = await integration.mcpClient().listTools(); + const tool = tools.find((tool) => tool.name === "switch-connection"); + expect(tool).toBeUndefined(); + }); - describe("with connection string", () => { - it("connects to the database", async () => { - const response = await integration.mcpClient().callTool({ - name: "connect", - arguments: { - connectionString: integration.connectionString(), - }, + describe("with connection string", () => { + it("connects to the database", async () => { + const response = await integration.mcpClient().callTool({ + name: "connect", + arguments: { + connectionString: integration.connectionString(), + }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("Successfully connected"); }); - const content = getResponseContent(response.content); - expect(content).toContain("Successfully connected"); }); - }); - describe("with invalid connection string", () => { - it("returns error message", async () => { - const response = await integration.mcpClient().callTool({ - name: "connect", - arguments: { connectionString: "mongodb://localhost:12345" }, - }); - const content = getResponseContent(response.content); - expect(content).toContain("Error running connect"); + describe("with invalid connection string", () => { + it("returns error message", async () => { + const response = await integration.mcpClient().callTool({ + name: "connect", + arguments: { connectionString: "mongodb://localhost:12345" }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("Error running connect"); - // Should not suggest using the config connection string (because we don't have one) - expect(content).not.toContain("Your config lists a different connection string"); + // Should not suggest using the config connection string (because we don't have one) + expect(content).not.toContain("Your config lists a different connection string"); + }); }); - }); -}); + }, + () => config, + describe.skip +); diff --git a/tests/integration/tools/mongodb/mongodbHelpers.ts b/tests/integration/tools/mongodb/mongodbHelpers.ts index 807b0d20..2b4ea6a0 100644 --- a/tests/integration/tools/mongodb/mongodbHelpers.ts +++ b/tests/integration/tools/mongodb/mongodbHelpers.ts @@ -14,24 +14,30 @@ interface MongoDBIntegrationTest { export function describeWithMongoDB( name: string, fn: (integration: IntegrationTest & MongoDBIntegrationTest & { connectMcpClient: () => Promise }) => void, - getUserConfig: (mdbIntegration: MongoDBIntegrationTest) => UserConfig = () => config + getUserConfig: (mdbIntegration: MongoDBIntegrationTest) => UserConfig = () => config, + describeFn = describe ) { - describe(name, () => { + describeFn(name, () => { const mdbIntegration = setupMongoDBIntegrationTest(); - const integration = setupIntegrationTest(() => getUserConfig(mdbIntegration)); + const integration = setupIntegrationTest(() => ({ + ...getUserConfig(mdbIntegration), + connectionString: mdbIntegration.connectionString(), + })); - afterEach(() => { - integration.mcpServer().userConfig.connectionString = undefined; + beforeEach(() => { + integration.mcpServer().userConfig.connectionString = mdbIntegration.connectionString(); }); fn({ ...integration, ...mdbIntegration, connectMcpClient: async () => { - await integration.mcpClient().callTool({ - name: "connect", - arguments: { connectionString: mdbIntegration.connectionString() }, - }); + // TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/141 - reenable when + // the connect tool is reenabled + // await integration.mcpClient().callTool({ + // name: "connect", + // arguments: { connectionString: mdbIntegration.connectionString() }, + // }); }, }); }); @@ -132,7 +138,8 @@ export function validateAutoConnectBehavior( }, beforeEachImpl?: () => Promise ): void { - describe("when not connected", () => { + // TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/141 - reenable when the connect tool is reenabled + describe.skip("when not connected", () => { if (beforeEachImpl) { beforeEach(() => beforeEachImpl()); }