@piagent/platform 1.1.9 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +175 -1
- package/README.md +2 -2
- package/docs/command-reference-vietnamese.md +19 -5
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +154 -2
- package/docs/model-options.md +10 -8
- package/docs/operator-manual-vietnamese.md +25 -13
- package/docs/publishing-for-teams.md +2 -0
- package/docs/quickstart-vietnamese.md +2 -2
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +4 -3
- package/docs/team-onboarding.md +4 -4
- package/docs/usage-observability.md +29 -0
- package/docs/vercel-docs-site.md +34 -4
- package/docs-site/architecture.html +152 -0
- package/docs-site/assets/docs.css +1001 -0
- package/docs-site/assets/docs.js +84 -0
- package/docs-site/commands.html +203 -0
- package/docs-site/content/architecture.html +29 -0
- package/docs-site/content/commands.html +80 -0
- package/docs-site/content/index.html +80 -0
- package/docs-site/content/mcp-approval.html +133 -0
- package/docs-site/content/mcp-auth.html +76 -0
- package/docs-site/content/mcp-servers.html +111 -0
- package/docs-site/content/mcp.html +120 -0
- package/docs-site/content/permissions.html +54 -0
- package/docs-site/content/profiles.html +68 -0
- package/docs-site/content/quickstart.html +144 -0
- package/docs-site/content/runtime.html +45 -0
- package/docs-site/content/scope.html +71 -0
- package/docs-site/content/security.html +51 -0
- package/docs-site/content/team.html +23 -0
- package/docs-site/content/workflows.html +47 -0
- package/docs-site/index.html +131 -1534
- package/docs-site/mcp-approval.html +256 -0
- package/docs-site/mcp-auth.html +199 -0
- package/docs-site/mcp-servers.html +234 -0
- package/docs-site/mcp.html +243 -0
- package/docs-site/permissions.html +177 -0
- package/docs-site/profiles.html +191 -0
- package/docs-site/quickstart.html +267 -0
- package/docs-site/runtime.html +168 -0
- package/docs-site/scope.html +194 -0
- package/docs-site/security.html +174 -0
- package/docs-site/team.html +142 -0
- package/docs-site/workflows.html +170 -0
- package/package.json +4 -2
- package/packages/piagent-core/capabilities/capability-core.js +58 -5
- package/packages/piagent-core/capabilities/capability-sources.js +73 -12
- package/packages/piagent-core/extensions/document-intake.ts +5 -5
- package/packages/piagent-core/extensions/piagent-guard.ts +432 -2
- package/packages/piagent-core/extensions/policy-core.js +1586 -98
- package/packages/piagent-core/mcp/mcp-approval-store.js +216 -0
- package/packages/piagent-core/mcp/mcp-auth-readiness.js +225 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +400 -0
- package/packages/piagent-core/mcp/mcp-config-layers.js +466 -0
- package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
- package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
- package/packages/piagent-core/mcp/mcp-server-entry.js +293 -0
- package/packages/piagent-core/mcp/mcp-session-view.js +236 -0
- package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
- package/packages/piagent-core/package.json +7 -3
- package/packages/piagent-core/prompts/model-options.md +8 -8
- package/scripts/build-docs-site.mjs +421 -0
- package/scripts/configure-model-scope.sh +21 -5
- package/scripts/configure-subagents.sh +18 -5
- package/scripts/init-project.sh +15 -8
- package/scripts/install-global.sh +5 -5
- package/scripts/mcp-manage.mjs +656 -0
- 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/piagent-cli.mjs +1 -1
- package/scripts/preview-docs-site.mjs +88 -0
- package/scripts/quality-benchmark.sh +37 -9
- package/scripts/setup.sh +23 -15
- package/scripts/team-doctor.sh +18 -2
- package/scripts/uninstall-global.sh +10 -4
- package/scripts/verify-local.sh +65 -19
- package/scripts/verify-release-identity.mjs +37 -2
- package/scripts/configure-mcp.sh +0 -349
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
SCOPES,
|
|
8
|
+
REPOSITORY_SCOPES,
|
|
9
|
+
McpConfigError,
|
|
10
|
+
collectServers,
|
|
11
|
+
configPathForScope,
|
|
12
|
+
isRecord,
|
|
13
|
+
mergedServerEntry,
|
|
14
|
+
readMcpConfig,
|
|
15
|
+
writeMcpConfig
|
|
16
|
+
} from "../packages/piagent-core/mcp/mcp-config-layers.js";
|
|
17
|
+
import {
|
|
18
|
+
CATALOG_PRESETS,
|
|
19
|
+
CATALOG_PREREQUISITES,
|
|
20
|
+
CATALOG_SERVERS,
|
|
21
|
+
isCatalogPreset,
|
|
22
|
+
resolvePreset
|
|
23
|
+
} from "../packages/piagent-core/mcp/mcp-server-catalog.js";
|
|
24
|
+
import {
|
|
25
|
+
McpEntryError,
|
|
26
|
+
assertServerName,
|
|
27
|
+
buildServerEntry,
|
|
28
|
+
maskServerEntry
|
|
29
|
+
} from "../packages/piagent-core/mcp/mcp-server-entry.js";
|
|
30
|
+
import {
|
|
31
|
+
approvalState,
|
|
32
|
+
approvalStorePath,
|
|
33
|
+
clearApproval,
|
|
34
|
+
recordApproval
|
|
35
|
+
} from "../packages/piagent-core/mcp/mcp-approval-store.js";
|
|
36
|
+
import { evaluateServerReadiness } from "../packages/piagent-core/mcp/mcp-auth-readiness.js";
|
|
37
|
+
import {
|
|
38
|
+
McpViewError,
|
|
39
|
+
describeServer,
|
|
40
|
+
formatDoctorReport,
|
|
41
|
+
formatServerDetail,
|
|
42
|
+
formatServerTable,
|
|
43
|
+
sessionMcpState,
|
|
44
|
+
usesDocker
|
|
45
|
+
} from "../packages/piagent-core/mcp/mcp-session-view.js";
|
|
46
|
+
|
|
47
|
+
const SUBCOMMANDS = new Set([
|
|
48
|
+
"add", "remove", "get", "list", "enable", "disable", "approve", "reject", "reset", "doctor", "preset"
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
function usage() {
|
|
52
|
+
process.stdout.write(`Usage:
|
|
53
|
+
piagent-mcp <command> [options]
|
|
54
|
+
piagent-mcp --preset <preset> [options] seed a pinned server group
|
|
55
|
+
|
|
56
|
+
Commands:
|
|
57
|
+
add <name> --url <url> Add a remote MCP server
|
|
58
|
+
add <name> -- <command> [args] Add a local MCP server
|
|
59
|
+
remove <name> Remove a server from a scope
|
|
60
|
+
get <name> Show one server, with credentials masked
|
|
61
|
+
list Show every server visible here, with state
|
|
62
|
+
enable <name> / disable <name> Turn a server off without losing its config
|
|
63
|
+
approve <name> / reject <name> Decide on a server this repository defines
|
|
64
|
+
reset Forget every decision for this project
|
|
65
|
+
doctor Report what each server still needs
|
|
66
|
+
|
|
67
|
+
Options:
|
|
68
|
+
--scope <global|pi-global|project|pi-project>
|
|
69
|
+
Config layer (default: global; project for approve)
|
|
70
|
+
--project <path> Project path for project scopes (default: cwd)
|
|
71
|
+
--config <path> Explicit config path; overrides --scope/--project
|
|
72
|
+
--env KEY=value Environment entry for a local server (repeatable)
|
|
73
|
+
--header "Name: value" Header for a remote server (repeatable)
|
|
74
|
+
--bearer-token-env-var <VAR> Variable holding the bearer token for a remote server
|
|
75
|
+
--oauth-client-id <id> OAuth client identifier
|
|
76
|
+
--oauth-scopes <a,b> Pin the OAuth scopes requested
|
|
77
|
+
--direct-tools Expose this server's tools directly instead of via the proxy
|
|
78
|
+
--preset <name> Seed a pinned group: ${Object.keys(CATALOG_PRESETS).join(", ")}
|
|
79
|
+
--replace Overwrite existing definitions the preset covers
|
|
80
|
+
--dry-run Print what would be written
|
|
81
|
+
--list Print available presets/servers
|
|
82
|
+
--json Machine-readable output
|
|
83
|
+
-h, --help
|
|
84
|
+
|
|
85
|
+
Credentials are never written into MCP config. Pass a variable reference such as
|
|
86
|
+
--env 'GITHUB_PERSONAL_ACCESS_TOKEN=\${GITHUB_PERSONAL_ACCESS_TOKEN}' and export the
|
|
87
|
+
value in your shell. OAuth sign-in belongs to Pi: run /mcp-auth <name> inside a session.
|
|
88
|
+
|
|
89
|
+
Servers defined by a repository (project and pi-project scopes) are not usable
|
|
90
|
+
until approved here. The decision is stored in your home directory, so a clone
|
|
91
|
+
cannot approve itself, and it is tied to the definition that was approved.
|
|
92
|
+
|
|
93
|
+
Examples:
|
|
94
|
+
piagent-mcp --preset core --scope global --replace
|
|
95
|
+
piagent-mcp add sentry --url https://mcp.sentry.dev/mcp --scope global
|
|
96
|
+
piagent-mcp add internal --scope project -- npx -y @acme/internal-mcp
|
|
97
|
+
piagent-mcp list
|
|
98
|
+
piagent-mcp approve internal
|
|
99
|
+
piagent-mcp doctor
|
|
100
|
+
`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class UsageError extends Error {}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string[]} argv
|
|
107
|
+
* @returns {{command: string, positionals: string[], flags: Map<string, string|true>, repeated: Map<string, string[]>, passthrough: string[]}}
|
|
108
|
+
*/
|
|
109
|
+
function parseArgs(argv) {
|
|
110
|
+
const repeatable = new Set(["--env", "--header"]);
|
|
111
|
+
const valued = new Set([
|
|
112
|
+
"--scope", "--project", "--config", "--preset", "--url", "--bearer-token-env-var",
|
|
113
|
+
"--oauth-client-id", "--oauth-scopes", "--env", "--header"
|
|
114
|
+
]);
|
|
115
|
+
const boolean = new Set(["--replace", "--dry-run", "--list", "--json", "--direct-tools", "--validate-preset", "--force"]);
|
|
116
|
+
|
|
117
|
+
const flags = new Map();
|
|
118
|
+
const repeated = new Map();
|
|
119
|
+
const positionals = [];
|
|
120
|
+
/** @type {string[]} */
|
|
121
|
+
let passthrough = [];
|
|
122
|
+
|
|
123
|
+
let command = "";
|
|
124
|
+
let index = 0;
|
|
125
|
+
if (argv[0] && !argv[0].startsWith("-")) {
|
|
126
|
+
command = argv[0];
|
|
127
|
+
index = 1;
|
|
128
|
+
if (!SUBCOMMANDS.has(command)) throw new UsageError(`unknown command: ${command}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (; index < argv.length; index += 1) {
|
|
132
|
+
const token = argv[index];
|
|
133
|
+
if (token === "--") {
|
|
134
|
+
passthrough = argv.slice(index + 1);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
if (token === "-h" || token === "--help") {
|
|
138
|
+
flags.set("--help", true);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!token.startsWith("-")) {
|
|
142
|
+
positionals.push(token);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (boolean.has(token)) {
|
|
146
|
+
flags.set(token, true);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!valued.has(token)) throw new UsageError(`unknown option: ${token}`);
|
|
150
|
+
const value = argv[index + 1];
|
|
151
|
+
if (value === undefined || value === "--") throw new UsageError(`missing value for ${token}`);
|
|
152
|
+
index += 1;
|
|
153
|
+
if (repeatable.has(token)) {
|
|
154
|
+
repeated.set(token, [...(repeated.get(token) ?? []), value]);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
flags.set(token, value);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { command, positionals, flags, repeated, passthrough };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @param {Map<string, string|true>} flags @param {string} name @returns {string|undefined} */
|
|
164
|
+
function value(flags, name) {
|
|
165
|
+
const raw = flags.get(name);
|
|
166
|
+
return typeof raw === "string" ? raw : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {Map<string, string|true>} flags
|
|
171
|
+
* @param {string} fallback
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
function requireScope(flags, fallback) {
|
|
175
|
+
const scope = value(flags, "--scope") ?? fallback;
|
|
176
|
+
if (!SCOPES.includes(scope)) throw new UsageError(`unsupported scope: ${scope}. Use one of ${SCOPES.join(", ")}.`);
|
|
177
|
+
return scope;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {Map<string, string|true>} flags @returns {string} */
|
|
181
|
+
function projectPath(flags) {
|
|
182
|
+
return path.resolve(value(flags, "--project") ?? process.cwd());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {Map<string, string|true>} flags @param {string} scope @returns {string} */
|
|
186
|
+
function targetConfig(flags, scope) {
|
|
187
|
+
const explicit = value(flags, "--config");
|
|
188
|
+
if (explicit) {
|
|
189
|
+
if (path.basename(explicit) !== "mcp.json" && path.basename(explicit) !== ".mcp.json") {
|
|
190
|
+
throw new UsageError("--config must point at an mcp.json or .mcp.json file");
|
|
191
|
+
}
|
|
192
|
+
return path.resolve(explicit);
|
|
193
|
+
}
|
|
194
|
+
return configPathForScope(scope, { projectPath: projectPath(flags) });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {string} name @param {Map<string, string|true>} flags @returns {{scope: string, file: string, entry: Record<string, unknown>}} */
|
|
198
|
+
function locateServer(name, flags) {
|
|
199
|
+
const explicitScope = value(flags, "--scope");
|
|
200
|
+
const servers = collectServers({ projectPath: projectPath(flags) })
|
|
201
|
+
.filter((server) => server.name === name && (!explicitScope || server.scope === explicitScope));
|
|
202
|
+
if (servers.length === 0) {
|
|
203
|
+
throw new UsageError(
|
|
204
|
+
explicitScope
|
|
205
|
+
? `no server named ${name} in ${explicitScope} scope`
|
|
206
|
+
: `no server named ${name}. Run \`piagent-mcp list\` to see what is configured.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (servers.length > 1) {
|
|
210
|
+
throw new UsageError(
|
|
211
|
+
`${name} is defined in more than one scope (${servers.map((server) => server.scope).join(", ")}). ` +
|
|
212
|
+
"Pass --scope to say which one."
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return servers[0];
|
|
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
|
+
|
|
237
|
+
// --- commands ---------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/** @param {ReturnType<typeof parseArgs>} args */
|
|
240
|
+
function commandAdd(args) {
|
|
241
|
+
const name = args.positionals[0];
|
|
242
|
+
if (!name) throw new UsageError("add needs a server name");
|
|
243
|
+
const scope = requireScope(args.flags, "global");
|
|
244
|
+
const entry = buildServerEntry({
|
|
245
|
+
name,
|
|
246
|
+
url: value(args.flags, "--url") ?? undefined,
|
|
247
|
+
command: args.passthrough.length > 0 ? args.passthrough : undefined,
|
|
248
|
+
env: args.repeated.get("--env") ?? [],
|
|
249
|
+
header: args.repeated.get("--header") ?? [],
|
|
250
|
+
bearerTokenEnvVar: value(args.flags, "--bearer-token-env-var"),
|
|
251
|
+
oauthClientId: value(args.flags, "--oauth-client-id"),
|
|
252
|
+
oauthScopes: value(args.flags, "--oauth-scopes"),
|
|
253
|
+
directTools: args.flags.get("--direct-tools") === true
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const file = targetConfig(args.flags, scope);
|
|
257
|
+
const config = readMcpConfig(file);
|
|
258
|
+
const existed = Object.hasOwn(config.mcpServers, name);
|
|
259
|
+
if (existed && args.flags.get("--replace") !== true) {
|
|
260
|
+
throw new UsageError(`${name} already exists in ${scope} scope. Pass --replace to overwrite it.`);
|
|
261
|
+
}
|
|
262
|
+
config.mcpServers[name] = entry;
|
|
263
|
+
|
|
264
|
+
if (args.flags.get("--dry-run") === true) {
|
|
265
|
+
process.stdout.write(`${JSON.stringify({ ok: true, dryRun: true, name, scope, file, entry: maskServerEntry(entry) }, null, 2)}\n`);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
writeMcpConfig(file, config);
|
|
269
|
+
|
|
270
|
+
// A previous decision described a different server. Dropping it here means the
|
|
271
|
+
// operator is asked again rather than inheriting approval for what was replaced.
|
|
272
|
+
if (existed && REPOSITORY_SCOPES.has(scope)) clearApproval({ projectPath: projectPath(args.flags), name });
|
|
273
|
+
|
|
274
|
+
const transport = typeof entry.url === "string" ? `URL: ${entry.url}` : `command: ${[entry.command, ...(Array.isArray(entry.args) ? entry.args : [])].join(" ")}`;
|
|
275
|
+
process.stdout.write(`${existed ? "Replaced" : "Added"} ${typeof entry.url === "string" ? "remote" : "local"} MCP server ${name} with ${transport} in ${scope} scope\n`);
|
|
276
|
+
process.stdout.write(`File modified: ${file}\n`);
|
|
277
|
+
if (REPOSITORY_SCOPES.has(scope)) {
|
|
278
|
+
process.stdout.write(`This scope is part of the repository, so the server is not usable until approved: piagent-mcp approve ${name}\n`);
|
|
279
|
+
}
|
|
280
|
+
reportMissingEnv(entry);
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @param {Record<string, unknown>} entry */
|
|
285
|
+
function reportMissingEnv(entry) {
|
|
286
|
+
const readiness = evaluateServerReadiness({ name: "", scope: "global", entry }, { projectPath: process.cwd() });
|
|
287
|
+
if (readiness.state === "needs-env") {
|
|
288
|
+
process.stderr.write(`\nStill needed before it can connect: ${readiness.detail}.\n`);
|
|
289
|
+
}
|
|
290
|
+
if (readiness.state === "needs-command") {
|
|
291
|
+
process.stderr.write(`\nStill needed before it can connect: ${readiness.detail}.\n`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** @param {ReturnType<typeof parseArgs>} args */
|
|
296
|
+
function commandRemove(args) {
|
|
297
|
+
const name = args.positionals[0];
|
|
298
|
+
if (!name) throw new UsageError("remove needs a server name");
|
|
299
|
+
const located = locateServer(name, args.flags);
|
|
300
|
+
assertOwnedByThisTool(located, "remove");
|
|
301
|
+
const config = readMcpConfig(located.file);
|
|
302
|
+
delete config.mcpServers[name];
|
|
303
|
+
writeMcpConfig(located.file, config);
|
|
304
|
+
process.stdout.write(`Removed MCP server ${name} from ${located.scope} scope\n`);
|
|
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
|
+
}
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** @param {ReturnType<typeof parseArgs>} args */
|
|
319
|
+
function commandGet(args) {
|
|
320
|
+
const name = args.positionals[0];
|
|
321
|
+
if (!name) throw new UsageError("get needs a server name");
|
|
322
|
+
const detail = describeServer({
|
|
323
|
+
projectPath: projectPath(args.flags),
|
|
324
|
+
name,
|
|
325
|
+
scope: value(args.flags, "--scope"),
|
|
326
|
+
listHint: "piagent-mcp list"
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (args.flags.get("--json") === true) {
|
|
330
|
+
process.stdout.write(`${JSON.stringify({
|
|
331
|
+
name,
|
|
332
|
+
scope: detail.scope,
|
|
333
|
+
file: detail.file,
|
|
334
|
+
state: detail.readiness.state,
|
|
335
|
+
detail: detail.readiness.detail,
|
|
336
|
+
entry: detail.masked
|
|
337
|
+
}, null, 2)}\n`);
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const line of formatServerDetail(detail)) process.stdout.write(`${line}\n`);
|
|
342
|
+
process.stdout.write(` remove: piagent-mcp remove ${name} --scope ${detail.scope}\n`);
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** @param {ReturnType<typeof parseArgs>} args */
|
|
347
|
+
function commandList(args) {
|
|
348
|
+
const scopeFilter = value(args.flags, "--scope");
|
|
349
|
+
const project = projectPath(args.flags);
|
|
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;
|
|
356
|
+
|
|
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.
|
|
361
|
+
process.stdout.write(`${JSON.stringify({
|
|
362
|
+
project,
|
|
363
|
+
usable: state.usable,
|
|
364
|
+
blockingConfig: state.blockingConfig,
|
|
365
|
+
servers: rows.map((row) => ({
|
|
366
|
+
name: row.name,
|
|
367
|
+
scope: row.scope,
|
|
368
|
+
origin: row.origin,
|
|
369
|
+
requiresApproval: row.requiresApproval,
|
|
370
|
+
file: row.file,
|
|
371
|
+
state: row.readiness.state,
|
|
372
|
+
detail: row.readiness.detail,
|
|
373
|
+
entry: maskServerEntry(row.entry)
|
|
374
|
+
}))
|
|
375
|
+
}, null, 2)}\n`);
|
|
376
|
+
return state.usable ? 0 : 1;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const problem of state.blockingConfig) process.stdout.write(`BLOCKED every tool call: ${problem.detail}\n`);
|
|
380
|
+
|
|
381
|
+
if (rows.length === 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
|
+
}
|
|
391
|
+
}
|
|
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;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @param {ReturnType<typeof parseArgs>} args @param {boolean} enabled */
|
|
398
|
+
function commandToggle(args, enabled) {
|
|
399
|
+
const name = args.positionals[0];
|
|
400
|
+
if (!name) throw new UsageError(`${enabled ? "enable" : "disable"} needs a server name`);
|
|
401
|
+
const located = locateServer(name, args.flags);
|
|
402
|
+
assertOwnedByThisTool(located, enabled ? "enable" : "disable");
|
|
403
|
+
const config = readMcpConfig(located.file);
|
|
404
|
+
const entry = { ...(isRecord(config.mcpServers[name]) ? config.mcpServers[name] : {}) };
|
|
405
|
+
if (enabled) delete entry.enabled;
|
|
406
|
+
else entry.enabled = false;
|
|
407
|
+
config.mcpServers[name] = entry;
|
|
408
|
+
writeMcpConfig(located.file, config);
|
|
409
|
+
process.stdout.write(`${enabled ? "Enabled" : "Disabled"} MCP server ${name} in ${located.scope} scope\n`);
|
|
410
|
+
process.stdout.write(`File modified: ${located.file}\n`);
|
|
411
|
+
if (!enabled) process.stdout.write("The definition is kept, so `piagent-mcp enable` restores it.\n");
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @param {ReturnType<typeof parseArgs>} args @param {"approved"|"rejected"} decision */
|
|
416
|
+
function commandDecide(args, decision) {
|
|
417
|
+
const name = args.positionals[0];
|
|
418
|
+
if (!name) throw new UsageError(`${decision === "approved" ? "approve" : "reject"} needs a server name`);
|
|
419
|
+
assertServerName(name);
|
|
420
|
+
const project = projectPath(args.flags);
|
|
421
|
+
const servers = collectServers({ projectPath: project, scopes: [...REPOSITORY_SCOPES] })
|
|
422
|
+
.filter((server) => server.name === name);
|
|
423
|
+
if (servers.length === 0) {
|
|
424
|
+
throw new UsageError(
|
|
425
|
+
`${name} is not defined by this repository. Approval only applies to servers in ${[...REPOSITORY_SCOPES].join(" or ")} scope.`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
if (servers.length > 1) throw new UsageError(`${name} is defined in ${servers.map((server) => server.scope).join(" and ")}; remove one.`);
|
|
429
|
+
|
|
430
|
+
const server = servers[0];
|
|
431
|
+
const before = approvalState({ projectPath: project, name, entry: server.entry });
|
|
432
|
+
if (decision === "approved" && before.state === "pending" && args.flags.get("--force") !== true) {
|
|
433
|
+
// Approving without reading it is the failure this gate exists to prevent, so
|
|
434
|
+
// the definition is put in front of the operator before the decision lands.
|
|
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}`);
|
|
447
|
+
}
|
|
448
|
+
const after = approvalState({ projectPath: project, name, entry: server.entry });
|
|
449
|
+
process.stdout.write(`${decision === "approved" ? "Approved" : "Rejected"} MCP server ${name} (${server.scope}) for ${project}\n`);
|
|
450
|
+
process.stdout.write(`Pinned to this definition: ${after.digest}\n`);
|
|
451
|
+
process.stdout.write("Changing the server's command, URL or arguments returns it to pending.\n");
|
|
452
|
+
return 0;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** @param {ReturnType<typeof parseArgs>} args */
|
|
456
|
+
function commandReset(args) {
|
|
457
|
+
const project = projectPath(args.flags);
|
|
458
|
+
const name = args.positionals[0];
|
|
459
|
+
if (!clearApproval({ projectPath: project, name })) {
|
|
460
|
+
throw new UsageError(`could not write ${approvalStorePath() ?? "the approval store"}; nothing was forgotten`);
|
|
461
|
+
}
|
|
462
|
+
process.stdout.write(name
|
|
463
|
+
? `Forgot the decision for ${name} in ${project}\n`
|
|
464
|
+
: `Forgot every MCP approval decision for ${project}\n`);
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** @param {ReturnType<typeof parseArgs>} args */
|
|
469
|
+
function commandDoctor(args) {
|
|
470
|
+
const project = projectPath(args.flags);
|
|
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;
|
|
477
|
+
|
|
478
|
+
// The one check too slow for a session start: a daemon that is installed but
|
|
479
|
+
// not running looks exactly like one that is running until something asks it.
|
|
480
|
+
let dockerRunning;
|
|
481
|
+
if (usesDocker(rows)) {
|
|
482
|
+
const probe = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 });
|
|
483
|
+
dockerRunning = probe.status === 0;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (args.flags.get("--json") === true) {
|
|
487
|
+
process.stdout.write(`${JSON.stringify({
|
|
488
|
+
project,
|
|
489
|
+
dockerRunning,
|
|
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
|
|
494
|
+
}, null, 2)}\n`);
|
|
495
|
+
return state.blocked.length > 0 || broken.length > 0 || unverifiable.length > 0 ? 1 : 0;
|
|
496
|
+
}
|
|
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
|
+
|
|
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
|
+
}
|
|
506
|
+
process.stdout.write("No MCP servers configured.\n");
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const report = formatDoctorReport(rows, { dockerRunning, rerun: "piagent-mcp doctor" });
|
|
511
|
+
for (const line of report.lines) process.stdout.write(`${line}\n`);
|
|
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
|
|
527
|
+
? "\nPASS: every configured MCP server can be reached.\n"
|
|
528
|
+
: `\n${problems} MCP problem(s) need attention.\n`);
|
|
529
|
+
return problems === 0 ? 0 : 1;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** @param {ReturnType<typeof parseArgs>} args */
|
|
533
|
+
function commandPreset(args) {
|
|
534
|
+
if (args.flags.get("--list") === true) {
|
|
535
|
+
process.stdout.write(`${JSON.stringify({
|
|
536
|
+
presets: CATALOG_PRESETS,
|
|
537
|
+
servers: Object.fromEntries(Object.entries(CATALOG_SERVERS).map(([name, server]) => [
|
|
538
|
+
name,
|
|
539
|
+
{ description: server.description, requires: CATALOG_PREREQUISITES[name] ?? [] }
|
|
540
|
+
]))
|
|
541
|
+
}, null, 2)}\n`);
|
|
542
|
+
return 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const presetName = value(args.flags, "--preset") ?? "core";
|
|
546
|
+
if (!presetName) throw new UsageError("--preset cannot be empty");
|
|
547
|
+
if (!isCatalogPreset(presetName)) {
|
|
548
|
+
throw new UsageError(`unknown preset: ${presetName}. Available presets: ${Object.keys(CATALOG_PRESETS).join(", ")}`);
|
|
549
|
+
}
|
|
550
|
+
// Answering the name question and stopping, so a caller can ask before it
|
|
551
|
+
// installs rather than discovering the typo after two packages have landed.
|
|
552
|
+
if (args.flags.get("--validate-preset") === true) {
|
|
553
|
+
process.stdout.write(`${presetName}\n`);
|
|
554
|
+
return 0;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const scope = requireScope(args.flags, "global");
|
|
558
|
+
const file = targetConfig(args.flags, scope);
|
|
559
|
+
const config = readMcpConfig(file);
|
|
560
|
+
const replace = args.flags.get("--replace") === true;
|
|
561
|
+
const dryRun = args.flags.get("--dry-run") === true;
|
|
562
|
+
|
|
563
|
+
const added = [];
|
|
564
|
+
const kept = [];
|
|
565
|
+
const replaced = [];
|
|
566
|
+
for (const server of resolvePreset(presetName)) {
|
|
567
|
+
if (Object.hasOwn(config.mcpServers, server.name)) {
|
|
568
|
+
if (!replace) {
|
|
569
|
+
kept.push(server.name);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
config.mcpServers[server.name] = server.config;
|
|
573
|
+
replaced.push(server.name);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
config.mcpServers[server.name] = server.config;
|
|
577
|
+
added.push(server.name);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const report = {
|
|
581
|
+
configPath: file,
|
|
582
|
+
preset: presetName,
|
|
583
|
+
dryRun,
|
|
584
|
+
replace,
|
|
585
|
+
added,
|
|
586
|
+
kept,
|
|
587
|
+
replaced,
|
|
588
|
+
serverCount: Object.keys(config.mcpServers).length
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
if (dryRun) {
|
|
592
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
593
|
+
process.stdout.write(`${JSON.stringify({ settings: config.settings, mcpServers: config.mcpServers }, null, 2)}\n`);
|
|
594
|
+
return 0;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
writeMcpConfig(file, config);
|
|
598
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
599
|
+
|
|
600
|
+
// stdout stays machine-readable; the operator note goes to stderr.
|
|
601
|
+
const lines = resolvePreset(presetName).flatMap((server) => server.requires.map((item) => ` ${server.name}: ${item}`));
|
|
602
|
+
if (lines.length > 0) {
|
|
603
|
+
process.stderr.write("\nServer definitions are written. Each still needs this before it can connect:\n");
|
|
604
|
+
for (const line of lines) process.stderr.write(`${line}\n`);
|
|
605
|
+
process.stderr.write("Servers connect lazily, so nothing above is checked until the first call.\n");
|
|
606
|
+
process.stderr.write("Run `piagent-mcp doctor` to see which ones are ready on this machine.\n");
|
|
607
|
+
}
|
|
608
|
+
return 0;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// --- entry ------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
function main(argv) {
|
|
614
|
+
let args;
|
|
615
|
+
try {
|
|
616
|
+
args = parseArgs(argv);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
process.stderr.write(`FAIL: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
619
|
+
usage();
|
|
620
|
+
return 2;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (args.flags.get("--help") === true || (!args.command && argv.length === 0)) {
|
|
624
|
+
usage();
|
|
625
|
+
return 0;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
switch (args.command || "preset") {
|
|
630
|
+
case "add": return commandAdd(args);
|
|
631
|
+
case "remove": return commandRemove(args);
|
|
632
|
+
case "get": return commandGet(args);
|
|
633
|
+
case "list": return commandList(args);
|
|
634
|
+
case "enable": return commandToggle(args, true);
|
|
635
|
+
case "disable": return commandToggle(args, false);
|
|
636
|
+
case "approve": return commandDecide(args, "approved");
|
|
637
|
+
case "reject": return commandDecide(args, "rejected");
|
|
638
|
+
case "reset": return commandReset(args);
|
|
639
|
+
case "doctor": return commandDoctor(args);
|
|
640
|
+
default: return commandPreset(args);
|
|
641
|
+
}
|
|
642
|
+
} catch (error) {
|
|
643
|
+
if (error instanceof UsageError || error instanceof McpEntryError || error instanceof McpConfigError || error instanceof McpViewError) {
|
|
644
|
+
process.stderr.write(`FAIL: ${error.message}\n`);
|
|
645
|
+
return 2;
|
|
646
|
+
}
|
|
647
|
+
process.stderr.write(`FAIL: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
648
|
+
return 1;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
653
|
+
process.exit(main(process.argv.slice(2)));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export { main };
|
|
@@ -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
|
-
|
|
41
|
+
require_value "$1" "${2:-}"
|
|
42
|
+
PROVIDER="$2"
|
|
33
43
|
shift 2
|
|
34
44
|
;;
|
|
35
45
|
-h|--help)
|