catalog.test.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. import { describe, expect, test } from "bun:test"
  2. import { Client } from "@modelcontextprotocol/sdk/client/index.js"
  3. import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
  4. import { Server } from "@modelcontextprotocol/sdk/server/index.js"
  5. import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
  6. import { McpCatalog } from "@/mcp/catalog"
  7. import { Effect } from "effect"
  8. const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any
  9. function clientReturning(result: unknown) {
  10. return {
  11. callTool: async () => result,
  12. } as unknown as Client
  13. }
  14. function mcpTool() {
  15. return {
  16. name: "screenshot",
  17. description: "Take a screenshot",
  18. inputSchema: {
  19. type: "object",
  20. properties: {},
  21. additionalProperties: false,
  22. },
  23. } as any
  24. }
  25. describe("McpCatalog.convertTool", () => {
  26. test("preserves content when structuredContent is also present", async () => {
  27. const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }]
  28. const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } }
  29. const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent }))
  30. const output = await converted.execute?.({}, options)
  31. expect(output).toMatchObject({ content, structuredContent })
  32. })
  33. test("falls back to structuredContent only when content is absent", async () => {
  34. const structuredContent = { results: [{ title: "one" }] }
  35. const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent }))
  36. const output = await converted.execute?.({}, options)
  37. expect(output).toMatchObject({
  38. structuredContent,
  39. content: [{ type: "text", text: JSON.stringify(structuredContent) }],
  40. })
  41. })
  42. })
  43. test("preserves output schema validation across paginated tool discovery", async () => {
  44. const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
  45. server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
  46. Promise.resolve(
  47. params?.cursor === "page-2"
  48. ? {
  49. tools: [
  50. {
  51. name: "second",
  52. inputSchema: { type: "object" },
  53. outputSchema: {
  54. type: "object",
  55. properties: { value: { type: "number" } },
  56. required: ["value"],
  57. },
  58. },
  59. ],
  60. }
  61. : {
  62. tools: [
  63. {
  64. name: "first",
  65. inputSchema: { type: "object" },
  66. outputSchema: {
  67. type: "object",
  68. properties: { value: { type: "string" } },
  69. required: ["value"],
  70. },
  71. },
  72. ],
  73. nextCursor: "page-2",
  74. },
  75. ),
  76. )
  77. server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
  78. Promise.resolve({
  79. content: [],
  80. structuredContent: { value: params.name === "first" ? 42 : 1 },
  81. }),
  82. )
  83. const client = new Client({ name: "pagination-test", version: "1.0.0" })
  84. const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  85. await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
  86. try {
  87. const tools = await Effect.runPromise(McpCatalog.defs(client))
  88. expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"])
  89. await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
  90. "Structured content does not match the tool's output schema",
  91. )
  92. } finally {
  93. await Promise.all([client.close(), server.close()])
  94. }
  95. })