From bb3220d75d106a894ee7f9930e35978358048ae7 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Mon, 17 Aug 2026 15:11:53 +0800 Subject: [PATCH] [feature] MCP servers: add, edit, copy and remove from the panel --- CHANGELOG.md | 28 + app/src-tauri/src/lib.rs | 180 ++++- app/src/api.ts | 35 + app/src/components/McpServerDetail.tsx | 970 +++++++++++++++++++++++++ app/src/components/McpView.tsx | 71 +- app/src/styles.css | 170 +++++ app/src/types.ts | 63 ++ 7 files changed, 1510 insertions(+), 7 deletions(-) create mode 100644 app/src/components/McpServerDetail.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 61bbf8e..4e271a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **MCP servers can be added, edited, copied and removed from the panel.** The + matrix could already show you that Cursor was missing the server Claude Code + has, and then leave you to go and fix it somewhere else. A row now opens a + drawer: the clients that have that server as chips, an editable form for the + one you picked — transport, command and arguments or url, env vars, headers — + a "copy here" beside every client that is missing it, and a remove. `+ add + server` in the head opens the same drawer empty, with a checkbox per client + to say where it should land. + + The form always shows exactly one client's copy and says whose, because that + is the truth: six clients keep six files and Cursor's definition of a server + can differ from Codex's, so a save writes one file rather than flattening the + difference. Every write goes through the same core path the CLI uses, keeping + the rolling backup, the parse–modify–serialize round trip and the atomic + rename; the report says which file was written, where the backup is, and + repeats core's caveats, the restart hint included. Saving is an explicit + button — nothing autosaves, and closing a dirty drawer asks first. A Claude + Code entry in a project scope is shown and explained rather than offered: + patchbay does not write that scope, and now says so where you would try. + + The value boundary the matrix was built on is unchanged. `mcp_list`, which + fills the table and refreshes after every write, still reports env var and + header *names* and a count of arguments. Values are read by one new command, + for one named server of one named client, because you opened its drawer — + and a copy still reports which values travelled between files by name. + ### Fixed - **The main pane no longer scrolls sideways.** 0.3.3 stopped the *window* diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 40de405..383cc38 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -6,9 +6,12 @@ use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc}; use patchbay_core::keys::NewKey; +// `CopyReport` and `WriteReport` are not in core's prelude re-exports; the +// module is public and they belong to the write layer this file wraps. +use patchbay_core::mcp_clients::{CopyReport, WriteReport}; use patchbay_core::{ KeyEntry, KeyExpiryState, KeyRegistry, McpClient, McpClientRegistry, PermissionsReport, - Registry, SwitchOutcome, ToolStatus, VerifyOutcome, + Registry, ServerSpec, SwitchOutcome, ToolStatus, TransportSpec, VerifyOutcome, }; /// Probe errors are surfaced to the panel as strings; the panel renders them, @@ -223,6 +226,124 @@ async fn mcp_list() -> CmdResult> { off_thread(|| Ok(McpClientRegistry::detect()?.clients())).await } +/// Serde mirror of [`TransportSpec`], tagged the same way the value-free +/// [`patchbay_core::McpTransport`] on the matrix is, so the panel has one +/// spelling of "how a client reaches a server" rather than two. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(tag = "transport", rename_all = "snake_case")] +enum McpTransportWire { + Stdio { command: String, args: Vec }, + Http { url: String }, + Sse { url: String }, +} + +/// Serde mirror of [`ServerSpec`]: the one shape crossing this boundary that +/// carries MCP **values**. +/// +/// That is deliberate and it is fenced. [`mcp_list`] — the call that fills the +/// matrix, runs on load and refreshes after every write — stays value-free: +/// names of env vars and headers, an argument *count*. Values are read only by +/// [`mcp_read_spec`], for one named server of one named client, because the +/// user opened its drawer, and they exist to be put back by [`mcp_add`]. An +/// MCP entry's `env`, `headers` and stdio arguments are exactly where the API +/// keys live, so nothing here may be logged and nothing may widen the list. +/// +/// `env` and `headers` are ordered pairs rather than maps: file order is what +/// the round-trip has to preserve, and JSON objects do not promise it. +#[derive(serde::Serialize, serde::Deserialize)] +struct McpSpecWire { + #[serde(flatten)] + transport: McpTransportWire, + #[serde(default)] + env: Vec<(String, String)>, + #[serde(default)] + headers: Vec<(String, String)>, +} + +impl From for McpSpecWire { + fn from(spec: ServerSpec) -> Self { + let transport = match spec.transport { + TransportSpec::Stdio { command, args } => McpTransportWire::Stdio { command, args }, + TransportSpec::Http { url } => McpTransportWire::Http { url }, + TransportSpec::Sse { url } => McpTransportWire::Sse { url }, + }; + Self { + transport, + env: spec.env, + headers: spec.headers, + } + } +} + +impl From for ServerSpec { + fn from(wire: McpSpecWire) -> Self { + let transport = match wire.transport { + McpTransportWire::Stdio { command, args } => TransportSpec::Stdio { command, args }, + McpTransportWire::Http { url } => TransportSpec::Http { url }, + McpTransportWire::Sse { url } => TransportSpec::Sse { url }, + }; + Self { + transport, + env: wire.env, + headers: wire.headers, + } + } +} + +/// One server as one client has it written down, values included — what the +/// edit form is prefilled from. +/// +/// Fetched on demand, for the single server whose drawer was opened. Core reads +/// the user scope only, so a Claude Code entry that lives under a project comes +/// back as an error naming the file; the panel shows it verbatim. +#[tauri::command] +async fn mcp_read_spec(client: String, name: String) -> CmdResult { + off_thread(move || { + let registry = McpClientRegistry::detect()?; + Ok(registry.read_spec(&client, &name)?.into()) + }) + .await +} + +/// Register (or, with `overwrite`, replace) a server in one client's config. +/// +/// Editing is spelled as an overwriting add on purpose: core has one write +/// path, and it is the one with the rolling backup, the parse–modify–serialize +/// round trip and the atomic rename. A second "update" entry point would be a +/// second chance to lose someone's config. +#[tauri::command] +async fn mcp_add( + client: String, + name: String, + spec: McpSpecWire, + overwrite: bool, +) -> CmdResult { + off_thread(move || { + let registry = McpClientRegistry::detect()?; + registry.add_server(&client, name.trim(), &spec.into(), overwrite) + }) + .await +} + +/// Unregister a server from one client. It stops that client launching it; the +/// server itself is untouched, and the backup named in the report is the undo. +#[tauri::command] +async fn mcp_remove(client: String, name: String) -> CmdResult { + off_thread(move || McpClientRegistry::detect()?.remove_server(&client, &name)).await +} + +/// Copy one server into other clients, translating formats on the way. Core +/// validates every target before writing any of them. +#[tauri::command] +async fn mcp_copy( + name: String, + from: String, + to: Vec, + overwrite: bool, +) -> CmdResult { + off_thread(move || McpClientRegistry::detect()?.copy_server(&name, &from, &to, overwrite)).await +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -260,8 +381,63 @@ pub fn run() { keys_list, key_add, key_remove, - mcp_list + mcp_list, + mcp_read_spec, + mcp_add, + mcp_remove, + mcp_copy ]) .run(tauri::generate_context!()) .expect("error while running patchbay"); } + +#[cfg(test)] +mod tests { + use super::*; + + /// The wire shape the panel's `McpSpec` is typed against. `#[serde(flatten)]` + /// over an internally tagged enum is the one thing here that could silently + /// change spelling under a serde bump, and it is the shape a save is built + /// from — a mismatch would look like "the drawer will not write". + #[test] + fn spec_round_trips_through_the_panel_shape() { + let json = serde_json::json!({ + "transport": "stdio", + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"], + "env": [["API_KEY", "s3cret"]], + "headers": [], + }); + let wire: McpSpecWire = serde_json::from_value(json.clone()).expect("deserializes"); + let spec: ServerSpec = wire.into(); + assert_eq!( + spec.transport, + TransportSpec::Stdio { + command: "npx".into(), + args: vec!["-y".into(), "@upstash/context7-mcp".into()], + } + ); + assert_eq!( + spec.env, + vec![("API_KEY".to_string(), "s3cret".to_string())] + ); + assert_eq!(serde_json::to_value(McpSpecWire::from(spec)).unwrap(), json); + } + + /// Remote transports keep their tag, so an SSE entry does not come back as + /// HTTP — the two are written differently by every client that has a `type`. + #[test] + fn sse_keeps_its_tag() { + let wire = McpSpecWire { + transport: McpTransportWire::Sse { + url: "https://mcp.example.com/sse".into(), + }, + env: Vec::new(), + headers: vec![("Authorization".into(), "Bearer t".into())], + }; + let text = serde_json::to_string(&wire).unwrap(); + let back: ServerSpec = serde_json::from_str::(&text).unwrap().into(); + assert!(matches!(back.transport, TransportSpec::Sse { .. })); + assert_eq!(back.headers.len(), 1); + } +} diff --git a/app/src/api.ts b/app/src/api.ts index 05eb797..1748848 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -2,6 +2,9 @@ import { invoke } from "@tauri-apps/api/core"; import type { KeyRow, McpClient, + McpCopyReport, + McpSpec, + McpWriteReport, NewKeyInput, PermissionsReport, RemovedKey, @@ -45,4 +48,36 @@ export const keyAdd = (key: NewKeyInput, secret: string) => /** Unregister a key: metadata row and keychain item both. Not a revocation. */ export const keyRemove = (id: string) => invoke("key_remove", { id }); +/** + * The matrix. Value-free by construction: env var and header *names*, and a + * count of a stdio command's arguments. Nothing that comes back from here is a + * secret, which is why it can be held in view state and refreshed on a timer. + */ export const mcpList = () => invoke("mcp_list"); + +/** + * One server's full definition from one client's config, values included. + * + * The single read path in the panel that returns MCP secrets, and it is scoped + * to the one server whose drawer the user opened. Its answer belongs to that + * drawer's form state and nowhere else — never in the list state the matrix + * renders from, never in a log line. + */ +export const mcpReadSpec = (client: string, name: string) => + invoke("mcp_read_spec", { client, name }); + +/** + * Write a server into one client's config. `overwrite` is the difference + * between adding and editing: core refuses a name that already exists without + * it, and an edit is a read-modify-write of the entry that is already there. + */ +export const mcpAdd = (client: string, name: string, spec: McpSpec, overwrite: boolean) => + invoke("mcp_add", { client, name, spec, overwrite }); + +/** Unregister a server from one client. Not a deletion of the server itself. */ +export const mcpRemove = (client: string, name: string) => + invoke("mcp_remove", { client, name }); + +/** Copy a server into other clients, translating JSON ↔ TOML on the way. */ +export const mcpCopy = (name: string, from: string, to: string[], overwrite: boolean) => + invoke("mcp_copy", { name, from, to, overwrite }); diff --git a/app/src/components/McpServerDetail.tsx b/app/src/components/McpServerDetail.tsx new file mode 100644 index 0000000..41373e2 --- /dev/null +++ b/app/src/components/McpServerDetail.tsx @@ -0,0 +1,970 @@ +import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; +import { mcpAdd, mcpCopy, mcpReadSpec, mcpRemove } from "../api"; +import { + isWritableScope, + type McpClient, + type McpCopyReport, + type McpServerEntry, + type McpSpec, + type McpTransportKind, + type McpWriteReport, +} from "../types"; + +/** + * The drawer that writes MCP config: one server, one client's copy of it at a + * time, with an explicit save. + * + * Two things about this shape are load-bearing. + * + * **One client's copy, never "the server".** The matrix makes six clients look + * like one row, but each of them has its own file with its own idea of what + * that server is — Cursor's copy can point at a different command than Codex's, + * and pretending otherwise would let a save silently flatten the difference. + * So the form always shows exactly one client's entry, says whose, and a save + * writes that one file. + * + * **Values live here and nowhere else.** `mcp_list`, which fills the matrix and + * is refetched after every write, is value-free: names of env vars and headers, + * a count of arguments. The secrets — a bearer token in a header, a key in + * `env`, a `--api-key=…` argument — are fetched by `mcpReadSpec` only when a + * drawer opens on one named server, live in this component's form state, and go + * back out through `mcpAdd`. Nothing here may put them in the list state, and + * nothing may log them. + * + * Editing is spelled as an overwriting add because core has exactly one write + * path, and it is the one with the rolling backup, the parse–modify–serialize + * round trip and the atomic rename. + */ +export function McpServerDetail({ + mode, + name: serverName, + clients, + onClose, + refresh, + onGone, +}: Readonly<{ + mode: "add" | "edit"; + /** The server being edited. Ignored, and unused, in add mode. */ + name: string; + clients: McpClient[]; + onClose: () => void; + /** Re-read the matrix; resolves with the fresh list so callers can look. */ + refresh: () => Promise; + /** The drawer removed the last copy and is closing — say so out there. */ + onGone: (text: string) => void; +}>) { + const [name, setName] = useState(mode === "add" ? "" : serverName); + const [form, setForm] = useState
(EMPTY_FORM); + /** The spec as it was loaded, to tell "edited" from "just looked at". */ + const [baseline, setBaseline] = useState(EMPTY_SPEC); + const [targets, setTargets] = useState([]); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState(null); + /** An action parked behind the "discard changes?" confirm. */ + const [pending, setPending] = useState<{ what: string; run: () => void } | null>(null); + + /** Every client that has this server, in whatever scope. */ + const holders = useMemo( + () => (mode === "edit" ? clients.filter((c) => entriesFor(c, serverName).length > 0) : []), + [clients, mode, serverName], + ); + const [source, setSource] = useState(() => pickSource(holders, serverName)); + + const sourceEntries = useMemo( + () => entriesFor(holders.find((c) => c.client === source), serverName), + [holders, source, serverName], + ); + /** A Claude Code entry under `projects.` is read-only: core will not + * write that scope, so the panel must not offer a form for it. */ + const editable = mode === "add" || sourceEntries.some(isWritableScope); + + // Starts true in edit mode: the fetch is fired by an effect, and a frame of + // the previous client's values under the new client's name would be a lie. + const [loading, setLoading] = useState(mode === "edit"); + const [loadError, setLoadError] = useState(null); + + // Values arrive here and only here: one server, one client, because a drawer + // was opened on it. + useEffect(() => { + if (mode !== "edit" || !editable) { + // A project-scope copy has nothing to fetch — core will not read it out + // and the drawer explains why instead of spinning. + setLoading(false); + return; + } + let live = true; + setLoading(true); + setLoadError(null); + mcpReadSpec(source, serverName) + .then((spec) => { + if (!live) return; + const loaded = formOf(spec); + setForm(loaded); + setBaseline(fingerprint(loaded)); + }) + .catch((e) => live && setLoadError(String(e))) + .finally(() => live && setLoading(false)); + return () => { + live = false; + }; + }, [mode, editable, source, serverName]); + + const dirty = + mode === "add" + ? name.trim().length > 0 || fingerprint(form) !== EMPTY_SPEC + : editable && !loading && !loadError && fingerprint(form) !== baseline; + + /** Run `action`, unless there are unsaved edits to ask about first. */ + const guard = useCallback( + (what: string, run: () => void) => { + if (dirty) setPending({ what, run }); + else run(); + }, + [dirty], + ); + + const close = useCallback(() => guard("close this drawer", onClose), [guard, onClose]); + + useEffect(() => { + const onKey = (e: globalThis.KeyboardEvent) => e.key === "Escape" && close(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [close]); + + const blocked = blocker(mode, name, form, targets); + + const save = async (e: FormEvent) => { + e.preventDefault(); + if (blocked || busy) return; + setBusy(true); + setOutcome(null); + const spec = specOf(form); + try { + if (mode === "add") { + // Per client, so a name that is free in Cursor still lands there when + // Codex already has one. `overwrite: false` — an add that quietly + // replaces something is the thing core refuses to do. + const rows: AddRow[] = []; + for (const key of targets) { + const label = clients.find((c) => c.client === key)?.label ?? key; + try { + rows.push({ label, report: await mcpAdd(key, name.trim(), spec, false) }); + } catch (err) { + rows.push({ label, error: String(err) }); + } + } + setOutcome({ kind: "adds", rows }); + if (rows.some((r) => r.report)) await refresh(); + } else { + const report = await mcpAdd(source, serverName, spec, true); + setBaseline(fingerprint(form)); + setOutcome({ kind: "write", report }); + await refresh(); + } + } catch (err) { + setOutcome({ kind: "error", text: String(err) }); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + setBusy(true); + setOutcome(null); + try { + const report = await mcpRemove(source, serverName); + const fresh = await refresh(); + const left = fresh.filter((c) => entriesFor(c, serverName).length > 0); + if (left.length === 0) { + onGone( + `removed \`${serverName}\` from ${report.label} — that was the last copy, so the row ` + + `is gone from the matrix` + + (report.backup_path ? `. the file as it was is at ${report.backup_path}` : ""), + ); + onClose(); + return; + } + setOutcome({ kind: "write", report }); + setSource(pickSource(left, serverName)); + } catch (err) { + setOutcome({ kind: "error", text: String(err) }); + } finally { + setBusy(false); + } + }; + + const copyTo = async (target: string) => { + setBusy(true); + setOutcome(null); + try { + setOutcome({ kind: "copy", report: await mcpCopy(serverName, source, [target], false) }); + await refresh(); + } catch (err) { + setOutcome({ kind: "error", text: String(err) }); + } finally { + setBusy(false); + } + }; + + const title = mode === "add" ? "add server" : serverName; + + return ( + <> + {/* Same drawer idiom as the tool detail and the key vault: a real button + for click-outside so it answers to the keyboard, and a held-open + rather than showModal() so the panel's own scrim stays the + one wash over the board. */} + + + + {pending && ( +
+ + Unsaved changes. Discard them and {pending.what}? + + + +
+ )} + + {mode === "edit" && ( + guard(`switch to the ${labelOf(holders, key)} copy`, () => setSource(key))} + /> + )} + + void save(e)}> + {mode === "add" && ( + <> + + + {/* A div rather than a fieldset: the panel's `.field` is a flex + column and a fieldset's legend does not sit in one properly. + Each checkbox carries its own
+ + ); +} + +/* ------------------------------------------------------------------------- + the form's own model + ------------------------------------------------------------------------- */ + +/** A key/value row as the form holds it. Half-typed rows are legal here. */ +type Pair = { k: string; v: string }; + +type Form = { + transport: McpTransportKind; + command: string; + args: string[]; + url: string; + env: Pair[]; + headers: Pair[]; +}; + +const EMPTY_FORM: Form = { + transport: "stdio", + command: "", + args: [], + url: "", + env: [], + headers: [], +}; + +function formOf(spec: McpSpec): Form { + // The other transport's fields stay in the form as blanks rather than being + // dropped, so flipping the segmented control and back loses nothing typed. + const rest = { + env: spec.env.map(([k, v]) => ({ k, v })), + headers: spec.headers.map(([k, v]) => ({ k, v })), + }; + if (spec.transport === "stdio") { + return { transport: "stdio", command: spec.command, args: [...spec.args], url: "", ...rest }; + } + return { transport: spec.transport, command: "", args: [], url: spec.url, ...rest }; +} + +/** + * The form as core wants it. Names are trimmed; **values never are** — a + * secret with a trailing space is still that secret, and quietly editing it + * would be a bug nobody could see. Blank rows are the leftovers of a `+` press + * and are dropped. + */ +function specOf(form: Form): McpSpec { + const both = { + env: pairsOf(form.env), + headers: pairsOf(form.headers), + }; + if (form.transport === "stdio") { + return { + transport: "stdio", + command: form.command.trim(), + args: form.args.filter((a) => a.length > 0), + ...both, + }; + } + if (form.transport === "http") return { transport: "http", url: form.url.trim(), ...both }; + return { transport: "sse", url: form.url.trim(), ...both }; +} + +const pairsOf = (rows: Pair[]): [string, string][] => + rows.filter((r) => r.k.trim().length > 0).map((r) => [r.k.trim(), r.v]); + +/** What "unsaved changes" compares. Built from the spec, so adding an empty + * row or flipping to a transport and back is not an edit. */ +const fingerprint = (form: Form): string => JSON.stringify(specOf(form)); + +const EMPTY_SPEC = fingerprint(EMPTY_FORM); + +/** + * Why Save is off, or null when it is on. Fail-fast in the form: core would + * refuse all of these too, but after a write attempt and with the file already + * backed up, which is a worse way to learn you left the command blank. + */ +function blocker(mode: "add" | "edit", name: string, form: Form, targets: string[]): string | null { + if (mode === "add") { + const trimmed = name.trim(); + if (!trimmed) return "give it a name"; + if (trimmed.length > 128) return "the name is longer than 128 characters"; + if (hasControlChar(trimmed)) return "the name contains a control character"; + if (targets.length === 0) return "pick at least one client to write to"; + } + if (form.transport === "stdio") { + if (!form.command.trim()) return "stdio needs a command"; + } else if (!form.url.trim()) { + return `${form.transport} needs a url`; + } + return rowProblem(form.env, "env var") ?? rowProblem(form.headers, "header"); +} + +/** Core's one hard rule about names, checked by codepoint rather than by a + * regex full of escapes nobody can read. */ +const hasControlChar = (s: string): boolean => + [...s].some((ch) => { + const code = ch.codePointAt(0) ?? 0; + return code < 0x20 || code === 0x7f; + }); + +function rowProblem(rows: Pair[], what: string): string | null { + const seen = new Set(); + for (const row of rows) { + const key = row.k.trim(); + if (!key) { + if (row.v.length > 0) return `one ${what} has a value but no name`; + continue; + } + if (seen.has(key)) return `${what} \`${key}\` is listed twice`; + seen.add(key); + } + return null; +} + +function saveLabel(mode: "add" | "edit", busy: boolean): string { + if (busy) return mode === "add" ? "writing…" : "saving…"; + return mode === "add" ? "add server" : "save"; +} + +/* ------------------------------------------------------------------------- + client selection + ------------------------------------------------------------------------- */ + +const entriesFor = (client: McpClient | undefined, name: string): McpServerEntry[] => + client?.servers.filter((s) => s.name === name) ?? []; + +/** Prefer a copy patchbay can actually write. */ +function pickSource(holders: McpClient[], name: string): string { + const writable = holders.find((c) => entriesFor(c, name).some(isWritableScope)); + return (writable ?? holders[0])?.client ?? ""; +} + +const labelOf = (clients: McpClient[], key: string): string => + clients.find((c) => c.client === key)?.label ?? key; + +/** + * Which client's copy is on screen. The chips are the honest part of the + * drawer: six clients can each hold a different definition of the same name, + * and this is where you find that out. + */ +function ClientPicker({ + holders, + name, + source, + onPick, +}: Readonly<{ + holders: McpClient[]; + name: string; + source: string; + onPick: (key: string) => void; +}>) { + return ( +
+ editing +
+ {holders.map((c) => { + const writable = entriesFor(c, name).some(isWritableScope); + return ( + + ); + })} +
+ + each client keeps its own copy — this form shows one of them, and a save writes that one + file + +
+ ); +} + +/** The one case the drawer will not edit, said in full rather than by a + * greyed-out button. */ +function ProjectScopeNote({ entries }: Readonly<{ entries: McpServerEntry[] }>) { + const scopes = entries.map((e) => e.scope).filter(Boolean) as string[]; + const keys = [...entries.flatMap((e) => e.env_keys), ...entries.flatMap((e) => e.header_keys)]; + return ( +
+
+ + + this copy lives in a project scope ({scopes.join(", ")}), not the user scope. patchbay + only writes the user scope — a project's servers are that project's business. Edit it with{" "} + claude mcp from that project, or by hand. + +
+ {/* Value-free, straight off the matrix: what it is and the names of what + it sets. Reading the values out of a scope patchbay will not write is + not a thing this drawer needs to do. */} + + {entries.map(summaryOf).join(" · ")} + {keys.length > 0 && ` · sets ${keys.join(", ")}`} + +
+ ); +} + +const summaryOf = (e: McpServerEntry): string => + e.transport === "stdio" ? `stdio ${e.command} (${e.args_len})` : `${e.transport} ${e.url}`; + +/* ------------------------------------------------------------------------- + repeated rows + ------------------------------------------------------------------------- */ + +/** A list of single values — a stdio command's arguments, in file order. */ +function ValueRows({ + label, + hint, + rows, + onChange, +}: Readonly<{ + label: string; + hint: string; + rows: string[]; + onChange: (rows: string[]) => void; +}>) { + return ( +
+ {label} + {rows.map((value, i) => ( + // Keyed by position: these rows have no identity of their own, and + // two arguments may legitimately be the same string. +
+ onChange(rows.map((r, j) => (j === i ? e.target.value : r)))} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + /> + +
+ ))} +
+ + {hint} +
+
+ ); +} + +/** Name/value rows — env vars and headers. */ +function PairRows({ + label, + hint, + rows, + onChange, +}: Readonly<{ + label: string; + hint: string; + rows: Pair[]; + onChange: (rows: Pair[]) => void; +}>) { + const set = (i: number, patch: Partial) => + onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r))); + + return ( +
+ {label} + {rows.map((row, i) => ( + // Keyed by position, as above. +
+ set(i, { k: e.target.value })} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + /> + set(i, { v: e.target.value })} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + autoComplete="off" + /> + +
+ ))} +
+ + {hint} +
+
+ ); +} + +/* ------------------------------------------------------------------------- + copy and remove + ------------------------------------------------------------------------- */ + +/** + * The clients that do *not* have this server, each with the one action that + * changes that. This is the whole reason the matrix exists — seeing that Cursor + * is missing what Claude Code has is only useful if you can then fix it. + */ +function CopySection({ + clients, + name, + source, + sourceLabel, + editable, + busy, + onCopy, +}: Readonly<{ + clients: McpClient[]; + name: string; + source: string; + sourceLabel: string; + editable: boolean; + busy: boolean; + onCopy: (key: string) => void; +}>) { + // "Has it" means has it in a scope patchbay writes: a project-scope entry + // does not stop a user-scope copy landing beside it, and core agrees. + const missing = clients.filter( + (c) => c.client !== source && !entriesFor(c, name).some(isWritableScope), + ); + if (missing.length === 0) return null; + + return ( +
+ copy elsewhere + {editable ? ( + <> +
    + {missing.map((c) => ( +
  • + {c.label} + {c.config_path} + +
  • + ))} +
+ + copies the {sourceLabel} definition, values and all, translating the file format on the + way + + + ) : ( + + pick a copy patchbay can read first — a project-scope entry is not a source it will hand + around + + )} +
+ ); +} + +/** Removing, behind the panel's one confirm idiom: in place, saying what it + * does and does not do, never pre-armed. */ +function RemoveSection({ + label, + name, + path, + busy, + onRemove, +}: Readonly<{ label: string; name: string; path: string; busy: boolean; onRemove: () => void }>) { + const [armed, setArmed] = useState(false); + + return ( +
+ remove + {armed ? ( +
+ + Remove {name} from {label}? It stops that client launching the server; the server + itself and every other client's copy are untouched. {path} is backed up first. + + + +
+ ) : ( +
+ +
+ )} +
+ ); +} + +/* ------------------------------------------------------------------------- + what happened + ------------------------------------------------------------------------- */ + +type AddRow = { label: string; report?: McpWriteReport; error?: string }; + +type Outcome = + | { kind: "write"; report: McpWriteReport } + | { kind: "copy"; report: McpCopyReport } + | { kind: "adds"; rows: AddRow[] } + | { kind: "error"; text: string }; + +function OutcomeBlock({ outcome }: Readonly<{ outcome: Outcome }>) { + if (outcome.kind === "error") { + // Core's sentence, unedited: it names the file, says what it refused and + // what to do instead. Paraphrasing it would only ever lose something. + return ( +
+ + {outcome.text} +
+ ); + } + + if (outcome.kind === "write") return ; + + if (outcome.kind === "adds") { + return ( +
+ {outcome.rows.map((row) => + row.report ? ( + + ) : ( +
+ + + {row.label}: {row.error} + +
+ ), + )} +
+ ); + } + + const { report } = outcome; + const carried = [...report.env_carried, ...report.header_carried]; + return ( +
+ {/* Values travelled between files. Core reports which ones by name; not + saying so would make a copy look cheaper than it is. */} + {carried.length > 0 && ( +
+ + carried {carried.join(", ")} — the values went into the target files too, not just the + names + +
+ )} + {report.written.map((w) => ( + + ))} +
+ ); +} + +function WriteBlock({ report }: Readonly<{ report: McpWriteReport }>) { + return ( +
+ + wrote {report.name} to {report.label} — {report.config_path} + {report.created_file && " (created)"} + {report.backup_path && ( + <> +
+ backup: {report.backup_path} + + )} +
+ {report.notes.map((n) => ( + + {n} + + ))} +
+ ); +} diff --git a/app/src/components/McpView.tsx b/app/src/components/McpView.tsx index 6dbe82d..7fad57c 100644 --- a/app/src/components/McpView.tsx +++ b/app/src/components/McpView.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { mcpList } from "../api"; import type { McpClient, McpServerEntry } from "../types"; +import { McpServerDetail } from "./McpServerDetail"; /** What one client has to say about one server name. */ type Cell = "user" | "project" | "none"; @@ -14,6 +15,9 @@ function cellFor(entries: McpServerEntry[]): Cell { const MARK: Record = { user: "✓", project: "proj", none: "—" }; +/** Which drawer is open, if any. */ +type Open = { mode: "add" } | { mode: "edit"; name: string }; + /** * Which AI clients have which MCP servers. The matrix is the point: MCP config * is per-client and lives in six different files, so the only way to see that @@ -22,12 +26,34 @@ const MARK: Record = { user: "✓", project: "proj", none: "—" } * Clients that are not installed keep their column — an empty column is the * answer to "did I configure it there?", and hiding it would lose that. * - * Read-only: `pb mcp add/copy/rm` does the writing. Transports show a command - * or a URL and the *names* of env vars and headers, never their values. + * The matrix itself stays value-free: a command or a URL, and the *names* of + * env vars and headers, never their values. Values are read one server at a + * time, by `McpServerDetail`, because a row was opened — see the note there. + * Seeing the gap is only half of it, so a row is a way in: open it and you can + * edit that client's copy, copy it to the clients that are missing it, or take + * it out. `pb mcp add/copy/rm` still does the same work from a terminal. */ export function McpView() { const [clients, setClients] = useState(null); const [error, setError] = useState(null); + const [open, setOpen] = useState(null); + /** The outcome of a write that outlived the drawer it happened in. */ + const [note, setNote] = useState(null); + + /** Re-read the matrix. Resolves with the fresh list, which is what lets the + * drawer decide whether the server it was showing still exists. */ + const load = useCallback(async () => { + const fresh = await mcpList(); + setClients(fresh); + setError(null); + return fresh; + }, []); + + /** Open a drawer, and drop the last drawer's parting note with it. */ + const show = (next: Open) => { + setNote(null); + setOpen(next); + }; useEffect(() => { let live = true; @@ -67,14 +93,26 @@ export function McpView() { {servers.length} {servers.length === 1 ? "server" : "servers"} across {configured} of{" "} {clients.length} clients + + {note && ( +
+ {note} +
+ )} + {servers.length === 0 ? (

no MCP servers registered

None of the {clients.length} clients patchbay knows about has a server configured.

+
) : (
@@ -101,8 +139,17 @@ export function McpView() { // description as any. const spec = clients.flatMap((c) => c.servers).find((s) => s.name === name)!; return ( - - {name} + // The whole row opens the drawer, the way a board card does: + // the row's information *is* what you came to act on. A + // cannot be a button, so the name cell holds a real one for + // the keyboard; its click bubbles here and asks for the same + // thing twice, which costs nothing. + show({ mode: "edit", name })}> + + + {clients.map((c) => { const cell = cellFor(c.servers.filter((s) => s.name === name)); return ( @@ -147,6 +194,20 @@ export function McpView() { )} )} + + {open && ( + setOpen(null)} + refresh={load} + onGone={setNote} + /> + )}
); } diff --git a/app/src/styles.css b/app/src/styles.css index 196975a..24220f9 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -1584,3 +1584,173 @@ dialog.detail { font-size: 10px; color: var(--muted); } + +/* ---------- MCP server drawer ---------- */ + +/* A matrix row is a way into the server, the way a board card is a way into a + tool. The carries the click and the hover; the name cell holds a real + button so the keyboard gets the same door. */ +.row-open { + cursor: pointer; +} + +.table tbody tr.row-open:hover, +.table tbody tr.row-open:focus-within { + background: var(--panel-2); +} + +.row-open-name { + padding: 0; + border: 0; + background: none; + font-family: inherit; + font-size: inherit; + color: inherit; + cursor: pointer; + text-align: left; +} + +.table tbody tr.row-open:hover .row-open-name { + color: var(--accent); +} + +/* Which client's copy is on screen. A chip you can press, so it wears the + chip's shape rather than inventing a second one. */ +.chip-pick { + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid transparent; + color: var(--ink-2); + cursor: pointer; + transition: border-color 90ms linear, color 90ms linear; +} + +.chip-pick:hover { + color: var(--ink); + border-color: var(--accent); +} + +.chip-pick.is-on { + background: var(--ok-wash); + color: var(--ok); + border-color: var(--ok); +} + +/* A project-scope copy is shown, and shown as the thing patchbay will not + write — pressing it opens the explanation rather than a form. */ +.chip-pick.is-proj { + font-style: italic; +} + +.chip-tag { + font-size: 9px; + letter-spacing: 0.06em; + color: var(--muted); +} + +/* Three transports, one of them true: a row of buttons rather than a select, + because the choice changes which fields are below it and a select hides + that behind a press. */ +.segmented { + display: inline-flex; + border: 1px solid var(--line); + border-radius: 4px; + overflow: hidden; + align-self: flex-start; +} + +.segment { + padding: 3px 12px; + border: 0; + border-right: 1px solid var(--line); + background: transparent; + color: var(--muted); + font-family: var(--mono); + font-size: 10.5px; + cursor: pointer; + transition: color 90ms linear, background 90ms linear; +} + +.segment:last-child { + border-right: 0; +} + +.segment:hover { + color: var(--ink); +} + +.segment.is-on { + background: var(--panel-2); + color: var(--accent); +} + +/* One env var, header or argument per row: name, value, and the button that + takes the row away. */ +.pair { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.pair .field-input { + flex: 1 1 auto; + min-width: 0; +} + +.pair-key { + flex: 0 1 38%; +} + +/* The clients that do not have this server, and the one action that changes + that. */ +.copy-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.copy-list li { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.copy-label { + flex: none; + width: 92px; + font-size: 11px; + color: var(--ink-2); +} + +.copy-path { + flex: 1 1 auto; + min-width: 0; + font-family: var(--mono); + font-size: 10px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The vault's confirm row, outside a table: same shape, same restraint. */ +.confirm-standalone { + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-2); +} + +/* A caveat core attached to a write — the restart hint, a format limitation. + Indented under the report it belongs to. */ +.write-note { + display: flex; + gap: 6px; + color: var(--muted); +} diff --git a/app/src/types.ts b/app/src/types.ts index 433fc23..ab1af0d 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -224,6 +224,69 @@ export interface McpClient { notes: string[]; } +/** The three transports, as a spec that is about to be written. */ +export type McpTransportKind = "stdio" | "http" | "sse"; + +/** + * Mirrors the Tauri shell's `McpTransportWire` — `McpTransport` with the values + * back in: the arguments themselves rather than a count. + */ +export type McpTransportSpec = + | { transport: "stdio"; command: string; args: string[] } + | { transport: "http"; url: string } + | { transport: "sse"; url: string }; + +/** + * One server as one client has it written down, values included. + * + * The only shape in the panel that carries MCP secrets — an `Authorization` + * header, a `--api-key=…` argument, a token in `env`. It arrives from + * `mcpReadSpec` for one server the user opened, lives in that drawer's form + * state, and leaves through `mcpAdd`. It must never be put in the list state + * the matrix renders from, and must never be logged. + * + * Pairs, not objects: file order is what an edit has to preserve. + */ +export type McpSpec = McpTransportSpec & { + env: [string, string][]; + headers: [string, string][]; +}; + +/** Mirrors `patchbay_core::mcp_clients::WriteReport`. */ +export interface McpWriteReport { + client: string; + label: string; + name: string; + config_path: string; + /** Where the undo lives. Null only when the config file was created. */ + backup_path: string | null; + created_file: boolean; + /** Format caveats and the restart hint. Show all of them. */ + notes: string[]; +} + +/** Mirrors `patchbay_core::mcp_clients::CopyReport`. */ +export interface McpCopyReport { + name: string; + from: string; + summary: string; + /** Names of env vars whose VALUES travelled. Say so. */ + env_carried: string[]; + /** Names of headers whose VALUES travelled. Same. */ + header_carried: string[]; + written: McpWriteReport[]; +} + +/** + * Whether this client has the server in a scope patchbay will write. + * + * Mirrors `McpServerEntry::is_writable_scope`. A Claude Code entry under + * `projects.` is that project's business: core reads it, labels it, and + * refuses to touch it — so the panel must not offer to. + */ +export const isWritableScope = (e: McpServerEntry): boolean => + !(e.scope?.startsWith("project:") ?? false); + export type SwitchOutcome = | { result: "switched"; tool: string; profile_id: string; detail: string; notes: string[] } | { result: "unsupported"; tool: string; reason: string; hint: string | null }