@poncho-ai/harness 0.40.0 → 0.41.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,311 @@
1
+ import { createServer } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { ToolContext } from "@poncho-ai/sdk";
5
+ import { LocalMcpBridge } from "../src/mcp.js";
6
+
7
+ interface ServerHandle {
8
+ port: number;
9
+ initializeCount: number;
10
+ shutdown: () => Promise<void>;
11
+ observedAuthHeaders: string[];
12
+ }
13
+
14
+ async function startMockMcpServer(): Promise<ServerHandle> {
15
+ const observedAuthHeaders: string[] = [];
16
+ let initializeCount = 0;
17
+ let sessionCounter = 0;
18
+ const server = createServer(async (req, res) => {
19
+ if (req.method === "DELETE") {
20
+ res.statusCode = 200;
21
+ res.end();
22
+ return;
23
+ }
24
+ const auth = req.headers.authorization;
25
+ if (auth) observedAuthHeaders.push(auth);
26
+ const chunks: Buffer[] = [];
27
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
28
+ const body = Buffer.concat(chunks).toString("utf8");
29
+ const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
30
+ if (payload.method === "initialize") {
31
+ initializeCount += 1;
32
+ sessionCounter += 1;
33
+ res.setHeader("Content-Type", "application/json");
34
+ res.setHeader("Mcp-Session-Id", `s_${sessionCounter}`);
35
+ res.end(
36
+ JSON.stringify({
37
+ jsonrpc: "2.0",
38
+ id: payload.id,
39
+ result: {
40
+ protocolVersion: "2025-03-26",
41
+ capabilities: { tools: { listChanged: true } },
42
+ serverInfo: { name: "remote", version: "1.0.0" },
43
+ },
44
+ }),
45
+ );
46
+ return;
47
+ }
48
+ if (payload.method === "notifications/initialized") {
49
+ res.statusCode = 202;
50
+ res.end();
51
+ return;
52
+ }
53
+ if (payload.method === "tools/list") {
54
+ res.setHeader("Content-Type", "application/json");
55
+ res.end(
56
+ JSON.stringify({
57
+ jsonrpc: "2.0",
58
+ id: payload.id,
59
+ result: {
60
+ tools: [
61
+ {
62
+ name: "ping",
63
+ inputSchema: {
64
+ type: "object",
65
+ properties: { value: { type: "string" } },
66
+ },
67
+ },
68
+ ],
69
+ },
70
+ }),
71
+ );
72
+ return;
73
+ }
74
+ if (payload.method === "tools/call") {
75
+ res.setHeader("Content-Type", "application/json");
76
+ res.end(
77
+ JSON.stringify({
78
+ jsonrpc: "2.0",
79
+ id: payload.id,
80
+ result: { result: { echoed: payload.params?.arguments?.value ?? "" } },
81
+ }),
82
+ );
83
+ return;
84
+ }
85
+ res.statusCode = 404;
86
+ res.end();
87
+ });
88
+ await new Promise<void>((r) => server.listen(0, () => r()));
89
+ const address = server.address() as AddressInfo;
90
+ return {
91
+ port: address.port,
92
+ get initializeCount() {
93
+ return initializeCount;
94
+ },
95
+ observedAuthHeaders,
96
+ shutdown: () => new Promise<void>((r) => server.close(() => r())),
97
+ };
98
+ }
99
+
100
+ const stubContext = (tenantId: string): ToolContext => ({
101
+ agentId: "agent",
102
+ runId: "run",
103
+ step: 1,
104
+ workingDir: process.cwd(),
105
+ parameters: {},
106
+ tenantId,
107
+ });
108
+
109
+ describe("LocalMcpBridge per-tenant client cache (PR 3)", () => {
110
+ it("reuses the same StreamableHttpMcpRpcClient across calls from the same tenant", async () => {
111
+ process.env.LINEAR_TOKEN = "default-token";
112
+ const server = await startMockMcpServer();
113
+ try {
114
+ const bridge = new LocalMcpBridge({
115
+ mcp: [
116
+ {
117
+ name: "remote",
118
+ url: `http://127.0.0.1:${server.port}/mcp`,
119
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
120
+ },
121
+ ],
122
+ });
123
+ bridge.setEnvResolver(async (tenantId, envName) => {
124
+ if (envName !== "LINEAR_TOKEN") return undefined;
125
+ return tenantId === "tenant-A" ? "token-A" : undefined;
126
+ });
127
+ await bridge.startLocalServers();
128
+ await bridge.discoverTools();
129
+ const tools = await bridge.loadTools(["remote/ping"]);
130
+ const tool = tools.find((t) => t.name === "remote/ping")!;
131
+
132
+ const constructionsBefore = bridge.tenantClientConstructions;
133
+ await tool.handler({ value: "1" }, stubContext("tenant-A"));
134
+ await tool.handler({ value: "2" }, stubContext("tenant-A"));
135
+ await tool.handler({ value: "3" }, stubContext("tenant-A"));
136
+
137
+ // One construction for tenant-A; subsequent calls reuse it.
138
+ expect(bridge.tenantClientConstructions - constructionsBefore).toBe(1);
139
+ // The mock server saw a single initialize for the per-tenant client
140
+ // (in addition to the initial discovery initialize).
141
+ const tenantInits = server.observedAuthHeaders.filter(
142
+ (h) => h === "Bearer token-A",
143
+ ).length;
144
+ // initialize + notifications/initialized + 3x tools/call = 5 requests
145
+ // with token-A; only one initialize.
146
+ expect(tenantInits).toBeGreaterThanOrEqual(4);
147
+
148
+ await bridge.stopLocalServers();
149
+ } finally {
150
+ await server.shutdown();
151
+ }
152
+ });
153
+
154
+ it("uses different cached clients for different tenants", async () => {
155
+ process.env.LINEAR_TOKEN = "default-token";
156
+ const server = await startMockMcpServer();
157
+ try {
158
+ const bridge = new LocalMcpBridge({
159
+ mcp: [
160
+ {
161
+ name: "remote",
162
+ url: `http://127.0.0.1:${server.port}/mcp`,
163
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
164
+ },
165
+ ],
166
+ });
167
+ bridge.setEnvResolver(async (tenantId, envName) => {
168
+ if (envName !== "LINEAR_TOKEN") return undefined;
169
+ if (tenantId === "tenant-A") return "token-A";
170
+ if (tenantId === "tenant-B") return "token-B";
171
+ return undefined;
172
+ });
173
+ await bridge.startLocalServers();
174
+ await bridge.discoverTools();
175
+ const tools = await bridge.loadTools(["remote/ping"]);
176
+ const tool = tools.find((t) => t.name === "remote/ping")!;
177
+
178
+ const constructionsBefore = bridge.tenantClientConstructions;
179
+ await tool.handler({ value: "a1" }, stubContext("tenant-A"));
180
+ await tool.handler({ value: "b1" }, stubContext("tenant-B"));
181
+ await tool.handler({ value: "a2" }, stubContext("tenant-A"));
182
+ await tool.handler({ value: "b2" }, stubContext("tenant-B"));
183
+
184
+ // One construction per tenant, then reuse.
185
+ expect(bridge.tenantClientConstructions - constructionsBefore).toBe(2);
186
+
187
+ await bridge.stopLocalServers();
188
+ } finally {
189
+ await server.shutdown();
190
+ }
191
+ });
192
+
193
+ it("rebuilds the cached client when the tenant's token changes", async () => {
194
+ process.env.LINEAR_TOKEN = "default-token";
195
+ const server = await startMockMcpServer();
196
+ try {
197
+ const bridge = new LocalMcpBridge({
198
+ mcp: [
199
+ {
200
+ name: "remote",
201
+ url: `http://127.0.0.1:${server.port}/mcp`,
202
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
203
+ },
204
+ ],
205
+ });
206
+ let currentToken = "token-v1";
207
+ bridge.setEnvResolver(async (tenantId, envName) => {
208
+ if (envName !== "LINEAR_TOKEN" || tenantId !== "tenant-A") return undefined;
209
+ return currentToken;
210
+ });
211
+ await bridge.startLocalServers();
212
+ await bridge.discoverTools();
213
+ const tools = await bridge.loadTools(["remote/ping"]);
214
+ const tool = tools.find((t) => t.name === "remote/ping")!;
215
+
216
+ const constructionsBefore = bridge.tenantClientConstructions;
217
+ await tool.handler({ value: "1" }, stubContext("tenant-A"));
218
+ // Rotate the user's token — next call should rebuild the client.
219
+ currentToken = "token-v2";
220
+ await tool.handler({ value: "2" }, stubContext("tenant-A"));
221
+ await tool.handler({ value: "3" }, stubContext("tenant-A"));
222
+
223
+ // 1 build for v1, 1 build for v2; the v2 build is reused for the 3rd call.
224
+ expect(bridge.tenantClientConstructions - constructionsBefore).toBe(2);
225
+
226
+ await bridge.stopLocalServers();
227
+ } finally {
228
+ await server.shutdown();
229
+ }
230
+ });
231
+
232
+ it("evicts cached clients after the configured idle TTL", async () => {
233
+ process.env.LINEAR_TOKEN = "default-token";
234
+ const server = await startMockMcpServer();
235
+ try {
236
+ const bridge = new LocalMcpBridge(
237
+ {
238
+ mcp: [
239
+ {
240
+ name: "remote",
241
+ url: `http://127.0.0.1:${server.port}/mcp`,
242
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
243
+ },
244
+ ],
245
+ },
246
+ { tenantClientTtlMs: 10 }, // very short TTL for the test
247
+ );
248
+ bridge.setEnvResolver(async (tenantId, envName) => {
249
+ if (envName !== "LINEAR_TOKEN") return undefined;
250
+ return tenantId === "tenant-A" ? "token-A" : undefined;
251
+ });
252
+ await bridge.startLocalServers();
253
+ await bridge.discoverTools();
254
+ const tools = await bridge.loadTools(["remote/ping"]);
255
+ const tool = tools.find((t) => t.name === "remote/ping")!;
256
+
257
+ const constructionsBefore = bridge.tenantClientConstructions;
258
+ await tool.handler({ value: "1" }, stubContext("tenant-A"));
259
+ // Sleep beyond TTL, then call again — should evict + rebuild.
260
+ await new Promise((r) => setTimeout(r, 30));
261
+ await tool.handler({ value: "2" }, stubContext("tenant-A"));
262
+ expect(bridge.tenantClientConstructions - constructionsBefore).toBe(2);
263
+
264
+ await bridge.stopLocalServers();
265
+ } finally {
266
+ await server.shutdown();
267
+ }
268
+ });
269
+
270
+ it("closes cached tenant clients on stopLocalServers()", async () => {
271
+ process.env.LINEAR_TOKEN = "default-token";
272
+ const server = await startMockMcpServer();
273
+ try {
274
+ const bridge = new LocalMcpBridge({
275
+ mcp: [
276
+ {
277
+ name: "remote",
278
+ url: `http://127.0.0.1:${server.port}/mcp`,
279
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
280
+ },
281
+ ],
282
+ });
283
+ bridge.setEnvResolver(async (tenantId, envName) => {
284
+ if (envName !== "LINEAR_TOKEN") return undefined;
285
+ return tenantId === "tenant-A" ? "token-A" : undefined;
286
+ });
287
+ await bridge.startLocalServers();
288
+ await bridge.discoverTools();
289
+ const tools = await bridge.loadTools(["remote/ping"]);
290
+ const tool = tools.find((t) => t.name === "remote/ping")!;
291
+ await tool.handler({ value: "1" }, stubContext("tenant-A"));
292
+
293
+ // Before stop: cache has one entry.
294
+ // After stop: should be empty (and a subsequent call would rebuild).
295
+ await bridge.stopLocalServers();
296
+
297
+ // Re-run discovery to bring servers back up — verify cache rebuilds.
298
+ await bridge.startLocalServers();
299
+ await bridge.discoverTools();
300
+ const tools2 = await bridge.loadTools(["remote/ping"]);
301
+ const tool2 = tools2.find((t) => t.name === "remote/ping")!;
302
+ const constructionsBefore = bridge.tenantClientConstructions;
303
+ await tool2.handler({ value: "post-stop" }, stubContext("tenant-A"));
304
+ expect(bridge.tenantClientConstructions - constructionsBefore).toBe(1);
305
+
306
+ await bridge.stopLocalServers();
307
+ } finally {
308
+ await server.shutdown();
309
+ }
310
+ });
311
+ });
package/test/mcp.test.ts CHANGED
@@ -442,4 +442,178 @@ describe("mcp bridge protocol transports", () => {
442
442
  await bridge.stopLocalServers();
443
443
  await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
444
444
  });
