@proofofwork-agency/toolpin 0.2.3

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 (61) hide show
  1. package/CONTRIBUTING.md +117 -0
  2. package/LICENSE +183 -0
  3. package/README.md +323 -0
  4. package/SECURITY.md +61 -0
  5. package/action.yml +134 -0
  6. package/dist/canonicalJson.js +38 -0
  7. package/dist/capabilities.js +139 -0
  8. package/dist/ci.js +26 -0
  9. package/dist/cli.js +1843 -0
  10. package/dist/clientSupport.js +76 -0
  11. package/dist/codexToml.js +213 -0
  12. package/dist/config.js +337 -0
  13. package/dist/constants.js +3 -0
  14. package/dist/continueYaml.js +76 -0
  15. package/dist/doctor.js +163 -0
  16. package/dist/install.js +191 -0
  17. package/dist/installed.js +405 -0
  18. package/dist/integrity.js +14 -0
  19. package/dist/inventory.js +169 -0
  20. package/dist/packageIntegrity.js +153 -0
  21. package/dist/plan.js +595 -0
  22. package/dist/policy.js +310 -0
  23. package/dist/registry.js +1610 -0
  24. package/dist/runtimeAdvisory.js +80 -0
  25. package/dist/safeFetch.js +157 -0
  26. package/dist/sarif.js +162 -0
  27. package/dist/scan.js +113 -0
  28. package/dist/search.js +44 -0
  29. package/dist/secrets.js +165 -0
  30. package/dist/signing.js +146 -0
  31. package/dist/tester.js +240 -0
  32. package/dist/trust.js +528 -0
  33. package/dist/tui/app.js +1731 -0
  34. package/dist/tui/command.js +50 -0
  35. package/dist/tui/configSnippet.js +11 -0
  36. package/dist/tui/constants.js +37 -0
  37. package/dist/tui/format.js +31 -0
  38. package/dist/tui/installedState.js +23 -0
  39. package/dist/tui/layout.js +65 -0
  40. package/dist/tui/selectors.js +282 -0
  41. package/dist/tui/types.js +1 -0
  42. package/dist/tui/ui/trust.js +77 -0
  43. package/dist/tui/views/installed.js +82 -0
  44. package/dist/tui/views/panels.js +637 -0
  45. package/dist/tui.js +12 -0
  46. package/dist/types.js +1 -0
  47. package/dist/verificationTrust.js +103 -0
  48. package/dist/verify.js +537 -0
  49. package/dist/version.js +1 -0
  50. package/dist/versions.js +127 -0
  51. package/docs/assets/readme/terminal-demo.svg +174 -0
  52. package/docs/assets/readme/tui-browse-overview.jpg +0 -0
  53. package/docs/assets/readme/tui-config-preview.jpg +0 -0
  54. package/docs/assets/readme/tui-help.jpg +0 -0
  55. package/docs/assets/readme/tui-installed-inventory.jpg +0 -0
  56. package/docs/how-to/catch-drift-in-ci.md +189 -0
  57. package/docs/how-to/custom-registries.md +156 -0
  58. package/docs/how-to/toolpin-curated-registry.md +153 -0
  59. package/package.json +76 -0
  60. package/registry/README.md +92 -0
  61. package/registry/v0/servers +115 -0
