* 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.
Unified · Split
| @@ -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. | |
| @@ -0,0 +1,111 @@ | ||
| 1 | +// Cross-target (browser): MCP tool-catalog freshness, FILMED through the real | |
| 2 | +// web UI. The session video + per-step screenshots are the artifact: a user | |
| 3 | +// adds a live MCP server, sees its tool in the Tools tab, runs a tool that | |
| 4 | +// renames the server's catalog mid-call (the server pushes | |
| 5 | +// `notifications/tools/list_changed` on the open connection), and then WATCHES | |
| 6 | +// the Tools tab serve the renamed catalog on the next visit — no Refresh | |
| 7 | +// click anywhere in the journey. The old behavior freezes this UI on the | |
| 8 | +// stale tool forever. | |
| 9 | +import { randomBytes } from "node:crypto"; | |
| 10 | + | |
| 11 | +import { expect } from "@effect/vitest"; | |
| 12 | +import { Effect } from "effect"; | |
| 13 | +import { makeMutableCatalogMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; | |
| 14 | + | |
| 15 | +import { scenario } from "../src/scenario"; | |
| 16 | +import { Browser, Target } from "../src/services"; | |
| 17 | + | |
| 18 | +scenario( | |
| 19 | + "MCP catalog · the Tools tab follows a server-side rename after a list_changed notification", | |
| 20 | + {}, | |
| 21 | + Effect.scoped( | |
| 22 | + Effect.gen(function* () { | |
| 23 | + const target = yield* Target; | |
| 24 | + const browser = yield* Browser; | |
| 25 | + | |
| 26 | + // A real MCP server whose `rename_greet` tool renames `greet` → | |
| 27 | + // `greet_v2` mid-call and notifies the open connection. The server name | |
| 28 | + // is unique per run (it derives the integration namespace) so runs can't | |
| 29 | + // collide on targets whose identities share one tenant. | |
| 30 | + const mutable = makeMutableCatalogMcpServer({ | |
| 31 | + name: `catalog-sync-${randomBytes(3).toString("hex")}`, | |
| 32 | + }); | |
| 33 | + const server = yield* serveMcpServer(mutable.factory); | |
| 34 | + const identity = yield* target.newIdentity(); | |
| 35 | + | |
| 36 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 37 | + await step("Open the add-MCP flow pointed at the live server", async () => { | |
| 38 | + await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { | |
| 39 | + waitUntil: "networkidle", | |
| 40 | + }); | |
| 41 | + // The URL auto-probes (debounced); the method list appears once the | |
| 42 | + // probe lands — an open server seeds the detected no-auth method. | |
| 43 | + await page.getByText("How does this server authenticate?").waitFor(); | |
| 44 | + await page.getByText("Method 1 · Detected").waitFor(); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + await step("Add the source", async () => { | |
| 48 | + await page.getByRole("button", { name: "Add source" }).click(); | |
| 49 | + await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 }); | |
| 50 | + await page.getByText("Connections").first().waitFor(); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + await step("Connect with no authentication", async () => { | |
| 54 | + await page.getByRole("button", { name: "Add connection" }).first().click(); | |
| 55 | + await page.getByRole("tab", { name: "No authentication" }).waitFor(); | |
| 56 | + await page.getByRole("button", { name: "Add connection" }).last().click(); | |
| 57 | + // The connect flow dials the server and produces the tool catalog. | |
| 58 | + await page.getByText("Connection added").waitFor(); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + // Tools render as a collapsed dotted-name tree (namespace → leaf); | |
| 62 | + // typing in the filter box expands every match, so it is both the | |
| 63 | + // reveal mechanism and a search the video shows off. | |
| 64 | + const filterTools = async (query: string) => { | |
| 65 | + const filter = page.getByPlaceholder(/^Filter \d+ tools/); | |
| 66 | + await filter.waitFor(); | |
| 67 | + await filter.fill(query); | |
| 68 | + }; | |
| 69 | + | |
| 70 | + // A tool's tree row is a button whose accessible name is the full | |
| 71 | + // leaf label — filter-highlight <mark> spans inside the label don't | |
| 72 | + // fragment it, so exact role queries can't false-match a substring | |
| 73 | + // (`greet` inside `greet_v2`). | |
| 74 | + const toolRow = (name: string) => page.getByRole("button", { name, exact: true }).first(); | |
| 75 | + | |
| 76 | + await step("The Tools tab lists the server's v1 catalog", async () => { | |
| 77 | + await page.getByRole("tab", { name: "Tools" }).click(); | |
| 78 | + await filterTools("greet"); | |
| 79 | + await toolRow(mutable.initialToolName).waitFor(); | |
| 80 | + await toolRow("rename_greet").waitFor(); | |
| 81 | + }); | |
| 82 | + | |
| 83 | + await step("Run rename_greet — the server renames its catalog mid-call", async () => { | |
| 84 | + await toolRow("rename_greet").click(); | |
| 85 | + await page.getByRole("tab", { name: "Run" }).click(); | |
| 86 | + await page.getByRole("button", { name: "Run", exact: true }).click(); | |
| 87 | + // The result card proves the call reached the server (the payload | |
| 88 | + // renders inside a highlighted code block, so assert the badge); | |
| 89 | + // the server pushed list_changed on the same open connection. | |
| 90 | + await page.getByText("Result").first().waitFor(); | |
| 91 | + await page.getByText("Success").first().waitFor(); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + await step("Revisit Tools — the catalog followed the rename by itself", async () => { | |
| 95 | + // Re-enter the page: a fresh tools read. The list_changed the server | |
| 96 | + // sent during the call marked the catalog stale, so THIS read | |
| 97 | + // re-lists — the renamed tool appears with no Refresh click. | |
| 98 | + await page.reload({ waitUntil: "networkidle" }); | |
| 99 | + await page.getByRole("tab", { name: "Tools" }).click(); | |
| 100 | + await filterTools("greet"); | |
| 101 | + await toolRow(mutable.renamedToolName).waitFor({ timeout: 30_000 }); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + const staleToolStillListed = await toolRow(mutable.initialToolName) | |
| 105 | + .isVisible() | |
| 106 | + .catch(() => false); | |
| 107 | + expect(staleToolStillListed, "the retired tool left the catalog").toBe(false); | |
| 108 | + }); | |
| 109 | + }), | |
| 110 | + ), | |
| 111 | +); | |
| @@ -0,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 | +); | |
| @@ -389,8 +389,21 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 388 unmodified lines | ||
| 389 | 389 | readonly orgSlug?: string; |
| 390 | 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`). */ | |
| 405 | +export const DEFAULT_TOOLS_SYNC_TTL_MS = 15 * 60 * 1000; | |
| 406 | + | |
| 394 | 407 | // --------------------------------------------------------------------------- |
| 395 | 408 | // collectTables — return the executor-owned Fuma table set. Plugins persist |
| 396 | 409 | // 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 | 1982 | const now = new Date(); |
| 1959 | 1983 | const toolRows = result.tools.map((tool: ToolDef) => ({ |
| 1960 | 1984 | tenant: keys.tenant, |
| @@ -2448,6 +2472,21 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 487 unmodified lines | ||
| 2448 | 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 | 2491 | // Active policy source. |
| 2453 | 2492 | // ------------------------------------------------------------------ |
| @@ -2561,31 +2600,86 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 107 unmodified lines | ||
| 2561 | 2600 | return true; |
| 2562 | 2601 | }; |
| 2563 | 2602 | |
| 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. | |
| 2570 | 2622 | 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, | |
| 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 | 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 | 2657 | 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); | |
| 2588 | 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 | 2683 | yield* produceConnectionTools(integrationRow, { |
| 2590 | 2684 | owner: connection.owner as Owner, |
| 2591 | 2685 | integration: IntegrationSlug.make(connection.integration), |
| @@ -3339,6 +3433,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 747 unmodified lines | ||
| 3339 | 3433 | update: (ref, input) => connectionsUpdate(ref, input), |
| 3340 | 3434 | remove: (ref) => connectionsRemove(ref), |
| 3341 | 3435 | refresh: (ref) => connectionsRefresh(ref), |
| 3436 | + markToolsStale: (ref) => connectionsMarkToolsStale(ref), | |
| 3342 | 3437 | resolveValue: (ref) => resolveConnectionValueByRef(ref), |
| 3343 | 3438 | }, |
| 3344 | 3439 | providers: { |
| @@ -219,6 +219,12 @@ export interface PluginCtx<TStore = unknown> { · 218 unmodified lines | ||
| 219 | 219 | readonly Tool[], |
| 220 | 220 | 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 | 228 | /** Resolve a connection's value through its provider (and OAuth refresh). |
| 223 | 229 | * null if the provider can't produce one. */ |
| 224 | 230 | readonly resolveValue: (ref: ConnectionRef) => Effect.Effect<string | null, StorageFailure>; |
| @@ -276,6 +282,13 @@ export interface ResolveToolsResult { · 51 unmodified lines | ||
| 276 | 282 | readonly tools: readonly ToolDef[]; |
| 277 | 283 | /** Shared JSON-schema `$defs` reachable from the tools' `$ref`s. */ |
| 278 | 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 | 540 | input: ResolveToolsInput<TStore>, |
| 528 | 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 | 551 | /** Invoke a dynamic tool. Called when the static-handler map doesn't have the |
| 531 | 552 | * address. The plugin applies `input.credential` to the outbound request. */ |
| 532 | 553 | readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>; |
| @@ -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 | +}); | |
| @@ -2,20 +2,71 @@ · 1 unmodified line | ||
| 2 | 2 | // MCP tool discovery — connect to an MCP server and list its tools |
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | −import { Effect } from "effect"; | |
| 5 | +import { Effect, Option } from "effect"; | |
| 6 | 6 | |
| 7 | −import type { McpConnector } from "./connection"; | |
| 7 | +import type { McpConnection, McpConnector } from "./connection"; | |
| 8 | 8 | import { McpToolDiscoveryError } from "./errors"; |
| 9 | 9 | import { |
| 10 | + decodeListToolsPage, | |
| 10 | 11 | extractManifestFromListToolsResult, |
| 11 | − isListToolsResult, | |
| 12 | 12 | type McpToolManifest, |
| 13 | 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. | |
| 18 | +const MAX_LIST_TOOLS_PAGES = 100; | |
| 19 | + | |
| 15 | 20 | // --------------------------------------------------------------------------- |
| 16 | 21 | // 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 | + */ | |
| 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 | + | |
| 19 | 70 | /** |
| 20 | 71 | * Connect to an MCP server and discover all available tools. |
| 21 | 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 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 | + ); | |
| 63 | 92 | |
| 64 | 93 | return manifest; |
| 65 | 94 | }); |
| @@ -28,6 +28,9 @@ export class McpInvocationError extends Data.TaggedError("McpInvocationError")<{ · 27 unmodified lines | ||
| 28 | 28 | readonly toolName: string; |
| 29 | 29 | readonly message: string; |
| 30 | 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 | |
| 33 | 36 | export class McpOAuthReauthorizationRequired extends Data.TaggedError( |
| @@ -14,7 +14,12 @@ · 13 unmodified lines | ||
| 14 | 14 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; |
| 15 | 15 | |
| 16 | 16 | 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"; | |
| 18 | 23 | |
| 19 | 24 | import { |
| 20 | 25 | ElicitationId, |
| @@ -62,6 +67,31 @@ const statusFromStreamableHttpError = (cause: unknown): number | undefined => { · 41 unmodified lines | ||
| 62 | 67 | const httpStatusFromCause = (cause: unknown): number | undefined => |
| 63 | 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). | |
| 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 | + | |
| 65 | 95 | // --------------------------------------------------------------------------- |
| 66 | 96 | // Elicitation bridge — decode incoming MCP ElicitRequest, route through |
| 67 | 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 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 | |
| 132 | 180 | // --------------------------------------------------------------------------- |
| 133 | 181 | |
| 134 | 182 | const useConnection = ( |
| @@ -136,9 +184,11 @@ const useConnection = ( · 1 unmodified line | ||
| 136 | 184 | toolName: string, |
| 137 | 185 | args: Record<string, unknown>, |
| 138 | 186 | elicit: Elicit, |
| 187 | + onToolListChanged: (() => void) | undefined, | |
| 139 | 188 | ): Effect.Effect<unknown, McpInvocationError | McpOAuthReauthorizationRequired> => |
| 140 | 189 | Effect.gen(function* () { |
| 141 | 190 | installElicitationHandler(connection.client, elicit); |
| 191 | + installToolListChangedHandler(connection.client, onToolListChanged); | |
| 142 | 192 | return yield* Effect.tryPromise({ |
| 143 | 193 | try: () => connection.client.callTool({ name: toolName, arguments: args }), |
| 144 | 194 | catch: (cause) => { |
| @@ -152,6 +202,7 @@ const useConnection = ( · 7 unmodified lines | ||
| 152 | 202 | toolName, |
| 153 | 203 | message: `MCP tool call failed for ${toolName}`, |
| 154 | 204 | ...(status === undefined ? {} : { status }), |
| 205 | + ...(isUnknownToolCause(cause, toolName) ? { unknownTool: true } : {}), | |
| 155 | 206 | }); |
| 156 | 207 | }, |
| 157 | 208 | }).pipe( |
| @@ -174,6 +225,10 @@ export interface InvokeMcpToolInput { · 16 unmodified lines | ||
| 174 | 225 | /** Dials a fresh connection. The connection is closed after the call. */ |
| 175 | 226 | readonly connector: McpConnector; |
| 176 | 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 | |
| 179 | 234 | export 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 | 269 | }).pipe( |
| 209 | 270 | Effect.scoped, |
| 210 | 271 | Effect.withSpan("plugin.mcp.invoke", { |
| @@ -44,16 +44,30 @@ const ListToolsResult = Schema.Struct({ · 43 unmodified lines | ||
| 44 | 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. | |
| 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 | + | |
| 47 | 60 | const ServerInfo = Schema.Struct({ |
| 48 | 61 | name: Schema.optional(Schema.String), |
| 49 | 62 | version: Schema.optional(Schema.String), |
| 50 | 63 | }); |
| 51 | 64 | |
| 52 | 65 | const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult); |
| 66 | +const decodeListToolsPageOption = Schema.decodeUnknownOption(ListToolsPage); | |
| 53 | 67 | const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo); |
| 54 | 68 | |
| 55 | −export const isListToolsResult = (value: unknown): boolean => | |
| 56 | − Option.isSome(decodeListToolsResult(value)); | |
| 69 | +export const decodeListToolsPage = (value: unknown): Option.Option<McpListToolsPage> => | |
| 70 | + decodeListToolsPageOption(value); | |
| 57 | 71 | |
| 58 | 72 | // --------------------------------------------------------------------------- |
| 59 | 73 | // Tool ID sanitization |
| @@ -45,7 +45,7 @@ import { · 44 unmodified lines | ||
| 45 | 45 | McpOAuthReauthorizationRequired, |
| 46 | 46 | McpToolDiscoveryError, |
| 47 | 47 | } from "./errors"; |
| 48 | −import { invokeMcpTool } from "./invoke"; | |
| 48 | +import { invokeMcpTool, isUnknownToolMessage } from "./invoke"; | |
| 49 | 49 | import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; |
| 50 | 50 | import { mcpPresets } from "./presets"; |
| 51 | 51 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; |
| @@ -413,6 +413,22 @@ const extractMcpErrorMessage = (content: unknown): string => { · 361 unmodified lines | ||
| 413 | 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. | |
| 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 | + | |
| 416 | 432 | /** Match `token` as a separator-bounded run inside a URL hostname or path, |
| 417 | 433 | * used as a low-confidence detection hint when wire-shape detection fails. */ |
| 418 | 434 | const urlMatchesToken = (url: URL, token: string): boolean => { |
| @@ -666,6 +682,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 247 unmodified lines | ||
| 666 | 682 | return { |
| 667 | 683 | id: MCP_PLUGIN_ID, |
| 668 | 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 | 688 | integrationPresets: presetEntries, |
| 670 | 689 | // Surfaced to the client bundle via the Vite plugin. The MCP `./client` |
| 671 | 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 | 1134 | // Per-connection tool production. Dial the server using the connection's |
| 1116 | 1135 | // 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. | |
| 1121 | 1142 | // ----------------------------------------------------------------------- |
| 1122 | 1143 | resolveTools: ({ config, connection, template, getValues, httpClientLayer }) => |
| 1123 | 1144 | Effect.gen(function* () { |
| 1124 | 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 | 1148 | // Discovery tolerates unresolved credentials (an open server lists |
| 1128 | 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 | 1173 | : { ok: false as const, manifest: null }; |
| 1153 | 1174 | |
| 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) }; | |
| 1156 | 1179 | }).pipe( |
| 1157 | 1180 | Effect.withSpan("mcp.plugin.resolve_tools", { |
| 1158 | 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 | 1188 | invokeTool: ({ ctx, toolRow, credential, args, elicit }) => |
| 1163 | 1189 | Effect.gen(function* () { |
| @@ -1207,6 +1233,19 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 43 unmodified lines | ||
| 1207 | 1233 | options?.httpClientLayer ?? ctx.httpClientLayer, |
| 1208 | 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 | 1249 | const raw = yield* invokeMcpTool({ |
| 1211 | 1250 | toolId: String(toolRow.name), |
| 1212 | 1251 | toolName: stamp.toolName, |
| @@ -1214,13 +1253,32 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 1 unmodified line | ||
| 1214 | 1253 | transport, |
| 1215 | 1254 | connector, |
| 1216 | 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 | 1267 | const envelope = Option.getOrUndefined(decodeMcpToolCallEnvelope(raw)); |
| 1220 | 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 | 1279 | return ToolResult.fail({ |
| 1222 | 1280 | code: "mcp_tool_error", |
| 1223 | − message: extractMcpErrorMessage(envelope.content), | |
| 1281 | + message: errorMessage, | |
| 1224 | 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 | 1324 | return Effect.fail(error); |
| 1258 | 1325 | }), |
| 1259 | 1326 | Effect.withSpan("mcp.plugin.invoke_tool", { |
| @@ -8,6 +8,7 @@ export { · 7 unmodified lines | ||
| 8 | 8 | makeElicitationMcpServer, |
| 9 | 9 | makeGreetingMcpServer, |
| 10 | 10 | makeImageMcpServer, |
| 11 | + makeMutableCatalogMcpServer, | |
| 11 | 12 | serveMcpServer, |
| 12 | 13 | serveMcpServerWithOAuth, |
| 13 | 14 | type McpTestRequest, |
| @@ -454,6 +454,72 @@ export const makeElicitationMcpServer = () => { · 453 unmodified lines | ||
| 454 | 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 | + */ | |
| 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 | + | |
| 457 | 523 | export const makeAnnotationsMcpServer = () => { |
| 458 | 524 | const server = new McpServer( |
| 459 | 525 | { name: "annotations-test-server", version: "1.0.0" }, |