diff --git a/packages/core/agents/index.ts b/packages/core/agents/index.ts index 924c31494e..486db14902 100644 --- a/packages/core/agents/index.ts +++ b/packages/core/agents/index.ts @@ -7,3 +7,4 @@ export * from "./use-workspace-presence-prefetch"; export * from "./constants"; export * from "./visibility-label"; export * from "./use-workspace-agent-availability"; +export * from "./mcp-support"; diff --git a/packages/core/agents/mcp-support.ts b/packages/core/agents/mcp-support.ts new file mode 100644 index 0000000000..32d7c72099 --- /dev/null +++ b/packages/core/agents/mcp-support.ts @@ -0,0 +1,11 @@ +// The set of runtime providers whose backend reads `agent.mcp_config` and +// forwards MCP servers to the underlying CLI. The MCP config tab is hidden +// for every other provider so a user can't save a value the runtime will +// silently ignore. Keep this list in sync with the backends in +// `server/pkg/agent/` that read `ExecOptions.McpConfig`. +const MCP_SUPPORTED_PROVIDERS = new Set(["claude", "codex"]); + +export function providerSupportsMcpConfig(provider: string | undefined | null): boolean { + if (!provider) return false; + return MCP_SUPPORTED_PROVIDERS.has(provider); +} diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index fb5af1868e..81d9b48468 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -153,6 +153,27 @@ export interface Agent { * alongside `has_custom_env`. Treat `undefined` as zero. MUL-2600. */ custom_env_key_count?: number; + /** + * MCP server configuration forwarded to the runtime CLI (Claude's + * `--mcp-config`). The shape is opaque to the platform — whatever + * JSON the CLI accepts, the daemon writes to disk verbatim. `null` + * (or the field omitted on legacy backends) means no config; the + * daemon falls back to the CLI's own default. MUL-2764. + * + * When the caller can't see secrets (an agent actor, or a non-owner + * non-admin), the server replaces the value with `null` and sets + * `mcp_config_redacted` to true so the UI can render a "configured + * but hidden" state without exposing potentially sensitive fields. + */ + mcp_config?: unknown | null; + /** + * True when the server stripped `mcp_config` from this response + * because the caller lacks permission to see secrets. The UI uses + * this to distinguish "no config" (`mcp_config === null && + * !mcp_config_redacted`) from "config exists but you can't see it". + * Older backends omit this field; treat `undefined` as false. + */ + mcp_config_redacted?: boolean; visibility: AgentVisibility; status: AgentStatus; max_concurrent_tasks: number; @@ -295,6 +316,15 @@ export interface UpdateAgentRequest { * MUL-2600. */ custom_args?: string[]; + /** + * MCP server configuration. Tri-state semantics (MUL-2764): + * - field omitted → no change + * - `null` → clear the column; the daemon falls back to the CLI's + * built-in default at launch + * - object → replace the stored JSON verbatim; the platform does + * not validate the shape (MCP CLI accepts whatever it accepts) + */ + mcp_config?: unknown | null; visibility?: AgentVisibility; status?: AgentStatus; max_concurrent_tasks?: number; diff --git a/packages/views/agents/components/agent-overview-pane.test.tsx b/packages/views/agents/components/agent-overview-pane.test.tsx new file mode 100644 index 0000000000..8c0d17f7e4 --- /dev/null +++ b/packages/views/agents/components/agent-overview-pane.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom + +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Agent, AgentRuntime } from "@multica/core/types"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enAgents from "../../locales/en/agents.json"; + +const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } }; + +// AgentOverviewPane pulls in ActorIssuesPanel which in turn touches the api +// layer. The test only cares about which top-of-pane tab buttons render, +// not what each tab does, so we stub the heavy children. +vi.mock("./tabs/activity-tab", () => ({ + ActivityTab: () =>
activity-tab
, +})); +vi.mock("./tabs/instructions-tab", () => ({ + InstructionsTab: () =>
instructions-tab
, +})); +vi.mock("./tabs/skills-tab", () => ({ + SkillsTab: () =>
skills-tab
, +})); +vi.mock("./tabs/env-tab", () => ({ + EnvTab: () =>
env-tab
, +})); +vi.mock("./tabs/custom-args-tab", () => ({ + CustomArgsTab: () =>
custom-args-tab
, +})); +vi.mock("./tabs/mcp-config-tab", () => ({ + McpConfigTab: () =>
mcp-config-tab
, +})); +vi.mock("../../common/actor-issues-panel", () => ({ + ActorIssuesPanel: () =>
actor-issues-panel
, +})); + +import { AgentOverviewPane } from "./agent-overview-pane"; + +const baseAgent: Agent = { + id: "agent-1", + workspace_id: "ws-1", + runtime_id: "runtime-1", + name: "Agent", + description: "", + instructions: "", + avatar_url: null, + runtime_mode: "local", + runtime_config: {}, + custom_args: [], + visibility: "workspace", + status: "idle", + max_concurrent_tasks: 1, + model: "", + owner_id: "user-1", + skills: [], + created_at: "2026-05-28T00:00:00Z", + updated_at: "2026-05-28T00:00:00Z", + archived_at: null, + archived_by: null, +}; + +function makeRuntime(provider: string): AgentRuntime { + return { + id: "runtime-1", + workspace_id: "ws-1", + daemon_id: null, + name: "Runtime", + runtime_mode: "local", + provider, + launch_header: "", + status: "online", + device_info: "", + metadata: {}, + owner_id: null, + visibility: "private", + last_seen_at: null, + created_at: "2026-05-28T00:00:00Z", + updated_at: "2026-05-28T00:00:00Z", + }; +} + +function renderPane(runtimes: AgentRuntime[]) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ); +} + +describe("AgentOverviewPane MCP tab visibility", () => { + it("renders the MCP tab when the agent runs on the Claude runtime", () => { + renderPane([makeRuntime("claude")]); + expect(screen.getByRole("button", { name: /^MCP$/i })).toBeInTheDocument(); + }); + + it("renders the MCP tab when the agent runs on the Codex runtime", () => { + // Codex now reads mcp_config too — must not be hidden from the tab strip. + renderPane([makeRuntime("codex")]); + expect(screen.getByRole("button", { name: /^MCP$/i })).toBeInTheDocument(); + }); + + it("hides the MCP tab for providers whose backend does not read mcp_config", () => { + // Saving an MCP config on e.g. Gemini would be a silent no-op at run + // time — that's the bug this hiding logic is meant to prevent. + renderPane([makeRuntime("gemini")]); + expect( + screen.queryByRole("button", { name: /^MCP$/i }), + ).not.toBeInTheDocument(); + }); + + it("keeps the MCP tab visible when the runtime row hasn't loaded yet", () => { + // Empty runtimes[] mimics the brief window between the page mounting and + // the runtimes query resolving. Hiding the tab would flicker it off and + // then back on, which reads as a bug. + renderPane([]); + expect(screen.getByRole("button", { name: /^MCP$/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/views/agents/components/agent-overview-pane.tsx b/packages/views/agents/components/agent-overview-pane.tsx index 611a27b24e..d4ce333e14 100644 --- a/packages/views/agents/components/agent-overview-pane.tsx +++ b/packages/views/agents/components/agent-overview-pane.tsx @@ -1,15 +1,17 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Activity, BookOpenText, FileText, KeyRound, ListTodo, + Plug, Terminal, } from "lucide-react"; import type { Agent, AgentRuntime } from "@multica/core/types"; +import { providerSupportsMcpConfig } from "@multica/core/agents"; import { AlertDialog, AlertDialogAction, @@ -25,6 +27,7 @@ import { InstructionsTab } from "./tabs/instructions-tab"; import { SkillsTab } from "./tabs/skills-tab"; import { EnvTab } from "./tabs/env-tab"; import { CustomArgsTab } from "./tabs/custom-args-tab"; +import { McpConfigTab } from "./tabs/mcp-config-tab"; import { ActorIssuesPanel } from "../../common/actor-issues-panel"; import { useT } from "../../i18n"; @@ -34,15 +37,17 @@ type DetailTab = | "instructions" | "skills" | "env" - | "custom_args"; + | "custom_args" + | "mcp_config"; -const TAB_LABEL_KEY: Record = { +const TAB_LABEL_KEY: Record = { activity: "activity", tasks: "tasks", instructions: "instructions", skills: "skills", env: "environment", custom_args: "custom_args", + mcp_config: "mcp_config", }; const detailTabs: { @@ -55,6 +60,7 @@ const detailTabs: { { id: "skills", icon: BookOpenText }, { id: "env", icon: KeyRound }, { id: "custom_args", icon: Terminal }, + { id: "mcp_config", icon: Plug }, ]; interface AgentOverviewPaneProps { @@ -103,6 +109,24 @@ export function AgentOverviewPane({ ? runtimes.find((r) => r.id === agent.runtime_id) ?? null : null; + // The MCP tab is only shown when the agent's runtime backend actually + // consumes mcp_config — see providerSupportsMcpConfig. We default to + // showing it when the runtime row hasn't loaded yet so a slow fetch + // can't transiently flicker the tab off and then on. + const visibleTabs = useMemo(() => { + const showMcp = runtime ? providerSupportsMcpConfig(runtime.provider) : true; + return detailTabs.filter((tab) => tab.id !== "mcp_config" || showMcp); + }, [runtime]); + + // If the active tab disappears (e.g. user just switched the agent's + // runtime to one that doesn't read mcp_config), fall back to Activity + // for this render so the pane is never empty. The user's stored + // activeTab is left alone — switching back to a supporting runtime + // brings their selection back. + const effectiveTab: DetailTab = visibleTabs.some((tab) => tab.id === activeTab) + ? activeTab + : "activity"; + const requestTabChange = (next: DetailTab) => { if (next === activeTab) return; if (activeDirty) { @@ -129,13 +153,13 @@ export function AgentOverviewPane({ // the grid-driven full-height behavior on tablet and up.
- {detailTabs.map((tab) => ( + {visibleTabs.map((tab) => (
- {activeTab === "activity" && } - {activeTab === "tasks" && ( + {effectiveTab === "activity" && } + {effectiveTab === "tasks" && (
)} - {activeTab === "instructions" && ( + {effectiveTab === "instructions" && ( )} - {activeTab === "skills" && ( + {effectiveTab === "skills" && ( )} - {activeTab === "env" && ( + {effectiveTab === "env" && ( )} - {activeTab === "custom_args" && ( + {effectiveTab === "custom_args" && ( )} + {effectiveTab === "mcp_config" && ( + + onUpdate(agent.id, updates)} + onDirtyChange={setActiveDirty} + /> + + )}
{pendingTab !== null && ( diff --git a/packages/views/agents/components/tabs/mcp-config-tab.test.tsx b/packages/views/agents/components/tabs/mcp-config-tab.test.tsx new file mode 100644 index 0000000000..4aaa03131d --- /dev/null +++ b/packages/views/agents/components/tabs/mcp-config-tab.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment jsdom + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Agent } from "@multica/core/types"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../../locales/en/common.json"; +import enAgents from "../../../locales/en/agents.json"; + +const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } }; + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { McpConfigTab } from "./mcp-config-tab"; + +const baseAgent: Agent = { + id: "agent-1", + workspace_id: "ws-1", + runtime_id: "runtime-1", + name: "Agent", + description: "", + instructions: "", + avatar_url: null, + runtime_mode: "local", + runtime_config: {}, + custom_args: [], + visibility: "workspace", + status: "idle", + max_concurrent_tasks: 1, + model: "", + owner_id: "user-1", + skills: [], + created_at: "2026-05-28T00:00:00Z", + updated_at: "2026-05-28T00:00:00Z", + archived_at: null, + archived_by: null, +}; + +function renderTab( + overrides: Partial = {}, + onSave = vi.fn().mockResolvedValue(undefined), +) { + const agent = { ...baseAgent, ...overrides }; + const result = render( + + + , + ); + return { ...result, onSave }; +} + +describe("McpConfigTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders a read-only redacted state when the server omitted the value", () => { + // mcp_config_redacted means the server knows there IS a config but + // hid it from this caller. The tab must NOT expose the editor or + // any input — even an empty textarea would let a non-privileged + // member silently overwrite an admin-owned config on save. + renderTab({ mcp_config: null, mcp_config_redacted: true }); + + expect(screen.getByText(/hidden from your view/i)).toBeInTheDocument(); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /save/i })).not.toBeInTheDocument(); + }); + + it("shows the editor empty when no config is set, and Save stays disabled", () => { + renderTab({ mcp_config: null }); + + const editor = screen.getByLabelText(/MCP config JSON editor/i) as HTMLTextAreaElement; + expect(editor.value).toBe(""); + + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + }); + + it("pretty-prints the existing config and saves a parsed object", async () => { + const user = userEvent.setup(); + const stored = { mcpServers: { fetch: { command: "uvx" } } }; + const { onSave } = renderTab({ mcp_config: stored }); + + const editor = screen.getByLabelText(/MCP config JSON editor/i) as HTMLTextAreaElement; + expect(editor.value).toBe(JSON.stringify(stored, null, 2)); + + // userEvent.type interprets `{` / `[` as keyboard modifiers, so a + // raw JSON paste goes through fireEvent.change instead — the same + // path the browser uses when the user pastes. + const replacement = JSON.stringify({ + mcpServers: { fetch: { command: "npx" } }, + }); + fireEvent.change(editor, { target: { value: replacement } }); + + const save = screen.getByRole("button", { name: /save/i }); + expect(save).toBeEnabled(); + await user.click(save); + + expect(onSave).toHaveBeenCalledTimes(1); + // We pass the parsed object, not the raw string, so the backend + // gets a real JSON shape and not an escaped string. + expect(onSave).toHaveBeenCalledWith({ + mcp_config: { mcpServers: { fetch: { command: "npx" } } }, + }); + }); + + it("clearing the editor saves null to wipe the column", async () => { + const user = userEvent.setup(); + const { onSave } = renderTab({ mcp_config: { mcpServers: {} } }); + + const editor = screen.getByLabelText(/MCP config JSON editor/i) as HTMLTextAreaElement; + await user.clear(editor); + + const save = screen.getByRole("button", { name: /save/i }); + await user.click(save); + + // null is what the backend reads as "clear this column" — sending + // an empty string or {} would either fail validation or store an + // empty object, both of which surprise the user. + expect(onSave).toHaveBeenCalledWith({ mcp_config: null }); + }); + + it("disables Save and surfaces an inline error on invalid JSON", () => { + const { onSave } = renderTab({ mcp_config: null }); + + const editor = screen.getByLabelText(/MCP config JSON editor/i); + fireEvent.change(editor, { target: { value: "{ not json" } }); + + expect(screen.getByText(/Invalid JSON/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("rejects top-level arrays and primitives", () => { + renderTab({ mcp_config: null }); + + const editor = screen.getByLabelText(/MCP config JSON editor/i); + fireEvent.change(editor, { target: { value: "[1,2,3]" } }); + + expect( + screen.getByText(/MCP config must be a JSON object/i), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + }); + + it("syncs the editor to a refreshed agent prop when the user hasn't edited", () => { + // Reproduces the stale-editor bug: a background refetch / WS event swaps + // in a newer `agent.mcp_config`, and the editor must follow it (so the + // next Save writes the new value, not the old one). Comparing the draft + // against the *previous* original — not the new one — is what makes this + // work. Without the ref, the effect would self-defeat: on re-render the + // draft already equals the new original, the equality check is true, + // but the conditional only re-assigns `original` to itself, so a draft + // that started life equal to the OLD original is never touched. + const initial = { mcpServers: { fetch: { command: "uvx" } } }; + const updated = { mcpServers: { fetch: { command: "npx" } } }; + const agent = { ...baseAgent, mcp_config: initial }; + + const { rerender } = render( + + + , + ); + + const editor = screen.getByLabelText( + /MCP config JSON editor/i, + ) as HTMLTextAreaElement; + expect(editor.value).toBe(JSON.stringify(initial, null, 2)); + + rerender( + + + , + ); + + // Editor follows the new prop and the dirty hint is NOT shown — if it + // were, the next Save would write the *old* JSON back over the new one. + expect(editor.value).toBe(JSON.stringify(updated, null, 2)); + expect(screen.queryByText(/unsaved changes/i)).not.toBeInTheDocument(); + }); + + it("preserves an in-flight edit when the agent prop is refreshed underneath", () => { + // The mirror of the test above: if the user IS editing, a background + // refresh must not clobber their draft. + const initial = { mcpServers: { fetch: { command: "uvx" } } }; + const updated = { mcpServers: { fetch: { command: "npx" } } }; + const agent = { ...baseAgent, mcp_config: initial }; + + const { rerender } = render( + + + , + ); + + const editor = screen.getByLabelText( + /MCP config JSON editor/i, + ) as HTMLTextAreaElement; + const draft = JSON.stringify({ mcpServers: { fetch: { command: "wip" } } }); + fireEvent.change(editor, { target: { value: draft } }); + expect(editor.value).toBe(draft); + + rerender( + + + , + ); + + expect(editor.value).toBe(draft); + }); + +}); diff --git a/packages/views/agents/components/tabs/mcp-config-tab.tsx b/packages/views/agents/components/tabs/mcp-config-tab.tsx new file mode 100644 index 0000000000..d257b8b3c5 --- /dev/null +++ b/packages/views/agents/components/tabs/mcp-config-tab.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Eraser, Loader2, Lock, Save } from "lucide-react"; +import type { Agent } from "@multica/core/types"; +import { Button } from "@multica/ui/components/ui/button"; +import { Textarea } from "@multica/ui/components/ui/textarea"; +import { toast } from "sonner"; +import { useT } from "../../../i18n"; + +// `null` and the empty string are the two ways the user can mean "no +// config" — the server stores either as a NULL column and the daemon +// falls back to the runtime CLI default at launch. We normalise to +// the empty string in the editor so the dirty check has one canonical +// form to compare against. +function configToText(value: unknown): string { + if (value === null || value === undefined) return ""; + return JSON.stringify(value, null, 2); +} + +export function McpConfigTab({ + agent, + onSave, + onDirtyChange, +}: { + agent: Agent; + onSave: (updates: { mcp_config: unknown | null }) => Promise; + onDirtyChange?: (dirty: boolean) => void; +}) { + const { t } = useT("agents"); + + const redacted = agent.mcp_config_redacted === true; + const original = useMemo(() => configToText(agent.mcp_config), [agent.mcp_config]); + const [text, setText] = useState(original); + const [saving, setSaving] = useState(false); + + // Sync local draft when the agent prop changes (e.g. after a successful + // save invalidates the cache and a fresh agent arrives). We only sync + // when the user has no in-flight edits — comparing the current draft + // against the *previous* original (not the new one) is what tells us + // "they haven't touched this since the last sync". Comparing against + // the new original would skip the sync whenever the server-side value + // changes underneath an untouched draft, leaving the editor showing a + // stale value that a later Save would write back, clobbering another + // admin's edit. + const previousOriginalRef = useRef(original); + useEffect(() => { + setText((current) => + current === previousOriginalRef.current ? original : current, + ); + previousOriginalRef.current = original; + }, [original]); + + const trimmed = text.trim(); + const parseResult = useMemo< + | { ok: true; value: unknown | null } + | { ok: false; error: string } + >(() => { + if (trimmed === "") return { ok: true, value: null }; + try { + const value = JSON.parse(trimmed); + // The MCP CLI accepts an object (`{"mcpServers": …}`); a top-level + // array or primitive is almost certainly a user mistake, so reject + // here rather than surprise them with a server-side error later. + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return { + ok: false, + error: "mcp_config_not_object", + }; + } + return { ok: true, value }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "invalid JSON", + }; + } + }, [trimmed]); + + const dirty = text !== original; + + useEffect(() => { + onDirtyChange?.(dirty); + }, [dirty, onDirtyChange]); + + if (redacted) { + return ( +
+

+ + {t(($) => $.tab_body.mcp_config.redacted_title)} +

+

+ {t(($) => $.tab_body.mcp_config.redacted_hint)} +

+
+ ); + } + + const handleSave = async () => { + if (!parseResult.ok) return; + setSaving(true); + try { + await onSave({ mcp_config: parseResult.value }); + // Normalise the editor to the pretty-printed canonical form so the + // dirty check stops firing after a successful save (the user's + // raw input may differ from what configToText would emit). + setText(configToText(parseResult.value)); + toast.success(t(($) => $.tab_body.mcp_config.saved_toast)); + } catch (err) { + toast.error( + err instanceof Error && err.message + ? err.message + : t(($) => $.tab_body.mcp_config.save_failed_toast), + ); + } finally { + setSaving(false); + } + }; + + const handleClear = () => { + setText(""); + }; + + const showInvalid = trimmed !== "" && !parseResult.ok; + const invalidMessage = !parseResult.ok && parseResult.error === "mcp_config_not_object" + ? t(($) => $.tab_body.mcp_config.invalid_not_object) + : !parseResult.ok + ? t(($) => $.tab_body.mcp_config.invalid_json, { error: parseResult.error }) + : ""; + + return ( +
+
+

+ {t(($) => $.tab_body.mcp_config.intro)} +

+ {trimmed !== "" && ( + + )} +
+ +