@diegopetrucci/pi-extensions 0.1.74 → 0.1.78
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/LICENSE +2 -0
- package/README.md +17 -12
- package/extensions/agent-workflow-audit/README.md +1 -1
- package/extensions/agent-workflow-audit/index.ts +1631 -1009
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/README.md +3 -1
- package/extensions/annotate-git-diff/clipboard.ts +109 -100
- package/extensions/annotate-git-diff/git.ts +846 -768
- package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
- package/extensions/annotate-git-diff/index.ts +712 -671
- package/extensions/annotate-git-diff/package.json +1 -1
- package/extensions/annotate-git-diff/prompt.ts +45 -45
- package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
- package/extensions/annotate-git-diff/review-server.ts +337 -311
- package/extensions/annotate-git-diff/types.ts +132 -132
- package/extensions/annotate-git-diff/ui.ts +27 -27
- package/extensions/annotate-git-diff/watch.ts +74 -74
- package/extensions/annotate-git-diff/web/app.js +1947 -1884
- package/extensions/annotate-git-diff/web/index.html +5 -6
- package/extensions/annotate-last-message/README.md +1 -1
- package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
- package/extensions/annotate-last-message/index.ts +210 -206
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/prompt.ts +87 -66
- package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
- package/extensions/annotate-last-message/session.ts +94 -89
- package/extensions/annotate-last-message/theme.ts +199 -193
- package/extensions/annotate-last-message/types.ts +23 -23
- package/extensions/annotate-last-message/ui.ts +27 -25
- package/extensions/annotate-last-message/web/app.js +4 -7
- package/extensions/annotate-last-message/web/index.html +3 -3
- package/extensions/annotate-last-message/web/md-renderer.js +1 -1
- package/extensions/brrr/README.md +1 -1
- package/extensions/brrr/index.ts +213 -191
- package/extensions/brrr/package.json +1 -1
- package/extensions/claude-fast/README.md +5 -79
- package/extensions/claude-fast/index.ts +31 -289
- package/extensions/claude-fast/package.json +2 -3
- package/extensions/code-reviewer/README.md +6 -6
- package/extensions/code-reviewer/index.ts +1825 -1373
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/README.md +1 -1
- package/extensions/confirm-destructive/index.ts +50 -52
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/README.md +9 -9
- package/extensions/context-cap/index.ts +313 -305
- package/extensions/context-cap/package.json +2 -2
- package/extensions/context-inspector/index.ts +1211 -1064
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/README.md +4 -3
- package/extensions/contrarian/index.ts +1863 -1737
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/index.ts +37 -41
- package/extensions/dirty-repo-guard/package.json +1 -1
- package/extensions/dynamic-context-pruning/README.md +11 -43
- package/extensions/dynamic-context-pruning/index.ts +2131 -2105
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +52 -14
- package/extensions/fast/index.ts +513 -317
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/README.md +1 -1
- package/extensions/git-footer/index.ts +458 -411
- package/extensions/git-footer/package.json +2 -3
- package/extensions/gnosis/index.ts +180 -130
- package/extensions/gnosis/package.json +1 -1
- package/extensions/inline-bash/index.ts +87 -103
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/README.md +1 -1
- package/extensions/librarian/index.ts +1877 -1419
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/README.md +5 -5
- package/extensions/minimal-footer/index.ts +987 -1012
- package/extensions/minimal-footer/minimal-footer.example.json +1 -1
- package/extensions/minimal-footer/openai-usage.ts +195 -190
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/notify/index.ts +222 -230
- package/extensions/notify/package.json +1 -1
- package/extensions/openai-fast/README.md +5 -73
- package/extensions/openai-fast/index.ts +32 -272
- package/extensions/openai-fast/package.json +2 -3
- package/extensions/oracle/README.md +4 -3
- package/extensions/oracle/index.ts +1740 -1627
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +3 -0
- package/extensions/permission-gate/index.ts +785 -612
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/permission-gate/powershell-safety.ts +840 -793
- package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
- package/extensions/project-mcp-json/README.md +199 -0
- package/extensions/project-mcp-json/config.ts +564 -0
- package/extensions/project-mcp-json/index.ts +119 -0
- package/extensions/project-mcp-json/package.json +38 -0
- package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
- package/extensions/quiet-tools/README.md +30 -19
- package/extensions/quiet-tools/index.ts +376 -291
- package/extensions/quiet-tools/package.json +2 -2
- package/extensions/review/README.md +2 -2
- package/extensions/review/index.ts +1745 -1872
- package/extensions/review/package.json +1 -1
- package/extensions/todo/index.ts +280 -280
- package/extensions/todo/package.json +1 -1
- package/extensions/triage-comments/README.md +6 -4
- package/extensions/triage-comments/index.ts +2620 -1888
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +9 -6
- package/assets/social-preview.png +0 -0
- package/extensions/claude-fast/claude-fast.example.json +0 -4
- package/extensions/openai-fast/openai-fast.example.json +0 -4
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Claude Code-style .mcp.json files into Pi McpServerConfig objects.
|
|
3
|
+
*
|
|
4
|
+
* Design constraints:
|
|
5
|
+
* - Strict field allowlist — never spreads raw input.
|
|
6
|
+
* - One-pass ${VAR} / ${VAR:-default} expansion (non-recursive).
|
|
7
|
+
* - Env/header/oauth.clientSecret values are encoded as Pi resolver literals
|
|
8
|
+
* after expansion so Pi's second pass does not re-interpret them.
|
|
9
|
+
* - Warnings never contain config values, expanded strings, or raw JSON text.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { McpServerConfig } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
// Local types matching Pi's McpServerConfig shape.
|
|
15
|
+
// McpStdioServerConfig / McpHttpServerConfig / McpOAuthConfig are not part
|
|
16
|
+
// of the public package exports; we replicate the relevant subset here.
|
|
17
|
+
|
|
18
|
+
type McpServerConfigBase = {
|
|
19
|
+
description?: string;
|
|
20
|
+
timeout?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type McpStdioServerConfig = McpServerConfigBase & {
|
|
24
|
+
type: "stdio";
|
|
25
|
+
command: string;
|
|
26
|
+
args?: string[];
|
|
27
|
+
env?: Record<string, string>;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type McpOAuthConfig = {
|
|
32
|
+
clientId?: string;
|
|
33
|
+
clientSecret?: string;
|
|
34
|
+
callbackPort?: number;
|
|
35
|
+
callbackUrl?: string;
|
|
36
|
+
scope?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type McpHttpServerConfig = McpServerConfigBase & {
|
|
40
|
+
type: "http";
|
|
41
|
+
url: string;
|
|
42
|
+
headers?: Record<string, string>;
|
|
43
|
+
oauth?: McpOAuthConfig;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type ParsedServer = {
|
|
47
|
+
name: string;
|
|
48
|
+
// Cast to McpServerConfig (the public Pi type) for use with pi.registerMcpServer.
|
|
49
|
+
config: McpServerConfig;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type ParseResult = {
|
|
53
|
+
servers: ParsedServer[];
|
|
54
|
+
warnings: string[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const SERVER_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
58
|
+
|
|
59
|
+
/** All top-level fields that the strict allowlist recognises. */
|
|
60
|
+
const ALL_KNOWN_FIELDS = new Set([
|
|
61
|
+
// Shared
|
|
62
|
+
"type",
|
|
63
|
+
"timeout",
|
|
64
|
+
"description",
|
|
65
|
+
// stdio
|
|
66
|
+
"command",
|
|
67
|
+
"args",
|
|
68
|
+
"env",
|
|
69
|
+
"cwd",
|
|
70
|
+
// http
|
|
71
|
+
"url",
|
|
72
|
+
"headers",
|
|
73
|
+
"oauth",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/** Fields that are always rejected regardless of server type. */
|
|
77
|
+
const REJECTED_FIELDS = new Set(["auth", "headersHelper"]);
|
|
78
|
+
|
|
79
|
+
/** Claude-side type values that map to Pi stdio. */
|
|
80
|
+
const STDIO_TYPE_VALUES = new Set(["stdio"]);
|
|
81
|
+
|
|
82
|
+
/** Claude-side type values that map to Pi http. */
|
|
83
|
+
const HTTP_TYPE_VALUES = new Set(["http", "streamable-http"]);
|
|
84
|
+
|
|
85
|
+
/** All allowed type values — anything else causes the entry to be skipped. */
|
|
86
|
+
const ALLOWED_TYPE_VALUES = new Set([...STDIO_TYPE_VALUES, ...HTTP_TYPE_VALUES]);
|
|
87
|
+
|
|
88
|
+
/** Allowed sub-fields of the oauth object. */
|
|
89
|
+
const OAUTH_ALLOWED_FIELDS = new Set([
|
|
90
|
+
"clientId",
|
|
91
|
+
"clientSecret",
|
|
92
|
+
"callbackPort",
|
|
93
|
+
"callbackUrl",
|
|
94
|
+
"scope",
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Encode a string so Pi's resolve-config-value treats it as a literal.
|
|
99
|
+
*
|
|
100
|
+
* Pi's second-pass resolver interprets:
|
|
101
|
+
* - Leading `!` as a shell-command invocation
|
|
102
|
+
* - `${NAME}` / `$NAME` as environment-variable references
|
|
103
|
+
* - `$$` as an escaped literal `$`
|
|
104
|
+
* - `$!` as an escaped literal `!`
|
|
105
|
+
*
|
|
106
|
+
* To make an already-expanded value pass through unchanged:
|
|
107
|
+
* 1. Replace every `$` with `$$` (prevents env-var and escape sequences).
|
|
108
|
+
* 2. If the result starts with `!`, prepend `$` (renders as `$!` → `!`).
|
|
109
|
+
*/
|
|
110
|
+
export function encodeLiteral(value: string): string {
|
|
111
|
+
// Use a replacer function so the replacement string is never treated as a
|
|
112
|
+
// special pattern (a plain replacement of "$$" would be interpreted by
|
|
113
|
+
// String.replace as the escape sequence for a literal "$", making it a no-op).
|
|
114
|
+
const escaped = value.replace(/\$/g, () => "$$");
|
|
115
|
+
return escaped.startsWith("!") ? `$${escaped}` : escaped;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Pattern for safe-to-echo variable names.
|
|
120
|
+
* Only names that look like valid POSIX identifiers are shown in warnings;
|
|
121
|
+
* anything else is replaced with a generic placeholder to prevent adversarial
|
|
122
|
+
* content from appearing in user-visible messages.
|
|
123
|
+
*/
|
|
124
|
+
const SAFE_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
125
|
+
|
|
126
|
+
function safeVarName(name: string): string {
|
|
127
|
+
return SAFE_VAR_NAME_RE.test(name) ? name : "<invalid variable name>";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Sanitize an untrusted string for safe inclusion in a warning message.
|
|
132
|
+
* Strips all ASCII control characters (0x00–0x1f, including ESC = 0x1b) and
|
|
133
|
+
* the DEL character (0x7f), then caps the output at 80 characters to prevent
|
|
134
|
+
* log flooding or terminal injection.
|
|
135
|
+
*/
|
|
136
|
+
// eslint-disable-next-line no-control-regex
|
|
137
|
+
const CONTROL_CHARS_RE = /[\x00-\x1f\x7f]/g;
|
|
138
|
+
|
|
139
|
+
export function sanitizeStringForWarning(s: string): string {
|
|
140
|
+
const stripped = s.replace(CONTROL_CHARS_RE, "");
|
|
141
|
+
return stripped.length <= 80 ? stripped : stripped.slice(0, 80) + "\u2026";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type ExpandOk = { ok: true; value: string };
|
|
145
|
+
type ExpandFail = { ok: false; missingVar: string };
|
|
146
|
+
type ExpandResult = ExpandOk | ExpandFail;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Look up a variable name using only own properties of env and process.env.
|
|
150
|
+
*
|
|
151
|
+
* Using bracket notation directly (e.g. env[name]) would resolve inherited
|
|
152
|
+
* prototype properties such as "constructor", "toString", or "__proto__".
|
|
153
|
+
* Object.hasOwn guards against that by only matching keys the object itself
|
|
154
|
+
* declares. We also require typeof === "string" to reject any non-string
|
|
155
|
+
* own property that somehow ends up in the env record.
|
|
156
|
+
*/
|
|
157
|
+
function lookupVar(env: Record<string, string>, name: string): string | undefined {
|
|
158
|
+
if (Object.hasOwn(env, name) && typeof env[name] === "string") {
|
|
159
|
+
return env[name];
|
|
160
|
+
}
|
|
161
|
+
if (Object.hasOwn(process.env, name) && typeof process.env[name] === "string") {
|
|
162
|
+
return process.env[name];
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One-pass expansion of `${VAR}` and `${VAR:-default}` in a template string.
|
|
169
|
+
*
|
|
170
|
+
* - Substitution is non-recursive: values that contain `${…}` are not
|
|
171
|
+
* processed again.
|
|
172
|
+
* - A missing variable with no default causes a failure result that names
|
|
173
|
+
* only the variable (never its value).
|
|
174
|
+
* - `env` supplements `process.env`; `env` values take precedence.
|
|
175
|
+
*/
|
|
176
|
+
export function expandVars(template: string, env: Record<string, string>): ExpandResult {
|
|
177
|
+
let missingVar: string | undefined;
|
|
178
|
+
|
|
179
|
+
const result = template.replace(/\$\{([^}]+)\}/g, (_, expr: string) => {
|
|
180
|
+
if (missingVar !== undefined) return "";
|
|
181
|
+
|
|
182
|
+
const sepIdx = expr.indexOf(":-");
|
|
183
|
+
if (sepIdx >= 0) {
|
|
184
|
+
const varName = expr.slice(0, sepIdx);
|
|
185
|
+
const defaultVal = expr.slice(sepIdx + 2);
|
|
186
|
+
const val = lookupVar(env, varName);
|
|
187
|
+
return val !== undefined ? val : defaultVal;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const varName = expr;
|
|
191
|
+
const val = lookupVar(env, varName);
|
|
192
|
+
if (val === undefined) {
|
|
193
|
+
missingVar = varName;
|
|
194
|
+
return "";
|
|
195
|
+
}
|
|
196
|
+
return val;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (missingVar !== undefined) {
|
|
200
|
+
return { ok: false, missingVar };
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, value: result };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
206
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isStringArray(v: unknown): v is string[] {
|
|
210
|
+
return Array.isArray(v) && v.every((e) => typeof e === "string");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Parse a Claude Code-style `.mcp.json` file and return Pi-ready server
|
|
215
|
+
* configs plus any warnings encountered.
|
|
216
|
+
*
|
|
217
|
+
* @param text Raw UTF-8 content of the `.mcp.json` file.
|
|
218
|
+
* @param env Optional supplemental environment variables used for
|
|
219
|
+
* `${VAR}` expansion (merged over `process.env`).
|
|
220
|
+
*/
|
|
221
|
+
export function parseMcpJson(text: string, env: Record<string, string> = {}): ParseResult {
|
|
222
|
+
const warnings: string[] = [];
|
|
223
|
+
const servers: ParsedServer[] = [];
|
|
224
|
+
|
|
225
|
+
let root: unknown;
|
|
226
|
+
try {
|
|
227
|
+
root = JSON.parse(text);
|
|
228
|
+
} catch {
|
|
229
|
+
// Never include raw parse error text in warnings.
|
|
230
|
+
warnings.push("project-mcp-json: .mcp.json could not be parsed as JSON; skipping all servers");
|
|
231
|
+
return { servers, warnings };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!isRecord(root)) {
|
|
235
|
+
warnings.push("project-mcp-json: .mcp.json root must be a JSON object; skipping all servers");
|
|
236
|
+
return { servers, warnings };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const mcpServers = root["mcpServers"];
|
|
240
|
+
if (mcpServers === undefined) {
|
|
241
|
+
return { servers, warnings };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!isRecord(mcpServers)) {
|
|
245
|
+
warnings.push("project-mcp-json: mcpServers must be an object; skipping all servers");
|
|
246
|
+
return { servers, warnings };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const [name, rawEntry] of Object.entries(mcpServers)) {
|
|
250
|
+
const safeName = sanitizeStringForWarning(name);
|
|
251
|
+
const warn = (msg: string) => warnings.push(`project-mcp-json: server "${safeName}": ${msg}`);
|
|
252
|
+
|
|
253
|
+
if (!SERVER_NAME_RE.test(name)) {
|
|
254
|
+
warn("invalid name (allowed: letters, digits, _ and -); skipping");
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!isRecord(rawEntry)) {
|
|
259
|
+
warn("entry must be an object; skipping");
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let rejectedField: string | undefined;
|
|
264
|
+
for (const rejected of REJECTED_FIELDS) {
|
|
265
|
+
if (rejected in rawEntry) {
|
|
266
|
+
rejectedField = rejected;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (rejectedField !== undefined) {
|
|
271
|
+
warn(`field "${rejectedField}" is not allowed; skipping`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const unknownFields = Object.keys(rawEntry).filter((k) => !ALL_KNOWN_FIELDS.has(k));
|
|
276
|
+
if (unknownFields.length > 0) {
|
|
277
|
+
warn(
|
|
278
|
+
`unknown field(s) "${unknownFields.map(sanitizeStringForWarning).join('", "')}" are not allowed; skipping`,
|
|
279
|
+
);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const rawType = rawEntry["type"];
|
|
284
|
+
if (rawType !== undefined && typeof rawType !== "string") {
|
|
285
|
+
warn("type must be a string; skipping");
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const typeStr: string | undefined = rawType as string | undefined;
|
|
289
|
+
|
|
290
|
+
if (typeStr !== undefined && !ALLOWED_TYPE_VALUES.has(typeStr)) {
|
|
291
|
+
warn("type value is not supported (allowed: stdio, http, streamable-http); skipping");
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const hasCommand = "command" in rawEntry;
|
|
296
|
+
const hasUrl = "url" in rawEntry;
|
|
297
|
+
|
|
298
|
+
if (hasCommand && hasUrl) {
|
|
299
|
+
warn("cannot have both command and url; skipping");
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let kind: "stdio" | "http";
|
|
304
|
+
if (typeStr !== undefined) {
|
|
305
|
+
kind = STDIO_TYPE_VALUES.has(typeStr) ? "stdio" : "http";
|
|
306
|
+
} else if (hasCommand) {
|
|
307
|
+
kind = "stdio";
|
|
308
|
+
} else if (hasUrl) {
|
|
309
|
+
kind = "http";
|
|
310
|
+
} else {
|
|
311
|
+
warn("must have either command (stdio) or url (http); skipping");
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (kind === "stdio" && hasUrl) {
|
|
316
|
+
warn("type stdio cannot have a url field; skipping");
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (kind === "http" && hasCommand) {
|
|
320
|
+
warn("type http cannot have a command field; skipping");
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (kind === "stdio") {
|
|
325
|
+
const parsed = parseStdioEntry(rawEntry, env, warn);
|
|
326
|
+
if (parsed !== null) servers.push({ name, config: parsed });
|
|
327
|
+
} else {
|
|
328
|
+
const parsed = parseHttpEntry(rawEntry, env, warn);
|
|
329
|
+
if (parsed !== null) servers.push({ name, config: parsed });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { servers, warnings };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseStdioEntry(
|
|
337
|
+
raw: Record<string, unknown>,
|
|
338
|
+
env: Record<string, string>,
|
|
339
|
+
warn: (msg: string) => void,
|
|
340
|
+
): McpStdioServerConfig | null {
|
|
341
|
+
const rawCommand = raw["command"];
|
|
342
|
+
if (typeof rawCommand !== "string" || rawCommand.length === 0) {
|
|
343
|
+
warn("command must be a non-empty string; skipping");
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
const commandExpanded = expandVars(rawCommand, env);
|
|
347
|
+
if (!commandExpanded.ok) {
|
|
348
|
+
warn(
|
|
349
|
+
`variable "${safeVarName(commandExpanded.missingVar)}" in command is not set and has no default; skipping`,
|
|
350
|
+
);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const rawArgs = raw["args"];
|
|
355
|
+
if (rawArgs !== undefined && !isStringArray(rawArgs)) {
|
|
356
|
+
warn("args must be an array of strings; skipping");
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const expandedArgs: string[] = [];
|
|
360
|
+
for (const arg of rawArgs ?? []) {
|
|
361
|
+
const res = expandVars(arg, env);
|
|
362
|
+
if (!res.ok) {
|
|
363
|
+
warn(
|
|
364
|
+
`variable "${safeVarName(res.missingVar)}" in args is not set and has no default; skipping`,
|
|
365
|
+
);
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
expandedArgs.push(res.value);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const rawEnv = raw["env"];
|
|
372
|
+
if (rawEnv !== undefined && !isRecord(rawEnv)) {
|
|
373
|
+
warn("env must be an object; skipping");
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const encodedEnv: Record<string, string> = {};
|
|
377
|
+
if (isRecord(rawEnv)) {
|
|
378
|
+
for (const [k, v] of Object.entries(rawEnv)) {
|
|
379
|
+
if (typeof v !== "string") {
|
|
380
|
+
warn(`env["${sanitizeStringForWarning(k)}"] must be a string; skipping`);
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
const res = expandVars(v, env);
|
|
384
|
+
if (!res.ok) {
|
|
385
|
+
warn(
|
|
386
|
+
`variable "${safeVarName(res.missingVar)}" in env["${sanitizeStringForWarning(k)}"] is not set and has no default; skipping`,
|
|
387
|
+
);
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
// Encode so Pi's second pass treats the expanded value as a literal
|
|
391
|
+
encodedEnv[k] = encodeLiteral(res.value);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const rawCwd = raw["cwd"];
|
|
396
|
+
if (rawCwd !== undefined && typeof rawCwd !== "string") {
|
|
397
|
+
warn("cwd must be a string; skipping");
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
let expandedCwd: string | undefined;
|
|
401
|
+
if (typeof rawCwd === "string") {
|
|
402
|
+
const res = expandVars(rawCwd, env);
|
|
403
|
+
if (!res.ok) {
|
|
404
|
+
warn(
|
|
405
|
+
`variable "${safeVarName(res.missingVar)}" in cwd is not set and has no default; skipping`,
|
|
406
|
+
);
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
expandedCwd = res.value;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const config: McpStdioServerConfig = {
|
|
413
|
+
type: "stdio",
|
|
414
|
+
command: commandExpanded.value,
|
|
415
|
+
...(expandedArgs.length > 0 ? { args: expandedArgs } : {}),
|
|
416
|
+
...(Object.keys(encodedEnv).length > 0 ? { env: encodedEnv } : {}),
|
|
417
|
+
...(expandedCwd !== undefined ? { cwd: expandedCwd } : {}),
|
|
418
|
+
...parseSharedFields(raw),
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
return config;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function parseHttpEntry(
|
|
425
|
+
raw: Record<string, unknown>,
|
|
426
|
+
env: Record<string, string>,
|
|
427
|
+
warn: (msg: string) => void,
|
|
428
|
+
): McpHttpServerConfig | null {
|
|
429
|
+
const rawUrl = raw["url"];
|
|
430
|
+
if (typeof rawUrl !== "string" || rawUrl.length === 0) {
|
|
431
|
+
warn("url must be a non-empty string; skipping");
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
const urlExpanded = expandVars(rawUrl, env);
|
|
435
|
+
if (!urlExpanded.ok) {
|
|
436
|
+
warn(
|
|
437
|
+
`variable "${safeVarName(urlExpanded.missingVar)}" in url is not set and has no default; skipping`,
|
|
438
|
+
);
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const rawHeaders = raw["headers"];
|
|
443
|
+
if (rawHeaders !== undefined && !isRecord(rawHeaders)) {
|
|
444
|
+
warn("headers must be an object; skipping");
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const encodedHeaders: Record<string, string> = {};
|
|
448
|
+
if (isRecord(rawHeaders)) {
|
|
449
|
+
for (const [k, v] of Object.entries(rawHeaders)) {
|
|
450
|
+
if (typeof v !== "string") {
|
|
451
|
+
warn(`headers["${sanitizeStringForWarning(k)}"] must be a string; skipping`);
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
const res = expandVars(v, env);
|
|
455
|
+
if (!res.ok) {
|
|
456
|
+
warn(
|
|
457
|
+
`variable "${safeVarName(res.missingVar)}" in headers["${sanitizeStringForWarning(k)}"] is not set and has no default; skipping`,
|
|
458
|
+
);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
encodedHeaders[k] = encodeLiteral(res.value);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const rawOauth = raw["oauth"];
|
|
466
|
+
let parsedOauth: McpOAuthConfig | undefined;
|
|
467
|
+
if (rawOauth !== undefined) {
|
|
468
|
+
if (!isRecord(rawOauth)) {
|
|
469
|
+
warn("oauth must be an object; skipping");
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
const oauthUnknown = Object.keys(rawOauth).filter((k) => !OAUTH_ALLOWED_FIELDS.has(k));
|
|
473
|
+
if (oauthUnknown.length > 0) {
|
|
474
|
+
warn(
|
|
475
|
+
`unknown oauth field(s) "${oauthUnknown.map(sanitizeStringForWarning).join('", "')}" are not allowed; skipping`,
|
|
476
|
+
);
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const oauth: McpOAuthConfig = {};
|
|
481
|
+
|
|
482
|
+
const rawClientId = rawOauth["clientId"];
|
|
483
|
+
if (rawClientId !== undefined) {
|
|
484
|
+
if (typeof rawClientId !== "string") {
|
|
485
|
+
warn("oauth.clientId must be a string; skipping");
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
oauth.clientId = rawClientId;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const rawClientSecret = rawOauth["clientSecret"];
|
|
492
|
+
if (rawClientSecret !== undefined) {
|
|
493
|
+
if (typeof rawClientSecret !== "string") {
|
|
494
|
+
warn("oauth.clientSecret must be a string; skipping");
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
const res = expandVars(rawClientSecret, env);
|
|
498
|
+
if (!res.ok) {
|
|
499
|
+
warn(
|
|
500
|
+
`variable "${safeVarName(res.missingVar)}" in oauth.clientSecret is not set and has no default; skipping`,
|
|
501
|
+
);
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
oauth.clientSecret = encodeLiteral(res.value);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const rawCallbackPort = rawOauth["callbackPort"];
|
|
508
|
+
if (rawCallbackPort !== undefined) {
|
|
509
|
+
if (typeof rawCallbackPort !== "number") {
|
|
510
|
+
warn("oauth.callbackPort must be a number; skipping");
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
oauth.callbackPort = rawCallbackPort;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const rawCallbackUrl = rawOauth["callbackUrl"];
|
|
517
|
+
if (rawCallbackUrl !== undefined) {
|
|
518
|
+
if (typeof rawCallbackUrl !== "string") {
|
|
519
|
+
warn("oauth.callbackUrl must be a string; skipping");
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
oauth.callbackUrl = rawCallbackUrl;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const rawScope = rawOauth["scope"];
|
|
526
|
+
if (rawScope !== undefined) {
|
|
527
|
+
if (typeof rawScope !== "string") {
|
|
528
|
+
warn("oauth.scope must be a string; skipping");
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
oauth.scope = rawScope;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
parsedOauth = oauth;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const config: McpHttpServerConfig = {
|
|
538
|
+
type: "http",
|
|
539
|
+
url: urlExpanded.value,
|
|
540
|
+
...(Object.keys(encodedHeaders).length > 0 ? { headers: encodedHeaders } : {}),
|
|
541
|
+
...(parsedOauth !== undefined ? { oauth: parsedOauth } : {}),
|
|
542
|
+
...parseSharedFields(raw),
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
return config;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
type SharedFields = Pick<McpServerConfigBase, "timeout" | "description">;
|
|
549
|
+
|
|
550
|
+
function parseSharedFields(raw: Record<string, unknown>): SharedFields {
|
|
551
|
+
const result: SharedFields = {};
|
|
552
|
+
|
|
553
|
+
const rawTimeout = raw["timeout"];
|
|
554
|
+
if (typeof rawTimeout === "number") {
|
|
555
|
+
result.timeout = rawTimeout;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const rawDescription = raw["description"];
|
|
559
|
+
if (typeof rawDescription === "string") {
|
|
560
|
+
result.description = rawDescription;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-mcp-json: reads a Claude Code-style .mcp.json from the project
|
|
3
|
+
* working directory and registers the configured MCP servers with pi.
|
|
4
|
+
*
|
|
5
|
+
* The handler is synchronous (no awaits); there is no async gap between the
|
|
6
|
+
* trust decision and server registration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
getAgentDir,
|
|
12
|
+
hasTrustRequiringProjectResources,
|
|
13
|
+
ProjectTrustStore,
|
|
14
|
+
SettingsManager,
|
|
15
|
+
type DefaultProjectTrust,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { parseMcpJson, sanitizeStringForWarning } from "./config.ts";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
export default function (pi: ExtensionAPI): void {
|
|
22
|
+
// Guard: pi.registerMcpServer was introduced in Pi 1.0. If the API is
|
|
23
|
+
// absent (older Pi version), emit a single session-start notice and bail
|
|
24
|
+
// out rather than crashing or emitting per-server errors.
|
|
25
|
+
if (typeof (pi as unknown as Record<string, unknown>).registerMcpServer !== "function") {
|
|
26
|
+
pi.on("session_start", (_event, ctx) => {
|
|
27
|
+
if (ctx.hasUI) {
|
|
28
|
+
ctx.ui.notify(
|
|
29
|
+
"project-mcp-json: Pi >=1.0 is required to register MCP servers. Please upgrade Pi.",
|
|
30
|
+
"info",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pi.on("session_start", (_event, ctx) => {
|
|
38
|
+
if (!ctx.isProjectTrusted()) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const mcpJsonPath = path.join(ctx.cwd, ".mcp.json");
|
|
43
|
+
if (!fs.existsSync(mcpJsonPath)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const agentDir = getAgentDir();
|
|
48
|
+
|
|
49
|
+
if (!hasTrustRequiringProjectResources(ctx.cwd)) {
|
|
50
|
+
const trustStore = new ProjectTrustStore(agentDir);
|
|
51
|
+
const savedDecision = trustStore.get(ctx.cwd);
|
|
52
|
+
|
|
53
|
+
if (savedDecision === false) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (savedDecision !== true) {
|
|
58
|
+
let defaultTrust: DefaultProjectTrust;
|
|
59
|
+
try {
|
|
60
|
+
defaultTrust = SettingsManager.create(ctx.cwd, agentDir, {
|
|
61
|
+
projectTrusted: false,
|
|
62
|
+
}).getDefaultProjectTrust();
|
|
63
|
+
} catch {
|
|
64
|
+
defaultTrust = "ask";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (defaultTrust === "never") {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (defaultTrust === "ask") {
|
|
72
|
+
if (ctx.hasUI) {
|
|
73
|
+
ctx.ui.notify(
|
|
74
|
+
"project-mcp-json: Found .mcp.json — run /trust to enable its MCP servers.",
|
|
75
|
+
"info",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let rawContent: string;
|
|
84
|
+
try {
|
|
85
|
+
rawContent = fs.readFileSync(mcpJsonPath, "utf-8");
|
|
86
|
+
} catch {
|
|
87
|
+
// File disappeared or became unreadable between the existsSync and now.
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { servers, warnings } = parseMcpJson(rawContent, {});
|
|
92
|
+
|
|
93
|
+
for (const warning of warnings) {
|
|
94
|
+
if (ctx.hasUI) {
|
|
95
|
+
ctx.ui.notify(warning, "warning");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (servers.length === 0) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const server of servers) {
|
|
104
|
+
try {
|
|
105
|
+
pi.registerMcpServer(server.name, server.config);
|
|
106
|
+
} catch {
|
|
107
|
+
// Do not forward the raw error message; it may contain internal Pi
|
|
108
|
+
// details. Emit a generic warning that names the server only.
|
|
109
|
+
if (ctx.hasUI) {
|
|
110
|
+
ctx.ui.notify(
|
|
111
|
+
`project-mcp-json: could not register server "${sanitizeStringForWarning(server.name)}" — ` +
|
|
112
|
+
`name may already be in use by another extension or configuration.`,
|
|
113
|
+
"warning",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|