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
7The integration Edit sheet showed stdio servers as read-only text and told you
8to remove and recreate the integration to change its command. Fixing a typo in
9an argument, moving a server to a new path, or adding a static environment
10variable meant editing `executor.jsonc` by hand, or losing the integration's
11connections and tool policies to a delete-and-re-add.
12
13The sheet now edits the command, its arguments, the working directory, and the
14declared environment map, staged and applied by the sheet's own Save like the
15remote editor beside it. Arguments use the same quote-aware parsing as the add
16flow, so an argument containing spaces survives a round trip.
17
18The environment field edits the DECLARED static variables only. A stdio server
19receives those plus a small fixed base set — it does not inherit executor's
20environment — and secret values still belong to the connection, entered per
21account against the server's declared `stdio_env` method.
22
23Saving revises the integration config, which is already enough to rebuild the
24tool catalog: connections whose catalog predates the revision re-list on their
25next read, so an edited command's tools are correct without an explicit
26refresh.
apps/local/vite.config.ts+35−1
@@ -1,5 +1,6 @@
1import { readFileSync } from "node:fs";1import { readFileSync } from "node:fs";
2import { resolve } from "node:path";2import { createRequire } from "node:module";
3import { dirname, resolve } from "node:path";
3import { Readable } from "node:stream";4import { Readable } from "node:stream";
4import { fileURLToPath } from "node:url";5import { fileURLToPath } from "node:url";
5import { defineConfig, type Plugin } from "vite";6import { defineConfig, type Plugin } from "vite";
@@ -42,6 +43,19 @@ const EXECUTOR_GITHUB_URL = ( · 36 unmodified lines
42const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));43const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
43const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));44const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));
44 45
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.
53const JS_YAML_DIR = dirname(
54 createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve(
55 "js-yaml",
56 ),
57);
58
45const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>59const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>
46 new Response(60 new Response(
47 JSON.stringify(61 JSON.stringify(
@@ -215,6 +229,25 @@ export default defineConfig({ · 167 unmodified lines
215 outDir: resolve(import.meta.dirname, "dist"),229 outDir: resolve(import.meta.dirname, "dist"),
216 emptyOutDir: true,230 emptyOutDir: true,
217 },231 },
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 },
218 define: {251 define: {
219 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),252 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
220 // The local app IS the npm-installed CLI, so its update card shows the npm253 // 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
229 },262 },
230 resolve: {263 resolve: {
231 tsconfigPaths: true,264 tsconfigPaths: true,
265 alias: { "js-yaml": JS_YAML_DIR },
232 },266 },
233 server: {267 server: {
234 port: parseInt(process.env.PORT ?? "5173", 10),268 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.
16import { fileURLToPath } from "node:url";
17
18import { expect } from "@effect/vitest";
19import { Effect } from "effect";
20import { HttpApiClient } from "effect/unstable/httpapi";
21import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
22import { composePluginApi } from "@executor-js/api/server";
23import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
24
25import { scenario } from "../src/scenario";
26import { Browser, Cli, RunDir, Target } from "../src/services";
27import { withLocalServer } from "./local-server";
28
29const api = composePluginApi([mcpHttpPlugin()] as const);
30
31const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
32
33const SECRET = "s3cr3t-typed-into-the-edit-sheet";
34
35scenario(
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
222 autoTools.map((t) => t.name),222 autoTools.map((t) => t.name),
223 "auto negotiation falls back to legacy and still discovers tools",223 "auto negotiation falls back to legacy and still discovers tools",
224 ).toContain("echo_tool");224 ).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");
225 }),267 }),
226 { env: DAEMON_ENV },268 { env: DAEMON_ENV },
227 );269 );
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+1−13
@@ -41,6 +41,7 @@ import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; · 40 unmodified lines
41import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";41import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
42import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";42import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
43import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";43import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
44import { parseStdioArgs } from "./stdio-fields";
44import { isProbableMcpEndpoint } from "./probe-url";45import { isProbableMcpEndpoint } from "./probe-url";
45import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";46import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
46import { mcpPresets, type McpPreset } from "../sdk/presets";47import { mcpPresets, type McpPreset } from "../sdk/presets";
@@ -62,19 +63,6 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 15 unmodified lines
62 return mcpPresets.find((p) => p.id === id);63 return mcpPresets.find((p) => p.id === id);
63}64}
64 65
65// Splits the raw args field into tokens, honoring double-quoted groups so an
66// argument with spaces stays intact.
67function 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
78// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
79// State machine (remote flow)67// State machine (remote flow)
80// ---------------------------------------------------------------------------68// ---------------------------------------------------------------------------
packages/plugins/mcp/src/react/EditMcpIntegration.tsx+158−22
@@ -13,10 +13,16 @@ import { · 12 unmodified lines
13 type AuthMethodRow,13 type AuthMethodRow,
14 type AuthMethodSeed,14 type AuthMethodSeed,
15} from "@executor-js/react/components/auth-method-list-editor";15} from "@executor-js/react/components/auth-method-list-editor";
16import { Badge } from "@executor-js/react/components/badge";16import {
17import { FormErrorAlert } from "@executor-js/react/lib/integration-add";17 CardStack,
18 CardStackContent,
19 CardStackEntryField,
20} from "@executor-js/react/components/card-stack";
21import { Input } from "@executor-js/react/components/input";
22import { Textarea } from "@executor-js/react/components/textarea";
23import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add";
18 24
19import { configureMcpAuth, mcpServerAtom } from "./atoms";25import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms";
20import type {26import type {
21 McpAuthMethod,27 McpAuthMethod,
22 McpCanonicalAuthMethodInput,28 McpCanonicalAuthMethodInput,
@@ -27,6 +33,7 @@ import { · 4 unmodified lines
27 mcpAuthMethodInputFromEditorValue,33 mcpAuthMethodInputFromEditorValue,
28 mcpWireAuthInput,34 mcpWireAuthInput,
29} from "./auth-method-config";35} from "./auth-method-config";
36import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
30 37
31type McpServer = {38type McpServer = {
32 readonly slug: IntegrationSlug;39 readonly slug: IntegrationSlug;
@@ -174,30 +181,160 @@ function RemoteEdit(props: { · 141 unmodified lines
174}181}
175 182
176// ---------------------------------------------------------------------------183// ---------------------------------------------------------------------------
177// Stdio read-only view184// 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.
178// ---------------------------------------------------------------------------189// ---------------------------------------------------------------------------
179 190
180function StdioReadOnly(props: {191type McpStdioConfig = Extract<McpIntegrationConfig, { transport: "stdio" }>;
181 server: McpServer & { config: 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. */
195const 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
201function StdioEdit(props: {
202 server: McpServer & { config: McpStdioConfig };
203 onPendingChange?: EditSheetSectionProps["onPendingChange"];
182}) {204}) {
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
184 return (265 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">
186 <div className="space-y-1">267 <div className="space-y-1">
187 <p className="text-sm font-medium text-foreground">Server command</p>268 <p className="text-sm font-medium text-foreground">Server command</p>
188 <p className="text-xs text-muted-foreground">269 <p className="text-xs text-muted-foreground">
189 Stdio MCP integrations cannot be edited. Remove and recreate the integration with the270 Changes apply when you save. The server's tools are then rediscovered with the new
190 updated command.271 command.
191 </p>272 </p>
192 </div>273 </div>
193 <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2">274
194 <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">275 <CardStack>
195 {command} {(args ?? []).join(" ")}276 <CardStackContent className="border-t-0">
196 </p>277 <CardStackEntryField
197 <Badge variant="secondary" className="text-xs">278 label="Command"
198 stdio279 description="- The executable to run (e.g. npx, uvx, node)."
199 </Badge>280 >
200 </div>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} />}
201 </div>338 </div>
202 );339 );
203}340}
@@ -219,10 +356,9 @@ export default function EditMcpIntegration({ · 15 unmodified lines
219 356
220 if (server.config.transport === "stdio") {357 if (server.config.transport === "stdio") {
221 return (358 return (
222 <StdioReadOnly359 <StdioEdit
223 server={360 server={server as McpServer & { config: McpStdioConfig }}
224 server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }361 {...(onPendingChange ? { onPendingChange } : {})}
225 }
226 />362 />
227 );363 );
228 }364 }
packages/plugins/mcp/src/react/stdio-fields.test.ts+70−0
@@ -0,0 +1,70 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
4
5describe("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
27describe("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
38describe("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
61describe("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. */
11export 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. */
24export 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). */
32export 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. */
47export function formatStdioEnv(env: Readonly<Record<string, string>> | undefined): string {
48 return Object.entries(env ?? {})
49 .map(([key, value]) => `${key}=${value}`)
50 .join("\n");
51}