pi-mcp-extension 1.4.1 → 1.5.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
@@ -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.4.1",
3
+ "version": "1.5.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": [
@@ -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
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * MCP OAuth Callback Server
3
+ *
4
+ * Simple HTTP server that handles OAuth callbacks from the authorization server.
5
+ * Uses Node.js http module for compatibility with no external dependencies.
6
+ */
7
+
8
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
9
+ import type { Socket } from "net";
10
+ import { URL } from "url";
11
+
12
+ interface PendingAuth {
13
+ resolve: (code: string) => void;
14
+ reject: (error: Error) => void;
15
+ timeout: ReturnType<typeof setTimeout>;
16
+ }
17
+
18
+ let server: Server | null = null;
19
+ let actualServerPort: number | null = null;
20
+ let actualServerHost: string | null = null;
21
+ const serverSockets = new Set<Socket>();
22
+ const pendingAuths = new Map<string, PendingAuth>();
23
+
24
+ const DEFAULT_PORT = 19876;
25
+ const DEFAULT_HOST = "127.0.0.1";
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
+ }
40
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
41
+
42
+ const HTML_SUCCESS = `<!DOCTYPE html>
43
+ <html><head><meta charset="utf-8"><title>Pi - Authorization Successful</title>
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}
45
+ .container{text-align:center;padding:2rem}h1{color:#4ade80;margin-bottom:1rem}p{color:#aaa}</style>
46
+ </head><body>
47
+ <div class="container"><h1>✓ Authorization Successful</h1><p>You can close this window and return to Pi.</p></div>
48
+ <script>setTimeout(()=>window.close(),2000)</script>
49
+ </body></html>`;
50
+
51
+ const HTML_ERROR = (error: string) => `<!DOCTYPE html>
52
+ <html><head><meta charset="utf-8"><title>Pi - Authorization Failed</title>
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}
54
+ .container{text-align:center;padding:2rem}h1{color:#f87171;margin-bottom:1rem}p{color:#aaa}
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
+ </head><body>
57
+ <div class="container"><h1>✗ Authorization Failed</h1><p>An error occurred during authorization.</p><div class="error">${escapeHtml(error)}</div></div>
58
+ </body></html>`;
59
+
60
+ /**
61
+ * Escape HTML entities to prevent XSS attacks.
62
+ */
63
+ function escapeHtml(text: string): string {
64
+ return text
65
+ .replace(/&/g, "&amp;")
66
+ .replace(/</g, "&lt;")
67
+ .replace(/>/g, "&gt;")
68
+ .replace(/"/g, "&quot;")
69
+ .replace(/'/g, "&#039;");
70
+ }
71
+
72
+ /**
73
+ * Handle incoming HTTP requests to the callback server.
74
+ */
75
+ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
76
+ const url = new URL(req.url || "/", "http://localhost");
77
+
78
+ // Only handle the callback path
79
+ if (url.pathname !== CALLBACK_PATH) {
80
+ res.writeHead(404, { "Content-Type": "text/plain", "Connection": "close" });
81
+ res.end("Not found");
82
+ return;
83
+ }
84
+
85
+ const code = url.searchParams.get("code");
86
+ const state = url.searchParams.get("state");
87
+ const error = url.searchParams.get("error");
88
+ const errorDescription = url.searchParams.get("error_description");
89
+
90
+ // Enforce state parameter presence for CSRF protection
91
+ if (!state) {
92
+ const errorMsg = "Missing required state parameter - potential CSRF attack";
93
+ res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
94
+ res.end(HTML_ERROR(errorMsg));
95
+ return;
96
+ }
97
+
98
+ // Handle OAuth errors
99
+ if (error) {
100
+ const errorMsg = errorDescription || error;
101
+ res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
102
+ res.end(HTML_ERROR(errorMsg));
103
+ if (pendingAuths.has(state)) {
104
+ const pending = pendingAuths.get(state)!;
105
+ clearTimeout(pending.timeout);
106
+ pendingAuths.delete(state);
107
+ setTimeout(() => pending.reject(new Error(errorMsg)), 0);
108
+ }
109
+ return;
110
+ }
111
+
112
+ // Require authorization code
113
+ if (!code) {
114
+ res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
115
+ res.end(HTML_ERROR("No authorization code provided"));
116
+ return;
117
+ }
118
+
119
+ // Validate state parameter
120
+ if (!pendingAuths.has(state)) {
121
+ const errorMsg = "Invalid or expired state parameter - potential CSRF attack";
122
+ res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
123
+ res.end(HTML_ERROR(errorMsg));
124
+ return;
125
+ }
126
+
127
+ const pending = pendingAuths.get(state)!;
128
+
129
+ // Clear timeout and resolve the pending promise
130
+ clearTimeout(pending.timeout);
131
+ pendingAuths.delete(state);
132
+ pending.resolve(code);
133
+
134
+ res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
135
+ res.end(HTML_SUCCESS);
136
+ }
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
+
208
+ /**
209
+ * Ensure the callback server is running.
210
+ * Scans forward for an available local port when port fallback is enabled.
211
+ *
212
+ * @param preferredPort - The preferred port to use (default: 19876)
213
+ * @returns The actual port the server is listening on
214
+ */
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
+
219
+ if (server) {
220
+ const address = server.address();
221
+ if (actualServerPort === null && address && typeof address === "object" && "port" in address) {
222
+ actualServerPort = address.port;
223
+ }
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();
237
+ }
238
+
239
+ const maxAttempts = allowPortFallback ? 25 : 1; // Try up to 25 ports unless a fixed redirect URI is configured
240
+
241
+ for (let offset = 0; offset < maxAttempts; offset++) {
242
+ const candidatePort = preferredPort + offset;
243
+ const candidateServer = createServer(handleRequest);
244
+
245
+ try {
246
+ candidateServer.on("connection", (socket: Socket) => {
247
+ serverSockets.add(socket);
248
+ socket.once("close", () => {
249
+ serverSockets.delete(socket);
250
+ });
251
+ });
252
+
253
+ await new Promise<void>((resolve, reject) => {
254
+ candidateServer.once("error", (err: any) => {
255
+ reject(err);
256
+ });
257
+
258
+ candidateServer.listen(candidatePort, host, () => {
259
+ resolve();
260
+ });
261
+ });
262
+ server = candidateServer;
263
+ actualServerPort = candidatePort;
264
+ actualServerHost = host;
265
+ server.unref(); // Don't block process exit
266
+ return candidatePort;
267
+ } catch (error) {
268
+ const nodeError = error as NodeJS.ErrnoException;
269
+ await new Promise<void>((resolve) => {
270
+ candidateServer.close(() => resolve());
271
+ });
272
+
273
+ // If not EADDRINUSE, rethrow
274
+ if (nodeError.code !== "EADDRINUSE") {
275
+ throw error;
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
+ }
281
+ }
282
+ }
283
+
284
+ throw new Error(
285
+ `OAuth callback port ${preferredPort} is already in use and no free port was found in range ${preferredPort}-${preferredPort + maxAttempts - 1}`
286
+ );
287
+ }
288
+
289
+ /**
290
+ * Wait for a callback with the given OAuth state.
291
+ * Returns a promise that resolves with the authorization code.
292
+ *
293
+ * @param oauthState - The OAuth state parameter to wait for
294
+ * @param timeoutMs - Timeout in milliseconds (default: 5 minutes)
295
+ * @returns Promise that resolves with the authorization code
296
+ */
297
+ export function waitForCallback(
298
+ oauthState: string,
299
+ timeoutMs: number = TIMEOUT_MS
300
+ ): Promise<string> {
301
+ return new Promise((resolve, reject) => {
302
+ const timeout = setTimeout(() => {
303
+ if (pendingAuths.has(oauthState)) {
304
+ pendingAuths.delete(oauthState);
305
+ reject(new Error("OAuth callback timeout - authorization took too long"));
306
+ }
307
+ }, timeoutMs);
308
+
309
+ pendingAuths.set(oauthState, { resolve, reject, timeout });
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Cancel a pending authorization by state.
315
+ *
316
+ * @param oauthState - The OAuth state to cancel
317
+ */
318
+ export function cancelCallback(oauthState: string): void {
319
+ const pending = pendingAuths.get(oauthState);
320
+ if (pending) {
321
+ clearTimeout(pending.timeout);
322
+ pendingAuths.delete(oauthState);
323
+ pending.reject(new Error("Authorization cancelled"));
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Stop the callback server and reject all pending authorizations.
329
+ */
330
+ export async function stopCallbackServer(): Promise<void> {
331
+ await closeCurrentServer();
332
+
333
+ // Reject all pending auths (defer to allow any pending operations to complete)
334
+ const pendingList = Array.from(pendingAuths.entries());
335
+ pendingAuths.clear();
336
+ setTimeout(() => {
337
+ for (const [, pending] of pendingList) {
338
+ clearTimeout(pending.timeout);
339
+ pending.reject(new Error("OAuth callback server stopped"));
340
+ }
341
+ }, 0);
342
+ }
343
+
344
+ /**
345
+ * Check if the callback server is running.
346
+ */
347
+ export function isCallbackServerRunning(): boolean {
348
+ return server !== null;
349
+ }
350
+
351
+ /**
352
+ * Get the number of pending authorizations.
353
+ */
354
+ export function getPendingAuthCount(): number {
355
+ return pendingAuths.size;
356
+ }
357
+
358
+ // Export constants for testing/config
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([