pi-mcp-extension 1.11.2 → 1.11.4
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 +75 -46
- package/package.json +1 -1
- package/src/connection-coordinator.ts +58 -15
- package/src/index.ts +13 -1
- package/src/server-manager.ts +111 -28
- package/src/stdio-process.ts +296 -23
package/README.md
CHANGED
|
@@ -20,14 +20,15 @@
|
|
|
20
20
|
- **Clean cancellation** — `AbortSignal` propagation via `notifications/cancelled`
|
|
21
21
|
- **Smart reconnection** — Fixed delay retry schedule (1s → 3s → 5s → 10s → 30s), configurable max retries, and automatic recovery when a server transport closes unexpectedly
|
|
22
22
|
- **Health checks** — Opt-in periodic pings to detect stale connections
|
|
23
|
-
- **Safe process lifecycle** — Child PID tracking (`StdioClientTransport.pid`) with
|
|
23
|
+
- **Safe process lifecycle** — Child PID tracking (`StdioClientTransport.pid`) with tree-aware cleanup: descendants of wrapper commands (`npx`, `npm exec`, `uvx`, `sh -c`) are terminated too, and inherited pipes are released so teardown never stalls
|
|
24
24
|
- **Stable tool names** — Sanitized, collision-avoiding names (max 64 chars) that stay constant across reconnects
|
|
25
25
|
- **No tool churn** — Activate/deactivate pattern ensures tools don't flicker during reconnection
|
|
26
|
-
- **Layered config & trust boundary** — Global `<agent dir>/mcp.json`, project `.pi/mcp.json` (loaded only in trusted projects via `ctx.isProjectTrusted()
|
|
26
|
+
- **Layered config & trust boundary** — Global `<agent dir>/mcp.json`, project `.pi/mcp.json` (loaded only in trusted projects via `ctx.isProjectTrusted()`, including Pi 1.3.0 partial overrides), and Pi 1.x extension-registered servers (`pi.registerMcpServer()`)
|
|
27
27
|
- **Rich tool results & annotations** — MCP `image` content passthrough (`ImageContent`), plus `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` surfaced in tool descriptions
|
|
28
|
-
- **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
|
|
29
|
-
- **Structured error handling** — Distinct error codes
|
|
30
|
-
- **
|
|
28
|
+
- **On-demand agent tools & commands** — `mcp_status` and `mcp_connect` agent tools plus `/mcp`, `/mcp <name>` (with config provenance and failure hints), `/mcp:start`, `/mcp:stop`, and `/mcp:auth` slash commands with server-name autocomplete
|
|
29
|
+
- **Structured error handling** — Distinct error codes (`config`, `connection`, `auth`, `timeout`, `protocol`, `tool`) and actionable failure hints
|
|
30
|
+
- **Redacted debug logging** — Opt-in `PI_MCP_DEBUG=1` JSON Lines log (`<agent dir>/logs/pi-mcp.log`, `0600`, ~2 MB rotation) with automatic secret masking and zero TUI `stderr` noise
|
|
31
|
+
- **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 origin/issuer-bound local persistence (see [OAUTH.md](./OAUTH.md))
|
|
31
32
|
|
|
32
33
|
## Installation
|
|
33
34
|
|
|
@@ -57,7 +58,7 @@ pi -e npm:pi-mcp-extension
|
|
|
57
58
|
}
|
|
58
59
|
```
|
|
59
60
|
|
|
60
|
-
3. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level):
|
|
61
|
+
3. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level). Both files support **JSONC** (`//` line and `/* ... */` block comments):
|
|
61
62
|
```jsonc
|
|
62
63
|
{
|
|
63
64
|
"mcpServers": {
|
|
@@ -70,11 +71,11 @@ pi -e npm:pi-mcp-extension
|
|
|
70
71
|
}
|
|
71
72
|
```
|
|
72
73
|
|
|
73
|
-
4. Start Pi — your MCP tools are ready. Use `/mcp` to check
|
|
74
|
+
4. Start Pi — your MCP tools are ready. Use `/mcp` to check all servers or `/mcp <name>` to inspect one in detail.
|
|
74
75
|
|
|
75
76
|
## Configuration
|
|
76
77
|
|
|
77
|
-
Servers can be configured via JSON files or registered programmatically by other Pi 1.x extensions:
|
|
78
|
+
Servers can be configured via JSON/JSONC files (`//` and `/* */` comments are supported) or registered programmatically by other Pi 1.x extensions:
|
|
78
79
|
|
|
79
80
|
| Source | Precedence & Scope |
|
|
80
81
|
|---|---|
|
|
@@ -103,7 +104,7 @@ Servers can be configured via JSON files or registered programmatically by other
|
|
|
103
104
|
"lifecycle": "eager"
|
|
104
105
|
},
|
|
105
106
|
"deepsource": {
|
|
106
|
-
"url": "https://mcp.deepsource.
|
|
107
|
+
"url": "https://mcp.deepsource.com/mcp", // "transport" defaults to "streamable-http" when "url" is set
|
|
107
108
|
"lifecycle": "eager"
|
|
108
109
|
},
|
|
109
110
|
|
|
@@ -123,6 +124,7 @@ Servers can be configured via JSON files or registered programmatically by other
|
|
|
123
124
|
"context7": {
|
|
124
125
|
"command": "npx",
|
|
125
126
|
"args": ["-y", "@context7/mcp"],
|
|
127
|
+
"cwd": "./",
|
|
126
128
|
"transport": "stdio",
|
|
127
129
|
"lifecycle": "lazy"
|
|
128
130
|
},
|
|
@@ -184,14 +186,14 @@ An override may set `enabled`, `lifecycle`, and Pi's `exposure` / `toolExposure`
|
|
|
184
186
|
|
|
185
187
|
### OAuth Config
|
|
186
188
|
|
|
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.
|
|
189
|
+
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, origin (`serverUrl`) and authorization-server (`issuer`) binding, and automatic backup of corrupt state files. See [OAUTH.md](./OAUTH.md) for architecture and troubleshooting details.
|
|
188
190
|
|
|
189
191
|
| Field | Type | Default | Description |
|
|
190
192
|
|---|---|---|---|
|
|
191
193
|
| `type` | `"oauth"` | `"oauth"` | Authentication type |
|
|
192
194
|
| `scope` | `string` | discovered | Requested scope when the server does not advertise one in `WWW-Authenticate` |
|
|
193
195
|
| `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID (skips Dynamic Client Registration) |
|
|
194
|
-
| `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
|
|
196
|
+
| `clientSecret` | `string` | — | Secret for a pre-registered confidential client (used in memory; never written to disk) |
|
|
195
197
|
| `clientName` (or `oauth.clientName`) | `string` | `pi-mcp/<server name>` | `client_name` sent with Dynamic Client Registration, for servers that only accept known clients |
|
|
196
198
|
| `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. |
|
|
197
199
|
|
|
@@ -199,7 +201,7 @@ Configured under `auth` (or `oauth`). Token and client state are persisted per s
|
|
|
199
201
|
|
|
200
202
|
Server-name arguments provide autocomplete suggestions from the configured MCP servers. `/mcp:auth` suggests only servers configured for OAuth.
|
|
201
203
|
|
|
202
|
-
| Command | Description |
|
|
204
|
+
| Slash Command | Description |
|
|
203
205
|
|---|---|
|
|
204
206
|
| `/mcp` | Show status summary of all configured servers |
|
|
205
207
|
| `/mcp <name>` | Show detailed status for one server: config source, transport/lifecycle, tool activation, OAuth credential status, failure hint, and stderr log |
|
|
@@ -208,7 +210,12 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
|
|
|
208
210
|
| `/mcp:auth <name>` | Connect with stored OAuth credentials and client registration; request browser authorization only if needed |
|
|
209
211
|
| `/mcp:auth <name> --reset` | Discard stored tokens and client registration (re-running Dynamic Client Registration) and request fresh authorization |
|
|
210
212
|
|
|
211
|
-
|
|
213
|
+
In addition to user slash commands, two built-in **agent tools** let the LLM inspect and connect configured servers directly:
|
|
214
|
+
|
|
215
|
+
| Agent Tool | Parameters | Description |
|
|
216
|
+
|---|---|---|
|
|
217
|
+
| `mcp_status` | `{}` | List configured servers with `lifecycle`, `state`, `tools` (`discovered` / `active`), `auth` credential presence, `source` (`global` / `project` / `extension`), and safe `error` action hints (never returns tokens, headers, or authorization URLs) |
|
|
218
|
+
| `mcp_connect` | `{ name: string }` | Connect one named server, run interactive OAuth if needed, and verify that discovered tools are active before returning success (returns fixed actionable categories on failure instead of raw server response text) |
|
|
212
219
|
|
|
213
220
|
### Inspecting one server (`/mcp <name>`)
|
|
214
221
|
|
|
@@ -276,39 +283,53 @@ While a Pi UI is active, the extension never writes diagnostics to Pi's stderr (
|
|
|
276
283
|
|
|
277
284
|
## How It Works
|
|
278
285
|
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
│ Pi Agent
|
|
282
|
-
│
|
|
283
|
-
│
|
|
284
|
-
│ │ index.ts
|
|
285
|
-
│ │ (entry
|
|
286
|
-
│
|
|
287
|
-
│
|
|
288
|
-
│
|
|
289
|
-
│
|
|
290
|
-
│
|
|
291
|
-
│
|
|
292
|
-
│
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
286
|
+
```text
|
|
287
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
288
|
+
│ Pi Agent (TUI / RPC, ExtensionAPI) │
|
|
289
|
+
│ │
|
|
290
|
+
│ ┌────────────────────┐ ┌────────────────────────┐ │
|
|
291
|
+
│ │ index.ts │───▶│ register-commands │ (/mcp, :start, │
|
|
292
|
+
│ │ (entry & mcp_* │ │ (/mcp* slash commands) │ :stop, :auth) │
|
|
293
|
+
│ │ agent tools) │ └───────────┬────────────┘ │
|
|
294
|
+
│ └────┬──────────┬────┘ │ │
|
|
295
|
+
│ │ └──────────────┐ │ │
|
|
296
|
+
│ │ ▼ ▼ │
|
|
297
|
+
│ │ ┌────────────────────────┐ │
|
|
298
|
+
│ │ │ connection-coordinator │ │
|
|
299
|
+
│ │ │ (in-flight connects, │ │
|
|
300
|
+
│ │ │ authQueue, teardown) │ │
|
|
301
|
+
│ │ └───────────┬────────────┘ │
|
|
302
|
+
│ │ │ │
|
|
303
|
+
│ │ ▼ │
|
|
304
|
+
│ │ ┌────────────────────────┐ ┌───────────┐ │
|
|
305
|
+
│ │ │ server-manager │───▶│tool-bridge│ │
|
|
306
|
+
│ │ │ (stopped → starting │ │(MCP → Pi) │ │
|
|
307
|
+
│ │ │ → ready lifecycle) │ └─────┬─────┘ │
|
|
308
|
+
│ ▼ └───────────┬────────────┘ │ │
|
|
309
|
+
│ ┌────────────────────┐ ▼ ▼ │
|
|
310
|
+
│ │ config │ ┌────────────────────────┐ ┌───────────┐ │
|
|
311
|
+
│ │ (Zod + JSONC) │ │ transport-factory & │ │ TypeBox │ │
|
|
312
|
+
│ └────────┬───────────┘ │ oauth-provider (SDK) │ │ (schema) │ │
|
|
313
|
+
│ │ └───────────┬────────────┘ └───────────┘ │
|
|
314
|
+
└───────────┼────────────────────────────┼────────────────────────────────┘
|
|
315
|
+
│ │
|
|
316
|
+
▼ ▼
|
|
317
|
+
Global/Project mcp.json ┌──────────────┐
|
|
318
|
+
+ Registered Extensions │ MCP Servers │
|
|
319
|
+
│ (stdio/http) │
|
|
320
|
+
└──────────────┘
|
|
300
321
|
```
|
|
301
322
|
|
|
302
323
|
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
|
|
303
|
-
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`
|
|
324
|
+
2. **Eager servers connect** at `session_start` via `ConnectionCoordinator` (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`
|
|
304
325
|
3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages) before a server enters `ready`
|
|
305
|
-
4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
|
|
326
|
+
4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas (preserving nullable union ordering, resolving `$ref`s cycle-safely, and inferring/flattening `allOf` object schemas)
|
|
306
327
|
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`)
|
|
307
328
|
6. **Tools activate/deactivate** automatically as servers connect/disconnect
|
|
308
329
|
7. **AbortSignal** is wired through to `notifications/cancelled` for clean cancellation
|
|
309
330
|
8. **Reconnection & recovery** uses a fixed delay schedule with configurable max retries on initial failure, health check failure, or unexpected transport close (`client.onclose`)
|
|
310
331
|
9. **`list_changed` notifications** trigger re-discovery, deactivating stale tools automatically
|
|
311
|
-
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)
|
|
332
|
+
10. **Session shutdown** cancels pending auth/connection flows, stops the callback server, deactivates all tools, flushes the debug log, and cleanly stops all servers (with a `SIGKILL` safety net for `stdio` child processes)
|
|
312
333
|
|
|
313
334
|
## MCP Spec Compliance
|
|
314
335
|
|
|
@@ -340,30 +361,38 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, workflow, and the pre-merge
|
|
|
340
361
|
## Development
|
|
341
362
|
|
|
342
363
|
```bash
|
|
343
|
-
# Install dependencies
|
|
344
|
-
npm
|
|
364
|
+
# Install dependencies (clean lockfile install)
|
|
365
|
+
npm ci
|
|
345
366
|
|
|
346
|
-
# Lint (type-aware ESLint)
|
|
367
|
+
# Lint (type-aware ESLint + structural/complexity checks)
|
|
347
368
|
npm run lint
|
|
348
369
|
|
|
349
370
|
# Type check (strict mode)
|
|
350
371
|
npm run typecheck
|
|
351
372
|
|
|
352
|
-
# Run all
|
|
373
|
+
# Run all test suites
|
|
353
374
|
npm test
|
|
354
375
|
|
|
376
|
+
# Run all test suites with coverage floor (95% lines / 85% branches / 83% functions)
|
|
377
|
+
npm run test:coverage
|
|
378
|
+
|
|
355
379
|
# Run integration tests only (real stdio server)
|
|
356
380
|
npm run test:integration
|
|
381
|
+
|
|
382
|
+
# Verify npm tarball contents
|
|
383
|
+
npm pack --dry-run
|
|
357
384
|
```
|
|
358
385
|
|
|
359
386
|
### Testing
|
|
360
387
|
|
|
361
388
|
The test suite includes:
|
|
362
|
-
- **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`
|
|
363
|
-
- **Server lifecycle & coordination** — Connect, retry, unexpected close recovery, shutdown with PID `SIGKILL` safety net, health check, concurrent connect/reset coordination, and cancellation
|
|
364
|
-
- **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
|
|
365
|
-
- **Tool bridge &
|
|
366
|
-
- **
|
|
389
|
+
- **Config & Pi 1.x / 1.3.0 compatibility** (`tests/config.test.ts`, `tests/pi-1x-compat.test.ts`) — Zod schema, JSONC comments, `$PI_CODING_AGENT_DIR` resolution, global/project merge, project trust boundary (`ctx.isProjectTrusted()`), Pi 1.3.0 `enabled: false` and partial project overrides, `stdio` `cwd`, `oauth.clientName`, Pi 1.x schema aliases (`type: "http"`, URL-only HTTP, `oauth`, `timeout`), and dynamic `pi.getMcpServers()` / `mcp_servers_change`
|
|
390
|
+
- **Server lifecycle & coordination** (`tests/server-manager.test.ts`, `tests/stdio-process.test.ts`, `tests/transport-factory.test.ts`, `tests/connection-coordinator.test.ts`, `tests/connection-coordination.test.ts`) — Connect, retry, unexpected close recovery, shutdown with PID `SIGKILL` safety net, health check, concurrent connect/reset coordination, and cancellation
|
|
391
|
+
- **OAuth 2.1 & auth locks** (`tests/oauth-fixes.test.ts`, `tests/authorize-flow.test.ts`, `tests/auth-lock.test.ts`, `tests/auth-commands.test.ts`, `tests/auth-arguments.test.ts`, `tests/fake-oauth-server.test.ts`) — Challenge discovery, PKCE, Dynamic Client Registration, origin and issuer binding, startup authorization prompts, headless/SSH redirect URL paste-back, URL scheme validation, cross-process lock heartbeat, and stale lock reclamation
|
|
392
|
+
- **Tool bridge, status diagnostics & debug log** (`tests/tool-bridge.test.ts`, `tests/management-tools.test.ts`, `tests/server-status.test.ts`, `tests/status-commands.test.ts`, `tests/completions.test.ts`, `tests/debug-log.test.ts`) — JSON Schema → TypeBox conversion (`allOf` object inference, `$ref` cycle protection, union `null` ordering), name sanitization, image content passthrough, `isError` filtering, activate/deactivate, `mcp_status` / `mcp_connect`, `/mcp <name>` config provenance and failure hints, slash command completions, and redacted `PI_MCP_DEBUG=1` JSONL logging
|
|
393
|
+
- **Property-based fuzzing** (`tests/fuzz.test.ts`) — `fast-check` properties covering JSONC comment stripping, callback/redirect URL validation, `WWW-Authenticate` challenge parsing, JSON Schema → TypeBox conversion, tool naming, terminal/log secret sanitization, and custom header merging
|
|
394
|
+
- **Release flow & repository policy** (`tests/release.test.ts`, `tests/ruleset-audit.test.ts`, `tests/repo-policy.test.ts`) — Release PR orchestration and npm/git self-healing (`scripts/release.ts`), live GitHub ruleset drift auditing (`scripts/ruleset-audit.ts`), and CI/ESLint/Qlty policy invariants
|
|
395
|
+
- **End-to-end integration** — Real mock MCP and OAuth servers over `stdio` and HTTP with tool discovery and execution
|
|
367
396
|
|
|
368
397
|
## Security
|
|
369
398
|
|
package/package.json
CHANGED
|
@@ -73,6 +73,24 @@ type InteractiveAuthParams = {
|
|
|
73
73
|
reset: boolean;
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Whether a declined server still deserves the `run /mcp:auth <name>` footer hint.
|
|
78
|
+
*
|
|
79
|
+
* WHY: A decline records "this server needed authorization at some point", which stays
|
|
80
|
+
* true until there is positive evidence otherwise. `ready` is that evidence, and so is a
|
|
81
|
+
* `stopped` server whose `lastError` is now a non-auth failure (`ECONNREFUSED`, timeout) —
|
|
82
|
+
* that failure overwrote `AuthRequiredError`, so authorization is no longer the problem
|
|
83
|
+
* and the hint would send the user down the wrong path. An unknown server drops out too.
|
|
84
|
+
* Anything ambiguous (no `lastError` yet, still `starting`) keeps the hint, because
|
|
85
|
+
* clearing it on doubt would hide a pending authorization the user was just told about.
|
|
86
|
+
*/
|
|
87
|
+
function retainsAuthHint(server: Pick<ManagedServer, "state" | "lastError"> | undefined): boolean {
|
|
88
|
+
if (!server || server.state === "ready") return false;
|
|
89
|
+
const lastError = server.lastError;
|
|
90
|
+
if (!lastError || lastError instanceof AuthRequiredError) return true;
|
|
91
|
+
return server.state !== "stopped";
|
|
92
|
+
}
|
|
93
|
+
|
|
76
94
|
export class ConnectionCoordinator {
|
|
77
95
|
private readonly deps: ConnectionCoordinatorDeps;
|
|
78
96
|
private readonly connectAttempts = new Map<string, ConnectAttemptEntry>();
|
|
@@ -124,7 +142,7 @@ export class ConnectionCoordinator {
|
|
|
124
142
|
syncStatus(ctx: ExtensionContext | undefined = this.activeCtx): void {
|
|
125
143
|
for (const declinedName of Array.from(this.declinedStartupAuth)) {
|
|
126
144
|
const server = this.deps.manager.getServer(declinedName);
|
|
127
|
-
if (!server
|
|
145
|
+
if (!retainsAuthHint(server)) {
|
|
128
146
|
this.declinedStartupAuth.delete(declinedName);
|
|
129
147
|
}
|
|
130
148
|
}
|
|
@@ -147,18 +165,23 @@ export class ConnectionCoordinator {
|
|
|
147
165
|
|
|
148
166
|
handleRecoveryFailed(serverName: string, error: unknown): void {
|
|
149
167
|
if (error instanceof AuthRequiredError) {
|
|
150
|
-
this.declinedStartupAuth.add(serverName);
|
|
151
168
|
this.activeCtx?.ui.notify(
|
|
152
169
|
`pi-mcp: ${serverName} requires re-authorization — run /mcp:auth ${serverName}`,
|
|
153
170
|
"warning",
|
|
154
171
|
);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
);
|
|
172
|
+
// WHY: Route through markAuthRequired so a mid-session 401 (reported by
|
|
173
|
+
// ServerManager.handleUnauthorizedServerError) lands in the declined set and
|
|
174
|
+
// repaints the footer hint exactly like a declined startup prompt.
|
|
175
|
+
this.markAuthRequired(serverName);
|
|
176
|
+
return;
|
|
161
177
|
}
|
|
178
|
+
this.activeCtx?.ui.notify(
|
|
179
|
+
`pi-mcp: Lost connection to ${serverName} — ${formatErrorMessage(error)}`,
|
|
180
|
+
"error",
|
|
181
|
+
);
|
|
182
|
+
// WHY: The failure just overwrote lastError, so a hint left over from an earlier
|
|
183
|
+
// decline now points at authorization that is not the problem — clear it.
|
|
184
|
+
this.syncStatus(this.activeCtx);
|
|
162
185
|
}
|
|
163
186
|
|
|
164
187
|
cancelServer(serverName: string): void {
|
|
@@ -325,6 +348,9 @@ export class ConnectionCoordinator {
|
|
|
325
348
|
try {
|
|
326
349
|
await this.deps.manager.startServer(name, ctx.cwd);
|
|
327
350
|
} catch (err) {
|
|
351
|
+
// WHY: A cancelled start means the user stopped the server (/mcp:stop) or the
|
|
352
|
+
// session is tearing down; reporting it as a start failure would be a bogus error.
|
|
353
|
+
if (err instanceof AuthCancelledError) return;
|
|
328
354
|
if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
|
|
329
355
|
authRequiredServers.add(name);
|
|
330
356
|
return;
|
|
@@ -335,12 +361,14 @@ export class ConnectionCoordinator {
|
|
|
335
361
|
}),
|
|
336
362
|
);
|
|
337
363
|
|
|
364
|
+
// WHY: Reconcile the footer hint once every eager start has settled — a declined
|
|
365
|
+
// server may have connected cleanly or failed with a non-auth error, and either
|
|
366
|
+
// outcome makes "run /mcp:auth <name>" stale.
|
|
367
|
+
this.syncStatus(ctx);
|
|
368
|
+
|
|
338
369
|
for (const [name] of serversToStart) {
|
|
339
370
|
if (!authRequiredServers.has(name)) continue;
|
|
340
|
-
if (this.declinedStartupAuth.has(name))
|
|
341
|
-
this.syncStatus(ctx);
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
371
|
+
if (this.declinedStartupAuth.has(name)) continue;
|
|
344
372
|
this.enqueueStartupAuthPrompt(name, ctx);
|
|
345
373
|
}
|
|
346
374
|
|
|
@@ -483,8 +511,13 @@ export class ConnectionCoordinator {
|
|
|
483
511
|
await this._startAndGuardAbort(serverName, ctx, controller.signal);
|
|
484
512
|
return true;
|
|
485
513
|
} catch (err) {
|
|
486
|
-
if (
|
|
487
|
-
|
|
514
|
+
if (err instanceof AuthRequiredError && serverConfig.auth) return false;
|
|
515
|
+
// WHY: A non-auth connect failure (e.g. ECONNREFUSED) overwrites lastError, so a
|
|
516
|
+
// hint left over from an earlier decline no longer describes this server's problem.
|
|
517
|
+
if (!(err instanceof AuthCancelledError)) {
|
|
518
|
+
this.syncStatus(ctx);
|
|
519
|
+
}
|
|
520
|
+
throw err;
|
|
488
521
|
}
|
|
489
522
|
}
|
|
490
523
|
|
|
@@ -493,7 +526,17 @@ export class ConnectionCoordinator {
|
|
|
493
526
|
this._ensureActive(controller.signal, serverName);
|
|
494
527
|
await this.deps.authorize(serverName, ctx, controller.signal, reset);
|
|
495
528
|
this._ensureActive(controller.signal, serverName);
|
|
496
|
-
|
|
529
|
+
try {
|
|
530
|
+
await this._startAndGuardAbort(serverName, ctx, controller.signal);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
// WHY: Authorization can succeed and the reconnect still fail for a non-auth reason
|
|
533
|
+
// (ECONNREFUSED, timeout). lastError now names that failure, so a hint left over from
|
|
534
|
+
// the earlier decline would tell the user to authorize a server that just authorized.
|
|
535
|
+
if (!(err instanceof AuthCancelledError)) {
|
|
536
|
+
this.syncStatus(ctx);
|
|
537
|
+
}
|
|
538
|
+
throw err;
|
|
539
|
+
}
|
|
497
540
|
}
|
|
498
541
|
|
|
499
542
|
private async _startAndGuardAbort(
|
package/src/index.ts
CHANGED
|
@@ -162,8 +162,20 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
162
162
|
manager.setRecoveryFailedCallback((serverName, error) => {
|
|
163
163
|
coordinator.handleRecoveryFailed(serverName, error);
|
|
164
164
|
});
|
|
165
|
+
manager.setServerReadyCallback(() => {
|
|
166
|
+
// WHY: Eager starts and background _recover() reconnects never pass through
|
|
167
|
+
// coordinator.connect(), so without this the "run /mcp:auth <name>" footer hint
|
|
168
|
+
// survives a server that has since connected cleanly.
|
|
169
|
+
coordinator.syncStatus();
|
|
170
|
+
});
|
|
165
171
|
bridge.setAuthErrorCallback(async (serverName) => {
|
|
166
|
-
await manager.handleUnauthorizedServerError(serverName);
|
|
172
|
+
const authError = await manager.handleUnauthorizedServerError(serverName);
|
|
173
|
+
if (authError) {
|
|
174
|
+
// WHY: A mid-session 401/403 stops the server synchronously, which cancels the
|
|
175
|
+
// deferred _recover() that would have called onRecoveryFailed — so report the
|
|
176
|
+
// failure here or the re-auth warning and the footer hint never appear.
|
|
177
|
+
coordinator.handleRecoveryFailed(serverName, authError);
|
|
178
|
+
}
|
|
167
179
|
});
|
|
168
180
|
|
|
169
181
|
// ── 3. Session lifecycle ──────────────────────────────────────────────────
|
package/src/server-manager.ts
CHANGED
|
@@ -29,8 +29,10 @@ import type { ToolRefreshOptions } from "./tool-bridge.js";
|
|
|
29
29
|
import { sanitizeTerminalText, type SanitizeTerminalTextOptions } from "./terminal-text.js";
|
|
30
30
|
import { describeError, reportDiagnostic } from "./debug-log.js";
|
|
31
31
|
import {
|
|
32
|
+
destroyStdioStreams,
|
|
32
33
|
ensurePidTerminated,
|
|
33
34
|
instrumentStdioTransport,
|
|
35
|
+
waitForChildClose,
|
|
34
36
|
} from "./stdio-process.js";
|
|
35
37
|
import { createTransport } from "./transport-factory.js";
|
|
36
38
|
import {
|
|
@@ -104,6 +106,17 @@ export type ToolRefreshCallback = (
|
|
|
104
106
|
/** Called when background auto-recovery after a disconnect or health check fails. */
|
|
105
107
|
export type RecoveryFailedCallback = (serverName: string, error: Error) => void;
|
|
106
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Called every time a server reaches `ready`, whether the transition came from an
|
|
111
|
+
* explicit connect, an eager startup, or background `_recover()`.
|
|
112
|
+
*
|
|
113
|
+
* WHY: Readiness is the only state change that makes a previously declined
|
|
114
|
+
* `run /mcp:auth <name>` footer hint obsolete, and eager starts plus background
|
|
115
|
+
* recovery bypass `ConnectionCoordinator.connect()`, which is where the hint is
|
|
116
|
+
* otherwise repainted.
|
|
117
|
+
*/
|
|
118
|
+
export type ServerReadyCallback = (serverName: string) => void;
|
|
119
|
+
|
|
107
120
|
interface HandshakeParams {
|
|
108
121
|
server: ManagedServer;
|
|
109
122
|
client: Client;
|
|
@@ -204,6 +217,7 @@ export class ServerManager {
|
|
|
204
217
|
private onToolRefresh: ToolRefreshCallback | null = null;
|
|
205
218
|
private onServerStopped: ((name: string) => void) | null = null;
|
|
206
219
|
private onRecoveryFailed: RecoveryFailedCallback | null = null;
|
|
220
|
+
private onServerReady: ServerReadyCallback | null = null;
|
|
207
221
|
|
|
208
222
|
constructor(config: McpConfig, private readonly storageDir?: string) {
|
|
209
223
|
this.settings = config.settings;
|
|
@@ -237,6 +251,11 @@ export class ServerManager {
|
|
|
237
251
|
this.onRecoveryFailed = cb;
|
|
238
252
|
}
|
|
239
253
|
|
|
254
|
+
/** Register callback invoked whenever a server reaches the `ready` state. */
|
|
255
|
+
setServerReadyCallback(cb: ServerReadyCallback): void {
|
|
256
|
+
this.onServerReady = cb;
|
|
257
|
+
}
|
|
258
|
+
|
|
240
259
|
private _resolveTimeoutMs(server: ManagedServer): number {
|
|
241
260
|
return server.config.requestTimeoutMs ?? this.settings.requestTimeoutMs;
|
|
242
261
|
}
|
|
@@ -333,11 +352,29 @@ export class ServerManager {
|
|
|
333
352
|
/**
|
|
334
353
|
* Mark an OAuth server as requiring re-authorization when a mid-session
|
|
335
354
|
* request (tools/list or tools/call) fails with 401/403 insufficient_scope.
|
|
355
|
+
*
|
|
356
|
+
* Returns the {@link AuthRequiredError} when a server that was already `ready`
|
|
357
|
+
* lost its credentials mid-session, and `undefined` otherwise (unknown server,
|
|
358
|
+
* non-OAuth server, or a server that is not `ready` — see below).
|
|
359
|
+
*
|
|
360
|
+
* Only a `ready` server is handled here. For any other state this is a no-op and
|
|
361
|
+
* must NOT touch the server: a 401/403 during a connect attempt (`initialize` ok,
|
|
362
|
+
* `tools/list` 401 while `starting`) arrives while that attempt owns the
|
|
363
|
+
* lifecycle, and shutting the server down here would abort the attempt's own
|
|
364
|
+
* signal. `classifyConnectFailure` then sees `aborted` first and reports
|
|
365
|
+
* `AuthCancelledError` instead of `auth-required`, which suppresses the startup
|
|
366
|
+
* auth prompt, the interactive authorization, and the recovery failure alike.
|
|
367
|
+
* Left alone, the attempt's `_raiseAuthRequired` invalidates the tokens, records
|
|
368
|
+
* the `AuthRequiredError`, and reports it exactly once.
|
|
336
369
|
*/
|
|
337
|
-
async handleUnauthorizedServerError(name: string): Promise<
|
|
370
|
+
async handleUnauthorizedServerError(name: string): Promise<AuthRequiredError | undefined> {
|
|
338
371
|
const server = this.servers.get(name);
|
|
339
372
|
if (!server || !server.config.auth || server.config.transport === "stdio") {
|
|
340
|
-
return;
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
if (server.state !== "ready") {
|
|
376
|
+
// WHY: See the JSDoc above — an in-flight connect attempt reports its own 401.
|
|
377
|
+
return undefined;
|
|
341
378
|
}
|
|
342
379
|
const authError = new AuthRequiredError(name);
|
|
343
380
|
// WHY: Shut down immediately before async disk I/O so server.generation increments
|
|
@@ -346,6 +383,7 @@ export class ServerManager {
|
|
|
346
383
|
server.lastError = authError;
|
|
347
384
|
await invalidateStoredTokens(name, this.storageDir, server.config.url);
|
|
348
385
|
server.lastError = authError;
|
|
386
|
+
return authError;
|
|
349
387
|
}
|
|
350
388
|
|
|
351
389
|
/** Get recent stderr output for a server. */
|
|
@@ -555,30 +593,9 @@ export class ServerManager {
|
|
|
555
593
|
);
|
|
556
594
|
|
|
557
595
|
// tools/list_changed: re-discover tools and update Pi registrations
|
|
558
|
-
client.setNotificationHandler(
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if (!signal.aborted && this.onToolRefresh && server.state === "ready" && server.client === client) {
|
|
562
|
-
try {
|
|
563
|
-
const refreshTimeoutMs = this._resolveTimeoutMs(server);
|
|
564
|
-
await this.onToolRefresh(server.name, server.client, {
|
|
565
|
-
requestTimeoutMs: refreshTimeoutMs,
|
|
566
|
-
signal,
|
|
567
|
-
});
|
|
568
|
-
} catch (err) {
|
|
569
|
-
const { kind, message } = describeError(err);
|
|
570
|
-
reportDiagnostic({
|
|
571
|
-
level: "error",
|
|
572
|
-
event: "server.tools_refresh_failed",
|
|
573
|
-
server: server.name,
|
|
574
|
-
message: `Failed to refresh tools for ${server.name}: ${message}`,
|
|
575
|
-
fields: { kind },
|
|
576
|
-
notify: true,
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
},
|
|
581
|
-
);
|
|
596
|
+
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
597
|
+
await this._handleToolListChanged(server, client, signal);
|
|
598
|
+
});
|
|
582
599
|
|
|
583
600
|
// notifications/message: structured logging from MCP servers
|
|
584
601
|
client.setNotificationHandler(
|
|
@@ -606,6 +623,48 @@ export class ServerManager {
|
|
|
606
623
|
return client;
|
|
607
624
|
}
|
|
608
625
|
|
|
626
|
+
/**
|
|
627
|
+
* Re-discover tools after a `notifications/tools/list_changed` from a ready server.
|
|
628
|
+
*
|
|
629
|
+
* Extracted from the notification handler so the handler registration stays a single
|
|
630
|
+
* guarded call.
|
|
631
|
+
*/
|
|
632
|
+
private async _handleToolListChanged(
|
|
633
|
+
server: ManagedServer,
|
|
634
|
+
client: Client,
|
|
635
|
+
signal: AbortSignal,
|
|
636
|
+
): Promise<void> {
|
|
637
|
+
const onToolRefresh = this.onToolRefresh;
|
|
638
|
+
if (signal.aborted || !onToolRefresh || server.state !== "ready" || server.client !== client) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
const refreshTimeoutMs = this._resolveTimeoutMs(server);
|
|
643
|
+
await onToolRefresh(server.name, server.client, {
|
|
644
|
+
requestTimeoutMs: refreshTimeoutMs,
|
|
645
|
+
signal,
|
|
646
|
+
});
|
|
647
|
+
} catch (err) {
|
|
648
|
+
const { kind, message } = describeError(err);
|
|
649
|
+
// WHY: Two failures here are already accounted for and must not toast: a cancelled
|
|
650
|
+
// refresh (/mcp:stop, session teardown) is expected, and a 401/403 was just reported
|
|
651
|
+
// by handleUnauthorizedServerError as a re-authorization warning plus the footer hint
|
|
652
|
+
// — a second "Failed to refresh tools" error would read as an unrelated problem.
|
|
653
|
+
const alreadyReported =
|
|
654
|
+
err instanceof AuthCancelledError ||
|
|
655
|
+
signal.aborted ||
|
|
656
|
+
server.lastError instanceof AuthRequiredError;
|
|
657
|
+
reportDiagnostic({
|
|
658
|
+
level: alreadyReported ? "info" : "error",
|
|
659
|
+
event: "server.tools_refresh_failed",
|
|
660
|
+
server: server.name,
|
|
661
|
+
message: `Failed to refresh tools for ${server.name}: ${message}`,
|
|
662
|
+
fields: { kind },
|
|
663
|
+
notify: !alreadyReported,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
609
668
|
private async _handshakeAndDiscover(params: HandshakeParams): Promise<void> {
|
|
610
669
|
const { server, client, transport, timeoutMs, signal, currentCwd } = params;
|
|
611
670
|
await this._connectWithTimeoutAndSignal(client, transport, timeoutMs, signal, server.name);
|
|
@@ -624,6 +683,9 @@ export class ServerManager {
|
|
|
624
683
|
this._setState(server, "ready");
|
|
625
684
|
server.retryCount = 0;
|
|
626
685
|
server.lastError = null;
|
|
686
|
+
// WHY: Fire only after the server is observably ready with a cleared lastError, so a
|
|
687
|
+
// listener that repaints session state never sees a half-transitioned server.
|
|
688
|
+
this.onServerReady?.(server.name);
|
|
627
689
|
if (server.cwd && server.cwd !== currentCwd) {
|
|
628
690
|
void client.sendRootsListChanged().catch(() => {
|
|
629
691
|
// WHY: Best-effort notification if cwd was updated while handshake was in flight.
|
|
@@ -827,6 +889,26 @@ export class ServerManager {
|
|
|
827
889
|
};
|
|
828
890
|
}
|
|
829
891
|
|
|
892
|
+
/**
|
|
893
|
+
* Terminate the stdio child process tree, then release the child's pipe handles.
|
|
894
|
+
*
|
|
895
|
+
* WHY: `client.close()` waits for the child's `'close'` event, which never fires while a
|
|
896
|
+
* leaked grandchild still holds the inherited stdout/stderr write ends — the SDK then
|
|
897
|
+
* burns its full 2 × 2,000ms escalation budget. Killing the tree first and destroying the
|
|
898
|
+
* pipes afterwards keeps teardown bounded even if a descendant escaped the tree kill.
|
|
899
|
+
*/
|
|
900
|
+
private async _terminateStdioTree(
|
|
901
|
+
pid: number | null,
|
|
902
|
+
transport: Transport | undefined,
|
|
903
|
+
): Promise<void> {
|
|
904
|
+
try {
|
|
905
|
+
await ensurePidTerminated(pid);
|
|
906
|
+
await waitForChildClose(transport);
|
|
907
|
+
} finally {
|
|
908
|
+
destroyStdioStreams(transport);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
830
912
|
private async _cleanupFailedConnect(
|
|
831
913
|
server: ManagedServer,
|
|
832
914
|
client: Client,
|
|
@@ -839,7 +921,7 @@ export class ServerManager {
|
|
|
839
921
|
server.childPid = null;
|
|
840
922
|
await Promise.allSettled([
|
|
841
923
|
client.close(),
|
|
842
|
-
|
|
924
|
+
this._terminateStdioTree(pid, transport),
|
|
843
925
|
]);
|
|
844
926
|
// WHY: Secondary safety check to guarantee PID termination even if transport.close()
|
|
845
927
|
// or child process exit raced during the initial Promise.allSettled.
|
|
@@ -1081,13 +1163,14 @@ export class ServerManager {
|
|
|
1081
1163
|
server.childPid ??
|
|
1082
1164
|
(server.transport as unknown as { _process?: { pid?: number } })?._process?.pid ??
|
|
1083
1165
|
null;
|
|
1166
|
+
const transport = server.transport;
|
|
1084
1167
|
server.client = null;
|
|
1085
1168
|
server.transport = undefined;
|
|
1086
1169
|
server.childPid = null;
|
|
1087
1170
|
|
|
1088
1171
|
await Promise.allSettled([
|
|
1089
1172
|
client?.close(),
|
|
1090
|
-
|
|
1173
|
+
this._terminateStdioTree(pid, transport),
|
|
1091
1174
|
]);
|
|
1092
1175
|
await ensurePidTerminated(pid);
|
|
1093
1176
|
}
|
package/src/stdio-process.ts
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides pure StringDecoder-based line splitting across chunk boundaries,
|
|
5
5
|
* StdioClientTransport instrumentation (early PID capture, memoized close,
|
|
6
|
-
* stderr wiring),
|
|
7
|
-
*
|
|
6
|
+
* stderr wiring), descendant process-tree discovery for wrappers such as
|
|
7
|
+
* `npx`, `uvx`, or `sh -c`, and deterministic SIGTERM → bounded wait → SIGKILL
|
|
8
|
+
* cleanup with injectable process/timer dependencies.
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
10
13
|
import { StringDecoder } from "node:string_decoder";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import type { ChildProcess } from "node:child_process";
|
|
11
16
|
import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
12
17
|
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
13
20
|
// ─── Line Splitting ───────────────────────────────────────────────────────────
|
|
14
21
|
|
|
15
22
|
export interface LineSplitter {
|
|
@@ -27,6 +34,7 @@ export function createLineSplitter(onLine: (line: string) => void): LineSplitter
|
|
|
27
34
|
|
|
28
35
|
return {
|
|
29
36
|
write(chunk: Buffer): void {
|
|
37
|
+
flushed = false;
|
|
30
38
|
remainder += decoder.write(chunk);
|
|
31
39
|
const lines = remainder.split("\n");
|
|
32
40
|
remainder = lines.pop() ?? "";
|
|
@@ -49,6 +57,25 @@ export function createLineSplitter(onLine: (line: string) => void): LineSplitter
|
|
|
49
57
|
|
|
50
58
|
// ─── Transport Instrumentation ────────────────────────────────────────────────
|
|
51
59
|
|
|
60
|
+
/** Grace window for an exited child's pipes to drain naturally before force-destroying them. */
|
|
61
|
+
const PIPE_DRAIN_GRACE_MS = 100;
|
|
62
|
+
|
|
63
|
+
interface StdioChildRecord {
|
|
64
|
+
process: ChildProcess;
|
|
65
|
+
closed: boolean;
|
|
66
|
+
/** Flush the buffered partial stderr line before the pipe is destroyed. */
|
|
67
|
+
flushStderr(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Child processes of instrumented transports, keyed by transport.
|
|
72
|
+
*
|
|
73
|
+
* WHY: `StdioClientTransport.close()` clears `_process` synchronously, so the child
|
|
74
|
+
* handle is gone by the time teardown needs its pipes and its stderr flush. A WeakMap
|
|
75
|
+
* keeps both reachable without pinning a closed transport in memory.
|
|
76
|
+
*/
|
|
77
|
+
const childRecords = new WeakMap<object, StdioChildRecord>();
|
|
78
|
+
|
|
52
79
|
export interface InstrumentStdioTransportOptions {
|
|
53
80
|
onPid(pid: number): void;
|
|
54
81
|
onStderrLine(line: string): void;
|
|
@@ -66,17 +93,35 @@ export function instrumentStdioTransport(
|
|
|
66
93
|
// WHY: Capture child PID immediately when StdioClientTransport spawns the process
|
|
67
94
|
// (before `initialize` is sent), so a timeout or cancellation during `initialize`
|
|
68
95
|
// can still terminate the child process deterministically before retrying.
|
|
96
|
+
const spawnedProcess = (): ChildProcess | undefined =>
|
|
97
|
+
(transport as unknown as { _process?: ChildProcess })._process;
|
|
98
|
+
|
|
99
|
+
let flushStderr: (() => void) | null = null;
|
|
100
|
+
|
|
101
|
+
const recordProcess = (proc: ChildProcess | undefined): void => {
|
|
102
|
+
if (!proc?.pid) return;
|
|
103
|
+
if (!childRecords.has(transport)) {
|
|
104
|
+
const record: StdioChildRecord = {
|
|
105
|
+
process: proc,
|
|
106
|
+
closed: false,
|
|
107
|
+
flushStderr: () => flushStderr?.(),
|
|
108
|
+
};
|
|
109
|
+
proc.once?.("close", () => {
|
|
110
|
+
record.closed = true;
|
|
111
|
+
});
|
|
112
|
+
childRecords.set(transport, record);
|
|
113
|
+
}
|
|
114
|
+
onPid(proc.pid);
|
|
115
|
+
};
|
|
116
|
+
|
|
69
117
|
const origStart = transport.start.bind(transport);
|
|
70
118
|
transport.start = async () => {
|
|
71
119
|
const startPromise = origStart();
|
|
72
|
-
|
|
73
|
-
if (earlyProc?.pid) {
|
|
74
|
-
onPid(earlyProc.pid);
|
|
75
|
-
}
|
|
120
|
+
recordProcess(spawnedProcess());
|
|
76
121
|
await startPromise;
|
|
77
|
-
const proc = (
|
|
122
|
+
const proc = spawnedProcess();
|
|
78
123
|
if (proc?.pid) {
|
|
79
|
-
|
|
124
|
+
recordProcess(proc);
|
|
80
125
|
} else if (transport.pid) {
|
|
81
126
|
onPid(transport.pid);
|
|
82
127
|
}
|
|
@@ -99,26 +144,212 @@ export function instrumentStdioTransport(
|
|
|
99
144
|
transport.stderr.on("data", (chunk: Buffer) => {
|
|
100
145
|
splitter.write(chunk);
|
|
101
146
|
});
|
|
102
|
-
const
|
|
147
|
+
const flush = (): void => {
|
|
103
148
|
splitter.end();
|
|
104
149
|
};
|
|
105
|
-
|
|
106
|
-
transport.stderr.on("
|
|
150
|
+
flushStderr = flush;
|
|
151
|
+
transport.stderr.on("end", flush);
|
|
152
|
+
transport.stderr.on("close", flush);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wait briefly for an exited child process to drain its stdio pipes and emit `'close'`
|
|
158
|
+
* naturally before `destroyStdioStreams` force-destroys the parent's pipe read ends.
|
|
159
|
+
*
|
|
160
|
+
* WHY: On Windows, `process.stderr` is an overlapped named pipe whose final bytes can
|
|
161
|
+
* still be in flight in libuv when `TerminateProcess` completes. Letting `'close'` fire
|
|
162
|
+
* naturally preserves trailing stderr output when no grandchild holds the pipe open, while
|
|
163
|
+
* bounding the wait to `PIPE_DRAIN_GRACE_MS` when a surviving grandchild keeps it open.
|
|
164
|
+
*/
|
|
165
|
+
export async function waitForChildClose(
|
|
166
|
+
transport: object | null | undefined,
|
|
167
|
+
timeoutMs = PIPE_DRAIN_GRACE_MS,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
if (!transport) return;
|
|
170
|
+
const record = childRecords.get(transport);
|
|
171
|
+
if (!record || record.closed || typeof record.process.once !== "function") return;
|
|
172
|
+
await new Promise<void>((resolve) => {
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
record.process.removeListener?.("close", onClose);
|
|
175
|
+
resolve();
|
|
176
|
+
}, timeoutMs);
|
|
177
|
+
const onClose = (): void => {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
resolve();
|
|
180
|
+
};
|
|
181
|
+
record.process.once("close", onClose);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Destroy the child's `stdout`/`stderr` pipes once the process tree is gone.
|
|
187
|
+
*
|
|
188
|
+
* WHY: A surviving grandchild (e.g. a server spawned by an `npx` wrapper) inherits the
|
|
189
|
+
* wrapper's pipe write ends, so Node never emits `'close'` for the wrapper and
|
|
190
|
+
* `StdioClientTransport.close()` burns its full 4000ms escalation budget waiting for the
|
|
191
|
+
* `'close'` event. Destroying the parent's read ends lets `close()` resolve immediately
|
|
192
|
+
* even when a descendant escaped the tree kill. `stdin` is left alone: it is our write
|
|
193
|
+
* end and the SDK still ends it as part of its own close sequence.
|
|
194
|
+
*/
|
|
195
|
+
export function destroyStdioStreams(transport: object | null | undefined): void {
|
|
196
|
+
if (!transport) return;
|
|
197
|
+
const record = childRecords.get(transport);
|
|
198
|
+
if (!record) return;
|
|
199
|
+
childRecords.delete(transport);
|
|
200
|
+
// WHY: Flush the buffered partial stderr line first. Destroying our read end keeps the
|
|
201
|
+
// SDK's PassThrough from ever emitting 'end', so without this a forced teardown drops the
|
|
202
|
+
// server's last words (the buffered remainder of a line with no trailing newline).
|
|
203
|
+
record.flushStderr();
|
|
204
|
+
record.process.stdout?.destroy();
|
|
205
|
+
record.process.stderr?.destroy();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Descendant Discovery ─────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Upper bound on tracked descendants.
|
|
212
|
+
*
|
|
213
|
+
* WHY: A wrapper that respawns children while we walk the tree could otherwise make
|
|
214
|
+
* discovery unbounded; teardown only needs the tree as it stood when we snapshotted it.
|
|
215
|
+
*/
|
|
216
|
+
const MAX_DESCENDANTS = 256;
|
|
217
|
+
|
|
218
|
+
/** Read the direct child PIDs of `pid` from Linux `/proc`. */
|
|
219
|
+
async function readProcChildren(pid: number): Promise<number[]> {
|
|
220
|
+
// WHY: /proc/<pid>/task/<tid>/children lists each thread's direct children, so a
|
|
221
|
+
// multithreaded wrapper is covered without scanning every process in /proc.
|
|
222
|
+
const taskDir = `/proc/${pid}/task`;
|
|
223
|
+
const tids = await readdir(taskDir);
|
|
224
|
+
const children: number[] = [];
|
|
225
|
+
for (const tid of tids) {
|
|
226
|
+
const raw = await readChildrenFile(`${taskDir}/${tid}/children`);
|
|
227
|
+
for (const token of raw.split(/\s+/)) {
|
|
228
|
+
if (token) children.push(Number(token));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return children;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function readChildrenFile(path: string): Promise<string> {
|
|
235
|
+
try {
|
|
236
|
+
return await readFile(path, "utf8");
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// WHY: A thread can exit between readdir() and readFile(), and a process that dies
|
|
239
|
+
// mid-walk simply has no children left to report.
|
|
240
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return "";
|
|
241
|
+
throw err;
|
|
107
242
|
}
|
|
108
243
|
}
|
|
109
244
|
|
|
245
|
+
/** Timeout for a single `ps` process-table snapshot on non-Linux POSIX systems. */
|
|
246
|
+
const PS_TIMEOUT_MS = 1_000;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read the full `ps` process table and index it as `parentPid → childPids`.
|
|
250
|
+
*
|
|
251
|
+
* WHY: macOS and other non-Linux POSIX systems have no `/proc`; `ps -A -o pid=,ppid=`
|
|
252
|
+
* dumps the entire process table in one call so a single snapshot can serve the whole
|
|
253
|
+
* tree walk without spawning `ps` once per node.
|
|
254
|
+
*/
|
|
255
|
+
async function readPsProcessTable(): Promise<Map<number, number[]>> {
|
|
256
|
+
const { stdout } = await execFileAsync("ps", ["-A", "-o", "pid=,ppid="], {
|
|
257
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
258
|
+
timeout: PS_TIMEOUT_MS,
|
|
259
|
+
});
|
|
260
|
+
return indexProcessTable(stdout);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Index `ps -A -o pid=,ppid=` output as a parent PID → child PIDs map. */
|
|
264
|
+
export function indexProcessTable(stdout: string): Map<number, number[]> {
|
|
265
|
+
const childrenOf = new Map<number, number[]>();
|
|
266
|
+
for (const line of stdout.split("\n")) {
|
|
267
|
+
const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
|
|
268
|
+
if (!match) continue;
|
|
269
|
+
const childPid = Number(match[1]);
|
|
270
|
+
const parentPid = Number(match[2]);
|
|
271
|
+
const siblings = childrenOf.get(parentPid);
|
|
272
|
+
if (siblings) {
|
|
273
|
+
siblings.push(childPid);
|
|
274
|
+
} else {
|
|
275
|
+
childrenOf.set(parentPid, [childPid]);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return childrenOf;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function createPlatformChildReader(): (pid: number) => Promise<number[]> {
|
|
282
|
+
if (process.platform === "linux") return readProcChildren;
|
|
283
|
+
if (process.platform === "win32") {
|
|
284
|
+
// WHY: Windows has no `/proc` or `ps`, and the portable tree kill (`taskkill /T`)
|
|
285
|
+
// must be driven by the caller's own signal semantics. Discovery reports nothing so
|
|
286
|
+
// teardown falls back to killing the top-level PID plus destroying the pipes.
|
|
287
|
+
return () => Promise.resolve([]);
|
|
288
|
+
}
|
|
289
|
+
let tablePromise: Promise<Map<number, number[]>> | null = null;
|
|
290
|
+
return async (pid: number): Promise<number[]> => {
|
|
291
|
+
tablePromise ??= readPsProcessTable();
|
|
292
|
+
const childrenOf = await tablePromise;
|
|
293
|
+
return childrenOf.get(pid) ?? [];
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Snapshot the descendant PIDs of `pid`, deepest generation first.
|
|
299
|
+
*
|
|
300
|
+
* Returns `[]` when discovery is unavailable (Windows, containers without `ps` or
|
|
301
|
+
* `/proc`), which leaves the caller's single-PID teardown intact.
|
|
302
|
+
*/
|
|
303
|
+
export async function listDescendantPids(
|
|
304
|
+
pid: number,
|
|
305
|
+
readChildren: (pid: number) => Promise<number[]> = createPlatformChildReader(),
|
|
306
|
+
): Promise<number[]> {
|
|
307
|
+
try {
|
|
308
|
+
const descendants = await walkDescendants(pid, readChildren);
|
|
309
|
+
// Breadth-first order reversed: each generation is signalled before its parent, so a
|
|
310
|
+
// wrapper cannot respawn or reap a child that is still being torn down.
|
|
311
|
+
return descendants.reverse();
|
|
312
|
+
} catch {
|
|
313
|
+
// WHY: Discovery is best-effort by design: the PID can exit between the liveness check
|
|
314
|
+
// and the `/proc` walk, and minimal containers ship neither `ps` nor `/proc`. A tree we
|
|
315
|
+
// cannot read must never fail the caller's shutdown path — teardown still kills the
|
|
316
|
+
// root PID and releases the inherited pipes.
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function walkDescendants(
|
|
322
|
+
pid: number,
|
|
323
|
+
readChildren: (pid: number) => Promise<number[]>,
|
|
324
|
+
): Promise<number[]> {
|
|
325
|
+
const descendants: number[] = [];
|
|
326
|
+
const seen = new Set<number>([pid]);
|
|
327
|
+
const pending = await readChildren(pid);
|
|
328
|
+
while (pending.length > 0 && descendants.length < MAX_DESCENDANTS) {
|
|
329
|
+
const next = pending.shift();
|
|
330
|
+
if (next === undefined || seen.has(next)) continue;
|
|
331
|
+
seen.add(next);
|
|
332
|
+
descendants.push(next);
|
|
333
|
+
pending.push(...(await readChildren(next)));
|
|
334
|
+
}
|
|
335
|
+
return descendants;
|
|
336
|
+
}
|
|
337
|
+
|
|
110
338
|
// ─── Process Supervision ──────────────────────────────────────────────────────
|
|
111
339
|
|
|
112
340
|
export interface StdioProcessDeps {
|
|
113
341
|
kill(pid: number, signal?: NodeJS.Signals | number): void | boolean;
|
|
114
342
|
sleep(ms: number): Promise<void>;
|
|
115
343
|
now(): number;
|
|
344
|
+
/** Snapshot descendant PIDs of `pid`, deepest first. Resolves `[]` when unavailable. */
|
|
345
|
+
listDescendantPids(pid: number): Promise<number[]>;
|
|
116
346
|
}
|
|
117
347
|
|
|
118
348
|
const defaultDeps: StdioProcessDeps = {
|
|
119
349
|
kill: (pid, signal) => process.kill(pid, signal),
|
|
120
350
|
sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
121
351
|
now: () => Date.now(),
|
|
352
|
+
listDescendantPids: (pid) => listDescendantPids(pid),
|
|
122
353
|
};
|
|
123
354
|
|
|
124
355
|
function resolveDeps(deps?: Partial<StdioProcessDeps>): StdioProcessDeps {
|
|
@@ -127,6 +358,7 @@ function resolveDeps(deps?: Partial<StdioProcessDeps>): StdioProcessDeps {
|
|
|
127
358
|
kill: deps.kill ?? defaultDeps.kill,
|
|
128
359
|
sleep: deps.sleep ?? defaultDeps.sleep,
|
|
129
360
|
now: deps.now ?? defaultDeps.now,
|
|
361
|
+
listDescendantPids: deps.listDescendantPids ?? defaultDeps.listDescendantPids,
|
|
130
362
|
};
|
|
131
363
|
}
|
|
132
364
|
|
|
@@ -146,29 +378,68 @@ export function isPidAlive(
|
|
|
146
378
|
}
|
|
147
379
|
}
|
|
148
380
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
signal: NodeJS.Signals,
|
|
152
|
-
timeoutMs: number,
|
|
153
|
-
d: StdioProcessDeps,
|
|
154
|
-
): Promise<boolean> {
|
|
381
|
+
/** Send `signal` to one PID; `false` when the PID was already gone (ESRCH). */
|
|
382
|
+
function signalPid(pid: number, signal: NodeJS.Signals, d: StdioProcessDeps): boolean {
|
|
155
383
|
try {
|
|
156
384
|
d.kill(pid, signal);
|
|
385
|
+
return true;
|
|
157
386
|
} catch (err) {
|
|
158
387
|
if ((err as NodeJS.ErrnoException).code === "ESRCH") {
|
|
159
|
-
return
|
|
388
|
+
return false;
|
|
160
389
|
}
|
|
390
|
+
// WHY: EPERM and platform refusals mean this PID cannot be signalled (a recycled PID
|
|
391
|
+
// or a sandboxed process). Teardown still has to signal the rest of the tree within
|
|
392
|
+
// the bounded wait instead of rejecting the caller's shutdown path.
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function allExited(pids: readonly number[], d: StdioProcessDeps): boolean {
|
|
398
|
+
return pids.every((pid) => !isPidAlive(pid, d));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function signalAndWaitForExit(
|
|
402
|
+
pids: readonly number[],
|
|
403
|
+
signal: NodeJS.Signals,
|
|
404
|
+
timeoutMs: number,
|
|
405
|
+
d: StdioProcessDeps,
|
|
406
|
+
): Promise<boolean> {
|
|
407
|
+
const attempted = pids.map((pid) => signalPid(pid, signal, d));
|
|
408
|
+
// Every target reported ESRCH: the tree is already gone, so there is nothing to wait for.
|
|
409
|
+
if (!attempted.some(Boolean)) {
|
|
410
|
+
return true;
|
|
161
411
|
}
|
|
162
412
|
|
|
163
413
|
const deadline = d.now() + timeoutMs;
|
|
164
414
|
while (d.now() < deadline) {
|
|
165
|
-
if (
|
|
415
|
+
if (allExited(pids, d)) {
|
|
166
416
|
return true;
|
|
167
417
|
}
|
|
168
418
|
await d.sleep(25);
|
|
169
419
|
}
|
|
170
420
|
|
|
171
|
-
return
|
|
421
|
+
return allExited(pids, d);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Escalate to SIGKILL across the surviving tree.
|
|
426
|
+
*
|
|
427
|
+
* WHY: The wrapper can spawn new children while it ignores SIGTERM, so the tree is
|
|
428
|
+
* re-snapshotted; the original snapshot still covers descendants of a wrapper that
|
|
429
|
+
* already exited (they were re-parented to init and are no longer discoverable).
|
|
430
|
+
*/
|
|
431
|
+
async function escalateToSigkill(
|
|
432
|
+
rootPid: number,
|
|
433
|
+
descendants: readonly number[],
|
|
434
|
+
d: StdioProcessDeps,
|
|
435
|
+
): Promise<void> {
|
|
436
|
+
const rescanned = await d.listDescendantPids(rootPid);
|
|
437
|
+
const survivors = [...new Set([...rescanned, ...descendants])].filter((candidate) =>
|
|
438
|
+
isPidAlive(candidate, d),
|
|
439
|
+
);
|
|
440
|
+
const targets = [...survivors, rootPid].filter((candidate) => isPidAlive(candidate, d));
|
|
441
|
+
if (targets.length === 0) return;
|
|
442
|
+
await signalAndWaitForExit(targets, "SIGKILL", 200, d);
|
|
172
443
|
}
|
|
173
444
|
|
|
174
445
|
export async function ensurePidTerminated(
|
|
@@ -180,10 +451,12 @@ export async function ensurePidTerminated(
|
|
|
180
451
|
return;
|
|
181
452
|
}
|
|
182
453
|
|
|
183
|
-
|
|
454
|
+
// WHY: Snapshot before signalling any PID: a wrapper that exits first re-parents its
|
|
455
|
+
// children to init, after which they can no longer be found by parent PID.
|
|
456
|
+
const descendants = await d.listDescendantPids(pid);
|
|
457
|
+
if (await signalAndWaitForExit([...descendants, pid], "SIGTERM", 500, d)) {
|
|
184
458
|
return;
|
|
185
459
|
}
|
|
186
460
|
|
|
187
|
-
await
|
|
461
|
+
await escalateToSigkill(pid, descendants, d);
|
|
188
462
|
}
|
|
189
|
-
|