@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +133 -1
  2. package/README.md +1 -1
  3. package/docs/command-reference-vietnamese.md +8 -3
  4. package/docs/distribution-standard.md +7 -7
  5. package/docs/mcp-and-tools.md +49 -3
  6. package/docs/operator-manual-vietnamese.md +20 -8
  7. package/docs/publishing-for-teams.md +1 -0
  8. package/docs/release-install-policy.md +6 -6
  9. package/docs/security-threat-model.md +4 -3
  10. package/docs/team-onboarding.md +3 -3
  11. package/docs/usage-observability.md +29 -0
  12. package/docs-site/architecture.html +1 -1
  13. package/docs-site/commands.html +1 -1
  14. package/docs-site/content/mcp-approval.html +54 -2
  15. package/docs-site/content/quickstart.html +1 -1
  16. package/docs-site/index.html +1 -1
  17. package/docs-site/mcp-approval.html +55 -3
  18. package/docs-site/mcp-auth.html +1 -1
  19. package/docs-site/mcp-servers.html +1 -1
  20. package/docs-site/mcp.html +1 -1
  21. package/docs-site/permissions.html +1 -1
  22. package/docs-site/profiles.html +1 -1
  23. package/docs-site/quickstart.html +2 -2
  24. package/docs-site/runtime.html +1 -1
  25. package/docs-site/scope.html +1 -1
  26. package/docs-site/security.html +1 -1
  27. package/docs-site/team.html +1 -1
  28. package/docs-site/workflows.html +1 -1
  29. package/package.json +1 -1
  30. package/packages/piagent-core/capabilities/capability-core.js +41 -4
  31. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  32. package/packages/piagent-core/extensions/piagent-guard.ts +139 -25
  33. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  34. package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
  35. package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
  36. package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
  37. package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
  38. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  39. package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
  40. package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
  41. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  42. package/packages/piagent-core/package.json +1 -1
  43. package/scripts/configure-model-scope.sh +19 -3
  44. package/scripts/configure-subagents.sh +17 -4
  45. package/scripts/init-project.sh +15 -8
  46. package/scripts/mcp-manage.mjs +111 -22
  47. package/scripts/pi-model-catalog.sh +11 -1
  48. package/scripts/pi-session-stats.sh +27 -1
  49. package/scripts/pi-usage-history.mjs +567 -0
  50. package/scripts/quality-benchmark.sh +37 -9
  51. package/scripts/setup.sh +21 -13
  52. package/scripts/uninstall-global.sh +10 -4
  53. package/scripts/verify-local.sh +19 -0
  54. package/scripts/verify-release-identity.mjs +29 -0
@@ -202,6 +202,24 @@ export function parseRemoteUrl(value) {
202
202
  } catch {
203
203
  throw new McpEntryError(`--url is not a valid URL: ${value}`);
204
204
  }
205
+ // The same rule the rest of this file applies to `--env` and `--header`. An
206
+ // address is not a safer place to keep a token than a header is: it goes into
207
+ // the same committed file, and it additionally tends to reach request logs and
208
+ // shell history. The refusal is masked so the error itself does not repeat it.
209
+ if (url.username || url.password) {
210
+ throw new McpEntryError(
211
+ "--url must not carry credentials in the address. Use --bearer-token-env-var, " +
212
+ `or --header with a \${VAR} reference. Received: ${maskUrlCredentials(url.href)}`
213
+ );
214
+ }
215
+ for (const [name, item] of url.searchParams) {
216
+ if (credentialLikeName(name) && !ENV_REFERENCE.test(item)) {
217
+ throw new McpEntryError(
218
+ `--url query parameter ${name} looks like a credential. Use --bearer-token-env-var, ` +
219
+ `or give it a \${VAR} reference.`
220
+ );
221
+ }
222
+ }
205
223
  if (url.protocol === "https:") return url;
206
224
  if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url;
207
225
  throw new McpEntryError(
@@ -209,6 +227,35 @@ export function parseRemoteUrl(value) {
209
227
  );
210
228
  }
211
229
 
