@simonepri/refined-antigravity-acp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { extractUserMessage, readConversationHistory } from "./history.js";
3
+ import { formatSystemContext } from "./sanitize.js";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { unlinkSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+
9
+ describe("readConversationHistory", () => {
10
+ it("returns empty array for non-existent session", () => {
11
+ const history = readConversationHistory("non-existent-session-id");
12
+ expect(history).toEqual([]);
13
+ });
14
+
15
+ it("can read existing session if available", () => {
16
+ const history = readConversationHistory("bb2bf99a-ec9e-4d4a-9924-6aec7b7291cf");
17
+ if (history.length > 0) {
18
+ expect(history[0].item.type).toBe("user_message");
19
+ if (history[0].item.type === "user_message") {
20
+ expect(history[0].item.text).toContain("We are building");
21
+ }
22
+ }
23
+ });
24
+ it("sanitizes assistant messages and does not leak task_notification", () => {
25
+ const history = readConversationHistory("528dc461-72a0-4c97-a5d5-d24b66eb6d6c");
26
+ for (const entry of history) {
27
+ if (entry.item.type === "assistant_message") {
28
+ expect(entry.item.text).not.toContain("<task_notification>");
29
+ expect(entry.item.text).not.toContain("</task_notification>");
30
+ }
31
+ }
32
+ });
33
+ });
34
+
35
+ function encodeVarint(val: number): Buffer {
36
+ const bytes: number[] = [];
37
+ let v = val;
38
+ while (v >= 0x80) {
39
+ bytes.push((v & 0x7f) | 0x80);
40
+ v >>>= 7;
41
+ }
42
+ bytes.push(v);
43
+ return Buffer.from(bytes);
44
+ }
45
+
46
+ function encodeLengthDelimited(fieldNum: number, data: Uint8Array): Buffer {
47
+ const tag = (fieldNum << 3) | 2;
48
+ return Buffer.concat([encodeVarint(tag), encodeVarint(data.length), Buffer.from(data)]);
49
+ }
50
+
51
+ function makeUserPayload(text: string): Uint8Array {
52
+ const f2 = encodeLengthDelimited(2, Buffer.from(text, "utf-8"));
53
+ const f19 = encodeLengthDelimited(19, f2);
54
+ return new Uint8Array(f19);
55
+ }
56
+
57
+ describe("user message replay system context stripping", () => {
58
+ it("extractUserMessage strips injected system context and formatting guidance", () => {
59
+ const raw = `${formatSystemContext("Act as an SRE.")}\n\nInspect deployment health`;
60
+ const payload = makeUserPayload(raw);
61
+ const item = extractUserMessage(payload, 1);
62
+ expect(item).not.toBeNull();
63
+ expect(item?.type).toBe("user_message");
64
+ expect((item as { text: string }).text).toBe("Inspect deployment health");
65
+ expect((item as { text: string }).text).not.toContain("[System Context]");
66
+ expect((item as { text: string }).text).not.toContain("[Formatting Guidance]");
67
+ });
68
+
69
+ it("extractUserMessage strips formatting guidance when no system prompt was present", () => {
70
+ const raw = `${formatSystemContext(undefined)}\n\nShow me the pods`;
71
+ const payload = makeUserPayload(raw);
72
+ const item = extractUserMessage(payload, 2);
73
+ expect(item).not.toBeNull();
74
+ expect((item as { text: string }).text).toBe("Show me the pods");
75
+ expect((item as { text: string }).text).not.toContain("[Formatting Guidance]");
76
+ });
77
+
78
+ it("readConversationHistory strips injected system context on SQLite replay", () => {
79
+ const tempDbPath = join(
80
+ tmpdir(),
81
+ `test-history-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
82
+ );
83
+ try {
84
+ const db = new DatabaseSync(tempDbPath);
85
+ db.exec(
86
+ "CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER, step_payload BLOB);",
87
+ );
88
+ const stmt = db.prepare("INSERT INTO steps (idx, step_type, step_payload) VALUES (?, ?, ?)");
89
+
90
+ const raw = `${formatSystemContext("Act as an SRE.")}\n\nInspect deployment health`;
91
+ stmt.run(1, 14, makeUserPayload(raw));
92
+ db.close();
93
+
94
+ const events = readConversationHistory("test-session", tempDbPath);
95
+ expect(events).toHaveLength(1);
96
+ expect(events[0].item.type).toBe("user_message");
97
+ expect((events[0].item as { text: string }).text).toBe("Inspect deployment health");
98
+ expect((events[0].item as { text: string }).text).not.toContain("[System Context]");
99
+ expect((events[0].item as { text: string }).text).not.toContain("[Formatting Guidance]");
100
+ } finally {
101
+ try {
102
+ unlinkSync(tempDbPath);
103
+ } catch {
104
+ // ignore
105
+ }
106
+ }
107
+ });
108
+
109
+ it("readConversationHistory strips both mid-turn steering prefix and injected system context", () => {
110
+ const tempDbPath = join(
111
+ tmpdir(),
112
+ `test-history-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
113
+ );
114
+ try {
115
+ const db = new DatabaseSync(tempDbPath);
116
+ db.exec(
117
+ "CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER, step_payload BLOB);",
118
+ );
119
+ const stmt = db.prepare("INSERT INTO steps (idx, step_type, step_payload) VALUES (?, ?, ?)");
120
+
121
+ const raw = `[Mid-turn update]: ${formatSystemContext("Act as an SRE.")}\n\nUpdate: stop and check logs`;
122
+ stmt.run(1, 14, makeUserPayload(raw));
123
+ db.close();
124
+
125
+ const events = readConversationHistory("test-session", tempDbPath);
126
+ expect(events).toHaveLength(1);
127
+ expect(events[0].item.type).toBe("user_message");
128
+ expect((events[0].item as { text: string }).text).toBe("Update: stop and check logs");
129
+ expect((events[0].item as { text: string }).text).not.toContain("[Mid-turn update]");
130
+ expect((events[0].item as { text: string }).text).not.toContain("[System Context]");
131
+ } finally {
132
+ try {
133
+ unlinkSync(tempDbPath);
134
+ } catch {
135
+ // ignore
136
+ }
137
+ }
138
+ });
139
+ });
@@ -0,0 +1,157 @@
1
+ import { sanitizeAssistantText, stripSteeringPrefix, stripSystemContext } from "./sanitize.js";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import type { ProviderTimelineItem } from "@getpaseo/plugin/server/provider";
7
+
8
+ export interface ReplayedTimelineEvent {
9
+ readonly item: ProviderTimelineItem;
10
+ readonly timestamp?: string;
11
+ }
12
+
13
+ interface StepRow {
14
+ readonly idx: number;
15
+ readonly step_type: number;
16
+ readonly step_payload: Uint8Array | null;
17
+ }
18
+
19
+ function decodeVarint(buffer: Uint8Array, start: number): [number, number] {
20
+ let res = 0;
21
+ let shift = 0;
22
+ let offset = start;
23
+ while (offset < buffer.length) {
24
+ const b = buffer[offset++];
25
+ res |= (b & 0x7f) << shift;
26
+ shift += 7;
27
+ if (!(b & 0x80)) break;
28
+ }
29
+ return [res, offset];
30
+ }
31
+
32
+ function getField(buffer: Uint8Array, targetField: number): Uint8Array | null {
33
+ let offset = 0;
34
+ while (offset < buffer.length) {
35
+ let tag = 0;
36
+ let nextOffset = 0;
37
+ try {
38
+ [tag, nextOffset] = decodeVarint(buffer, offset);
39
+ } catch {
40
+ break;
41
+ }
42
+ offset = nextOffset;
43
+ const fieldNum = tag >> 3;
44
+ const wireType = tag & 0x7;
45
+ if (wireType === 2) {
46
+ const [len, afterLen] = decodeVarint(buffer, offset);
47
+ offset = afterLen;
48
+ const slice = buffer.subarray(offset, offset + len);
49
+ offset += len;
50
+ if (fieldNum === targetField) return slice;
51
+ } else if (wireType === 0) {
52
+ const [, afterVal] = decodeVarint(buffer, offset);
53
+ offset = afterVal;
54
+ } else if (wireType === 1) {
55
+ offset += 8;
56
+ } else if (wireType === 5) {
57
+ offset += 4;
58
+ } else {
59
+ break;
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function extractTimestamp(payload: Uint8Array): string | undefined {
66
+ const f5 = getField(payload, 5);
67
+ if (!f5) return undefined;
68
+ const f1 = getField(f5, 1);
69
+ if (!f1) return undefined;
70
+ let offset = 0;
71
+ let seconds = 0;
72
+ let nanos = 0;
73
+ while (offset < f1.length) {
74
+ const [tag, nextOffset] = decodeVarint(f1, offset);
75
+ offset = nextOffset;
76
+ const fieldNum = tag >> 3;
77
+ const [val, afterVal] = decodeVarint(f1, offset);
78
+ offset = afterVal;
79
+ if (fieldNum === 1) seconds = val;
80
+ else if (fieldNum === 2) nanos = val;
81
+ }
82
+ if (!seconds) return undefined;
83
+ return new Date(seconds * 1000 + Math.floor(nanos / 1_000_000)).toISOString();
84
+ }
85
+
86
+ export function extractUserMessage(payload: Uint8Array, idx: number): ProviderTimelineItem | null {
87
+ const f19 = getField(payload, 19);
88
+ if (!f19) return null;
89
+ const textBytes = getField(f19, 2);
90
+ if (!textBytes || textBytes.length === 0) return null;
91
+ // Steered turns are persisted with the mid-turn marker baked into the user message,
92
+ // so replay has to strip it back out before the text reaches the Paseo timeline.
93
+ const rawText = Buffer.from(textBytes).toString("utf-8");
94
+ const text = stripSystemContext(stripSteeringPrefix(rawText)).trim();
95
+ if (!text) return null;
96
+ return { id: `hist-user-${idx}`, type: "user_message", text };
97
+ }
98
+
99
+ function extractAssistantMessage(payload: Uint8Array, idx: number): ProviderTimelineItem | null {
100
+ const f20 = getField(payload, 20);
101
+ if (!f20) return null;
102
+ const textBytes = getField(f20, 1);
103
+ if (!textBytes || textBytes.length === 0) return null;
104
+ const rawText = Buffer.from(textBytes).toString("utf-8");
105
+ const text = sanitizeAssistantText(rawText);
106
+ if (!text) return null;
107
+ return { id: `hist-asst-${idx}`, type: "assistant_message", text };
108
+ }
109
+
110
+ function extractTimelineItem(
111
+ stepType: number,
112
+ payload: Uint8Array,
113
+ idx: number,
114
+ ): ProviderTimelineItem | null {
115
+ if (stepType === 14) return extractUserMessage(payload, idx);
116
+ if (stepType === 15) return extractAssistantMessage(payload, idx);
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * Reads and replays historic conversation events from Antigravity SQLite DB.
122
+ * Database is located at ~/.gemini/antigravity-acp/conversations/<sessionId>.db.
123
+ */
124
+ export function readConversationHistory(
125
+ sessionId: string,
126
+ customDbPath?: string,
127
+ ): ReplayedTimelineEvent[] {
128
+ const dbPath =
129
+ customDbPath ??
130
+ join(homedir(), ".gemini", "antigravity-acp", "conversations", `${sessionId}.db`);
131
+ if (!existsSync(dbPath)) return [];
132
+
133
+ let db: DatabaseSync | null = null;
134
+ try {
135
+ db = new DatabaseSync(dbPath, { readOnly: true });
136
+ const stmt = db.prepare(
137
+ "SELECT idx, step_type, step_payload FROM steps WHERE step_type IN (14, 15) ORDER BY idx ASC;",
138
+ );
139
+ const rows = stmt.all() as unknown as StepRow[];
140
+ const events: ReplayedTimelineEvent[] = [];
141
+
142
+ for (const row of rows) {
143
+ if (!row.step_payload) continue;
144
+ const payload = new Uint8Array(row.step_payload);
145
+ const item = extractTimelineItem(row.step_type, payload, row.idx);
146
+ if (!item) continue;
147
+ const timestamp = extractTimestamp(payload);
148
+ events.push({ item, timestamp });
149
+ }
150
+ return events;
151
+ } catch (err) {
152
+ console.error(`[paseo-antigravity] Failed to read conversation history for ${sessionId}:`, err);
153
+ return [];
154
+ } finally {
155
+ db?.close();
156
+ }
157
+ }
@@ -0,0 +1,368 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { McpProxy, McpProxyPool } from "./mcp-proxy.js";
7
+
8
+ interface Received {
9
+ method: string;
10
+ url: string;
11
+ protocolVersion?: string;
12
+ authorization?: string;
13
+ accept?: string;
14
+ contentType?: string;
15
+ body: string;
16
+ }
17
+
18
+ describe("McpProxy", () => {
19
+ const started: Array<McpProxy | McpProxyPool> = [];
20
+ let upstream: http.Server | null = null;
21
+ const received: Received[] = [];
22
+ const tempFiles: string[] = [];
23
+
24
+ afterEach(async () => {
25
+ for (const s of started.splice(0)) {
26
+ await (s instanceof McpProxy ? s.stop() : s.stopAll());
27
+ }
28
+ if (upstream) {
29
+ await new Promise<void>((resolve) => upstream!.close(() => resolve()));
30
+ upstream = null;
31
+ }
32
+ received.length = 0;
33
+ for (const f of tempFiles.splice(0)) fs.rmSync(f, { force: true });
34
+ });
35
+
36
+ async function startUpstream(
37
+ handler?: (req: http.IncomingMessage, res: http.ServerResponse, body: string) => boolean | void,
38
+ ): Promise<string> {
39
+ upstream = http.createServer((req, res) => {
40
+ let body = "";
41
+ req.on("data", (chunk) => (body += chunk.toString()));
42
+ req.on("end", () => {
43
+ received.push({
44
+ method: req.method ?? "",
45
+ url: req.url ?? "",
46
+ protocolVersion: req.headers["mcp-protocol-version"] as string | undefined,
47
+ authorization: req.headers.authorization as string | undefined,
48
+ accept: req.headers.accept as string | undefined,
49
+ contentType: req.headers["content-type"] as string | undefined,
50
+ body,
51
+ });
52
+ if (handler && handler(req, res, body)) return;
53
+ // Mirror Paseo: only the MCP endpoint exists, everything else is a 404.
54
+ if (!req.url?.startsWith("/mcp/agents")) {
55
+ res.writeHead(404);
56
+ res.end("not found");
57
+ return;
58
+ }
59
+ res.writeHead(200, { "Content-Type": "application/json" });
60
+ res.end(JSON.stringify({ status: "ok" }));
61
+ });
62
+ });
63
+ const port = await new Promise<number>((resolve) => {
64
+ upstream!.listen(0, "127.0.0.1", () => {
65
+ resolve((upstream!.address() as { port: number }).port);
66
+ });
67
+ });
68
+ return `http://127.0.0.1:${port}`;
69
+ }
70
+
71
+ function track<T extends McpProxy | McpProxyPool>(value: T): T {
72
+ started.push(value);
73
+ return value;
74
+ }
75
+
76
+ it("starts and assigns a port", async () => {
77
+ const proxy = track(new McpProxy("http://127.0.0.1:6767", { portFile: null }));
78
+ const port = await proxy.start();
79
+ expect(port).toBeGreaterThan(0);
80
+ expect(proxy.getPort()).toBe(port);
81
+ });
82
+
83
+ it("downgrades an unsupported protocol version and preserves path, query and body", async () => {
84
+ const origin = await startUpstream();
85
+ const proxy = track(new McpProxy(origin, { portFile: null }));
86
+ await proxy.start();
87
+
88
+ const response = await fetch(proxy.rewriteUrl(`${origin}/mcp/agents?callerAgentId=abc123`), {
89
+ method: "POST",
90
+ headers: {
91
+ "Content-Type": "application/json",
92
+ Authorization: "Bearer token",
93
+ "mcp-protocol-version": "2026-07-28",
94
+ },
95
+ body: JSON.stringify({ hello: "world" }),
96
+ });
97
+
98
+ expect(response.status).toBe(200);
99
+ expect(received).toHaveLength(1);
100
+ expect(received[0].protocolVersion).toBe("2025-11-25");
101
+ expect(received[0].url).toBe("/mcp/agents?callerAgentId=abc123");
102
+ expect(received[0].authorization).toBe("Bearer token");
103
+ expect(received[0].body).toBe(JSON.stringify({ hello: "world" }));
104
+ });
105
+
106
+ it("leaves a supported protocol version untouched", async () => {
107
+ const origin = await startUpstream();
108
+ const proxy = track(new McpProxy(origin, { portFile: null }));
109
+ await proxy.start();
110
+
111
+ await fetch(proxy.rewriteUrl(`${origin}/mcp/agents`), {
112
+ headers: { "mcp-protocol-version": "2024-11-05" },
113
+ });
114
+
115
+ expect(received[0].protocolVersion).toBe("2024-11-05");
116
+ });
117
+
118
+ it("forwards unrelated paths to the upstream path rather than the MCP endpoint", async () => {
119
+ // agy probes OAuth discovery before using an MCP server. Collapsing that onto the
120
+ // MCP endpoint would answer 200 where the client must see a 404 to conclude that
121
+ // the resource is not OAuth-protected.
122
+ const origin = await startUpstream();
123
+ const proxy = track(new McpProxy(origin, { portFile: null }));
124
+ const port = await proxy.start();
125
+
126
+ const response = await fetch(
127
+ `http://127.0.0.1:${port}/.well-known/oauth-protected-resource/mcp/agents`,
128
+ );
129
+
130
+ expect(response.status).toBe(404);
131
+ expect(received[0].url).toBe("/.well-known/oauth-protected-resource/mcp/agents");
132
+ });
133
+
134
+ it("absorbs upstream 4xx on notification (no id) and responds with 202, logging method and status", async () => {
135
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
136
+ try {
137
+ const origin = await startUpstream((_req, res) => {
138
+ res.writeHead(400, { "Content-Type": "application/json" });
139
+ res.end(
140
+ JSON.stringify({
141
+ jsonrpc: "2.0",
142
+ error: { code: -32600, message: "Bad Request" },
143
+ id: null,
144
+ }),
145
+ );
146
+ return true;
147
+ });
148
+ const proxy = track(new McpProxy(origin, { portFile: null }));
149
+ await proxy.start();
150
+
151
+ const notificationPayload = {
152
+ jsonrpc: "2.0",
153
+ method: "notifications/cancelled",
154
+ };
155
+
156
+ const response = await fetch(proxy.rewriteUrl(`${origin}/mcp/agents`), {
157
+ method: "POST",
158
+ headers: { "Content-Type": "application/json" },
159
+ body: JSON.stringify(notificationPayload),
160
+ });
161
+
162
+ expect(response.status).toBe(202);
163
+ expect(await response.text()).toBe("");
164
+ expect(received).toHaveLength(1);
165
+ expect(JSON.parse(received[0].body)).toEqual(notificationPayload);
166
+ expect(spy).toHaveBeenCalledWith(
167
+ '[paseo-antigravity][mcp-proxy] Absorbed upstream 400 on notification "notifications/cancelled"; replied 202 so go-sdk does not tear the session down',
168
+ );
169
+ } finally {
170
+ spy.mockRestore();
171
+ }
172
+ });
173
+
174
+ it("absorbs upstream 4xx on non-cancelled notification and logs investigation warning", async () => {
175
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
176
+ try {
177
+ const origin = await startUpstream((_req, res) => {
178
+ res.writeHead(400, { "Content-Type": "application/json" });
179
+ res.end(
180
+ JSON.stringify({
181
+ jsonrpc: "2.0",
182
+ error: { code: -32600, message: "Bad Request" },
183
+ id: null,
184
+ }),
185
+ );
186
+ return true;
187
+ });
188
+ const proxy = track(new McpProxy(origin, { portFile: null }));
189
+ await proxy.start();
190
+
191
+ const notificationPayload = {
192
+ jsonrpc: "2.0",
193
+ method: "notifications/initialized",
194
+ };
195
+
196
+ const response = await fetch(proxy.rewriteUrl(`${origin}/mcp/agents`), {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify(notificationPayload),
200
+ });
201
+
202
+ expect(response.status).toBe(202);
203
+ expect(await response.text()).toBe("");
204
+ expect(received).toHaveLength(1);
205
+ expect(JSON.parse(received[0].body)).toEqual(notificationPayload);
206
+ expect(spy).toHaveBeenCalledWith(
207
+ '[paseo-antigravity][mcp-proxy] Absorbed upstream 400 on notification "notifications/initialized"; replied 202 so go-sdk does not tear the session down (indicates proxy normalization bug; investigate)',
208
+ );
209
+ } finally {
210
+ spy.mockRestore();
211
+ }
212
+ });
213
+
214
+ it("passes through upstream 4xx unchanged on call (with id)", async () => {
215
+ const upstreamErrorBody = {
216
+ jsonrpc: "2.0",
217
+ id: 42,
218
+ error: { code: -32602, message: "Invalid params" },
219
+ };
220
+ const origin = await startUpstream((_req, res) => {
221
+ res.writeHead(400, { "Content-Type": "application/json" });
222
+ res.end(JSON.stringify(upstreamErrorBody));
223
+ return true;
224
+ });
225
+ const proxy = track(new McpProxy(origin, { portFile: null }));
226
+ await proxy.start();
227
+
228
+ const callPayload = {
229
+ jsonrpc: "2.0",
230
+ id: 42,
231
+ method: "tools/call",
232
+ params: { name: "test_tool" },
233
+ };
234
+
235
+ const response = await fetch(proxy.rewriteUrl(`${origin}/mcp/agents`), {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify(callPayload),
239
+ });
240
+
241
+ expect(response.status).toBe(400);
242
+ expect(await response.json()).toEqual(upstreamErrorBody);
243
+ expect(received).toHaveLength(1);
244
+ expect(JSON.parse(received[0].body)).toEqual(callPayload);
245
+ });
246
+
247
+ it("injects Accept header when missing or incomplete and sets Content-Type for request body", async () => {
248
+ const origin = await startUpstream();
249
+ const proxy = track(new McpProxy(origin, { portFile: null }));
250
+ await proxy.start();
251
+
252
+ // 1. Missing Accept header and missing Content-Type with body
253
+ await fetch(proxy.rewriteUrl(`${origin}/mcp/agents`), {
254
+ method: "POST",
255
+ body: Buffer.from(JSON.stringify({ ping: 1 })),
256
+ });
257
+ expect(received[0].accept).toBe("application/json, text/event-stream");
258
+ expect(received[0].contentType).toBe("application/json");
259
+
260
+ // 2. Incomplete Accept header (only application/json)
261
+ await fetch(proxy.rewriteUrl(`${origin}/mcp/agents`), {
262
+ method: "POST",
263
+ headers: {
264
+ Accept: "application/json",
265
+ "Content-Type": "application/json",
266
+ },
267
+ body: JSON.stringify({ ping: 2 }),
268
+ });
269
+ expect(received[1].accept).toBe("application/json, text/event-stream");
270
+
271
+ // 3. Incomplete Accept header (only text/event-stream)
272
+ await fetch(proxy.rewriteUrl(`${origin}/mcp/agents`), {
273
+ method: "POST",
274
+ headers: {
275
+ Accept: "text/event-stream",
276
+ "Content-Type": "application/json",
277
+ },
278
+ body: JSON.stringify({ ping: 3 }),
279
+ });
280
+ expect(received[2].accept).toBe("application/json, text/event-stream");
281
+
282
+ // 4. Complete Accept header containing both is preserved
283
+ await fetch(proxy.rewriteUrl(`${origin}/mcp/agents`), {
284
+ method: "POST",
285
+ headers: {
286
+ Accept: "text/event-stream, application/json",
287
+ "Content-Type": "application/json",
288
+ },
289
+ body: JSON.stringify({ ping: 4 }),
290
+ });
291
+ expect(received[3].accept).toBe("text/event-stream, application/json");
292
+ });
293
+
294
+ it("formats proxy connection errors as JSON-RPC error body with application/json", async () => {
295
+ const deadServer = http.createServer();
296
+ await new Promise<void>((resolve) => deadServer.listen(0, "127.0.0.1", () => resolve()));
297
+ const deadPort = (deadServer.address() as { port: number }).port;
298
+ await new Promise<void>((resolve) => deadServer.close(() => resolve()));
299
+
300
+ const deadOrigin = `http://127.0.0.1:${deadPort}`;
301
+ const proxy = track(new McpProxy(deadOrigin, { portFile: null }));
302
+ await proxy.start();
303
+
304
+ const response = await fetch(proxy.rewriteUrl(`${deadOrigin}/mcp/agents`));
305
+
306
+ expect(response.status).toBe(502);
307
+ expect(response.headers.get("content-type")).toContain("application/json");
308
+ const json = await response.json();
309
+ expect(json).toEqual({
310
+ jsonrpc: "2.0",
311
+ error: {
312
+ code: -32603,
313
+ message: expect.stringContaining("MCP proxy error:"),
314
+ },
315
+ id: null,
316
+ });
317
+ });
318
+
319
+ it("throws instead of returning an un-normalized URL when not running", async () => {
320
+ const proxy = track(new McpProxy("http://127.0.0.1:6767", { portFile: null }));
321
+ const original = "http://127.0.0.1:6767/mcp/agents?callerAgentId=a1";
322
+
323
+ expect(() => proxy.rewriteUrl(original)).toThrow(/not running/);
324
+
325
+ await proxy.start();
326
+ expect(proxy.rewriteUrl(original)).not.toBe(original);
327
+
328
+ await proxy.stop();
329
+ expect(() => proxy.rewriteUrl(original)).toThrow(/not running/);
330
+ });
331
+
332
+ it("reuses the port remembered from a previous run", async () => {
333
+ const portFile = path.join(
334
+ fs.mkdtempSync(path.join(os.tmpdir(), "paseo-agy-proxy-")),
335
+ "ports.json",
336
+ );
337
+ tempFiles.push(portFile);
338
+
339
+ const first = track(new McpProxy("http://127.0.0.1:6767", { portFile }));
340
+ const port = await first.start();
341
+ await first.stop();
342
+
343
+ const second = track(new McpProxy("http://127.0.0.1:6767", { portFile }));
344
+ expect(await second.start()).toBe(port);
345
+ });
346
+ });
347
+
348
+ describe("McpProxyPool", () => {
349
+ const pools: McpProxyPool[] = [];
350
+
351
+ afterEach(async () => {
352
+ for (const pool of pools.splice(0)) await pool.stopAll();
353
+ });
354
+
355
+ it("shares one proxy per upstream origin", async () => {
356
+ const pool = new McpProxyPool({ portFile: null });
357
+ pools.push(pool);
358
+
359
+ const a = await pool.rewriteUrl("http://127.0.0.1:6767/mcp/agents?callerAgentId=a");
360
+ const b = await pool.rewriteUrl("http://127.0.0.1:6767/mcp/agents?callerAgentId=b");
361
+ const other = await pool.rewriteUrl("http://127.0.0.1:6868/mcp/agents");
362
+
363
+ expect(new URL(a).port).toBe(new URL(b).port);
364
+ expect(new URL(a).port).not.toBe(new URL(other).port);
365
+ expect(new URL(a).pathname + new URL(a).search).toBe("/mcp/agents?callerAgentId=a");
366
+ expect(new URL(b).pathname + new URL(b).search).toBe("/mcp/agents?callerAgentId=b");
367
+ });
368
+ });