Files
UHD_Media_Tool/apps/server/src/modules/mcp/sessions.ts
T
rootandClaude Opus 5.5 653100ceb4 feat(mcp): Streamable HTTP MCP server on the capability registry
POST/GET/DELETE /api/v1/mcp with API Key auth; tools are the capabilities
visible to the key's scope (download__submit naming), every call goes through
registry.invoke (authorize, audit, idempotency key, ledger) with source=mcp.
Resources: settings summary, pending decisions, subscriptions overview;
prompts: triage_pending, acquire_title, diagnose_task. Sessions bound to the
API key, idle reaping, per-key rate limits, enable switch and key allowlist.
Also GET /capabilities/stats (from audit_event) and /capabilities/tools.
Tests connect with the official SDK client over Streamable HTTP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:28:34 +08:00

134 lines
4.3 KiB
TypeScript

// @ts-types="@modelcontextprotocol/sdk/server/index"
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
// @ts-types="@modelcontextprotocol/sdk/server/webStandardStreamableHttp"
import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import type { McpSessionInfo } from "@umt/contracts";
// MCP 会话表(进程内):会话与建立它的 API Key 绑定,换 Key 复用会话一律拒绝。
// 空闲超时回收;每个 Key 的会话数与每分钟工具调用数有上限。
export const MCP_LIMITS = {
/** 每个 API Key 每分钟的工具调用上限 */
toolCallsPerMinute: 60,
/** 每个 API Key 每分钟的 HTTP 请求上限(含 list / resources / 推送流) */
requestsPerMinute: 600,
/** 每个 API Key 同时保持的会话上限 */
maxSessionsPerKey: 8,
/** 全进程会话上限 */
maxSessions: 256,
/** 空闲多久回收(ms) */
idleMs: 30 * 60_000,
} as const;
export interface McpSession {
id: string;
transport: WebStandardStreamableHTTPServerTransport;
server: Server;
principalKind: string;
principalId: string;
principalName: string;
clientName: string | null;
createdAt: Date;
lastSeenAt: Date;
toolCalls: number;
}
/** 滑动窗口计数器:key → 最近一分钟的时间戳 */
export class RateWindow {
private readonly hits = new Map<string, number[]>();
constructor(
readonly limit: number,
readonly windowMs = 60_000,
private readonly now: () => number = Date.now,
) {}
/** 记一次;超限返回 false(不计入) */
take(key: string): boolean {
const t = this.now();
const arr = (this.hits.get(key) ?? []).filter((x) => t - x < this.windowMs);
if (arr.length >= this.limit) {
this.hits.set(key, arr);
return false;
}
arr.push(t);
this.hits.set(key, arr);
return true;
}
/** 清掉整窗口都没有记录的 key */
sweep(): void {
const t = this.now();
for (const [k, arr] of this.hits) if (arr.every((x) => t - x >= this.windowMs)) this.hits.delete(k);
}
}
export class McpSessionTable {
readonly sessions = new Map<string, McpSession>();
readonly toolCalls = new RateWindow(MCP_LIMITS.toolCallsPerMinute);
readonly requests = new RateWindow(MCP_LIMITS.requestsPerMinute);
private timer: ReturnType<typeof setInterval> | null = null;
constructor(private readonly onClose?: (s: McpSession, reason: string) => void) {}
countFor(principalId: string): number {
let n = 0;
for (const s of this.sessions.values()) if (s.principalId === principalId) n++;
return n;
}
add(s: McpSession): void {
this.sessions.set(s.id, s);
this.ensureSweeper();
}
get(id: string): McpSession | undefined {
return this.sessions.get(id);
}
/** 从表里摘掉并关闭传输(transport.close 会触发 onsessionclosed,重复调用无害) */
async remove(id: string, reason: string): Promise<void> {
const s = this.sessions.get(id);
if (!s) return;
this.sessions.delete(id);
this.onClose?.(s, reason);
await s.server.close().catch(() => {});
}
/** 回收空闲会话 */
async sweep(now = Date.now()): Promise<void> {
for (const s of [...this.sessions.values()]) {
if (now - s.lastSeenAt.getTime() > MCP_LIMITS.idleMs) await this.remove(s.id, "idle");
}
this.toolCalls.sweep();
this.requests.sweep();
}
async closeAll(): Promise<void> {
if (this.timer) clearInterval(this.timer);
this.timer = null;
for (const id of [...this.sessions.keys()]) await this.remove(id, "shutdown");
}
list(): McpSessionInfo[] {
return [...this.sessions.values()]
.sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime())
.map((s) => ({
idPrefix: s.id.slice(0, 8),
apiKeyId: s.principalId,
apiKeyName: s.principalName,
clientName: s.clientName,
createdAt: s.createdAt.toISOString(),
lastSeenAt: s.lastSeenAt.toISOString(),
toolCalls: s.toolCalls,
}));
}
private ensureSweeper() {
if (this.timer) return;
this.timer = setInterval(() => void this.sweep(), 60_000);
// 不因为回收定时器挂住进程退出
(this.timer as unknown as { unref?: () => void }).unref?.();
if (typeof Deno !== "undefined") Deno.unrefTimer(this.timer as unknown as number);
}
}