pi-mcp-extension 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@
21
21
  - **Global + project config** — Layered config (project overrides global) with per-server tuning
22
22
  - **Tool annotations** — `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` surfaced in tool descriptions
23
23
  - **Structured error handling** — Distinct error codes for config, connection, protocol, and tool-level failures
24
+ - **OAuth 2.1** — Browser authorization with PKCE, protected-resource discovery, token refresh, and secure local persistence
24
25
 
25
26
  ## Installation
26
27
 
@@ -80,6 +81,9 @@ Config files are loaded from two locations. **Project config overrides global co
80
81
  "supabase": {
81
82
  "transport": "streamable-http",
82
83
  "url": "https://mcp.supabase.com/mcp",
84
+ "auth": {
85
+ "type": "oauth"
86
+ },
83
87
  "lifecycle": "eager"
84
88
  },
85
89
  "deepsource": {
@@ -137,18 +141,34 @@ Config files are loaded from two locations. **Project config overrides global co
137
141
  | `args` | `string[]` | `[]` | Arguments for the command |
138
142
  | `env` | `Record<string, string>` | — | Extra environment variables for the child process |
139
143
  | `url` | `string` | — | Server URL (**required** for streamable-http/sse) |
140
- | `lifecycle` | `"eager" \| "lazy"` | `"lazy"` | `eager` = auto-start on session start, `lazy` = manual via `/mcp:start` |
144
+ | `headers` | `Record<string, string>` | — | Static headers sent with HTTP and SSE requests |
145
+ | `auth` | `object` | — | OAuth configuration described below |
146
+ | `lifecycle` | `"eager" \| "lazy"` | `"lazy"` | `eager` = auto-start on session start, `lazy` = connect on request via `/mcp:start` |
141
147
  | `requestTimeoutMs` | `number` | global setting | Per-server timeout override |
142
148
  | `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval for connection health monitoring |
143
149
 
150
+ ### OAuth Config
151
+
152
+ | Field | Type | Default | Description |
153
+ |---|---|---|---|
154
+ | `type` | `"oauth"` | `"oauth"` | Authentication type |
155
+ | `scope` | `string` | discovered | Requested scope when the server does not advertise one |
156
+ | `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID |
157
+ | `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
158
+ | `redirectUrl` | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback used by `/mcp:auth` |
159
+
144
160
  ## Commands
145
161
 
146
162
  | Command | Description |
147
163
  |---|---|
148
164
  | `/mcp` | Show status summary of all configured servers |
149
165
  | `/mcp <name>` | Show detailed status and stderr log for a specific server |
150
- | `/mcp:start <name>` | Start a server (resets retry count) |
151
- | `/mcp:stop <name>` | Stop a running server and deactivate its tools |
166
+ | `/mcp:start <name>` | Connect a server and wait for tool discovery |
167
+ | `/mcp:stop <name>` | Cancel a pending connection or authorization, or stop a running server and deactivate its tools |
168
+ | `/mcp:auth <name>` | Connect with stored OAuth credentials; request browser authorization only if needed |
169
+ | `/mcp:auth <name> --reset` | Discard stored OAuth credentials and request fresh authorization |
170
+
171
+ Repeated credential resets share one pending authorization; stop or shutdown cancels it. Lazy servers remain stopped until requested. An interactive Pi session is required when browser authorization is needed. Eager startup never opens a browser. Retry opens the browser again only when selected in the OAuth prompt.
152
172
 
153
173
  ## How It Works
154
174
 
@@ -176,7 +196,7 @@ Config files are loaded from two locations. **Project config overrides global co
176
196
  ```
177
197
 
178
198
  1. **Config is loaded** from global and project files (project overrides global by server name)
179
- 2. **Eager servers connect** at session start; lazy servers wait for `/mcp:start`
199
+ 2. **Eager servers connect** at session start without opening browsers; lazy servers wait for `/mcp:start`
180
200
  3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages)
181
201
  4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
182
202
  5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix)
@@ -217,7 +237,7 @@ npm install
217
237
  # Type check (strict mode)
218
238
  npm run typecheck
219
239
 
220
- # Run all tests (47 tests)
240
+ # Run all tests
221
241
  npm test
222
242
 
223
243
  # Run integration tests only (real stdio server)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -0,0 +1,60 @@
