History

feat(mcp): add edit support for stdio sources (#1775)

* feat(mcp): add edit support for stdio sources - Add StdioEditForm with Save/Cancel buttons for stdio MCP sources - Implement updateStdioServer backend function using Effect - Connect UI to backend with proper error handling - Enable canEdit for stdio sources - Use shared Input, Label, and Textarea components - 42/42 MCP tests passing Closes #812 * Rework the stdio edit sheet onto the current shape Edit the command, arguments, working directory, and declared env map through the sheet's staged Save instead of a second inline form, using the add flow's CardStack fields and its quote-aware argument parsing (now shared). Drop the parallel updateStdioServer endpoint: configureServer already replaces an integration config. It now also re-runs discovery on the connections, since the tool catalog is derived from the config it just replaced. The env field edits the declared static map only. A stdio server receives that plus a fixed base set, so the copy no longer implies it inherits executor's environment. * Drop the rediscovery call; core already revises the catalog An integrations config write stamps config_revised_at, and every connection whose catalog predates the stamp re-lists on its next read. Refreshing from the plugin duplicated that, eagerly, on the save path. The local stdio scenario passes either way, which is how the duplication showed up. Cover the edit in that scenario instead: it adds a declared env var, and the fixture only advertises saw_declared_env when that variable reached the child, so the tool appearing after a plain config replace shows the rebuild happens with no refresh of its own. * Drive the stdio edit sheet in a browser e2e Local is the only surface with stdio MCP enabled, so the sheet gets its scenario there. It edits the declared env through the form and then reads the tool catalog: the fixture advertises saw_declared_env only when that variable is in the spawned child environment, so the tool appearing proves the edit travelled form to config to respawn. Name the four fields with aria-label. CardStackEntryField renders its label without htmlFor, so the controls had no accessible name at all. * Pre-bundle the local dev server's late-discovered deps The local app's dev server discovered effect/Match, effect/Predicate and js-yaml only when a lazy React chunk first rendered them. Vite then re-optimized and full-reloaded the page. Opening the integration Edit sheet is one of those chunks, so the reload landed on the open sheet and closed it, and the new stdio edit scenario waited 30 seconds for a field that had been thrown away. Pre-bundle the same list apps/host-selfhost and apps/cloud already carry, so nothing is discovered mid-run. ---------

Aarushi Singh committed 2 months ago

Commit ecb87de1 parent 21680298 files · +521 −36Browse files

Unified · Split

8 files
  1. .changeset/mcp-stdio-edit-ui.md +26 −0
  2. apps/local/vite.config.ts +35 −1
  3. e2e/local/stdio-mcp-edit-ui.test.ts +138 −0
  4. e2e/local/stdio-mcp.test.ts +42 −0
  5. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +1 −13
  6. packages/plugins/mcp/src/react/EditMcpIntegration.tsx +158 −22
  7. packages/plugins/mcp/src/react/stdio-fields.test.ts +70 −0
  8. packages/plugins/mcp/src/react/stdio-fields.ts +51 −0
.changeset/mcp-stdio-edit-ui.md+26−0
@@ -0,0 +1,26 @@
1+---
2+"executor": patch
3+---
4+
5+**Stdio MCP integrations can be edited from the UI**
6+
7+The integration Edit sheet showed stdio servers as read-only text and told you
8+to remove and recreate the integration to change its command. Fixing a typo in
9+an argument, moving a server to a new path, or adding a static environment
10+variable meant editing `executor.jsonc` by hand, or losing the integration's
11+connections and tool policies to a delete-and-re-add.
12+
13+The sheet now edits the command, its arguments, the working directory, and the
14+declared environment map, staged and applied by the sheet's own Save like the
15+remote editor beside it. Arguments use the same quote-aware parsing as the add
16+flow, so an argument containing spaces survives a round trip.
17+
18+The environment field edits the DECLARED static variables only. A stdio server
19+receives those plus a small fixed base set — it does not inherit executor's
20+environment — and secret values still belong to the connection, entered per
21+account against the server's declared `stdio_env` method.
22+
23+Saving revises the integration config, which is already enough to rebuild the
24+tool catalog: connections whose catalog predates the revision re-list on their
25+next read, so an edited command's tools are correct without an explicit
26+refresh.
apps/local/vite.config.ts+35−1
@@ -1,5 +1,6 @@
11 import { readFileSync } from "node:fs";
2−import { resolve } from "node:path";
2+import { createRequire } from "node:module";
3+import { dirname, resolve } from "node:path";
34 import { Readable } from "node:stream";
45 import { fileURLToPath } from "node:url";
56 import { defineConfig, type Plugin } from "vite";
@@ -42,6 +43,19 @@ const EXECUTOR_GITHUB_URL = ( · 36 unmodified lines
4243 const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
4344 const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));
4445
46+// js-yaml reaches the browser through @executor-js/plugin-openapi's UI, so it
47+// belongs in the pre-bundle below. Vite resolves `optimizeDeps.include` from
48+// `root`, which here is packages/app, and bun's isolated install keeps js-yaml
49+// in the openapi plugin's own node_modules — out of reach of both a plain
50+// "js-yaml" specifier and the "<pkg> > <dep>" form from that directory. Point
51+// the bare specifier at the package the plugin already resolves, so `root` can
52+// name it. It is the same copy either way, so nothing about resolution changes.
53+const JS_YAML_DIR = dirname(
54+ createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve(
55+ "js-yaml",
56+ ),
57+);
58+
4559 const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>
4660 new Response(
4761 JSON.stringify(
@@ -215,6 +229,25 @@ export default defineConfig({ · 167 unmodified lines
215229 outDir: resolve(import.meta.dirname, "dist"),
216230 emptyOutDir: true,
217231 },
232+ // Deps vite only discovers once a lazy-loaded React chunk actually renders
233+ // (e.g. opening the integration Edit sheet, or the MCP/OpenAPI add-source
234+ // flow). Discovering one mid-run forces a re-optimize and a full page
235+ // reload, which throws away whatever the person was doing — an open sheet
236+ // and its form state included. Pre-bundle them at boot so vite never
237+ // discovers them mid-run. Keep in sync with apps/host-selfhost and
238+ // apps/cloud.
239+ optimizeDeps: {
240+ include: [
241+ "effect/Match",
242+ "effect/Predicate",
243+ "effect/Exit",
244+ "effect/Option",
245+ "effect/Cause",
246+ "effect/Data",
247+ "effect/Schema",
248+ "js-yaml",
249+ ],
250+ },
218251 define: {
219252 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
220253 // The local app IS the npm-installed CLI, so its update card shows the npm
@@ -229,6 +262,7 @@ export default defineConfig({ · 8 unmodified lines
229262 },
230263 resolve: {
231264 tsconfigPaths: true,
265+ alias: { "js-yaml": JS_YAML_DIR },
232266 },
233267 server: {
234268 port: parseInt(process.env.PORT ?? "5173", 10),
e2e/local/stdio-mcp-edit-ui.test.ts+138−0
@@ -0,0 +1,138 @@
1+// Local-only — the integration Edit sheet for a STDIO MCP server, driven in a
2+// real browser. `local` is the only surface that enables stdio MCP
3+// (`dangerouslyAllowStdioMCP: true`), so it is the only place this sheet can be
4+// exercised end to end.
5+//
6+// The sheet used to say "Stdio MCP integrations cannot be edited. Remove and
7+// recreate the integration with the updated command." (#812) — changing a
8+// command meant editing `executor.jsonc` by hand, or losing the integration's
9+// connections and policies to a delete-and-re-add.
10+//
11+// The assertion is deliberately not "the field accepted my text". The scenario
12+// edits the DECLARED env map through the form and then reads the tool catalog:
13+// the fixture advertises `saw_declared_env` only when that variable is present
14+// in the spawned child's environment, so the tool appearing proves the edited
15+// config travelled the whole way — form, config write, respawn, rediscovery.
16+import { fileURLToPath } from "node:url";
17+
18+import { expect } from "@effect/vitest";
19+import { Effect } from "effect";
20+import { HttpApiClient } from "effect/unstable/httpapi";
21+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
22+import { composePluginApi } from "@executor-js/api/server";
23+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
24+
25+import { scenario } from "../src/scenario";
26+import { Browser, Cli, RunDir, Target } from "../src/services";
27+import { withLocalServer } from "./local-server";
28+
29+const api = composePluginApi([mcpHttpPlugin()] as const);
30+
31+const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
32+
33+const SECRET = "s3cr3t-typed-into-the-edit-sheet";
34+
35+scenario(
36+ "Local · a stdio MCP server's command and environment are editable from the integration Edit sheet",
37+ { timeout: 300_000 },
38+ Effect.gen(function* () {
39+ const cli = yield* Cli;
40+ const browser = yield* Browser;
41+ const target = yield* Target;
42+ const runDir = yield* RunDir;
43+ const identity = yield* target.newIdentity();
44+
45+ yield* withLocalServer(cli, runDir, (server) =>
46+ Effect.gen(function* () {
47+ const client = yield* HttpApiClient.make(api, {
48+ baseUrl: new URL("/api", server.origin).toString(),
49+ transformClient: HttpClient.mapRequest((request) =>
50+ HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
51+ ),
52+ }).pipe(Effect.provide(FetchHttpClient.layer));
53+
54+ const slug = "e2e-stdio-editable";
55+
56+ // A plain stdio server: no declared env, so the env-gated tool is absent
57+ // until the edit adds the variable.
58+ yield* client.mcp.addServer({
59+ payload: {
60+ transport: "stdio",
61+ name: "E2E Stdio Editable",
62+ command: "node",
63+ args: [FIXTURE],
64+ slug,
65+ },
66+ });
67+
68+ const before = yield* client.tools.list({ query: { integration: slug } });
69+ expect(
70+ before.map((t) => t.name),
71+ "the server starts with its base tool and no declared env",
72+ ).toContain("echo_tool");
73+ expect(
74+ before.map((t) => t.name),
75+ "the env-gated tool is absent before the edit",
76+ ).not.toContain("saw_declared_env");
77+
78+ yield* browser.session(identity, async ({ page, step }) => {
79+ await step("Open the stdio integration from the console", async () => {
80+ await page.goto(server.url, { waitUntil: "domcontentloaded" });
81+ await page.getByTestId(`integration-entry-${slug}`).first().click();
82+ // Wait for the detail page's own data, not just its shell: clicking
83+ // Edit while the accounts panel is still a skeleton races the layout
84+ // shift that lands when it resolves.
85+ await page.getByText("default").first().waitFor({ timeout: 30_000 });
86+ await page.getByRole("button", { name: "Edit" }).waitFor({ timeout: 30_000 });
87+ });
88+
89+ await step("Open the Edit sheet — the stdio command is editable", async () => {
90+ await page.getByRole("button", { name: "Edit" }).click();
91+ await page.getByText("Edit integration").waitFor({ timeout: 30_000 });
92+ await page.getByText("Server command").waitFor({ timeout: 30_000 });
93+ // The read-only dead end this replaces.
94+ expect(
95+ await page.getByText("Stdio MCP integrations cannot be edited").count(),
96+ "the read-only message is gone",
97+ ).toBe(0);
98+ expect(
99+ await page.getByRole("textbox", { name: "Command" }).inputValue(),
100+ "the stored command is loaded into the form",
101+ ).toBe("node");
102+ });
103+
104+ await step("Declare an environment variable and save", async () => {
105+ await page
106+ .getByRole("textbox", { name: "Environment variables" })
107+ .fill(`EXECUTOR_E2E_SECRET=${SECRET}`);
108+ await page.getByRole("button", { name: "Save" }).click();
109+ await page.getByText("Server command").waitFor({ state: "hidden", timeout: 30_000 });
110+ });
111+ });
112+
113+ // The edit persisted as the DECLARED static env map on the config.
114+ const stored = yield* client.mcp.getServer({ params: { slug } });
115+ expect(
116+ stored?.config.transport === "stdio" ? stored.config.env : undefined,
117+ "the sheet wrote the declared env map",
118+ ).toEqual({ EXECUTOR_E2E_SECRET: SECRET });
119+ expect(
120+ stored?.config.transport === "stdio" ? stored.config.command : undefined,
121+ "the command it did not touch is unchanged",
122+ ).toBe("node");
123+
124+ // And it reached the spawned server: the fixture gates this tool's very
125+ // existence on that variable being in its own environment.
126+ const after = yield* client.tools.list({ query: { integration: slug } });
127+ expect(
128+ after.map((t) => t.name),
129+ "the edited environment reached the respawned server and the catalog was rebuilt",
130+ ).toContain("saw_declared_env");
131+ expect(
132+ after.map((t) => t.name),
133+ "the rebuild kept the tools that still exist",
134+ ).toContain("echo_tool");
135+ }),
136+ );
137+ }),
138+);
e2e/local/stdio-mcp.test.ts+42−0
@@ -222,6 +222,48 @@ scenario( · 221 unmodified lines
222222 autoTools.map((t) => t.name),
223223 "auto negotiation falls back to legacy and still discovers tools",
224224 ).toContain("echo_tool");
225+ expect(
226+ autoTools.map((t) => t.name),
227+ "this server declared no env, so the env-gated tool is absent to begin with",
228+ ).not.toContain("saw_declared_env");
229+
230+ // --- Editing a stdio server's config (what the integration Edit sheet
231+ // now does instead of telling you to remove and recreate). The tool
232+ // catalog is persisted per connection, so a plain config replace is
233+ // only enough because core stamps `config_revised_at` on a config
234+ // write and every connection whose catalog predates that stamp is
235+ // rebuilt on the next read. The edit path therefore needs NO explicit
236+ // refresh of its own — this asserts that, so nobody adds one back.
237+ //
238+ // Adding a declared env var is the lever: the fixture advertises
239+ // `saw_declared_env` only when that variable reached the child, so the
240+ // tool appearing with no further action proves both halves — the new
241+ // config reached the spawn, and the catalog was rebuilt from it. ---
242+ const editedConfig = {
243+ ...autoStored!.config,
244+ env: { EXECUTOR_E2E_SECRET: SECRET },
245+ } as typeof autoStored.config;
246+
247+ yield* client.mcp.configureServer({
248+ params: { slug: autoSlug },
249+ payload: { config: editedConfig },
250+ });
251+
252+ const editedStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });
253+ expect(
254+ JSON.stringify(editedStored?.config ?? {}),
255+ "the edit persisted, and left the untouched negotiation mode alone",
256+ ).toContain('"versionNegotiation":"auto"');
257+
258+ const editedTools = yield* client.tools.list({ query: { integration: autoSlug } });
259+ expect(
260+ editedTools.map((t) => t.name),
261+ "the edited config reached the spawn and the catalog was rediscovered",
262+ ).toContain("saw_declared_env");
263+ expect(
264+ editedTools.map((t) => t.name),
265+ "rediscovery replaced the catalog rather than dropping what still exists",
266+ ).toContain("echo_tool");
225267 }),
226268 { env: DAEMON_ENV },
227269 );
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+1−13
@@ -41,6 +41,7 @@ import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; · 40 unmodified lines
4141 import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
4242 import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
4343 import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
44+import { parseStdioArgs } from "./stdio-fields";
4445 import { isProbableMcpEndpoint } from "./probe-url";
4546 import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
4647 import { mcpPresets, type McpPreset } from "../sdk/presets";
@@ -62,19 +63,6 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 15 unmodified lines
6263 return mcpPresets.find((p) => p.id === id);
6364 }
6465
65−// Splits the raw args field into tokens, honoring double-quoted groups so an
66−// argument with spaces stays intact.
67−function parseStdioArgs(raw: string): string[] {
68− if (!raw.trim()) return [];
69− const args: string[] = [];
70− const regex = /[^\s"]+|"([^"]*)"/g;
71− let match;
72− while ((match = regex.exec(raw)) !== null) {
73− args.push(match[1] ?? match[0]);
74− }
75− return args;
76−}
77−
7866 // ---------------------------------------------------------------------------
7967 // State machine (remote flow)
8068 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/react/EditMcpIntegration.tsx+158−22
@@ -13,10 +13,16 @@ import { · 12 unmodified lines
1313 type AuthMethodRow,
1414 type AuthMethodSeed,
1515 } from "@executor-js/react/components/auth-method-list-editor";
16−import { Badge } from "@executor-js/react/components/badge";
17−import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
16+import {
17+ CardStack,
18+ CardStackContent,
19+ CardStackEntryField,
20+} from "@executor-js/react/components/card-stack";
21+import { Input } from "@executor-js/react/components/input";
22+import { Textarea } from "@executor-js/react/components/textarea";
23+import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add";
1824
19−import { configureMcpAuth, mcpServerAtom } from "./atoms";
25+import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms";
2026 import type {
2127 McpAuthMethod,
2228 McpCanonicalAuthMethodInput,
@@ -27,6 +33,7 @@ import { · 4 unmodified lines
2733 mcpAuthMethodInputFromEditorValue,
2834 mcpWireAuthInput,
2935 } from "./auth-method-config";
36+import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
3037
3138 type McpServer = {
3239 readonly slug: IntegrationSlug;
@@ -174,30 +181,160 @@ function RemoteEdit(props: { · 141 unmodified lines
174181 }
175182
176183 // ---------------------------------------------------------------------------
177−// Stdio read-only view
184+// Stdio edit — the command, its arguments, the working directory, and the
185+// DECLARED static environment. Secret env vars are not edited here: a stdio
186+// server declares them as a `stdio_env` auth method and their values live on
187+// the connection, managed from the integration page's accounts hub. Changes
188+// are staged and applied by the sheet's Save, like the remote editor above.
178189 // ---------------------------------------------------------------------------
179190
180−function StdioReadOnly(props: {
181− server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> };
191+type McpStdioConfig = Extract<McpIntegrationConfig, { transport: "stdio" }>;
192+
193+/** Order-independent identity for a declared env map, so re-ordering the lines
194+ * in the field is not reported as a change. */
195+const envIdentity = (env: Readonly<Record<string, string>> | undefined): string =>
196+ Object.entries(env ?? {})
197+ .sort(([a], [b]) => a.localeCompare(b))
198+ .map(([key, value]) => `${key}=${value}`)
199+ .join("\n");
200+
201+function StdioEdit(props: {
202+ server: McpServer & { config: McpStdioConfig };
203+ onPendingChange?: EditSheetSectionProps["onPendingChange"];
182204 }) {
183− const { command, args } = props.server.config;
205+ const { server } = props;
206+ const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" });
207+
208+ const [command, setCommand] = useState(server.config.command);
209+ const [args, setArgs] = useState(() => formatStdioArgs(server.config.args));
210+ const [cwd, setCwd] = useState(server.config.cwd ?? "");
211+ const [env, setEnv] = useState(() => formatStdioEnv(server.config.env));
212+ const [error, setError] = useState<string | null>(null);
213+
214+ // The edited config. `configureServer` replaces the whole blob, so anything
215+ // this form does not surface (`versionNegotiation`, `authenticationTemplate`)
216+ // is carried through untouched. Empty optional fields are omitted rather than
217+ // written as `undefined`.
218+ const edited = useMemo<McpStdioConfig>(() => {
219+ const { args: _args, cwd: _cwd, env: _env, ...rest } = server.config;
220+ const nextArgs = parseStdioArgs(args);
221+ const nextEnv = parseStdioEnv(env);
222+ const nextCwd = cwd.trim();
223+ return {
224+ ...rest,
225+ command: command.trim(),
226+ ...(nextArgs.length > 0 ? { args: nextArgs } : {}),
227+ ...(Object.keys(nextEnv).length > 0 ? { env: nextEnv } : {}),
228+ ...(nextCwd !== "" ? { cwd: nextCwd } : {}),
229+ };
230+ }, [args, command, cwd, env, server.config]);
231+
232+ const changed =
233+ edited.command !== server.config.command ||
234+ formatStdioArgs(edited.args) !== formatStdioArgs(server.config.args) ||
235+ (edited.cwd ?? "") !== (server.config.cwd ?? "") ||
236+ envIdentity(edited.env) !== envIdentity(server.config.env);
237+
238+ // Staged apply, run by the sheet's Save. Persisting the config re-runs
239+ // discovery on every connection, so the tool catalog matches the new command.
240+ const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
241+ setError(null);
242+ if (edited.command === "") {
243+ setError("A command is required.");
244+ return { ok: false };
245+ }
246+ const exit = await doConfigure({
247+ params: { slug: server.slug },
248+ payload: { config: edited },
249+ reactivityKeys: integrationWriteKeys,
250+ });
251+ if (Exit.isFailure(exit)) {
252+ setError(errorMessageFromExit(exit, "Failed to update the server command"));
253+ return { ok: false };
254+ }
255+ return { ok: true, summary: "Server command updated." };
256+ }, [doConfigure, edited, server.slug]);
257+
258+ const onPendingChangeRef = useRef(props.onPendingChange);
259+ onPendingChangeRef.current = props.onPendingChange;
260+ useEffect(() => {
261+ onPendingChangeRef.current?.(changed ? applyStaged : null);
262+ return () => onPendingChangeRef.current?.(null);
263+ }, [changed, applyStaged]);
264+
184265 return (
185− <div className="space-y-3 border-t border-border/60 pt-5">
266+ <div className="space-y-4 border-t border-border/60 pt-5">
186267 <div className="space-y-1">
187268 <p className="text-sm font-medium text-foreground">Server command</p>
188269 <p className="text-xs text-muted-foreground">
189− Stdio MCP integrations cannot be edited. Remove and recreate the integration with the
190− updated command.
270+ Changes apply when you save. The server's tools are then rediscovered with the new
271+ command.
191272 </p>
192273 </div>
193− <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2">
194− <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
195− {command} {(args ?? []).join(" ")}
196− </p>
197− <Badge variant="secondary" className="text-xs">
198− stdio
199− </Badge>
200− </div>
274+
275+ <CardStack>
276+ <CardStackContent className="border-t-0">
277+ <CardStackEntryField
278+ label="Command"
279+ description="- The executable to run (e.g. npx, uvx, node)."
280+ >
281+ <Input
282+ value={command}
283+ aria-label="Command"
284+ onChange={(e) => setCommand((e.target as HTMLInputElement).value)}
285+ placeholder="npx"
286+ className="font-mono text-sm"
287+ />
288+ </CardStackEntryField>
289+
290+ <CardStackEntryField
291+ label="Arguments"
292+ description="- Space-separated arguments passed to the command."
293+ >
294+ <Input
295+ value={args}
296+ aria-label="Arguments"
297+ onChange={(e) => setArgs((e.target as HTMLInputElement).value)}
298+ placeholder="-y chrome-devtools-mcp@latest"
299+ className="font-mono text-sm"
300+ />
301+ </CardStackEntryField>
302+
303+ <CardStackEntryField
304+ label="Working directory"
305+ description="- Optional. Where the command runs."
306+ >
307+ <Input
308+ value={cwd}
309+ aria-label="Working directory"
310+ onChange={(e) => setCwd((e.target as HTMLInputElement).value)}
311+ placeholder="/path/to/server"
312+ className="font-mono text-sm"
313+ />
314+ </CardStackEntryField>
315+
316+ <CardStackEntryField
317+ label="Environment variables"
318+ description="- One KEY=value per line. The server receives these plus a small base set; it does not inherit executor's environment."
319+ >
320+ <Textarea
321+ value={env}
322+ aria-label="Environment variables"
323+ onChange={(e) => setEnv((e.target as HTMLTextAreaElement).value)}
324+ placeholder={"LOG_LEVEL=debug\nREGION=eu-west-1"}
325+ className="font-mono text-sm"
326+ />
327+ </CardStackEntryField>
328+ </CardStackContent>
329+ </CardStack>
330+
331+ <p className="text-xs text-muted-foreground">
332+ Secret values do not belong here. A server that needs an API key declares its variable name
333+ as an authentication method, and the value is entered per connection from the accounts
334+ panel.
335+ </p>
336+
337+ {error && <FormErrorAlert message={error} />}
201338 </div>
202339 );
203340 }
@@ -219,10 +356,9 @@ export default function EditMcpIntegration({ · 15 unmodified lines
219356
220357 if (server.config.transport === "stdio") {
221358 return (
222− <StdioReadOnly
223− server={
224− server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }
225− }
359+ <StdioEdit
360+ server={server as McpServer & { config: McpStdioConfig }}
361+ {...(onPendingChange ? { onPendingChange } : {})}
226362 />
227363 );
228364 }
packages/plugins/mcp/src/react/stdio-fields.test.ts+70−0
@@ -0,0 +1,70 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
4+
5+describe("parseStdioArgs", () => {
6+ it("splits on whitespace", () => {
7+ expect(parseStdioArgs("-y chrome-devtools-mcp@latest")).toEqual([
8+ "-y",
9+ "chrome-devtools-mcp@latest",
10+ ]);
11+ });
12+
13+ it("keeps a double-quoted group intact", () => {
14+ expect(parseStdioArgs('--dir "/Users/a b/mcp" --port 3000')).toEqual([
15+ "--dir",
16+ "/Users/a b/mcp",
17+ "--port",
18+ "3000",
19+ ]);
20+ });
21+
22+ it("returns nothing for a blank field", () => {
23+ expect(parseStdioArgs(" ")).toEqual([]);
24+ });
25+});
26+
27+describe("formatStdioArgs", () => {
28+ it("re-quotes tokens containing whitespace so the round trip is lossless", () => {
29+ const args = ["--dir", "/Users/a b/mcp", "--port", "3000"];
30+ expect(parseStdioArgs(formatStdioArgs(args))).toEqual(args);
31+ });
32+
33+ it("renders an absent list as an empty field", () => {
34+ expect(formatStdioArgs(undefined)).toBe("");
35+ });
36+});
37+
38+describe("parseStdioEnv", () => {
39+ it("reads one KEY=value per line", () => {
40+ expect(parseStdioEnv("LOG_LEVEL=debug\nREGION=eu-west-1")).toEqual({
41+ LOG_LEVEL: "debug",
42+ REGION: "eu-west-1",
43+ });
44+ });
45+
46+ it("splits on the first = only, so a value may contain =", () => {
47+ expect(parseStdioEnv("DSN=postgres://u:p@host/db?a=b")).toEqual({
48+ DSN: "postgres://u:p@host/db?a=b",
49+ });
50+ });
51+
52+ it("keeps a declared-but-empty value", () => {
53+ expect(parseStdioEnv("NO_COLOR=")).toEqual({ NO_COLOR: "" });
54+ });
55+
56+ it("drops blank lines, comments, and lines with no key", () => {
57+ expect(parseStdioEnv("\n# a comment\n=orphan\nA=1\n")).toEqual({ A: "1" });
58+ });
59+});
60+
61+describe("formatStdioEnv", () => {
62+ it("round-trips a declared map", () => {
63+ const env = { LOG_LEVEL: "debug", DSN: "postgres://u:p@host/db?a=b" };
64+ expect(parseStdioEnv(formatStdioEnv(env))).toEqual(env);
65+ });
66+
67+ it("renders an absent map as an empty field", () => {
68+ expect(formatStdioEnv(undefined)).toBe("");
69+ });
70+});
packages/plugins/mcp/src/react/stdio-fields.ts+51−0
@@ -0,0 +1,51 @@
1+// ---------------------------------------------------------------------------
2+// Text ↔ config codecs for the stdio fields, shared by the add flow
3+// (`AddMcpIntegration`) and the edit sheet (`EditMcpIntegration`). Both render
4+// the same single-line arguments field and the same KEY=value environment
5+// block, so the parsing lives in one place and round-trips: a config that is
6+// formatted and re-parsed is unchanged.
7+// ---------------------------------------------------------------------------
8+
9+/** Split the raw arguments field into tokens, honoring double-quoted groups so
10+ * an argument with spaces stays intact. */
11+export function parseStdioArgs(raw: string): string[] {
12+ if (!raw.trim()) return [];
13+ const args: string[] = [];
14+ const regex = /[^\s"]+|"([^"]*)"/g;
15+ let match;
16+ while ((match = regex.exec(raw)) !== null) {
17+ args.push(match[1] ?? match[0]);
18+ }
19+ return args;
20+}
21+
22+/** Render stored arguments back into the single-line field, re-quoting any
23+ * token that contains whitespace so `parseStdioArgs` recovers it. */
24+export function formatStdioArgs(args: readonly string[] | undefined): string {
25+ return (args ?? []).map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");
26+}
27+
28+/** Parse the KEY=value block into the declared static env map. Splits on the
29+ * FIRST `=` only, so a value may itself contain `=`. Blank lines, `#`
30+ * comments, and lines without a key are dropped; an empty value is kept
31+ * (declaring a variable as empty is meaningful to a subprocess). */
32+export function parseStdioEnv(raw: string): Record<string, string> {
33+ const env: Record<string, string> = {};
34+ for (const line of raw.split("\n")) {
35+ const trimmed = line.trim();
36+ if (trimmed === "" || trimmed.startsWith("#")) continue;
37+ const separator = trimmed.indexOf("=");
38+ if (separator <= 0) continue;
39+ const key = trimmed.slice(0, separator).trim();
40+ if (key === "") continue;
41+ env[key] = trimmed.slice(separator + 1).trim();
42+ }
43+ return env;
44+}
45+
46+/** Render the stored env map back into the KEY=value block. */
47+export function formatStdioEnv(env: Readonly<Record<string, string>> | undefined): string {
48+ return Object.entries(env ?? {})
49+ .map(([key, value]) => `${key}=${value}`)
50+ .join("\n");
51+}