diff --git a/.changeset/persist-mcp-install-options.md b/.changeset/persist-mcp-install-options.md new file mode 100644 index 000000000..1a7b31609 --- /dev/null +++ b/.changeset/persist-mcp-install-options.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Persist the Connect-an-agent card's transport, artifact, integration-search, and approval preferences in the browser so its generated MCP install command remains stable across page reloads. diff --git a/e2e/cloud/connect-panel.test.ts b/e2e/cloud/connect-panel.test.ts index 12202df77..f31d1b067 100644 --- a/e2e/cloud/connect-panel.test.ts +++ b/e2e/cloud/connect-panel.test.ts @@ -44,11 +44,54 @@ scenario( "--transport http", ); - await step("Switch back to Remote HTTP", async () => { + await step("Choose connection-specific install options", async () => { await page.getByRole("tab", { name: "Remote HTTP" }).click(); + await page.getByRole("button", { name: "Advanced" }).click(); + await page.getByRole("switch", { name: "Artifacts" }).click(); + await page.getByRole("switch", { name: "Integration search tools" }).click(); + await page.getByRole("combobox", { name: "Elicitation mode" }).selectOption("browser"); await settle(page); }); - expect(await command(), "the HTTP command is restored").toContain("--transport http"); + const customizedHttpCommand = await command(); + expect(customizedHttpCommand, "the HTTP command is restored").toContain("--transport http"); + expect(customizedHttpCommand, "disabled artifacts are encoded per connection").toContain( + "artifacts=false", + ); + expect(customizedHttpCommand, "integration search is encoded per connection").toContain( + "search_tools=true", + ); + expect(customizedHttpCommand, "browser approval is encoded per connection").toContain( + "elicitation_mode=browser", + ); + + await step("Reload with the install options preserved", async () => { + await page.reload({ waitUntil: "networkidle" }); + await page.getByText("Connect an agent").first().waitFor(); + await page.getByRole("button", { name: "Advanced" }).click(); + }); + expect(await page.getByRole("switch", { name: "Artifacts" }).isChecked()).toBe(false); + expect(await page.getByRole("switch", { name: "Integration search tools" }).isChecked()).toBe( + true, + ); + expect(await page.getByRole("combobox", { name: "Elicitation mode" }).inputValue()).toBe( + "browser", + ); + expect(await command(), "the same customized command survives a reload").toBe( + customizedHttpCommand, + ); + + await step("Preserve the selected transport across another reload", async () => { + await page.getByRole("tab", { name: "Standard I/O" }).click(); + await settle(page); + await page.reload({ waitUntil: "networkidle" }); + await page.getByText("Connect an agent").first().waitFor(); + }); + expect( + await page.getByRole("tab", { name: "Standard I/O" }).getAttribute("aria-selected"), + ).toBe("true"); + expect(await command(), "the persisted stdio command retains compatible options").toContain( + "mcp --no-artifacts --search-tools", + ); }); }), ); diff --git a/packages/react/src/components/mcp-install-card.test.ts b/packages/react/src/components/mcp-install-card.test.ts index 38ed2a7ba..fb2cf411c 100644 --- a/packages/react/src/components/mcp-install-card.test.ts +++ b/packages/react/src/components/mcp-install-card.test.ts @@ -1,6 +1,31 @@ import { describe, expect, it } from "@effect/vitest"; -import { buildMcpHttpEndpoint, buildMcpInstallCommand, shellQuoteWord } from "./mcp-install-card"; +import { + buildMcpHttpEndpoint, + buildMcpInstallCommand, + mcpInstallPreferencesStorageKey, + shellQuoteWord, +} from "./mcp-install-card"; + +describe("MCP install preference storage key", () => { + it("gives each organization its own key", () => { + // `localStorage` is per-origin. Two orgs signed in through one browser — + // or two people sharing a machine — must not inherit each other's + // transport and elicitation choices, because the command they render is + // different. + expect(mcpInstallPreferencesStorageKey("acme")).not.toBe( + mcpInstallPreferencesStorageKey("globex"), + ); + expect(mcpInstallPreferencesStorageKey("acme")).toBe("executor.mcpInstallPreferences.v1.acme"); + }); + + it("falls back to one bucket on hosts with no organization", () => { + // Local and desktop are single-user and carry no org slug; they get a + // stable key rather than an unscoped one shared with every cloud org. + expect(mcpInstallPreferencesStorageKey(null)).toBe("executor.mcpInstallPreferences.v1.local"); + expect(mcpInstallPreferencesStorageKey(null)).not.toBe(mcpInstallPreferencesStorageKey("acme")); + }); +}); describe("MCP install command rendering", () => { it("quotes shell words without giving scope paths command syntax", () => { diff --git a/packages/react/src/components/mcp-install-card.tsx b/packages/react/src/components/mcp-install-card.tsx index 7701913ec..cbefa8264 100644 --- a/packages/react/src/components/mcp-install-card.tsx +++ b/packages/react/src/components/mcp-install-card.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { Option, Schema } from "effect"; import { trackEvent } from "../api/analytics"; import CursorIcon from "@lobehub/icons/es/Cursor/components/Mono"; import ClaudeIcon from "@lobehub/icons/es/Claude/components/Color"; @@ -20,6 +21,66 @@ import { type TransportMode = "stdio" | "http"; export type McpElicitationMode = "browser" | "model" | "native"; +const McpInstallPreferencesSchema = Schema.Struct({ + mode: Schema.Literals(["stdio", "http"]), + httpElicitationMode: Schema.Literals(["browser", "model", "native"]), + artifacts: Schema.Boolean, + searchTools: Schema.Boolean, +}); + +type McpInstallPreferences = typeof McpInstallPreferencesSchema.Type; + +const MCP_INSTALL_PREFERENCES_STORAGE_PREFIX = "executor.mcpInstallPreferences.v1"; +/** Hosts that are not org-scoped (local, desktop) share this one suffix. */ +const UNSCOPED_ORGANIZATION_SUFFIX = "local"; + +/** + * Storage key for one organization's install preferences. + * + * `localStorage` is per-origin, not per-account, so a single key would carry + * one org's transport and elicitation choices into every other org — and into + * every other user — signed in through the same browser. The rendered command + * differs per org, so a shared preference is wrong rather than merely + * surprising. Scoping by slug keeps each org's choices to itself; hosts with + * no org context are a single user by construction and share one bucket. + */ +export const mcpInstallPreferencesStorageKey = (organizationSlug: string | null): string => + `${MCP_INSTALL_PREFERENCES_STORAGE_PREFIX}.${organizationSlug ?? UNSCOPED_ORGANIZATION_SUFFIX}`; + +const DEFAULT_MCP_INSTALL_PREFERENCES: McpInstallPreferences = { + mode: "http", + httpElicitationMode: "model", + artifacts: true, + searchTools: false, +}; +const decodeMcpInstallPreferences = Schema.decodeUnknownOption( + Schema.fromJsonString(McpInstallPreferencesSchema), +); + +const readMcpInstallPreferences = (storageKey: string): McpInstallPreferences => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: localStorage can throw when browser storage is disabled + try { + const raw = globalThis.localStorage?.getItem(storageKey); + return raw + ? Option.getOrElse(decodeMcpInstallPreferences(raw), () => DEFAULT_MCP_INSTALL_PREFERENCES) + : DEFAULT_MCP_INSTALL_PREFERENCES; + } catch { + return DEFAULT_MCP_INSTALL_PREFERENCES; + } +}; + +const writeMcpInstallPreferences = ( + storageKey: string, + preferences: McpInstallPreferences, +): void => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: localStorage can throw when browser storage is disabled + try { + globalThis.localStorage?.setItem(storageKey, JSON.stringify(preferences)); + } catch { + // Best-effort persistence; the options still apply to this rendered command. + } +}; + const SUPPORTED_AGENTS = [ { key: "cursor", label: "Cursor", Icon: CursorIcon }, { key: "claude", label: "Claude", Icon: ClaudeIcon }, @@ -143,11 +204,6 @@ export const buildMcpInstallCommand = (input: { }; export function McpInstallCard(props: { className?: string }) { - const [mode, setMode] = useState("http"); - const [advancedOpen, setAdvancedOpen] = useState(false); - const [httpElicitationMode, setHttpElicitationMode] = useState("model"); - const [artifacts, setArtifacts] = useState(true); - const [searchTools, setSearchTools] = useState(false); const organizationSlug = useOrganizationSlug(); const serverConnection = useExecutorServerConnection(); // Desktop hosts ship Electron without putting an `executor` binary on @@ -155,6 +211,26 @@ export function McpInstallCard(props: { className?: string }) { // HTTP path there; it routes through the active sidecar connection. const showStdio = isLocal && serverConnection.kind !== "desktop-sidecar" && !hasDesktopConnectionBridge(); + const storageKey = mcpInstallPreferencesStorageKey(organizationSlug); + const [preferences, setPreferences] = useState(() => + readMcpInstallPreferences(storageKey), + ); + // Switching organizations must load that org's own preferences. Reloading in + // an effect would let the save effect below run first and write the previous + // org's choices under the new org's key, which is the bleed this scoping + // exists to prevent. Adjusting during render re-runs this component before + // anything commits, so the save effect only ever sees a matched pair. + const [loadedStorageKey, setLoadedStorageKey] = useState(storageKey); + if (loadedStorageKey !== storageKey) { + setLoadedStorageKey(storageKey); + setPreferences(readMcpInstallPreferences(storageKey)); + } + const [advancedOpen, setAdvancedOpen] = useState(false); + const { mode, httpElicitationMode, artifacts, searchTools } = preferences; + + useEffect(() => { + writeMcpInstallPreferences(storageKey, preferences); + }, [storageKey, preferences]); const elicitationMode = mode === "stdio" ? "model" : httpElicitationMode; @@ -225,7 +301,7 @@ export function McpInstallCard(props: { className?: string }) { { - setArtifacts(next); + setPreferences((current) => ({ ...current, artifacts: next })); trackEvent("mcp_install_artifacts_toggled", { artifacts: next }); }} aria-label="Artifacts" @@ -243,7 +319,7 @@ export function McpInstallCard(props: { className?: string }) { { - setSearchTools(next); + setPreferences((current) => ({ ...current, searchTools: next })); trackEvent("mcp_install_search_tools_toggled", { search_tools: next }); }} aria-label="Integration search tools" @@ -263,7 +339,7 @@ export function McpInstallCard(props: { className?: string }) { value={elicitationMode} onChange={(event) => { const next = event.target.value as McpElicitationMode; - setHttpElicitationMode(next); + setPreferences((current) => ({ ...current, httpElicitationMode: next })); trackEvent("mcp_install_elicitation_mode_changed", { elicitation_mode: next }); }} aria-label="Elicitation mode" @@ -352,7 +428,7 @@ export function McpInstallCard(props: { className?: string }) { value={mode} onValueChange={(v) => { const next = v as TransportMode; - setMode(next); + setPreferences((current) => ({ ...current, mode: next })); trackEvent("mcp_install_transport_switched", { transport: next }); }} >