From af431841951a80ddd6b978362282e96f5bb96a19 Mon Sep 17 00:00:00 2001 From: Griffin Evans Date: Mon, 29 Jun 2026 13:06:28 -0700 Subject: [PATCH 1/9] Make stdio MCPs editable --- packages/plugins/mcp/src/api/group.ts | 8 +- .../plugins/mcp/src/react/AddMcpSource.tsx | 58 +++-- .../plugins/mcp/src/react/EditMcpSource.tsx | 198 ++++++++++++++-- packages/plugins/mcp/src/sdk/plugin.test.ts | 218 +++++++++++++++++- packages/plugins/mcp/src/sdk/plugin.ts | 179 ++++++++++---- .../plugins/mcp/src/sdk/stdio-config.test.ts | 98 ++++++++ packages/plugins/mcp/src/sdk/stdio-config.ts | 167 ++++++++++++++ 7 files changed, 836 insertions(+), 90 deletions(-) create mode 100644 packages/plugins/mcp/src/sdk/stdio-config.test.ts create mode 100644 packages/plugins/mcp/src/sdk/stdio-config.ts diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 324b9d684..4d5163738 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,6 +4,7 @@ import { IntegrationSlug, InternalError, IntegrationAlreadyExistsError, + IntegrationNotFoundError, } from "@executor-js/sdk/shared"; import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; @@ -21,6 +22,7 @@ import { const SlugParams = { slug: IntegrationSlug }; const StringMap = Schema.Record(Schema.String, Schema.String); +const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); // --------------------------------------------------------------------------- // Add server — discriminated union on transport. An MCP server is registered @@ -52,9 +54,9 @@ const AddStdioServerPayload = Schema.Struct({ command: Schema.String, args: Schema.optional(Schema.Array(Schema.String)), /** Declare the secret env vars this server needs, by name. Their values are - * supplied as the connection's secrets (the connect step), not here. */ + * supplied as the connection's secrets. */ envVars: Schema.optional(Schema.Array(Schema.String)), - /** One-shot secret env values (programmatic). The UI sends `envVars`. */ + /** Static, non-credential environment variables injected into the subprocess. */ env: Schema.optional(StringMap), cwd: Schema.optional(Schema.String), slug: Schema.optional(Schema.String), @@ -171,7 +173,7 @@ export const McpGroup = HttpApiGroup.make("mcp") params: SlugParams, payload: ConfigureServerPayload, success: ConfigureServerResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], + error: [InternalError, McpConnectionError, McpToolDiscoveryError, IntegrationNotFound], }), ) .add( diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index b6e391dbe..49d716b72 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -17,7 +17,7 @@ import { } from "@executor-js/react/components/card-stack"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; -import { TagInput } from "@executor-js/react/components/tag-input"; +import { Textarea } from "@executor-js/react/components/textarea"; import { integrationDisplayNameFromStdio, integrationDisplayNameFromUrl, @@ -39,6 +39,7 @@ import { probeMcpEndpoint, addMcpServer } from "./atoms"; import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; import { mcpPresets, type McpPreset } from "../sdk/presets"; +import { parseStdioArgs, parseStdioEnv, stdioEnvParseErrorMessage } from "../sdk/stdio-config"; // The remote add flow REGISTERS the server's declared auth methods through the // shared `AuthMethodListEditor` — accounts (the API key value / OAuth sign-in) @@ -57,19 +58,6 @@ function findPreset(id: string | undefined): McpPreset | undefined { return mcpPresets.find((p) => p.id === id); } -// Splits the raw args field into tokens, honoring double-quoted groups so an -// argument with spaces stays intact. -function parseStdioArgs(raw: string): string[] { - if (!raw.trim()) return []; - const args: string[] = []; - const regex = /[^\s"]+|"([^"]*)"/g; - let match; - while ((match = regex.exec(raw)) !== null) { - args.push(match[1] ?? match[0]); - } - return args; -} - // --------------------------------------------------------------------------- // State machine (remote flow) // --------------------------------------------------------------------------- @@ -185,7 +173,8 @@ export default function AddMcpSource(props: { const [stdioArgs, setStdioArgs] = useState( isStdioPreset && preset.args ? preset.args.join(" ") : "", ); - const [stdioEnvVars, setStdioEnvVars] = useState([]); + const [stdioEnvText, setStdioEnvText] = useState(""); + const [stdioCwd, setStdioCwd] = useState(""); const stdioIdentity = useIntegrationIdentity({ fallbackName: isStdioPreset ? preset.name @@ -352,18 +341,26 @@ export default function AddMcpSource(props: { const handleAddStdio = useCallback(async () => { const cmd = stdioCommand.trim(); if (!cmd) return; + const parsedEnv = parseStdioEnv(stdioEnvText); + if (!parsedEnv.ok) { + setStdioError(stdioEnvParseErrorMessage(parsedEnv.error)); + return; + } setStdioAdding(true); setStdioError(null); const displayName = stdioIdentity.name.trim() || cmd; const slug = slugifyNamespace(stdioIdentity.namespace) || undefined; + const parsedArgs = parseStdioArgs(stdioArgs); + const trimmedCwd = stdioCwd.trim(); const exit = await doAddServer({ payload: { transport: "stdio" as const, name: displayName, ...(slug ? { slug } : {}), command: cmd, - args: parseStdioArgs(stdioArgs), - envVars: stdioEnvVars.length > 0 ? stdioEnvVars : undefined, + ...(parsedArgs.length > 0 ? { args: parsedArgs } : {}), + ...(parsedEnv.env !== undefined ? { env: parsedEnv.env } : {}), + ...(trimmedCwd.length > 0 ? { cwd: trimmedCwd } : {}), }, reactivityKeys: integrationWriteKeys, }); @@ -373,7 +370,7 @@ export default function AddMcpSource(props: { return; } props.onComplete(exit.value.slug); - }, [stdioCommand, stdioArgs, stdioEnvVars, stdioIdentity, doAddServer, props]); + }, [stdioCommand, stdioArgs, stdioEnvText, stdioCwd, stdioIdentity, doAddServer, props]); // ---- Render ---- @@ -507,14 +504,29 @@ export default function AddMcpSource(props: { /> + + setStdioCwd((e.target as HTMLInputElement).value)} + placeholder="/path/to/project" + className="font-mono text-sm" + data-ph-block + /> + + - setStdioEnvText((e.target as HTMLTextAreaElement).value)} + placeholder={"DEBUG=true\nAPI_BASE_URL=https://example.com"} + className="font-mono text-sm" + data-ph-block /> diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index bbb563e7e..8ebcc3a3a 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -13,10 +13,12 @@ import { type AuthMethodRow, type AuthMethodSeed, } from "@executor-js/react/components/auth-method-list-editor"; -import { Badge } from "@executor-js/react/components/badge"; -import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; +import { Textarea } from "@executor-js/react/components/textarea"; +import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add"; -import { configureMcpAuth, mcpServerAtom } from "./atoms"; +import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms"; import type { McpAuthMethod, McpCanonicalAuthMethodInput, @@ -27,6 +29,15 @@ import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput, } from "./auth-method-config"; +import { + canonicalizeStdioConfig, + canonicalizeStdioDraft, + parseStdioArgs, + parseStdioEnv, + sameCanonicalStdioConfig, + stdioEnvParseErrorMessage, + stdioEnvToText, +} from "../sdk/stdio-config"; type McpServer = { readonly slug: IntegrationSlug; @@ -174,30 +185,176 @@ function RemoteEdit(props: { } // --------------------------------------------------------------------------- -// Stdio read-only view +// Stdio edit // --------------------------------------------------------------------------- -function StdioReadOnly(props: { +function StdioEdit(props: { server: McpServer & { config: Extract }; + onPendingChange?: EditSheetSectionProps["onPendingChange"]; }) { - const { command, args } = props.server.config; + const { server } = props; + const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" }); + + const [commandDraft, setCommandDraft] = useState(server.config.command); + const [argsDraft, setArgsDraft] = useState((server.config.args ?? []).join(" ")); + const [cwdDraft, setCwdDraft] = useState(server.config.cwd ?? ""); + const [envDraft, setEnvDraft] = useState(stdioEnvToText(server.config.env)); + const [error, setError] = useState(null); + + const lastResetSlug = useRef(null); + useEffect(() => { + if (lastResetSlug.current === server.slug) return; + lastResetSlug.current = server.slug; + setCommandDraft(server.config.command); + setArgsDraft((server.config.args ?? []).join(" ")); + setCwdDraft(server.config.cwd ?? ""); + setEnvDraft(stdioEnvToText(server.config.env)); + setError(null); + }, [ + server.config.args, + server.config.command, + server.config.cwd, + server.config.env, + server.slug, + ]); + + const stdioDraftChanged = useMemo( + () => + commandDraft !== server.config.command || + argsDraft !== (server.config.args ?? []).join(" ") || + cwdDraft !== (server.config.cwd ?? "") || + envDraft !== stdioEnvToText(server.config.env), + [argsDraft, commandDraft, cwdDraft, envDraft, server.config], + ); + + const parsedEnvForComparison = useMemo(() => parseStdioEnv(envDraft), [envDraft]); + const processConfigChanged = useMemo(() => { + if (!parsedEnvForComparison.ok) return false; + return !sameCanonicalStdioConfig( + canonicalizeStdioConfig(server.config), + canonicalizeStdioDraft({ + command: commandDraft, + args: parseStdioArgs(argsDraft), + env: parsedEnvForComparison.env, + cwd: cwdDraft, + }), + ); + }, [argsDraft, commandDraft, cwdDraft, parsedEnvForComparison, server.config]); + + const applyStaged = useCallback(async (): Promise => { + setError(null); + const command = commandDraft.trim(); + if (command.length === 0) { + setError("Command is required."); + return { ok: false }; + } + + const parsedEnv = parseStdioEnv(envDraft); + if (!parsedEnv.ok) { + setError(stdioEnvParseErrorMessage(parsedEnv.error)); + return { ok: false }; + } + + const args = parseStdioArgs(argsDraft); + const cwd = cwdDraft.trim(); + const nextConfig: Extract = { + transport: "stdio", + command, + ...(args.length > 0 ? { args } : {}), + ...(parsedEnv.env !== undefined ? { env: parsedEnv.env } : {}), + ...(cwd.length > 0 ? { cwd } : {}), + ...(server.config.authenticationTemplate !== undefined + ? { authenticationTemplate: server.config.authenticationTemplate } + : {}), + }; + + if ( + sameCanonicalStdioConfig( + canonicalizeStdioConfig(server.config), + canonicalizeStdioConfig(nextConfig), + ) + ) { + return { ok: true, summary: null }; + } + + const exit = await doConfigure({ + params: { slug: server.slug }, + payload: { config: nextConfig }, + reactivityKeys: integrationWriteKeys, + }); + if (Exit.isFailure(exit)) { + setError(errorMessageFromExit(exit, "Failed to update command settings")); + return { ok: false }; + } + return { ok: true, summary: "Command settings updated." }; + }, [argsDraft, commandDraft, cwdDraft, doConfigure, envDraft, server.config, server.slug]); + + const onPendingChangeRef = useRef(props.onPendingChange); + onPendingChangeRef.current = props.onPendingChange; + useEffect(() => { + onPendingChangeRef.current?.(stdioDraftChanged ? applyStaged : null); + return () => onPendingChangeRef.current?.(null); + }, [stdioDraftChanged, applyStaged]); + return ( -
+
-

Server command

-

- Stdio MCP sources cannot be edited. Remove and recreate the source with the updated - command. -

+

Command settings

+

Changes apply when you save.

-
-

- {command} {(args ?? []).join(" ")} -

- - stdio - + +
+ + + + + + +