247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { once } from "node:events";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import test, { type TestContext } from "node:test";
|
|
import express from "express";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
import { installMcpTransports } from "../src/mcp-http.js";
|
|
import { createApprovalMcpServer } from "../src/server.js";
|
|
import type { ApprovalService } from "../src/approval-service.js";
|
|
import type { AppConfig } from "../src/config.js";
|
|
|
|
const config: AppConfig = {
|
|
clientId: "mock-client", clientSecret: "mock-secret", apiBaseUrl: "https://api.test",
|
|
oapiBaseUrl: "https://oapi.test", publicBaseUrl: "http://localhost:3000", port: 3000,
|
|
requestTimeoutMs: 1000, officialLookbackDays: 120, officialScanConcurrency: 2,
|
|
officialClientOnlyProcessCodes: [], batchLimit: 20, approvalRefSecret: "mock-session-test-key",
|
|
};
|
|
|
|
async function until(condition: () => boolean) {
|
|
for (let i = 0; i < 200; i += 1) {
|
|
if (condition()) return;
|
|
await delay(10);
|
|
}
|
|
assert.fail("HTTP lifecycle event did not arrive");
|
|
}
|
|
|
|
async function harness(t: TestContext, readTodos: () => Promise<void> = async () => {},
|
|
limits: { maxSessionsPerUser?: number } = { maxSessionsPerUser: 8 }) {
|
|
let time = Date.now();
|
|
let closedStreams = 0;
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, res, next) => {
|
|
if (req.method === "GET" && req.path === "/mcp") res.once("close", () => { closedStreams += 1; });
|
|
next();
|
|
});
|
|
const routes = installMcpTransports(app, {
|
|
now: () => time,
|
|
maxSessionsPerUser: limits.maxSessionsPerUser,
|
|
authenticate: (req, res, next) => {
|
|
// Synthetic bearer credentials; no external approval or OAuth writes.
|
|
const token = req.get("authorization");
|
|
if (token !== "Bearer mock-a" && token !== "Bearer mock-b") { res.sendStatus(401); return; }
|
|
req.auth = { token: "mock", clientId: "mock-client", scopes: ["mcp:tools"], extra: { userId: token === "Bearer mock-a" ? "mock-user-a" : "mock-user-b" } };
|
|
next();
|
|
},
|
|
requestUser: (req) => ({ userId: String(req.auth!.extra!.userId) }),
|
|
createServer: (user, lifecycle) => createApprovalMcpServer(config, {
|
|
async listMyTodos() { await readTodos(); return { items: [], diagnostics: {} }; },
|
|
} as unknown as ApprovalService, undefined, undefined, undefined, user, undefined, undefined, lifecycle),
|
|
});
|
|
const http = app.listen(0, "127.0.0.1");
|
|
await once(http, "listening");
|
|
const address = http.address();
|
|
assert.ok(address && typeof address !== "string");
|
|
const endpoint = new URL(`http://127.0.0.1:${address.port}/mcp`);
|
|
const clients: Client[] = [];
|
|
t.after(async () => {
|
|
await Promise.all(clients.map((client) => client.close()));
|
|
await routes.close();
|
|
http.closeAllConnections();
|
|
await new Promise<void>((resolve) => http.close(() => resolve()));
|
|
});
|
|
|
|
async function connect(account = "a") {
|
|
let streamReady = false;
|
|
const client = new Client({ name: "mock-old-bridge", version: "1.0" });
|
|
clients.push(client);
|
|
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
requestInit: { headers: { authorization: `Bearer mock-${account}` } },
|
|
fetch: async (input, init) => {
|
|
const res = await fetch(input, init);
|
|
if (init?.method === "GET" && res.status === 200) streamReady = true;
|
|
return res;
|
|
},
|
|
});
|
|
await client.connect(transport);
|
|
await until(() => streamReady);
|
|
return { client, transport };
|
|
}
|
|
|
|
async function rpc(sessionId?: string, account = "a", method = "initialize") {
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { authorization: `Bearer mock-${account}`, "content-type": "application/json",
|
|
accept: "application/json, text/event-stream", ...(sessionId ? { "mcp-session-id": sessionId } : {}) },
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 123, method,
|
|
...(method === "initialize" ? { params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "mock", version: "1" } } } : {}) }),
|
|
});
|
|
const body = await response.text();
|
|
return { response, body };
|
|
}
|
|
|
|
return { connect, rpc, endpoint, cleanup: routes.cleanup,
|
|
advance: (ms: number) => { time += ms; },
|
|
async disconnect(client: Client) {
|
|
const before = closedStreams;
|
|
await client.close(); // Deliberately no terminateSession(), like existing clients.
|
|
await until(() => closedStreams > before);
|
|
},
|
|
};
|
|
}
|
|
|
|
test("old bridge disconnects release eight occupied slots without a client upgrade", async (t) => {
|
|
const h = await harness(t);
|
|
const sessions = [];
|
|
for (let i = 0; i < 8; i += 1) sessions.push(await h.connect());
|
|
const full = await h.rpc();
|
|
assert.equal(full.response.status, 429);
|
|
assert.equal(full.response.headers.get("retry-after"), "60");
|
|
assert.match(full.body, /MCP session limit reached/);
|
|
for (const { client } of sessions) await h.disconnect(client);
|
|
h.advance(59_000);
|
|
assert.equal((await h.rpc()).response.status, 429, "retain reconnect grace");
|
|
h.advance(1_001);
|
|
const replacement = await h.connect(); // Admission itself sweeps expired sessions.
|
|
const result = await replacement.client.callTool({ name: "approval_list_todos", arguments: { source: "dingtalk" } });
|
|
assert.notEqual(result.isError, true);
|
|
assert.equal((await h.rpc(sessions[0]!.transport.sessionId, "a", "tools/list")).response.status, 404);
|
|
});
|
|
|
|
test("default gateway capacity admits 32 live connections for one user", async (t) => {
|
|
const h = await harness(t, undefined, {});
|
|
const sessions = [];
|
|
for (let i = 0; i < 32; i += 1) sessions.push(await h.connect());
|
|
assert.equal((await h.rpc()).response.status, 429);
|
|
assert.ok((await sessions[0]!.client.listTools()).tools.length > 0);
|
|
assert.ok((await sessions[31]!.client.listTools()).tools.length > 0);
|
|
});
|
|
|
|
test("operator override permits 64 sessions while retaining its configured cap", async (t) => {
|
|
const h = await harness(t, undefined, { maxSessionsPerUser: 64 });
|
|
const results = await Promise.all(Array.from({ length: 65 }, () => h.rpc()));
|
|
assert.equal(results.filter(({ response }) => response.status === 200).length, 64);
|
|
assert.equal(results.filter(({ response }) => response.status === 429).length, 1);
|
|
});
|
|
|
|
test("live streams survive idle cleanup and capacity does not evict another user", async (t) => {
|
|
const h = await harness(t);
|
|
const sessions = [];
|
|
for (let i = 0; i < 8; i += 1) sessions.push(await h.connect());
|
|
h.advance(3 * 60 * 60_000);
|
|
h.cleanup();
|
|
assert.equal((await h.rpc()).response.status, 429);
|
|
const other = await h.connect("b");
|
|
assert.ok((await other.client.listTools()).tools.length > 0);
|
|
assert.ok((await sessions[0]!.client.listTools()).tools.length > 0);
|
|
assert.equal((await h.rpc(sessions[0]!.transport.sessionId, "b", "tools/list")).response.status, 403);
|
|
});
|
|
|
|
test("GET reconnect within grace preserves the original session", async (t) => {
|
|
const h = await harness(t);
|
|
const original = await h.connect();
|
|
const id = original.transport.sessionId!;
|
|
await h.disconnect(original.client);
|
|
h.advance(30_000);
|
|
const abort = new AbortController();
|
|
const response = await fetch(h.endpoint, { headers: {
|
|
authorization: "Bearer mock-a", accept: "text/event-stream", "mcp-session-id": id,
|
|
}, signal: abort.signal });
|
|
t.after(() => abort.abort());
|
|
assert.equal(response.status, 200);
|
|
h.advance(90_000);
|
|
h.cleanup();
|
|
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 200);
|
|
abort.abort();
|
|
});
|
|
|
|
test("clients without GET retain the existing two-hour idle lifetime", async (t) => {
|
|
const h = await harness(t);
|
|
const initialized = await h.rpc();
|
|
const id = initialized.response.headers.get("mcp-session-id")!;
|
|
assert.ok(id);
|
|
h.advance(5 * 60_000);
|
|
h.cleanup();
|
|
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 200);
|
|
h.advance(2 * 60 * 60_000 + 1);
|
|
h.cleanup();
|
|
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 404);
|
|
});
|
|
|
|
test("disconnected in-flight approval reads finish before their session is reclaimed", async (t) => {
|
|
let started = false;
|
|
let finish!: () => void;
|
|
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
|
t.after(() => finish());
|
|
const h = await harness(t, async () => { started = true; await gate; });
|
|
const active = await h.connect();
|
|
const id = active.transport.sessionId!;
|
|
const pending = active.client.callTool({ name: "approval_list_todos", arguments: { source: "dingtalk" } }).catch(() => undefined);
|
|
await until(() => started);
|
|
await h.disconnect(active.client);
|
|
await pending;
|
|
h.advance(25 * 60 * 60_000);
|
|
h.cleanup();
|
|
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 200, "even max age must not interrupt work");
|
|
finish();
|
|
await delay(20);
|
|
h.cleanup();
|
|
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 404);
|
|
});
|
|
|
|
test("concurrent initialization cannot bypass the per-user session cap", async (t) => {
|
|
const h = await harness(t);
|
|
const results = await Promise.all(Array.from({ length: 12 }, () => h.rpc()));
|
|
assert.equal(results.filter(({ response }) => response.status === 200).length, 8);
|
|
assert.equal(results.filter(({ response }) => response.status === 429).length, 4);
|
|
});
|
|
|
|
test("cancelled requests retain their slot until the handler actually settles", async (t) => {
|
|
let started = false;
|
|
let finish!: () => void;
|
|
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
|
t.after(() => finish());
|
|
const h = await harness(t, async () => { started = true; await gate; });
|
|
const active = await h.connect();
|
|
const id = active.transport.sessionId!;
|
|
const abort = new AbortController();
|
|
const pending = active.client.callTool({ name: "approval_list_todos", arguments: { source: "dingtalk" } }, undefined, { signal: abort.signal }).catch(() => undefined);
|
|
await until(() => started);
|
|
abort.abort(); // SDK sends notifications/cancelled; the server suppresses the reply.
|
|
await pending;
|
|
await h.disconnect(active.client);
|
|
h.advance(60_001);
|
|
h.cleanup();
|
|
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 200);
|
|
finish();
|
|
await delay(20);
|
|
h.advance(60_001);
|
|
h.cleanup();
|
|
assert.equal((await h.rpc(id, "a", "tools/list")).response.status, 404, "cancellation must not leak the slot forever");
|
|
});
|
|
|
|
test("rejected duplicate GET does not evict the valid stream", async (t) => {
|
|
const h = await harness(t);
|
|
const active = await h.connect();
|
|
const response = await fetch(h.endpoint, { headers: {
|
|
authorization: "Bearer mock-a", accept: "text/event-stream", "mcp-session-id": active.transport.sessionId!,
|
|
} });
|
|
await response.text();
|
|
assert.equal(response.status, 409);
|
|
h.advance(60_001);
|
|
h.cleanup();
|
|
assert.ok((await active.client.listTools()).tools.length > 0);
|
|
});
|