@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +175 -1
  2. package/README.md +2 -2
  3. package/docs/command-reference-vietnamese.md +19 -5
  4. package/docs/distribution-standard.md +7 -7
  5. package/docs/mcp-and-tools.md +154 -2
  6. package/docs/model-options.md +10 -8
  7. package/docs/operator-manual-vietnamese.md +25 -13
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/quickstart-vietnamese.md +2 -2
  10. package/docs/release-install-policy.md +6 -6
  11. package/docs/security-threat-model.md +4 -3
  12. package/docs/team-onboarding.md +4 -4
  13. package/docs/usage-observability.md +29 -0
  14. package/docs/vercel-docs-site.md +34 -4
  15. package/docs-site/architecture.html +152 -0
  16. package/docs-site/assets/docs.css +1001 -0
  17. package/docs-site/assets/docs.js +84 -0
  18. package/docs-site/commands.html +203 -0
  19. package/docs-site/content/architecture.html +29 -0
  20. package/docs-site/content/commands.html +80 -0
  21. package/docs-site/content/index.html +80 -0
  22. package/docs-site/content/mcp-approval.html +133 -0
  23. package/docs-site/content/mcp-auth.html +76 -0
  24. package/docs-site/content/mcp-servers.html +111 -0
  25. package/docs-site/content/mcp.html +120 -0
  26. package/docs-site/content/permissions.html +54 -0
  27. package/docs-site/content/profiles.html +68 -0
  28. package/docs-site/content/quickstart.html +144 -0
  29. package/docs-site/content/runtime.html +45 -0
  30. package/docs-site/content/scope.html +71 -0
  31. package/docs-site/content/security.html +51 -0
  32. package/docs-site/content/team.html +23 -0
  33. package/docs-site/content/workflows.html +47 -0
  34. package/docs-site/index.html +131 -1534
  35. package/docs-site/mcp-approval.html +256 -0
  36. package/docs-site/mcp-auth.html +199 -0
  37. package/docs-site/mcp-servers.html +234 -0
  38. package/docs-site/mcp.html +243 -0
  39. package/docs-site/permissions.html +177 -0
  40. package/docs-site/profiles.html +191 -0
  41. package/docs-site/quickstart.html +267 -0
  42. package/docs-site/runtime.html +168 -0
  43. package/docs-site/scope.html +194 -0
  44. package/docs-site/security.html +174 -0
  45. package/docs-site/team.html +142 -0
  46. package/docs-site/workflows.html +170 -0
  47. package/package.json +4 -2
  48. package/packages/piagent-core/capabilities/capability-core.js +58 -5
  49. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  50. package/packages/piagent-core/extensions/document-intake.ts +5 -5
  51. package/packages/piagent-core/extensions/piagent-guard.ts +432 -2
  52. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  53. package/packages/piagent-core/mcp/mcp-approval-store.js +216 -0
  54. package/packages/piagent-core/mcp/mcp-auth-readiness.js +225 -0
  55. package/packages/piagent-core/mcp/mcp-command-actions.js +400 -0
  56. package/packages/piagent-core/mcp/mcp-config-layers.js +466 -0
  57. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  58. package/packages/piagent-core/mcp/mcp-server-catalog.js +150 -0
  59. package/packages/piagent-core/mcp/mcp-server-entry.js +293 -0
  60. package/packages/piagent-core/mcp/mcp-session-view.js +236 -0
  61. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  62. package/packages/piagent-core/package.json +7 -3
  63. package/packages/piagent-core/prompts/model-options.md +8 -8
  64. package/scripts/build-docs-site.mjs +421 -0
  65. package/scripts/configure-model-scope.sh +21 -5
  66. package/scripts/configure-subagents.sh +18 -5
  67. package/scripts/init-project.sh +15 -8
  68. package/scripts/install-global.sh +5 -5
  69. package/scripts/mcp-manage.mjs +656 -0
  70. package/scripts/pi-model-catalog.sh +11 -1
  71. package/scripts/pi-session-stats.sh +27 -1
  72. package/scripts/pi-usage-history.mjs +567 -0
  73. package/scripts/piagent-cli.mjs +1 -1
  74. package/scripts/preview-docs-site.mjs +88 -0
  75. package/scripts/quality-benchmark.sh +37 -9
  76. package/scripts/setup.sh +23 -15
  77. package/scripts/team-doctor.sh +18 -2
  78. package/scripts/uninstall-global.sh +10 -4
  79. package/scripts/verify-local.sh +65 -19
  80. package/scripts/verify-release-identity.mjs +37 -2
  81. 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
+ }