// 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"); }), ); }),);
// 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");
}),
);
}),
);