Skip to content

Commit 653c2c7

Browse files
committed
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.
1 parent 4ef2199 commit 653c2c7

17 files changed

Lines changed: 709 additions & 85 deletions

File tree

apps/local/src/executor.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "@executor-js/sdk";
1515
import { collectTables } from "@executor-js/api/server";
1616
import { loadPluginsFromJsonc } from "@executor-js/config";
17+
import type { McpPluginExtension } from "@executor-js/plugin-mcp";
1718

1819
import executorConfig from "../executor.config";
1920
import { localDataMigrations } from "./db/data-migrations";
@@ -218,6 +219,26 @@ const createLocalExecutorLayer = () => {
218219
}
219220
}
220221

222+
// Heal stdio MCP integrations added before auto-connect existed (they
223+
// landed with zero connections ⇒ zero tools) and move any legacy inline
224+
// env into the secret store. No-op on a fresh install; never fails boot.
225+
// Local is the only app that enables stdio, so this only runs here.
226+
// 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
227+
const mcpExtension = (executor as unknown as { readonly mcp?: McpPluginExtension }).mcp;
228+
if (mcpExtension) {
229+
yield* mcpExtension
230+
.reconcileStdioConnections()
231+
.pipe(
232+
Effect.catch(() =>
233+
Effect.sync(() =>
234+
console.warn(
235+
"[executor] stdio connection reconcile failed; existing stdio servers may show no tools until re-added",
236+
),
237+
),
238+
),
239+
);
240+
}
241+
221242
return { executor, plugins };
222243
}),
223244
);
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

e2e/local/stdio-mcp.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
);

packages/core/api/src/integrations/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const IntegrationParams = { slug: IntegrationSlug };
3131
/** Where a credential value is carried — mirrors the SDK's
3232
* `AuthPlacementDescriptor`. */
3333
const PlacementDescriptor = Schema.Struct({
34-
carrier: Schema.Literals(["header", "query"]),
34+
carrier: Schema.Literals(["header", "query", "env"]),
3535
name: Schema.String,
3636
prefix: Schema.String,
3737
/** Input variable this placement renders from (absent ⇒ `token`). Without

packages/core/sdk/src/integration.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ export interface IntegrationDisplayDescriptor {
2424
readonly url?: string;
2525
}
2626

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. */
2930
export interface AuthPlacementDescriptor {
30-
readonly carrier: "header" | "query";
31+
readonly carrier: "header" | "query" | "env";
3132
readonly name: string;
3233
/** Literal prepended to the value (e.g. `"Bearer "`). Empty when bare. */
3334
readonly prefix: string;

packages/plugins/mcp/src/api/group.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ const AddStdioServerPayload = Schema.Struct({
5151
description: Schema.optional(Schema.String),
5252
command: Schema.String,
5353
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`. */
5458
env: Schema.optional(StringMap),
5559
cwd: Schema.optional(Schema.String),
5660
slug: Schema.optional(Schema.String),

packages/plugins/mcp/src/api/handlers.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const failingExtension: McpPluginExtension = {
2626
probeEndpoint: () => Effect.die(new Error("Not implemented")),
2727
addServer: () => unused,
2828
removeServer: () => unused,
29+
reconcileStdioConnections: () => unused,
2930
getServer: () => Effect.succeed(null),
3031
configureServer: () => unused,
3132
configureAuth: () => unused,

packages/plugins/mcp/src/api/handlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const toServerInput = (
3636
description?: string;
3737
command: string;
3838
args?: readonly string[];
39+
envVars?: readonly string[];
3940
env?: Record<string, string>;
4041
cwd?: string;
4142
slug?: string;
@@ -46,6 +47,7 @@ const toServerInput = (
4647
description: p.description,
4748
command: p.command,
4849
args: p.args ? [...p.args] : undefined,
50+
envVars: p.envVars ? [...p.envVars] : undefined,
4951
env: p.env,
5052
cwd: p.cwd,
5153
slug: p.slug,

0 commit comments

Comments
 (0)