History

Auto-connect stdio MCP servers; store env as connection secrets (#1129)

* 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.

Rhys Sullivan committed 4 months ago

Commit 44fff2f1 parent add2e4023 files · +914 −102Browse files

Unified · Split

23 files
  1. .github/workflows/ci.yml +38 −0
  2. apps/local/src/executor.ts +21 −0
  3. e2e/local/fixtures/stdio-mcp-server.mjs +104 −0
  4. e2e/local/local-server.ts +3 −1
  5. e2e/local/stdio-mcp.test.ts +145 −0
  6. e2e/package.json +1 −0
  7. e2e/vitest.config.ts +9 −6
  8. packages/core/api/src/integrations/api.ts +1 −1
  9. packages/core/sdk/src/integration.ts +4 −3
  10. packages/plugins/mcp/src/api/group.ts +4 −0
  11. packages/plugins/mcp/src/api/handlers.test.ts +1 −0
  12. packages/plugins/mcp/src/api/handlers.ts +2 −0
  13. packages/plugins/mcp/src/react/AddMcpSource.tsx +27 −62
  14. packages/plugins/mcp/src/react/McpAccountsPanel.tsx +7 −4
  15. packages/plugins/mcp/src/react/auth-method-config.ts +26 −1
  16. packages/plugins/mcp/src/sdk/plugin.ts +198 −9
  17. packages/plugins/mcp/src/sdk/types.ts +31 −2
  18. packages/react/src/components/add-account-modal.tsx +43 −9
  19. packages/react/src/components/tag-input.tsx +102 −0
  20. packages/react/src/lib/auth-placements.tsx +7 −2
  21. packages/react/src/lib/shared-auth-method-codec.ts +7 −2
  22. packages/react/src/plugins/integration-identity.test.ts +43 −0
  23. packages/react/src/plugins/integration-identity.tsx +90 −0
.github/workflows/ci.yml+38−0
@@ -74,6 +74,44 @@ jobs: · 73 unmodified lines
74 74
75 - run: bun run test75 - 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 desktop-smoke:115 desktop-smoke:
78 name: Desktop smoke build116 name: Desktop smoke build
79 runs-on: ubuntu-latest117 runs-on: ubuntu-latest
apps/local/src/executor.ts+21−0
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
14} from "@executor-js/sdk";14} from "@executor-js/sdk";
15import { collectTables } from "@executor-js/api/server";15import { collectTables } from "@executor-js/api/server";
16import { loadPluginsFromJsonc } from "@executor-js/config";16import { loadPluginsFromJsonc } from "@executor-js/config";
17import type { McpPluginExtension } from "@executor-js/plugin-mcp";
17 18
18import executorConfig from "../executor.config";19import executorConfig from "../executor.config";
19import { localDataMigrations } from "./db/data-migrations";20import { 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 return { executor, plugins };253 return { executor, plugins };
233 }),254 }),
234 );255 );
e2e/local/fixtures/stdio-mcp-server.mjs+104−0
@@ -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
15import { createInterface } from "node:readline";
16
17const send = (message) => {
18 process.stdout.write(`${JSON.stringify(message)}\n`);
19};
20
21const 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
33if (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
41const 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
91const rl = createInterface({ input: process.stdin });
92rl.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});
e2e/local/local-server.ts+3−1
@@ -61,7 +61,9 @@ export const withLocalServer = ( · 60 unmodified lines
61 markFocus(runDir, "terminal");61 markFocus(runDir, "terminal");
62 const snapshot = await term.screen.waitUntil(62 const snapshot = await term.screen.waitUntil(
63 (current) => TOKEN_URL.test(current.text),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 const url = TOKEN_URL.exec(snapshot.text)?.[0];68 const url = TOKEN_URL.exec(snapshot.text)?.[0];
67 if (!url) {69 if (!url) {
e2e/local/stdio-mcp.test.ts+145−0
@@ -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.
17import { fileURLToPath } from "node:url";
18
19import { expect } from "@effect/vitest";
20import { Effect } from "effect";
21import { HttpApiClient } from "effect/unstable/httpapi";
22import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
23import { composePluginApi } from "@executor-js/api/server";
24import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
25import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
26
27import { scenario } from "../src/scenario";
28import { Cli, RunDir } from "../src/services";
29import { withLocalServer } from "./local-server";
30
31const api = composePluginApi([mcpHttpPlugin()] as const);
32
33const 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.
38const SECRET = "s3cr3t-from-the-vault";
39
40scenario(
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);
e2e/package.json+1−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 "test:selfhost": "vitest run --project selfhost",10 "test:selfhost": "vitest run --project selfhost",
11 "test:selfhost-docker": "vitest run --project selfhost-docker",11 "test:selfhost-docker": "vitest run --project selfhost-docker",
12 "test:cloudflare": "vitest run --project cloudflare",12 "test:cloudflare": "vitest run --project cloudflare",
13 "test:local": "vitest run --project local",
13 "test:watch": "vitest",14 "test:watch": "vitest",
14 "ports": "bun scripts/ports.ts",15 "ports": "bun scripts/ports.ts",
15 "summary": "bun scripts/summary.ts",16 "summary": "bun scripts/summary.ts",
e2e/vitest.config.ts+9−6
@@ -79,15 +79,18 @@ export default defineConfig({ · 78 unmodified lines
79 }),79 }),
80 // The single-user local app. Each scenario launches its OWN `executor80 // The single-user local app. Each scenario launches its OWN `executor
81 // web` via the CLI on a throwaway data dir + an OS-assigned port, so81 // web` via the CLI on a throwaway data dir + an OS-assigned port, so
82 // there is no shared instance and scenarios are independent — file82 // there is no shared instance and scenarios are independent. Files run
83 // parallelism is ON. No globalSetup (nothing shared to boot). Only83 // SERIALLY (like every other server-booting project): a cold `executor
84 // local/** scenarios. Not part of the default `npm run test` chain; run84 // web` boot runs vite's optimizeDeps, and several booting at once on a
85 // with `vitest run --project local`.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 project("local", {89 project("local", {
87 include: ["local/**/*.test.ts"],90 include: ["local/**/*.test.ts"],
88 globalSetup: [],91 globalSetup: [],
89 fileParallelism: true,92 fileParallelism: false,
90 testTimeout: 180_000,93 testTimeout: 240_000,
91 }),94 }),
92 // The supervised CLI daemon inside a guest VM, one project per OS. The95 // The supervised CLI daemon inside a guest VM, one project per OS. The
93 // globalsetup provisions a VM, `executor service install`s the daemon, and96 // globalsetup provisions a VM, `executor service install`s the daemon, and
packages/core/api/src/integrations/api.ts+1−1
@@ -31,7 +31,7 @@ const IntegrationParams = { slug: IntegrationSlug }; · 30 unmodified lines
31/** Where a credential value is carried — mirrors the SDK's31/** Where a credential value is carried — mirrors the SDK's
32 * `AuthPlacementDescriptor`. */32 * `AuthPlacementDescriptor`. */
33const PlacementDescriptor = Schema.Struct({33const PlacementDescriptor = Schema.Struct({
34 carrier: Schema.Literals(["header", "query"]),34 carrier: Schema.Literals(["header", "query", "env"]),
35 name: Schema.String,35 name: Schema.String,
36 prefix: Schema.String,36 prefix: Schema.String,
37 /** Input variable this placement renders from (absent ⇒ `token`). Without37 /** Input variable this placement renders from (absent ⇒ `token`). Without
packages/core/sdk/src/integration.ts+4−3
@@ -24,10 +24,11 @@ export interface IntegrationDisplayDescriptor { · 23 unmodified lines
24 readonly url?: string;24 readonly url?: string;
25}25}
26 26
27/** Where a credential value is carried on the outbound request. Mirrors the27/** Where a credential value is carried. `header`/`query` place it on an
28 * client's `Placement`. */28 * outbound HTTP request (mirrors the client's `Placement`); `env` injects it
29 * as an environment variable for a stdio (subprocess) integration. */
29export interface AuthPlacementDescriptor {30export interface AuthPlacementDescriptor {
30 readonly carrier: "header" | "query";31 readonly carrier: "header" | "query" | "env";
31 readonly name: string;32 readonly name: string;
32 /** Literal prepended to the value (e.g. `"Bearer "`). Empty when bare. */33 /** Literal prepended to the value (e.g. `"Bearer "`). Empty when bare. */
33 readonly prefix: string;34 readonly prefix: string;
packages/plugins/mcp/src/api/group.ts+4−0
@@ -51,6 +51,10 @@ const AddStdioServerPayload = Schema.Struct({ · 50 unmodified lines
51 description: Schema.optional(Schema.String),51 description: Schema.optional(Schema.String),
52 command: Schema.String,52 command: Schema.String,
53 args: Schema.optional(Schema.Array(Schema.String)),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 env: Schema.optional(StringMap),58 env: Schema.optional(StringMap),
55 cwd: Schema.optional(Schema.String),59 cwd: Schema.optional(Schema.String),
56 slug: Schema.optional(Schema.String),60 slug: Schema.optional(Schema.String),
packages/plugins/mcp/src/api/handlers.test.ts+1−0
@@ -26,6 +26,7 @@ const failingExtension: McpPluginExtension = { · 25 unmodified lines
26 probeEndpoint: () => Effect.die(new Error("Not implemented")),26 probeEndpoint: () => Effect.die(new Error("Not implemented")),
27 addServer: () => unused,27 addServer: () => unused,
28 removeServer: () => unused,28 removeServer: () => unused,
29 reconcileStdioConnections: () => unused,
29 getServer: () => Effect.succeed(null),30 getServer: () => Effect.succeed(null),
30 configureServer: () => unused,31 configureServer: () => unused,
31 configureAuth: () => unused,32 configureAuth: () => unused,
packages/plugins/mcp/src/api/handlers.ts+2−0
@@ -36,6 +36,7 @@ const toServerInput = ( · 35 unmodified lines
36 description?: string;36 description?: string;
37 command: string;37 command: string;
38 args?: readonly string[];38 args?: readonly string[];
39 envVars?: readonly string[];
39 env?: Record<string, string>;40 env?: Record<string, string>;
40 cwd?: string;41 cwd?: string;
41 slug?: string;42 slug?: string;
@@ -46,6 +47,7 @@ const toServerInput = ( · 4 unmodified lines
46 description: p.description,47 description: p.description,
47 command: p.command,48 command: p.command,
48 args: p.args ? [...p.args] : undefined,49 args: p.args ? [...p.args] : undefined,
50 envVars: p.envVars ? [...p.envVars] : undefined,
49 env: p.env,51 env: p.env,
50 cwd: p.cwd,52 cwd: p.cwd,
51 slug: p.slug,53 slug: p.slug,
packages/plugins/mcp/src/react/AddMcpSource.tsx+27−62
@@ -18,8 +18,9 @@ import { · 17 unmodified lines
18import { FloatActions } from "@executor-js/react/components/float-actions";18import { FloatActions } from "@executor-js/react/components/float-actions";
19import { Input } from "@executor-js/react/components/input";19import { Input } from "@executor-js/react/components/input";
20import { Spinner } from "@executor-js/react/components/spinner";20import { Spinner } from "@executor-js/react/components/spinner";
21import { Textarea } from "@executor-js/react/components/textarea";21import { TagInput } from "@executor-js/react/components/tag-input";
22import {22import {
23 integrationDisplayNameFromStdio,
23 integrationDisplayNameFromUrl,24 integrationDisplayNameFromUrl,
24 slugifyNamespace,25 slugifyNamespace,
25 IntegrationIdentityFields,26 IntegrationIdentityFields,
@@ -48,14 +49,6 @@ import { mcpPresets, type McpPreset } from "../sdk/presets"; · 22 unmodified lines
48// the user can add alternate methods (e.g. an API key alongside OAuth, or a49// the user can add alternate methods (e.g. an API key alongside OAuth, or a
49// declared method on a server that advertises none).50// declared method on a server that advertises none).
50 51
51const STDIO_ENV_ESCAPE_REPLACEMENTS: Readonly<Record<string, string>> = {
52 "\\": "\\",
53 n: "\n",
54 r: "\r",
55 t: "\t",
56 '"': '"',
57};
58
59// ---------------------------------------------------------------------------52// ---------------------------------------------------------------------------
60// Preset lookup53// Preset lookup
61// ---------------------------------------------------------------------------54// ---------------------------------------------------------------------------
@@ -65,6 +58,19 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 3 unmodified lines
65 return mcpPresets.find((p) => p.id === id);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.
63function 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// State machine (remote flow)75// State machine (remote flow)
70// ---------------------------------------------------------------------------76// ---------------------------------------------------------------------------
@@ -180,9 +186,12 @@ export default function AddMcpSource(props: { · 109 unmodified lines
180 const [stdioArgs, setStdioArgs] = useState(186 const [stdioArgs, setStdioArgs] = useState(
181 isStdioPreset && preset.args ? preset.args.join(" ") : "",187 isStdioPreset && preset.args ? preset.args.join(" ") : "",
182 );188 );
183 const [stdioEnv, setStdioEnv] = useState("");189 const [stdioEnvVars, setStdioEnvVars] = useState<string[]>([]);
184 const stdioIdentity = useIntegrationIdentity({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 const [stdioAdding, setStdioAdding] = useState(false);196 const [stdioAdding, setStdioAdding] = useState(false);
188 const [stdioError, setStdioError] = useState<string | null>(null);197 const [stdioError, setStdioError] = useState<string | null>(null);
@@ -341,47 +350,6 @@ export default function AddMcpSource(props: { · 152 unmodified lines
341 350
342 // ---- Stdio actions ----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 const handleAddStdio = useCallback(async () => {353 const handleAddStdio = useCallback(async () => {
386 const cmd = stdioCommand.trim();354 const cmd = stdioCommand.trim();
387 if (!cmd) return;355 if (!cmd) return;
@@ -396,7 +364,7 @@ export default function AddMcpSource(props: { · 8 unmodified lines
396 ...(slug ? { slug } : {}),364 ...(slug ? { slug } : {}),
397 command: cmd,365 command: cmd,
398 args: parseStdioArgs(stdioArgs),366 args: parseStdioArgs(stdioArgs),
399 env: parseStdioEnv(stdioEnv),367 envVars: stdioEnvVars.length > 0 ? stdioEnvVars : undefined,
400 },368 },
401 reactivityKeys: integrationWriteKeys,369 reactivityKeys: integrationWriteKeys,
402 });370 });
@@ -406,7 +374,7 @@ export default function AddMcpSource(props: { · 3 unmodified lines
406 return;374 return;
407 }375 }
408 props.onComplete(exit.value.slug);376 props.onComplete(exit.value.slug);
409 }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAddServer, props]);377 }, [stdioCommand, stdioArgs, stdioEnvVars, stdioIdentity, doAddServer, props]);
410 378
411 // ---- Render ----379 // ---- Render ----
412 380
@@ -548,15 +516,12 @@ export default function AddMcpSource(props: { · 135 unmodified lines
548 516
549 <CardStackEntryField517 <CardStackEntryField
550 label="Environment variables"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 <Textarea521 <TagInput
554 value={stdioEnv}522 values={stdioEnvVars}
555 onChange={(e) => setStdioEnv((e.target as HTMLTextAreaElement).value)}523 onChange={setStdioEnvVars}
556 placeholder={"KEY=value\nANOTHER=value"}524 placeholder="Add an env var, e.g. GITHUB_TOKEN"
557 rows={3}
558 maxRows={10}
559 className="font-mono text-sm"
560 />525 />
561 </CardStackEntryField>526 </CardStackEntryField>
562 </CardStackContent>527 </CardStackContent>
packages/plugins/mcp/src/react/McpAccountsPanel.tsx+7−4
@@ -50,13 +50,16 @@ export default function McpAccountsPanel(props: { · 49 unmodified lines
50 const remote = config !== null && config.transport === "remote" ? config : null;50 const remote = config !== null && config.transport === "remote" ? config : null;
51 51
52 const existingTemplate = useMemo<readonly McpAuthMethod[]>(52 const existingTemplate = useMemo<readonly McpAuthMethod[]>(
53 () => remote?.authenticationTemplate ?? [],53 () => config?.authenticationTemplate ?? [],
54 [remote],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 const methods = useMemo<readonly AuthMethod[]>(60 const methods = useMemo<readonly AuthMethod[]>(
58 () => (remote ? authMethodsFromConfig(existingTemplate, remote.endpoint) : []),61 () => (config ? authMethodsFromConfig(existingTemplate, remote?.endpoint ?? "") : []),
59 [existingTemplate, remote],62 [existingTemplate, config, remote],
60 );63 );
61 64
62 const configure = useCallback<ConfigureAuthMethods<McpAuthMethod>>(65 const configure = useCallback<ConfigureAuthMethods<McpAuthMethod>>(
packages/plugins/mcp/src/react/auth-method-config.ts+26−1
@@ -17,7 +17,30 @@ import { · 16 unmodified lines
17} from "@executor-js/react/lib/shared-auth-method-codec";17} from "@executor-js/react/lib/shared-auth-method-codec";
18 18
19import { wireAuthInputFromShared } from "@executor-js/react/lib/shared-auth-method-codec";19import { wireAuthInputFromShared } from "@executor-js/react/lib/shared-auth-method-codec";
20import type { McpAuthMethod, McpAuthMethodInput, McpCanonicalAuthMethodInput } from "../sdk/types";20import 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`. */
30const 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). */
40const stdioEnvEditorValue = (method: McpStdioEnvMethod): AuthTemplateEditorValue => ({
41 kind: "apikey",
42 placements: method.vars.map((name) => ({ carrier: "env", name, prefix: "", variable: name })),
43});
21 44
22/** Serialize a canonical method into the wire input union (apikey → the45/** Serialize a canonical method into the wire input union (apikey → the
23 * request-shaped dialect; none/oauth2 pass through). */46 * request-shaped dialect; none/oauth2 pass through). */
@@ -53,6 +76,7 @@ export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplat · 29 unmodified lines
53 if (method.kind === "oauth2") {76 if (method.kind === "oauth2") {
54 return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] };77 return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] };
55 }78 }
79 if (method.kind === "stdio_env") return stdioEnvEditorValue(method);
56 return editorValueFromSharedMethod(method);80 return editorValueFromSharedMethod(method);
57}81}
58 82
@@ -66,6 +90,7 @@ export function authMethodsFromConfig( · 7 unmodified lines
66): AuthMethod[] {90): AuthMethod[] {
67 return methods.map((method: McpAuthMethod): AuthMethod => {91 return methods.map((method: McpAuthMethod): AuthMethod => {
68 if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint);92 if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint);
93 if (method.kind === "stdio_env") return stdioEnvAuthMethod(method);
69 return authMethodFromSharedTemplate(method);94 return authMethodFromSharedTemplate(method);
70 });95 });
71}96}
packages/plugins/mcp/src/sdk/plugin.ts+198−9
@@ -7,6 +7,8 @@ import * as z from "zod/v4"; · 6 unmodified lines
7 7
8import {8import {
9 authToolFailure,9 authToolFailure,
10 AuthTemplateSlug,
11 ConnectionName,
10 definePlugin,12 definePlugin,
11 IntegrationAlreadyExistsError,13 IntegrationAlreadyExistsError,
12 IntegrationSlug,14 IntegrationSlug,
@@ -58,7 +60,7 @@ import { · 45 unmodified lines
58 normalizeMcpAuthMethods,60 normalizeMcpAuthMethods,
59 parseMcpIntegrationConfig,61 parseMcpIntegrationConfig,
60 type McpIntegrationConfig as McpIntegrationConfigType,62 type McpIntegrationConfig as McpIntegrationConfigType,
61 type McpRemoteIntegrationConfig,63 type McpStdioEnvMethod,
62 type McpStdioIntegrationConfig,64 type McpStdioIntegrationConfig,
63} from "./types";65} from "./types";
64 66
@@ -169,6 +171,13 @@ const McpStdioServerInputSchema = Schema.Struct({ · 104 unmodified lines
169 description: Schema.optional(Schema.String),171 description: Schema.optional(Schema.String),
170 command: Schema.String,172 command: Schema.String,
171 args: Schema.optional(Schema.Array(Schema.String)),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 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),181 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
173 cwd: Schema.optional(Schema.String),182 cwd: Schema.optional(Schema.String),
174 slug: Schema.optional(Schema.String),183 slug: Schema.optional(Schema.String),
@@ -295,14 +304,34 @@ const normalizeSlug = (input: McpServerInput): string => · 120 unmodified lines
295 command: input.transport === "stdio" ? input.command : undefined,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). */
308const 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. */
313const 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
298const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => {319const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => {
299 if (input.transport === "stdio") {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 return {326 return {
301 transport: "stdio",327 transport: "stdio",
302 command: input.command,328 command: input.command,
303 args: input.args ? [...input.args] : undefined,329 args: input.args ? [...input.args] : undefined,
304 env: input.env,
305 cwd: input.cwd,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 return {337 return {
@@ -453,10 +482,10 @@ const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ · 144 unmodified lines
453 * config-driven), so existing connections keep working. Ambiguity across482 * config-driven), so existing connections keep working. Ambiguity across
454 * several methods renders no auth rather than guessing. */483 * several methods renders no auth rather than guessing. */
455const selectAuthMethod = (484const selectAuthMethod = (
456 config: McpRemoteIntegrationConfig,485 config: McpIntegrationConfigType,
457 templateSlug: string | null,486 templateSlug: string | null,
458): McpAuthMethod | undefined => {487): McpAuthMethod | undefined => {
459 const methods = config.authenticationTemplate;488 const methods = config.authenticationTemplate ?? [];
460 if (templateSlug !== null) {489 if (templateSlug !== null) {
461 const match = methods.find((method: McpAuthMethod) => method.slug === templateSlug);490 const match = methods.find((method: McpAuthMethod) => method.slug === templateSlug);
462 if (match) return match;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 return Effect.succeed({525 return Effect.succeed({
485 transport: "stdio" as const,526 transport: "stdio" as const,
486 command: config.command,527 command: config.command,
487 args: config.args,528 args: config.args,
488 env: config.env,529 env: Object.keys(env).length > 0 ? env : undefined,
489 cwd: config.cwd,530 cwd: config.cwd,
490 } satisfies McpStdioIntegrationConfig);531 } satisfies McpStdioIntegrationConfig);
491 }532 }
@@ -535,13 +576,28 @@ const buildConnectorInput = ( · 43 unmodified lines
535// the connect flow probes to confirm and falls back.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. */
581const 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
538export const describeMcpAuthMethods = (589export const describeMcpAuthMethods = (
539 record: IntegrationRecord,590 record: IntegrationRecord,
540): readonly AuthMethodDescriptor[] => {591): readonly AuthMethodDescriptor[] => {
541 const config = parseMcpIntegrationConfig(record.config);592 const config = parseMcpIntegrationConfig(record.config);
542 if (!config || config.transport === "stdio") return [];593 if (!config) return [];
543 594
544 return config.authenticationTemplate.map((method: McpAuthMethod): AuthMethodDescriptor => {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 if (method.kind === "apikey") return describeApiKeyAuthMethod(method);601 if (method.kind === "apikey") return describeApiKeyAuthMethod(method);
546 if (method.kind === "oauth2") {602 if (method.kind === "oauth2") {
547 return {603 return {
@@ -549,7 +605,12 @@ export const describeMcpAuthMethods = ( · 1 unmodified line
549 label: "OAuth",605 label: "OAuth",
550 kind: "oauth",606 kind: "oauth",
551 template: method.slug,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 return describeNoneAuthMethod(method.slug);616 return describeNoneAuthMethod(method.slug);
@@ -778,6 +839,54 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 222 unmodified lines
778 attributes: { "mcp.integration.slug": slug },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 return { slug };890 return { slug };
782 }).pipe(891 }).pipe(
783 Effect.withSpan("mcp.plugin.add_server", {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 const removeServer = (slug: string) =>976 const removeServer = (slug: string) =>
792 Effect.gen(function* () {977 Effect.gen(function* () {
793 const integration = slugFrom(slug);978 const integration = slugFrom(slug);
@@ -919,6 +1104,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 125 unmodified lines
919 probeEndpoint,1104 probeEndpoint,
920 addServer,1105 addServer,
921 removeServer,1106 removeServer,
1107 reconcileStdioConnections,
922 getServer,1108 getServer,
923 configureServer,1109 configureServer,
924 configureAuth,1110 configureAuth,
@@ -1262,6 +1448,9 @@ export interface McpPluginExtension { · 337 unmodified lines
1262 McpExtensionFailure | IntegrationAlreadyExistsError1448 McpExtensionFailure | IntegrationAlreadyExistsError
1263 >;1449 >;
1264 readonly removeServer: (slug: string) => Effect.Effect<void, McpExtensionFailure>;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 readonly getServer: (1454 readonly getServer: (
1266 slug: string,1455 slug: string,
1267 ) => Effect.Effect<1456 ) => Effect.Effect<
packages/plugins/mcp/src/sdk/types.ts+31−2
@@ -54,7 +54,26 @@ export const McpOAuthMethod = Schema.Struct({ · 53 unmodified lines
54});54});
55export type McpOAuthMethod = typeof McpOAuthMethod.Type;55export type McpOAuthMethod = typeof McpOAuthMethod.Type;
56 56
57export 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. */
64export const McpStdioEnvMethod = Schema.Struct({
65 slug: Schema.String,
66 kind: Schema.Literal("stdio_env"),
67 vars: Schema.Array(Schema.String),
68});
69export type McpStdioEnvMethod = typeof McpStdioEnvMethod.Type;
70
71export const McpAuthMethod = Schema.Union([
72 NoneAuthMethod,
73 ApiKeyAuthMethod,
74 McpOAuthMethod,
75 McpStdioEnvMethod,
76]);
58export type McpAuthMethod = typeof McpAuthMethod.Type;77export type McpAuthMethod = typeof McpAuthMethod.Type;
59 78
60/** Single-method `auth` shorthand on `addServer` — agent convenience for the79/** Single-method `auth` shorthand on `addServer` — agent convenience for the
@@ -180,10 +199,20 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 119 unmodified lines
180 command: Schema.String,199 command: Schema.String,
181 /** Arguments to the command */200 /** Arguments to the command */
182 args: Schema.optional(Schema.Array(Schema.String)),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 env: Schema.optional(StringMap),208 env: Schema.optional(StringMap),
185 /** Working directory */209 /** Working directory */
186 cwd: Schema.optional(Schema.String),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});
188export type McpStdioIntegrationConfig = typeof McpStdioIntegrationConfig.Type;217export type McpStdioIntegrationConfig = typeof McpStdioIntegrationConfig.Type;
189 218
packages/react/src/components/add-account-modal.tsx+43−9
@@ -151,6 +151,9 @@ function isOnePasswordRegistered( · 150 unmodified lines
151function PasteCredentialInputs(props: {151function PasteCredentialInputs(props: {
152 readonly inputs: readonly CredentialInput[];152 readonly inputs: readonly CredentialInput[];
153 readonly singleInput: boolean;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 readonly values: Readonly<Record<string, string>>;157 readonly values: Readonly<Record<string, string>>;
155 readonly onChange: (values: Record<string, string>) => void;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 return (197 return (
192 <div className="space-y-2">198 <div className="space-y-2">
193 {props.inputs.map((input) => (199 {props.inputs.map((input) => (
194 <div key={input.variable} className="space-y-1">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 <Input204 <Input
196 type="password"205 type="password"
197 autoComplete="new-password"206 autoComplete="new-password"
198 placeholder="paste the value / token"207 placeholder={labelled ? "secret value" : "paste the value / token"}
199 value={props.values[input.variable] ?? ""}208 value={props.values[input.variable] ?? ""}
200 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>209 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
201 props.onChange({210 props.onChange({
@@ -282,6 +291,13 @@ function OnePasswordItemSelect(props: { · 80 unmodified lines
282function CredentialValueFields(props: {291function CredentialValueFields(props: {
283 readonly inputs: readonly CredentialInput[];292 readonly inputs: readonly CredentialInput[];
284 readonly singleInput: boolean;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 readonly values: Readonly<Record<string, string>>;301 readonly values: Readonly<Record<string, string>>;
286 readonly onValuesChange: (values: Record<string, string>) => void;302 readonly onValuesChange: (values: Record<string, string>) => void;
287 readonly origin: CredentialOrigin;303 readonly origin: CredentialOrigin;
@@ -290,7 +306,10 @@ function CredentialValueFields(props: { · 2 unmodified lines
290 readonly onOnePasswordItemIdChange: (value: string) => void;306 readonly onOnePasswordItemIdChange: (value: string) => void;
291}) {307}) {
292 const providers = useAtomValue(providersAtom);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 return (314 return (
296 <div className="space-y-3">315 <div className="space-y-3">
@@ -326,6 +345,7 @@ function CredentialValueFields(props: { · 29 unmodified lines
326 <PasteCredentialInputs345 <PasteCredentialInputs
327 inputs={props.inputs}346 inputs={props.inputs}
328 singleInput={props.singleInput}347 singleInput={props.singleInput}
348 showLabels={props.showLabels}
329 values={props.values}349 values={props.values}
330 onChange={props.onValuesChange}350 onChange={props.onValuesChange}
331 />351 />
@@ -855,6 +875,13 @@ function AddAccountModalView(props: AddAccountModalProps) { · 523 unmodified lines
855 }));875 }));
856 }, [method]);876 }, [method]);
857 const singleInput = credentialInputs.length <= 1;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 // DCR-capable: the integration advertises dynamic registration (MCP oauth2),885 // DCR-capable: the integration advertises dynamic registration (MCP oauth2),
859 // OR carries a discovery URL we can probe at connect time. When DCR-capable886 // OR carries a discovery URL we can probe at connect time. When DCR-capable
860 // and not yet fallen back, we skip the app picker entirely (Option A).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 value={methodId}1454 value={methodId}
1428 className="mt-0 min-w-0 space-y-5 rounded-md border border-border/60 bg-muted/15 p-4"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 ? (1457 {method?.placements && !isEnvMethod && singleInput
1431 <div className="flex flex-wrap gap-x-3.5 gap-y-1">1458 ? (() => {
1432 {method.placements.map((placement, i: number) => (1459 const shown = method.placements.filter((p) => p.carrier !== "env");
1433 <PlacementLine key={i} placement={placement} />1460 return shown.length > 0 ? (
1434 ))}1461 <div className="flex flex-wrap gap-x-3.5 gap-y-1">
1435 </div>1462 {shown.map((placement, i: number) => (
1436 ) : null}1463 <PlacementLine key={i} placement={placement} />
1464 ))}
1465 </div>
1466 ) : null;
1467 })()
1468 : null}
1437 1469
1438 {!isNoAuth && (1470 {!isNoAuth && (
1439 <div className="space-y-2">1471 <div className="space-y-2">
@@ -1540,6 +1572,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 100 unmodified lines
1540 <CredentialValueFields1572 <CredentialValueFields
1541 inputs={credentialInputs}1573 inputs={credentialInputs}
1542 singleInput={singleInput}1574 singleInput={singleInput}
1575 showLabels={isEnvMethod}
1576 allowExternalProvider={!isEnvMethod}
1543 values={values}1577 values={values}
1544 onValuesChange={setValues}1578 onValuesChange={setValues}
1545 origin={credentialOrigin}1579 origin={credentialOrigin}
packages/react/src/components/tag-input.tsx+102−0
@@ -0,0 +1,102 @@
1import * as React from "react";
2import { PlusIcon, XIcon } from "lucide-react";
3
4import { cn } from "../lib/utils";
5import { Badge } from "./badge";
6import { Button } from "./button";
7import { 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
17export 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}
packages/react/src/lib/auth-placements.tsx+7−2
@@ -18,7 +18,7 @@ · 17 unmodified lines
18import { AuthTemplateSlug } from "@executor-js/sdk/shared";18import { AuthTemplateSlug } from "@executor-js/sdk/shared";
19import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";19import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";
20 20
21export type Carrier = "header" | "query";21export type Carrier = "header" | "query" | "env";
22 22
23export interface Placement {23export interface Placement {
24 readonly carrier: Carrier;24 readonly carrier: Carrier;
@@ -87,6 +87,9 @@ export function placementLabel(placement: Placement): string { · 62 unmodified lines
87 if (placement.carrier === "header") {87 if (placement.carrier === "header") {
88 return `${placement.name || "Authorization"} header`;88 return `${placement.name || "Authorization"} header`;
89 }89 }
90 if (placement.carrier === "env") {
91 return `${placement.name || "TOKEN"} env var`;
92 }
90 return `${placement.name || "api_key"} query param`;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 const lead =103 const lead =
101 placement.carrier === "header"104 placement.carrier === "header"
102 ? `${placement.name || "Authorization"}: `105 ? `${placement.name || "Authorization"}: `
103 : `?${placement.name || "api_key"}=`;106 : placement.carrier === "env"
107 ? `${placement.name || "TOKEN"}=`
108 : `?${placement.name || "api_key"}=`;
104 return (109 return (
105 <span className="inline-flex items-center font-mono text-xs text-muted-foreground">110 <span className="inline-flex items-center font-mono text-xs text-muted-foreground">
106 {lead}111 {lead}
packages/react/src/lib/shared-auth-method-codec.ts+7−2
@@ -113,8 +113,13 @@ export const wirePlacementsFromEditor = ( · 112 unmodified lines
113): readonly AuthPlacement[] => {113): readonly AuthPlacement[] => {
114 const variables = assignVariables(placements);114 const variables = assignVariables(placements);
115 return placements115 return placements
116 .filter((p: Placement) => p.name.trim().length > 0 || p.literal !== undefined)116 .filter(
117 .map((p: Placement): AuthPlacement => {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 const variable = variables.get(p);123 const variable = variables.get(p);
119 return {124 return {
120 carrier: p.carrier,125 carrier: p.carrier,
packages/react/src/plugins/integration-identity.test.ts+43−0
@@ -2,8 +2,11 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
2 2
3import {3import {
4 domainLabelFromUrl,4 domainLabelFromUrl,
5 humanizeStdioToken,
6 integrationDisplayNameFromStdio,
5 integrationDisplayNameFromUrl,7 integrationDisplayNameFromUrl,
6 pascalCaseDomainLabel,8 pascalCaseDomainLabel,
9 stdioPackageToken,
7} from "./integration-identity";10} from "./integration-identity";
8 11
9describe("integration identity URL display names", () => {12describe("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
29describe("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});
packages/react/src/plugins/integration-identity.tsx+90−0
@@ -46,6 +46,96 @@ export function integrationDisplayNameFromUrl(url: string, sourceKind: string): · 45 unmodified lines
46 return displayLabel ? `${displayLabel} ${sourceKind}` : null;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.
51const 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>`.
69const 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 */
77export 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 */
95export 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 */
129export 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// Hook — owns the name + namespace state with namespace auto-derivation140// Hook — owns the name + namespace state with namespace auto-derivation
51// ---------------------------------------------------------------------------141// ---------------------------------------------------------------------------