@@ -0,0 +1,405 @@
1
+ import { doctorLockfile, readInstalledServerConfig } from "./doctor.js";
2
+ import { installServerConfig, removeServerConfig, resolveConfigTarget } from "./install.js";
3
+ import { listInstalledServers } from "./inventory.js";
4
+ import { buildInstallPlan, lockKey, readLockfile, removeLockfileEntry, writeLockfile } from "./plan.js";
5
+ import { enforcePolicy } from "./policy.js";
6
+ import { testInstalledClientConfig } from "./tester.js";
7
+ import { verifyServer } from "./verify.js";
8
+ import { compareVersionStatus, compareVersionish } from "./versions.js";
9
+ export async function loadInstalledServerStates(options) {
10
+ const scope = options.scope ?? "all";
11
+ const [inventory, doctor] = await Promise.all([
12
+ listInstalledServers({ scope, client: options.client }),
13
+ doctorLockfile("mcp-lock.json", scope).catch(() => undefined),
14
+ ]);
15
+ const issues = doctor?.issues ?? [];
16
+ const rows = inventory.entries.map((entry) => {
17
+ const locked = findLockedPlan(options.lockfile, entry.serverName, entry.client);
18
+ const match = resolveInstalledRegistryMatch(options.servers, entry.serverName, locked?.version);
19
+ const registryMatch = match.registryMatch ?? (locked ? "exact" : undefined);
20
+ const testResult = options.tests?.[installedId(entry.serverName, entry.client, entry.scope)];
21
+ const lockDrift = issues.some((issue) => issueMatchesInstalled(issue, entry.serverName, entry.client, entry.scope));
22
+ const versionDelta = locked?.version && match.latestVersion ? compareVersionStatus(match.latestVersion, locked.version) : undefined;
23
+ const updateAvailable = versionDelta !== undefined && versionDelta > 0;
24
+ const lifecycleAction = lifecycleActionFor({ locked: Boolean(locked), updateAvailable, updateServer: match.updateServer, registryMatch });
25
+ const canTest = entry.client !== "zed";
26
+ const runningStatus = testResult?.ok
27
+ ? "reachable"
28
+ : lockDrift || updateAvailable
29
+ ? "stale"
30
+ : testResult
31
+ ? "unknown"
32
+ : "not_checked";
33
+ const issue = lockDrift
34
+ ? issues.find((candidate) => issueMatchesInstalled(candidate, entry.serverName, entry.client, entry.scope))?.message
35
+ : match.ambiguousCandidates?.length
36
+ ? `ambiguous registry alias match: ${match.ambiguousCandidates.join(", ")}`
37
+ : undefined;
38
+ return {
39
+ id: installedId(entry.serverName, entry.client, entry.scope),
40
+ client: entry.client,
41
+ scope: entry.scope,
42
+ file: entry.file,
43
+ serverName: entry.serverName,
44
+ installed: true,
45
+ locked: Boolean(locked),
46
+ lockDrift,
47
+ lockedVersion: locked?.version,
48
+ currentVersion: locked?.version,
49
+ latestVersion: match.latestVersion ?? locked?.version,
50
+ updateAvailable,
51
+ source: locked?.resolved?.source,
52
+ canUpdate: lifecycleAction !== "none",
53
+ canDelete: true,
54
+ canTest,
55
+ registryMatch,
56
+ registryStatus: (registryMatch ?? "none"),
57
+ lifecycleAction,
58
+ testSource: (canTest ? "config" : "none"),
59
+ runningStatus,
60
+ testResult,
61
+ installableServer: match.currentServer,
62
+ updateServer: match.updateServer,
63
+ issue,
64
+ registryCandidates: match.ambiguousCandidates,
65
+ };
66
+ });
67
+ if (options.lockfile) {
68
+ const installedKeys = new Set(rows.map((row) => installedId(row.serverName, row.client, row.scope)));
69
+ for (const locked of Object.values(options.lockfile.servers)) {
70
+ const lockedScope = locked.scope ?? "project";
71
+ if (scope !== "all" && lockedScope !== scope)
72
+ continue;
73
+ if (options.client && options.client !== "all" && locked.client !== options.client)
74
+ continue;
75
+ const id = installedId(locked.name, locked.client, lockedScope);
76
+ if (installedKeys.has(id))
77
+ continue;
78
+ const match = resolveInstalledRegistryMatch(options.servers, locked.name, locked.version);
79
+ const target = safeConfigTarget(locked.client, lockedScope);
80
+ rows.push({
81
+ id,
82
+ client: locked.client,
83
+ scope: lockedScope,
84
+ file: target?.file ?? "missing client config",
85
+ serverName: locked.name,
86
+ installed: false,
87
+ locked: true,
88
+ lockDrift: true,
89
+ lockedVersion: locked.version,
90
+ currentVersion: undefined,
91
+ latestVersion: match.latestVersion ?? locked.version,
92
+ updateAvailable: false,
93
+ source: locked.resolved?.source,
94
+ canUpdate: false,
95
+ canDelete: true,
96
+ canTest: false,
97
+ registryMatch: match.registryMatch,
98
+ registryStatus: (match.registryMatch ?? "none"),
99
+ lifecycleAction: "none",
100
+ testSource: "none",
101
+ runningStatus: "stale",
102
+ installableServer: match.currentServer,
103
+ updateServer: match.updateServer,
104
+ issue: "locked in mcp-lock.json but missing from checked client config",
105
+ registryCandidates: match.ambiguousCandidates,
106
+ });
107
+ }
108
+ }
109
+ return rows.sort((left, right) => left.scope.localeCompare(right.scope)
110
+ || left.client.localeCompare(right.client)
111
+ || left.serverName.localeCompare(right.serverName)
112
+ || left.file.localeCompare(right.file));
113
+ }
114
+ export async function testInstalledServer(options) {
115
+ const target = await getInstalledConfig(options.serverName, options.client, options.scope);
116
+ return testInstalledClientConfig(options.serverName, target.config, options.timeoutMs ?? 15000);
117
+ }
118
+ export async function adoptInstalledServer(options) {
119
+ const lockfilePath = options.lockfilePath ?? "mcp-lock.json";
120
+ const row = await getInstalledLifecycleRow(options.installedName, options.client, options.scope, options.servers, lockfilePath);
121
+ if (row.locked)
122
+ throw new Error(`${row.serverName} is already locked for ${row.client}; use \`toolpin update ${row.serverName} --client ${row.client} --scope ${row.scope}\`.`);
123
+ if (row.registryCandidates?.length) {
124
+ throw new Error(`Ambiguous registry alias match for ${row.serverName}: ${row.registryCandidates.join(", ")}`);
125
+ }
126
+ if (row.lifecycleAction !== "adopt" || !row.updateServer) {
127
+ throw new Error(`No adoptable registry match found for ${row.serverName}.`);
128
+ }
129
+ return mutateInstalledRow(row, row.updateServer, "adopt", options);
130
+ }
131
+ export async function updateInstalledServer(options) {
132
+ const lockfilePath = options.lockfilePath ?? "mcp-lock.json";
133
+ const row = await getInstalledLifecycleRow(options.serverName, options.client, options.scope, options.servers, lockfilePath);
134
+ if (!row.locked)
135
+ throw new Error(`${row.serverName} is not locked for ${row.client}; use \`toolpin adopt ${row.serverName} --client ${row.client} --scope ${row.scope}\` if you want to lock a registry match.`);
136
+ if (row.registryCandidates?.length) {
137
+ throw new Error(`Ambiguous registry alias match for ${row.serverName}: ${row.registryCandidates.join(", ")}`);
138
+ }
139
+ const targetServer = options.version
140
+ ? resolveInstalledRegistryVersion(options.servers, row, options.version)
141
+ : row.updateServer;
142
+ if (!targetServer) {
143
+ throw new Error(`No locked update is available for ${row.serverName}.`);
144
+ }
145
+ return mutateInstalledRow(row, targetServer, "update", options);
146
+ }
147
+ export async function updateAllInstalledServers(options) {
148
+ const lockfilePath = options.lockfilePath ?? "mcp-lock.json";
149
+ const lockfile = await readLockfile(lockfilePath).catch(() => undefined);
150
+ const rows = await loadInstalledServerStates({
151
+ servers: options.servers,
152
+ lockfile,
153
+ scope: options.scope,
154
+ client: options.client,
155
+ });
156
+ const updated = [];
157
+ const skippedAdoptable = [];
158
+ const skipped = [];
159
+ for (const row of rows) {
160
+ if (row.lifecycleAction === "adopt" && row.updateServer) {
161
+ skippedAdoptable.push({ serverName: row.serverName, client: row.client, scope: row.scope, targetName: row.updateServer.name });
162
+ continue;
163
+ }
164
+ if (row.lifecycleAction !== "update" || !row.updateServer) {
165
+ skipped.push({ serverName: row.serverName, client: row.client, scope: row.scope, reason: row.locked ? "current" : "unlocked/no-registry-match" });
166
+ continue;
167
+ }
168
+ updated.push(await mutateInstalledRow(row, row.updateServer, "update", options));
169
+ }
170
+ return {
171
+ dryRun: options.dryRun === true,
172
+ scope: options.scope,
173
+ client: options.client,
174
+ updated,
175
+ skippedAdoptable,
176
+ skipped,
177
+ };
178
+ }
179
+ export function installedId(serverName, client, scope) {
180
+ return `${scope}:${client}:${serverName}`;
181
+ }
182
+ function lifecycleActionFor(input) {
183
+ if (!input.updateServer?.installable || !input.registryMatch)
184
+ return "none";
185
+ if (input.locked)
186
+ return input.updateAvailable ? "update" : "none";
187
+ return "adopt";
188
+ }
189
+ function safeConfigTarget(client, scope) {
190
+ try {
191
+ return resolveConfigTarget(client, scope);
192
+ }
193
+ catch {
194
+ return undefined;
195
+ }
196
+ }
197
+ async function getInstalledLifecycleRow(serverName, client, scope, servers, lockfilePath) {
198
+ const lockfile = await readLockfile(lockfilePath).catch(() => undefined);
199
+ const rows = await loadInstalledServerStates({ servers, lockfile, scope, client });
200
+ const row = rows.find((candidate) => candidate.serverName === serverName && candidate.client === client && candidate.scope === scope);
201
+ if (!row) {
202
+ throw new Error(`No installed config entry found for ${serverName} in ${client} ${scope} config.`);
203
+ }
204
+ return row;
205
+ }
206
+ async function mutateInstalledRow(row, server, action, options) {
207
+ const lockfilePath = options.lockfilePath ?? "mcp-lock.json";
208
+ const planned = mutationPlan(row, server, action, lockfilePath);
209
+ const dryRun = options.dryRun === true;
210
+ const { plan, verification } = await buildCheckedPlan(server, row.client, row.scope, options);
211
+ if (dryRun) {
212
+ return {
213
+ action,
214
+ dryRun,
215
+ serverName: row.serverName,
216
+ targetName: server.name,
217
+ client: row.client,
218
+ scope: row.scope,
219
+ fromVersion: row.lockedVersion,
220
+ toVersion: server.version,
221
+ lockfilePath,
222
+ lockfileWritten: false,
223
+ verification,
224
+ planned,
225
+ };
226
+ }
227
+ let removedAlias;
228
+ if (row.serverName !== server.name) {
229
+ removedAlias = await removeServerConfig(row.serverName, row.client, row.scope);
230
+ await removeLockfileEntry(row.serverName, row.client, lockfilePath);
231
+ }
232
+ const config = await installServerConfig(server, row.client, row.scope);
233
+ await writeLockfile(plan, lockfilePath, lockKey(server.name, row.client));
234
+ return {
235
+ action,
236
+ dryRun,
237
+ serverName: row.serverName,
238
+ targetName: server.name,
239
+ client: row.client,
240
+ scope: row.scope,
241
+ fromVersion: row.lockedVersion,
242
+ toVersion: server.version,
243
+ removedAlias,
244
+ config,
245
+ lockfilePath,
246
+ lockfileWritten: true,
247
+ verification,
248
+ planned,
249
+ };
250
+ }
251
+ async function buildCheckedPlan(server, client, scope, options) {
252
+ let capabilityManifest;
253
+ let verification;
254
+ if (options.verify) {
255
+ verification = await verifyServer(server, {
256
+ liveRemoteProbe: true,
257
+ livePackageProbe: true,
258
+ timeoutMs: options.timeoutMs ?? 15000,
259
+ requireVerified: options.requireVerified === true,
260
+ });
261
+ if (!verification.ok) {
262
+ throw new Error([
263
+ `${server.name} failed verification.`,
264
+ ...verification.issues.map((issue) => `- ${issue.severity}: ${issue.code}: ${issue.message}`),
265
+ ].join("\n"));
266
+ }
267
+ capabilityManifest = verification.capabilityManifest;
268
+ }
269
+ const plan = buildInstallPlan(server, client, { capabilityManifest, verificationReport: verification, scope });
270
+ if (options.enforcePolicy !== false) {
271
+ const report = await enforcePolicy(plan, options.policyPath ?? ".toolpin/policy.json");
272
+ if (!report.ok) {
273
+ throw new Error([
274
+ `Lifecycle action refused by policy ${options.policyPath ?? ".toolpin/policy.json"}.`,
275
+ ...report.issues.map((issue) => `- ${issue.code}: ${issue.message}`),
276
+ ].join("\n"));
277
+ }
278
+ }
279
+ return { plan, verification };
280
+ }
281
+ async function getInstalledConfig(serverName, client, scope) {
282
+ let target;
283
+ try {
284
+ target = resolveConfigTarget(client, scope);
285
+ }
286
+ catch (error) {
287
+ throw new Error(`Unsupported ${client} ${scope} target: ${error instanceof Error ? error.message : String(error)}`);
288
+ }
289
+ const installedConfig = await readInstalledServerConfig(target.file, serverName, client);
290
+ if (installedConfig.kind === "missing") {
291
+ throw new Error(`Installed config entry ${serverName} is missing from ${target.file}.`);
292
+ }
293
+ if (installedConfig.kind === "unreadable") {
294
+ throw new Error(installedConfig.message);
295
+ }
296
+ return { file: target.file, config: installedConfig.config };
297
+ }
298
+ function mutationPlan(row, server, action, lockfilePath) {
299
+ return [
300
+ `${action} ${row.serverName} (${row.client}/${row.scope})`,
301
+ row.serverName !== server.name ? `remove installed alias ${row.serverName}` : `keep installed name ${row.serverName}`,
302
+ `write registry target ${server.name}@${server.version}`,
303
+ `write lockfile entry ${lockKey(server.name, row.client)} to ${lockfilePath}`,
304
+ ];
305
+ }
306
+ function resolveInstalledRegistryMatch(servers, installedName, currentVersion) {
307
+ const candidates = matchingServers(servers, installedName);
308
+ if (!candidates.length)
309
+ return {};
310
+ const exact = candidates.filter((server) => server.name === installedName);
311
+ const pool = exact.length ? exact : candidates;
312
+ const names = [...new Set(pool.map((server) => server.name))].sort();
313
+ if (!exact.length && names.length > 1) {
314
+ return { ambiguousCandidates: names };
315
+ }
316
+ const updateServer = bestServer(pool);
317
+ const currentServer = currentVersion
318
+ ? pool.find((server) => server.version === currentVersion) ?? updateServer
319
+ : updateServer;
320
+ if (!updateServer)
321
+ return {};
322
+ return {
323
+ registryMatch: updateServer.name === installedName ? "exact" : "alias",
324
+ latestVersion: updateServer.version,
325
+ currentServer,
326
+ updateServer,
327
+ };
328
+ }
329
+ function resolveInstalledRegistryVersion(servers, row, version) {
330
+ const pool = matchingServers(servers, row.serverName)
331
+ .filter((server) => !row.updateServer || server.name === row.updateServer.name);
332
+ const server = pool.find((candidate) => candidate.version === version);
333
+ if (!server) {
334
+ const known = pool.map((candidate) => candidate.version).filter(Boolean).join(", ");
335
+ throw new Error(`No installable registry version ${version} found for ${row.serverName}.${known ? ` Known versions: ${known}.` : ""}`);
336
+ }
337
+ return server;
338
+ }
339
+ function findLockedPlan(lockfile, serverName, client) {
340
+ const keyed = lockfile?.servers[lockKey(serverName, client)];
341
+ const legacy = lockfile?.servers[serverName];
342
+ return keyed ?? (legacy?.client === client ? legacy : undefined);
343
+ }
344
+ function bestServer(servers) {
345
+ return servers.reduce((best, candidate) => {
346
+ if (!best)
347
+ return candidate;
348
+ if (candidate.isLatest && !best.isLatest)
349
+ return candidate;
350
+ if (candidate.isLatest === best.isLatest && compareVersionish(candidate.version, best.version) > 0)
351
+ return candidate;
352
+ return best;
353
+ }, undefined);
354
+ }
355
+ function matchingServers(servers, installedName) {
356
+ const normalized = normalizeName(installedName);
357
+ return servers
358
+ .filter((server) => server.installable && serverAliases(server).has(normalized))
359
+ .sort((left, right) => matchWeight(left, installedName) - matchWeight(right, installedName)
360
+ || right.isLatest.toString().localeCompare(left.isLatest.toString())
361
+ || compareVersionish(right.version, left.version)
362
+ || left.name.localeCompare(right.name));
363
+ }
364
+ function matchWeight(server, installedName) {
365
+ if (server.name === installedName)
366
+ return 0;
367
+ const normalized = normalizeName(installedName);
368
+ if (normalizeName(server.name.split("/").pop() ?? "") === normalized)
369
+ return 1;
370
+ if (normalizeName(server.title) === normalized)
371
+ return 2;
372
+ return 3;
373
+ }
374
+ function serverAliases(server) {
375
+ const aliases = new Set();
376
+ addAlias(aliases, server.name);
377
+ addAlias(aliases, server.name.split("/").pop());
378
+ addAlias(aliases, server.title);
379
+ addAlias(aliases, server.repositoryUrl?.split("/").filter(Boolean).pop());
380
+ for (const pkg of server.raw.packages ?? []) {
381
+ addAlias(aliases, pkg.identifier);
382
+ addAlias(aliases, pkg.identifier.split("/").pop());
383
+ }
384
+ return aliases;
385
+ }
386
+ function addAlias(aliases, value) {
387
+ const normalized = normalizeName(value);
388
+ if (!normalized)
389
+ return;
390
+ aliases.add(normalized);
391
+ for (const prefix of ["server-", "mcp-", "mcp-server-", "@modelcontextprotocol/server-"]) {
392
+ if (normalized.startsWith(prefix))
393
+ aliases.add(normalized.slice(prefix.length));
394
+ }
395
+ }
396
+ function normalizeName(value) {
397
+ return (value ?? "")
398
+ .toLowerCase()
399
+ .replace(/^@/, "")
400
+ .replace(/[^a-z0-9]+/g, "-")
401
+ .replace(/^-+|-+$/g, "");
402
+ }
403
+ function issueMatchesInstalled(issue, serverName, client, scope) {
404
+ return issue.serverName === serverName && issue.client === client && (!issue.scope || issue.scope === scope);
405
+ }
@@ -0,0 +1,14 @@
1
+ const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/i;
2
+ const OCI_SHA256_DIGEST_PATTERN = /@sha256:([a-f0-9]{64})$/i;
3
+ export function ociDigestPin(identifier) {
4
+ return OCI_SHA256_DIGEST_PATTERN.exec(identifier)?.[1];
5
+ }
6
+ export function hasValidOciDigestPin(identifier) {
7
+ return ociDigestPin(identifier) !== undefined;
8
+ }
9
+ export function hasOciDigestMarker(identifier) {
10
+ return identifier.includes("@sha256:");
11
+ }
12
+ export function isValidSha256Hex(value) {
13
+ return typeof value === "string" && SHA256_HEX_PATTERN.test(value);
14
+ }
@@ -0,0 +1,169 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse as parseYaml } from "yaml";
3
+ import { clientsForScope, clientConfigRootKey } from "./config.js";
4
+ import { resolveConfigTarget } from "./install.js";
5
+ export async function listInstalledServers(options = {}) {
6
+ const scope = options.scope ?? "all";
7
+ const client = options.client ?? "all";
8
+ const entries = [];
9
+ const issues = [];
10
+ let checked = 0;
11
+ for (const targetScope of scopesToList(scope)) {
12
+ const clients = client === "all" ? clientsForScope(targetScope) : [client];
13
+ for (const targetClient of clients) {
14
+ let target;
15
+ try {
16
+ target = resolveConfigTarget(targetClient, targetScope);
17
+ }
18
+ catch (error) {
19
+ issues.push({
20
+ client: targetClient,
21
+ scope: targetScope,
22
+ kind: "invalid_scope",
23
+ message: error instanceof Error ? error.message : String(error),
24
+ });
25
+ continue;
26
+ }
27
+ checked += 1;
28
+ const names = await readInstalledServerNames(target.file, targetClient);
29
+ if (names.kind === "unreadable") {
30
+ issues.push({
31
+ client: targetClient,
32
+ scope: targetScope,
33
+ file: target.file,
34
+ kind: "unreadable",
35
+ message: names.message,
36
+ });
37
+ continue;
38
+ }
39
+ for (const serverName of names.serverNames) {
40
+ entries.push({ client: targetClient, scope: targetScope, file: target.file, serverName });
41
+ }
42
+ }
43
+ }
44
+ entries.sort((left, right) => left.scope.localeCompare(right.scope)
45
+ || left.client.localeCompare(right.client)
46
+ || left.serverName.localeCompare(right.serverName)
47
+ || left.file.localeCompare(right.file));
48
+ return {
49
+ ok: issues.length === 0,
50
+ checked,
51
+ entries,
52
+ issues,
53
+ };
54
+ }
55
+ async function readInstalledServerNames(file, client) {
56
+ let raw;
57
+ try {
58
+ raw = await readFile(file, "utf8");
59
+ }
60
+ catch (error) {
61
+ if (error.code === "ENOENT")
62
+ return { kind: "ok", serverNames: [] };
63
+ return { kind: "unreadable", message: error instanceof Error ? error.message : String(error) };
64
+ }
65
+ try {
66
+ if (client === "codex")
67
+ return { kind: "ok", serverNames: listCodexServerNames(raw) };
68
+ if (client === "continue")
69
+ return { kind: "ok", serverNames: listContinueServerNames(raw) };
70
+ const parsed = parseClientJsonConfig(raw, client);
71
+ const section = asRecord(parsed)[clientConfigRootKey(client)];
72
+ return { kind: "ok", serverNames: Object.keys(asRecord(section)).sort() };
73
+ }
74
+ catch (error) {
75
+ return {
76
+ kind: "unreadable",
77
+ message: error instanceof Error ? error.message : String(error),
78
+ };
79
+ }
80
+ }
81
+ function parseClientJsonConfig(raw, client) {
82
+ if (!raw.trim()) {
83
+ throw new Error(`${client} MCP config is empty; expected JSON with a "${clientConfigRootKey(client)}" object.`);
84
+ }
85
+ try {
86
+ return JSON.parse(raw);
87
+ }
88
+ catch (error) {
89
+ const detail = error instanceof Error ? error.message : String(error);
90
+ throw new Error(`${client} MCP config is invalid JSON; expected a JSON object with "${clientConfigRootKey(client)}". Parser detail: ${detail}`);
91
+ }
92
+ }
93
+ function listCodexServerNames(raw) {
94
+ const names = new Set();
95
+ for (const line of raw.split(/\r?\n/)) {
96
+ const trimmed = line.trim();
97
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
98
+ continue;
99
+ const path = parseTomlPath(trimmed.slice(1, -1).trim());
100
+ if (path[0] === "mcp_servers" && path[1])
101
+ names.add(path[1]);
102
+ }
103
+ return [...names].sort();
104
+ }
105
+ function listContinueServerNames(raw) {
106
+ if (!raw.trim())
107
+ return [];
108
+ const parsed = parseYaml(raw);
109
+ const servers = asRecord(parsed).mcpServers;
110
+ if (!Array.isArray(servers))
111
+ return [];
112
+ return servers
113
+ .map((server) => asRecord(server).name)
114
+ .filter((name) => typeof name === "string" && name.length > 0)
115
+ .sort();
116
+ }
117
+ function scopesToList(scope) {
118
+ return scope === "all" ? ["project", "global"] : [scope];
119
+ }
120
+ function parseTomlPath(value) {
121
+ const keys = [];
122
+ let current = "";
123
+ let quoted = false;
124
+ let escaped = false;
125
+ for (const char of value) {
126
+ if (quoted) {
127
+ current += char;
128
+ if (escaped) {
129
+ escaped = false;
130
+ }
131
+ else if (char === "\\") {
132
+ escaped = true;
133
+ }
134
+ else if (char === '"') {
135
+ quoted = false;
136
+ }
137
+ continue;
138
+ }
139
+ if (char === ".") {
140
+ keys.push(parseTomlKey(current.trim()) ?? current.trim());
141
+ current = "";
142
+ }
143
+ else {
144
+ current += char;
145
+ if (char === '"')
146
+ quoted = true;
147
+ }
148
+ }
149
+ if (current.trim())
150
+ keys.push(parseTomlKey(current.trim()) ?? current.trim());
151
+ return keys;
152
+ }
153
+ function parseTomlKey(value) {
154
+ if (!value)
155
+ return undefined;
156
+ if (value.startsWith('"') && value.endsWith('"')) {
157
+ try {
158
+ const parsed = JSON.parse(value);
159
+ return typeof parsed === "string" ? parsed : undefined;
160
+ }
161
+ catch {
162
+ return undefined;
163
+ }
164
+ }
165
+ return value;
166
+ }
167
+ function asRecord(value) {
168
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value) ? value : {};
169
+ }