diff --git a/jest.config.js b/jest.config.js index 88e80263..59baa966 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,4 +16,5 @@ export default { ], }, moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], + setupFilesAfterEnv: ["jest-extended/all"], }; diff --git a/package-lock.json b/package-lock.json index f5e0be10..eb36763b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "jest-environment-node": "^29.7.0", + "jest-extended": "^4.0.2", "mongodb-runner": "^5.8.2", "openapi-types": "^12.1.3", "openapi-typescript": "^7.6.1", @@ -10050,6 +10051,28 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-extended": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz", + "integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-diff": "^29.0.0", + "jest-get-type": "^29.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "jest": ">=27.2.5" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + } + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", diff --git a/package.json b/package.json index bdbeed63..a4f6f264 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "jest-environment-node": "^29.7.0", + "jest-extended": "^4.0.2", "mongodb-runner": "^5.8.2", "openapi-types": "^12.1.3", "openapi-typescript": "^7.6.1", @@ -55,9 +56,9 @@ "typescript-eslint": "^8.29.1" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.8.0", "@mongodb-js/devtools-connect": "^3.7.2", "@mongosh/service-provider-node-driver": "^3.6.0", - "@modelcontextprotocol/sdk": "^1.8.0", "bson": "^6.10.3", "mongodb": "^6.15.0", "mongodb-log-writer": "^2.4.1", diff --git a/src/tools/mongodb/createIndex.ts b/src/tools/mongodb/create/createIndex.ts similarity index 94% rename from src/tools/mongodb/createIndex.ts rename to src/tools/mongodb/create/createIndex.ts index 30bc17af..455dc24f 100644 --- a/src/tools/mongodb/createIndex.ts +++ b/src/tools/mongodb/create/createIndex.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { DbOperationArgs, DbOperationType, MongoDBToolBase } from "./mongodbTool.js"; -import { ToolArgs } from "../tool.js"; +import { DbOperationArgs, DbOperationType, MongoDBToolBase } from "../mongodbTool.js"; +import { ToolArgs } from "../../tool.js"; import { IndexDirection } from "mongodb"; export class CreateIndexTool extends MongoDBToolBase { diff --git a/src/tools/mongodb/connect.ts b/src/tools/mongodb/metadata/connect.ts similarity index 91% rename from src/tools/mongodb/connect.ts rename to src/tools/mongodb/metadata/connect.ts index 66df62e3..408b31f8 100644 --- a/src/tools/mongodb/connect.ts +++ b/src/tools/mongodb/metadata/connect.ts @@ -1,9 +1,9 @@ import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { DbOperationType, MongoDBToolBase } from "./mongodbTool.js"; -import { ToolArgs } from "../tool.js"; -import { ErrorCodes, MongoDBError } from "../../errors.js"; -import config from "../../config.js"; +import { DbOperationType, MongoDBToolBase } from "../mongodbTool.js"; +import { ToolArgs } from "../../tool.js"; +import { ErrorCodes, MongoDBError } from "../../../errors.js"; +import config from "../../../config.js"; export class ConnectTool extends MongoDBToolBase { protected name = "connect"; diff --git a/src/tools/mongodb/collectionIndexes.ts b/src/tools/mongodb/read/collectionIndexes.ts similarity index 93% rename from src/tools/mongodb/collectionIndexes.ts rename to src/tools/mongodb/read/collectionIndexes.ts index 4d8cae90..e3d4a0e9 100644 --- a/src/tools/mongodb/collectionIndexes.ts +++ b/src/tools/mongodb/read/collectionIndexes.ts @@ -1,6 +1,6 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { DbOperationArgs, DbOperationType, MongoDBToolBase } from "./mongodbTool.js"; -import { ToolArgs } from "../tool.js"; +import { DbOperationArgs, DbOperationType, MongoDBToolBase } from "../mongodbTool.js"; +import { ToolArgs } from "../../tool.js"; export class CollectionIndexesTool extends MongoDBToolBase { protected name = "collection-indexes"; diff --git a/src/tools/mongodb/tools.ts b/src/tools/mongodb/tools.ts index ac22e095..d6627e74 100644 --- a/src/tools/mongodb/tools.ts +++ b/src/tools/mongodb/tools.ts @@ -1,8 +1,8 @@ -import { ConnectTool } from "./connect.js"; +import { ConnectTool } from "./metadata/connect.js"; import { ListCollectionsTool } from "./metadata/listCollections.js"; -import { CollectionIndexesTool } from "./collectionIndexes.js"; +import { CollectionIndexesTool } from "./read/collectionIndexes.js"; import { ListDatabasesTool } from "./metadata/listDatabases.js"; -import { CreateIndexTool } from "./createIndex.js"; +import { CreateIndexTool } from "./create/createIndex.js"; import { CollectionSchemaTool } from "./metadata/collectionSchema.js"; import { InsertOneTool } from "./create/insertOne.js"; import { FindTool } from "./read/find.js"; diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 207492da..11963fc2 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -7,76 +7,124 @@ import fs from "fs/promises"; import { Session } from "../../src/session.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -export async function setupIntegrationTest(): Promise<{ - client: Client; - server: Server; - teardown: () => Promise; -}> { - const clientTransport = new InMemoryTransport(); - const serverTransport = new InMemoryTransport(); - - await serverTransport.start(); - await clientTransport.start(); - - clientTransport.output.pipeTo(serverTransport.input); - serverTransport.output.pipeTo(clientTransport.input); - - const client = new Client( - { - name: "test-client", - version: "1.2.3", - }, - { - capabilities: {}, - } - ); - - const server = new Server({ - mcpServer: new McpServer({ - name: "test-server", - version: "1.2.3", - }), - session: new Session(), +export function jestTestMCPClient(): () => Client { + let client: Client | undefined; + let server: Server | undefined; + + beforeEach(async () => { + const clientTransport = new InMemoryTransport(); + const serverTransport = new InMemoryTransport(); + + await serverTransport.start(); + await clientTransport.start(); + + clientTransport.output.pipeTo(serverTransport.input); + serverTransport.output.pipeTo(clientTransport.input); + + client = new Client( + { + name: "test-client", + version: "1.2.3", + }, + { + capabilities: {}, + } + ); + + server = new Server({ + mcpServer: new McpServer({ + name: "test-server", + version: "1.2.3", + }), + session: new Session(), + }); + await server.connect(serverTransport); + await client.connect(clientTransport); }); - await server.connect(serverTransport); - await client.connect(clientTransport); - - return { - client, - server, - teardown: async () => { - await client.close(); - await server.close(); - }, + + afterEach(async () => { + await client?.close(); + client = undefined; + + await server?.close(); + server = undefined; + }); + + return () => { + if (!client) { + throw new Error("beforeEach() hook not ran yet"); + } + + return client; }; } -export async function runMongoDB(): Promise { - const tmpDir = path.join(__dirname, "..", "tmp"); - await fs.mkdir(tmpDir, { recursive: true }); +export function jestTestCluster(): () => runner.MongoCluster { + let cluster: runner.MongoCluster | undefined; - try { - const cluster = await MongoCluster.start({ - tmpDir: path.join(tmpDir, "mongodb-runner", "dbs"), - logDir: path.join(tmpDir, "mongodb-runner", "logs"), - topology: "standalone", - }); + function runMongodb() {} + + beforeAll(async function () { + // Downloading Windows executables in CI takes a long time because + // they include debug symbols... + const tmpDir = path.join(__dirname, "..", "tmp"); + await fs.mkdir(tmpDir, { recursive: true }); + + // On Windows, we may have a situation where mongod.exe is not fully released by the OS + // before we attempt to run it again, so we add a retry. + const dbsDir = path.join(tmpDir, "mongodb-runner", `dbs`); + for (let i = 0; i < 10; i++) { + try { + cluster = await MongoCluster.start({ + tmpDir: dbsDir, + logDir: path.join(tmpDir, "mongodb-runner", "logs"), + topology: "standalone", + }); + + return; + } catch (err) { + console.error(`Failed to start cluster in ${dbsDir}, attempt ${i}: ${err}`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + }, 120_000); + + afterAll(async function () { + await cluster?.close(); + cluster = undefined; + }); + + return () => { + if (!cluster) { + throw new Error("beforeAll() hook not ran yet"); + } return cluster; - } catch (err) { - throw err; - } + }; +} + +export function getResponseContent(content: unknown): string { + return getResponseElements(content) + .map((item) => item.text) + .join("\n"); } -export function validateToolResponse(content: unknown): string { +export function getResponseElements(content: unknown): { type: string; text: string }[] { expect(Array.isArray(content)).toBe(true); - const response = content as Array<{ type: string; text: string }>; + const response = content as { type: string; text: string }[]; for (const item of response) { expect(item).toHaveProperty("type"); expect(item).toHaveProperty("text"); expect(item.type).toBe("text"); } - return response.map((item) => item.text).join("\n"); + return response; +} + +export async function connect(client: Client, cluster: runner.MongoCluster): Promise { + await client.callTool({ + name: "connect", + arguments: { connectionStringOrClusterName: cluster.connectionString }, + }); } diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index 1d804943..572c6711 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,39 +1,29 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { setupIntegrationTest } from "./helpers.js"; +import { jestTestMCPClient } from "./helpers.js"; describe("Server integration test", () => { - let client: Client; - let teardown: () => Promise; - - beforeEach(async () => { - ({ client, teardown } = await setupIntegrationTest()); - }); - - afterEach(async () => { - await teardown(); - }); + const client = jestTestMCPClient(); describe("list capabilities", () => { it("should return positive number of tools", async () => { - const tools = await client.listTools(); + const tools = await client().listTools(); expect(tools).toBeDefined(); expect(tools.tools.length).toBeGreaterThan(0); }); it("should return no resources", async () => { - await expect(() => client.listResources()).rejects.toMatchObject({ + await expect(() => client().listResources()).rejects.toMatchObject({ message: "MCP error -32601: Method not found", }); }); it("should return no prompts", async () => { - await expect(() => client.listPrompts()).rejects.toMatchObject({ + await expect(() => client().listPrompts()).rejects.toMatchObject({ message: "MCP error -32601: Method not found", }); }); it("should return capabilities", async () => { - const capabilities = client.getServerCapabilities(); + const capabilities = client().getServerCapabilities(); expect(capabilities).toBeDefined(); expect(capabilities?.completions).toBeUndefined(); expect(capabilities?.experimental).toBeUndefined(); diff --git a/tests/integration/tools/mongodb/connect.test.ts b/tests/integration/tools/mongodb/connect.test.ts deleted file mode 100644 index 7e5dacec..00000000 --- a/tests/integration/tools/mongodb/connect.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { runMongoDB, setupIntegrationTest, validateToolResponse } from "../../helpers.js"; -import runner from "mongodb-runner"; - -import config from "../../../../src/config.js"; - -describe("Connect tool", () => { - let client: Client; - let serverClientTeardown: () => Promise; - - let cluster: runner.MongoCluster; - - beforeAll(async () => { - cluster = await runMongoDB(); - }, 60_000); - - beforeEach(async () => { - ({ client, teardown: serverClientTeardown } = await setupIntegrationTest()); - }); - - afterEach(async () => { - await serverClientTeardown?.(); - }); - - afterAll(async () => { - await cluster.close(); - }); - - describe("with default config", () => { - it("should have correct metadata", async () => { - const tools = await client.listTools(); - const connectTool = tools.tools.find((tool) => tool.name === "connect"); - expect(connectTool).toBeDefined(); - expect(connectTool!.description).toBe("Connect to a MongoDB instance"); - expect(connectTool!.inputSchema.type).toBe("object"); - expect(connectTool!.inputSchema.properties).toBeDefined(); - - const propertyNames = Object.keys(connectTool!.inputSchema.properties!); - expect(propertyNames).toHaveLength(1); - expect(propertyNames[0]).toBe("connectionStringOrClusterName"); - - const connectionStringOrClusterNameProp = connectTool!.inputSchema.properties![propertyNames[0]] as { - type: string; - description: string; - }; - expect(connectionStringOrClusterNameProp.type).toBe("string"); - expect(connectionStringOrClusterNameProp.description).toContain("MongoDB connection string"); - expect(connectionStringOrClusterNameProp.description).toContain("cluster name"); - }); - - describe("without connection string", () => { - it("prompts for connection string", async () => { - const response = await client.callTool({ name: "connect", arguments: {} }); - const content = validateToolResponse(response.content); - expect(content).toContain("No connection details provided"); - expect(content).toContain("mongodb://localhost:27017"); - }); - }); - - describe("with connection string", () => { - it("connects to the database", async () => { - const response = await client.callTool({ - name: "connect", - arguments: { connectionStringOrClusterName: cluster.connectionString }, - }); - const content = validateToolResponse(response.content); - expect(content).toContain("Successfully connected"); - expect(content).toContain(cluster.connectionString); - }); - }); - - describe("with invalid connection string", () => { - it("returns error message", async () => { - const response = await client.callTool({ - name: "connect", - arguments: { connectionStringOrClusterName: "mongodb://localhost:12345" }, - }); - const content = validateToolResponse(response.content); - expect(content).toContain("Error running connect"); - }); - }); - }); - - describe("with connection string in config", () => { - beforeEach(async () => { - config.connectionString = cluster.connectionString; - }); - - it("uses the connection string from config", async () => { - const response = await client.callTool({ name: "connect", arguments: {} }); - const content = validateToolResponse(response.content); - expect(content).toContain("Successfully connected"); - expect(content).toContain(cluster.connectionString); - }); - - it("prefers connection string from arguments", async () => { - const newConnectionString = `${cluster.connectionString}?appName=foo-bar`; - const response = await client.callTool({ - name: "connect", - arguments: { connectionStringOrClusterName: newConnectionString }, - }); - const content = validateToolResponse(response.content); - expect(content).toContain("Successfully connected"); - expect(content).toContain(newConnectionString); - }); - }); -}); diff --git a/tests/integration/tools/mongodb/metadata/connect.test.ts b/tests/integration/tools/mongodb/metadata/connect.test.ts new file mode 100644 index 00000000..2030b9dd --- /dev/null +++ b/tests/integration/tools/mongodb/metadata/connect.test.ts @@ -0,0 +1,87 @@ +import { getResponseContent, jestTestMCPClient, jestTestCluster } from "../../../helpers.js"; + +import config from "../../../../../src/config.js"; + +describe("Connect tool", () => { + const client = jestTestMCPClient(); + const cluster = jestTestCluster(); + + it("should have correct metadata", async () => { + const { tools } = await client().listTools(); + const connectTool = tools.find((tool) => tool.name === "connect")!; + expect(connectTool).toBeDefined(); + expect(connectTool.description).toBe("Connect to a MongoDB instance"); + expect(connectTool.inputSchema.type).toBe("object"); + expect(connectTool.inputSchema.properties).toBeDefined(); + + const propertyNames = Object.keys(connectTool.inputSchema.properties!); + expect(propertyNames).toHaveLength(1); + expect(propertyNames[0]).toBe("connectionStringOrClusterName"); + + const connectionStringOrClusterNameProp = connectTool.inputSchema.properties![propertyNames[0]] as { + type: string; + description: string; + }; + expect(connectionStringOrClusterNameProp.type).toBe("string"); + expect(connectionStringOrClusterNameProp.description).toContain("MongoDB connection string"); + expect(connectionStringOrClusterNameProp.description).toContain("cluster name"); + }); + + describe("with default config", () => { + describe("without connection string", () => { + it("prompts for connection string", async () => { + const response = await client().callTool({ name: "connect", arguments: {} }); + const content = getResponseContent(response.content); + expect(content).toContain("No connection details provided"); + expect(content).toContain("mongodb://localhost:27017"); + }); + }); + + describe("with connection string", () => { + it("connects to the database", async () => { + const response = await client().callTool({ + name: "connect", + arguments: { connectionStringOrClusterName: cluster().connectionString }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("Successfully connected"); + expect(content).toContain(cluster().connectionString); + }); + }); + + describe("with invalid connection string", () => { + it("returns error message", async () => { + const response = await client().callTool({ + name: "connect", + arguments: { connectionStringOrClusterName: "mongodb://localhost:12345" }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("Error running connect"); + }); + }); + }); + + describe("with connection string in config", () => { + beforeEach(async () => { + config.connectionString = cluster().connectionString; + }); + + it("uses the connection string from config", async () => { + const response = await client().callTool({ name: "connect", arguments: {} }); + const content = getResponseContent(response.content); + expect(content).toContain("Successfully connected"); + expect(content).toContain(cluster().connectionString); + }); + + it("prefers connection string from arguments", async () => { + const newConnectionString = `${cluster().connectionString}?appName=foo-bar`; + const response = await client().callTool({ + name: "connect", + arguments: { connectionStringOrClusterName: newConnectionString }, + }); + const content = getResponseContent(response.content); + expect(content).toContain("Successfully connected"); + expect(content).toContain(newConnectionString); + }); + }); +}); diff --git a/tests/integration/tools/mongodb/metadata/listDatabases.test.ts b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts new file mode 100644 index 00000000..153eca13 --- /dev/null +++ b/tests/integration/tools/mongodb/metadata/listDatabases.test.ts @@ -0,0 +1,54 @@ +import { getResponseElements, connect, jestTestCluster, jestTestMCPClient } from "../../../helpers.js"; +import { MongoClient } from "mongodb"; +import { toIncludeSameMembers } from "jest-extended"; + +describe("listDatabases tool", () => { + const client = jestTestMCPClient(); + const cluster = jestTestCluster(); + + it("should have correct metadata", async () => { + const { tools } = await client().listTools(); + const listDatabases = tools.find((tool) => tool.name === "list-databases")!; + expect(listDatabases).toBeDefined(); + expect(listDatabases.description).toBe("List all databases for a MongoDB connection"); + expect(listDatabases.inputSchema.type).toBe("object"); + expect(listDatabases.inputSchema.properties).toBeDefined(); + + const propertyNames = Object.keys(listDatabases.inputSchema.properties!); + expect(propertyNames).toHaveLength(0); + }); + + describe("with no preexisting databases", () => { + it("returns only the system databases", async () => { + await connect(client(), cluster()); + const response = await client().callTool({ name: "list-databases", arguments: {} }); + const dbNames = getDbNames(response.content); + + expect(dbNames).toIncludeSameMembers(["admin", "config", "local"]); + }); + }); + + describe("with preexisting databases", () => { + it("returns their names and sizes", async () => { + const mongoClient = new MongoClient(cluster().connectionString); + await mongoClient.db("foo").collection("bar").insertOne({ test: "test" }); + await mongoClient.db("baz").collection("qux").insertOne({ test: "test" }); + await mongoClient.close(); + + await connect(client(), cluster()); + + const response = await client().callTool({ name: "list-databases", arguments: {} }); + const dbNames = getDbNames(response.content); + expect(dbNames).toIncludeSameMembers(["admin", "config", "local", "foo", "baz"]); + }); + }); +}); + +function getDbNames(content: unknown): (string | null)[] { + const responseItems = getResponseElements(content); + + return responseItems.map((item) => { + const match = item.text.match(/Name: (.*), Size: \d+ bytes/); + return match ? match[1] : null; + }); +}