pi-mcp-extension 1.10.2 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/pi-mcp-extension.svg)](https://www.npmjs.com/package/pi-mcp-extension)
4
4
  [![license](https://img.shields.io/npm/l/pi-mcp-extension.svg)](https://github.com/irahardianto/pi-mcp-extension/blob/main/LICENSE)
5
+ [![CI](https://github.com/irahardianto/pi-mcp-extension/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/irahardianto/pi-mcp-extension/actions/workflows/ci.yml)
6
+ [![Code Coverage](https://qlty.sh/gh/irahardianto/projects/pi-mcp-extension/coverage.svg)](https://qlty.sh/gh/irahardianto/projects/pi-mcp-extension)
7
+ [![Maintainability](https://qlty.sh/gh/irahardianto/projects/pi-mcp-extension/maintainability.svg)](https://qlty.sh/gh/irahardianto/projects/pi-mcp-extension)<br>
5
8
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/irahardianto/pi-mcp-extension/badge)](https://scorecard.dev/viewer/?uri=github.com/irahardianto/pi-mcp-extension)
6
9
  [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/15659/badge)](https://www.bestpractices.dev/projects/15659)
7
10
 
@@ -148,21 +151,37 @@ In project-level `.pi/mcp.json`, `settings` keys carry no defaults — only the
148
151
 
149
152
  ### Per-Server Config
150
153
 
151
- For Pi 1.x compatibility, server entries also accept `type` as an alias for `transport`, `"http"` as an alias for `"streamable-http"`, `oauth` as an alias for `auth`, and `timeout` (in seconds) as an alias for `requestTimeoutMs`.
154
+ For Pi 1.x compatibility, server entries also accept `type` as an alias for `transport`, `"http"` as an alias for `"streamable-http"`, `oauth` as an alias for `auth`, `timeout` (in seconds) as an alias for `requestTimeoutMs`, and `enabled: false` as a request to keep the server out of the eager set (`lifecycle: "lazy"`). Pi's `auth.provider` (`/login` provider token) is **rejected** at load time instead of being silently downgraded to OAuth 2.1 — use `auth` (OAuth) or static `headers`.
152
155
 
153
156
  | Field | Type | Default | Description |
154
157
  |---|---|---|---|
155
158
  | `transport` (or `type`) | `"stdio" \| "streamable-http" \| "http" \| "sse"` | `"stdio"` (`"streamable-http"` when `url` is set without `command`) | Transport protocol (`"http"` is normalized to `"streamable-http"`) |
156
159
  | `command` | `string` | — | Executable to spawn (**required** for `stdio`) |
157
160
  | `args` | `string[]` | `[]` | Arguments for the command |
161
+ | `cwd` | `string` | session working directory | Working directory for a `stdio` server (rejected on `streamable-http` / `sse`). A relative path resolves against the session working directory and a leading `~` expands to your home directory |
158
162
  | `env` | `Record<string, string>` | — | Extra environment variables for the child process (literal values, no `${VAR}` interpolation) |
159
163
  | `url` | `string` | — | Server URL (**required** for `streamable-http` and `sse`) |
160
164
  | `headers` | `Record<string, string>` | — | Static headers sent with `streamable-http` and `sse` requests |
161
165
  | `auth` (or `oauth`) | `object` | — | OAuth 2.1 configuration described below and in [OAUTH.md](./OAUTH.md) (`streamable-http` / `sse` only) |
162
166
  | `lifecycle` | `"eager" \| "lazy"` | `"lazy"` (`"eager"` for `pi.registerMcpServer()` unless `enabled: false`) | `eager` = auto-start on `session_start`, `lazy` = connect on request via `mcp_connect`, `/mcp:start`, or `/mcp:auth` |
167
+ | `enabled` | `boolean` | `true` | Pi 1.3.0 switch: `false` keeps the entry configured but out of the eager set (normalized to `lifecycle: "lazy"`, even when `lifecycle: "eager"` is also set) |
163
168
  | `requestTimeoutMs` (or `timeout`) | `number` | global setting | Per-server timeout override in ms (`timeout` is specified in seconds and multiplied by `1000`) |
164
169
  | `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval in ms for proactive connection health monitoring |
165
170
 
171
+ ### Project Override Entries
172
+
173
+ A trusted project's `.pi/mcp.json` may carry a Pi 1.3.0 **partial override** for a global server: an entry with no `command`, `url`, `type`, or `transport` changes only per-project settings and keeps the rest of the global entry, including credentials the project cannot set itself.
174
+
175
+ ```jsonc
176
+ // ~/.pi/agent/mcp.json
177
+ { "mcpServers": { "internal-tools": { "command": "internal-mcp", "lifecycle": "eager" } } }
178
+
179
+ // <project>/.pi/mcp.json — turn it off for this project only
180
+ { "mcpServers": { "internal-tools": { "enabled": false } } }
181
+ ```
182
+
183
+ An override may set `enabled`, `lifecycle`, and Pi's `exposure` / `toolExposure` (the two Pi-native exposure keys are accepted for Pi compatibility and ignored, because this extension decides tool activation itself). Any other key, or an override for a name no global entry defines, is reported as a config error naming the file and the server.
184
+
166
185
  ### OAuth Config
167
186
 
168
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.
@@ -173,6 +192,7 @@ Configured under `auth` (or `oauth`). Token and client state are persisted per s
173
192
  | `scope` | `string` | discovered | Requested scope when the server does not advertise one in `WWW-Authenticate` |
174
193
  | `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID (skips Dynamic Client Registration) |
175
194
  | `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
195
+ | `clientName` (or `oauth.clientName`) | `string` | `pi-mcp/<server name>` | `client_name` sent with Dynamic Client Registration, for servers that only accept known clients |
176
196
  | `redirectUrl` (or `oauth.callbackUrl` / `oauth.callbackPort`) | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback (`localhost`, `127.0.0.1`, or `::1` with path `/callback` and an explicit non-zero port; `callbackPort` maps to `http://127.0.0.1:<port>/callback`). Scans forward from `19876` when omitted; binds the exact host and port when configured. |
177
197
 
178
198
  ## Commands
@@ -182,13 +202,39 @@ Server-name arguments provide autocomplete suggestions from the configured MCP s
182
202
  | Command | Description |
183
203
  |---|---|
184
204
  | `/mcp` | Show status summary of all configured servers |
185
- | `/mcp <name>` | Show detailed status and stderr log for a specific server |
205
+ | `/mcp <name>` | Show detailed status for one server: config source, transport/lifecycle, tool activation, OAuth credential status, failure hint, and stderr log |
186
206
  | `/mcp:start <name>` | Connect a server and wait for tool discovery |
187
207
  | `/mcp:stop <name>` | Cancel a pending connection, startup prompt, or authorization, or stop a running server and deactivate its tools |
188
208
  | `/mcp:auth <name>` | Connect with stored OAuth credentials and client registration; request browser authorization only if needed |
189
209
  | `/mcp:auth <name> --reset` | Discard stored tokens and client registration (re-running Dynamic Client Registration) and request fresh authorization |
190
210
 
191
- Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and connect one named server without routine slash commands. `mcp_status` reports lifecycle, connection state, discovered and active tool counts, credential presence, and safe action hints without returning tokens, headers, or authorization URLs. `mcp_connect` activates discovered tools before returning success. Connection failures return fixed actionable categories instead of raw server response text.
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.
212
+
213
+ ### Inspecting one server (`/mcp <name>`)
214
+
215
+ `/mcp <name>` answers "why is this server in this state?" without leaving Pi:
216
+
217
+ ```text
218
+ Server: supabase
219
+ State: stopped
220
+ Retries: 5
221
+ Transport: streamable-http
222
+ Lifecycle: lazy
223
+ Source: global
224
+ Tools: 0/0 active
225
+ OAuth: credentials stored
226
+ Last error: Connection timed out after 30000ms
227
+ Hint: run with PI_MCP_DEBUG=1 pi to log redacted diagnostics, or report server issues at https://github.com/irahardianto/pi-mcp-extension/issues
228
+
229
+ Recent output:
230
+ (no stderr output from supabase)
231
+ ```
232
+
233
+ - `Source` is the entry that won: `global` (`<agent dir>/mcp.json`), `project` (`<cwd>/.pi/mcp.json`), or `extension` (`pi.registerMcpServer()`). `mcp_status` reports the same value as `source` on each row.
234
+ - When the project is untrusted, `Source` never says `project` and a `Project config: not loaded (<path>)` line explains why `mcp.json` in the workspace is not in effect.
235
+ - `Tools` compares discovered tools with the ones Pi has active; a server can be `ready` with `0/N active` when another extension restricts tools.
236
+ - `OAuth` appears only for servers with an `auth` config: `credentials stored`, `authorization may be required`, or `corrupt state file backed up (re-auth required)` (the corrupt file is backed up and the view still renders).
237
+ - A failed server adds one `Hint:` line: `/mcp:auth <name>` (plus `--reset` when stored credentials expired) for a missing/expired authorization, waiting for another Pi process holding the auth lock, or `PI_MCP_DEBUG=1 pi` plus the issue tracker for a connection failure.
192
238
 
193
239
  ### OAuth startup, headless/SSH, and concurrency behavior
194
240
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.10.2",
3
+ "version": "1.11.1",
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": [
@@ -33,7 +33,7 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@modelcontextprotocol/sdk": "^1.32.1",
36
+ "@modelcontextprotocol/sdk": "^1.32.2",
37
37
  "zod": "^4.6.7"
38
38
  },
39
39
  "peerDependencies": {
@@ -41,20 +41,23 @@
41
41
  "@earendil-works/pi-coding-agent": "*",
42
42
  "typebox": "*"
43
43
  },
44
+ "overrides": {
45
+ "undici": "^8.12.3"
46
+ },
44
47
  "devDependencies": {
45
48
  "@eslint/js": "^10.0.1",
46
49
  "@types/node": "^26.6.5",
47
50
  "eslint": "^10.13.0",
48
51
  "fast-check": "^4.10.2",
49
52
  "tsx": "^4.19.4",
50
- "typescript": "^5.7.0",
51
- "typescript-eslint": "^8.71.2"
53
+ "typescript": "^6.0.3",
54
+ "typescript-eslint": "^8.72.0"
52
55
  },
53
56
  "scripts": {
54
57
  "lint": "eslint .",
55
58
  "typecheck": "tsc --noEmit",
56
59
  "test": "node --import tsx/esm --test ./tests/*.test.ts",
57
- "test:coverage": "node --import tsx/esm --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=95 --test-coverage-branches=85 --test-coverage-functions=83 ./tests/*.test.ts",
60
+ "test:coverage": "node --import tsx/esm --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info --test-coverage-include='src/**' --test-coverage-lines=95 --test-coverage-branches=85 --test-coverage-functions=83 ./tests/*.test.ts",
58
61
  "test:integration": "node --import tsx/esm --test ./tests/server-manager.test.ts"
59
62
  }
60
63
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Pure lock-record and staleness policy for pi-mcp's cross-process OAuth
3
+ * authorization lock. No filesystem, clock, or process access.
4
+ */
5
+
6
+ import { z } from "zod";
7
+
8
+ // ─── Constants & Schema ───────────────────────────────────────────────────────
9
+
10
+ export const HEARTBEAT_MS = 30_000;
11
+ export const STALE_MS = 90_000; // 3 × 30s heartbeat
12
+
13
+ export const LockRecordSchema = z.object({
14
+ v: z.literal(1),
15
+ pid: z.number().int().positive(),
16
+ hostname: z.string().min(1),
17
+ nonce: z.string().min(1),
18
+ createdAt: z.number(),
19
+ });
20
+
21
+ export type LockRecord = z.output<typeof LockRecordSchema>;
22
+
23
+ // ─── Pure Staleness Policy ────────────────────────────────────────────────────
24
+
25
+ export function parseLockRecord(raw: string): LockRecord | null {
26
+ if (!raw) return null;
27
+ try {
28
+ const parsed: unknown = JSON.parse(raw);
29
+ const result = LockRecordSchema.safeParse(parsed);
30
+ return result.success ? result.data : null;
31
+ } catch {
32
+ // WHY: JSON.parse on a string throws SyntaxError if the lock file is corrupt or
33
+ // partially written. LockRecordSchema.safeParse does not throw. Unparseable lock
34
+ // files map to null so isLockStale() can fall back to mtime-based staleness.
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Pure policy determining whether an existing lock file is stale and safe to reclaim.
41
+ */
42
+ export function isLockStale(
43
+ record: LockRecord | null,
44
+ mtimeMs: number,
45
+ now: number,
46
+ self: { hostname: string; isPidAlive: (pid: number) => boolean },
47
+ ): boolean {
48
+ if (now - mtimeMs > STALE_MS) {
49
+ return true;
50
+ }
51
+ if (record === null) {
52
+ return false;
53
+ }
54
+ if (record.hostname === self.hostname && !self.isPidAlive(record.pid)) {
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ export function formatHolderDetails(record: LockRecord | null): string | undefined {
61
+ if (!record) return undefined;
62
+ return `pid ${record.pid} on ${record.hostname}`;
63
+ }