UNPKG

mcp-client

Version:

An MCP client for Node.js

720 lines (652 loc) 15.7 kB
import { MCPClient, ErrorCode, McpError } from "./MCPClient.js"; import { z } from "zod"; import { test, expect, expectTypeOf, vi } from "vitest"; import { getRandomPort } from "get-port-please"; import { FastMCP, FastMCPSession } from "fastmcp"; import { setTimeout as delay } from "timers/promises"; const runWithTestServer = async ({ run, client: createClient, server: createServer, }: { server?: () => Promise<FastMCP>; client?: () => Promise<MCPClient>; run: ({ server, client, session, }: { server: FastMCP; client: MCPClient; session: FastMCPSession; }) => Promise<void>; }) => { const port = await getRandomPort(); const server = createServer ? await createServer() : new FastMCP({ name: "Test", version: "1.0.0", }); await server.start({ transportType: "sse", sse: { endpoint: "/sse", port, }, }); const sseUrl = `http://localhost:${port}/sse`; try { const client = createClient ? await createClient() : new MCPClient( { name: "example-client", version: "1.0.0", }, { capabilities: {}, }, ); const [session] = await Promise.all([ new Promise<FastMCPSession>((resolve) => { server.on("connect", (event) => { resolve(event.session); }); }), client.connect({ url: sseUrl, type: "sse" }), ]); await run({ server, client, session }); } finally { await server.stop(); } return port; }; test("closes a connection", async () => { await runWithTestServer({ run: async ({ client }) => { await client.close(); }, }); }); test("pings a server", async () => { await runWithTestServer({ run: async ({ client }) => { await expect(client.ping()).resolves.toBeNull(); }, }); }); test("gets tools", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addTool({ name: "add", description: "Add two numbers", parameters: z.object({ a: z.number(), b: z.number(), }), execute: async (args) => { return String(args.a + args.b); }, }); return server; }, run: async ({ client }) => { const tools = await client.getAllTools(); expect(tools).toEqual([ { description: "Add two numbers", inputSchema: { $schema: "http://json-schema.org/draft-07/schema#", additionalProperties: false, properties: { a: { type: "number", }, b: { type: "number", }, }, required: ["a", "b"], type: "object", }, name: "add", }, ]); }, }); }); test("calls a tool", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addTool({ name: "add", description: "Add two numbers", parameters: z.object({ a: z.number(), b: z.number(), }), execute: async (args) => { return String(args.a + args.b); }, }); return server; }, run: async ({ client }) => { await expect( client.callTool({ name: "add", arguments: { a: 1, b: 2, }, }), ).resolves.toEqual({ content: [{ type: "text", text: "3" }], }); }, }); }); test("calls a tool with a custom result schema", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addTool({ name: "add", description: "Add two numbers", parameters: z.object({ a: z.number(), b: z.number(), }), execute: async (args) => { return String(args.a + args.b); }, }); return server; }, run: async ({ client }) => { const result = await client.callTool( { name: "add", arguments: { a: 1, b: 2, }, }, { resultSchema: z.object({ content: z.array( z.object({ type: z.literal("text"), text: z.string(), }), ), }), }, ); expectTypeOf(result).toEqualTypeOf<{ content: { type: "text"; text: string }[]; }>(); }, }); }); test("handles errors", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addTool({ name: "add", description: "Add two numbers", parameters: z.object({ a: z.number(), b: z.number(), }), execute: async () => { throw new Error("Something went wrong"); }, }); return server; }, run: async ({ client }) => { expect( await client.callTool({ name: "add", arguments: { a: 1, b: 2, }, }), ).toEqual({ content: [ { type: "text", text: expect.stringContaining("Something went wrong"), }, ], isError: true, }); }, }); }); test("calling an unknown tool throws McpError with MethodNotFound code", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); return server; }, run: async ({ client }) => { try { await client.callTool({ name: "add", arguments: { a: 1, b: 2, }, }); } catch (error) { console.log(error); expect(error).toBeInstanceOf(McpError); // @ts-expect-error - we know that error is an McpError expect(error.code).toBe(ErrorCode.MethodNotFound); } }, }); }); test("tracks tool progress", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addTool({ name: "add", description: "Add two numbers", parameters: z.object({ a: z.number(), b: z.number(), }), execute: async (args, { reportProgress }) => { reportProgress({ progress: 0, total: 10, }); await delay(100); return String(args.a + args.b); }, }); return server; }, run: async ({ client }) => { const onProgress = vi.fn(); await client.callTool( { name: "add", arguments: { a: 1, b: 2, }, }, { requestOptions: { onProgress, }, }, ); expect(onProgress).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalledWith({ progress: 0, total: 10, }); }, }); }); test("sets logging levels", async () => { await runWithTestServer({ run: async ({ client, session }) => { await client.setLoggingLevel("debug"); expect(session.loggingLevel).toBe("debug"); await client.setLoggingLevel("info"); expect(session.loggingLevel).toBe("info"); }, }); }); test("sends logging messages to the client", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addTool({ name: "add", description: "Add two numbers", parameters: z.object({ a: z.number(), b: z.number(), }), execute: async (args, { log }) => { log.debug("debug message", { foo: "bar", }); log.error("error message"); log.info("info message"); log.warn("warn message"); return String(args.a + args.b); }, }); return server; }, run: async ({ client }) => { const onLog = vi.fn(); client.on("loggingMessage", onLog); await client.callTool({ name: "add", arguments: { a: 1, b: 2, }, }); expect(onLog).toHaveBeenCalledTimes(4); expect(onLog).toHaveBeenNthCalledWith(1, { level: "debug", message: "debug message", context: { foo: "bar", }, }); expect(onLog).toHaveBeenNthCalledWith(2, { level: "error", message: "error message", }); expect(onLog).toHaveBeenNthCalledWith(3, { level: "info", message: "info message", }); expect(onLog).toHaveBeenNthCalledWith(4, { level: "warning", message: "warn message", }); }, }); }); test("adds resources", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addResource({ uri: "file:///logs/app.log", name: "Application Logs", mimeType: "text/plain", async load() { return { text: "Example log content", }; }, }); return server; }, run: async ({ client }) => { expect(await client.getAllResources()).toEqual([ { uri: "file:///logs/app.log", name: "Application Logs", mimeType: "text/plain", }, ]); }, }); }); test("clients reads a resource", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addResource({ uri: "file:///logs/app.log", name: "Application Logs", mimeType: "text/plain", async load() { return { text: "Example log content", }; }, }); return server; }, run: async ({ client }) => { expect( await client.getResource({ uri: "file:///logs/app.log", }), ).toEqual({ contents: [ { uri: "file:///logs/app.log", name: "Application Logs", text: "Example log content", mimeType: "text/plain", }, ], }); }, }); }); test("clients reads a resource that returns multiple resources", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addResource({ uri: "file:///logs/app.log", name: "Application Logs", mimeType: "text/plain", async load() { return [ { text: "a", }, { text: "b", }, ]; }, }); return server; }, run: async ({ client }) => { expect( await client.getResource({ uri: "file:///logs/app.log", }), ).toEqual({ contents: [ { uri: "file:///logs/app.log", name: "Application Logs", text: "a", mimeType: "text/plain", }, { uri: "file:///logs/app.log", name: "Application Logs", text: "b", mimeType: "text/plain", }, ], }); }, }); }); test("adds prompts", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addPrompt({ name: "git-commit", description: "Generate a Git commit message", arguments: [ { name: "changes", description: "Git diff or description of changes", required: true, }, ], load: async (args) => { return `Generate a concise but descriptive commit message for these changes:\n\n${args.changes}`; }, }); return server; }, run: async ({ client }) => { expect( await client.getPrompt({ name: "git-commit", arguments: { changes: "foo", }, }), ).toEqual({ description: "Generate a Git commit message", messages: [ { role: "user", content: { type: "text", text: "Generate a concise but descriptive commit message for these changes:\n\nfoo", }, }, ], }); expect(await client.getAllPrompts()).toEqual([ { name: "git-commit", description: "Generate a Git commit message", arguments: [ { name: "changes", description: "Git diff or description of changes", required: true, }, ], }, ]); }, }); }); test("completes prompt arguments", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addPrompt({ name: "countryPoem", description: "Writes a poem about a country", load: async ({ name }) => { return `Hello, ${name}!`; }, arguments: [ { name: "name", description: "Name of the country", required: true, complete: async (value) => { if (value === "Germ") { return { values: ["Germany"], }; } return { values: [], }; }, }, ], }); return server; }, run: async ({ client }) => { const response = await client.complete({ ref: { type: "ref/prompt", name: "countryPoem", }, argument: { name: "name", value: "Germ", }, }); expect(response).toEqual({ completion: { values: ["Germany"], }, }); }, }); }); test("lists resource templates", async () => { await runWithTestServer({ server: async () => { const server = new FastMCP({ name: "Test", version: "1.0.0", }); server.addResourceTemplate({ uriTemplate: "file:///logs/{name}.log", name: "Application Logs", mimeType: "text/plain", arguments: [ { name: "name", description: "Name of the log", required: true, }, ], load: async ({ name }) => { return { text: `Example log content for ${name}`, }; }, }); return server; }, run: async ({ client }) => { expect(await client.getAllResourceTemplates()).toEqual([ { name: "Application Logs", uriTemplate: "file:///logs/{name}.log", }, ]); }, }); });