Skip to content

Commit 1c48182

Browse files
authored
fix(mcp): keep tool catalogs in sync with the server's live tool set (#1246)
* fix(mcp): keep tool catalogs in sync with the server's live tool set A connection's tools were listed once at create time and only re-listed on an executor-side config edit or a manual Refresh, so server-side catalog changes (tools added, removed, renamed) silently broke invocations and never surfaced new tools. Spec-compliance and staleness fixes: - Follow nextCursor pagination on tools/list: discovery previously kept only the first page of a paginated catalog. - Handle notifications/tools/list_changed received during a call window: the connection's persisted catalog is marked stale (tools_synced_at cleared) and re-listed on the next tools read. - Detect unknown-tool rejections on tools/call, in both wire shapes (the spec's -32602 protocol error and the reference TS SDK's isError envelope), anchored to the exact tool name. The call answers a typed mcp_tool_unknown failure telling the caller to re-list, and the catalog heals on the next read. - Re-list remote catalogs on read once older than a freshness TTL (ExecutorConfig.toolsSyncTtlMs, default 15 minutes, null disables), covering servers that change tools with no notification. Plugins opt in via the new remoteToolCatalog flag; MCP sets it. - Treat failed listings as non-authoritative (ResolveToolsResult .incomplete): keep the previously persisted catalog instead of wiping working tools over a transient outage, and stamp the sync time so a down server isn't re-dialed on every read. Core additions: ctx.connections.markToolsStale (deferred refresh for mid-invocation signals) and a bounded stale-scan in toolsList that only widens with the TTL when a loaded plugin actually declares a remote catalog. * test(e2e): prove MCP tool-catalog sync end-to-end Four cross-target scenarios (cloud + selfhost) driving only public surfaces (typed API + sandbox executions) against real MCP servers whose catalogs mutate mid-scenario: - list_changed: a tool call renames the server's catalog mid-call; the notification rides the open connection and the very next tools read re-lists — no refresh click, no failing retry. - unknown-tool self-heal: the catalog mutates outside any call window (no notification available); invoking the retired tool fails with the typed mcp_tool_unknown error telling the agent to re-list, and that failure alone heals the catalog for the next read. - pagination: a server paging tools/list via nextCursor registers its whole catalog, not just the first page. - outage resilience: a refresh against a dead server keeps the working catalog instead of wiping it, and converges to the server's new catalog once it recovers. The mutable-catalog test fixture now also sends list_changed through the tool handler's request-scoped notifier: the SDK's RegisteredTool.update emits it with no relatedRequestId, which the streamable-http transport routes to the standalone GET SSE stream — a stream a request-scoped client may never hold open. Stamping the request id routes it onto the in-flight call's response stream, matching how a real server notifies mid-call. * 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 5225f85 commit 1c48182

13 files changed

Lines changed: 1330 additions & 63 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@executor-js/plugin-mcp": patch
3+
"@executor-js/sdk": patch
4+
---
5+
6+
Keep MCP tool catalogs in sync with the server's live tool set. Previously a
7+
connection's tools were listed once at create time and never updated unless the
8+
integration's config changed or a user clicked Refresh, so server-side tool
9+
changes silently broke invocations.
10+
11+
- `tools/list` discovery now follows `nextCursor` pagination per the MCP spec,
12+
so servers with paginated catalogs list completely instead of first-page-only.
13+
- The client handles `notifications/tools/list_changed` received during a tool
14+
call and marks the connection's persisted catalog stale; the next tools read
15+
re-lists from the server.
16+
- An unknown-tool rejection from the server (protocol error or the reference
17+
SDK's error envelope) returns a typed `mcp_tool_unknown` failure telling the
18+
caller to re-list, and marks the catalog stale so it heals on the next read.
19+
- Remote catalogs now also refresh on read once older than a freshness TTL
20+
(`ExecutorConfig.toolsSyncTtlMs`, default 15 minutes, `null` to disable),
21+
covering servers that change tools without notifying.
22+
- A failed listing (server unreachable, auth not ready) no longer wipes the
23+
previously persisted catalog; it is kept and retried after the TTL.
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+
);

0 commit comments

Comments
 (0)