1// The bridge intentionally treats MCP JSON-RPC messages as opaque JSON.
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5
6import { makeAcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts";
7
8describe("AcpMcpOverAcpBridge", () => {
9 it.effect("forwards authenticated MCP requests and closes the negotiated session", () =>
10 Effect.gen(function* () {
11 const requests: Array<{
12 readonly method: string;
13 readonly headers: Headers;
14 readonly body: unknown;
15 }> = [];
16 const bridge = yield* makeAcpMcpOverAcpBridge({
17 endpoint: "http://127.0.0.1:1/mcp",
18 authorization: "Bearer bridge-test",
19 allocateConnectionId: Effect.succeed("connection-1"),
20 fetchImplementation: async (_url, init) => {
21 const method = init?.method ?? "GET";
22 const headers = new Headers(init?.headers);
23 const body = init?.body === undefined ? null : JSON.parse(String(init.body));
24 requests.push({ method, headers, body });
25 if (method === "DELETE") return new Response(null, { status: 204 });
26 if (body === null || typeof body !== "object") return new Response(null, { status: 202 });
27 const request = body as { readonly id?: unknown; readonly method?: unknown };
28 if (request.method === "notifications/initialized") {
29 return new Response(null, { status: 202 });
30 }
31 return new Response(
32 JSON.stringify({
33 jsonrpc: "2.0",
34 id: request.id,
35 result:
36 request.method === "initialize"
37 ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: {} }
38 : { tools: [] },
39 }),
40 {
41 headers: { "content-type": "application/json", "mcp-session-id": "session-42" },
42 },
43 );
44 },
45 });
46
47 const connected = yield* bridge.connect({ serverId: "t3-code" });
48 expect(connected).toEqual({ connectionId: "connection-1" });
49 expect(
50 yield* bridge.message({ connectionId: connected.connectionId, method: "initialize" }),
51 ).toMatchObject({ protocolVersion: "2025-06-18" });
52 expect(
53 yield* bridge.message({ connectionId: connected.connectionId, method: "tools/list" }),
54 ).toEqual({ tools: [] });
55 yield* bridge.notification({
56 connectionId: connected.connectionId,
57 method: "notifications/initialized",
58 });
59 yield* bridge.disconnect({ connectionId: connected.connectionId });
60
61 expect(requests[0]?.headers.get("authorization")).toBe("Bearer bridge-test");
62 expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-42");
63 expect(requests[1]?.headers.get("mcp-protocol-version")).toBe("2025-06-18");
64 expect(requests.at(-1)?.method).toBe("DELETE");
65 expect(requests.at(-1)?.headers.get("mcp-session-id")).toBe("session-42");
66 }),
67 );
68
69 it.effect("aborts an in-flight HTTP request when the ACP call is interrupted", () =>
70 Effect.gen(function* () {
71 const started = Promise.withResolvers<AbortSignal>();
72 const bridge = yield* makeAcpMcpOverAcpBridge({
73 endpoint: "http://127.0.0.1:1/mcp",
74 authorization: "Bearer bridge-test",
75 allocateConnectionId: Effect.succeed("connection-1"),
76 fetchImplementation: (_url, init) => {
77 if (!init?.signal) throw new Error("Missing cancellation signal");
78 started.resolve(init.signal);
79 return new Promise<Response>(() => {});
80 },
81 });
82 const connected = yield* bridge.connect({ serverId: "t3-code" });
83 const request = yield* bridge
84 .message({ connectionId: connected.connectionId, method: "tools/list" })
85 .pipe(Effect.forkChild);
86 const signal = yield* Effect.promise(() => started.promise);
87 yield* Fiber.interrupt(request);
88 expect(signal.aborted).toBe(true);
89 }).pipe(Effect.scoped),
90 );
91
92 it.effect("rejects unknown servers, connections, and oversized messages", () =>
93 Effect.gen(function* () {
94 const bridge = yield* makeAcpMcpOverAcpBridge({
95 endpoint: "http://127.0.0.1:1/mcp",
96 authorization: "Bearer bridge-test",
97 allocateConnectionId: Effect.succeed("connection-1"),
98 fetchImplementation: () => Promise.resolve(new Response(null, { status: 202 })),
99 });
100
101 expect(yield* bridge.connect({ serverId: "other" }).pipe(Effect.flip)).toMatchObject({
102 _tag: "AcpMcpOverAcpError",
103 });
104 expect(
105 yield* bridge.message({ connectionId: "missing", method: "tools/list" }).pipe(Effect.flip),
106 ).toMatchObject({ _tag: "AcpMcpOverAcpError" });
107
108 const connected = yield* bridge.connect({ serverId: "t3-code" });
109 const failure = yield* bridge
110 .message({
111 connectionId: connected.connectionId,
112 method: "tools/call",
113 params: { payload: "x".repeat(8 * 1024 * 1024) },
114 })
115 .pipe(Effect.flip);
116 expect(failure.message).toContain("exceeds 8 MiB");
117 }),
118 );
119});