Merge pull request #2160: fix(mcp): accept manual OAuth redirect input

This commit is contained in:
can1357
2026-06-10 08:26:58 +02:00
5 changed files with 88 additions and 3 deletions
+4
View File
@@ -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();