|
| 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 | +); |
0 commit comments