// @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 = { 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: "工具与控制台、内置智能体共用同一套能力、授权与审计。只读工具可以直接调用;有副作用的工具需要持续授权或单次审批,缺授权时返回 unauthorized,不要重复尝试。资源里有设置概要、待处理事项与订阅总览。", }, ); const limiter = sharedInvokeLimiter(); server.setRequestHandler(ListToolsRequestSchema, (_req, extra) => ({ tools: visibleTools(ctx.capabilities, callerOf(extra.authInfo).scopes), })); server.setRequestHandler(CallToolRequestSchema, async (req, extra): Promise => { 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) : 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");