Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 6 additions & 17 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<typeof MCP.BrowserOpenFailed>
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") {
Expand Down Expand Up @@ -307,7 +297,6 @@ export const McpAuthCommand = effectCmd({
prompts.log.error(error instanceof Error ? error.message : String(error))
}),
),
Effect.ensuring(unsubscribe),
)

prompts.outro("Done")
Expand Down
11 changes: 9 additions & 2 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ export interface Interface {
readonly startAuth: (
mcpName: string,
) => Effect.Effect<{ authorizationUrl: string; oauthState: string }, NotFoundError>
readonly authenticate: (mcpName: string) => Effect.Effect<Status, NotFoundError>
readonly authenticate: (
mcpName: string,
onAuthorization?: (authorizationUrl: string) => void,
) => Effect.Effect<Status, NotFoundError>
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status, NotFoundError>
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean, NotFoundError>
Expand Down Expand Up @@ -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
Expand All @@ -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) =>
Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/test/mcp/oauth-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string>()
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" })
Expand Down
Loading