445
+
446
+ it("re-initializes and retries when the server expires the session (404)", async () => {
447
+ process.env.LINEAR_TOKEN = "token-123";
448
+ let sessionCounter = 0;
449
+ let activeSession = "";
450
+ let initializeCount = 0;
451
+ let toolCallAttempts = 0;
452
+ const server = createServer(async (req, res) => {
453
+ if (req.method === "DELETE") {
454
+ res.statusCode = 200;
455
+ res.end();
456
+ return;
457
+ }
458
+ const chunks: Buffer[] = [];
459
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
460
+ const body = Buffer.concat(chunks).toString("utf8");
461
+ const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
462
+ if (payload.method === "initialize") {
463
+ initializeCount += 1;
464
+ sessionCounter += 1;
465
+ activeSession = `session_${sessionCounter}`;
466
+ res.setHeader("Content-Type", "application/json");
467
+ res.setHeader("Mcp-Session-Id", activeSession);
468
+ res.end(
469
+ JSON.stringify({
470
+ jsonrpc: "2.0",
471
+ id: payload.id,
472
+ result: {
473
+ protocolVersion: "2025-03-26",
474
+ capabilities: { tools: { listChanged: true } },
475
+ serverInfo: { name: "remote", version: "1.0.0" },
476
+ },
477
+ }),
478
+ );
479
+ return;
480
+ }
481
+ if (payload.method === "notifications/initialized") {
482
+ res.statusCode = 202;
483
+ res.end();
484
+ return;
485
+ }
486
+ if (req.headers["mcp-session-id"] !== activeSession) {
487
+ res.statusCode = 404;
488
+ res.end();
489
+ return;
490
+ }
491
+ if (payload.method === "tools/list") {
492
+ res.setHeader("Content-Type", "application/json");
493
+ res.end(
494
+ JSON.stringify({
495
+ jsonrpc: "2.0",
496
+ id: payload.id,
497
+ result: {
498
+ tools: [
499
+ {
500
+ name: "ping",
501
+ inputSchema: {
502
+ type: "object",
503
+ properties: { value: { type: "string" } },
504
+ },
505
+ },
506
+ ],
507
+ },
508
+ }),
509
+ );
510
+ return;
511
+ }
512
+ if (payload.method === "tools/call") {
513
+ toolCallAttempts += 1;
514
+ // Simulate session expiry on the first tool call after discovery:
515
+ // invalidate the current session so the existing Mcp-Session-Id is stale,
516
+ // then return 404 once. The client should re-initialize and retry.
517
+ if (toolCallAttempts === 1) {
518
+ activeSession = "";
519
+ res.statusCode = 404;
520
+ res.end();
521
+ return;
522
+ }
523
+ res.setHeader("Content-Type", "application/json");
524
+ res.end(
525
+ JSON.stringify({
526
+ jsonrpc: "2.0",
527
+ id: payload.id,
528
+ result: { result: { echoed: payload.params?.arguments?.value ?? "" } },
529
+ }),
530
+ );
531
+ return;
532
+ }
533
+ res.statusCode = 404;
534
+ res.end();
535
+ });
536
+ await new Promise<void>((r) => server.listen(0, () => r()));
537
+ const address = server.address();
538
+ if (!address || typeof address === "string") throw new Error("Unexpected address");
539
+
540
+ const bridge = new LocalMcpBridge({
541
+ mcp: [
542
+ {
543
+ name: "remote",
544
+ url: `http://127.0.0.1:${address.port}/mcp`,
545
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
546
+ },
547
+ ],
548
+ });
549
+
550
+ await bridge.startLocalServers();
551
+ await bridge.discoverTools();
552
+ const tools = await bridge.loadTools(["remote/ping"]);
553
+ const tool = tools.find((entry) => entry.name === "remote/ping");
554
+ expect(tool).toBeDefined();
555
+
556
+ const output = await tool?.handler(
557
+ { value: "after-expiry" },
558
+ {
559
+ agentId: "agent",
560
+ runId: "run",
561
+ step: 1,
562
+ workingDir: process.cwd(),
563
+ parameters: {},
564
+ },
565
+ );
566
+
567
+ expect(output).toEqual({ echoed: "after-expiry" });
568
+ expect(toolCallAttempts).toBe(2);
569
+ expect(initializeCount).toBe(2);
570
+
571
+ await bridge.stopLocalServers();
572
+ await new Promise<void>((r) => server.close(() => r()));
573
+ });
574
+
575
+ it("does not retry on 404 when no session has been established", async () => {
576
+ process.env.LINEAR_TOKEN = "token-123";
577
+ let initializeCount = 0;
578
+ const server = createServer(async (req, res) => {
579
+ if (req.method === "DELETE") {
580
+ res.statusCode = 200;
581
+ res.end();
582
+ return;
583
+ }
584
+ const chunks: Buffer[] = [];
585
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
586
+ const body = Buffer.concat(chunks).toString("utf8");
587
+ const payload = body.trim().length > 0 ? (JSON.parse(body) as any) : {};
588
+ if (payload.method === "initialize") {
589
+ initializeCount += 1;
590
+ // No Mcp-Session-Id: server-side 404 here is a true endpoint failure, not session expiry.
591
+ res.statusCode = 404;
592
+ res.end();
593
+ return;
594
+ }
595
+ res.statusCode = 404;
596
+ res.end();
597
+ });
598
+ await new Promise<void>((r) => server.listen(0, () => r()));
599
+ const address = server.address();
600
+ if (!address || typeof address === "string") throw new Error("Unexpected address");
601
+
602
+ const bridge = new LocalMcpBridge({
603
+ mcp: [
604
+ {
605
+ name: "remote",
606
+ url: `http://127.0.0.1:${address.port}/mcp`,
607
+ auth: { type: "bearer", tokenEnv: "LINEAR_TOKEN" },
608
+ },
609
+ ],
610
+ });
611
+
612
+ await bridge.startLocalServers();
613
+ await bridge.discoverTools();
614
+ expect(initializeCount).toBe(1);
615
+
616
+ await bridge.stopLocalServers();
617
+ await new Promise<void>((r) => server.close(() => r()));
618
+ });
445
619
  });