From 653c2c7a75d09a18f6ec4394661beb27575fa413 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 25 Jun 2026 01:34:52 -0700 Subject: [PATCH] Auto-connect stdio MCP servers; store env as connection secrets Adding a stdio MCP server registered an integration but no connection, so the v1.5 per-connection tool model produced zero tools on a fresh install. Auto-create the default connection on add (no-auth, or one-shot env values), declare secret env vars as a stdio_env auth method whose values live on the connection's secret store, add a boot-time reconcile for pre-existing stdio integrations, and give the add form a TagInput for declaring env var names. --- apps/local/src/executor.ts | 21 ++ e2e/local/fixtures/stdio-mcp-server.mjs | 104 +++++++++ e2e/local/stdio-mcp.test.ts | 145 ++++++++++++ packages/core/api/src/integrations/api.ts | 2 +- packages/core/sdk/src/integration.ts | 7 +- packages/plugins/mcp/src/api/group.ts | 4 + packages/plugins/mcp/src/api/handlers.test.ts | 1 + packages/plugins/mcp/src/api/handlers.ts | 2 + .../plugins/mcp/src/react/AddMcpSource.tsx | 59 +---- .../mcp/src/react/McpAccountsPanel.tsx | 11 +- .../mcp/src/react/auth-method-config.ts | 27 ++- packages/plugins/mcp/src/sdk/plugin.ts | 207 +++++++++++++++++- packages/plugins/mcp/src/sdk/types.ts | 33 ++- .../src/components/add-account-modal.tsx | 51 ++++- packages/react/src/components/tag-input.tsx | 102 +++++++++ packages/react/src/lib/auth-placements.tsx | 9 +- .../react/src/lib/shared-auth-method-codec.ts | 9 +- 17 files changed, 709 insertions(+), 85 deletions(-) create mode 100644 e2e/local/fixtures/stdio-mcp-server.mjs create mode 100644 e2e/local/stdio-mcp.test.ts create mode 100644 packages/react/src/components/tag-input.tsx diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 152b32019..639a1ad43 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -14,6 +14,7 @@ import { } from "@executor-js/sdk"; import { collectTables } from "@executor-js/api/server"; import { loadPluginsFromJsonc } from "@executor-js/config"; +import type { McpPluginExtension } from "@executor-js/plugin-mcp"; import executorConfig from "../executor.config"; import { localDataMigrations } from "./db/data-migrations"; @@ -218,6 +219,26 @@ const createLocalExecutorLayer = () => { } } + // Heal stdio MCP integrations added before auto-connect existed (they + // landed with zero connections ⇒ zero tools) and move any legacy inline + // env into the secret store. No-op on a fresh install; never fails boot. + // Local is the only app that enables stdio, so this only runs here. + // oxlint-disable-next-line executor/no-double-cast -- typed boundary: the executor IS its own plugin-extension map (executor[pluginId]) but LocalExecutor doesn't surface per-plugin extensions statically + const mcpExtension = (executor as unknown as { readonly mcp?: McpPluginExtension }).mcp; + if (mcpExtension) { + yield* mcpExtension + .reconcileStdioConnections() + .pipe( + Effect.catch(() => + Effect.sync(() => + console.warn( + "[executor] stdio connection reconcile failed; existing stdio servers may show no tools until re-added", + ), + ), + ), + ); + } + return { executor, plugins }; }), ); diff --git a/e2e/local/fixtures/stdio-mcp-server.mjs b/e2e/local/fixtures/stdio-mcp-server.mjs new file mode 100644 index 000000000..9f57c79a2 --- /dev/null +++ b/e2e/local/fixtures/stdio-mcp-server.mjs @@ -0,0 +1,104 @@ +// A zero-dependency MCP server over stdio, for the `local` e2e project. +// +// The real `@modelcontextprotocol/sdk` stdio server pulls in the whole SDK and +// is awkward to resolve from an arbitrary spawn cwd under bun's node_modules +// layout. The MCP stdio framing is just newline-delimited JSON-RPC, so we hand- +// roll the three methods a tool-discovery + invoke round-trip needs: +// `initialize`, `tools/list`, `tools/call` (plus `ping`). This keeps the +// fixture a single self-contained file the executor server can launch as +// `node ` with nothing to install. +// +// It exposes one tool, `echo_tool`, and (when EXECUTOR_E2E_SECRET is set in the +// child env) a second `whoami` tool that returns that env value — so a scenario +// can prove a per-connection secret env var actually reached the subprocess. + +import { createInterface } from "node:readline"; + +const send = (message) => { + process.stdout.write(`${JSON.stringify(message)}\n`); +}; + +const TOOLS = [ + { + name: "echo_tool", + description: "Echoes the provided text back", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, +]; + +if (process.env.EXECUTOR_E2E_SECRET) { + TOOLS.push({ + name: "whoami", + description: "Returns the secret env value the server was launched with", + inputSchema: { type: "object", properties: {} }, + }); +} + +const handle = (msg) => { + if (msg.method === "initialize") { + send({ + jsonrpc: "2.0", + id: msg.id, + result: { + // Echo the client's protocol version so we never fail version + // negotiation against whatever SDK build is on the other end. + protocolVersion: msg.params?.protocolVersion ?? "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "executor-e2e-stdio", version: "1.0.0" }, + }, + }); + return; + } + + // Notifications carry no id and expect no response. + if (msg.id === undefined || msg.id === null) return; + + if (msg.method === "ping") { + send({ jsonrpc: "2.0", id: msg.id, result: {} }); + return; + } + + if (msg.method === "tools/list") { + send({ jsonrpc: "2.0", id: msg.id, result: { tools: TOOLS } }); + return; + } + + if (msg.method === "tools/call") { + const name = msg.params?.name; + const text = + name === "whoami" + ? (process.env.EXECUTOR_E2E_SECRET ?? "") + : String(msg.params?.arguments?.text ?? ""); + send({ + jsonrpc: "2.0", + id: msg.id, + result: { content: [{ type: "text", text }] }, + }); + return; + } + + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32601, message: `Method not found: ${msg.method}` }, + }); +}; + +const rl = createInterface({ input: process.stdin }); +rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code + try { + // oxlint-disable-next-line executor/no-json-parse -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code + msg = JSON.parse(trimmed); + } catch { + return; + } + handle(msg); +}); diff --git a/e2e/local/stdio-mcp.test.ts b/e2e/local/stdio-mcp.test.ts new file mode 100644 index 000000000..9a3deb097 --- /dev/null +++ b/e2e/local/stdio-mcp.test.ts @@ -0,0 +1,145 @@ +// Repro + regression guard for the user report: "On a totally fresh install +// (no existing data dir) on macOS, Executor does not detect any tools for a +// STDIO MCP server." +// +// `withLocalServer` boots a real `executor web` on a THROWAWAY data dir (the +// fresh-install condition) and the `local` app is the only surface that enables +// stdio MCP (`dangerouslyAllowStdioMCP: true`). We add a stdio MCP server over +// the bearer-authed API and assert its tools are discoverable — and that the +// secret env it needs is stored on the connection (the secret store), not in +// the integration's config blob. +// +// The original bug: `mcp.addServer` only registered an INTEGRATION. Per the +// v1.5 integrations/connections split, tools are produced per-CONNECTION, and a +// stdio add never created one, so the integration landed with zero connections +// and zero tools. The fix auto-creates the default connection on add and routes +// the env values into the connection's secret store. +import { fileURLToPath } from "node:url"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Cli, RunDir } from "../src/services"; +import { withLocalServer } from "./local-server"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url)); + +// The fixture exposes `whoami` ONLY when EXECUTOR_E2E_SECRET is present in its +// process env. So `whoami` showing up in the discovered tools is direct proof +// the connection's secret env reached the spawned subprocess. +const SECRET = "s3cr3t-from-the-vault"; + +scenario( + "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret", + { timeout: 180_000 }, + Effect.gen(function* () { + const cli = yield* Cli; + const runDir = yield* RunDir; + + yield* withLocalServer(cli, runDir, (server) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(api, { + baseUrl: new URL("/api", server.origin).toString(), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + + const slug = "e2e-stdio"; + + // Add the stdio server exactly as the desktop/local "Add MCP" flow does, + // including a secret env var the server needs. + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio", + command: "node", + args: [FIXTURE], + env: { EXECUTOR_E2E_SECRET: SECRET }, + slug, + }, + }); + + // The integration lands in the catalog — the add itself works. + const integrations = yield* client.integrations.list(); + expect( + integrations.map((i) => String(i.slug)), + "the stdio MCP integration is registered", + ).toContain(slug); + + // The add auto-creates the default connection (the v1.5 split makes this + // the thing that drives tool discovery). Pre-fix there were zero. + const connections = yield* client.connections.list({ query: { integration: slug } }); + expect( + connections.map((c) => String(c.name)), + "a default connection was auto-created for the stdio server", + ).toContain("default"); + + // THE SYMPTOM, fixed: the stdio server's tools are detected. `whoami` + // appearing proves the connection's secret env reached the subprocess. + const tools = yield* client.tools.list({ query: { integration: slug } }); + const names = tools.map((t) => t.name); + expect(names, "the stdio server's base tool is detected").toContain("echo_tool"); + expect( + names, + "the secret env var reached the spawned subprocess (whoami is gated on it)", + ).toContain("whoami"); + + // "Properly store auth": the secret value is NOT in the integration's + // config blob — only the var NAME is declared there; the value lives on + // the connection (the secret store). + const stored = yield* client.mcp.getServer({ params: { slug } }); + expect( + JSON.stringify(stored?.config ?? {}), + "the secret value is not persisted in the integration config", + ).not.toContain(SECRET); + + // --- The UI path: DECLARE env var names, then provide the secret value + // as a connection credential (what the add form now does). --- + const declSlug = "e2e-stdio-decl"; + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio Declared", + command: "node", + args: [FIXTURE], + envVars: ["EXECUTOR_E2E_SECRET"], + slug: declSlug, + }, + }); + + // Declaring a secret env var (no value) does NOT auto-connect: the + // secret is still missing, so there are no tools until you connect. + const beforeConns = yield* client.connections.list({ query: { integration: declSlug } }); + expect(beforeConns, "no connection until the secret is provided").toHaveLength(0); + const beforeTools = yield* client.tools.list({ query: { integration: declSlug } }); + expect(beforeTools, "no tools until the secret is provided").toHaveLength(0); + + // Provide the secret as the connection credential (the connect step). + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("default"), + integration: IntegrationSlug.make(declSlug), + template: AuthTemplateSlug.make("env"), + values: { EXECUTOR_E2E_SECRET: SECRET }, + }, + }); + + const declTools = yield* client.tools.list({ query: { integration: declSlug } }); + expect( + declTools.map((t) => t.name), + "connecting with the secret discovers the env-gated tool", + ).toContain("whoami"); + }), + ); + }), +); diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts index c92dc1db3..8302b5f6c 100644 --- a/packages/core/api/src/integrations/api.ts +++ b/packages/core/api/src/integrations/api.ts @@ -31,7 +31,7 @@ const IntegrationParams = { slug: IntegrationSlug }; /** Where a credential value is carried — mirrors the SDK's * `AuthPlacementDescriptor`. */ const PlacementDescriptor = Schema.Struct({ - carrier: Schema.Literals(["header", "query"]), + carrier: Schema.Literals(["header", "query", "env"]), name: Schema.String, prefix: Schema.String, /** Input variable this placement renders from (absent ⇒ `token`). Without diff --git a/packages/core/sdk/src/integration.ts b/packages/core/sdk/src/integration.ts index 11c3de69a..4b1dbc5da 100644 --- a/packages/core/sdk/src/integration.ts +++ b/packages/core/sdk/src/integration.ts @@ -24,10 +24,11 @@ export interface IntegrationDisplayDescriptor { readonly url?: string; } -/** Where a credential value is carried on the outbound request. Mirrors the - * client's `Placement`. */ +/** Where a credential value is carried. `header`/`query` place it on an + * outbound HTTP request (mirrors the client's `Placement`); `env` injects it + * as an environment variable for a stdio (subprocess) integration. */ export interface AuthPlacementDescriptor { - readonly carrier: "header" | "query"; + readonly carrier: "header" | "query" | "env"; readonly name: string; /** Literal prepended to the value (e.g. `"Bearer "`). Empty when bare. */ readonly prefix: string; diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 30a0a20af..324b9d684 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -51,6 +51,10 @@ const AddStdioServerPayload = Schema.Struct({ description: Schema.optional(Schema.String), 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. */ + envVars: Schema.optional(Schema.Array(Schema.String)), + /** One-shot secret env values (programmatic). The UI sends `envVars`. */ env: Schema.optional(StringMap), cwd: Schema.optional(Schema.String), slug: Schema.optional(Schema.String), diff --git a/packages/plugins/mcp/src/api/handlers.test.ts b/packages/plugins/mcp/src/api/handlers.test.ts index cb02ecbb5..6d9048878 100644 --- a/packages/plugins/mcp/src/api/handlers.test.ts +++ b/packages/plugins/mcp/src/api/handlers.test.ts @@ -26,6 +26,7 @@ const failingExtension: McpPluginExtension = { probeEndpoint: () => Effect.die(new Error("Not implemented")), addServer: () => unused, removeServer: () => unused, + reconcileStdioConnections: () => unused, getServer: () => Effect.succeed(null), configureServer: () => unused, configureAuth: () => unused, diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 58b057cd9..6ca5c3188 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -36,6 +36,7 @@ const toServerInput = ( description?: string; command: string; args?: readonly string[]; + envVars?: readonly string[]; env?: Record; cwd?: string; slug?: string; @@ -46,6 +47,7 @@ const toServerInput = ( description: p.description, command: p.command, args: p.args ? [...p.args] : undefined, + envVars: p.envVars ? [...p.envVars] : undefined, env: p.env, cwd: p.cwd, slug: p.slug, diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index b70e2722e..527f62f08 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -18,7 +18,7 @@ import { import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; import { Spinner } from "@executor-js/react/components/spinner"; -import { Textarea } from "@executor-js/react/components/textarea"; +import { TagInput } from "@executor-js/react/components/tag-input"; import { integrationDisplayNameFromUrl, slugifyNamespace, @@ -48,14 +48,6 @@ import { mcpPresets, type McpPreset } from "../sdk/presets"; // the user can add alternate methods (e.g. an API key alongside OAuth, or a // declared method on a server that advertises none). -const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly> = { - "\\": "\\", - n: "\n", - r: "\r", - t: "\t", - '"': '"', -}; - // --------------------------------------------------------------------------- // Preset lookup // --------------------------------------------------------------------------- @@ -180,7 +172,7 @@ export default function AddMcpSource(props: { const [stdioArgs, setStdioArgs] = useState( isStdioPreset && preset.args ? preset.args.join(" ") : "", ); - const [stdioEnv, setStdioEnv] = useState(""); + const [stdioEnvVars, setStdioEnvVars] = useState([]); const stdioIdentity = useIntegrationIdentity({ fallbackName: isStdioPreset ? preset.name : stdioCommand, }); @@ -352,36 +344,6 @@ export default function AddMcpSource(props: { return args; }; - const parseStdioEnvValue = (raw: string): string => { - const value = raw.trim(); - if (value.length < 2) return value; - - const quote = value[0]; - if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) { - return value; - } - - const inner = value.slice(1, -1); - if (quote === "'") return inner; - - return inner.replace( - /\\([\\nrt"])/g, - (_, escaped: string) => STDIO_ENV_ESCAPE_REPLACEMENTS[escaped] ?? escaped, - ); - }; - - const parseStdioEnv = (raw: string): Record | undefined => { - if (!raw.trim()) return undefined; - const env: Record = {}; - for (const line of raw.split("\n")) { - const eq = line.indexOf("="); - if (eq > 0) { - env[line.slice(0, eq).trim()] = parseStdioEnvValue(line.slice(eq + 1)); - } - } - return Object.keys(env).length > 0 ? env : undefined; - }; - const handleAddStdio = useCallback(async () => { const cmd = stdioCommand.trim(); if (!cmd) return; @@ -396,7 +358,7 @@ export default function AddMcpSource(props: { ...(slug ? { slug } : {}), command: cmd, args: parseStdioArgs(stdioArgs), - env: parseStdioEnv(stdioEnv), + envVars: stdioEnvVars.length > 0 ? stdioEnvVars : undefined, }, reactivityKeys: integrationWriteKeys, }); @@ -406,7 +368,7 @@ export default function AddMcpSource(props: { return; } props.onComplete(exit.value.slug); - }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAddServer, props]); + }, [stdioCommand, stdioArgs, stdioEnvVars, stdioIdentity, doAddServer, props]); // ---- Render ---- @@ -548,15 +510,12 @@ export default function AddMcpSource(props: { -