pi-mcp-adapter 2.22.0 → 2.23.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,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.23.0] - 2026-08-11
11
+
12
+ ### Added
13
+ - Added interactive callback URL pasting to `/mcp-auth` for OAuth flows running on remote or headless machines. Thanks @trevorleibert-mixpanel for PR #330.
14
+
15
+ ### Fixed
16
+ - Stopped load-time MCP initialization from printing a TUI startup error when Pi action methods are not bound yet. Thanks @21307369 for issue #327.
17
+ - Kept interactive OAuth authorization URLs clickable as a single terminal hyperlink. Thanks @rfccg for PR #329.
18
+
10
19
  ## [2.22.0] - 2026-08-11
11
20
 
12
21
  ### Added
package/README.md CHANGED
@@ -265,7 +265,9 @@ The adapter owns only its client socket and closes that connection when the Pi r
265
265
 
266
266
  ### Remote/headless OAuth
267
267
 
268
- If Pi is running on a remote server and cannot open a local browser, start OAuth through the proxy tool. Persistent OAuth still requires an available OS credential store; on headless Linux that usually means an unlocked Secret Service/libsecret keyring. The adapter fails closed instead of falling back to plaintext credentials when the secure store is unavailable.
268
+ If Pi is running on a remote server, `/mcp-auth <server>` prints the authorization URL and opens a callback input. Open the URL in your local browser. After approval, the browser may fail to load the localhost callback page because localhost refers to your workstation; copy the full URL from its address bar and paste it into Pi. The input closes automatically instead when the browser can reach Pi's callback directly.
269
+
270
+ The same flow is available through the proxy tool for non-interactive clients. Persistent OAuth still requires an available OS credential store; on headless Linux that usually means an unlocked Secret Service/libsecret keyring. The adapter fails closed instead of falling back to plaintext credentials when the secure store is unavailable.
269
271
 
270
272
  On Linux, if credential access fails because Pi inherited a revoked session keyring, the adapter uses a best-effort recovery path through `keyctl session - node <packaged helper>` so explicit re-authentication can write fresh credentials without killing a long-lived tmux server. This path requires `keyctl` and `node` on `PATH`; missing, locked, or otherwise unavailable credential stores still fail closed.
271
273
 
package/commands.ts CHANGED
@@ -25,6 +25,10 @@ import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markS
25
25
  import { openPath, resolveServerUrl, sanitizeTerminalText } from "./utils.ts";
26
26
  import { isAbortError } from "./runtime-owner.ts";
27
27
 
28
+ function terminalHyperlink(label: string, url: string): string {
29
+ return `\u001B]8;;${sanitizeTerminalText(url)}\u001B\\${sanitizeTerminalText(label)}\u001B]8;;\u001B\\`;
30
+ }
31
+
28
32
  export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
29
33
  if (!ctx.hasUI) return;
30
34
 
@@ -273,11 +277,17 @@ export async function authenticateServer(
273
277
  ...(authStorageOptions.baseDir ? { authStorageOptions } : {}),
274
278
  onAuthorizationUrl: (authorizationUrl) => {
275
279
  ui.notify(
276
- `Open this URL to authenticate ${serverName}:\n\n${authorizationUrl}\n\n` +
277
- "After approving, return to Pi; the local callback will complete automatically.",
280
+ `Open this URL to authenticate ${serverName}:\n\n${terminalHyperlink(authorizationUrl, authorizationUrl)}\n\n` +
281
+ "After approving, Pi will complete automatically if the browser can reach its localhost callback. " +
282
+ "On a remote machine, copy the full localhost URL from the browser address bar and paste it into Pi.",
278
283
  "info"
279
284
  );
280
285
  },
286
+ onAuthorizationInput: (_authorizationUrl, inputSignal) => ui.input(
287
+ `Complete ${serverName} OAuth`,
288
+ "Paste the full callback URL, or wait for automatic completion",
289
+ { signal: inputSignal },
290
+ ),
281
291
  ...(signal ? { signal } : {}),
282
292
  ...(runtime ? { runtime } : {}),
283
293
  });
package/index.ts CHANGED
@@ -169,13 +169,23 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
169
169
  return resolveDirectTools(config, cache, prefix, envDirectToolOverride);
170
170
  }
171
171
 
