pi-mcp-extension 1.9.0 → 1.9.2
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 +67 -42
- package/package.json +3 -3
- package/src/callback-server.ts +4 -2
- package/src/config.ts +292 -95
- package/src/index.ts +167 -40
- package/src/server-manager.ts +25 -12
- package/src/tool-bridge.ts +7 -2
package/README.md
CHANGED
|
@@ -9,19 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
12
|
-
- **Multi-transport** — `stdio` subprocesses, `streamable-http
|
|
12
|
+
- **Multi-transport** — `stdio` subprocesses, `streamable-http` (including `"http"` and URL-only shorthand), and legacy `sse` out of the box
|
|
13
13
|
- **Auto-discovery** — Paginated `tools/list` with cursor following (MCP 2025-03-26 spec)
|
|
14
14
|
- **Live tool refresh** — `notifications/tools/list_changed` triggers re-discovery without restart
|
|
15
15
|
- **Clean cancellation** — `AbortSignal` propagation via `notifications/cancelled`
|
|
16
|
-
- **Smart reconnection** — Fixed delay retry schedule (1s → 3s → 5s → 10s → 30s), configurable max retries
|
|
16
|
+
- **Smart reconnection** — Fixed delay retry schedule (1s → 3s → 5s → 10s → 30s), configurable max retries, and automatic recovery when a server transport closes unexpectedly
|
|
17
17
|
- **Health checks** — Opt-in periodic pings to detect stale connections
|
|
18
|
-
- **Safe process lifecycle** — PID tracking with safety-net `SIGKILL` for orphaned subprocesses
|
|
18
|
+
- **Safe process lifecycle** — Child PID tracking (`StdioClientTransport.pid`) with safety-net `SIGKILL` for orphaned subprocesses
|
|
19
19
|
- **Stable tool names** — Sanitized, collision-avoiding names (max 64 chars) that stay constant across reconnects
|
|
20
20
|
- **No tool churn** — Activate/deactivate pattern ensures tools don't flicker during reconnection
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
21
|
+
- **Layered config & trust boundary** — Global `<agent dir>/mcp.json`, project `.pi/mcp.json` (loaded only in trusted projects via `ctx.isProjectTrusted()`), and Pi 1.x extension-registered servers (`pi.registerMcpServer()`)
|
|
22
|
+
- **Rich tool results & annotations** — MCP `image` content passthrough (`ImageContent`), plus `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` surfaced in tool descriptions
|
|
23
|
+
- **On-demand agent tools & commands** — `mcp_status` and `mcp_connect` agent tools plus `/mcp`, `/mcp:start`, `/mcp:stop`, and `/mcp:auth` slash commands with server-name autocomplete
|
|
23
24
|
- **Structured error handling** — Distinct error codes for config, connection, protocol, and tool-level failures
|
|
24
|
-
- **OAuth 2.1** — Browser authorization with PKCE, protected-resource discovery, token refresh, and secure local persistence
|
|
25
|
+
- **OAuth 2.1** — Browser and headless/SSH authorization with PKCE, protected-resource discovery, Dynamic Client Registration, startup prompts for eager servers, cross-process lock recovery, token refresh, and secure local persistence (see [OAUTH.md](./OAUTH.md))
|
|
25
26
|
|
|
26
27
|
## Installation
|
|
27
28
|
|
|
@@ -42,7 +43,16 @@ pi -e npm:pi-mcp-extension
|
|
|
42
43
|
pi install npm:pi-mcp-extension
|
|
43
44
|
```
|
|
44
45
|
|
|
45
|
-
2.
|
|
46
|
+
2. *(Pi 1.x)* Disable Pi's built-in `mcp` extension so Pi does not warn that both extensions are enabled at the same time — either run `pi config` and uncheck `mcp` under **Built-in extensions**, or add `"extensions": ["-builtin:mcp"]` to `~/.pi/agent/settings.json`:
|
|
47
|
+
```jsonc
|
|
48
|
+
{
|
|
49
|
+
"extensions": [
|
|
50
|
+
"-builtin:mcp"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
3. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level):
|
|
46
56
|
```jsonc
|
|
47
57
|
{
|
|
48
58
|
"mcpServers": {
|
|
@@ -55,16 +65,17 @@ pi -e npm:pi-mcp-extension
|
|
|
55
65
|
}
|
|
56
66
|
```
|
|
57
67
|
|
|
58
|
-
|
|
68
|
+
4. Start Pi — your MCP tools are ready. Use `/mcp` to check server status.
|
|
59
69
|
|
|
60
70
|
## Configuration
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
Servers can be configured via JSON files or registered programmatically by other Pi 1.x extensions:
|
|
63
73
|
|
|
64
|
-
|
|
|
74
|
+
| Source | Precedence & Scope |
|
|
65
75
|
|---|---|
|
|
66
|
-
|
|
|
67
|
-
|
|
|
76
|
+
| `.pi/mcp.json` | **Highest** — Project-level overrides per server and per explicitly stated setting. Loaded on `session_start` **only when the project is trusted** (`ctx.isProjectTrusted()`); never read at bootstrap or in untrusted projects. |
|
|
77
|
+
| `~/.pi/agent/mcp.json` | **Global** — Applies to all projects (honors `$PI_CODING_AGENT_DIR` with `~` expansion when set: `<agent dir>/mcp.json`). |
|
|
78
|
+
| `pi.registerMcpServer()` | **Extension-registered** — Discovered via `pi.getMcpServers()` and updated live on `mcp_servers_change` events when replacing `builtin:mcp`. Any server with the same name in `mcp.json` takes precedence. |
|
|
68
79
|
|
|
69
80
|
### Full Example
|
|
70
81
|
|
|
@@ -87,8 +98,7 @@ Config files are loaded from two locations. **Project config overrides global co
|
|
|
87
98
|
"lifecycle": "eager"
|
|
88
99
|
},
|
|
89
100
|
"deepsource": {
|
|
90
|
-
"
|
|
91
|
-
"url": "https://mcp.deepsource.io/mcp",
|
|
101
|
+
"url": "https://mcp.deepsource.io/mcp", // "transport" defaults to "streamable-http" when "url" is set
|
|
92
102
|
"lifecycle": "eager"
|
|
93
103
|
},
|
|
94
104
|
|
|
@@ -126,6 +136,8 @@ Config files are loaded from two locations. **Project config overrides global co
|
|
|
126
136
|
|
|
127
137
|
### Global Settings
|
|
128
138
|
|
|
139
|
+
In project-level `.pi/mcp.json`, `settings` keys carry no defaults — only the keys explicitly stated in the project file override global settings.
|
|
140
|
+
|
|
129
141
|
| Field | Type | Default | Description |
|
|
130
142
|
|---|---|---|---|
|
|
131
143
|
| `toolPrefix` | `string` | `"mcp"` | Prefix for registered tools: `<prefix>_<server>_<tool>`. Must match `^[a-zA-Z][a-zA-Z0-9_]*$`. |
|
|
@@ -134,28 +146,32 @@ Config files are loaded from two locations. **Project config overrides global co
|
|
|
134
146
|
|
|
135
147
|
### Per-Server Config
|
|
136
148
|
|
|
149
|
+
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`, and `timeout` (in seconds) as an alias for `requestTimeoutMs`.
|
|
150
|
+
|
|
137
151
|
| Field | Type | Default | Description |
|
|
138
152
|
|---|---|---|---|
|
|
139
|
-
| `transport` | `"stdio" \| "streamable-http" \| "sse"` | `"stdio"` | Transport protocol |
|
|
140
|
-
| `command` | `string` | — | Executable to spawn (**required** for stdio) |
|
|
153
|
+
| `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"`) |
|
|
154
|
+
| `command` | `string` | — | Executable to spawn (**required** for `stdio`) |
|
|
141
155
|
| `args` | `string[]` | `[]` | Arguments for the command |
|
|
142
|
-
| `env` | `Record<string, string>` | — | Extra environment variables for the child process |
|
|
143
|
-
| `url` | `string` | — | Server URL (**required** for streamable-http
|
|
144
|
-
| `headers` | `Record<string, string>` | — | Static headers sent with
|
|
145
|
-
| `auth` | `object` | — | OAuth configuration described below |
|
|
146
|
-
| `lifecycle` | `"eager" \| "lazy"` | `"lazy"` | `eager` = auto-start on
|
|
147
|
-
| `requestTimeoutMs` | `number` | global setting | Per-server timeout override |
|
|
148
|
-
| `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval for connection health monitoring |
|
|
156
|
+
| `env` | `Record<string, string>` | — | Extra environment variables for the child process (literal values, no `${VAR}` interpolation) |
|
|
157
|
+
| `url` | `string` | — | Server URL (**required** for `streamable-http` and `sse`) |
|
|
158
|
+
| `headers` | `Record<string, string>` | — | Static headers sent with `streamable-http` and `sse` requests |
|
|
159
|
+
| `auth` (or `oauth`) | `object` | — | OAuth 2.1 configuration described below and in [OAUTH.md](./OAUTH.md) (`streamable-http` / `sse` only) |
|
|
160
|
+
| `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` |
|
|
161
|
+
| `requestTimeoutMs` (or `timeout`) | `number` | global setting | Per-server timeout override in ms (`timeout` is specified in seconds and multiplied by `1000`) |
|
|
162
|
+
| `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval in ms for proactive connection health monitoring |
|
|
149
163
|
|
|
150
164
|
### OAuth Config
|
|
151
165
|
|
|
166
|
+
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.
|
|
167
|
+
|
|
152
168
|
| Field | Type | Default | Description |
|
|
153
169
|
|---|---|---|---|
|
|
154
170
|
| `type` | `"oauth"` | `"oauth"` | Authentication type |
|
|
155
|
-
| `scope` | `string` | discovered | Requested scope when the server does not advertise one |
|
|
156
|
-
| `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID |
|
|
171
|
+
| `scope` | `string` | discovered | Requested scope when the server does not advertise one in `WWW-Authenticate` |
|
|
172
|
+
| `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID (skips Dynamic Client Registration) |
|
|
157
173
|
| `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
|
|
158
|
-
| `redirectUrl` | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback
|
|
174
|
+
| `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. |
|
|
159
175
|
|
|
160
176
|
## Commands
|
|
161
177
|
|
|
@@ -166,11 +182,18 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
|
|
|
166
182
|
| `/mcp` | Show status summary of all configured servers |
|
|
167
183
|
| `/mcp <name>` | Show detailed status and stderr log for a specific server |
|
|
168
184
|
| `/mcp:start <name>` | Connect a server and wait for tool discovery |
|
|
169
|
-
| `/mcp:stop <name>` | Cancel a pending connection or authorization, or stop a running server and deactivate its tools |
|
|
185
|
+
| `/mcp:stop <name>` | Cancel a pending connection, startup prompt, or authorization, or stop a running server and deactivate its tools |
|
|
170
186
|
| `/mcp:auth <name>` | Connect with stored OAuth credentials; request browser authorization only if needed |
|
|
171
187
|
| `/mcp:auth <name> --reset` | Discard stored OAuth credentials and request fresh authorization |
|
|
172
188
|
|
|
173
|
-
Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and connect one named server without routine slash commands. `mcp_status` reports lifecycle, connection state, credential presence, and safe action hints without returning tokens, headers, or authorization URLs. `mcp_connect` activates discovered tools before returning success. Connection failures return fixed actionable categories instead of raw server response text.
|
|
189
|
+
Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and connect one named server without routine slash commands. `mcp_status` reports lifecycle, connection state, discovered and active tool counts, credential presence, and safe action hints without returning tokens, headers, or authorization URLs. `mcp_connect` activates discovered tools before returning success. Connection failures return fixed actionable categories instead of raw server response text.
|
|
190
|
+
|
|
191
|
+
### OAuth startup, headless/SSH, and concurrency behavior
|
|
192
|
+
|
|
193
|
+
- **Interactive sessions required for browser auth**: Print and RPC sessions fail promptly when interactive authorization is required.
|
|
194
|
+
- **Startup prompt for eager OAuth servers**: Silent startup and background reconnects never open a browser unprompted or trigger Dynamic Client Registration when no client credentials are stored. When an `eager` OAuth server needs interactive authorization at startup in an interactive Pi session, Pi presents a sequential `<name> needs authorization` prompt (`Authorize now` / `Not now`). Choosing `Authorize now` runs the interactive OAuth flow; choosing `Not now` leaves the server stopped, shows a `run /mcp:auth <name>` footer hint, and remembers the decline for the rest of the session.
|
|
195
|
+
- **Headless and SSH support**: While authorization is pending, Pi displays the authorization URL and offers `Retry: open browser again`, `Paste redirect URL`, and `Cancel authentication`. On remote/SSH or headless machines, open the URL locally and either forward the callback port (`ssh -L <port>:127.0.0.1:<port>`) or paste the browser's final `http://127.0.0.1:<port>/callback?code=...&state=...` redirect URL into `Paste redirect URL`. Only `https:` (or loopback `http:`) authorization URLs are passed to the system browser opener.
|
|
196
|
+
- **Coordinated connects and cross-process locks**: Concurrent connect requests join an in-flight attempt, while `--reset` supersedes a normal connect and shares one pending reset. Cross-process interactive flows are serialized per server via `<agent dir>/mcp-auth/<hash>.json.lock` with a 30s heartbeat and automatic reclamation of locks from dead local PIDs or stale heartbeats (>90s), while allowing silent token refreshes to persist rotated tokens.
|
|
174
197
|
|
|
175
198
|
### Connection readiness and active tools
|
|
176
199
|
|
|
@@ -199,20 +222,20 @@ Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and
|
|
|
199
222
|
▼ ▼
|
|
200
223
|
Global/Project ┌──────────────┐
|
|
201
224
|
mcp.json files │ MCP Servers │
|
|
202
|
-
|
|
225
|
+
+ Extensions │ (stdio/http) │
|
|
203
226
|
└──────────────┘
|
|
204
227
|
```
|
|
205
228
|
|
|
206
|
-
1. **Config is loaded** from global
|
|
207
|
-
2. **Eager servers connect** at
|
|
208
|
-
3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages)
|
|
229
|
+
1. **Config is loaded** from global `<agent dir>/mcp.json`, trusted project `.pi/mcp.json` (checked via `ctx.isProjectTrusted()` on `session_start`), and extension-registered servers (`pi.getMcpServers()` / `mcp_servers_change`), with `mcp.json` taking precedence over extension-registered servers and project entries overriding global ones
|
|
230
|
+
2. **Eager servers connect** at `session_start` (offering a sequential `Authorize now` / `Not now` prompt when an eager OAuth server needs authorization in an interactive session); lazy servers wait for `mcp_connect`, `/mcp:start`, or `/mcp:auth`
|
|
231
|
+
3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages) before a server enters `ready`
|
|
209
232
|
4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
|
|
210
|
-
5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix)
|
|
233
|
+
5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix) and pass through text and `image` content blocks (`ImageContent`)
|
|
211
234
|
6. **Tools activate/deactivate** automatically as servers connect/disconnect
|
|
212
235
|
7. **AbortSignal** is wired through to `notifications/cancelled` for clean cancellation
|
|
213
|
-
8. **Reconnection** uses a fixed delay schedule with configurable max retries
|
|
236
|
+
8. **Reconnection & recovery** uses a fixed delay schedule with configurable max retries on initial failure, health check failure, or unexpected transport close (`client.onclose`)
|
|
214
237
|
9. **`list_changed` notifications** trigger re-discovery, deactivating stale tools automatically
|
|
215
|
-
10. **Session shutdown**
|
|
238
|
+
10. **Session shutdown** cancels pending auth/connection flows, stops the callback server, deactivates all tools, and cleanly stops all servers (with a `SIGKILL` safety net for `stdio` child processes)
|
|
216
239
|
|
|
217
240
|
## MCP Spec Compliance
|
|
218
241
|
|
|
@@ -222,15 +245,16 @@ Implements the **MCP 2025-03-26** specification for tool clients:
|
|
|
222
245
|
|---|---|
|
|
223
246
|
| Protocol version `2025-03-26` (+ `2024-11-05` fallback) | ✅ |
|
|
224
247
|
| `stdio` transport | ✅ |
|
|
225
|
-
| `streamable-http` transport | ✅ |
|
|
248
|
+
| `streamable-http` transport (including `"http"` / URL-only shorthand) | ✅ |
|
|
226
249
|
| `sse` transport (legacy backwards compat) | ✅ |
|
|
250
|
+
| OAuth 2.1 authorization (PKCE S256, RFC 9728 Protected Resource Metadata, RFC 7591 DCR) | ✅ |
|
|
227
251
|
| Cursor-based `tools/list` pagination | ✅ |
|
|
228
|
-
| `tools/call` with parameters | ✅ |
|
|
252
|
+
| `tools/call` with parameters & `image` content (`ImageContent`) passthrough | ✅ |
|
|
229
253
|
| `roots/list` capability (workspace root) | ✅ |
|
|
230
254
|
| `notifications/tools/list_changed` (live refresh) | ✅ |
|
|
231
255
|
| `notifications/cancelled` (AbortSignal) | ✅ |
|
|
232
256
|
| `notifications/message` (structured logging) | ✅ |
|
|
233
|
-
| `isError: true` distinction (protocol vs tool errors) | ✅ |
|
|
257
|
+
| `isError: true` distinction (protocol vs tool errors, non-text filtered on error) | ✅ |
|
|
234
258
|
| Tool annotations (`readOnlyHint`, `destructiveHint`, etc.) | ✅ |
|
|
235
259
|
| Resources bridge | ⏳ v2 |
|
|
236
260
|
| Prompts bridge | ⏳ v2 |
|
|
@@ -255,10 +279,11 @@ npm run test:integration
|
|
|
255
279
|
### Testing
|
|
256
280
|
|
|
257
281
|
The test suite includes:
|
|
258
|
-
- **Config
|
|
259
|
-
- **Server lifecycle** — Connect, retry, shutdown, health check,
|
|
260
|
-
- **
|
|
261
|
-
- **
|
|
282
|
+
- **Config & Pi 1.x compatibility** — Zod schema, `$PI_CODING_AGENT_DIR` resolution, global/project merge, project trust boundary (`ctx.isProjectTrusted()`), Pi 1.x schema aliases (`type: "http"`, URL-only HTTP, `oauth`, `timeout`), and dynamic `pi.getMcpServers()` / `mcp_servers_change`
|
|
283
|
+
- **Server lifecycle & coordination** — Connect, retry, unexpected close recovery, shutdown with PID `SIGKILL` safety net, health check, concurrent connect/reset coordination, and cancellation
|
|
284
|
+
- **OAuth 2.1 & auth locks** — Challenge discovery, PKCE, Dynamic Client Registration, startup authorization prompts, headless/SSH redirect URL paste-back, URL scheme validation, cross-process lock heartbeat and stale lock reclamation
|
|
285
|
+
- **Tool bridge & management tools** — JSON Schema → TypeBox conversion, name sanitization, image content passthrough, `isError` filtering, activate/deactivate, `mcp_status` / `mcp_connect`, and slash command completions
|
|
286
|
+
- **End-to-end integration** — Real mock MCP and OAuth servers over stdio and HTTP with tool discovery and execution
|
|
262
287
|
|
|
263
288
|
## License
|
|
264
289
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mcp-extension",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.2",
|
|
4
4
|
"description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"zod": "^3.25.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@
|
|
41
|
-
"@
|
|
40
|
+
"@earendil-works/pi-ai": "*",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
42
|
"typebox": "*"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
package/src/callback-server.ts
CHANGED
|
@@ -149,7 +149,9 @@ async function closeCurrentServer(): Promise<void> {
|
|
|
149
149
|
* scans forward from the default port if needed.
|
|
150
150
|
*/
|
|
151
151
|
export function callbackServerConfigFromRedirectUrl(redirectUrl?: string): CallbackServerConfig {
|
|
152
|
-
|
|
152
|
+
// WHY: Only treat `undefined` (omitted) as default; an explicit empty string `""`
|
|
153
|
+
// must fall through to `new URL("")` and throw a validation error.
|
|
154
|
+
if (redirectUrl === undefined) {
|
|
153
155
|
return {
|
|
154
156
|
preferredPort: DEFAULT_PORT,
|
|
155
157
|
host: DEFAULT_HOST,
|
|
@@ -236,7 +238,7 @@ export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT,
|
|
|
236
238
|
});
|
|
237
239
|
|
|
238
240
|
await new Promise<void>((resolve, reject) => {
|
|
239
|
-
candidateServer.once("error", (err:
|
|
241
|
+
candidateServer.once("error", (err: Error) => {
|
|
240
242
|
reject(err);
|
|
241
243
|
});
|
|
242
244
|
|
package/src/config.ts
CHANGED
|
@@ -14,99 +14,160 @@ import { readFile } from "node:fs/promises";
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { z } from "zod";
|
|
17
|
+
import { callbackServerConfigFromRedirectUrl } from "./callback-server.js";
|
|
17
18
|
import { McpError } from "./errors.js";
|
|
18
19
|
|
|
19
20
|
// ─── Zod Schemas ──────────────────────────────────────────────────────────────
|
|
20
21
|
|
|
21
|
-
const AuthConfigSchema = z.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
* Only applies to streamable-http and sse transports.
|
|
73
|
-
*/
|
|
74
|
-
auth: AuthConfigSchema.optional(),
|
|
75
|
-
/**
|
|
76
|
-
* "eager" — start at session_start.
|
|
77
|
-
* "lazy" — start manually via /mcp:start command.
|
|
78
|
-
*/
|
|
79
|
-
lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
|
|
80
|
-
/** Per-request timeout in ms. Overrides global setting. Default: 30000. */
|
|
81
|
-
requestTimeoutMs: z.number().positive().optional(),
|
|
82
|
-
/**
|
|
83
|
-
* Opt-in heartbeat interval (ping) in ms.
|
|
84
|
-
* Only useful for long-lived connections where you want proactive liveness checks.
|
|
85
|
-
* Default: disabled.
|
|
86
|
-
*/
|
|
87
|
-
healthCheckIntervalMs: z.number().positive().optional(),
|
|
88
|
-
})
|
|
89
|
-
.refine(
|
|
90
|
-
(cfg) => {
|
|
91
|
-
if (cfg.transport === "stdio") return cfg.command !== undefined;
|
|
92
|
-
return cfg.url !== undefined;
|
|
93
|
-
},
|
|
94
|
-
(cfg) => ({
|
|
95
|
-
message:
|
|
96
|
-
cfg.transport === "stdio"
|
|
97
|
-
? `"command" is required for stdio transport`
|
|
98
|
-
: `"url" is required for ${cfg.transport} transport`,
|
|
22
|
+
const AuthConfigSchema = z.preprocess(
|
|
23
|
+
(raw) => {
|
|
24
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
25
|
+
const obj = { ...(raw as Record<string, unknown>) };
|
|
26
|
+
if (obj.redirectUrl === undefined) {
|
|
27
|
+
if (typeof obj.callbackUrl === "string") {
|
|
28
|
+
obj.redirectUrl = obj.callbackUrl;
|
|
29
|
+
} else if (typeof obj.callbackPort === "number") {
|
|
30
|
+
obj.redirectUrl = `http://127.0.0.1:${obj.callbackPort}/callback`;
|
|
31
|
+
} else if (obj.callbackUrl !== undefined) {
|
|
32
|
+
obj.redirectUrl = obj.callbackUrl;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
delete obj.callbackUrl;
|
|
36
|
+
delete obj.callbackPort;
|
|
37
|
+
return obj;
|
|
38
|
+
},
|
|
39
|
+
z
|
|
40
|
+
.object({
|
|
41
|
+
/** Auth type. Currently only "oauth" is supported. Default: "oauth". */
|
|
42
|
+
type: z.enum(["oauth"]).default("oauth"),
|
|
43
|
+
/**
|
|
44
|
+
* Callback URL for the OAuth redirect.
|
|
45
|
+
* Default: auto-detected local callback server.
|
|
46
|
+
*/
|
|
47
|
+
redirectUrl: z.string().optional(),
|
|
48
|
+
/**
|
|
49
|
+
* Optional scope to request during authorization.
|
|
50
|
+
*/
|
|
51
|
+
scope: z.string().optional(),
|
|
52
|
+
/**
|
|
53
|
+
* Pre-registered client_id (skip dynamic client registration).
|
|
54
|
+
*/
|
|
55
|
+
clientId: z.string().optional(),
|
|
56
|
+
/**
|
|
57
|
+
* Pre-registered client_secret.
|
|
58
|
+
*/
|
|
59
|
+
clientSecret: z.string().optional(),
|
|
60
|
+
})
|
|
61
|
+
.superRefine((auth, ctx) => {
|
|
62
|
+
if (auth.redirectUrl !== undefined) {
|
|
63
|
+
try {
|
|
64
|
+
callbackServerConfigFromRedirectUrl(auth.redirectUrl);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
ctx.addIssue({
|
|
67
|
+
code: z.ZodIssueCode.custom,
|
|
68
|
+
path: ["redirectUrl"],
|
|
69
|
+
message: err instanceof Error ? err.message : String(err),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
99
73
|
}),
|
|
100
|
-
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
export const ServerConfigSchema = z.preprocess(
|
|
77
|
+
(raw) => {
|
|
78
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
79
|
+
const obj = { ...(raw as Record<string, unknown>) };
|
|
80
|
+
const rawTransport = obj.transport ?? obj.type;
|
|
81
|
+
if (rawTransport === "http") {
|
|
82
|
+
obj.transport = "streamable-http";
|
|
83
|
+
} else if (obj.transport === undefined && rawTransport !== undefined) {
|
|
84
|
+
obj.transport = rawTransport;
|
|
85
|
+
} else if (obj.transport === undefined && obj.url !== undefined && obj.command === undefined) {
|
|
86
|
+
obj.transport = "streamable-http";
|
|
87
|
+
}
|
|
88
|
+
if (obj.auth === undefined && obj.oauth && typeof obj.oauth === "object" && !Array.isArray(obj.oauth)) {
|
|
89
|
+
obj.auth = obj.oauth;
|
|
90
|
+
}
|
|
91
|
+
if (obj.requestTimeoutMs === undefined && typeof obj.timeout === "number" && obj.timeout > 0) {
|
|
92
|
+
obj.requestTimeoutMs = obj.timeout * 1000;
|
|
93
|
+
}
|
|
94
|
+
return obj;
|
|
95
|
+
},
|
|
96
|
+
z
|
|
97
|
+
.object({
|
|
98
|
+
/** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
|
|
99
|
+
command: z.string().optional(),
|
|
100
|
+
/** Arguments passed to command. */
|
|
101
|
+
args: z.array(z.string()).default([]),
|
|
102
|
+
/**
|
|
103
|
+
* Extra environment variables passed to the child process as literals.
|
|
104
|
+
* These merge with process.env; project env overrides parent env.
|
|
105
|
+
* No ${VAR} interpolation — set vars in your shell environment instead.
|
|
106
|
+
*/
|
|
107
|
+
env: z.record(z.string()).optional(),
|
|
108
|
+
/** Transport protocol. Default: "stdio" (or "streamable-http" when "url" is provided). */
|
|
109
|
+
transport: z.enum(["stdio", "streamable-http", "sse"]).default("stdio"),
|
|
110
|
+
/**
|
|
111
|
+
* URL for streamable-http or sse transports.
|
|
112
|
+
* Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
|
|
113
|
+
*/
|
|
114
|
+
url: z.string().url().optional(),
|
|
115
|
+
/**
|
|
116
|
+
* Static HTTP headers to include with every request (streamable-http / sse only).
|
|
117
|
+
* Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
|
|
118
|
+
* For OAuth2, use the "auth" field instead.
|
|
119
|
+
*/
|
|
120
|
+
headers: z.record(z.string()).optional(),
|
|
121
|
+
/**
|
|
122
|
+
* OAuth2 configuration for servers that require authorization.
|
|
123
|
+
* When set, the transport will use the SDK's OAuth flow (discovery,
|
|
124
|
+
* dynamic client registration, PKCE, token refresh).
|
|
125
|
+
* Only applies to streamable-http and sse transports.
|
|
126
|
+
*/
|
|
127
|
+
auth: AuthConfigSchema.optional(),
|
|
128
|
+
/**
|
|
129
|
+
* "eager" — start at session_start.
|
|
130
|
+
* "lazy" — start manually via /mcp:start command.
|
|
131
|
+
*/
|
|
132
|
+
lifecycle: z.enum(["eager", "lazy"]).default("lazy"),
|
|
133
|
+
/** Per-request timeout in ms. Overrides global setting. Default: 30000. */
|
|
134
|
+
requestTimeoutMs: z.number().positive().optional(),
|
|
135
|
+
/**
|
|
136
|
+
* Opt-in heartbeat interval (ping) in ms.
|
|
137
|
+
* Only useful for long-lived connections where you want proactive liveness checks.
|
|
138
|
+
* Default: disabled.
|
|
139
|
+
*/
|
|
140
|
+
healthCheckIntervalMs: z.number().positive().optional(),
|
|
141
|
+
})
|
|
142
|
+
.refine(
|
|
143
|
+
(cfg) => {
|
|
144
|
+
if (cfg.transport === "stdio") return cfg.command !== undefined;
|
|
145
|
+
return cfg.url !== undefined;
|
|
146
|
+
},
|
|
147
|
+
(cfg) => ({
|
|
148
|
+
message:
|
|
149
|
+
cfg.transport === "stdio"
|
|
150
|
+
? `"command" is required for stdio transport`
|
|
151
|
+
: `"url" is required for ${cfg.transport} transport`,
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
.refine(
|
|
155
|
+
(cfg) => !(cfg.transport === "stdio" && cfg.auth !== undefined),
|
|
156
|
+
{
|
|
157
|
+
message: "OAuth authentication is only supported for 'sse' and 'streamable-http' transports, not 'stdio'",
|
|
158
|
+
path: ["auth"],
|
|
159
|
+
},
|
|
160
|
+
),
|
|
161
|
+
);
|
|
101
162
|
|
|
102
163
|
const SettingsSchema = z.object({
|
|
103
164
|
/**
|
|
104
165
|
* Prefix used in Pi tool names: <prefix>_<server>_<tool>.
|
|
105
|
-
* Must match [a-zA-Z0-9_]
|
|
166
|
+
* Must match ^[a-zA-Z][a-zA-Z0-9_]*$. Default: "mcp".
|
|
106
167
|
*/
|
|
107
168
|
toolPrefix: z
|
|
108
169
|
.string()
|
|
109
|
-
.regex(/^[a-zA-Z0-9_]
|
|
170
|
+
.regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "toolPrefix must start with a letter and contain only alphanumeric characters and underscores")
|
|
110
171
|
.default("mcp"),
|
|
111
172
|
/** Default per-request timeout in ms for all servers. Default: 30000. */
|
|
112
173
|
requestTimeoutMs: z.number().positive().default(30000),
|
|
@@ -127,7 +188,7 @@ const McpConfigSchema = z.object({
|
|
|
127
188
|
const ProjectSettingsSchema = z.object({
|
|
128
189
|
toolPrefix: z
|
|
129
190
|
.string()
|
|
130
|
-
.regex(/^[a-zA-Z0-9_]
|
|
191
|
+
.regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, "toolPrefix must start with a letter and contain only alphanumeric characters and underscores")
|
|
131
192
|
.optional(),
|
|
132
193
|
requestTimeoutMs: z.number().positive().optional(),
|
|
133
194
|
maxRetries: z.number().int().min(0).max(10).optional(),
|
|
@@ -145,16 +206,117 @@ export type ServerConfig = z.output<typeof ServerConfigSchema>;
|
|
|
145
206
|
export type Settings = z.output<typeof SettingsSchema>;
|
|
146
207
|
export type McpConfig = z.output<typeof McpConfigSchema>;
|
|
147
208
|
|
|
209
|
+
export interface LoadConfigOptions {
|
|
210
|
+
/**
|
|
211
|
+
* Whether to read project-level `<cwd>/.pi/mcp.json`.
|
|
212
|
+
* Set to `false` when the project is untrusted (`ctx.isProjectTrusted() === false`)
|
|
213
|
+
* or during bootstrap before session trust is resolved.
|
|
214
|
+
* Default: `true`.
|
|
215
|
+
*/
|
|
216
|
+
includeProjectConfig?: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
148
219
|
// ─── Loader ───────────────────────────────────────────────────────────────────
|
|
149
220
|
|
|
150
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Strip `//` line comments and `/* ... *\/` block comments outside of double-quoted
|
|
223
|
+
* JSON strings in a single character-by-character pass.
|
|
224
|
+
*
|
|
225
|
+
* Preserves `\n` and `\r` characters so line numbers in `JSON.parse` error messages
|
|
226
|
+
* remain accurate. Unterminated `/*` block comments are left intact so `JSON.parse`
|
|
227
|
+
* fails with a syntax error rather than silently truncating the document.
|
|
228
|
+
*/
|
|
229
|
+
export function stripJsonComments(text: string): string {
|
|
230
|
+
let out = "";
|
|
231
|
+
let inString = false;
|
|
232
|
+
let i = 0;
|
|
233
|
+
|
|
234
|
+
while (i < text.length) {
|
|
235
|
+
const ch = text[i]!;
|
|
236
|
+
|
|
237
|
+
if (inString) {
|
|
238
|
+
if (ch === "\\") {
|
|
239
|
+
out += ch;
|
|
240
|
+
if (i + 1 < text.length) {
|
|
241
|
+
out += text[i + 1]!;
|
|
242
|
+
i += 2;
|
|
243
|
+
} else {
|
|
244
|
+
i += 1;
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (ch === '"') {
|
|
249
|
+
inString = false;
|
|
250
|
+
}
|
|
251
|
+
out += ch;
|
|
252
|
+
i += 1;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (ch === '"') {
|
|
257
|
+
inString = true;
|
|
258
|
+
out += ch;
|
|
259
|
+
i += 1;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
264
|
+
i += 2;
|
|
265
|
+
while (i < text.length && text[i] !== "\n") {
|
|
266
|
+
if (text[i] === "\r" && text[i + 1] === "\n") break;
|
|
267
|
+
i += 1;
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
273
|
+
const closeIdx = text.indexOf("*/", i + 2);
|
|
274
|
+
if (closeIdx === -1) {
|
|
275
|
+
// WHY: Leave unterminated block comments intact so JSON.parse fails with a
|
|
276
|
+
// SyntaxError rather than silently truncating the rest of the file.
|
|
277
|
+
out += text.slice(i);
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
for (let j = i; j < closeIdx + 2; j += 1) {
|
|
281
|
+
const commentCh = text[j]!;
|
|
282
|
+
out += commentCh === "\n" || commentCh === "\r" ? commentCh : " ";
|
|
283
|
+
}
|
|
284
|
+
i = closeIdx + 2;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
out += ch;
|
|
289
|
+
i += 1;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function readJsonFile(path: string): Promise<unknown> {
|
|
296
|
+
let text: string;
|
|
151
297
|
try {
|
|
152
|
-
|
|
153
|
-
return JSON.parse(text) as unknown;
|
|
298
|
+
text = await readFile(path, "utf-8");
|
|
154
299
|
} catch (err) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
throw
|
|
300
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
301
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
302
|
+
throw new McpError(
|
|
303
|
+
`Failed to read mcp.json at ${path}: ${detail}`,
|
|
304
|
+
"<config>",
|
|
305
|
+
"config",
|
|
306
|
+
err,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
return JSON.parse(stripJsonComments(text));
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
314
|
+
throw new McpError(
|
|
315
|
+
`Invalid JSON in mcp.json at ${path}: ${detail}`,
|
|
316
|
+
"<config>",
|
|
317
|
+
"config",
|
|
318
|
+
err,
|
|
319
|
+
);
|
|
158
320
|
}
|
|
159
321
|
}
|
|
160
322
|
|
|
@@ -181,6 +343,36 @@ function parseWith<S extends z.ZodTypeAny>(
|
|
|
181
343
|
return result.data;
|
|
182
344
|
}
|
|
183
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Parse and normalize a server configuration registered via `pi.registerMcpServer()`.
|
|
348
|
+
* Extension-registered servers default to `lifecycle: "eager"` (unless `enabled: false`
|
|
349
|
+
* or `lifecycle` is explicitly specified) so they connect on `session_start` / `mcp_servers_change`.
|
|
350
|
+
*/
|
|
351
|
+
export function parseRegisteredServerConfig(
|
|
352
|
+
name: string,
|
|
353
|
+
raw: unknown,
|
|
354
|
+
sourcePath = "<extension>",
|
|
355
|
+
): ServerConfig {
|
|
356
|
+
const rawObj = raw && typeof raw === "object" && !Array.isArray(raw)
|
|
357
|
+
? (raw as Record<string, unknown>)
|
|
358
|
+
: undefined;
|
|
359
|
+
const withLifecycle = rawObj && rawObj.lifecycle === undefined
|
|
360
|
+
? { ...rawObj, lifecycle: rawObj.enabled === false ? "lazy" : "eager" }
|
|
361
|
+
: raw;
|
|
362
|
+
const result = ServerConfigSchema.safeParse(withLifecycle);
|
|
363
|
+
if (!result.success) {
|
|
364
|
+
const issues = result.error.issues
|
|
365
|
+
.map((i) => ` mcpServers.${name}${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`)
|
|
366
|
+
.join("\n");
|
|
367
|
+
throw new McpError(
|
|
368
|
+
`Invalid MCP server "${name}" from ${sourcePath}:\n${issues}`,
|
|
369
|
+
name,
|
|
370
|
+
"config",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return result.data;
|
|
374
|
+
}
|
|
375
|
+
|
|
184
376
|
function mergeConfigs(
|
|
185
377
|
globalCfg: McpConfig,
|
|
186
378
|
projectCfg: ProjectConfig,
|
|
@@ -199,7 +391,7 @@ function mergeConfigs(
|
|
|
199
391
|
|
|
200
392
|
/**
|
|
201
393
|
* Pi's agent directory, resolved the way Pi resolves it (`getAgentDir()` in
|
|
202
|
-
* @
|
|
394
|
+
* @earendil-works/pi-coding-agent): $PI_CODING_AGENT_DIR when set (tilde expanded), else
|
|
203
395
|
* ~/.pi/agent. Pi profiles and alternative agent dirs only work through that variable.
|
|
204
396
|
*/
|
|
205
397
|
export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -214,27 +406,32 @@ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
214
406
|
|
|
215
407
|
/**
|
|
216
408
|
* Load and merge global (<agent dir>/mcp.json) and project (<cwd>/.pi/mcp.json) configs.
|
|
217
|
-
* Project config takes precedence over global config.
|
|
409
|
+
* Project config takes precedence over global config when `options.includeProjectConfig !== false`.
|
|
218
410
|
* Returns a fully validated, merged config.
|
|
219
411
|
*/
|
|
220
|
-
export async function loadConfig(
|
|
412
|
+
export async function loadConfig(
|
|
413
|
+
cwd: string,
|
|
414
|
+
globalPath = join(agentDir(), "mcp.json"),
|
|
415
|
+
options?: LoadConfigOptions,
|
|
416
|
+
): Promise<McpConfig> {
|
|
417
|
+
const includeProjectConfig = options?.includeProjectConfig !== false;
|
|
221
418
|
const projectPath = join(cwd, ".pi", "mcp.json");
|
|
222
419
|
|
|
223
420
|
const [globalRaw, projectRaw] = await Promise.all([
|
|
224
421
|
readJsonFile(globalPath),
|
|
225
|
-
readJsonFile(projectPath),
|
|
422
|
+
includeProjectConfig ? readJsonFile(projectPath) : Promise.resolve(undefined),
|
|
226
423
|
]);
|
|
227
424
|
|
|
228
425
|
// If neither file exists, return an empty valid config
|
|
229
|
-
if (globalRaw ===
|
|
426
|
+
if (globalRaw === undefined && projectRaw === undefined) {
|
|
230
427
|
return McpConfigSchema.parse({});
|
|
231
428
|
}
|
|
232
429
|
|
|
233
|
-
const globalCfg = globalRaw !==
|
|
430
|
+
const globalCfg = globalRaw !== undefined
|
|
234
431
|
? parseConfig(globalRaw, globalPath)
|
|
235
432
|
: McpConfigSchema.parse({});
|
|
236
433
|
|
|
237
|
-
if (projectRaw ===
|
|
434
|
+
if (projectRaw === undefined) return globalCfg;
|
|
238
435
|
|
|
239
436
|
const projectCfg = parseWith(ProjectConfigSchema, projectRaw, projectPath);
|
|
240
437
|
return mergeConfigs(globalCfg, projectCfg);
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
* Wires together: config → server manager → tool bridge → Pi API.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
11
|
-
|
|
10
|
+
import type {
|
|
11
|
+
ExtensionAPI,
|
|
12
|
+
ExtensionContext,
|
|
13
|
+
ExtensionCommandContext,
|
|
14
|
+
ExtensionUIContext,
|
|
15
|
+
RegisteredMcpServer,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { loadConfig, parseRegisteredServerConfig, type McpConfig, type ServerConfig } from "./config.js";
|
|
12
18
|
import { ServerManager } from "./server-manager.js";
|
|
13
19
|
import { AuthLockError, AuthRequiredError } from "./oauth-provider.js";
|
|
14
20
|
import * as Type from "typebox";
|
|
@@ -281,26 +287,73 @@ export function serverNameCompletions(
|
|
|
281
287
|
return completions.length > 0 ? completions : null;
|
|
282
288
|
}
|
|
283
289
|
|
|
290
|
+
function collectRegisteredServers(
|
|
291
|
+
entries: readonly RegisteredMcpServer[],
|
|
292
|
+
fileServers: Record<string, ServerConfig>,
|
|
293
|
+
onError?: (err: unknown) => void,
|
|
294
|
+
): Record<string, ServerConfig> {
|
|
295
|
+
const registered: Record<string, ServerConfig> = {};
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
// Servers configured in mcp.json take precedence over extension-registered servers.
|
|
298
|
+
if (Object.prototype.hasOwnProperty.call(fileServers, entry.name)) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
registered[entry.name] = parseRegisteredServerConfig(
|
|
303
|
+
entry.name,
|
|
304
|
+
entry.config,
|
|
305
|
+
entry.extensionPath || "<extension>",
|
|
306
|
+
);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
onError?.(err);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return registered;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function mergeWithRegisteredServers(
|
|
315
|
+
fileConfig: McpConfig,
|
|
316
|
+
registeredServers: Record<string, ServerConfig>,
|
|
317
|
+
): McpConfig {
|
|
318
|
+
return {
|
|
319
|
+
settings: fileConfig.settings,
|
|
320
|
+
mcpServers: {
|
|
321
|
+
...registeredServers,
|
|
322
|
+
...fileConfig.mcpServers,
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
284
327
|
export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
|
|
285
328
|
// ── 1. Load and validate config ──────────────────────────────────────────
|
|
286
|
-
// cwd
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
329
|
+
// cwd and project trust are available on the ExtensionContext passed to event handlers.
|
|
330
|
+
// At bootstrap (before session_start) we load ONLY the global config so untrusted
|
|
331
|
+
// <cwd>/.pi/mcp.json files are never read before ctx.isProjectTrusted() is checked.
|
|
332
|
+
let fileConfig: McpConfig = {
|
|
333
|
+
settings: { toolPrefix: "mcp", requestTimeoutMs: 30000, maxRetries: 5 },
|
|
334
|
+
mcpServers: {},
|
|
335
|
+
};
|
|
291
336
|
try {
|
|
292
|
-
|
|
337
|
+
fileConfig = await loadConfig(paths.bootstrapCwd ?? process.cwd(), paths.globalConfigPath, {
|
|
338
|
+
includeProjectConfig: false,
|
|
339
|
+
});
|
|
293
340
|
} catch (err) {
|
|
294
341
|
// Can't notify yet (no ctx), so log to stderr. The session_start handler
|
|
295
342
|
// will re-try with the real cwd and surface errors properly.
|
|
296
343
|
console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`);
|
|
297
|
-
return;
|
|
298
344
|
}
|
|
299
345
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
346
|
+
const getRegisteredEntries = (): RegisteredMcpServer[] =>
|
|
347
|
+
typeof (pi as { getMcpServers?: () => RegisteredMcpServer[] }).getMcpServers === "function"
|
|
348
|
+
? pi.getMcpServers()
|
|
349
|
+
: [];
|
|
350
|
+
|
|
351
|
+
let extensionServers = collectRegisteredServers(
|
|
352
|
+
getRegisteredEntries(),
|
|
353
|
+
fileConfig.mcpServers,
|
|
354
|
+
(err) => console.error(`[pi-mcp] Config error: ${err instanceof McpError ? err.message : String(err)}`),
|
|
355
|
+
);
|
|
356
|
+
let config: McpConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
|
|
304
357
|
|
|
305
358
|
// ── 2. Initialize bridge components ──────────────────────────────────────
|
|
306
359
|
const manager = new ServerManager(config, undefined, paths.authStorageDir);
|
|
@@ -391,6 +444,42 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
391
444
|
})();
|
|
392
445
|
}
|
|
393
446
|
|
|
447
|
+
async function startEagerServers(
|
|
448
|
+
serversToStart: Array<[string, ServerConfig]>,
|
|
449
|
+
ctx: ExtensionContext,
|
|
450
|
+
): Promise<void> {
|
|
451
|
+
const authRequiredServers = new Set<string>();
|
|
452
|
+
|
|
453
|
+
// Start all eager servers concurrently
|
|
454
|
+
await Promise.allSettled(
|
|
455
|
+
serversToStart.map(async ([name, cfg]) => {
|
|
456
|
+
try {
|
|
457
|
+
await manager.startServer(name, ctx.cwd);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
|
|
460
|
+
authRequiredServers.add(name);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const msg = formatErrorMessage(err);
|
|
464
|
+
ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
|
|
465
|
+
}
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
for (const [name] of serversToStart) {
|
|
470
|
+
if (!authRequiredServers.has(name)) continue;
|
|
471
|
+
if (declinedStartupAuth.has(name)) {
|
|
472
|
+
ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
enqueueStartupAuthPrompt(name, ctx);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < 4 * authRequiredServers.size; i++) {
|
|
479
|
+
await Promise.resolve();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
394
483
|
manager.setToolRefreshCallback(async (serverName, client) => {
|
|
395
484
|
await bridge.refreshTools(serverName, client, () => {
|
|
396
485
|
const server = manager.getServer(serverName);
|
|
@@ -401,17 +490,33 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
401
490
|
|
|
402
491
|
// ── 3. Session lifecycle ──────────────────────────────────────────────────
|
|
403
492
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
404
|
-
//
|
|
405
|
-
|
|
493
|
+
// Only load project-level <cwd>/.pi/mcp.json when the project is trusted.
|
|
494
|
+
const includeProjectConfig =
|
|
495
|
+
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function"
|
|
496
|
+
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
497
|
+
: true;
|
|
498
|
+
|
|
499
|
+
let nextFileConfig = fileConfig;
|
|
406
500
|
try {
|
|
407
|
-
|
|
501
|
+
nextFileConfig = await loadConfig(ctx.cwd, paths.globalConfigPath, { includeProjectConfig });
|
|
408
502
|
} catch (err) {
|
|
409
503
|
const msg = formatErrorMessage(err);
|
|
410
504
|
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
|
|
411
505
|
return;
|
|
412
506
|
}
|
|
413
507
|
|
|
414
|
-
|
|
508
|
+
fileConfig = nextFileConfig;
|
|
509
|
+
extensionServers = collectRegisteredServers(
|
|
510
|
+
getRegisteredEntries(),
|
|
511
|
+
fileConfig.mcpServers,
|
|
512
|
+
(err) => {
|
|
513
|
+
const msg = formatErrorMessage(err);
|
|
514
|
+
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
|
|
515
|
+
},
|
|
516
|
+
);
|
|
517
|
+
const sessionConfig = mergeWithRegisteredServers(fileConfig, extensionServers);
|
|
518
|
+
|
|
519
|
+
// If config changed (different cwd with project-level overrides or registered servers),
|
|
415
520
|
// shut down old servers and rebuild the manager's server list
|
|
416
521
|
if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
|
|
417
522
|
declinedStartupAuth.clear();
|
|
@@ -426,6 +531,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
426
531
|
// Shut down all running servers
|
|
427
532
|
await manager.shutdownAll();
|
|
428
533
|
// Rebuild server entries from new config
|
|
534
|
+
bridge.updateSettings(sessionConfig.settings);
|
|
429
535
|
manager.rebuildServers(sessionConfig);
|
|
430
536
|
config = sessionConfig;
|
|
431
537
|
}
|
|
@@ -434,35 +540,56 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
434
540
|
([, cfg]) => cfg.lifecycle === "eager",
|
|
435
541
|
);
|
|
436
542
|
|
|
437
|
-
|
|
543
|
+
await startEagerServers(eagerServers, ctx);
|
|
544
|
+
});
|
|
438
545
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}),
|
|
546
|
+
// Connect or disconnect MCP servers registered dynamically via pi.registerMcpServer().
|
|
547
|
+
pi.on("mcp_servers_change", async (event, ctx: ExtensionContext) => {
|
|
548
|
+
const entries = Array.isArray((event as { servers?: unknown })?.servers)
|
|
549
|
+
? ((event as { servers: RegisteredMcpServer[] }).servers)
|
|
550
|
+
: getRegisteredEntries();
|
|
551
|
+
|
|
552
|
+
const nextExtensionServers = collectRegisteredServers(
|
|
553
|
+
entries,
|
|
554
|
+
fileConfig.mcpServers,
|
|
555
|
+
(err) => {
|
|
556
|
+
const msg = formatErrorMessage(err);
|
|
557
|
+
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
|
|
558
|
+
},
|
|
453
559
|
);
|
|
454
560
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
561
|
+
const removedOrChanged: string[] = [];
|
|
562
|
+
for (const [name, prevCfg] of Object.entries(extensionServers)) {
|
|
563
|
+
const nextCfg = nextExtensionServers[name];
|
|
564
|
+
if (!nextCfg || JSON.stringify(prevCfg) !== JSON.stringify(nextCfg)) {
|
|
565
|
+
removedOrChanged.push(name);
|
|
460
566
|
}
|
|
461
|
-
enqueueStartupAuthPrompt(name, ctx);
|
|
462
567
|
}
|
|
463
568
|
|
|
464
|
-
for (
|
|
465
|
-
|
|
569
|
+
for (const name of removedOrChanged) {
|
|
570
|
+
declinedStartupAuth.delete(name);
|
|
571
|
+
startupPromptControllers.get(name)?.abort();
|
|
572
|
+
startupPromptControllers.delete(name);
|
|
573
|
+
connectAttempts.get(name)?.controller.abort();
|
|
574
|
+
bridge.removeServer(name);
|
|
575
|
+
await manager.removeServer(name);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const addedEager: Array<[string, ServerConfig]> = [];
|
|
579
|
+
for (const [name, nextCfg] of Object.entries(nextExtensionServers)) {
|
|
580
|
+
if (!manager.getServer(name)) {
|
|
581
|
+
manager.setServer(name, nextCfg);
|
|
582
|
+
if (nextCfg.lifecycle === "eager") {
|
|
583
|
+
addedEager.push([name, nextCfg]);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
extensionServers = nextExtensionServers;
|
|
589
|
+
config = mergeWithRegisteredServers(fileConfig, extensionServers);
|
|
590
|
+
|
|
591
|
+
if (addedEager.length > 0) {
|
|
592
|
+
await startEagerServers(addedEager, ctx);
|
|
466
593
|
}
|
|
467
594
|
});
|
|
468
595
|
|
package/src/server-manager.ts
CHANGED
|
@@ -256,6 +256,30 @@ export class ServerManager {
|
|
|
256
256
|
);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** Add or replace a stopped server entry in the manager. */
|
|
260
|
+
setServer(name: string, serverConfig: ServerConfig): void {
|
|
261
|
+
this.servers.set(name, {
|
|
262
|
+
name,
|
|
263
|
+
config: serverConfig,
|
|
264
|
+
state: "stopped",
|
|
265
|
+
client: null,
|
|
266
|
+
childPid: null,
|
|
267
|
+
retryCount: 0,
|
|
268
|
+
lastError: null,
|
|
269
|
+
stderrLog: [],
|
|
270
|
+
healthCheckTimer: null,
|
|
271
|
+
retryTimer: null,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Shut down and remove a server entry from the manager. */
|
|
276
|
+
async removeServer(name: string): Promise<void> {
|
|
277
|
+
const server = this.servers.get(name);
|
|
278
|
+
if (!server) return;
|
|
279
|
+
await this._shutdown(server);
|
|
280
|
+
this.servers.delete(name);
|
|
281
|
+
}
|
|
282
|
+
|
|
259
283
|
/**
|
|
260
284
|
* Rebuild the server map from a new config.
|
|
261
285
|
* Must only be called after shutdownAll() — old servers are discarded.
|
|
@@ -266,18 +290,7 @@ export class ServerManager {
|
|
|
266
290
|
this.settings = config.settings;
|
|
267
291
|
this.servers.clear();
|
|
268
292
|
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
269
|
-
this.
|
|
270
|
-
name,
|
|
271
|
-
config: serverConfig,
|
|
272
|
-
state: "stopped",
|
|
273
|
-
client: null,
|
|
274
|
-
childPid: null,
|
|
275
|
-
retryCount: 0,
|
|
276
|
-
lastError: null,
|
|
277
|
-
stderrLog: [],
|
|
278
|
-
healthCheckTimer: null,
|
|
279
|
-
retryTimer: null,
|
|
280
|
-
});
|
|
293
|
+
this.setServer(name, serverConfig);
|
|
281
294
|
}
|
|
282
295
|
}
|
|
283
296
|
|
package/src/tool-bridge.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
16
16
|
import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
17
17
|
import * as Type from "typebox";
|
|
18
18
|
import type { TSchema } from "typebox";
|
|
19
|
-
import type { ExtensionAPI } from "@
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { McpError } from "./errors.js";
|
|
21
21
|
import type { Settings } from "./config.js";
|
|
22
22
|
|
|
@@ -313,7 +313,7 @@ export async function listAllTools(
|
|
|
313
313
|
* Tools are registered once and activated/deactivated as servers connect/disconnect.
|
|
314
314
|
*/
|
|
315
315
|
export class ToolBridge {
|
|
316
|
-
private
|
|
316
|
+
private settings: Settings;
|
|
317
317
|
private readonly pi: PiExtensionAPI;
|
|
318
318
|
/** Tracks which Pi tool names belong to which MCP server. */
|
|
319
319
|
private readonly serverToolNames = new Map<string, Set<string>>();
|
|
@@ -323,6 +323,11 @@ export class ToolBridge {
|
|
|
323
323
|
this.pi = pi;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
/** Update bridge settings when session config is reloaded on session_start. */
|
|
327
|
+
updateSettings(settings: Settings): void {
|
|
328
|
+
this.settings = settings;
|
|
329
|
+
}
|
|
330
|
+
|
|
326
331
|
/**
|
|
327
332
|
* Refresh tools for a server — called on initial connect and on list_changed.
|
|
328
333
|
* Always re-registers tools with the current client reference so that
|