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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:28:34 +08:00
1 parent 73ee3c6614
commit 653100ceb4
10 files changed
+1226 -1

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
// @ts-types="@modelcontextprotocol/sdk/server/auth/types"
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
// @ts-types="@modelcontextprotocol/sdk/server/index"
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
// @ts-types="@modelcontextprotocol/sdk/types"
import {
CallToolRequestSchema,
type CallToolResult,
ErrorCode,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
McpError,
ReadResourceRequestSchema,
type Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { type CapabilityRegistry, isCapabilityError, redactForModel, toolNameToCapabilityId } from "@umt/agent";
import { type CapabilityKind, MCP_SERVER_NAME, mcpToolVisible } from "@umt/contracts";
import { sharedInvokeLimiter, UpstreamBusyError, UpstreamTimeoutError } from "../agent/invoke-limits.ts";
import type { ServerContext } from "../types.ts";
import { MCP_PROMPTS, MCP_RESOURCES, renderPrompt } from "./catalog.ts";
import type { McpSessionTable } from "./sessions.ts";
// 一个 MCP 会话对应一个 Server 实例。tools 就是能力注册中心里对这个 Key 可见的能力,
// 调用一律走 ctx.capabilities.invoke(source = "mcp"):授权回调、审计、服务端幂等键、操作账本与 Web / Agent 同一条路径。
// 模型或客户端参数里的"已获同意"不起作用,授权只看 automation_grant / approval 记录。
const KIND_LABEL: Record<CapabilityKind, string> = {
read: "只读",
preview: "预览,不落变更",
propose: "提案,需要授权",
approve: "审批,需要授权",
execute: "执行,有副作用,需要持续授权或单次审批",
};
/** 请求时的调用者(每个请求都重新鉴权,scope 以当前请求为准) */
export interface McpCaller {
kind: string;
id: string;
name: string;
scopes: string[];
}
export function callerOf(auth: AuthInfo | undefined): McpCaller {
const extra = (auth?.extra ?? {}) as { kind?: string; name?: string };
return {
kind: extra.kind ?? "api_key",
id: auth?.clientId ?? "?",
name: extra.name ?? "?",
scopes: auth?.scopes ?? [],
};
}
export const actorIdOfCaller = (c: McpCaller) => (c.kind === "api_key" ? `api_key:${c.id}` : `user:${c.id}`);
/** 对这组 scope 可见的 MCP 工具 */
export function visibleTools(registry: CapabilityRegistry, scopes: readonly string[]): Tool[] {
return registry
.list()
.filter((c) => mcpToolVisible(c.descriptor, scopes))
.map(({ descriptor: d, toolName }) => {
const schema = registry.inputJsonSchema(d.id);
return {
name: toolName,
title: d.title,
description: [
d.description,
"",
`能力 ${d.id} v${d.version};${KIND_LABEL[d.kind]};风险 ${d.riskLevel};scope ${d.scope}。`,
].join("\n"),
inputSchema: { ...schema, type: "object" } as Tool["inputSchema"],
annotations: {
title: d.title,
readOnlyHint: d.kind === "read" || d.kind === "preview",
destructiveHint: d.kind === "execute" && d.riskLevel === "high",
idempotentHint: d.idempotent,
openWorldHint: false,
},
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
const errorResult = (code: string, message: string): CallToolResult => ({
isError: true,
content: [{ type: "text", text: JSON.stringify({ error: { code, message } }) }],
});
export function createMcpServer(
ctx: ServerContext,
table: McpSessionTable,
version: string,
getSessionId: () => string | undefined,
): Server {
const server = new Server(
{ name: MCP_SERVER_NAME, title: "UHD 媒体自动化", version },
{
capabilities: {
tools: { listChanged: false },
resources: { listChanged: false },
prompts: { listChanged: false },
},
instructions:
"工具与控制台、内置 Agent 共用同一套能力、授权与审计。只读工具可以直接调用;有副作用的工具需要持续授权或单次审批,缺授权时返回 unauthorized,不要重复尝试。资源里有设置概要、待处理事项与订阅总览。",
},
);
const limiter = sharedInvokeLimiter();
server.setRequestHandler(ListToolsRequestSchema, (_req, extra) => ({
tools: visibleTools(ctx.capabilities, callerOf(extra.authInfo).scopes),
}));
server.setRequestHandler(CallToolRequestSchema, async (req, extra): Promise<CallToolResult> => {
const caller = callerOf(extra.authInfo);
const id = toolNameToCapabilityId(req.params.name);
const entry = id ? ctx.capabilities.get(id) : undefined;
if (!id || !entry) return errorResult("not_found", `没有这个工具:${req.params.name}`);
if (!mcpToolVisible(entry.descriptor, caller.scopes)) {
return errorResult("forbidden", `当前 API Key 的 scope 不能调用 ${id}(需要 ${entry.descriptor.scope})`);
}
if (!table.toolCalls.take(caller.id)) {
return errorResult("rate_limited", "工具调用太频繁,超过每分钟上限。稍后重试");
}
const sessionId = extra.sessionId ?? getSessionId() ?? "nosession";
const session = table.get(sessionId);
if (session) session.toolCalls++;
const allowedKinds: CapabilityKind[] = ["read", "preview", "propose", "approve", "execute"].filter((k) =>
mcpToolVisible({ ...entry.descriptor, kind: k as CapabilityKind }, caller.scopes),
) as CapabilityKind[];
try {
const input = req.params.arguments ?? {};
const output = await limiter.run(id, input, extra.signal, (signal) =>
ctx.capabilities.invoke(id, input, {
actorId: actorIdOfCaller(caller),
// 幂等键按会话归属:同一会话同参数的有副作用调用只执行一次(账本直接返回上次结果)
taskId: `mcp:${sessionId}`,
scopeIds: [],
signal: signal ?? extra.signal,
source: "mcp",
invocationId: `mcp:${sessionId}:${String(extra.requestId)}`,
allowedKinds,
}),
);
const safe = redactForModel(output);
const structured =
safe && typeof safe === "object" && !Array.isArray(safe) ? (safe as Record<string, unknown>) : undefined;
return {
content: [{ type: "text", text: JSON.stringify(safe ?? null) }],
...(structured ? { structuredContent: structured } : {}),
};
} catch (err) {
if (err instanceof UpstreamBusyError) return errorResult("rate_limited", err.message);
if (err instanceof UpstreamTimeoutError) return errorResult("upstream_unavailable", err.message);
if (isCapabilityError(err)) return errorResult(err.code, err.message);
ctx.logger.error({ err, capability: id }, "mcp tool call failed");
return errorResult("internal", "服务器内部错误");
}
});
server.setRequestHandler(ListResourcesRequestSchema, (_req, extra) => {
const caller = callerOf(extra.authInfo);
return { resources: hasReadScope(caller.scopes) ? MCP_RESOURCES.map(({ load: _, ...r }) => r) : [] };
});
server.setRequestHandler(ReadResourceRequestSchema, async (req, extra) => {
const caller = callerOf(extra.authInfo);
const r = MCP_RESOURCES.find((x) => x.uri === req.params.uri);
if (!r) throw new McpError(ErrorCode.InvalidParams, `没有这个资源:${req.params.uri}`);
if (!hasReadScope(caller.scopes)) throw new McpError(ErrorCode.InvalidRequest, "读取资源需要 read scope");
const data = redactForModel(await r.load(ctx));
return { contents: [{ uri: r.uri, mimeType: r.mimeType, text: JSON.stringify(data, null, 2) }] };
});
server.setRequestHandler(ListPromptsRequestSchema, () => ({
prompts: MCP_PROMPTS.map(({ render: _, ...p }) => p),
}));
server.setRequestHandler(GetPromptRequestSchema, (req) => {
const out = renderPrompt(req.params.name, req.params.arguments ?? {});
if (!("messages" in out)) throw new McpError(ErrorCode.InvalidParams, out.error);
return out;
});
return server;
}
const hasReadScope = (scopes: readonly string[]) => scopes.includes("read") || scopes.includes("admin");