pi-mcp-extension 1.11.0 → 1.11.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/README.md +18 -1
- package/package.json +1 -1
- package/src/config.ts +149 -8
- package/src/oauth-provider.ts +3 -1
- package/src/transport-factory.ts +24 -1
package/README.md
CHANGED
|
@@ -151,21 +151,37 @@ In project-level `.pi/mcp.json`, `settings` keys carry no defaults — only the
|
|
|
151
151
|
|
|
152
152
|
### Per-Server Config
|
|
153
153
|
|
|
154
|
-
For Pi 1.x compatibility, server entries also accept `type` as an alias for `transport`, `"http"` as an alias for `"streamable-http"`, `oauth` as an alias for `auth`,
|
|
154
|
+
For Pi 1.x compatibility, server entries also accept `type` as an alias for `transport`, `"http"` as an alias for `"streamable-http"`, `oauth` as an alias for `auth`, `timeout` (in seconds) as an alias for `requestTimeoutMs`, and `enabled: false` as a request to keep the server out of the eager set (`lifecycle: "lazy"`). Pi's `auth.provider` (`/login` provider token) is **rejected** at load time instead of being silently downgraded to OAuth 2.1 — use `auth` (OAuth) or static `headers`.
|
|
155
155
|
|
|
156
156
|
| Field | Type | Default | Description |
|
|
157
157
|
|---|---|---|---|
|
|
158
158
|
| `transport` (or `type`) | `"stdio" \| "streamable-http" \| "http" \| "sse"` | `"stdio"` (`"streamable-http"` when `url` is set without `command`) | Transport protocol (`"http"` is normalized to `"streamable-http"`) |
|
|
159
159
|
| `command` | `string` | — | Executable to spawn (**required** for `stdio`) |
|
|
160
160
|
| `args` | `string[]` | `[]` | Arguments for the command |
|
|
161
|
+
| `cwd` | `string` | session working directory | Working directory for a `stdio` server (rejected on `streamable-http` / `sse`). A relative path resolves against the session working directory and a leading `~` expands to your home directory |
|
|
161
162
|
| `env` | `Record<string, string>` | — | Extra environment variables for the child process (literal values, no `${VAR}` interpolation) |
|
|
162
163
|
| `url` | `string` | — | Server URL (**required** for `streamable-http` and `sse`) |
|
|
163
164
|
| `headers` | `Record<string, string>` | — | Static headers sent with `streamable-http` and `sse` requests |
|
|
164
165
|
| `auth` (or `oauth`) | `object` | — | OAuth 2.1 configuration described below and in [OAUTH.md](./OAUTH.md) (`streamable-http` / `sse` only) |
|
|
165
166
|
| `lifecycle` | `"eager" \| "lazy"` | `"lazy"` (`"eager"` for `pi.registerMcpServer()` unless `enabled: false`) | `eager` = auto-start on `session_start`, `lazy` = connect on request via `mcp_connect`, `/mcp:start`, or `/mcp:auth` |
|
|
167
|
+
| `enabled` | `boolean` | `true` | Pi 1.3.0 switch: `false` keeps the entry configured but out of the eager set (normalized to `lifecycle: "lazy"`, even when `lifecycle: "eager"` is also set) |
|
|
166
168
|
| `requestTimeoutMs` (or `timeout`) | `number` | global setting | Per-server timeout override in ms (`timeout` is specified in seconds and multiplied by `1000`) |
|
|
167
169
|
| `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval in ms for proactive connection health monitoring |
|
|
168
170
|
|
|
171
|
+
### Project Override Entries
|
|
172
|
+
|
|
173
|
+
A trusted project's `.pi/mcp.json` may carry a Pi 1.3.0 **partial override** for a global server: an entry with no `command`, `url`, `type`, or `transport` changes only per-project settings and keeps the rest of the global entry, including credentials the project cannot set itself.
|
|
174
|
+
|
|
175
|
+
```jsonc
|
|
176
|
+
// ~/.pi/agent/mcp.json
|
|
177
|
+
{ "mcpServers": { "internal-tools": { "command": "internal-mcp", "lifecycle": "eager" } } }
|
|
178
|
+
|
|
179
|
+
// <project>/.pi/mcp.json — turn it off for this project only
|
|
180
|
+
{ "mcpServers": { "internal-tools": { "enabled": false } } }
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
An override may set `enabled`, `lifecycle`, and Pi's `exposure` / `toolExposure` (the two Pi-native exposure keys are accepted for Pi compatibility and ignored, because this extension decides tool activation itself). Any other key, or an override for a name no global entry defines, is reported as a config error naming the file and the server.
|
|
184
|
+
|
|
169
185
|
### OAuth Config
|
|
170
186
|
|
|
171
187
|
Configured under `auth` (or `oauth`). Token and client state are persisted per server in `<agent dir>/mcp-auth/<hash>.json` (`0700` directory, `0600` files on POSIX) with atomic writes and automatic backup of corrupt state files. See [OAUTH.md](./OAUTH.md) for architecture and troubleshooting details.
|
|
@@ -176,6 +192,7 @@ Configured under `auth` (or `oauth`). Token and client state are persisted per s
|
|
|
176
192
|
| `scope` | `string` | discovered | Requested scope when the server does not advertise one in `WWW-Authenticate` |
|
|
177
193
|
| `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID (skips Dynamic Client Registration) |
|
|
178
194
|
| `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
|
|
195
|
+
| `clientName` (or `oauth.clientName`) | `string` | `pi-mcp/<server name>` | `client_name` sent with Dynamic Client Registration, for servers that only accept known clients |
|
|
179
196
|
| `redirectUrl` (or `oauth.callbackUrl` / `oauth.callbackPort`) | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback (`localhost`, `127.0.0.1`, or `::1` with path `/callback` and an explicit non-zero port; `callbackPort` maps to `http://127.0.0.1:<port>/callback`). Scans forward from `19876` when omitted; binds the exact host and port when configured. |
|
|
180
197
|
|
|
181
198
|
## Commands
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* $PI_CODING_AGENT_DIR when set (Pi profiles / alternative agent dirs), else ~/.pi/agent
|
|
8
8
|
*
|
|
9
9
|
* Project servers/settings override global servers/settings per-key (shallow merge).
|
|
10
|
-
*
|
|
10
|
+
* A Pi 1.3.0 partial project entry (`{ "enabled": false }` — no `command`/`url`) overrides only the
|
|
11
|
+
* per-project keys of the global server of that name, keeping the global entry's credentials.
|
|
12
|
+
* No deep merge of any other keys, no env var interpolation — WYSIWYG config.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { readFile } from "node:fs/promises";
|
|
@@ -58,8 +60,27 @@ const AuthConfigSchema = z.preprocess(
|
|
|
58
60
|
* Pre-registered client_secret.
|
|
59
61
|
*/
|
|
60
62
|
clientSecret: z.string().optional(),
|
|
63
|
+
/**
|
|
64
|
+
* `client_name` sent with Dynamic Client Registration (Pi 1.3.0 `oauth.clientName`).
|
|
65
|
+
* Default: `pi-mcp/<server name>`.
|
|
66
|
+
*/
|
|
67
|
+
clientName: z.string().trim().min(1).optional(),
|
|
68
|
+
/**
|
|
69
|
+
* Pi's `/login` provider auth (`auth.provider`), accepted only so it can be rejected with a
|
|
70
|
+
* clear error: this extension runs OAuth 2.1 and cannot forward a Pi provider token.
|
|
71
|
+
*/
|
|
72
|
+
provider: z.unknown().optional(),
|
|
61
73
|
})
|
|
62
74
|
.superRefine((auth, ctx) => {
|
|
75
|
+
if (auth.provider !== undefined) {
|
|
76
|
+
ctx.addIssue({
|
|
77
|
+
code: "custom",
|
|
78
|
+
path: ["provider"],
|
|
79
|
+
message:
|
|
80
|
+
'"auth.provider" sends a Pi /login provider token, which pi-mcp-extension does not support; ' +
|
|
81
|
+
'remove it to use OAuth 2.1, or send a static credential with "headers"',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
63
84
|
if (auth.redirectUrl !== undefined) {
|
|
64
85
|
try {
|
|
65
86
|
callbackServerConfigFromRedirectUrl(auth.redirectUrl);
|
|
@@ -104,6 +125,17 @@ function normalizeAuthAlias(obj: Record<string, unknown>): void {
|
|
|
104
125
|
}
|
|
105
126
|
}
|
|
106
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Map Pi 1.3.0 `enabled: false` onto `lifecycle: "lazy"`. Pi keeps a disabled entry configured but
|
|
130
|
+
* out of the connect set; the closest behavior here is the lazy lifecycle (manual `/mcp:start`).
|
|
131
|
+
* An explicit `lifecycle: "eager"` never wins over `enabled: false`, which is what Pi does too.
|
|
132
|
+
*/
|
|
133
|
+
function normalizeEnabledAlias(obj: Record<string, unknown>): void {
|
|
134
|
+
if (obj.enabled === false) {
|
|
135
|
+
obj.lifecycle = "lazy";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
/** Map legacy `timeout` (seconds) onto `requestTimeoutMs` (milliseconds). */
|
|
108
140
|
function normalizeTimeoutAlias(obj: Record<string, unknown>): void {
|
|
109
141
|
if (obj.requestTimeoutMs !== undefined) {
|
|
@@ -115,13 +147,14 @@ function normalizeTimeoutAlias(obj: Record<string, unknown>): void {
|
|
|
115
147
|
obj.requestTimeoutMs = obj.timeout * 1000;
|
|
116
148
|
}
|
|
117
149
|
|
|
118
|
-
/** Rewrite Pi 1.x config aliases (`type`, `oauth`, `timeout`) before schema validation. */
|
|
150
|
+
/** Rewrite Pi 1.x config aliases (`type`, `oauth`, `timeout`, `enabled`) before schema validation. */
|
|
119
151
|
function normalizeServerConfigAliases(raw: unknown): unknown {
|
|
120
152
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
121
153
|
const obj = { ...(raw as Record<string, unknown>) };
|
|
122
154
|
normalizeTransportAlias(obj);
|
|
123
155
|
normalizeAuthAlias(obj);
|
|
124
156
|
normalizeTimeoutAlias(obj);
|
|
157
|
+
normalizeEnabledAlias(obj);
|
|
125
158
|
return obj;
|
|
126
159
|
}
|
|
127
160
|
|
|
@@ -133,6 +166,11 @@ export const ServerConfigSchema = z.preprocess(
|
|
|
133
166
|
command: z.string().optional(),
|
|
134
167
|
/** Arguments passed to command. */
|
|
135
168
|
args: z.array(z.string()).default([]),
|
|
169
|
+
/**
|
|
170
|
+
* Working directory for a stdio server (Pi 1.3.0 `cwd`). A leading `~` expands to the home
|
|
171
|
+
* directory and a relative path resolves against the session working directory.
|
|
172
|
+
*/
|
|
173
|
+
cwd: z.string().min(1).optional(),
|
|
136
174
|
/**
|
|
137
175
|
* Extra environment variables passed to the child process as literals.
|
|
138
176
|
* These merge with process.env; project env overrides parent env.
|
|
@@ -164,6 +202,11 @@ export const ServerConfigSchema = z.preprocess(
|
|
|
164
202
|
* "lazy" — start manually via /mcp:start command.
|
|
165
203
|
*/
|
|
166
204
|
lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
|
|
205
|
+
/**
|
|
206
|
+
* Pi 1.3.0 switch: `false` keeps the entry configured but out of the eager set (normalized to
|
|
207
|
+
* `lifecycle: "lazy"`). Default: `true`.
|
|
208
|
+
*/
|
|
209
|
+
enabled: z.boolean().optional(),
|
|
167
210
|
/** Per-request timeout in ms. Overrides global setting. Default: 30000. */
|
|
168
211
|
requestTimeoutMs: z.number().positive().optional(),
|
|
169
212
|
/**
|
|
@@ -188,11 +231,20 @@ export const ServerConfigSchema = z.preprocess(
|
|
|
188
231
|
message: "OAuth authentication is only supported for 'sse' and 'streamable-http' transports, not 'stdio'",
|
|
189
232
|
});
|
|
190
233
|
}
|
|
191
|
-
} else
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
234
|
+
} else {
|
|
235
|
+
if (cfg.url === undefined) {
|
|
236
|
+
ctx.addIssue({
|
|
237
|
+
code: "custom",
|
|
238
|
+
message: `"url" is required for ${cfg.transport} transport`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (cfg.cwd !== undefined) {
|
|
242
|
+
ctx.addIssue({
|
|
243
|
+
code: "custom",
|
|
244
|
+
path: ["cwd"],
|
|
245
|
+
message: `"cwd" is only supported for 'stdio' transport, not '${cfg.transport}'`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
196
248
|
}
|
|
197
249
|
}),
|
|
198
250
|
);
|
|
@@ -242,6 +294,91 @@ const ProjectConfigSchema = z.object({
|
|
|
242
294
|
});
|
|
243
295
|
type ProjectConfig = z.output<typeof ProjectConfigSchema>;
|
|
244
296
|
|
|
297
|
+
// ─── Pi 1.3.0 project override entries ────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Keys a Pi 1.3.0 project entry may override on the global entry of the same name (`/mcp` writes
|
|
301
|
+
* such an entry as soon as a global server is disabled or its exposure is changed for one project).
|
|
302
|
+
* `exposure` / `toolExposure` are Pi-native tool-exposure settings: they are accepted so a shared
|
|
303
|
+
* `mcp.json` loads, and ignored because this extension decides tool activation itself. `lifecycle`
|
|
304
|
+
* is this extension's equivalent of `enabled`.
|
|
305
|
+
*/
|
|
306
|
+
const PROJECT_OVERRIDE_KEYS = ["enabled", "exposure", "toolExposure", "lifecycle"] as const;
|
|
307
|
+
|
|
308
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
309
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A project entry without a transport definition only overrides the global server of that name. */
|
|
313
|
+
function isProjectOverrideEntry(value: unknown): value is Record<string, unknown> {
|
|
314
|
+
if (!isPlainObject(value)) return false;
|
|
315
|
+
return (
|
|
316
|
+
value.command === undefined &&
|
|
317
|
+
value.url === undefined &&
|
|
318
|
+
value.type === undefined &&
|
|
319
|
+
value.transport === undefined
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function invalidProjectConfig(projectPath: string, detail: string): McpError {
|
|
324
|
+
return new McpError(`Invalid mcp.json at ${projectPath}:\n ${detail}`, "<config>", "config");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Merge a partial project override onto the raw global entry, keeping the global credentials. */
|
|
328
|
+
function mergeProjectOverride(
|
|
329
|
+
name: string,
|
|
330
|
+
override: Record<string, unknown>,
|
|
331
|
+
globalServers: Record<string, unknown>,
|
|
332
|
+
projectPath: string,
|
|
333
|
+
): Record<string, unknown> {
|
|
334
|
+
const base = globalServers[name];
|
|
335
|
+
if (!isPlainObject(base)) {
|
|
336
|
+
throw invalidProjectConfig(
|
|
337
|
+
projectPath,
|
|
338
|
+
`mcpServers.${name}: a project override needs "command" or "url" in this file, or a global server of that name`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const unsupported = Object.keys(override).filter(
|
|
342
|
+
(key) => !(PROJECT_OVERRIDE_KEYS as readonly string[]).includes(key),
|
|
343
|
+
);
|
|
344
|
+
if (unsupported.length > 0) {
|
|
345
|
+
throw invalidProjectConfig(
|
|
346
|
+
projectPath,
|
|
347
|
+
`mcpServers.${name}: a project override can only set ${PROJECT_OVERRIDE_KEYS.join(", ")} (found ${unsupported.join(", ")})`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
const merged: Record<string, unknown> = { ...base };
|
|
351
|
+
if (override.lifecycle !== undefined && override.enabled === undefined) {
|
|
352
|
+
delete merged.enabled;
|
|
353
|
+
}
|
|
354
|
+
for (const key of PROJECT_OVERRIDE_KEYS) {
|
|
355
|
+
if (override[key] !== undefined) {
|
|
356
|
+
merged[key] = override[key];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return merged;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Resolve Pi 1.3.0 partial project entries (`{ "<server>": { "enabled": false } }`) against the raw
|
|
364
|
+
* global file *before* validation, so `ProjectConfigSchema` sees a complete entry instead of
|
|
365
|
+
* failing with `"command" is required for stdio transport` (which tore down every global server on
|
|
366
|
+
* `session_start`).
|
|
367
|
+
*/
|
|
368
|
+
function applyProjectOverrides(projectRaw: unknown, globalRaw: unknown, projectPath: string): unknown {
|
|
369
|
+
if (!isPlainObject(projectRaw) || !isPlainObject(projectRaw.mcpServers)) return projectRaw;
|
|
370
|
+
const globalServers = isPlainObject(globalRaw) && isPlainObject(globalRaw.mcpServers)
|
|
371
|
+
? globalRaw.mcpServers
|
|
372
|
+
: {};
|
|
373
|
+
const servers: Record<string, unknown> = {};
|
|
374
|
+
for (const [name, value] of Object.entries(projectRaw.mcpServers)) {
|
|
375
|
+
servers[name] = isProjectOverrideEntry(value)
|
|
376
|
+
? mergeProjectOverride(name, value, globalServers, projectPath)
|
|
377
|
+
: value;
|
|
378
|
+
}
|
|
379
|
+
return { ...projectRaw, mcpServers: servers };
|
|
380
|
+
}
|
|
381
|
+
|
|
245
382
|
// ─── Public Types ─────────────────────────────────────────────────────────────
|
|
246
383
|
|
|
247
384
|
export type AuthConfig = z.output<typeof AuthConfigSchema>;
|
|
@@ -425,7 +562,11 @@ export async function loadConfigWithOrigins(
|
|
|
425
562
|
|
|
426
563
|
const projectCfg = projectRaw === undefined
|
|
427
564
|
? undefined
|
|
428
|
-
: parseWith(
|
|
565
|
+
: parseWith(
|
|
566
|
+
ProjectConfigSchema,
|
|
567
|
+
applyProjectOverrides(projectRaw, globalRaw, projectConfigPath),
|
|
568
|
+
projectConfigPath,
|
|
569
|
+
);
|
|
429
570
|
|
|
430
571
|
return {
|
|
431
572
|
config: projectCfg === undefined ? globalCfg : mergeConfigs(globalCfg, projectCfg),
|
package/src/oauth-provider.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface AuthConfig {
|
|
|
57
57
|
* Pre-registered client_secret.
|
|
58
58
|
*/
|
|
59
59
|
clientSecret?: string | undefined;
|
|
60
|
+
/** `client_name` sent with Dynamic Client Registration. Default: `pi-mcp/<server name>`. */
|
|
61
|
+
clientName?: string | undefined;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
const DEFAULT_CALLBACK_PORT = 19876;
|
|
@@ -252,7 +254,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
252
254
|
redirectUris = [String(this.redirectUrl)];
|
|
253
255
|
}
|
|
254
256
|
return {
|
|
255
|
-
client_name: `pi-mcp/${this.serverName}`,
|
|
257
|
+
client_name: this.authConfig.clientName ?? `pi-mcp/${this.serverName}`,
|
|
256
258
|
redirect_uris: redirectUris,
|
|
257
259
|
grant_types: ["authorization_code", "refresh_token"],
|
|
258
260
|
response_types: ["code"],
|
package/src/transport-factory.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* SDK workarounds.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
9
11
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
10
12
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
11
13
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
@@ -67,15 +69,36 @@ function wireStdioStderr(transport: StdioClientTransport, onStderr: (line: strin
|
|
|
67
69
|
transport.stderr.on("close", flush);
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/** Expand a leading `~` / `~/` / `~\` to the home directory, the way Pi resolves a config `cwd`. */
|
|
73
|
+
function expandHome(path: string): string {
|
|
74
|
+
if (path === "~") return homedir();
|
|
75
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
|
|
76
|
+
return path;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the working directory for a stdio server: Pi 1.3.0's per-server `cwd` (relative paths
|
|
81
|
+
* resolve against the session working directory, `~` expands to the home directory), else the
|
|
82
|
+
* session working directory unchanged.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveStdioCwd(
|
|
85
|
+
configuredCwd: string | undefined,
|
|
86
|
+
sessionCwd: string | undefined,
|
|
87
|
+
): string | undefined {
|
|
88
|
+
if (configuredCwd === undefined) return sessionCwd;
|
|
89
|
+
return resolve(sessionCwd ?? process.cwd(), expandHome(configuredCwd));
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
function createStdioTransport(
|
|
71
93
|
serverName: string,
|
|
72
94
|
config: ServerConfig,
|
|
73
|
-
|
|
95
|
+
sessionCwd: string | undefined,
|
|
74
96
|
onStderr: ((line: string) => void) | undefined,
|
|
75
97
|
): Transport {
|
|
76
98
|
if (!config.command) {
|
|
77
99
|
throw new McpError("stdio transport requires 'command'", serverName, "config");
|
|
78
100
|
}
|
|
101
|
+
const cwd = resolveStdioCwd(config.cwd, sessionCwd);
|
|
79
102
|
const transport = new StdioClientTransport({
|
|
80
103
|
command: config.command,
|
|
81
104
|
args: config.args,
|