@piagent/platform 1.1.8 → 1.2.0
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 +51 -1
- package/README.md +2 -2
- package/docs/command-reference-vietnamese.md +13 -4
- package/docs/distribution-standard.md +7 -7
- package/docs/mcp-and-tools.md +108 -2
- package/docs/model-options.md +10 -8
- package/docs/operator-manual-vietnamese.md +12 -12
- 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 +1 -1
- package/docs/team-onboarding.md +4 -4
- 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 +81 -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 +204 -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 +17 -1
- package/packages/piagent-core/extensions/document-intake.ts +5 -5
- package/packages/piagent-core/extensions/piagent-guard.ts +319 -4
- package/packages/piagent-core/extensions/update-check.js +41 -0
- package/packages/piagent-core/mcp/mcp-approval-store.js +175 -0
- package/packages/piagent-core/mcp/mcp-auth-readiness.js +197 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +291 -0
- package/packages/piagent-core/mcp/mcp-config-layers.js +133 -0
- package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
- package/packages/piagent-core/mcp/mcp-server-entry.js +242 -0
- package/packages/piagent-core/mcp/mcp-session-view.js +185 -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 +2 -2
- package/scripts/configure-subagents.sh +1 -1
- package/scripts/install-global.sh +5 -5
- package/scripts/mcp-manage.mjs +567 -0
- package/scripts/piagent-cli.mjs +1 -1
- package/scripts/preview-docs-site.mjs +88 -0
- package/scripts/setup.sh +2 -2
- package/scripts/team-doctor.sh +18 -2
- package/scripts/verify-local.sh +46 -19
- package/scripts/verify-release-identity.mjs +8 -2
- package/scripts/configure-mcp.sh +0 -349
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { REPOSITORY_SCOPES } from "./mcp-config-layers.js";
|
|
4
|
+
import { approvalState } from "./mcp-approval-store.js";
|
|
5
|
+
import { CATALOG_OPTIONAL_ENV } from "./mcp-server-catalog.js";
|
|
6
|
+
|
|
7
|
+
// Whether a configured server can actually be used, answered from state this
|
|
8
|
+
// process already has. No network, no child process: this runs at session start,
|
|
9
|
+
// and a check that costs a round trip would be paid on every session for
|
|
10
|
+
// information that changes when somebody edits a config.
|
|
11
|
+
//
|
|
12
|
+
// The line it will not cross is guessing. Two things are knowable here — an
|
|
13
|
+
// environment variable a config references is set or it is not, an executable is
|
|
14
|
+
// on PATH or it is not — and those are reported. Whether an OAuth sign-in is
|
|
15
|
+
// still valid is known to Pi and not to this file, so it is reported as
|
|
16
|
+
// "sign-in is handled elsewhere" rather than as a failure that may not be one.
|
|
17
|
+
|
|
18
|
+
const ENV_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {"disabled"|"rejected"|"approval-changed"|"pending-approval"|"needs-command"|"needs-env"|"oauth"|"ready"} ReadinessState
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* States that mean the server will not answer a tool call. `oauth` is not one of
|
|
26
|
+
* them: it may well be signed in already.
|
|
27
|
+
* @type {Set<ReadinessState>}
|
|
28
|
+
*/
|
|
29
|
+
export const BLOCKING_STATES = new Set([
|
|
30
|
+
"disabled", "rejected", "approval-changed", "pending-approval", "needs-command", "needs-env"
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Every `${VAR}` a server entry references, wherever it appears.
|
|
35
|
+
* @param {unknown} entry
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
export function referencedEnvVars(entry) {
|
|
39
|
+
const names = new Set();
|
|
40
|
+
walk(entry, (text) => {
|
|
41
|
+
for (const match of text.matchAll(ENV_REFERENCE)) names.add(match[1]);
|
|
42
|
+
});
|
|
43
|
+
return [...names].sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {unknown} value @param {(text: string) => void} visit @returns {void} */
|
|
47
|
+
function walk(value, visit) {
|
|
48
|
+
if (typeof value === "string") {
|
|
49
|
+
visit(value);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
for (const item of value) walk(item, visit);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (value && typeof value === "object") {
|
|
57
|
+
for (const item of Object.values(value)) walk(item, visit);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} command
|
|
63
|
+
* @param {{env?: Record<string, string|undefined>}} [options]
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
export function commandOnPath(command, options = {}) {
|
|
67
|
+
const env = options.env ?? process.env;
|
|
68
|
+
if (typeof command !== "string" || command.length === 0) return false;
|
|
69
|
+
// An explicit path is checked where it points rather than searched for.
|
|
70
|
+
if (command.includes(path.sep)) return isExecutable(command);
|
|
71
|
+
for (const directory of (env.PATH ?? "").split(path.delimiter)) {
|
|
72
|
+
if (!directory) continue;
|
|
73
|
+
if (isExecutable(path.join(directory, command))) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {string} file @returns {boolean} */
|
|
79
|
+
function isExecutable(file) {
|
|
80
|
+
try {
|
|
81
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
82
|
+
return fs.statSync(file).isFile();
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {object} ServerReadiness
|
|
90
|
+
* @property {string} name
|
|
91
|
+
* @property {string} scope
|
|
92
|
+
* @property {ReadinessState} state
|
|
93
|
+
* @property {string} detail human-readable, never contains a value
|
|
94
|
+
* @property {string} [remedy] the command that resolves it
|
|
95
|
+
* @property {string[]} missingEnv
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {{name: string, scope: string, entry: Record<string, unknown>}} server
|
|
100
|
+
* @param {{projectPath: string, env?: Record<string, string|undefined>, home?: string, store?: Record<string, unknown>}} options
|
|
101
|
+
* @returns {ServerReadiness}
|
|
102
|
+
*/
|
|
103
|
+
export function evaluateServerReadiness(server, options) {
|
|
104
|
+
const env = options.env ?? process.env;
|
|
105
|
+
const base = { name: server.name, scope: server.scope, missingEnv: /** @type {string[]} */ ([]) };
|
|
106
|
+
|
|
107
|
+
if (server.entry.enabled === false) {
|
|
108
|
+
return { ...base, state: "disabled", detail: "disabled in config", remedy: `piagent-mcp enable ${server.name} --scope ${server.scope}` };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (REPOSITORY_SCOPES.has(server.scope)) {
|
|
112
|
+
const approval = approvalState({
|
|
113
|
+
projectPath: options.projectPath,
|
|
114
|
+
name: server.name,
|
|
115
|
+
entry: server.entry,
|
|
116
|
+
store: options.store,
|
|
117
|
+
home: options.home
|
|
118
|
+
});
|
|
119
|
+
if (approval.state === "rejected") {
|
|
120
|
+
return { ...base, state: "rejected", detail: "rejected for this project", remedy: `piagent-mcp approve ${server.name}` };
|
|
121
|
+
}
|
|
122
|
+
if (approval.state === "changed") {
|
|
123
|
+
return {
|
|
124
|
+
...base,
|
|
125
|
+
state: "approval-changed",
|
|
126
|
+
detail: "definition changed since it was approved",
|
|
127
|
+
remedy: `piagent-mcp get ${server.name} then piagent-mcp approve ${server.name}`
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (approval.state === "pending") {
|
|
131
|
+
return {
|
|
132
|
+
...base,
|
|
133
|
+
state: "pending-approval",
|
|
134
|
+
detail: `defined by this repository in ${server.scope} scope and not approved here`,
|
|
135
|
+
remedy: `piagent-mcp approve ${server.name}`
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof server.entry.command === "string" && !commandOnPath(server.entry.command, { env })) {
|
|
141
|
+
return {
|
|
142
|
+
...base,
|
|
143
|
+
state: "needs-command",
|
|
144
|
+
detail: `${server.entry.command} is not on PATH`,
|
|
145
|
+
remedy: `install ${server.entry.command}, or edit the server with piagent-mcp add ${server.name} --scope ${server.scope} -- <command>`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const optional = new Set(CATALOG_OPTIONAL_ENV[server.name] ?? []);
|
|
150
|
+
const missingEnv = referencedEnvVars(server.entry)
|
|
151
|
+
.filter((name) => !optional.has(name))
|
|
152
|
+
.filter((name) => !env[name]?.trim());
|
|
153
|
+
if (missingEnv.length > 0) {
|
|
154
|
+
return {
|
|
155
|
+
...base,
|
|
156
|
+
missingEnv,
|
|
157
|
+
state: "needs-env",
|
|
158
|
+
detail: `${missingEnv.join(", ")} ${missingEnv.length === 1 ? "is" : "are"} referenced but not set`,
|
|
159
|
+
remedy: `export ${missingEnv[0]}=... in your shell profile`
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (server.entry.auth === "oauth") {
|
|
164
|
+
return {
|
|
165
|
+
...base,
|
|
166
|
+
state: "oauth",
|
|
167
|
+
detail: "signs in with OAuth; Pi holds the session",
|
|
168
|
+
remedy: `/mcp-auth ${server.name} inside Pi if it is not signed in`
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { ...base, state: "ready", detail: "ready" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One line for the session start, or nothing when every server is usable.
|
|
177
|
+
*
|
|
178
|
+
* Only definite problems are named. A server waiting on OAuth is left out
|
|
179
|
+
* because this process cannot tell a signed-in one from a signed-out one, and a
|
|
180
|
+
* notice that appears every session regardless of state stops being read.
|
|
181
|
+
*
|
|
182
|
+
* @param {ServerReadiness[]} readiness
|
|
183
|
+
* @returns {string|undefined}
|
|
184
|
+
*/
|
|
185
|
+
export function readinessNotice(readiness) {
|
|
186
|
+
const blocked = readiness.filter((item) => BLOCKING_STATES.has(item.state));
|
|
187
|
+
if (blocked.length === 0) return undefined;
|
|
188
|
+
const waiting = blocked.filter((item) => item.state === "pending-approval" || item.state === "approval-changed");
|
|
189
|
+
const parts = blocked.slice(0, 4).map((item) => `${item.name} (${item.detail})`);
|
|
190
|
+
const overflow = blocked.length > parts.length ? `, and ${blocked.length - parts.length} more` : "";
|
|
191
|
+
const lead = waiting.length > 0
|
|
192
|
+
? "MCP servers need a decision or setup"
|
|
193
|
+
: "MCP servers are configured but cannot connect";
|
|
194
|
+
// This line is only ever printed at session start, so it names the command
|
|
195
|
+
// that answers it from there rather than sending the reader to a shell.
|
|
196
|
+
return `${lead}: ${parts.join("; ")}${overflow}. Run \`/piagent-mcp doctor\` for what each one needs.`;
|
|
197
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { REPOSITORY_SCOPES, collectServers, isRecord, readMcpConfig, writeMcpConfig } from "./mcp-config-layers.js";
|
|
2
|
+
import { approvalState, clearApproval, recordApproval } from "./mcp-approval-store.js";
|
|
3
|
+
import { maskServerEntry } from "./mcp-server-entry.js";
|
|
4
|
+
import {
|
|
5
|
+
McpViewError,
|
|
6
|
+
blockedRows,
|
|
7
|
+
collectServerRows,
|
|
8
|
+
describeServer,
|
|
9
|
+
formatDoctorReport,
|
|
10
|
+
formatServerDetail,
|
|
11
|
+
formatServerTable
|
|
12
|
+
} from "./mcp-session-view.js";
|
|
13
|
+
|
|
14
|
+
// What `/piagent-mcp` does, separated from how a session shows it.
|
|
15
|
+
//
|
|
16
|
+
// Each action takes plain options and returns a report: a one-line notice, the
|
|
17
|
+
// lines to display, and the structured detail behind them. Nothing here knows
|
|
18
|
+
// about `pi`, a UI, or a terminal, so the menu path and the typed-subcommand
|
|
19
|
+
// path cannot drift into doing different things — they call the same function.
|
|
20
|
+
|
|
21
|
+
export { McpViewError };
|
|
22
|
+
|
|
23
|
+
/** Actions that only read. The rest write a config file or an approval. */
|
|
24
|
+
export const READ_ACTIONS = new Set(["status", "get", "doctor"]);
|
|
25
|
+
|
|
26
|
+
/** Actions that name a single server. `reset` accepts one but does not need it. */
|
|
27
|
+
export const SERVER_ACTIONS = new Set(["get", "approve", "reject", "reset", "enable", "disable"]);
|
|
28
|
+
|
|
29
|
+
/** Writes that belong in a shell, with the command to run there. */
|
|
30
|
+
export const TERMINAL_ONLY = new Map([
|
|
31
|
+
["add", "piagent-mcp add <name> [--scope <scope>] --url <url> | -- <command> [args...]"],
|
|
32
|
+
["remove", "piagent-mcp remove <name> --scope <scope>"],
|
|
33
|
+
["preset", "piagent-mcp --preset <core|popular|design> --scope <scope>"]
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export const HELP_LINES = [
|
|
37
|
+
"/piagent-mcp open the menu",
|
|
38
|
+
"/piagent-mcp status every configured server, with scope and readiness",
|
|
39
|
+
"/piagent-mcp get <name> one server in detail, credential values masked",
|
|
40
|
+
"/piagent-mcp doctor what each server still needs before it can answer",
|
|
41
|
+
"/piagent-mcp approve <name> allow a server this repository defines",
|
|
42
|
+
"/piagent-mcp reject <name> refuse it",
|
|
43
|
+
"/piagent-mcp reset [name] forget the decision, or every decision",
|
|
44
|
+
"/piagent-mcp enable|disable <name>",
|
|
45
|
+
"",
|
|
46
|
+
"Adding, removing and presets are terminal commands: run `piagent-mcp --help`.",
|
|
47
|
+
"Live connection state and OAuth sign-in belong to the adapter: /mcp and /mcp-auth."
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The menu, built from what this project actually has.
|
|
52
|
+
*
|
|
53
|
+
* An entry is offered only when it would do something: approving is not offered
|
|
54
|
+
* when no server here needs a decision, and a menu that lists impossible actions
|
|
55
|
+
* teaches the reader to stop reading it. Counts go in the labels so the menu
|
|
56
|
+
* answers part of the question before anything is picked.
|
|
57
|
+
*
|
|
58
|
+
* @param {{projectPath: string}} options
|
|
59
|
+
*/
|
|
60
|
+
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));
|
|
64
|
+
const decisions = repositoryRows.map((row) => ({
|
|
65
|
+
row,
|
|
66
|
+
state: approvalState({ projectPath: options.projectPath, name: row.name, entry: row.entry }).state
|
|
67
|
+
}));
|
|
68
|
+
const undecided = decisions.filter((item) => item.state !== "approved");
|
|
69
|
+
const decided = decisions.filter((item) => item.state !== "pending");
|
|
70
|
+
const disabled = rows.filter((row) => row.readiness.state === "disabled");
|
|
71
|
+
const enabled = rows.filter((row) => row.readiness.state !== "disabled");
|
|
72
|
+
|
|
73
|
+
const entries = [
|
|
74
|
+
{
|
|
75
|
+
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"
|
|
78
|
+
}
|
|
79
|
+
];
|
|
80
|
+
if (stuck.length > 0) {
|
|
81
|
+
entries.push({
|
|
82
|
+
value: "doctor",
|
|
83
|
+
label: `Doctor — ${stuck.length} need attention`,
|
|
84
|
+
description: "What each blocked server still needs",
|
|
85
|
+
recommended: true
|
|
86
|
+
});
|
|
87
|
+
} else if (rows.length > 0) {
|
|
88
|
+
entries.push({ value: "doctor", label: "Doctor — all reachable", description: "Re-check what each server needs" });
|
|
89
|
+
}
|
|
90
|
+
if (rows.length > 0) {
|
|
91
|
+
entries.push({ value: "get", label: "Inspect a server", description: "Full definition, credential values masked" });
|
|
92
|
+
}
|
|
93
|
+
if (undecided.length > 0) {
|
|
94
|
+
entries.push({
|
|
95
|
+
value: "approve",
|
|
96
|
+
label: `Approve a repository server — ${undecided.length} waiting`,
|
|
97
|
+
description: "This repository defines it; nobody on this machine has allowed it yet",
|
|
98
|
+
recommended: stuck.length === 0
|
|
99
|
+
});
|
|
100
|
+
entries.push({ value: "reject", label: "Reject a repository server", description: "Refuse it for this project" });
|
|
101
|
+
} else if (repositoryRows.length > 0) {
|
|
102
|
+
entries.push({ value: "reject", label: "Reject a repository server", description: "Withdraw a server this project had allowed" });
|
|
103
|
+
}
|
|
104
|
+
if (decided.length > 0) {
|
|
105
|
+
entries.push({ value: "reset", label: "Forget a decision", description: "Return a server to pending so it is asked again" });
|
|
106
|
+
}
|
|
107
|
+
if (enabled.length > 0) {
|
|
108
|
+
entries.push({ value: "disable", label: "Turn a server off", description: "Stop using it without losing its definition" });
|
|
109
|
+
}
|
|
110
|
+
if (disabled.length > 0) {
|
|
111
|
+
entries.push({ value: "enable", label: `Turn a server back on — ${disabled.length} off`, description: "Restore a disabled server" });
|
|
112
|
+
}
|
|
113
|
+
entries.push({ value: "add", label: "Add a server", description: "Shows the terminal command; writing config is not a slash command" });
|
|
114
|
+
entries.push({ value: "help", label: "All subcommands", description: "Everything /piagent-mcp accepts" });
|
|
115
|
+
return { entries, rows };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Servers an action can be applied to. Approving a server the repository does
|
|
120
|
+
* not define is not a thing, so it is not offered.
|
|
121
|
+
*
|
|
122
|
+
* @param {{projectPath: string, action: string}} options
|
|
123
|
+
*/
|
|
124
|
+
export function serverChoices(options) {
|
|
125
|
+
const { projectPath, action } = options;
|
|
126
|
+
const rows = collectServerRows({ projectPath });
|
|
127
|
+
const filtered = rows.filter((row) => {
|
|
128
|
+
if (action === "approve" || action === "reject" || action === "reset") return REPOSITORY_SCOPES.has(row.scope);
|
|
129
|
+
if (action === "enable") return row.readiness.state === "disabled";
|
|
130
|
+
if (action === "disable") return row.readiness.state !== "disabled";
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
return filtered.map((row) => ({
|
|
134
|
+
value: row.name,
|
|
135
|
+
label: `${row.name} (${row.scope})`,
|
|
136
|
+
description: row.readiness.detail
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @param {{projectPath: string, scope?: string}} options */
|
|
141
|
+
export function status(options) {
|
|
142
|
+
const rows = collectServerRows({ projectPath: options.projectPath, scopes: options.scope ? [options.scope] : undefined });
|
|
143
|
+
const stuck = blockedRows(rows);
|
|
144
|
+
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.`] : [])],
|
|
152
|
+
details: {
|
|
153
|
+
project: options.projectPath,
|
|
154
|
+
servers: rows.map((row) => ({ name: row.name, scope: row.scope, state: row.readiness.state, detail: row.readiness.detail }))
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {{projectPath: string, name: string, scope?: string}} options */
|
|
160
|
+
export function detail(options) {
|
|
161
|
+
const found = describeServer({
|
|
162
|
+
projectPath: options.projectPath,
|
|
163
|
+
name: options.name,
|
|
164
|
+
scope: options.scope,
|
|
165
|
+
listHint: "/piagent-mcp",
|
|
166
|
+
scopeHint: "--scope"
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
notify: { message: `MCP ${options.name}: ${found.readiness.state}`, level: "info" },
|
|
170
|
+
lines: formatServerDetail(found, { inSession: true }),
|
|
171
|
+
details: { name: options.name, scope: found.scope, file: found.file, state: found.readiness.state, entry: found.masked }
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The Docker daemon probe is deliberately absent: it spawns a process, and this
|
|
177
|
+
* runs inside the session's own turn. `piagent-mcp doctor` in a terminal does it.
|
|
178
|
+
*
|
|
179
|
+
* @param {{projectPath: string}} options
|
|
180
|
+
*/
|
|
181
|
+
export function doctor(options) {
|
|
182
|
+
const rows = collectServerRows({ projectPath: options.projectPath });
|
|
183
|
+
const report = formatDoctorReport(rows, { rerun: "/piagent-mcp doctor", inSession: true });
|
|
184
|
+
return {
|
|
185
|
+
notify: {
|
|
186
|
+
message: report.problems === 0 ? "MCP: every server can be reached" : `MCP: ${report.problems} need attention`,
|
|
187
|
+
level: report.problems === 0 ? "info" : "warning"
|
|
188
|
+
},
|
|
189
|
+
lines: [
|
|
190
|
+
...report.lines,
|
|
191
|
+
...(report.problems === 0 ? [] : ["", "The Docker daemon check is terminal-only: piagent-mcp doctor"])
|
|
192
|
+
],
|
|
193
|
+
details: { project: options.projectPath, problems: report.problems }
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {{projectPath: string, name: string, decision: "approved"|"rejected"}} options */
|
|
198
|
+
export function decide(options) {
|
|
199
|
+
const { projectPath, name, decision } = options;
|
|
200
|
+
const matches = collectServers({ projectPath, scopes: [...REPOSITORY_SCOPES] }).filter((server) => server.name === name);
|
|
201
|
+
if (matches.length === 0) {
|
|
202
|
+
throw new McpViewError(
|
|
203
|
+
`${name} is not defined by this repository. Approval only applies to servers in ${[...REPOSITORY_SCOPES].join(" or ")} scope.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (matches.length > 1) {
|
|
207
|
+
throw new McpViewError(`${name} is defined in ${matches.map((server) => server.scope).join(" and ")}; remove one.`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const server = matches[0];
|
|
211
|
+
const before = approvalState({ projectPath, name, entry: server.entry });
|
|
212
|
+
// Approving without reading it is the failure this gate exists to prevent, so
|
|
213
|
+
// the definition is put in front of the operator alongside the decision.
|
|
214
|
+
const preview = decision === "approved" && before.state !== "approved"
|
|
215
|
+
? [JSON.stringify(maskServerEntry(server.entry), null, 2), ""]
|
|
216
|
+
: [];
|
|
217
|
+
recordApproval({ projectPath, name, entry: server.entry, decision });
|
|
218
|
+
const after = approvalState({ projectPath, name, entry: server.entry });
|
|
219
|
+
return {
|
|
220
|
+
notify: { message: `MCP ${name}: ${decision}`, level: "info" },
|
|
221
|
+
lines: [
|
|
222
|
+
...preview,
|
|
223
|
+
`${decision === "approved" ? "Approved" : "Rejected"} MCP server ${name} (${server.scope}) for ${projectPath}`,
|
|
224
|
+
`Pinned to this definition: ${after.digest}`,
|
|
225
|
+
"Changing the server's command, URL or arguments returns it to pending."
|
|
226
|
+
],
|
|
227
|
+
details: { name, scope: server.scope, decision, digest: after.digest, previous: before.state }
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {{projectPath: string, name?: string}} options */
|
|
232
|
+
export function reset(options) {
|
|
233
|
+
const { projectPath, name } = options;
|
|
234
|
+
clearApproval({ projectPath, name });
|
|
235
|
+
return {
|
|
236
|
+
notify: { message: name ? `MCP ${name}: decision forgotten` : "MCP: all decisions forgotten", level: "info" },
|
|
237
|
+
lines: [name
|
|
238
|
+
? `Forgot the decision for ${name} in ${projectPath}`
|
|
239
|
+
: `Forgot every MCP approval decision for ${projectPath}`],
|
|
240
|
+
details: { name: name ?? null, decision: "reset" }
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @param {{projectPath: string, name: string, scope?: string, enabled: boolean}} options */
|
|
245
|
+
export function toggle(options) {
|
|
246
|
+
const { projectPath, name, scope, enabled } = options;
|
|
247
|
+
const found = describeServer({ projectPath, name, scope, listHint: "/piagent-mcp", scopeHint: "--scope" });
|
|
248
|
+
const config = readMcpConfig(found.file);
|
|
249
|
+
const entry = { ...(isRecord(config.mcpServers[name]) ? config.mcpServers[name] : {}) };
|
|
250
|
+
if (enabled) delete entry.enabled;
|
|
251
|
+
else entry.enabled = false;
|
|
252
|
+
config.mcpServers[name] = entry;
|
|
253
|
+
writeMcpConfig(found.file, config);
|
|
254
|
+
return {
|
|
255
|
+
notify: { message: `MCP ${name}: ${enabled ? "enabled" : "disabled"}`, level: "info" },
|
|
256
|
+
lines: [
|
|
257
|
+
`${enabled ? "Enabled" : "Disabled"} MCP server ${name} in ${found.scope} scope`,
|
|
258
|
+
`File modified: ${found.file}`,
|
|
259
|
+
...(enabled ? [] : ["The definition is kept, so /piagent-mcp enable restores it."]),
|
|
260
|
+
"The adapter reads this at startup: run /reload for it to take effect in this session."
|
|
261
|
+
],
|
|
262
|
+
details: { name, scope: found.scope, file: found.file, enabled }
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @param {string} action */
|
|
267
|
+
export function terminalOnly(action) {
|
|
268
|
+
const command = TERMINAL_ONLY.get(action);
|
|
269
|
+
if (!command) return undefined;
|
|
270
|
+
return {
|
|
271
|
+
notify: { message: `MCP: ${action} is a terminal command`, level: "warning" },
|
|
272
|
+
// Why this line sits where it does belongs in the docs. What an operator
|
|
273
|
+
// needs here is the command to run and the reason it is not this one.
|
|
274
|
+
lines: [
|
|
275
|
+
`${action} writes a server definition, so it stays in the terminal:`,
|
|
276
|
+
` ${command}`,
|
|
277
|
+
"",
|
|
278
|
+
"Shell quoting, ${VAR} references and a `--` command line are a shell's job.",
|
|
279
|
+
"Re-reading them here would be a second, worse parser."
|
|
280
|
+
],
|
|
281
|
+
details: { action, command }
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function help() {
|
|
286
|
+
return {
|
|
287
|
+
notify: { message: "MCP: subcommands", level: "info" },
|
|
288
|
+
lines: HELP_LINES,
|
|
289
|
+
details: { actions: [...READ_ACTIONS, ...SERVER_ACTIONS] }
|
|
290
|
+
};
|
|
291
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Four files can define MCP servers, and which one a change belongs in is the
|
|
5
|
+
// first thing anyone gets wrong. The scope names are the vocabulary the CLI
|
|
6
|
+
// exposes, so they are resolved in exactly one place.
|
|
7
|
+
//
|
|
8
|
+
// `global` and `pi-project` are the two that matter for safety. `global` is
|
|
9
|
+
// outside every repository, so nothing a repository contains can reach it.
|
|
10
|
+
// `project` is the file a repository can carry, which is why servers found there
|
|
11
|
+
// are the ones the approval gate covers.
|
|
12
|
+
|
|
13
|
+
const BASELINE_SETTINGS = {
|
|
14
|
+
toolPrefix: "server",
|
|
15
|
+
directTools: false,
|
|
16
|
+
idleTimeout: 10,
|
|
17
|
+
outputGuard: true
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const SCOPES = ["global", "pi-global", "project", "pi-project"];
|
|
21
|
+
|
|
22
|
+
/** Scopes whose file a repository can carry, and which therefore need approval. */
|
|
23
|
+
export const REPOSITORY_SCOPES = new Set(["project", "pi-project"]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} scope
|
|
27
|
+
* @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string}} [options]
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function configPathForScope(scope, options = {}) {
|
|
31
|
+
const env = options.env ?? process.env;
|
|
32
|
+
const home = options.home ?? env.HOME ?? "";
|
|
33
|
+
const projectPath = options.projectPath ?? process.cwd();
|
|
34
|
+
switch (scope) {
|
|
35
|
+
case "global":
|
|
36
|
+
return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "mcp", "mcp.json");
|
|
37
|
+
case "pi-global":
|
|
38
|
+
return path.join(env.PI_CODING_AGENT_DIR || path.join(home, ".pi", "agent"), "mcp.json");
|
|
39
|
+
case "project":
|
|
40
|
+
return path.join(projectPath, ".mcp.json");
|
|
41
|
+
case "pi-project":
|
|
42
|
+
return path.join(projectPath, ".pi", "mcp.json");
|
|
43
|
+
default:
|
|
44
|
+
throw new McpConfigError(`unsupported scope: ${scope}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class McpConfigError extends Error {}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read a config file into the shape the rest of this module assumes. A file that
|
|
52
|
+
* is missing reads as empty; a file that is present but malformed throws, because
|
|
53
|
+
* silently treating it as empty would let a write drop servers somebody
|
|
54
|
+
* configured by hand.
|
|
55
|
+
* @param {string} file
|
|
56
|
+
* @returns {{settings: Record<string, unknown>, mcpServers: Record<string, unknown>, rest: Record<string, unknown>}}
|
|
57
|
+
*/
|
|
58
|
+
export function readMcpConfig(file) {
|
|
59
|
+
let raw;
|
|
60
|
+
try {
|
|
61
|
+
raw = fs.readFileSync(file, "utf8");
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
64
|
+
return { settings: {}, mcpServers: {}, rest: {} };
|
|
65
|
+
}
|
|
66
|
+
throw new McpConfigError(`cannot read MCP config: ${file}`);
|
|
67
|
+
}
|
|
68
|
+
let document;
|
|
69
|
+
try {
|
|
70
|
+
document = JSON.parse(raw);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new McpConfigError(`cannot parse MCP config: ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
73
|
+
}
|
|
74
|
+
if (!isRecord(document)) return { settings: {}, mcpServers: {}, rest: {} };
|
|
75
|
+
const { settings, mcpServers, ...rest } = document;
|
|
76
|
+
return {
|
|
77
|
+
settings: isRecord(settings) ? settings : {},
|
|
78
|
+
mcpServers: isRecord(mcpServers) ? mcpServers : {},
|
|
79
|
+
rest
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} file
|
|
85
|
+
* @param {{settings: Record<string, unknown>, mcpServers: Record<string, unknown>, rest?: Record<string, unknown>}} config
|
|
86
|
+
* @returns {void}
|
|
87
|
+
*/
|
|
88
|
+
export function writeMcpConfig(file, config) {
|
|
89
|
+
const document = {
|
|
90
|
+
...(config.rest ?? {}),
|
|
91
|
+
settings: { ...BASELINE_SETTINGS, ...config.settings },
|
|
92
|
+
mcpServers: config.mcpServers
|
|
93
|
+
};
|
|
94
|
+
const output = `${JSON.stringify(document, null, 2)}\n`;
|
|
95
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
96
|
+
// Written beside the target and renamed, so a crash mid-write leaves the old
|
|
97
|
+
// config rather than a truncated one that would read as "no servers".
|
|
98
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
99
|
+
fs.writeFileSync(temporary, output);
|
|
100
|
+
fs.renameSync(temporary, file);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every server visible from a project, with the scope each came from. Later
|
|
105
|
+
* scopes do not merge into earlier ones: a name defined twice is reported twice,
|
|
106
|
+
* because which definition a client picks is the client's rule, not ours, and
|
|
107
|
+
* hiding the duplicate is how a project-scoped override goes unnoticed.
|
|
108
|
+
* @param {{projectPath?: string, env?: Record<string, string|undefined>, home?: string, scopes?: string[]}} [options]
|
|
109
|
+
* @returns {{name: string, scope: string, file: string, entry: Record<string, unknown>}[]}
|
|
110
|
+
*/
|
|
111
|
+
export function collectServers(options = {}) {
|
|
112
|
+
const found = [];
|
|
113
|
+
for (const scope of options.scopes ?? SCOPES) {
|
|
114
|
+
const file = configPathForScope(scope, options);
|
|
115
|
+
let config;
|
|
116
|
+
try {
|
|
117
|
+
config = readMcpConfig(file);
|
|
118
|
+
} catch {
|
|
119
|
+
// A layer this process cannot parse is reported as empty rather than
|
|
120
|
+
// failing the whole listing; `piagent-mcp get` on that scope still throws.
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for (const [name, entry] of Object.entries(config.mcpServers)) {
|
|
124
|
+
if (isRecord(entry)) found.push({ name, scope, file, entry });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
131
|
+
export function isRecord(value) {
|
|
132
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
133
|
+
}
|