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 +9 -0
- package/README.md +3 -1
- package/commands.ts +12 -2
- package/index.ts +13 -3
- package/mcp-auth-flow.ts +64 -3
- package/package.json +1 -1
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
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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,
|
|
797
|
+
return await completeAuth(serverName, authorizationResponse.input, {
|
|
737
798
|
...options,
|
|
738
799
|
...(signal ? { signal } : {}),
|
|
739
800
|
runtime,
|