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:
1 parent
73ee3c6614
commit
653100ceb4
10 files changed
+1226
-1
No files matched your search
@@ -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");
|
||||
Reference in new issue
Block a user