Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/local/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@executor-js/sdk";
import { collectTables } from "@executor-js/api/server";
import { loadPluginsFromJsonc } from "@executor-js/config";
import type { McpPluginExtension } from "@executor-js/plugin-mcp";

import executorConfig from "../executor.config";
import { localDataMigrations } from "./db/data-migrations";
Expand Down Expand Up @@ -218,6 +219,26 @@ const createLocalExecutorLayer = () => {
}
}

// Heal stdio MCP integrations added before auto-connect existed (they
// landed with zero connections ⇒ zero tools) and move any legacy inline
// env into the secret store. No-op on a fresh install; never fails boot.
// Local is the only app that enables stdio, so this only runs here.
// oxlint-disable-next-line executor/no-double-cast -- typed boundary: the executor IS its own plugin-extension map (executor[pluginId]) but LocalExecutor doesn't surface per-plugin extensions statically
const mcpExtension = (executor as unknown as { readonly mcp?: McpPluginExtension }).mcp;
if (mcpExtension) {
yield* mcpExtension
.reconcileStdioConnections()
.pipe(
Effect.catch(() =>
Effect.sync(() =>
console.warn(
"[executor] stdio connection reconcile failed; existing stdio servers may show no tools until re-added",
),
),
),
);
}

return { executor, plugins };
}),
);
Expand Down
104 changes: 104 additions & 0 deletions e2e/local/fixtures/stdio-mcp-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// A zero-dependency MCP server over stdio, for the `local` e2e project.
//
// The real `@modelcontextprotocol/sdk` stdio server pulls in the whole SDK and
// is awkward to resolve from an arbitrary spawn cwd under bun's node_modules
// layout. The MCP stdio framing is just newline-delimited JSON-RPC, so we hand-
// roll the three methods a tool-discovery + invoke round-trip needs:
// `initialize`, `tools/list`, `tools/call` (plus `ping`). This keeps the
// fixture a single self-contained file the executor server can launch as
// `node <thisfile>` with nothing to install.
//
// It exposes one tool, `echo_tool`, and (when EXECUTOR_E2E_SECRET is set in the
// child env) a second `whoami` tool that returns that env value — so a scenario
// can prove a per-connection secret env var actually reached the subprocess.

import { createInterface } from "node:readline";

const send = (message) => {
process.stdout.write(`${JSON.stringify(message)}\n`);
};

const TOOLS = [
{
name: "echo_tool",
description: "Echoes the provided text back",
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
},
];

if (process.env.EXECUTOR_E2E_SECRET) {
TOOLS.push({
name: "whoami",
description: "Returns the secret env value the server was launched with",
inputSchema: { type: "object", properties: {} },
});
}

const handle = (msg) => {
if (msg.method === "initialize") {
send({
jsonrpc: "2.0",
id: msg.id,
result: {
// Echo the client's protocol version so we never fail version
// negotiation against whatever SDK build is on the other end.
protocolVersion: msg.params?.protocolVersion ?? "2025-06-18",
capabilities: { tools: {} },
serverInfo: { name: "executor-e2e-stdio", version: "1.0.0" },
},
});
return;
}

// Notifications carry no id and expect no response.
if (msg.id === undefined || msg.id === null) return;

if (msg.method === "ping") {
send({ jsonrpc: "2.0", id: msg.id, result: {} });
return;
}

if (msg.method === "tools/list") {
send({ jsonrpc: "2.0", id: msg.id, result: { tools: TOOLS } });
return;
}

if (msg.method === "tools/call") {
const name = msg.params?.name;
const text =
name === "whoami"
? (process.env.EXECUTOR_E2E_SECRET ?? "")
: String(msg.params?.arguments?.text ?? "");
send({
jsonrpc: "2.0",
id: msg.id,
result: { content: [{ type: "text", text }] },
});
return;
}

send({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32601, message: `Method not found: ${msg.method}` },
});
};

