diff --git a/package-lock.json b/package-lock.json index 3373cb35e..bc8fc2313 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1639,7 +1639,6 @@ "resolved": "https://registry.npmjs.org/@mongodb-js/oidc-plugin/-/oidc-plugin-2.0.4.tgz", "integrity": "sha512-mB7kEK80+DD2QrB01GmtFKm02ItJpIO9j7OARMHI4RL+rVQD3Ey9giluf3xQtuSdcmg7a+bf5fkJgQZCWMvRPg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "express": "^5.1.0", "node-fetch": "^3.3.2", @@ -1893,7 +1892,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -4035,7 +4033,6 @@ "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -5001,7 +4998,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5064,7 +5060,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5781,7 +5776,6 @@ "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=16.20.1" } @@ -7288,7 +7282,6 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7350,7 +7343,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7664,7 +7656,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -9941,7 +9932,6 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -10004,7 +9994,6 @@ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^6.10.4", @@ -10154,7 +10143,6 @@ "resolved": "https://registry.npmjs.org/mongodb-log-writer/-/mongodb-log-writer-2.4.2.tgz", "integrity": "sha512-jXKSNG/z3sBgD42p2puOoBHKcxKHJhiIVfvGhSlwNezJIr7aL74kpKowQ3kG8Oq+nljhjfDNru8Meeq24Em3lg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "heap-js": "^2.3.0" }, @@ -11230,7 +11218,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11515,7 +11502,6 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11529,7 +11515,6 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -13135,7 +13120,6 @@ "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", @@ -13748,7 +13732,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13937,7 +13920,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -14091,7 +14073,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14364,7 +14345,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14481,7 +14461,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14524,7 +14503,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -15132,7 +15110,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 07d65e89b..ccf720534 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,16 @@ "types": "./dist/cjs/lib.d.ts", "default": "./dist/cjs/lib.js" } + }, + "./tools": { + "import": { + "types": "./dist/esm/tools/index.d.ts", + "default": "./dist/esm/tools/index.js" + }, + "require": { + "types": "./dist/cjs/tools/index.d.ts", + "default": "./dist/cjs/tools/index.js" + } } }, "main": "./dist/cjs/lib.js", diff --git a/src/lib.ts b/src/lib.ts index a9d5cfed6..2ae4b1bb5 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -3,6 +3,8 @@ export { Session, type SessionOptions } from "./common/session.js"; export { type UserConfig } from "./common/config.js"; export { LoggerBase, type LogPayload, type LoggerType, type LogLevel } from "./common/logger.js"; export { StreamableHttpRunner } from "./transports/streamableHttp.js"; +export { StdioRunner } from "./transports/stdio.js"; +export { TransportRunnerBase, type TransportRunnerConfig } from "./transports/base.js"; export { ConnectionManager, type AnyConnectionState, @@ -21,3 +23,4 @@ export { ErrorCodes } from "./common/errors.js"; export { Telemetry } from "./telemetry/telemetry.js"; export { Keychain, registerGlobalSecretToRedact } from "./common/keychain.js"; export type { Secret } from "./common/keychain.js"; +export { Elicitation } from "./elicitation.js"; diff --git a/src/server.ts b/src/server.ts index 8c1643588..4e7e6d976 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Session } from "./common/session.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { AtlasTools } from "./tools/atlas/tools.js"; -import { AtlasLocalTools } from "./tools/atlasLocal/tools.js"; -import { MongoDbTools } from "./tools/mongodb/tools.js"; import { Resources } from "./resources/resources.js"; import type { LogLevel } from "./common/logger.js"; import { LogId, McpLogger } from "./common/logger.js"; @@ -24,6 +21,7 @@ import { validateConnectionString } from "./helpers/connectionOptions.js"; import { packageInfo } from "./common/packageInfo.js"; import { type ConnectionErrorHandler } from "./common/connectionErrorHandler.js"; import type { Elicitation } from "./elicitation.js"; +import { AllTools } from "./tools/index.js"; export interface ServerOptions { session: Session; @@ -32,7 +30,28 @@ export interface ServerOptions { telemetry: Telemetry; elicitation: Elicitation; connectionErrorHandler: ConnectionErrorHandler; - toolConstructors?: (new (params: ToolConstructorParams) => ToolBase)[]; + /** + * Custom tool constructors to register with the server. + * This will override any default tools. You can use both existing and custom tools by using the `mongodb-mcp-server/tools` export. + * + * ```ts + * import { AllTools, ToolBase } from "mongodb-mcp-server/tools"; + * class CustomTool extends ToolBase { + * name = "custom_tool"; + * // ... + * } + * const server = new Server({ + * session: mySession, + * userConfig: myUserConfig, + * mcpServer: myMcpServer, + * telemetry: myTelemetry, + * elicitation: myElicitation, + * connectionErrorHandler: myConnectionErrorHandler, + * tools: [...AllTools, CustomTool], + * }); + * ``` + */ + tools?: (new (params: ToolConstructorParams) => ToolBase)[]; } export class Server { @@ -61,7 +80,7 @@ export class Server { telemetry, connectionErrorHandler, elicitation, - toolConstructors, + tools, }: ServerOptions) { this.startTime = Date.now(); this.session = session; @@ -70,7 +89,7 @@ export class Server { this.userConfig = userConfig; this.elicitation = elicitation; this.connectionErrorHandler = connectionErrorHandler; - this.toolConstructors = toolConstructors ?? [...AtlasTools, ...MongoDbTools, ...AtlasLocalTools]; + this.toolConstructors = tools ?? AllTools; } async connect(transport: Transport): Promise { diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 000000000..ded820067 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,16 @@ +import { AtlasTools } from "./atlas/tools.js"; +import { AtlasLocalTools } from "./atlasLocal/tools.js"; +import { MongoDbTools } from "./mongodb/tools.js"; + +const AllTools = [...MongoDbTools, ...AtlasTools, ...AtlasLocalTools]; + +export { AllTools, MongoDbTools, AtlasTools, AtlasLocalTools }; + +export { + ToolBase, + type ToolConstructorParams, + type ToolCategory, + type OperationType, + type ToolArgs, + type ToolExecutionContext, +} from "./tool.js"; diff --git a/src/transports/base.ts b/src/transports/base.ts index 5e37c4cce..77d67c97f 100644 --- a/src/transports/base.ts +++ b/src/transports/base.ts @@ -20,6 +20,7 @@ import type { AtlasLocalClientFactoryFn } from "../common/atlasLocal.js"; import { defaultCreateAtlasLocalClient } from "../common/atlasLocal.js"; import type { Client } from "@mongodb-js/atlas-local"; import { VectorSearchEmbeddingsManager } from "../common/search/vectorSearchEmbeddingsManager.js"; +import type { ToolBase, ToolConstructorParams } from "../tools/tool.js"; export type TransportRunnerConfig = { userConfig: UserConfig; @@ -28,6 +29,7 @@ export type TransportRunnerConfig = { createAtlasLocalClient?: AtlasLocalClientFactoryFn; additionalLoggers?: LoggerBase[]; telemetryProperties?: Partial; + tools?: (new (params: ToolConstructorParams) => ToolBase)[]; }; export abstract class TransportRunnerBase { @@ -38,6 +40,7 @@ export abstract class TransportRunnerBase { private readonly connectionErrorHandler: ConnectionErrorHandler; private readonly atlasLocalClient: Promise; private readonly telemetryProperties: Partial; + private readonly tools?: (new (params: ToolConstructorParams) => ToolBase)[]; protected constructor({ userConfig, @@ -46,12 +49,14 @@ export abstract class TransportRunnerBase { createAtlasLocalClient = defaultCreateAtlasLocalClient, additionalLoggers = [], telemetryProperties = {}, + tools, }: TransportRunnerConfig) { this.userConfig = userConfig; this.createConnectionManager = createConnectionManager; this.connectionErrorHandler = connectionErrorHandler; this.atlasLocalClient = createAtlasLocalClient(); this.telemetryProperties = telemetryProperties; + this.tools = tools; const loggers: LoggerBase[] = [...additionalLoggers]; if (this.userConfig.loggers.includes("stderr")) { loggers.push(new ConsoleLogger(Keychain.root)); @@ -114,6 +119,7 @@ export abstract class TransportRunnerBase { userConfig: this.userConfig, connectionErrorHandler: this.connectionErrorHandler, elicitation, + tools: this.tools, }); // We need to create the MCP logger after the server is constructed diff --git a/tests/integration/build.test.ts b/tests/integration/build.test.ts index 064af001a..e647956a3 100644 --- a/tests/integration/build.test.ts +++ b/tests/integration/build.test.ts @@ -11,6 +11,9 @@ const projectRoot = path.resolve(currentDir, "../.."); const esmPath = path.resolve(projectRoot, "dist/esm/lib.js"); const cjsPath = path.resolve(projectRoot, "dist/cjs/lib.js"); +const esmToolsPath = path.resolve(projectRoot, "dist/esm/tools/index.js"); +const cjsToolsPath = path.resolve(projectRoot, "dist/cjs/tools/index.js"); + describe("Build Test", () => { it("should successfully require CommonJS module", () => { const require = createRequire(__filename); @@ -49,7 +52,24 @@ describe("Build Test", () => { "Session", "StreamableHttpRunner", "Telemetry", + "Elicitation", ]) ); }); + + it("should have matching exports between CommonJS and ESM tools modules", async () => { + // Import CommonJS module + const require = createRequire(__filename); + const cjsModule = require(cjsToolsPath) as Record; + + // Import ESM module + const esmModule = (await import(esmToolsPath)) as Record; + + // Compare exports + const cjsKeys = Object.keys(cjsModule).sort(); + const esmKeys = Object.keys(esmModule).sort(); + + expect(cjsKeys).toEqual(esmKeys); + expect(cjsKeys).toEqual(expect.arrayContaining(["MongoDbTools", "AtlasTools", "AtlasLocalTools", "AllTools"])); + }); }); diff --git a/tests/integration/customTools.test.ts b/tests/integration/customTools.test.ts new file mode 100644 index 000000000..ac99cdae3 --- /dev/null +++ b/tests/integration/customTools.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { ToolBase, type ToolArgs } from "../../src/tools/index.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { TelemetryToolMetadata } from "../../src/telemetry/types.js"; +import { defaultTestConfig, driverOptions, setupIntegrationTest } from "./helpers.js"; + +describe("Custom Tools", () => { + const { mcpClient, mcpServer } = setupIntegrationTest( + () => ({ ...defaultTestConfig }), + () => driverOptions, + { + serverOptions: { + tools: [CustomGreetingTool, CustomCalculatorTool], + }, + } + ); + + it("should register custom tools instead of default tools", async () => { + // Check that custom tools are registered + const tools = await mcpClient().listTools(); + const customGreetingTool = tools.tools.find((t) => t.name === "custom_greeting"); + const customCalculatorTool = tools.tools.find((t) => t.name === "custom_calculator"); + + expect(customGreetingTool).toBeDefined(); + expect(customCalculatorTool).toBeDefined(); + + // Check that default tools are NOT registered since we only provided custom tools + const defaultTool = tools.tools.find((t) => t.name === "list-databases"); + expect(defaultTool).toBeUndefined(); + }); + + it("should execute custom tools", async () => { + const result = await mcpClient().callTool({ + name: "custom_greeting", + arguments: { name: "World" }, + }); + + expect(result.content).toEqual([ + { + type: "text", + text: "Hello, World! This is a custom tool.", + }, + ]); + + const result2 = await mcpClient().callTool({ + name: "custom_calculator", + arguments: { a: 5, b: 3 }, + }); + + expect(result2.content).toEqual([ + { + type: "text", + text: "Result: 8", + }, + ]); + + const result3 = await mcpClient().callTool({ + name: "custom_calculator", + arguments: { a: 4, b: 7 }, + }); + + expect(result3.content).toEqual([ + { + type: "text", + text: "Result: 11", + }, + ]); + }); + + it("should respect tool categories and operation types from custom tools", () => { + const customGreetingTool = mcpServer().tools.find((t) => t.name === "custom_greeting"); + expect(customGreetingTool?.category).toBe("mongodb"); + expect(customGreetingTool?.operationType).toBe("read"); + + const customCalculatorTool = mcpServer().tools.find((t) => t.name === "custom_calculator"); + expect(customCalculatorTool?.category).toBe("mongodb"); + expect(customCalculatorTool?.operationType).toBe("read"); + }); +}); + +/** + * Example custom tool that can be provided by library consumers + */ +class CustomGreetingTool extends ToolBase { + name = "custom_greeting"; + category = "mongodb" as const; + operationType = "read" as const; + protected description = "A custom tool that greets the user"; + protected argsShape = { + name: z.string().describe("The name to greet"), + }; + + protected execute({ name }: ToolArgs): Promise { + return Promise.resolve({ + content: [ + { + type: "text", + text: `Hello, ${name}! This is a custom tool.`, + }, + ], + }); + } + + protected resolveTelemetryMetadata(): TelemetryToolMetadata { + return {}; + } +} + +/** + * Another example custom tool that performs a calculation + */ +class CustomCalculatorTool extends ToolBase { + name = "custom_calculator"; + category = "mongodb" as const; + operationType = "read" as const; + protected description = "A custom tool that performs calculations"; + protected argsShape = { + a: z.number().describe("First number"), + b: z.number().describe("Second number"), + }; + + protected execute({ a, b }: ToolArgs): Promise { + return Promise.resolve({ + content: [ + { + type: "text", + text: `Result: ${a + b}`, + }, + ], + }); + } + + protected resolveTelemetryMetadata(): TelemetryToolMetadata { + return {}; + } +} diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 78560f52b..7796dda69 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -1,7 +1,7 @@ import { CompositeLogger } from "../../src/common/logger.js"; import { ExportsManager } from "../../src/common/exportsManager.js"; import { Session } from "../../src/common/session.js"; -import { Server } from "../../src/server.js"; +import { Server, type ServerOptions } from "../../src/server.js"; import { Telemetry } from "../../src/telemetry/telemetry.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; @@ -67,9 +67,11 @@ export function setupIntegrationTest( { elicitInput, getClientCapabilities, + serverOptions, }: { elicitInput?: ReturnType; getClientCapabilities?: () => MockClientCapabilities; + serverOptions?: Partial; } = {} ): IntegrationTest { let mcpClient: Client | undefined; @@ -147,6 +149,7 @@ export function setupIntegrationTest( mcpServer: mcpServerInstance, elicitation, connectionErrorHandler, + ...serverOptions, }); await mcpServer.connect(serverTransport); diff --git a/tests/integration/tools/mongodb/mongodbTool.test.ts b/tests/integration/tools/mongodb/mongodbTool.test.ts index cb324d392..df28d4835 100644 --- a/tests/integration/tools/mongodb/mongodbTool.test.ts +++ b/tests/integration/tools/mongodb/mongodbTool.test.ts @@ -143,7 +143,7 @@ describe("MongoDBTool implementations", () => { mcpServer: internalMcpServer, connectionErrorHandler: errorHandler, elicitation, - toolConstructors, + tools: toolConstructors, }); await mcpServer.connect(serverTransport);