@piagent/platform 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -1
  2. package/README.md +1 -1
  3. package/catalog/capabilities.json +4 -4
  4. package/docs/command-reference-vietnamese.md +15 -4
  5. package/docs/context-window-policy.md +1 -0
  6. package/docs/distribution-standard.md +7 -7
  7. package/docs/mcp-and-tools.md +49 -3
  8. package/docs/operator-manual-vietnamese.md +52 -11
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/quickstart-vietnamese.md +4 -2
  11. package/docs/release-install-policy.md +6 -6
  12. package/docs/security-threat-model.md +4 -3
  13. package/docs/team-onboarding.md +3 -3
  14. package/docs/usage-observability.md +49 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/mcp-approval.html +54 -2
  18. package/docs-site/content/quickstart.html +1 -1
  19. package/docs-site/index.html +1 -1
  20. package/docs-site/mcp-approval.html +55 -3
  21. package/docs-site/mcp-auth.html +1 -1
  22. package/docs-site/mcp-servers.html +1 -1
  23. package/docs-site/mcp.html +1 -1
  24. package/docs-site/permissions.html +1 -1
  25. package/docs-site/profiles.html +1 -1
  26. package/docs-site/quickstart.html +2 -2
  27. package/docs-site/runtime.html +1 -1
  28. package/docs-site/scope.html +1 -1
  29. package/docs-site/security.html +1 -1
  30. package/docs-site/team.html +1 -1
  31. package/docs-site/workflows.html +1 -1
  32. package/package.json +1 -1
  33. package/packages/piagent-core/README.md +2 -0
  34. package/packages/piagent-core/capabilities/capability-core.js +41 -4
  35. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  36. package/packages/piagent-core/extensions/piagent-guard.ts +539 -70
  37. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  38. package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
  39. package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
  40. package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
  41. package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
  42. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  43. package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
  44. package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
  45. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/prompts/be-to-fe.md +1 -0
  48. package/packages/piagent-core/prompts/piagent-commands.md +1 -0
  49. package/packages/piagent-core/prompts/platform-improve.md +1 -0
  50. package/packages/piagent-core/prompts/review.md +1 -0
  51. package/packages/piagent-core/prompts/scout.md +1 -0
  52. package/packages/piagent-core/prompts/task.md +1 -0
  53. package/scripts/configure-model-scope.sh +19 -3
  54. package/scripts/configure-subagents.sh +17 -4
  55. package/scripts/init-project.sh +15 -8
  56. package/scripts/mcp-manage.mjs +111 -22
  57. package/scripts/pi-model-catalog.sh +11 -1
  58. package/scripts/pi-session-stats.sh +27 -1
  59. package/scripts/pi-usage-history.mjs +567 -0
  60. package/scripts/quality-benchmark.sh +37 -9
  61. package/scripts/setup.sh +21 -13
  62. package/scripts/uninstall-global.sh +10 -4
  63. package/scripts/verify-local.sh +19 -0
  64. package/scripts/verify-release-identity.mjs +29 -0
@@ -68,22 +68,53 @@ export function projectKey(projectPath) {
68
68
  }
69
69
  }
70
70
 
