@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +39 -0
  4. package/src/auth-flow.test.ts +583 -0
  5. package/src/auth-flow.ts +310 -0
  6. package/src/auth-run.test.ts +309 -0
  7. package/src/auth-run.ts +146 -0
  8. package/src/auth-storage.test.ts +338 -0
  9. package/src/auth-storage.ts +330 -0
  10. package/src/auto-auth.test.ts +231 -0
  11. package/src/auto-auth.ts +135 -0
  12. package/src/callback-server.test.ts +446 -0
  13. package/src/callback-server.ts +538 -0
  14. package/src/commands-auth.test.ts +320 -0
  15. package/src/commands-auth.ts +128 -0
  16. package/src/commands.test.ts +834 -0
  17. package/src/commands.ts +424 -0
  18. package/src/config-write.test.ts +213 -0
  19. package/src/config-write.ts +207 -0
  20. package/src/config.test.ts +468 -0
  21. package/src/config.ts +278 -0
  22. package/src/direct-tools.test.ts +473 -0
  23. package/src/direct-tools.ts +250 -0
  24. package/src/host-configs.test.ts +231 -0
  25. package/src/host-configs.ts +106 -0
  26. package/src/index.test.ts +689 -0
  27. package/src/index.ts +146 -0
  28. package/src/lifecycle.test.ts +274 -0
  29. package/src/lifecycle.ts +77 -0
  30. package/src/metadata-cache.test.ts +383 -0
  31. package/src/metadata-cache.ts +231 -0
  32. package/src/npx-resolver.test.ts +142 -0
  33. package/src/npx-resolver.ts +126 -0
  34. package/src/oauth-provider.test.ts +404 -0
  35. package/src/oauth-provider.ts +197 -0
  36. package/src/oauth-types.ts +54 -0
  37. package/src/panel-rows.ts +210 -0
  38. package/src/panel.test.ts +298 -0
  39. package/src/panel.ts +742 -0
  40. package/src/proxy-tool.ts +524 -0
  41. package/src/renderer.test.ts +326 -0
  42. package/src/renderer.ts +239 -0
  43. package/src/schema-validator.test.ts +56 -0
  44. package/src/schema-validator.ts +42 -0
  45. package/src/server-client.test.ts +1001 -0
  46. package/src/server-client.ts +576 -0
  47. package/src/server-manager.ts +139 -0
  48. package/src/setup-panel.test.ts +162 -0
  49. package/src/setup-panel.ts +715 -0
  50. package/src/tool-naming.test.ts +168 -0
  51. package/src/tool-naming.ts +114 -0
  52. 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
+ }