Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ 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.

#### 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.
Expand Down
3 changes: 3 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/mongodb-mcp-remote/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
},
"scripts": {
"compile": "tsc --project tsconfig.json",
"pretest": "pnpm compile",
"test": "vitest --run"
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@types/node": "^25.6.0",
"typescript": "^5.9.2",
"vitest": "^4.1.8"
Expand Down
167 changes: 167 additions & 0 deletions packages/mongodb-mcp-remote/src/testHelpers/mockRemote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse, Server } from "node:http";
import type { AddressInfo } from "node:net";

/**
* 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<void>;
}

function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => resolve(body));
});
}

function sendJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body);
res.writeHead(status, { "Content-Type": "application/json" });
res.end(payload);
}

function sendRpc(res: ServerResponse, payload: unknown, mode: "sse" | "json"): void {
if (mode === "sse") {
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write(`event: message\ndata: ${JSON.stringify(payload)}\n\n`);
res.end();
} else {
sendJson(res, 200, payload);
}
}

/** Starts a mock remote MCP server + mock Atlas token endpoint on a random free port. */
export async function startMockRemote(options: MockRemoteOptions = {}): Promise<MockRemote> {
const responseMode = options.responseMode ?? "sse";
let tokenRequests = 0;
let lastAuth: string | undefined;
let mcpFailuresRemaining = 0;

const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
// Mock Atlas token endpoint
if (req.url === "/api/oauth/token") {
tokenRequests++;
sendJson(res, 200, { access_token: "mock-token-123", expires_in: 3600, token_type: "Bearer" });
return;
}

// Mock remote MCP endpoint: requires the bearer token, else 401
if (req.url === "/mcp") {
const body = await readBody(req);
const parsed = body
? (JSON.parse(body) as {
id?: string | number | null;
method?: string;
params?: { protocolVersion?: string };
})
: {};
const { id, method, params } = parsed;
lastAuth = req.headers["authorization"];

// 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--;
sendJson(res, 401, { error: "unauthorized" });
return;
}

if (lastAuth !== "Bearer mock-token-123") {
sendJson(res, 401, { error: "unauthorized" });
return;
}

// Notifications (e.g. notifications/initialized) have no id
if (id === undefined || id === null) {
res.writeHead(202);
res.end();
return;
}

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" },
},
},
responseMode
);
return;
}

if (method === "tools/list") {
sendRpc(
res,
{
jsonrpc: "2.0",
id,
result: {
tools: [{ name: "find", description: "Mock find tool", inputSchema: { type: "object" } }],
},
},
responseMode
);
return;
}

sendRpc(res, { jsonrpc: "2.0", id, result: {} }, responseMode);
return;
}

res.writeHead(404);
res.end();
};

const server: Server = createServer((req, res) => {
void handleRequest(req, res);
});

await new Promise<void>((resolve) => server.listen(0, () => resolve()));
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())),
};
}
78 changes: 78 additions & 0 deletions packages/mongodb-mcp-remote/src/tokenManager.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof oauth.processClientCredentialsResponse>>);
// }

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.
});
});
93 changes: 93 additions & 0 deletions packages/mongodb-mcp-remote/src/wrapper.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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<Client> {
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");
});
2 changes: 1 addition & 1 deletion packages/mongodb-mcp-remote/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"]
}
Loading
Loading