@piagent/platform 1.2.0 → 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.
- package/CHANGELOG.md +133 -1
- package/README.md +1 -1
- package/docs/command-reference-vietnamese.md +8 -3
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +49 -3
- package/docs/operator-manual-vietnamese.md +20 -8
- package/docs/publishing-for-teams.md +1 -0
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +4 -3
- package/docs/team-onboarding.md +3 -3
- package/docs/usage-observability.md +29 -0
- package/docs-site/architecture.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/mcp-approval.html +54 -2
- package/docs-site/content/quickstart.html +1 -1
- package/docs-site/index.html +1 -1
- package/docs-site/mcp-approval.html +55 -3
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +2 -2
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/package.json +1 -1
- package/packages/piagent-core/capabilities/capability-core.js +41 -4
- package/packages/piagent-core/capabilities/capability-sources.js +73 -12
- package/packages/piagent-core/extensions/piagent-guard.ts +139 -25
- package/packages/piagent-core/extensions/policy-core.js +1586 -98
- package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
- package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
- package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
- package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
- package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
- package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
- package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
- package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
- package/packages/piagent-core/package.json +1 -1
- package/scripts/configure-model-scope.sh +19 -3
- package/scripts/configure-subagents.sh +17 -4
- package/scripts/init-project.sh +15 -8
- package/scripts/mcp-manage.mjs +111 -22
- package/scripts/pi-model-catalog.sh +11 -1
- package/scripts/pi-session-stats.sh +27 -1
- package/scripts/pi-usage-history.mjs +567 -0
- package/scripts/quality-benchmark.sh +37 -9
- package/scripts/setup.sh +21 -13
- package/scripts/uninstall-global.sh +10 -4
- package/scripts/verify-local.sh +19 -0
- 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
|
-
|
|
75
|
-
|
|
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
|
-
*
|
|
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))
|
|
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
|
+
}
|