@poncho-ai/harness 0.40.1 → 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.
package/src/mcp.ts CHANGED
@@ -46,6 +46,12 @@ class McpHttpError extends Error {
46
46
  }
47
47
  }
48
48
 
49
+ class McpSessionExpiredError extends Error {
50
+ constructor() {
51
+ super("MCP session expired");
52
+ }
53
+ }
54
+
49
55
  class StreamableHttpMcpRpcClient implements McpRpcClient {
50
56
  private readonly endpoint: string;
51
57
  private readonly timeoutMs: number;
@@ -106,6 +112,9 @@ class StreamableHttpMcpRpcClient implements McpRpcClient {
106
112
  if (response.status === 403) {
107
113
  throw new McpHttpError(403, "MCP server forbidden");
108
114
  }
115
+ if (response.status === 404 && this.sessionId) {
116
+ throw new McpSessionExpiredError();
117
+ }
109
118
  if (!response.ok) {
110
119
  throw new Error(`MCP HTTP request failed with status ${response.status}`);
111
120
  }
@@ -192,20 +201,32 @@ class StreamableHttpMcpRpcClient implements McpRpcClient {
192
201
  }
193
202
 
194
203
  private async request(method: string, params?: Record<string, unknown>): Promise<unknown> {
195
- await this.ensureInitialized();
196
- const id = this.idCounter++;
197
- const payload = {
198
- jsonrpc: "2.0",
199
- id,
200
- method,
201
- params: params ?? {},
202
- };
203
- const payloads = await this.postMessage(payload);
204
- const result = this.extractResult(payloads, id);
205
- if (result.error) {
206
- throw new Error(result.error.message ?? `MCP error on ${method}`);
204
+ for (let attempt = 0; attempt < 2; attempt++) {
205
+ try {
206
+ await this.ensureInitialized();
207
+ const id = this.idCounter++;
208
+ const payload = {
209
+ jsonrpc: "2.0",
210
+ id,
211
+ method,
212
+ params: params ?? {},
213
+ };
214
+ const payloads = await this.postMessage(payload);
215
+ const result = this.extractResult(payloads, id);
216
+ if (result.error) {
217
+ throw new Error(result.error.message ?? `MCP error on ${method}`);
218
+ }
219
+ return result.result;
220
+ } catch (error) {
221
+ if (error instanceof McpSessionExpiredError && attempt === 0) {
222
+ this.sessionId = undefined;
223
+ this.initialized = false;
224
+ continue;
225
+ }
226
+ throw error;
227
+ }
207
228
  }
208
- return result.result;
229
+ throw new Error(`MCP request to ${method} failed after session retry`);
209
230
  }
210
231
 
211
232
  private async ensureInitialized(): Promise<void> {
@@ -270,6 +291,16 @@ class StreamableHttpMcpRpcClient implements McpRpcClient {
270
291
  }
271
292
  }
272
293
 
294
+ interface CachedTenantClient {
295
+ client: StreamableHttpMcpRpcClient;
296
+ token: string;
297
+ lastUsed: number;
298
+ }
299
+
300
+ const TENANT_CLIENT_TTL_MS = 15 * 60 * 1000;
301
+ const tenantClientKey = (serverName: string, tenantId: string): string =>
302
+ `${serverName}\0${tenantId}`;
303
+
273
304
  export class LocalMcpBridge {
274
305
  private readonly remoteServers: RemoteMcpServerConfig[];
275
306
  private readonly rpcClients = new Map<string, McpRpcClient>();
@@ -277,6 +308,18 @@ export class LocalMcpBridge {
277
308
  private readonly unavailableServers = new Map<string, string>();
278
309
  private readonly authFailedServers = new Set<string>();
279
310
  private envResolver?: (tenantId: string | undefined, envName: string) => Promise<string | undefined>;
311
+ /**
312
+ * Per-tenant MCP client cache. For consumer/SaaS deployments where every
313
+ * call resolves a different bearer token, building a fresh
314
+ * `StreamableHttpMcpRpcClient` per call would force a fresh `initialize`
315
+ * round-trip every time. We keep one client per `(serverName, tenantId)`
316
+ * with TTL-based idle eviction; on token rotation we evict the entry
317
+ * lazily and rebuild.
318
+ */
319
+ private readonly tenantClients = new Map<string, CachedTenantClient>();
320
+ /** Test/observability hook: bumped every time a new tenant client is constructed. */
321
+ tenantClientConstructions = 0;
322
+ private readonly tenantClientTtlMs: number;
280
323
 
281
324
  /**
282
325
  * Set a resolver for per-tenant env vars (e.g. MCP auth tokens).
@@ -286,7 +329,8 @@ export class LocalMcpBridge {
286
329
  this.envResolver = resolver;
287
330
  }
288
331
 
289
- constructor(config: McpConfig | undefined) {
332
+ constructor(config: McpConfig | undefined, options?: { tenantClientTtlMs?: number }) {
333
+ this.tenantClientTtlMs = options?.tenantClientTtlMs ?? TENANT_CLIENT_TTL_MS;
290
334
  this.remoteServers = (config?.mcp ?? []).filter((entry): entry is RemoteMcpServerConfig =>
291
335
  typeof entry.url === "string",
292
336
  );
@@ -477,6 +521,43 @@ export class LocalMcpBridge {
477
521
  await client.close();
478
522
  }
479
523
  this.rpcClients.clear();
524
+ for (const [, entry] of this.tenantClients) {
525
+ await entry.client.close();
526
+ }
527
+ this.tenantClients.clear();
528
+ }
529
+
530
+ private getOrCreateTenantClient(
531
+ serverName: string,
532
+ tenantId: string,
533
+ token: string,
534
+ server: RemoteMcpServerConfig,
535
+ ): StreamableHttpMcpRpcClient {
536
+ const key = tenantClientKey(serverName, tenantId);
537
+ const now = Date.now();
538
+ // Lazily evict idle entries on access — no background timer needed.
539
+ const existing = this.tenantClients.get(key);
540
+ if (existing) {
541
+ const idle = now - existing.lastUsed > this.tenantClientTtlMs;
542
+ const tokenChanged = existing.token !== token;
543
+ if (idle || tokenChanged) {
544
+ // Best-effort close; the new client supersedes it.
545
+ void existing.client.close();
546
+ this.tenantClients.delete(key);
547
+ } else {
548
+ existing.lastUsed = now;
549
+ return existing.client;
550
+ }
551
+ }
552
+ const client = new StreamableHttpMcpRpcClient(
553
+ server.url,
554
+ server.timeoutMs ?? 10_000,
555
+ token,
556
+ server.headers,
557
+ );
558
+ this.tenantClients.set(key, { client, token, lastUsed: now });
559
+ this.tenantClientConstructions += 1;
560
+ return client;
480
561
  }
481
562
 
482
563
  listServers(): RemoteMcpServerConfig[] {
@@ -617,20 +698,18 @@ export class LocalMcpBridge {
617
698
  handler: async (input, context) => {
618
699
  try {
619
700
  // Per-tenant token resolution: if we have a resolver and the server uses tokenEnv,
620
- // create a per-request client with the tenant-specific token
701
+ // resolve the tenant token and reuse a cached per-tenant client when present.
621
702
  const tokenEnv = server?.auth?.tokenEnv;
622
- let callClient = client;
623
- if (tokenEnv && this.envResolver && context?.tenantId) {
703
+ let callClient: McpRpcClient = client;
704
+ if (tokenEnv && this.envResolver && context?.tenantId && server) {
624
705
  const tenantToken = await this.envResolver(context.tenantId, tokenEnv);
625
706
  const defaultToken = process.env[tokenEnv];
626
- // Only create a per-request client when the tenant has a different token.
627
- // Using the original client preserves the established MCP session.
628
707
  if (tenantToken && tenantToken !== defaultToken) {
629
- callClient = new StreamableHttpMcpRpcClient(
630
- server.url,
631
- server.timeoutMs ?? 10_000,
708
+ callClient = this.getOrCreateTenantClient(
709
+ serverName,
710
+ context.tenantId,
632
711
  tenantToken,
633
- server.headers,
712
+ server,
634
713
  );
635
714
  }
636
715
  }
@@ -33,6 +33,8 @@ export interface VfsDirEntry {
33
33
  // ---------------------------------------------------------------------------
34
34
 
35
35
  export interface StorageEngine {
36
+ /** Partition key: every read/write is scoped to this agent id. */
37
+ readonly agentId: string;
36
38
  /** Run migrations and prepare the storage backend. */
37
39
  initialize(): Promise<void>;
38
40
  /** Gracefully release resources. */
@@ -55,7 +55,7 @@ const vfsKey = (tenantId: string, path: string) => `${tenantId}\0${path}`;
55
55
  // ---------------------------------------------------------------------------
56
56
 
57
57
  export class InMemoryEngine implements StorageEngine {
58
- private readonly agentId: string;
58
+ readonly agentId: string;
59
59
 
60
60
  // Conversation data
61
61
  private convs = new Map<string, Conversation>();
@@ -191,7 +191,7 @@ const colBytes = (v: unknown): number => {
191
191
 
192
192
  export abstract class SqlStorageEngine implements StorageEngine {
193
193
  protected readonly dialect: Dialect;
194
- protected readonly agentId: string;
194
+ readonly agentId: string;
195
195
  protected abstract readonly executor: QueryExecutor;
196
196
  protected readonly egressMeter = new ConversationEgressMeter();
197
197
 
@@ -0,0 +1,63 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { AgentHarness } from "../src/harness.js";
6
+ import type { PonchoConfig } from "../src/config.js";
7
+
8
+ const AGENT_MD = `---
9
+ name: config-inject-agent
10
+ model:
11
+ provider: anthropic
12
+ name: claude-opus-4-5
13
+ ---
14
+
15
+ # Config injection test
16
+ `;
17
+
18
+ describe("HarnessOptions.config injection (PR 2)", () => {
19
+ it("uses an injected PonchoConfig instead of reading poncho.config.js from disk", async () => {
20
+ const dir = await mkdtemp(join(tmpdir(), "poncho-cfg-injected-"));
21
+ try {
22
+ await writeFile(join(dir, "AGENT.md"), AGENT_MD, "utf8");
23
+ // Deliberately do NOT write a poncho.config.js — the injected
24
+ // config should be used end-to-end.
25
+ const config: PonchoConfig = {
26
+ tools: { web_search: false },
27
+ storage: { provider: "memory" },
28
+ };
29
+
30
+ const harness = new AgentHarness({ workingDir: dir, config });
31
+ await harness.initialize();
32
+
33
+ const names = harness.listTools().map((t) => t.name);
34
+ // web_search was disabled in the injected config; bash is a default
35
+ // built-in that should still be registered.
36
+ expect(names).not.toContain("web_search");
37
+ expect(names).toContain("bash");
38
+ } finally {
39
+ await rm(dir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ it("disk-loaded behaviour is unchanged when no config option is provided", async () => {
44
+ const dir = await mkdtemp(join(tmpdir(), "poncho-cfg-disk-"));
45
+ try {
46
+ await writeFile(join(dir, "AGENT.md"), AGENT_MD, "utf8");
47
+ // Write a poncho.config.js that disables a tool — proves loadPonchoConfig
48
+ // ran (otherwise web_search would be present).
49
+ await writeFile(
50
+ join(dir, "poncho.config.js"),
51
+ "export default { tools: { web_search: false }, storage: { provider: 'memory' } };\n",
52
+ "utf8",
53
+ );
54
+ const harness = new AgentHarness({ workingDir: dir });
55
+ await harness.initialize();
56
+ const names = harness.listTools().map((t) => t.name);
57
+ expect(names).not.toContain("web_search");
58
+ expect(names).toContain("bash");
59
+ } finally {
60
+ await rm(dir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ });
@@ -0,0 +1,93 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import { AgentHarness } from "../src/harness.js";
6
+ import { InMemoryEngine } from "../src/storage/memory-engine.js";
7
+
8
+ const AGENT_MD = `---
9
+ name: injected-agent
10
+ model:
11
+ provider: anthropic
12
+ name: claude-opus-4-5
13
+ ---
14
+
15
+ # Injected agent
16
+
17
+ You are a test agent.
18
+ `;
19
+
20
+ describe("HarnessOptions injection (PR 1)", () => {
21
+ it("initializes without an AGENT.md on disk when agentDefinition + storageEngine are provided", async () => {
22
+ const dir = await mkdtemp(join(tmpdir(), "poncho-injection-"));
23
+ try {
24
+ const engine = new InMemoryEngine("user-123");
25
+ const harness = new AgentHarness({
26
+ workingDir: dir,
27
+ agentDefinition: AGENT_MD,
28
+ storageEngine: engine,
29
+ });
30
+ await expect(harness.initialize()).resolves.toBeUndefined();
31
+ // No AGENT.md was written into `dir` — confirm initialize ran from
32
+ // injected content alone.
33
+ expect(harness.frontmatter?.name).toBe("injected-agent");
34
+ } finally {
35
+ await rm(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ it("mirrors storageEngine.agentId onto frontmatter.id on the injected path", async () => {
40
+ const dir = await mkdtemp(join(tmpdir(), "poncho-injection-id-"));
41
+ try {
42
+ const engine = new InMemoryEngine("user-456");
43
+ const harness = new AgentHarness({
44
+ workingDir: dir,
45
+ agentDefinition: AGENT_MD,
46
+ storageEngine: engine,
47
+ });
48
+ await harness.initialize();
49
+ expect(harness.frontmatter?.id).toBe("user-456");
50
+ } finally {
51
+ await rm(dir, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ it("accepts a pre-parsed ParsedAgent as agentDefinition", async () => {
56
+ const dir = await mkdtemp(join(tmpdir(), "poncho-injection-parsed-"));
57
+ try {
58
+ const engine = new InMemoryEngine("user-789");
59
+ const parsed = {
60
+ frontmatter: {
61
+ name: "preparsed-agent",
62
+ model: { provider: "anthropic" as const, name: "claude-opus-4-5" },
63
+ },
64
+ body: "# Pre-parsed agent\n",
65
+ };
66
+ const harness = new AgentHarness({
67
+ workingDir: dir,
68
+ agentDefinition: parsed,
69
+ storageEngine: engine,
70
+ });
71
+ await harness.initialize();
72
+ expect(harness.frontmatter?.name).toBe("preparsed-agent");
73
+ expect(harness.frontmatter?.id).toBe("user-789");
74
+ } finally {
75
+ await rm(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ it("throws when agentDefinition is provided without storageEngine", () => {
80
+ expect(
81
+ () =>
82
+ new AgentHarness({
83
+ agentDefinition: AGENT_MD,
84
+ }),
85
+ ).toThrow(/agentDefinition requires HarnessOptions\.storageEngine/);
86
+ });
87
+
88
+ it("falls back to disk path when neither agentDefinition nor storageEngine is provided (existing behaviour unchanged)", async () => {
89
+ // This is implicitly covered by every other test in harness.test.ts —
90
+ // we simply assert that the constructor accepts no injection options.
91
+ expect(() => new AgentHarness({ workingDir: tmpdir() })).not.toThrow();
92
+ });
93
+ });
@@ -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
+ });