diff --git a/.github/workflows/code-health.yml b/.github/workflows/code-health.yml index 4e9e729d5..1d0aa7bde 100644 --- a/.github/workflows/code-health.yml +++ b/.github/workflows/code-health.yml @@ -49,6 +49,9 @@ jobs: SKIP_ATLAS_LOCAL_TESTS: "true" MDB_MONGOT_PASSWORD: ${{ secrets.TEST_MDB_MONGOT_PASSWORD }} MDB_VOYAGE_API_KEY: ${{ secrets.TEST_MDB_MCP_VOYAGE_API_KEY }} + REMOTE_MCP_E2E_CLIENT_ID: ${{ secrets.TEST_REMOTE_MCP_CLIENT_ID }} + REMOTE_MCP_E2E_CLIENT_SECRET: ${{ secrets.TEST_REMOTE_MCP_CLIENT_SECRET }} + REMOTE_MCP_E2E_BASE_URL: ${{ vars.TEST_REMOTE_MCP_BASE_URL }} - name: Upload test results if: always() && matrix.os == 'ubuntu-latest' && matrix.node-version == '22' uses: actions/upload-artifact@v7.0.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c63f72b9..41662f9a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,24 @@ pnpm test path/to/test/file.test.ts pnpm test path/to/directory ``` +### Testing the `mongodb-mcp-remote` wrapper + +Tests for the wrapper package live in `packages/mongodb-mcp-remote/src/*.test.ts` and are +picked up by the normal `pnpm test`. To run just the package: + +```bash +pnpm --filter mongodb-mcp-remote test +``` + +- **Unit** tests mock the network, so they need no external setup. +- **Integration** tests spawn the built wrapper (`dist/cli.js`) over stdio and drive it + against an in-process mock remote MCP server (`src/testHelpers/mockRemote.ts`), so they run deterministically. +- **End-to-end** tests (`src/wrapper.e2e.test.ts`) hit the real cloud-dev Remote MCP server. + They run in the normal `pnpm test` but are skipped unless `MDB_MCP_REMOTE_E2E_CLIENT_ID` + is set, and skip when `SKIP_REMOTE_MCP_E2E=true`. + In CI the `run-tests` job injects `MDB_MCP_REMOTE_E2E_*` from the `TEST_REMOTE_MCP_*` + GitHub secrets/var; until those are registered the e2e tests self-skip and CI stays green. + #### Accuracy Tests and colima If you use [colima](https://github.com/abiosoft/colima) to run Docker on Mac, you will need to apply [additional configuration](https://node.testcontainers.org/supported-container-runtimes/#colima) to ensure the accuracy tests run correctly. diff --git a/knip.json b/knip.json index 05e0db6b9..56b4d0f9c 100644 --- a/knip.json +++ b/knip.json @@ -43,6 +43,9 @@ "vitest.config.ts", "setup.ts" ] + }, + "packages/mongodb-mcp-remote": { + "entry": ["src/cli.ts!", "src/**/*.test.ts", "src/testHelpers/**/*.ts"] } }, "ignoreExportsUsedInFile": true diff --git a/packages/mongodb-mcp-remote/package.json b/packages/mongodb-mcp-remote/package.json index bd20ba164..8917f41c2 100644 --- a/packages/mongodb-mcp-remote/package.json +++ b/packages/mongodb-mcp-remote/package.json @@ -23,10 +23,14 @@ }, "scripts": { "compile": "tsc --project tsconfig.json", + "pretest": "pnpm compile", "test": "vitest --run" }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/express": "^5.0.0", "@types/node": "^25.6.0", + "express": "^5.2.1", "typescript": "^5.9.2", "vitest": "^4.1.8" } diff --git a/packages/mongodb-mcp-remote/src/testHelpers/mockRemote.ts b/packages/mongodb-mcp-remote/src/testHelpers/mockRemote.ts new file mode 100644 index 000000000..e7925a0bb --- /dev/null +++ b/packages/mongodb-mcp-remote/src/testHelpers/mockRemote.ts @@ -0,0 +1,143 @@ +import express, { type Express, type Response } from "express"; +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; + +/** + * A mock remote MCP server + mock Atlas token endpoint, for integration tests. + * + * It pretends to be mcp.mongodb.com (and the Atlas token endpoint) so the wrapper can be + * tested without a real network and with the same result every run: + * - POST /api/oauth/token → returns a fixed client-credentials token + * - POST /mcp → requires the bearer token, else 401; replies with JSON-RPC + * + * The real cloud-dev server replies to /mcp with Server-Sent Events (SSE) and returns an + * Mcp-Session-Id header on initialize (confirmed in MCP-542). So the mock defaults to SSE + * and returns a session id; pass `responseMode: "json"` to make it reply with plain JSON + * instead (the Streamable HTTP spec allows either, so the wrapper should handle both). + * + * Kept in one shared file so wrapper.integration.test.ts (and any other test) all use the + * same mock remote. + */ +export interface MockRemoteOptions { + responseMode?: "sse" | "json"; +} + +export interface MockRemote { + url: string; + /** How many times the token endpoint was called — lets a test check token reuse/refresh. */ + tokenRequestCount: () => number; + /** The Authorization header from the most recent /mcp call. */ + lastAuthHeader: () => string | undefined; + /** Make the next N /mcp calls return 401, so a test can check the refresh-and-retry behavior. */ + failNextMcpCalls: (count: number) => void; + close: () => Promise; +} + +/** Starts a mock remote MCP server + mock Atlas token endpoint on a random free port. */ +export async function startMockRemote(options: MockRemoteOptions = {}): Promise { + const responseMode = options.responseMode ?? "sse"; + let tokenRequests = 0; + let lastAuth: string | undefined; + let mcpFailuresRemaining = 0; + + // Reply to an /mcp request with a JSON-RPC payload, as SSE or plain JSON. + const sendRpc = (res: Response, payload: unknown): void => { + if (responseMode === "sse") { + res.setHeader("Content-Type", "text/event-stream"); + res.write(`event: message\ndata: ${JSON.stringify(payload)}\n\n`); + res.end(); + } else { + res.json(payload); + } + }; + + const app: Express = express(); + app.use(express.json()); + + // Mock Atlas token endpoint + app.post("/api/oauth/token", (_req, res) => { + tokenRequests++; + res.json({ access_token: "mock-token-123", expires_in: 3600, token_type: "Bearer" }); + }); + + // Mock remote MCP endpoint: requires the bearer token, else 401 + app.post("/mcp", (req, res) => { + lastAuth = req.headers["authorization"]; + + const { id, method, params } = (req.body ?? {}) as { + id?: string | number | null; + method?: string; + params?: { protocolVersion?: string }; + }; + + // Don't apply the forced 401 to the initialize call or to notifications + const isHandshakeOrNotification = method === "initialize" || id === undefined || id === null; + + if (!isHandshakeOrNotification && mcpFailuresRemaining > 0) { + mcpFailuresRemaining--; + res.status(401).json({ error: "unauthorized" }); + return; + } + if (lastAuth !== "Bearer mock-token-123") { + res.status(401).json({ error: "unauthorized" }); + return; + } + + // Notifications (e.g. notifications/initialized) have no id + if (id === undefined || id === null) { + res.status(202).end(); + return; + } + + // Return a session id on the first (initialize) call + res.setHeader("Mcp-Session-Id", "mock-session-1"); + + if (method === "initialize") { + sendRpc(res, { + jsonrpc: "2.0", + id, + result: { + protocolVersion: params?.protocolVersion ?? "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "mock-remote-mcp", version: "0.0.0" }, + }, + }); + return; + } + + if (method === "tools/list") { + sendRpc(res, { + jsonrpc: "2.0", + id, + result: { + tools: [ + { + name: "find", + description: "Mock find tool", + inputSchema: { type: "object" }, + }, + ], + }, + }); + return; + } + + // Default: return a minimal valid JSON-RPC result. + sendRpc(res, { jsonrpc: "2.0", id, result: {} }); + }); + + const server: Server = await new Promise((resolve) => { + const s = app.listen(0, () => resolve(s)); // port 0 → OS picks a free port + }); + const port = (server.address() as AddressInfo).port; + + return { + url: `http://127.0.0.1:${port}`, + tokenRequestCount: () => tokenRequests, + lastAuthHeader: () => lastAuth, + failNextMcpCalls: (count: number): void => { + mcpFailuresRemaining = count; + }, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} diff --git a/packages/mongodb-mcp-remote/src/tokenManager.test.ts b/packages/mongodb-mcp-remote/src/tokenManager.test.ts new file mode 100644 index 000000000..a59f67f35 --- /dev/null +++ b/packages/mongodb-mcp-remote/src/tokenManager.test.ts @@ -0,0 +1,78 @@ +import { describe, it, beforeEach, afterEach, vi } from "vitest"; + +/** + * + * Default to mocking the `oauth4webapi` module, consistent with the MCP server's + * existing auth unit test (tests/unit/common/auth/clientCredentials.test.ts). + * + * TODO: if the wrapper's token manager ends up calling `fetch` directly instead of + * using `oauth4webapi`, switch to mocking `fetch` (vi.stubGlobal("fetch", ...)). + * + * TODO: every test here is `it.todo` until the token manager exists in + * packages/mongodb-mcp-remote/src/. Tests will be implemented in MCP-539. + */ + +// Example of mocking the network with oauth4webapi (similar to +// clientCredentials.test.ts). +// +// import * as oauth from "oauth4webapi"; +// import { TokenManager } from "./tokenManager.js"; +// +// vi.mock("oauth4webapi", () => ({ +// clientCredentialsGrantRequest: vi.fn(), +// processClientCredentialsResponse: vi.fn(), +// customFetch: Symbol("customFetch"), +// })); +// +// function mockTokenResponse(accessToken: string, expiresIn = 3600): void { +// vi.mocked(oauth.processClientCredentialsResponse).mockResolvedValue({ +// access_token: accessToken, +// expires_in: expiresIn, +// } as Awaited>); +// } + +describe("TokenManager", () => { + beforeEach(() => { + // Fake timers let us fast-forward to just before/after token expiry + // to test proactive refresh without waiting in real time. + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe("acquiring a token", () => { + it.todo("requests a token with grant_type=client_credentials and basic auth header"); + // mockTokenResponse("t1"); + // const mgr = new TokenManager({ clientId: "id", clientSecret: "secret", baseUrl: "https://x" }); + // expect(await mgr.getToken()).toBe("t1"); + // expect(oauth.clientCredentialsGrantRequest).toHaveBeenCalled(); + + it.todo("throws a clear error when the token endpoint rejects the credentials"); + // vi.mocked(oauth.clientCredentialsGrantRequest).mockRejectedValue(new Error("invalid_client")); + // await expect(new TokenManager(badOpts).getToken()).rejects.toThrow(/credential/i); + }); + + describe("caching", () => { + it.todo("reuses a still-valid cached token instead of requesting a new one"); + // first getToken() fetches; second getToken() does not → clientCredentialsGrantRequest called once + + it.todo("requests a new token once the cached one is within the 10-minute pre-expiry buffer"); + // set expires_in so the token is valid, advance timers with vi.advanceTimersByTime(...) + // to inside the 10-min buffer, then getToken() should refetch. + }); + + describe("single-flight refresh", () => { + it.todo("collapses concurrent getToken() calls into a single token request"); + // fire Promise.all([mgr.getToken(), mgr.getToken(), mgr.getToken()]) + // expect(oauth.clientCredentialsGrantRequest).toHaveBeenCalledTimes(1); + }); + + describe("invalidation", () => { + it.todo("forces a fresh token request after invalidate() is called"); + // used by the 401/403 retry path: invalidate(), then getToken() refetches. + }); +}); diff --git a/packages/mongodb-mcp-remote/src/wrapper.e2e.test.ts b/packages/mongodb-mcp-remote/src/wrapper.e2e.test.ts new file mode 100644 index 000000000..a387f72a5 --- /dev/null +++ b/packages/mongodb-mcp-remote/src/wrapper.e2e.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from "vitest"; + +/** + * These run in the default test flow but are skipped unless real ingress + * service-account credentials are present, so runs without secrets are not impacted. + * + * Skip when: + * - SKIP_REMOTE_MCP_E2E=true, or + * - no REMOTE_MCP_E2E_CLIENT_ID is set + * TODO: The tests below will be implemented in by MCP-539 once wrapper is code complete (MCP-536). + */ +const skipE2E = process.env.SKIP_REMOTE_MCP_E2E === "true" || !process.env.REMOTE_MCP_E2E_CLIENT_ID; + +describe.skipIf(skipE2E)("mongodb-mcp-remote wrapper (e2e, cloud-dev)", () => { + it.todo("acquires a token from the configured Atlas endpoint and lists tools"); + // Spawn the built wrapper against MDB_MCP_API_* (cloud-dev ingress creds), then: + // const res = await client.listTools(); + // expect(res.tools.length).toBeGreaterThan(0); + + it.todo("invokes a read-only tool end-to-end against cloud-dev"); + + it.todo("surfaces a clear auth error when the ingress credentials are invalid"); +}); diff --git a/packages/mongodb-mcp-remote/src/wrapper.integration.test.ts b/packages/mongodb-mcp-remote/src/wrapper.integration.test.ts new file mode 100644 index 000000000..6276730eb --- /dev/null +++ b/packages/mongodb-mcp-remote/src/wrapper.integration.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { startMockRemote, type MockRemote } from "./testHelpers/mockRemote.js"; + +/** + * Integration tests for the mongodb-mcp-remote wrapper. + * + * Each test runs the whole wrapper end to end: an MCP SDK `Client` starts the built wrapper + * (dist/cli.js) as a child process and talks to it over stdin/stdout, while the wrapper + * forwards requests over HTTP to a mock remote server. That mock (mockRemote.ts) stands in + * for both the Remote MCP server and the Atlas token endpoint, so the tests run offline and + * deterministically. + * + * TODO: most tests here are `it.todo` until the wrapper implementation is complete in + * packages/mongodb-mcp-remote/src/. Tests will be implemented in MCP-539. + */ + +const CLI_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist", "cli.js"); + +/** + * Connects a real MCP SDK client by spawning the wrapper CLI against the mock remote. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- not called yet; the it.todo tests below will use it (remove once MCP-539 enables them) +async function connectClientToWrapper(remote: MockRemote): Promise { + const client = new Client({ name: "integration-test", version: "0.0.0" }); + const transport = new StdioClientTransport({ + command: process.execPath, // node + args: [CLI_PATH], + env: { + MDB_MCP_API_CLIENT_ID: "mock-id", + MDB_MCP_API_CLIENT_SECRET: "mock-secret", + MDB_MCP_API_BASE_URL: remote.url, + MDB_MCP_REMOTE_URL: `${remote.url}/mcp`, + }, + }); + await client.connect(transport); + return client; +} + +describe("mongodb-mcp-remote wrapper (integration)", () => { + let remote: MockRemote; + let client: Client | undefined; + + beforeEach(async () => { + remote = await startMockRemote(); + }); + + afterEach(async () => { + await client?.close(); + client = undefined; + await remote.close(); + }); + + it("mock remote responds to token and mcp endpoints", async () => { + // Checks the mock remote works on its own + const tokenRes = await fetch(`${remote.url}/api/oauth/token`, { method: "POST" }); + expect(tokenRes.status).toBe(200); + await expect(tokenRes.json()).resolves.toMatchObject({ access_token: "mock-token-123" }); + + const unauthorized = await fetch(`${remote.url}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + expect(unauthorized.status).toBe(401); // no bearer token → rejected + expect(remote.tokenRequestCount()).toBe(1); + }); + + it.todo("forwards a tools/list request and attaches the bearer token to the remote call"); + // client = await connectClientToWrapper(remote); + // const res = await client.listTools(); + // expect(res.tools.map((t) => t.name)).toContain("find"); + // expect(remote.lastAuthHeader()).toBe("Bearer mock-token-123"); + + it.todo("reuses the cached token across multiple calls (token endpoint hit once)"); + // make two calls, then: expect(remote.tokenRequestCount()).toBe(1); + + it.todo("on 401 it refreshes the token and retries the request once"); + // client = await connectClientToWrapper(remote); + // remote.failNextMcpCalls(1); const res = await client.listTools(); + // expect(res.tools.map((t) => t.name)).toContain("find"); + // expect(remote.tokenRequestCount()).toBeGreaterThanOrEqual(2); + + it.todo("fails fast with a clear error when credentials are rejected at startup"); + // point the token endpoint at a 401 and assert client.connect(...) rejects. + + it.todo("preserves the JSON-RPC request id on forwarded responses"); + + it.todo("maps remote/network failures to distinct JSON-RPC error codes"); +}); diff --git a/packages/mongodb-mcp-remote/tsconfig.json b/packages/mongodb-mcp-remote/tsconfig.json index 49d0cdfb7..038e80206 100644 --- a/packages/mongodb-mcp-remote/tsconfig.json +++ b/packages/mongodb-mcp-remote/tsconfig.json @@ -15,5 +15,5 @@ "forceConsistentCasingInFileNames": true }, "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules", "src/**/*.test.ts"] + "exclude": ["dist", "node_modules", "src/**/*.test.ts", "src/testHelpers/**"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ab4f3d98..7819debb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,9 +299,18 @@ importers: packages/mongodb-mcp-remote: devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 '@types/node': specifier: ^25.6.0 version: 25.6.0 + express: + specifier: ^5.2.1 + version: 5.2.1 typescript: specifier: ^5.9.2 version: 5.9.3