History

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.

Rhys Sullivan committed 4 months ago

Commit 1c481821 parent 5225f8513 files · +1330 −63Browse files

Unified · Split

13 files
  1. .changeset/mcp-tool-catalog-sync.md +23 −0
  2. e2e/scenarios/mcp-catalog-sync-ui.test.ts +111 −0
  3. e2e/scenarios/mcp-catalog-sync.test.ts +526 −0
  4. packages/core/sdk/src/executor.ts +114 −19
  5. packages/core/sdk/src/plugin.ts +21 −0
  6. packages/plugins/mcp/src/sdk/catalog-sync.test.ts +250 −0
  7. packages/plugins/mcp/src/sdk/discover.ts +57 −28
  8. packages/plugins/mcp/src/sdk/errors.ts +3 −0
  9. packages/plugins/mcp/src/sdk/invoke.ts +64 −3
  10. packages/plugins/mcp/src/sdk/manifest.ts +16 −2
  11. packages/plugins/mcp/src/sdk/plugin.ts +78 −11
  12. packages/plugins/mcp/src/testing/index.ts +1 −0
  13. packages/plugins/mcp/src/testing/server.ts +66 −0
.changeset/mcp-tool-catalog-sync.md+23−0
@@ -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.
e2e/scenarios/mcp-catalog-sync-ui.test.ts+111−0
@@ -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+);
e2e/scenarios/mcp-catalog-sync.test.ts+526−0
@@ -0,0 +1,526 @@
1+// Cross-target: MCP tool-catalog freshness — the "the server changed its tools
2+// and executor noticed" promise. A connection's catalog used to be listed once
3+// at create time and never again: a server-side rename left stale tools failing
4+// forever and new tools invisible until a human clicked Refresh. These journeys
5+// prove the catalog now converges on its own, driven only through public
6+// surfaces (typed API + sandbox executions) against real MCP servers whose
7+// catalogs mutate mid-scenario:
8+//
9+// 1. list_changed — a tool call mutates the server's catalog mid-call; the
10+// server pushes `notifications/tools/list_changed` on the open connection
11+// and the very next tools read re-lists. No refresh, no second failing
12+// invocation: the notification alone drives convergence.
13+// 2. unknown-tool self-heal — the catalog mutates OUTSIDE any call window
14+// (no notification to react to). Invoking the retired tool fails with the
15+// typed `mcp_tool_unknown` error telling the agent to re-list, and that
16+// failure alone heals the catalog for the next read.
17+// 3. pagination — a server that pages `tools/list` with `nextCursor` gets
18+// its WHOLE catalog registered, not just the first page.
19+// 4. outage resilience — a re-list against a dead server keeps the
20+// previously working catalog instead of wiping it, and converges to the
21+// server's new catalog once it comes back.
22+import { randomBytes } from "node:crypto";
23+import { createServer } from "node:http";
24+
25+import { expect } from "@effect/vitest";
26+import { Effect } from "effect";
27+import { composePluginApi } from "@executor-js/api/server";
28+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
29+import { makeMutableCatalogMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
30+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
31+
32+import { scenario } from "../src/scenario";
33+import { Api, Target } from "../src/services";
34+
35+const api = composePluginApi([mcpHttpPlugin()] as const);
36+
37+const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`;
38+
39+// Sandbox code for the agent path: call one addressed MCP tool and return the
40+// ToolResult envelope fields the assertions need. Tool failures are values in
41+// the sandbox (`{ ok: false, error }`), not exceptions.
42+const invokeToolCode = (slug: string, connection: string, tool: string, args: unknown) => `
43+const result = await tools.${slug}.org.${connection}.${tool}(${JSON.stringify(args)});
44+return { ok: result.ok, payload: result.ok ? result.data : result.error };
45+`;
46+
47+type SandboxToolOutcome = {
48+ readonly ok: boolean;
49+ readonly payload?: {
50+ readonly code?: string;
51+ readonly message?: string;
52+ };
53+};
54+
55+// ---------------------------------------------------------------------------
56+// A minimal streamable-http MCP fixture with a MUTABLE catalog and a kill
57+// switch, for the journeys the SDK test server can't express: multi-page
58+// tools/list responses and a server that goes down between listings. Speaks
59+// just enough JSON-RPC for the discovery path (initialize, notifications/*,
60+// tools/list).
61+// ---------------------------------------------------------------------------
62+
63+type PagedMcpFixture = {
64+ readonly url: string;
65+ /** Replace the catalog; each inner array is one tools/list page. */
66+ readonly setPages: (pages: readonly (readonly string[])[]) => void;
67+ /** true → every request answers 503, as an unreachable server would. */
68+ readonly setDead: (dead: boolean) => void;
69+};
70+
71+const servePagedMcpFixture = (initialPages: readonly (readonly string[])[]) =>
72+ Effect.acquireRelease(
73+ Effect.callback<PagedMcpFixture & { readonly close: () => void }>((resume) => {
74+ let pages = initialPages;
75+ let dead = false;
76+
77+ const server = createServer((request, response) => {
78+ const respondJson = (status: number, body: unknown) => {
79+ response.writeHead(status, { "content-type": "application/json" });
80+ response.end(JSON.stringify(body));
81+ };
82+
83+ if (dead) {
84+ response.writeHead(503, { "content-type": "text/plain" });
85+ response.end("fixture is down");
86+ return;
87+ }
88+ if (request.method === "GET") {
89+ // 405 = "no standalone SSE stream" — an expected shape the client
90+ // handles without error.
91+ response.writeHead(405, { "content-type": "text/plain" });
92+ response.end("SSE disabled");
93+ return;
94+ }
95+
96+ let body = "";
97+ request.on("data", (chunk: unknown) => {
98+ body += String(chunk);
99+ });
100+ request.on("end", () => {
101+ const rpc = JSON.parse(body) as {
102+ readonly id?: string | number | null;
103+ readonly method?: string;
104+ readonly params?: { readonly cursor?: string };
105+ };
106+
107+ if (rpc.method === "initialize") {
108+ respondJson(200, {
109+ jsonrpc: "2.0",
110+ id: rpc.id ?? null,
111+ result: {
112+ protocolVersion: "2025-06-18",
113+ capabilities: { tools: { listChanged: true } },
114+ serverInfo: { name: "paged-catalog-fixture", version: "1.0.0" },
115+ },
116+ });
117+ return;
118+ }
119+ if (rpc.method?.startsWith("notifications/")) {
120+ response.writeHead(202);
121+ response.end();
122+ return;
123+ }
124+ if (rpc.method === "tools/list") {
125+ const cursor = rpc.params?.cursor;
126+ const index = cursor === undefined ? 0 : Number(cursor.replace("page-", ""));
127+ const page = pages[index] ?? [];
128+ const nextCursor = index + 1 < pages.length ? `page-${index + 1}` : undefined;
129+ respondJson(200, {
130+ jsonrpc: "2.0",
131+ id: rpc.id ?? null,
132+ result: {
133+ tools: page.map((name) => ({
134+ name,
135+ description: `Tool ${name}`,
136+ inputSchema: { type: "object", properties: {} },
137+ })),
138+ ...(nextCursor === undefined ? {} : { nextCursor }),
139+ },
140+ });
141+ return;
142+ }
143+ respondJson(200, {
144+ jsonrpc: "2.0",
145+ id: rpc.id ?? null,
146+ error: { code: -32601, message: `Method not found: ${rpc.method}` },
147+ });
148+ });
149+ });
150+
151+ server.listen(0, "127.0.0.1", () => {
152+ const address = server.address();
153+ const port = typeof address === "object" && address ? address.port : 0;
154+ resume(
155+ Effect.succeed({
156+ url: `http://127.0.0.1:${port}/mcp`,
157+ setPages: (next: readonly (readonly string[])[]) => {
158+ pages = next;
159+ },
160+ setDead: (next: boolean) => {
161+ dead = next;
162+ },
163+ close: () => {
164+ server.close();
165+ server.closeAllConnections();
166+ },
167+ }),
168+ );
169+ });
170+ }),
171+ (fixture) => Effect.sync(fixture.close),
172+ );
173+
174+// ---------------------------------------------------------------------------
175+// 1. list_changed received during a call window → next read re-lists
176+// ---------------------------------------------------------------------------
177+
178+scenario(
179+ "MCP catalog · a list_changed notification during a call refreshes the catalog on the next read",
180+ {},
181+ Effect.scoped(
182+ Effect.gen(function* () {
183+ const target = yield* Target;
184+ const { client: makeApiClient } = yield* Api;
185+ const identity = yield* target.newIdentity();
186+ const client = yield* makeApiClient(api, identity);
187+ const slug = freshSlug("mcp_notify");
188+
189+ // A real MCP server (SDK McpServer over streamable-http) whose
190+ // `rename_greet` tool renames `greet` → `greet_v2` mid-call, emitting
191+ // `notifications/tools/list_changed` on the open connection.
192+ const mutable = makeMutableCatalogMcpServer();
193+ const server = yield* serveMcpServer(mutable.factory);
194+
195+ yield* client.mcp.addServer({
196+ payload: { transport: "remote", name: "Mutable catalog MCP", endpoint: server.url, slug },
197+ });
198+
199+ yield* Effect.gen(function* () {
200+ yield* client.connections.create({
201+ payload: {
202+ owner: "org",
203+ name: ConnectionName.make("main"),
204+ integration: IntegrationSlug.make(slug),
205+ template: AuthTemplateSlug.make("none"),
206+ value: "",
207+ },
208+ });
209+
210+ const toolNames = Effect.map(
211+ client.tools.list({ query: { integration: IntegrationSlug.make(slug) } }),
212+ (tools) => tools.map((tool) => String(tool.name)).sort(),
213+ );
214+
215+ expect(yield* toolNames, "the initial catalog holds the v1 tool").toEqual([
216+ mutable.initialToolName,
217+ "rename_greet",
218+ ]);
219+
220+ // The catalog mutates DURING this call; the notification arrives on
221+ // the same connection the call rides.
222+ const executed = yield* client.executions.execute({
223+ payload: { code: invokeToolCode(slug, "main", "rename_greet", {}), autoApprove: true },
224+ });
225+ expect(executed.status, "the mutating call completed").toBe("completed");
226+ const outcome = JSON.parse(executed.text) as SandboxToolOutcome;
227+ expect(outcome.ok, executed.text).toBe(true);
228+
229+ // THE promise: no refresh click, no failing retry — the very next
230+ // tools read already serves the renamed catalog.
231+ expect(yield* toolNames, "the next read follows the notification").toEqual([
232+ mutable.renamedToolName,
233+ "rename_greet",
234+ ]);
235+ }).pipe(
236+ Effect.ensuring(
237+ Effect.gen(function* () {
238+ yield* client.connections
239+ .remove({
240+ params: {
241+ owner: "org",
242+ integration: IntegrationSlug.make(slug),
243+ name: ConnectionName.make("main"),
244+ },
245+ })
246+ .pipe(Effect.ignore);
247+ yield* client.mcp
248+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
249+ .pipe(Effect.ignore);
250+ }),
251+ ),
252+ );
253+ }),
254+ ),
255+);
256+
257+// ---------------------------------------------------------------------------
258+// 2. drift with NO notification → typed unknown-tool failure → self-heal
259+// ---------------------------------------------------------------------------
260+
261+scenario(
262+ "MCP catalog · calling a tool the server retired fails typed and heals the catalog",
263+ {},
264+ Effect.scoped(
265+ Effect.gen(function* () {
266+ const target = yield* Target;
267+ const { client: makeApiClient } = yield* Api;
268+ const identity = yield* target.newIdentity();
269+ const client = yield* makeApiClient(api, identity);
270+ const slug = freshSlug("mcp_drift");
271+
272+ const mutable = makeMutableCatalogMcpServer();
273+ const server = yield* serveMcpServer(mutable.factory);
274+
275+ yield* client.mcp.addServer({
276+ payload: { transport: "remote", name: "Drifting catalog MCP", endpoint: server.url, slug },
277+ });
278+
279+ yield* Effect.gen(function* () {
280+ yield* client.connections.create({
281+ payload: {
282+ owner: "org",
283+ name: ConnectionName.make("main"),
284+ integration: IntegrationSlug.make(slug),
285+ template: AuthTemplateSlug.make("none"),
286+ value: "",
287+ },
288+ });
289+
290+ const toolNames = Effect.map(
291+ client.tools.list({ query: { integration: IntegrationSlug.make(slug) } }),
292+ (tools) => tools.map((tool) => String(tool.name)).sort(),
293+ );
294+
295+ expect(yield* toolNames, "the catalog was registered against v1").toContain(
296+ mutable.initialToolName,
297+ );
298+
299+ // The server owner ships a rename while executor holds NO connection —
300+ // there is no notification to react to; executor's catalog is drifted
301+ // and it cannot know yet.
302+ mutable.renameTool();
303+
304+ // An agent (trusting the stale catalog) calls the retired tool. The
305+ // spec answer is an unknown-tool error; executor must surface it as a
306+ // typed, actionable failure — not a generic error the agent retries
307+ // blindly.
308+ const executed = yield* client.executions.execute({
309+ payload: {
310+ code: invokeToolCode(slug, "main", mutable.initialToolName, { name: "world" }),
311+ autoApprove: true,
312+ },
313+ });
314+ expect(executed.status, "the failing call still completes the sandbox").toBe("completed");
315+ const outcome = JSON.parse(executed.text) as SandboxToolOutcome;
316+ expect(outcome.ok, "the stale invocation reports failure as a value").toBe(false);
317+ expect(outcome.payload?.code, "the failure is the typed drift error").toBe(
318+ "mcp_tool_unknown",
319+ );
320+ expect(
321+ outcome.payload?.message,
322+ "the message tells the agent to re-list, not retry",
323+ ).toContain("list tools again");
324+
325+ // The failure alone marked the catalog stale: the next read converges
326+ // with no refresh click and no waiting on a freshness window.
327+ const healed = yield* toolNames;
328+ expect(healed, "the healed catalog serves the renamed tool").toContain(
329+ mutable.renamedToolName,
330+ );
331+ expect(healed, "the retired tool is gone from the catalog").not.toContain(
332+ mutable.initialToolName,
333+ );
334+ }).pipe(
335+ Effect.ensuring(
336+ Effect.gen(function* () {
337+ yield* client.connections
338+ .remove({
339+ params: {
340+ owner: "org",
341+ integration: IntegrationSlug.make(slug),
342+ name: ConnectionName.make("main"),
343+ },
344+ })
345+ .pipe(Effect.ignore);
346+ yield* client.mcp
347+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
348+ .pipe(Effect.ignore);
349+ }),
350+ ),
351+ );
352+ }),
353+ ),
354+);
355+
356+// ---------------------------------------------------------------------------
357+// 3. paginated tools/list → the whole catalog registers, not page one
358+// ---------------------------------------------------------------------------
359+
360+scenario(
361+ "MCP catalog · a server that pages tools/list registers its whole catalog",
362+ {},
363+ Effect.scoped(
364+ Effect.gen(function* () {
365+ const target = yield* Target;
366+ const { client: makeApiClient } = yield* Api;
367+ const identity = yield* target.newIdentity();
368+ const client = yield* makeApiClient(api, identity);
369+ const slug = freshSlug("mcp_paged");
370+
371+ // Two pages: [alpha, beta] then [gamma]. A first-page-only client
372+ // registers two tools and silently loses gamma.
373+ const fixture = yield* servePagedMcpFixture([["alpha", "beta"], ["gamma"]]);
374+
375+ yield* client.mcp.addServer({
376+ payload: {
377+ transport: "remote",
378+ name: "Paged catalog MCP",
379+ endpoint: fixture.url,
380+ slug,
381+ remoteTransport: "streamable-http",
382+ },
383+ });
384+
385+ yield* Effect.gen(function* () {
386+ yield* client.connections.create({
387+ payload: {
388+ owner: "org",
389+ name: ConnectionName.make("main"),
390+ integration: IntegrationSlug.make(slug),
391+ template: AuthTemplateSlug.make("none"),
392+ value: "",
393+ },
394+ });
395+
396+ const tools = yield* client.tools.list({
397+ query: { integration: IntegrationSlug.make(slug) },
398+ });
399+ expect(
400+ tools.map((tool) => String(tool.name)).sort(),
401+ "every page of the catalog registered",
402+ ).toEqual(["alpha", "beta", "gamma"]);
403+ }).pipe(
404+ Effect.ensuring(
405+ Effect.gen(function* () {
406+ yield* client.connections
407+ .remove({
408+ params: {
409+ owner: "org",
410+ integration: IntegrationSlug.make(slug),
411+ name: ConnectionName.make("main"),
412+ },
413+ })
414+ .pipe(Effect.ignore);
415+ yield* client.mcp
416+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
417+ .pipe(Effect.ignore);
418+ }),
419+ ),
420+ );
421+ }),
422+ ),
423+);
424+
425+// ---------------------------------------------------------------------------
426+// 4. a re-list against a dead server keeps the working catalog
427+// ---------------------------------------------------------------------------
428+
429+scenario(
430+ "MCP catalog · an outage during refresh keeps the working catalog and recovery converges",
431+ {},
432+ Effect.scoped(
433+ Effect.gen(function* () {
434+ const target = yield* Target;
435+ const { client: makeApiClient } = yield* Api;
436+ const identity = yield* target.newIdentity();
437+ const client = yield* makeApiClient(api, identity);
438+ const slug = freshSlug("mcp_outage");
439+
440+ const fixture = yield* servePagedMcpFixture([["alpha", "beta"]]);
441+
442+ yield* client.mcp.addServer({
443+ payload: {
444+ transport: "remote",
445+ name: "Flaky MCP",
446+ endpoint: fixture.url,
447+ slug,
448+ remoteTransport: "streamable-http",
449+ },
450+ });
451+
452+ yield* Effect.gen(function* () {
453+ yield* client.connections.create({
454+ payload: {
455+ owner: "org",
456+ name: ConnectionName.make("main"),
457+ integration: IntegrationSlug.make(slug),
458+ template: AuthTemplateSlug.make("none"),
459+ value: "",
460+ },
461+ });
462+
463+ const connectionParams = {
464+ owner: "org",
465+ integration: IntegrationSlug.make(slug),
466+ name: ConnectionName.make("main"),
467+ } as const;
468+ const toolNames = Effect.map(
469+ client.tools.list({ query: { integration: IntegrationSlug.make(slug) } }),
470+ (tools) => tools.map((tool) => String(tool.name)).sort(),
471+ );
472+
473+ expect(yield* toolNames, "the catalog registered while the server was up").toEqual([
474+ "alpha",
475+ "beta",
476+ ]);
477+
478+ // The server goes down; a refresh (the UI button / agent tool path)
479+ // re-lists against a dead endpoint. The listing is non-authoritative:
480+ // it must NOT wipe the working catalog.
481+ fixture.setDead(true);
482+ const refreshedWhileDown = yield* client.connections.refresh({
483+ params: connectionParams,
484+ });
485+ expect(
486+ refreshedWhileDown.map((tool) => String(tool.name)).sort(),
487+ "refresh during the outage answers the kept catalog",
488+ ).toEqual(["alpha", "beta"]);
489+ expect(yield* toolNames, "the outage never wiped the working tools").toEqual([
490+ "alpha",
491+ "beta",
492+ ]);
493+
494+ // The server comes back CHANGED (beta retired, delta added). A
495+ // refresh now converges to the live catalog.
496+ fixture.setDead(false);
497+ fixture.setPages([["alpha", "delta"]]);
498+ const refreshedAfterRecovery = yield* client.connections.refresh({
499+ params: connectionParams,
500+ });
501+ expect(
502+ refreshedAfterRecovery.map((tool) => String(tool.name)).sort(),
503+ "refresh after recovery serves the server's new catalog",
504+ ).toEqual(["alpha", "delta"]);
505+ expect(yield* toolNames, "the read surface follows").toEqual(["alpha", "delta"]);
506+ }).pipe(
507+ Effect.ensuring(
508+ Effect.gen(function* () {
509+ yield* client.connections
510+ .remove({
511+ params: {
512+ owner: "org",
513+ integration: IntegrationSlug.make(slug),
514+ name: ConnectionName.make("main"),
515+ },
516+ })
517+ .pipe(Effect.ignore);
518+ yield* client.mcp
519+ .removeServer({ params: { slug: IntegrationSlug.make(slug) } })
520+ .pipe(Effect.ignore);
521+ }),
522+ ),
523+ );
524+ }),
525+ ),
526+);
packages/core/sdk/src/executor.ts+114−19
@@ -389,8 +389,21 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 388 unmodified lines
389389 readonly orgSlug?: string;
390390 readonly includeProviders?: boolean;
391391 };
392+ /**
393+ * How long a connection's persisted tool catalog stays fresh when its plugin
394+ * lists a live remote catalog (`plugin.remoteToolCatalog`, e.g. MCP servers,
395+ * whose tool sets change server-side with no executor-visible signal). Once
396+ * older than this, the catalog is re-listed on the next tools read. Defaults
397+ * to 15 minutes; pass `null` to disable time-based re-sync (stale-mark and
398+ * config-revision re-sync still apply).
399+ */
400+ readonly toolsSyncTtlMs?: number | null;
392401 }
393402
403+/** Default freshness window for remote-catalog connections (see
404+ * `ExecutorConfig.toolsSyncTtlMs`). */
405+export const DEFAULT_TOOLS_SYNC_TTL_MS = 15 * 60 * 1000;
406+
394407 // ---------------------------------------------------------------------------
395408 // collectTables — return the executor-owned Fuma table set. Plugins persist
396409 // through host-owned facades (`pluginStorage`, `blobs`) instead of contributing
@@ -1955,6 +1968,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1558 unmodified lines
19551968 ),
19561969 );
19571970
1971+ if (result.incomplete === true) {
1972+ // Non-authoritative listing (source unreachable, auth not ready).
1973+ // Keep the existing catalog — replacing it would wipe working tools
1974+ // over a transient outage — and stamp the sync time anyway so a down
1975+ // server isn't re-dialed on every read; the freshness TTL re-attempts
1976+ // later.
1977+ yield* stampSynced;
1978+ const keptRows = yield* core.findMany("tool", { where });
1979+ return keptRows.map((row) => rowToTool(row as ConnectionToolRow));
1980+ }
1981+
19581982 const now = new Date();
19591983 const toolRows = result.tools.map((tool: ToolDef) => ({
19601984 tenant: keys.tenant,
@@ -2448,6 +2472,21 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 487 unmodified lines
24482472 return yield* produceConnectionTools(integrationRow, ref);
24492473 });
24502474
2475+ // Clear the sync stamp so the next tools read re-produces this connection's
2476+ // catalog. The deferred variant of `connectionsRefresh` for signals that
2477+ // arrive mid-invocation (an MCP `notifications/tools/list_changed`, an
2478+ // unknown-tool rejection) where re-listing inline would block the caller.
2479+ const connectionsMarkToolsStale = (ref: ConnectionRef): Effect.Effect<void, StorageFailure> =>
2480+ core.updateMany("connection", {
2481+ where: (b: AnyCb) =>
2482+ b.and(
2483+ byOwner(ref.owner)(b),
2484+ b("integration", "=", String(ref.integration)),
2485+ b("name", "=", String(ref.name)),
2486+ ),
2487+ set: { tools_synced_at: null },
2488+ });
2489+
24512490 // ------------------------------------------------------------------
24522491 // Active policy source.
24532492 // ------------------------------------------------------------------
@@ -2561,31 +2600,86 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 107 unmodified lines
25612600 return true;
25622601 };
25632602
2564− // Rebuild any visible connection whose tool catalog predates its
2565− // integration's last tool-affecting config change. The change's author
2566− // could only rewrite catalogs in their own partition (owner policy);
2567− // every other subject converges here, on their own read, under their own
2568− // binding. Best-effort: a failed rebuild leaves the stale-but-working
2569− // catalog in place and retries on the next read.
2603+ // How long a remote-catalog connection's persisted tools stay fresh
2604+ // (`ExecutorConfig.toolsSyncTtlMs`; `null` disables time-based re-sync).
2605+ const toolsSyncTtlMs =
2606+ config.toolsSyncTtlMs === undefined ? DEFAULT_TOOLS_SYNC_TTL_MS : config.toolsSyncTtlMs;
2607+
2608+ // Rebuild any visible connection whose persisted tool catalog is stale.
2609+ // Three triggers:
2610+ // - stale-marked: `tools_synced_at` is NULL (`connections.markToolsStale`
2611+ // — an MCP `tools/list_changed` notification or unknown-tool rejection
2612+ // cleared it mid-invocation);
2613+ // - config-revised: the integration's last tool-affecting config change
2614+ // postdates the connection's catalog. The change's author could only
2615+ // rewrite catalogs in their own partition (owner policy); every other
2616+ // subject converges here, on their own read, under their own binding;
2617+ // - expired: the plugin lists a live remote catalog (an MCP server, whose
2618+ // tool set can change with no executor-visible signal) and the catalog
2619+ // is older than the freshness TTL.
2620+ // Best-effort: a failed rebuild leaves the stale-but-working catalog in
2621+ // place and retries on the next read.
25702622 const syncStaleConnectionTools = Effect.gen(function* () {
2571− const revised = yield* core.findMany("integration", {
2572− where: (b: AnyCb) => b.isNotNull("config_revised_at"),
2573− });
2574− if (revised.length === 0) return;
2575− const revisedAt = new Map(
2576− revised.map((row) => [row.slug, Number(row.config_revised_at)] as const),
2623+ const integrations = yield* core.findMany("integration", {});
2624+ if (integrations.length === 0) return;
2625+ const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const));
2626+ // The TTL only matters when a loaded plugin actually lists a live remote
2627+ // catalog; otherwise skip it so age alone never widens the stale query.
2628+ const anyRemoteCatalog = Array.from(runtimes.values()).some(
2629+ (runtime) => runtime.plugin.remoteToolCatalog === true,
25772630 );
2631+ const cutoff =
2632+ toolsSyncTtlMs == null || !anyRemoteCatalog ? null : Date.now() - toolsSyncTtlMs;
2633+
2634+ // Bound the scan to potentially-stale rows: stale-marked (NULL stamp) or
2635+ // synced before the latest instant any trigger could fire at (the TTL
2636+ // cutoff / the newest config revision). Per-row trigger checks below
2637+ // re-verify against each row's own integration; in steady state this
2638+ // query returns nothing and the read pays one indexed lookup.
2639+ const latestRevision = integrations.reduce<number | null>(
2640+ (max, row) =>
2641+ row.config_revised_at == null
2642+ ? max
2643+ : Math.max(max ?? Number(row.config_revised_at), Number(row.config_revised_at)),
2644+ null,
2645+ );
2646+ const staleBefore =
2647+ cutoff === null && latestRevision === null
2648+ ? null
2649+ : Math.max(cutoff ?? Number.MIN_SAFE_INTEGER, latestRevision ?? Number.MIN_SAFE_INTEGER);
2650+
25782651 const connections = yield* core.findMany("connection", {
2579− where: (b: AnyCb) => b.or(...revised.map((row) => b("integration", "=", row.slug))),
2652+ where: (b: AnyCb) =>
2653+ staleBefore === null
2654+ ? b.isNull("tools_synced_at")
2655+ : b.or(b.isNull("tools_synced_at"), b("tools_synced_at", "<", staleBefore)),
25802656 });
25812657 for (const connection of connections) {
2582− const revisedTime = revisedAt.get(connection.integration);
2583− if (revisedTime === undefined) continue;
2584− const syncedAt =
2585− connection.tools_synced_at == null ? 0 : Number(connection.tools_synced_at);
2586− if (syncedAt >= revisedTime) continue;
2587− const integrationRow = revised.find((row) => row.slug === connection.integration);
2658+ const integrationRow = integrationBySlug.get(connection.integration);
25882659 if (!integrationRow) continue;
2660+ const runtime = runtimes.get(integrationRow.plugin_id);
2661+ // Only re-produce catalogs this executor can actually re-list —
2662+ // rebuilding under an unloaded plugin would clear a working catalog.
2663+ // (A loaded plugin without `resolveTools` still flows through:
2664+ // `produceConnectionTools` runs its clear-and-stamp cleanup path.)
2665+ if (!runtime) continue;
2666+
2667+ const syncedAt =
2668+ connection.tools_synced_at == null ? null : Number(connection.tools_synced_at);
2669+ const revisedTime =
2670+ integrationRow.config_revised_at == null
2671+ ? null
2672+ : Number(integrationRow.config_revised_at);
2673+
2674+ const staleMarked = syncedAt === null;
2675+ const configRevised = revisedTime !== null && (syncedAt ?? 0) < revisedTime;
2676+ const expired =
2677+ cutoff !== null &&
2678+ runtime.plugin.remoteToolCatalog === true &&
2679+ syncedAt !== null &&
2680+ syncedAt < cutoff;
2681+ if (!staleMarked && !configRevised && !expired) continue;
2682+
25892683 yield* produceConnectionTools(integrationRow, {
25902684 owner: connection.owner as Owner,
25912685 integration: IntegrationSlug.make(connection.integration),
@@ -3339,6 +3433,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 747 unmodified lines
33393433 update: (ref, input) => connectionsUpdate(ref, input),
33403434 remove: (ref) => connectionsRemove(ref),
33413435 refresh: (ref) => connectionsRefresh(ref),
3436+ markToolsStale: (ref) => connectionsMarkToolsStale(ref),
33423437 resolveValue: (ref) => resolveConnectionValueByRef(ref),
33433438 },
33443439 providers: {
packages/core/sdk/src/plugin.ts+21−0
@@ -219,6 +219,12 @@ export interface PluginCtx<TStore = unknown> { · 218 unmodified lines
219219 readonly Tool[],
220220 ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure
221221 >;
222+ /** Mark a connection's persisted tool catalog stale (clears its sync
223+ * stamp) without re-listing inline. The next tools read re-produces it.
224+ * For signals that arrive mid-invocation — e.g. an MCP server sending
225+ * `notifications/tools/list_changed` or rejecting a call as an unknown
226+ * tool — where an inline `refresh` would block the caller. */
227+ readonly markToolsStale: (ref: ConnectionRef) => Effect.Effect<void, StorageFailure>;
222228 /** Resolve a connection's value through its provider (and OAuth refresh).
223229 * null if the provider can't produce one. */
224230 readonly resolveValue: (ref: ConnectionRef) => Effect.Effect<string | null, StorageFailure>;
@@ -276,6 +282,13 @@ export interface ResolveToolsResult { · 51 unmodified lines
276282 readonly tools: readonly ToolDef[];
277283 /** Shared JSON-schema `$defs` reachable from the tools' `$ref`s. */
278284 readonly definitions?: Record<string, unknown>;
285+ /** The source could not be (fully) enumerated: unreachable server, auth not
286+ * ready, listing aborted. The result is non-authoritative, so the executor
287+ * keeps the connection's existing persisted catalog instead of replacing it
288+ * (a transient outage must not wipe working tools). Omit / `false` when the
289+ * listing is authoritative, including a genuine "this source has zero
290+ * tools". */
291+ readonly incomplete?: boolean;
279292 }
280293
281294 // ---------------------------------------------------------------------------
@@ -527,6 +540,14 @@ export interface PluginSpec< · 245 unmodified lines
527540 input: ResolveToolsInput<TStore>,
528541 ) => Effect.Effect<ResolveToolsResult, StorageFailure>;
529542
543+ /** Declare that `resolveTools` lists a live remote catalog (an MCP server)
544+ * that can change without any executor-side config change. Core keeps such
545+ * connections fresh: their persisted tools are re-listed on a tools read
546+ * once older than the executor's `toolsSyncTtlMs`, in addition to the
547+ * config-revision and stale-mark triggers every plugin gets. Leave unset
548+ * for catalogs derived purely from stored state (specs, static config). */
549+ readonly remoteToolCatalog?: boolean;
550+
530551 /** Invoke a dynamic tool. Called when the static-handler map doesn't have the
531552 * address. The plugin applies `input.credential` to the outbound request. */
532553 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;
packages/plugins/mcp/src/sdk/catalog-sync.test.ts+250−0
@@ -0,0 +1,250 @@
1+// ---------------------------------------------------------------------------
2+// MCP tool-catalog freshness (end-to-end).
3+//
4+// The persisted per-connection tool catalog must converge with the server's
5+// live tool set. Spec inputs the executor reacts to:
6+// - `notifications/tools/list_changed` received during a call window marks
7+// the connection stale; the next tools read re-lists.
8+// - An unknown-tool protocol error (`-32602`, "Tool … not found") on
9+// `tools/call` means the catalog drifted: the call fails with a typed
10+// `mcp_tool_unknown` ToolResult and the catalog heals on the next read.
11+// - `tools/list` is paginated; discovery follows `nextCursor` to the end.
12+// - A failed listing (server unreachable) is non-authoritative: the
13+// previously persisted catalog is kept, not wiped.
14+// ---------------------------------------------------------------------------
15+
16+import { describe, expect, it } from "@effect/vitest";
17+import { Effect, Option, Schema } from "effect";
18+import { HttpServerResponse } from "effect/unstable/http";
19+
20+import {
21+ AuthTemplateSlug,
22+ ConnectionName,
23+ IntegrationSlug,
24+ ToolAddress,
25+ createExecutor,
26+} from "@executor-js/sdk";
27+import {
28+ makeTestConfig,
29+ memoryCredentialsPlugin,
30+ serveTestHttpApp,
31+} from "@executor-js/sdk/testing";
32+
33+import { mcpPlugin } from "./plugin";
34+import { createMcpConnector } from "./connection";
35+import { discoverTools } from "./discover";
36+import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing";
37+
38+const INTEG = IntegrationSlug.make("catalog_mcp");
39+const CONNECTION = ConnectionName.make("main");
40+const TEMPLATE = AuthTemplateSlug.make("none");
41+
42+const makeCatalogTestExecutor = (
43+ serverUrl: string,
44+ options?: { readonly toolsSyncTtlMs?: number | null },
45+) =>
46+ createExecutor({
47+ ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),
48+ ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }),
49+ }).pipe(
50+ Effect.tap((executor) =>
51+ Effect.gen(function* () {
52+ yield* executor.mcp.addServer({
53+ name: "catalog-mcp",
54+ endpoint: serverUrl,
55+ slug: String(INTEG),
56+ });
57+ yield* executor.connections.create({
58+ owner: "org",
59+ name: CONNECTION,
60+ integration: INTEG,
61+ template: TEMPLATE,
62+ value: "",
63+ });
64+ }),
65+ ),
66+ );
67+
68+const toolNames = (tools: readonly { readonly name: unknown }[]): readonly string[] =>
69+ tools.map((tool) => String(tool.name)).sort();
70+
71+describe("MCP tool-catalog sync (end-to-end)", () => {
72+ it.effect(
73+ "tools/list_changed during a call marks the catalog stale and the next read re-lists",
74+ () =>
75+ Effect.gen(function* () {
76+ const mutable = makeMutableCatalogMcpServer();
77+ const server = yield* serveMcpServer(mutable.factory);
78+ const executor = yield* makeCatalogTestExecutor(server.url);
79+
80+ expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);
81+
82+ // `rename_greet` renames the greet tool mid-call; the SDK server sends
83+ // `notifications/tools/list_changed` on the open connection, which the
84+ // invoke path records and turns into a stale mark after the call.
85+ const result = yield* executor.execute(
86+ ToolAddress.make(`tools.${String(INTEG)}.org.main.rename_greet`),
87+ {},
88+ );
89+ expect(result).toMatchObject({ ok: true });
90+
91+ // No manual refresh: the next tools read re-lists from the server.
92+ const refreshed = toolNames(yield* executor.tools.list());
93+ expect(refreshed).toContain(mutable.renamedToolName);
94+ expect(refreshed).not.toContain(mutable.initialToolName);
95+ }),
96+ );
97+
98+ it.effect(
99+ "unknown-tool rejection fails typed, marks stale, and the catalog heals on the next read",
100+ () =>
101+ Effect.gen(function* () {
102+ const mutable = makeMutableCatalogMcpServer();
103+ const server = yield* serveMcpServer(mutable.factory);
104+ // TTL disabled: only the unknown-tool signal may trigger the re-list.
105+ const executor = yield* makeCatalogTestExecutor(server.url, { toolsSyncTtlMs: null });
106+
107+ expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);
108+
109+ // Mutate the server catalog outside any executor call window — the
110+ // executor has no notification to react to and its catalog is drifted.
111+ mutable.renameTool();
112+
113+ const staleAddress = ToolAddress.make(
114+ `tools.${String(INTEG)}.org.main.${mutable.initialToolName}`,
115+ );
116+ const result = yield* executor.execute(staleAddress, { name: "world" });
117+ expect(result).toMatchObject({
118+ ok: false,
119+ error: { code: "mcp_tool_unknown" },
120+ });
121+
122+ // The failure marked the connection stale; the next read converges.
123+ const healed = toolNames(yield* executor.tools.list());
124+ expect(healed).toContain(mutable.renamedToolName);
125+ expect(healed).not.toContain(mutable.initialToolName);
126+ }),
127+ );
128+
129+ it.effect("expired catalogs re-list on read once older than the freshness TTL", () =>
130+ Effect.gen(function* () {
131+ const mutable = makeMutableCatalogMcpServer();
132+ const server = yield* serveMcpServer(mutable.factory);
133+ // Everything is instantly stale — every tools read re-lists.
134+ const executor = yield* makeCatalogTestExecutor(server.url, { toolsSyncTtlMs: 0 });
135+
136+ expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);
137+
138+ // Server-side change with no notification and no executor signal at all.
139+ mutable.renameTool();
140+
141+ const refreshed = toolNames(yield* executor.tools.list());
142+ expect(refreshed).toContain(mutable.renamedToolName);
143+ expect(refreshed).not.toContain(mutable.initialToolName);
144+ }),
145+ );
146+
147+ it.effect("a fresh catalog inside the TTL is served from the persisted rows", () =>
148+ Effect.gen(function* () {
149+ const mutable = makeMutableCatalogMcpServer();
150+ const server = yield* serveMcpServer(mutable.factory);
151+ const executor = yield* makeCatalogTestExecutor(server.url, {
152+ toolsSyncTtlMs: 60 * 60 * 1000,
153+ });
154+
155+ expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);
156+ const sessionsAfterFirstList = server.sessionCount();
157+
158+ mutable.renameTool();
159+
160+ // Within the TTL and with no stale signal, reads serve the persisted
161+ // catalog without dialing the server.
162+ expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);
163+ expect(server.sessionCount()).toBe(sessionsAfterFirstList);
164+ }),
165+ );
166+});
167+
168+// ---------------------------------------------------------------------------
169+// Pagination — discovery follows `nextCursor` across tools/list pages.
170+// ---------------------------------------------------------------------------
171+
172+const JsonRpcId = Schema.Union([Schema.String, Schema.Number, Schema.Null]);
173+const JsonRpcRequest = Schema.Struct({
174+ id: Schema.optional(JsonRpcId),
175+ method: Schema.String,
176+ params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
177+});
178+type JsonRpcRequest = typeof JsonRpcRequest.Type;
179+
180+const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(JsonRpcRequest));
181+
182+const jsonRpcResult = (request: JsonRpcRequest, result: unknown) =>
183+ HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: request.id ?? null, result });
184+
185+const pageTool = (name: string) => ({
186+ name,
187+ description: `Tool ${name}`,
188+ inputSchema: { type: "object", properties: {} },
189+});
190+
191+// A minimal paginated fixture: page1 → cursor "p2" → page2 → done.
192+const servePaginatedListServer = () =>
193+ serveTestHttpApp((request) =>
194+ Effect.gen(function* () {
195+ if (request.method === "GET") {
196+ return HttpServerResponse.text("SSE disabled", { status: 405 });
197+ }
198+ const body = yield* request.text.pipe(Effect.orDie);
199+ return Option.match(decodeJsonRpcRequest(body), {
200+ onNone: () => HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 }),
201+ onSome: (rpc) => {
202+ if (rpc.method === "initialize") {
203+ return jsonRpcResult(rpc, {
204+ protocolVersion: "2025-06-18",
205+ capabilities: { tools: { listChanged: true } },
206+ serverInfo: { name: "paginated-fixture", version: "1.0.0" },
207+ });
208+ }
209+ if (rpc.method === "notifications/initialized") {
210+ return HttpServerResponse.text("", { status: 202 });
211+ }
212+ if (rpc.method === "tools/list") {
213+ const cursor = rpc.params?.cursor;
214+ if (cursor === undefined) {
215+ return jsonRpcResult(rpc, {
216+ tools: [pageTool("alpha"), pageTool("beta")],
217+ nextCursor: "p2",
218+ });
219+ }
220+ if (cursor === "p2") {
221+ return jsonRpcResult(rpc, { tools: [pageTool("gamma")] });
222+ }
223+ return HttpServerResponse.text("Unknown cursor", { status: 400 });
224+ }
225+ return HttpServerResponse.text("Unexpected JSON-RPC method", { status: 400 });
226+ },
227+ });
228+ }),
229+ );
230+
231+describe("MCP tools/list pagination", () => {
232+ it.effect("discoverTools follows nextCursor across every page", () =>
233+ Effect.gen(function* () {
234+ const server = yield* servePaginatedListServer();
235+ const manifest = yield* discoverTools(
236+ createMcpConnector({
237+ transport: "remote",
238+ endpoint: server.url("/mcp"),
239+ remoteTransport: "streamable-http",
240+ }),
241+ );
242+
243+ expect(manifest.tools.map((tool) => tool.toolName).sort()).toEqual([
244+ "alpha",
245+ "beta",
246+ "gamma",
247+ ]);
248+ }),
249+ );
250+});
packages/plugins/mcp/src/sdk/discover.ts+57−28
@@ -2,20 +2,71 @@ · 1 unmodified line
22 // MCP tool discovery — connect to an MCP server and list its tools
33 // ---------------------------------------------------------------------------
44
5−import { Effect } from "effect";
5+import { Effect, Option } from "effect";
66
7−import type { McpConnector } from "./connection";
7+import type { McpConnection, McpConnector } from "./connection";
88 import { McpToolDiscoveryError } from "./errors";
99 import {
10+ decodeListToolsPage,
1011 extractManifestFromListToolsResult,
11− isListToolsResult,
1212 type McpToolManifest,
1313 } from "./manifest";
1414
15+// Backstop for a server that returns a cycling / never-terminating cursor.
16+// The spec puts no bound on page count; a compliant server terminates by
17+// omitting `nextCursor`, so any real catalog fits well inside this.
18+const MAX_LIST_TOOLS_PAGES = 100;
19+
1520 // ---------------------------------------------------------------------------
1621 // Public API
1722 // ---------------------------------------------------------------------------
1823
24+/**
25+ * List every tool from an open MCP connection, following `nextCursor`
26+ * pagination (spec: `tools/list` is a paginated operation — a single call
27+ * returns one page, not the catalog).
28+ */
29+const listAllTools = (
30+ connection: McpConnection,
31+): Effect.Effect<McpToolManifest, McpToolDiscoveryError> =>
32+ Effect.gen(function* () {
33+ const tools: unknown[] = [];
34+ let cursor: string | undefined = undefined;
35+
36+ for (let page = 0; page < MAX_LIST_TOOLS_PAGES; page++) {
37+ const params: { cursor?: string } | undefined = cursor === undefined ? undefined : { cursor };
38+ const listResult = yield* Effect.tryPromise({
39+ try: () => connection.client.listTools(params),
40+ catch: () =>
41+ new McpToolDiscoveryError({
42+ stage: "list_tools",
43+ message: "Failed listing MCP tools",
44+ }),
45+ });
46+
47+ const decoded = decodeListToolsPage(listResult);
48+ if (Option.isNone(decoded)) {
49+ return yield* new McpToolDiscoveryError({
50+ stage: "list_tools",
51+ message: "MCP listTools response did not match the expected schema",
52+ });
53+ }
54+
55+ tools.push(...decoded.value.tools);
56+ const nextCursor = decoded.value.nextCursor;
57+ if (nextCursor == null || nextCursor === "") break;
58+ cursor = nextCursor;
59+ }
60+
61+ return extractManifestFromListToolsResult(
62+ { tools },
63+ {
64+ serverInfo: connection.client.getServerVersion?.(),
65+ instructions: connection.client.getInstructions?.(),
66+ },
67+ );
68+ });
69+
1970 /**
2071 * Connect to an MCP server and discover all available tools.
2172 * Returns the parsed manifest containing server metadata and tool entries.
@@ -35,31 +86,9 @@ export const discoverTools = ( · 13 unmodified lines
3586 ),
3687 );
3788
38− // List tools
39− const listResult = yield* Effect.tryPromise({
40− try: () => connection.client.listTools(),
41− catch: () =>
42− new McpToolDiscoveryError({
43− stage: "list_tools",
44− message: "Failed listing MCP tools",
45− }),
46− });
47−
48− if (!isListToolsResult(listResult)) {
49− yield* closeConnection(connection);
50− return yield* new McpToolDiscoveryError({
51− stage: "list_tools",
52− message: "MCP listTools response did not match the expected schema",
53− });
54− }
55−
56− const manifest = extractManifestFromListToolsResult(listResult, {
57− serverInfo: connection.client.getServerVersion?.(),
58− instructions: connection.client.getInstructions?.(),
59− });
60−
61− // Close the connection after discovery
62− yield* closeConnection(connection);
89+ const manifest = yield* listAllTools(connection).pipe(
90+ Effect.onExit(() => closeConnection(connection)),
91+ );
6392
6493 return manifest;
6594 });
packages/plugins/mcp/src/sdk/errors.ts+3−0
@@ -28,6 +28,9 @@ export class McpInvocationError extends Data.TaggedError("McpInvocationError")<{ · 27 unmodified lines
2828 readonly toolName: string;
2929 readonly message: string;
3030 readonly status?: number;
31+ /** The server rejected the call as an unknown tool (protocol error), which
32+ * means the persisted catalog has drifted from the server's live tool set. */
33+ readonly unknownTool?: boolean;
3134 }> {}
3235
3336 export class McpOAuthReauthorizationRequired extends Data.TaggedError(
packages/plugins/mcp/src/sdk/invoke.ts+64−3
@@ -14,7 +14,12 @@ · 13 unmodified lines
1414 import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";
1515
1616 import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17−import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
17+import {
18+ ElicitRequestSchema,
19+ ErrorCode,
20+ McpError,
21+ ToolListChangedNotificationSchema,
22+} from "@modelcontextprotocol/sdk/types.js";
1823
1924 import {
2025 ElicitationId,
@@ -62,6 +67,31 @@ const statusFromStreamableHttpError = (cause: unknown): number | undefined => { · 41 unmodified lines
6267 const httpStatusFromCause = (cause: unknown): number | undefined =>
6368 statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);
6469
70+// The spec answers `tools/call` for a tool the server no longer advertises
71+// with a protocol error (`-32602 Invalid params`, example message
72+// "Unknown tool: …"); the reference TypeScript SDK server instead catches that
73+// error and returns it as an execution-error envelope (`isError: true`, text
74+// "Tool <name> not found"). Both shapes mean the same thing — the persisted
75+// catalog drifted — so both are detected, anchored to the exact tool name to
76+// keep a domain error that merely mentions "not found" from matching. A miss
77+// is benign (the catalog still heals via TTL or explicit refresh).
78+const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79+
80+export const isUnknownToolMessage = (message: string, toolName: string): boolean => {
81+ const name = escapeRegExp(toolName);
82+ return new RegExp(
83+ `(?:unknown tool:?\\s*"?${name}"?|tool\\s+"?${name}"?\\s+(?:not found|is not available|does not exist))`,
84+ "i",
85+ ).test(message);
86+};
87+
88+const isUnknownToolCause = (cause: unknown, toolName: string): boolean =>
89+ // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass
90+ cause instanceof McpError &&
91+ (cause.code === ErrorCode.InvalidParams || cause.code === ErrorCode.MethodNotFound) &&
92+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's McpError, whose message carries the only unknown-tool discriminator the protocol provides
93+ isUnknownToolMessage(cause.message, toolName);
94+
6595 // ---------------------------------------------------------------------------
6696 // Elicitation bridge — decode incoming MCP ElicitRequest, route through
6797 // the host's elicit function, marshal the response back to MCP shape.
@@ -128,7 +158,25 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic · 60 unmodified lines
128158 };
129159
130160 // ---------------------------------------------------------------------------
131−// Single tool call — install handler, callTool, return raw result
161+// tools/list_changed bridge — while a connection is open (the call window),
162+// listen for the spec's `notifications/tools/list_changed` and surface it to
163+// the host so it can mark the persisted catalog stale. Registering the handler
164+// is unconditional: it only fires if the server sends the notification, and a
165+// server that never does costs nothing.
166+// ---------------------------------------------------------------------------
167+
168+const installToolListChangedHandler = (
169+ client: McpConnection["client"],
170+ onToolListChanged: (() => void) | undefined,
171+): void => {
172+ if (!onToolListChanged) return;
173+ client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
174+ onToolListChanged();
175+ });
176+};
177+
178+// ---------------------------------------------------------------------------
179+// Single tool call — install handlers, callTool, return raw result
132180 // ---------------------------------------------------------------------------
133181
134182 const useConnection = (
@@ -136,9 +184,11 @@ const useConnection = ( · 1 unmodified line
136184 toolName: string,
137185 args: Record<string, unknown>,
138186 elicit: Elicit,
187+ onToolListChanged: (() => void) | undefined,
139188 ): Effect.Effect<unknown, McpInvocationError | McpOAuthReauthorizationRequired> =>
140189 Effect.gen(function* () {
141190 installElicitationHandler(connection.client, elicit);
191+ installToolListChangedHandler(connection.client, onToolListChanged);
142192 return yield* Effect.tryPromise({
143193 try: () => connection.client.callTool({ name: toolName, arguments: args }),
144194 catch: (cause) => {
@@ -152,6 +202,7 @@ const useConnection = ( · 7 unmodified lines
152202 toolName,
153203 message: `MCP tool call failed for ${toolName}`,
154204 ...(status === undefined ? {} : { status }),
205+ ...(isUnknownToolCause(cause, toolName) ? { unknownTool: true } : {}),
155206 });
156207 },
157208 }).pipe(
@@ -174,6 +225,10 @@ export interface InvokeMcpToolInput { · 16 unmodified lines
174225 /** Dials a fresh connection. The connection is closed after the call. */
175226 readonly connector: McpConnector;
176227 readonly elicit: Elicit;
228+ /** Fired when the server sends `notifications/tools/list_changed` during
229+ * the call window. Synchronous and non-throwing by contract; the caller
230+ * uses it to mark the persisted catalog stale. */
231+ readonly onToolListChanged?: () => void;
177232 }
178233
179234 export const invokeMcpTool = (
@@ -204,7 +259,13 @@ export const invokeMcpTool = ( · 24 unmodified lines
204259 ),
205260 );
206261
207− return yield* useConnection(connection, input.toolName, args, input.elicit);
262+ return yield* useConnection(
263+ connection,
264+ input.toolName,
265+ args,
266+ input.elicit,
267+ input.onToolListChanged,
268+ );
208269 }).pipe(
209270 Effect.scoped,
210271 Effect.withSpan("plugin.mcp.invoke", {
packages/plugins/mcp/src/sdk/manifest.ts+16−2
@@ -44,16 +44,30 @@ const ListToolsResult = Schema.Struct({ · 43 unmodified lines
4444 tools: Schema.Array(ListedTool),
4545 });
4646
47+// One page of a paginated `tools/list` response. Entries stay opaque here so a
48+// page with foreign tool shapes still pages correctly; per-entry decoding
49+// happens in `extractManifestFromListToolsResult` over the merged list.
50+const ListToolsPage = Schema.Struct({
51+ tools: Schema.Array(Schema.Unknown),
52+ nextCursor: Schema.optional(Schema.NullOr(Schema.String)),
53+});
54+
55+export interface McpListToolsPage {
56+ readonly tools: readonly unknown[];
57+ readonly nextCursor?: string | null;
58+}
59+
4760 const ServerInfo = Schema.Struct({
4861 name: Schema.optional(Schema.String),
4962 version: Schema.optional(Schema.String),
5063 });
5164
5265 const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult);
66+const decodeListToolsPageOption = Schema.decodeUnknownOption(ListToolsPage);
5367 const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo);
5468
55−export const isListToolsResult = (value: unknown): boolean =>
56− Option.isSome(decodeListToolsResult(value));
69+export const decodeListToolsPage = (value: unknown): Option.Option<McpListToolsPage> =>
70+ decodeListToolsPageOption(value);
5771
5872 // ---------------------------------------------------------------------------
5973 // Tool ID sanitization
packages/plugins/mcp/src/sdk/plugin.ts+78−11
@@ -45,7 +45,7 @@ import { · 44 unmodified lines
4545 McpOAuthReauthorizationRequired,
4646 McpToolDiscoveryError,
4747 } from "./errors";
48−import { invokeMcpTool } from "./invoke";
48+import { invokeMcpTool, isUnknownToolMessage } from "./invoke";
4949 import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest";
5050 import { mcpPresets } from "./presets";
5151 import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";
@@ -413,6 +413,22 @@ const extractMcpErrorMessage = (content: unknown): string => { · 361 unmodified lines
413413 return "MCP tool returned an error";
414414 };
415415
416+// The server no longer advertises this tool — the persisted catalog drifted.
417+// Answered after marking the connection stale so the next tools read re-lists;
418+// the message tells the caller to re-list instead of retrying blind.
419+const unknownToolFailure = (
420+ toolName: string,
421+ credential: { readonly integration: unknown; readonly connection: unknown },
422+) =>
423+ ToolResult.fail({
424+ code: "mcp_tool_unknown",
425+ message: `The MCP server no longer provides tool "${toolName}". Its tool catalog changed; list tools again for the current set.`,
426+ details: {
427+ integration: String(credential.integration),
428+ connection: String(credential.connection),
429+ },
430+ });
431+
416432 /** Match `token` as a separator-bounded run inside a URL hostname or path,
417433 * used as a low-confidence detection hint when wire-shape detection fails. */
418434 const urlMatchesToken = (url: URL, token: string): boolean => {
@@ -666,6 +682,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 247 unmodified lines
666682 return {
667683 id: MCP_PLUGIN_ID,
668684 packageName: "@executor-js/plugin-mcp",
685+ // MCP servers own their tool catalogs and change them server-side with no
686+ // executor-visible signal — opt into core's freshness TTL re-listing.
687+ remoteToolCatalog: true,
669688 integrationPresets: presetEntries,
670689 // Surfaced to the client bundle via the Vite plugin. The MCP `./client`
671690 // factory reads `allowStdio` and gates the stdio tab + presets.
@@ -1114,15 +1133,17 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 442 unmodified lines
11141133 // -----------------------------------------------------------------------
11151134 // Per-connection tool production. Dial the server using the connection's
11161135 // resolved value (rendered through the integration's auth template) and
1117− // list its tools. The real MCP tool name + upstream annotations are
1118− // stamped into each ToolDef's annotations so invokeTool can recover them.
1119− // Discovery failures (auth not ready, server down) yield an empty tool set
1120− // rather than failing — the connection still lands and can be refreshed.
1136+ // list its tools (following `nextCursor` pagination). The real MCP tool
1137+ // name + upstream annotations are stamped into each ToolDef's annotations
1138+ // so invokeTool can recover them. Discovery failures (auth not ready,
1139+ // server down) yield an `incomplete` empty result rather than failing —
1140+ // the connection still lands, and core keeps any previously persisted
1141+ // catalog instead of wiping it over a transient outage.
11211142 // -----------------------------------------------------------------------
11221143 resolveTools: ({ config, connection, template, getValues, httpClientLayer }) =>
11231144 Effect.gen(function* () {
11241145 const parsed = parseMcpIntegrationConfig(config);
1125− if (!parsed) return { tools: [] as readonly ToolDef[] };
1146+ if (!parsed) return { tools: [] as readonly ToolDef[], incomplete: true };
11261147
11271148 // Discovery tolerates unresolved credentials (an open server lists
11281149 // tools unauthenticated; a bad value just yields zero tools).
@@ -1151,13 +1172,18 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 22 unmodified lines
11511172 )
11521173 : { ok: false as const, manifest: null };
11531174
1154− const entries = manifest.ok && manifest.manifest ? manifest.manifest.tools : [];
1155− return { tools: entries.map(toToolDef) };
1175+ if (!manifest.ok || !manifest.manifest) {
1176+ return { tools: [] as readonly ToolDef[], incomplete: true };
1177+ }
1178+ return { tools: manifest.manifest.tools.map(toToolDef) };
11561179 }).pipe(
11571180 Effect.withSpan("mcp.plugin.resolve_tools", {
11581181 attributes: { "mcp.connection.name": String(connection.name) },
11591182 }),
1160− ) as Effect.Effect<{ readonly tools: readonly ToolDef[] }, StorageFailure>,
1183+ ) as Effect.Effect<
1184+ { readonly tools: readonly ToolDef[]; readonly incomplete?: boolean },
1185+ StorageFailure
1186+ >,
11611187
11621188 invokeTool: ({ ctx, toolRow, credential, args, elicit }) =>
11631189 Effect.gen(function* () {
@@ -1207,6 +1233,19 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 43 unmodified lines
12071233 options?.httpClientLayer ?? ctx.httpClientLayer,
12081234 ).pipe(Effect.map((ci) => createMcpConnector(ci)));
12091235
1236+ const connectionRef = {
1237+ owner: credential.owner,
1238+ integration: credential.integration,
1239+ name: credential.connection,
1240+ };
1241+
1242+ // Spec: a server whose tool list changed sends
1243+ // `notifications/tools/list_changed` on any open connection — the call
1244+ // window included. Record it here (the handler must be sync) and mark
1245+ // the persisted catalog stale after the call settles, so the next
1246+ // tools read re-lists instead of serving the drifted catalog.
1247+ let toolListChanged = false;
1248+
12101249 const raw = yield* invokeMcpTool({
12111250 toolId: String(toolRow.name),
12121251 toolName: stamp.toolName,
@@ -1214,13 +1253,32 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 1 unmodified line
12141253 transport,
12151254 connector,
12161255 elicit,
1217− });
1256+ onToolListChanged: () => {
1257+ toolListChanged = true;
1258+ },
1259+ }).pipe(
1260+ Effect.onExit(() =>
1261+ toolListChanged
1262+ ? ctx.connections.markToolsStale(connectionRef).pipe(Effect.ignore)
1263+ : Effect.void,
1264+ ),
1265+ );
12181266
12191267 const envelope = Option.getOrUndefined(decodeMcpToolCallEnvelope(raw));
12201268 if (envelope?.isError === true) {
1269+ const errorMessage = extractMcpErrorMessage(envelope.content);
1270+ // The reference TS SDK server reports an unknown tool as an
1271+ // execution-error envelope ("Tool <name> not found") rather than the
1272+ // spec's protocol error. Same meaning: the persisted catalog
1273+ // drifted. Mark it stale and answer with the typed drift failure.
1274+ if (isUnknownToolMessage(errorMessage, stamp.toolName)) {
1275+ return yield* ctx.connections
1276+ .markToolsStale(connectionRef)
1277+ .pipe(Effect.ignore, Effect.as(unknownToolFailure(String(toolRow.name), credential)));
1278+ }
12211279 return ToolResult.fail({
12221280 code: "mcp_tool_error",
1223− message: extractMcpErrorMessage(envelope.content),
1281+ message: errorMessage,
12241282 details: { content: envelope.content },
12251283 });
12261284 }
@@ -1254,6 +1312,15 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 27 unmodified lines
12541312 }),
12551313 );
12561314 }
1315+ if (error.unknownTool === true) {
1316+ return ctx.connections
1317+ .markToolsStale({
1318+ owner: credential.owner,
1319+ integration: credential.integration,
1320+ name: credential.connection,
1321+ })
1322+ .pipe(Effect.ignore, Effect.as(unknownToolFailure(String(toolRow.name), credential)));
1323+ }
12571324 return Effect.fail(error);
12581325 }),
12591326 Effect.withSpan("mcp.plugin.invoke_tool", {
packages/plugins/mcp/src/testing/index.ts+1−0
@@ -8,6 +8,7 @@ export { · 7 unmodified lines
88 makeElicitationMcpServer,
99 makeGreetingMcpServer,
1010 makeImageMcpServer,
11+ makeMutableCatalogMcpServer,
1112 serveMcpServer,
1213 serveMcpServerWithOAuth,
1314 type McpTestRequest,
packages/plugins/mcp/src/testing/server.ts+66−0
@@ -454,6 +454,72 @@ export const makeElicitationMcpServer = () => { · 453 unmodified lines
454454 return server;
455455 };
456456
457+/**
458+ * A server whose tool catalog mutates at runtime. `renameTool` renames the
459+ * advertised tool from `initialToolName` to `renamedToolName` via the SDK's
460+ * `RegisteredTool.update`, which sends `notifications/tools/list_changed` to
461+ * connected sessions. Calling the retired name afterwards yields the spec's
462+ * unknown-tool protocol error. The rename applies to every live session and
463+ * to sessions created after it (one shared name across the factory), so a
464+ * mutation made during one client's call window is visible to the next
465+ * connection's `tools/list`. The `rename_greet` tool performs the rename
466+ * mid-call, so a client with an open connection receives the notification
467+ * inside its own call window.
468+ */
469+export const makeMutableCatalogMcpServer = (
470+ options: {
471+ readonly name?: string;
472+ readonly initialToolName?: string;
473+ readonly renamedToolName?: string;
474+ } = {},
475+) => {
476+ const serverName = options.name ?? "mutable-catalog-test-server";
477+ const initialToolName = options.initialToolName ?? "greet";
478+ const renamedToolName = options.renamedToolName ?? "greet_v2";
479+ const registrations = new Set<{ update: (updates: { name: string }) => void }>();
480+ let currentToolName = initialToolName;
481+
482+ const renameTool = () => {
483+ currentToolName = renamedToolName;
484+ for (const registered of registrations) {
485+ registered.update({ name: renamedToolName });
486+ }
487+ };
488+
489+ const factory = () => {
490+ const server = new McpServer({ name: serverName, version: "1.0.0" }, { capabilities: {} });
491+ const registered = server.registerTool(
492+ currentToolName,
493+ {
494+ description: "Greets the caller",
495+ inputSchema: { name: z.string() },
496+ },
497+ async ({ name }: { name: string }) => ({
498+ content: [{ type: "text" as const, text: `greeting:${name}` }],
499+ }),
500+ );
501+ registrations.add(registered);
502+ server.registerTool(
503+ "rename_greet",
504+ { description: "Renames the greet tool", inputSchema: {} },
505+ async (_args, extra) => {
506+ renameTool();
507+ // `RegisteredTool.update` already emitted list_changed, but with no
508+ // relatedRequestId the transport routes it to the standalone GET SSE
509+ // stream, which a request-scoped client may never have open. Send it
510+ // through the handler's `extra` too: that stamps the request id, so
511+ // the notification rides THIS call's response stream and is
512+ // guaranteed to reach the caller before the tool result.
513+ await extra.sendNotification({ method: "notifications/tools/list_changed" });
514+ return { content: [{ type: "text" as const, text: "renamed" }] };
515+ },
516+ );
517+ return server;
518+ };
519+
520+ return { factory, renameTool, initialToolName, renamedToolName };
521+};
522+
457523 export const makeAnnotationsMcpServer = () => {
458524 const server = new McpServer(
459525 { name: "annotations-test-server", version: "1.0.0" },