diff --git a/apps/web/app/api/mcp.ts b/apps/web/app/api/mcp.ts new file mode 100644 index 00000000..5bf8f67f --- /dev/null +++ b/apps/web/app/api/mcp.ts @@ -0,0 +1,70 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CapabilityStatsResponse, + CapabilityToolsResponse, + McpServerConfig, + type McpServerConfigUpdate, + McpServerStatus, +} from "@umt/contracts"; +import { TTL_MINUTE, TTL_SECOND } from "~/lib/api.ts"; +import { apiParsed } from "./parse.ts"; + +// 能力清单 / 调用统计 / 对外 MCP 服务端(AG-1):GET /capabilities/tools、/capabilities/stats、/mcp/status,PATCH /mcp/config。 + +const KEY = { + tools: ["capabilities", "tools"] as const, + stats: (days: number) => ["capabilities", "stats", days] as const, + mcp: ["mcp", "status"] as const, +}; + +/** 能力清单(描述符 + JSON Schema + 授权状态);会话页工具卡片与设置页共用,改动少,缓存久一点 */ +export function useCapabilityTools(enabled = true) { + return useQuery({ + queryKey: KEY.tools, + queryFn: ({ signal }) => apiParsed(CapabilityToolsResponse, "/capabilities/tools", { signal }), + staleTime: TTL_MINUTE, + enabled, + }); +} + +export function useCapabilityStats(days = 7) { + return useQuery({ + queryKey: KEY.stats(days), + queryFn: ({ signal }) => apiParsed(CapabilityStatsResponse, "/capabilities/stats", { params: { days }, signal }), + staleTime: 30 * TTL_SECOND, + placeholderData: keepPreviousData, + }); +} + +export function useMcpStatus() { + return useQuery({ + queryKey: KEY.mcp, + queryFn: ({ signal }) => apiParsed(McpServerStatus, "/mcp/status", { signal }), + refetchInterval: 15 * TTL_SECOND, + }); +} + +export function useUpdateMcpConfig() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: McpServerConfigUpdate) => apiParsed(McpServerConfig, "/mcp/config", { method: "PATCH", body }), + onSuccess: () => qc.invalidateQueries({ queryKey: KEY.mcp }), + }); +} + +/** + * 给 Claude Code 等 MCP 客户端的接入配置(Streamable HTTP)。密钥用环境变量占位,不含明文: + * 客户端侧把 UMT_API_KEY 设为在「API Key」分区新建的 Key。 + */ +export function mcpClientConfig(origin: string, path: string, serverName: string) { + return { + mcpServers: { + [serverName]: { + type: "http", + url: `${origin}${path}`, + // biome-ignore lint/suspicious/noTemplateCurlyInString: 客户端侧的环境变量占位,不是本地模板 + headers: { Authorization: "Bearer ${UMT_API_KEY}" }, + }, + }, + }; +} diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index 86ca9bd3..4880de46 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -52,6 +52,7 @@ import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/component import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { errorMessage } from "~/lib/api.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; +import { ToolCallIo } from "./tool-call-card-io.tsx"; import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts"; // Agent 会话的展示层:把 use-agent-stream 折叠出的 StreamItem 映射到 agentui 组件(components/agentui/, @@ -230,6 +231,7 @@ export function ToolCallCard({ ) : null} + ); } diff --git a/apps/web/app/components/agent/tool-call-card-io.tsx b/apps/web/app/components/agent/tool-call-card-io.tsx new file mode 100644 index 00000000..ac98244c --- /dev/null +++ b/apps/web/app/components/agent/tool-call-card-io.tsx @@ -0,0 +1,87 @@ +import { Braces } from "lucide-react"; +import { useState } from "react"; +import { useCapabilityTools } from "~/api/mcp.ts"; +import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { JsonView } from "~/components/console/json-view.tsx"; +import { KvList } from "~/components/console/kv-list.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { formatDateTime } from "~/lib/utils.ts"; +import type { ToolCallView } from "./use-agent-stream.ts"; + +// 会话页工具卡片的"查看 schema 与本次输入输出"抽屉。 +// 本次输入输出取事件流里的摘要:服务端已脱敏(凭证键一律遮盖),超长部分截断;schema 取能力清单(GET /capabilities/tools)。 + +/** 摘要尽量按 JSON 美化;截断后的摘要不是合法 JSON,原样显示 */ +export function parseSummary(text: string | null): { value: unknown; truncated: boolean } { + if (text === null) return { value: null, truncated: false }; + const truncated = /…\(\d+ more\)$/.test(text); + if (truncated) return { value: text, truncated }; + try { + return { value: JSON.parse(text), truncated }; + } catch { + return { value: text, truncated }; + } +} + +export function ToolCallIo({ call }: { call: ToolCallView }) { + const [open, setOpen] = useState(false); + return ( + <> + + {open ? setOpen(false)} /> : null} + + ); +} + +function ToolCallIoSheet({ call, onClose }: { call: ToolCallView; onClose: () => void }) { + const tools = useCapabilityTools(); + const tool = tools.data?.items.find((t) => t.id === call.capabilityId) ?? null; + const input = parseSummary(call.inputSummary); + const output = parseSummary(call.outputSummary); + const failed = call.status === "failed" || call.status === "denied"; + + return ( + !o && onClose()} + title={tool?.title ?? call.capabilityId} + description={call.capabilityId} + width="lg" + > + + + + + + + + {input.truncated || output.truncated ? ( +

