Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/persist-mcp-install-options.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 45 additions & 2 deletions e2e/cloud/connect-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
}),
);
27 changes: 26 additions & 1 deletion packages/react/src/components/mcp-install-card.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
94 changes: 85 additions & 9 deletions packages/react/src/components/mcp-install-card.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 },
Expand Down Expand Up @@ -143,18 +204,33 @@ export const buildMcpInstallCommand = (input: {
};

export function McpInstallCard(props: { className?: string }) {
const [mode, setMode] = useState<TransportMode>("http");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [httpElicitationMode, setHttpElicitationMode] = useState<McpElicitationMode>("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
// PATH, and the bundled sidecar is locked to the running app. Force the
// 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<McpInstallPreferences>(() =>
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;

Expand Down Expand Up @@ -225,7 +301,7 @@ export function McpInstallCard(props: { className?: string }) {
<Switch
checked={artifacts}
onCheckedChange={(next) => {
setArtifacts(next);
setPreferences((current) => ({ ...current, artifacts: next }));
trackEvent("mcp_install_artifacts_toggled", { artifacts: next });
}}
aria-label="Artifacts"
Expand All @@ -243,7 +319,7 @@ export function McpInstallCard(props: { className?: string }) {
<Switch
checked={searchTools}
onCheckedChange={(next) => {
setSearchTools(next);
setPreferences((current) => ({ ...current, searchTools: next }));
trackEvent("mcp_install_search_tools_toggled", { search_tools: next });
}}
aria-label="Integration search tools"
Expand All @@ -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"
Expand Down Expand Up @@ -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 });
}}
>
Expand Down
Loading