const rl = createInterface({ input: process.stdin });
rl.on("line", (line) => {
const trimmed = line.trim();
if (!trimmed) return;
let msg;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code
try {
// oxlint-disable-next-line executor/no-json-parse -- standalone zero-dep fixture: hand-rolled JSON-RPC framing, not product code
msg = JSON.parse(trimmed);
} catch {
return;
}
handle(msg);
});
145 changes: 145 additions & 0 deletions e2e/local/stdio-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Repro + regression guard for the user report: "On a totally fresh install
// (no existing data dir) on macOS, Executor does not detect any tools for a
// STDIO MCP server."
//
// `withLocalServer` boots a real `executor web` on a THROWAWAY data dir (the
// fresh-install condition) and the `local` app is the only surface that enables
// stdio MCP (`dangerouslyAllowStdioMCP: true`). We add a stdio MCP server over
// the bearer-authed API and assert its tools are discoverable — and that the
// secret env it needs is stored on the connection (the secret store), not in
// the integration's config blob.
//
// The original bug: `mcp.addServer` only registered an INTEGRATION. Per the
// v1.5 integrations/connections split, tools are produced per-CONNECTION, and a
// stdio add never created one, so the integration landed with zero connections
// and zero tools. The fix auto-creates the default connection on add and routes
// the env values into the connection's secret store.
import { fileURLToPath } from "node:url";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { HttpApiClient } from "effect/unstable/httpapi";
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
import { composePluginApi } from "@executor-js/api/server";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Cli, RunDir } from "../src/services";
import { withLocalServer } from "./local-server";

const api = composePluginApi([mcpHttpPlugin()] as const);

const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));

// The fixture exposes `whoami` ONLY when EXECUTOR_E2E_SECRET is present in its
// process env. So `whoami` showing up in the discovered tools is direct proof
// the connection's secret env reached the spawned subprocess.
const SECRET = "s3cr3t-from-the-vault";

