@piagent/platform 1.1.9 → 1.2.1

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 (81) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/README.md +2 -2
  3. package/docs/command-reference-vietnamese.md +19 -5
  4. package/docs/distribution-standard.md +7 -7
  5. package/docs/mcp-and-tools.md +154 -2
  6. package/docs/model-options.md +10 -8
  7. package/docs/operator-manual-vietnamese.md +25 -13
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/quickstart-vietnamese.md +2 -2
  10. package/docs/release-install-policy.md +6 -6
  11. package/docs/security-threat-model.md +4 -3
  12. package/docs/team-onboarding.md +4 -4
  13. package/docs/usage-observability.md +29 -0
  14. package/docs/vercel-docs-site.md +34 -4
  15. package/docs-site/architecture.html +152 -0
  16. package/docs-site/assets/docs.css +1001 -0
  17. package/docs-site/assets/docs.js +84 -0
  18. package/docs-site/commands.html +203 -0
  19. package/docs-site/content/architecture.html +29 -0
  20. package/docs-site/content/commands.html +80 -0
  21. package/docs-site/content/index.html +80 -0
  22. package/docs-site/content/mcp-approval.html +133 -0
  23. package/docs-site/content/mcp-auth.html +76 -0
  24. package/docs-site/content/mcp-servers.html +111 -0
  25. package/docs-site/content/mcp.html +120 -0
  26. package/docs-site/content/permissions.html +54 -0
  27. package/docs-site/content/profiles.html +68 -0
  28. package/docs-site/content/quickstart.html +144 -0
  29. package/docs-site/content/runtime.html +45 -0
  30. package/docs-site/content/scope.html +71 -0
  31. package/docs-site/content/security.html +51 -0
  32. package/docs-site/content/team.html +23 -0
  33. package/docs-site/content/workflows.html +47 -0
  34. package/docs-site/index.html +131 -1534
  35. package/docs-site/mcp-approval.html +256 -0
  36. package/docs-site/mcp-auth.html +199 -0
  37. package/docs-site/mcp-servers.html +234 -0
  38. package/docs-site/mcp.html +243 -0
  39. package/docs-site/permissions.html +177 -0
  40. package/docs-site/profiles.html +191 -0
  41. package/docs-site/quickstart.html +267 -0
  42. package/docs-site/runtime.html +168 -0
  43. package/docs-site/scope.html +194 -0
  44. package/docs-site/security.html +174 -0
  45. package/docs-site/team.html +142 -0
  46. package/docs-site/workflows.html +170 -0
  47. package/package.json +4 -2
  48. package/packages/piagent-core/capabilities/capability-core.js +58 -5
  49. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  50. package/packages/piagent-core/extensions/document-intake.ts +5 -5
  51. package/packages/piagent-core/extensions/piagent-guard.ts +432 -2
  52. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  53. package/packages/piagent-core/mcp/mcp-approval-store.js +216 -0
  54. package/packages/piagent-core/mcp/mcp-auth-readiness.js +225 -0
  55. package/packages/piagent-core/mcp/mcp-command-actions.js +400 -0
  56. package/packages/piagent-core/mcp/mcp-config-layers.js +466 -0
  57. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  58. package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
  59. package/packages/piagent-core/mcp/mcp-server-entry.js +293 -0
  60. package/packages/piagent-core/mcp/mcp-session-view.js +236 -0
  61. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  62. package/packages/piagent-core/package.json +7 -3
  63. package/packages/piagent-core/prompts/model-options.md +8 -8
  64. package/scripts/build-docs-site.mjs +421 -0
  65. package/scripts/configure-model-scope.sh +21 -5
  66. package/scripts/configure-subagents.sh +18 -5
  67. package/scripts/init-project.sh +15 -8
  68. package/scripts/install-global.sh +5 -5
  69. package/scripts/mcp-manage.mjs +656 -0
  70. package/scripts/pi-model-catalog.sh +11 -1
  71. package/scripts/pi-session-stats.sh +27 -1
  72. package/scripts/pi-usage-history.mjs +567 -0
  73. package/scripts/piagent-cli.mjs +1 -1
  74. package/scripts/preview-docs-site.mjs +88 -0
  75. package/scripts/quality-benchmark.sh +37 -9
  76. package/scripts/setup.sh +23 -15
  77. package/scripts/team-doctor.sh +18 -2
  78. package/scripts/uninstall-global.sh +10 -4
  79. package/scripts/verify-local.sh +65 -19
  80. package/scripts/verify-release-identity.mjs +37 -2
  81. package/scripts/configure-mcp.sh +0 -349
