diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 06cb77bce135..b42651dc173e 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -19,8 +19,6 @@ import path from "path" import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" -import { EventV2Bridge } from "@/event-v2-bridge" -import { EventV2 } from "@opencode-ai/core/event" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -258,21 +256,13 @@ export const McpAuthCommand = effectCmd({ const spinner = prompts.spinner() spinner.start("Starting OAuth flow...") - // Subscribe to browser open failure events to show URL for manual opening - const events = yield* EventV2Bridge.Service - const unsubscribe = yield* events.listen((event) => { - if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void - const data = event.data as EventV2.Data - if (data.mcpName === serverName) { - spinner.stop("Could not open browser automatically") - prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(data.url) + yield* MCP.Service.use((mcp) => + mcp.authenticate(serverName, (url) => { + spinner.stop("Authorize in your browser:") + prompts.log.info(url) spinner.start("Waiting for authorization...") - } - return Effect.void - }) - - yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( + }), + ).pipe( Effect.tap((status) => Effect.sync(() => { if (status.status === "connected") { @@ -307,7 +297,6 @@ export const McpAuthCommand = effectCmd({ prompts.log.error(error instanceof Error ? error.message : String(error)) }), ), - Effect.ensuring(unsubscribe), ) prompts.outro("Done") diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index db673244b3d7..2355a89ad771 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -179,7 +179,10 @@ export interface Interface { readonly startAuth: ( mcpName: string, ) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError> - readonly authenticate: (mcpName: string) => Effect.Effect + readonly authenticate: ( + mcpName: string, + onAuthorization?: (authorizationUrl: string) => void, + ) => Effect.Effect readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect readonly removeAuth: (mcpName: string) => Effect.Effect readonly supportsOAuth: (mcpName: string) => Effect.Effect @@ -858,7 +861,10 @@ export const layer = Layer.effect( ) }) - const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) { + const authenticate = Effect.fn("MCP.authenticate")(function* ( + mcpName: string, + onAuthorization?: (authorizationUrl: string) => void, + ) { const result = yield* startAuth(mcpName) if (!result.authorizationUrl) { const client = "client" in result ? result.client : undefined @@ -882,6 +888,7 @@ export const layer = Layer.effect( } const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) + onAuthorization?.(result.authorizationUrl) yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( Effect.flatMap((subprocess) => diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index b0dfb06e6570..5797aef8d932 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -163,10 +163,10 @@ const trackBrowserOpenFailed = Effect.gen(function* () { return event }) -const authenticateScoped = (name: string) => +const authenticateScoped = (name: string, onAuthorization?: (authorizationUrl: string) => void) => Effect.gen(function* () { const mcp = yield* service - yield* mcp.authenticate(name).pipe( + yield* mcp.authenticate(name, onAuthorization).pipe( Effect.ignore, Effect.catchCause(() => Effect.void), Effect.forkScoped, @@ -225,12 +225,19 @@ mcpTest.instance( const opened = yield* trackBrowserOpen const event = yield* trackBrowserOpenFailed - yield* authenticateScoped("test-oauth-server-3") + const authorization = yield* Deferred.make() + yield* authenticateScoped("test-oauth-server-3", (url) => Deferred.doneUnsafe(authorization, Effect.succeed(url))) const url = yield* awaitWithTimeout(Deferred.await(opened), "Timed out waiting for open()", "5 seconds") + const authorizationUrl = yield* awaitWithTimeout( + Deferred.await(authorization), + "Timed out waiting for authorization URL", + "5 seconds", + ) const failure = yield* Deferred.await(event).pipe(Effect.timeoutOption("700 millis")) expect(failure).toEqual(Option.none()) + expect(authorizationUrl).toBe(url) expect(typeof url).toBe("string") expect(url).toContain("https://") expect(transportCalls.at(-1)?.options.requestInit?.headers).toEqual({ "X-Custom-Header": "custom-value" })