71
- /** @param {{home?: string}} [options] @returns {Record<string, Record<string, {digest?: string, decision?: string, decidedAt?: string}>>} */
72
- export function readApprovalStore(options = {}) {
71
+ /**
72
+ * The store, and whether the empty case means "nothing decided" or "nobody could
73
+ * read it". Both read as pending, which is the right answer for a gate and the
74
+ * wrong one for a write: overwriting a store this process could not parse throws
75
+ * away every decision recorded in it, and the operator's only clue would be that
76
+ * their other projects silently returned to pending.
77
+ *
78
+ * @param {{home?: string}} [options]
79
+ * @returns {{projects: Record<string, Record<string, {digest?: string, decision?: string, decidedAt?: string}>>, readable: boolean, detail?: string}}
80
+ */
81
+ export function loadApprovalStore(options = {}) {
73
82
  const file = approvalStorePath(options);
74
- if (!file) return {};
83
+ if (!file) return { projects: {}, readable: false, detail: "no home directory to read an approval store from" };
84
+ let raw;
75
85
  try {
76
86
  // A store larger than this is not something this code wrote.
77
- if (fs.statSync(file).size > MAX_STORE_BYTES) return {};
78
- const document = JSON.parse(fs.readFileSync(file, "utf8"));
79
- const projects = document?.projects;
80
- if (!projects || typeof projects !== "object" || Array.isArray(projects)) return {};
81
- return projects;
82
- } catch {
83
- // An unreadable store denies rather than permits: every server reads as
84
- // undecided, which is the state that blocks.
85
- return {};
87
+ if (fs.statSync(file).size > MAX_STORE_BYTES) {
88
+ return { projects: {}, readable: false, detail: `${file} is larger than ${MAX_STORE_BYTES} bytes` };
89
+ }
90
+ raw = fs.readFileSync(file, "utf8");
91
+ } catch (error) {
92
+ // Absent is the ordinary state before the first decision.
93
+ if (error && typeof error === "object" && error.code === "ENOENT") return { projects: {}, readable: true };
94
+ return { projects: {}, readable: false, detail: `cannot read ${file}` };
86
95
  }
96
+ let document;
97
+ try {
98
+ document = JSON.parse(raw);
99
+ } catch (error) {
100
+ return { projects: {}, readable: false, detail: `cannot parse ${file}: ${error instanceof Error ? error.message : String(error)}` };
101
+ }
102
+ const projects = document?.projects;
103
+ if (projects === undefined && document && typeof document === "object" && !Array.isArray(document)) {
104
+ // A document with no `projects` key is a store this code did not write.
105
+ return { projects: {}, readable: false, detail: `${file} has no projects object` };
106
+ }
107
+ if (!projects || typeof projects !== "object" || Array.isArray(projects)) {
108
+ return { projects: {}, readable: false, detail: `${file} has no projects object` };
109
+ }
110
+ return { projects, readable: true };
111
+ }
112
+
113
+ /** @param {{home?: string}} [options] @returns {Record<string, Record<string, {digest?: string, decision?: string, decidedAt?: string}>>} */
114
+ export function readApprovalStore(options = {}) {
115
+ // An unreadable store denies rather than permits: every server reads as
116
+ // undecided, which is the state that blocks.
117
+ return loadApprovalStore(options).projects;
87
118
  }
88
119
 
89
120
  /**
@@ -139,7 +170,12 @@ export function approvalState(options) {
139
170
  * @returns {boolean}
140
171
  */
141
172
  export function recordApproval(options) {
142
- const store = readApprovalStore({ home: options.home });
173
+ const loaded = loadApprovalStore({ home: options.home });
174
+ // Writing on top of a store nobody could parse would drop every decision it
175
+ // holds -- including the ones for other projects, which the operator would
176
+ // find out about only by watching them go back to pending one at a time.
177
+ if (!loaded.readable) return false;
178
+ const store = loaded.projects;
143
179
  const key = projectKey(options.projectPath);
144
180
  const project = { ...(store[key] ?? {}) };
145
181
  project[options.name] = {
@@ -159,9 +195,14 @@ export function recordApproval(options) {
159
195
  * @returns {boolean}
160
196
  */
161
197
  export function clearApproval(options) {
162
- const store = readApprovalStore({ home: options.home });
198
+ const loaded = loadApprovalStore({ home: options.home });
199
+ if (!loaded.readable) return false;
200
+ const store = loaded.projects;
163
201
  const key = projectKey(options.projectPath);
164
- if (!store[key]) return false;
202
+ // Nothing recorded is the state this function exists to reach, so it succeeds.
203
+ // Returning false here would be indistinguishable from a failed write, and the
204
+ // caller has to be able to tell those apart.
205
+ if (!store[key]) return true;
165
206
  if (!options.name) {
166
207
  const { [key]: _removed, ...rest } = store;
167
208
  return writeApprovalStore(rest, { home: options.home });
@@ -40,6 +40,14 @@ export function referencedEnvVars(entry) {
40
40
  walk(entry, (text) => {
41
41
  for (const match of text.matchAll(ENV_REFERENCE)) names.add(match[1]);
42
42
  });
43
+ // `bearerTokenEnvVar` names its variable directly instead of through a
44
+ // `${VAR}` reference, which is the entire point of the field: the token must
45
+ // not appear in the config. The walk above only recognises the reference form,
46
+ // so without this the one server setting that guarantees a credential is
47
+ // needed was the one setting that never produced a needs-env.
48
+ if (typeof entry?.bearerTokenEnvVar === "string" && entry.bearerTokenEnvVar.trim()) {
49
+ names.add(entry.bearerTokenEnvVar.trim());
50
+ }
43
51
  return [...names].sort();
44
52
  }
45
53
 
@@ -96,7 +104,17 @@ function isExecutable(file) {
96
104
  */
97
105
 
98
106
  /**
99
- * @param {{name: string, scope: string, entry: Record<string, unknown>}} server
107
+ * The import kind a server arrived through, or undefined when the layer named
108
+ * it directly.
109
+ * @param {{origin?: string}} server
110
+ * @returns {string|undefined}
111
+ */
112
+ function importedFrom(server) {
113
+ return server.origin?.startsWith("import:") ? server.origin.slice("import:".length) : undefined;
114
+ }
115
+
116
+ /**
117
+ * @param {{name: string, scope: string, entry: Record<string, unknown>, requiresApproval?: boolean, origin?: string}} server
100
118
  * @param {{projectPath: string, env?: Record<string, string|undefined>, home?: string, store?: Record<string, unknown>}} options
101
119
  * @returns {ServerReadiness}
102
120
  */
@@ -108,7 +126,12 @@ export function evaluateServerReadiness(server, options) {
108
126
  return { ...base, state: "disabled", detail: "disabled in config", remedy: `piagent-mcp enable ${server.name} --scope ${server.scope}` };
109
127
  }
110
128
 
111
- if (REPOSITORY_SCOPES.has(server.scope)) {
129
+ // `collectServers` decides this, because the scope a server is listed under
130
+ // is not the same question as who put it there: a `global` config importing
131
+ // a repository-relative kind reads its definitions out of the clone. Falling
132
+ // back to scope keeps a caller that builds a server record by hand working.
133
+ const needsApproval = server.requiresApproval ?? REPOSITORY_SCOPES.has(server.scope);
134
+ if (needsApproval) {
112
135
  const approval = approvalState({
113
136
  projectPath: options.projectPath,
114
137
  name: server.name,
@@ -131,7 +154,12 @@ export function evaluateServerReadiness(server, options) {
131
154
  return {
132
155
  ...base,
133
156
  state: "pending-approval",
134
- detail: `defined by this repository in ${server.scope} scope and not approved here`,
157
+ // Naming the scope alone reads as a lie for an imported server: the
158
+ // scope is where the `imports` key sits, and the definition is in a
159
+ // different file entirely. Say which of the two it was.
160
+ detail: importedFrom(server)
161
+ ? `imported from ${importedFrom(server)} config by the ${server.scope} layer, and not approved here`
162
+ : `defined by this repository in ${server.scope} scope and not approved here`,
135
163
  remedy: `piagent-mcp approve ${server.name}`
136
164
  };
137
165
  }
@@ -1,14 +1,21 @@
1
- import { REPOSITORY_SCOPES, collectServers, isRecord, readMcpConfig, writeMcpConfig } from "./mcp-config-layers.js";
2
- import { approvalState, clearApproval, recordApproval } from "./mcp-approval-store.js";
1
+ import {
2
+ REPOSITORY_SCOPES,
3
+ collectServers,
4
+ isRecord,
5
+ mergedServerEntry,
6
+ readMcpConfig,
7
+ writeMcpConfig
8
+ } from "./mcp-config-layers.js";
9
+ import { approvalStorePath, approvalState, clearApproval, recordApproval } from "./mcp-approval-store.js";
3
10
  import { maskServerEntry } from "./mcp-server-entry.js";
4
11
  import {
5
12
  McpViewError,
6
- blockedRows,
7
13
  collectServerRows,
8
14
  describeServer,
9
15
  formatDoctorReport,
10
16
  formatServerDetail,
11
- formatServerTable
17
+ formatServerTable,
18
+ sessionMcpState
12
19
  } from "./mcp-session-view.js";
13
20
 
14
21
  // What `/piagent-mcp` does, separated from how a session shows it.
@@ -58,9 +65,14 @@ export const HELP_LINES = [
58
65
  * @param {{projectPath: string}} options
59
66
  */
60
67
  export function menuOptions(options) {
61
- const rows = collectServerRows({ projectPath: options.projectPath });
62
- const stuck = blockedRows(rows);
63
- const repositoryRows = rows.filter((row) => REPOSITORY_SCOPES.has(row.scope));
68
+ const state = sessionMcpState({ projectPath: options.projectPath });
69
+ const { rows, blocked: stuck } = state;
70
+ // Not `REPOSITORY_SCOPES.has(row.scope)`: a repository-relative import
71
+ // declared by a `global` config produces a row whose scope is `global` and
72
+ // whose definition came out of the clone. Those were the rows the menu never
73
+ // offered to approve, so the only route to a decision was knowing to type the
74
+ // subcommand -- for exactly the servers whose origin is hardest to see.
75
+ const repositoryRows = rows.filter((row) => row.requiresApproval);
64
76
  const decisions = repositoryRows.map((row) => ({
65
77
  row,
66
78
  state: approvalState({ projectPath: options.projectPath, name: row.name, entry: row.entry }).state
@@ -73,11 +85,24 @@ export function menuOptions(options) {
73
85
  const entries = [
74
86
  {
75
87
  value: "status",
76
- label: rows.length === 0 ? "Servers — none configured" : `Servers — ${rows.length - stuck.length}/${rows.length} ready`,
77
- description: "Every server visible from this project, with its scope and state"
88
+ label: state.usable
89
+ ? (rows.length === 0 ? "Servers — none configured" : `Servers — ${rows.length - stuck.length}/${rows.length} ready`)
90
+ : "Servers — every tool call blocked",
91
+ description: state.usable
92
+ ? "Every server visible from this project, with its scope and state"
93
+ : "Config here leaves nothing to check an approval against"
78
94
  }
79
95
  ];
80
- if (stuck.length > 0) {
96
+ // Ahead of everything per-server: while this stands, no decision about an
97
+ // individual server changes whether anything runs.
98
+ if (!state.usable) {
99
+ entries.push({
100
+ value: "doctor",
101
+ label: `Doctor — ${state.blockingConfig.length} config problem${state.blockingConfig.length === 1 ? "" : "s"} blocking every call`,
102
+ description: "Which file to edit before any server can answer",
103
+ recommended: true
104
+ });
105
+ } else if (stuck.length > 0) {
81
106
  entries.push({
82
107
  value: "doctor",
83
108
  label: `Doctor — ${stuck.length} need attention`,
@@ -125,7 +150,13 @@ export function serverChoices(options) {
125
150
  const { projectPath, action } = options;
126
151
  const rows = collectServerRows({ projectPath });
127
152
  const filtered = rows.filter((row) => {
128
- if (action === "approve" || action === "reject" || action === "reset") return REPOSITORY_SCOPES.has(row.scope);
153
+ if (action === "approve" || action === "reject" || action === "reset") return row.requiresApproval;
154
+ // A server reached through `imports` lives in a file this command does not
155
+ // own. Offering it here produced a write against the declaring layer that
156
+ // reported success and changed nothing the adapter reads.
157
+ if (action === "enable" || action === "disable") {
158
+ if (row.origin?.startsWith("import:")) return false;
159
+ }
129
160
  if (action === "enable") return row.readiness.state === "disabled";
130
161
  if (action === "disable") return row.readiness.state !== "disabled";
131
162
  return true;
@@ -139,19 +170,43 @@ export function serverChoices(options) {
139
170
 
140
171
  /** @param {{projectPath: string, scope?: string}} options */
141
172
  export function status(options) {
142
- const rows = collectServerRows({ projectPath: options.projectPath, scopes: options.scope ? [options.scope] : undefined });
143
- const stuck = blockedRows(rows);
173
+ const state = sessionMcpState({
174
+ projectPath: options.projectPath,
175
+ scopes: options.scope ? [options.scope] : undefined
176
+ });
177
+ const { rows, blocked: stuck } = state;
178
+ // Reported before the per-server table, and it decides the headline. A status
179
+ // that says "0 servers configured" in a session where every tool call is being
180
+ // refused is not a smaller version of the truth, it points at the wrong file.
181
+ const blocking = state.blockingConfig.map((problem) => `BLOCKED every tool call: ${problem.detail}`);
182
+ const summary = !state.usable
183
+ ? "MCP: every tool call blocked by config"
184
+ : rows.length === 0
185
+ ? "MCP: no servers configured"
186
+ : `MCP: ${rows.length - stuck.length}/${rows.length} ready`;
144
187
  return {
145
- notify: {
146
- message: rows.length === 0 ? "MCP: no servers configured" : `MCP: ${rows.length - stuck.length}/${rows.length} ready`,
147
- level: stuck.length > 0 ? "warning" : "info"
148
- },
149
- lines: rows.length === 0
150
- ? ["No MCP servers configured.", "Seed the pinned baseline in a terminal: piagent-mcp --preset core --scope global"]
151
- : [formatServerTable(rows), ...(stuck.length > 0 ? ["", `${stuck.length} not usable yet. Run /piagent-mcp doctor.`] : [])],
188
+ notify: { message: summary, level: state.usable && stuck.length === 0 ? "info" : "warning" },
189
+ lines: [
190
+ ...blocking,
191
+ ...(blocking.length > 0 ? [""] : []),
192
+ ...(rows.length === 0
193
+ ? (state.usable
194
+ ? ["No MCP servers configured.", "Seed the pinned baseline in a terminal: piagent-mcp --preset core --scope global"]
195
+ : [])
196
+ : [formatServerTable(rows), ...(stuck.length > 0 ? ["", `${stuck.length} not usable yet. Run /piagent-mcp doctor.`] : [])])
197
+ ],
152
198
  details: {
153
199
  project: options.projectPath,
154
- servers: rows.map((row) => ({ name: row.name, scope: row.scope, state: row.readiness.state, detail: row.readiness.detail }))
200
+ usable: state.usable,
201
+ blockingConfig: state.blockingConfig,
202
+ servers: rows.map((row) => ({
203
+ name: row.name,
204
+ scope: row.scope,
205
+ origin: row.origin,
206
+ requiresApproval: row.requiresApproval,
207
+ state: row.readiness.state,
208
+ detail: row.readiness.detail
209
+ }))
155
210
  }
156
211
  };
157
212
  }
@@ -179,18 +234,38 @@ export function detail(options) {
179
234
  * @param {{projectPath: string}} options
180
235
  */
181
236
  export function doctor(options) {
182
- const rows = collectServerRows({ projectPath: options.projectPath });
183
- const report = formatDoctorReport(rows, { rerun: "/piagent-mcp doctor", inSession: true });
237
+ const state = sessionMcpState({ projectPath: options.projectPath });
238
+ const report = formatDoctorReport(state.rows, { rerun: "/piagent-mcp doctor", inSession: true });
239
+ // A layer nobody can parse counts as a problem: it is hiding whatever it
240
+ // defines. An import declaration does not — the servers it brings in are in
241
+ // the table above with their own approval state — but it is named, because
242
+ // reading the repository's config alone would not reveal where they came from.
243
+ const problems = report.problems + state.unreadable.length + state.blockingConfig.length;
184
244
  return {
185
245
  notify: {
186
- message: report.problems === 0 ? "MCP: every server can be reached" : `MCP: ${report.problems} need attention`,
187
- level: report.problems === 0 ? "info" : "warning"
246
+ message: problems === 0 ? "MCP: every server can be reached" : `MCP: ${problems} need attention`,
247
+ level: problems === 0 ? "info" : "warning"
188
248
  },
189
249
  lines: [
250
+ // First. No per-server remedy below is worth acting on while one of these
251
+ // stands, because none of them makes a tool call succeed.
252
+ ...state.blockingConfig.map((problem) => `BLOCKED every tool call: ${problem.detail}`),
253
+ ...(state.blockingConfig.length > 0 ? [""] : []),
190
254
  ...report.lines,
191
- ...(report.problems === 0 ? [] : ["", "The Docker daemon check is terminal-only: piagent-mcp doctor"])
255
+ ...state.unreadable.map((layer) => `Unreadable ${layer.scope} config: ${layer.detail}`),
256
+ ...state.imports.map((layer) =>
257
+ `${layer.file} imports servers from ${layer.kinds.join(", ")} config; ` +
258
+ "they need approval here like any server the repository defines."),
259
+ ...(problems === 0 ? [] : ["", "The Docker daemon check is terminal-only: piagent-mcp doctor"])
192
260
  ],
193
- details: { project: options.projectPath, problems: report.problems }
261
+ details: {
262
+ project: options.projectPath,
263
+ problems,
264
+ usable: state.usable,
265
+ blockingConfig: state.blockingConfig,
266
+ unreadableLayers: state.unreadable.map((layer) => layer.scope),
267
+ repositoryImports: state.imports.map((layer) => ({ scope: layer.scope, kinds: layer.kinds }))
268
+ }
194
269
  };
195
270
  }
196
271
 
@@ -211,10 +286,23 @@ export function decide(options) {
211
286
  const before = approvalState({ projectPath, name, entry: server.entry });
212
287
  // Approving without reading it is the failure this gate exists to prevent, so
213
288
  // the definition is put in front of the operator alongside the decision.
289
+ //
290
+ // What is shown is the merged entry, not this layer's fragment. The adapter
291
+ // merges a same-named server key by key across layers, so a repository that
292
+ // declares only `{"args": ["--evil"]}` gets the command from a lower layer --
293
+ // and a preview of the fragment showed the operator an argument list with no
294
+ // command attached while consenting to a full command line.
214
295
  const preview = decision === "approved" && before.state !== "approved"
215
- ? [JSON.stringify(maskServerEntry(server.entry), null, 2), ""]
296
+ ? [JSON.stringify(maskServerEntry(mergedServerEntry({ projectPath, name, entry: server.entry })), null, 2), ""]
216
297
  : [];
217
- recordApproval({ projectPath, name, entry: server.entry, decision });
298
+ // A decision that was not written is not a decision. Reporting success here
299
+ // would leave the operator believing a server is approved while the gate keeps
300
+ // blocking it, or worse, believing one is rejected while nothing recorded it.
301
+ if (!recordApproval({ projectPath, name, entry: server.entry, decision })) {
302
+ throw new McpViewError(
303
+ `could not write ${approvalStorePath() ?? "the approval store"}; ${name} is still ${before.state}`
304
+ );
305
+ }
218
306
  const after = approvalState({ projectPath, name, entry: server.entry });
219
307
  return {
220
308
  notify: { message: `MCP ${name}: ${decision}`, level: "info" },
@@ -231,7 +319,9 @@ export function decide(options) {
231
319
  /** @param {{projectPath: string, name?: string}} options */
232
320
  export function reset(options) {
233
321
  const { projectPath, name } = options;
234
- clearApproval({ projectPath, name });
322
+ if (!clearApproval({ projectPath, name })) {
323
+ throw new McpViewError(`could not write ${approvalStorePath() ?? "the approval store"}; nothing was forgotten`);
324
+ }
235
325
  return {
236
326
  notify: { message: name ? `MCP ${name}: decision forgotten` : "MCP: all decisions forgotten", level: "info" },
237
327
  lines: [name
@@ -241,10 +331,29 @@ export function reset(options) {
241
331
  };
242
332
  }
243
333
 
334
+ /**
335
+ * A server this command has no file of its own to edit: it was read out of
336
+ * another tool's config through `imports`. The write used to land in whichever
337
+ * layer declared the import, where the adapter never looks for it, so the
338
+ * command reported the server disabled and the next tool call still reached it.
339
+ *
340
+ * @param {{name: string, origin?: string, scope: string, file: string}} found
341
+ * @param {string} action
342
+ */
343
+ function assertOwnedByThisTool(found, action) {
344
+ if (!found.origin?.startsWith("import:")) return;
345
+ const kind = found.origin.slice("import:".length);
346
+ throw new McpViewError(
347
+ `${found.name} is not defined by any config this command owns. The ${found.scope} config imports ${kind}, ` +
348
+ `and the definition lives in ${found.file}. Edit that file to ${action} it, or drop "${kind}" from the imports list.`
349
+ );
350
+ }
351
+
244
352
  /** @param {{projectPath: string, name: string, scope?: string, enabled: boolean}} options */
245
353
  export function toggle(options) {
246
354
  const { projectPath, name, scope, enabled } = options;
247
355
  const found = describeServer({ projectPath, name, scope, listHint: "/piagent-mcp", scopeHint: "--scope" });
356
+ assertOwnedByThisTool(found, enabled ? "enable" : "disable");
248
357
  const config = readMcpConfig(found.file);
249
358
  const entry = { ...(isRecord(config.mcpServers[name]) ? config.mcpServers[name] : {}) };
250
359
  if (enabled) delete entry.enabled;