230
+ /**
231
+ * A URL with anything credential-shaped in it replaced. `add` refuses to write
232
+ * such a URL, but a config written by hand or by an older version can still hold
233
+ * one, and display is exactly where it would leak: `list` and `get` output is
234
+ * meant to be pasteable into an issue.
235
+ *
236
+ * A value that does not parse as a URL is returned untouched. Guessing at the
237
+ * structure of something that is not a URL would be a way to mangle a value
238
+ * without protecting anything.
239
+ *
240
+ * @param {unknown} value
241
+ * @returns {unknown}
242
+ */
243
+ export function maskUrlCredentials(value) {
244
+ if (typeof value !== "string") return value;
245
+ let url;
246
+ try {
247
+ url = new URL(value);
248
+ } catch {
249
+ return value;
250
+ }
251
+ if (url.password) url.password = "*****";
252
+ if (url.username) url.username = "*****";
253
+ for (const [name, item] of [...url.searchParams]) {
254
+ if (credentialLikeName(name) && !ENV_REFERENCE.test(item)) url.searchParams.set(name, "*****");
255
+ }
256
+ return url.href;
257
+ }
258
+
212
259
  /** @param {string} hostname @returns {boolean} */
213
260
  function isLoopbackHost(hostname) {
214
261
  const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
@@ -236,6 +283,10 @@ export function maskServerEntry(entry) {
236
283
  );
237
284
  continue;
238
285
  }
286
+ if (key === "url") {
287
+ out[key] = maskUrlCredentials(value);
288
+ continue;
289
+ }
239
290
  out[key] = value;
240
291
  }
241
292
  return out;
@@ -1,6 +1,12 @@
1
- import { REPOSITORY_SCOPES, SCOPES, collectServers } from "./mcp-config-layers.js";
1
+ import {
2
+ SCOPES,
3
+ collectServers,
4
+ repositoryImportDeclarations,
5
+ unreadableLayers,
6
+ unverifiableMcpConfig
7
+ } from "./mcp-config-layers.js";
2
8
  import { approvalState } from "./mcp-approval-store.js";
3
- import { maskServerEntry } from "./mcp-server-entry.js";
9
+ import { maskServerEntry, maskUrlCredentials } from "./mcp-server-entry.js";
4
10
  import { evaluateServerReadiness } from "./mcp-auth-readiness.js";
5
11
  import { CATALOG_PREREQUISITES } from "./mcp-server-catalog.js";
6
12
 
@@ -24,7 +30,9 @@ export class McpViewError extends Error {}
24
30
  * @returns {string}
25
31
  */
