271 lines · 12.9 KB · TypeScript
RawDownload
// 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.//// The scenario also covers what ELSE reaches that subprocess. A stdio server// that declared any `env` used to be spawned with `{ ...process.env, ...env }`,// so it received every variable the daemon holds — including the key that// decrypts the secret store. `withLocalServer`'s `env` option plants two// variables on the real daemon process (see DAEMON_ENV) and the fixture reports// which of them survived the spawn, so the boundary is measured on the product// path rather than at the call site of the transport.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"; // Planted on the `executor web` process itself, so the only way either value// can reach a stdio subprocess is by being inherited across the spawn.//// NO_PROXY stands in for the infrastructure allowlist: it is one of the// variables a stdio server must keep inheriting, and it is the inert member of// that list — with no proxy configured alongside it, it changes nothing about// the daemon's own egress, so planting it cannot perturb the rest of the run.//// The sentinel stands in for a real host secret such as EXECUTOR_SECRET_KEY.// A variable only this scenario sets keeps the assertion honest on a machine// where the real one happens not to be exported.const PROXY_VALUE = "stdio-env-e2e.invalid";const HOST_ONLY_SENTINEL = "host-secret-that-must-not-reach-a-stdio-server";const DAEMON_ENV = {  NO_PROXY: PROXY_VALUE,  EXECUTOR_E2E_HOST_ONLY_SECRET: HOST_ONLY_SENTINEL,}; scenario(  "Local · a stdio MCP server's tools are detected on a fresh install, with env stored as a secret and the daemon's own environment withheld",  // Must stay STRICTLY greater than the boot-URL wait in `withLocalServer`  // (currently 240s). When this CI job runs `stdio-mcp.test.ts` alone it always  // pays a cold `vite optimizeDeps` boot (no prior file to warm the cache), the  // one variable step. If this test timeout equals the boot wait, both deadlines  // fire together and vitest's generic "Test timed out" wins, swallowing the  // harness's "printed no ?_token URL\n<terminal tail>" error that tells us what  // boot actually got stuck on. Keep the gap so the boot diagnostic surfaces.  { timeout: 300_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");           // --- What the daemon's own environment does, and does not, hand to a          // stdio server. This is the branch that leaked: `e2e-stdio` declares an          // `env`, and the declared-env branch used to spawn with the whole of          // `process.env` merged underneath. Each `saw_*` tool exists only because          // the matching variable was present in the CHILD's environment. ---          expect(names, "the declared env var is visible to the server").toContain(            "saw_declared_env",          );          expect(            names,            "the host's proxy configuration still reaches the server, or every stdio server behind a corporate proxy breaks",          ).toContain("saw_proxy_env");          expect(            names,            "a host variable the source never declared (EXECUTOR_E2E_HOST_ONLY_SECRET) must not reach the server",          ).not.toContain("saw_host_secret");           // "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");           // --- versionNegotiation "auto" survives the API → config → connector          // path and still reaches a legacy server: the probe gets the fixture's          // method-not-found for `server/discover` (a definitive legacy verdict)          // and falls back to `initialize`. Modern-era acceptance against a real          // legacy-disabled SDK v2 server lives in the plugin's          // stdio-negotiation.test.ts. ---          const autoSlug = "e2e-stdio-auto";          yield* client.mcp.addServer({            payload: {              transport: "stdio",              name: "E2E Stdio Auto",              command: "node",              args: [FIXTURE],              versionNegotiation: "auto",              slug: autoSlug,            },          });           const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });          expect(            JSON.stringify(autoStored?.config ?? {}),            "the negotiation mode is persisted on the integration config",          ).toContain('"versionNegotiation":"auto"');           const autoTools = yield* client.tools.list({ query: { integration: autoSlug } });          expect(            autoTools.map((t) => t.name),            "auto negotiation falls back to legacy and still discovers tools",          ).toContain("echo_tool");          expect(            autoTools.map((t) => t.name),            "this server declared no env, so the env-gated tool is absent to begin with",          ).not.toContain("saw_declared_env");           // --- Editing a stdio server's config (what the integration Edit sheet          // now does instead of telling you to remove and recreate). The tool          // catalog is persisted per connection, so a plain config replace is          // only enough because core stamps `config_revised_at` on a config          // write and every connection whose catalog predates that stamp is          // rebuilt on the next read. The edit path therefore needs NO explicit          // refresh of its own — this asserts that, so nobody adds one back.          //          // Adding a declared env var is the lever: the fixture advertises          // `saw_declared_env` only when that variable reached the child, so the          // tool appearing with no further action proves both halves — the new          // config reached the spawn, and the catalog was rebuilt from it. ---          const editedConfig = {            ...autoStored!.config,            env: { EXECUTOR_E2E_SECRET: SECRET },          } as typeof autoStored.config;           yield* client.mcp.configureServer({            params: { slug: autoSlug },            payload: { config: editedConfig },          });           const editedStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });          expect(            JSON.stringify(editedStored?.config ?? {}),            "the edit persisted, and left the untouched negotiation mode alone",          ).toContain('"versionNegotiation":"auto"');           const editedTools = yield* client.tools.list({ query: { integration: autoSlug } });          expect(            editedTools.map((t) => t.name),            "the edited config reached the spawn and the catalog was rediscovered",          ).toContain("saw_declared_env");          expect(            editedTools.map((t) => t.name),            "rediscovery replaced the catalog rather than dropping what still exists",          ).toContain("echo_tool");        }),      { env: DAEMON_ENV },    );  }),);