1
+ /**
2
+ * OAuth challenge discovery helpers for manual MCP authentication commands.
3
+ */
4
+
5
+ import { extractWWWAuthenticateParams } from "@modelcontextprotocol/sdk/client/auth.js";
6
+
7
+ export interface ManualAuthChallenge {
8
+ resourceMetadataUrl?: URL | undefined;
9
+ scope?: string | undefined;
10
+ }
11
+
12
+ export type AuthChallengeFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
13
+
14
+ export const AUTH_CHALLENGE_TIMEOUT_MS = 10_000;
15
+
16
+ export interface ManualAuthChallengeOptions {
17
+ fetchFn?: AuthChallengeFetch | undefined;
18
+ timeoutMs?: number | undefined;
19
+ headers?: Record<string, string> | undefined;
20
+ }
21
+
22
+ /**
23
+ * Discover OAuth challenge parameters from an MCP resource server.
24
+ *
25
+ * Some resource servers expose the protected resource metadata URL only in the
26
+ * WWW-Authenticate header of the initial 401 response. Manual auth must carry
27
+ * those values into the SDK auth flow so discovery reaches the real
28
+ * authorization server instead of falling back to the resource server origin.
29
+ */
30
+ export async function discoverManualAuthChallenge(
31
+ serverUrl: string,
32
+ options: ManualAuthChallengeOptions = {},
33
+ ): Promise<ManualAuthChallenge> {
34
+ const fetchFn = options.fetchFn ?? fetch;
35
+ const headers = new Headers(options.headers);
36
+ headers.set("Accept", "application/json, text/event-stream");
37
+ const response = await fetchFn(serverUrl, {
38
+ method: "GET",
39
+ headers,
40
+ signal: AbortSignal.timeout(options.timeoutMs ?? AUTH_CHALLENGE_TIMEOUT_MS),
41
+ });
42
+
43
+ try {
44
+ if (response.status !== 401 && response.status !== 403) {
45
+ return {};
46
+ }
47
+
48
+ const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
49
+ if (response.status === 403 && error !== "insufficient_scope") {
50
+ return {};
51
+ }
52
+
53
+ return {
54
+ ...(resourceMetadataUrl ? { resourceMetadataUrl } : {}),
55
+ ...(scope ? { scope } : {}),
56
+ };
57
+ } finally {
58
+ await response.body?.cancel().catch(() => {});
59
+ }
60
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
9
+ import type { Socket } from "net";
9
10
  import { URL } from "url";
10
11
 
11
12
  interface PendingAuth {
@@ -16,14 +17,30 @@ interface PendingAuth {
16
17
 
17
18
  let server: Server | null = null;
18
19
  let actualServerPort: number | null = null;
20
+ let actualServerHost: string | null = null;
21
+ const serverSockets = new Set<Socket>();
19
22
  const pendingAuths = new Map<string, PendingAuth>();
20
23
 
21
24
  const DEFAULT_PORT = 19876;
25
+ const DEFAULT_HOST = "127.0.0.1";
22
26
  const CALLBACK_PATH = "/callback";
27
+
28
+ export interface CallbackServerOptions {
29
+ /** Host to bind. Defaults to 127.0.0.1 for local-only callbacks. */
30
+ host?: string | undefined;
31
+ /** Whether to scan following ports when the preferred port is busy. */
32
+ allowPortFallback?: boolean | undefined;
33
+ }
34
+
35
+ export interface CallbackServerConfig {
36
+ preferredPort: number;
37
+ host: string;
38
+ allowPortFallback: boolean;
39
+ }
23
40
  const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
24
41
 
25
42
  const HTML_SUCCESS = `<!DOCTYPE html>
26
- <html><head><title>Pi - Authorization Successful</title>
43
+ <html><head><meta charset="utf-8"><title>Pi - Authorization Successful</title>
27
44
  <style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
28
45
  .container{text-align:center;padding:2rem}h1{color:#4ade80;margin-bottom:1rem}p{color:#aaa}</style>
29
46
  </head><body>
@@ -32,7 +49,7 @@ const HTML_SUCCESS = `<!DOCTYPE html>
32
49
  </body></html>`;
33
50
 
34
51
  const HTML_ERROR = (error: string) => `<!DOCTYPE html>
35
- <html><head><title>Pi - Authorization Failed</title>
52
+ <html><head><meta charset="utf-8"><title>Pi - Authorization Failed</title>
36
53
  <style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
37
54
  .container{text-align:center;padding:2rem}h1{color:#f87171;margin-bottom:1rem}p{color:#aaa}
38
55
  .error{color:#fca5a5;font-family:monospace;margin-top:1rem;padding:1rem;background:rgba(248,113,113,0.1);border-radius:.5rem;white-space:pre-wrap;word-break:break-word}</style>
@@ -56,11 +73,11 @@ function escapeHtml(text: string): string {
56
73
  * Handle incoming HTTP requests to the callback server.
57
74
  */
58
75
  function handleRequest(req: IncomingMessage, res: ServerResponse): void {
59
- const url = new URL(req.url || "/", `http://${req.headers.host}`);
76
+ const url = new URL(req.url || "/", "http://localhost");
60
77
 
61
78
  // Only handle the callback path
62
79
  if (url.pathname !== CALLBACK_PATH) {
63
- res.writeHead(404, { "Content-Type": "text/plain" });
80
+ res.writeHead(404, { "Content-Type": "text/plain", "Connection": "close" });
64
81
  res.end("Not found");
65
82
  return;
66
83
  }
@@ -73,7 +90,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
73
90
  // Enforce state parameter presence for CSRF protection
74
91
  if (!state) {
75
92
  const errorMsg = "Missing required state parameter - potential CSRF attack";
76
- res.writeHead(400, { "Content-Type": "text/html" });
93
+ res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
77
94
  res.end(HTML_ERROR(errorMsg));
78
95
  return;
79
96
  }
@@ -81,7 +98,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
81
98
  // Handle OAuth errors
82
99
  if (error) {
83
100
  const errorMsg = errorDescription || error;
84
- res.writeHead(200, { "Content-Type": "text/html" });
101
+ res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
85
102
  res.end(HTML_ERROR(errorMsg));
86
103
  if (pendingAuths.has(state)) {
87
104
  const pending = pendingAuths.get(state)!;
@@ -94,7 +111,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
94
111
 
95
112
  // Require authorization code
96
113
  if (!code) {
97
- res.writeHead(400, { "Content-Type": "text/html" });
114
+ res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
98
115
  res.end(HTML_ERROR("No authorization code provided"));
99
116
  return;
100
117
  }
@@ -102,7 +119,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
102
119
  // Validate state parameter
103
120
  if (!pendingAuths.has(state)) {
104
121
  const errorMsg = "Invalid or expired state parameter - potential CSRF attack";
105
- res.writeHead(400, { "Content-Type": "text/html" });
122
+ res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
106
123
  res.end(HTML_ERROR(errorMsg));
107
124
  return;
108
125
  }
@@ -114,54 +131,137 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
114
131
  pendingAuths.delete(state);
115
132
  pending.resolve(code);
116
133
 
117
- res.writeHead(200, { "Content-Type": "text/html" });
134
+ res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
118
135
  res.end(HTML_SUCCESS);
119
136
  }
120
137
 
138
+ function isLocalCallbackHost(hostname: string): boolean {
139
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
140
+ }
141
+
142
+ async function closeCurrentServer(): Promise<void> {
143
+ if (!server) return;
144
+
145
+ const currentServer = server;
146
+ server = null;
147
+ actualServerPort = null;
148
+ actualServerHost = null;
149
+
150
+ for (const socket of Array.from(serverSockets)) {
151
+ socket.destroy();
152
+ }
153
+ serverSockets.clear();
154
+
155
+ await new Promise<void>((resolve) => {
156
+ currentServer.close(() => {
157
+ resolve();
158
+ });
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Build callback server settings from the configured OAuth redirect URL.
164
+ *
165
+ * Local HTTP redirect URLs must use a fixed listener port because OAuth
166
+ * providers require exact redirect URI registration. When no local redirect URL
167
+ * is configured, the callback server keeps the historical default behavior and
168
+ * scans forward from the default port if needed.
169
+ */
170
+ export function callbackServerConfigFromRedirectUrl(redirectUrl?: string): CallbackServerConfig {
171
+ if (!redirectUrl) {
172
+ return {
173
+ preferredPort: DEFAULT_PORT,
174
+ host: DEFAULT_HOST,
175
+ allowPortFallback: true,
176
+ };
177
+ }
178
+
179
+ const url = new URL(redirectUrl);
180
+ if (url.protocol !== "http:" || !isLocalCallbackHost(url.hostname)) {
181
+ throw new Error("Manual OAuth redirect URL must use HTTP with localhost, 127.0.0.1, or ::1");
182
+ }
183
+
184
+ if (url.hash) {
185
+ throw new Error("Manual OAuth redirect URL must not include a fragment");
186
+ }
187
+
188
+ if (url.username || url.password) {
189
+ throw new Error("Manual OAuth redirect URL must not include credentials");
190
+ }
191
+
192
+ if (url.pathname !== CALLBACK_PATH) {
193
+ throw new Error(`Local OAuth redirect URL must use path ${CALLBACK_PATH}`);
194
+ }
195
+
196
+ const preferredPort = Number(url.port || "80");
197
+ if (preferredPort === 0) {
198
+ throw new Error("Manual OAuth redirect URL must use a fixed non-zero port");
199
+ }
200
+
201
+ return {
202
+ preferredPort,
203
+ host: url.hostname === "[::1]" ? "::1" : url.hostname,
204
+ allowPortFallback: false,
205
+ };
206
+ }
207
+
121
208
  /**
122
209
  * Ensure the callback server is running.
123
- * Scans forward for an available local port if the preferred port is busy.
210
+ * Scans forward for an available local port when port fallback is enabled.
124
211
  *
125
212
  * @param preferredPort - The preferred port to use (default: 19876)
126
213
  * @returns The actual port the server is listening on
127
214
  */
128
- export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT): Promise<number> {
215
+ export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT, options: CallbackServerOptions = {}): Promise<number> {
216
+ const host = options.host ?? DEFAULT_HOST;
217
+ const allowPortFallback = options.allowPortFallback ?? true;
218
+
129
219
  if (server) {
130
- // Already running, return the tracked actual port
131
- if (actualServerPort !== null) {
132
- return actualServerPort;
133
- }
134
- // Fallback: try to get the port from the server
135
220
  const address = server.address();
136
- if (address && typeof address === "object" && "port" in address) {
221
+ if (actualServerPort === null && address && typeof address === "object" && "port" in address) {
137
222
  actualServerPort = address.port;
138
- return address.port;
139
223
  }
140
- // If we still can't determine the port, return the preferred port
141
- // (this shouldn't happen in practice)
142
- return preferredPort;
224
+
225
+ const serverMatchesRequest = actualServerPort === preferredPort && actualServerHost === host;
226
+ if (serverMatchesRequest && actualServerPort !== null) {
227
+ return actualServerPort;
228
+ }
229
+
230
+ if (pendingAuths.size > 0) {
231
+ throw new Error(
232
+ `OAuth callback server is already handling an authorization flow on ${actualServerHost}:${actualServerPort}`
233
+ );
234
+ }
235
+
236
+ await closeCurrentServer();
143
237
  }
144
238
 
145
- const maxAttempts = 25; // Try up to 25 ports
239
+ const maxAttempts = allowPortFallback ? 25 : 1; // Try up to 25 ports unless a fixed redirect URI is configured
146
240
 
147
241
  for (let offset = 0; offset < maxAttempts; offset++) {
148
242
  const candidatePort = preferredPort + offset;
149
243
  const candidateServer = createServer(handleRequest);
150
244
 
151
245
  try {
246
+ candidateServer.on("connection", (socket: Socket) => {
247
+ serverSockets.add(socket);
248
+ socket.once("close", () => {
249
+ serverSockets.delete(socket);
250
+ });
251
+ });
252
+
152
253
  await new Promise<void>((resolve, reject) => {
153
254
  candidateServer.once("error", (err: any) => {
154
255
  reject(err);
155
256
  });
156
257
 
157
- // Bind to 127.0.0.1 explicitly (IPv4) to avoid issues with IPv6
158
- candidateServer.listen(candidatePort, "127.0.0.1", () => {
258
+ candidateServer.listen(candidatePort, host, () => {
159
259
  resolve();
160
260
  });
161
261
  });
162
-
163
262
  server = candidateServer;
164
263
  actualServerPort = candidatePort;
264
+ actualServerHost = host;
165
265
  server.unref(); // Don't block process exit
166
266
  return candidatePort;
167
267
  } catch (error) {
@@ -174,6 +274,10 @@ export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT)
174
274
  if (nodeError.code !== "EADDRINUSE") {
175
275
  throw error;
176
276
  }
277
+
278
+ if (!allowPortFallback) {
279
+ throw new Error(`OAuth callback port ${preferredPort} is already in use, and redirect URL requires this exact port`);
280
+ }
177
281
  }
178
282
  }
179
283
 
@@ -224,15 +328,7 @@ export function cancelCallback(oauthState: string): void {
224
328
  * Stop the callback server and reject all pending authorizations.
225
329
  */
226
330
  export async function stopCallbackServer(): Promise<void> {
227
- if (server) {
228
- await new Promise<void>((resolve) => {
229
- server!.close(() => {
230
- resolve();
231
- });
232
- });
233
- server = null;
234
- actualServerPort = null;
235
- }
331
+ await closeCurrentServer();
236
332
 
237
333
  // Reject all pending auths (defer to allow any pending operations to complete)
238
334
  const pendingList = Array.from(pendingAuths.entries());
@@ -260,4 +356,4 @@ export function getPendingAuthCount(): number {
260
356
  }
261
357
 
262
358
  // Export constants for testing/config
263
- export { DEFAULT_PORT, CALLBACK_PATH, TIMEOUT_MS };
359
+ export { DEFAULT_PORT, DEFAULT_HOST, CALLBACK_PATH, TIMEOUT_MS };
package/src/config.ts CHANGED
@@ -170,8 +170,7 @@ function mergeConfigs(
170
170
  * Project config takes precedence over global config.
171
171
  * Returns a fully validated, merged config.
172
172
  */
173
- export async function loadConfig(cwd: string): Promise<McpConfig> {
174
- const globalPath = join(homedir(), ".pi", "agent", "mcp.json");
173
+ export async function loadConfig(cwd: string, globalPath = join(homedir(), ".pi", "agent", "mcp.json")): Promise<McpConfig> {
175
174
  const projectPath = join(cwd, ".pi", "mcp.json");
176
175
 
177
176
  const [globalRaw, projectRaw] = await Promise.all([