branch:
mcp.test.ts
12776 bytesRaw
import { describe, it, expect } from "vitest";
import { env } from "cloudflare:workers";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";
import { DynamicWorkerExecutor } from "../executor";
import { codeMcpServer, openApiMcpServer } from "../mcp";

function createUpstreamServer() {
  const server = new McpServer({
    name: "test-tools",
    version: "1.0.0"
  });

  server.registerTool(
    "add",
    {
      description: "Add two numbers",
      inputSchema: {
        a: z.number().describe("First number"),
        b: z.number().describe("Second number")
      }
    },
    async ({ a, b }) => ({
      content: [{ type: "text", text: String(a + b) }]
    })
  );

  server.registerTool(
    "greet",
    {
      description: "Generate a greeting",
      inputSchema: {
        name: z.string().describe("Name to greet")
      }
    },
    async ({ name }) => ({
      content: [{ type: "text", text: `Hello, ${name}!` }]
    })
  );

  return server;
}

async function connectClient(server: McpServer): Promise<Client> {
  const [clientTransport, serverTransport] =
    InMemoryTransport.createLinkedPair();
  await server.connect(serverTransport);
  const client = new Client({ name: "test-client", version: "1.0.0" });
  await client.connect(clientTransport);
  return client;
}

function callText(result: Awaited<ReturnType<Client["callTool"]>>): string {
  return (result.content as Array<{ type: string; text: string }>)[0].text;
}

describe("codeMcpServer", () => {
  it("should expose a single code tool", async () => {
    const upstream = createUpstreamServer();
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const wrapped = await codeMcpServer({ server: upstream, executor });
    const client = await connectClient(wrapped);

    const { tools } = await client.listTools();
    expect(tools.map((t) => t.name)).toEqual(["code"]);

    await client.close();
  });

  it("code tool description should declare codemode with add and greet methods", async () => {
    const upstream = createUpstreamServer();
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const wrapped = await codeMcpServer({ server: upstream, executor });
    const client = await connectClient(wrapped);

    const { tools } = await client.listTools();

    expect(tools[0].description).toMatchSnapshot();

    await client.close();
  });

  it("code tool should call upstream add(10, 32) and return 42", async () => {
    const upstream = createUpstreamServer();
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const wrapped = await codeMcpServer({ server: upstream, executor });
    const client = await connectClient(wrapped);

    const result = await client.callTool({
      name: "code",
      arguments: {
        code: `async () => {
          const r = await codemode.add({ a: 10, b: 32 });
          return r;
        }`
      }
    });

    expect(JSON.parse(callText(result)).content[0].text).toBe("42");

    await client.close();
  });

  it("code tool should chain add then greet", async () => {
    const upstream = createUpstreamServer();
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const wrapped = await codeMcpServer({ server: upstream, executor });
    const client = await connectClient(wrapped);

    const result = await client.callTool({
      name: "code",
      arguments: {
        code: `async () => {
          const sum = await codemode.add({ a: 5, b: 3 });
          const greeting = await codemode.greet({ name: "Result is " + sum.content[0].text });
          return greeting;
        }`
      }
    });

    expect(JSON.parse(callText(result)).content[0].text).toBe(
      "Hello, Result is 8!"
    );

    await client.close();
  });

  it("code tool should return error on throw", async () => {
    const upstream = createUpstreamServer();
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const wrapped = await codeMcpServer({ server: upstream, executor });
    const client = await connectClient(wrapped);

    const result = await client.callTool({
      name: "code",
      arguments: {
        code: "async () => { throw new Error('test error'); }"
      }
    });

    expect(callText(result)).toBe("Error: test error");

    await client.close();
  });

  it("code tool should handle undefined return value", async () => {
    const upstream = createUpstreamServer();
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const wrapped = await codeMcpServer({ server: upstream, executor });
    const client = await connectClient(wrapped);

    const result = await client.callTool({
      name: "code",
      arguments: {
        code: "async () => { return undefined; }"
      }
    });

    expect(callText(result)).toBe("undefined");

    await client.close();
  });

  it("code tool should handle null return value", async () => {
    const upstream = createUpstreamServer();
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const wrapped = await codeMcpServer({ server: upstream, executor });
    const client = await connectClient(wrapped);

    const result = await client.callTool({
      name: "code",
      arguments: {
        code: "async () => { return null; }"
      }
    });

    expect(callText(result)).toBe("null");

    await client.close();
  });

  it("code tool should handle non-existent upstream tool", async () => {
    const upstream = createUpstreamServer();
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const wrapped = await codeMcpServer({ server: upstream, executor });
    const client = await connectClient(wrapped);

    const result = await client.callTool({
      name: "code",
      arguments: {
        code: "async () => { return await codemode.nonexistent({}); }"
      }
    });

    expect(callText(result)).toBe('Error: Tool "nonexistent" not found');

    await client.close();
  });
});

