* 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. * Derive stdio MCP display name from the package, not the runner The stdio add form fell back to the bare command (npx, uvx, ...) for both the display name and namespace, so every npm-launched server landed as "npx". Derive a name from the package/module being run instead: skip the runner and its flags/subcommands, drop the npm scope, version, path, and the usual MCP affixes (mcp-server-, server-, -mcp), then title-case. So "npx -y @modelcontextprotocol/server-github" becomes "Github MCP" and "uvx mcp-server-time" becomes "Time MCP", falling back to the raw command when nothing meaningful can be extracted. * Run the local e2e project in CI The local vitest project (e2e/local/**, including the stdio MCP auto-connect coverage) was excluded from both the default e2e chain and root `bun run test`, so it never ran in CI. Add a test:local script and a dedicated CI job that boots the scenarios with Node 22 and Chromium installed, since each one launches a real executor web server and some drive a browser. * Fix ci.yml: restore desktop-smoke job header The previous commit's edit consumed the desktop-smoke job header when inserting e2e-local, leaving its body orphaned with a duplicate steps key. GitHub rejected the workflow on startup. Restore the header. * Make local e2e reliable in CI: serialize boots, add headless shell The local project booted its executor web servers with file parallelism on, the only server-booting project that did. On a CI runner several cold vite boots at once thrashed past the token-URL wait, timing out stdio-mcp and auth. Run the files serially like every other server-booting project so the first boot warms the vite cache. Also install chromium-headless-shell, which the browser scenarios launch and which does not come with the chromium download. * Install Playwright browsers via e2e's pinned version Running the install from the repo root let bunx float to the latest playwright, which fetched a browser build the test runtime (playwright 1.60.0, pinned in e2e) never looks for, so the browser scenarios failed to launch. Install from e2e so the resolved playwright matches the one the scenarios use. * Scope the e2e CI job to the stdio MCP scenario Running the whole local project in one CI job stacked several executor web + vite boots on the runner; a later boot would intermittently stall past the token-URL wait, and the auth scenario carries its own pre-existing browser flakiness. Run just the stdio MCP scenario, the auto-connect / env-as-secret regression guard, on its own so the job is a reliable gate. Also give the cold-boot wait headroom (120s to 180s) for CI runners. Expanding to the full local suite is a follow-up once the rest is stabilized.
Unified · Split
| @@ -74,6 +74,44 @@ jobs: · 73 unmodified lines | ||
| 74 | 74 | |
| 75 | 75 | - run: bun run test |
| 76 | 76 | |
| 77 | + e2e-local: | |
| 78 | + name: E2E (stdio MCP) | |
| 79 | + runs-on: ubuntu-latest | |
| 80 | + steps: | |
| 81 | + - uses: actions/checkout@v4 | |
| 82 | + | |
| 83 | + - uses: oven-sh/setup-bun@v2 | |
| 84 | + with: | |
| 85 | + bun-version: 1.3.11 | |
| 86 | + | |
| 87 | + # The local scenarios boot a real `executor web` (which spawns a Node | |
| 88 | + # sidecar) and some drive a browser, so pin Node 22 and install Chromium. | |
| 89 | + - uses: actions/setup-node@v4 | |
| 90 | + with: | |
| 91 | + node-version: 22 | |
| 92 | + | |
| 93 | + - run: bun install --frozen-lockfile | |
| 94 | + | |
| 95 | + # `chromium` and the new `chromium-headless-shell` ship as separate | |
| 96 | + # downloads; the browser-driven scenarios launch the headless shell. | |
| 97 | + # Install from e2e so bunx resolves ITS pinned playwright (the version the | |
| 98 | + # tests run against) rather than floating to the latest, which would fetch | |
| 99 | + # a browser build the test runtime does not look for. | |
| 100 | + - name: Install Playwright Chromium | |
| 101 | + run: bunx playwright install --with-deps chromium chromium-headless-shell | |
| 102 | + working-directory: e2e | |
| 103 | + | |
| 104 | + # The `local` project is excluded from the default `test` chain (each | |
| 105 | + # scenario boots its own `executor web`). Run just the stdio MCP scenario | |
| 106 | + # here: it is the auto-connect / env-as-secret regression guard, and | |
| 107 | + # running it alone avoids the boot-resource accumulation and the | |
| 108 | + # pre-existing browser flakiness of the rest of the local suite. Expanding | |
| 109 | + # to the full `local` project (bun run test:local) is a follow-up once | |
| 110 | + # those are stabilized. | |
| 111 | + - name: Run the stdio MCP scenario | |
| 112 | + run: bunx vitest run --project local local/stdio-mcp.test.ts | |
| 113 | + working-directory: e2e | |
| 114 | + | |
| 77 | 115 | desktop-smoke: |
| 78 | 116 | name: Desktop smoke build |
| 79 | 117 | runs-on: ubuntu-latest |
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | ||
| 14 | 14 | } from "@executor-js/sdk"; |
| 15 | 15 | import { collectTables } from "@executor-js/api/server"; |
| 16 | 16 | import { loadPluginsFromJsonc } from "@executor-js/config"; |
| 17 | +import type { McpPluginExtension } from "@executor-js/plugin-mcp"; | |
| 17 | 18 | |
| 18 | 19 | import executorConfig from "../executor.config"; |
| 19 | 20 | import { localDataMigrations } from "./db/data-migrations"; |
| @@ -229,6 +230,26 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 209 unmodified lines | ||
| 229 | 230 | } |
| 230 | 231 | } |
| 231 | 232 | |
| 233 | + // Heal stdio MCP integrations added before auto-connect existed (they | |
| 234 | + // landed with zero connections ⇒ zero tools) and move any legacy inline | |
| 235 | + // env into the secret store. No-op on a fresh install; never fails boot. | |
| 236 | + // Local is the only app that enables stdio, so this only runs here. | |
| 237 | + // 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 | |
| 238 | + const mcpExtension = (executor as unknown as { readonly mcp?: McpPluginExtension }).mcp; | |
| 239 | + if (mcpExtension) { | |
| 240 | + yield* mcpExtension | |
| 241 | + .reconcileStdioConnections() | |
| 242 | + .pipe( | |
| 243 | + Effect.catch(() => | |
| 244 | + Effect.sync(() => | |
| 245 | + console.warn( | |
| 246 | + "[executor] stdio connection reconcile failed; existing stdio servers may show no tools until re-added", | |
| 247 | + ), | |
| 248 | + ), | |
| 249 | + ), | |
| 250 | + ); | |
| 251 | + } | |
| 252 | + | |
| 232 | 253 | return { executor, plugins }; |
| 233 | 254 | }), |
| 234 | 255 | ); |
| @@ -0,0 +1,104 @@ | ||
| 1 | +// A zero-dependency MCP server over stdio, for the `local` e2e project. | |
| 2 | +// | |
| 3 | +// The real `@modelcontextprotocol/sdk` stdio server pulls in the whole SDK and | |
| 4 | +// is awkward to resolve from an arbitrary spawn cwd under bun's node_modules | |
| 5 | +// layout. The MCP stdio framing is just newline-delimited JSON-RPC, so we hand- | |
| 6 | +// roll the three methods a tool-discovery + invoke round-trip needs: | |
| 7 | +// `initialize`, `tools/list`, `tools/call` (plus `ping`). This keeps the | |
| 8 | +// fixture a single self-contained file the executor server can launch as | |
| 9 | +// `node <thisfile>` with nothing to install. | |
| 10 | +// | |
| 11 | +// It exposes one tool, `echo_tool`, and (when EXECUTOR_E2E_SECRET is set in the | |
| 12 | +// child env) a second `whoami` tool that returns that env value — so a scenario | |
| 13 | +// can prove a per-connection secret env var actually reached the subprocess. | |
| 14 | + | |
| 15 | +import { createInterface } from "node:readline"; | |
| 16 | + | |
| 17 | +const send = (message) => { | |
| 18 | + process.stdout.write(`${JSON.stringify(message)}\n`); | |
| 19 | +}; | |
| 20 | + | |
| 21 | +const TOOLS = [ | |
| 22 | + { | |
| 23 | + name: "echo_tool", | |
| 24 | + description: "Echoes the provided text back", | |
| 25 | + inputSchema: { | |
| 26 | + type: "object", | |
| 27 | + properties: { text: { type: "string" } }, | |
| 28 | + required: ["text"], | |
| 29 | + }, | |
| 30 | + }, | |
| 31 | +]; | |
| 32 | + | |
| 33 | +if (process.env.EXECUTOR_E2E_SECRET) { | |
| 34 | + TOOLS.push({ | |
| 35 | + name: "whoami", | |
| 36 | + description: "Returns the secret env value the server was launched with", | |
| 37 | + inputSchema: { type: "object", properties: {} }, | |
| 38 | + }); | |
| 39 | +} | |
| 40 | + | |
| 41 | +const handle = (msg) => { | |
| 42 | + if (msg.method === "initialize") { | |
| 43 | + send({ | |
| 44 | + jsonrpc: "2.0", | |
| 45 | + id: msg.id, | |
| 46 | + result: { | |
| 47 | + // Echo the client's protocol version so we never fail version | |
| 48 | + // negotiation against whatever SDK build is on the other end. | |
| 49 | + protocolVersion: msg.params?.protocolVersion ?? "2025-06-18", | |
| 50 | + capabilities: { tools: {} }, | |
| 51 | + serverInfo: { name: "executor-e2e-stdio", version: "1.0.0" }, | |
| 52 | + }, | |
| 53 | + }); | |
| 54 | + return; | |
| 55 | + } | |
| 56 | + | |
| 57 | + // Notifications carry no id and expect no response. | |
| 58 | + if (msg.id === undefined || msg.id === null) return; | |
| 59 | + | |
| 60 | + if (msg.method === "ping") { | |
| 61 | + send({ jsonrpc: "2.0", id: msg.id, result: {} }); | |
| 62 | + return; | |
| 63 | + } | |
| 64 | + | |
| 65 | + if (msg.method === "tools/list") { | |
| 66 | + send({ jsonrpc: "2.0", id: msg.id, result: { tools: TOOLS } }); | |
| 67 | + return; | |
| 68 | + } | |
| 69 | + | |
| 70 | + if (msg.method === "tools/call") { | |
| 71 | + const name = msg.params?.name; | |
| 72 | + const text = | |
| 73 | + name === "whoami" | |
| 74 | + ? (process.env.EXECUTOR_E2E_SECRET ?? "") | |
| 75 | + : String(msg.params?.arguments?.text ?? ""); | |
| 76 | + send({ | |
| 77 | + jsonrpc: "2.0", | |
| 78 | + id: msg.id, | |
| 79 | + result: { content: [{ type: "text", text }] }, | |
| 80 | + }); | |
| 81 | + return; | |
| 82 | + } | |
| 83 | + | |
| 84 | + send({ | |
| 85 | + jsonrpc: "2.0", | |
| 86 | + id: msg.id, | |
| 87 | + error: { code: -32601, message: `Method not found: ${msg.method}` }, | |
| 88 | + }); | |
| 89 | +}; | |
| 90 | + | |
| 91 | +const rl = createInterface({ input: process.stdin }); | |
| 92 | +rl.on("line", (line) => { | |
| 93 | + const trimmed = line.trim(); | |
| 94 | + if (!trimmed) return; | |
| 95 | + let msg; | |
| 96 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code | |
| 97 | + try { | |
| 98 | + // oxlint-disable-next-line executor/no-json-parse -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code | |
| 99 | + msg = JSON.parse(trimmed); | |
| 100 | + } catch { | |
| 101 | + return; | |
| 102 | + } | |
| 103 | + handle(msg); | |
| 104 | +}); | |
| @@ -61,7 +61,9 @@ export const withLocalServer = ( · 60 unmodified lines | ||
| 61 | 61 | markFocus(runDir, "terminal"); |
| 62 | 62 | const snapshot = await term.screen.waitUntil( |
| 63 | 63 | (current) => TOKEN_URL.test(current.text), |
| 64 | − { timeoutMs: 120_000 }, | |
| 64 | + // A cold `executor web` runs vite's optimizeDeps before it prints | |
| 65 | + // the URL; give CI runners headroom over the warm ~3s boot. | |
| 66 | + { timeoutMs: 180_000 }, | |
| 65 | 67 | ); |
| 66 | 68 | const url = TOKEN_URL.exec(snapshot.text)?.[0]; |
| 67 | 69 | if (!url) { |
| @@ -0,0 +1,145 @@ | ||
| 1 | +// Repro + regression guard for the user report: "On a totally fresh install | |
| 2 | +// (no existing data dir) on macOS, Executor does not detect any tools for a | |
| 3 | +// STDIO MCP server." | |
| 4 | +// | |
| 5 | +// `withLocalServer` boots a real `executor web` on a THROWAWAY data dir (the | |
| 6 | +// fresh-install condition) and the `local` app is the only surface that enables | |
| 7 | +// stdio MCP (`dangerouslyAllowStdioMCP: true`). We add a stdio MCP server over | |
| 8 | +// the bearer-authed API and assert its tools are discoverable — and that the | |
| 9 | +// secret env it needs is stored on the connection (the secret store), not in | |
| 10 | +// the integration's config blob. | |
| 11 | +// | |
| 12 | +// The original bug: `mcp.addServer` only registered an INTEGRATION. Per the | |
| 13 | +// v1.5 integrations/connections split, tools are produced per-CONNECTION, and a | |
| 14 | +// stdio add never created one, so the integration landed with zero connections | |
| 15 | +// and zero tools. The fix auto-creates the default connection on add and routes | |
| 16 | +// the env values into the connection's secret store. | |
| 17 | +import { fileURLToPath } from "node:url"; | |
| 18 | + | |
| 19 | +import { expect } from "@effect/vitest"; | |
| 20 | +import { Effect } from "effect"; | |
| 21 | +import { HttpApiClient } from "effect/unstable/httpapi"; | |
| 22 | +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 23 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 24 | +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | |
| 25 | +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 26 | + | |
| 27 | +import { scenario } from "../src/scenario"; | |
| 28 | +import { Cli, RunDir } from "../src/services"; | |
| 29 | +import { withLocalServer } from "./local-server"; | |
| 30 | + | |
| 31 | +const api = composePluginApi([mcpHttpPlugin()] as const); | |
| 32 | + | |
| 33 | +const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url)); | |
| 34 | + | |
| 35 | +// The fixture exposes `whoami` ONLY when EXECUTOR_E2E_SECRET is present in its | |
| 36 | +// process env. So `whoami` showing up in the discovered tools is direct proof | |
| 37 | +// the connection's secret env reached the spawned subprocess. | |
| 38 | +const SECRET = "s3cr3t-from-the-vault"; | |
| 39 | + | |
| 40 | +scenario( | |
| 41 | + "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret", | |
| 42 | + { timeout: 180_000 }, | |
| 43 | + Effect.gen(function* () { | |
| 44 | + const cli = yield* Cli; | |
| 45 | + const runDir = yield* RunDir; | |
| 46 | + | |
| 47 | + yield* withLocalServer(cli, runDir, (server) => | |
| 48 | + Effect.gen(function* () { | |
| 49 | + const client = yield* HttpApiClient.make(api, { | |
| 50 | + baseUrl: new URL("/api", server.origin).toString(), | |
| 51 | + transformClient: HttpClient.mapRequest((request) => | |
| 52 | + HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), | |
| 53 | + ), | |
| 54 | + }).pipe(Effect.provide(FetchHttpClient.layer)); | |
| 55 | + | |
| 56 | + const slug = "e2e-stdio"; | |
| 57 | + | |
| 58 | + // Add the stdio server exactly as the desktop/local "Add MCP" flow does, | |
| 59 | + // including a secret env var the server needs. | |
| 60 | + yield* client.mcp.addServer({ | |
| 61 | + payload: { | |
| 62 | + transport: "stdio", | |
| 63 | + name: "E2E Stdio", | |
| 64 | + command: "node", | |
| 65 | + args: [FIXTURE], | |
| 66 | + env: { EXECUTOR_E2E_SECRET: SECRET }, | |
| 67 | + slug, | |
| 68 | + }, | |
| 69 | + }); | |
| 70 | + | |
| 71 | + // The integration lands in the catalog — the add itself works. | |
| 72 | + const integrations = yield* client.integrations.list(); | |
| 73 | + expect( | |
| 74 | + integrations.map((i) => String(i.slug)), | |
| 75 | + "the stdio MCP integration is registered", | |
| 76 | + ).toContain(slug); | |
| 77 | + | |
| 78 | + // The add auto-creates the default connection (the v1.5 split makes this | |
| 79 | + // the thing that drives tool discovery). Pre-fix there were zero. | |
| 80 | + const connections = yield* client.connections.list({ query: { integration: slug } }); | |
| 81 | + expect( | |
| 82 | + connections.map((c) => String(c.name)), | |
| 83 | + "a default connection was auto-created for the stdio server", | |
| 84 | + ).toContain("default"); | |
| 85 | + | |
| 86 | + // THE SYMPTOM, fixed: the stdio server's tools are detected. `whoami` | |
| 87 | + // appearing proves the connection's secret env reached the subprocess. | |
| 88 | + const tools = yield* client.tools.list({ query: { integration: slug } }); | |
| 89 | + const names = tools.map((t) => t.name); | |
| 90 | + expect(names, "the stdio server's base tool is detected").toContain("echo_tool"); | |
| 91 | + expect( | |
| 92 | + names, | |
| 93 | + "the secret env var reached the spawned subprocess (whoami is gated on it)", | |
| 94 | + ).toContain("whoami"); | |
| 95 | + | |
| 96 | + // "Properly store auth": the secret value is NOT in the integration's | |
| 97 | + // config blob — only the var NAME is declared there; the value lives on | |
| 98 | + // the connection (the secret store). | |
| 99 | + const stored = yield* client.mcp.getServer({ params: { slug } }); | |
| 100 | + expect( | |
| 101 | + JSON.stringify(stored?.config ?? {}), | |
| 102 | + "the secret value is not persisted in the integration config", | |
| 103 | + ).not.toContain(SECRET); | |
| 104 | + | |
| 105 | + // --- The UI path: DECLARE env var names, then provide the secret value | |
| 106 | + // as a connection credential (what the add form now does). --- | |
| 107 | + const declSlug = "e2e-stdio-decl"; | |
| 108 | + yield* client.mcp.addServer({ | |
| 109 | + payload: { | |
| 110 | + transport: "stdio", | |
| 111 | + name: "E2E Stdio Declared", | |
| 112 | + command: "node", | |
| 113 | + args: [FIXTURE], | |
| 114 | + envVars: ["EXECUTOR_E2E_SECRET"], | |
| 115 | + slug: declSlug, | |
| 116 | + }, | |
| 117 | + }); | |
| 118 | + | |
| 119 | + // Declaring a secret env var (no value) does NOT auto-connect: the | |
| 120 | + // secret is still missing, so there are no tools until you connect. | |
| 121 | + const beforeConns = yield* client.connections.list({ query: { integration: declSlug } }); | |
| 122 | + expect(beforeConns, "no connection until the secret is provided").toHaveLength(0); | |
| 123 | + const beforeTools = yield* client.tools.list({ query: { integration: declSlug } }); | |
| 124 | + expect(beforeTools, "no tools until the secret is provided").toHaveLength(0); | |
| 125 | + | |
| 126 | + // Provide the secret as the connection credential (the connect step). | |
| 127 | + yield* client.connections.create({ | |
| 128 | + payload: { | |
| 129 | + owner: "org", | |
| 130 | + name: ConnectionName.make("default"), | |
| 131 | + integration: IntegrationSlug.make(declSlug), | |
| 132 | + template: AuthTemplateSlug.make("env"), | |
| 133 | + values: { EXECUTOR_E2E_SECRET: SECRET }, | |
| 134 | + }, | |
| 135 | + }); | |
| 136 | + | |
| 137 | + const declTools = yield* client.tools.list({ query: { integration: declSlug } }); | |
| 138 | + expect( | |
| 139 | + declTools.map((t) => t.name), | |
| 140 | + "connecting with the secret discovers the env-gated tool", | |
| 141 | + ).toContain("whoami"); | |
| 142 | + }), | |
| 143 | + ); | |
| 144 | + }), | |
| 145 | +); | |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | "test:selfhost": "vitest run --project selfhost", |
| 11 | 11 | "test:selfhost-docker": "vitest run --project selfhost-docker", |
| 12 | 12 | "test:cloudflare": "vitest run --project cloudflare", |
| 13 | + "test:local": "vitest run --project local", | |
| 13 | 14 | "test:watch": "vitest", |
| 14 | 15 | "ports": "bun scripts/ports.ts", |
| 15 | 16 | "summary": "bun scripts/summary.ts", |
| @@ -79,15 +79,18 @@ export default defineConfig({ · 78 unmodified lines | ||
| 79 | 79 | }), |
| 80 | 80 | // The single-user local app. Each scenario launches its OWN `executor |
| 81 | 81 | // web` via the CLI on a throwaway data dir + an OS-assigned port, so |
| 82 | − // there is no shared instance and scenarios are independent — file | |
| 83 | − // parallelism is ON. No globalSetup (nothing shared to boot). Only | |
| 84 | − // local/** scenarios. Not part of the default `npm run test` chain; run | |
| 85 | − // with `vitest run --project local`. | |
| 82 | + // there is no shared instance and scenarios are independent. Files run | |
| 83 | + // SERIALLY (like every other server-booting project): a cold `executor | |
| 84 | + // web` boot runs vite's optimizeDeps, and several booting at once on a | |
| 85 | + // CI runner thrash hard enough to blow the token-URL wait. Serial lets | |
| 86 | + // the first boot warm the shared vite cache so the rest come up fast. No | |
| 87 | + // globalSetup (nothing shared to boot). Only local/** scenarios. Not part | |
| 88 | + // of the default `npm run test` chain; run with `vitest run --project local`. | |
| 86 | 89 | project("local", { |
| 87 | 90 | include: ["local/**/*.test.ts"], |
| 88 | 91 | globalSetup: [], |
| 89 | − fileParallelism: true, | |
| 90 | − testTimeout: 180_000, | |
| 92 | + fileParallelism: false, | |
| 93 | + testTimeout: 240_000, | |
| 91 | 94 | }), |
| 92 | 95 | // The supervised CLI daemon inside a guest VM, one project per OS. The |
| 93 | 96 | // globalsetup provisions a VM, `executor service install`s the daemon, and |
| @@ -31,7 +31,7 @@ const IntegrationParams = { slug: IntegrationSlug }; · 30 unmodified lines | ||
| 31 | 31 | /** Where a credential value is carried — mirrors the SDK's |
| 32 | 32 | * `AuthPlacementDescriptor`. */ |
| 33 | 33 | const PlacementDescriptor = Schema.Struct({ |
| 34 | − carrier: Schema.Literals(["header", "query"]), | |
| 34 | + carrier: Schema.Literals(["header", "query", "env"]), | |
| 35 | 35 | name: Schema.String, |
| 36 | 36 | prefix: Schema.String, |
| 37 | 37 | /** Input variable this placement renders from (absent ⇒ `token`). Without |
| @@ -24,10 +24,11 @@ export interface IntegrationDisplayDescriptor { · 23 unmodified lines | ||
| 24 | 24 | readonly url?: string; |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | −/** Where a credential value is carried on the outbound request. Mirrors the | |
| 28 | − * client's `Placement`. */ | |
| 27 | +/** Where a credential value is carried. `header`/`query` place it on an | |
| 28 | + * outbound HTTP request (mirrors the client's `Placement`); `env` injects it | |
| 29 | + * as an environment variable for a stdio (subprocess) integration. */ | |
| 29 | 30 | export interface AuthPlacementDescriptor { |
| 30 | − readonly carrier: "header" | "query"; | |
| 31 | + readonly carrier: "header" | "query" | "env"; | |
| 31 | 32 | readonly name: string; |
| 32 | 33 | /** Literal prepended to the value (e.g. `"Bearer "`). Empty when bare. */ |
| 33 | 34 | readonly prefix: string; |
| @@ -51,6 +51,10 @@ const AddStdioServerPayload = Schema.Struct({ · 50 unmodified lines | ||
| 51 | 51 | description: Schema.optional(Schema.String), |
| 52 | 52 | command: Schema.String, |
| 53 | 53 | args: Schema.optional(Schema.Array(Schema.String)), |
| 54 | + /** Declare the secret env vars this server needs, by name. Their values are | |
| 55 | + * supplied as the connection's secrets (the connect step), not here. */ | |
| 56 | + envVars: Schema.optional(Schema.Array(Schema.String)), | |
| 57 | + /** One-shot secret env values (programmatic). The UI sends `envVars`. */ | |
| 54 | 58 | env: Schema.optional(StringMap), |
| 55 | 59 | cwd: Schema.optional(Schema.String), |
| 56 | 60 | slug: Schema.optional(Schema.String), |
| @@ -26,6 +26,7 @@ const failingExtension: McpPluginExtension = { · 25 unmodified lines | ||
| 26 | 26 | probeEndpoint: () => Effect.die(new Error("Not implemented")), |
| 27 | 27 | addServer: () => unused, |
| 28 | 28 | removeServer: () => unused, |
| 29 | + reconcileStdioConnections: () => unused, | |
| 29 | 30 | getServer: () => Effect.succeed(null), |
| 30 | 31 | configureServer: () => unused, |
| 31 | 32 | configureAuth: () => unused, |
| @@ -36,6 +36,7 @@ const toServerInput = ( · 35 unmodified lines | ||
| 36 | 36 | description?: string; |
| 37 | 37 | command: string; |
| 38 | 38 | args?: readonly string[]; |
| 39 | + envVars?: readonly string[]; | |
| 39 | 40 | env?: Record<string, string>; |
| 40 | 41 | cwd?: string; |
| 41 | 42 | slug?: string; |
| @@ -46,6 +47,7 @@ const toServerInput = ( · 4 unmodified lines | ||
| 46 | 47 | description: p.description, |
| 47 | 48 | command: p.command, |
| 48 | 49 | args: p.args ? [...p.args] : undefined, |
| 50 | + envVars: p.envVars ? [...p.envVars] : undefined, | |
| 49 | 51 | env: p.env, |
| 50 | 52 | cwd: p.cwd, |
| 51 | 53 | slug: p.slug, |
| @@ -18,8 +18,9 @@ import { · 17 unmodified lines | ||
| 18 | 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 19 | 19 | import { Input } from "@executor-js/react/components/input"; |
| 20 | 20 | import { Spinner } from "@executor-js/react/components/spinner"; |
| 21 | −import { Textarea } from "@executor-js/react/components/textarea"; | |
| 21 | +import { TagInput } from "@executor-js/react/components/tag-input"; | |
| 22 | 22 | import { |
| 23 | + integrationDisplayNameFromStdio, | |
| 23 | 24 | integrationDisplayNameFromUrl, |
| 24 | 25 | slugifyNamespace, |
| 25 | 26 | IntegrationIdentityFields, |
| @@ -48,14 +49,6 @@ import { mcpPresets, type McpPreset } from "../sdk/presets"; · 22 unmodified lines | ||
| 48 | 49 | // the user can add alternate methods (e.g. an API key alongside OAuth, or a |
| 49 | 50 | // declared method on a server that advertises none). |
| 50 | 51 | |
| 51 | −const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = { | |
| 52 | − "\\": "\\", | |
| 53 | − n: "\n", | |
| 54 | − r: "\r", | |
| 55 | − t: "\t", | |
| 56 | − '"': '"', | |
| 57 | −}; | |
| 58 | − | |
| 59 | 52 | // --------------------------------------------------------------------------- |
| 60 | 53 | // Preset lookup |
| 61 | 54 | // --------------------------------------------------------------------------- |
| @@ -65,6 +58,19 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 3 unmodified lines | ||
| 65 | 58 | return mcpPresets.find((p) => p.id === id); |
| 66 | 59 | } |
| 67 | 60 | |
| 61 | +// Splits the raw args field into tokens, honoring double-quoted groups so an | |
| 62 | +// argument with spaces stays intact. | |
| 63 | +function parseStdioArgs(raw: string): string[] { | |
| 64 | + if (!raw.trim()) return []; | |
| 65 | + const args: string[] = []; | |
| 66 | + const regex = /[^\s"]+|"([^"]*)"/g; | |
| 67 | + let match; | |
| 68 | + while ((match = regex.exec(raw)) !== null) { | |
| 69 | + args.push(match[1] ?? match[0]); | |
| 70 | + } | |
| 71 | + return args; | |
| 72 | +} | |
| 73 | + | |
| 68 | 74 | // --------------------------------------------------------------------------- |
| 69 | 75 | // State machine (remote flow) |
| 70 | 76 | // --------------------------------------------------------------------------- |
| @@ -180,9 +186,12 @@ export default function AddMcpSource(props: { · 109 unmodified lines | ||
| 180 | 186 | const [stdioArgs, setStdioArgs] = useState( |
| 181 | 187 | isStdioPreset && preset.args ? preset.args.join(" ") : "", |
| 182 | 188 | ); |
| 183 | − const [stdioEnv, setStdioEnv] = useState(""); | |
| 189 | + const [stdioEnvVars, setStdioEnvVars] = useState<string[]>([]); | |
| 184 | 190 | const stdioIdentity = useIntegrationIdentity({ |
| 185 | − fallbackName: isStdioPreset ? preset.name : stdioCommand, | |
| 191 | + fallbackName: isStdioPreset | |
| 192 | + ? preset.name | |
| 193 | + : (integrationDisplayNameFromStdio(stdioCommand, parseStdioArgs(stdioArgs), "MCP") ?? | |
| 194 | + stdioCommand), | |
| 186 | 195 | }); |
| 187 | 196 | const [stdioAdding, setStdioAdding] = useState(false); |
| 188 | 197 | const [stdioError, setStdioError] = useState<string | null>(null); |
| @@ -341,47 +350,6 @@ export default function AddMcpSource(props: { · 152 unmodified lines | ||
| 341 | 350 | |
| 342 | 351 | // ---- Stdio actions ---- |
| 343 | 352 | |
| 344 | − const parseStdioArgs = (raw: string): string[] => { | |
| 345 | − if (!raw.trim()) return []; | |
| 346 | − const args: string[] = []; | |
| 347 | − const regex = /[^\s"]+|"([^"]*)"/g; | |
| 348 | − let match; | |
| 349 | − while ((match = regex.exec(raw)) !== null) { | |
| 350 | − args.push(match[1] ?? match[0]); | |
| 351 | − } | |
| 352 | − return args; | |
| 353 | − }; | |
| 354 | − | |
| 355 | − const parseStdioEnvValue = (raw: string): string => { | |
| 356 | − const value = raw.trim(); | |
| 357 | − if (value.length < 2) return value; | |
| 358 | − | |
| 359 | − const quote = value[0]; | |
| 360 | − if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) { | |
| 361 | − return value; | |
| 362 | − } | |
| 363 | − | |
| 364 | − const inner = value.slice(1, -1); | |
| 365 | − if (quote === "'") return inner; | |
| 366 | − | |
| 367 | − return inner.replace( | |
| 368 | − /\\([\\nrt"])/g, | |
| 369 | − (_, escaped: string) => STDIO_ENV_ESCAPE_REPLACEMENTS[escaped] ?? escaped, | |
| 370 | − ); | |
| 371 | − }; | |
| 372 | − | |
| 373 | − const parseStdioEnv = (raw: string): Record<string, string> | undefined => { | |
| 374 | − if (!raw.trim()) return undefined; | |
| 375 | − const env: Record<string, string> = {}; | |
| 376 | − for (const line of raw.split("\n")) { | |
| 377 | − const eq = line.indexOf("="); | |
| 378 | − if (eq > 0) { | |
| 379 | − env[line.slice(0, eq).trim()] = parseStdioEnvValue(line.slice(eq + 1)); | |
| 380 | − } | |
| 381 | − } | |
| 382 | − return Object.keys(env).length > 0 ? env : undefined; | |
| 383 | − }; | |
| 384 | − | |
| 385 | 353 | const handleAddStdio = useCallback(async () => { |
| 386 | 354 | const cmd = stdioCommand.trim(); |
| 387 | 355 | if (!cmd) return; |
| @@ -396,7 +364,7 @@ export default function AddMcpSource(props: { · 8 unmodified lines | ||
| 396 | 364 | ...(slug ? { slug } : {}), |
| 397 | 365 | command: cmd, |
| 398 | 366 | args: parseStdioArgs(stdioArgs), |
| 399 | − env: parseStdioEnv(stdioEnv), | |
| 367 | + envVars: stdioEnvVars.length > 0 ? stdioEnvVars : undefined, | |
| 400 | 368 | }, |
| 401 | 369 | reactivityKeys: integrationWriteKeys, |
| 402 | 370 | }); |
| @@ -406,7 +374,7 @@ export default function AddMcpSource(props: { · 3 unmodified lines | ||
| 406 | 374 | return; |
| 407 | 375 | } |
| 408 | 376 | props.onComplete(exit.value.slug); |
| 409 | − }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAddServer, props]); | |
| 377 | + }, [stdioCommand, stdioArgs, stdioEnvVars, stdioIdentity, doAddServer, props]); | |
| 410 | 378 | |
| 411 | 379 | // ---- Render ---- |
| 412 | 380 | |
| @@ -548,15 +516,12 @@ export default function AddMcpSource(props: { · 135 unmodified lines | ||
| 548 | 516 | |
| 549 | 517 | <CardStackEntryField |
| 550 | 518 | label="Environment variables" |
| 551 | − description="- One per line, KEY=value format." | |
| 519 | + description="- Names only; secret values are entered when you connect." | |
| 552 | 520 | > |
| 553 | − <Textarea | |
| 554 | − value={stdioEnv} | |
| 555 | − onChange={(e) => setStdioEnv((e.target as HTMLTextAreaElement).value)} | |
| 556 | − placeholder={"KEY=value\nANOTHER=value"} | |
| 557 | − rows={3} | |
| 558 | − maxRows={10} | |
| 559 | − className="font-mono text-sm" | |
| 521 | + <TagInput | |
| 522 | + values={stdioEnvVars} | |
| 523 | + onChange={setStdioEnvVars} | |
| 524 | + placeholder="Add an env var, e.g. GITHUB_TOKEN" | |
| 560 | 525 | /> |
| 561 | 526 | </CardStackEntryField> |
| 562 | 527 | </CardStackContent> |
| @@ -50,13 +50,16 @@ export default function McpAccountsPanel(props: { · 49 unmodified lines | ||
| 50 | 50 | const remote = config !== null && config.transport === "remote" ? config : null; |
| 51 | 51 | |
| 52 | 52 | const existingTemplate = useMemo<readonly McpAuthMethod[]>( |
| 53 | − () => remote?.authenticationTemplate ?? [], | |
| 54 | − [remote], | |
| 53 | + () => config?.authenticationTemplate ?? [], | |
| 54 | + [config], | |
| 55 | 55 | ); |
| 56 | 56 | |
| 57 | + // Stdio servers declare a `stdio_env` / `none` method too, so their | |
| 58 | + // auto-created connection and its env credentials surface here. The endpoint | |
| 59 | + // only feeds remote oauth methods' probe URL; stdio has none. | |
| 57 | 60 | const methods = useMemo<readonly AuthMethod[]>( |
| 58 | − () => (remote ? authMethodsFromConfig(existingTemplate, remote.endpoint) : []), | |
| 59 | − [existingTemplate, remote], | |
| 61 | + () => (config ? authMethodsFromConfig(existingTemplate, remote?.endpoint ?? "") : []), | |
| 62 | + [existingTemplate, config, remote], | |
| 60 | 63 | ); |
| 61 | 64 | |
| 62 | 65 | const configure = useCallback<ConfigureAuthMethods<McpAuthMethod>>( |
| @@ -17,7 +17,30 @@ import { · 16 unmodified lines | ||
| 17 | 17 | } from "@executor-js/react/lib/shared-auth-method-codec"; |
| 18 | 18 | |
| 19 | 19 | import { wireAuthInputFromShared } from "@executor-js/react/lib/shared-auth-method-codec"; |
| 20 | −import type { McpAuthMethod, McpAuthMethodInput, McpCanonicalAuthMethodInput } from "../sdk/types"; | |
| 20 | +import type { | |
| 21 | + McpAuthMethod, | |
| 22 | + McpAuthMethodInput, | |
| 23 | + McpCanonicalAuthMethodInput, | |
| 24 | + McpStdioEnvMethod, | |
| 25 | +} from "../sdk/types"; | |
| 26 | + | |
| 27 | +/** Stdio env method → generic hub `AuthMethod`: one `env`-carrier placement per | |
| 28 | + * declared var, so the account form collects one secret per env var. Mirrors | |
| 29 | + * the server's `describeMcpAuthMethods`. */ | |
| 30 | +const stdioEnvAuthMethod = (method: McpStdioEnvMethod): AuthMethod => ({ | |
| 31 | + id: method.slug, | |
| 32 | + label: "Environment variables", | |
| 33 | + kind: "apikey", | |
| 34 | + source: "spec", | |
| 35 | + template: AuthTemplateSlug.make(method.slug), | |
| 36 | + placements: method.vars.map((name) => ({ carrier: "env", name, prefix: "", variable: name })), | |
| 37 | +}); | |
| 38 | + | |
| 39 | +/** Stdio env method → editor value (apikey over env placements). */ | |
| 40 | +const stdioEnvEditorValue = (method: McpStdioEnvMethod): AuthTemplateEditorValue => ({ | |
| 41 | + kind: "apikey", | |
| 42 | + placements: method.vars.map((name) => ({ carrier: "env", name, prefix: "", variable: name })), | |
| 43 | +}); | |
| 21 | 44 | |
| 22 | 45 | /** Serialize a canonical method into the wire input union (apikey → the |
| 23 | 46 | * request-shaped dialect; none/oauth2 pass through). */ |
| @@ -53,6 +76,7 @@ export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplat · 29 unmodified lines | ||
| 53 | 76 | if (method.kind === "oauth2") { |
| 54 | 77 | return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }; |
| 55 | 78 | } |
| 79 | + if (method.kind === "stdio_env") return stdioEnvEditorValue(method); | |
| 56 | 80 | return editorValueFromSharedMethod(method); |
| 57 | 81 | } |
| 58 | 82 | |
| @@ -66,6 +90,7 @@ export function authMethodsFromConfig( · 7 unmodified lines | ||
| 66 | 90 | ): AuthMethod[] { |
| 67 | 91 | return methods.map((method: McpAuthMethod): AuthMethod => { |
| 68 | 92 | if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint); |
| 93 | + if (method.kind === "stdio_env") return stdioEnvAuthMethod(method); | |
| 69 | 94 | return authMethodFromSharedTemplate(method); |
| 70 | 95 | }); |
| 71 | 96 | } |
| @@ -7,6 +7,8 @@ import * as z from "zod/v4"; · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | 9 | authToolFailure, |
| 10 | + AuthTemplateSlug, | |
| 11 | + ConnectionName, | |
| 10 | 12 | definePlugin, |
| 11 | 13 | IntegrationAlreadyExistsError, |
| 12 | 14 | IntegrationSlug, |
| @@ -58,7 +60,7 @@ import { · 45 unmodified lines | ||
| 58 | 60 | normalizeMcpAuthMethods, |
| 59 | 61 | parseMcpIntegrationConfig, |
| 60 | 62 | type McpIntegrationConfig as McpIntegrationConfigType, |
| 61 | − type McpRemoteIntegrationConfig, | |
| 63 | + type McpStdioEnvMethod, | |
| 62 | 64 | type McpStdioIntegrationConfig, |
| 63 | 65 | } from "./types"; |
| 64 | 66 | |
| @@ -169,6 +171,13 @@ const McpStdioServerInputSchema = Schema.Struct({ · 104 unmodified lines | ||
| 169 | 171 | description: Schema.optional(Schema.String), |
| 170 | 172 | command: Schema.String, |
| 171 | 173 | args: Schema.optional(Schema.Array(Schema.String)), |
| 174 | + /** DECLARE the secret env vars this server needs, by NAME. Their values are | |
| 175 | + * supplied as the connection's secret credentials, not here — so the UI | |
| 176 | + * defines what env vars exist and the connect step provides the secrets. */ | |
| 177 | + envVars: Schema.optional(Schema.Array(Schema.String)), | |
| 178 | + /** Provide secret env values directly (programmatic / agent one-shot): the | |
| 179 | + * add then auto-creates the connection holding them. The UI uses `envVars` | |
| 180 | + * instead and leaves the values to the connect step. */ | |
| 172 | 181 | env: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 173 | 182 | cwd: Schema.optional(Schema.String), |
| 174 | 183 | slug: Schema.optional(Schema.String), |
| @@ -295,14 +304,34 @@ const normalizeSlug = (input: McpServerInput): string => · 120 unmodified lines | ||
| 295 | 304 | command: input.transport === "stdio" ? input.command : undefined, |
| 296 | 305 | }); |
| 297 | 306 | |
| 307 | +/** Slug for a stdio server's secret-env auth method (one per integration). */ | |
| 308 | +const STDIO_ENV_TEMPLATE = "env"; | |
| 309 | + | |
| 310 | +/** The secret env var NAMES a stdio add declares: the explicit `envVars` | |
| 311 | + * declaration plus the keys of any one-shot `env` values, de-duplicated and | |
| 312 | + * order-preserving. */ | |
| 313 | +const stdioEnvVarNames = (input: McpStdioServerInput): readonly string[] => { | |
| 314 | + const names = new Set<string>(input.envVars ?? []); | |
| 315 | + for (const key of Object.keys(input.env ?? {})) names.add(key); | |
| 316 | + return [...names]; | |
| 317 | +}; | |
| 318 | + | |
| 298 | 319 | const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => { |
| 299 | 320 | if (input.transport === "stdio") { |
| 321 | + // The config only DECLARES the secret env vars by NAME (a `stdio_env` | |
| 322 | + // method); their values are credentials and live on the connection, never | |
| 323 | + // in this blob. Names come from the explicit `envVars` declaration and/or | |
| 324 | + // the keys of any one-shot `env` values. | |
| 325 | + const vars = stdioEnvVarNames(input); | |
| 300 | 326 | return { |
| 301 | 327 | transport: "stdio", |
| 302 | 328 | command: input.command, |
| 303 | 329 | args: input.args ? [...input.args] : undefined, |
| 304 | − env: input.env, | |
| 305 | 330 | cwd: input.cwd, |
| 331 | + authenticationTemplate: | |
| 332 | + vars.length > 0 | |
| 333 | + ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] | |
| 334 | + : [{ slug: "none", kind: "none" }], | |
| 306 | 335 | }; |
| 307 | 336 | } |
| 308 | 337 | return { |
| @@ -453,10 +482,10 @@ const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ · 144 unmodified lines | ||
| 453 | 482 | * config-driven), so existing connections keep working. Ambiguity across |
| 454 | 483 | * several methods renders no auth rather than guessing. */ |
| 455 | 484 | const selectAuthMethod = ( |
| 456 | − config: McpRemoteIntegrationConfig, | |
| 485 | + config: McpIntegrationConfigType, | |
| 457 | 486 | templateSlug: string | null, |
| 458 | 487 | ): McpAuthMethod | undefined => { |
| 459 | − const methods = config.authenticationTemplate; | |
| 488 | + const methods = config.authenticationTemplate ?? []; | |
| 460 | 489 | if (templateSlug !== null) { |
| 461 | 490 | const match = methods.find((method: McpAuthMethod) => method.slug === templateSlug); |
| 462 | 491 | if (match) return match; |
| @@ -481,11 +510,23 @@ const buildConnectorInput = ( · 18 unmodified lines | ||
| 481 | 510 | }), |
| 482 | 511 | ); |
| 483 | 512 | } |
| 513 | + // Secret env lives on the connection: render the bound `stdio_env` | |
| 514 | + // method's vars from the connection's resolved values, layered over any | |
| 515 | + // static (non-credential / legacy-inline) env in the config. A var that | |
| 516 | + // resolved to nothing is skipped rather than injected as empty. | |
| 517 | + const method = selectAuthMethod(config, templateSlug); | |
| 518 | + const env: Record<string, string> = { ...(config.env ?? {}) }; | |
| 519 | + if (method?.kind === "stdio_env") { | |
| 520 | + for (const variable of method.vars) { | |
| 521 | + const value = values[variable]; | |
| 522 | + if (value != null) env[variable] = value; | |
| 523 | + } | |
| 524 | + } | |
| 484 | 525 | return Effect.succeed({ |
| 485 | 526 | transport: "stdio" as const, |
| 486 | 527 | command: config.command, |
| 487 | 528 | args: config.args, |
| 488 | − env: config.env, | |
| 529 | + env: Object.keys(env).length > 0 ? env : undefined, | |
| 489 | 530 | cwd: config.cwd, |
| 490 | 531 | } satisfies McpStdioIntegrationConfig); |
| 491 | 532 | } |
| @@ -535,13 +576,28 @@ const buildConnectorInput = ( · 43 unmodified lines | ||
| 535 | 576 | // the connect flow probes to confirm and falls back. |
| 536 | 577 | // --------------------------------------------------------------------------- |
| 537 | 578 | |
| 579 | +/** A stdio server's secret env method, projected so the console can render one | |
| 580 | + * credential input per env var (carrier `env`) and re-create the connection. */ | |
| 581 | +const describeStdioEnvAuthMethod = (method: McpStdioEnvMethod): AuthMethodDescriptor => ({ | |
| 582 | + id: method.slug, | |
| 583 | + label: "Environment variables", | |
| 584 | + kind: "apikey", | |
| 585 | + template: method.slug, | |
| 586 | + placements: method.vars.map((name) => ({ carrier: "env", name, prefix: "", variable: name })), | |
| 587 | +}); | |
| 588 | + | |
| 538 | 589 | export const describeMcpAuthMethods = ( |
| 539 | 590 | record: IntegrationRecord, |
| 540 | 591 | ): readonly AuthMethodDescriptor[] => { |
| 541 | 592 | const config = parseMcpIntegrationConfig(record.config); |
| 542 | − if (!config || config.transport === "stdio") return []; | |
| 543 | − | |
| 544 | − return config.authenticationTemplate.map((method: McpAuthMethod): AuthMethodDescriptor => { | |
| 593 | + if (!config) return []; | |
| 594 | + | |
| 595 | + // Stdio servers declare a single `stdio_env` method (or `none`); remote | |
| 596 | + // servers declare header/query/oauth methods. Both project from the same | |
| 597 | + // optional `authenticationTemplate`. | |
| 598 | + const methods = config.authenticationTemplate ?? []; | |
| 599 | + return methods.map((method: McpAuthMethod): AuthMethodDescriptor => { | |
| 600 | + if (method.kind === "stdio_env") return describeStdioEnvAuthMethod(method); | |
| 545 | 601 | if (method.kind === "apikey") return describeApiKeyAuthMethod(method); |
| 546 | 602 | if (method.kind === "oauth2") { |
| 547 | 603 | return { |
| @@ -549,7 +605,12 @@ export const describeMcpAuthMethods = ( · 1 unmodified line | ||
| 549 | 605 | label: "OAuth", |
| 550 | 606 | kind: "oauth", |
| 551 | 607 | template: method.slug, |
| 552 | − oauth: { discoveryUrl: config.endpoint, supportsDynamicRegistration: true }, | |
| 608 | + // Only remote configs carry an endpoint; stdio never reaches here with | |
| 609 | + // oauth2. | |
| 610 | + oauth: { | |
| 611 | + discoveryUrl: config.transport === "remote" ? config.endpoint : undefined, | |
| 612 | + supportsDynamicRegistration: true, | |
| 613 | + }, | |
| 553 | 614 | }; |
| 554 | 615 | } |
| 555 | 616 | return describeNoneAuthMethod(method.slug); |
| @@ -778,6 +839,54 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 222 unmodified lines | ||
| 778 | 839 | attributes: { "mcp.integration.slug": slug }, |
| 779 | 840 | }), |
| 780 | 841 | ); |
| 842 | + | |
| 843 | + // Auto-create the stdio server's default connection so its tools are | |
| 844 | + // discovered immediately (without it the integration lands with zero | |
| 845 | + // connections and therefore zero tools — the fresh-install "no tools | |
| 846 | + // detected" report). Two cases connect on add: | |
| 847 | + // • one-shot `env` VALUES were supplied (agent path) → bind them as | |
| 848 | + // the connection's secrets. | |
| 849 | + // • the server needs NO secret env at all → a no-auth connection. | |
| 850 | + // When the server only DECLARES env var names (the UI path), the | |
| 851 | + // secrets are still missing, so we leave the connection to the connect | |
| 852 | + // step where the user enters one masked value per declared var. | |
| 853 | + if (input.transport === "stdio") { | |
| 854 | + const hasValues = input.env != null && Object.keys(input.env).length > 0; | |
| 855 | + const declaresSecrets = stdioEnvVarNames(input).length > 0; | |
| 856 | + if (hasValues || !declaresSecrets) { | |
| 857 | + yield* ctx.connections | |
| 858 | + .create({ | |
| 859 | + owner: "org", | |
| 860 | + name: ConnectionName.make("default"), | |
| 861 | + integration: slugFrom(slug), | |
| 862 | + template: AuthTemplateSlug.make(hasValues ? STDIO_ENV_TEMPLATE : "none"), | |
| 863 | + values: hasValues ? { ...input.env } : {}, | |
| 864 | + }) | |
| 865 | + .pipe( | |
| 866 | + // These can't arise right after a successful register with | |
| 867 | + // valid inputs, but the channel must stay within | |
| 868 | + // McpExtensionFailure; surface them as a connection error | |
| 869 | + // rather than swallow a real failure. | |
| 870 | + Effect.catchTags({ | |
| 871 | + IntegrationNotFoundError: (cause) => | |
| 872 | + Effect.fail( | |
| 873 | + new McpConnectionError({ transport: "stdio", message: cause.message }), | |
| 874 | + ), | |
| 875 | + CredentialProviderNotRegisteredError: (cause) => | |
| 876 | + Effect.fail( | |
| 877 | + new McpConnectionError({ transport: "stdio", message: cause.message }), | |
| 878 | + ), | |
| 879 | + InvalidConnectionInputError: (cause) => | |
| 880 | + Effect.fail( | |
| 881 | + new McpConnectionError({ transport: "stdio", message: cause.message }), | |
| 882 | + ), | |
| 883 | + }), | |
| 884 | + Effect.withSpan("mcp.plugin.bootstrap_stdio_connection", { | |
| 885 | + attributes: { "mcp.integration.slug": slug }, | |
| 886 | + }), | |
| 887 | + ); | |
| 888 | + } | |
| 889 | + } | |
| 781 | 890 | return { slug }; |
| 782 | 891 | }).pipe( |
| 783 | 892 | Effect.withSpan("mcp.plugin.add_server", { |
| @@ -788,6 +897,82 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 4 unmodified lines | ||
| 788 | 897 | }), |
| 789 | 898 | ); |
| 790 | 899 | |
| 900 | + // Heal stdio integrations that pre-date the auto-connect model: before it, | |
| 901 | + // adding a stdio server registered only the integration, so it landed with | |
| 902 | + // zero connections and therefore zero tools (the "no tools detected" | |
| 903 | + // report). For each such integration with no connection, create the | |
| 904 | + // default one — and move any legacy inline `env` (then stored plaintext in | |
| 905 | + // the config blob) into the connection's secret store, rewriting the | |
| 906 | + // config to the canonical shape that only declares the var NAMES. | |
| 907 | + // | |
| 908 | + // Idempotent and order-safe: once a connection exists the integration is | |
| 909 | + // skipped; the secret is persisted (connection.create) BEFORE the config | |
| 910 | + // is stripped, so a failure between the two leaves the env recoverable | |
| 911 | + // (the connection has it, and the still-inline config env also works). A | |
| 912 | + // single bad integration is logged and skipped, never failing the caller. | |
| 913 | + const reconcileStdioConnections = () => | |
| 914 | + Effect.gen(function* () { | |
| 915 | + const integrations = yield* ctx.core.integrations.list(); | |
| 916 | + for (const integration of integrations) { | |
| 917 | + if (integration.kind !== MCP_PLUGIN_ID) continue; | |
| 918 | + yield* Effect.gen(function* () { | |
| 919 | + const record = yield* ctx.core.integrations.get(integration.slug); | |
| 920 | + const config = record ? parseMcpIntegrationConfig(record.config) : null; | |
| 921 | + if (!config || config.transport !== "stdio") return; | |
| 922 | + | |
| 923 | + // Only heal LEGACY pre-revamp stdio rows (no declared methods). | |
| 924 | + // A new-shape row declares its auth method and owns its connection | |
| 925 | + // lifecycle: a zero-connection one is INTENTIONAL — it declared | |
| 926 | + // secret env vars (the UI "declare then connect" path) and is | |
| 927 | + // awaiting its secrets. Auto-creating a no-auth connection here | |
| 928 | + // would run a secret-needing server without its secret and clobber | |
| 929 | + // that flow. | |
| 930 | + if (config.authenticationTemplate !== undefined) return; | |
| 931 | + | |
| 932 | + const connections = yield* ctx.connections.list({ | |
| 933 | + integration: integration.slug, | |
| 934 | + }); | |
| 935 | + if (connections.length > 0) return; // already connectable — nothing to heal. | |
| 936 | + | |
| 937 | + const inlineEnv = config.env ?? {}; | |
| 938 | + const envVars = Object.keys(inlineEnv); | |
| 939 | + const hasEnv = envVars.length > 0; | |
| 940 | + | |
| 941 | + yield* ctx.connections.create({ | |
| 942 | + owner: "org", | |
| 943 | + name: ConnectionName.make("default"), | |
| 944 | + integration: integration.slug, | |
| 945 | + template: AuthTemplateSlug.make(hasEnv ? STDIO_ENV_TEMPLATE : "none"), | |
| 946 | + values: hasEnv ? { ...inlineEnv } : {}, | |
| 947 | + }); | |
| 948 | + | |
| 949 | + // The secret is now on the connection: canonicalize this legacy | |
| 950 | + // config (declare the var names as a stdio_env method, dropping the | |
| 951 | + // inline plaintext values; or `none` for a no-secret server). | |
| 952 | + const nextConfig: McpIntegrationConfigType = { | |
| 953 | + transport: "stdio", | |
| 954 | + command: config.command, | |
| 955 | + args: config.args, | |
| 956 | + cwd: config.cwd, | |
| 957 | + authenticationTemplate: hasEnv | |
| 958 | + ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] | |
| 959 | + : [{ slug: "none", kind: "none" }], | |
| 960 | + }; | |
| 961 | + yield* ctx.core.integrations.update(integration.slug, { config: nextConfig }); | |
| 962 | + }).pipe( | |
| 963 | + Effect.catch((cause) => | |
| 964 | + Effect.logWarning( | |
| 965 | + `mcp: failed healing stdio connection for "${integration.slug}"`, | |
| 966 | + cause, | |
| 967 | + ), | |
| 968 | + ), | |
| 969 | + Effect.withSpan("mcp.plugin.reconcile_stdio_connection", { | |
| 970 | + attributes: { "mcp.integration.slug": String(integration.slug) }, | |
| 971 | + }), | |
| 972 | + ); | |
| 973 | + } | |
| 974 | + }).pipe(Effect.withSpan("mcp.plugin.reconcile_stdio_connections")); | |
| 975 | + | |
| 791 | 976 | const removeServer = (slug: string) => |
| 792 | 977 | Effect.gen(function* () { |
| 793 | 978 | const integration = slugFrom(slug); |
| @@ -919,6 +1104,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 125 unmodified lines | ||
| 919 | 1104 | probeEndpoint, |
| 920 | 1105 | addServer, |
| 921 | 1106 | removeServer, |
| 1107 | + reconcileStdioConnections, | |
| 922 | 1108 | getServer, |
| 923 | 1109 | configureServer, |
| 924 | 1110 | configureAuth, |
| @@ -1262,6 +1448,9 @@ export interface McpPluginExtension { · 337 unmodified lines | ||
| 1262 | 1448 | McpExtensionFailure | IntegrationAlreadyExistsError |
| 1263 | 1449 | >; |
| 1264 | 1450 | readonly removeServer: (slug: string) => Effect.Effect<void, McpExtensionFailure>; |
| 1451 | + /** Ensure every stdio integration has its default connection (migrating any | |
| 1452 | + * legacy inline env into the secret store). Idempotent; safe to run at boot. */ | |
| 1453 | + readonly reconcileStdioConnections: () => Effect.Effect<void, McpExtensionFailure>; | |
| 1265 | 1454 | readonly getServer: ( |
| 1266 | 1455 | slug: string, |
| 1267 | 1456 | ) => Effect.Effect< |
| @@ -54,7 +54,26 @@ export const McpOAuthMethod = Schema.Struct({ · 53 unmodified lines | ||
| 54 | 54 | }); |
| 55 | 55 | export type McpOAuthMethod = typeof McpOAuthMethod.Type; |
| 56 | 56 | |
| 57 | −export const McpAuthMethod = Schema.Union([NoneAuthMethod, ApiKeyAuthMethod, McpOAuthMethod]); | |
| 57 | +/** Stdio env credential: the named environment variables a stdio server needs | |
| 58 | + * (often API keys / tokens). A connection supplies one secret value per `var`, | |
| 59 | + * keyed by the var name; at launch the connector injects them into the | |
| 60 | + * subprocess env. The VALUES live in the secret store as the connection's | |
| 61 | + * inputs, never in this config blob — that is the "properly store auth" half | |
| 62 | + * of the stdio model, mirroring how remote apikey methods keep their secrets | |
| 63 | + * on the connection rather than the integration. */ | |
| 64 | +export const McpStdioEnvMethod = Schema.Struct({ | |
| 65 | + slug: Schema.String, | |
| 66 | + kind: Schema.Literal("stdio_env"), | |
| 67 | + vars: Schema.Array(Schema.String), | |
| 68 | +}); | |
| 69 | +export type McpStdioEnvMethod = typeof McpStdioEnvMethod.Type; | |
| 70 | + | |
| 71 | +export const McpAuthMethod = Schema.Union([ | |
| 72 | + NoneAuthMethod, | |
| 73 | + ApiKeyAuthMethod, | |
| 74 | + McpOAuthMethod, | |
| 75 | + McpStdioEnvMethod, | |
| 76 | +]); | |
| 58 | 77 | export type McpAuthMethod = typeof McpAuthMethod.Type; |
| 59 | 78 | |
| 60 | 79 | /** Single-method `auth` shorthand on `addServer` — agent convenience for the |
| @@ -180,10 +199,20 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 119 unmodified lines | ||
| 180 | 199 | command: Schema.String, |
| 181 | 200 | /** Arguments to the command */ |
| 182 | 201 | args: Schema.optional(Schema.Array(Schema.String)), |
| 183 | − /** Environment variables */ | |
| 202 | + /** Static, non-credential environment variables injected verbatim into the | |
| 203 | + * subprocess. Secret env (API keys / tokens) is NOT stored here — it is | |
| 204 | + * declared as a `stdio_env` method in `authenticationTemplate` and its | |
| 205 | + * values live on the connection. Optional + legacy: pre-revamp stdio | |
| 206 | + * integrations stored their (then-plaintext) env here, so it stays | |
| 207 | + * decodable. */ | |
| 184 | 208 | env: Schema.optional(StringMap), |
| 185 | 209 | /** Working directory */ |
| 186 | 210 | cwd: Schema.optional(Schema.String), |
| 211 | + /** Declared auth methods — a single `stdio_env` method naming the secret env | |
| 212 | + * vars, or `none`. A connection's `template` picks one by slug, exactly as | |
| 213 | + * for remote servers. Optional so pre-revamp stdio configs (which had no | |
| 214 | + * methods) still decode; absence is treated as no declared secret env. */ | |
| 215 | + authenticationTemplate: Schema.optional(Schema.Array(McpAuthMethod)), | |
| 187 | 216 | }); |
| 188 | 217 | export type McpStdioIntegrationConfig = typeof McpStdioIntegrationConfig.Type; |
| 189 | 218 | |
| @@ -151,6 +151,9 @@ function isOnePasswordRegistered( · 150 unmodified lines | ||
| 151 | 151 | function PasteCredentialInputs(props: { |
| 152 | 152 | readonly inputs: readonly CredentialInput[]; |
| 153 | 153 | readonly singleInput: boolean; |
| 154 | + /** Force the per-input label even for a single input (env vars name their | |
| 155 | + * field rather than relying on a generic "value / token" placeholder). */ | |
| 156 | + readonly showLabels?: boolean; | |
| 154 | 157 | readonly values: Readonly<Record<string, string>>; |
| 155 | 158 | readonly onChange: (values: Record<string, string>) => void; |
| 156 | 159 | }) { |
| @@ -188,14 +191,20 @@ function PasteCredentialInputs(props: { · 31 unmodified lines | ||
| 188 | 191 | ); |
| 189 | 192 | } |
| 190 | 193 | |
| 194 | + // Past the multi-input grid above, singleInput is always true, so labelled | |
| 195 | + // reduces to showLabels: env vars opt in to naming their single field. | |
| 196 | + const labelled = props.showLabels ?? false; | |
| 191 | 197 | return ( |
| 192 | 198 | <div className="space-y-2"> |
| 193 | 199 | {props.inputs.map((input) => ( |
| 194 | 200 | <div key={input.variable} className="space-y-1"> |
| 201 | + {labelled && ( | |
| 202 | + <Label className="font-mono text-xs text-muted-foreground">{input.label}</Label> | |
| 203 | + )} | |
| 195 | 204 | <Input |
| 196 | 205 | type="password" |
| 197 | 206 | autoComplete="new-password" |
| 198 | − placeholder="paste the value / token" | |
| 207 | + placeholder={labelled ? "secret value" : "paste the value / token"} | |
| 199 | 208 | value={props.values[input.variable] ?? ""} |
| 200 | 209 | onChange={(e: React.ChangeEvent<HTMLInputElement>) => |
| 201 | 210 | props.onChange({ |
| @@ -282,6 +291,13 @@ function OnePasswordItemSelect(props: { · 80 unmodified lines | ||
| 282 | 291 | function CredentialValueFields(props: { |
| 283 | 292 | readonly inputs: readonly CredentialInput[]; |
| 284 | 293 | readonly singleInput: boolean; |
| 294 | + readonly showLabels?: boolean; | |
| 295 | + /** Allow an external provider (1Password) origin. Off for env methods: the | |
| 296 | + * wire's external `from` is keyed under the canonical `token` variable, but | |
| 297 | + * an env credential must be keyed under its env var name, so a 1Password | |
| 298 | + * origin would persist the secret under the wrong key and the subprocess | |
| 299 | + * would launch without it. Paste stays correctly keyed per variable. */ | |
| 300 | + readonly allowExternalProvider?: boolean; | |
| 285 | 301 | readonly values: Readonly<Record<string, string>>; |
| 286 | 302 | readonly onValuesChange: (values: Record<string, string>) => void; |
| 287 | 303 | readonly origin: CredentialOrigin; |
| @@ -290,7 +306,10 @@ function CredentialValueFields(props: { · 2 unmodified lines | ||
| 290 | 306 | readonly onOnePasswordItemIdChange: (value: string) => void; |
| 291 | 307 | }) { |
| 292 | 308 | const providers = useAtomValue(providersAtom); |
| 293 | − const onePasswordAvailable = props.singleInput && isOnePasswordRegistered(providers); | |
| 309 | + const onePasswordAvailable = | |
| 310 | + props.singleInput && | |
| 311 | + (props.allowExternalProvider ?? true) && | |
| 312 | + isOnePasswordRegistered(providers); | |
| 294 | 313 | |
| 295 | 314 | return ( |
| 296 | 315 | <div className="space-y-3"> |
| @@ -326,6 +345,7 @@ function CredentialValueFields(props: { · 29 unmodified lines | ||
| 326 | 345 | <PasteCredentialInputs |
| 327 | 346 | inputs={props.inputs} |
| 328 | 347 | singleInput={props.singleInput} |
| 348 | + showLabels={props.showLabels} | |
| 329 | 349 | values={props.values} |
| 330 | 350 | onChange={props.onValuesChange} |
| 331 | 351 | /> |
| @@ -855,6 +875,13 @@ function AddAccountModalView(props: AddAccountModalProps) { · 523 unmodified lines | ||
| 855 | 875 | })); |
| 856 | 876 | }, [method]); |
| 857 | 877 | const singleInput = credentialInputs.length <= 1; |
| 878 | + // A stdio env method: every credential is injected as an environment variable | |
| 879 | + // (carrier "env"). These read as named secrets, not an HTTP placement, so we | |
| 880 | + // skip the `KEY=••••••` placement preview and label each field by its var. | |
| 881 | + const isEnvMethod = | |
| 882 | + method != null && | |
| 883 | + method.placements.length > 0 && | |
| 884 | + method.placements.every((p) => p.carrier === "env"); | |
| 858 | 885 | // DCR-capable: the integration advertises dynamic registration (MCP oauth2), |
| 859 | 886 | // OR carries a discovery URL we can probe at connect time. When DCR-capable |
| 860 | 887 | // and not yet fallen back, we skip the app picker entirely (Option A). |
| @@ -1427,13 +1454,18 @@ function AddAccountModalView(props: AddAccountModalProps) { · 566 unmodified lines | ||
| 1427 | 1454 | value={methodId} |
| 1428 | 1455 | className="mt-0 min-w-0 space-y-5 rounded-md border border-border/60 bg-muted/15 p-4" |
| 1429 | 1456 | > |
| 1430 | − {method?.placements && method.placements.length > 0 && singleInput ? ( | |
| 1431 | − <div className="flex flex-wrap gap-x-3.5 gap-y-1"> | |
| 1432 | − {method.placements.map((placement, i: number) => ( | |
| 1433 | − <PlacementLine key={i} placement={placement} /> | |
| 1434 | − ))} | |
| 1435 | − </div> | |
| 1436 | − ) : null} | |
| 1457 | + {method?.placements && !isEnvMethod && singleInput | |
| 1458 | + ? (() => { | |
| 1459 | + const shown = method.placements.filter((p) => p.carrier !== "env"); | |
| 1460 | + return shown.length > 0 ? ( | |
| 1461 | + <div className="flex flex-wrap gap-x-3.5 gap-y-1"> | |
| 1462 | + {shown.map((placement, i: number) => ( | |
| 1463 | + <PlacementLine key={i} placement={placement} /> | |
| 1464 | + ))} | |
| 1465 | + </div> | |
| 1466 | + ) : null; | |
| 1467 | + })() | |
| 1468 | + : null} | |
| 1437 | 1469 | |
| 1438 | 1470 | {!isNoAuth && ( |
| 1439 | 1471 | <div className="space-y-2"> |
| @@ -1540,6 +1572,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 100 unmodified lines | ||
| 1540 | 1572 | <CredentialValueFields |
| 1541 | 1573 | inputs={credentialInputs} |
| 1542 | 1574 | singleInput={singleInput} |
| 1575 | + showLabels={isEnvMethod} | |
| 1576 | + allowExternalProvider={!isEnvMethod} | |
| 1543 | 1577 | values={values} |
| 1544 | 1578 | onValuesChange={setValues} |
| 1545 | 1579 | origin={credentialOrigin} |
| @@ -0,0 +1,102 @@ | ||
| 1 | +import * as React from "react"; | |
| 2 | +import { PlusIcon, XIcon } from "lucide-react"; | |
| 3 | + | |
| 4 | +import { cn } from "../lib/utils"; | |
| 5 | +import { Badge } from "./badge"; | |
| 6 | +import { Button } from "./button"; | |
| 7 | +import { Input } from "./input"; | |
| 8 | + | |
| 9 | +// --------------------------------------------------------------------------- | |
| 10 | +// TagInput — type a value and click + (or press Enter) to add it; the added | |
| 11 | +// values stack as removable chips BELOW the input. The × removes one, | |
| 12 | +// Backspace on an empty field removes the last, and pasting a separated list | |
| 13 | +// adds them all. Values are de-duplicated and order-preserving. Used e.g. to | |
| 14 | +// declare the env var NAMES a stdio MCP server needs. | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | + | |
| 17 | +export function TagInput(props: { | |
| 18 | + readonly values: readonly string[]; | |
| 19 | + readonly onChange: (next: string[]) => void; | |
| 20 | + readonly placeholder?: string; | |
| 21 | + readonly className?: string; | |
| 22 | + /** Split pattern for a committed token / paste. Defaults to runs of | |
| 23 | + * whitespace and commas. */ | |
| 24 | + readonly separator?: RegExp; | |
| 25 | +}) { | |
| 26 | + const { values, onChange } = props; | |
| 27 | + const [draft, setDraft] = React.useState(""); | |
| 28 | + const separator = props.separator ?? /[\s,]+/; | |
| 29 | + | |
| 30 | + const commit = (raw: string) => { | |
| 31 | + const tokens = raw | |
| 32 | + .split(separator) | |
| 33 | + .map((s) => s.trim()) | |
| 34 | + .filter(Boolean); | |
| 35 | + if (tokens.length === 0) return; | |
| 36 | + const next = [...values]; | |
| 37 | + for (const token of tokens) if (!next.includes(token)) next.push(token); | |
| 38 | + onChange(next); | |
| 39 | + setDraft(""); | |
| 40 | + }; | |
| 41 | + | |
| 42 | + return ( | |
| 43 | + <div className={cn("space-y-2", props.className)}> | |
| 44 | + <div className="flex items-center gap-2"> | |
| 45 | + <Input | |
| 46 | + value={draft} | |
| 47 | + onChange={(e) => setDraft(e.target.value)} | |
| 48 | + onKeyDown={(e) => { | |
| 49 | + if (e.key === "Enter" || e.key === ",") { | |
| 50 | + e.preventDefault(); | |
| 51 | + commit(draft); | |
| 52 | + } else if (e.key === "Backspace" && draft === "" && values.length > 0) { | |
| 53 | + onChange(values.slice(0, -1)); | |
| 54 | + } | |
| 55 | + }} | |
| 56 | + onPaste={(e) => { | |
| 57 | + const text = e.clipboardData.getData("text"); | |
| 58 | + if (separator.test(text)) { | |
| 59 | + e.preventDefault(); | |
| 60 | + commit(text); | |
| 61 | + } | |
| 62 | + }} | |
| 63 | + // Don't lose a typed-but-unsubmitted value when focus leaves (e.g. the | |
| 64 | + // user clicks the form's submit instead of + / Enter). | |
| 65 | + onBlur={() => commit(draft)} | |
| 66 | + placeholder={props.placeholder} | |
| 67 | + className="flex-1 font-mono" | |
| 68 | + /> | |
| 69 | + <Button | |
| 70 | + type="button" | |
| 71 | + variant="outline" | |
| 72 | + size="icon" | |
| 73 | + aria-label="Add" | |
| 74 | + disabled={draft.trim().length === 0} | |
| 75 | + onClick={() => commit(draft)} | |
| 76 | + > | |
| 77 | + <PlusIcon /> | |
| 78 | + </Button> | |
| 79 | + </div> | |
| 80 | + | |
| 81 | + {values.length > 0 && ( | |
| 82 | + <div className="flex flex-wrap gap-1.5"> | |
| 83 | + {values.map((value) => ( | |
| 84 | + <Badge key={value} variant="secondary" className="gap-0.5 py-0.5 pr-1 font-mono"> | |
| 85 | + {value} | |
| 86 | + <Button | |
| 87 | + type="button" | |
| 88 | + variant="ghost" | |
| 89 | + size="icon-sm" | |
| 90 | + aria-label={`Remove ${value}`} | |
| 91 | + className="size-4 rounded-full hover:bg-transparent hover:text-foreground" | |
| 92 | + onClick={() => onChange(values.filter((v) => v !== value))} | |
| 93 | + > | |
| 94 | + <XIcon /> | |
| 95 | + </Button> | |
| 96 | + </Badge> | |
| 97 | + ))} | |
| 98 | + </div> | |
| 99 | + )} | |
| 100 | + </div> | |
| 101 | + ); | |
| 102 | +} | |
| @@ -18,7 +18,7 @@ · 17 unmodified lines | ||
| 18 | 18 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 19 | 19 | import type { AuthMethodDescriptor } from "@executor-js/sdk/shared"; |
| 20 | 20 | |
| 21 | −export type Carrier = "header" | "query"; | |
| 21 | +export type Carrier = "header" | "query" | "env"; | |
| 22 | 22 | |
| 23 | 23 | export interface Placement { |
| 24 | 24 | readonly carrier: Carrier; |
| @@ -87,6 +87,9 @@ export function placementLabel(placement: Placement): string { · 62 unmodified lines | ||
| 87 | 87 | if (placement.carrier === "header") { |
| 88 | 88 | return `${placement.name || "Authorization"} header`; |
| 89 | 89 | } |
| 90 | + if (placement.carrier === "env") { | |
| 91 | + return `${placement.name || "TOKEN"} env var`; | |
| 92 | + } | |
| 90 | 93 | return `${placement.name || "api_key"} query param`; |
| 91 | 94 | } |
| 92 | 95 | |
| @@ -100,7 +103,9 @@ export function PlacementLine(props: { readonly placement: Placement; readonly m · 7 unmodified lines | ||
| 100 | 103 | const lead = |
| 101 | 104 | placement.carrier === "header" |
| 102 | 105 | ? `${placement.name || "Authorization"}: ` |
| 103 | − : `?${placement.name || "api_key"}=`; | |
| 106 | + : placement.carrier === "env" | |
| 107 | + ? `${placement.name || "TOKEN"}=` | |
| 108 | + : `?${placement.name || "api_key"}=`; | |
| 104 | 109 | return ( |
| 105 | 110 | <span className="inline-flex items-center font-mono text-xs text-muted-foreground"> |
| 106 | 111 | {lead} |
| @@ -113,8 +113,13 @@ export const wirePlacementsFromEditor = ( · 112 unmodified lines | ||
| 113 | 113 | ): readonly AuthPlacement[] => { |
| 114 | 114 | const variables = assignVariables(placements); |
| 115 | 115 | return placements |
| 116 | − .filter((p: Placement) => p.name.trim().length > 0 || p.literal !== undefined) | |
| 117 | − .map((p: Placement): AuthPlacement => { | |
| 116 | + .filter( | |
| 117 | + // `env` placements belong to stdio integrations, not the HTTP custom- | |
| 118 | + // method editor this codec serializes; they never reach the wire here. | |
| 119 | + (p: Placement): p is Placement & { readonly carrier: "header" | "query" } => | |
| 120 | + p.carrier !== "env" && (p.name.trim().length > 0 || p.literal !== undefined), | |
| 121 | + ) | |
| 122 | + .map((p): AuthPlacement => { | |
| 118 | 123 | const variable = variables.get(p); |
| 119 | 124 | return { |
| 120 | 125 | carrier: p.carrier, |
| @@ -2,8 +2,11 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | domainLabelFromUrl, |
| 5 | + humanizeStdioToken, | |
| 6 | + integrationDisplayNameFromStdio, | |
| 5 | 7 | integrationDisplayNameFromUrl, |
| 6 | 8 | pascalCaseDomainLabel, |
| 9 | + stdioPackageToken, | |
| 7 | 10 | } from "./integration-identity"; |
| 8 | 11 | |
| 9 | 12 | describe("integration identity URL display names", () => { |
| @@ -22,3 +25,43 @@ describe("integration identity URL display names", () => { · 12 unmodified lines | ||
| 22 | 25 | ); |
| 23 | 26 | }); |
| 24 | 27 | }); |
| 28 | + | |
| 29 | +describe("integration identity stdio display names", () => { | |
| 30 | + it("picks the package spec past the runner and its flags", () => { | |
| 31 | + expect(stdioPackageToken("npx", ["-y", "@modelcontextprotocol/server-github"])).toBe( | |
| 32 | + "@modelcontextprotocol/server-github", | |
| 33 | + ); | |
| 34 | + expect(stdioPackageToken("pnpm", ["dlx", "mcp-server-time"])).toBe("mcp-server-time"); | |
| 35 | + expect(stdioPackageToken("uvx", ["mcp-server-time"])).toBe("mcp-server-time"); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it("uses the command itself only when it is not a generic runner", () => { | |
| 39 | + expect(stdioPackageToken("npx", [])).toBeNull(); | |
| 40 | + expect(stdioPackageToken("my-mcp-server", [])).toBe("my-mcp-server"); | |
| 41 | + }); | |
| 42 | + | |
| 43 | + it("strips npm scope, MCP affixes, versions, and paths when humanizing", () => { | |
| 44 | + expect(humanizeStdioToken("@modelcontextprotocol/server-github")).toBe("Github"); | |
| 45 | + expect(humanizeStdioToken("mcp-server-sequential-thinking")).toBe("Sequential Thinking"); | |
| 46 | + expect(humanizeStdioToken("@scope/notion-mcp@1.2.3")).toBe("Notion"); | |
| 47 | + expect(humanizeStdioToken("./build/index.js")).toBe("Index"); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it("derives a humanized name with the kind suffix, ignoring trailing path args", () => { | |
| 51 | + expect( | |
| 52 | + integrationDisplayNameFromStdio("npx", ["-y", "@modelcontextprotocol/server-github"], "MCP"), | |
| 53 | + ).toBe("Github MCP"); | |
| 54 | + expect( | |
| 55 | + integrationDisplayNameFromStdio( | |
| 56 | + "npx", | |
| 57 | + ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/work"], | |
| 58 | + "MCP", | |
| 59 | + ), | |
| 60 | + ).toBe("Filesystem MCP"); | |
| 61 | + expect(integrationDisplayNameFromStdio("uvx", ["mcp-server-time"], "MCP")).toBe("Time MCP"); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it("returns null for a bare runner so callers can fall back to the command", () => { | |
| 65 | + expect(integrationDisplayNameFromStdio("npx", [], "MCP")).toBeNull(); | |
| 66 | + }); | |
| 67 | +}); | |
| @@ -46,6 +46,96 @@ export function integrationDisplayNameFromUrl(url: string, sourceKind: string): · 45 unmodified lines | ||
| 46 | 46 | return displayLabel ? `${displayLabel} ${sourceKind}` : null; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | +// Package runners whose own name is never the integration name: the meaningful | |
| 50 | +// token is the package or module they execute, not the runner itself. | |
| 51 | +const STDIO_RUNNERS = new Set([ | |
| 52 | + "npx", | |
| 53 | + "bunx", | |
| 54 | + "pnpm", | |
| 55 | + "yarn", | |
| 56 | + "npm", | |
| 57 | + "uvx", | |
| 58 | + "uv", | |
| 59 | + "pipx", | |
| 60 | + "bun", | |
| 61 | + "deno", | |
| 62 | + "node", | |
| 63 | + "python", | |
| 64 | + "python3", | |
| 65 | +]); | |
| 66 | + | |
| 67 | +// Subcommands that sit between a runner and its package: `pnpm dlx <pkg>`, | |
| 68 | +// `npm exec <pkg>`, `uv run <pkg>`, `deno run <spec>`. | |
| 69 | +const STDIO_RUNNER_SUBCOMMANDS = new Set(["dlx", "exec", "run", "x", "--"]); | |
| 70 | + | |
| 71 | +/** | |
| 72 | + * Picks the package/module spec from a stdio launch command: the first | |
| 73 | + * positional argument that isn't a runner flag or subcommand, falling back to | |
| 74 | + * the command itself when it isn't a generic runner (a server invoked | |
| 75 | + * directly). Returns `null` when only a bare runner is present. | |
| 76 | + */ | |
| 77 | +export function stdioPackageToken(command: string, args: readonly string[]): string | null { | |
| 78 | + for (const raw of args) { | |
| 79 | + const arg = raw.trim(); | |
| 80 | + if (!arg || arg.startsWith("-")) continue; // skip flags like -y, --yes | |
| 81 | + if (STDIO_RUNNER_SUBCOMMANDS.has(arg)) continue; // skip dlx / exec / run | |
| 82 | + return arg; | |
| 83 | + } | |
| 84 | + const cmd = command.trim(); | |
| 85 | + if (!cmd || STDIO_RUNNERS.has(cmd.toLowerCase())) return null; | |
| 86 | + return cmd; | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** | |
| 90 | + * Reduces a package/module spec to human words: drops an npm scope, a version | |
| 91 | + * or tag suffix, a path and file extension, and the noise affixes MCP packages | |
| 92 | + * conventionally carry (`mcp-server-`, `server-`, `-mcp`), then title-cases. | |
| 93 | + * Returns `null` when nothing alphanumeric survives. | |
| 94 | + */ | |
| 95 | +export function humanizeStdioToken(token: string): string | null { | |
| 96 | + let name = token.trim(); | |
| 97 | + if (name.startsWith("@")) { | |
| 98 | + // Scoped npm package: @modelcontextprotocol/server-github → server-github. | |
| 99 | + name = name.split("/").pop() ?? name; | |
| 100 | + } else if (/[\\/]/.test(name)) { | |
| 101 | + // A filesystem path (node ./build/index.js) → its base filename. | |
| 102 | + name = name.split(/[\\/]/).pop() ?? name; | |
| 103 | + name = name.replace(/\.[a-z0-9]+$/i, ""); // strip extension | |
| 104 | + } | |
| 105 | + name = name.replace(/@[^@/]*$/, "") || name; // drop a trailing @version / @tag | |
| 106 | + name = name | |
| 107 | + .replace(/^mcp[-_]server[-_]/i, "") | |
| 108 | + .replace(/^mcp[-_]/i, "") | |
| 109 | + .replace(/^server[-_]/i, "") | |
| 110 | + .replace(/[-_]mcp[-_]server$/i, "") | |
| 111 | + .replace(/[-_]mcp$/i, "") | |
| 112 | + .replace(/[-_]server$/i, ""); | |
| 113 | + const words = name | |
| 114 | + .split(/[^a-z0-9]+/i) | |
| 115 | + .map((word) => word.trim()) | |
| 116 | + .filter(Boolean); | |
| 117 | + if (words.length === 0) return null; | |
| 118 | + return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" "); | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** | |
| 122 | + * Derives a display-name candidate from a stdio launch command by extracting | |
| 123 | + * the package/module being run and humanizing it, e.g. | |
| 124 | + * `npx -y @modelcontextprotocol/server-github` → `"Github MCP"`, | |
| 125 | + * `uvx mcp-server-time` → `"Time MCP"`, `node ./build/index.js` → `"Index MCP"`. | |
| 126 | + * Returns `null` when nothing meaningful can be extracted, so callers can fall | |
| 127 | + * back to the raw command. | |
| 128 | + */ | |
| 129 | +export function integrationDisplayNameFromStdio( | |
| 130 | + command: string, | |
| 131 | + args: readonly string[], | |
| 132 | + sourceKind: string, | |
| 133 | +): string | null { | |
| 134 | + const token = stdioPackageToken(command, args); | |
| 135 | + const label = token ? humanizeStdioToken(token) : null; | |
| 136 | + return label ? `${label} ${sourceKind}` : null; | |
| 137 | +} | |
| 138 | + | |
| 49 | 139 | // --------------------------------------------------------------------------- |
| 50 | 140 | // Hook — owns the name + namespace state with namespace auto-derivation |
| 51 | 141 | // --------------------------------------------------------------------------- |