138 lines · 6.1 KB · TypeScript
RawDownload
// Local-only — the integration Edit sheet for a STDIO MCP server, driven in a// real browser. `local` is the only surface that enables stdio MCP// (`dangerouslyAllowStdioMCP: true`), so it is the only place this sheet can be// exercised end to end.//// The sheet used to say "Stdio MCP integrations cannot be edited. Remove and// recreate the integration with the updated command." (#812) — changing a// command meant editing `executor.jsonc` by hand, or losing the integration's// connections and policies to a delete-and-re-add.//// The assertion is deliberately not "the field accepted my text". The scenario// edits the DECLARED env map through the form and then reads the tool catalog:// the fixture advertises `saw_declared_env` only when that variable is present// in the spawned child's environment, so the tool appearing proves the edited// config travelled the whole way — form, config write, respawn, rediscovery.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 { scenario } from "../src/scenario";import { Browser, Cli, RunDir, Target } 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)); const SECRET = "s3cr3t-typed-into-the-edit-sheet"; scenario(  "Local · a stdio MCP server's command and environment are editable from the integration Edit sheet",  { timeout: 300_000 },  Effect.gen(function* () {    const cli = yield* Cli;    const browser = yield* Browser;    const target = yield* Target;    const runDir = yield* RunDir;    const identity = yield* target.newIdentity();     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-editable";         // A plain stdio server: no declared env, so the env-gated tool is absent        // until the edit adds the variable.        yield* client.mcp.addServer({          payload: {            transport: "stdio",            name: "E2E Stdio Editable",            command: "node",            args: [FIXTURE],            slug,          },        });         const before = yield* client.tools.list({ query: { integration: slug } });        expect(          before.map((t) => t.name),          "the server starts with its base tool and no declared env",        ).toContain("echo_tool");        expect(          before.map((t) => t.name),          "the env-gated tool is absent before the edit",        ).not.toContain("saw_declared_env");         yield* browser.session(identity, async ({ page, step }) => {          await step("Open the stdio integration from the console", async () => {            await page.goto(server.url, { waitUntil: "domcontentloaded" });            await page.getByTestId(`integration-entry-${slug}`).first().click();            // Wait for the detail page's own data, not just its shell: clicking            // Edit while the accounts panel is still a skeleton races the layout            // shift that lands when it resolves.            await page.getByText("default").first().waitFor({ timeout: 30_000 });            await page.getByRole("button", { name: "Edit" }).waitFor({ timeout: 30_000 });          });           await step("Open the Edit sheet — the stdio command is editable", async () => {            await page.getByRole("button", { name: "Edit" }).click();            await page.getByText("Edit integration").waitFor({ timeout: 30_000 });            await page.getByText("Server command").waitFor({ timeout: 30_000 });            // The read-only dead end this replaces.            expect(              await page.getByText("Stdio MCP integrations cannot be edited").count(),              "the read-only message is gone",            ).toBe(0);            expect(              await page.getByRole("textbox", { name: "Command" }).inputValue(),              "the stored command is loaded into the form",            ).toBe("node");          });           await step("Declare an environment variable and save", async () => {            await page              .getByRole("textbox", { name: "Environment variables" })              .fill(`EXECUTOR_E2E_SECRET=${SECRET}`);            await page.getByRole("button", { name: "Save" }).click();            await page.getByText("Server command").waitFor({ state: "hidden", timeout: 30_000 });          });        });         // The edit persisted as the DECLARED static env map on the config.        const stored = yield* client.mcp.getServer({ params: { slug } });        expect(          stored?.config.transport === "stdio" ? stored.config.env : undefined,          "the sheet wrote the declared env map",        ).toEqual({ EXECUTOR_E2E_SECRET: SECRET });        expect(          stored?.config.transport === "stdio" ? stored.config.command : undefined,          "the command it did not touch is unchanged",        ).toBe("node");         // And it reached the spawned server: the fixture gates this tool's very        // existence on that variable being in its own environment.        const after = yield* client.tools.list({ query: { integration: slug } });        expect(          after.map((t) => t.name),          "the edited environment reached the respawned server and the catalog was rebuilt",        ).toContain("saw_declared_env");        expect(          after.map((t) => t.name),          "the rebuild kept the tools that still exist",        ).toContain("echo_tool");      }),    );  }),);