Files
UHD_Media_Tool/apps/server/test/mcp/mcp.test.ts
T
rootandClaude Opus 5.5 653100ceb4 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]>
2026-09-28 20:28:34 +08:00

258 lines
12 KiB
TypeScript

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);
});
});