scenario(
"Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret",
{ timeout: 180_000 },
Effect.gen(function* () {
const cli = yield* Cli;
const runDir = yield* RunDir;

yield* withLocalServer(cli, runDir, (server) =>
Effect.gen(function* () {
const client = yield* HttpApiClient.make(api, {
baseUrl: new URL("/api", server.origin).toString(),
transformClient: HttpClient.mapRequest((request) =>
HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
),
}).pipe(Effect.provide(FetchHttpClient.layer));

const slug = "e2e-stdio";

// Add the stdio server exactly as the desktop/local "Add MCP" flow does,
// including a secret env var the server needs.
yield* client.mcp.addServer({
payload: {
transport: "stdio",
name: "E2E Stdio",
command: "node",
args: [FIXTURE],
env: { EXECUTOR_E2E_SECRET: SECRET },
slug,
},
});

// The integration lands in the catalog — the add itself works.
const integrations = yield* client.integrations.list();
expect(
integrations.map((i) => String(i.slug)),
"the stdio MCP integration is registered",
).toContain(slug);

// The add auto-creates the default connection (the v1.5 split makes this
// the thing that drives tool discovery). Pre-fix there were zero.
const connections = yield* client.connections.list({ query: { integration: slug } });
expect(
connections.map((c) => String(c.name)),
"a default connection was auto-created for the stdio server",
).toContain("default");

// THE SYMPTOM, fixed: the stdio server's tools are detected. `whoami`
// appearing proves the connection's secret env reached the subprocess.
const tools = yield* client.tools.list({ query: { integration: slug } });
const names = tools.map((t) => t.name);
expect(names, "the stdio server's base tool is detected").toContain("echo_tool");
expect(
names,
"the secret env var reached the spawned subprocess (whoami is gated on it)",
).toContain("whoami");

// "Properly store auth": the secret value is NOT in the integration's
// config blob — only the var NAME is declared there; the value lives on
// the connection (the secret store).
const stored = yield* client.mcp.getServer({ params: { slug } });
expect(
JSON.stringify(stored?.config ?? {}),
"the secret value is not persisted in the integration config",
).not.toContain(SECRET);

// --- The UI path: DECLARE env var names, then provide the secret value
// as a connection credential (what the add form now does). ---
const declSlug = "e2e-stdio-decl";
yield* client.mcp.addServer({
payload: {
transport: "stdio",
name: "E2E Stdio Declared",
command: "node",
args: [FIXTURE],
envVars: ["EXECUTOR_E2E_SECRET"],
slug: declSlug,
},
});

// Declaring a secret env var (no value) does NOT auto-connect: the
// secret is still missing, so there are no tools until you connect.
const beforeConns = yield* client.connections.list({ query: { integration: declSlug } });
expect(beforeConns, "no connection until the secret is provided").toHaveLength(0);
const beforeTools = yield* client.tools.list({ query: { integration: declSlug } });
expect(beforeTools, "no tools until the secret is provided").toHaveLength(0);

// Provide the secret as the connection credential (the connect step).
yield* client.connections.create({
payload: {
owner: "org",
name: ConnectionName.make("default"),
integration: IntegrationSlug.make(declSlug),
template: AuthTemplateSlug.make("env"),
values: { EXECUTOR_E2E_SECRET: SECRET },
},
});

const declTools = yield* client.tools.list({ query: { integration: declSlug } });
expect(
declTools.map((t) => t.name),
"connecting with the secret discovers the env-gated tool",
).toContain("whoami");
}),
);
}),
);
2 changes: 1 addition & 1 deletion packages/core/api/src/integrations/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const IntegrationParams = { slug: IntegrationSlug };
/** Where a credential value is carried — mirrors the SDK's
* `AuthPlacementDescriptor`. */
const PlacementDescriptor = Schema.Struct({
carrier: Schema.Literals(["header", "query"]),
carrier: Schema.Literals(["header", "query", "env"]),
name: Schema.String,
prefix: Schema.String,
/** Input variable this placement renders from (absent ⇒ `token`). Without
Expand Down
7 changes: 4 additions & 3 deletions packages/core/sdk/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ export interface IntegrationDisplayDescriptor {
readonly url?: string;
}

/** Where a credential value is carried on the outbound request. Mirrors the
* client's `Placement`. */
/** Where a credential value is carried. `header`/`query` place it on an
* outbound HTTP request (mirrors the client's `Placement`); `env` injects it
* as an environment variable for a stdio (subprocess) integration. */
export interface AuthPlacementDescriptor {
readonly carrier: "header" | "query";
readonly carrier: "header" | "query" | "env";
readonly name: string;
/** Literal prepended to the value (e.g. `"Bearer "`). Empty when bare. */
readonly prefix: string;
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/mcp/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ const AddStdioServerPayload = Schema.Struct({
description: Schema.optional(Schema.String),
command: Schema.String,
args: Schema.optional(Schema.Array(Schema.String)),
/** Declare the secret env vars this server needs, by name. Their values are
* supplied as the connection's secrets (the connect step), not here. */
envVars: Schema.optional(Schema.Array(Schema.String)),
/** One-shot secret env values (programmatic). The UI sends `envVars`. */
env: Schema.optional(StringMap),
cwd: Schema.optional(Schema.String),
slug: Schema.optional(Schema.String),
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/mcp/src/api/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const failingExtension: McpPluginExtension = {
probeEndpoint: () => Effect.die(new Error("Not implemented")),
addServer: () => unused,
removeServer: () => unused,
reconcileStdioConnections: () => unused,
getServer: () => Effect.succeed(null),
configureServer: () => unused,
configureAuth: () => unused,
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/mcp/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const toServerInput = (
description?: string;
command: string;
args?: readonly string[];
envVars?: readonly string[];
env?: Record<string, string>;
cwd?: string;
slug?: string;
Expand All @@ -46,6 +47,7 @@ const toServerInput = (
description: p.description,
command: p.command,
args: p.args ? [...p.args] : undefined,
envVars: p.envVars ? [...p.envVars] : undefined,
env: p.env,
cwd: p.cwd,
slug: p.slug,
Expand Down
Loading
Loading