@piagent/platform 1.2.0 → 1.2.2

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 (64) hide show
  1. package/CHANGELOG.md +149 -1
  2. package/README.md +1 -1
  3. package/catalog/capabilities.json +4 -4
  4. package/docs/command-reference-vietnamese.md +15 -4
  5. package/docs/context-window-policy.md +1 -0
  6. package/docs/distribution-standard.md +7 -7
  7. package/docs/mcp-and-tools.md +49 -3
  8. package/docs/operator-manual-vietnamese.md +52 -11
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/quickstart-vietnamese.md +4 -2
  11. package/docs/release-install-policy.md +6 -6
  12. package/docs/security-threat-model.md +4 -3
  13. package/docs/team-onboarding.md +3 -3
  14. package/docs/usage-observability.md +49 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/mcp-approval.html +54 -2
  18. package/docs-site/content/quickstart.html +1 -1
  19. package/docs-site/index.html +1 -1
  20. package/docs-site/mcp-approval.html +55 -3
  21. package/docs-site/mcp-auth.html +1 -1
  22. package/docs-site/mcp-servers.html +1 -1
  23. package/docs-site/mcp.html +1 -1
  24. package/docs-site/permissions.html +1 -1
  25. package/docs-site/profiles.html +1 -1
  26. package/docs-site/quickstart.html +2 -2
  27. package/docs-site/runtime.html +1 -1
  28. package/docs-site/scope.html +1 -1
  29. package/docs-site/security.html +1 -1
  30. package/docs-site/team.html +1 -1
  31. package/docs-site/workflows.html +1 -1
  32. package/package.json +1 -1
  33. package/packages/piagent-core/README.md +2 -0
  34. package/packages/piagent-core/capabilities/capability-core.js +41 -4
  35. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  36. package/packages/piagent-core/extensions/piagent-guard.ts +539 -70
  37. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  38. package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
  39. package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
  40. package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
  41. package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
  42. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  43. package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
  44. package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
  45. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/prompts/be-to-fe.md +1 -0
  48. package/packages/piagent-core/prompts/piagent-commands.md +1 -0
  49. package/packages/piagent-core/prompts/platform-improve.md +1 -0
  50. package/packages/piagent-core/prompts/review.md +1 -0
  51. package/packages/piagent-core/prompts/scout.md +1 -0
  52. package/packages/piagent-core/prompts/task.md +1 -0
  53. package/scripts/configure-model-scope.sh +19 -3
  54. package/scripts/configure-subagents.sh +17 -4
  55. package/scripts/init-project.sh +15 -8
  56. package/scripts/mcp-manage.mjs +111 -22
  57. package/scripts/pi-model-catalog.sh +11 -1
  58. package/scripts/pi-session-stats.sh +27 -1
  59. package/scripts/pi-usage-history.mjs +567 -0
  60. package/scripts/quality-benchmark.sh +37 -9
  61. package/scripts/setup.sh +21 -13
  62. package/scripts/uninstall-global.sh +10 -4
  63. package/scripts/verify-local.sh +19 -0
  64. package/scripts/verify-release-identity.mjs +29 -0
@@ -1,5 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import {
4
+ REPOSITORY_RELATIVE_KINDS,
5
+ canEnumerateImportKind,
6
+ declaredImports,
7
+ importKindFiles,
8
+ importedServers,
9
+ unreadableImportTargets
10
+ } from "./mcp-import-kinds.js";
11
+ import { erasesToolOrigin } from "./mcp-tool-naming.js";
3
12
 
4
13
  // Four files can define MCP servers, and which one a change belongs in is the
5
14
  // first thing anyone gets wrong. The scope names are the vocabulary the CLI
@@ -61,7 +70,7 @@ export function readMcpConfig(file) {
61
70
  raw = fs.readFileSync(file, "utf8");
62
71
  } catch (error) {
63
72
  if (error && typeof error === "object" && error.code === "ENOENT") {
64
- return { settings: {}, mcpServers: {}, rest: {} };
73
+ return { settings: {}, mcpServers: {}, imports: [], rawImports: [], rest: {} };
65
74
  }
66
75
  throw new McpConfigError(`cannot read MCP config: ${file}`);
67
76
  }