describe("openApiMcpServer", () => {
  const sampleSpec = {
    openapi: "3.0.0",
    paths: {
      "/users": {
        get: {
          summary: "List users",
          tags: ["users"],
          parameters: [
            {
              name: "limit",
              in: "query",
              schema: { type: "integer" }
            }
          ]
        },
        post: {
          summary: "Create user",
          tags: ["users"],
          requestBody: {
            content: {
              "application/json": {
                schema: {
                  type: "object",
                  properties: { name: { type: "string" } }
                }
              }
            }
          }
        }
      },
      "/users/{id}": {
        get: {
          summary: "Get user by ID",
          tags: ["users"],
          parameters: [
            {
              name: "id",
              in: "path",
              required: true,
              schema: { type: "string" }
            }
          ]
        }
      }
    }
  };

  it("should expose search and execute tools", async () => {
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const server = openApiMcpServer({
      spec: sampleSpec,
      executor,
      request: async () => ({})
    });
    const client = await connectClient(server);

    const { tools } = await client.listTools();
    expect(tools.map((t) => t.name).sort()).toEqual(["execute", "search"]);

    await client.close();
  });

  it("search tool description should match snapshot", async () => {
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const server = openApiMcpServer({
      spec: sampleSpec,
      executor,
      request: async () => ({})
    });
    const client = await connectClient(server);

    const { tools } = await client.listTools();
    const searchTool = tools.find((t) => t.name === "search");
    expect(searchTool!.description).toMatchSnapshot();

    await client.close();
  });

  it("execute tool description should match snapshot", async () => {
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const server = openApiMcpServer({
      spec: sampleSpec,
      executor,
      request: async () => ({})
    });
    const client = await connectClient(server);

    const { tools } = await client.listTools();
    const executeTool = tools.find((t) => t.name === "execute");
    expect(executeTool!.description).toMatchSnapshot();

    await client.close();
  });

  it("search tool should list spec paths via codemode.spec()", async () => {
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const server = openApiMcpServer({
      spec: sampleSpec,
      executor,
      request: async () => ({})
    });
    const client = await connectClient(server);

    const result = await client.callTool({
      name: "search",
      arguments: {
        code: "async () => { const spec = await codemode.spec(); return Object.keys(spec.paths); }"
      }
    });

    expect(JSON.parse(callText(result))).toEqual(["/users", "/users/{id}"]);

    await client.close();
  });

  it("search tool should return first operation summary", async () => {
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const server = openApiMcpServer({
      spec: sampleSpec,
      executor,
      request: async () => ({})
    });
    const client = await connectClient(server);

    const result = await client.callTool({
      name: "search",
      arguments: {
        code: "async () => { const spec = await codemode.spec(); return spec.paths['/users'].get.summary; }"
      }
    });

    expect(callText(result)).toBe("List users");

    await client.close();
  });

  it("execute tool should proxy codemode.request() to host-side function", async () => {
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const server = openApiMcpServer({
      spec: sampleSpec,
      executor,
      request: async (opts) => ({
        status: 200,
        method: opts.method,
        path: opts.path,
        data: [{ id: 1, name: "Alice" }]
      })
    });
    const client = await connectClient(server);

    const result = await client.callTool({
      name: "execute",
      arguments: {
        code: 'async () => await codemode.request({ method: "GET", path: "/users" })'
      }
    });

    expect(JSON.parse(callText(result))).toEqual({
      status: 200,
      method: "GET",
      path: "/users",
      data: [{ id: 1, name: "Alice" }]
    });

    await client.close();
  });

  it("execute tool should return error when request throws", async () => {
    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const server = openApiMcpServer({
      spec: sampleSpec,
      executor,
      request: async () => {
        throw new Error("unauthorized");
      }
    });
    const client = await connectClient(server);

    const result = await client.callTool({
      name: "execute",
      arguments: {
        code: 'async () => await codemode.request({ method: "GET", path: "/secret" })'
      }
    });

    expect(callText(result)).toBe("Error: unauthorized");

    await client.close();
  });

  it("should resolve $refs before injecting spec", async () => {
    const specWithRefs = {
      openapi: "3.0.0",
      paths: {
        "/items": {
          get: {
            summary: "List items",
            responses: {
              "200": {
                content: {
                  "application/json": {
                    schema: { $ref: "#/components/schemas/ItemList" }
                  }
                }
              }
            }
          }
        }
      },
      components: {
        schemas: {
          ItemList: {
            type: "array",
            items: { type: "object", properties: { id: { type: "string" } } }
          }
        }
      }
    };

    const executor = new DynamicWorkerExecutor({ loader: env.LOADER });
    const server = openApiMcpServer({
      spec: specWithRefs,
      executor,
      request: async () => ({})
    });
    const client = await connectClient(server);

    const result = await client.callTool({
      name: "search",
      arguments: {
        code: "async () => { const spec = await codemode.spec(); return spec.paths['/items'].get.responses['200'].content['application/json'].schema; }"
      }
    });

    expect(JSON.parse(callText(result))).toEqual({
      type: "array",
      items: { type: "object", properties: { id: { type: "string" } } }
    });

    await client.close();
  });
});