* 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 | 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`). */ | ||
| 405 | export const DEFAULT_TOOLS_SYNC_TTL_MS = 15 * 60 * 1000; | ||
| 406 | |||
| 394 | // --------------------------------------------------------------------------- | 407 | // --------------------------------------------------------------------------- |
| 395 | // collectTables — return the executor-owned Fuma table set. Plugins persist | 408 | // collectTables — return the executor-owned Fuma table set. Plugins persist |
| 396 | // through host-owned facades (`pluginStorage`, `blobs`) instead of contributing | 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 | 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 its | 2603 | // How long a remote-catalog connection's persisted tools stay fresh |
| 2565 | // integration's last tool-affecting config change. The change's author | 2604 | // (`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 own | 2606 | config.toolsSyncTtlMs === undefined ? DEFAULT_TOOLS_SYNC_TTL_MS : config.toolsSyncTtlMs; |
| 2568 | // binding. Best-effort: a failed rebuild leaves the stale-but-working | 2607 | |
| 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: { |
| @@ -219,6 +219,12 @@ export interface PluginCtx<TStore = unknown> { · 218 unmodified lines | |||
| 219 | readonly Tool[], | 219 | readonly Tool[], |
| 220 | ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure | 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 | /** 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 the | 551 | /** 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>; |
| @@ -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 | // MCP tool discovery — connect to an MCP server and list its tools | 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 | import { McpToolDiscoveryError } from "./errors"; | 8 | import { McpToolDiscoveryError } from "./errors"; |
| 9 | import { | 9 | import { |
| 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. | ||
| 18 | const MAX_LIST_TOOLS_PAGES = 100; | ||
| 19 | |||
| 15 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
| 16 | // Public API | 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 | * 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 tools | 89 | 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 | }); |
| @@ -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 | ||
| 33 | export class McpOAuthReauthorizationRequired extends Data.TaggedError( | 36 | export class McpOAuthReauthorizationRequired extends Data.TaggedError( |
| @@ -14,7 +14,12 @@ · 13 unmodified lines | |||
| 14 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; | 14 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; |
| 15 | 15 | ||
| 16 | import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 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 | import { | 24 | import { |
| 20 | ElicitationId, | 25 | ElicitationId, |
| @@ -62,6 +67,31 @@ const statusFromStreamableHttpError = (cause: unknown): number | undefined => { · 41 unmodified lines | |||
| 62 | const httpStatusFromCause = (cause: unknown): number | undefined => | 67 | const 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). | ||
| 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 | // Elicitation bridge — decode incoming MCP ElicitRequest, route through | 96 | // 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 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 | const useConnection = ( | 182 | const 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 | ||
| 179 | export const invokeMcpTool = ( | 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 | }).pipe( | 269 | }).pipe( |
| 209 | Effect.scoped, | 270 | Effect.scoped, |
| 210 | Effect.withSpan("plugin.mcp.invoke", { | 271 | Effect.withSpan("plugin.mcp.invoke", { |
| @@ -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. | ||
| 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 | const ServerInfo = Schema.Struct({ | 60 | const 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 | ||
| 52 | const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult); | 65 | const decodeListToolsResult = Schema.decodeUnknownOption(ListToolsResult); |
| 66 | const decodeListToolsPageOption = Schema.decodeUnknownOption(ListToolsPage); | ||
| 53 | const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo); | 67 | const decodeServerInfo = Schema.decodeUnknownOption(ServerInfo); |
| 54 | 68 | ||
| 55 | export const isListToolsResult = (value: unknown): boolean => | 69 | export const decodeListToolsPage = (value: unknown): Option.Option<McpListToolsPage> => |
| 56 | Option.isSome(decodeListToolsResult(value)); | 70 | decodeListToolsPageOption(value); |
| 57 | 71 | ||
| 58 | // --------------------------------------------------------------------------- | 72 | // --------------------------------------------------------------------------- |
| 59 | // Tool ID sanitization | 73 | // Tool ID sanitization |
| @@ -45,7 +45,7 @@ import { · 44 unmodified lines | |||
| 45 | McpOAuthReauthorizationRequired, | 45 | McpOAuthReauthorizationRequired, |
| 46 | McpToolDiscoveryError, | 46 | McpToolDiscoveryError, |
| 47 | } from "./errors"; | 47 | } from "./errors"; |
| 48 | import { invokeMcpTool } from "./invoke"; | 48 | import { invokeMcpTool, isUnknownToolMessage } from "./invoke"; |
| 49 | import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; | 49 | import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; |
| 50 | import { mcpPresets } from "./presets"; | 50 | import { mcpPresets } from "./presets"; |
| 51 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; | 51 | import { 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. | ||
| 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 | /** 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. */ |
| 418 | const urlMatchesToken = (url: URL, token: string): boolean => { | 434 | const 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's | 1134 | // Per-connection tool production. Dial the server using the connection's |
| 1116 | // resolved value (rendered through the integration's auth template) and | 1135 | // resolved value (rendered through the integration's auth template) and |
| 1117 | // list its tools. The real MCP tool name + upstream annotations are | 1136 | // 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 set | 1138 | // 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 lists | 1148 | // 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", { |
| @@ -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, |
| @@ -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 | */ | ||
| 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 | export const makeAnnotationsMcpServer = () => { | 523 | export 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" }, |