@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,240 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// Defined here rather than imported from the layer module, which imports this
|
|
6
|
+
// one: a cycle between the two would resolve, but only by accident of ordering.
|
|
7
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The adapter accepts an `imports` key that pulls server definitions out of
|
|
13
|
+
// other coding tools' config files. Those definitions land in a session without
|
|
14
|
+
// ever appearing in any of the four scopes this platform manages, so anything
|
|
15
|
+
// the approval gate is meant to cover has to be resolved here too.
|
|
16
|
+
//
|
|
17
|
+
// Each kind is described by two independent sets of locations. One is where the
|
|
18
|
+
// adapter looks today; the other is where the tool actually keeps its config.
|
|
19
|
+
// They disagree for three of the six kinds, and the gate has to hold under both:
|
|
20
|
+
// reading only the adapter's location misses a definition the moment the adapter
|
|
21
|
+
// is corrected, and reading only the real location misses what loads right now.
|
|
22
|
+
// The union is the safe direction to be wrong in — a server listed here that
|
|
23
|
+
// never loads costs one approval prompt, a server that loads without being
|
|
24
|
+
// listed costs the gate.
|
|
25
|
+
//
|
|
26
|
+
// Only `vscode` resolves inside the project. That one distinction is what makes
|
|
27
|
+
// it dangerous: a cloned repository carries the file it points at, so the
|
|
28
|
+
// servers it defines travel with the clone exactly like `.mcp.json` does.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* adapterPaths: (home: string, projectPath: string) => string[],
|
|
33
|
+
* actualPaths: (home: string, projectPath: string) => string[],
|
|
34
|
+
* serverKeys: string[],
|
|
35
|
+
* repositoryRelative: boolean,
|
|
36
|
+
* parsedAsJson: boolean
|
|
37
|
+
* }} ImportKind
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** @type {Record<string, ImportKind>} */
|
|
41
|
+
export const IMPORT_KINDS = {
|
|
42
|
+
cursor: {
|
|
43
|
+
adapterPaths: (home) => [path.join(home, ".cursor", "mcp.json")],
|
|
44
|
+
actualPaths: (home) => [path.join(home, ".cursor", "mcp.json")],
|
|
45
|
+
serverKeys: ["mcpServers", "mcp-servers"],
|
|
46
|
+
repositoryRelative: false,
|
|
47
|
+
parsedAsJson: true
|
|
48
|
+
},
|
|
49
|
+
"claude-code": {
|
|
50
|
+
adapterPaths: (home) => [
|
|
51
|
+
path.join(home, ".claude", "mcp.json"),
|
|
52
|
+
path.join(home, ".claude.json"),
|
|
53
|
+
path.join(home, ".claude", "claude_desktop_config.json")
|
|
54
|
+
],
|
|
55
|
+
// Servers added at user scope sit at the top level of `~/.claude.json`;
|
|
56
|
+
// servers added at local scope are nested per project and are not read by
|
|
57
|
+
// either side, which is worth knowing but changes nothing here.
|
|
58
|
+
actualPaths: (home) => [path.join(home, ".claude.json")],
|
|
59
|
+
serverKeys: ["mcpServers"],
|
|
60
|
+
repositoryRelative: false,
|
|
61
|
+
parsedAsJson: true
|
|
62
|
+
},
|
|
63
|
+
"claude-desktop": {
|
|
64
|
+
adapterPaths: (home) => [
|
|
65
|
+
path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
66
|
+
],
|
|
67
|
+
actualPaths: (home) => [
|
|
68
|
+
path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
69
|
+
],
|
|
70
|
+
serverKeys: ["mcpServers"],
|
|
71
|
+
repositoryRelative: false,
|
|
72
|
+
parsedAsJson: true
|
|
73
|
+
},
|
|
74
|
+
codex: {
|
|
75
|
+
adapterPaths: (home) => [path.join(home, ".codex", "config.json")],
|
|
76
|
+
// The real file is TOML under a `mcp_servers` table, and nothing here parses
|
|
77
|
+
// TOML. Enumeration is therefore incomplete for this kind, which is what
|
|
78
|
+
// `canEnumerateImportKind` reports: a repository that declares it is refused
|
|
79
|
+
// on the declaration, because listing the servers that happen to be readable
|
|
80
|
+
// says nothing about the ones that are not.
|
|
81
|
+
actualPaths: (home) => [path.join(home, ".codex", "config.toml")],
|
|
82
|
+
serverKeys: ["mcpServers", "mcp_servers"],
|
|
83
|
+
repositoryRelative: false,
|
|
84
|
+
parsedAsJson: false
|
|
85
|
+
},
|
|
86
|
+
windsurf: {
|
|
87
|
+
adapterPaths: (home) => [path.join(home, ".windsurf", "mcp.json")],
|
|
88
|
+
actualPaths: (home) => [path.join(home, ".codeium", "windsurf", "mcp_config.json")],
|
|
89
|
+
serverKeys: ["mcpServers", "mcp-servers"],
|
|
90
|
+
repositoryRelative: false,
|
|
91
|
+
parsedAsJson: true
|
|
92
|
+
},
|
|
93
|
+
vscode: {
|
|
94
|
+
adapterPaths: (_home, projectPath) => [path.join(projectPath, ".vscode", "mcp.json")],
|
|
95
|
+
actualPaths: (_home, projectPath) => [path.join(projectPath, ".vscode", "mcp.json")],
|
|
96
|
+
// The adapter reads `mcpServers`; the documented format uses `servers`. Both
|
|
97
|
+
// are read so the gate covers a hand-written file either way.
|
|
98
|
+
serverKeys: ["mcpServers", "mcp-servers", "servers"],
|
|
99
|
+
repositoryRelative: true,
|
|
100
|
+
parsedAsJson: true
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Import kinds whose file a repository carries, and which therefore need approval. */
|
|
105
|
+
export const REPOSITORY_RELATIVE_KINDS = new Set(
|
|
106
|
+
Object.entries(IMPORT_KINDS)
|
|
107
|
+
.filter(([, kind]) => kind.repositoryRelative)
|
|
108
|
+
.map(([name]) => name)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether every server this kind can contribute is visible to this platform.
|
|
113
|
+
*
|
|
114
|
+
* False means the answer from `importedServers` is a subset of unknown size, not
|
|
115
|
+
* a list. A caller deciding what to block needs to know the difference: blocking
|
|
116
|
+
* what came back would look like coverage while saying nothing about the rest.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} kindName
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function canEnumerateImportKind(kindName) {
|
|
122
|
+
return IMPORT_KINDS[kindName]?.parsedAsJson === true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The `imports` a config document declares, as a list of known kind names. An
|
|
127
|
+
* unknown kind is dropped rather than rejected: the adapter ignores it too, and
|
|
128
|
+
* a name this version does not recognise is not evidence of anything.
|
|
129
|
+
* @param {unknown} value
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
export function declaredImports(value) {
|
|
133
|
+
if (!Array.isArray(value)) return [];
|
|
134
|
+
const kinds = [];
|
|
135
|
+
for (const entry of value) {
|
|
136
|
+
if (typeof entry !== "string") continue;
|
|
137
|
+
const kind = entry.trim();
|
|
138
|
+
if (Object.hasOwn(IMPORT_KINDS, kind) && !kinds.includes(kind)) kinds.push(kind);
|
|
139
|
+
}
|
|
140
|
+
return kinds;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Every file an import kind can be read from, adapter location and real location
|
|
145
|
+
* alike. Exported because "does this kind actually resolve to anything on this
|
|
146
|
+
* machine" is a question two callers ask, and each answering it from its own
|
|
147
|
+
* copy of the path list is how the two answers drift apart.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} kindName
|
|
150
|
+
* @param {{projectPath?: string, home?: string}} [options]
|
|
151
|
+
* @returns {string[]}
|
|
152
|
+
*/
|
|
153
|
+
export function importKindFiles(kindName, options = {}) {
|
|
154
|
+
const kind = IMPORT_KINDS[kindName];
|
|
155
|
+
if (!kind) return [];
|
|
156
|
+
const home = options.home ?? os.homedir();
|
|
157
|
+
const projectPath = options.projectPath ?? process.cwd();
|
|
158
|
+
return [...new Set([...kind.adapterPaths(home, projectPath), ...kind.actualPaths(home, projectPath)])];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Every server an import kind can put into a session, from either location and
|
|
163
|
+
* under any of the keys that location might use. Files that are missing
|
|
164
|
+
* contribute nothing; a file that exists but cannot be parsed is reported by
|
|
165
|
+
* `unreadableImportTargets`, because "no servers" and "servers nobody could
|
|
166
|
+
* read" are the two answers an operator most needs told apart.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} kindName
|
|
169
|
+
* @param {{projectPath?: string, home?: string}} [options]
|
|
170
|
+
* @returns {{name: string, file: string, entry: Record<string, unknown>}[]}
|
|
171
|
+
*/
|
|
172
|
+
export function importedServers(kindName, options = {}) {
|
|
173
|
+
const kind = IMPORT_KINDS[kindName];
|
|
174
|
+
if (!kind) return [];
|
|
175
|
+
const files = importKindFiles(kindName, options);
|
|
176
|
+
|
|
177
|
+
const found = [];
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
for (const file of files) {
|
|
180
|
+
if (!kind.parsedAsJson && !file.endsWith(".json")) continue;
|
|
181
|
+
let document;
|
|
182
|
+
try {
|
|
183
|
+
document = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
184
|
+
} catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!isRecord(document)) continue;
|
|
188
|
+
for (const key of kind.serverKeys) {
|
|
189
|
+
const servers = document[key];
|
|
190
|
+
if (!isRecord(servers)) continue;
|
|
191
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
192
|
+
// A name defined under two keys of the same file is one server; a name
|
|
193
|
+
// defined in two files is reported once, keyed by the first file seen,
|
|
194
|
+
// because approving it twice would ask the same question twice.
|
|
195
|
+
const dedupe = `${name}\u0000${file}`;
|
|
196
|
+
if (seen.has(dedupe) || !isRecord(entry)) continue;
|
|
197
|
+
seen.add(dedupe);
|
|
198
|
+
found.push({ name, file, entry });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Import targets that exist but cannot be read into servers. `importedServers`
|
|
207
|
+
* skips these, which is right for a listing and wrong for a diagnosis: the
|
|
208
|
+
* caller cannot otherwise tell a tool that defines no servers from a tool whose
|
|
209
|
+
* config nothing could parse, and only one of those two is a gap in the gate.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} kindName
|
|
212
|
+
* @param {{projectPath?: string, home?: string}} [options]
|
|
213
|
+
* @returns {{kind: string, file: string, detail: string}[]}
|
|
214
|
+
*/
|
|
215
|
+
export function unreadableImportTargets(kindName, options = {}) {
|
|
216
|
+
const kind = IMPORT_KINDS[kindName];
|
|
217
|
+
if (!kind) return [];
|
|
218
|
+
const problems = [];
|
|
219
|
+
for (const file of importKindFiles(kindName, options)) {
|
|
220
|
+
if (!kind.parsedAsJson && !file.endsWith(".json")) continue;
|
|
221
|
+
let raw;
|
|
222
|
+
try {
|
|
223
|
+
raw = fs.readFileSync(file, "utf8");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// Absent is the normal case for five of six kinds and is not a problem.
|
|
226
|
+
if (error && typeof error === "object" && error.code === "ENOENT") continue;
|
|
227
|
+
problems.push({ kind: kindName, file, detail: `cannot read ${file}` });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
let document;
|
|
231
|
+
try {
|
|
232
|
+
document = JSON.parse(raw);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
problems.push({ kind: kindName, file, detail: `cannot parse ${file}: ${error instanceof Error ? error.message : String(error)}` });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!isRecord(document)) problems.push({ kind: kindName, file, detail: `${file} is not a JSON object` });
|
|
238
|
+
}
|
|
239
|
+
return problems;
|
|
240
|
+
}
|
|
@@ -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,293 @@
|
|
|
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
|
+
// The same rule the rest of this file applies to `--env` and `--header`. An
|
|
206
|
+
// address is not a safer place to keep a token than a header is: it goes into
|
|
207
|
+
// the same committed file, and it additionally tends to reach request logs and
|
|
208
|
+
// shell history. The refusal is masked so the error itself does not repeat it.
|
|
209
|
+
if (url.username || url.password) {
|
|
210
|
+
throw new McpEntryError(
|
|
211
|
+
"--url must not carry credentials in the address. Use --bearer-token-env-var, " +
|
|
212
|
+
`or --header with a \${VAR} reference. Received: ${maskUrlCredentials(url.href)}`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
for (const [name, item] of url.searchParams) {
|
|
216
|
+
if (credentialLikeName(name) && !ENV_REFERENCE.test(item)) {
|
|
217
|
+
throw new McpEntryError(
|
|
218
|
+
`--url query parameter ${name} looks like a credential. Use --bearer-token-env-var, ` +
|
|
219
|
+
`or give it a \${VAR} reference.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (url.protocol === "https:") return url;
|
|
224
|
+
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url;
|
|
225
|
+
throw new McpEntryError(
|
|
226
|
+
`--url must be https, or http on localhost. Received: ${url.protocol}//${url.hostname}`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A URL with anything credential-shaped in it replaced. `add` refuses to write
|
|
232
|
+
* such a URL, but a config written by hand or by an older version can still hold
|
|
233
|
+
* one, and display is exactly where it would leak: `list` and `get` output is
|
|
234
|
+
* meant to be pasteable into an issue.
|
|
235
|
+
*
|
|
236
|
+
* A value that does not parse as a URL is returned untouched. Guessing at the
|
|
237
|
+
* structure of something that is not a URL would be a way to mangle a value
|
|
238
|
+
* without protecting anything.
|
|
239
|
+
*
|
|
240
|
+
* @param {unknown} value
|
|
241
|
+
* @returns {unknown}
|
|
242
|
+
*/
|
|
243
|
+
export function maskUrlCredentials(value) {
|
|
244
|
+
if (typeof value !== "string") return value;
|
|
245
|
+
let url;
|
|
246
|
+
try {
|
|
247
|
+
url = new URL(value);
|
|
248
|
+
} catch {
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
if (url.password) url.password = "*****";
|
|
252
|
+
if (url.username) url.username = "*****";
|
|
253
|
+
for (const [name, item] of [...url.searchParams]) {
|
|
254
|
+
if (credentialLikeName(name) && !ENV_REFERENCE.test(item)) url.searchParams.set(name, "*****");
|
|
255
|
+
}
|
|
256
|
+
return url.href;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @param {string} hostname @returns {boolean} */
|
|
260
|
+
function isLoopbackHost(hostname) {
|
|
261
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
262
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A server entry with every value that could be a credential replaced. Used for
|
|
267
|
+
* display, so `list` and `get` can be pasted into an issue.
|
|
268
|
+
* @param {Record<string, unknown>} entry
|
|
269
|
+
* @returns {Record<string, unknown>}
|
|
270
|
+
*/
|
|
271
|
+
export function maskServerEntry(entry) {
|
|
272
|
+
/** @type {Record<string, unknown>} */
|
|
273
|
+
const out = {};
|
|
274
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
275
|
+
if ((key === "env" || key === "headers") && value && typeof value === "object" && !Array.isArray(value)) {
|
|
276
|
+
out[key] = Object.fromEntries(
|
|
277
|
+
Object.entries(/** @type {Record<string, unknown>} */ (value)).map(([name, item]) => [
|
|
278
|
+
name,
|
|
279
|
+
// An environment reference is the name of a variable, not its contents,
|
|
280
|
+
// so showing it is the point. Anything else is treated as a value.
|
|
281
|
+
typeof item === "string" && ENV_REFERENCE.test(item) ? item : "*****"
|
|
282
|
+
])
|
|
283
|
+
);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (key === "url") {
|
|
287
|
+
out[key] = maskUrlCredentials(value);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
out[key] = value;
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|