Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-mcp/__tests__/mcp.test.ts

Snapshot at 34e3175

Containing directory

12,105 bytes · typescriptView raw
1import { describe, expect, it } from "vitest";
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4import { createMcpHandler } from "../src/mcp";
5import { MAX_BYTES } from "../src/core";
6import { FixtureCore } from "./fixtures";
7
8const publicOrigin = "https://mcp.idiot.tax";
9const url = `${publicOrigin}/mcp`;
10function rpc(method: string, params: unknown = {}, token = "api_fixture_builder") {
11  return new Request(url, {
12    method: "POST",
13    headers: {
14      authorization: `Bearer ${token}`,
15      "content-type": "application/json",
16      accept: "application/json, text/event-stream",
17    },
18    body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
19  });
20}
21async function connect(core = new FixtureCore()) {
22  const handler = createMcpHandler({ core: core.client, publicOrigin });
23  const client = new Client({ name: "fixture-inspector", version: "1" });
24  const transport = new StreamableHTTPClientTransport(new URL(url), {
25    requestInit: { headers: { authorization: "Bearer api_fixture_builder" } },
26    fetch: (input, init) => handler(new Request(input, init)),
27  });
28  await client.connect(transport);
29  return { core, client, handler };
30}
31
32describe("fixture Streamable HTTP integration", () => {
33  it("initializes with the official SDK and lists thirteen tools with honest annotations", async () => {
34    const { client } = await connect();
35    const { tools } = await client.listTools();
36    expect(tools.map((tool) => tool.name).toSorted()).toEqual([
37      "activity",
38      "claim",
39      "get_context",
40      "get_review",
41      "get_review_diff",
42      "join",
43      "list_judge_races",
44      "refresh_token",
45      "report_usage",
46      "request_review",
47      "status",
48      "submit_verdict",
49      "submit_work",
50    ]);
51    for (const tool of tools) {
52      const read = [
53        "get_context",
54        "status",
55        "activity",
56        "list_judge_races",
57        "get_review",
58        "get_review_diff",
59      ].includes(tool.name);
60      expect(tool.annotations?.readOnlyHint).toBe(read);
61      expect(tool.annotations?.idempotentHint).toBe(read);
62    }
63    const refresh = tools.find((tool) => tool.name === "refresh_token")!;
64    expect(refresh.description).toContain("Never print or log the token");
65    expect(refresh.description).toContain("http.extraHeader");
66    expect(refresh.inputSchema.required).toEqual(["repoName"]);
67    expect(refresh.annotations?.destructiveHint).toBe(true);
68    await client.close();
69  });
70  it("maps every tool to the contracted route and passes the core bearer unchanged", async () => {
71    const { core, client } = await connect();
72    for (const name of ["get_context", "status"])
73      await client.callTool({ name, arguments: { project: "demo" } });
74    await client.callTool({ name: "activity", arguments: { project: "demo", after: 3, limit: 8 } });
75    const joined = await client.callTool({
76      name: "join",
77      arguments: { project: "demo", agent: "builder" },
78    });
79    expect(joined.isError).toBeUndefined();
80    await client.callTool({
81      name: "claim",
82      arguments: { project: "demo", packet: "FH-004", repoName: "demo--builder-2" },
83    });
84    // A later join must not redirect the earlier packet binding.
85    await client.callTool({ name: "join", arguments: { project: "demo", agent: "builder" } });
86    await client.callTool({
87      name: "submit_work",
88      arguments: {
89        project: "demo",
90        packet: "FH-004",
91        files: [{ path: "src/a.ts", content: "fixture" }],
92        message: "fixture commit",
93      },
94    });
95    await client.callTool({
96      name: "request_review",
97      arguments: {
98        project: "demo",
99        packet: "FH-004",
100        repoName: "demo--builder-2",
101        sha: "a".repeat(40),
102      },
103    });
104    expect(
105      core.requests.map(
106        (request) =>
107          `${request.method} ${new URL(request.url).pathname}${new URL(request.url).search}`,
108      ),
109    ).toEqual([
110      "GET /projects/demo/state",
111      "GET /projects/demo/me",
112      "GET /projects/demo/state",
113      "GET /projects/demo/me",
114      "GET /projects/demo/activity?after=3&limit=8",
115      "POST /projects/demo/join",
116      "POST /projects/demo/packets/FH-004/claim",
117      "POST /projects/demo/join",
118      "GET /projects/demo/state",
119      "POST /projects/demo/forks/demo--builder-2/commit",
120      "POST /projects/demo/reviews",
121    ]);
122    for (const request of core.requests)
123      expect(request.headers.get("authorization")).toBe("Bearer api_fixture_builder");
124    expect(await core.requests[6]!.json()).toEqual({ repoName: "demo--builder-2" });
125    expect(await core.requests[9]!.json()).toEqual({
126      packet: "FH-004",
127      files: [{ path: "src/a.ts", content: "fixture" }],
128      message: "fixture commit",
129    });
130    await client.close();
131  });
132  it("rejects missing, invalid and owner credentials before listing or executing", async () => {
133    const { handler, core, client } = await connect();
134    for (const token of ["", "invalid", "owner_fixture_token"]) {
135      const response = await handler(rpc("tools/list", {}, token));
136      expect(response.status).toBe(401);
137      expect(response.headers.get("www-authenticate")).toContain(
138        "/.well-known/oauth-protected-resource/mcp",
139      );
140    }
141    expect(core.requests).toHaveLength(0);
142    await client.close();
143  });
144  it("refuses cross-project, cross-agent, traversal, extra properties and owner tools", async () => {
145    const { client, core } = await connect();
146    const bad = [
147      { name: "status", arguments: { project: "foreign" } },
148      { name: "join", arguments: { project: "demo", agent: "other" } },
149      { name: "claim", arguments: { project: "demo", packet: "../../promote" } },
150      { name: "status", arguments: { project: "demo", token: "owner" } },
151      { name: "promote", arguments: { project: "demo" } },
152      ...["../secret", "/secret", ".git/config", "src\\x", "src//x"].map((path) => ({
153        name: "submit_work",
154        arguments: {
155          project: "demo",
156          packet: "FH-004",
157          message: "fixture",
158          files: [{ path, content: "x" }],
159        },
160      })),
161    ];
162    for (const input of bad) expect((await client.callTool(input)).isError).toBe(true);
163    expect(core.requests).toHaveLength(0);
164    await client.close();
165  });
166  it("fails missing, foreign and ambiguous bindings without committing", async () => {
167    const { core, client } = await connect();
168    for (const claims of [
169      [],
170      [{ packet: "FH-004", agent: "other", repoName: "demo--other-1" }],
171      [core.claims[0]!, core.claims[0]!],
172    ]) {
173      core.claims = claims;
174      const result = await client.callTool({
175        name: "submit_work",
176        arguments: {
177          project: "demo",
178          packet: "FH-004",
179          files: [{ path: "a", content: "x" }],
180          message: "fixture",
181        },
182      });
183      expect(result.isError).toBe(true);
184    }
185    expect(core.requests.every((request) => new URL(request.url).pathname.endsWith("/state"))).toBe(
186      true,
187    );
188    await client.close();
189  });
190  it("preserves core conflicts and redacts provider exception contents", async () => {
191    const { core, client } = await connect();
192    core.failure = 409;
193    const result = await client.callTool({
194      name: "claim",
195      arguments: { project: "demo", packet: "FH-004" },
196    });
197    expect(result.isError).toBe(true);
198    expect(JSON.stringify(result)).toContain("Core conflict");
199    expect(JSON.stringify(result)).not.toContain("provider_fixture_exception");
200    expect(core.requests).toHaveLength(1);
201    await client.close();
202  });
203  it("enforces file count, duplicate paths, streamed request and fully encoded response limits", async () => {
204    const { core, handler, client } = await connect();
205    for (const files of [
206      Array.from({ length: 201 }, (_, i) => ({ path: `a${i}`, content: "x" })),
207      [
208        { path: "a", content: "x" },
209        { path: "a", content: "y" },
210      ],
211    ]) {
212      expect(
213        (
214          await client.callTool({
215            name: "submit_work",
216            arguments: { project: "demo", packet: "FH-004", files, message: "fixture" },
217          })
218        ).isError,
219      ).toBe(true);
220    }
221    const oversized = await handler(
222      new Request(url, {
223        method: "POST",
224        headers: {
225          authorization: "Bearer api_fixture_builder",
226          "content-type": "application/json",
227          accept: "application/json, text/event-stream",
228        },
229        body: "x".repeat(MAX_BYTES),
230      }),
231    );
232    expect(oversized.status).toBe(413);
233    core.payload = { claims: [], work: [], data: '"'.repeat(MAX_BYTES / 3) };
234    const response = await handler(
235      rpc("tools/call", { name: "status", arguments: { project: "demo" } }),
236    );
237    expect(response.status).toBe(413); // JSON nested inside the MCP text content grows again.
238    expect((await response.text()).length).toBeLessThan(1024);
239    await client.close();
240  });
241  it("enforces origin and path before dispatch, declines persistent streams", async () => {
242    const core = new FixtureCore();
243    const handler = createMcpHandler({ core: core.client, publicOrigin, path: "/agent/mcp" });
244    expect((await handler(rpc("tools/list"))).status).toBe(404);
245    const request = new Request(`${publicOrigin}/agent/mcp`, {
246      headers: { origin: "https://other.idiot.tax", authorization: "Bearer api_fixture_builder" },
247    });
248    expect((await handler(request)).status).toBe(403);
249    expect(
250      (
251        await handler(
252          new Request(`${publicOrigin}/agent/mcp`, {
253            headers: { authorization: "Bearer api_fixture_builder" },
254          }),
255        )
256      ).status,
257    ).toBe(405);
258    expect(core.requests).toHaveLength(0);
259  });
260  it("bounds slow authentication and stops late operations after the deadline", async () => {
261    const core = new FixtureCore();
262    let release!: () => void;
263    core.client.authenticate = async (_token, signal) => {
264      await new Promise<void>((resolve) => {
265        release = resolve;
266      });
267      signal.throwIfAborted();
268      return { project: "demo", agent: "builder" };
269    };
270    const response = await createMcpHandler({ core: core.client, publicOrigin, timeoutMs: 10 })(
271      rpc("tools/list"),
272    );
273    expect(response.status).toBe(504);
274    release();
275    await Promise.resolve();
276    expect(core.requests).toHaveLength(0);
277  });
278  it("cancels an unfinished input stream at the whole-call deadline", async () => {
279    const core = new FixtureCore();
280    let cancelled = false;
281    const body = new ReadableStream<Uint8Array>({
282      cancel() {
283        cancelled = true;
284      },
285    });
286    const init = {
287      method: "POST",
288      duplex: "half",
289      headers: {
290        authorization: "Bearer api_fixture_builder",
291        "content-type": "application/json",
292        accept: "application/json, text/event-stream",
293      },
294      body,
295    };
296    const response = await createMcpHandler({ core: core.client, publicOrigin, timeoutMs: 10 })(
297      new Request(url, init),
298    );
299    expect(response.status).toBe(504);
300    expect(cancelled).toBe(true);
301    expect(core.requests).toHaveLength(0);
302  });
303  it("rejects a streamed oversized request despite a false Content-Length", async () => {
304    const core = new FixtureCore();
305    const body = new ReadableStream<Uint8Array>({
306      start(controller) {
307        controller.enqueue(new Uint8Array(MAX_BYTES));
308        controller.close();
309      },
310    });
311    const init = {
312      method: "POST",
313      duplex: "half",
314      headers: {
315        authorization: "Bearer api_fixture_builder",
316        "content-type": "application/json",
317        "content-length": "1",
318        accept: "application/json, text/event-stream",
319      },
320      body,
321    };
322    const response = await createMcpHandler({ core: core.client, publicOrigin })(
323      new Request(url, init),
324    );
325    expect(response.status).toBe(413);
326    expect(core.requests).toHaveLength(0);
327  });
328});
329