import { describe, it, expect, mock } from "bun:test"; import type { IClient } from "../client-like.ts"; import { GatewayClient, displayToolName, namespaceCode, slugify, stripToolNamespace, } from "./gateway-client.ts"; // Namespaced tool name for a client key, mirroring GatewayClient's scheme. const ns = (key: string, tool: string) => `${namespaceCode(key)}_${tool}`; function createMockClient( tools: { name: string }[] = [], resources: { uri: string; name: string }[] = [], prompts: { name: string }[] = [], ): IClient { return { listTools: mock(async () => ({ tools: tools.map((t) => ({ ...t, description: `desc-${t.name}`, inputSchema: { type: "object" as const }, })), })), callTool: mock(async (params) => ({ content: [{ type: "text" as const, text: `result-from-${params.name}` }], })), listResources: mock(async () => ({ resources: resources.map((r) => ({ ...r, mimeType: "text/plain", })), })), readResource: mock(async (params) => ({ contents: [{ uri: params.uri, text: "content", mimeType: "text/plain" }], })), listResourceTemplates: mock(async () => ({ resourceTemplates: [], })), listPrompts: mock(async () => ({ prompts: prompts.map((p) => ({ ...p, description: `desc-${p.name}`, })), })), getPrompt: mock(async (params) => ({ messages: [ { role: "user" as const, content: { type: "text" as const, text: `prompt-${params.name}`, }, }, ], })), getServerCapabilities: mock(() => ({ tools: {}, resources: {}, prompts: {}, })), getInstructions: mock(() => undefined), close: mock(async () => {}), }; } describe("slugify", () => { it("lowercases and replaces non-alphanumeric with hyphens", () => { expect(slugify("My Server")).toBe("my-server"); expect(slugify("Salesforce CRM")).toBe("salesforce-crm"); expect(slugify("a--b__c")).toBe("a-b-c"); expect(slugify("---leading-trailing---")).toBe("leading-trailing"); expect(slugify("simple")).toBe("simple"); }); }); describe("stripToolNamespace", () => { it("strips clientId prefix", () => { expect(stripToolNamespace(ns("my-conn", "SOME_TOOL"), "my-conn")).toBe( "SOME_TOOL", ); }); it("returns unchanged when no clientId", () => { expect(stripToolNamespace("SOME_TOOL")).toBe("SOME_TOOL"); }); it("returns unchanged when clientId does not match", () => { expect(stripToolNamespace("other_SOME_TOOL", "my-conn")).toBe( "other_SOME_TOOL", ); }); it("strips real connection ID prefix", () => { const cid = "conn-dvitqc2ooobdzmrd5ky24"; expect(stripToolNamespace(ns(cid, "hello_world"), cid)).toBe("hello_world"); }); }); describe("displayToolName", () => { it("strips clientId prefix and formats for display", () => { expect(displayToolName(ns("my-conn", "SOME_TOOL"), "my-conn")).toBe( "some tool", ); }); it("returns formatted name when no clientId", () => { expect(displayToolName("SOME_TOOL")).toBe("some tool"); }); }); describe("GatewayClient", () => { describe("tool namespacing", () => { it("prefixes tool names with the client key namespace code", async () => { const clientA = createMockClient([{ name: "toolA" }]); const clientB = createMockClient([{ name: "toolB" }]); const gw = new GatewayClient({ a: { client: clientA }, b: { client: clientB }, }); const result = await gw.listTools(); expect(result.tools).toHaveLength(2); const names = result.tools.map((t) => t.name); expect(names).toContain(ns("a", "toolA")); expect(names).toContain(ns("b", "toolB")); }); it("keeps namespaced tool names short (fits 64-char limit under a second prefix)", () => { // conn ids are ~26 chars; the namespace code must be short so a // downstream client can add its own prefix and still stay <= 64. expect(namespaceCode("conn_MMGhTBDv1JmlGbsdHmSn5").length).toBeLessThan( 9, ); expect(namespaceCode("conn_MMGhTBDv1JmlGbsdHmSn5")).not.toContain("_"); }); it("tags tools with _meta.gatewayClientId", async () => { const clientA = createMockClient([{ name: "toolA" }]); const gw = new GatewayClient({ myKey: { client: clientA } }); const result = await gw.listTools(); expect(result.tools[0].name).toBe(ns("myKey", "toolA")); expect((result.tools[0]._meta as any).gatewayClientId).toBe("myKey"); }); it("allows same tool name across different clients", async () => { const clientA = createMockClient([{ name: "search" }]); const clientB = createMockClient([{ name: "search" }]); const gw = new GatewayClient({ a: { client: clientA }, b: { client: clientB }, }); const result = await gw.listTools(); expect(result.tools).toHaveLength(2); expect(result.tools.map((t) => t.name)).toEqual([ ns("a", "search"), ns("b", "search"), ]); }); it("gives distinct keys distinct namespace codes", () => { expect(namespaceCode("alpha")).not.toBe(namespaceCode("beta")); expect(namespaceCode("conn_a")).not.toBe(namespaceCode("conn_b")); }); }); describe("resilience to failing connections", () => { it("skips a connection whose listTools throws, keeps the rest", async () => { const healthy = createMockClient([{ name: "ok_tool" }]); const broken = createMockClient([{ name: "dead_tool" }]); // Simulate a 401 / circuit-breaker-open upstream. broken.listTools = mock(async () => { throw new Error("circuit breaker is open — downstream unreachable"); }); const gw = new GatewayClient({ healthy: { client: healthy }, broken: { client: broken }, }); const result = await gw.listTools(); expect(result.tools.map((t) => t.name)).toEqual([ ns("healthy", "ok_tool"), ]); }); it("skips a connection whose lazy factory throws on resolve", async () => { const healthy = createMockClient([{ name: "ok_tool" }]); const gw = new GatewayClient({ healthy: { client: healthy }, broken: { client: () => { throw new Error("Unauthorized: Authentication required"); }, }, }); const result = await gw.listTools(); expect(result.tools.map((t) => t.name)).toEqual([ ns("healthy", "ok_tool"), ]); }); it("degrades resources and prompts the same way", async () => { const healthy = createMockClient( [], [{ uri: "res://ok", name: "ok" }], [{ name: "ok_prompt" }], ); const broken = createMockClient(); broken.listResources = mock(async () => { throw new Error("boom"); }); broken.listPrompts = mock(async () => { throw new Error("boom"); }); const gw = new GatewayClient({ healthy: { client: healthy }, broken: { client: broken }, }); expect((await gw.listResources()).resources.map((r) => r.uri)).toEqual([ "res://ok", ]); expect((await gw.listPrompts()).prompts.map((p) => p.name)).toEqual([ ns("healthy", "ok_prompt"), ]); }); }); describe("pagination", () => { it("follows nextCursor across multiple pages", async () => { const client = createMockClient(); let call = 0; const tool = (name: string) => ({ name, inputSchema: { type: "object" as const }, }); client.listTools = mock(async () => { call++; if (call === 1) { return { tools: [tool("a")], nextCursor: "page2" }; } return { tools: [tool("b")] }; }) as IClient["listTools"]; const gw = new GatewayClient({ srv: { client } }); const result = await gw.listTools(); expect(result.tools.map((t) => t.name)).toEqual([ ns("srv", "a"), ns("srv", "b"), ]); }); it("caps pagination instead of looping forever on a server that never stops returning nextCursor", async () => { const client = createMockClient(); let call = 0; client.listTools = mock(async () => { call++; // A misbehaving/malicious upstream that always claims there's another page. return { tools: [ { name: `t${call}`, inputSchema: { type: "object" as const } }, ], nextCursor: "always-more", }; }) as IClient["listTools"]; const gw = new GatewayClient({ srv: { client } }); const result = await gw.listTools(); expect(result.tools.length).toBe(1000); expect(call).toBe(1000); }); }); describe("prompt namespacing", () => { it("prefixes prompt names with slugified client key", async () => { const client = createMockClient([], [], [{ name: "greet" }]); const gw = new GatewayClient({ server: { client } }); const result = await gw.listPrompts(); expect(result.prompts[0].name).toBe(ns("server", "greet")); }); }); describe("routing", () => { it("callTool routes to correct client with original name", async () => { const clientA = createMockClient([{ name: "toolA" }]); const clientB = createMockClient([{ name: "toolB" }]); const gw = new GatewayClient({ a: { client: clientA }, b: { client: clientB }, }); await gw.callTool({ name: ns("b", "toolB"), arguments: {} }); expect(clientB.callTool).toHaveBeenCalledWith( { name: "toolB", arguments: {} }, undefined, undefined, ); expect(clientA.callTool).not.toHaveBeenCalled(); }); it("callTool works without listing first", async () => { const client = createMockClient([{ name: "doStuff" }]); const gw = new GatewayClient({ srv: { client } }); await gw.callTool({ name: ns("srv", "doStuff"), arguments: { x: 1 } }); expect(client.callTool).toHaveBeenCalledWith( { name: "doStuff", arguments: { x: 1 } }, undefined, undefined, ); }); it("readResource routes to correct client", async () => { const clientA = createMockClient( [], [{ uri: "file:///a.txt", name: "a" }], ); const clientB = createMockClient( [], [{ uri: "file:///b.txt", name: "b" }], ); const gw = new GatewayClient({ a: { client: clientA }, b: { client: clientB }, }); await gw.listResources(); await gw.readResource({ uri: "file:///b.txt" }); expect(clientB.readResource).toHaveBeenCalled(); expect(clientA.readResource).not.toHaveBeenCalled(); }); it("getPrompt routes to correct client with original name", async () => { const clientA = createMockClient([], [], [{ name: "promptA" }]); const clientB = createMockClient([], [], [{ name: "promptB" }]); const gw = new GatewayClient({ a: { client: clientA }, b: { client: clientB }, }); await gw.getPrompt({ name: ns("b", "promptB"), arguments: {} }); expect(clientB.getPrompt).toHaveBeenCalledWith({ name: "promptB", arguments: {}, }); expect(clientA.getPrompt).not.toHaveBeenCalled(); }); it("throws for unknown namespace when not found by original name", async () => { const gw = new GatewayClient({ a: { client: createMockClient([{ name: "t" }]) }, }); await expect( gw.callTool({ name: "unknown_t", arguments: {} }), ).rejects.toThrow(/unknown namespace.*not found by original name/); }); it("throws for name without separator when not found", async () => { const gw = new GatewayClient({ a: { client: createMockClient([{ name: "t" }]) }, }); await expect( gw.callTool({ name: "noprefix", arguments: {} }), ).rejects.toThrow(/could not resolve tool/); }); it("resolves un-namespaced tool name by searching clients", async () => { const clientA = createMockClient([{ name: "TOOL_A" }]); const clientB = createMockClient([{ name: "TOOL_B" }]); const gw = new GatewayClient({ "conn-abc": { client: clientA }, "conn-def": { client: clientB }, }); // Call with un-namespaced name — should find TOOL_B on conn-def await gw.callTool({ name: "TOOL_B", arguments: { x: 1 } }); expect(clientB.callTool).toHaveBeenCalledWith( { name: "TOOL_B", arguments: { x: 1 } }, undefined, undefined, ); }); }); describe("lazy factory", () => { it("factory is called on first use and cached", async () => { const client = createMockClient([{ name: "lazy_tool" }]); const factory = mock(() => client); const gw = new GatewayClient({ lazy: { client: factory } }); expect(factory).not.toHaveBeenCalled(); await gw.listTools(); expect(factory).toHaveBeenCalledTimes(1); gw.refresh(); await gw.listTools(); expect(factory).toHaveBeenCalledTimes(1); }); it("async factory is supported", async () => { const client = createMockClient([{ name: "async_tool" }]); const factory = mock(async () => client); const gw = new GatewayClient({ async: { client: factory } }); const result = await gw.listTools(); expect(result.tools).toHaveLength(1); expect(result.tools[0].name).toBe(ns("async", "async_tool")); }); }); describe("per-client selection", () => { it("filters tools by selected names", async () => { const client = createMockClient([ { name: "toolA" }, { name: "toolB" }, { name: "toolC" }, ]); const gw = new GatewayClient({ c: { client, tools: ["toolA", "toolC"] }, }); const result = await gw.listTools(); expect(result.tools).toHaveLength(2); const names = result.tools.map((t) => t.name); expect(names).toContain(ns("c", "toolA")); expect(names).toContain(ns("c", "toolC")); expect(names).not.toContain(ns("c", "toolB")); }); it("empty tools array blocks all tools", async () => { const clientA = createMockClient([{ name: "t1" }]); const clientB = createMockClient([{ name: "t2" }]); const gw = new GatewayClient({ a: { client: clientA, tools: [] }, b: { client: clientB }, }); const result = await gw.listTools(); expect(result.tools.map((t) => t.name)).toEqual([ns("b", "t2")]); }); it("filters resources by selected URIs", async () => { const client = createMockClient( [], [ { uri: "file:///a.txt", name: "a" }, { uri: "file:///b.txt", name: "b" }, ], ); const gw = new GatewayClient({ c: { client, resources: ["file:///a.txt"] }, }); const result = await gw.listResources(); expect(result.resources).toHaveLength(1); expect(result.resources[0].uri).toBe("file:///a.txt"); }); it("filters resources by selected names (name-first IDs)", async () => { const client = createMockClient( [], [ { uri: "file:///a.txt", name: "a" }, { uri: "file:///b.txt", name: "b" }, ], ); const gw = new GatewayClient({ c: { client, resources: ["a"] }, }); const result = await gw.listResources(); expect(result.resources).toHaveLength(1); expect(result.resources[0].uri).toBe("file:///a.txt"); }); it("filters prompts by selected names", async () => { const client = createMockClient([], [], [{ name: "p1" }, { name: "p2" }]); const gw = new GatewayClient({ c: { client, prompts: ["p2"] }, }); const result = await gw.listPrompts(); expect(result.prompts).toHaveLength(1); expect(result.prompts[0].name).toBe(ns("c", "p2")); }); it("per-client selection across multiple clients", async () => { const gw = new GatewayClient({ a: { client: createMockClient([{ name: "a1" }, { name: "a2" }]), tools: ["a1"], }, b: { client: createMockClient([{ name: "b1" }, { name: "b2" }]), tools: ["b2"], }, }); const result = await gw.listTools(); expect(result.tools.map((t) => t.name)).toEqual([ ns("a", "a1"), ns("b", "b2"), ]); }); }); describe("getResolvedClient", () => { it("returns the resolved client", async () => { const client = createMockClient([]); const gw = new GatewayClient({ k: { client } }); expect(await gw.getResolvedClient("k")).toBe(client); }); it("throws for unknown key", async () => { const gw = new GatewayClient({}); await expect(gw.getResolvedClient("x")).rejects.toThrow(); }); it("rejects (does not throw synchronously) when the factory throws synchronously", async () => { const gw = new GatewayClient({ broken: { client: () => { throw new Error("Unauthorized: Authentication required"); }, }, }); // Must not throw here — resolveClient's Promise contract // requires a rejected promise, not a synchronous exception. let result: Promise | undefined; expect(() => { result = gw.getResolvedClient("broken"); }).not.toThrow(); await expect(result).rejects.toThrow( "Unauthorized: Authentication required", ); }); }); describe("refresh()", () => { it("invalidates cache so next list re-fetches", async () => { const client = createMockClient([{ name: "tool1" }]); const gw = new GatewayClient({ c: { client } }); await gw.listTools(); expect(client.listTools).toHaveBeenCalledTimes(1); await gw.listTools(); expect(client.listTools).toHaveBeenCalledTimes(1); gw.refresh(); await gw.listTools(); expect(client.listTools).toHaveBeenCalledTimes(2); }); }); describe("close()", () => { it("calls close on all resolved clients", async () => { const clientA = createMockClient([{ name: "a" }]); const clientB = createMockClient([{ name: "b" }]); const gw = new GatewayClient({ a: { client: clientA }, b: { client: clientB }, }); await gw.listTools(); await gw.close(); expect(clientA.close).toHaveBeenCalled(); expect(clientB.close).toHaveBeenCalled(); }); it("does not call close on unresolved factory clients", async () => { const client = createMockClient([{ name: "a" }]); const factory = mock(() => client); const gw = new GatewayClient({ lazy: { client: factory } }); await gw.close(); expect(factory).not.toHaveBeenCalled(); expect(client.close).not.toHaveBeenCalled(); }); }); describe("empty clients record", () => { it("returns empty tool list", async () => { const gw = new GatewayClient({}); const result = await gw.listTools(); expect(result.tools).toHaveLength(0); }); it("returns empty resources list", async () => { const gw = new GatewayClient({}); const result = await gw.listResources(); expect(result.resources).toHaveLength(0); }); it("returns empty prompts list", async () => { const gw = new GatewayClient({}); const result = await gw.listPrompts(); expect(result.prompts).toHaveLength(0); }); }); describe("getServerCapabilities", () => { it("returns { tools: {}, resources: {}, prompts: {} }", () => { const gw = new GatewayClient({}); const caps = gw.getServerCapabilities(); expect(caps).toEqual({ tools: {}, resources: {}, prompts: {} }); }); }); describe("getInstructions", () => { it("returns undefined", () => { const gw = new GatewayClient({}); expect(gw.getInstructions()).toBeUndefined(); }); }); describe("caching", () => { it("caches listTools results across calls", async () => { const client = createMockClient([{ name: "tool1" }]); const gw = new GatewayClient({ c: { client } }); const r1 = await gw.listTools(); const r2 = await gw.listTools(); expect(r1).toBe(r2); expect(client.listTools).toHaveBeenCalledTimes(1); }); }); });