pi-mcp-extension 1.4.1 → 1.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
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,263 @@
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 { URL } from "url";
10
+
11
+ interface PendingAuth {
12
+ resolve: (code: string) => void;
13
+ reject: (error: Error) => void;
14
+ timeout: ReturnType<typeof setTimeout>;
15
+ }
16
+
17
+ let server: Server | null = null;
18
+ let actualServerPort: number | null = null;
19
+ const pendingAuths = new Map<string, PendingAuth>();
20
+
21
+ const DEFAULT_PORT = 19876;
22
+ const CALLBACK_PATH = "/callback";
23
+ const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
24
+
25
+ const HTML_SUCCESS = `<!DOCTYPE html>
26
+ <html><head><title>Pi - Authorization Successful</title>
27
+ <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
+ .container{text-align:center;padding:2rem}h1{color:#4ade80;margin-bottom:1rem}p{color:#aaa}</style>
29
+ </head><body>
30
+ <div class="container"><h1>✓ Authorization Successful</h1><p>You can close this window and return to Pi.</p></div>
31
+ <script>setTimeout(()=>window.close(),2000)</script>
32
+ </body></html>`;
33
+
34
+ const HTML_ERROR = (error: string) => `<!DOCTYPE html>
35
+ <html><head><title>Pi - Authorization Failed</title>
36
+ <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
+ .container{text-align:center;padding:2rem}h1{color:#f87171;margin-bottom:1rem}p{color:#aaa}
38
+ .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>
39
+ </head><body>
40
+ <div class="container"><h1>✗ Authorization Failed</h1><p>An error occurred during authorization.</p><div class="error">${escapeHtml(error)}</div></div>
41
+ </body></html>`;
42
+
43
+ /**
44
+ * Escape HTML entities to prevent XSS attacks.
45
+ */
46
+ function escapeHtml(text: string): string {
47
+ return text
48
+ .replace(/&/g, "&amp;")
49
+ .replace(/</g, "&lt;")
50
+ .replace(/>/g, "&gt;")
51
+ .replace(/"/g, "&quot;")
52
+ .replace(/'/g, "&#039;");
53
+ }
54
+
55
+ /**
56
+ * Handle incoming HTTP requests to the callback server.
57
+ */
58
+ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
59
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
60
+
61
+ // Only handle the callback path
62
+ if (url.pathname !== CALLBACK_PATH) {
63
+ res.writeHead(404, { "Content-Type": "text/plain" });
64
+ res.end("Not found");
65
+ return;
66
+ }
67
+
68
+ const code = url.searchParams.get("code");
69
+ const state = url.searchParams.get("state");
70
+ const error = url.searchParams.get("error");
71
+ const errorDescription = url.searchParams.get("error_description");
72
+
73
+ // Enforce state parameter presence for CSRF protection
74
+ if (!state) {
75
+ const errorMsg = "Missing required state parameter - potential CSRF attack";
76
+ res.writeHead(400, { "Content-Type": "text/html" });
77
+ res.end(HTML_ERROR(errorMsg));
78
+ return;
79
+ }
80
+
81
+ // Handle OAuth errors
82
+ if (error) {
83
+ const errorMsg = errorDescription || error;
84
+ res.writeHead(200, { "Content-Type": "text/html" });
85
+ res.end(HTML_ERROR(errorMsg));
86
+ if (pendingAuths.has(state)) {
87
+ const pending = pendingAuths.get(state)!;
88
+ clearTimeout(pending.timeout);
89
+ pendingAuths.delete(state);
90
+ setTimeout(() => pending.reject(new Error(errorMsg)), 0);
91
+ }
92
+ return;
93
+ }
94
+
95
+ // Require authorization code
96
+ if (!code) {
97
+ res.writeHead(400, { "Content-Type": "text/html" });
98
+ res.end(HTML_ERROR("No authorization code provided"));
99
+ return;
100
+ }
101
+
102
+ // Validate state parameter
103
+ if (!pendingAuths.has(state)) {
104
+ const errorMsg = "Invalid or expired state parameter - potential CSRF attack";
105
+ res.writeHead(400, { "Content-Type": "text/html" });
106
+ res.end(HTML_ERROR(errorMsg));
107
+ return;
108
+ }
109
+
110
+ const pending = pendingAuths.get(state)!;
111
+
112
+ // Clear timeout and resolve the pending promise
113
+ clearTimeout(pending.timeout);
114
+ pendingAuths.delete(state);
115
+ pending.resolve(code);
116
+
117
+ res.writeHead(200, { "Content-Type": "text/html" });
118
+ res.end(HTML_SUCCESS);
119
+ }
120
+
121
+ /**
122
+ * Ensure the callback server is running.
123
+ * Scans forward for an available local port if the preferred port is busy.
124
+ *
125
+ * @param preferredPort - The preferred port to use (default: 19876)
126
+ * @returns The actual port the server is listening on
127
+ */
128
+ export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT): Promise<number> {
129
+ 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
+ const address = server.address();
136
+ if (address && typeof address === "object" && "port" in address) {
137
+ actualServerPort = address.port;
138
+ return address.port;
139
+ }
140
+ // If we still can't determine the port, return the preferred port
141
+ // (this shouldn't happen in practice)
142
+ return preferredPort;
143
+ }
144
+
145
+ const maxAttempts = 25; // Try up to 25 ports
146
+
147
+ for (let offset = 0; offset < maxAttempts; offset++) {
148
+ const candidatePort = preferredPort + offset;
149
+ const candidateServer = createServer(handleRequest);
150
+
151
+ try {
152
+ await new Promise<void>((resolve, reject) => {
153
+ candidateServer.once("error", (err: any) => {
154
+ reject(err);
155
+ });
156
+
157
+ // Bind to 127.0.0.1 explicitly (IPv4) to avoid issues with IPv6
158
+ candidateServer.listen(candidatePort, "127.0.0.1", () => {
159
+ resolve();
160
+ });
161
+ });
162
+
163
+ server = candidateServer;
164
+ actualServerPort = candidatePort;
165
+ server.unref(); // Don't block process exit
166
+ return candidatePort;
167
+ } catch (error) {
168
+ const nodeError = error as NodeJS.ErrnoException;
169
+ await new Promise<void>((resolve) => {
170
+ candidateServer.close(() => resolve());
171
+ });
172
+
173
+ // If not EADDRINUSE, rethrow
174
+ if (nodeError.code !== "EADDRINUSE") {
175
+ throw error;
176
+ }
177
+ }
178
+ }
179
+
180
+ throw new Error(
181
+ `OAuth callback port ${preferredPort} is already in use and no free port was found in range ${preferredPort}-${preferredPort + maxAttempts - 1}`
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Wait for a callback with the given OAuth state.
187
+ * Returns a promise that resolves with the authorization code.
188
+ *
189
+ * @param oauthState - The OAuth state parameter to wait for
190
+ * @param timeoutMs - Timeout in milliseconds (default: 5 minutes)
191
+ * @returns Promise that resolves with the authorization code
192
+ */
193
+ export function waitForCallback(
194
+ oauthState: string,
195
+ timeoutMs: number = TIMEOUT_MS
196
+ ): Promise<string> {
197
+ return new Promise((resolve, reject) => {
198
+ const timeout = setTimeout(() => {
199
+ if (pendingAuths.has(oauthState)) {
200
+ pendingAuths.delete(oauthState);
201
+ reject(new Error("OAuth callback timeout - authorization took too long"));
202
+ }
203
+ }, timeoutMs);
204
+
205
+ pendingAuths.set(oauthState, { resolve, reject, timeout });
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Cancel a pending authorization by state.
211
+ *
212
+ * @param oauthState - The OAuth state to cancel
213
+ */
214
+ export function cancelCallback(oauthState: string): void {
215
+ const pending = pendingAuths.get(oauthState);
216
+ if (pending) {
217
+ clearTimeout(pending.timeout);
218
+ pendingAuths.delete(oauthState);
219
+ pending.reject(new Error("Authorization cancelled"));
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Stop the callback server and reject all pending authorizations.
225
+ */
226
+ 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
+ }
236
+
237
+ // Reject all pending auths (defer to allow any pending operations to complete)
238
+ const pendingList = Array.from(pendingAuths.entries());
239
+ pendingAuths.clear();
240
+ setTimeout(() => {
241
+ for (const [, pending] of pendingList) {
242
+ clearTimeout(pending.timeout);
243
+ pending.reject(new Error("OAuth callback server stopped"));
244
+ }
245
+ }, 0);
246
+ }
247
+
248
+ /**
249
+ * Check if the callback server is running.
250
+ */
251
+ export function isCallbackServerRunning(): boolean {
252
+ return server !== null;
253
+ }
254
+
255
+ /**
256
+ * Get the number of pending authorizations.
257
+ */
258
+ export function getPendingAuthCount(): number {
259
+ return pendingAuths.size;
260
+ }
261
+
262
+ // Export constants for testing/config
263
+ export { DEFAULT_PORT, CALLBACK_PATH, TIMEOUT_MS };
package/src/index.ts CHANGED
@@ -14,9 +14,17 @@ import type { TransportAuthCallbacks } from "./server-manager.js";
14
14
  import { ToolBridge } from "./tool-bridge.js";
15
15
  import { McpError } from "./errors.js";
16
16
  import { exec } from "node:child_process";
17
- import { promisify } from "node:util";
18
17
 
19
- const execAsync = promisify(exec);
18
+ // OAuth imports
19
+ import {
20
+ ensureCallbackServer,
21
+ waitForCallback,
22
+ cancelCallback,
23
+ stopCallbackServer,
24
+ } from "./callback-server.js";
25
+ import { setCallbackPort, McpOAuthProvider } from "./oauth-provider.js";
26
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
27
+ import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
20
28
 
21
29
  /**
22
30
  * Open a URL in the user's default browser.
@@ -28,8 +36,15 @@ function openBrowser(url: string): void {
28
36
  : process.platform === "win32"
29
37
  ? `start "" "${url}"`
30
38
  : `xdg-open "${url}"`;
31
- exec(cmd, (err) => {
32
- if (err) console.error(`[pi-mcp] Failed to open browser: ${err.message}`);
39
+
40
+ exec(cmd, (err, stdout, stderr) => {
41
+ if (err) {
42
+ const errorMsg = `[pi-mcp] Failed to open browser: ${err.message}`;
43
+ console.error(errorMsg);
44
+ if (stderr) {
45
+ console.error(`[pi-mcp] Browser error output: ${stderr}`);
46
+ }
47
+ }
33
48
  });
34
49
  }
35
50
 
@@ -116,6 +131,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
116
131
  });
117
132
 
118
133
  pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
134
+ // Stop the callback server
135
+ await stopCallbackServer().catch(() => {});
136
+
119
137
  // Deactivate all tools before shutting down servers
120
138
  for (const server of manager.getAllServers()) {
121
139
  bridge.deactivateServer(server.name);
@@ -245,33 +263,108 @@ export default async function (pi: ExtensionAPI): Promise<void> {
245
263
  return;
246
264
  }
247
265
 
266
+ const config = server.config;
267
+ let oauthState: string | undefined;
268
+
248
269
  try {
249
270
  // Stop the server if running
250
271
  if (server.state !== "stopped") {
251
272
  bridge.deactivateServer(serverName);
252
273
  await manager.stopServer(serverName);
253
274
  }
275
+
276
+ // Validate that we have a server URL (required for OAuth)
277
+ if (!config.url) {
278
+ throw new McpError(
279
+ `Server "${serverName}" has OAuth configured but no URL. OAuth requires a URL-based server transport.`,
280
+ serverName,
281
+ "config",
282
+ );
283
+ }
284
+
254
285
  // Reset all OAuth credentials (tokens, client info, PKCE, discovery)
255
286
  await manager.resetServerAuth(serverName);
287
+
256
288
  ctx.ui.notify(
257
- `pi-mcp: Reset credentials for ${serverName}. Starting server — a browser window will open for authentication...`,
289
+ `pi-mcp: Starting OAuth flow for ${serverName}...`,
258
290
  "info",
259
291
  );
260
- // Start the server — this triggers the OAuth flow and opens the browser
261
- await manager.startServer(serverName, ctx.cwd);
262
292
 
263
- const newStatus = await manager.getServerAuthStatus(serverName);
264
- if (newStatus?.hasTokens) {
293
+ // 1. Start the callback server
294
+ const port = await ensureCallbackServer();
295
+ setCallbackPort(port);
296
+
297
+ // 2. Generate a cryptographically secure state parameter for CSRF protection
298
+ oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
299
+ .map((b: number) => b.toString(16).padStart(2, "0"))
300
+ .join("");
301
+
302
+ // 3. Register the callback promise BEFORE opening the browser
303
+ const callbackPromise = waitForCallback(oauthState);
304
+
305
+ // 4. Create auth provider and transport
306
+ const authProvider = new McpOAuthProvider(
307
+ serverName,
308
+ config.auth || { type: "oauth" },
309
+ (url: URL) => {
310
+ console.error(`[pi-mcp] Opening browser for ${serverName}...`);
311
+ openBrowser(url.toString());
312
+ },
313
+ );
314
+
315
+ // CRITICAL FIX #1: Set the OAuth state on the provider before calling auth()
316
+ // This ensures the state parameter is included in the authorization URL
317
+ authProvider.setState(oauthState);
318
+
319
+ const transport = new StreamableHTTPClientTransport(
320
+ new URL(config.url),
321
+ { authProvider },
322
+ );
323
+
324
+ // 5. Start the auth flow - this will trigger redirectToAuthorization which opens the browser
325
+ // CRITICAL FIX #2: Check the return value of auth() instead of catching UnauthorizedError
326
+ // The SDK returns 'REDIRECT' when it needs browser interaction, not an error
327
+ const authResult = await auth(authProvider, { serverUrl: config.url });
328
+
329
+ if (authResult === "AUTHORIZED") {
330
+ // Auth succeeded without needing browser interaction (e.g., had valid tokens)
265
331
  ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully!`, "info");
266
- } else {
332
+ } else if (authResult === "REDIRECT") {
333
+ // Browser was opened, wait for the callback from the user
267
334
  ctx.ui.notify(
268
- `pi-mcp: ${serverName} started but authentication may still be in progress. Check the browser window.`,
335
+ `pi-mcp: Browser opened for ${serverName}. Complete authorization to continue...`,
269
336
  "info",
270
337
  );
338
+
339
+ // 6. Wait for the callback (this blocks until the user authorizes)
340
+ const code = await callbackPromise;
341
+
342
+ // 7. Complete the OAuth flow with the authorization code
343
+ await transport.finishAuth(code);
344
+
345
+ ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully!`, "info");
346
+ } else {
347
+ throw new McpError(
348
+ `Unexpected auth result: ${authResult}`,
349
+ serverName,
350
+ "protocol",
351
+ );
271
352
  }
353
+
354
+ // 8. Close the transport (we'll create a new one when starting the server)
355
+ await transport.close().catch(() => {});
356
+
357
+ // 9. Start the server with fresh auth credentials
358
+ await manager.startServer(serverName, ctx.cwd);
359
+
272
360
  } catch (err) {
273
361
  const msg = err instanceof McpError ? err.userMessage : String(err);
274
362
  ctx.ui.notify(`pi-mcp: Authentication failed for ${serverName} — ${msg}`, "error");
363
+
364
+ // Clean up on error
365
+ if (oauthState) {
366
+ cancelCallback(oauthState);
367
+ }
275
368
  }
276
369
  },
277
370
  });
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
15
15
  import type { OAuthClientMetadata, OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
16
- import { readFile, writeFile, mkdir } from "node:fs/promises";
16
+ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
18
  import { homedir } from "node:os";
19
19
  import { createHash } from "node:crypto";
@@ -43,6 +43,20 @@ export interface AuthConfig {
43
43
  clientSecret?: string | undefined;
44
44
  }
45
45
 
46
+ // ─── Callback Server Port ───────────────────────────────────────────────────────
47
+
48
+ let callbackPort = 19876;
49
+
50
+ /** Set the callback server port. Called by the auth flow when the server starts. */
51
+ export function setCallbackPort(port: number): void {
52
+ callbackPort = port;
53
+ }
54
+
55
+ /** Get the current callback server port. */
56
+ export function getCallbackPort(): number {
57
+ return callbackPort;
58
+ }
59
+
46
60
  // ─── Persistent State Types ───────────────────────────────────────────────────
47
61
 
48
62
  interface StoredClientInfo {
@@ -115,6 +129,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
115
129
  private authConfig: AuthConfig;
116
130
  private _redirectUrl: string | undefined;
117
131
  private _onAuthRequired: ((url: URL) => void | Promise<void>) | undefined;
132
+ private _oauthState: string | undefined;
118
133
 
119
134
  constructor(
120
135
  serverName: string,
@@ -129,16 +144,19 @@ export class McpOAuthProvider implements OAuthClientProvider {
129
144
 
130
145
  // --- redirectUrl ---
131
146
 
132
- get redirectUrl(): string | URL | undefined {
133
- return this._redirectUrl;
147
+ get redirectUrl(): string | URL {
148
+ // Use configured redirect URL if provided, otherwise use the callback server
149
+ // Use 127.0.0.1 explicitly (IPv4) to match the callback server binding
150
+ return this._redirectUrl || `http://127.0.0.1:${callbackPort}/callback`;
134
151
  }
135
152
 
136
153
  // --- clientMetadata ---
137
154
 
138
155
  get clientMetadata(): OAuthClientMetadata {
156
+ const redirectUrl = String(this.redirectUrl);
139
157
  return {
140
158
  client_name: `pi-mcp/${this.serverName}`,
141
- redirect_uris: this._redirectUrl ? [this._redirectUrl] : [],
159
+ redirect_uris: [redirectUrl],
142
160
  grant_types: ["authorization_code", "refresh_token"],
143
161
  response_types: ["code"],
144
162
  token_endpoint_auth_method: this.authConfig.clientSecret ? "client_secret_basic" : "none",
@@ -182,6 +200,21 @@ export class McpOAuthProvider implements OAuthClientProvider {
182
200
  async tokens(): Promise<OAuthTokens | undefined> {
183
201
  const state = await loadState(this.serverName);
184
202
  if (!state.tokens) return undefined;
203
+
204
+ // Always return stored tokens — even if expired.
205
+ // The SDK's auth() function checks tokens?.refresh_token and attempts
206
+ // silent refresh before falling back to a new authorization flow.
207
+ // Returning undefined for expired tokens would prevent that silent refresh
208
+ // and force the user to re-authenticate via browser every time.
209
+ //
210
+ // Flow when we return expired tokens:
211
+ // transport sends expired access_token → 401
212
+ // → auth() sees refresh_token → silent refresh → success → retry
213
+ //
214
+ // Flow when we return undefined (WRONG):
215
+ // transport has no token → auth() → no refresh possible → REDIRECT
216
+ // → user must re-authenticate in browser
217
+
185
218
  // Build OAuthTokens — only include defined fields
186
219
  const tokens: Record<string, string> = {
187
220
  access_token: state.tokens.access_token,
@@ -249,6 +282,25 @@ export class McpOAuthProvider implements OAuthClientProvider {
249
282
  return state.discoveryState;
250
283
  }
251
284
 
285
+ // --- OAuth state parameter (CSRF protection) ---
286
+
287
+ /**
288
+ * Set the OAuth state parameter before calling auth().
289
+ * This should be called with a cryptographically random value.
290
+ */
291
+ setState(state: string): void {
292
+ this._oauthState = state;
293
+ }
294
+
295
+ /**
296
+ * Returns the OAuth state parameter for CSRF protection.
297
+ * This is called by the SDK's auth() function when building the authorization URL.
298
+ * Returns empty string if no state has been set (no CSRF protection).
299
+ */
300
+ async state(): Promise<string> {
301
+ return this._oauthState || "";
302
+ }
303
+
252
304
  // --- Credential invalidation ---
253
305
 
254
306
  async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
@@ -311,6 +363,5 @@ export async function getAuthStatus(serverName: string): Promise<{
311
363
  * Used to force re-authorization on next connection.
312
364
  */
313
365
  export async function resetAuth(serverName: string): Promise<void> {
314
- const provider = new McpOAuthProvider(serverName, {});
315
- await provider.invalidateCredentials("all");
366
+ await unlink(statePath(serverName)).catch(() => {});
316
367
  }