@ian-pascoe/pi-mcp 0.1.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.
@@ -0,0 +1,740 @@
1
+ // oxlint-disable anti-slop/no-conditional-empty-object-spread -- Exact optional SDK and store fields must be omitted rather than written as undefined.
2
+ // oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-unknown-parameters -- This module owns the strict JSON parser boundary for persisted SDK discovery documents.
3
+ import { execFile as execFileCallback } from "node:child_process";
4
+ import { randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { createServer, type Server } from "node:http";
6
+ import type {
7
+ FetchLike,
8
+ OAuthClientInformationContext,
9
+ OAuthClientMetadata,
10
+ OAuthClientProvider,
11
+ OAuthDiscoveryState,
12
+ StoredOAuthClientInformation,
13
+ StoredOAuthTokens,
14
+ } from "@modelcontextprotocol/client";
15
+ import { auth, resourceUrlFromServerUrl } from "@modelcontextprotocol/client";
16
+ import { type McpAuthBinding, type McpAuthEntry, McpAuthStore } from "./mcp-auth-store.js";
17
+ import type { McpStoreJsonObject, McpStoreJsonValue } from "./mcp-settings-store.js";
18
+
19
+ /** Construction values for one URL-bound SDK OAuth provider. */
20
+ export interface McpOAuthProviderOptions {
21
+ /** Persistent credential store shared by aliases with the same URL and client identity. */
22
+ readonly authStore: McpAuthStore;
23
+ /** Stable OAuth client identity used to bind persisted credentials. */
24
+ readonly clientIdentity: string;
25
+ /** Pre-registered client identifier, when Dynamic Client Registration is unnecessary. */
26
+ readonly clientId?: string;
27
+ /** Secret associated with a pre-registered client identifier. */
28
+ readonly clientSecret?: string;
29
+ /** Receives the authorization URL only during an explicit authentication operation. */
30
+ readonly onAuthorizationUrl: (url: URL) => void | Promise<void>;
31
+ /** Loopback redirect URL registered for this client. */
32
+ readonly redirectUrl: string;
33
+ /** Requested OAuth scopes. */
34
+ readonly scopes?: readonly string[];
35
+ /** Resolved MCP Server URL that owns the credentials. */
36
+ readonly serverUrl: string;
37
+ /** Current epoch time in milliseconds; injectable for token-expiry tests. */
38
+ readonly now?: () => number;
39
+ }
40
+
41
+ /** Safe persistence failure raised only inside the SDK OAuth provider boundary. */
42
+ export class McpOAuthPersistenceError extends Error {
43
+ readonly _tag = "McpOAuthPersistenceError" as const;
44
+
45
+ constructor(readonly operation: string) {
46
+ super(`MCP OAuth persistence failed during ${operation}`);
47
+ }
48
+ }
49
+
50
+ /** Default loopback endpoint used by explicit MCP OAuth authorization. */
51
+ export const DEFAULT_MCP_OAUTH_REDIRECT_URL = "http://127.0.0.1:19876/mcp/oauth/callback";
52
+
53
+ /** Browser process operation injected by CLI and Pi command composition roots. */
54
+ export type McpOAuthExecFile = (
55
+ file: string,
56
+ args: readonly string[],
57
+ options: { readonly shell: false },
58
+ ) => Promise<void>;
59
+
60
+ /** Interaction and persistence inputs for one explicit MCP OAuth authorization. */
61
+ export interface AuthenticateMcpOAuthOptions {
62
+ /** Strict persistent credential store. */
63
+ readonly authStore: McpAuthStore;
64
+ /** Stable identity used to bind registered clients and credentials. */
65
+ readonly clientIdentity?: string;
66
+ /** Optional pre-registered client ID. HTTPS values also enable CIMD. */
67
+ readonly clientId?: string;
68
+ /** Optional pre-registered client secret. */
69
+ readonly clientSecret?: string;
70
+ /** Executes the platform browser opener without a shell. */
71
+ readonly execFile?: McpOAuthExecFile;
72
+ /** Suppress browser opening while still printing the authorization URL. */
73
+ readonly noOpen?: boolean;
74
+ /** Platform used to select the browser executable. */
75
+ readonly platform?: NodeJS.Platform;
76
+ /** Explicit loopback redirect URL; defaults to {@link DEFAULT_MCP_OAUTH_REDIRECT_URL}. */
77
+ readonly redirectUrl?: string;
78
+ /** Abort the current explicit authorization operation. */
79
+ readonly signal?: AbortSignal;
80
+ /** Requested OAuth scopes. */
81
+ readonly scopes?: readonly string[];
82
+ /** Safe server identifier used only in structured failures. */
83
+ readonly serverId: string;
84
+ /** Resolved remote MCP Server URL. */
85
+ readonly serverUrl: string;
86
+ /** Total interaction budget in milliseconds. */
87
+ readonly timeoutMs?: number;
88
+ /** Optional remote-environment input of a full callback URL or `code state` pair. */
89
+ readonly waitForPaste?: (signal: AbortSignal) => Promise<string>;
90
+ /** Prints the authorization URL before any best-effort browser open. */
91
+ readonly writeAuthorizationUrl: (url: string) => void | Promise<void>;
92
+ }
93
+
94
+ /** Safe expected failure from an explicit OAuth authorization operation. */
95
+ export class McpOAuthError extends Error {
96
+ readonly _tag = "McpOAuthError" as const;
97
+
98
+ constructor(
99
+ readonly code:
100
+ | "already_active"
101
+ | "authorization_failed"
102
+ | "callback_unavailable"
103
+ | "cancelled"
104
+ | "invalid_callback"
105
+ | "invalid_redirect_uri"
106
+ | "state_mismatch"
107
+ | "store_failed"
108
+ | "timeout",
109
+ readonly serverId: string,
110
+ ) {
111
+ super(`MCP OAuth authorization failed (${code}) for server ${JSON.stringify(serverId)}`);
112
+ }
113
+ }
114
+
115
+ /** Result of an explicit OAuth operation; errors never include credentials or callback values. */
116
+ export type AuthenticateMcpOAuthResult =
117
+ | { readonly ok: true }
118
+ | { readonly error: McpOAuthError; readonly ok: false };
119
+
120
+ function parseJsonValue(input: unknown): McpStoreJsonValue | undefined {
121
+ if (
122
+ input === null ||
123
+ typeof input === "string" ||
124
+ typeof input === "boolean" ||
125
+ (typeof input === "number" && Number.isFinite(input))
126
+ ) {
127
+ return input;
128
+ }
129
+ if (Array.isArray(input)) {
130
+ const parsed: McpStoreJsonValue[] = [];
131
+ for (const item of input) {
132
+ const value = parseJsonValue(item);
133
+ if (value === undefined) return undefined;
134
+ parsed.push(value);
135
+ }
136
+ return parsed;
137
+ }
138
+ if (typeof input !== "object") return undefined;
139
+ const parsed: Record<string, McpStoreJsonValue> = {};
140
+ for (const [key, item] of Object.entries(input)) {
141
+ if (item === undefined) continue;
142
+ const value = parseJsonValue(item);
143
+ if (value === undefined) return undefined;
144
+ parsed[key] = value;
145
+ }
146
+ return parsed;
147
+ }
148
+
149
+ function parseJsonObject(input: unknown): McpStoreJsonObject | undefined {
150
+ const parsed = parseJsonValue(input);
151
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
152
+ // SAFETY: parseJsonValue returned a JSON object after null and array rejection.
153
+ return parsed as McpStoreJsonObject;
154
+ }
155
+
156
+ function discoveryIssuer(entry: McpAuthEntry): string | undefined {
157
+ const metadata = entry.discovery?.authorizationServerMetadata;
158
+ return typeof metadata?.issuer === "string"
159
+ ? metadata.issuer
160
+ : entry.discovery?.authorizationServerUrl;
161
+ }
162
+
163
+ function issuerMatches(
164
+ entry: McpAuthEntry,
165
+ context: OAuthClientInformationContext | undefined,
166
+ ): boolean {
167
+ if (context === undefined) return true;
168
+ const issuer = discoveryIssuer(entry);
169
+ return (
170
+ issuer === undefined ||
171
+ (URL.canParse(issuer) &&
172
+ URL.canParse(context.issuer) &&
173
+ new URL(issuer).href === new URL(context.issuer).href)
174
+ );
175
+ }
176
+
177
+ /** Public SDK OAuth provider backed by the strict URL-bound MCP auth store. */
178
+ export class McpOAuthProvider implements OAuthClientProvider {
179
+ /** HTTPS URL-based Client ID used for Client ID Metadata Documents, when configured. */
180
+ readonly clientMetadataUrl?: string;
181
+ private readonly binding: McpAuthBinding;
182
+ private readonly now: () => number;
183
+
184
+ constructor(private readonly options: McpOAuthProviderOptions) {
185
+ this.binding = {
186
+ clientIdentity: options.clientIdentity,
187
+ serverUrl: options.serverUrl,
188
+ };
189
+ this.now = options.now ?? Date.now;
190
+ const clientId = options.clientId;
191
+ if (
192
+ clientId !== undefined &&
193
+ URL.canParse(clientId) &&
194
+ new URL(clientId).protocol === "https:"
195
+ ) {
196
+ this.clientMetadataUrl = clientId;
197
+ }
198
+ }
199
+
200
+ /** Loopback redirect URL supplied to authorization and registration requests. */
201
+ get redirectUrl(): string {
202
+ return this.options.redirectUrl;
203
+ }
204
+
205
+ /** Client metadata used for CIMD or Dynamic Client Registration. */
206
+ get clientMetadata(): OAuthClientMetadata {
207
+ const scope = this.options.scopes?.join(" ");
208
+ return {
209
+ client_name: "Pi MCP",
210
+ client_uri: "https://github.com/ian-pascoe/pi-extensions",
211
+ grant_types: ["authorization_code", "refresh_token"],
212
+ redirect_uris: [this.redirectUrl],
213
+ response_types: ["code"],
214
+ token_endpoint_auth_method: this.options.clientSecret ? "client_secret_post" : "none",
215
+ ...(scope === undefined || scope.length === 0 ? {} : { scope }),
216
+ };
217
+ }
218
+
219
+ /** Return configured or dynamically registered client information for the validated issuer. */
220
+ async clientInformation(
221
+ context?: OAuthClientInformationContext,
222
+ ): Promise<StoredOAuthClientInformation | undefined> {
223
+ if (this.options.clientId !== undefined) {
224
+ return {
225
+ client_id: this.options.clientId,
226
+ ...(this.options.clientSecret === undefined
227
+ ? {}
228
+ : { client_secret: this.options.clientSecret }),
229
+ ...(context === undefined ? {} : { issuer: context.issuer }),
230
+ };
231
+ }
232
+ const entry = await this.readEntry("load client information");
233
+ const client = entry?.clientInformation;
234
+ if (entry === undefined || client === undefined || !issuerMatches(entry, context))
235
+ return undefined;
236
+ const issuer = context?.issuer ?? discoveryIssuer(entry);
237
+ return {
238
+ client_id: client.clientId,
239
+ ...(client.clientIdIssuedAt === undefined
240
+ ? {}
241
+ : { client_id_issued_at: client.clientIdIssuedAt }),
242
+ ...(client.clientSecret === undefined ? {} : { client_secret: client.clientSecret }),
243
+ ...(client.clientSecretExpiresAt === undefined
244
+ ? {}
245
+ : { client_secret_expires_at: client.clientSecretExpiresAt }),
246
+ ...(issuer === undefined ? {} : { issuer }),
247
+ };
248
+ }
249
+
250
+ /** Persist dynamically registered client information without logging credentials. */
251
+ async saveClientInformation(
252
+ client: StoredOAuthClientInformation,
253
+ context?: OAuthClientInformationContext,
254
+ ): Promise<void> {
255
+ await this.updateEntry(
256
+ {
257
+ clientInformation: {
258
+ clientId: client.client_id,
259
+ ...(client.client_id_issued_at === undefined
260
+ ? {}
261
+ : { clientIdIssuedAt: client.client_id_issued_at }),
262
+ ...(client.client_secret === undefined ? {} : { clientSecret: client.client_secret }),
263
+ ...(client.client_secret_expires_at === undefined
264
+ ? {}
265
+ : { clientSecretExpiresAt: client.client_secret_expires_at }),
266
+ ...(this.clientMetadataUrl === undefined
267
+ ? {}
268
+ : { metadataDocumentUrl: this.clientMetadataUrl }),
269
+ },
270
+ },
271
+ "save client information",
272
+ );
273
+ if (context !== undefined) await this.ensureIssuerRecorded(context.issuer);
274
+ }
275
+
276
+ /** Load the latest token set, retaining its authorization-server issuer stamp. */
277
+ async tokens(context?: OAuthClientInformationContext): Promise<StoredOAuthTokens | undefined> {
278
+ const entry = await this.readEntry("load tokens");
279
+ const tokens = entry?.tokens;
280
+ if (entry === undefined || tokens === undefined || !issuerMatches(entry, context))
281
+ return undefined;
282
+ const issuer = context?.issuer ?? discoveryIssuer(entry);
283
+ const expiresIn =
284
+ tokens.expiresAt === undefined
285
+ ? undefined
286
+ : Math.max(0, Math.floor(tokens.expiresAt - this.now() / 1_000));
287
+ return {
288
+ access_token: tokens.accessToken,
289
+ ...(expiresIn === undefined ? {} : { expires_in: expiresIn }),
290
+ ...(issuer === undefined ? {} : { issuer }),
291
+ ...(tokens.refreshToken === undefined ? {} : { refresh_token: tokens.refreshToken }),
292
+ ...(tokens.scope === undefined ? {} : { scope: tokens.scope }),
293
+ token_type: tokens.tokenType,
294
+ };
295
+ }
296
+
297
+ /** Persist newly issued or refreshed OAuth tokens in the mode-0600 auth store. */
298
+ async saveTokens(
299
+ tokens: StoredOAuthTokens,
300
+ context?: OAuthClientInformationContext,
301
+ ): Promise<void> {
302
+ await this.updateEntry(
303
+ {
304
+ tokens: {
305
+ accessToken: tokens.access_token,
306
+ ...(tokens.expires_in === undefined
307
+ ? {}
308
+ : { expiresAt: this.now() / 1_000 + tokens.expires_in }),
309
+ ...(tokens.refresh_token === undefined ? {} : { refreshToken: tokens.refresh_token }),
310
+ ...(tokens.scope === undefined ? {} : { scope: tokens.scope }),
311
+ tokenType: tokens.token_type ?? "Bearer",
312
+ },
313
+ },
314
+ "save tokens",
315
+ );
316
+ const issuer = context?.issuer ?? tokens.issuer;
317
+ if (issuer !== undefined) await this.ensureIssuerRecorded(issuer);
318
+ }
319
+
320
+ /** Present the authorization URL through the explicit interaction owner. */
321
+ redirectToAuthorization(authorizationUrl: URL): void | Promise<void> {
322
+ return this.options.onAuthorizationUrl(authorizationUrl);
323
+ }
324
+
325
+ /** Persist the PKCE verifier before leaving the process for authorization. */
326
+ async saveCodeVerifier(codeVerifier: string): Promise<void> {
327
+ await this.updateEntry({ authorization: { codeVerifier } }, "save PKCE verifier");
328
+ }
329
+
330
+ /** Load the PKCE verifier required for authorization-code exchange. */
331
+ async codeVerifier(): Promise<string> {
332
+ const entry = await this.readEntry("load PKCE verifier");
333
+ const codeVerifier = entry?.authorization?.codeVerifier;
334
+ if (codeVerifier === undefined) throw new McpOAuthPersistenceError("load PKCE verifier");
335
+ return codeVerifier;
336
+ }
337
+
338
+ /** Generate or restore the CSRF state bound to the active URL/client identity. */
339
+ async state(): Promise<string> {
340
+ const entry = await this.readEntry("load authorization state");
341
+ const existing = entry?.authorization?.state;
342
+ if (existing !== undefined) return existing;
343
+ const state = randomBytes(32).toString("hex");
344
+ await this.updateEntry({ authorization: { state } }, "save authorization state");
345
+ return state;
346
+ }
347
+
348
+ /** Persist SDK-validated RFC 9728 and authorization-server discovery state. */
349
+ async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
350
+ const authorizationServerMetadata = parseJsonObject(state.authorizationServerMetadata);
351
+ const protectedResourceMetadata = parseJsonObject(state.resourceMetadata);
352
+ await this.updateEntry(
353
+ {
354
+ discovery: {
355
+ authorizationServerUrl: state.authorizationServerUrl,
356
+ ...(authorizationServerMetadata === undefined ? {} : { authorizationServerMetadata }),
357
+ ...(protectedResourceMetadata === undefined ? {} : { protectedResourceMetadata }),
358
+ ...(state.resourceMetadataUrl === undefined
359
+ ? {}
360
+ : { resourceMetadataUrl: state.resourceMetadataUrl }),
361
+ },
362
+ },
363
+ "save discovery state",
364
+ );
365
+ }
366
+
367
+ /** Restore only URL- and issuer-consistent discovery state for the SDK. */
368
+ async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
369
+ const entry = await this.readEntry("load discovery state");
370
+ const discovery = entry?.discovery;
371
+ if (discovery?.authorizationServerUrl === undefined) return undefined;
372
+ const metadata = discovery.authorizationServerMetadata;
373
+ const issuer = typeof metadata?.issuer === "string" ? metadata.issuer : undefined;
374
+ if (
375
+ !URL.canParse(discovery.authorizationServerUrl) ||
376
+ (issuer !== undefined &&
377
+ (!URL.canParse(issuer) ||
378
+ new URL(issuer).href !== new URL(discovery.authorizationServerUrl).href))
379
+ ) {
380
+ return undefined;
381
+ }
382
+ const resource = discovery.protectedResourceMetadata?.resource;
383
+ if (
384
+ resource !== undefined &&
385
+ (typeof resource !== "string" ||
386
+ !URL.canParse(resource) ||
387
+ new URL(resource).href !== resourceUrlFromServerUrl(this.options.serverUrl).href)
388
+ ) {
389
+ return undefined;
390
+ }
391
+ const resourceMetadataUrl = discovery.resourceMetadataUrl;
392
+ if (
393
+ resourceMetadataUrl !== undefined &&
394
+ (!URL.canParse(resourceMetadataUrl) ||
395
+ !["http:", "https:"].includes(new URL(resourceMetadataUrl).protocol))
396
+ ) {
397
+ return undefined;
398
+ }
399
+ // SAFETY: McpAuthStore recursively parsed every persisted JSON value. The URL, issuer,
400
+ // and protected-resource fields used by the SDK are refined above; remaining extension
401
+ // fields are opaque JSON preserved from SDK-produced discovery documents.
402
+ return {
403
+ authorizationServerUrl: discovery.authorizationServerUrl,
404
+ ...(metadata === undefined ? {} : { authorizationServerMetadata: metadata }),
405
+ ...(discovery.protectedResourceMetadata === undefined
406
+ ? {}
407
+ : { resourceMetadata: discovery.protectedResourceMetadata }),
408
+ ...(resourceMetadataUrl === undefined ? {} : { resourceMetadataUrl }),
409
+ } as OAuthDiscoveryState;
410
+ }
411
+
412
+ /** Remove the selected SDK credential scope from the bound auth entry. */
413
+ async invalidateCredentials(
414
+ scope: "all" | "client" | "tokens" | "verifier" | "discovery",
415
+ ): Promise<void> {
416
+ if (scope === "all") {
417
+ const removed = await this.options.authStore.removeEntry(this.binding);
418
+ if (!removed.ok) throw new McpOAuthPersistenceError("remove credentials");
419
+ return;
420
+ }
421
+ await this.updateEntry(
422
+ scope === "client"
423
+ ? { clientInformation: null }
424
+ : scope === "tokens"
425
+ ? { tokens: null }
426
+ : scope === "discovery"
427
+ ? { discovery: null }
428
+ : { authorization: null },
429
+ `invalidate ${scope}`,
430
+ );
431
+ }
432
+
433
+ private async readEntry(operation: string): Promise<McpAuthEntry | undefined> {
434
+ const result = await this.options.authStore.readEntry(this.binding);
435
+ if (!result.ok) throw new McpOAuthPersistenceError(operation);
436
+ return result.value;
437
+ }
438
+
439
+ private async updateEntry(
440
+ patch: Parameters<McpAuthStore["updateEntry"]>[1],
441
+ operation: string,
442
+ ): Promise<void> {
443
+ const result = await this.options.authStore.updateEntry(this.binding, patch);
444
+ if (!result.ok) throw new McpOAuthPersistenceError(operation);
445
+ }
446
+
447
+ private async ensureIssuerRecorded(issuer: string): Promise<void> {
448
+ const entry = await this.readEntry("load issuer binding");
449
+ if (entry?.discovery?.authorizationServerUrl !== undefined) return;
450
+ await this.updateEntry(
451
+ { discovery: { authorizationServerUrl: new URL(issuer).href } },
452
+ "save issuer binding",
453
+ );
454
+ }
455
+ }
456
+
457
+ interface OAuthCallbackParameters {
458
+ readonly code?: string;
459
+ readonly error?: string;
460
+ readonly iss?: string;
461
+ readonly state?: string;
462
+ }
463
+
464
+ let authorizationActive = false;
465
+
466
+ function isLoopbackHostname(hostname: string): boolean {
467
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]";
468
+ }
469
+
470
+ function parseLoopbackRedirect(value: string): URL | undefined {
471
+ try {
472
+ const url = new URL(value);
473
+ return url.protocol === "http:" && isLoopbackHostname(url.hostname) ? url : undefined;
474
+ } catch {
475
+ return undefined;
476
+ }
477
+ }
478
+
479
+ function callbackParameters(searchParams: URLSearchParams): OAuthCallbackParameters {
480
+ const code = searchParams.get("code");
481
+ const error = searchParams.get("error");
482
+ const iss = searchParams.get("iss");
483
+ const state = searchParams.get("state");
484
+ return {
485
+ ...(code === null ? {} : { code }),
486
+ ...(error === null ? {} : { error }),
487
+ ...(iss === null ? {} : { iss }),
488
+ ...(state === null ? {} : { state }),
489
+ };
490
+ }
491
+
492
+ function parseCallbackInput(input: string, redirectUrl: URL): OAuthCallbackParameters | undefined {
493
+ const trimmed = input.trim();
494
+ if (URL.canParse(trimmed)) {
495
+ const callbackUrl = new URL(trimmed);
496
+ if (
497
+ callbackUrl.origin !== redirectUrl.origin ||
498
+ callbackUrl.pathname !== redirectUrl.pathname
499
+ ) {
500
+ return undefined;
501
+ }
502
+ return callbackParameters(callbackUrl.searchParams);
503
+ }
504
+ const pair = trimmed.split(/\s+/u);
505
+ return pair.length === 2 && pair[0] !== undefined && pair[1] !== undefined
506
+ ? { code: pair[0], state: pair[1] }
507
+ : undefined;
508
+ }
509
+
510
+ function callbackParametersFromUrl(url: URL): OAuthCallbackParameters {
511
+ return callbackParameters(url.searchParams);
512
+ }
513
+
514
+ function listen(server: Server, redirectUrl: URL): Promise<void> {
515
+ const port = redirectUrl.port.length > 0 ? Number(redirectUrl.port) : 80;
516
+ return new Promise((resolveListen, rejectListen) => {
517
+ const onError = (error: Error) => {
518
+ server.off("listening", onListening);
519
+ rejectListen(error);
520
+ };
521
+ const onListening = () => {
522
+ server.off("error", onError);
523
+ resolveListen();
524
+ };
525
+ server.once("error", onError);
526
+ server.once("listening", onListening);
527
+ server.listen(port, redirectUrl.hostname === "[::1]" ? "::1" : redirectUrl.hostname);
528
+ });
529
+ }
530
+
531
+ function closeServer(server: Server): Promise<void> {
532
+ return new Promise((resolveClose) => {
533
+ if (!server.listening) {
534
+ resolveClose();
535
+ return;
536
+ }
537
+ server.close(() => resolveClose());
538
+ });
539
+ }
540
+
541
+ function createCallbackServer(
542
+ redirectUrl: URL,
543
+ resolveCallback: (parameters: OAuthCallbackParameters) => void,
544
+ ): Server {
545
+ return createServer((request, response) => {
546
+ const requestUrl = new URL(request.url ?? "/", redirectUrl.origin);
547
+ if (request.method !== "GET") {
548
+ response.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
549
+ response.end("Method not allowed");
550
+ return;
551
+ }
552
+ if (requestUrl.pathname !== redirectUrl.pathname) {
553
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
554
+ response.end("Not found");
555
+ return;
556
+ }
557
+ resolveCallback(callbackParametersFromUrl(requestUrl));
558
+ response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
559
+ response.end("Authorization received. You may close this window.");
560
+ });
561
+ }
562
+
563
+ function defaultExecFile(
564
+ file: string,
565
+ args: readonly string[],
566
+ options: { shell: false },
567
+ ): Promise<void> {
568
+ return new Promise((resolveExecution, rejectExecution) => {
569
+ execFileCallback(file, args, options, (error) => {
570
+ if (error === null) resolveExecution();
571
+ else rejectExecution(error);
572
+ });
573
+ });
574
+ }
575
+
576
+ function browserCommand(
577
+ platform: NodeJS.Platform,
578
+ url: string,
579
+ ): readonly [string, readonly string[]] {
580
+ if (platform === "darwin") return ["open", [url]];
581
+ if (platform === "win32") {
582
+ return ["rundll32.exe", ["url.dll,FileProtocolHandler", url]];
583
+ }
584
+ return ["xdg-open", [url]];
585
+ }
586
+
587
+ function statesMatch(received: string, expected: string): boolean {
588
+ const receivedBytes = Buffer.from(received);
589
+ const expectedBytes = Buffer.from(expected);
590
+ return (
591
+ receivedBytes.length === expectedBytes.length && timingSafeEqual(receivedBytes, expectedBytes)
592
+ );
593
+ }
594
+
595
+ function mcpOAuthAbortError(
596
+ signal: AbortSignal,
597
+ timeoutSignal: AbortSignal,
598
+ serverId: string,
599
+ ): McpOAuthError {
600
+ return new McpOAuthError(
601
+ signal.reason === timeoutSignal.reason ? "timeout" : "cancelled",
602
+ serverId,
603
+ );
604
+ }
605
+
606
+ function waitForAbort(
607
+ signal: AbortSignal,
608
+ timeoutSignal: AbortSignal,
609
+ serverId: string,
610
+ ): Promise<never> {
611
+ return new Promise((_, rejectAbort) => {
612
+ const reject = (): void => rejectAbort(mcpOAuthAbortError(signal, timeoutSignal, serverId));
613
+ if (signal.aborted) reject();
614
+ else signal.addEventListener("abort", reject, { once: true });
615
+ });
616
+ }
617
+
618
+ /** Run one explicit OAuth authorization through SDK discovery, DCR/CIMD, and token exchange. */
619
+ export async function authenticateMcpOAuth(
620
+ options: AuthenticateMcpOAuthOptions,
621
+ ): Promise<AuthenticateMcpOAuthResult> {
622
+ if (authorizationActive) {
623
+ return { error: new McpOAuthError("already_active", options.serverId), ok: false };
624
+ }
625
+ const redirectUrl = parseLoopbackRedirect(options.redirectUrl ?? DEFAULT_MCP_OAUTH_REDIRECT_URL);
626
+ if (redirectUrl === undefined) {
627
+ return { error: new McpOAuthError("invalid_redirect_uri", options.serverId), ok: false };
628
+ }
629
+ if (options.signal?.aborted === true) {
630
+ return { error: new McpOAuthError("cancelled", options.serverId), ok: false };
631
+ }
632
+
633
+ authorizationActive = true;
634
+ const controller = new AbortController();
635
+ const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? 5 * 60_000);
636
+ const signal = AbortSignal.any([
637
+ controller.signal,
638
+ timeoutSignal,
639
+ ...(options.signal === undefined ? [] : [options.signal]),
640
+ ]);
641
+ const authCalls: Promise<unknown>[] = [];
642
+ const fetchFn: FetchLike = (input, init) => fetch(input, { ...init, signal });
643
+ let resolveLoopback: (parameters: OAuthCallbackParameters) => void = () => undefined;
644
+ const loopback = new Promise<OAuthCallbackParameters>((resolveCallback) => {
645
+ resolveLoopback = resolveCallback;
646
+ });
647
+ const callbackServer = createCallbackServer(redirectUrl, resolveLoopback);
648
+
649
+ try {
650
+ try {
651
+ await listen(callbackServer, redirectUrl);
652
+ } catch {
653
+ return { error: new McpOAuthError("callback_unavailable", options.serverId), ok: false };
654
+ }
655
+
656
+ const provider = new McpOAuthProvider({
657
+ authStore: options.authStore,
658
+ clientIdentity: options.clientIdentity ?? "@ian-pascoe/pi-mcp",
659
+ ...(options.clientId === undefined ? {} : { clientId: options.clientId }),
660
+ ...(options.clientSecret === undefined ? {} : { clientSecret: options.clientSecret }),
661
+ onAuthorizationUrl: async (authorizationUrl) => {
662
+ await options.writeAuthorizationUrl(authorizationUrl.href);
663
+ if (options.noOpen === true) return;
664
+ const [file, args] = browserCommand(
665
+ options.platform ?? process.platform,
666
+ authorizationUrl.href,
667
+ );
668
+ await (options.execFile ?? defaultExecFile)(file, args, { shell: false }).catch(
669
+ () => undefined,
670
+ );
671
+ },
672
+ redirectUrl: redirectUrl.href,
673
+ ...(options.scopes === undefined ? {} : { scopes: options.scopes }),
674
+ serverUrl: options.serverUrl,
675
+ });
676
+ const scope = options.scopes?.join(" ");
677
+ await provider.invalidateCredentials("verifier");
678
+ const firstAuth = auth(provider, {
679
+ fetchFn,
680
+ serverUrl: options.serverUrl,
681
+ ...(scope === undefined || scope.length === 0 ? {} : { scope }),
682
+ });
683
+ authCalls.push(firstAuth);
684
+ const abortFailure = waitForAbort(signal, timeoutSignal, options.serverId);
685
+ const first = await Promise.race([firstAuth, abortFailure]);
686
+ if (first === "AUTHORIZED") return { ok: true };
687
+
688
+ const pasted = options
689
+ .waitForPaste?.(signal)
690
+ .then((input) => parseCallbackInput(input, redirectUrl));
691
+ const parameters = await Promise.race([
692
+ pasted === undefined ? loopback : Promise.race([loopback, pasted]),
693
+ abortFailure,
694
+ ]);
695
+ if (parameters === undefined) {
696
+ return { error: new McpOAuthError("invalid_callback", options.serverId), ok: false };
697
+ }
698
+ const expectedState = await provider.state();
699
+ if (parameters.state === undefined || !statesMatch(parameters.state, expectedState)) {
700
+ return { error: new McpOAuthError("state_mismatch", options.serverId), ok: false };
701
+ }
702
+ if (parameters.error !== undefined) {
703
+ return { error: new McpOAuthError("authorization_failed", options.serverId), ok: false };
704
+ }
705
+ if (parameters.code === undefined) {
706
+ return { error: new McpOAuthError("invalid_callback", options.serverId), ok: false };
707
+ }
708
+ const completionAuth = auth(provider, {
709
+ authorizationCode: parameters.code,
710
+ fetchFn,
711
+ ...(parameters.iss === undefined ? {} : { iss: parameters.iss }),
712
+ serverUrl: options.serverUrl,
713
+ ...(scope === undefined || scope.length === 0 ? {} : { scope }),
714
+ });
715
+ authCalls.push(completionAuth);
716
+ const completed = await Promise.race([completionAuth, abortFailure]);
717
+ if (completed !== "AUTHORIZED") {
718
+ return { error: new McpOAuthError("authorization_failed", options.serverId), ok: false };
719
+ }
720
+ await provider.invalidateCredentials("verifier");
721
+ return { ok: true };
722
+ } catch (cause) {
723
+ if (signal.aborted) {
724
+ return { error: mcpOAuthAbortError(signal, timeoutSignal, options.serverId), ok: false };
725
+ }
726
+ if (cause instanceof McpOAuthError) return { error: cause, ok: false };
727
+ return {
728
+ error: new McpOAuthError(
729
+ cause instanceof McpOAuthPersistenceError ? "store_failed" : "authorization_failed",
730
+ options.serverId,
731
+ ),
732
+ ok: false,
733
+ };
734
+ } finally {
735
+ controller.abort();
736
+ await Promise.allSettled(authCalls);
737
+ await closeServer(callbackServer);
738
+ authorizationActive = false;
739
+ }
740
+ }