事件流只保存 500 字符以内的摘要,超出部分已截断。

+ ) : null} + + {tools.isPending ? ( + + ) : tools.error ? ( + tools.refetch()} plain /> + ) : tool ? ( + + ) : ( +

这个能力没有注册,没有 schema。

+ )} +
+ {tool ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/app/components/settings/agent-tools.tsx b/apps/web/app/components/settings/agent-tools.tsx new file mode 100644 index 00000000..0bb47ce3 --- /dev/null +++ b/apps/web/app/components/settings/agent-tools.tsx @@ -0,0 +1,495 @@ +import type { + CapabilityAuthState, + CapabilityCaller, + CapabilityKind, + CapabilityStatsItem, + CapabilityToolInfo, + McpServerStatus, + RiskLevel, +} from "@umt/contracts"; +import { Check, Copy } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useApiKeys } from "~/api/api-keys.ts"; +import { + mcpClientConfig, + useCapabilityStats, + useCapabilityTools, + useMcpStatus, + useUpdateMcpConfig, +} from "~/api/mcp.ts"; +import { DataTable } from "~/components/console/data-table.tsx"; +import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { JsonView } from "~/components/console/json-view.tsx"; +import { KvList } from "~/components/console/kv-list.tsx"; +import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Switch } from "~/components/ui/switch.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { formatRelative } from "~/lib/labels.ts"; +import { formatDateTime } from "~/lib/utils.ts"; +import { SettingsBlock } from "./settings-block.tsx"; + +// 设置 → Agent → 工具与 MCP:对外 MCP 服务端(端点、启用、允许的 API Key、会话、最近调用、接入配置) +// + 能力清单(作用域 / 风险 / 幂等与重放 / 授权状态 / 7 天调用与失败率 / 最近一次,点开看 schema)。 + +export const KIND_LABEL: Record = { + read: "只读", + preview: "预览", + propose: "提案", + approve: "审批", + execute: "执行", +}; + +const RISK: Record = { + low: { label: "低风险", tone: "neutral" }, + medium: { label: "中风险", tone: "warning" }, + high: { label: "高风险", tone: "destructive" }, +}; + +const AUTH: Record = { + read_only: { label: "无需授权", tone: "neutral", hint: "只读或预览,不落变更,Key 有对应 scope 即可调用" }, + granted: { label: "有持续授权", tone: "success", hint: "Agent、MCP、API Key 调用按持续授权放行" }, + needs_approval: { + label: "需审批", + tone: "warning", + hint: "没有覆盖它的持续授权,Agent、MCP、API Key 调用需要单次审批", + }, + agent_only: { label: "仅 Agent 会话", tone: "neutral", hint: "只在 Agent 会话内可用,不经 MCP 暴露" }, +}; + +export const CALLER_LABEL: Record = { + agent: "内置 Agent", + mcp: "MCP", + rest: "REST", + other: "其他", +}; + +const percent = (n: number, d: number) => (d > 0 ? `${Math.round((n / d) * 100)}%` : "—"); + +export function AgentToolsBlock() { + return ( +
+ + +
+ ); +} + +// ── MCP 服务端 ── + +function McpServerBlock() { + const status = useMcpStatus(); + return ( + + {status.isPending ? ( + + ) : status.error ? ( + status.refetch()} plain /> + ) : ( + + )} + + ); +} + +function McpServerBody({ s }: { s: McpServerStatus }) { + const update = useUpdateMcpConfig(); + const keys = useApiKeys(); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const endpoint = `${origin}${s.path}`; + const config = useMemo(() => mcpClientConfig(origin, s.path, s.serverName), [origin, s.path, s.serverName]); + const activeKeys = (keys.data?.items ?? []).filter((k) => !k.revokedAt); + const restricted = s.allowedApiKeyIds !== null; + + const save = (patch: Parameters[0], ok: string) => + update.mutate(patch, { + onSuccess: () => toast.success(ok), + onError: (e) => toast.error(errorMessage(e, "保存失败")), + }); + + const toggleKey = (id: string, on: boolean) => { + const cur = new Set(s.allowedApiKeyIds ?? []); + if (on) cur.add(id); + else cur.delete(id); + save({ allowedApiKeyIds: [...cur] }, "已更新允许的 API Key"); + }; + + return ( +
+
+
+ save({ enabled: v }, v ? "MCP 服务端已启用" : "MCP 服务端已停用,现有会话已断开")} + aria-labelledby="mcp-enabled-label" + /> + + {s.enabled ? "已启用" : "已停用"} + +
+ 0 ? "success" : "neutral"}>{s.sessions.length} 个会话在线 +
+ + , wide: true }, + { label: "传输", value: "Streamable HTTP(POST / GET / DELETE)" }, + { label: "鉴权", value: "Authorization: Bearer " }, + { + label: "限速", + value: `每个 Key 每分钟 ${s.rateLimitPerMinute} 次工具调用,最多 ${s.maxSessionsPerKey} 个会话`, + }, + { + label: "可见工具", + value: `read ${s.toolsByScope.read} 个 · read + organize.execute ${s.toolsByScope.readExecute} 个 · admin ${s.toolsByScope.admin} 个`, + }, + ]} + /> + + +
+ + + {restricted ? ( + activeKeys.length === 0 ? ( +

还没有 API Key。在「API Key」分区新建。

+ ) : ( +
    + {activeKeys.map((k) => ( +
  • + +
  • + ))} +
+ ) + ) : null} +

