diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7304823f..7cd91fbb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -55,4 +55,4 @@ jobs: rm -rf node_modules npm pkg set scripts.prepare="exit 0" npm install --omit=dev - - run: npx -y @modelcontextprotocol/inspector --cli --method tools/list -- node dist/index.js + - run: npx -y @modelcontextprotocol/inspector --cli --method tools/list -- node dist/esm/index.js diff --git a/.github/workflows/prepare_release.yaml b/.github/workflows/prepare_release.yaml index 5300316a..13739e1c 100644 --- a/.github/workflows/prepare_release.yaml +++ b/.github/workflows/prepare_release.yaml @@ -32,6 +32,7 @@ jobs: id: bump-version run: | echo "NEW_VERSION=$(npm version ${{ inputs.version }} --no-git-tag-version)" >> $GITHUB_OUTPUT + npm run build:update-package-version - name: Create release PR uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # 7.0.8 id: create-pr diff --git a/.smithery/Dockerfile b/.smithery/Dockerfile index e2b469da..c518fbd3 100644 --- a/.smithery/Dockerfile +++ b/.smithery/Dockerfile @@ -27,4 +27,4 @@ RUN npm ci --production --ignore-scripts # Expose no ports (stdio only) # Default command -CMD ["node", "dist/index.js"] +CMD ["node", "dist/esm/index.js"] diff --git a/.smithery/smithery.yaml b/.smithery/smithery.yaml index 13952c7b..6e7f7eb7 100644 --- a/.smithery/smithery.yaml +++ b/.smithery/smithery.yaml @@ -40,7 +40,7 @@ startCommand: # A function that produces the CLI command to start the MCP on stdio. |- (config) => { - const args = ['dist/index.js']; + const args = ['dist/esm/index.js']; if (config) { if (config.atlasClientId) { args.push('--apiClientId'); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b35e5f4b..34fe72f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ This project implements a Model Context Protocol (MCP) server for MongoDB and Mo { "mcpServers": { "MongoDB": { - "command": "/path/to/mongodb-mcp-server/dist/index.js" + "command": "/path/to/mongodb-mcp-server/dist/esm/index.js" } } } @@ -104,7 +104,7 @@ npm run inspect This is equivalent to: ```shell -npx @modelcontextprotocol/inspector -- node dist/index.js +npx @modelcontextprotocol/inspector -- node dist/esm/index.js ``` ## Pull Request Guidelines diff --git a/package-lock.json b/package-lock.json index cdf955ad..d6e7d7dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "zod": "^3.25.76" }, "bin": { - "mongodb-mcp-server": "dist/index.js" + "mongodb-mcp-server": "dist/esm/index.js" }, "devDependencies": { "@ai-sdk/azure": "^1.3.24", diff --git a/package.json b/package.json index 9f8d0847..9c45131e 100644 --- a/package.json +++ b/package.json @@ -2,28 +2,47 @@ "name": "mongodb-mcp-server", "description": "MongoDB Model Context Protocol Server", "version": "0.2.0", - "main": "dist/index.js", + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/esm/lib.d.ts", + "default": "./dist/esm/lib.js" + }, + "require": { + "types": "./dist/cjs/lib.d.ts", + "default": "./dist/cjs/lib.js" + } + } + }, + "main": "./dist/cjs/lib.js", + "types": "./dist/cjs/lib.d.ts", "author": "MongoDB ", "homepage": "https://github.com/mongodb-js/mongodb-mcp-server", "repository": { "url": "https://github.com/mongodb-js/mongodb-mcp-server.git" }, "bin": { - "mongodb-mcp-server": "dist/index.js" + "mongodb-mcp-server": "dist/esm/index.js" }, "publishConfig": { "access": "public" }, - "type": "module", + "files": [ + "dist" + ], "scripts": { "start": "node dist/index.js --transport http --loggers stderr mcp", "start:stdio": "node dist/index.js --transport stdio --loggers stderr mcp", "prepare": "npm run build", "build:clean": "rm -rf dist", - "build:compile": "tsc --project tsconfig.build.json", - "build:chmod": "chmod +x dist/index.js", - "build": "npm run build:clean && npm run build:compile && npm run build:chmod", - "inspect": "npm run build && mcp-inspector -- dist/index.js", + "build:update-package-version": "tsx scripts/updatePackageVersion.ts", + "build:esm": "tsc --project tsconfig.esm.json", + "build:cjs": "tsc --project tsconfig.cjs.json", + "build:universal-package": "tsx scripts/createUniversalPackage.ts", + "build:chmod": "chmod +x dist/esm/index.js", + "build": "npm run build:clean && npm run build:esm && npm run build:cjs && npm run build:universal-package && npm run build:chmod", + "inspect": "npm run build && mcp-inspector -- dist/esm/index.js", "prettier": "prettier", "check": "npm run build && npm run check:types && npm run check:lint && npm run check:format", "check:lint": "eslint .", diff --git a/scripts/createUniversalPackage.ts b/scripts/createUniversalPackage.ts new file mode 100644 index 00000000..1f8381bc --- /dev/null +++ b/scripts/createUniversalPackage.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env tsx + +import { writeFileSync, mkdirSync } from "fs"; +import { resolve } from "path"; + +const distDir = resolve("dist"); + +/** + * Node uses the package.json to know whether files with a .js extensions + * should be interpreted as CommonJS or ESM. + */ +// ESM package.json +const esmPath = resolve(distDir, "esm", "package.json"); +mkdirSync(resolve(distDir, "esm"), { recursive: true }); +writeFileSync(esmPath, JSON.stringify({ type: "module" })); + +// CJS package.json +const cjsPath = resolve(distDir, "cjs", "package.json"); +mkdirSync(resolve(distDir, "cjs"), { recursive: true }); +writeFileSync(cjsPath, JSON.stringify({ type: "commonjs" })); + +// Create a dist/index.js file that imports the ESM index.js file +// To minimize breaking changes from pre-universal package time. +const indexPath = resolve(distDir, "index.js"); +writeFileSync(indexPath, `import "./esm/index.js";`); diff --git a/scripts/updatePackageVersion.ts b/scripts/updatePackageVersion.ts new file mode 100644 index 00000000..ddb1b315 --- /dev/null +++ b/scripts/updatePackageVersion.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +// Read package.json +const packageJsonPath = join(import.meta.dirname, "..", "package.json"); +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { + version: string; +}; + +// Define the packageInfo.ts content +const packageInfoContent = `// This file was generated by scripts/updatePackageVersion.ts - Do not edit it manually. +export const packageInfo = { + version: "${packageJson.version}", + mcpServerName: "MongoDB MCP Server", +}; +`; + +// Write to packageInfo.ts +const packageInfoPath = join(import.meta.dirname, "..", "src", "common", "packageInfo.ts"); +writeFileSync(packageInfoPath, packageInfoContent); diff --git a/src/common/packageInfo.ts b/src/common/packageInfo.ts index 6c075dc0..6fe81a48 100644 --- a/src/common/packageInfo.ts +++ b/src/common/packageInfo.ts @@ -1,6 +1,5 @@ -import packageJson from "../../package.json" with { type: "json" }; - +// This file was generated by scripts/updatePackageVersion.ts - Do not edit it manually. export const packageInfo = { - version: packageJson.version, + version: "0.2.0", mcpServerName: "MongoDB MCP Server", }; diff --git a/src/lib.ts b/src/lib.ts new file mode 100644 index 00000000..773933ff --- /dev/null +++ b/src/lib.ts @@ -0,0 +1,4 @@ +export { Server, type ServerOptions } from "./server.js"; +export { Telemetry } from "./telemetry/telemetry.js"; +export { Session, type SessionOptions } from "./common/session.js"; +export type { UserConfig, ConnectOptions } from "./common/config.js"; diff --git a/src/resources/common/config.ts b/src/resources/common/config.ts index 8d2e8089..2bd8a8aa 100644 --- a/src/resources/common/config.ts +++ b/src/resources/common/config.ts @@ -1,21 +1,28 @@ import { ReactiveResource } from "../resource.js"; import { config } from "../../common/config.js"; import type { UserConfig } from "../../common/config.js"; +import type { Server } from "../../server.js"; +import type { Telemetry } from "../../telemetry/telemetry.js"; -export class ConfigResource extends ReactiveResource( - { - name: "config", - uri: "config://config", - config: { - description: - "Server configuration, supplied by the user either as environment variables or as startup arguments", - }, - }, - { - initial: { ...config }, - events: [], +export class ConfigResource extends ReactiveResource { + constructor(server: Server, telemetry: Telemetry) { + super( + { + name: "config", + uri: "config://config", + config: { + description: + "Server configuration, supplied by the user either as environment variables or as startup arguments", + }, + }, + { + initial: { ...config }, + events: [], + }, + server, + telemetry + ); } -) { reduce(eventName: undefined, event: undefined): UserConfig { void eventName; void event; diff --git a/src/resources/common/debug.ts b/src/resources/common/debug.ts index 609b4b8e..bf6cf5f5 100644 --- a/src/resources/common/debug.ts +++ b/src/resources/common/debug.ts @@ -1,4 +1,6 @@ import { ReactiveResource } from "../resource.js"; +import type { Server } from "../../server.js"; +import type { Telemetry } from "../../telemetry/telemetry.js"; type ConnectionStateDebuggingInformation = { readonly tag: "connected" | "connecting" | "disconnected" | "errored"; @@ -8,20 +10,28 @@ type ConnectionStateDebuggingInformation = { readonly errorReason?: string; }; -export class DebugResource extends ReactiveResource( - { - name: "debug-mongodb", - uri: "debug://mongodb", - config: { - description: - "Debugging information for MongoDB connectivity issues. Tracks the last connectivity error and attempt information.", - }, - }, - { - initial: { tag: "disconnected" } as ConnectionStateDebuggingInformation, - events: ["connect", "disconnect", "close", "connection-error"], +export class DebugResource extends ReactiveResource< + ConnectionStateDebuggingInformation, + readonly ["connect", "disconnect", "close", "connection-error"] +> { + constructor(server: Server, telemetry: Telemetry) { + super( + { + name: "debug-mongodb", + uri: "debug://mongodb", + config: { + description: + "Debugging information for MongoDB connectivity issues. Tracks the last connectivity error and attempt information.", + }, + }, + { + initial: { tag: "disconnected" }, + events: ["connect", "disconnect", "close", "connection-error"], + }, + server, + telemetry + ); } -) { reduce( eventName: "connect" | "disconnect" | "close" | "connection-error", event: string | undefined diff --git a/src/resources/resource.ts b/src/resources/resource.ts index f5902a80..58ab13b8 100644 --- a/src/resources/resource.ts +++ b/src/resources/resource.ts @@ -8,76 +8,84 @@ import { LogId } from "../common/logger.js"; type PayloadOf = SessionEvents[K][0]; -type ResourceConfiguration = { name: string; uri: string; config: ResourceMetadata }; +export type ResourceConfiguration = { + name: string; + uri: string; + config: ResourceMetadata; +}; -export function ReactiveResource( - { name, uri, config: resourceConfig }: ResourceConfiguration, - { - initial, - events, - }: { - initial: Value; - events: RelevantEvents; - } -) { - type SomeEvent = RelevantEvents[number]; +export type ReactiveResourceOptions = { + initial: Value; + events: RelevantEvents; +}; - abstract class NewReactiveResource { - protected readonly session: Session; - protected readonly config: UserConfig; - protected current: Value; +export abstract class ReactiveResource { + protected readonly session: Session; + protected readonly config: UserConfig; + protected current: Value; + protected readonly name: string; + protected readonly uri: string; + protected readonly resourceConfig: ResourceMetadata; + protected readonly events: RelevantEvents; - constructor( - protected readonly server: Server, - protected readonly telemetry: Telemetry, - current?: Value - ) { - this.current = current ?? initial; - this.session = server.session; - this.config = server.userConfig; + constructor( + resourceConfiguration: ResourceConfiguration, + options: ReactiveResourceOptions, + protected readonly server: Server, + protected readonly telemetry: Telemetry, + current?: Value + ) { + this.name = resourceConfiguration.name; + this.uri = resourceConfiguration.uri; + this.resourceConfig = resourceConfiguration.config; + this.events = options.events; + this.current = current ?? options.initial; + this.session = server.session; + this.config = server.userConfig; - for (const event of events) { - this.session.on(event, (...args: SessionEvents[typeof event]) => { - this.reduceApply(event, (args as unknown[])[0] as PayloadOf); - void this.triggerUpdate(); - }); - } - } + this.setupEventListeners(); + } - public register(): void { - this.server.mcpServer.registerResource(name, uri, resourceConfig, this.resourceCallback); + private setupEventListeners(): void { + for (const event of this.events) { + this.session.on(event, (...args: SessionEvents[typeof event]) => { + this.reduceApply(event, (args as unknown[])[0] as PayloadOf); + void this.triggerUpdate(); + }); } + } - private resourceCallback: ReadResourceCallback = (uri) => ({ - contents: [ - { - text: this.toOutput(), - mimeType: "application/json", - uri: uri.href, - }, - ], - }); + public register(): void { + this.server.mcpServer.registerResource(this.name, this.uri, this.resourceConfig, this.resourceCallback); + } - private async triggerUpdate() { - try { - await this.server.mcpServer.server.sendResourceUpdated({ uri }); - this.server.mcpServer.sendResourceListChanged(); - } catch (error: unknown) { - this.session.logger.warning({ - id: LogId.resourceUpdateFailure, - context: "resource", - message: `Could not send the latest resources to the client: ${error as string}`, - }); - } - } + private resourceCallback: ReadResourceCallback = (uri) => ({ + contents: [ + { + text: this.toOutput(), + mimeType: "application/json", + uri: uri.href, + }, + ], + }); - reduceApply(eventName: SomeEvent, ...event: PayloadOf[]): void { - this.current = this.reduce(eventName, ...event); + private async triggerUpdate(): Promise { + try { + await this.server.mcpServer.server.sendResourceUpdated({ uri: this.uri }); + this.server.mcpServer.sendResourceListChanged(); + } catch (error: unknown) { + this.session.logger.warning({ + id: LogId.resourceUpdateFailure, + context: "resource", + message: `Could not send the latest resources to the client: ${error as string}`, + }); } + } - protected abstract reduce(eventName: SomeEvent, ...event: PayloadOf[]): Value; - abstract toOutput(): string; + public reduceApply(eventName: RelevantEvents[number], ...event: PayloadOf[]): void { + this.current = this.reduce(eventName, ...event); } - return NewReactiveResource; + protected abstract reduce(eventName: RelevantEvents[number], ...event: PayloadOf[]): Value; + public abstract toOutput(): string; } diff --git a/tests/integration/build.test.ts b/tests/integration/build.test.ts new file mode 100644 index 00000000..7282efe4 --- /dev/null +++ b/tests/integration/build.test.ts @@ -0,0 +1,46 @@ +import { createRequire } from "module"; +import path from "path"; +import { describe, it, expect } from "vitest"; + +// Current directory where the test file is located +const currentDir = import.meta.dirname; + +// Get project root (go up from tests/integration to project root) +const projectRoot = path.resolve(currentDir, "../.."); + +const esmPath = path.resolve(projectRoot, "dist/esm/lib.js"); +const cjsPath = path.resolve(projectRoot, "dist/cjs/lib.js"); + +describe("Build Test", () => { + it("should successfully require CommonJS module", () => { + const require = createRequire(__filename); + + const cjsModule = require(cjsPath) as Record; + + expect(cjsModule).toBeDefined(); + expect(typeof cjsModule).toBe("object"); + }); + + it("should successfully import ESM module", async () => { + const esmModule = (await import(esmPath)) as Record; + + expect(esmModule).toBeDefined(); + expect(typeof esmModule).toBe("object"); + }); + + it("should have matching exports between CommonJS and ESM modules", async () => { + // Import CommonJS module + const require = createRequire(__filename); + const cjsModule = require(cjsPath) as Record; + + // Import ESM module + const esmModule = (await import(esmPath)) as Record; + + // Compare exports + const cjsKeys = Object.keys(cjsModule).sort(); + const esmKeys = Object.keys(esmModule).sort(); + + expect(cjsKeys).toEqual(esmKeys); + expect(cjsKeys).toEqual(["Server", "Session", "Telemetry"]); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 48a9b414..b21c6771 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -15,7 +15,9 @@ "resolveJsonModule": true, "allowSyntheticDefaultImports": true, "typeRoots": ["./node_modules/@types", "./src/types"], - "noImplicitReturns": true + "noImplicitReturns": true, + "declaration": true, + "declarationMap": true }, "include": ["src/**/*.ts"] } diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 00000000..ad8b3832 --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "outDir": "./dist/cjs" + } +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 00000000..d1ba80d1 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "outDir": "./dist/esm" + } +}