Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-mcp/__tests__/mcp.test.ts
Snapshot at 34e3175
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