26
32
  export function describeTarget(entry, limit = 56) {
27
- if (typeof entry.url === "string") return entry.url;
33
+ // Masked here as well as in the detail view. This is the string that goes in
34
+ // the `list` table, which is the output most likely to be screenshotted.
35
+ if (typeof entry.url === "string") return String(maskUrlCredentials(entry.url));
28
36
  const argv = [entry.command, ...(Array.isArray(entry.args) ? entry.args : [])].filter((item) => typeof item === "string");
29
37
  const text = argv.join(" ");
30
38
  return text.length > limit ? `${text.slice(0, limit - 3)}...` : text;
@@ -58,6 +66,39 @@ export function blockedRows(rows) {
58
66
  return rows.filter((row) => row.readiness.state !== "ready" && row.readiness.state !== "oauth");
59
67
  }
60
68
 
69
+ /**
70
+ * Everything any surface needs to answer "what is MCP doing here right now".
71
+ *
72
+ * The three surfaces — the guard, `/piagent-mcp`, and the terminal CLI — used to
73
+ * assemble this themselves out of four separate calls, and each one picked a
74
+ * different subset. That is how `list` came to print "No MCP servers configured"
75
+ * and exit 0 in a session where the guard was refusing every tool call: the
76
+ * listing was reading rows, and the thing that made the answer wrong was not a
77
+ * row. Everything that decides whether MCP works is gathered here once.
78
+ *
79
+ * `blockingConfig` is the part that outranks per-server state. While any of it
80
+ * stands, no server is usable whatever its own readiness says, so a caller that
81
+ * reports readiness without reporting this is giving an answer that is locally
82
+ * true and globally wrong.
83
+ *
84
+ * @param {{projectPath: string, scopes?: string[]}} options
85
+ */
86
+ export function sessionMcpState(options) {
87
+ const { projectPath, scopes } = options;
88
+ const rows = collectServerRows({ projectPath, scopes });
89
+ const blockingConfig = unverifiableMcpConfig({ projectPath });
90
+ return {
91
+ rows,
92
+ blocked: blockedRows(rows),
93
+ blockingConfig,
94
+ unreadable: unreadableLayers({ projectPath }),
95
+ imports: repositoryImportDeclarations({ projectPath }),
96
+ // One boolean rather than each caller re-deriving the rule. `list --json`
97
+ // read `servers: []` as success while this was true.
98
+ usable: blockingConfig.length === 0
99
+ };
100
+ }
101
+
61
102
  /**
62
103
  * One named server, resolved to a single scope. `listHint` is the caller's own
63
104
  * way of listing servers, so the error names a command the reader can actually
@@ -81,7 +122,11 @@ export function describeServer(options) {
81
122
  );
82
123
  }
83
124
  const row = matches[0];
84
- const approval = REPOSITORY_SCOPES.has(row.scope)
125
+ // `requiresApproval`, not the scope. A server imported into a `global` config
126
+ // from a repository-relative kind carries the scope of the file that declared
127
+ // the import and still needs a decision here, so keying off the scope showed
128
+ // no approval line for exactly the servers whose approval is least obvious.
129
+ const approval = row.requiresApproval
85
130
  ? approvalState({ projectPath, name, entry: row.entry })
86
131
  : undefined;
87
132
  return { ...row, masked: maskServerEntry(row.entry), approval };
@@ -134,6 +179,12 @@ export function formatServerDetail(detail, options = {}) {
134
179
  detail.name,
135
180
  ` scope: ${detail.scope}`,
136
181
  ` file: ${detail.file}`,
182
+ // Only for a server that came through `imports`. Without it the `scope` and
183
+ // `file` lines read as a config this tool can edit, and every write command
184
+ // suggested against them fails or edits the wrong file.
185
+ ...(detail.origin?.startsWith("import:")
186
+ ? [` origin: ${detail.origin} (declared by the ${detail.scope} config; edit ${detail.file} directly)`]
187
+ : []),
137
188
  ` transport: ${detail.transport}`
138
189
  ];
139
190
  for (const key of DETAIL_FIELDS) {
@@ -0,0 +1,90 @@
1
+ // How the adapter names the tools it exposes, mirrored here so a tool call can
2
+ // be traced back to the server behind it.
3
+ //
4
+ // The adapter builds a prefix from the server name and joins it to the tool name
5
+ // with an underscore. With the default `toolPrefix: "server"` a server called
6
+ // `github` exposes `github_create_issue`. There is no `mcp__` anywhere in that
7
+ // scheme, which is why a gate written against `mcp__<server>__<tool>` never
8
+ // fired: it was watching for a name shape nothing produces.
9
+ //
10
+ // `none` is the case with no answer. It strips the prefix entirely, so the tool
11
+ // arrives under whatever name the server gave it and carries no evidence of
12
+ // where it came from. Attribution is impossible by construction, which makes the
13
+ // combination of that setting with `directTools` in a repository-carried config
14
+ // something to refuse rather than something to parse.
15
+
16
+ /** Prefix modes the adapter accepts. */
17
+ export const TOOL_PREFIX_MODES = new Set(["server", "none", "short"]);
18
+
19
+ /**
20
+ * The prefix a server's tools carry under one mode, matching the adapter's own
21
+ * derivation. An empty string means the mode erases the origin.
22
+ * @param {string} serverName
23
+ * @param {string} mode
24
+ * @returns {string}
25
+ */
26
+ export function serverPrefix(serverName, mode) {
27
+ if (mode === "none") return "";
28
+ if (mode === "short") {
29
+ const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
30
+ return short || "mcp";
31
+ }
32
+ return serverName.replace(/-/g, "_");
33
+ }
34
+
35
+ /**
36
+ * Every prefix a server's tools could arrive under. All prefixing modes are
37
+ * covered rather than just the configured one, because the setting lives in the
38
+ * same file as the servers: a repository that wants a different mode can simply
39
+ * declare it, so trusting the declared value to decide what to inspect would
40
+ * hand the choice back to the thing being gated.
41
+ * @param {string} serverName
42
+ * @returns {string[]}
43
+ */
44
+ export function serverPrefixes(serverName) {
45
+ const prefixes = new Set();
46
+ for (const mode of ["server", "short"]) {
47
+ const prefix = serverPrefix(serverName, mode);
48
+ if (prefix) prefixes.add(prefix);
49
+ }
50
+ return [...prefixes];
51
+ }
52
+
53
+ /**
54
+ * Which of the given servers exposes this tool, or undefined when the name
55
+ * carries no prefix belonging to any of them.
56
+ *
57
+ * The longest match wins. Servers named `db` and `db_admin` both prefix
58
+ * `db_admin_query`, and answering `db` there would check the wrong server's
59
+ * approval — the more specific prefix is the one that actually produced it.
60
+ *
61
+ * @param {string} toolName
62
+ * @param {Iterable<string>} serverNames
63
+ * @returns {string|undefined}
64
+ */
65
+ export function attributeDirectTool(toolName, serverNames) {
66
+ if (typeof toolName !== "string" || toolName.length === 0) return undefined;
67
+ let best;
68
+ let bestLength = 0;
69
+ for (const name of serverNames) {
70
+ for (const prefix of serverPrefixes(name)) {
71
+ if (prefix.length <= bestLength) continue;
72
+ if (!toolName.startsWith(`${prefix}_`)) continue;
73
+ best = name;
74
+ bestLength = prefix.length;
75
+ }
76
+ }
77
+ return best;
78
+ }
79
+
80
+ /**
81
+ * Whether a config's settings put tools on the model under names that cannot be
82
+ * traced to a server. Both halves are required: without `directTools` every call
83
+ * goes through the proxy, which names its server explicitly.
84
+ * @param {Record<string, unknown>} settings
85
+ * @returns {boolean}
86
+ */
87
+ export function erasesToolOrigin(settings) {
88
+ if (!settings || typeof settings !== "object") return false;
89
+ return settings.directTools === true && settings.toolPrefix === "none";
90
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -34,18 +34,34 @@ DEFAULT_MODEL="openai-codex/gpt-5.5:xhigh"
34
34
  SETTINGS_PATH="${PI_CODING_AGENT_DIR:-"${HOME}/.pi/agent"}/settings.json"
35
35
  DRY_RUN=false
36
36
 
37
+ # A flag whose value is missing swallows the next flag instead. `--settings
38
+ # --dry-run` used to set the settings path to the string "--dry-run", leave
39
+ # DRY_RUN false, then write a file by that name and report success — a run that
40
+ # both skipped the dry run it was asked for and never touched the real settings.
41
+ require_value() {
42
+ local option="$1"
43
+ local value="${2:-}"
44
+ if [[ -z "$value" || "$value" == --* ]]; then
45
+ echo "Missing value for $option" >&2
46
+ exit 2
47
+ fi
48
+ }
49
+
37
50
  while [[ $# -gt 0 ]]; do
38
51
  case "$1" in
39
52
  --preset)
40
- PRESET="${2:-}"
53
+ require_value "$1" "${2:-}"
54
+ PRESET="$2"
41
55
  shift 2
42
56
  ;;
43
57
  --default-model)
44
- DEFAULT_MODEL="${2:-}"
58
+ require_value "$1" "${2:-}"
59
+ DEFAULT_MODEL="$2"
45
60
  shift 2
46
61
  ;;
47
62
  --settings)
48
- SETTINGS_PATH="${2:-}"
63
+ require_value "$1" "${2:-}"
64
+ SETTINGS_PATH="$2"
49
65
  shift 2
50
66
  ;;
51
67
  --dry-run)
