Skip to content
Source
Sign inSign up
Open History to see the last commit

mcpOverAcpBridge.test.ts

175 lines7.4 KB

1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
4​
5import { makeAcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts";
6​
7describe("AcpMcpOverAcpBridge", () => {
8 it.effect("forwards authenticated MCP requests and closes the negotiated session", () =>
9 Effect.gen(function* () {
10 const initializeStarted = Promise.withResolvers<void>();
11 const initializeResponse = Promise.withResolvers<void>();
12 const listQueued = Promise.withResolvers<void>();
13 const requests: Array<{
14 readonly method: string;
15 readonly headers: Headers;
16 readonly body: unknown;
17 }> = [];
18 const bridge = yield* makeAcpMcpOverAcpBridge({
19 endpoint: "http://127.0.0.1:1/mcp",
20 authorization: "Bearer bridge-test",
21 allocateConnectionId: Effect.succeed("connection-1"),
22 fetchImplementation: async (_url, init) => {
23 const method = init?.method ?? "GET";
24 const headers = new Headers(init?.headers);
25 const body = init?.body === undefined ? null : JSON.parse(String(init.body));
26 requests.push({ method, headers, body });
27 if (method === "DELETE") return new Response(null, { status: 204 });
28 if (body === null || typeof body !== "object") return new Response(null, { status: 202 });
29 const request = body as { readonly id?: unknown; readonly method?: unknown };
30 if (request.method === "initialize") {
31 initializeStarted.resolve();
32 await initializeResponse.promise;
33 }
34 if (request.method === "notifications/initialized") {
35 return new Response(null, { status: 202 });
36 }
37 return new Response(
38 JSON.stringify({
39 jsonrpc: "2.0",
40 id: request.id,
41 result:
42 request.method === "initialize"
43 ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: {} }
44 : { tools: [] },
45 }),
46 {
47 headers: { "content-type": "application/json", "mcp-session-id": "session-42" },
48 },
49 );
50 },
51 });
52​
53 const connected = yield* bridge.connect({ serverId: "t3-code" });
54 expect(connected).toEqual({ connectionId: "connection-1" });
55 const initialize = yield* bridge
56 .message({ connectionId: connected.connectionId, method: "initialize" })
57 .pipe(Effect.forkChild);
58 yield* Effect.promise(() => initializeStarted.promise);
59 const list = yield* Effect.gen(function* () {
60 listQueued.resolve();
61 return yield* bridge.message({
62 connectionId: connected.connectionId,
63 method: "tools/list",
64 });
65 }).pipe(Effect.forkChild);
66 yield* Effect.promise(() => listQueued.promise);
67 initializeResponse.resolve();
68 expect(yield* Fiber.join(initialize)).toMatchObject({ protocolVersion: "2025-06-18" });
69 expect(yield* Fiber.join(list)).toEqual({ tools: [] });
70 yield* bridge.notification({
71 connectionId: connected.connectionId,
72 method: "notifications/initialized",
73 });
74 yield* bridge.disconnect({ connectionId: connected.connectionId });
75​
76 expect(requests[0]?.headers.get("authorization")).toBe("Bearer bridge-test");
77 expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-42");
78 expect(requests[1]?.headers.get("mcp-protocol-version")).toBe("2025-06-18");
79 expect(requests.at(-1)?.method).toBe("DELETE");
80 expect(requests.at(-1)?.headers.get("mcp-session-id")).toBe("session-42");
81 }),
82 );
83​
84 it.effect("aborts an in-flight HTTP request when the ACP call is interrupted", () =>
85 Effect.gen(function* () {
86 const started = Promise.withResolvers<AbortSignal>();
87 const bridge = yield* makeAcpMcpOverAcpBridge({
88 endpoint: "http://127.0.0.1:1/mcp",
89 authorization: "Bearer bridge-test",
90 allocateConnectionId: Effect.succeed("connection-1"),
91 fetchImplementation: (_url, init) => {
92 if (!init?.signal) throw new Error("Missing cancellation signal");
93 started.resolve(init.signal);
94 return new Promise<Response>(() => {});
95 },
96 });
97 const connected = yield* bridge.connect({ serverId: "t3-code" });
98 const request = yield* bridge
99 .message({ connectionId: connected.connectionId, method: "tools/list" })
100 .pipe(Effect.forkChild);
101 const signal = yield* Effect.promise(() => started.promise);
102 yield* Fiber.interrupt(request);
103 expect(signal.aborted).toBe(true);
104 }),
105 );
106​
107 it.effect("answers a call while a long call on the same connection is pending", () =>
108 Effect.gen(function* () {
109 const waitStarted = Promise.withResolvers<void>();
110 const waitResponse = Promise.withResolvers<Response>();
111 const reply = (id: unknown, result: unknown) =>
112 new Response(JSON.stringify({ jsonrpc: "2.0", id, result }), {
113 headers: { "content-type": "application/json", "mcp-session-id": "session-42" },
114 });
115 const bridge = yield* makeAcpMcpOverAcpBridge({
116 endpoint: "http://127.0.0.1:1/mcp",
117 authorization: "Bearer bridge-test",
118 allocateConnectionId: Effect.succeed("connection-1"),
119 fetchImplementation: (_url, init) => {
120 const request = JSON.parse(String(init?.body)) as {
121 readonly id?: unknown;
122 readonly method?: unknown;
123 };
124 if (request.method === "initialize") {
125 return Promise.resolve(reply(request.id, { protocolVersion: "2025-06-18" }));
126 }
127 if (request.method === "tools/call") {
128 waitStarted.resolve();
129 return waitResponse.promise;
130 }
131 return Promise.resolve(reply(request.id, { tools: [] }));
132 },
133 });
134 const { connectionId } = yield* bridge.connect({ serverId: "t3-code" });
135 yield* bridge.message({ connectionId, method: "initialize" });
136​
137 const wait = yield* bridge
138 .message({ connectionId, method: "tools/call", params: { name: "t3_thread_wait" } })
139 .pipe(Effect.forkChild);
140 yield* Effect.promise(() => waitStarted.promise);
141 expect(yield* bridge.message({ connectionId, method: "tools/list" })).toEqual({ tools: [] });
142​
143 waitResponse.resolve(reply(2, { content: [] }));
144 expect(yield* Fiber.join(wait)).toEqual({ content: [] });
145 }),
146 );
147​
148 it.effect("rejects unknown servers, connections, and oversized messages", () =>
149 Effect.gen(function* () {
150 const bridge = yield* makeAcpMcpOverAcpBridge({
151 endpoint: "http://127.0.0.1:1/mcp",
152 authorization: "Bearer bridge-test",
153 allocateConnectionId: Effect.succeed("connection-1"),
154 fetchImplementation: () => Promise.resolve(new Response(null, { status: 202 })),
155 });
156​
157 expect(yield* bridge.connect({ serverId: "other" }).pipe(Effect.flip)).toMatchObject({
158 _tag: "AcpMcpOverAcpError",
159 });
160 expect(
161 yield* bridge.message({ connectionId: "missing", method: "tools/list" }).pipe(Effect.flip),
162 ).toMatchObject({ _tag: "AcpMcpOverAcpError" });
163​
164 const connected = yield* bridge.connect({ serverId: "t3-code" });
165 const failure = yield* bridge
166 .message({
167 connectionId: connected.connectionId,
168 method: "tools/call",
169 params: { payload: "x".repeat(8 * 1024 * 1024) },
170 })
171 .pipe(Effect.flip);
172 expect(failure.message).toContain("exceeds 8 MiB");
173 }),
174 );
175});