@@ -0,0 +1,466 @@
1
+ import fs from "node:fs";
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";
12
+
13
+ // Four files can define MCP servers, and which one a change belongs in is the
14
+ // first thing anyone gets wrong. The scope names are the vocabulary the CLI
15
+ // exposes, so they are resolved in exactly one place.
16
+ //
17
+ // `global` and `pi-project` are the two that matter for safety. `global` is
18
+ // outside every repository, so nothing a repository contains can reach it.
19
+ // `project` is the file a repository can carry, which is why servers found there
20
+ // are the ones the approval gate covers.
21
+
22
+ const BASELINE_SETTINGS = {
23
+ toolPrefix: "server",
24
+ directTools: false,
25
+ idleTimeout: 10,
26
+ outputGuard: true
27
+ };
28
+
29
+ export const SCOPES = ["global", "pi-global", "project", "pi-project"];
30
+
31
+ /** Scopes whose file a repository can carry, and which therefore need approval. */
32
+ export const REPOSITORY_SCOPES = new Set(["project", "pi-project"]);
33
+
34
+ /**
35
+ * @param {string} scope
36
+ * @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
37
+ * @returns {string}
38
+ */
39
+ export function configPathForScope(scope, options = {}) {
40
+ const env = options.env ?? process.env;
41
+ const home = options.home ?? env.HOME ?? "";
42
+ const projectPath = options.projectPath ?? process.cwd();
43
+ switch (scope) {
44
+ case "global":
45
+ return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "mcp", "mcp.json");
46
+ case "pi-global":
47
+ return path.join(env.PI_CODING_AGENT_DIR || path.join(home, ".pi", "agent"), "mcp.json");
48
+ case "project":
49
+ return path.join(projectPath, ".mcp.json");
50
+ case "pi-project":
51
+ return path.join(projectPath, ".pi", "mcp.json");
52
+ default:
53
+ throw new McpConfigError(`unsupported scope: ${scope}`);
54
+ }
55
+ }
56
+
57
+ export class McpConfigError extends Error {}
58
+
59
+ /**
60
+ * Read a config file into the shape the rest of this module assumes. A file that
61
+ * is missing reads as empty; a file that is present but malformed throws, because
62
+ * silently treating it as empty would let a write drop servers somebody
63
+ * configured by hand.
64
+ * @param {string} file
65
+ * @returns {{settings: Record<string, unknown>, mcpServers: Record<string, unknown>, rest: Record<string, unknown>}}
66
+ */
67
+ export function readMcpConfig(file) {
68
+ let raw;
69
+ try {
70
+ raw = fs.readFileSync(file, "utf8");
71
+ } catch (error) {
72
+ if (error && typeof error === "object" && error.code === "ENOENT") {
73
+ return { settings: {}, mcpServers: {}, imports: [], rawImports: [], rest: {} };
74
+ }
75
+ throw new McpConfigError(`cannot read MCP config: ${file}`);
76
+ }
77
+ let document;
78
+ try {
79
+ document = JSON.parse(raw);
80
+ } catch (error) {
81
+ throw new McpConfigError(`cannot parse MCP config: ${file}: ${error instanceof Error ? error.message : String(error)}`);
82
+ }
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;
91
+ return {
92
+ settings: isRecord(settings) ? settings : {},
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") : [],
101
+ rest
102
+ };
103
+ }
104
+
105
+ /**
106
+ * @param {string} file
107
+ * @param {{settings: Record<string, unknown>, mcpServers: Record<string, unknown>, rest?: Record<string, unknown>}} config
108
+ * @returns {void}
109
+ */
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);
116
+ const document = {
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 } : {}),
122
+ settings: { ...BASELINE_SETTINGS, ...config.settings },
123
+ mcpServers: config.mcpServers
124
+ };
125
+ const output = `${JSON.stringify(document, null, 2)}\n`;
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
+ }
140
+ // Written beside the target and renamed, so a crash mid-write leaves the old
141
+ // config rather than a truncated one that would read as "no servers".
142
+ const temporary = `${file}.${process.pid}.tmp`;
143
+ fs.writeFileSync(temporary, output);
144
+ fs.renameSync(temporary, file);
145
+ }
146
+
147
+ /**
148
+ * Every server visible from a project, with the scope each came from. Later
149
+ * scopes do not merge into earlier ones: a name defined twice is reported twice,
150
+ * because which definition a client picks is the client's rule, not ours, and
151
+ * hiding the duplicate is how a project-scoped override goes unnoticed.
152
+ * @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string, scopes?: string[]}} [options]
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}[]}
166
+ */
167
+ export function collectServers(options = {}) {
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
+
177
+ for (const scope of options.scopes ?? SCOPES) {
178
+ const file = configPathForScope(scope, options);
179
+ let config;
180
+ try {
181
+ config = readMcpConfig(file);
182
+ } catch {
183
+ // A layer this process cannot parse is reported as empty rather than
184
+ // failing the whole listing; `piagent-mcp get` on that scope still throws
185
+ // and `piagent-mcp doctor` reports it as unreadable.
186
+ continue;
187
+ }
188
+ for (const [name, entry] of Object.entries(config.mcpServers)) {
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
+ }
223
+ }
224
+ }
225
+
226
+ return found;
227
+ }
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
+
463
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
464
+ export function isRecord(value) {
465
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
466
+ }