@@ -38,22 +38,35 @@ MODEL_SCOPE="none"
38
38
  DRY_RUN=false
39
39
  LIST=false
40
40
 
41
+ require_value() {
42
+ local option="$1"
43
+ local value="${2:-}"
44
+ if [[ -z "$value" || "$value" == --* ]]; then
45
+ echo "Missing value for $option" >&2
46
+ exit 2
47
+ fi
48
+ }
49
+
41
50
  while [[ $# -gt 0 ]]; do
42
51
  case "$1" in
43
52
  --preset)
44
- PRESET="${2:-}"
53
+ require_value "$1" "${2:-}"
54
+ PRESET="$2"
45
55
  shift 2
46
56
  ;;
47
57
  --config)
48
- CONFIG_PATH="${2:-}"
58
+ require_value "$1" "${2:-}"
59
+ CONFIG_PATH="$2"
49
60
  shift 2
50
61
  ;;
51
62
  --settings)
52
- SETTINGS_PATH="${2:-}"
63
+ require_value "$1" "${2:-}"
64
+ SETTINGS_PATH="$2"
53
65
  shift 2
54
66
  ;;
55
67
  --model-scope)
56
- MODEL_SCOPE="${2:-}"
68
+ require_value "$1" "${2:-}"
69
+ MODEL_SCOPE="$2"
57
70
  shift 2
