250 lines · 9.6 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// MCP tool-catalog freshness (end-to-end).//// The persisted per-connection tool catalog must converge with the server's// live tool set. Spec inputs the executor reacts to://   - `notifications/tools/list_changed` received during a call window marks//     the connection stale; the next tools read re-lists.//   - An unknown-tool protocol error (`-32602`, "Tool … not found") on//     `tools/call` means the catalog drifted: the call fails with a typed//     `mcp_tool_unknown` ToolResult and the catalog heals on the next read.//   - `tools/list` is paginated; discovery follows `nextCursor` to the end.//   - A failed listing (server unreachable) is non-authoritative: the//     previously persisted catalog is kept, not wiped.// --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest";import { Effect, Option, Schema } from "effect";import { HttpServerResponse } from "effect/unstable/http"; import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  ToolAddress,  createExecutor,} from "@executor-js/sdk";import {  makeTestConfig,  memoryCredentialsPlugin,  serveTestHttpApp,} from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin";import { createMcpConnector } from "./connection";import { discoverTools } from "./discover";import { makeMutableCatalogMcpServer, serveMcpServer } from "../testing"; const INTEG = IntegrationSlug.make("catalog_mcp");const CONNECTION = ConnectionName.make("main");const TEMPLATE = AuthTemplateSlug.make("none"); const makeCatalogTestExecutor = (  serverUrl: string,  options?: { readonly toolsSyncTtlMs?: number | null },) =>  createExecutor({    ...makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const }),    ...(options?.toolsSyncTtlMs === undefined ? {} : { toolsSyncTtlMs: options.toolsSyncTtlMs }),  }).pipe(    Effect.tap((executor) =>      Effect.gen(function* () {        yield* executor.mcp.addServer({          name: "catalog-mcp",          endpoint: serverUrl,          slug: String(INTEG),        });        yield* executor.connections.create({          owner: "org",          name: CONNECTION,          integration: INTEG,          template: TEMPLATE,          value: "",        });      }),    ),  ); const toolNames = (tools: readonly { readonly name: unknown }[]): readonly string[] =>  tools.map((tool) => String(tool.name)).sort(); describe("MCP tool-catalog sync (end-to-end)", () => {  it.effect(    "tools/list_changed during a call marks the catalog stale and the next read re-lists",    () =>      Effect.gen(function* () {        const mutable = makeMutableCatalogMcpServer();        const server = yield* serveMcpServer(mutable.factory);        const executor = yield* makeCatalogTestExecutor(server.url);         expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);         // `rename_greet` renames the greet tool mid-call; the SDK server sends        // `notifications/tools/list_changed` on the open connection, which the        // invoke path records and turns into a stale mark after the call.        const result = yield* executor.execute(          ToolAddress.make(`tools.${String(INTEG)}.org.main.rename_greet`),          {},        );        expect(result).toMatchObject({ ok: true });         // No manual refresh: the next tools read re-lists from the server.        const refreshed = toolNames(yield* executor.tools.list());        expect(refreshed).toContain(mutable.renamedToolName);        expect(refreshed).not.toContain(mutable.initialToolName);      }),  );   it.effect(    "unknown-tool rejection fails typed, marks stale, and the catalog heals on the next read",    () =>      Effect.gen(function* () {        const mutable = makeMutableCatalogMcpServer();        const server = yield* serveMcpServer(mutable.factory);        // TTL disabled: only the unknown-tool signal may trigger the re-list.        const executor = yield* makeCatalogTestExecutor(server.url, { toolsSyncTtlMs: null });         expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);         // Mutate the server catalog outside any executor call window — the        // executor has no notification to react to and its catalog is drifted.        mutable.renameTool();         const staleAddress = ToolAddress.make(          `tools.${String(INTEG)}.org.main.${mutable.initialToolName}`,        );        const result = yield* executor.execute(staleAddress, { name: "world" });        expect(result).toMatchObject({          ok: false,          error: { code: "mcp_tool_unknown" },        });         // The failure marked the connection stale; the next read converges.        const healed = toolNames(yield* executor.tools.list());        expect(healed).toContain(mutable.renamedToolName);        expect(healed).not.toContain(mutable.initialToolName);      }),  );   it.effect("expired catalogs re-list on read once older than the freshness TTL", () =>    Effect.gen(function* () {      const mutable = makeMutableCatalogMcpServer();      const server = yield* serveMcpServer(mutable.factory);      // Everything is instantly stale — every tools read re-lists.      const executor = yield* makeCatalogTestExecutor(server.url, { toolsSyncTtlMs: 0 });       expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);       // Server-side change with no notification and no executor signal at all.      mutable.renameTool();       const refreshed = toolNames(yield* executor.tools.list());      expect(refreshed).toContain(mutable.renamedToolName);      expect(refreshed).not.toContain(mutable.initialToolName);    }),  );   it.effect("a fresh catalog inside the TTL is served from the persisted rows", () =>    Effect.gen(function* () {      const mutable = makeMutableCatalogMcpServer();      const server = yield* serveMcpServer(mutable.factory);      const executor = yield* makeCatalogTestExecutor(server.url, {        toolsSyncTtlMs: 60 * 60 * 1000,      });       expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);      const sessionsAfterFirstList = server.sessionCount();       mutable.renameTool();       // Within the TTL and with no stale signal, reads serve the persisted      // catalog without dialing the server.      expect(toolNames(yield* executor.tools.list())).toContain(mutable.initialToolName);      expect(server.sessionCount()).toBe(sessionsAfterFirstList);    }),  );}); // ---------------------------------------------------------------------------// Pagination — discovery follows `nextCursor` across tools/list pages.// --------------------------------------------------------------------------- const JsonRpcId = Schema.Union([Schema.String, Schema.Number, Schema.Null]);const JsonRpcRequest = Schema.Struct({  id: Schema.optional(JsonRpcId),  method: Schema.String,  params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),});type JsonRpcRequest = typeof JsonRpcRequest.Type; const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(JsonRpcRequest)); const jsonRpcResult = (request: JsonRpcRequest, result: unknown) =>  HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: request.id ?? null, result }); const pageTool = (name: string) => ({  name,  description: `Tool ${name}`,  inputSchema: { type: "object", properties: {} },}); // A minimal paginated fixture: page1 → cursor "p2" → page2 → done.const servePaginatedListServer = () =>  serveTestHttpApp((request) =>    Effect.gen(function* () {      if (request.method === "GET") {        return HttpServerResponse.text("SSE disabled", { status: 405 });      }      const body = yield* request.text.pipe(Effect.orDie);      return Option.match(decodeJsonRpcRequest(body), {        onNone: () => HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 }),        onSome: (rpc) => {          if (rpc.method === "initialize") {            return jsonRpcResult(rpc, {              protocolVersion: "2025-06-18",              capabilities: { tools: { listChanged: true } },              serverInfo: { name: "paginated-fixture", version: "1.0.0" },            });          }          if (rpc.method === "notifications/initialized") {            return HttpServerResponse.text("", { status: 202 });          }          if (rpc.method === "tools/list") {            const cursor = rpc.params?.cursor;            if (cursor === undefined) {              return jsonRpcResult(rpc, {                tools: [pageTool("alpha"), pageTool("beta")],                nextCursor: "p2",              });            }            if (cursor === "p2") {              return jsonRpcResult(rpc, { tools: [pageTool("gamma")] });            }            return HttpServerResponse.text("Unknown cursor", { status: 400 });          }          return HttpServerResponse.text("Unexpected JSON-RPC method", { status: 400 });        },      });    }),  ); describe("MCP tools/list pagination", () => {  it.effect("discoverTools follows nextCursor across every page", () =>    Effect.gen(function* () {      const server = yield* servePaginatedListServer();      const manifest = yield* discoverTools(        createMcpConnector({          transport: "remote",          endpoint: server.url("/mcp"),          remoteTransport: "streamable-http",        }),      );       expect(manifest.tools.map((tool) => tool.toolName).sort()).toEqual([        "alpha",        "beta",        "gamma",      ]);    }),  );});