fix: reclaim disconnected gateway MCP sessions
This commit is contained in:
@@ -128,6 +128,19 @@ https://approval.justconn.top/downloads/dingmcp/install.md
|
||||
|
||||
## 运维检查
|
||||
|
||||
网关会话上限为每个已认证用户 8 个、全局 1000 个,同时计入尚未完成初始化的连接。
|
||||
现有 Bridge 客户端退出或刷新登录时,即使没有发送 MCP DELETE,网关也会在观察到
|
||||
Streamable HTTP GET 长连接断开后保留 60 秒重连宽限期,再回收遗留会话;后台每 15 秒
|
||||
检查一次,新连接进入时也会检查,因此通常在断开后的 60–75 秒内释放名额。
|
||||
宽限期内恢复长连接或继续发送请求会保留原会话;仅使用 POST 的客户端仍使用 2 小时
|
||||
闲置期限。正常连接中的长会话不按闲置期限清理,最长存活时间仍为 24 小时。
|
||||
实际请求处理结束前不会因为断线、取消或超时回收会话,也不会重试任何审批写入。
|
||||
回收状态只保存在内存中,不记录审批参数、正文、Token 或用户身份到日志。
|
||||
|
||||
如果 8 个会话确实仍在连接,网关仍返回 HTTP 429 和 `Retry-After: 60`,需要关闭不用的
|
||||
Agent 连接再重试。旧客户端可能仍将 429 显示为“审批网关暂时无法连接”;此次服务端
|
||||
修复无需用户更新客户端,但不会改变客户端内置的错误文案。
|
||||
|
||||
```bash
|
||||
systemctl --user status dingtalk-approval-mcp.service
|
||||
curl --fail http://127.0.0.1:3010/healthz
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Express, Request, Response, RequestHandler } from "express";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { AuthenticatedDingTalkUser } from "./xft.js";
|
||||
import { McpSessionLifecycle } from "./mcp-session-lifecycle.js";
|
||||
|
||||
type TransportEntry =
|
||||
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; lifecycle: McpSessionLifecycle }
|
||||
| { kind: "sse"; transport: SSEServerTransport; userId: string; lifecycle: McpSessionLifecycle };
|
||||
|
||||
const MCP_SESSION_MAX_PER_USER = 8;
|
||||
const MCP_SESSION_MAX_TOTAL = 1000;
|
||||
|
||||
interface McpTransportOptions {
|
||||
authenticate?: RequestHandler;
|
||||
// Production always resolves this from verified OAuth, never request parameters.
|
||||
requestUser: (req: Request) => AuthenticatedDingTalkUser;
|
||||
createServer: (user: AuthenticatedDingTalkUser, lifecycle: McpSessionLifecycle) => McpServer;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export function installMcpTransports(app: Express, options: McpTransportOptions) {
|
||||
const now = options.now ?? Date.now;
|
||||
const transports = new Map<string, TransportEntry>();
|
||||
let pendingTransportSessions = 0;
|
||||
const pendingTransportSessionsByUser = new Map<string, number>();
|
||||
const closeTransport = (id: string, entry: TransportEntry) => {
|
||||
transports.delete(id);
|
||||
void entry.transport.close().catch(() => undefined);
|
||||
};
|
||||
const cleanupTransports = () => {
|
||||
for (const [id, entry] of transports) {
|
||||
if (entry.lifecycle.expired()) {
|
||||
closeTransport(id, entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
const sessionCapacityAvailable = (userId: string) => transports.size + pendingTransportSessions < MCP_SESSION_MAX_TOTAL
|
||||
&& [...transports.values()].filter((entry) => entry.userId === userId).length
|
||||
+ (pendingTransportSessionsByUser.get(userId) ?? 0) < MCP_SESSION_MAX_PER_USER;
|
||||
const reserveSession = (userId: string) => {
|
||||
pendingTransportSessions += 1;
|
||||
pendingTransportSessionsByUser.set(userId, (pendingTransportSessionsByUser.get(userId) ?? 0) + 1);
|
||||
};
|
||||
const releaseSessionReservation = (userId: string) => {
|
||||
pendingTransportSessions = Math.max(0, pendingTransportSessions - 1);
|
||||
const remaining = Math.max(0, (pendingTransportSessionsByUser.get(userId) ?? 1) - 1);
|
||||
if (remaining === 0) pendingTransportSessionsByUser.delete(userId);
|
||||
else pendingTransportSessionsByUser.set(userId, remaining);
|
||||
};
|
||||
const transportCleanupTimer = setInterval(cleanupTransports, 15_000);
|
||||
transportCleanupTimer.unref();
|
||||
|
||||
const mcpHandler = async (req: Request, res: Response) => {
|
||||
let reservedUserId: string | undefined;
|
||||
try {
|
||||
cleanupTransports();
|
||||
const user = options.requestUser(req);
|
||||
const sessionId = req.get("mcp-session-id");
|
||||
let entry = sessionId ? transports.get(sessionId) : undefined;
|
||||
if (entry && entry.kind !== "streamable") {
|
||||
res.status(400).json({ error: "Invalid MCP session transport" });
|
||||
return;
|
||||
}
|
||||
if (entry && entry.userId !== user.userId) {
|
||||
res.status(403).json({ error: "MCP session belongs to another authenticated user" });
|
||||
return;
|
||||
}
|
||||
if (entry) entry.lifecycle.touch();
|
||||
if (!entry && req.method === "POST" && !sessionId && isInitializeRequest(req.body)) {
|
||||
if (!sessionCapacityAvailable(user.userId)) {
|
||||
res.set("Retry-After", "60").status(429).json({ error: "MCP session limit reached; close an old Agent session and retry" });
|
||||
return;
|
||||
}
|
||||
reserveSession(user.userId);
|
||||
reservedUserId = user.userId;
|
||||
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
transports.set(id, entry!);
|
||||
// The live entry now owns the slot; do not count its reservation twice.
|
||||
releaseSessionReservation(user.userId);
|
||||
reservedUserId = undefined;
|
||||
},
|
||||
});
|
||||
transport.onclose = () => { if (transport.sessionId) transports.delete(transport.sessionId); };
|
||||
entry = { kind: "streamable", transport, userId: user.userId, lifecycle: new McpSessionLifecycle(now) };
|
||||
await options.createServer(user, entry.lifecycle).connect(transport);
|
||||
}
|
||||
if (!entry || entry.kind !== "streamable") {
|
||||
res.status(sessionId ? 404 : 400).json({ error: "Missing or invalid MCP session" });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET") entry.lifecycle.trackStream(res);
|
||||
await entry.transport.handleRequest(req, res, req.body);
|
||||
} catch (error) { sendServerError(res, error); }
|
||||
finally { if (reservedUserId) releaseSessionReservation(reservedUserId); }
|
||||
};
|
||||
app.all("/mcp", ...(options.authenticate ? [options.authenticate, mcpHandler] : [mcpHandler]));
|
||||
|
||||
const sseHandler = async (req: Request, res: Response) => {
|
||||
const user = options.requestUser(req);
|
||||
cleanupTransports();
|
||||
if (!sessionCapacityAvailable(user.userId)) {
|
||||
res.set("Retry-After", "60").status(429).send("MCP session limit reached");
|
||||
return;
|
||||
}
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
const lifecycle = new McpSessionLifecycle(now);
|
||||
lifecycle.trackStream(res);
|
||||
transports.set(transport.sessionId, { kind: "sse", transport, userId: user.userId, lifecycle });
|
||||
res.on("close", () => transports.delete(transport.sessionId));
|
||||
await options.createServer(user, lifecycle).connect(transport);
|
||||
};
|
||||
app.get("/sse", ...(options.authenticate ? [options.authenticate, sseHandler] : [sseHandler]));
|
||||
const messagesHandler = async (req: Request, res: Response) => {
|
||||
const user = options.requestUser(req);
|
||||
const entry = transports.get(String(req.query.sessionId ?? ""));
|
||||
if (!entry || entry.kind !== "sse") {
|
||||
res.status(404).send("Invalid SSE session");
|
||||
return;
|
||||
}
|
||||
if (entry.userId !== user.userId) {
|
||||
res.status(403).send("Invalid SSE session");
|
||||
return;
|
||||
}
|
||||
entry.lifecycle.touch();
|
||||
await entry.transport.handlePostMessage(req, res, req.body);
|
||||
};
|
||||
app.post("/messages", ...(options.authenticate ? [options.authenticate, messagesHandler] : [messagesHandler]));
|
||||
return {
|
||||
cleanup: cleanupTransports,
|
||||
async close() {
|
||||
clearInterval(transportCleanupTimer);
|
||||
const entries = [...transports.values()];
|
||||
transports.clear();
|
||||
await Promise.all(entries.map((entry) => entry.transport.close()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sendServerError(res: Response, _error: unknown): void {
|
||||
console.error("[dingtalk-approval-mcp] MCP transport request failed");
|
||||
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Response } from "express";
|
||||
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
|
||||
const DISCONNECT_GRACE_MS = 60_000;
|
||||
const IDLE_TTL_MS = 2 * 60 * 60_000;
|
||||
const MAX_AGE_MS = 24 * 60 * 60_000;
|
||||
|
||||
/** Only transport metadata lives here; never approval arguments or results. */
|
||||
export class McpSessionLifecycle {
|
||||
private readonly createdAt: number;
|
||||
private lastSeenAt: number;
|
||||
private disconnectedAt?: number;
|
||||
private openStreams = 0;
|
||||
private activeRequests = 0;
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {
|
||||
this.createdAt = this.lastSeenAt = now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Install before registering tools, using the SDK's public handler API.
|
||||
* Track actual handler completion, not HTTP close or JSON-RPC replies:
|
||||
* cancelled requests may finish upstream without ever sending a reply.
|
||||
*/
|
||||
trackRequests(server: Server): void {
|
||||
const setRequestHandler = server.setRequestHandler.bind(server);
|
||||
server.setRequestHandler = (schema, handler) => {
|
||||
setRequestHandler(schema, async (request, extra) => {
|
||||
this.activeRequests += 1;
|
||||
try { return await handler(request, extra); }
|
||||
finally {
|
||||
this.activeRequests -= 1;
|
||||
this.touch();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
touch(): void { this.lastSeenAt = this.now(); }
|
||||
|
||||
trackStream(res: Response): void {
|
||||
this.openStreams += 1;
|
||||
res.once("close", () => {
|
||||
this.openStreams -= 1;
|
||||
// A rejected duplicate GET (409) must not start the disconnect timer.
|
||||
if (res.statusCode === 200) {
|
||||
this.disconnectedAt = this.now();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
expired(): boolean {
|
||||
if (this.activeRequests > 0) return false;
|
||||
const now = this.now();
|
||||
if (now - this.createdAt >= MAX_AGE_MS) return true;
|
||||
if (this.openStreams > 0) return false;
|
||||
if (this.disconnectedAt !== undefined
|
||||
&& now - Math.max(this.disconnectedAt, this.lastSeenAt) >= DISCONNECT_GRACE_MS) return true;
|
||||
// GET streams are optional in Streamable HTTP. POST-only clients retain
|
||||
// the existing idle timeout instead of being mistaken for dead clients.
|
||||
return now - this.lastSeenAt >= IDLE_TTL_MS;
|
||||
}
|
||||
}
|
||||
+13
-121
@@ -1,13 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { McpSessionLifecycle } from "./mcp-session-lifecycle.js";
|
||||
import { installMcpTransports } from "./mcp-http.js";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { hostHeaderValidation } from "@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js";
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
||||
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
||||
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
APPROVAL_BATCH_LIMIT,
|
||||
@@ -28,15 +26,6 @@ import { InvoiceDeduplicationService, InvoiceDedupStore, invoiceDedupDatabasePat
|
||||
import { APPLICATION_VERSION } from "./version.js";
|
||||
import { SignedReferenceCodec } from "./signed-reference.js";
|
||||
|
||||
type TransportEntry =
|
||||
| { kind: "streamable"; transport: StreamableHTTPServerTransport; userId: string; createdAt: number; lastSeenAt: number }
|
||||
| { kind: "sse"; transport: SSEServerTransport; userId: string; createdAt: number; lastSeenAt: number };
|
||||
|
||||
const MCP_SESSION_IDLE_TTL_MS = 2 * 60 * 60_000;
|
||||
const MCP_SESSION_MAX_AGE_MS = 24 * 60 * 60_000;
|
||||
const MCP_SESSION_MAX_PER_USER = 8;
|
||||
const MCP_SESSION_MAX_TOTAL = 1000;
|
||||
|
||||
export const APPROVAL_MCP_INSTRUCTIONS = [
|
||||
"先调用 approval_list_todos 获取当前待办。薪福通待办不按单据类型过滤;availableActions=[] 且 manualAction.reason=unsupported_xft_task_type 的项目仍要展示给用户,但只能引导到薪福通客户端查看和处理,禁止尝试 MCP 写入。用户只是在查看时,仅回答标题、申请人、金额、时间等业务信息,不主动讨论审批接口、批处理方式或索取写操作授权。用户明确要求处理后,在其授权范围内内部选择整组引用或自动分批;技术分批不得造成重复确认。",
|
||||
"approvalId、selectionId、targetId、单次条数限制、分批方式以及内部引用是否返回均属于 Agent 与 MCP 的内部调用细节,绝不向用户展示、解释或要求用户操作。面向用户只说明目标审批、业务动作、成功结果和需要用户决策的业务失败;不要说引用是否签发、是否失效、是否存在一键全选或如何分组。",
|
||||
@@ -61,11 +50,13 @@ export function createApprovalMcpServer(
|
||||
authenticatedUser?: AuthenticatedDingTalkUser,
|
||||
audit?: (userId: string, action: string, reference: string | undefined, outcome: string) => void,
|
||||
invoiceDedup?: InvoiceDeduplicationService,
|
||||
lifecycle?: McpSessionLifecycle,
|
||||
): McpServer {
|
||||
const server = new McpServer(
|
||||
{ name: "dingtalk-approval-mcp", version: APPLICATION_VERSION },
|
||||
{ instructions: APPROVAL_MCP_INSTRUCTIONS },
|
||||
);
|
||||
lifecycle?.trackRequests(server.server);
|
||||
const unified = new UnifiedApprovalService(config, service, xft, xftChallenges, officialOaReturns, invoiceDedup);
|
||||
const user = authenticatedUser ?? config.trustedLocalUser;
|
||||
if (!user) throw new Error("MCP 会话缺少已认证用户");
|
||||
@@ -469,114 +460,15 @@ export async function startHttpServer(config: AppConfig): Promise<void> {
|
||||
...(authMiddleware ? [authMiddleware, attachmentBody, attachmentUploadHandler] : [attachmentBody, attachmentUploadHandler]),
|
||||
);
|
||||
|
||||
const transports = new Map<string, TransportEntry>();
|
||||
let pendingTransportSessions = 0;
|
||||
const pendingTransportSessionsByUser = new Map<string, number>();
|
||||
const closeTransport = (id: string, entry: TransportEntry) => {
|
||||
transports.delete(id);
|
||||
void entry.transport.close().catch(() => undefined);
|
||||
};
|
||||
const cleanupTransports = () => {
|
||||
const now = Date.now();
|
||||
for (const [id, entry] of transports) {
|
||||
if (entry.lastSeenAt < now - MCP_SESSION_IDLE_TTL_MS || entry.createdAt < now - MCP_SESSION_MAX_AGE_MS) {
|
||||
closeTransport(id, entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
const sessionCapacityAvailable = (userId: string) => transports.size + pendingTransportSessions < MCP_SESSION_MAX_TOTAL
|
||||
&& [...transports.values()].filter((entry) => entry.userId === userId).length
|
||||
+ (pendingTransportSessionsByUser.get(userId) ?? 0) < MCP_SESSION_MAX_PER_USER;
|
||||
const reserveSession = (userId: string) => {
|
||||
pendingTransportSessions += 1;
|
||||
pendingTransportSessionsByUser.set(userId, (pendingTransportSessionsByUser.get(userId) ?? 0) + 1);
|
||||
};
|
||||
const releaseSessionReservation = (userId: string) => {
|
||||
pendingTransportSessions = Math.max(0, pendingTransportSessions - 1);
|
||||
const remaining = Math.max(0, (pendingTransportSessionsByUser.get(userId) ?? 1) - 1);
|
||||
if (remaining === 0) pendingTransportSessionsByUser.delete(userId);
|
||||
else pendingTransportSessionsByUser.set(userId, remaining);
|
||||
};
|
||||
const transportCleanupTimer = setInterval(cleanupTransports, 10 * 60_000);
|
||||
transportCleanupTimer.unref();
|
||||
|
||||
const mcpHandler = async (req: Request, res: Response) => {
|
||||
let reservedUserId: string | undefined;
|
||||
try {
|
||||
cleanupTransports();
|
||||
const user = requestUser(config, oauth, req.auth);
|
||||
const sessionId = headerString(req.headers["mcp-session-id"]);
|
||||
let entry = sessionId ? transports.get(sessionId) : undefined;
|
||||
if (entry && entry.kind !== "streamable") {
|
||||
res.status(400).json({ error: "Invalid MCP session transport" });
|
||||
return;
|
||||
}
|
||||
if (entry && entry.userId !== user.userId) {
|
||||
res.status(403).json({ error: "MCP session belongs to another authenticated user" });
|
||||
return;
|
||||
}
|
||||
if (entry) entry.lastSeenAt = Date.now();
|
||||
if (!entry && req.method === "POST" && !sessionId && isInitializeRequest(req.body)) {
|
||||
if (!sessionCapacityAvailable(user.userId)) {
|
||||
res.status(429).json({ error: "MCP session limit reached; close an old Agent session and retry" });
|
||||
return;
|
||||
}
|
||||
reserveSession(user.userId);
|
||||
reservedUserId = user.userId;
|
||||
const createdAt = Date.now();
|
||||
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
transports.set(id, { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: Date.now() });
|
||||
},
|
||||
});
|
||||
transport.onclose = () => { if (transport.sessionId) transports.delete(transport.sessionId); };
|
||||
entry = { kind: "streamable", transport, userId: user.userId, createdAt, lastSeenAt: createdAt };
|
||||
await createApprovalMcpServer(
|
||||
config, service, xft, undefined, officialOaReturns, user,
|
||||
oauth ? (userId, action, reference, outcome) => oauth.audit(userId, action, reference, outcome) : undefined,
|
||||
invoiceDedup,
|
||||
).connect(transport);
|
||||
}
|
||||
if (!entry || entry.kind !== "streamable") {
|
||||
res.status(sessionId ? 404 : 400).json({ error: "Missing or invalid MCP session" });
|
||||
return;
|
||||
}
|
||||
await entry.transport.handleRequest(req, res, req.body);
|
||||
} catch (error) { sendServerError(res, error); }
|
||||
finally { if (reservedUserId) releaseSessionReservation(reservedUserId); }
|
||||
};
|
||||
app.all("/mcp", ...(authMiddleware ? [authMiddleware, mcpHandler] : [mcpHandler]));
|
||||
|
||||
const sseHandler = async (req: Request, res: Response) => {
|
||||
const user = requestUser(config, oauth, req.auth);
|
||||
cleanupTransports();
|
||||
if (!sessionCapacityAvailable(user.userId)) {
|
||||
res.status(429).send("MCP session limit reached");
|
||||
return;
|
||||
}
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
const createdAt = Date.now();
|
||||
transports.set(transport.sessionId, { kind: "sse", transport, userId: user.userId, createdAt, lastSeenAt: createdAt });
|
||||
res.on("close", () => transports.delete(transport.sessionId));
|
||||
await createApprovalMcpServer(config, service, xft, undefined, officialOaReturns, user, undefined, invoiceDedup).connect(transport);
|
||||
};
|
||||
app.get("/sse", ...(authMiddleware ? [authMiddleware, sseHandler] : [sseHandler]));
|
||||
const messagesHandler = async (req: Request, res: Response) => {
|
||||
const user = requestUser(config, oauth, req.auth);
|
||||
const entry = transports.get(String(req.query.sessionId ?? ""));
|
||||
if (!entry || entry.kind !== "sse") {
|
||||
res.status(404).send("Invalid SSE session");
|
||||
return;
|
||||
}
|
||||
if (entry.userId !== user.userId) {
|
||||
res.status(403).send("Invalid SSE session");
|
||||
return;
|
||||
}
|
||||
entry.lastSeenAt = Date.now();
|
||||
await entry.transport.handlePostMessage(req, res, req.body);
|
||||
};
|
||||
app.post("/messages", ...(authMiddleware ? [authMiddleware, messagesHandler] : [messagesHandler]));
|
||||
installMcpTransports(app, {
|
||||
authenticate: authMiddleware,
|
||||
requestUser: (req) => requestUser(config, oauth, req.auth),
|
||||
createServer: (user, lifecycle) => createApprovalMcpServer(
|
||||
config, service, xft, undefined, officialOaReturns, user,
|
||||
oauth ? (userId, action, reference, outcome) => oauth.audit(userId, action, reference, outcome) : undefined,
|
||||
invoiceDedup, lifecycle,
|
||||
),
|
||||
});
|
||||
app.get("/healthz", (_req, res) => res.json({ status: "ok", mode: config.appMode }));
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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 () => {}) {
|
||||
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,
|
||||
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("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);
|
||||
});
|
||||
Reference in New Issue
Block a user