+ Key 的 scope 决定能看到哪些工具:read 只有只读和预览类,organize.execute 或 admin 才有执行类。 +

+
+
+ + + {s.sessions.length === 0 ? ( +

+ 没有在线会话。客户端连上后出现在这里,空闲 30 分钟自动断开。 +

+ ) : ( +
    + {s.sessions.map((x) => ( +
  • + {x.idPrefix} + {x.apiKeyName} + {x.clientName ?? "未知客户端"} + + {x.toolCalls} 次调用 · 最近 {formatRelative(x.lastSeenAt)} + +
  • + ))} +
+ )} +
+ + + {s.recentCalls.length === 0 ? ( +

还没有经 MCP 的调用。

+ ) : ( +
    + {s.recentCalls.slice(0, 8).map((c) => ( +
  • + {c.capabilityId} + + + {formatRelative(c.at)} + {c.durationMs !== null ? ` · ${c.durationMs} ms` : ""} + +
  • + ))} +
+ )} +
+ + } + > +

+ 适用于 Claude Code 等支持 Streamable HTTP 的客户端。密钥用环境变量 UMT_API_KEY 占位,配置里不含明文。 +

+ +
+
+ ); +} + +function OutcomeBadge({ outcome }: { outcome: string | null }) { + if (!outcome) return null; + const map: Record = { + ok: { label: "成功", tone: "success" }, + denied: { label: "被拒绝", tone: "warning" }, + invalid: { label: "参数不符", tone: "destructive" }, + error: { label: "失败", tone: "destructive" }, + }; + const m = map[outcome] ?? { label: outcome, tone: "neutral" as const }; + return {m.label}; +} + +function CopyText({ text }: { text: string }) { + return ( + + {text} + + + ); +} + +function CopyButton({ text, label }: { text: string; label: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +// ── 能力清单 ── + +type Row = CapabilityToolInfo & { stats: CapabilityStatsItem | null }; + +function CapabilitiesBlock() { + const tools = useCapabilityTools(); + const stats = useCapabilityStats(7); + const [openId, setOpenId] = useState(null); + const byId = useMemo(() => new Map((stats.data?.items ?? []).map((s) => [s.capabilityId, s])), [stats.data]); + const rows: Row[] | undefined = tools.data?.items.map((t) => ({ ...t, stats: byId.get(t.id) ?? null })); + const open = rows?.find((r) => r.id === openId) ?? null; + const automationOff = tools.data && (!tools.data.automationEnabled || tools.data.readOnlyReconcile); + + return ( + + {automationOff ? ( +

+ {tools.data?.readOnlyReconcile ? "只读对账模式" : "自动化总开关已关闭"} + :执行类能力对 Agent、MCP、API Key 一律拒绝,持续授权暂不生效。 +

+ ) : null} + r.id} + loading={tools.isPending} + error={tools.error} + onRetry={() => tools.refetch()} + onRowClick={(r) => setOpenId(r.id)} + empty={{ title: "没有已注册的能力" }} + columns={[ + { + key: "id", + header: "能力", + cell: (r) => ( +
+

{r.title}

+ {r.id} +
+ ), + }, + { + key: "kind", + header: "类别 / scope", + hideBelow: "md", + cell: (r) => ( +
+ {KIND_LABEL[r.kind]} + {r.scope} +
+ ), + }, + { + key: "risk", + header: "风险", + hideBelow: "sm", + cell: (r) => {RISK[r.riskLevel].label}, + }, + { + key: "idem", + header: "幂等 / 重放", + hideBelow: "lg", + cell: (r) => ( + + {r.idempotent ? "幂等" : "非幂等"} · {r.replay === "safe" ? "可重放" : "不重放"} + + ), + }, + { + key: "auth", + header: "授权状态", + cell: (r) => ( + + {AUTH[r.authState].label} + + ), + }, + { + key: "calls", + header: "7 天调用", + align: "right", + hideBelow: "sm", + cell: (r) => + r.stats ? ( +
+ {r.stats.calls} + 0 + ? "text-meta text-warning" + : "text-meta text-muted-foreground" + } + > + 失败 {percent(r.stats.failures, r.stats.calls)} + {r.stats.denied > 0 ? ` · 拒绝 ${r.stats.denied}` : ""} + +
+ ) : ( + 0 + ), + }, + { + key: "last", + header: "最近一次", + hideBelow: "md", + cell: (r) => + r.stats?.lastCalledAt ? ( +
+ {formatRelative(r.stats.lastCalledAt)} + + {r.stats.lastCaller ? CALLER_LABEL[r.stats.lastCaller] : ""} + +
+ ) : ( + — + ), + }, + ]} + /> + setOpenId(null)} /> +
+ ); +} + +function CapabilitySheet({ row, onClose }: { row: Row | null; onClose: () => void }) { + return ( + !o && onClose()} + title={row?.title ?? ""} + description={row?.id} + width="lg" + > + {row ? ( + <> +

{row.description}

+
+ {KIND_LABEL[row.kind]} + scope · {row.scope} + {RISK[row.riskLevel].label} + {AUTH[row.authState].label} +
+ + {row.stats ? ( + + row.stats!.byCaller[k] > 0) + .map((k) => `${CALLER_LABEL[k]} ${row.stats!.byCaller[k]}`) + .join(" · "), + wide: true, + }, + ]} + /> + + ) : null} + + + + + + ) : null} +
+ ); +} diff --git a/apps/web/app/components/settings/agent.tsx b/apps/web/app/components/settings/agent.tsx index f1678d5a..610229ad 100644 --- a/apps/web/app/components/settings/agent.tsx +++ b/apps/web/app/components/settings/agent.tsx @@ -1,3 +1,4 @@ +import { AgentToolsBlock } from "./agent-tools.tsx"; import { PiUpstreamsBlock } from "./pi-upstreams.tsx"; import type { FieldValue } from "./resource-section.tsx"; import { num, str } from "./resource-section.tsx"; @@ -194,6 +195,7 @@ export function AgentSection() { )} + ); }