pi-mcp-extension 1.11.2 → 1.11.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 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 safety-net `SIGKILL` for orphaned subprocesses
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()`), and Pi 1.x extension-registered servers (`pi.registerMcpServer()`)
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 for config, connection, protocol, and tool-level failures
30
- - **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))
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 server status.
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.io/mcp", // "transport" defaults to "streamable-http" when "url" is set
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
- 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, config source, 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.
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 │───▶│ server-manager │───▶│ tool-bridge │ │
285
- │ │ (entry) │ │ (lifecycle) │ │ (MCP → Pi) │ │
286
- │ └──────────┘ └───────┬────────┘ └──────┬──────┘ │
287
- │ │ │ │ │
288
- │ ▼ ▼ ▼ │
289
- │ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
290
- │ │ config │ │ MCP SDK │ │ TypeBox │ │
291
- │ │ (Zod) │ │ (transport) │ │ (schema) │ │
292
- │ └──────────┘ └──────────────┘ └──────────────┘ │
293
- └─────────────────────────────────────────────────────────┘
294
- │ │
295
- ▼ ▼
296
- Global/Project ┌──────────────┐
297
- mcp.json files │ MCP Servers │
298
- + Extensions │ (stdio/http) │
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 install
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 tests
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 & management tools** — JSON Schema → TypeBox conversion, name sanitization, image content passthrough, `isError` filtering, activate/deactivate, `mcp_status` / `mcp_connect`, and slash command completions
366
- - **End-to-end integration** — Real mock MCP and OAuth servers over stdio and HTTP with tool discovery and execution
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.11.2",
3
+ "version": "1.11.3",
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": [
@@ -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 {
@@ -827,6 +829,26 @@ export class ServerManager {
827
829
  };
828
830
  }
829
831
 
832
+ /**
833
+ * Terminate the stdio child process tree, then release the child's pipe handles.
834
+ *
835
+ * WHY: `client.close()` waits for the child's `'close'` event, which never fires while a
836
+ * leaked grandchild still holds the inherited stdout/stderr write ends — the SDK then
837
+ * burns its full 2 × 2,000ms escalation budget. Killing the tree first and destroying the
838
+ * pipes afterwards keeps teardown bounded even if a descendant escaped the tree kill.
839
+ */
840
+ private async _terminateStdioTree(
841
+ pid: number | null,
842
+ transport: Transport | undefined,
843
+ ): Promise<void> {
844
+ try {
845
+ await ensurePidTerminated(pid);
846
+ await waitForChildClose(transport);
847
+ } finally {
848
+ destroyStdioStreams(transport);
849
+ }
850
+ }
851
+
830
852
  private async _cleanupFailedConnect(
831
853
  server: ManagedServer,
832
854
  client: Client,
@@ -839,7 +861,7 @@ export class ServerManager {
839
861
  server.childPid = null;
840
862
  await Promise.allSettled([
841
863
  client.close(),
842
- ensurePidTerminated(pid),
864
+ this._terminateStdioTree(pid, transport),
843
865
  ]);
844
866
  // WHY: Secondary safety check to guarantee PID termination even if transport.close()
845
867
  // or child process exit raced during the initial Promise.allSettled.
@@ -1081,13 +1103,14 @@ export class ServerManager {
1081
1103
  server.childPid ??
1082
1104
  (server.transport as unknown as { _process?: { pid?: number } })?._process?.pid ??
1083
1105
  null;
1106
+ const transport = server.transport;
1084
1107
  server.client = null;
1085
1108
  server.transport = undefined;
1086
1109
  server.childPid = null;
1087
1110
 
1088
1111
  await Promise.allSettled([
1089
1112
  client?.close(),
1090
- ensurePidTerminated(pid),
1113
+ this._terminateStdioTree(pid, transport),
1091
1114
  ]);
1092
1115
  await ensurePidTerminated(pid);
1093
1116
  }
@@ -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), and deterministic SIGTERM → bounded wait → SIGKILL cleanup
7
- * with injectable process/timer dependencies.
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
- const earlyProc = (transport as unknown as { _process?: { pid?: number } })._process;
73
- if (earlyProc?.pid) {
74
- onPid(earlyProc.pid);
75
- }
120
+ recordProcess(spawnedProcess());
76
121
  await startPromise;
77
- const proc = (transport as unknown as { _process?: { pid?: number } })._process;
122
+ const proc = spawnedProcess();
78
123
  if (proc?.pid) {
79
- onPid(proc.pid);
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 flushStderr = (): void => {
147
+ const flush = (): void => {
103
148
  splitter.end();
104
149
  };
105
- transport.stderr.on("end", flushStderr);
106
- transport.stderr.on("close", flushStderr);
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
- async function signalAndWaitForExit(
150
- pid: number,
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 true;
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 (!isPidAlive(pid, d)) {
415
+ if (allExited(pids, d)) {
166
416
  return true;
167
417
  }
168
418
  await d.sleep(25);
169
419
  }
170
420
 
171
- return !isPidAlive(pid, d);
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
- if (await signalAndWaitForExit(pid, "SIGTERM", 500, d)) {
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 signalAndWaitForExit(pid, "SIGKILL", 200, d);
461
+ await escalateToSigkill(pid, descendants, d);
188
462
  }
189
-