diff --git a/deno.json b/deno.json index 56596eab..7bcd86b4 100644 --- a/deno.json +++ b/deno.json @@ -22,6 +22,8 @@ "@hono/swagger-ui": "npm:@hono/swagger-ui@0.6.1", "@simplewebauthn/server": "npm:@simplewebauthn/server@13.3.3", "@hono/zod-openapi": "npm:@hono/zod-openapi@1.6.3", + "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@1.30.1", + "@modelcontextprotocol/sdk/": "npm:/@modelcontextprotocol/sdk@1.30.1/", "@types/node": "npm:@types/node@24.19.0", "@types/nunjucks": "npm:@types/nunjucks@3.2.6", "@types/pg": "npm:@types/pg@8.23.1", diff --git a/deno.lock b/deno.lock index 2385626d..eccc5365 100644 --- a/deno.lock +++ b/deno.lock @@ -6,12 +6,13 @@ "npm:@babel/parser@7.29.9": "7.29.9", "npm:@biomejs/biome@*": "2.5.14", "npm:@biomejs/biome@2.5.14": "2.5.14", - "npm:@earendil-works/pi-agent-core@0.87.1": "0.87.1_zod@4.6.5", - "npm:@earendil-works/pi-ai@0.87.1": "0.87.1_zod@4.6.5", + "npm:@earendil-works/pi-agent-core@0.87.1": "0.87.1_@modelcontextprotocol+sdk@1.30.1__zod@4.6.5_zod@4.6.5", + "npm:@earendil-works/pi-ai@0.87.1": "0.87.1_@modelcontextprotocol+sdk@1.30.1__zod@4.6.5_zod@4.6.5", "npm:@fontsource-variable/inter-tight@^5.2.7": "5.3.0", "npm:@fontsource-variable/space-grotesk@^5.2.10": "5.3.0", "npm:@hono/swagger-ui@0.6.1": "0.6.1_hono@4.13.9", "npm:@hono/zod-openapi@1.6.3": "1.6.3_hono@4.13.9_zod@4.6.5", + "npm:@modelcontextprotocol/sdk@1.30.1": "1.30.1_zod@4.6.5", "npm:@react-router/dev@*": "7.12.0_react-router@7.12.0_typescript@7.0.2_vite@7.3.6_@types+node@24.19.0_react@19.3.0_react-dom@19.3.0_yaml@2.9.1", "npm:@react-router/dev@7.12.0": "7.12.0_react-router@7.12.0_typescript@7.0.2_vite@7.3.6_@types+node@24.19.0_react@19.3.0_react-dom@19.3.0_yaml@2.9.1", "npm:@react-router/node@7.12.0": "7.12.0_react-router@7.12.0_typescript@7.0.2_react@19.3.0_react-dom@19.3.0", @@ -635,7 +636,7 @@ "esbuild" ] }, - "@earendil-works/pi-agent-core@0.87.1_zod@4.6.5": { + "@earendil-works/pi-agent-core@0.87.1_@modelcontextprotocol+sdk@1.30.1__zod@4.6.5_zod@4.6.5": { "integrity": "sha512-Zev3B0HK7YS5A4EZQ2XnEqiJuirx6QBiltJ+LpmjV5a/+2IU0cfKtIfnkNkORK707XOvKBY2WRtk7cAwHpbh2Q==", "dependencies": [ "@earendil-works/chord", @@ -647,7 +648,7 @@ "yaml@2.9.0" ] }, - "@earendil-works/pi-ai@0.87.1_zod@4.6.5": { + "@earendil-works/pi-ai@0.87.1_@modelcontextprotocol+sdk@1.30.1__zod@4.6.5_zod@4.6.5": { "integrity": "sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA==", "dependencies": [ "@anthropic-ai/sdk", @@ -826,14 +827,18 @@ "@fontsource-variable/space-grotesk@5.3.0": { "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==" }, - "@google/genai@2.21.0": { + "@google/genai@2.21.0_@modelcontextprotocol+sdk@1.30.1__zod@4.6.5_zod@4.6.5": { "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", "dependencies": [ + "@modelcontextprotocol/sdk@1.30.1_zod@4.6.5", "google-auth-library", "p-retry", "protobufjs", "ws" ], + "optionalPeers": [ + "@modelcontextprotocol/sdk@1.30.1_zod@4.6.5" + ], "scripts": true }, "@hexagon/base64@1.1.28": { @@ -961,8 +966,29 @@ "json-schema-typed@8.0.2", "pkce-challenge", "raw-body", - "zod@3.25.76", - "zod-to-json-schema" + "zod-to-json-schema@3.25.2_zod@3.25.76" + ] + }, + "@modelcontextprotocol/sdk@1.30.1_zod@4.6.5": { + "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "dependencies": [ + "@hono/node-server", + "ajv", + "ajv-formats@3.0.1_ajv@8.20.0", + "content-type@1.0.5", + "cors", + "cross-spawn", + "eventsource", + "eventsource-parser", + "express", + "express-rate-limit", + "hono", + "jose", + "json-schema-typed@8.0.2", + "pkce-challenge", + "raw-body", + "zod@4.6.5", + "zod-to-json-schema@3.25.2_zod@4.6.5" ] }, "@mswjs/interceptors@0.41.9": { @@ -5180,7 +5206,7 @@ "@babel/plugin-transform-typescript", "@babel/preset-typescript", "@dotenvx/dotenvx", - "@modelcontextprotocol/sdk", + "@modelcontextprotocol/sdk@1.30.1_zod@3.25.76", "@types/validate-npm-package-name", "browserslist", "commander@14.0.3", @@ -5208,7 +5234,7 @@ "tsconfig-paths", "validate-npm-package-name", "zod@3.25.76", - "zod-to-json-schema" + "zod-to-json-schema@3.25.2_zod@3.25.76" ], "bin": true }, @@ -5777,9 +5803,12 @@ "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==" }, "zod-to-json-schema@3.25.2_zod@3.25.76": { + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==" + }, + "zod-to-json-schema@3.25.2_zod@4.6.5": { "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "dependencies": [ - "zod@3.25.76" + "zod@4.6.5" ] }, "zod@3.25.76": { @@ -5796,6 +5825,7 @@ "npm:@earendil-works/pi-ai@0.87.1", "npm:@hono/swagger-ui@0.6.1", "npm:@hono/zod-openapi@1.6.3", + "npm:@modelcontextprotocol/sdk@1.30.1", "npm:@simplewebauthn/server@13.3.3", "npm:@types/node@24.19.0", "npm:@types/nunjucks@3.2.6", diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index c5327aa0..e016ec78 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -11,6 +11,7 @@ import * as execution from "./execution.ts"; import * as files from "./files.ts"; import * as identity from "./identity.ts"; import * as instances from "./instances.ts"; +import * as mcp from "./mcp.ts"; import * as ops from "./ops.ts"; import * as organization from "./organization.ts"; import * as pi from "./pi.ts"; @@ -1564,6 +1565,40 @@ export const Api = { body: z.object({ input: z.unknown(), preview: z.boolean().default(false) }), response: z.object({ output: z.unknown(), taskId: Id.nullable(), planId: Id.nullable() }), }), + stats: ep({ + method: "get", + path: "/capabilities/stats", + scope: "read", + summary: "能力调用统计:每能力调用数、失败数、平均耗时、最近调用、调用来源(内置 Agent / MCP / REST)", + query: mcp.CapabilityStatsQuery, + response: mcp.CapabilityStatsResponse, + }), + tools: ep({ + method: "get", + path: "/capabilities/tools", + scope: "read", + summary: "能力清单:描述符、工具名、输入输出 JSON Schema、当前授权状态", + response: mcp.CapabilityToolsResponse, + }), + }, + + // ── 对外 MCP 服务端(Streamable HTTP 在 /mcp,不进 OpenAPI;这里是状态与配置)── + mcp: { + status: ep({ + method: "get", + path: "/mcp/status", + scope: "admin", + summary: "MCP 服务端状态:启用、允许的 API Key、当前会话、最近调用、限速", + response: mcp.McpServerStatus, + }), + update: ep({ + method: "patch", + path: "/mcp/config", + scope: "admin", + summary: "修改 MCP 服务端配置(启用开关、允许的 API Key)", + body: mcp.McpServerConfigUpdate, + response: mcp.McpServerConfig, + }), }, // ── 设置 ── @@ -2328,3 +2363,4 @@ export type EndpointResponse = z.infer; export * from "./files.ts"; export * from "./schedules.ts"; +export * from "./mcp.ts"; diff --git a/packages/contracts/src/mcp.ts b/packages/contracts/src/mcp.ts new file mode 100644 index 00000000..02a12d14 --- /dev/null +++ b/packages/contracts/src/mcp.ts @@ -0,0 +1,162 @@ +import { z } from "zod"; +import { CapabilityDescriptor, type CapabilityKind } from "./capability.ts"; +import { type ApiScope, Id, Timestamp } from "./common.ts"; + +// 对外 MCP 服务端与能力可视化(REQ R13、§10.3、§12.3)。 +// MCP 每个 tool 对应一个已注册能力(工具名 `域__动作`),调用走同一个 registry.invoke(授权 / 审计 / 幂等键 / 操作账本)。 + +/** MCP 端点(相对 API_PREFIX);Streamable HTTP,POST 发消息、GET 开服务端推送流、DELETE 结束会话 */ +export const MCP_PATH = "/mcp"; +/** MCP 协议里声明的服务端名 */ +export const MCP_SERVER_NAME = "uhd-media-tool"; + +/** 调用来源归类:内置 Agent(含后台任务)、MCP、REST(控制台 / API Key 直接调用) */ +export const CapabilityCaller = z.enum(["agent", "mcp", "rest", "other"]); +export type CapabilityCaller = z.infer; + +/** 审计 payload.source → 调用来源 */ +export function callerOfSource(source: string | null | undefined): CapabilityCaller { + if (source === "agent" || source === "task") return "agent"; + if (source === "mcp") return "mcp"; + if (source === "web" || source === "cli") return "rest"; + return "other"; +} + +/** + * 能力当前的授权状态(服务端按 automation_grant 判定): + * - read_only:read / preview,只看 scope,不需要授权; + * - granted:有覆盖它的有效持续授权(自动来源仍受自动化总开关约束); + * - needs_approval:没有持续授权,需要单次审批(或控制台手动执行); + * - agent_only:只在 Agent 会话内可用(agent.* 能力,例如派生子会话)。 + */ +export const CapabilityAuthState = z.enum(["read_only", "granted", "needs_approval", "agent_only"]); +export type CapabilityAuthState = z.infer; + +export const CapabilityStatsQuery = z.object({ + days: z.coerce.number().int().min(1).max(90).default(7), +}); +export type CapabilityStatsQuery = z.infer; + +const CallerCounts = z.object({ + agent: z.number().int().nonnegative(), + mcp: z.number().int().nonnegative(), + rest: z.number().int().nonnegative(), + other: z.number().int().nonnegative(), +}); + +/** 单个能力在统计窗口内的调用汇总(来自 audit_event 的 capability.invoke / capability.denied) */ +export const CapabilityStatsItem = z.object({ + capabilityId: z.string(), + calls: z.number().int().nonnegative(), + /** outcome = error / invalid */ + failures: z.number().int().nonnegative(), + /** 授权或白名单拒绝 */ + denied: z.number().int().nonnegative(), + avgDurationMs: z.number().nonnegative().nullable(), + lastCalledAt: Timestamp.nullable(), + lastOutcome: z.string().nullable(), + lastCaller: CapabilityCaller.nullable(), + byCaller: CallerCounts, +}); +export type CapabilityStatsItem = z.infer; + +export const CapabilityStatsResponse = z.object({ + days: z.number().int(), + since: Timestamp, + items: z.array(CapabilityStatsItem), +}); +export type CapabilityStatsResponse = z.infer; + +/** 能力清单(给工具页与会话页工具卡片):描述符 + 工具名 + JSON Schema + 授权状态 */ +export const CapabilityToolInfo = CapabilityDescriptor.extend({ + toolName: z.string(), + inputJsonSchema: z.record(z.string(), z.unknown()), + outputJsonSchema: z.record(z.string(), z.unknown()), + authState: CapabilityAuthState, + /** 覆盖它的有效持续授权数 */ + activeGrants: z.number().int().nonnegative(), + /** 能否经 MCP 暴露(agent.* 只在 Agent 会话内) */ + mcpExposed: z.boolean(), +}); +export type CapabilityToolInfo = z.infer; + +export const CapabilityToolsResponse = z.object({ + /** 自动化总开关:关着时 execute 类能力对自动来源(Agent / MCP / API Key)一律拒绝 */ + automationEnabled: z.boolean(), + readOnlyReconcile: z.boolean(), + items: z.array(CapabilityToolInfo), +}); +export type CapabilityToolsResponse = z.infer; + +/** MCP 服务端配置(mcp_server_config 单行) */ +export const McpServerConfig = z.object({ + enabled: z.boolean(), + /** 允许连接的 API Key;null = 所有未撤销的 Key */ + allowedApiKeyIds: z.array(Id).nullable(), + updatedAt: Timestamp.nullable(), +}); +export type McpServerConfig = z.infer; + +export const McpServerConfigUpdate = z + .object({ + enabled: z.boolean(), + allowedApiKeyIds: z.array(Id).max(200).nullable(), + }) + .partial(); +export type McpServerConfigUpdate = z.infer; + +export const McpSessionInfo = z.object({ + /** 会话 ID 前 8 位(完整 ID 等同会话凭据,不回显) */ + idPrefix: z.string(), + apiKeyId: z.string(), + apiKeyName: z.string(), + clientName: z.string().nullable(), + createdAt: Timestamp, + lastSeenAt: Timestamp, + toolCalls: z.number().int().nonnegative(), +}); +export type McpSessionInfo = z.infer; + +export const McpRecentCall = z.object({ + at: Timestamp, + capabilityId: z.string(), + outcome: z.string(), + actorId: z.string().nullable(), + durationMs: z.number().nullable(), + errorCode: z.string().nullable(), +}); +export type McpRecentCall = z.infer; + +export const McpServerStatus = McpServerConfig.extend({ + /** 相对站点根的端点路径(/api/v1/mcp) */ + path: z.string(), + transport: z.literal("streamable-http"), + serverName: z.string(), + serverVersion: z.string(), + /** 每个 API Key 每分钟的工具调用上限 */ + rateLimitPerMinute: z.number().int().positive(), + /** 每个 API Key 同时保持的会话上限 */ + maxSessionsPerKey: z.number().int().positive(), + sessions: z.array(McpSessionInfo), + recentCalls: z.array(McpRecentCall), + /** 常见 Key 组合能看到的工具数(read / read + organize.execute / admin) */ + toolsByScope: z.object({ + read: z.number().int().nonnegative(), + readExecute: z.number().int().nonnegative(), + admin: z.number().int().nonnegative(), + }), +}); +export type McpServerStatus = z.infer; + +/** MCP 工具可见性:能力 scope 被 Key 覆盖;没有 organize.execute(或 admin)时只给 read / preview 类 */ +export function mcpToolVisible( + d: Pick, + scopes: readonly string[], +): boolean { + if (d.id.startsWith("agent.")) return false; + const has = (s: ApiScope) => + scopes.includes("admin") || scopes.includes(s) || (s === "organize.read" && scopes.includes("organize.execute")); + if (!has(d.scope)) return false; + const readish: readonly CapabilityKind[] = ["read", "preview"]; + return readish.includes(d.kind) || has("organize.execute"); +} diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts index 4e080743..f6b439b0 100644 --- a/packages/contracts/test/db-sync.test.ts +++ b/packages/contracts/test/db-sync.test.ts @@ -132,6 +132,8 @@ const INTERNAL_TABLES = [ // W9-I 进程心跳(0034):服务端内部,经 /system/health 的专用 DTO 暴露 "ProcessHeartbeat", "ProcessHeartbeatSample", + // AG-1 MCP 服务端单行配置(0039):经 /mcp/status 与 /mcp/config 的专用 DTO 暴露 + "McpServerConfig", // C1-B 恢复码 / Passkey / WebAuthn 挑战(0037):服务端内部,经 contracts/auth.ts 的专用 DTO 暴露 "AdminRecoveryCode", "AdminPasskey", diff --git a/packages/db/migrations/0039_capability_stats.ts b/packages/db/migrations/0039_capability_stats.ts new file mode 100644 index 00000000..5cc6b074 --- /dev/null +++ b/packages/db/migrations/0039_capability_stats.ts @@ -0,0 +1,36 @@ +import type { Kysely } from "kysely"; +import { dropTables, execDdl } from "../src/migration-helpers.ts"; + +// AG-1 对外 MCP 服务端与能力统计: +// - mcp_server_config:单行配置(启用开关、允许连接的 API Key;null = 所有未撤销的 Key)。 +// - 能力调用统计直接汇总 audit_event(capability.invoke / capability.denied 覆盖读写全部能力, +// capability_invocation 只记有副作用的调用);补一条按能力 + 时间的部分索引。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + ` +create table mcp_server_config ( + id integer primary key default 1 check (id = 1), + enabled boolean not null default true, + allowed_api_key_ids text[], + updated_by text, + updated_at timestamptz not null default now() +); +insert into mcp_server_config (id) values (1); + +create index if not exists audit_event_capability_idx on audit_event (target_id, at) + where target_type = 'capability'; +`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + `drop index if exists audit_event_capability_idx; +${dropTables(["mcp_server_config"])}`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 77d41f26..cb1ac803 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -868,6 +868,14 @@ export interface MediaAssetTable { download_task_id: string | null; } +export interface McpServerConfigTable { + id: Generated; + enabled: Generated; + allowed_api_key_ids: string[] | null; + updated_by: string | null; + updated_at: GeneratedTimestamptz; +} + export interface MediaEntityTable { id: Generated; kind: MediaEntityKind; @@ -1623,6 +1631,7 @@ export interface DB { library_root: LibraryRootTable; login_attempt: LoginAttemptTable; media_asset: MediaAssetTable; + mcp_server_config: McpServerConfigTable; media_entity: MediaEntityTable; media_server_instance: MediaServerInstanceTable; metadata_field: MetadataFieldTable; @@ -1772,6 +1781,9 @@ export type LoginAttemptUpdate = Updateable; export type MediaAssetRow = Selectable; export type NewMediaAsset = Insertable; export type MediaAssetUpdate = Updateable; +export type McpServerConfigRow = Selectable; +export type NewMcpServerConfig = Insertable; +export type McpServerConfigUpdate = Updateable; export type MediaEntityRow = Selectable; export type NewMediaEntity = Insertable; export type MediaEntityUpdate = Updateable; diff --git a/packages/db/test/schema.test.ts b/packages/db/test/schema.test.ts index b49cbfe1..1962e803 100644 --- a/packages/db/test/schema.test.ts +++ b/packages/db/test/schema.test.ts @@ -109,6 +109,8 @@ const EXPECTED_TABLES = [ // W9-I 进程心跳(0034) "process_heartbeat", "process_heartbeat_sample", + // AG-1 MCP 服务端配置(0039) + "mcp_server_config", // C1-B 恢复码 / Passkey / WebAuthn 挑战(0037) "admin_recovery_code", "admin_passkey", @@ -132,6 +134,8 @@ const NON_ENTITY_TABLES = [ // 进程心跳:instance_id 主键 / 只追加的采样 "process_heartbeat", "process_heartbeat_sample", + // 单行配置(id = 1) + "mcp_server_config", // 一次性凭据:只记创建 / 用掉的时间 "admin_recovery_code", "webauthn_challenge",