Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .github/workflows/code-health.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
4 changes: 4 additions & 0 deletions packages/mongodb-mcp-remote/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
143 changes: 143 additions & 0 deletions packages/mongodb-mcp-remote/src/testHelpers/mockRemote.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

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

// 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())),
};
}
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.
});
});
23 changes: 23 additions & 0 deletions packages/mongodb-mcp-remote/src/wrapper.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading
Loading