Skip to content

Commit fccc181

Browse files
committed
test(e2e): film the Tools tab following an MCP server-side rename
A browser scenario (cloud, video + per-step screenshots) walking the whole user journey: add a live MCP server through the add flow, connect with no auth, see the v1 tool in the Tools tab, run the tool that renames the server's catalog mid-call (the server pushes list_changed on the open connection), then revisit Tools and watch the renamed catalog appear — no Refresh click anywhere. The old behavior freezes this UI on the stale tool forever. The mutable-catalog fixture takes a per-run server name so the derived integration namespace can't collide on shared-tenant targets.
1 parent f113f07 commit fccc181

2 files changed

Lines changed: 114 additions & 4 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Cross-target (browser): MCP tool-catalog freshness, FILMED through the real
2+
// web UI. The session video + per-step screenshots are the artifact: a user
3+
// adds a live MCP server, sees its tool in the Tools tab, runs a tool that
4+
// renames the server's catalog mid-call (the server pushes
5+
// `notifications/tools/list_changed` on the open connection), and then WATCHES
6+
// the Tools tab serve the renamed catalog on the next visit — no Refresh
7+
// click anywhere in the journey. The old behavior freezes this UI on the
8+
// stale tool forever.
9+
import { randomBytes } from "node:crypto";
10+
11+
import { expect } from "@effect/vitest";
12+
import { Effect } from "effect";
13+
import { makeMutableCatalogMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
14+
15+
import { scenario } from "../src/scenario";
16+
import { Browser, Target } from "../src/services";
17+
18+
scenario(
19+
"MCP catalog · the Tools tab follows a server-side rename after a list_changed notification",
20+
{},
21+
Effect.scoped(
22+
Effect.gen(function* () {
23+
const target = yield* Target;
24+
const browser = yield* Browser;
25+
26+
// A real MCP server whose `rename_greet` tool renames `greet` →
27+
// `greet_v2` mid-call and notifies the open connection. The server name
28+
// is unique per run (it derives the integration namespace) so runs can't
29+
// collide on targets whose identities share one tenant.
30+
const mutable = makeMutableCatalogMcpServer({
31+
name: `catalog-sync-${randomBytes(3).toString("hex")}`,
32+
});
33+
const server = yield* serveMcpServer(mutable.factory);
34+
const identity = yield* target.newIdentity();
35+
36+
yield* browser.session(identity, async ({ page, step }) => {
37+
await step("Open the add-MCP flow pointed at the live server", async () => {
38+
await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
39+
waitUntil: "networkidle",
40+
});
41+
// The URL auto-probes (debounced); the method list appears once the
42+
// probe lands — an open server seeds the detected no-auth method.
43+
await page.getByText("How does this server authenticate?").waitFor();
44+
await page.getByText("Method 1 · Detected").waitFor();
45+
});
46+
47+
await step("Add the source", async () => {
48+
await page.getByRole("button", { name: "Add source" }).click();
49+
await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
50+
await page.getByText("Connections").first().waitFor();
51+
});
52+
53+
await step("Connect with no authentication", async () => {
54+
await page.getByRole("button", { name: "Add connection" }).first().click();
55+
await page.getByRole("tab", { name: "No authentication" }).waitFor();
56+
await page.getByRole("button", { name: "Add connection" }).last().click();
57+
// The connect flow dials the server and produces the tool catalog.
58+
await page.getByText("Connection added").waitFor();
59+
});
60+
61+
// Tools render as a collapsed dotted-name tree (namespace → leaf);
62+
// typing in the filter box expands every match, so it is both the
63+
// reveal mechanism and a search the video shows off.
64+
const filterTools = async (query: string) => {
65+
const filter = page.getByPlaceholder(/^Filter \d+ tools/);
66+
await filter.waitFor();
67+
await filter.fill(query);
68+
};
69+
70+
// A tool's tree row is a button whose accessible name is the full
71+
// leaf label — filter-highlight <mark> spans inside the label don't
72+
// fragment it, so exact role queries can't false-match a substring
73+
// (`greet` inside `greet_v2`).
74+
const toolRow = (name: string) => page.getByRole("button", { name, exact: true }).first();
75+
76+
await step("The Tools tab lists the server's v1 catalog", async () => {
77+
await page.getByRole("tab", { name: "Tools" }).click();
78+
await filterTools("greet");
79+
await toolRow(mutable.initialToolName).waitFor();
80+
await toolRow("rename_greet").waitFor();
81+
});
82+
83+
await step("Run rename_greet — the server renames its catalog mid-call", async () => {
84+
await toolRow("rename_greet").click();
85+
await page.getByRole("tab", { name: "Run" }).click();
86+
await page.getByRole("button", { name: "Run", exact: true }).click();
87+
// The result card proves the call reached the server (the payload
88+
// renders inside a highlighted code block, so assert the badge);
89+
// the server pushed list_changed on the same open connection.
90+
await page.getByText("Result").first().waitFor();
91+
await page.getByText("Success").first().waitFor();
92+
});
93+
94+
await step("Revisit Tools — the catalog followed the rename by itself", async () => {
95+
// Re-enter the page: a fresh tools read. The list_changed the server
96+
// sent during the call marked the catalog stale, so THIS read
97+
// re-lists — the renamed tool appears with no Refresh click.
98+
await page.reload({ waitUntil: "networkidle" });
99+
await page.getByRole("tab", { name: "Tools" }).click();
100+
await filterTools("greet");
101+
await toolRow(mutable.renamedToolName).waitFor({ timeout: 30_000 });
102+
});
103+
104+
const staleToolStillListed = await toolRow(mutable.initialToolName)
105+
.isVisible()
106+
.catch(() => false);
107+
expect(staleToolStillListed, "the retired tool left the catalog").toBe(false);
108+
});
109+
}),
110+
),
111+
);

packages/plugins/mcp/src/testing/server.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,12 @@ export const makeElicitationMcpServer = () => {
468468
*/
469469
export const makeMutableCatalogMcpServer = (
470470
options: {
471+
readonly name?: string;
471472
readonly initialToolName?: string;
472473
readonly renamedToolName?: string;
473474
} = {},
474475
) => {
476+
const serverName = options.name ?? "mutable-catalog-test-server";
475477
const initialToolName = options.initialToolName ?? "greet";
476478
const renamedToolName = options.renamedToolName ?? "greet_v2";
477479
const registrations = new Set<{ update: (updates: { name: string }) => void }>();
@@ -485,10 +487,7 @@ export const makeMutableCatalogMcpServer = (
485487
};
486488

487489
const factory = () => {
488-
const server = new McpServer(
489-
{ name: "mutable-catalog-test-server", version: "1.0.0" },
490-
{ capabilities: {} },
491-
);
490+
const server = new McpServer({ name: serverName, version: "1.0.0" }, { capabilities: {} });
492491
const registered = server.registerTool(
493492
currentToolName,
494493
{

0 commit comments

Comments
 (0)