diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index 2ec7c09c91..76aeefe9a1 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -166,6 +166,7 @@ export const ACTIONS: UIAction[] = [ { id: "skill.enabled", kind: "kernel-mutation", target: "entity", proof: "authority-effect" }, { id: "mcp.enabled", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "mcp.retry", kind: "repeatable", target: "entity", proof: "interaction" }, + { id: "mcp.trust", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "mcp.remove", kind: "destructive", target: "entity", proof: "interaction" }, { id: "mcp.load", kind: "kernel-mutation", target: "entity", proof: "interaction" }, diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 7b4578d7e2..95a7d61216 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -549,6 +549,13 @@ export const EN: Record = { "忘记": "Forget", "重连": "Reconnect", "立即连接": "Connect now", + "定义有改动": "Definition changed", + "无法核对": "Cannot be checked", + "等待你批准的工具": "Tools waiting for your approval", + "这个服务有 {n} 个工具的定义与你批准过的不一致,已暂时不交给模型:": "{n} of this service's tools differ from what you approved and are withheld from the model for now:", + "还有 {n} 个没有列出,批准时会一并生效。": "{n} more are not listed; approving covers them too.", + "批准并重新连接": "Approve and reconnect", + "批准中…": "Approving…", "记的是 {had},但它答的是 {got}。": "Recorded as {had}, but it answers as {got}.", // 收工回执 "没有未经验证的部分": "nothing left unverified", diff --git a/desktop/frontend-next/src/port/mcp.ts b/desktop/frontend-next/src/port/mcp.ts index 5d53a3eb61..ce6f8a1606 100644 --- a/desktop/frontend-next/src/port/mcp.ts +++ b/desktop/frontend-next/src/port/mcp.ts @@ -28,6 +28,20 @@ export interface McpEntry { launch?: string; // Why a pending server waits for the user, as the kernel's code. pendingReason?: string; + // Tools this connection withheld from the model because their definition is + // not the one the user approved. heldDigest names exactly that set, and + // approving it hands the digest back so what is approved is what was shown. + // heldMore counts withheld tools past the list's cap. + held?: McpHeld[]; + heldDigest?: string; + heldMore?: number; +} + +// One withheld tool and how it differs from what was approved: added, changed, +// or unverifiable when the approval record could not be read. +export interface McpHeld { + name: string; + class: string; } // always puts a server's tools in every request; deferred reaches them through diff --git a/desktop/frontend-next/src/port/mock_mcp.ts b/desktop/frontend-next/src/port/mock_mcp.ts index 04277cdc0e..207148efb9 100644 --- a/desktop/frontend-next/src/port/mock_mcp.ts +++ b/desktop/frontend-next/src/port/mock_mcp.ts @@ -107,6 +107,16 @@ export class MockMcp extends MockProvider { ]; } + async trustMcp(name: string, digest: string) { + const s = this.servers.find((x) => x.name === name); + if (!s) return { state: "failed", error: "no configured MCP server named " + name }; + if (!s.held?.length || s.heldDigest !== digest) return { state: "failed", error: "the held MCP tool definitions changed since they were shown" }; + s.held = undefined; + s.heldDigest = undefined; + s.tools = s.tools + 1; + return { state: "ready", tools: s.tools }; + } + async reconnectMcp(name: string) { const s = this.servers.find((x) => x.name === name); if (!s) return { state: "failed", error: "no configured MCP server named " + name }; diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index e75520881c..980afbac27 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -300,6 +300,9 @@ export interface AgentPort { // Retries a failed or disconnected server and answers with its new state, so // the caller never has to race a follow-up GET against the connect. reconnectMcp(name: string): Promise<{ state: string; tools?: number; error?: string }>; + // Approves the withheld definitions the digest names, then reconnects. The + // kernel refuses a digest that no longer matches what it withholds. + trustMcp(name: string, digest: string): Promise<{ state: string; tools?: number; error?: string }>; setMcpEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise; clearMcpOverride(name: string, root?: string): Promise; setMcpLoad(name: string, load: McpLoad): Promise; diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts index 3b956594b9..ac9bd22ea2 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -138,6 +138,17 @@ export class SsePort extends SseWorkspace implements AgentPort { return this.post("/memory/restore", { name, revision }); } + async trustMcp(name: string, digest: string) { + const res = await fetch(this.base + "/mcp/trust", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ name, digest }), + }); + const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number | unknown[]; error?: string }; + if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`); + return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error }; + } async reconnectMcp(name: string) { const res = await fetch(this.base + "/mcp/reconnect", { method: "POST", diff --git a/desktop/frontend-next/src/port/sse_mcp.ts b/desktop/frontend-next/src/port/sse_mcp.ts index 4b444d77f7..29a3e762f1 100644 --- a/desktop/frontend-next/src/port/sse_mcp.ts +++ b/desktop/frontend-next/src/port/sse_mcp.ts @@ -21,6 +21,17 @@ export class SseMcp extends SseProvider { // whole point of the button, so it is read rather than thrown away. // 502 carries the diagnosis in the body — the reason the retry failed is the // whole point of the button, so it is read rather than thrown away. + async trustMcp(name: string, digest: string) { + const res = await fetch(this.base + "/mcp/trust", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ name, digest }), + }); + const body = (await res.json().catch(() => ({}))) as { state?: string; tools?: number | unknown[]; error?: string }; + if (!res.ok && !body.error) throw new Error(`/mcp/trust: ${res.status}`); + return { state: body.state ?? (res.ok ? "ready" : "failed"), tools: typeof body.tools === "number" ? body.tools : body.tools?.length, error: body.error }; + } async reconnectMcp(name: string) { const res = await fetch(this.base + "/mcp/reconnect", { method: "POST", diff --git a/desktop/frontend-next/src/styles/mcpheld.css b/desktop/frontend-next/src/styles/mcpheld.css new file mode 100644 index 0000000000..53cfc51dd3 --- /dev/null +++ b/desktop/frontend-next/src/styles/mcpheld.css @@ -0,0 +1,6 @@ +.srv-held { margin: 6px 0 0 15px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--overlay); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--text); display: grid; gap: 6px; justify-items: start } +.srv-held ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 2px; max-width: 100% } +.srv-held li { display: flex; gap: 8px; align-items: baseline; min-width: 0 } +.srv-held li b { font-weight: var(--w-med); overflow-wrap: anywhere } +.srv-held li i { font-style: normal; color: var(--muted); white-space: nowrap } +.srv-held .hn { color: var(--muted) } diff --git a/desktop/frontend-next/src/ui/ServerRow.tsx b/desktop/frontend-next/src/ui/ServerRow.tsx index 2a2450f069..93d199912c 100644 --- a/desktop/frontend-next/src/ui/ServerRow.tsx +++ b/desktop/frontend-next/src/ui/ServerRow.tsx @@ -5,6 +5,7 @@ import { Exception } from "./CapabilityScope"; import { Clip } from "./Clip"; import { Switch } from "./Switch"; import { reason } from "../i18n/kernel"; +import "../styles/mcpheld.css"; // One external service: whether it is answering, what it brought, and the two // acts that change that — switching it off and letting it try again. @@ -28,6 +29,14 @@ const PENDING_WHY: Record = { changed_since_enabled: "启用之后它要启动的内容变了,或是由旧版本启用的。确认下面的命令后重新启用。", }; +// How a withheld tool differs from what the user approved, keyed by the kernel's +// class. A class with no entry is shown as the kernel wrote it. +const HELD_CLASS: Record = { + added: "新增", + changed: "定义有改动", + unverifiable: "无法核对", +}; + // A tag only when the schema carries the server's tools or config asks it to: // deferred is the default, and a badge on every row is a badge on none. function loadTag(m: McpEntry): { text: string; pending: boolean } | null { @@ -131,6 +140,30 @@ export function ServerRow({ ); + const heldTools = m.held ?? []; + // The approval is for the exact set the kernel withheld; the digest it sent is + // handed back, and a set that changed meanwhile is refused rather than waved through. + const held = heldTools.length > 0 && !!m.heldDigest && ( +
+ + {t("这个服务有 {n} 个工具的定义与你批准过的不一致,已暂时不交给模型:", { n: heldTools.length + (m.heldMore ?? 0) })} + +
    + {heldTools.map((h) => ( +
  • + {h.name} + {t(HELD_CLASS[h.class] ?? h.class)} +
  • + ))} +
+ {!!m.heldMore && {t("还有 {n} 个没有列出,批准时会一并生效。", { n: m.heldMore })}} + {live && ( + + )} +
+ ); const tools = m.toolList ?? []; const tag = loadTag(m); // The tool list is what gets loaded, so the choice sits above it; a server @@ -194,6 +227,7 @@ export function ServerRow({
{head}
{about} {launch} + {held} {why &&
{why}
} {confirm} @@ -201,11 +235,12 @@ export function ServerRow({ } return ( // Asking to remove has to open the row: the confirmation lives inside the - // fold, and what the server contributes is worth seeing before dropping it. -
+ // fold. A row with withheld tools opens too, so the approval is not behind a click. +
0 || undefined}> {head} {about} {launch} + {held} {why &&
{why}
} {confirm} {load} diff --git a/desktop/frontend-next/src/ui/mcp-held.interaction.test.tsx b/desktop/frontend-next/src/ui/mcp-held.interaction.test.tsx new file mode 100644 index 0000000000..9ab0e5ea60 --- /dev/null +++ b/desktop/frontend-next/src/ui/mcp-held.interaction.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { ServerRow } from "./ServerRow"; +import type { AgentPort, McpEntry } from "../port/port"; + +afterEach(cleanup); + +const withheld = (over: Partial = {}): McpEntry => ({ + name: "notes", state: "ready", enabled: true, transport: "stdio", tools: 1, + held: [{ name: "mcp__notes__search", class: "changed" }, { name: "mcp__notes__add", class: "added" }], + heldDigest: "digest-1", ...over, +}); + +function draw(m: McpEntry, live = true) { + const trustMcp = vi.fn(async () => ({ state: "ready", tools: 2 })); + const onDone = vi.fn(); + render(); + return { trustMcp, onDone }; +} + +describe("tools a server changed since the user approved them", () => { + it("lists each withheld tool with how it differs", () => { + draw(withheld()); + const group = screen.getByRole("group", { name: "等待你批准的工具" }); + expect(group.textContent).toContain("mcp__notes__search"); + expect(group.textContent).toContain("定义有改动"); + expect(group.textContent).toContain("mcp__notes__add"); + expect(group.textContent).toContain("2 个工具"); + }); + + it("opens a row that has tools, so the approval is not hidden behind a fold", () => { + draw(withheld({ toolList: [{ name: "add", description: "Add a note" }] })); + expect(document.querySelector("details.mcp-server")?.hasAttribute("open")).toBe(true); + }); + + it("approves exactly the digest it was shown, then refreshes the list", async () => { + const { trustMcp, onDone } = draw(withheld()); + await userEvent.click(screen.getByRole("button", { name: "批准并重新连接" })); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + expect(trustMcp).toHaveBeenCalledTimes(1); + expect(trustMcp).toHaveBeenCalledWith("notes", "digest-1"); + }); + + it("shows the kernel's refusal instead of claiming success when the set changed meanwhile", async () => { + const trustMcp = vi.fn(async () => ({ state: "failed", error: "the held MCP tool definitions changed since they were shown" })); + render( {}} root="/w" live />); + await userEvent.click(screen.getByRole("button", { name: "批准并重新连接" })); + expect(await screen.findByText(/changed since they were shown/)).toBeTruthy(); + }); + + it("says how many more are withheld than listed", () => { + draw(withheld({ heldMore: 7 })); + expect(screen.getByRole("group", { name: "等待你批准的工具" }).textContent).toContain("9 个工具"); + expect(screen.getByText(/还有 7 个没有列出/)).toBeTruthy(); + }); + + it("offers no approval from a row the person cannot edit, and nothing when none is withheld", () => { + draw(withheld(), false); + expect(screen.queryByRole("button", { name: "批准并重新连接" })).toBeNull(); + cleanup(); + draw(withheld({ held: undefined, heldDigest: undefined })); + expect(screen.queryByRole("group", { name: "等待你批准的工具" })).toBeNull(); + }); +}); diff --git a/internal/frontend/serve/catalog.go b/internal/frontend/serve/catalog.go index cac8c7d00e..0e82873f60 100644 --- a/internal/frontend/serve/catalog.go +++ b/internal/frontend/serve/catalog.go @@ -262,8 +262,25 @@ type mcpEntry struct { Launch string `json:"launch,omitempty"` // PendingReason says why a pending server waits for the user, as a code. PendingReason string `json:"pendingReason,omitempty"` + // Held lists tools withheld from the model for a definition the user has not + // approved. HeldDigest names that whole set and comes back with the approval. + // HeldMore counts the withheld tools past the list's cap. + Held []mcpHeld `json:"held,omitempty"` + HeldDigest string `json:"heldDigest,omitempty"` + HeldMore int `json:"heldMore,omitempty"` } +// mcpHeld is one withheld tool: its visible name and how it differs from what +// was approved (added, changed, or unverifiable when the record cannot be read). +type mcpHeld struct { + Name string `json:"name"` + Class string `json:"class"` +} + +// mcpHeldLimit bounds how many withheld tools one row lists; the rest are only +// counted, so a server that withholds thousands cannot make the page heavy. +const mcpHeldLimit = 50 + // mcpTool is one tool as the server describes it, plus the two hints that // decide how a call is classified. Error carries a schema the host rejected: // the tool is listed but not callable, which is a different thing from absent. @@ -356,12 +373,25 @@ func (s *Server) mcp(w http.ResponseWriter, r *http.Request) { } if configured { row.Launch = launchText(st.Entry) + row.Held, row.HeldDigest, row.HeldMore = mcpHeldViews(st.Held) } out = append(out, row) } writeJSON(w, map[string]any{"servers": out, "scope": scopeView(ctl)}) } +func mcpHeldViews(held []plugin.HeldTool) ([]mcpHeld, string, int) { + if len(held) == 0 { + return nil, "", 0 + } + shown := held[:min(len(held), mcpHeldLimit)] + out := make([]mcpHeld, len(shown)) + for i, h := range shown { + out[i] = mcpHeld{Name: displayText(h.VisibleName, mcpServerTextLimit), Class: string(h.Class)} + } + return out, plugin.HeldDigest(held), len(held) - len(shown) +} + // remembered fills a row that has no live connection to ask with what the last // successful handshake recorded. It is marked as remembered rather than merged // silently: a row that cannot tell the two apart claims a server that is off or diff --git a/internal/frontend/serve/mcp_held_test.go b/internal/frontend/serve/mcp_held_test.go new file mode 100644 index 0000000000..5144c87226 --- /dev/null +++ b/internal/frontend/serve/mcp_held_test.go @@ -0,0 +1,48 @@ +package serve + +import ( + "fmt" + "strings" + "testing" + + "reasonix/internal/contract/tool" + "reasonix/internal/ext/plugin" +) + +func TestMCPHeldViewsNameTheSetTheDigestCovers(t *testing.T) { + held := []plugin.HeldTool{ + {RawName: "a", VisibleName: "mcp__s__a", Class: tool.HeldMCPAdded, Digest: "d1"}, + {RawName: "b", VisibleName: "mcp__s__b", Class: tool.HeldMCPChanged, Digest: "d2", Approved: "d0"}, + } + views, digest, more := mcpHeldViews(held) + if len(views) != 2 || more != 0 || views[0].Class != "added" || views[1].Class != "changed" { + t.Fatalf("views = %+v more = %d", views, more) + } + if digest != plugin.HeldDigest(held) { + t.Fatalf("digest %q is not the digest of the held set", digest) + } +} + +func TestMCPHeldViewsBoundTheRowButNotTheDigest(t *testing.T) { + var held []plugin.HeldTool + for i := range mcpHeldLimit + 7 { + held = append(held, plugin.HeldTool{RawName: fmt.Sprint("t", i), VisibleName: fmt.Sprint("t", i), Class: tool.HeldMCPAdded, Digest: "d"}) + } + views, digest, more := mcpHeldViews(held) + if len(views) != mcpHeldLimit || more != 7 { + t.Fatalf("listed %d, more %d", len(views), more) + } + if digest != plugin.HeldDigest(held) { + t.Fatal("the digest must cover every withheld tool, including the ones past the cap") + } +} + +func TestMCPHeldViewsSanitiseServerWrittenNames(t *testing.T) { + views, _, _ := mcpHeldViews([]plugin.HeldTool{{RawName: "x", VisibleName: "evil\x1b[31m" + strings.Repeat("n", mcpServerTextLimit*2), Class: tool.HeldMCPChanged, Digest: "d"}}) + if strings.ContainsRune(views[0].Name, 0x1b) || len([]rune(views[0].Name)) > mcpServerTextLimit+1 { + t.Fatalf("name was not sanitised and bounded: %q", views[0].Name) + } + if v, d, m := mcpHeldViews(nil); v != nil || d != "" || m != 0 { + t.Fatal("no held tools must produce no row fields") + } +} diff --git a/tools/repolint/wireparity.go b/tools/repolint/wireparity.go index 13193600c7..940b7bbf08 100644 --- a/tools/repolint/wireparity.go +++ b/tools/repolint/wireparity.go @@ -89,6 +89,7 @@ var mirroredWireTypes = []wireMirror{ // The MCP row: a status the host answered with and the page cannot read is a // row that goes back to reading the server's own prose for it. {"internal/frontend/serve/catalog.go", "mcpEntry", tsMcpFile, "McpEntry"}, + {"internal/frontend/serve/catalog.go", "mcpHeld", tsMcpFile, "McpHeld"}, // The fold bounds and the one in force: a field the panel cannot read is a // threshold a user sets and never sees applied. {"internal/session/control/compaction_settings.go", "CompactionSettings", tsModelFile, "CompactionSettings"},