58
71
  ;;
59
72
  --dry-run)
@@ -49,21 +49,28 @@ FORCE_SETTINGS=false
49
49
  SKIP_AGENTS=false
50
50
  SKIP_REVIEW=false
51
51
 
52
+ # Checking only that a value is present lets the next flag serve as the value.
53
+ # `--profile --force-profile` used to name a profile "--force-profile" and leave
54
+ # the force off, so init failed on an unknown profile while the flag the operator
55
+ # actually typed went nowhere.
56
+ require_value() {
57
+ local option="$1"
58
+ local value="${2:-}"
59
+ if [[ -z "$value" || "$value" == --* ]]; then
60
+ echo "Missing value for $option" >&2
61
+ exit 2
62
+ fi
63
+ }
64
+
52
65
  while [[ $# -gt 0 ]]; do
53
66
  case "$1" in
54
67
  --profile)
55
- if [[ $# -lt 2 ]]; then
56
- echo "Missing value for --profile" >&2
57
- exit 2
58
- fi
68
+ require_value "$1" "${2:-}"
59
69
  PROFILE_INPUT="$2"
60
70
  shift 2
61
71
  ;;
62
72
  --package-source)
63
- if [[ $# -lt 2 ]]; then
64
- echo "Missing value for --package-source" >&2
65
- exit 2
66
- fi
73
+ require_value "$1" "${2:-}"
67
74
  PACKAGE_SOURCE="$2"
68
75
  shift 2
69
76
  ;;
@@ -10,6 +10,7 @@ import {
10
10
  collectServers,
11
11
  configPathForScope,
12
12
  isRecord,
13
+ mergedServerEntry,
13
14
  readMcpConfig,
14
15
  writeMcpConfig
15
16
  } from "../packages/piagent-core/mcp/mcp-config-layers.js";
@@ -28,18 +29,18 @@ import {
28
29
  } from "../packages/piagent-core/mcp/mcp-server-entry.js";
29
30
  import {
30
31
  approvalState,
32
+ approvalStorePath,
31
33
  clearApproval,
32
34
  recordApproval
33
35
  } from "../packages/piagent-core/mcp/mcp-approval-store.js";
34
36
  import { evaluateServerReadiness } from "../packages/piagent-core/mcp/mcp-auth-readiness.js";
35
37
  import {
36
38
  McpViewError,
37
- blockedRows,
38
- collectServerRows,
39
39
  describeServer,
40
40
  formatDoctorReport,
41
41
  formatServerDetail,
42
42
  formatServerTable,
43
+ sessionMcpState,
43
44
  usesDocker
44
45
  } from "../packages/piagent-core/mcp/mcp-session-view.js";
45
46
 
@@ -214,6 +215,25 @@ function locateServer(name, flags) {
214
215
  return servers[0];
215
216
  }
216
217
 
218
+ /**
219
+ * A server this command has no file of its own to edit: it was read out of
220
+ * another tool's config through `imports`. `located.file` is that other tool's
221
+ * config, and writing it back through `writeMcpConfig` would rewrite somebody's
222
+ * Cursor or VS Code file in this platform's format -- adding a `settings` block
223
+ * it does not use and dropping every key this shape does not carry.
224
+ *
225
+ * @param {{name: string, origin?: string, scope: string, file: string}} located
226
+ * @param {string} action
227
+ */
228
+ function assertOwnedByThisTool(located, action) {
229
+ if (!located.origin?.startsWith("import:")) return;
230
+ const kind = located.origin.slice("import:".length);
231
+ throw new UsageError(
232
+ `${located.name} is not defined by any config this command owns. The ${located.scope} config imports ${kind}, ` +
233
+ `and the definition lives in ${located.file}. Edit that file to ${action} it, or drop "${kind}" from its imports list.`
234
+ );
235
+ }
236
+
217
237
  // --- commands ---------------------------------------------------------------
218
238
 
219
239
  /** @param {ReturnType<typeof parseArgs>} args */
@@ -277,12 +297,21 @@ function commandRemove(args) {
277
297
  const name = args.positionals[0];
278
298
  if (!name) throw new UsageError("remove needs a server name");
279
299
  const located = locateServer(name, args.flags);
300
+ assertOwnedByThisTool(located, "remove");
280
301
  const config = readMcpConfig(located.file);
281
302
  delete config.mcpServers[name];
282
303
  writeMcpConfig(located.file, config);
283
- clearApproval({ projectPath: projectPath(args.flags), name });
284
304
  process.stdout.write(`Removed MCP server ${name} from ${located.scope} scope\n`);
285
305
  process.stdout.write(`File modified: ${located.file}\n`);
306
+ // The definition is gone either way; the decision about it is a separate
307
+ // record, and leaving one behind for a name that no longer exists means a
308
+ // later server reusing the name would meet an approval nobody granted it.
309
+ if (!clearApproval({ projectPath: projectPath(args.flags), name })) {
310
+ process.stdout.write(
311
+ `Warning: could not update ${approvalStorePath() ?? "the approval store"}, ` +
312
+ `so the recorded decision for ${name} is still there. Run \`piagent-mcp reset ${name}\` once it is writable.\n`
313
+ );
314
+ }
286
315
  return 0;
287
316
  }
288
317
 
@@ -318,33 +347,51 @@ function commandGet(args) {
318
347
  function commandList(args) {
319
348
  const scopeFilter = value(args.flags, "--scope");
320
349
  const project = projectPath(args.flags);
321
- const rows = collectServerRows({ projectPath: project, scopes: scopeFilter ? [scopeFilter] : undefined });
350
+ // Listing nothing is the correct answer only when nothing is reaching the
351
+ // session. A config the gate cannot verify reaches it with an unknown set of
352
+ // servers, and telling somebody to seed a baseline in that state sends them
353
+ // to the wrong file entirely.
354
+ const state = sessionMcpState({ projectPath: project, scopes: scopeFilter ? [scopeFilter] : undefined });
355
+ const { rows } = state;
322
356
 
323
357
  if (args.flags.get("--json") === true) {
358
+ // The same fields the human output carries. A caller scripting against this
359
+ // read `servers: []` as "nothing configured" while every tool call was being
360
+ // refused, and an exit code of 0 agreed with it.
324
361
  process.stdout.write(`${JSON.stringify({
325
362
  project,
363
+ usable: state.usable,
364
+ blockingConfig: state.blockingConfig,
326
365
  servers: rows.map((row) => ({
327
366
  name: row.name,
328
367
  scope: row.scope,
368
+ origin: row.origin,
369
+ requiresApproval: row.requiresApproval,
329
370
  file: row.file,
330
371
  state: row.readiness.state,
331
372
  detail: row.readiness.detail,
332
373
  entry: maskServerEntry(row.entry)
333
374
  }))
334
375
  }, null, 2)}\n`);
335
- return 0;
376
+ return state.usable ? 0 : 1;
336
377
  }
337
378
 
379
+ for (const problem of state.blockingConfig) process.stdout.write(`BLOCKED every tool call: ${problem.detail}\n`);
380
+
338
381
  if (rows.length === 0) {
339
- process.stdout.write("No MCP servers configured.\n");
340
- process.stdout.write("Seed the pinned baseline with: piagent-mcp --preset core --scope global\n");
341
- return 0;
382
+ if (state.usable) {
383
+ process.stdout.write("No MCP servers configured.\n");
384
+ process.stdout.write("Seed the pinned baseline with: piagent-mcp --preset core --scope global\n");
385
+ }
386
+ } else {
387
+ process.stdout.write(`${formatServerTable(rows)}\n`);
388
+ if (state.blocked.length > 0) {
389
+ process.stdout.write(`\n${state.blocked.length} server(s) not usable yet. Run \`piagent-mcp doctor\`.\n`);
390
+ }
342
391
  }
343
-
344
- process.stdout.write(`${formatServerTable(rows)}\n`);
345
- const blocked = blockedRows(rows);
346
- if (blocked.length > 0) process.stdout.write(`\n${blocked.length} server(s) not usable yet. Run \`piagent-mcp doctor\`.\n`);
347
- return 0;
392
+ // Out of the no-rows branch: the exit code answers "can MCP work here", and
393
+ // that question does not depend on how many rows happened to print.
394
+ return state.usable ? 0 : 1;
348
395
  }
349
396
 
350
397
  /** @param {ReturnType<typeof parseArgs>} args @param {boolean} enabled */
@@ -352,6 +399,7 @@ function commandToggle(args, enabled) {
352
399
  const name = args.positionals[0];
353
400
  if (!name) throw new UsageError(`${enabled ? "enable" : "disable"} needs a server name`);
354
401
  const located = locateServer(name, args.flags);
402
+ assertOwnedByThisTool(located, enabled ? "enable" : "disable");
355
403
  const config = readMcpConfig(located.file);
356
404
  const entry = { ...(isRecord(config.mcpServers[name]) ? config.mcpServers[name] : {}) };
357
405
  if (enabled) delete entry.enabled;
@@ -384,9 +432,19 @@ function commandDecide(args, decision) {
384
432
  if (decision === "approved" && before.state === "pending" && args.flags.get("--force") !== true) {
385
433
  // Approving without reading it is the failure this gate exists to prevent, so
386
434
  // the definition is put in front of the operator before the decision lands.
387
- process.stdout.write(`${JSON.stringify(maskServerEntry(server.entry), null, 2)}\n`);
435
+ // The merged entry, not this layer's fragment: the adapter merges a
436
+ // same-named server key by key across layers, so a repository declaring only
437
+ // `{"args": [...]}` runs with a command it never showed the operator.
438
+ process.stdout.write(`${JSON.stringify(
439
+ maskServerEntry(mergedServerEntry({ projectPath: project, name, entry: server.entry })), null, 2
440
+ )}\n`);
441
+ }
442
+ // A decision that was not written is not a decision. Printing "Approved" while
443
+ // the store rejected the write would leave the operator waiting for a gate that
444
+ // is still blocking, with nothing on screen to explain why.
445
+ if (!recordApproval({ projectPath: project, name, entry: server.entry, decision })) {
446
+ throw new UsageError(`could not write ${approvalStorePath() ?? "the approval store"}; ${name} is still ${before.state}`);
388
447
  }
389
- recordApproval({ projectPath: project, name, entry: server.entry, decision });
390
448
  const after = approvalState({ projectPath: project, name, entry: server.entry });
391
449
  process.stdout.write(`${decision === "approved" ? "Approved" : "Rejected"} MCP server ${name} (${server.scope}) for ${project}\n`);
392
450
  process.stdout.write(`Pinned to this definition: ${after.digest}\n`);
@@ -398,7 +456,9 @@ function commandDecide(args, decision) {
398
456
  function commandReset(args) {
399
457
  const project = projectPath(args.flags);
400
458
  const name = args.positionals[0];
401
- clearApproval({ projectPath: project, name });
459
+ if (!clearApproval({ projectPath: project, name })) {
460
+ throw new UsageError(`could not write ${approvalStorePath() ?? "the approval store"}; nothing was forgotten`);
461
+ }
402
462
  process.stdout.write(name
403
463
  ? `Forgot the decision for ${name} in ${project}\n`
404
464
  : `Forgot every MCP approval decision for ${project}\n`);
@@ -408,7 +468,12 @@ function commandReset(args) {
408
468
  /** @param {ReturnType<typeof parseArgs>} args */
409
469
  function commandDoctor(args) {
410
470
  const project = projectPath(args.flags);
411
- const rows = collectServerRows({ projectPath: project });
471
+ // Resolved before the no-servers shortcut below. A layer nobody can parse
472
+ // produces no rows, so reporting "no servers configured" and stopping would
473
+ // answer the question exactly backwards. The guard refuses every tool call
474
+ // under `blockingConfig`, and this is where somebody comes to find out why.
475
+ const state = sessionMcpState({ projectPath: project });
476
+ const { rows, unreadable: broken, imports, blockingConfig: unverifiable } = state;
412
477
 
413
478
  // The one check too slow for a session start: a daemon that is installed but
414
479
  // not running looks exactly like one that is running until something asks it.
@@ -422,22 +487,46 @@ function commandDoctor(args) {
422
487
  process.stdout.write(`${JSON.stringify({
423
488
  project,
424
489
  dockerRunning,
425
- servers: rows.map((row) => ({ name: row.name, scope: row.scope, state: row.readiness.state, detail: row.readiness.detail, remedy: row.readiness.remedy }))
490
+ servers: rows.map((row) => ({ name: row.name, scope: row.scope, state: row.readiness.state, detail: row.readiness.detail, remedy: row.readiness.remedy })),
491
+ unreadableLayers: broken,
492
+ repositoryImports: imports.map((layer) => ({ scope: layer.scope, kinds: layer.kinds })),
493
+ unverifiableConfig: unverifiable
426
494
  }, null, 2)}\n`);
427
- return blockedRows(rows).length > 0 ? 1 : 0;
495
+ return state.blocked.length > 0 || broken.length > 0 || unverifiable.length > 0 ? 1 : 0;
428
496
  }
429
497
 
498
+ for (const layer of broken) process.stdout.write(`Unreadable ${layer.scope} config: ${layer.detail}\n`);
499
+ for (const problem of unverifiable) process.stdout.write(`BLOCKED every tool call: ${problem.detail}\n`);
500
+
430
501
  if (rows.length === 0) {
502
+ if (unverifiable.length > 0 || broken.length > 0) {
503
+ process.stdout.write(`\n${unverifiable.length + broken.length} MCP config problem(s) need attention.\n`);
504
+ return 1;
505
+ }
431
506
  process.stdout.write("No MCP servers configured.\n");
432
507
  return 0;
433
508
  }
434
509
 
435
510
  const report = formatDoctorReport(rows, { dockerRunning, rerun: "piagent-mcp doctor" });
436
511
  for (const line of report.lines) process.stdout.write(`${line}\n`);
437
- process.stdout.write(report.problems === 0
512
+
513
+ // A layer nothing can parse hides whatever it defines and reads as empty, so
514
+ // it is counted (and already printed above). An import is named but not
515
+ // counted: the servers it brings in are in the report above with their own
516
+ // state, and this line says where they came from, which reading the
517
+ // repository's own config would not reveal.
518
+ for (const layer of imports) {
519
+ process.stdout.write(
520
+ `${layer.file} imports servers from ${layer.kinds.join(", ")} config; ` +
521
+ "they need approval here like any server the repository defines.\n"
522
+ );
523
+ }
524
+
525
+ const problems = report.problems + broken.length + unverifiable.length;
526
+ process.stdout.write(problems === 0
438
527
  ? "\nPASS: every configured MCP server can be reached.\n"
439
- : `\n${report.problems} MCP server(s) need attention.\n`);
440
- return report.problems === 0 ? 0 : 1;
528
+ : `\n${problems} MCP problem(s) need attention.\n`);
529
+ return problems === 0 ? 0 : 1;
441
530
  }
442
531
 
443
532
  /** @param {ReturnType<typeof parseArgs>} args */
@@ -22,6 +22,15 @@ USAGE
22
22
  FORMAT="text"
23
23
  PROVIDER=""
24
24
 
25
+ require_value() {
26
+ local option="$1"
27
+ local value="${2:-}"
28
+ if [[ -z "$value" || "$value" == --* ]]; then
29
+ echo "Missing value for $option" >&2
30
+ exit 2
31
+ fi
32
+ }
33
+
25
34
  while [[ $# -gt 0 ]]; do
26
35
  case "$1" in
27
36
  --json)
@@ -29,7 +38,8 @@ while [[ $# -gt 0 ]]; do
29
38
  shift
30
39
  ;;
31
40
  --provider)
32
- PROVIDER="${2:-}"
41
+ require_value "$1" "${2:-}"
42
+ PROVIDER="$2"
33
43
  shift 2
34
44
  ;;
35
45
  -h|--help)