pi-mcp-extension 1.9.10 → 1.10.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/README.md CHANGED
@@ -203,6 +203,30 @@ Agent tools `mcp_status` and `mcp_connect` let Pi inspect configured servers and
203
203
 
204
204
  `mcp_connect` checks current tool activation before reporting success, including when the server is already connected. If discovered tools are inactive, it reports that condition instead of overriding another extension's tool restrictions. Check the active tool policy or conflicting extension. A reconnect is not a substitute for fixing an extension that removes tools at each user turn.
205
205
 
206
+ ## Debug log
207
+
208
+ Set `PI_MCP_DEBUG=1` before starting Pi to record what the extension itself is doing — server state transitions, retries and backoff, OAuth refresh and cross-process lock contention, 401/403 classification, and session reloads:
209
+
210
+ ```bash
211
+ PI_MCP_DEBUG=1 pi
212
+ ```
213
+
214
+ The log is written to `<agent dir>/logs/pi-mcp.log` (JSON Lines, one event per line):
215
+
216
+ ```json
217
+ {"ts":"2026-01-01T00:00:00.000Z","level":"info","event":"server.state","server":"github","message":"Server github state starting -> ready","from":"starting","to":"ready","ms":412}
218
+ ```
219
+
220
+ - **Off by default.** Without `PI_MCP_DEBUG=1` no log file is created.
221
+ - **Private.** The directory is `0o700` and the file is `0o600`, like the OAuth credential files.
222
+ - **Bounded.** The file is rotated to `pi-mcp.log.1` at ~2 MB; a single backup is kept.
223
+ - **Redacted.** Only allow-listed field names are recorded, and a final pass masks anything that looks like a token (`access_token`, `refresh_token`, `client_secret`, `code=`, `code_verifier`, `Authorization:` / `Bearer …`).
224
+ - **Best-effort.** A failed log write never breaks a connection or a tool call.
225
+
226
+ Attach the file to a bug report. It contains no credentials.
227
+
228
+ While a Pi UI is active, the extension never writes diagnostics to Pi's stderr (that stream belongs to the TUI): events go to the log file instead, and user-actionable ones are surfaced as Pi notifications. Headless runs without a UI keep the stderr output they have always had — warnings, errors, and child-server output; informational events such as state transitions are recorded only in the debug log.
229
+
206
230
  ## How It Works
207
231
 