@@ -71,11 +80,24 @@ export function readMcpConfig(file) {
71
80
  } catch (error) {
72
81
  throw new McpConfigError(`cannot parse MCP config: ${file}: ${error instanceof Error ? error.message : String(error)}`);
73
82
  }
74
- if (!isRecord(document)) return { settings: {}, mcpServers: {}, rest: {} };
75
- const { settings, mcpServers, ...rest } = document;
83
+ // A JSON array, string or number parses fine and is not a config. Reading it
84
+ // as empty meant a later write replaced somebody's file with `{}` plus whatever
85
+ // the write added, so the document that could not be understood was destroyed
86
+ // rather than reported.
87
+ if (!isRecord(document)) {
88
+ throw new McpConfigError(`MCP config is not a JSON object: ${file}`);
89
+ }
90
+ const { settings, mcpServers, imports, ...rest } = document;
76
91
  return {
77
92
  settings: isRecord(settings) ? settings : {},
78
93
  mcpServers: isRecord(mcpServers) ? mcpServers : {},
94
+ // Pulled out of `rest` deliberately. While it rode along in the spread it
95
+ // was preserved by every write and read by nothing here, which is the exact
96
+ // shape of a key that decides what loads without anything checking it.
97
+ imports: declaredImports(imports),
98
+ // The list exactly as written, so a write can put back kinds this version
99
+ // does not recognise. Nothing reads this to decide behaviour.
100
+ rawImports: Array.isArray(imports) ? imports.filter((kind) => typeof kind === "string") : [],
79
101
  rest
80
102
  };
81
103
  }
