@piagent/platform 1.1.9 → 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 +43 -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 +1 -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 +316 -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,150 @@
|
|
|
1
|
+
// The servers this platform ships a pinned definition for, and the presets that
|
|
2
|
+
// group them. Pinned by version or digest because an MCP server is code that runs
|
|
3
|
+
// on the operator's machine with the operator's credentials; a floating tag would
|
|
4
|
+
// make every session a fresh supply-chain decision nobody is present to make.
|
|
5
|
+
//
|
|
6
|
+
// Nothing here is a secret. Every credential is referenced as an environment
|
|
7
|
+
// variable, so the config can be read, diffed and committed without leaking one.
|
|
8
|
+
|
|
9
|
+
/** @typedef {{description: string, config: Record<string, unknown>}} CatalogServer */
|
|
10
|
+
|
|
11
|
+
/** @type {Record<string, CatalogServer>} */
|
|
12
|
+
export const CATALOG_SERVERS = {
|
|
13
|
+
context7: {
|
|
14
|
+
description: "Official Context7 MCP server for up-to-date library/framework docs.",
|
|
15
|
+
config: {
|
|
16
|
+
command: "npx",
|
|
17
|
+
args: ["-y", "@upstash/context7-mcp@3.2.4"],
|
|
18
|
+
env: {
|
|
19
|
+
CONTEXT7_API_KEY: "${CONTEXT7_API_KEY}"
|
|
20
|
+
},
|
|
21
|
+
lifecycle: "lazy",
|
|
22
|
+
directTools: false
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"chrome-devtools": {
|
|
26
|
+
description: "Chrome DevTools MCP for runtime browser inspection, console logs, screenshots, and performance checks.",
|
|
27
|
+
config: {
|
|
28
|
+
command: "npx",
|
|
29
|
+
args: ["-y", "chrome-devtools-mcp@1.6.0", "--no-performance-crux"],
|
|
30
|
+
env: {
|
|
31
|
+
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: "1",
|
|
32
|
+
CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: "1"
|
|
33
|
+
},
|
|
34
|
+
lifecycle: "lazy",
|
|
35
|
+
directTools: false
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
playwright: {
|
|
39
|
+
description: "Playwright MCP for browser automation and UI verification workflows.",
|
|
40
|
+
config: {
|
|
41
|
+
command: "npx",
|
|
42
|
+
args: ["-y", "@playwright/mcp@0.0.78"],
|
|
43
|
+
lifecycle: "lazy",
|
|
44
|
+
directTools: false
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
github: {
|
|
48
|
+
description: "Official GitHub MCP server via Docker. Requires Docker and GITHUB_PERSONAL_ACCESS_TOKEN when used.",
|
|
49
|
+
config: {
|
|
50
|
+
command: "docker",
|
|
51
|
+
args: [
|
|
52
|
+
"run",
|
|
53
|
+
"-i",
|
|
54
|
+
"--rm",
|
|
55
|
+
"-e",
|
|
56
|
+
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
57
|
+
"-e",
|
|
58
|
+
"GITHUB_READ_ONLY=1",
|
|
59
|
+
"-e",
|
|
60
|
+
"GITHUB_LOCKDOWN_MODE=1",
|
|
61
|
+
"ghcr.io/github/github-mcp-server@sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3"
|
|
62
|
+
],
|
|
63
|
+
env: {
|
|
64
|
+
GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PERSONAL_ACCESS_TOKEN}"
|
|
65
|
+
},
|
|
66
|
+
lifecycle: "lazy",
|
|
67
|
+
directTools: false
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
figma: {
|
|
71
|
+
description: "Figma remote MCP server. Requires Figma OAuth from the MCP panel when first used.",
|
|
72
|
+
config: {
|
|
73
|
+
url: "https://mcp.figma.com/mcp",
|
|
74
|
+
auth: "oauth",
|
|
75
|
+
lifecycle: "lazy",
|
|
76
|
+
directTools: false
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"figma-desktop": {
|
|
80
|
+
description: "Figma desktop/local MCP server. Requires Figma desktop Dev Mode MCP enabled.",
|
|
81
|
+
config: {
|
|
82
|
+
url: "http://127.0.0.1:3845/mcp",
|
|
83
|
+
lifecycle: "lazy",
|
|
84
|
+
directTools: false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Writing a server definition does not make it usable. What each one still needs
|
|
90
|
+
// from the operator is stated here so it can be printed at the moment the
|
|
91
|
+
// definition is written, rather than living only in a description string that
|
|
92
|
+
// shows up under --list.
|
|
93
|
+
/** @type {Record<string, string[]>} */
|
|
94
|
+
export const CATALOG_PREREQUISITES = {
|
|
95
|
+
context7: ["optional: export CONTEXT7_API_KEY for a higher documentation quota"],
|
|
96
|
+
"chrome-devtools": ["a local Chrome installation"],
|
|
97
|
+
github: [
|
|
98
|
+
"Docker running",
|
|
99
|
+
"export GITHUB_PERSONAL_ACCESS_TOKEN with the scopes you intend to use"
|
|
100
|
+
],
|
|
101
|
+
playwright: ["browsers installed on first use (npx playwright install)"],
|
|
102
|
+
figma: ["Figma OAuth, completed from the Pi MCP panel on first use"],
|
|
103
|
+
"figma-desktop": ["Figma desktop running with Dev Mode MCP enabled"]
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Variables a pinned server references but works without. Context7 answers
|
|
107
|
+
// without a key and the key only raises the quota, so reporting it as missing
|
|
108
|
+
// would put a line in front of the operator every session about something that
|
|
109
|
+
// is not wrong. A server somebody adds by hand has no entry here: they typed the
|
|
110
|
+
// reference, so it is treated as one they meant.
|
|
111
|
+
/** @type {Record<string, string[]>} */
|
|
112
|
+
export const CATALOG_OPTIONAL_ENV = {
|
|
113
|
+
context7: ["CONTEXT7_API_KEY"]
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** @type {Record<string, string[]>} */
|
|
117
|
+
export const CATALOG_PRESETS = {
|
|
118
|
+
minimal: [],
|
|
119
|
+
docs: ["context7"],
|
|
120
|
+
browser: ["chrome-devtools", "playwright"],
|
|
121
|
+
github: ["github"],
|
|
122
|
+
design: ["figma"],
|
|
123
|
+
"design-local": ["figma-desktop"],
|
|
124
|
+
web: ["context7", "chrome-devtools", "playwright"],
|
|
125
|
+
core: ["context7", "chrome-devtools", "github"],
|
|
126
|
+
popular: ["context7", "chrome-devtools", "playwright", "github", "figma"],
|
|
127
|
+
all: ["context7", "chrome-devtools", "playwright", "github", "figma", "figma-desktop"]
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** @param {string} name @returns {boolean} */
|
|
131
|
+
export function isCatalogPreset(name) {
|
|
132
|
+
return Object.hasOwn(CATALOG_PRESETS, name);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The servers a preset installs, with their definitions resolved. Throws on an
|
|
137
|
+
* unknown preset so a typo is refused before anything is written.
|
|
138
|
+
* @param {string} name
|
|
139
|
+
* @returns {{name: string, config: Record<string, unknown>, requires: string[]}[]}
|
|
140
|
+
*/
|
|
141
|
+
export function resolvePreset(name) {
|
|
142
|
+
if (!isCatalogPreset(name)) {
|
|
143
|
+
throw new Error(`unknown preset: ${name}. Available presets: ${Object.keys(CATALOG_PRESETS).join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
return CATALOG_PRESETS[name].map((serverName) => {
|
|
146
|
+
const server = CATALOG_SERVERS[serverName];
|
|
147
|
+
if (!server) throw new Error(`preset ${name} references missing server ${serverName}`);
|
|
148
|
+
return { name: serverName, config: server.config, requires: CATALOG_PREREQUISITES[serverName] ?? [] };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { containsSensitiveText } from "../security/sensitive-data.js";
|
|
2
|
+
|
|
3
|
+
// Building a server definition from what someone typed. The rule this file
|
|
4
|
+
// exists to hold: a credential never reaches the config file.
|
|
5
|
+
//
|
|
6
|
+
// MCP config is read by several clients, lives in the operator's home or in a
|
|
7
|
+
// repository, and in the `project` scope is meant to be committed. A token typed
|
|
8
|
+
// into `--env` would land in all of those. So a value whose name says credential
|
|
9
|
+
// has to be an environment reference, and the error says which form to use rather
|
|
10
|
+
// than only that the input was refused.
|
|
11
|
+
|
|
12
|
+
const SERVER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
13
|
+
|
|
14
|
+
// A whole-value reference, `${VAR}`. A value that merely contains one — say
|
|
15
|
+
// `Bearer ${TOKEN}` — is handled separately, because a header legitimately needs
|
|
16
|
+
// the scheme in front of the reference.
|
|
17
|
+
const ENV_REFERENCE = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
|
|
18
|
+
const CONTAINS_ENV_REFERENCE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/;
|
|
19
|
+
|
|
20
|
+
const CREDENTIAL_NAME_TERMS = [
|
|
21
|
+
"token", "secret", "password", "passwd", "passphrase", "credential",
|
|
22
|
+
"apikey", "api_key", "accesskey", "access_key", "privatekey", "private_key",
|
|
23
|
+
"authorization", "auth", "session", "cookie", "signature", "pat"
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export class McpEntryError extends Error {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a name says the value beside it is a credential. Deliberately broad:
|
|
30
|
+
* a false positive costs one `${VAR}` indirection, a false negative writes a
|
|
31
|
+
* live token into a file somebody commits.
|
|
32
|
+
* @param {string} name
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function credentialLikeName(name) {
|
|
36
|
+
const normalized = String(name).toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
37
|
+
return CREDENTIAL_NAME_TERMS.some((term) => {
|
|
38
|
+
const bare = term.replace(/[^a-z0-9]+/g, "_");
|
|
39
|
+
return normalized === bare || normalized.startsWith(`${bare}_`) || normalized.endsWith(`_${bare}`) || normalized.includes(`_${bare}_`);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} raw `KEY=VALUE`
|
|
45
|
+
* @param {"env"|"header"} kind
|
|
46
|
+
* @returns {[string, string]}
|
|
47
|
+
*/
|
|
48
|
+
export function parseAssignment(raw, kind) {
|
|
49
|
+
const separator = kind === "header" ? ":" : "=";
|
|
50
|
+
const index = String(raw).indexOf(separator);
|
|
51
|
+
if (index <= 0) {
|
|
52
|
+
throw new McpEntryError(
|
|
53
|
+
kind === "header"
|
|
54
|
+
? `--header expects "Name: value", received: ${raw}`
|
|
55
|
+
: `--env expects KEY=value, received: ${raw}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const name = raw.slice(0, index).trim();
|
|
59
|
+
const value = raw.slice(index + 1).trim();
|
|
60
|
+
if (!name) throw new McpEntryError(`${kind} name cannot be empty`);
|
|
61
|
+
return [name, value];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Refuse a literal credential, and say what to type instead. `kind` only shapes
|
|
66
|
+
* the message; both channels are held to the same rule.
|
|
67
|
+
* @param {string} name
|
|
68
|
+
* @param {string} value
|
|
69
|
+
* @param {"env"|"header"} kind
|
|
70
|
+
* @returns {void}
|
|
71
|
+
*/
|
|
72
|
+
export function assertNoLiteralSecret(name, value, kind) {
|
|
73
|
+
const referenced = kind === "header" ? CONTAINS_ENV_REFERENCE.test(value) : ENV_REFERENCE.test(value);
|
|
74
|
+
if (referenced) return;
|
|
75
|
+
|
|
76
|
+
const suggestion = kind === "header"
|
|
77
|
+
? `--header '${name}: Bearer \${${suggestedVariable(name)}}'`
|
|
78
|
+
: `--env '${name}=\${${suggestedVariable(name)}}'`;
|
|
79
|
+
|
|
80
|
+
if (credentialLikeName(name)) {
|
|
81
|
+
throw new McpEntryError(
|
|
82
|
+
`${name} names a credential, so its value cannot be written into MCP config. ` +
|
|
83
|
+
`Export it in your shell and reference it instead: ${suggestion}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
// The name gave nothing away, so the value is read on its own. This catches a
|
|
87
|
+
// token pasted under a neutral name such as `--env CONFIG=ghp_...`.
|
|
88
|
+
if (containsSensitiveText(`${name}=${value}`)) {
|
|
89
|
+
throw new McpEntryError(
|
|
90
|
+
`the value given for ${name} looks like a credential, so it cannot be written into MCP config. ` +
|
|
91
|
+
`Export it in your shell and reference it instead: ${suggestion}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {string} name @returns {string} */
|
|
97
|
+
function suggestedVariable(name) {
|
|
98
|
+
const upper = String(name).toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
99
|
+
return upper || "TOKEN";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {string} name @returns {string} */
|
|
103
|
+
export function assertServerName(name) {
|
|
104
|
+
if (!SERVER_NAME.test(String(name ?? ""))) {
|
|
105
|
+
throw new McpEntryError(
|
|
106
|
+
`invalid server name: ${name}. Use letters, digits, dot, dash or underscore, up to 64 characters.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return name;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @typedef {object} BuildEntryInput
|
|
114
|
+
* @property {string} name
|
|
115
|
+
* @property {string} [url]
|
|
116
|
+
* @property {string[]} [command] argv, first element is the executable
|
|
117
|
+
* @property {string[]} [env] raw `KEY=value`
|
|
118
|
+
* @property {string[]} [header] raw `Name: value`
|
|
119
|
+
* @property {string} [bearerTokenEnvVar]
|
|
120
|
+
* @property {string} [oauthClientId]
|
|
121
|
+
* @property {string} [oauthScopes]
|
|
122
|
+
* @property {boolean} [directTools]
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {BuildEntryInput} input
|
|
127
|
+
* @returns {Record<string, unknown>}
|
|
128
|
+
*/
|
|
129
|
+
export function buildServerEntry(input) {
|
|
130
|
+
assertServerName(input.name);
|
|
131
|
+
const hasUrl = typeof input.url === "string" && input.url.length > 0;
|
|
132
|
+
const hasCommand = Array.isArray(input.command) && input.command.length > 0;
|
|
133
|
+
if (hasUrl === hasCommand) {
|
|
134
|
+
throw new McpEntryError("give either --url for a remote server or -- <command> for a local one, not both and not neither");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @type {Record<string, unknown>} */
|
|
138
|
+
const entry = { lifecycle: "lazy", directTools: input.directTools === true };
|
|
139
|
+
|
|
140
|
+
if (hasUrl) {
|
|
141
|
+
const url = parseRemoteUrl(input.url ?? "");
|
|
142
|
+
entry.url = url.href;
|
|
143
|
+
if (input.oauthClientId || input.oauthScopes) {
|
|
144
|
+
/** @type {Record<string, unknown>} */
|
|
145
|
+
const oauth = {};
|
|
146
|
+
if (input.oauthClientId) oauth.clientId = input.oauthClientId;
|
|
147
|
+
if (input.oauthScopes) oauth.scopes = input.oauthScopes;
|
|
148
|
+
entry.auth = "oauth";
|
|
149
|
+
entry.oauth = oauth;
|
|
150
|
+
}
|
|
151
|
+
if (input.bearerTokenEnvVar) {
|
|
152
|
+
// The variable name, never the token. Whoever reads the config learns which
|
|
153
|
+
// variable to export and nothing else.
|
|
154
|
+
entry.bearerTokenEnvVar = assertEnvVarName(input.bearerTokenEnvVar);
|
|
155
|
+
}
|
|
156
|
+
const headers = collectAssignments(input.header ?? [], "header");
|
|
157
|
+
if (Object.keys(headers).length > 0) entry.headers = headers;
|
|
158
|
+
} else {
|
|
159
|
+
const argv = input.command ?? [];
|
|
160
|
+
entry.command = argv[0];
|
|
161
|
+
if (argv.length > 1) entry.args = argv.slice(1);
|
|
162
|
+
const env = collectAssignments(input.env ?? [], "env");
|
|
163
|
+
if (Object.keys(env).length > 0) entry.env = env;
|
|
164
|
+
if (input.header?.length) throw new McpEntryError("--header applies to remote servers; a local server takes --env");
|
|
165
|
+
if (input.bearerTokenEnvVar) throw new McpEntryError("--bearer-token-env-var applies to remote servers only");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return entry;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** @param {string[]} raw @param {"env"|"header"} kind @returns {Record<string, string>} */
|
|
172
|
+
function collectAssignments(raw, kind) {
|
|
173
|
+
/** @type {Record<string, string>} */
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const item of raw) {
|
|
176
|
+
const [name, value] = parseAssignment(item, kind);
|
|
177
|
+
assertNoLiteralSecret(name, value, kind);
|
|
178
|
+
out[name] = value;
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @param {string} value @returns {string} */
|
|
184
|
+
function assertEnvVarName(value) {
|
|
185
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
186
|
+
throw new McpEntryError(`--bearer-token-env-var expects a variable name, received: ${value}`);
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A remote server has to be reachable over a transport this platform is willing
|
|
193
|
+
* to describe as one. `http://` is allowed only on the loopback address, where
|
|
194
|
+
* there is no network to intercept — Figma's desktop server is exactly that case.
|
|
195
|
+
* @param {string} value
|
|
196
|
+
* @returns {URL}
|
|
197
|
+
*/
|
|
198
|
+
export function parseRemoteUrl(value) {
|
|
199
|
+
let url;
|
|
200
|
+
try {
|
|
201
|
+
url = new URL(value);
|
|
202
|
+
} catch {
|
|
203
|
+
throw new McpEntryError(`--url is not a valid URL: ${value}`);
|
|
204
|
+
}
|
|
205
|
+
if (url.protocol === "https:") return url;
|
|
206
|
+
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url;
|
|
207
|
+
throw new McpEntryError(
|
|
208
|
+
`--url must be https, or http on localhost. Received: ${url.protocol}//${url.hostname}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @param {string} hostname @returns {boolean} */
|
|
213
|
+
function isLoopbackHost(hostname) {
|
|
214
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
215
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A server entry with every value that could be a credential replaced. Used for
|
|
220
|
+
* display, so `list` and `get` can be pasted into an issue.
|
|
221
|
+
* @param {Record<string, unknown>} entry
|
|
222
|
+
* @returns {Record<string, unknown>}
|
|
223
|
+
*/
|
|
224
|
+
export function maskServerEntry(entry) {
|
|
225
|
+
/** @type {Record<string, unknown>} */
|
|
226
|
+
const out = {};
|
|
227
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
228
|
+
if ((key === "env" || key === "headers") && value && typeof value === "object" && !Array.isArray(value)) {
|
|
229
|
+
out[key] = Object.fromEntries(
|
|
230
|
+
Object.entries(/** @type {Record<string, unknown>} */ (value)).map(([name, item]) => [
|
|
231
|
+
name,
|
|
232
|
+
// An environment reference is the name of a variable, not its contents,
|
|
233
|
+
// so showing it is the point. Anything else is treated as a value.
|
|
234
|
+
typeof item === "string" && ENV_REFERENCE.test(item) ? item : "*****"
|
|
235
|
+
])
|
|
236
|
+
);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
out[key] = value;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { REPOSITORY_SCOPES, SCOPES, collectServers } from "./mcp-config-layers.js";
|
|
2
|
+
import { approvalState } from "./mcp-approval-store.js";
|
|
3
|
+
import { maskServerEntry } from "./mcp-server-entry.js";
|
|
4
|
+
import { evaluateServerReadiness } from "./mcp-auth-readiness.js";
|
|
5
|
+
import { CATALOG_PREREQUISITES } from "./mcp-server-catalog.js";
|
|
6
|
+
|
|
7
|
+
// What the terminal CLI and the in-session `/piagent-mcp` command both show.
|
|
8
|
+
//
|
|
9
|
+
// The same question gets asked from two places — a shell prompt and a Pi
|
|
10
|
+
// session — and two answers that disagree about whether a server is usable is
|
|
11
|
+
// worse than either answer alone. So the reading, the states and the wording
|
|
12
|
+
// live here once, and both surfaces print what this returns.
|
|
13
|
+
//
|
|
14
|
+
// Read-only by construction: nothing here writes a config or an approval.
|
|
15
|
+
|
|
16
|
+
/** @typedef {import("./mcp-auth-readiness.js").ReadinessState} ReadinessState */
|
|
17
|
+
|
|
18
|
+
export class McpViewError extends Error {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The stdio command line, or the remote URL, shortened for a table cell.
|
|
22
|
+
* @param {Record<string, unknown>} entry
|
|
23
|
+
* @param {number} [limit]
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function describeTarget(entry, limit = 56) {
|
|
27
|
+
if (typeof entry.url === "string") return entry.url;
|
|
28
|
+
const argv = [entry.command, ...(Array.isArray(entry.args) ? entry.args : [])].filter((item) => typeof item === "string");
|
|
29
|
+
const text = argv.join(" ");
|
|
30
|
+
return text.length > limit ? `${text.slice(0, limit - 3)}...` : text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Every server visible from a project, with its readiness resolved.
|
|
35
|
+
* @param {{projectPath: string, scopes?: string[]}} options
|
|
36
|
+
*/
|
|
37
|
+
export function collectServerRows(options) {
|
|
38
|
+
const { projectPath, scopes } = options;
|
|
39
|
+
if (scopes) {
|
|
40
|
+
for (const scope of scopes) {
|
|
41
|
+
if (!SCOPES.includes(scope)) throw new McpViewError(`unsupported scope: ${scope}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return collectServers({ projectPath, scopes }).map((server) => ({
|
|
45
|
+
...server,
|
|
46
|
+
transport: typeof server.entry.url === "string" ? "http" : "stdio",
|
|
47
|
+
target: describeTarget(server.entry),
|
|
48
|
+
readiness: evaluateServerReadiness(server, { projectPath })
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Servers that will not answer a tool call. `oauth` is excluded: it may already
|
|
54
|
+
* be signed in, and this cannot tell.
|
|
55
|
+
* @param {ReturnType<typeof collectServerRows>} rows
|
|
56
|
+
*/
|
|
57
|
+
export function blockedRows(rows) {
|
|
58
|
+
return rows.filter((row) => row.readiness.state !== "ready" && row.readiness.state !== "oauth");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One named server, resolved to a single scope. `listHint` is the caller's own
|
|
63
|
+
* way of listing servers, so the error names a command the reader can actually
|
|
64
|
+
* run from where they are.
|
|
65
|
+
*
|
|
66
|
+
* @param {{projectPath: string, name: string, scope?: string, listHint?: string, scopeHint?: string}} options
|
|
67
|
+
*/
|
|
68
|
+
export function describeServer(options) {
|
|
69
|
+
const { projectPath, name, scope, listHint, scopeHint = "--scope" } = options;
|
|
70
|
+
const matches = collectServerRows({ projectPath, scopes: scope ? [scope] : undefined })
|
|
71
|
+
.filter((row) => row.name === name);
|
|
72
|
+
if (matches.length === 0) {
|
|
73
|
+
throw new McpViewError(scope
|
|
74
|
+
? `no server named ${name} in ${scope} scope`
|
|
75
|
+
: `no server named ${name}.${listHint ? ` Run \`${listHint}\` to see what is configured.` : ""}`);
|
|
76
|
+
}
|
|
77
|
+
if (matches.length > 1) {
|
|
78
|
+
throw new McpViewError(
|
|
79
|
+
`${name} is defined in more than one scope (${matches.map((row) => row.scope).join(", ")}). ` +
|
|
80
|
+
`Pass ${scopeHint} to say which one.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const row = matches[0];
|
|
84
|
+
const approval = REPOSITORY_SCOPES.has(row.scope)
|
|
85
|
+
? approvalState({ projectPath, name, entry: row.entry })
|
|
86
|
+
: undefined;
|
|
87
|
+
return { ...row, masked: maskServerEntry(row.entry), approval };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Readiness remedies are written as terminal commands, because that is where
|
|
92
|
+
* every one of them can be run. Inside a session the same step is a slash
|
|
93
|
+
* command, and telling a reader to shell out for something they can do where
|
|
94
|
+
* they are standing is how a surface ends up feeling harder than it is.
|
|
95
|
+
*
|
|
96
|
+
* @param {string | undefined} remedy
|
|
97
|
+
* @param {boolean} inSession
|
|
98
|
+
* @returns {string | undefined}
|
|
99
|
+
*/
|
|
100
|
+
export function remedyFor(remedy, inSession) {
|
|
101
|
+
if (!remedy || !inSession) return remedy;
|
|
102
|
+
return remedy.replace(/(^|[\s`])piagent-mcp /g, "$1/piagent-mcp ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Left-aligned columns sized to their contents. */
|
|
106
|
+
function table(headers, rows) {
|
|
107
|
+
const widths = headers.map((header, i) => Math.max(header.length, ...rows.map((cells) => cells[i].length)));
|
|
108
|
+
const line = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
|
|
109
|
+
return [line(headers), ...rows.map(line)].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {ReturnType<typeof collectServerRows>} rows
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
export function formatServerTable(rows) {
|
|
117
|
+
if (rows.length === 0) return "";
|
|
118
|
+
return table(
|
|
119
|
+
["NAME", "SCOPE", "TRANSPORT", "TARGET", "STATE"],
|
|
120
|
+
rows.map((row) => [row.name, row.scope, row.transport, row.target, row.readiness.state])
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Fields worth showing; the rest of an entry is noise in a status view. */
|
|
125
|
+
const DETAIL_FIELDS = ["url", "command", "args", "env", "headers", "auth", "bearerTokenEnvVar"];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {ReturnType<typeof describeServer>} detail
|
|
129
|
+
* @param {{inSession?: boolean}} [options]
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
export function formatServerDetail(detail, options = {}) {
|
|
133
|
+
const lines = [
|
|
134
|
+
detail.name,
|
|
135
|
+
` scope: ${detail.scope}`,
|
|
136
|
+
` file: ${detail.file}`,
|
|
137
|
+
` transport: ${detail.transport}`
|
|
138
|
+
];
|
|
139
|
+
for (const key of DETAIL_FIELDS) {
|
|
140
|
+
const item = detail.masked[key];
|
|
141
|
+
if (item === undefined) continue;
|
|
142
|
+
lines.push(` ${key}: ${typeof item === "string" ? item : JSON.stringify(item)}`);
|
|
143
|
+
}
|
|
144
|
+
lines.push(` state: ${detail.readiness.state} (${detail.readiness.detail})`);
|
|
145
|
+
const next = remedyFor(detail.readiness.remedy, options.inSession === true);
|
|
146
|
+
if (next) lines.push(` next: ${next}`);
|
|
147
|
+
if (detail.approval) lines.push(` approval: ${detail.approval.state} (${detail.approval.digest})`);
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The readiness report, one line per server plus the remedy for anything stuck.
|
|
153
|
+
* `dockerRunning` is passed in rather than probed: the probe spawns a process
|
|
154
|
+
* and only the caller knows whether it can afford that.
|
|
155
|
+
*
|
|
156
|
+
* @param {ReturnType<typeof collectServerRows>} rows
|
|
157
|
+
* @param {{dockerRunning?: boolean, rerun?: string, inSession?: boolean}} [options]
|
|
158
|
+
* @returns {{lines: string[], problems: number}}
|
|
159
|
+
*/
|
|
160
|
+
export function formatDoctorReport(rows, options = {}) {
|
|
161
|
+
if (rows.length === 0) return { lines: ["No MCP servers configured."], problems: 0 };
|
|
162
|
+
|
|
163
|
+
const lines = [];
|
|
164
|
+
let problems = 0;
|
|
165
|
+
for (const row of rows) {
|
|
166
|
+
const usable = row.readiness.state === "ready" || row.readiness.state === "oauth";
|
|
167
|
+
if (!usable) problems += 1;
|
|
168
|
+
lines.push(`${usable ? "OK " : "NEED"} ${row.name} (${row.scope}): ${row.readiness.detail}`);
|
|
169
|
+
const remedy = remedyFor(row.readiness.remedy, options.inSession === true);
|
|
170
|
+
if (remedy) lines.push(` -> ${remedy}`);
|
|
171
|
+
}
|
|
172
|
+
if (options.dockerRunning === false) {
|
|
173
|
+
problems += 1;
|
|
174
|
+
lines.push("NEED docker: installed but the daemon is not responding");
|
|
175
|
+
lines.push(` -> start Docker Desktop, then re-run ${options.rerun ?? "the check"}`);
|
|
176
|
+
}
|
|
177
|
+
const prerequisites = rows.flatMap((row) => (CATALOG_PREREQUISITES[row.name] ?? []).map((item) => ` ${row.name}: ${item}`));
|
|
178
|
+
if (prerequisites.length > 0) lines.push("", "Pinned servers also expect:", ...prerequisites);
|
|
179
|
+
return { lines, problems };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** True when any configured server runs through the docker CLI. */
|
|
183
|
+
export function usesDocker(rows) {
|
|
184
|
+
return rows.some((row) => row.entry.command === "docker");
|
|
185
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piagent/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": true,
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,8 +37,12 @@
|
|
|
37
37
|
"typebox": "1.1.38"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
|
-
"@earendil-works/pi-coding-agent": {
|
|
41
|
-
|
|
40
|
+
"@earendil-works/pi-coding-agent": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"typebox": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
42
46
|
},
|
|
43
47
|
"engines": {
|
|
44
48
|
"node": ">=22.19.0"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Explain native Pi model selector/scope options
|
|
2
|
+
description: Explain native Pi model selector/scope options across model families
|
|
3
3
|
argument-hint: "[task type, budget, or provider preference]"
|
|
4
4
|
---
|
|
5
5
|
|
|
@@ -18,15 +18,15 @@ Mandatory flow:
|
|
|
18
18
|
- `/scoped-models` for cycle scope;
|
|
19
19
|
- Ctrl+P / Shift+Ctrl+P for model cycling;
|
|
20
20
|
- Shift+Tab for thinking level.
|
|
21
|
-
4. Include both OpenAI Codex and
|
|
22
|
-
5. Do not limit the answer to one or two legacy examples. Include the current latest-family options
|
|
21
|
+
4. Include both model families — the OpenAI Codex models and the Anthropic Claude models — unless the user explicitly restricts the provider. Name them as model families, never as another tool.
|
|
22
|
+
5. Do not limit the answer to one or two legacy examples. Include the current latest-family options: the Codex 5.5/5.6 models and the Claude Sonnet/Opus/Fable models, where relevant.
|
|
23
23
|
6. If the user needs available model versions, recommend `piagent-models`, `/model`, or `pi --list-models`.
|
|
24
24
|
7. Explain thinking levels as task effort, not as quality guarantee.
|
|
25
25
|
8. Do not claim token/cost savings without benchmark evidence.
|
|
26
26
|
|
|
27
27
|
Current latest-family catalog to consider:
|
|
28
28
|
|
|
29
|
-
OpenAI Codex:
|
|
29
|
+
OpenAI Codex models (provider `openai-codex`):
|
|
30
30
|
|
|
31
31
|
- `openai-codex/gpt-5.3-codex-spark`
|
|
32
32
|
- `openai-codex/gpt-5.4-mini`
|
|
@@ -36,7 +36,7 @@ OpenAI Codex:
|
|
|
36
36
|
- `openai-codex/gpt-5.6-sol`
|
|
37
37
|
- `openai-codex/gpt-5.6-terra`
|
|
38
38
|
|
|
39
|
-
Claude
|
|
39
|
+
Anthropic Claude models (provider `anthropic`):
|
|
40
40
|
|
|
41
41
|
- `anthropic/claude-haiku-4-5`
|
|
42
42
|
- `anthropic/claude-sonnet-4-5`
|
|
@@ -50,7 +50,7 @@ Claude/Anthropic:
|
|
|
50
50
|
|
|
51
51
|
Default selector scope seeded by setup:
|
|
52
52
|
|
|
53
|
-
| Preset | OpenAI Codex |
|
|
53
|
+
| Preset | OpenAI Codex model | Anthropic Claude model | Use when |
|
|
54
54
|
|---|---|---|---|
|
|
55
55
|
| Fast scout | `openai-codex/gpt-5.4-mini:low` | `anthropic/claude-haiku-4-5:low` | quick reads, simple Q&A |
|
|
56
56
|
| Balanced implement | `openai-codex/gpt-5.5:medium` | `anthropic/claude-sonnet-5:medium` | normal source tasks |
|
|
@@ -63,6 +63,6 @@ Output:
|
|
|
63
63
|
- Current model/context if available.
|
|
64
64
|
- How to open/select/change model in Pi UI.
|
|
65
65
|
- What `enabledModels`/scope contains.
|
|
66
|
-
- Codex family options.
|
|
67
|
-
- Claude family options.
|
|
66
|
+
- Codex model family options.
|
|
67
|
+
- Claude model family options.
|
|
68
68
|
- How to inspect/re-apply model scope from terminal.
|