208
232
  ```
@@ -262,6 +286,10 @@ Implements the **MCP 2025-03-26** specification for tool clients:
262
286
  | Prompts bridge | ⏳ v2 |
263
287
  | Sampling | ⏳ v2 |
264
288
 
289
+ ## Contributing / Architecture
290
+
291
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, workflow, and the pre-merge smoke-test checklist, and [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for a behavioral overview of how the runtime fits together (lifecycle, coordination, config trust, and credential storage).
292
+
265
293
  ## Development
266
294
 
267
295
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.9.10",
3
+ "version": "1.10.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": [
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.32.1",
37
- "zod": "^3.25.0"
37
+ "zod": "^4.6.5"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-ai": "*",
@@ -0,0 +1,562 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
3
+ import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
4
+ import {
5
+ CallbackServer,
6
+ callbackServerConfigFromRedirectUrl,
7
+ } from "./callback-server.js";
8
+ import {
9
+ CallbackUrlValidationError,
10
+ isSafeAuthorizationUrl,
11
+ parseCallbackUrl,
12
+ } from "./callback-url.js";
13
+ import {
14
+ discoverManualAuthChallenge,
15
+ createHeadersFetch,
16
+ type ManualAuthChallenge,
17
+ } from "./auth-challenge.js";
18
+ import {
19
+ McpOAuthProvider,
20
+ acquireAuthLock,
21
+ AuthLockError,
22
+ type AuthLockHandle,
23
+ } from "./oauth-provider.js";
24
+ import { McpError, AuthCancelledError } from "./errors.js";
25
+ import { sanitizeTerminalText } from "./terminal-text.js";
26
+ import { describeError, reportDiagnostic } from "./debug-log.js";
27
+ import type { ServerConfig } from "./config.js";
28
+ import type { ServerManager } from "./server-manager.js";
29
+
30
+ export { AuthCancelledError } from "./errors.js";
31
+
32
+ export interface BrowserOpenCommand {
33
+ command: string;
34
+ args: string[];
35
+ }
36
+
37
+ export function browserOpenCommand(url: string): BrowserOpenCommand {
38
+ if (process.platform === "darwin") {
39
+ return { command: "open", args: [url] };
40
+ }
41
+ if (process.platform === "win32") {
42
+ return { command: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
43
+ }
44
+ return { command: "xdg-open", args: [url] };
45
+ }
46
+
47
+ /**
48
+ * Open a URL in the user's default browser.
49
+ * Works on macOS, Linux, and Windows.
50
+ */
51
+ export function openBrowser(url: string): Promise<void> {
52
+ return new Promise<void>((resolve, reject) => {
53
+ const { command, args } = browserOpenCommand(url);
54
+ const child = spawn(command, args, {
55
+ detached: true,
56
+ stdio: "ignore",
57
+ windowsHide: true,
58
+ });
59
+
60
+ child.once("error", (err) => {
61
+ reportDiagnostic({
62
+ level: "error",
63
+ event: "auth.browser_open_failed",
64
+ message: `Failed to open browser: ${sanitizeTerminalText(err.message)}`,
65
+ });
66
+ reject(err);
67
+ });
68
+
69
+ child.once("spawn", () => {
70
+ child.unref();
71
+ resolve();
72
+ });
73
+ });
74
+ }
75
+
76
+ export const authRetryOption = "Retry: open browser again";
77
+ export const authPasteOption = "Paste redirect URL";
78
+ export const authCancelOption = "Cancel authentication";
79
+
80
+ interface OAuthCallbackResult {
81
+ type: "callback";
82
+ code: string;
83
+ }
84
+
85
+ interface OAuthCallbackErrorResult {
86
+ type: "callbackError";
87
+ error: unknown;
88
+ }
89
+
90
+ interface OAuthUserChoiceResult {
91
+ type: "choice";
92
+ choice: string | undefined;
93
+ }
94
+
95
+ interface OAuthDialogAbortedResult {
96
+ type: "dialogAborted";
97
+ }
98
+
99
+ interface OAuthUserInputResult {
100
+ type: "input";
101
+ value: string | undefined;
102
+ }
103
+
104
+ type OAuthWaitResult = OAuthCallbackResult | OAuthCallbackErrorResult | OAuthUserChoiceResult | OAuthDialogAbortedResult;
105
+ type OAuthInputWaitResult = OAuthCallbackResult | OAuthCallbackErrorResult | OAuthUserInputResult | OAuthDialogAbortedResult;
106
+
107
+ export interface OAuthPasteOptions {
108
+ redirectUrl?: string | URL | undefined;
109
+ expectedState?: string | undefined;
110
+ onPasteResolved?: (() => void) | undefined;
111
+ }
112
+
113
+ export async function waitForOAuthCallback(
114
+ serverName: string,
115
+ callbackPromise: Promise<string>,
116
+ reopenBrowser: () => void | Promise<void>,
117
+ context: {
118
+ hasUI: boolean;
119
+ ui: Pick<ExtensionUIContext, "select"> & Partial<Pick<ExtensionUIContext, "input" | "notify">>;
120
+ },
121
+ pasteOptions?: OAuthPasteOptions,
122
+ ): Promise<string> {
123
+ if (!context.hasUI) {
124
+ throw new Error(`Authorization for ${serverName} requires an interactive Pi session`);
125
+ }
126
+
127
+ const callbackResultPromise: Promise<OAuthCallbackResult | OAuthCallbackErrorResult> = callbackPromise
128
+ .then((code): OAuthCallbackResult => ({ type: "callback", code }))
129
+ .catch((error): OAuthCallbackErrorResult => ({ type: "callbackError", error }));
130
+
131
+ while (true) {
132
+ const dialogAbortController = new AbortController();
133
+ const choicePromise: Promise<OAuthWaitResult> = context.ui
134
+ .select(
135
+ `OAuth pending for ${serverName}`,
136
+ [authRetryOption, authPasteOption, authCancelOption],
137
+ { signal: dialogAbortController.signal },
138
+ )
139
+ .then((choice): OAuthUserChoiceResult => ({ type: "choice", choice }))
140
+ .catch((error): OAuthDialogAbortedResult => {
141
+ if (dialogAbortController.signal.aborted) {
142
+ return { type: "dialogAborted" };
143
+ }
144
+ throw error;
145
+ });
146
+
147
+ const result = await Promise.race([callbackResultPromise, choicePromise]);
148
+
149
+ if (result.type === "callback") {
150
+ dialogAbortController.abort();
151
+ return result.code;
152
+ }
153
+
154
+ if (result.type === "callbackError") {
155
+ dialogAbortController.abort();
156
+ throw result.error;
157
+ }
158
+
159
+ if (result.type === "dialogAborted") {
160
+ continue;
161
+ }
162
+
163
+ if (result.choice === authRetryOption) {
164
+ await reopenBrowser();
165
+ continue;
166
+ }
167
+
168
+ if (result.choice === authPasteOption) {
169
+ if (!context.ui.input) {
170
+ continue;
171
+ }
172
+ const inputAbortController = new AbortController();
173
+ const inputPromise: Promise<OAuthInputWaitResult> = context.ui
174
+ .input(
175
+ `Paste redirect URL for ${serverName}`,
176
+ "http://127.0.0.1:19876/callback?code=...&state=...",
177
+ { signal: inputAbortController.signal },
178
+ )
179
+ .then((value): OAuthUserInputResult => ({ type: "input", value }))
180
+ .catch((error): OAuthDialogAbortedResult => {
181
+ if (inputAbortController.signal.aborted) {
182
+ return { type: "dialogAborted" };
183
+ }
184
+ throw error;
185
+ });
186
+
187
+ const inputResult = await Promise.race([callbackResultPromise, inputPromise]);
188
+
189
+ if (inputResult.type === "callback") {
190
+ inputAbortController.abort();
191
+ return inputResult.code;
192
+ }
193
+
194
+ if (inputResult.type === "callbackError") {
195
+ inputAbortController.abort();
196
+ throw inputResult.error;
197
+ }
198
+
199
+ if (inputResult.type === "dialogAborted" || !inputResult.value?.trim()) {
200
+ continue;
201
+ }
202
+
203
+ let parsed: ReturnType<typeof parseCallbackUrl>;
204
+ try {
205
+ parsed = parseCallbackUrl(inputResult.value, {
206
+ redirectUrl: pasteOptions?.redirectUrl,
207
+ expectedState: pasteOptions?.expectedState,
208
+ });
209
+ } catch (err) {
210
+ if (err instanceof CallbackUrlValidationError) {
211
+ context.ui.notify?.(`pi-mcp: Invalid redirect URL — ${sanitizeTerminalText(err.message)}`, "error");
212
+ continue;
213
+ }
214
+ throw err;
215
+ }
216
+
217
+ pasteOptions?.onPasteResolved?.();
218
+ if ("error" in parsed) {
219
+ throw new Error(parsed.error);
220
+ }
221
+ return parsed.code;
222
+ }
223
+
224
+ dialogAbortController.abort();
225
+ throw new AuthCancelledError(serverName);
226
+ }
227
+ }
228
+
229
+ export interface AuthorizeDeps {
230
+ manager: ServerManager;
231
+ getServerConfig?: ((name: string) => ServerConfig | undefined) | undefined;
232
+ authStorageDir?: string | undefined;
233
+ openBrowser?: ((url: string) => void | Promise<void>) | undefined;
234
+ callbackServer?: CallbackServer | undefined;
235
+ }
236
+
237
+ export async function authorize(
238
+ deps: AuthorizeDeps,
239
+ serverName: string,
240
+ ctx: ExtensionContext,
241
+ signal: AbortSignal,
242
+ reset: boolean,
243
+ ): Promise<void> {
244
+ const server = deps.manager.getServer(serverName);
245
+ const config = deps.getServerConfig ? deps.getServerConfig(serverName) : server?.config;
246
+ if (!config) {
247
+ throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
248
+ }
249
+
250
+ const callbackServer = deps.callbackServer ?? new CallbackServer();
251
+ const browserOpener = deps.openBrowser ?? openBrowser;
252
+ let oauthState: string | undefined;
253
+ let latestAuthorizationUrl: URL | undefined;
254
+ let lockLost = false;
255
+ const cancel = () => { if (oauthState) callbackServer.cancel(oauthState); };
256
+ signal.addEventListener("abort", cancel, { once: true });
257
+ let unlock: AuthLockHandle | undefined;
258
+ reportDiagnostic({
259
+ level: "info",
260
+ event: "auth.started",
261
+ server: serverName,
262
+ message: `Starting OAuth authorization for "${serverName}"`,
263
+ fields: { outcome: reset ? "reset" : "authorize" },
264
+ });
265
+ try {
266
+ if (signal.aborted) throw new AuthCancelledError(serverName);
267
+ unlock = await acquireAuthLock(serverName, deps.authStorageDir, {
268
+ onLost: () => {
269
+ lockLost = true;
270
+ reportDiagnostic({
271
+ level: "warn",
272
+ event: "auth.lock_lost",
273
+ server: serverName,
274
+ message: `Authorization lock lost for "${serverName}"`,
275
+ fields: { outcome: "contended" },
276
+ });
277
+ cancel();
278
+ },
279
+ });
280
+ if (signal.aborted) throw new AuthCancelledError(serverName);
281
+ if (reset) {
282
+ await unlock.assertHeld();
283
+ await deps.manager.resetServerAuth(serverName);
284
+ }
285
+ // Validate that we have a server URL (required for OAuth)
286
+ if (!config.url) {
287
+ throw new McpError(
288
+ `Server "${serverName}" has OAuth configured but no URL. OAuth requires a URL-based server transport.`,
289
+ serverName,
290
+ "config",
291
+ );
292
+ }
293
+
294
+ ctx.ui.notify(
295
+ `pi-mcp: Starting OAuth flow for ${serverName}...`,
296
+ "info",
297
+ );
298
+
299
+ if (!ctx.hasUI) throw new Error(`Authorization for ${serverName} requires an interactive Pi session`);
300
+ if (signal.aborted) throw new AuthCancelledError(serverName);
301
+
302
+ // 1. Start the callback server
303
+ const callbackServerConfig = callbackServerConfigFromRedirectUrl(config.auth?.redirectUrl);
304
+ const port = await callbackServer.ensure(callbackServerConfig.preferredPort, {
305
+ host: callbackServerConfig.host,
306
+ allowPortFallback: callbackServerConfig.allowPortFallback,
307
+ });
308
+ if (signal.aborted) throw new AuthCancelledError(serverName);
309
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
310
+ // WHY: In interactive mode, redirectUrl must match the port that the callback server
311
+ // actually bound (which may differ from preferredPort if port fallback was used).
312
+ const boundRedirectUrl = config.auth?.redirectUrl ?? `http://127.0.0.1:${port}/callback`;
313
+
314
+ // 2. Generate a cryptographically secure state parameter for CSRF protection
315
+ oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
316
+ .map((b: number) => b.toString(16).padStart(2, "0"))
317
+ .join("");
318
+
319
+ // 3. Create the auth provider.
320
+ const launchAuthorizationBrowser = async (url: URL): Promise<void> => {
321
+ if (!isSafeAuthorizationUrl(url)) {
322
+ throw new McpError(
323
+ `Refusing to open unsafe authorization URL for "${serverName}": only HTTPS (or loopback HTTP) URLs are allowed`,
324
+ serverName,
325
+ "protocol",
326
+ );
327
+ }
328
+ latestAuthorizationUrl = new URL(url.toString());
329
+ if (signal.aborted || lockLost) return;
330
+ try {
331
+ await browserOpener(url.toString());
332
+ ctx.ui.notify(
333
+ `pi-mcp: Authorize ${serverName}: ${url.toString()} (On SSH, forward the callback with ssh -L ${port}:127.0.0.1:${port}, or choose "${authPasteOption}".)`,
334
+ "info",
335
+ );
336
+ } catch (err) {
337
+ ctx.ui.notify(
338
+ `pi-mcp: Failed to open browser for ${serverName} (${sanitizeTerminalText(err instanceof Error ? err.message : String(err))}). Open ${url.toString()} manually or choose "${authPasteOption}".`,
339
+ "error",
340
+ );
341
+ }
342
+ };
343
+
344
+ const authProvider = new McpOAuthProvider({
345
+ serverName,
346
+ authConfig: config.auth || { type: "oauth" },
347
+ onAuthRequired: launchAuthorizationBrowser,
348
+ storageDir: deps.authStorageDir,
349
+ mode: "interactive",
350
+ isCancelled: () => signal.aborted,
351
+ assertLockHeld: () => unlock!.assertHeld(),
352
+ serverUrl: config.url,
353
+ redirectUrl: boundRedirectUrl,
354
+ });
355
+
356
+ if (!reset) {
357
+ // WHY: authorize() is only entered after silent connect failed with AuthRequiredError.
358
+ // If a refresh_token remained on disk (e.g. accepted by the Authorization Server
359
+ // while the Resource Server rejects the refreshed access token), the SDK's auth()
360
+ // would silently refresh and return "AUTHORIZED" without opening the browser.
361
+ // Clear stored tokens while preserving clientInfo and discoveryState.
362
+ try {
363
+ await authProvider.invalidateCredentials("tokens");
364
+ } catch (err) {
365
+ if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
366
+ const { kind, message } = describeError(err);
367
+ reportDiagnostic({
368
+ level: "warn",
369
+ event: "oauth.state_corrupt",
370
+ server: serverName,
371
+ message,
372
+ fields: { kind },
373
+ });
374
+ } else {
375
+ throw err;
376
+ }
377
+ }
378
+ if (signal.aborted) throw new AuthCancelledError(serverName);
379
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
380
+ }
381
+
382
+ // Why set state before calling auth(): the SDK reads provider.state() while
383
+ // building the authorization URL inside auth(), and omits the state parameter
384
+ // if empty, which the callback server then rejects as a CSRF error.
385
+ authProvider.setState(oauthState);
386
+
387
+ // 4. Discover protected-resource challenge metadata from the server.
388
+ const authChallenge: ManualAuthChallenge = await discoverManualAuthChallenge(config.url, {
389
+ headers: config.headers,
390
+ signal,
391
+ }).catch((): ManualAuthChallenge => {
392
+ if (signal.aborted) {
393
+ throw new AuthCancelledError(serverName);
394
+ }
395
+ reportDiagnostic({
396
+ level: "warn",
397
+ event: "auth.challenge_discovery_failed",
398
+ server: serverName,
399
+ message: `Failed to discover OAuth challenge for "${serverName}"; falling back to standard discovery`,
400
+ });
401
+ return {};
402
+ });
403
+ if (signal.aborted) throw new AuthCancelledError(serverName);
404
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
405
+
406
+ // 5. Register the callback immediately before auth() can open the browser.
407
+ // Attach a catch handler so cancellation after immediate authorization
408
+ // does not produce an unhandled rejection.
409
+ const callbackPromise = callbackServer.waitForCallback(oauthState);
410
+ callbackPromise.catch(() => {
411
+ // WHY: Suppress unhandled rejection when callbackPromise is cancelled after immediate authorization.
412
+ });
413
+
414
+ const customFetch = config.headers ? createHeadersFetch(config.headers) : undefined;
415
+ const authOptions: {
416
+ serverUrl: string;
417
+ resourceMetadataUrl?: URL;
418
+ scope?: string;
419
+ fetchFn?: typeof fetch;
420
+ } = { serverUrl: config.url };
421
+ if (authChallenge.resourceMetadataUrl) {
422
+ authOptions.resourceMetadataUrl = authChallenge.resourceMetadataUrl;
423
+ }
424
+ if (authChallenge.scope) {
425
+ authOptions.scope = authChallenge.scope;
426
+ }
427
+ if (customFetch) {
428
+ authOptions.fetchFn = customFetch;
429
+ }
430
+
431
+ // 6. Start the auth flow.
432
+ // Why branch on the return value: the SDK auth() call returns "AUTHORIZED" when
433
+ // stored or refreshed tokens already succeeded without opening a browser, or
434
+ // "REDIRECT" after invoking redirectToAuthorization(), in which case we must
435
+ // wait for the callback code and call auth() a second time with authorizationCode.
436
+ let authResult: "AUTHORIZED" | "REDIRECT";
437
+ try {
438
+ authResult = await auth(authProvider, authOptions);
439
+ } catch (err) {
440
+ if (err instanceof McpError && err.code === "config" && /Corrupt OAuth state file/i.test(err.message)) {
441
+ // WHY: McpOAuthProvider._loadState() backed up and removed the corrupt state file.
442
+ // Retry auth() once so interactive authorization proceeds cleanly.
443
+ const { kind, message } = describeError(err);
444
+ reportDiagnostic({
445
+ level: "warn",
446
+ event: "oauth.state_corrupt",
447
+ server: serverName,
448
+ message,
449
+ fields: { kind },
450
+ });
451
+ authResult = await auth(authProvider, authOptions);
452
+ } else {
453
+ throw err;
454
+ }
455
+ }
456
+ if (signal.aborted) throw new AuthCancelledError(serverName);
457
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
458
+
459
+ if (authResult === "AUTHORIZED") {
460
+ // Auth succeeded without needing browser interaction (e.g., had valid tokens).
461
+ ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully. Starting MCP server...`, "info");
462
+ } else if (authResult === "REDIRECT") {
463
+ // 7. Wait for the callback while giving the user an escape hatch.
464
+ const code = await waitForOAuthCallback(
465
+ serverName,
466
+ callbackPromise,
467
+ () => {
468
+ if (!latestAuthorizationUrl) {
469
+ throw new McpError(
470
+ `Authorization URL is not available for ${serverName}`,
471
+ serverName,
472
+ "protocol",
473
+ );
474
+ }
475
+ return launchAuthorizationBrowser(latestAuthorizationUrl);
476
+ },
477
+ ctx,
478
+ {
479
+ redirectUrl: authProvider.redirectUrl,
480
+ expectedState: oauthState,
481
+ onPasteResolved: cancel,
482
+ },
483
+ );
484
+ if (signal.aborted) throw new AuthCancelledError(serverName);
485
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
486
+ ctx.ui.notify(
487
+ `pi-mcp: Authorization callback received for ${serverName}. Exchanging token...`,
488
+ "info",
489
+ );
490
+ // 8. Complete the OAuth flow with the authorization code
491
+ const finishAuthOptions: {
492
+ serverUrl: string;
493
+ authorizationCode: string;
494
+ resourceMetadataUrl?: URL;
495
+ scope?: string;
496
+ fetchFn?: typeof fetch;
497
+ } = {
498
+ serverUrl: config.url,
499
+ authorizationCode: code,
500
+ };
501
+ if (authChallenge.resourceMetadataUrl) {
502
+ finishAuthOptions.resourceMetadataUrl = authChallenge.resourceMetadataUrl;
503
+ }
504
+ if (authChallenge.scope) {
505
+ finishAuthOptions.scope = authChallenge.scope;
506
+ }
507
+ if (customFetch) {
508
+ finishAuthOptions.fetchFn = customFetch;
509
+ }
510
+
511
+ const finishAuthResult = await auth(authProvider, finishAuthOptions);
512
+ if (signal.aborted) throw new AuthCancelledError(serverName);
513
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
514
+
515
+ if (finishAuthResult !== "AUTHORIZED") {
516
+ throw new McpError(
517
+ `Unexpected auth completion result: ${finishAuthResult}`,
518
+ serverName,
519
+ "protocol",
520
+ );
521
+ }
522
+
523
+ ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully. Starting MCP server...`, "info");
524
+ } else {
525
+ throw new McpError(
526
+ `Unexpected auth result: ${authResult}`,
527
+ serverName,
528
+ "protocol",
529
+ );
530
+ }
531
+
532
+ if (signal.aborted) throw new AuthCancelledError(serverName);
533
+ if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
534
+ reportDiagnostic({
535
+ level: "info",
536
+ event: "auth.completed",
537
+ server: serverName,
538
+ message: `OAuth authorization completed for "${serverName}"`,
539
+ fields: { outcome: "authorized" },
540
+ });
541
+ } catch (err) {
542
+ const { kind, message } = describeError(err);
543
+ reportDiagnostic({
544
+ level: err instanceof AuthCancelledError ? "info" : "error",
545
+ event: "auth.failed",
546
+ server: serverName,
547
+ message: `OAuth authorization failed for "${serverName}": ${message}`,
548
+ fields: { kind },
549
+ });
550
+ if (lockLost && !signal.aborted) {
551
+ throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
552
+ }
553
+ throw err;
554
+ } finally {
555
+ signal.removeEventListener("abort", cancel);
556
+ cancel();
557
+ await callbackServer.stop().catch(() => {
558
+ // WHY: Ignore callback server stop errors during authorize cleanup.
559
+ });
560
+ await unlock?.();
561
+ }
562
+ }