@@ -86,13 +108,35 @@ export function readMcpConfig(file) {
86
108
  * @returns {void}
87
109
  */
88
110
  export function writeMcpConfig(file, config) {
111
+ // `declaredImports` keeps only the kinds this version knows. Writing that back
112
+ // deleted any kind a newer adapter understands and this one does not, turning
113
+ // an unrelated `add` into a silent downgrade of somebody's config. The raw list
114
+ // is preserved and the known kinds are what the rest of this module reads.
115
+ const raw = Array.isArray(config.rawImports) ? config.rawImports : declaredImports(config.imports);
89
116
  const document = {
90
117
  ...(config.rest ?? {}),
118
+ // Written back rather than dropped. This key is refused at the gate when a
119
+ // repository carries it, and deleting a line out of somebody's own config
120
+ // as a side effect of an unrelated `add` is not a fix.
121
+ ...(raw.length > 0 ? { imports: raw } : {}),
91
122
  settings: { ...BASELINE_SETTINGS, ...config.settings },
92
123
  mcpServers: config.mcpServers
93
124
  };
94
125
  const output = `${JSON.stringify(document, null, 2)}\n`;
95
126
  fs.mkdirSync(path.dirname(file), { recursive: true });
127
+ // A symlink here points the write somewhere the caller did not name. Renaming
128
+ // over it replaces the link itself, so the file the operator has been editing
129
+ // stops being the file this reads -- and following it would write through a
130
+ // link a repository could have planted.
131
+ let existing;
132
+ try {
133
+ existing = fs.lstatSync(file);
134
+ } catch (error) {
135
+ if (!error || typeof error !== "object" || error.code !== "ENOENT") throw error;
136
+ }
137
+ if (existing?.isSymbolicLink()) {
138
+ throw new McpConfigError(`refusing to write through a symlink: ${file}`);
139
+ }
96
140
  // Written beside the target and renamed, so a crash mid-write leaves the old
97
141
  // config rather than a truncated one that would read as "no servers".
98
142
  const temporary = `${file}.${process.pid}.tmp`;
@@ -106,10 +150,30 @@ export function writeMcpConfig(file, config) {
106
150
  * because which definition a client picks is the client's rule, not ours, and
107
151
  * hiding the duplicate is how a project-scoped override goes unnoticed.
108
152
  * @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string, scopes?: string[]}} [options]
109
- * @returns {{name: string, scope: string, file: string, entry: Record<string, unknown>}[]}
153
+ * `origin` is the scope name for a server the layer declares directly, and
154
+ * `import:<kind>` for one reached through an `imports` key, so a caller can say
155
+ * where a server came from rather than only which layer named it.
156
+ *
157
+ * `requiresApproval` says whether this server is one a repository put into the
158
+ * session. Scope alone cannot answer that: a `global` config importing a
159
+ * repository-relative kind carries a scope of `global` and a definition that
160
+ * came out of the clone. Deciding it here keeps the gate and the listing on one
161
+ * rule — the listing reporting `ready` for a server the gate is blocking is
162
+ * worse than either answer alone, because it sends the operator looking
163
+ * anywhere but at the approval.
164
+ *
165
+ * @returns {{name: string, scope: string, file: string, entry: Record<string, unknown>, origin: string, requiresApproval: boolean}[]}
110
166
  */
111
167
  export function collectServers(options = {}) {
112
168
  const found = [];
169
+ const seen = new Set();
170
+ const push = (server) => {
171
+ const key = `${server.name}\u0000${server.file}`;
172
+ if (seen.has(key)) return;
173
+ seen.add(key);
174
+ found.push(server);
175
+ };
176
+
113
177
  for (const scope of options.scopes ?? SCOPES) {
114
178
  const file = configPathForScope(scope, options);
115
179
  let config;
@@ -117,16 +181,285 @@ export function collectServers(options = {}) {
117
181
  config = readMcpConfig(file);
118
182
  } catch {
119
183
  // A layer this process cannot parse is reported as empty rather than
120
- // failing the whole listing; `piagent-mcp get` on that scope still throws.
184
+ // failing the whole listing; `piagent-mcp get` on that scope still throws
185
+ // and `piagent-mcp doctor` reports it as unreadable.
121
186
  continue;
122
187
  }
123
188
  for (const [name, entry] of Object.entries(config.mcpServers)) {
124
- if (isRecord(entry)) found.push({ name, scope, file, entry });
189
+ if (isRecord(entry)) push({ name, scope, file, entry, origin: scope, requiresApproval: REPOSITORY_SCOPES.has(scope) });
190
+ }
191
+ }
192
+
193
+ // Imports of a repository-relative kind are scanned across every layer, not
194
+ // just the ones the caller asked for. Which file declares the import decides
195
+ // nothing: what makes the servers repository-scoped is that the file they are
196
+ // read from travels with the clone. A `global` config importing `vscode`
197
+ // therefore puts a repository's servers into every session on this machine,
198
+ // and that has to reach the gate the same way `.mcp.json` does.
199
+ //
200
+ // The other direction matters too: whichever kind a repository-scoped config
201
+ // names, the repository is choosing which servers a session runs. Those are
202
+ // the operator's own definitions, so the question is not whether to trust the
203
+ // command but whether this project was meant to start it — which is the same
204
+ // question the gate already asks.
205
+ if (options.includeRepositoryImports !== false) {
206
+ for (const scope of SCOPES) {
207
+ let config;
208
+ try {
209
+ config = readMcpConfig(configPathForScope(scope, options));
210
+ } catch {
211
+ continue;
212
+ }
213
+ const repositoryScoped = REPOSITORY_SCOPES.has(scope);
214
+ for (const kind of config.imports) {
215
+ if (!repositoryScoped && !REPOSITORY_RELATIVE_KINDS.has(kind)) continue;
216
+ for (const server of importedServers(kind, options)) {
217
+ // Both branches that reach here are the repository choosing a
218
+ // server: it either declared the import itself, or the file being
219
+ // read travels with the clone.
220
+ push({ name: server.name, scope, file: server.file, entry: server.entry, origin: `import:${kind}`, requiresApproval: true });
221
+ }
222
+ }
125
223
  }
126
224
  }
225
+
127
226
  return found;
128
227
  }
129
228
 
229
+ /**
230
+ * The definition a same-named server actually runs under.
231
+ *
232
+ * The adapter merges server maps key by key in scope order, so a later layer
233
+ * that names an existing server overrides only the keys it sets. A repository
234
+ * declaring `{"args": ["--evil"]}` therefore inherits `command` from the
235
+ * operator's own global config, and reading that layer alone shows an argument
236
+ * list with no command attached — which is not what will run, and not what an
237
+ * operator should be asked to consent to.
238
+ *
239
+ * The digest deliberately stays over the repository's own fragment: that is the
240
+ * part the repository controls, and hashing the merge would return a server to
241
+ * pending every time the operator edited their own global config.
242
+ *
243
+ * @param {{projectPath?: string, name: string, entry?: Record<string, unknown>, env?: Record<string, string|undefined>, home?: string}} options
244
+ * @returns {Record<string, unknown>}
245
+ */
246
+ export function mergedServerEntry(options) {
247
+ let merged = {};
248
+ let seen = false;
249
+ for (const scope of SCOPES) {
250
+ let config;
251
+ try {
252
+ config = readMcpConfig(configPathForScope(scope, options));
253
+ } catch {
254
+ continue;
255
+ }
256
+ const entry = config.mcpServers[options.name];
257
+ if (!isRecord(entry)) continue;
258
+ merged = { ...merged, ...entry };
259
+ seen = true;
260
+ }
261
+ // A server reached through `imports` is in no layer's `mcpServers`, so the
262
+ // entry the caller already has is the whole definition.
263
+ return seen ? merged : { ...(options.entry ?? {}) };
264
+ }
265
+
266
+ /**
267
+ * Layers that exist but cannot be read. `collectServers` skips these so one bad
268
+ * file does not take out the whole listing, which is the right call for a
269
+ * listing and the wrong one for a diagnosis: a config nothing can parse
270
+ * contributes no servers and is indistinguishable from a config that defines
271
+ * none. Somewhere has to say which of the two it was.
272
+ *
273
+ * @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string, scopes?: string[]}} [options]
274
+ * @returns {{scope: string, file: string, detail: string}[]}
275
+ */
276
+ export function unreadableLayers(options = {}) {
277
+ const problems = [];
278
+ for (const scope of options.scopes ?? SCOPES) {
279
+ const file = configPathForScope(scope, options);
280
+ let config;
281
+ try {
282
+ config = readMcpConfig(file);
283
+ } catch (error) {
284
+ problems.push({ scope, file, detail: error instanceof Error ? error.message : String(error) });
285
+ continue;
286
+ }
287
+ // A layer can also be unreadable one step removed: it parses, declares an
288
+ // import, and the file that import names is the one nothing can read. That
289
+ // layer contributes no servers for a reason the layer itself does not show,
290
+ // so the diagnosis has to follow the declaration to where it points.
291
+ for (const kind of config.imports) {
292
+ for (const target of unreadableImportTargets(kind, options)) {
293
+ problems.push({
294
+ scope,
295
+ file: target.file,
296
+ detail: `${file} imports ${kind}, and that config is present but unreadable: ${target.detail}`
297
+ });
298
+ }
299
+ }
300
+ }
301
+ return problems;
302
+ }
303
+
304
+ /**
305
+ * Repository-scoped layers that declare `imports`. A repository reaching into
306
+ * another tool's config on this machine is refused on the declaration rather
307
+ * than on its contents: one of the six kinds is TOML that nothing here parses,
308
+ * so an enumeration would be incomplete, and a repository has no business
309
+ * deciding which of the operator's personal servers a session loads regardless.
310
+ *
311
+ * @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
312
+ * @returns {{scope: string, file: string, kinds: string[]}[]}
313
+ */
314
+ export function repositoryImportDeclarations(options = {}) {
315
+ const declarations = [];
316
+ for (const scope of REPOSITORY_SCOPES) {
317
+ const file = configPathForScope(scope, options);
318
+ let config;
319
+ try {
320
+ config = readMcpConfig(file);
321
+ } catch {
322
+ continue;
323
+ }
324
+ const kinds = declaredImports(config.imports);
325
+ if (kinds.length > 0) declarations.push({ scope, file, kinds });
326
+ }
327
+ return declarations;
328
+ }
329
+
330
+ /**
331
+ * The `settings` block a session actually runs under.
332
+ *
333
+ * The adapter merges `settings` across all four layers in scope order, later
334
+ * layers winning key by key — it is one session-wide block, not a per-server
335
+ * one. Reading a single layer therefore answers a different question than the
336
+ * one that matters: `directTools` set in one file and `toolPrefix: "none"` set
337
+ * in another produce a session with neither property, and a check that looked at
338
+ * either file alone saw nothing wrong with it.
339
+ *
340
+ * @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
341
+ * @returns {{settings: Record<string, unknown>, contributors: Record<string, string>}}
342
+ */
343
+ export function mergedSettings(options = {}) {
344
+ const settings = { ...BASELINE_SETTINGS };
345
+ /** @type {Record<string, string>} */
346
+ const contributors = {};
347
+ for (const scope of SCOPES) {
348
+ let config;
349
+ try {
350
+ config = readMcpConfig(configPathForScope(scope, options));
351
+ } catch {
352
+ continue;
353
+ }
354
+ for (const [key, item] of Object.entries(config.settings)) {
355
+ settings[key] = item;
356
+ contributors[key] = scope;
357
+ }
358
+ }
359
+ return { settings, contributors };
360
+ }
361
+
362
+ /**
363
+ * Every file any decision in this module can depend on, including the ones that
364
+ * matter by being absent.
365
+ *
366
+ * The guard caches its gate and recomputes when a signature over these changes.
367
+ * That signature used to be built by hand from the repository layers, and when
368
+ * the scan below grew to read merged settings from all four scopes and to stat
369
+ * import targets outside the repository, the signature did not grow with it —
370
+ * so adding `directTools` to a personal global config left an already-loaded
371
+ * guard permitting calls that `piagent-mcp doctor` was reporting as blocked.
372
+ *
373
+ * Derived from the same traversal the readers use, so a reader that starts
374
+ * consulting a new file is watched for that file without anything else being
375
+ * updated to match. An absent file is listed too: `imports: ["codex"]` becomes
376
+ * a block the moment `~/.codex/config.toml` appears.
377
+ *
378
+ * @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
379
+ * @returns {string[]}
380
+ */
381
+ export function mcpDecisionInputs(options = {}) {
382
+ const inputs = [];
383
+ for (const scope of SCOPES) {
384
+ const file = configPathForScope(scope, options);
385
+ inputs.push(file);
386
+ let config;
387
+ try {
388
+ config = readMcpConfig(file);
389
+ } catch {
390
+ // Unreadable still counts: the file is watched, and becoming readable is
391
+ // a change the gate has to see.
392
+ continue;
393
+ }
394
+ // Every location the kind can be read from, whether or not it has one now.
395
+ // A kind that resolves to nothing today is exactly the kind whose file
396
+ // appearing tomorrow changes the answer.
397
+ for (const kind of config.imports) inputs.push(...importKindFiles(kind, options));
398
+ }
399
+ return [...new Set(inputs)];
400
+ }
401
+
402
+ /**
403
+ * Config that leaves the gate with nothing to check, so the only sound answer is
404
+ * to refuse every tool call.
405
+ *
406
+ * Kept here rather than in the guard because three surfaces have to agree on it.
407
+ * The guard enforces it; `doctor` and `list` are where an operator goes to find
408
+ * out why a session stopped working. When only the guard knew, `doctor` reported
409
+ * "No MCP servers configured" and exited 0 while nothing could run — an answer
410
+ * that sends the operator looking anywhere but at the config that caused it.
411
+ *
412
+ * @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
413
+ * @returns {{scope: string, file: string, detail: string}[]}
414
+ */
415
+ export function unverifiableMcpConfig(options = {}) {
416
+ const problems = [];
417
+ const merged = mergedSettings(options);
418
+ if (erasesToolOrigin(merged.settings)) {
419
+ // Which files to edit, not just that something is wrong: the two keys can be
420
+ // set in different layers, and naming only one of them sends the operator to
421
+ // a file where the other half is not.
422
+ const scopes = [...new Set(["directTools", "toolPrefix"].map((key) => merged.contributors[key]).filter(Boolean))];
423
+ const files = scopes.map((scope) => configPathForScope(scope, options));
424
+ problems.push({
425
+ scope: scopes.join(" + ") || "global",
426
+ file: files[0] ?? configPathForScope("global", options),
427
+ detail: `${files.join(" and ")} together turn on directTools with toolPrefix "none", which strips the ` +
428
+ "server name off every tool and leaves nothing to check an approval against. " +
429
+ "Remove that setting, then approve servers individually."
430
+ });
431
+ }
432
+
433
+ for (const scope of REPOSITORY_SCOPES) {
434
+ const file = configPathForScope(scope, options);
435
+ let config;
436
+ try {
437
+ config = readMcpConfig(file);
438
+ } catch {
439
+ // Unreadable is a different diagnosis with its own report, and nothing is
440
+ // assumed about the contents of a file nobody could read.
441
+ continue;
442
+ }
443
+ for (const kind of config.imports) {
444
+ if (canEnumerateImportKind(kind)) continue;
445
+ // An import of a kind nothing here can parse is only a hole when the file
446
+ // it names exists. Refusing on the declaration alone blocked every session
447
+ // whose repository listed a tool the operator does not even have installed,
448
+ // which is a config to clean up rather than a session to stop.
449
+ const present = importKindFiles(kind, options).filter((candidate) => fs.existsSync(candidate));
450
+ if (present.length === 0) continue;
451
+ problems.push({
452
+ scope,
453
+ file,
454
+ detail: `${file} imports servers from ${kind} config, which this platform cannot enumerate ` +
455
+ `(${present.join(", ")}), so which servers reach this session is unknown. ` +
456
+ "Remove the import and declare the servers you want directly."
457
+ });
458
+ }
459
+ }
460
+ return problems;
461
+ }
462
+
130
463
  /** @param {unknown} value @returns {value is Record<string, unknown>} */
131
464
  export function isRecord(value) {
132
465
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -0,0 +1,240 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ // Defined here rather than imported from the layer module, which imports this
6
+ // one: a cycle between the two would resolve, but only by accident of ordering.
7
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
8
+ function isRecord(value) {
9
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+
12
+ // The adapter accepts an `imports` key that pulls server definitions out of
13
+ // other coding tools' config files. Those definitions land in a session without
14
+ // ever appearing in any of the four scopes this platform manages, so anything
15
+ // the approval gate is meant to cover has to be resolved here too.
16
+ //
17
+ // Each kind is described by two independent sets of locations. One is where the
18
+ // adapter looks today; the other is where the tool actually keeps its config.
19
+ // They disagree for three of the six kinds, and the gate has to hold under both:
20
+ // reading only the adapter's location misses a definition the moment the adapter
21
+ // is corrected, and reading only the real location misses what loads right now.
22
+ // The union is the safe direction to be wrong in — a server listed here that
23
+ // never loads costs one approval prompt, a server that loads without being
24
+ // listed costs the gate.
25
+ //
26
+ // Only `vscode` resolves inside the project. That one distinction is what makes
27
+ // it dangerous: a cloned repository carries the file it points at, so the
28
+ // servers it defines travel with the clone exactly like `.mcp.json` does.
29
+
30
+ /**
31
+ * @typedef {{
32
+ * adapterPaths: (home: string, projectPath: string) => string[],
33
+ * actualPaths: (home: string, projectPath: string) => string[],
34
+ * serverKeys: string[],
35
+ * repositoryRelative: boolean,
36
+ * parsedAsJson: boolean
37
+ * }} ImportKind
38
+ */
39
+
40
+ /** @type {Record<string, ImportKind>} */
41
+ export const IMPORT_KINDS = {
42
+ cursor: {
43
+ adapterPaths: (home) => [path.join(home, ".cursor", "mcp.json")],
44
+ actualPaths: (home) => [path.join(home, ".cursor", "mcp.json")],
45
+ serverKeys: ["mcpServers", "mcp-servers"],
46
+ repositoryRelative: false,
47
+ parsedAsJson: true
48
+ },
49
+ "claude-code": {
50
+ adapterPaths: (home) => [
51
+ path.join(home, ".claude", "mcp.json"),
52
+ path.join(home, ".claude.json"),
53
+ path.join(home, ".claude", "claude_desktop_config.json")
54
+ ],
55
+ // Servers added at user scope sit at the top level of `~/.claude.json`;
56
+ // servers added at local scope are nested per project and are not read by
57
+ // either side, which is worth knowing but changes nothing here.
58
+ actualPaths: (home) => [path.join(home, ".claude.json")],
59
+ serverKeys: ["mcpServers"],
60
+ repositoryRelative: false,
61
+ parsedAsJson: true
62
+ },
63
+ "claude-desktop": {
64
+ adapterPaths: (home) => [
65
+ path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
66
+ ],
67
+ actualPaths: (home) => [
68
+ path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
69
+ ],
70
+ serverKeys: ["mcpServers"],
71
+ repositoryRelative: false,
72
+ parsedAsJson: true
73
+ },
74
+ codex: {
75
+ adapterPaths: (home) => [path.join(home, ".codex", "config.json")],
76
+ // The real file is TOML under a `mcp_servers` table, and nothing here parses
77
+ // TOML. Enumeration is therefore incomplete for this kind, which is what
78
+ // `canEnumerateImportKind` reports: a repository that declares it is refused
79
+ // on the declaration, because listing the servers that happen to be readable
80
+ // says nothing about the ones that are not.
81
+ actualPaths: (home) => [path.join(home, ".codex", "config.toml")],
82
+ serverKeys: ["mcpServers", "mcp_servers"],
83
+ repositoryRelative: false,
84
+ parsedAsJson: false
85
+ },
86
+ windsurf: {
87
+ adapterPaths: (home) => [path.join(home, ".windsurf", "mcp.json")],
88
+ actualPaths: (home) => [path.join(home, ".codeium", "windsurf", "mcp_config.json")],
89
+ serverKeys: ["mcpServers", "mcp-servers"],
90
+ repositoryRelative: false,
91
+ parsedAsJson: true
92
+ },
93
+ vscode: {
94
+ adapterPaths: (_home, projectPath) => [path.join(projectPath, ".vscode", "mcp.json")],
95
+ actualPaths: (_home, projectPath) => [path.join(projectPath, ".vscode", "mcp.json")],
96
+ // The adapter reads `mcpServers`; the documented format uses `servers`. Both
97
+ // are read so the gate covers a hand-written file either way.
98
+ serverKeys: ["mcpServers", "mcp-servers", "servers"],
99
+ repositoryRelative: true,
100
+ parsedAsJson: true
101
+ }
102
+ };
103
+
104
+ /** Import kinds whose file a repository carries, and which therefore need approval. */
105
+ export const REPOSITORY_RELATIVE_KINDS = new Set(
106
+ Object.entries(IMPORT_KINDS)
107
+ .filter(([, kind]) => kind.repositoryRelative)
108
+ .map(([name]) => name)
109
+ );
110
+
111
+ /**
112
+ * Whether every server this kind can contribute is visible to this platform.
113
+ *
114
+ * False means the answer from `importedServers` is a subset of unknown size, not
115
+ * a list. A caller deciding what to block needs to know the difference: blocking
116
+ * what came back would look like coverage while saying nothing about the rest.
117
+ *
118
+ * @param {string} kindName
119
+ * @returns {boolean}
120
+ */
121
+ export function canEnumerateImportKind(kindName) {
122
+ return IMPORT_KINDS[kindName]?.parsedAsJson === true;
123
+ }
124
+
125
+ /**
126
+ * The `imports` a config document declares, as a list of known kind names. An
127
+ * unknown kind is dropped rather than rejected: the adapter ignores it too, and
128
+ * a name this version does not recognise is not evidence of anything.
129
+ * @param {unknown} value
130
+ * @returns {string[]}
131
+ */
132
+ export function declaredImports(value) {
133
+ if (!Array.isArray(value)) return [];
134
+ const kinds = [];
135
+ for (const entry of value) {
136
+ if (typeof entry !== "string") continue;
137
+ const kind = entry.trim();
138
+ if (Object.hasOwn(IMPORT_KINDS, kind) && !kinds.includes(kind)) kinds.push(kind);
139
+ }
140
+ return kinds;
141
+ }
142
+
143
+ /**
144
+ * Every file an import kind can be read from, adapter location and real location
145
+ * alike. Exported because "does this kind actually resolve to anything on this
146
+ * machine" is a question two callers ask, and each answering it from its own
147
+ * copy of the path list is how the two answers drift apart.
148
+ *
149
+ * @param {string} kindName
150
+ * @param {{projectPath?: string, home?: string}} [options]
151
+ * @returns {string[]}
152
+ */
153
+ export function importKindFiles(kindName, options = {}) {
154
+ const kind = IMPORT_KINDS[kindName];
155
+ if (!kind) return [];
156
+ const home = options.home ?? os.homedir();
157
+ const projectPath = options.projectPath ?? process.cwd();
158
+ return [...new Set([...kind.adapterPaths(home, projectPath), ...kind.actualPaths(home, projectPath)])];
159
+ }
160
+
161
+ /**
162
+ * Every server an import kind can put into a session, from either location and
163
+ * under any of the keys that location might use. Files that are missing
164
+ * contribute nothing; a file that exists but cannot be parsed is reported by
165
+ * `unreadableImportTargets`, because "no servers" and "servers nobody could
166
+ * read" are the two answers an operator most needs told apart.
167
+ *
168
+ * @param {string} kindName
169
+ * @param {{projectPath?: string, home?: string}} [options]
170
+ * @returns {{name: string, file: string, entry: Record<string, unknown>}[]}
171
+ */
172
+ export function importedServers(kindName, options = {}) {
173
+ const kind = IMPORT_KINDS[kindName];
174
+ if (!kind) return [];
175
+ const files = importKindFiles(kindName, options);
176
+
177
+ const found = [];
178
+ const seen = new Set();
179
+ for (const file of files) {
180
+ if (!kind.parsedAsJson && !file.endsWith(".json")) continue;
181
+ let document;
182
+ try {
183
+ document = JSON.parse(fs.readFileSync(file, "utf8"));
184
+ } catch {
185
+ continue;
186
+ }
187
+ if (!isRecord(document)) continue;
188
+ for (const key of kind.serverKeys) {
189
+ const servers = document[key];
190
+ if (!isRecord(servers)) continue;
191
+ for (const [name, entry] of Object.entries(servers)) {
192
+ // A name defined under two keys of the same file is one server; a name
193
+ // defined in two files is reported once, keyed by the first file seen,
194
+ // because approving it twice would ask the same question twice.
195
+ const dedupe = `${name}\u0000${file}`;
196
+ if (seen.has(dedupe) || !isRecord(entry)) continue;
197
+ seen.add(dedupe);
198
+ found.push({ name, file, entry });
199
+ }
200
+ }
201
+ }
202
+ return found;
203
+ }
204
+
205
+ /**
206
+ * Import targets that exist but cannot be read into servers. `importedServers`
207
+ * skips these, which is right for a listing and wrong for a diagnosis: the
208
+ * caller cannot otherwise tell a tool that defines no servers from a tool whose
209
+ * config nothing could parse, and only one of those two is a gap in the gate.
210
+ *
211
+ * @param {string} kindName
212
+ * @param {{projectPath?: string, home?: string}} [options]
213
+ * @returns {{kind: string, file: string, detail: string}[]}
214
+ */
215
+ export function unreadableImportTargets(kindName, options = {}) {
216
+ const kind = IMPORT_KINDS[kindName];
217
+ if (!kind) return [];
218
+ const problems = [];
219
+ for (const file of importKindFiles(kindName, options)) {
220
+ if (!kind.parsedAsJson && !file.endsWith(".json")) continue;
221
+ let raw;
222
+ try {
223
+ raw = fs.readFileSync(file, "utf8");
224
+ } catch (error) {
225
+ // Absent is the normal case for five of six kinds and is not a problem.
226
+ if (error && typeof error === "object" && error.code === "ENOENT") continue;
227
+ problems.push({ kind: kindName, file, detail: `cannot read ${file}` });
228
+ continue;
229
+ }
230
+ let document;
231
+ try {
232
+ document = JSON.parse(raw);
233
+ } catch (error) {
234
+ problems.push({ kind: kindName, file, detail: `cannot parse ${file}: ${error instanceof Error ? error.message : String(error)}` });
235
+ continue;
236
+ }
237
+ if (!isRecord(document)) problems.push({ kind: kindName, file, detail: `${file} is not a JSON object` });
238
+ }
239
+ return problems;
240
+ }