Merge pull request #2160: fix(mcp): accept manual OAuth redirect input
This commit is contained in:
@@ -137,6 +137,10 @@
|
||||
- Fixed Windows stdio MCP servers launched through PATH shims such as `codegraph.cmd` so bare commands like `codegraph` resolve via `PATHEXT` before spawn ([#2174](https://github.com/can1357/oh-my-pi/issues/2174)).
|
||||
- Fixed compiled-binary extensions failing to load `@oh-my-pi/pi-*` packages when `bun --compile` quietly dropped one of the extra entrypoints (observed on macOS arm64 release builds): the legacy-pi compat shim's package-root override branch returned the bunfs path without checking the target was present, so the rewrite emitted a `file://` URL to a missing module and the #1216 fallback (scoped to the throwing `getResolvedSpecifier` path) never ran. Override targets are now validated against the on-disk filesystem at module init, missing entries are dropped, and resolution falls through to canonical lookup so Bun resolves the import from the extension's own `node_modules` ([#2168](https://github.com/can1357/oh-my-pi/issues/2168)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed MCP OAuth flows accepting pasted redirect URLs or authorization codes through `/login` in headless environments ([#2122](https://github.com/can1357/oh-my-pi/issues/2122)).
|
||||
|
||||
## [15.10.8] - 2026-06-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -46,9 +46,14 @@ import { theme } from "../theme/theme";
|
||||
import type { InteractiveModeContext } from "../types";
|
||||
import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
const MCP_MANUAL_INPUT_PROVIDER_ID = "mcp";
|
||||
const MCP_MANUAL_LOGIN_TIP = "Headless? Paste the redirect URL or code with /login <value>.";
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string, onTimeout?: () => void): Promise<T> {
|
||||
const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
|
||||
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
const timer = setTimeout(() => {
|
||||
onTimeout?.();
|
||||
reject(new Error(message));
|
||||
}, timeoutMs);
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
@@ -591,6 +596,13 @@ export class MCPCommandController {
|
||||
const resolvedClientId = clientId.trim() || parsedAuthUrl.searchParams.get("client_id") || undefined;
|
||||
const resolvedClientSecret = clientSecret.trim() || undefined;
|
||||
|
||||
const manualInput = this.ctx.oauthManualInput;
|
||||
if (manualInput.hasPending() && manualInput.pendingProviderId !== MCP_MANUAL_INPUT_PROVIDER_ID) {
|
||||
throw new Error(
|
||||
`OAuth login already in progress for ${manualInput.pendingProviderId}. Complete or cancel it before starting MCP OAuth.`,
|
||||
);
|
||||
}
|
||||
const oauthTimeout = new AbortController();
|
||||
try {
|
||||
// Create OAuth flow
|
||||
const flow = new MCPOAuthFlow(
|
||||
@@ -620,6 +632,7 @@ export class MCPCommandController {
|
||||
0,
|
||||
),
|
||||
);
|
||||
block.addChild(new Text(theme.fg("muted", MCP_MANUAL_LOGIN_TIP), 1, 0));
|
||||
block.addChild(new Spacer(1));
|
||||
block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
|
||||
// Try to open browser automatically
|
||||
@@ -644,11 +657,26 @@ export class MCPCommandController {
|
||||
onProgress: (message: string) => {
|
||||
this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
|
||||
},
|
||||
onManualCodeInput: () => {
|
||||
const pendingInput = manualInput.tryWaitForInput(MCP_MANUAL_INPUT_PROVIDER_ID);
|
||||
if (!pendingInput) {
|
||||
throw new Error(
|
||||
`OAuth login already in progress for ${manualInput.pendingProviderId}. Complete or cancel it before starting MCP OAuth.`,
|
||||
);
|
||||
}
|
||||
return pendingInput;
|
||||
},
|
||||
signal: oauthTimeout.signal,
|
||||
},
|
||||
);
|
||||
|
||||
// Execute OAuth flow with 5 minute timeout
|
||||
const credentials = await withTimeout(flow.login(), 5 * 60 * 1000, "OAuth flow timed out after 5 minutes");
|
||||
const credentials = await withTimeout(
|
||||
flow.login(),
|
||||
5 * 60 * 1000,
|
||||
"OAuth flow timed out after 5 minutes",
|
||||
() => oauthTimeout.abort("MCP OAuth flow timed out"),
|
||||
);
|
||||
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
@@ -687,6 +715,8 @@ export class MCPCommandController {
|
||||
} else {
|
||||
throw new Error(`OAuth authentication failed: ${errorMsg}`);
|
||||
}
|
||||
} finally {
|
||||
manualInput.clear("Manual MCP OAuth input cleared");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ export class OAuthManualInputManager {
|
||||
return promise;
|
||||
}
|
||||
|
||||
tryWaitForInput(providerId: string): Promise<string> | undefined {
|
||||
if (this.#pending) return undefined;
|
||||
return this.waitForInput(providerId);
|
||||
}
|
||||
|
||||
submit(input: string): boolean {
|
||||
if (!this.#pending) return false;
|
||||
const { resolve } = this.#pending;
|
||||
|
||||
@@ -346,4 +346,39 @@ describe("mcp oauth flow", () => {
|
||||
expect(flow.registeredClientSecret).toBeUndefined();
|
||||
expect(registrationCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts pasted redirect URLs through manual input", async () => {
|
||||
let tokenRequestBody = "";
|
||||
let manualAuthUrl = "";
|
||||
|
||||
const flow = new MCPOAuthFlow(
|
||||
{
|
||||
authorizationUrl: "https://provider.example/authorize",
|
||||
tokenUrl: "https://provider.example/token",
|
||||
clientId: "client-id",
|
||||
callbackPort: 14570,
|
||||
fetch: mockProviderTokenEndpoint(body => {
|
||||
tokenRequestBody = body;
|
||||
}),
|
||||
},
|
||||
{
|
||||
onAuth: info => {
|
||||
manualAuthUrl = info.url;
|
||||
},
|
||||
onManualCodeInput: async () => {
|
||||
const authUrl = new URL(manualAuthUrl);
|
||||
const redirectUri = authUrl.searchParams.get("redirect_uri") ?? "";
|
||||
const state = authUrl.searchParams.get("state") ?? "";
|
||||
return `${redirectUri}?code=manual-code&state=${encodeURIComponent(state)}`;
|
||||
},
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
},
|
||||
);
|
||||
|
||||
const credentials = await flow.login();
|
||||
const tokenParams = new URLSearchParams(tokenRequestBody);
|
||||
|
||||
expect(credentials.access).toBe("access-token");
|
||||
expect(tokenParams.get("code")).toBe("manual-code");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,17 @@ describe("OAuthManualInputManager", () => {
|
||||
expect(manager.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not replace pending input when using tryWaitForInput", async () => {
|
||||
const manager = new OAuthManualInputManager();
|
||||
const first = manager.waitForInput("openai-codex");
|
||||
|
||||
expect(manager.tryWaitForInput("mcp")).toBeUndefined();
|
||||
expect(manager.pendingProviderId).toBe("openai-codex");
|
||||
|
||||
expect(manager.submit("callback-url")).toBe(true);
|
||||
expect(await first).toBe("callback-url");
|
||||
});
|
||||
|
||||
it("returns false when no pending input", () => {
|
||||
const manager = new OAuthManualInputManager();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user