pi-mcp-extension 1.9.1 → 1.9.3
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 +1 -1
- package/src/callback-server.ts +4 -2
- package/src/config.ts +166 -50
- package/src/index.ts +2 -0
- package/src/tool-bridge.ts +582 -145
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
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,31 +14,64 @@ 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
|
-
|
|
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
|
+
}
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
42
75
|
|
|
43
76
|
export const ServerConfigSchema = z.preprocess(
|
|
44
77
|
(raw) => {
|
|
@@ -53,22 +86,7 @@ export const ServerConfigSchema = z.preprocess(
|
|
|
53
86
|
obj.transport = "streamable-http";
|
|
54
87
|
}
|
|
55
88
|
if (obj.auth === undefined && obj.oauth && typeof obj.oauth === "object" && !Array.isArray(obj.oauth)) {
|
|
56
|
-
|
|
57
|
-
const redirectUrl =
|
|
58
|
-
typeof oauth.redirectUrl === "string"
|
|
59
|
-
? oauth.redirectUrl
|
|
60
|
-
: typeof oauth.callbackUrl === "string"
|
|
61
|
-
? oauth.callbackUrl
|
|
62
|
-
: typeof oauth.callbackPort === "number"
|
|
63
|
-
? `http://127.0.0.1:${oauth.callbackPort}/callback`
|
|
64
|
-
: undefined;
|
|
65
|
-
obj.auth = {
|
|
66
|
-
type: "oauth",
|
|
67
|
-
...(redirectUrl !== undefined ? { redirectUrl } : {}),
|
|
68
|
-
...(typeof oauth.scope === "string" ? { scope: oauth.scope } : {}),
|
|
69
|
-
...(typeof oauth.clientId === "string" ? { clientId: oauth.clientId } : {}),
|
|
70
|
-
...(typeof oauth.clientSecret === "string" ? { clientSecret: oauth.clientSecret } : {}),
|
|
71
|
-
};
|
|
89
|
+
obj.auth = obj.oauth;
|
|
72
90
|
}
|
|
73
91
|
if (obj.requestTimeoutMs === undefined && typeof obj.timeout === "number" && obj.timeout > 0) {
|
|
74
92
|
obj.requestTimeoutMs = obj.timeout * 1000;
|
|
@@ -132,17 +150,24 @@ export const ServerConfigSchema = z.preprocess(
|
|
|
132
150
|
? `"command" is required for stdio transport`
|
|
133
151
|
: `"url" is required for ${cfg.transport} transport`,
|
|
134
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
|
+
},
|
|
135
160
|
),
|
|
136
161
|
);
|
|
137
162
|
|
|
138
163
|
const SettingsSchema = z.object({
|
|
139
164
|
/**
|
|
140
165
|
* Prefix used in Pi tool names: <prefix>_<server>_<tool>.
|
|
141
|
-
* Must match [a-zA-Z0-9_]
|
|
166
|
+
* Must match ^[a-zA-Z][a-zA-Z0-9_]*$. Default: "mcp".
|
|
142
167
|
*/
|
|
143
168
|
toolPrefix: z
|
|
144
169
|
.string()
|
|
145
|
-
.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")
|
|
146
171
|
.default("mcp"),
|
|
147
172
|
/** Default per-request timeout in ms for all servers. Default: 30000. */
|
|
148
173
|
requestTimeoutMs: z.number().positive().default(30000),
|
|
@@ -163,7 +188,7 @@ const McpConfigSchema = z.object({
|
|
|
163
188
|
const ProjectSettingsSchema = z.object({
|
|
164
189
|
toolPrefix: z
|
|
165
190
|
.string()
|
|
166
|
-
.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")
|
|
167
192
|
.optional(),
|
|
168
193
|
requestTimeoutMs: z.number().positive().optional(),
|
|
169
194
|
maxRetries: z.number().int().min(0).max(10).optional(),
|
|
@@ -193,14 +218,105 @@ export interface LoadConfigOptions {
|
|
|
193
218
|
|
|
194
219
|
// ─── Loader ───────────────────────────────────────────────────────────────────
|
|
195
220
|
|
|
196
|
-
|
|
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;
|
|
297
|
+
try {
|
|
298
|
+
text = await readFile(path, "utf-8");
|
|
299
|
+
} catch (err) {
|
|
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
|
+
|
|
197
310
|
try {
|
|
198
|
-
|
|
199
|
-
return JSON.parse(text) as unknown;
|
|
311
|
+
return JSON.parse(stripJsonComments(text));
|
|
200
312
|
} catch (err) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
);
|
|
204
320
|
}
|
|
205
321
|
}
|
|
206
322
|
|
|
@@ -303,19 +419,19 @@ export async function loadConfig(
|
|
|
303
419
|
|
|
304
420
|
const [globalRaw, projectRaw] = await Promise.all([
|
|
305
421
|
readJsonFile(globalPath),
|
|
306
|
-
includeProjectConfig ? readJsonFile(projectPath) : Promise.resolve(
|
|
422
|
+
includeProjectConfig ? readJsonFile(projectPath) : Promise.resolve(undefined),
|
|
307
423
|
]);
|
|
308
424
|
|
|
309
425
|
// If neither file exists, return an empty valid config
|
|
310
|
-
if (globalRaw ===
|
|
426
|
+
if (globalRaw === undefined && projectRaw === undefined) {
|
|
311
427
|
return McpConfigSchema.parse({});
|
|
312
428
|
}
|
|
313
429
|
|
|
314
|
-
const globalCfg = globalRaw !==
|
|
430
|
+
const globalCfg = globalRaw !== undefined
|
|
315
431
|
? parseConfig(globalRaw, globalPath)
|
|
316
432
|
: McpConfigSchema.parse({});
|
|
317
433
|
|
|
318
|
-
if (projectRaw ===
|
|
434
|
+
if (projectRaw === undefined) return globalCfg;
|
|
319
435
|
|
|
320
436
|
const projectCfg = parseWith(ProjectConfigSchema, projectRaw, projectPath);
|
|
321
437
|
return mergeConfigs(globalCfg, projectCfg);
|
package/src/index.ts
CHANGED
|
@@ -1023,6 +1023,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1023
1023
|
name: "mcp_status",
|
|
1024
1024
|
label: "MCP status",
|
|
1025
1025
|
description: "List configured MCP servers, lifecycle, connection and OAuth state. Use mcp_connect to connect one server.",
|
|
1026
|
+
promptSnippet: "Inspect configured MCP servers, their connection state, discovered tools, and authentication status.",
|
|
1026
1027
|
parameters: Type.Object({}),
|
|
1027
1028
|
async execute() {
|
|
1028
1029
|
const rows = await Promise.all(manager.getAllServers().map(async (server) => {
|
|
@@ -1046,6 +1047,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
1046
1047
|
name: "mcp_connect",
|
|
1047
1048
|
label: "Connect MCP server",
|
|
1048
1049
|
description: "Connect one named configured MCP server and activate its tools. May request interactive OAuth authorization.",
|
|
1050
|
+
promptSnippet: "Connect or reconnect to a configured MCP server by name and register its tools.",
|
|
1049
1051
|
parameters: Type.Object({ name: Type.String({ description: "Exact configured MCP server name" }) }),
|
|
1050
1052
|
async execute(_id, { name }, signal, _update, ctx) {
|
|
1051
1053
|
try {
|