From 99bb8775dd643cb2eae093ac964c93e872f4a85f Mon Sep 17 00:00:00 2001 From: baggiiiie Date: Wed, 26 Aug 2026 14:42:00 +0800 Subject: [PATCH 1/2] feat: persist MCP install preferences --- .changeset/persist-mcp-install-options.md | 5 ++ e2e/cloud/connect-panel.test.ts | 47 +++++++++++++- .../react/src/components/mcp-install-card.tsx | 65 ++++++++++++++++--- 3 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 .changeset/persist-mcp-install-options.md 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.tsx b/packages/react/src/components/mcp-install-card.tsx index 7701913ec..0019dedad 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,50 @@ 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_KEY = "executor.mcpInstallPreferences.v1"; +const DEFAULT_MCP_INSTALL_PREFERENCES: McpInstallPreferences = { + mode: "http", + httpElicitationMode: "model", + artifacts: true, + searchTools: false, +}; +const decodeMcpInstallPreferences = Schema.decodeUnknownOption( + Schema.fromJsonString(McpInstallPreferencesSchema), +); + +const readMcpInstallPreferences = (): 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(MCP_INSTALL_PREFERENCES_STORAGE_KEY); + return raw + ? Option.getOrElse(decodeMcpInstallPreferences(raw), () => DEFAULT_MCP_INSTALL_PREFERENCES) + : DEFAULT_MCP_INSTALL_PREFERENCES; + } catch { + return DEFAULT_MCP_INSTALL_PREFERENCES; + } +}; + +const writeMcpInstallPreferences = (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( + MCP_INSTALL_PREFERENCES_STORAGE_KEY, + 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 +188,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 +195,13 @@ 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 [preferences, setPreferences] = useState(readMcpInstallPreferences); + const [advancedOpen, setAdvancedOpen] = useState(false); + const { mode, httpElicitationMode, artifacts, searchTools } = preferences; + + useEffect(() => { + writeMcpInstallPreferences(preferences); + }, [preferences]); const elicitationMode = mode === "stdio" ? "model" : httpElicitationMode; @@ -225,7 +272,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 +290,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 +310,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 +399,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 }); }} > From f08e75b959d8b1059b4c683d96fad38e2d28e6fb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:24:08 -0700 Subject: [PATCH 2/2] Scope MCP install preferences to the active organization localStorage is per-origin, not per-account, so one key carried an org's transport and elicitation choices into every other org and every other user signed in through the same browser. The rendered install command differs per org, so the shared preference was wrong rather than merely surprising. Key the preferences by the active org slug, with one shared bucket for hosts that are not org-scoped (local, desktop). Nothing is migrated: the old global key is simply left unread, so those users fall back to defaults once. Switching orgs reloads that org's preferences during render rather than in an effect, so the save effect cannot write the previous org's choices under the new org's key. --- .../src/components/mcp-install-card.test.ts | 27 +++++++++- .../react/src/components/mcp-install-card.tsx | 51 +++++++++++++++---- 2 files changed, 66 insertions(+), 12 deletions(-) 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 0019dedad..cbefa8264 100644 --- a/packages/react/src/components/mcp-install-card.tsx +++ b/packages/react/src/components/mcp-install-card.tsx @@ -30,7 +30,23 @@ const McpInstallPreferencesSchema = Schema.Struct({ type McpInstallPreferences = typeof McpInstallPreferencesSchema.Type; -const MCP_INSTALL_PREFERENCES_STORAGE_KEY = "executor.mcpInstallPreferences.v1"; +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", @@ -41,10 +57,10 @@ const decodeMcpInstallPreferences = Schema.decodeUnknownOption( Schema.fromJsonString(McpInstallPreferencesSchema), ); -const readMcpInstallPreferences = (): McpInstallPreferences => { +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(MCP_INSTALL_PREFERENCES_STORAGE_KEY); + const raw = globalThis.localStorage?.getItem(storageKey); return raw ? Option.getOrElse(decodeMcpInstallPreferences(raw), () => DEFAULT_MCP_INSTALL_PREFERENCES) : DEFAULT_MCP_INSTALL_PREFERENCES; @@ -53,13 +69,13 @@ const readMcpInstallPreferences = (): McpInstallPreferences => { } }; -const writeMcpInstallPreferences = (preferences: McpInstallPreferences): void => { +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( - MCP_INSTALL_PREFERENCES_STORAGE_KEY, - JSON.stringify(preferences), - ); + globalThis.localStorage?.setItem(storageKey, JSON.stringify(preferences)); } catch { // Best-effort persistence; the options still apply to this rendered command. } @@ -195,13 +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 [preferences, setPreferences] = useState(readMcpInstallPreferences); + 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(preferences); - }, [preferences]); + writeMcpInstallPreferences(storageKey, preferences); + }, [storageKey, preferences]); const elicitationMode = mode === "stdio" ? "model" : httpElicitationMode;