pi-mcp-adapter 2.30.0 → 2.31.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/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.31.0] - 2026-08-28
11
+
12
+ ### Highlights
13
+ - MCP Apps-aware servers can now recognize Pi as a UI-capable host and expose interactive resources.
14
+ - UI capability advertising works consistently across legacy and modern MCP protocol negotiation.
15
+ - Manual OAuth callback completion now supports HTTPS redirect URLs for pre-registered clients.
16
+
17
+ ### Added
18
+
19
+ - Pi now advertises MCP Apps UI support with the `io.modelcontextprotocol/ui` extension capability, so compatible servers can expose UI resources. Thanks to [@VikashLoomba](https://github.com/VikashLoomba) for #465.
20
+
21
+ ### Fixed
22
+
23
+ - Pre-registered OAuth clients can use HTTPS callback URLs through manual callback completion instead of being rejected as non-local redirects. Thanks to [@jluisrojas](https://github.com/jluisrojas) for PR #464.
24
+
10
25
  ## [2.30.0] - 2026-08-28
11
26
 
12
27
  ### Highlights
package/README.md CHANGED
@@ -272,7 +272,7 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
272
272
  | `oauth.clientId` | Pre-registered OAuth client ID. MCP 2026 prefers pre-registered clients or Client ID Metadata Documents; this adapter falls back to Dynamic Client Registration when the ID is omitted and the server supports it. |
273
273
  | `oauth.clientSecret` | OAuth client secret for confidential clients; a value beginning with `!` runs a command when OAuth authenticates, while `!!` escapes a literal leading `!` |
274
274
  | `oauth.scope` | Requested OAuth scopes |
275
- | `oauth.redirectUri` | Exact localhost redirect URI for browser OAuth, including port and path, for providers that pre-register callbacks |
275
+ | `oauth.redirectUri` | Exact redirect URI for browser OAuth. Local `http://` loopback URIs need an explicit port. Pre-registered `https://` callbacks use manual completion by pasting the full callback URL. |
276
276
  | `oauth.clientName` | Client display name advertised during Dynamic Client Registration fallback |
277
277
  | `oauth.clientUri` | Client homepage URI advertised during Dynamic Client Registration fallback. Defaults to `piConfig.clientUri` from the host's manifest when set, and is omitted rather than guessed under a rebranded host |
278
278
  | `oauth.logoUri` | Client logo URL advertised during Dynamic Client Registration fallback (RFC 7591 `logo_uri`). Must be an absolute `http(s)` URL — consent screens fetch it server-side, so local paths render nothing. Omitted from the registration request when unset |
@@ -304,7 +304,7 @@ Use `"2026-07-28"` to pin that revision. Pinning has no legacy or SSE fallback a
304
304
 
305
305
  The stable SDK handles era-specific request envelopes, result decoding, list-changed subscriptions, cancellation, and multi-round-trip sampling/elicitation. The adapter keeps strict OAuth issuer validation in every mode. Adapter-level roots support, standard MCP logging presentation, and configuration/UI for protocol cache hints are not yet implemented.
306
306
 
307
- For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact callback registered with the provider, for example `"http://localhost:3118/callback"`. Dynamic clients normally omit it and use a lazy OS-assigned localhost callback port.
307
+ For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact callback registered with the provider, for example `"http://localhost:3118/callback"`. Dynamic clients normally omit it and use a lazy OS-assigned localhost callback port. A configured `https://` callback runs in manual mode because the adapter cannot receive a callback on another host. After authorization, copy the full callback URL from the browser address bar and paste it into `/mcp-auth` or `mcp({ action: "auth-complete", ... })`.
308
308
 
309
309
  If an internal authorization server publishes mismatched OAuth metadata and cannot be fixed immediately, set `oauth.skipIssuerMetadataValidation: true` on that server only. This is security-weakening. It disables the RFC 8414 issuer echo check and should not be used for public or untrusted servers.
310
310
 
package/commands.ts CHANGED
@@ -297,7 +297,7 @@ export async function authenticateServer(
297
297
  return ui.input(
298
298
  `Complete ${serverName} OAuth\n\n` +
299
299
  `${terminalHyperlink("Open authorization page", authorizationUrl)}\n${authorizationUrl}\n\n` +
300
- "Approve access, then paste the full localhost callback URL below.",
300
+ "Approve access, then paste the full callback URL from the browser address bar below.",
301
301
  undefined,
302
302
  { signal: inputSignal },
303
303
  );
package/dist/types.d.ts CHANGED
@@ -228,7 +228,7 @@ export interface OAuthConfig {
228
228
  scope?: string;
229
229
  /** Extra authorization URL parameters for provider-specific extensions. Flow-owned parameters cannot be overridden. */
230
230
  authorizationParams?: Record<string, string>;
231
- /** Exact authorization-code redirect URI for pre-registered clients */
231
+ /** Exact authorization-code redirect URI for pre-registered clients. HTTPS redirects use manual callback URL completion. */
232
232
  redirectUri?: string;
233
233
  /** Client display name for dynamic registration */
234
234
  clientName?: string;
package/mcp-auth-flow.ts CHANGED
@@ -75,6 +75,8 @@ type PendingAuth = {
75
75
  authProvider: McpOAuthProvider
76
76
  serverUrl: string
77
77
  authorizationUrl: string
78
+ manualRedirect: boolean
79
+ manualCompletionController?: AbortController
78
80
  discovery: AuthDiscovery
79
81
  authStorageOptions: AuthStorageOptions
80
82
  }
@@ -307,7 +309,11 @@ async function probeAuthDiscovery(serverUrl: string, definition?: ServerEntry, s
307
309
  }
308
310
  }
309
311
 
310
- function parseOAuthRedirectUri(redirectUri: string): { port: number; callbackHost: string; callbackPath: string } {
312
+ type OAuthRedirectTarget =
313
+ | { mode: "local"; port: number; callbackHost: string; callbackPath: string }
314
+ | { mode: "manual" }
315
+
316
+ function parseOAuthRedirectUri(redirectUri: string): OAuthRedirectTarget {
311
317
  let url: URL
312
318
  try {
313
319
  url = new URL(redirectUri)
@@ -315,12 +321,6 @@ function parseOAuthRedirectUri(redirectUri: string): { port: number; callbackHos
315
321
  throw new Error(`Invalid OAuth redirectUri: ${redirectUri}`, { cause: error })
316
322
  }
317
323
 
318
- const hostname = url.hostname.toLowerCase()
319
- const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"
320
- if (url.protocol !== "http:" || !isLocalhost) {
321
- throw new Error("OAuth redirectUri must be an http:// localhost or loopback URI")
322
- }
323
-
324
324
  if (url.username || url.password) {
325
325
  throw new Error("OAuth redirectUri must not include username or password")
326
326
  }
@@ -329,17 +329,32 @@ function parseOAuthRedirectUri(redirectUri: string): { port: number; callbackHos
329
329
  throw new Error("OAuth redirectUri must not include a fragment")
330
330
  }
331
331
 
332
+ const hostname = url.hostname.toLowerCase()
333
+ const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"
334
+ if (url.port) {
335
+ const parsedPort = Number.parseInt(url.port, 10)
336
+ if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
337
+ throw new Error("OAuth redirectUri port must be a positive numeric port")
338
+ }
339
+ }
340
+ if (url.protocol === "https:" && !isLocalhost) {
341
+ return { mode: "manual" }
342
+ }
343
+ if (url.protocol !== "http:" || !isLocalhost) {
344
+ throw new Error("OAuth redirectUri must be an https:// URI or an http:// localhost or loopback URI")
345
+ }
346
+
332
347
  if (!url.port) {
333
- throw new Error("OAuth redirectUri must include an explicit numeric port")
348
+ throw new Error("OAuth localhost redirectUri must include an explicit numeric port")
334
349
  }
335
350
 
336
351
  const port = Number.parseInt(url.port, 10)
337
352
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
338
- throw new Error("OAuth redirectUri must include an explicit numeric port")
353
+ throw new Error("OAuth localhost redirectUri must include an explicit numeric port")
339
354
  }
340
355
 
341
356
  const callbackHost = hostname === "[::1]" ? "::1" : hostname
342
- return { port, callbackHost, callbackPath: url.pathname }
357
+ return { mode: "local", port, callbackHost, callbackPath: url.pathname }
343
358
  }
344
359
 
345
360
  /**
@@ -393,25 +408,30 @@ export async function startAuth(
393
408
  return { authorizationUrl: existingPendingAuth.authorizationUrl }
394
409
  }
395
410
 
396
- const redirectCallback = config.redirectUri !== undefined ? parseOAuthRedirectUri(config.redirectUri) : undefined
411
+ const redirectTarget = config.redirectUri !== undefined ? parseOAuthRedirectUri(config.redirectUri) : undefined
412
+ const manualRedirect = redirectTarget?.mode === "manual"
397
413
  const oauthState = generateState()
398
414
 
399
- try {
400
- await ensureCallbackServer({
401
- strictPort: Boolean(config.clientId) || config.redirectUri !== undefined,
402
- oauthState,
403
- reserveState: true,
404
- ...(redirectCallback ? { port: redirectCallback.port, callbackHost: redirectCallback.callbackHost, callbackPath: redirectCallback.callbackPath } : {}),
405
- })
406
- throwIfAborted(signal)
407
- } catch (error) {
408
- releaseCallbackServer(oauthState)
415
+ if (!manualRedirect) {
409
416
  try {
410
- await cleanupAndReleaseCallbackServerIfIdle(() => clearOAuthState(serverName, authStorageOptions))
411
- } catch (cleanupError) {
412
- throw new AggregateError([error, cleanupError], "OAuth startup cleanup failed")
417
+ await ensureCallbackServer({
418
+ strictPort: Boolean(config.clientId) || config.redirectUri !== undefined,
419
+ oauthState,
420
+ reserveState: true,
421
+ ...(redirectTarget?.mode === "local"
422
+ ? { port: redirectTarget.port, callbackHost: redirectTarget.callbackHost, callbackPath: redirectTarget.callbackPath }
423
+ : {}),
424
+ })
425
+ throwIfAborted(signal)
426
+ } catch (error) {
427
+ releaseCallbackServer(oauthState)
428
+ try {
429
+ await cleanupAndReleaseCallbackServerIfIdle(() => clearOAuthState(serverName, authStorageOptions))
430
+ } catch (cleanupError) {
431
+ throw new AggregateError([error, cleanupError], "OAuth startup cleanup failed")
432
+ }
433
+ throw error
413
434
  }
414
- throw error
415
435
  }
416
436
 
417
437
  let capturedUrl: URL | undefined
@@ -455,7 +475,16 @@ export async function startAuth(
455
475
  if (!capturedUrl) {
456
476
  throw new UnauthorizedError("OAuth authorization URL was not provided")
457
477
  }
458
- await setPendingAuth(runtime, serverName, { serverName, authProvider, serverUrl, authorizationUrl: capturedUrl.toString(), discovery, authStorageOptions }, oauthState, signal, generation)
478
+ await setPendingAuth(runtime, serverName, {
479
+ serverName,
480
+ authProvider,
481
+ serverUrl,
482
+ authorizationUrl: capturedUrl.toString(),
483
+ manualRedirect,
484
+ ...(manualRedirect ? { manualCompletionController: new AbortController() } : {}),
485
+ discovery,
486
+ authStorageOptions,
487
+ }, oauthState, signal, generation)
459
488
  return { authorizationUrl: capturedUrl.toString() }
460
489
  } catch (error) {
461
490
  authProvider.deactivate()
@@ -484,7 +513,13 @@ async function setPendingAuth(
484
513
  state.pendingAuths.set(key, pendingAuth)
485
514
  state.pendingAuthStates.set(key, oauthState)
486
515
  const cleanupTimer = setTimeout(() => {
487
- void clearPendingAuthAndReleaseIfIdle(runtime, serverName, oauthState, pendingAuth.authStorageOptions).catch(error => {
516
+ void clearPendingAuthAndReleaseIfIdle(
517
+ runtime,
518
+ serverName,
519
+ oauthState,
520
+ pendingAuth.authStorageOptions,
521
+ new Error("OAuth authorization timeout - authorization took too long"),
522
+ ).catch(error => {
488
523
  console.error(`MCP Auth: Timed-out flow cleanup failed: ${formatTerminalError(error)}`)
489
524
  })
490
525
  }, MANUAL_AUTH_TIMEOUT_MS)
@@ -492,7 +527,13 @@ async function setPendingAuth(
492
527
  state.pendingAuthCleanupTimers.set(key, cleanupTimer)
493
528
  }
494
529
 
495
- async function clearPendingAuth(runtime: McpOAuthRuntime, serverName: string, oauthState?: string, fallbackStorageOptions: AuthStorageOptions = {}): Promise<void> {
530
+ async function clearPendingAuth(
531
+ runtime: McpOAuthRuntime,
532
+ serverName: string,
533
+ oauthState?: string,
534
+ fallbackStorageOptions: AuthStorageOptions = {},
535
+ reason: Error = new Error("Authorization cancelled"),
536
+ ): Promise<void> {
496
537
  const state = getRuntimeState(runtime)
497
538
  const key = getPendingAuthKey(serverName, fallbackStorageOptions)
498
539
  const pendingAuth = state.pendingAuths.get(key)
@@ -506,6 +547,7 @@ async function clearPendingAuth(runtime: McpOAuthRuntime, serverName: string, oa
506
547
  state.pendingAuthCleanupTimers.delete(key)
507
548
  }
508
549
 
550
+ pendingAuth?.manualCompletionController?.abort(reason)
509
551
  pendingAuth?.authProvider.deactivate()
510
552
  state.pendingAuths.delete(key)
511
553
  state.pendingAuthStates.delete(key)
@@ -524,9 +566,10 @@ async function clearPendingAuthAndReleaseIfIdle(
524
566
  serverName: string,
525
567
  oauthState: string | undefined,
526
568
  fallbackStorageOptions: AuthStorageOptions = {},
569
+ reason?: Error,
527
570
  ): Promise<void> {
528
571
  await cleanupAndReleaseCallbackServerIfIdle(
529
- () => clearPendingAuth(runtime, serverName, oauthState, fallbackStorageOptions),
572
+ () => clearPendingAuth(runtime, serverName, oauthState, fallbackStorageOptions, reason),
530
573
  )
531
574
  }
532
575
 
@@ -629,6 +672,19 @@ type AuthorizationResponse = {
629
672
  source: "callback" | "manual"
630
673
  }
631
674
 
675
+ function waitForManualCompletionCancellation(signal: AbortSignal): Promise<AuthorizationCodeInput> {
676
+ return new Promise((_, reject) => {
677
+ const rejectFromSignal = () => {
678
+ reject(signal.reason instanceof Error ? signal.reason : new Error("Authorization cancelled"))
679
+ }
680
+ if (signal.aborted) {
681
+ rejectFromSignal()
682
+ return
683
+ }
684
+ signal.addEventListener("abort", rejectFromSignal, { once: true })
685
+ })
686
+ }
687
+
632
688
  /**
633
689
  * Wait for either the localhost callback or a manually pasted redirect URL.
634
690
  * The manual input prompt is dismissed as soon as either path finishes.
@@ -686,7 +742,11 @@ export async function completeAuthFromInput(
686
742
  throwIfAborted(signal)
687
743
  const key = getPendingAuthKey(serverName, fallbackAuthStorageOptions)
688
744
  const oauthState = runtimeState.pendingAuthStates.get(key)
745
+ const pendingAuth = runtimeState.pendingAuths.get(key)
689
746
  throwIfAborted(signal)
747
+ if (pendingAuth?.manualRedirect && !getSearchParamsFromInput(input.trim())) {
748
+ throw new Error("Paste the full OAuth callback URL, including its code and state parameters")
749
+ }
690
750
  const parsed = parseAuthorizationRedirectInput(input, oauthState)
691
751
  return completeAuth(serverName, parsed, options)
692
752
  }
@@ -805,15 +865,28 @@ export async function authenticate(
805
865
  try {
806
866
  // Get the state that was already generated and stored in startAuth().
807
867
  // Keep this lookup and its abort check inside the cleanup boundary because
808
- // startAuth has already reserved callback state at this point.
868
+ // startAuth already owns the pending flow at this point.
809
869
  oauthState = runtimeState.pendingAuthStates.get(getPendingAuthKey(serverName, authStorageOptions))
810
870
  throwIfAborted(signal)
811
871
  if (!oauthState) {
812
872
  throw new Error("OAuth state not found - this should not happen")
813
873
  }
814
874
 
815
- // Register the callback BEFORE opening the browser.
816
- const callbackPromise = waitForCallback(oauthState)
875
+ const pendingAuth = runtimeState.pendingAuths.get(getPendingAuthKey(serverName, authStorageOptions))
876
+ if (!pendingAuth) {
877
+ throw new Error(`No pending OAuth flow for server: ${serverName}`)
878
+ }
879
+ if (pendingAuth.manualRedirect && !options.onAuthorizationInput) {
880
+ throw new Error(
881
+ `OAuth for ${serverName} uses a remote redirect URI. Complete it with auth-start/auth-complete or /mcp-auth.`,
882
+ )
883
+ }
884
+
885
+ // Register the localhost callback before opening the browser. Remote
886
+ // pre-registered callbacks are completed by pasting their full URL.
887
+ const callbackPromise: Promise<AuthorizationCodeInput> = pendingAuth.manualRedirect
888
+ ? waitForManualCompletionCancellation(pendingAuth.manualCompletionController!.signal)
889
+ : waitForCallback(oauthState)
817
890
  void callbackPromise.catch(() => {})
818
891
 
819
892
  // Open browser. Always surface the URL first so remote/headless users can copy it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.30.0",
3
+ "version": "2.31.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
package/proxy-modes.ts CHANGED
@@ -143,22 +143,30 @@ function getAuthFailedMessage(state: McpExtensionState, serverName: string, mess
143
143
  return `OAuth authentication failed for "${serverName}": ${message}. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`;
144
144
  }
145
145
 
146
- function getRedirectPort(authorizationUrl: string): number | undefined {
146
+ function getRedirectDetails(authorizationUrl: string): { port?: number; remote: boolean } {
147
147
  try {
148
148
  const redirectUri = new URL(authorizationUrl).searchParams.get("redirect_uri");
149
- if (!redirectUri) return undefined;
150
- const port = Number.parseInt(new URL(redirectUri).port, 10);
151
- return Number.isInteger(port) ? port : undefined;
149
+ if (!redirectUri) return { remote: false };
150
+ const redirect = new URL(redirectUri);
151
+ const hostname = redirect.hostname.toLowerCase();
152
+ const local = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
153
+ const port = Number.parseInt(redirect.port, 10);
154
+ return {
155
+ ...(Number.isInteger(port) ? { port } : {}),
156
+ remote: !local,
157
+ };
152
158
  } catch {
153
- return undefined;
159
+ return { remote: false };
154
160
  }
155
161
  }
156
162
 
157
163
  function formatManualAuthInstructions(serverName: string, authorizationUrl: string): string {
158
- const port = getRedirectPort(authorizationUrl);
159
- const portNote = port
160
- ? `\nThe redirect URL will use local port ${port}. On a remote server it is expected for that localhost page to fail locally; copy the address bar URL anyway.`
161
- : "";
164
+ const redirect = getRedirectDetails(authorizationUrl);
165
+ const redirectNote = redirect.remote
166
+ ? "The provider uses a pre-registered HTTPS callback. Copy its full URL from the browser address bar, even if the destination page reports an error."
167
+ : redirect.port
168
+ ? `The redirect URL will use local port ${redirect.port}. On a remote server it is expected for that localhost page to fail locally; copy the address bar URL anyway.`
169
+ : "";
162
170
 
163
171
  return [
164
172
  `MCP OAuth required for "${serverName}".`,
@@ -167,11 +175,13 @@ function formatManualAuthInstructions(serverName: string, authorizationUrl: stri
167
175
  "",
168
176
  authorizationUrl,
169
177
  "",
170
- "After approving, copy the full redirected localhost URL from your browser address bar and send it back with:",
178
+ "After approving, copy the full callback URL from your browser address bar and send it back with:",
171
179
  `mcp({ action: "auth-complete", server: "${serverName}", args: { redirectUrl: "PASTE_REDIRECT_URL_HERE" } })`,
172
180
  "",
173
- 'You can also pass just the `code` query parameter as `args: { code: "PASTE_CODE_HERE" }`. JSON-string args remain supported.',
174
- portNote.trimEnd(),
181
+ redirect.remote
182
+ ? "Remote HTTPS callbacks must include the full callback URL so the OAuth state can be checked. JSON-string args remain supported."
183
+ : 'You can also pass just the `code` query parameter as `args: { code: "PASTE_CODE_HERE" }`. JSON-string args remain supported.',
184
+ redirectNote,
175
185
  ].filter(Boolean).join("\n");
176
186
  }
177
187
 
package/server-manager.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import { resolveNpxBinary } from "./npx-resolver.ts";
35
35
  import { createJsonSchemaValidator } from "./json-schema-validator.ts";
36
36
  import { logger } from "./logger.ts";
37
+ import { RESOURCE_MIME_TYPE } from "./ui-app-bridge-helpers.ts";
37
38
  import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
38
39
  import { extractOAuthConfig, supportsOAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
39
40
  import { invalidateAuthEntryCache, type AuthStorageOptions } from "./mcp-auth.ts";
@@ -725,6 +726,11 @@ export class McpServerManager {
725
726
 
726
727
  private buildClientCapabilities() {
727
728
  return {
729
+ extensions: {
730
+ "io.modelcontextprotocol/ui": {
731
+ mimeTypes: [RESOURCE_MIME_TYPE],
732
+ },
733
+ },
728
734
  ...(this.samplingConfig ? { sampling: {} } : {}),
729
735
  ...(this.elicitationConfig
730
736
  ? {
package/types.ts CHANGED
@@ -378,7 +378,7 @@ export interface OAuthConfig {
378
378
  scope?: string;
379
379
  /** Extra authorization URL parameters for provider-specific extensions. Flow-owned parameters cannot be overridden. */
380
380
  authorizationParams?: Record<string, string>;
381
- /** Exact authorization-code redirect URI for pre-registered clients */
381
+ /** Exact authorization-code redirect URI for pre-registered clients. HTTPS redirects use manual callback URL completion. */
382
382
  redirectUri?: string;
383
383
  /** Client display name for dynamic registration */
384
384
  clientName?: string;