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
@@ -3,6 +3,7 @@ import { agentModule } from "./agent/index.ts";
|
||||
import { authSettingsModule } from "./auth-settings/index.ts";
|
||||
import { browserModule } from "./browser/index.ts";
|
||||
import { fileManagerModule } from "./file-manager/index.ts";
|
||||
import { mcpModule } from "./mcp/index.ts";
|
||||
import { piModule } from "./pi/index.ts";
|
||||
import { schedulesModule } from "./schedules/index.ts";
|
||||
import { searchModule } from "./search/index.ts";
|
||||
@@ -26,5 +27,6 @@ export const SERVER_MODULES: readonly ServerModule[] = [
|
||||
schedulesModule,
|
||||
searchModule,
|
||||
browserModule,
|
||||
mcpModule,
|
||||
systemModule,
|
||||
];
|
||||
@@ -0,0 +1,186 @@
|
||||
// @ts-types="@modelcontextprotocol/sdk/types"
|
||||
import type { GetPromptResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { sql } from "@umt/db";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// MCP 资源(只读摘要)与提示模板。资源内容出服务端前再过一遍 redactForModel(见 server.ts)。
|
||||
|
||||
export interface McpResourceDef {
|
||||
uri: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
mimeType: "application/json";
|
||||
load(ctx: ServerContext): Promise<unknown>;
|
||||
}
|
||||
|
||||
const OPEN_DECISION = ["open", "investigating", "awaiting_user"] as const;
|
||||
|
||||
export const MCP_RESOURCES: readonly McpResourceDef[] = [
|
||||
{
|
||||
uri: "umt://settings/summary",
|
||||
name: "settings-summary",
|
||||
title: "设置概要",
|
||||
description: "自动化总开关、只读对账、预算与 Agent 运行上限。不含凭证。",
|
||||
mimeType: "application/json",
|
||||
async load(ctx) {
|
||||
const s = await ctx.settings.all();
|
||||
return {
|
||||
automation: s.automation,
|
||||
budget: s.budget,
|
||||
agent: {
|
||||
maxToolCalls: s.agent.maxToolCalls,
|
||||
maxDurationSec: s.agent.maxDurationSec,
|
||||
autoInvestigate: s.agent.autoInvestigate,
|
||||
autoInvestigateMode: s.agent.autoInvestigateMode,
|
||||
},
|
||||
organize: { defaultMode: s.organize.defaultMode, allowCrossVolumeMove: s.organize.allowCrossVolumeMove },
|
||||
retention: s.retention,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
uri: "umt://decisions/pending",
|
||||
name: "decisions-pending",
|
||||
title: "待处理",
|
||||
description: "未结束的待处理事项(最多 50 条),按严重程度与时间排序。",
|
||||
mimeType: "application/json",
|
||||
async load(ctx) {
|
||||
const rows = await ctx.db
|
||||
.selectFrom("decision")
|
||||
.select(["id", "kind", "status", "severity", "title", "summary", "subject_type", "subject_id", "created_at"])
|
||||
.where("status", "in", OPEN_DECISION)
|
||||
.orderBy(sql`case severity when 'critical' then 0 when 'warning' then 1 else 2 end`)
|
||||
.orderBy("created_at", "desc")
|
||||
.limit(50)
|
||||
.execute();
|
||||
const total = await ctx.db
|
||||
.selectFrom("decision")
|
||||
.select((eb) => eb.fn.countAll<number>().as("n"))
|
||||
.where("status", "in", OPEN_DECISION)
|
||||
.executeTakeFirst();
|
||||
return {
|
||||
total: Number(total?.n ?? 0),
|
||||
items: rows.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
status: r.status,
|
||||
severity: r.severity,
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
subject: r.subject_type ? { type: r.subject_type, id: r.subject_id } : null,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
uri: "umt://subscriptions/overview",
|
||||
name: "subscriptions-overview",
|
||||
title: "订阅总览",
|
||||
description: "各状态的订阅数与最近更新的 20 个订阅。",
|
||||
mimeType: "application/json",
|
||||
async load(ctx) {
|
||||
const counts = await ctx.db
|
||||
.selectFrom("subscription")
|
||||
.select(["status", (eb) => eb.fn.countAll<number>().as("n")])
|
||||
.groupBy("status")
|
||||
.execute();
|
||||
const recent = await ctx.db
|
||||
.selectFrom("subscription as s")
|
||||
.leftJoin("media_entity as e", "e.id", "s.media_entity_id")
|
||||
.select([
|
||||
"s.id",
|
||||
"s.status",
|
||||
"s.media_kind",
|
||||
"s.title_query",
|
||||
"e.title",
|
||||
"e.year",
|
||||
"s.last_searched_at",
|
||||
"s.next_search_at",
|
||||
"s.updated_at",
|
||||
])
|
||||
.orderBy("s.updated_at", "desc")
|
||||
.limit(20)
|
||||
.execute();
|
||||
return {
|
||||
byStatus: Object.fromEntries(counts.map((c) => [c.status, Number(c.n)])),
|
||||
recent: recent.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title ?? r.title_query,
|
||||
year: r.year,
|
||||
mediaKind: r.media_kind,
|
||||
status: r.status,
|
||||
lastSearchedAt: r.last_searched_at?.toISOString() ?? null,
|
||||
nextSearchAt: r.next_search_at?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface McpPromptDef {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
arguments: { name: string; description: string; required: boolean }[];
|
||||
render(args: Record<string, string>): string;
|
||||
}
|
||||
|
||||
export const MCP_PROMPTS: readonly McpPromptDef[] = [
|
||||
{
|
||||
name: "triage_pending",
|
||||
title: "梳理待处理",
|
||||
description: "读待处理事项,逐条给出结论和下一步。",
|
||||
arguments: [],
|
||||
render: () =>
|
||||
[
|
||||
"读资源 umt://decisions/pending,逐条处理待处理事项:",
|
||||
"1. 用 task__inspect、catalog__lookup、release__search 等只读工具查清原因;",
|
||||
"2. 每条给出结论(能自动恢复 / 需要人工决定 / 可以忽略)和依据;",
|
||||
"3. 有副作用的操作(重试任务、提交下载、整理)只在已有持续授权时调用,返回 unauthorized 就停下,列出需要的审批。",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
name: "acquire_title",
|
||||
title: "收录一部作品",
|
||||
description: "从查作品到生成下载计划;提交下载要有授权。",
|
||||
arguments: [
|
||||
{ name: "title", description: "作品标题", required: true },
|
||||
{ name: "year", description: "年份(可选)", required: false },
|
||||
],
|
||||
render: (a) =>
|
||||
[
|
||||
`目标:收录「${a.title ?? ""}」${a.year ? `(${a.year})` : ""}。`,
|
||||
"1. catalog__lookup 确认作品,拿不准时列出候选,不要猜;",
|
||||
"2. release__search 找资源,release__inspect 看细节;",
|
||||
"3. download__plan 生成下载计划并说明选择理由;",
|
||||
"4. 有持续授权时再调用 download__submit;返回 unauthorized 就停下,给出计划 ID 等审批。",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
name: "diagnose_task",
|
||||
title: "诊断任务",
|
||||
description: "查看业务任务的状态和操作记录,说明失败原因与恢复办法。",
|
||||
arguments: [{ name: "taskId", description: "业务任务 ID", required: true }],
|
||||
render: (a) =>
|
||||
[
|
||||
`用 task__inspect 查看任务 ${a.taskId ?? ""}:`,
|
||||
"1. 说明当前状态、失败在哪一步、错误原文;",
|
||||
"2. 判断重试能否解决;能解决且有持续授权时调用 task__retry,否则说明需要的操作;",
|
||||
"3. 不要编造任务里没有的信息。",
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
|
||||
/** 渲染提示模板;模板不存在或缺必填参数时返回错误说明 */
|
||||
export function renderPrompt(name: string, args: Record<string, string>): GetPromptResult | { error: string } {
|
||||
const p = MCP_PROMPTS.find((x) => x.name === name);
|
||||
if (!p) return { error: `没有这个提示模板:${name}` };
|
||||
const missing = p.arguments.filter((a) => a.required && !args[a.name]?.trim()).map((a) => a.name);
|
||||
if (missing.length) return { error: `缺少参数:${missing.join("、")}` };
|
||||
return {
|
||||
description: p.description,
|
||||
messages: [{ role: "user", content: { type: "text", text: p.render(args) } }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { McpServerConfig, McpServerConfigUpdate } from "@umt/contracts";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
|
||||
// mcp_server_config 单行读写。读有 5s 缓存(每个 MCP 请求都要看启用开关与允许的 Key),写后立即失效。
|
||||
|
||||
const TTL_MS = 5_000;
|
||||
|
||||
export interface McpConfigStore {
|
||||
get(): Promise<McpServerConfig>;
|
||||
update(patch: McpServerConfigUpdate, actorId: string): Promise<McpServerConfig>;
|
||||
}
|
||||
|
||||
const DEFAULT: McpServerConfig = { enabled: true, allowedApiKeyIds: null, updatedAt: null };
|
||||
|
||||
export function createMcpConfigStore(db: () => Kysely<DB>): McpConfigStore {
|
||||
let cache: { at: number; value: McpServerConfig } | null = null;
|
||||
const read = async (): Promise<McpServerConfig> => {
|
||||
const r = await db().selectFrom("mcp_server_config").selectAll().where("id", "=", 1).executeTakeFirst();
|
||||
if (!r) return DEFAULT;
|
||||
return { enabled: r.enabled, allowedApiKeyIds: r.allowed_api_key_ids, updatedAt: r.updated_at.toISOString() };
|
||||
};
|
||||
return {
|
||||
async get() {
|
||||
if (cache && Date.now() - cache.at < TTL_MS) return cache.value;
|
||||
const value = await read();
|
||||
cache = { at: Date.now(), value };
|
||||
return value;
|
||||
},
|
||||
async update(patch, actorId) {
|
||||
const set = {
|
||||
...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
|
||||
...(patch.allowedApiKeyIds !== undefined
|
||||
? { allowed_api_key_ids: patch.allowedApiKeyIds ? [...new Set(patch.allowedApiKeyIds)] : null }
|
||||
: {}),
|
||||
updated_by: actorId,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
await db()
|
||||
.insertInto("mcp_server_config")
|
||||
.values({ id: 1, ...set })
|
||||
.onConflict((oc) => oc.column("id").doUpdateSet(set))
|
||||
.execute();
|
||||
cache = null;
|
||||
return read();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 这个 Key 能不能连 MCP:总开关开着,且在允许列表里(列表为 null 表示不限) */
|
||||
export function keyAllowed(cfg: McpServerConfig, principal: { kind: string; id: string }): string | null {
|
||||
if (!cfg.enabled) return "MCP 服务端已停用。在设置 → Agent → 工具与 MCP 里启用";
|
||||
if (principal.kind === "api_key" && cfg.allowedApiKeyIds && !cfg.allowedApiKeyIds.includes(principal.id)) {
|
||||
return "这个 API Key 不在 MCP 允许列表里";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import denoJson from "../../../deno.json" with { type: "json" };
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { createMcpConfigStore } from "./config-store.ts";
|
||||
import { registerMcpRoutes } from "./routes.ts";
|
||||
import { McpSessionTable } from "./sessions.ts";
|
||||
|
||||
/**
|
||||
* AG-1 对外 MCP 服务端(REQ R13、§12.3):/mcp 走 Streamable HTTP,API Key 鉴权,
|
||||
* 每个工具对应一个能力,调用走同一个 registry.invoke(不绕过授权);另提供能力统计、能力清单与 MCP 状态/配置。
|
||||
* 会话表在进程内(api / all 角色),停机时全部关闭。
|
||||
*/
|
||||
export function createMcpModule(opts: { version?: string } = {}): ServerModule & { sessions: McpSessionTable } {
|
||||
const table = new McpSessionTable();
|
||||
return {
|
||||
name: "mcp",
|
||||
sessions: table,
|
||||
registerRoutes(app, ctx) {
|
||||
registerMcpRoutes(app, ctx, {
|
||||
table,
|
||||
config: createMcpConfigStore(() => ctx.db),
|
||||
version: opts.version ?? denoJson.version,
|
||||
});
|
||||
},
|
||||
registerTasks() {},
|
||||
async stop() {
|
||||
await table.closeAll();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const mcpModule = createMcpModule();
|
||||
@@ -0,0 +1,179 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
// @ts-types="@modelcontextprotocol/sdk/server/auth/types"
|
||||
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
// @ts-types="@modelcontextprotocol/sdk/server/webStandardStreamableHttp"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
// @ts-types="@modelcontextprotocol/sdk/types"
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { API_PREFIX, Api, type ApiScope, MCP_PATH, MCP_SERVER_NAME } from "@umt/contracts";
|
||||
import type { Context } from "hono";
|
||||
import type { AuthEnv } from "../../http/auth.ts";
|
||||
import { route } from "../agent/http.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import { keyAllowed, type McpConfigStore } from "./config-store.ts";
|
||||
import { createMcpServer, visibleTools } from "./server.ts";
|
||||
import { MCP_LIMITS, type McpSessionTable } from "./sessions.ts";
|
||||
import { capabilityStats, capabilityTools, recentMcpCalls } from "./stats.ts";
|
||||
|
||||
// POST/GET/DELETE /mcp:Streamable HTTP(不进 OpenAPI);其余为能力统计、能力清单与 MCP 状态/配置。
|
||||
|
||||
/** JSON-RPC 错误信封(HTTP 层拒绝时用;MCP 客户端按 JSON-RPC 解析) */
|
||||
function rpcError(c: Context<AuthEnv>, status: 400 | 401 | 403 | 404 | 429 | 503, message: string) {
|
||||
return c.json({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }, status);
|
||||
}
|
||||
|
||||
export interface McpRouteDeps {
|
||||
table: McpSessionTable;
|
||||
config: McpConfigStore;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/** 常见 Key 组合 → 可见工具数(键名给界面用) */
|
||||
const SCOPE_SAMPLES: Record<"read" | "readExecute" | "admin", ApiScope[]> = {
|
||||
read: ["read"],
|
||||
readExecute: ["read", "organize.execute"],
|
||||
admin: ["admin"],
|
||||
};
|
||||
|
||||
export function registerMcpRoutes(app: ModuleApp, ctx: ServerContext, deps: McpRouteDeps) {
|
||||
const { table, config, version } = deps;
|
||||
|
||||
app.all(MCP_PATH, async (c) => {
|
||||
const p = c.get("principal");
|
||||
if (!p) return rpcError(c, 401, "需要 Bearer API Key");
|
||||
if (p.kind === "admin_session") return rpcError(c, 403, "MCP 只接受 API Key(Authorization: Bearer)");
|
||||
const denied = keyAllowed(await config.get(), p);
|
||||
if (denied) return rpcError(c, 403, denied);
|
||||
if (!table.requests.take(p.id)) return rpcError(c, 429, "请求太频繁,超过每分钟上限。稍后重试");
|
||||
const authInfo: AuthInfo = { token: "", clientId: p.id, scopes: p.scopes, extra: { kind: p.kind, name: p.name } };
|
||||
|
||||
const sid = c.req.header("mcp-session-id");
|
||||
if (sid) {
|
||||
const s = table.get(sid);
|
||||
if (!s) return rpcError(c, 404, "会话不存在或已过期。重新初始化");
|
||||
if (s.principalId !== p.id) return rpcError(c, 403, "这个会话属于另一个 API Key");
|
||||
s.lastSeenAt = new Date();
|
||||
const res = await s.transport.handleRequest(c.req.raw, { authInfo });
|
||||
if (c.req.method === "DELETE") await table.remove(sid, "client");
|
||||
return res;
|
||||
}
|
||||
|
||||
if (c.req.method !== "POST") return rpcError(c, 400, "缺少 Mcp-Session-Id。先发 initialize");
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return rpcError(c, 400, "请求体不是合法的 JSON");
|
||||
}
|
||||
const init = Array.isArray(body) ? body.find(isInitializeRequest) : isInitializeRequest(body) ? body : null;
|
||||
if (!init) return rpcError(c, 400, "缺少 Mcp-Session-Id。先发 initialize");
|
||||
if (table.countFor(p.id) >= MCP_LIMITS.maxSessionsPerKey) {
|
||||
return rpcError(c, 429, `这个 API Key 的会话数已达上限(${MCP_LIMITS.maxSessionsPerKey})。先关闭不用的会话`);
|
||||
}
|
||||
if (table.sessions.size >= MCP_LIMITS.maxSessions) return rpcError(c, 503, "MCP 会话数已满。稍后重试");
|
||||
|
||||
const clientName = (init.params.clientInfo?.name ?? null) as string | null;
|
||||
let sessionId: string | undefined;
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
sessionId = id;
|
||||
const now = new Date();
|
||||
table.add({
|
||||
id,
|
||||
transport,
|
||||
server,
|
||||
principalKind: p.kind,
|
||||
principalId: p.id,
|
||||
principalName: p.name,
|
||||
clientName,
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
toolCalls: 0,
|
||||
});
|
||||
void audit(ctx, p, "mcp.session.open", id, { clientName });
|
||||
},
|
||||
onsessionclosed: (id) => void table.remove(id, "client"),
|
||||
});
|
||||
const server = createMcpServer(ctx, table, version, () => sessionId);
|
||||
await server.connect(transport);
|
||||
return transport.handleRequest(c.req.raw, { parsedBody: body, authInfo });
|
||||
});
|
||||
|
||||
route(
|
||||
app,
|
||||
Api.capabilities.stats,
|
||||
async ({ query }) => capabilityStats(ctx.db, (query as { days: number }).days ?? 7),
|
||||
{ tag: "mcp" },
|
||||
);
|
||||
route(app, Api.capabilities.tools, async () => capabilityTools(ctx.db, ctx.capabilities, ctx.settings), {
|
||||
tag: "mcp",
|
||||
});
|
||||
route(
|
||||
app,
|
||||
Api.mcp.status,
|
||||
async () => {
|
||||
const cfg = await config.get();
|
||||
return {
|
||||
...cfg,
|
||||
path: `${API_PREFIX}${MCP_PATH}`,
|
||||
transport: "streamable-http" as const,
|
||||
serverName: MCP_SERVER_NAME,
|
||||
serverVersion: version,
|
||||
rateLimitPerMinute: MCP_LIMITS.toolCallsPerMinute,
|
||||
maxSessionsPerKey: MCP_LIMITS.maxSessionsPerKey,
|
||||
sessions: table.list(),
|
||||
recentCalls: await recentMcpCalls(ctx.db),
|
||||
toolsByScope: {
|
||||
read: visibleTools(ctx.capabilities, SCOPE_SAMPLES.read).length,
|
||||
readExecute: visibleTools(ctx.capabilities, SCOPE_SAMPLES.readExecute).length,
|
||||
admin: visibleTools(ctx.capabilities, SCOPE_SAMPLES.admin).length,
|
||||
},
|
||||
};
|
||||
},
|
||||
{ tag: "mcp" },
|
||||
);
|
||||
route(
|
||||
app,
|
||||
Api.mcp.update,
|
||||
async ({ body, actorId, c }) => {
|
||||
const before = await config.get();
|
||||
const after = await config.update(body, actorId);
|
||||
const p = c.get("principal");
|
||||
await audit(ctx, p, "mcp.config.update", "mcp", {
|
||||
before: { enabled: before.enabled, allowedApiKeyIds: before.allowedApiKeyIds },
|
||||
after: { enabled: after.enabled, allowedApiKeyIds: after.allowedApiKeyIds },
|
||||
});
|
||||
// 停用或缩小允许列表时,立即断开不再允许的会话
|
||||
for (const s of [...table.sessions.values()]) {
|
||||
if (keyAllowed(after, { kind: s.principalKind, id: s.principalId })) await table.remove(s.id, "config");
|
||||
}
|
||||
return after;
|
||||
},
|
||||
{ tag: "mcp" },
|
||||
);
|
||||
}
|
||||
|
||||
async function audit(
|
||||
ctx: ServerContext,
|
||||
p: { kind: string; id: string } | undefined,
|
||||
action: string,
|
||||
targetId: string,
|
||||
payload: object,
|
||||
) {
|
||||
try {
|
||||
await ctx.db
|
||||
.insertInto("audit_event")
|
||||
.values({
|
||||
actor_type: p?.kind === "api_key" ? "api_key" : "user",
|
||||
actor_id: p ? `${p.kind === "api_key" ? "api_key" : "user"}:${p.id}` : null,
|
||||
action,
|
||||
target_type: "mcp",
|
||||
target_id: targetId,
|
||||
payload: JSON.stringify(payload),
|
||||
})
|
||||
.execute();
|
||||
} catch (err) {
|
||||
ctx.logger.warn({ err, action }, "mcp audit failed");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -0,0 +1,133 @@
|
||||
// @ts-types="@modelcontextprotocol/sdk/server/index"
|
||||
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
// @ts-types="@modelcontextprotocol/sdk/server/webStandardStreamableHttp"
|
||||
import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import type { McpSessionInfo } from "@umt/contracts";
|
||||
|
||||
// MCP 会话表(进程内):会话与建立它的 API Key 绑定,换 Key 复用会话一律拒绝。
|
||||
// 空闲超时回收;每个 Key 的会话数与每分钟工具调用数有上限。
|
||||
|
||||
export const MCP_LIMITS = {
|
||||
/** 每个 API Key 每分钟的工具调用上限 */
|
||||
toolCallsPerMinute: 60,
|
||||
/** 每个 API Key 每分钟的 HTTP 请求上限(含 list / resources / 推送流) */
|
||||
requestsPerMinute: 600,
|
||||
/** 每个 API Key 同时保持的会话上限 */
|
||||
maxSessionsPerKey: 8,
|
||||
/** 全进程会话上限 */
|
||||
maxSessions: 256,
|
||||
/** 空闲多久回收(ms) */
|
||||
idleMs: 30 * 60_000,
|
||||
} as const;
|
||||
|
||||
export interface McpSession {
|
||||
id: string;
|
||||
transport: WebStandardStreamableHTTPServerTransport;
|
||||
server: Server;
|
||||
principalKind: string;
|
||||
principalId: string;
|
||||
principalName: string;
|
||||
clientName: string | null;
|
||||
createdAt: Date;
|
||||
lastSeenAt: Date;
|
||||
toolCalls: number;
|
||||
}
|
||||
|
||||
/** 滑动窗口计数器:key → 最近一分钟的时间戳 */
|
||||
export class RateWindow {
|
||||
private readonly hits = new Map<string, number[]>();
|
||||
constructor(
|
||||
readonly limit: number,
|
||||
readonly windowMs = 60_000,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
/** 记一次;超限返回 false(不计入) */
|
||||
take(key: string): boolean {
|
||||
const t = this.now();
|
||||
const arr = (this.hits.get(key) ?? []).filter((x) => t - x < this.windowMs);
|
||||
if (arr.length >= this.limit) {
|
||||
this.hits.set(key, arr);
|
||||
return false;
|
||||
}
|
||||
arr.push(t);
|
||||
this.hits.set(key, arr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 清掉整窗口都没有记录的 key */
|
||||
sweep(): void {
|
||||
const t = this.now();
|
||||
for (const [k, arr] of this.hits) if (arr.every((x) => t - x >= this.windowMs)) this.hits.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
export class McpSessionTable {
|
||||
readonly sessions = new Map<string, McpSession>();
|
||||
readonly toolCalls = new RateWindow(MCP_LIMITS.toolCallsPerMinute);
|
||||
readonly requests = new RateWindow(MCP_LIMITS.requestsPerMinute);
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(private readonly onClose?: (s: McpSession, reason: string) => void) {}
|
||||
|
||||
countFor(principalId: string): number {
|
||||
let n = 0;
|
||||
for (const s of this.sessions.values()) if (s.principalId === principalId) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
add(s: McpSession): void {
|
||||
this.sessions.set(s.id, s);
|
||||
this.ensureSweeper();
|
||||
}
|
||||
|
||||
get(id: string): McpSession | undefined {
|
||||
return this.sessions.get(id);
|
||||
}
|
||||
|
||||
/** 从表里摘掉并关闭传输(transport.close 会触发 onsessionclosed,重复调用无害) */
|
||||
async remove(id: string, reason: string): Promise<void> {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) return;
|
||||
this.sessions.delete(id);
|
||||
this.onClose?.(s, reason);
|
||||
await s.server.close().catch(() => {});
|
||||
}
|
||||
|
||||
/** 回收空闲会话 */
|
||||
async sweep(now = Date.now()): Promise<void> {
|
||||
for (const s of [...this.sessions.values()]) {
|
||||
if (now - s.lastSeenAt.getTime() > MCP_LIMITS.idleMs) await this.remove(s.id, "idle");
|
||||
}
|
||||
this.toolCalls.sweep();
|
||||
this.requests.sweep();
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
for (const id of [...this.sessions.keys()]) await this.remove(id, "shutdown");
|
||||
}
|
||||
|
||||
list(): McpSessionInfo[] {
|
||||
return [...this.sessions.values()]
|
||||
.sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime())
|
||||
.map((s) => ({
|
||||
idPrefix: s.id.slice(0, 8),
|
||||
apiKeyId: s.principalId,
|
||||
apiKeyName: s.principalName,
|
||||
clientName: s.clientName,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
lastSeenAt: s.lastSeenAt.toISOString(),
|
||||
toolCalls: s.toolCalls,
|
||||
}));
|
||||
}
|
||||
|
||||
private ensureSweeper() {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => void this.sweep(), 60_000);
|
||||
// 不因为回收定时器挂住进程退出
|
||||
(this.timer as unknown as { unref?: () => void }).unref?.();
|
||||
if (typeof Deno !== "undefined") Deno.unrefTimer(this.timer as unknown as number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { CapabilityRegistry } from "@umt/agent";
|
||||
import {
|
||||
CAPABILITY_CATALOG,
|
||||
type CapabilityAuthState,
|
||||
type CapabilityStatsItem,
|
||||
type CapabilityStatsResponse,
|
||||
type CapabilityToolInfo,
|
||||
type CapabilityToolsResponse,
|
||||
callerOfSource,
|
||||
type KnownCapabilityId,
|
||||
type McpRecentCall,
|
||||
mcpToolVisible,
|
||||
} from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import { z } from "zod";
|
||||
import { GRANT_ACTIONS } from "../agent/authorize.ts";
|
||||
import type { SettingsReader } from "../types.ts";
|
||||
|
||||
// 能力调用统计与能力清单。统计口径是 audit_event(registry.invoke 每次调用都写一条,含只读能力与拒绝);
|
||||
// capability_invocation 只记有副作用的调用,不适合做全量统计。
|
||||
|
||||
const AUDIT_ACTIONS = ["capability.invoke", "capability.denied"];
|
||||
|
||||
export async function capabilityStats(
|
||||
db: Kysely<DB>,
|
||||
days: number,
|
||||
now = new Date(),
|
||||
): Promise<CapabilityStatsResponse> {
|
||||
const since = new Date(now.getTime() - days * 86_400_000);
|
||||
const agg = await sql<{
|
||||
id: string;
|
||||
calls: number;
|
||||
failures: number;
|
||||
denied: number;
|
||||
avg_ms: string | null;
|
||||
last_at: Date;
|
||||
by_agent: number;
|
||||
by_mcp: number;
|
||||
by_rest: number;
|
||||
by_other: number;
|
||||
}>`
|
||||
select target_id as id,
|
||||
count(*)::int as calls,
|
||||
count(*) filter (where payload->>'outcome' in ('error', 'invalid'))::int as failures,
|
||||
count(*) filter (where payload->>'outcome' = 'denied')::int as denied,
|
||||
avg((payload->>'durationMs')::numeric) filter (where payload->>'outcome' <> 'denied') as avg_ms,
|
||||
max(at) as last_at,
|
||||
count(*) filter (where payload->>'source' in ('agent', 'task'))::int as by_agent,
|
||||
count(*) filter (where payload->>'source' = 'mcp')::int as by_mcp,
|
||||
count(*) filter (where payload->>'source' in ('web', 'cli'))::int as by_rest,
|
||||
count(*) filter (where coalesce(payload->>'source', '') not in ('agent', 'task', 'mcp', 'web', 'cli'))::int as by_other
|
||||
from audit_event
|
||||
where target_type = 'capability' and action in (${sql.join(AUDIT_ACTIONS)}) and at >= ${since}
|
||||
group by target_id`.execute(db);
|
||||
const last = await sql<{ id: string; outcome: string | null; source: string | null }>`
|
||||
select distinct on (target_id) target_id as id, payload->>'outcome' as outcome, payload->>'source' as source
|
||||
from audit_event
|
||||
where target_type = 'capability' and action in (${sql.join(AUDIT_ACTIONS)}) and at >= ${since}
|
||||
order by target_id, at desc`.execute(db);
|
||||
const lastBy = new Map(last.rows.map((r) => [r.id, r]));
|
||||
const items: CapabilityStatsItem[] = agg.rows
|
||||
.map((r) => {
|
||||
const l = lastBy.get(r.id);
|
||||
return {
|
||||
capabilityId: r.id,
|
||||
calls: r.calls,
|
||||
failures: r.failures,
|
||||
denied: r.denied,
|
||||
avgDurationMs: r.avg_ms === null ? null : Math.round(Number(r.avg_ms)),
|
||||
lastCalledAt: new Date(r.last_at).toISOString(),
|
||||
lastOutcome: l?.outcome ?? null,
|
||||
lastCaller: l ? callerOfSource(l.source) : null,
|
||||
byCaller: { agent: r.by_agent, mcp: r.by_mcp, rest: r.by_rest, other: r.by_other },
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.calls - a.calls || a.capabilityId.localeCompare(b.capabilityId));
|
||||
return { days, since: since.toISOString(), items };
|
||||
}
|
||||
|
||||
/** 最近的 MCP 来源调用 */
|
||||
export async function recentMcpCalls(db: Kysely<DB>, limit = 20): Promise<McpRecentCall[]> {
|
||||
const rows = await db
|
||||
.selectFrom("audit_event")
|
||||
.select(["at", "target_id", "actor_id", "payload"])
|
||||
.where("target_type", "=", "capability")
|
||||
.where("action", "in", AUDIT_ACTIONS)
|
||||
.where(sql<boolean>`payload->>'source' = 'mcp'`)
|
||||
.orderBy("at", "desc")
|
||||
.limit(limit)
|
||||
.execute();
|
||||
return rows.map((r) => {
|
||||
const p = (r.payload ?? {}) as { outcome?: string; durationMs?: number; errorCode?: string | null };
|
||||
return {
|
||||
at: r.at.toISOString(),
|
||||
capabilityId: r.target_id ?? "",
|
||||
outcome: p.outcome ?? "unknown",
|
||||
actorId: r.actor_id,
|
||||
durationMs: typeof p.durationMs === "number" ? p.durationMs : null,
|
||||
errorCode: p.errorCode ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface GrantRow {
|
||||
action: string;
|
||||
scope: unknown;
|
||||
policy_revision_id: string | null;
|
||||
policy_status: string | null;
|
||||
}
|
||||
|
||||
async function activeGrants(db: Kysely<DB>, now: Date): Promise<GrantRow[]> {
|
||||
return db
|
||||
.selectFrom("automation_grant as g")
|
||||
.leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
|
||||
.select(["g.action", "g.scope", "g.policy_revision_id", "p.status as policy_status"])
|
||||
.where("g.status", "=", "active")
|
||||
.where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)]))
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** 覆盖这个能力的有效持续授权数(与 authorize.ts 同口径:动作覆盖、策略版本仍发布、能力白名单包含) */
|
||||
export function grantsCovering(id: string, grants: readonly GrantRow[]): number {
|
||||
const actions = new Set([...(GRANT_ACTIONS[id] ?? [id]), "*"]);
|
||||
return grants.filter((g) => {
|
||||
if (!actions.has(g.action)) return false;
|
||||
if (g.policy_revision_id && g.policy_status !== "published") return false;
|
||||
const caps = ((g.scope ?? {}) as { capabilities?: string[] }).capabilities;
|
||||
return !caps?.length || caps.includes(id);
|
||||
}).length;
|
||||
}
|
||||
|
||||
function jsonSchema(schema: z.ZodType, io: "input" | "output"): Record<string, unknown> {
|
||||
try {
|
||||
const s = z.toJSONSchema(schema, { io, unrepresentable: "any" }) as Record<string, unknown>;
|
||||
delete s.$schema;
|
||||
return s;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const pascal = (id: string) =>
|
||||
id
|
||||
.split(/[._]/)
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
||||
.join("");
|
||||
|
||||
export async function capabilityTools(
|
||||
db: Kysely<DB>,
|
||||
registry: CapabilityRegistry,
|
||||
settings: SettingsReader,
|
||||
now = new Date(),
|
||||
): Promise<CapabilityToolsResponse> {
|
||||
const automation = await settings.get("automation");
|
||||
const grants = await activeGrants(db, now);
|
||||
const items: CapabilityToolInfo[] = registry
|
||||
.list()
|
||||
.map(({ descriptor: d, toolName }) => {
|
||||
const cat = CAPABILITY_CATALOG[d.id as KnownCapabilityId];
|
||||
const readish = d.kind === "read" || d.kind === "preview";
|
||||
const n = readish ? 0 : grantsCovering(d.id, grants);
|
||||
const authState: CapabilityAuthState = d.id.startsWith("agent.")
|
||||
? "agent_only"
|
||||
: readish
|
||||
? "read_only"
|
||||
: n > 0
|
||||
? "granted"
|
||||
: "needs_approval";
|
||||
return {
|
||||
id: d.id,
|
||||
version: d.version,
|
||||
title: d.title,
|
||||
description: d.description,
|
||||
kind: d.kind,
|
||||
inputSchema: cat?.inputSchema ?? `${pascal(d.id)}Input`,
|
||||
outputSchema: cat?.outputSchema ?? `${pascal(d.id)}Output`,
|
||||
scope: d.scope,
|
||||
riskLevel: d.riskLevel,
|
||||
idempotent: d.idempotent,
|
||||
supportsPreview: d.supportsPreview,
|
||||
replay: d.replay,
|
||||
toolName,
|
||||
inputJsonSchema: jsonSchema(d.inputSchema, "input"),
|
||||
outputJsonSchema: jsonSchema(d.outputSchema, "output"),
|
||||
authState,
|
||||
activeGrants: n,
|
||||
mcpExposed: mcpToolVisible(d, ["admin"]),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return { automationEnabled: automation.enabled, readOnlyReconcile: automation.readOnlyReconcile, items };
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type { CapabilitySpec } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||
import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.ts";
|
||||
import { createMcpModule } from "../../src/modules/mcp/index.ts";
|
||||
import { RateWindow } from "../../src/modules/mcp/sessions.ts";
|
||||
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
|
||||
import { json } from "../helpers.ts";
|
||||
|
||||
// AG-1:对外 MCP 服务端。用官方 SDK client 走 Streamable HTTP 实际连一次:
|
||||
// 工具列表按 API Key scope 过滤、调用 read 能力、execute 能力无授权被拒(同一 registry.invoke → 审计 / 账本)、
|
||||
// 会话与 Key 绑定、启用开关 / 允许列表、资源与提示模板、能力统计与能力清单端点。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
const spec = (over: Partial<CapabilitySpec>) =>
|
||||
({
|
||||
version: 1,
|
||||
title: "t",
|
||||
description: "测试用",
|
||||
idempotent: true,
|
||||
supportsPreview: false,
|
||||
riskLevel: "low",
|
||||
inputSchema: z.object({ text: z.string() }),
|
||||
outputSchema: z.object({ echo: z.string(), token: z.string().optional() }),
|
||||
...over,
|
||||
}) as unknown as CapabilitySpec;
|
||||
|
||||
const ECHO = spec({ id: "demo.echo", kind: "read", scope: "read", replay: "safe" });
|
||||
const APPLY = spec({
|
||||
id: "demo.apply",
|
||||
kind: "execute",
|
||||
scope: "organize.execute",
|
||||
riskLevel: "high",
|
||||
replay: "never",
|
||||
});
|
||||
const SPAWN = spec({ id: "agent.spawn", kind: "propose", scope: "organize.execute", replay: "never" });
|
||||
|
||||
describe.skipIf(!hasPg)("MCP 服务端(真实 PG + SDK client)", () => {
|
||||
let h: Harness;
|
||||
let readKey: string;
|
||||
let execKey: string;
|
||||
let applied = 0;
|
||||
const mcp = createMcpModule({ version: "0.0.0-test" });
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness({ extraModules: [mcp] });
|
||||
// 换成带库的注册中心:真实授权器 + audit_event + 操作账本
|
||||
const reg = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings });
|
||||
reg.register(ECHO, (i) => ({ echo: (i as { text: string }).text, token: "secret-token-value" }));
|
||||
reg.register(APPLY, (i) => {
|
||||
applied++;
|
||||
return { echo: `applied:${(i as { text: string }).text}` };
|
||||
});
|
||||
reg.register(SPAWN, () => ({ echo: "x" }));
|
||||
h.ctx.capabilities = reg;
|
||||
|
||||
const admin = new Browser(h.app);
|
||||
const token = await ensureSetupToken(h.ctx);
|
||||
const r = await admin.req("POST", "/auth/setup", {
|
||||
setupToken: token,
|
||||
username: "admin",
|
||||
password: "correct horse battery",
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const mk = async (name: string, scopes: string[]) =>
|
||||
(await json(await admin.req("POST", "/api-keys", { name, scopes }))).data.plaintextKey as string;
|
||||
readKey = await mk("reader", ["read"]);
|
||||
execKey = await mk("executor", ["read", "organize.execute"]);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mcp.stop?.();
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
async function connect(key: string) {
|
||||
const client = new Client({ name: "vitest-client", version: "1.0.0" });
|
||||
const transport = new StreamableHTTPClientTransport(new URL("http://umt.test/api/v1/mcp"), {
|
||||
requestInit: { headers: { authorization: `Bearer ${key}` } },
|
||||
fetch: async (url, init) => await h.app.fetch(new Request(url, init)),
|
||||
});
|
||||
await client.connect(transport);
|
||||
return { client, transport };
|
||||
}
|
||||
|
||||
it("read Key:只看到 read / preview 工具,调用只读能力拿到脱敏结果,execute 工具被拒", async () => {
|
||||
const { client } = await connect(readKey);
|
||||
const tools = (await client.listTools()).tools.map((t) => t.name);
|
||||
expect(tools).toContain("demo__echo");
|
||||
expect(tools).not.toContain("demo__apply");
|
||||
expect(tools).not.toContain("agent__spawn");
|
||||
const echo = (await client.listTools()).tools.find((t) => t.name === "demo__echo");
|
||||
expect(echo?.inputSchema).toMatchObject({ type: "object", properties: { text: { type: "string" } } });
|
||||
expect(echo?.annotations?.readOnlyHint).toBe(true);
|
||||
|
||||
const ok = await client.callTool({ name: "demo__echo", arguments: { text: "hi" } });
|
||||
expect(ok.isError).toBeFalsy();
|
||||
expect(ok.structuredContent).toMatchObject({ echo: "hi" });
|
||||
expect(JSON.stringify(ok.content)).not.toContain("secret-token-value");
|
||||
|
||||
const bad = await client.callTool({ name: "demo__echo", arguments: { text: 1 } });
|
||||
expect(bad.isError).toBe(true);
|
||||
expect(JSON.stringify(bad.content)).toContain("invalid_input");
|
||||
|
||||
const denied = await client.callTool({ name: "demo__apply", arguments: { text: "x" } });
|
||||
expect(denied.isError).toBe(true);
|
||||
expect(JSON.stringify(denied.content)).toContain("forbidden");
|
||||
expect(applied).toBe(0);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("execute Key:能看到 execute 工具,但没有持续授权时授权器拒绝;有授权后执行并记账本", async () => {
|
||||
const { client } = await connect(execKey);
|
||||
expect((await client.listTools()).tools.map((t) => t.name)).toContain("demo__apply");
|
||||
|
||||
const denied = await client.callTool({ name: "demo__apply", arguments: { text: "a" } });
|
||||
expect(denied.isError).toBe(true);
|
||||
expect(JSON.stringify(denied.content)).toContain("unauthorized");
|
||||
expect(applied).toBe(0);
|
||||
const audits = await sql<{ n: number }>`
|
||||
select count(*)::int as n from audit_event
|
||||
where action = 'capability.denied' and target_id = 'demo.apply' and payload->>'source' = 'mcp'`.execute(h.db);
|
||||
expect(audits.rows[0]!.n).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await h.db.insertInto("automation_grant").values({ action: "demo.apply", granted_by: "user:admin" }).execute();
|
||||
const ok = await client.callTool({ name: "demo__apply", arguments: { text: "a" } });
|
||||
expect(ok.isError).toBeFalsy();
|
||||
expect(ok.structuredContent).toMatchObject({ echo: "applied:a" });
|
||||
// 同一会话同参数:账本返回上次结果,不重复执行
|
||||
await client.callTool({ name: "demo__apply", arguments: { text: "a" } });
|
||||
expect(applied).toBe(1);
|
||||
const ledger = await h.db
|
||||
.selectFrom("capability_invocation")
|
||||
.select(["source", "status", "actor_id"])
|
||||
.where("capability_id", "=", "demo.apply")
|
||||
.execute();
|
||||
expect(ledger).toEqual([{ source: "mcp", status: "succeeded", actor_id: expect.stringMatching(/^api_key:/) }]);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("资源与提示模板", async () => {
|
||||
const { client } = await connect(readKey);
|
||||
const res = (await client.listResources()).resources.map((r) => r.uri);
|
||||
expect(res).toEqual(["umt://settings/summary", "umt://decisions/pending", "umt://subscriptions/overview"]);
|
||||
for (const uri of res) {
|
||||
const c = await client.readResource({ uri });
|
||||
const first = c.contents[0] as { text: string };
|
||||
expect(() => JSON.parse(first.text)).not.toThrow();
|
||||
}
|
||||
const settings = JSON.parse(
|
||||
((await client.readResource({ uri: "umt://settings/summary" })).contents[0] as { text: string }).text,
|
||||
);
|
||||
expect(settings.automation).toMatchObject({ enabled: true });
|
||||
const prompts = (await client.listPrompts()).prompts.map((p) => p.name);
|
||||
expect(prompts).toEqual(["triage_pending", "acquire_title", "diagnose_task"]);
|
||||
const p = await client.getPrompt({ name: "acquire_title", arguments: { title: "沙丘" } });
|
||||
expect(JSON.stringify(p.messages)).toContain("沙丘");
|
||||
await expect(client.getPrompt({ name: "diagnose_task", arguments: {} })).rejects.toThrow(/taskId/);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("会话与 API Key 绑定;cookie 会话、无凭证、停用与允许列表都被拒", async () => {
|
||||
const { client, transport } = await connect(readKey);
|
||||
const sid = transport.sessionId!;
|
||||
expect(sid).toBeTruthy();
|
||||
const hijack = await h.app.request("/api/v1/mcp", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${execKey}`,
|
||||
"mcp-session-id": sid,
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 9, method: "tools/list" }),
|
||||
});
|
||||
expect(hijack.status).toBe(403);
|
||||
expect((await h.app.request("/api/v1/mcp", { method: "POST", body: "{}" })).status).toBe(401);
|
||||
|
||||
const status = await json(await bearer(h.app, execKey)("GET", "/mcp/status"));
|
||||
expect(status.error?.code).toBe("forbidden"); // admin scope
|
||||
|
||||
const admin = new Browser(h.app);
|
||||
await admin.req("POST", "/auth/login", { username: "admin", password: "correct horse battery" });
|
||||
const init = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "x", version: "1" } },
|
||||
};
|
||||
const viaCookie = await admin.req("POST", "/mcp", init, {
|
||||
headers: { accept: "application/json, text/event-stream" },
|
||||
});
|
||||
expect(viaCookie.status).toBe(403);
|
||||
|
||||
const st = (await json(await admin.req("GET", "/mcp/status"))).data;
|
||||
expect(st).toMatchObject({ enabled: true, path: "/api/v1/mcp", transport: "streamable-http" });
|
||||
expect(st.sessions.length).toBeGreaterThanOrEqual(1);
|
||||
expect(st.recentCalls.length).toBeGreaterThan(0);
|
||||
expect(st.toolsByScope.read).toBe(1);
|
||||
|
||||
// 允许列表只放 execKey 之外的 Key → readKey 现有会话立即断开,新连接 403
|
||||
const keys = (await json(await admin.req("GET", "/api-keys?limit=50"))).data.items as {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
const execId = keys.find((k) => k.name === "executor")!.id;
|
||||
const upd = await admin.req("PATCH", "/mcp/config", { allowedApiKeyIds: [execId] });
|
||||
expect(upd.status).toBe(200);
|
||||
await expect(client.listTools()).rejects.toThrow();
|
||||
await expect(connect(readKey)).rejects.toThrow();
|
||||
const exec = await connect(execKey);
|
||||
await exec.client.close();
|
||||
|
||||
await admin.req("PATCH", "/mcp/config", { enabled: false });
|
||||
await expect(connect(execKey)).rejects.toThrow();
|
||||
await admin.req("PATCH", "/mcp/config", { enabled: true, allowedApiKeyIds: null });
|
||||
const again = await connect(readKey);
|
||||
await again.client.close();
|
||||
});
|
||||
|
||||
it("能力统计与能力清单端点", async () => {
|
||||
const get = bearer(h.app, readKey);
|
||||
const stats = (await json(await get("GET", "/capabilities/stats?days=7"))).data;
|
||||
const echo = stats.items.find((i: { capabilityId: string }) => i.capabilityId === "demo.echo");
|
||||
expect(echo).toMatchObject({ lastCaller: "mcp" });
|
||||
expect(echo.calls).toBeGreaterThanOrEqual(2);
|
||||
expect(echo.failures).toBeGreaterThanOrEqual(1);
|
||||
expect(echo.byCaller.mcp).toBe(echo.calls);
|
||||
const apply = stats.items.find((i: { capabilityId: string }) => i.capabilityId === "demo.apply");
|
||||
expect(apply.denied).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const tools = (await json(await get("GET", "/capabilities/tools"))).data;
|
||||
const by = (id: string) => tools.items.find((i: { id: string }) => i.id === id);
|
||||
expect(by("demo.echo")).toMatchObject({ authState: "read_only", toolName: "demo__echo", mcpExposed: true });
|
||||
expect(by("demo.apply")).toMatchObject({ authState: "granted", activeGrants: 1 });
|
||||
expect(by("agent.spawn")).toMatchObject({ authState: "agent_only", mcpExposed: false });
|
||||
expect(by("demo.echo").inputJsonSchema).toMatchObject({ type: "object" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("RateWindow", () => {
|
||||
it("窗口内超限拒绝,窗口滑过后恢复", () => {
|
||||
let t = 0;
|
||||
const w = new RateWindow(2, 1000, () => t);
|
||||
expect(w.take("k")).toBe(true);
|
||||
expect(w.take("k")).toBe(true);
|
||||
expect(w.take("k")).toBe(false);
|
||||
expect(w.take("other")).toBe(true);
|
||||
t = 1001;
|
||||
expect(w.take("k")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ const pingModule: ServerModule = {
|
||||
};
|
||||
|
||||
describe("ServerModule 骨架", () => {
|
||||
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 浏览器 + 进程与健康(末尾),名字唯一", () => {
|
||||
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 浏览器 + MCP + 进程与健康(末尾),名字唯一", () => {
|
||||
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
|
||||
"auth-settings",
|
||||
"sources-identity",
|
||||
@@ -54,6 +54,7 @@ describe("ServerModule 骨架", () => {
|
||||
"schedules",
|
||||
"search",
|
||||
"browser",
|
||||
"mcp",
|
||||
"system",
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user