Files
UHD_Media_Tool/apps/server/src/modules/mcp/server.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

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

189 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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:
"工具与控制台、内置智能体共用同一套能力、授权与审计。只读工具可以直接调用;有副作用的工具需要持续授权或单次审批,缺授权时返回 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");