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
6Keep MCP tool catalogs in sync with the server's live tool set. Previously a
7connection's tools were listed once at create time and never updated unless the
8integration's config changed or a user clicked Refresh, so server-side tool
9changes 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.
9import { randomBytes } from "node:crypto";
10
11import { expect } from "@effect/vitest";
12import { Effect } from "effect";
13import { makeMutableCatalogMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
14
15import { scenario } from "../src/scenario";
16import { Browser, Target } from "../src/services";
17
18scenario(
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.
22import { randomBytes } from "node:crypto";
23import { createServer } from "node:http";
24
25import { expect } from "@effect/vitest";
26import { Effect } from "effect";
27import { composePluginApi } from "@executor-js/api/server";
28import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
29import { makeMutableCatalogMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
30import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
31
32import { scenario } from "../src/scenario";
33import { Api, Target } from "../src/services";
34
35const api = composePluginApi([mcpHttpPlugin()] as const);
36
37const 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.
42const invokeToolCode = (slug: string, connection: string, tool: string, args: unknown) => `
43const result = await tools.${slug}.org.${connection}.${tool}(${JSON.stringify(args)});
44return { ok: result.ok, payload: result.ok ? result.data : result.error };
45`;
46
47type 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
63type 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
71const 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
178scenario(
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
261scenario(
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
360scenario(
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
429scenario(
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
389 readonly orgSlug?: string;389 readonly orgSlug?: string;
390 readonly includeProviders?: boolean;390 readonly includeProviders?: boolean;
391 };391 };
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;
392}401}
393 402
403/** Default freshness window for remote-catalog connections (see
404 * `ExecutorConfig.toolsSyncTtlMs`). */
405export const DEFAULT_TOOLS_SYNC_TTL_MS = 15 * 60 * 1000;
406
394// ---------------------------------------------------------------------------407// ---------------------------------------------------------------------------
395// collectTables — return the executor-owned Fuma table set. Plugins persist408// collectTables — return the executor-owned Fuma table set. Plugins persist
396// through host-owned facades (`pluginStorage`, `blobs`) instead of contributing409// 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
1955 ),1968 ),
1956 );1969 );
1957 1970
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
1958 const now = new Date();1982 const now = new Date();
1959 const toolRows = result.tools.map((tool: ToolDef) => ({1983 const toolRows = result.tools.map((tool: ToolDef) => ({
1960 tenant: keys.tenant,1984 tenant: keys.tenant,
@@ -2448,6 +2472,21 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 487 unmodified lines
2448 return yield* produceConnectionTools(integrationRow, ref);2472 return yield* produceConnectionTools(integrationRow, ref);
2449 });2473 });
2450 2474
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
2451 // ------------------------------------------------------------------2490 // ------------------------------------------------------------------
2452 // Active policy source.2491 // Active policy source.
2453 // ------------------------------------------------------------------2492 // ------------------------------------------------------------------
@@ -2561,31 +2600,86 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 107 unmodified lines
2561 return true;2600 return true;
2562 };2601 };
2563 2602
2564 // Rebuild any visible connection whose tool catalog predates its2603 // How long a remote-catalog connection's persisted tools stay fresh
2565 // integration's last tool-affecting config change. The change's author2604 // (`ExecutorConfig.toolsSyncTtlMs`; `null` disables time-based re-sync).
2566 // could only rewrite catalogs in their own partition (owner policy);2605 const toolsSyncTtlMs =
2567 // every other subject converges here, on their own read, under their own2606 config.toolsSyncTtlMs === undefined ? DEFAULT_TOOLS_SYNC_TTL_MS : config.toolsSyncTtlMs;
2568 // binding. Best-effort: a failed rebuild leaves the stale-but-working2607
2569 // catalog in place and retries on the next read.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.
2570 const syncStaleConnectionTools = Effect.gen(function* () {2622 const syncStaleConnectionTools = Effect.gen(function* () {
2571 const revised = yield* core.findMany("integration", {2623 const integrations = yield* core.findMany("integration", {});
2572 where: (b: AnyCb) => b.isNotNull("config_revised_at"),2624 if (integrations.length === 0) return;
2573 });2625 const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const));
2574 if (revised.length === 0) return;2626 // The TTL only matters when a loaded plugin actually lists a live remote
2575 const revisedAt = new Map(2627 // catalog; otherwise skip it so age alone never widens the stale query.
2576 revised.map((row) => [row.slug, Number(row.config_revised_at)] as const),2628 const anyRemoteCatalog = Array.from(runtimes.values()).some(
2629 (runtime) => runtime.plugin.remoteToolCatalog === true,
2577 );2630 );
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
2578 const connections = yield* core.findMany("connection", {2651 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)),
2580 });2656 });
2581 for (const connection of connections) {2657 for (const connection of connections) {
2582 const revisedTime = revisedAt.get(connection.integration);2658 const integrationRow = integrationBySlug.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);
2588 if (!integrationRow) continue;2659 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
2589 yield* produceConnectionTools(integrationRow, {2683 yield* produceConnectionTools(integrationRow, {
2590 owner: connection.owner as Owner,2684 owner: connection.owner as Owner,
2591 integration: IntegrationSlug.make(connection.integration),2685 integration: IntegrationSlug.make(connection.integration),
@@ -3339,6 +3433,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 747 unmodified lines
3339 update: (ref, input) => connectionsUpdate(ref, input),3433 update: (ref, input) => connectionsUpdate(ref, input),
3340 remove: (ref) => connectionsRemove(ref),3434 remove: (ref) => connectionsRemove(ref),
3341 refresh: (ref) => connectionsRefresh(ref),3435 refresh: (ref) => connectionsRefresh(ref),
3436 markToolsStale: (ref) => connectionsMarkToolsStale(ref),
3342 resolveValue: (ref) => resolveConnectionValueByRef(ref),3437 resolveValue: (ref) => resolveConnectionValueByRef(ref),
3343 },3438 },
3344 providers: {3439 providers: {
packages/core/sdk/src/plugin.ts+21−0
@@ -219,6 +219,12 @@ export interface PluginCtx<TStore = unknown> { · 218 unmodified lines
219 readonly Tool[],219 readonly Tool[],
220 ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure220 ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure
221 >;221 >;
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>;
222 /** Resolve a connection's value through its provider (and OAuth refresh).228 /** Resolve a connection's value through its provider (and OAuth refresh).
223 * null if the provider can't produce one. */229 * null if the provider can't produce one. */
224 readonly resolveValue: (ref: ConnectionRef) => Effect.Effect<string | null, StorageFailure>;230 readonly resolveValue: (ref: ConnectionRef) => Effect.Effect<string | null, StorageFailure>;
@@ -276,6 +282,13 @@ export interface ResolveToolsResult { · 51 unmodified lines
276 readonly tools: readonly ToolDef[];282 readonly tools: readonly ToolDef[];
277 /** Shared JSON-schema `$defs` reachable from the tools' `$ref`s. */283 /** Shared JSON-schema `$defs` reachable from the tools' `$ref`s. */
278 readonly definitions?: Record<string, unknown>;284 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;
279}292}
280 293
281// ---------------------------------------------------------------------------294// ---------------------------------------------------------------------------
@@ -527,6 +540,14 @@ export interface PluginSpec< · 245 unmodified lines
527 input: ResolveToolsInput<TStore>,540 input: ResolveToolsInput<TStore>,
528 ) => Effect.Effect<ResolveToolsResult, StorageFailure>;541 ) => Effect.Effect<ResolveToolsResult, StorageFailure>;
529 542
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
530 /** Invoke a dynamic tool. Called when the static-handler map doesn't have the551 /** Invoke a dynamic tool. Called when the static-handler map doesn't have the
531 * address. The plugin applies `input.credential` to the outbound request. */552 * address. The plugin applies `input.credential` to the outbound request. */
532 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;553 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
16import { describe, expect, it } from "@effect/vitest";
17import { Effect, Option, Schema } from "effect";
18import { HttpServerResponse } from "effect/unstable/http";
19
20import {
21 AuthTemplateSlug,
22 ConnectionName,
23 IntegrationSlug,
24 ToolAddress,
25 createExecutor,
26} from "@executor-js/sdk";
27import {
28 makeTestConfig,
29 memoryCredentialsPlugin,
30 serveTestHttpApp,
31} from "@executor-js/sdk/testing";
32
33import { mcpPlugin } from "./plugin";
34import { createMcpConnector } from "./connection";
35import { discoverTools } from "./discover";
36import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing";
37
38const INTEG = IntegrationSlug.make("catalog_mcp");
39const CONNECTION = ConnectionName.make("main");
40const TEMPLATE = AuthTemplateSlug.make("none");
41
42const 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
68const toolNames = (tools: readonly { readonly name: unknown }[]): readonly string[] =>
69 tools.map((tool) => String(tool.name)).sort();
70
71describe("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
172const JsonRpcId = Schema.Union([Schema.String, Schema.Number, Schema.Null]);
173const JsonRpcRequest = Schema.Struct({
174 id: Schema.optional(JsonRpcId),
175 method: Schema.String,
176 params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
177});
178type JsonRpcRequest = typeof JsonRpcRequest.Type;
179
180const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(JsonRpcRequest));
181
182const jsonRpcResult = (request: JsonRpcRequest, result: unknown) =>
183 HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: request.id ?? null, result });
184
185const 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.
192const 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
231describe("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
2// MCP tool discovery — connect to an MCP server and list its tools2// MCP tool discovery — connect to an MCP server and list its tools
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { Effect } from "effect";5import { Effect, Option } from "effect";
6 6
7import type { McpConnector } from "./connection";7import type { McpConnection, McpConnector } from "./connection";
8import { McpToolDiscoveryError } from "./errors";8import { McpToolDiscoveryError } from "./errors";
9import {9import {
10 decodeListToolsPage,
10 extractManifestFromListToolsResult,11 extractManifestFromListToolsResult,
11 isListToolsResult,
12 type McpToolManifest,12 type McpToolManifest,
13} from "./manifest";13} from "./manifest";
14 14
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.
18const MAX_LIST_TOOLS_PAGES = 100;
19
15// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
16// Public API21// Public API
17// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
18 23
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 */
29const 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
19/**70/**
20 * Connect to an MCP server and discover all available tools.71 * Connect to an MCP server and discover all available tools.
21 * Returns the parsed manifest containing server metadata and tool entries.72 * Returns the parsed manifest containing server metadata and tool entries.
@@ -35,31 +86,9 @@ export const discoverTools = ( · 13 unmodified lines
35 ),86 ),
36 );87 );
37 88
38 // List tools89 const manifest = yield* listAllTools(connection).pipe(
39 const listResult = yield* Effect.tryPromise({90 Effect.onExit(() => closeConnection(connection)),
40 try: () => connection.client.listTools(),91 );
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);
63 92
64 return manifest;93 return manifest;
65 });94 });
packages/plugins/mcp/src/sdk/errors.ts+3−0
@@ -28,6 +28,9 @@ export class McpInvocationError extends Data.TaggedError("McpInvocationError")<{ · 27 unmodified lines
28 readonly toolName: string;28 readonly toolName: string;
29 readonly message: string;29 readonly message: string;
30 readonly status?: number;30 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;
31}> {}34}> {}
32 35
33export class McpOAuthReauthorizationRequired extends Data.TaggedError(36export class McpOAuthReauthorizationRequired extends Data.TaggedError(
packages/plugins/mcp/src/sdk/invoke.ts+64−3
@@ -14,7 +14,12 @@ · 13 unmodified lines
14import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";14import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";
15 15
16import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";16import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";17import {
18 ElicitRequestSchema,
19 ErrorCode,
20 McpError,
21 ToolListChangedNotificationSchema,
22} from "@modelcontextprotocol/sdk/types.js";
18 23
19import {24import {
20 ElicitationId,25 ElicitationId,
@@ -62,6 +67,31 @@ const statusFromStreamableHttpError = (cause: unknown): number | undefined => { · 41 unmodified lines
62const httpStatusFromCause = (cause: unknown): number | undefined =>67const httpStatusFromCause = (cause: unknown): number | undefined =>
63 statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);68 statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);
64 69
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).
78const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
80export 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
88const 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
65// ---------------------------------------------------------------------------95// ---------------------------------------------------------------------------
66// Elicitation bridge — decode incoming MCP ElicitRequest, route through96// Elicitation bridge — decode incoming MCP ElicitRequest, route through
67// the host's elicit function, marshal the response back to MCP shape.97// 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
128};158};
129 159
130// ---------------------------------------------------------------------------160// ---------------------------------------------------------------------------
131// Single tool call — install handler, callTool, return raw result161// 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
168const 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
132// ---------------------------------------------------------------------------180// ---------------------------------------------------------------------------
133 181
134const useConnection = (182const useConnection = (
@@ -136,9 +184,11 @@ const useConnection = ( · 1 unmodified line
136 toolName: string,184 toolName: string,
137 args: Record<string, unknown>,185 args: Record<string, unknown>,
138 elicit: Elicit,186 elicit: Elicit,
187 onToolListChanged: (() => void) | undefined,
139): Effect.Effect<unknown, McpInvocationError | McpOAuthReauthorizationRequired> =>188): Effect.Effect<unknown, McpInvocationError | McpOAuthReauthorizationRequired> =>
140 Effect.gen(function* () {189 Effect.gen(function* () {
141 installElicitationHandler(connection.client, elicit);190 installElicitationHandler(connection.client, elicit);
191 installToolListChangedHandler(connection.client, onToolListChanged);
142 return yield* Effect.tryPromise({192 return yield* Effect.tryPromise({
143 try: () => connection.client.callTool({ name: toolName, arguments: args }),193 try: () => connection.client.callTool({ name: toolName, arguments: args }),
144 catch: (cause) => {194 catch: (cause) => {
@@ -152,6 +202,7 @@ const useConnection = ( · 7 unmodified lines
152 toolName,202 toolName,
153 message: `MCP tool call failed for ${toolName}`,203 message: `MCP tool call failed for ${toolName}`,
154 ...(status === undefined ? {} : { status }),204 ...(status === undefined ? {} : { status }),
205 ...(isUnknownToolCause(cause, toolName) ? { unknownTool: true } : {}),
155 });206 });
156 },207 },
157 }).pipe(208 }).pipe(
@@ -174,6 +225,10 @@ export interface InvokeMcpToolInput { · 16 unmodified lines
174 /** Dials a fresh connection. The connection is closed after the call. */225 /** Dials a fresh connection. The connection is closed after the call. */
175 readonly connector: McpConnector;226 readonly connector: McpConnector;
176 readonly elicit: Elicit;227 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;
177}232}
178 233
179export const invokeMcpTool = (234export const invokeMcpTool = (
@@ -204,7 +259,13 @@ export const invokeMcpTool = ( · 24 unmodified lines
204 ),259 ),
205 );260 );
206 261
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 );
208 }).pipe(269 }).pipe(
209 Effect.scoped,270 Effect.scoped,
210 Effect.withSpan("plugin.mcp.invoke", {271 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
44 tools: Schema.Array(ListedTool),44 tools: Schema.Array(ListedTool),
45});45});
46 46
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.
50const ListToolsPage = Schema.Struct({
51 tools: Schema.Array(Schema.Unknown),
52 nextCursor: Schema.optional(Schema.NullOr(Schema.String)),
53});
54
55export interface McpListToolsPage {
56 readonly tools: readonly unknown[];
57 readonly nextCursor?: string | null;
58}
59
47const ServerInfo = Schema.Struct({60const ServerInfo = Schema.Struct({
48 name: Schema.optional(Schema.String),61 name: Schema.optional(Schema.String),
49 version: Schema.optional(Schema.String),62 version: Schema.optional(Schema.String),
50});63});
51 64
52const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult);65const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult);
66const decodeListToolsPageOption = Schema.decodeUnknownOption(ListToolsPage);
53const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo);67const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo);
54 68
55export const isListToolsResult = (value: unknown): boolean =>69export const decodeListToolsPage = (value: unknown): Option.Option<McpListToolsPage> =>
56 Option.isSome(decodeListToolsResult(value));70 decodeListToolsPageOption(value);
57 71
58// ---------------------------------------------------------------------------72// ---------------------------------------------------------------------------
59// Tool ID sanitization73// Tool ID sanitization
packages/plugins/mcp/src/sdk/plugin.ts+78−11
@@ -45,7 +45,7 @@ import { · 44 unmodified lines
45 McpOAuthReauthorizationRequired,45 McpOAuthReauthorizationRequired,
46 McpToolDiscoveryError,46 McpToolDiscoveryError,
47} from "./errors";47} from "./errors";
48import { invokeMcpTool } from "./invoke";48import { invokeMcpTool, isUnknownToolMessage } from "./invoke";
49import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest";49import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest";
50import { mcpPresets } from "./presets";50import { mcpPresets } from "./presets";
51import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";51import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";
@@ -413,6 +413,22 @@ const extractMcpErrorMessage = (content: unknown): string => { · 361 unmodified lines
413 return "MCP tool returned an error";413 return "MCP tool returned an error";
414};414};
415 415
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.
419const 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
416/** Match `token` as a separator-bounded run inside a URL hostname or path,432/** Match `token` as a separator-bounded run inside a URL hostname or path,
417 * used as a low-confidence detection hint when wire-shape detection fails. */433 * used as a low-confidence detection hint when wire-shape detection fails. */
418const urlMatchesToken = (url: URL, token: string): boolean => {434const urlMatchesToken = (url: URL, token: string): boolean => {
@@ -666,6 +682,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 247 unmodified lines
666 return {682 return {
667 id: MCP_PLUGIN_ID,683 id: MCP_PLUGIN_ID,
668 packageName: "@executor-js/plugin-mcp",684 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,
669 integrationPresets: presetEntries,688 integrationPresets: presetEntries,
670 // Surfaced to the client bundle via the Vite plugin. The MCP `./client`689 // Surfaced to the client bundle via the Vite plugin. The MCP `./client`
671 // factory reads `allowStdio` and gates the stdio tab + presets.690 // factory reads `allowStdio` and gates the stdio tab + presets.
@@ -1114,15 +1133,17 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 442 unmodified lines
1114 // -----------------------------------------------------------------------1133 // -----------------------------------------------------------------------
1115 // Per-connection tool production. Dial the server using the connection's1134 // Per-connection tool production. Dial the server using the connection's
1116 // resolved value (rendered through the integration's auth template) and1135 // resolved value (rendered through the integration's auth template) and
1117 // list its tools. The real MCP tool name + upstream annotations are1136 // list its tools (following `nextCursor` pagination). The real MCP tool
1118 // stamped into each ToolDef's annotations so invokeTool can recover them.1137 // name + upstream annotations are stamped into each ToolDef's annotations
1119 // Discovery failures (auth not ready, server down) yield an empty tool set1138 // so invokeTool can recover them. Discovery failures (auth not ready,
1120 // rather than failing — the connection still lands and can be refreshed.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.
1121 // -----------------------------------------------------------------------1142 // -----------------------------------------------------------------------
1122 resolveTools: ({ config, connection, template, getValues, httpClientLayer }) =>1143 resolveTools: ({ config, connection, template, getValues, httpClientLayer }) =>
1123 Effect.gen(function* () {1144 Effect.gen(function* () {
1124 const parsed = parseMcpIntegrationConfig(config);1145 const parsed = parseMcpIntegrationConfig(config);
1125 if (!parsed) return { tools: [] as readonly ToolDef[] };1146 if (!parsed) return { tools: [] as readonly ToolDef[], incomplete: true };
1126 1147
1127 // Discovery tolerates unresolved credentials (an open server lists1148 // Discovery tolerates unresolved credentials (an open server lists
1128 // tools unauthenticated; a bad value just yields zero tools).1149 // tools unauthenticated; a bad value just yields zero tools).
@@ -1151,13 +1172,18 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 22 unmodified lines
1151 )1172 )
1152 : { ok: false as const, manifest: null };1173 : { ok: false as const, manifest: null };
1153 1174
1154 const entries = manifest.ok && manifest.manifest ? manifest.manifest.tools : [];1175 if (!manifest.ok || !manifest.manifest) {
1155 return { tools: entries.map(toToolDef) };1176 return { tools: [] as readonly ToolDef[], incomplete: true };
1177 }
1178 return { tools: manifest.manifest.tools.map(toToolDef) };
1156 }).pipe(1179 }).pipe(
1157 Effect.withSpan("mcp.plugin.resolve_tools", {1180 Effect.withSpan("mcp.plugin.resolve_tools", {
1158 attributes: { "mcp.connection.name": String(connection.name) },1181 attributes: { "mcp.connection.name": String(connection.name) },
1159 }),1182 }),
1160 ) as Effect.Effect<{ readonly tools: readonly ToolDef[] }, StorageFailure>,1183 ) as Effect.Effect<
1184 { readonly tools: readonly ToolDef[]; readonly incomplete?: boolean },
1185 StorageFailure
1186 >,
1161 1187
1162 invokeTool: ({ ctx, toolRow, credential, args, elicit }) =>1188 invokeTool: ({ ctx, toolRow, credential, args, elicit }) =>
1163 Effect.gen(function* () {1189 Effect.gen(function* () {
@@ -1207,6 +1233,19 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 43 unmodified lines
1207 options?.httpClientLayer ?? ctx.httpClientLayer,1233 options?.httpClientLayer ?? ctx.httpClientLayer,
1208 ).pipe(Effect.map((ci) => createMcpConnector(ci)));1234 ).pipe(Effect.map((ci) => createMcpConnector(ci)));
1209 1235
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
1210 const raw = yield* invokeMcpTool({1249 const raw = yield* invokeMcpTool({
1211 toolId: String(toolRow.name),1250 toolId: String(toolRow.name),
1212 toolName: stamp.toolName,1251 toolName: stamp.toolName,
@@ -1214,13 +1253,32 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 1 unmodified line
1214 transport,1253 transport,
1215 connector,1254 connector,
1216 elicit,1255 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 );
1218 1266
1219 const envelope = Option.getOrUndefined(decodeMcpToolCallEnvelope(raw));1267 const envelope = Option.getOrUndefined(decodeMcpToolCallEnvelope(raw));
1220 if (envelope?.isError === true) {1268 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 }
1221 return ToolResult.fail({1279 return ToolResult.fail({
1222 code: "mcp_tool_error",1280 code: "mcp_tool_error",
1223 message: extractMcpErrorMessage(envelope.content),1281 message: errorMessage,
1224 details: { content: envelope.content },1282 details: { content: envelope.content },
1225 });1283 });
1226 }1284 }
@@ -1254,6 +1312,15 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 27 unmodified lines
1254 }),1312 }),
1255 );1313 );
1256 }1314 }
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 }
1257 return Effect.fail(error);1324 return Effect.fail(error);
1258 }),1325 }),
1259 Effect.withSpan("mcp.plugin.invoke_tool", {1326 Effect.withSpan("mcp.plugin.invoke_tool", {
packages/plugins/mcp/src/testing/index.ts+1−0
@@ -8,6 +8,7 @@ export { · 7 unmodified lines
8 makeElicitationMcpServer,8 makeElicitationMcpServer,
9 makeGreetingMcpServer,9 makeGreetingMcpServer,
10 makeImageMcpServer,10 makeImageMcpServer,
11 makeMutableCatalogMcpServer,
11 serveMcpServer,12 serveMcpServer,
12 serveMcpServerWithOAuth,13 serveMcpServerWithOAuth,
13 type McpTestRequest,14 type McpTestRequest,
packages/plugins/mcp/src/testing/server.ts+66−0
@@ -454,6 +454,72 @@ export const makeElicitationMcpServer = () => { · 453 unmodified lines
454 return server;454 return server;
455};455};
456 456
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 */
469export 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
457export const makeAnnotationsMcpServer = () => {523export const makeAnnotationsMcpServer = () => {
458 const server = new McpServer(524 const server = new McpServer(
459 { name: "annotations-test-server", version: "1.0.0" },525 { name: "annotations-test-server", version: "1.0.0" },