@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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +86 -0
- package/dist/index.d.ts +44 -3
- package/dist/index.js +131 -36
- package/package.json +1 -1
- package/src/harness.ts +81 -18
- package/src/mcp.ts +102 -23
- package/src/storage/engine.ts +2 -0
- package/src/storage/memory-engine.ts +1 -1
- package/src/storage/sql-dialect.ts +1 -1
- package/test/harness-config-injection.test.ts +63 -0
- package/test/harness-injection.test.ts +93 -0
- package/test/mcp-tenant-cache.test.ts +311 -0
- package/test/mcp.test.ts +174 -0
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
630
|
-
|
|
631
|
-
|
|
708
|
+
callClient = this.getOrCreateTenantClient(
|
|
709
|
+
serverName,
|
|
710
|
+
context.tenantId,
|
|
632
711
|
tenantToken,
|
|
633
|
-
server
|
|
712
|
+
server,
|
|
634
713
|
);
|
|
635
714
|
}
|
|
636
715
|
}
|
package/src/storage/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|