- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
189 lines
8.0 KiB
TypeScript
189 lines
8.0 KiB
TypeScript
// @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");
|