172
+ function getActiveToolsIfReady(): string[] | undefined {
173
+ try {
174
+ return pi.getActiveTools?.();
175
+ } catch (error) {
176
+ if (error instanceof Error
177
+ && error.message.includes("Action methods cannot be called during extension loading")) return undefined;
178
+ throw error;
179
+ }
180
+ }
181
+
172
182
  function deactivateTools(toolNames: string[]): string[] {
173
183
  if (toolNames.length === 0) return [];
174
184
  const unregisterTool = (pi as ExtensionAPI & { unregisterTool?: (name: string) => boolean }).unregisterTool;
175
185
  const unregistered = toolNames.filter((toolName) => unregisterTool?.(toolName) === true);
176
186
  const fallbackNames = toolNames.filter((toolName) => !unregistered.includes(toolName));
177
187
  const remove = new Set(toolNames);
178
- const activeTools = pi.getActiveTools?.();
188
+ const activeTools = getActiveToolsIfReady();
179
189
  if (!activeTools || activeTools.length === 0) {
180
190
  for (const toolName of fallbackNames) fallbackDeactivatedTools.add(toolName);
181
191
  return unregistered;
@@ -207,7 +217,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
207
217
  registerDirectTool(spec);
208
218
  registeredDirectTools.set(spec.prefixedName, fingerprint);
209
219
  if (fallbackDeactivatedTools.delete(spec.prefixedName)) {
210
- const activeTools = pi.getActiveTools?.();
220
+ const activeTools = getActiveToolsIfReady();
211
221
  if (activeTools && !activeTools.includes(spec.prefixedName)) {
212
222
  pi.setActiveTools([...activeTools, spec.prefixedName]);
213
223
  }
@@ -849,7 +859,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
849
859
  registerProxyTool(description);
850
860
  return;
851
861
  }
852
- const activeTools = pi.getActiveTools?.();
862
+ const activeTools = getActiveToolsIfReady();
853
863
  if (activeTools && !activeTools.includes("mcp")) {
854
864
  pi.setActiveTools([...activeTools, "mcp"]);
855
865
  }
package/mcp-auth-flow.ts CHANGED
@@ -49,6 +49,10 @@ export interface McpOAuthRuntime {
49
49
 
50
50
  export interface AuthenticateOptions {
51
51
  onAuthorizationUrl?: (authorizationUrl: string) => void | Promise<void>
52
+ onAuthorizationInput?: (
53
+ authorizationUrl: string,
54
+ signal: AbortSignal,
55
+ ) => Promise<string | undefined>
52
56
  authStorageOptions?: AuthStorageOptions
53
57
  signal?: AbortSignal
54
58
  runtime?: McpOAuthRuntime
@@ -568,6 +572,53 @@ export function parseAuthorizationCodeInput(input: string, expectedState?: strin
568
572
  return parseAuthorizationRedirectInput(input, expectedState).code
569
573
  }
570
574
 
575
+ type AuthorizationResponse = {
576
+ input: AuthorizationCodeInput
577
+ source: "callback" | "manual"
578
+ }
579
+
580
+ /**
581
+ * Wait for either the localhost callback or a manually pasted redirect URL.
582
+ * The manual input prompt is dismissed as soon as either path finishes.
583
+ */
584
+ export async function waitForAuthorizationResponse(
585
+ callbackPromise: Promise<AuthorizationCodeInput>,
586
+ authorizationUrl: string,
587
+ expectedState: string,
588
+ onAuthorizationInput?: AuthenticateOptions["onAuthorizationInput"],
589
+ signal?: AbortSignal,
590
+ ): Promise<AuthorizationResponse> {
591
+ if (!onAuthorizationInput) {
592
+ return {
593
+ input: await abortable(callbackPromise, signal),
594
+ source: "callback",
595
+ }
596
+ }
597
+
598
+ const inputController = new AbortController()
599
+ try {
600
+ const response = await abortable(Promise.race([
601
+ callbackPromise.then((input) => ({ input, source: "callback" as const })),
602
+ onAuthorizationInput(authorizationUrl, inputController.signal).then((input) => ({
603
+ input,
604
+ source: "manual" as const,
605
+ })),
606
+ ]), signal)
607
+
608
+ if (response.source === "callback") return response
609
+ if (!response.input?.trim()) throw new Error("OAuth authentication cancelled")
610
+ if (!getSearchParamsFromInput(response.input.trim())) {
611
+ throw new Error("Paste the full OAuth callback URL, including its code and state parameters")
612
+ }
613
+ return {
614
+ input: parseAuthorizationRedirectInput(response.input, expectedState),
615
+ source: "manual",
616
+ }
617
+ } finally {
618
+ inputController.abort()
619
+ }
620
+ }
621
+
571
622
  /**
572
623
  * Complete OAuth authentication from manual user input.
573
624
  */
@@ -728,12 +779,22 @@ export async function authenticate(
728
779
  console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
729
780
  }
730
781
 
731
- const callbackResult = await abortable(callbackPromise, signal)
782
+ const authorizationResponse = await waitForAuthorizationResponse(
783
+ callbackPromise,
784
+ authorizationUrl,
785
+ oauthState,
786
+ options.onAuthorizationInput,
787
+ signal,
788
+ )
789
+ if (authorizationResponse.source === "manual") {
790
+ cancelPendingCallback(oauthState)
791
+ }
732
792
 
733
- // The callback server accepted only the flow-local reserved state.
793
+ // The callback server accepted only the flow-local reserved state. Manual
794
+ // input is checked against the same state before token exchange.
734
795
  throwIfAborted(signal)
735
796
 
736
- return await completeAuth(serverName, callbackResult, {
797
+ return await completeAuth(serverName, authorizationResponse.input, {
737
798
  ...options,
738
799
  ...(signal ? { signal } : {}),
739
800
  runtime,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.22.0",
3
+ "version": "2.23.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",