@pi-archimedes/mcp 2.3.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/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- package/src/types.ts +162 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ServerDef, ServerCacheEntry } from "./types.js";
|
|
6
|
+
import { CACHE_VERSION, CACHE_MAX_AGE_MS } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
computeServerHash,
|
|
10
|
+
loadMetadataCache,
|
|
11
|
+
saveServerCache,
|
|
12
|
+
isServerCacheValid,
|
|
13
|
+
getCachedTools,
|
|
14
|
+
getCachedPrompts,
|
|
15
|
+
recordServerOutcome,
|
|
16
|
+
setCachePathForTest,
|
|
17
|
+
} = await import("./metadata-cache.js");
|
|
18
|
+
|
|
19
|
+
let tempDir: string;
|
|
20
|
+
let cachePath: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tempDir = mkdtempSync(join(tmpdir(), "mcp-cache-test-"));
|
|
24
|
+
cachePath = join(tempDir, "mcp-cache.json");
|
|
25
|
+
setCachePathForTest(cachePath);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
setCachePathForTest(null);
|
|
30
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const baseStdioDef: ServerDef = {
|
|
34
|
+
type: "stdio",
|
|
35
|
+
command: "node",
|
|
36
|
+
args: ["--experimental", "server.js"],
|
|
37
|
+
env: { API_KEY: "abc" },
|
|
38
|
+
cwd: "/tmp/work",
|
|
39
|
+
protocolVersion: "2025-03-26",
|
|
40
|
+
includeTools: ["a", "b"],
|
|
41
|
+
excludeTools: ["c"],
|
|
42
|
+
exposeResources: true,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const baseHttpDef: ServerDef = {
|
|
46
|
+
type: "http",
|
|
47
|
+
url: "https://example.com/mcp",
|
|
48
|
+
auth: { token: "tok" },
|
|
49
|
+
headers: { "X-Api": "1" },
|
|
50
|
+
bearerTokenEnv: "BEARER_TOKEN",
|
|
51
|
+
protocolVersion: "2025-03-26",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe("computeServerHash", () => {
|
|
55
|
+
it("is stable across key order (stdio)", () => {
|
|
56
|
+
const reordered: ServerDef = {
|
|
57
|
+
exposeResources: true,
|
|
58
|
+
env: { API_KEY: "abc" },
|
|
59
|
+
type: "stdio",
|
|
60
|
+
command: "node",
|
|
61
|
+
includeTools: ["a", "b"],
|
|
62
|
+
protocolVersion: "2025-03-26",
|
|
63
|
+
args: ["--experimental", "server.js"],
|
|
64
|
+
cwd: "/tmp/work",
|
|
65
|
+
excludeTools: ["c"],
|
|
66
|
+
};
|
|
67
|
+
expect(computeServerHash(reordered)).toBe(computeServerHash(baseStdioDef));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is stable across key order (http, nested object key order)", () => {
|
|
71
|
+
const reordered: ServerDef = {
|
|
72
|
+
bearerTokenEnv: "BEARER_TOKEN",
|
|
73
|
+
url: "https://example.com/mcp",
|
|
74
|
+
type: "http",
|
|
75
|
+
headers: { "X-Api": "1" },
|
|
76
|
+
protocolVersion: "2025-03-26",
|
|
77
|
+
auth: { token: "tok" },
|
|
78
|
+
};
|
|
79
|
+
expect(computeServerHash(reordered)).toBe(computeServerHash(baseHttpDef));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("changes when command changes", () => {
|
|
83
|
+
const changed: ServerDef = { ...baseStdioDef, command: "python3" };
|
|
84
|
+
expect(computeServerHash(changed)).not.toBe(computeServerHash(baseStdioDef));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("changes when url changes (http)", () => {
|
|
88
|
+
const changed: ServerDef = { ...baseHttpDef, url: "https://other.com/mcp" };
|
|
89
|
+
expect(computeServerHash(changed)).not.toBe(computeServerHash(baseHttpDef));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does NOT change for runtime settings", () => {
|
|
93
|
+
const withRuntime: ServerDef = {
|
|
94
|
+
...baseStdioDef,
|
|
95
|
+
lifecycle: "lazy-keep-alive",
|
|
96
|
+
idleTimeout: 0,
|
|
97
|
+
requestTimeoutMs: 5000,
|
|
98
|
+
directTools: true,
|
|
99
|
+
toolPrefix: "none",
|
|
100
|
+
debug: true,
|
|
101
|
+
};
|
|
102
|
+
expect(computeServerHash(withRuntime)).toBe(computeServerHash(baseStdioDef));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does NOT change for registration-only / inert fields (protocolVersion, includeTools, excludeTools, exposeResources)", () => {
|
|
106
|
+
const changed: ServerDef = {
|
|
107
|
+
...baseStdioDef,
|
|
108
|
+
protocolVersion: "2025-06-18",
|
|
109
|
+
includeTools: ["z"],
|
|
110
|
+
excludeTools: ["w", "x"],
|
|
111
|
+
exposeResources: false,
|
|
112
|
+
};
|
|
113
|
+
expect(computeServerHash(changed)).toBe(computeServerHash(baseStdioDef));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("changes when headers change (http)", () => {
|
|
117
|
+
const changed: ServerDef = { ...baseHttpDef, headers: { "X-Api": "2" } };
|
|
118
|
+
expect(computeServerHash(changed)).not.toBe(computeServerHash(baseHttpDef));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("changes when bearerTokenEnv changes (http)", () => {
|
|
122
|
+
const changed: ServerDef = { ...baseHttpDef, bearerTokenEnv: "OTHER_TOKEN" };
|
|
123
|
+
expect(computeServerHash(changed)).not.toBe(computeServerHash(baseHttpDef));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns a 64-char hex sha-256 digest", () => {
|
|
127
|
+
expect(computeServerHash(baseStdioDef)).toMatch(/^[0-9a-f]{64}$/);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
function validEntry(def: ServerDef): ServerCacheEntry {
|
|
132
|
+
return {
|
|
133
|
+
configHash: computeServerHash(def),
|
|
134
|
+
tools: [{ name: "t1", inputSchema: {} }],
|
|
135
|
+
resources: [],
|
|
136
|
+
cachedAt: Date.now(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe("isServerCacheValid", () => {
|
|
141
|
+
it("is true for a matching, fresh entry", () => {
|
|
142
|
+
expect(isServerCacheValid(baseStdioDef, validEntry(baseStdioDef))).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("is false for an undefined entry", () => {
|
|
146
|
+
expect(isServerCacheValid(baseStdioDef, undefined)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("is false on hash mismatch (config changed)", () => {
|
|
150
|
+
const changed: ServerDef = { ...baseStdioDef, command: "python3" };
|
|
151
|
+
expect(isServerCacheValid(changed, validEntry(baseStdioDef))).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("is false when older than CACHE_MAX_AGE_MS", () => {
|
|
155
|
+
const stale: ServerCacheEntry = {
|
|
156
|
+
...validEntry(baseStdioDef),
|
|
157
|
+
cachedAt: Date.now() - CACHE_MAX_AGE_MS - 1000,
|
|
158
|
+
};
|
|
159
|
+
expect(isServerCacheValid(baseStdioDef, stale)).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("is true just under CACHE_MAX_AGE_MS", () => {
|
|
163
|
+
const fresh: ServerCacheEntry = {
|
|
164
|
+
...validEntry(baseStdioDef),
|
|
165
|
+
cachedAt: Date.now() - CACHE_MAX_AGE_MS + 60_000,
|
|
166
|
+
};
|
|
167
|
+
expect(isServerCacheValid(baseStdioDef, fresh)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("loadMetadataCache", () => {
|
|
172
|
+
it("returns empty structure when file is missing", () => {
|
|
173
|
+
expect(loadMetadataCache()).toEqual({ version: CACHE_VERSION, servers: {} });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("returns empty structure on corrupt JSON", () => {
|
|
177
|
+
writeFileSync(cachePath, "{not json", "utf-8");
|
|
178
|
+
expect(loadMetadataCache()).toEqual({ version: CACHE_VERSION, servers: {} });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("returns empty structure on version mismatch", () => {
|
|
182
|
+
writeFileSync(
|
|
183
|
+
cachePath,
|
|
184
|
+
JSON.stringify({ version: 999, servers: { a: validEntry(baseStdioDef) } }),
|
|
185
|
+
"utf-8",
|
|
186
|
+
);
|
|
187
|
+
expect(loadMetadataCache()).toEqual({ version: CACHE_VERSION, servers: {} });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("saveServerCache + loadMetadataCache round-trip", () => {
|
|
192
|
+
it("persists a server entry with computed hash and timestamp", () => {
|
|
193
|
+
saveServerCache("srv1", baseStdioDef, {
|
|
194
|
+
tools: [{ name: "t1", description: "d", inputSchema: { type: "object" } }],
|
|
195
|
+
resources: [],
|
|
196
|
+
prompts: [],
|
|
197
|
+
});
|
|
198
|
+
const cache = loadMetadataCache();
|
|
199
|
+
const entry = cache.servers["srv1"];
|
|
200
|
+
expect(entry).toBeDefined();
|
|
201
|
+
expect(entry?.configHash).toBe(computeServerHash(baseStdioDef));
|
|
202
|
+
expect(entry?.cachedAt).toBeGreaterThan(Date.now() - 5000);
|
|
203
|
+
expect(entry?.tools).toEqual([{ name: "t1", description: "d", inputSchema: { type: "object" } }]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("merges without clobbering other servers (read-merge-write)", () => {
|
|
207
|
+
saveServerCache("srv1", baseStdioDef, { tools: [], resources: [], prompts: [] });
|
|
208
|
+
saveServerCache("srv2", baseHttpDef, { tools: [], resources: [], prompts: [] });
|
|
209
|
+
const cache = loadMetadataCache();
|
|
210
|
+
expect(Object.keys(cache.servers).sort()).toEqual(["srv1", "srv2"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("writes atomically (no leftover tmp file)", () => {
|
|
214
|
+
saveServerCache("srv1", baseStdioDef, { tools: [], resources: [], prompts: [] });
|
|
215
|
+
expect(existsSync(cachePath)).toBe(true);
|
|
216
|
+
expect(existsSync(join(tempDir, "mcp-cache.json.tmp"))).toBe(false);
|
|
217
|
+
// File content must be valid JSON (not a partial write)
|
|
218
|
+
expect(() => JSON.parse(readFileSync(cachePath, "utf-8"))).not.toThrow();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("getCachedPrompts", () => {
|
|
223
|
+
it("returns cached prompts for a valid entry", () => {
|
|
224
|
+
const prompts = [{ name: "p1", description: "d" }];
|
|
225
|
+
saveServerCache("srv1", baseStdioDef, { tools: [], resources: [], prompts });
|
|
226
|
+
expect(getCachedPrompts("srv1", baseStdioDef)).toEqual(prompts);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("returns undefined when the entry is stale (hash mismatch)", () => {
|
|
230
|
+
saveServerCache("srv1", baseStdioDef, { tools: [], resources: [], prompts: [{ name: "p1" }] });
|
|
231
|
+
const changed: ServerDef = { ...baseStdioDef, command: "python3" };
|
|
232
|
+
expect(getCachedPrompts("srv1", changed)).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("returns undefined when the entry has no prompts", () => {
|
|
236
|
+
saveServerCache("srv1", baseStdioDef, { tools: [], resources: [] });
|
|
237
|
+
expect(getCachedPrompts("srv1", baseStdioDef)).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("returns undefined when the server is not in the cache", () => {
|
|
241
|
+
expect(getCachedPrompts("missing", baseStdioDef)).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("recordServerOutcome (ADR 0004)", () => {
|
|
246
|
+
it("persists an outcome with a timestamp and the error text", () => {
|
|
247
|
+
const before = Date.now();
|
|
248
|
+
recordServerOutcome("srv1", "needs-auth", "unauthorized");
|
|
249
|
+
const entry = loadMetadataCache().serverStatuses?.["srv1"];
|
|
250
|
+
expect(entry).toEqual({ status: "needs-auth", error: "unauthorized", at: expect.any(Number) });
|
|
251
|
+
expect(entry!.at).toBeGreaterThanOrEqual(before);
|
|
252
|
+
expect(entry!.at).toBeLessThanOrEqual(Date.now() + 1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("stores no error field when none is given (exactOptionalPropertyTypes-safe)", () => {
|
|
256
|
+
recordServerOutcome("srv1", "connected");
|
|
257
|
+
const entry = loadMetadataCache().serverStatuses?.["srv1"];
|
|
258
|
+
expect(entry).toEqual({ status: "connected", at: expect.any(Number) });
|
|
259
|
+
expect("error" in (entry ?? {})).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("keeps other servers' outcomes on re-record (read-merge-write)", () => {
|
|
263
|
+
recordServerOutcome("srv1", "connected");
|
|
264
|
+
recordServerOutcome("srv2", "error", "boom");
|
|
265
|
+
const statuses = loadMetadataCache().serverStatuses ?? {};
|
|
266
|
+
expect(statuses["srv1"]?.status).toBe("connected");
|
|
267
|
+
expect(statuses["srv2"]?.status).toBe("error");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("is idempotent per server (last write wins)", () => {
|
|
271
|
+
recordServerOutcome("srv1", "connected");
|
|
272
|
+
recordServerOutcome("srv1", "error", "ECONNREFUSED");
|
|
273
|
+
expect(loadMetadataCache().serverStatuses?.["srv1"]?.status).toBe("error");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("serverStatuses round-trip (ADR 0004 data-loss trap)", () => {
|
|
278
|
+
it("saveServerCache preserves a previously recorded outcome", () => {
|
|
279
|
+
recordServerOutcome("srv1", "needs-auth", "unauthorized");
|
|
280
|
+
saveServerCache("srv1", baseStdioDef, { tools: [{ name: "t1", inputSchema: {} }], resources: [] });
|
|
281
|
+
const cache = loadMetadataCache();
|
|
282
|
+
expect(cache.serverStatuses?.["srv1"]?.status).toBe("needs-auth");
|
|
283
|
+
expect(cache.servers["srv1"]).toBeDefined();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("recordServerOutcome preserves previously saved server entries", () => {
|
|
287
|
+
saveServerCache("srv1", baseStdioDef, { tools: [{ name: "t1", inputSchema: {} }], resources: [] });
|
|
288
|
+
recordServerOutcome("srv1", "connected");
|
|
289
|
+
expect(getCachedTools("srv1", baseStdioDef)).toEqual([{ name: "t1", inputSchema: {} }]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("both fields survive alternating save/record cycles", () => {
|
|
293
|
+
recordServerOutcome("srv1", "error", "one");
|
|
294
|
+
saveServerCache("srv2", baseHttpDef, { tools: [], resources: [] });
|
|
295
|
+
recordServerOutcome("srv2", "needs-auth");
|
|
296
|
+
saveServerCache("srv1", baseStdioDef, { tools: [], resources: [] });
|
|
297
|
+
const cache = loadMetadataCache();
|
|
298
|
+
expect(cache.serverStatuses?.["srv1"]?.status).toBe("error");
|
|
299
|
+
expect(cache.serverStatuses?.["srv2"]?.status).toBe("needs-auth");
|
|
300
|
+
expect(Object.keys(cache.servers).sort()).toEqual(["srv1", "srv2"]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("loads serverStatuses from a hand-written file (backwards-compatible load)", () => {
|
|
304
|
+
writeFileSync(
|
|
305
|
+
cachePath,
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
version: CACHE_VERSION,
|
|
308
|
+
servers: {},
|
|
309
|
+
serverStatuses: { srv1: { status: "needs-auth", at: Date.now() - 60_000 } },
|
|
310
|
+
}),
|
|
311
|
+
"utf-8",
|
|
312
|
+
);
|
|
313
|
+
expect(loadMetadataCache().serverStatuses?.["srv1"]?.status).toBe("needs-auth");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("still loads a legacy file without serverStatuses", () => {
|
|
317
|
+
writeFileSync(cachePath, JSON.stringify({ version: CACHE_VERSION, servers: {} }), "utf-8");
|
|
318
|
+
const cache = loadMetadataCache();
|
|
319
|
+
expect(cache).toEqual({ version: CACHE_VERSION, servers: {} });
|
|
320
|
+
expect(cache.serverStatuses).toBeUndefined();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("drops malformed individual entries instead of crashing", () => {
|
|
324
|
+
writeFileSync(
|
|
325
|
+
cachePath,
|
|
326
|
+
JSON.stringify({
|
|
327
|
+
version: CACHE_VERSION,
|
|
328
|
+
servers: {},
|
|
329
|
+
serverStatuses: {
|
|
330
|
+
good: { status: "connected", at: 123 },
|
|
331
|
+
badStatus: { status: "bogus", at: 123 },
|
|
332
|
+
noAt: { status: "connected" },
|
|
333
|
+
},
|
|
334
|
+
}),
|
|
335
|
+
"utf-8",
|
|
336
|
+
);
|
|
337
|
+
expect(Object.keys(loadMetadataCache().serverStatuses ?? {})).toEqual(["good"]);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("getCachedTools", () => {
|
|
342
|
+
it("returns cached tools for a valid entry", () => {
|
|
343
|
+
const tools = [{ name: "t1", description: "d", inputSchema: {} }];
|
|
344
|
+
saveServerCache("srv1", baseStdioDef, { tools, resources: [], prompts: [] });
|
|
345
|
+
expect(getCachedTools("srv1", baseStdioDef)).toEqual(tools);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("returns undefined when the entry is stale (hash mismatch)", () => {
|
|
349
|
+
saveServerCache("srv1", baseStdioDef, { tools: [], resources: [], prompts: [] });
|
|
350
|
+
const changed: ServerDef = { ...baseStdioDef, command: "python3" };
|
|
351
|
+
expect(getCachedTools("srv1", changed)).toBeUndefined();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("returns undefined when the entry is older than CACHE_MAX_AGE_MS", () => {
|
|
355
|
+
saveServerCache("srv1", baseStdioDef, { tools: [], resources: [], prompts: [] });
|
|
356
|
+
// Age the entry directly on disk
|
|
357
|
+
const cache = loadMetadataCache();
|
|
358
|
+
const entry = cache.servers["srv1"];
|
|
359
|
+
if (entry) entry.cachedAt = Date.now() - CACHE_MAX_AGE_MS - 1;
|
|
360
|
+
writeFileSync(cachePath, JSON.stringify(cache), "utf-8");
|
|
361
|
+
expect(getCachedTools("srv1", baseStdioDef)).toBeUndefined();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("returns undefined when the server is not in the cache", () => {
|
|
365
|
+
expect(getCachedTools("missing", baseStdioDef)).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe("cache path override", () => {
|
|
370
|
+
it("honors the MCP_CACHE_PATH env var when no test path is set", () => {
|
|
371
|
+
const envPath = join(tempDir, "env-cache.json");
|
|
372
|
+
setCachePathForTest(null);
|
|
373
|
+
process.env.MCP_CACHE_PATH = envPath;
|
|
374
|
+
try {
|
|
375
|
+
saveServerCache("srv1", baseStdioDef, { tools: [], resources: [], prompts: [] });
|
|
376
|
+
expect(readFileSync(envPath, "utf-8")).toContain("srv1");
|
|
377
|
+
expect(getCachedTools("srv1", baseStdioDef)).toEqual([]);
|
|
378
|
+
} finally {
|
|
379
|
+
delete process.env.MCP_CACHE_PATH;
|
|
380
|
+
setCachePathForTest(cachePath);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ServerDef, MetadataCache, ServerCacheEntry, CachedTool, ServerOutcomeRecord } from "./types.js";
|
|
6
|
+
import { CACHE_VERSION, CACHE_MAX_AGE_MS } from "./types.js";
|
|
7
|
+
import type { ServerStatus } from "./server-client.js";
|
|
8
|
+
|
|
9
|
+
/** Test override — null means "use MCP_CACHE_PATH env var or the default path" */
|
|
10
|
+
let testCachePath: string | null = null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Override the cache file path (for tests). Pass null to reset.
|
|
14
|
+
* Tests must use this so the real ~/.pi/agent/mcp-cache.json is never touched.
|
|
15
|
+
*/
|
|
16
|
+
export function setCachePathForTest(path: string | null): void {
|
|
17
|
+
testCachePath = path;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Default cache location: ~/.pi/agent/mcp-cache.json (MCP_CACHE_PATH overrides) */
|
|
21
|
+
function cachePath(): string {
|
|
22
|
+
if (testCachePath) return testCachePath;
|
|
23
|
+
const env = process.env.MCP_CACHE_PATH;
|
|
24
|
+
if (env) return env;
|
|
25
|
+
return join(getAgentDir(), "mcp-cache.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Project a ServerDef to only the identity-affecting fields — the fields
|
|
30
|
+
* that change WHAT the server is (connection + auth), not HOW it is managed.
|
|
31
|
+
* ServerDef is a discriminated union — narrow via "command" in def / "url" in def
|
|
32
|
+
* before accessing transport-specific fields. Runtime settings (lifecycle,
|
|
33
|
+
* idleTimeout, debug, directTools, toolPrefix, requestTimeoutMs, disabled)
|
|
34
|
+
* are deliberately excluded: changing them must not invalidate the cache.
|
|
35
|
+
* Likewise excluded: protocolVersion (documented inert), exposeResources
|
|
36
|
+
* (not yet implemented), and includeTools/excludeTools (they only filter
|
|
37
|
+
* DIRECT-tool registration, never the cached tool list) — toggling any of
|
|
38
|
+
* those must not invalidate the cache or trigger a client close+replace.
|
|
39
|
+
*/
|
|
40
|
+
function projectIdentity(def: ServerDef): Record<string, unknown> {
|
|
41
|
+
const out: Record<string, unknown> = {};
|
|
42
|
+
if ("command" in def) {
|
|
43
|
+
out.command = def.command;
|
|
44
|
+
if (def.args !== undefined) out.args = def.args;
|
|
45
|
+
if (def.env !== undefined) out.env = def.env;
|
|
46
|
+
if (def.cwd !== undefined) out.cwd = def.cwd;
|
|
47
|
+
}
|
|
48
|
+
if ("url" in def) {
|
|
49
|
+
out.url = def.url;
|
|
50
|
+
if (def.auth !== undefined) out.auth = def.auth;
|
|
51
|
+
if (def.headers !== undefined) out.headers = def.headers;
|
|
52
|
+
if (def.bearerTokenEnv !== undefined) out.bearerTokenEnv = def.bearerTokenEnv;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Deterministic JSON: object keys sorted recursively, undefined values dropped */
|
|
58
|
+
function stableStringify(value: unknown): string {
|
|
59
|
+
if (value === null) return "null";
|
|
60
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
61
|
+
if (typeof value === "object") {
|
|
62
|
+
const obj = value as Record<string, unknown>;
|
|
63
|
+
const parts = Object.keys(obj)
|
|
64
|
+
.sort()
|
|
65
|
+
.filter((k) => obj[k] !== undefined)
|
|
66
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`);
|
|
67
|
+
return `{${parts.join(",")}}`;
|
|
68
|
+
}
|
|
69
|
+
// string / number / boolean
|
|
70
|
+
return JSON.stringify(value) ?? "null";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* SHA-256 hex digest over the identity-affecting fields only, stable-stringified
|
|
75
|
+
* (sorted keys) so key order in the config file does not change the hash.
|
|
76
|
+
*/
|
|
77
|
+
export function computeServerHash(def: ServerDef): string {
|
|
78
|
+
return createHash("sha256")
|
|
79
|
+
.update(stableStringify(projectIdentity(def)))
|
|
80
|
+
.digest("hex");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function emptyCache(): MetadataCache {
|
|
84
|
+
return { version: CACHE_VERSION, servers: {} };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isServerOutcomeRecord(v: unknown): v is ServerOutcomeRecord {
|
|
88
|
+
if (typeof v !== "object" || v === null) return false;
|
|
89
|
+
const o = v as Record<string, unknown>;
|
|
90
|
+
return (
|
|
91
|
+
(o.status === "connected" || o.status === "needs-auth" || o.status === "error") &&
|
|
92
|
+
typeof o.at === "number" &&
|
|
93
|
+
(o.error === undefined || typeof o.error === "string")
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Keep only well-formed outcome entries; a garbage field must not sink the whole cache. */
|
|
98
|
+
function sanitizeServerStatuses(raw: unknown): Record<string, ServerOutcomeRecord> | undefined {
|
|
99
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
100
|
+
const kept: Record<string, ServerOutcomeRecord> = {};
|
|
101
|
+
for (const [name, v] of Object.entries(raw)) {
|
|
102
|
+
if (isServerOutcomeRecord(v)) kept[name] = v;
|
|
103
|
+
}
|
|
104
|
+
return Object.keys(kept).length > 0 ? kept : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Load the metadata cache; returns an empty valid structure on missing/corrupt file or version mismatch. */
|
|
108
|
+
export function loadMetadataCache(): MetadataCache {
|
|
109
|
+
const path = cachePath();
|
|
110
|
+
if (!existsSync(path)) return emptyCache();
|
|
111
|
+
try {
|
|
112
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
113
|
+
if (typeof raw !== "object" || raw === null) return emptyCache();
|
|
114
|
+
const obj = raw as { version?: unknown; servers?: unknown; [key: string]: unknown };
|
|
115
|
+
if (
|
|
116
|
+
obj.version !== CACHE_VERSION ||
|
|
117
|
+
typeof obj.servers !== "object" ||
|
|
118
|
+
obj.servers === null ||
|
|
119
|
+
Array.isArray(obj.servers)
|
|
120
|
+
) {
|
|
121
|
+
return emptyCache();
|
|
122
|
+
}
|
|
123
|
+
const cache: MetadataCache = {
|
|
124
|
+
version: CACHE_VERSION,
|
|
125
|
+
servers: obj.servers as Record<string, ServerCacheEntry>,
|
|
126
|
+
};
|
|
127
|
+
// ADR 0004 round-trip: preserve the persisted connection outcomes. The
|
|
128
|
+
// pre-fix reconstruction dropped this field, so every saveServerCache
|
|
129
|
+
// rewrite silently erased it (the data-loss trap).
|
|
130
|
+
const statuses = sanitizeServerStatuses(obj.serverStatuses);
|
|
131
|
+
if (statuses !== undefined) cache.serverStatuses = statuses;
|
|
132
|
+
return cache;
|
|
133
|
+
} catch {
|
|
134
|
+
return emptyCache();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Atomic write: tmp file + rename in the same directory. */
|
|
139
|
+
function writeCacheFile(cache: MetadataCache): void {
|
|
140
|
+
const path = cachePath();
|
|
141
|
+
const dir = dirname(path);
|
|
142
|
+
mkdirSync(dir, { recursive: true });
|
|
143
|
+
const tmp = `${path}.tmp`;
|
|
144
|
+
writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf-8");
|
|
145
|
+
renameSync(tmp, path);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read-merge-write: merge one server's entry into the on-disk cache.
|
|
150
|
+
* Any `serverStatuses` present in the on-disk file survive the rewrite
|
|
151
|
+
* (ADR 0004 round-trip — the load+save paths must never drop each other's
|
|
152
|
+
* field). Atomic via tmp file + rename in the same directory.
|
|
153
|
+
*/
|
|
154
|
+
export function saveServerCache(
|
|
155
|
+
serverName: string,
|
|
156
|
+
def: ServerDef,
|
|
157
|
+
entry: Omit<ServerCacheEntry, "configHash" | "cachedAt">,
|
|
158
|
+
): void {
|
|
159
|
+
const cache = loadMetadataCache();
|
|
160
|
+
cache.servers = {
|
|
161
|
+
...cache.servers,
|
|
162
|
+
[serverName]: {
|
|
163
|
+
...entry,
|
|
164
|
+
configHash: computeServerHash(def),
|
|
165
|
+
cachedAt: Date.now(),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
writeCacheFile(cache);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The SINGLE recorder for persisted connection outcomes (ADR 0004).
|
|
173
|
+
* Every connection settle point (session_start background probe, on-demand
|
|
174
|
+
* connect, reconnect) calls this — never touches the cache file directly,
|
|
175
|
+
* so the write path stays in one place. Best-effort: a cache write failure
|
|
176
|
+
* must never break the settle itself (same convention as saveServerCache).
|
|
177
|
+
*/
|
|
178
|
+
export function recordServerOutcome(
|
|
179
|
+
serverName: string,
|
|
180
|
+
status: ServerOutcomeRecord["status"],
|
|
181
|
+
error?: string,
|
|
182
|
+
): void {
|
|
183
|
+
try {
|
|
184
|
+
const cache = loadMetadataCache();
|
|
185
|
+
const entry: ServerOutcomeRecord = { status, at: Date.now() };
|
|
186
|
+
// Status lines are single-line; multi-line errors keep their first line
|
|
187
|
+
const firstLine = error?.split("\n")[0]?.trim();
|
|
188
|
+
if (firstLine !== undefined && firstLine.length > 0) entry.error = firstLine;
|
|
189
|
+
cache.serverStatuses = { ...(cache.serverStatuses ?? {}), [serverName]: entry };
|
|
190
|
+
writeCacheFile(cache);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.warn(
|
|
193
|
+
`[archimedes/mcp] Failed to record outcome for "${serverName}": ${e instanceof Error ? e.message : String(e)}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Settle-point convenience: map a finished ServerClient onto its recorded
|
|
200
|
+
* outcome. A client that is "disconnected"/"connecting" has no verified
|
|
201
|
+
* outcome (e.g. a generation-fenced connect that resolved empty) — nothing
|
|
202
|
+
* is recorded for those.
|
|
203
|
+
*/
|
|
204
|
+
export function recordClientOutcome(client: { name: string; status: ServerStatus; error: string | null }): void {
|
|
205
|
+
if (client.status !== "connected" && client.status !== "needs-auth" && client.status !== "error") return;
|
|
206
|
+
recordServerOutcome(client.name, client.status, client.error ?? undefined);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** True if the cache entry exists, its config hash matches, and it is not older than CACHE_MAX_AGE_MS */
|
|
210
|
+
export function isServerCacheValid(def: ServerDef, entry: ServerCacheEntry | undefined): boolean {
|
|
211
|
+
if (!entry) return false;
|
|
212
|
+
if (entry.configHash !== computeServerHash(def)) return false;
|
|
213
|
+
return Date.now() - entry.cachedAt <= CACHE_MAX_AGE_MS;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Get cached tools for a server if the cache is valid for the current def, else undefined */
|
|
217
|
+
export function getCachedTools(serverName: string, def: ServerDef): CachedTool[] | undefined {
|
|
218
|
+
const entry = loadMetadataCache().servers[serverName];
|
|
219
|
+
if (!entry || !isServerCacheValid(def, entry)) return undefined;
|
|
220
|
+
return entry.tools;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Get cached prompts for a server if the cache is valid for the current def, else undefined */
|
|
224
|
+
export function getCachedPrompts(
|
|
225
|
+
serverName: string,
|
|
226
|
+
def: ServerDef,
|
|
227
|
+
): Array<{ name: string; description?: string }> | undefined {
|
|
228
|
+
const entry = loadMetadataCache().servers[serverName];
|
|
229
|
+
if (!entry || !isServerCacheValid(def, entry)) return undefined;
|
|
230
|
+
return entry.prompts;
|
|
231
|
+
}
|