@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.
- package/CHANGELOG.md +149 -1
- package/README.md +1 -1
- package/catalog/capabilities.json +4 -4
- package/docs/command-reference-vietnamese.md +15 -4
- package/docs/context-window-policy.md +1 -0
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +49 -3
- package/docs/operator-manual-vietnamese.md +52 -11
- package/docs/publishing-for-teams.md +2 -0
- package/docs/quickstart-vietnamese.md +4 -2
- 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 +49 -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/README.md +2 -0
- 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 +539 -70
- 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/packages/piagent-core/prompts/be-to-fe.md +1 -0
- package/packages/piagent-core/prompts/piagent-commands.md +1 -0
- package/packages/piagent-core/prompts/platform-improve.md +1 -0
- package/packages/piagent-core/prompts/review.md +1 -0
- package/packages/piagent-core/prompts/scout.md +1 -0
- package/packages/piagent-core/prompts/task.md +1 -0
- 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
|
@@ -68,22 +68,53 @@ export function projectKey(projectPath) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
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)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
|
|
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
|
|
62
|
-
const stuck =
|
|
63
|
-
|
|
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:
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
187
|
-
level:
|
|
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
|
-
...(
|
|
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: {
|
|
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
|
-
|
|
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;
|