* 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. ---------
Unified · Split
| @@ -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. | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import { readFileSync } from "node:fs"; | 1 | import { readFileSync } from "node:fs"; |
| 2 | import { resolve } from "node:path"; | 2 | import { createRequire } from "node:module"; |
| 3 | import { dirname, resolve } from "node:path"; | ||
| 3 | import { Readable } from "node:stream"; | 4 | import { Readable } from "node:stream"; |
| 4 | import { fileURLToPath } from "node:url"; | 5 | import { fileURLToPath } from "node:url"; |
| 5 | import { defineConfig, type Plugin } from "vite"; | 6 | import { defineConfig, type Plugin } from "vite"; |
| @@ -42,6 +43,19 @@ const EXECUTOR_GITHUB_URL = ( · 36 unmodified lines | |||
| 42 | const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); | 43 | const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); |
| 43 | const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); | 44 | const 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. | ||
| 53 | const JS_YAML_DIR = dirname( | ||
| 54 | createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve( | ||
| 55 | "js-yaml", | ||
| 56 | ), | ||
| 57 | ); | ||
| 58 | |||
| 45 | const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => | 59 | const 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 npm | 253 | // 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), |
| @@ -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 | ); | ||
| @@ -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 | ); |
| @@ -41,6 +41,7 @@ import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; · 40 unmodified lines | |||
| 41 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; | 41 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; |
| 42 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; | 42 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; |
| 43 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; | 43 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; |
| 44 | import { parseStdioArgs } from "./stdio-fields"; | ||
| 44 | import { isProbableMcpEndpoint } from "./probe-url"; | 45 | import { isProbableMcpEndpoint } from "./probe-url"; |
| 45 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; | 46 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; |
| 46 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 47 | import { 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. | ||
| 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 | |||
| 78 | // --------------------------------------------------------------------------- | 66 | // --------------------------------------------------------------------------- |
| 79 | // State machine (remote flow) | 67 | // State machine (remote flow) |
| 80 | // --------------------------------------------------------------------------- | 68 | // --------------------------------------------------------------------------- |
| @@ -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"; |
| 16 | import { Badge } from "@executor-js/react/components/badge"; | 16 | import { |
| 17 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | 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"; | ||
| 18 | 24 | ||
| 19 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; | 25 | import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms"; |
| 20 | import type { | 26 | import 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"; |
| 36 | import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields"; | ||
| 30 | 37 | ||
| 31 | type McpServer = { | 38 | type 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 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. | ||
| 178 | // --------------------------------------------------------------------------- | 189 | // --------------------------------------------------------------------------- |
| 179 | 190 | ||
| 180 | function StdioReadOnly(props: { | 191 | type 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. */ | ||
| 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"]; | ||
| 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 the | 270 | 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 | stdio | 279 | 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 | <StdioReadOnly | 359 | <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 | } |
| @@ -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 | }); | ||
| @@ -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 | } | ||