Skip to content
Open
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
17 changes: 17 additions & 0 deletions packages/app/src/context/global-sync/mcp-auth-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Tracks MCP servers whose authorization URL was just opened via a direct
// window.open() from the "authenticate" click response, so the SSE-driven
// toast fallback (context/notification.tsx) can skip showing a redundant
// prompt for the same attempt.
const recentlyOpened = new Map<string, number>()
const TTL_MS = 10_000

export function markAuthorizationUrlOpened(mcpName: string) {
recentlyOpened.set(mcpName, Date.now())
}

export function consumeRecentlyOpened(mcpName: string) {
const at = recentlyOpened.get(mcpName)
if (at === undefined) return false
recentlyOpened.delete(mcpName)
return Date.now() - at <= TTL_MS
}
1 change: 1 addition & 0 deletions packages/app/src/context/global-sync/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ describe("toggleMcp", () => {
test("runs the status action before refreshing the owning query", async () => {
const calls: string[] = []
const input = (status: "connected" | "needs_auth" | "disabled") => ({
name: "test-mcp",
status,
connect: async () => {
calls.push("connect")
Expand Down
12 changes: 10 additions & 2 deletions packages/app/src/context/global-sync/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
import { markAuthorizationUrlOpened } from "./mcp-auth-tracker"

export async function toggleMcp(input: {
name: string
status: McpStatus["status"]
connect: () => Promise<void>
disconnect: () => Promise<void>
authenticate: () => Promise<void>
authenticate: () => Promise<{ authorizationUrl: string } | void>
refresh: () => Promise<void>
}) {
await {
// Mark before the request goes out: the mcp.browser.open.failed SSE event
// (published server-side as part of the same authenticate() call) can reach
// an already-open event stream before this request's own response comes
// back, so marking after the response would lose the race.
if (input.status === "needs_auth") markAuthorizationUrlOpened(input.name)
const result = await {
connected: input.disconnect,
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
needs_client_registration: input.connect,
}[input.status]()
if (result?.authorizationUrl) window.open(result.authorizationUrl, "_blank")
await input.refresh()
}
26 changes: 26 additions & 0 deletions packages/app/src/context/notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { showToast } from "@/utils/toast"
import { consumeRecentlyOpened } from "./global-sync/mcp-auth-tracker"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
Expand Down Expand Up @@ -386,6 +388,30 @@ function createServerNotificationState(input: {

const unsub = serverSDK().event.listen((e) => {
const event = e.details

if (event.type === "mcp.browser.open.failed") {
const { mcpName, url } = event.properties
// The authenticate click already opened this URL directly via window.open()
// (context/global-sync/mcp.ts) — skip the redundant toast for that attempt.
if (consumeRecentlyOpened(mcpName)) return
showToast({
persistent: true,
title: `Authorize ${mcpName}`,
description: "Open the link in your browser to complete MCP authorization.",
actions: [
{
label: "Open in browser",
onClick: () => window.open(url, "_blank"),
},
{
label: language.t("common.dismiss"),
onClick: "dismiss",
},
],
})
return
}

if (event.type !== "session.idle" && event.type !== "session.error") return

const directory = e.name
Expand Down
8 changes: 7 additions & 1 deletion packages/app/src/context/server-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500

if (event.type === "mcp.tools.changed") {
void queryClient.refetchQueries(queryOptionsApi.mcp(key))
}

session.apply(event)

if (directory === "global") {
Expand Down Expand Up @@ -472,6 +476,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const sdk = sdkFor(key)
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
await toggleMcp({
name,
status,
connect: async () => {
await sdk.mcp.connect({ name })
Expand All @@ -480,7 +485,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
await sdk.mcp.disconnect({ name })
},
authenticate: async () => {
await sdk.mcp.auth.authenticate({ name })
const result = (await sdk.mcp.auth.authenticate({ name })).data
if (result && "authorizationUrl" in result) return { authorizationUrl: result.authorizationUrl }
},
refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,9 @@ export const McpAuthCommand = effectCmd({
).pipe(
Effect.tap((status) =>
Effect.sync(() => {
if (status.status === "connected") {
if ("authorizationUrl" in status) {
spinner.stop("Authorization started; completing in the background once you finish in the browser.")
} else if (status.status === "connected") {
spinner.stop("Authentication successful!")
} else if (status.status === "needs_client_registration") {
spinner.stop("Authentication failed", 1)
Expand Down
35 changes: 34 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export interface Interface {
readonly authenticate: (
mcpName: string,
onAuthorization?: (authorizationUrl: string) => void,
) => Effect.Effect<Status, NotFoundError>
) => Effect.Effect<Status | { authorizationUrl: string; oauthState: string }, 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 @@ -891,6 +891,39 @@ const layer = Layer.effect(
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
onAuthorization?.(result.authorizationUrl)

if (process.env.OPENCODE_PUBLIC_URL) {
// Running as a remote web server — no browser to open on the server.
// Emit the event as a fallback (in case a client is listening on /global/event),
// but don't rely on it: return the authorizationUrl directly in the response so
// the client can open it itself from the request that triggered this click.
console.log("[MCP OAuth] emitting BrowserOpenFailed", { mcpName, url: result.authorizationUrl })
yield* events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)

const bridge = yield* EffectBridge.make()
bridge.fork(
Effect.gen(function* () {
const code = yield* Effect.promise(() => callbackPromise)
const storedState = yield* auth.getOAuthState(mcpName)
if (storedState !== result.oauthState) {
yield* auth.clearOAuthState(mcpName)
yield* Effect.logWarning("MCP OAuth state mismatch - potential CSRF attack", { mcpName })
return
}
yield* auth.clearOAuthState(mcpName)
yield* finishAuth(mcpName, code)
// The client returned from authenticate() before this ran, so it has
// no way to know completion happened — nudge it to refetch status.
yield* events.publish(ToolsChanged, { server: mcpName }).pipe(Effect.ignore)
}).pipe(
Effect.catch((error) =>
Effect.logWarning("MCP OAuth background completion failed", { mcpName, error: String(error) }),
),
),
)

return { authorizationUrl: result.authorizationUrl, oauthState: result.oauthState }
}

yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe(
Effect.flatMap((subprocess) =>
Effect.callback<void, Error>((resume) => {
Expand Down
53 changes: 31 additions & 22 deletions packages/opencode/src/mcp/oauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"

const OAUTH_CALLBACK_HOST = "127.0.0.1"
const LOCALHOST_REDIRECT_RE = /^https?:\/\/(127\.0\.0\.1|localhost)([:\/]|$)/

// Current callback server configuration (may differ from defaults if custom redirectUri is used)
let currentPort = OAUTH_CALLBACK_PORT
Expand Down Expand Up @@ -39,6 +40,17 @@ function stopIfIdle() {
server = undefined
}

function settleAuth(state: string, settler: (p: PendingAuth) => void): boolean {
const pending = pendingAuths.get(state)
if (!pending) return false
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
settler(pending)
stopIfIdle()
return true
}

function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)

Expand All @@ -63,16 +75,9 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").

if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.reject(new Error(errorMsg))
}
settleAuth(state, (p) => p.reject(new Error(errorMsg)))
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
stopIfIdle()
return
}

Expand All @@ -82,28 +87,32 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
return
}

// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
const resolved = settleAuth(state, (p) => p.resolve(code))
if (!resolved) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
res.end(OauthCallbackPage.error("Invalid or expired state parameter - potential CSRF attack", { provider: "MCP" }))
return
}

const pending = pendingAuths.get(state)!

clearTimeout(pending.timeout)
pendingAuths.delete(state)
cleanupStateIndex(state)
pending.resolve(code)

res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(OauthCallbackPage.success({ provider: "MCP" }))
stopIfIdle()
}

/** Resolve a pending OAuth callback received by the main web server (OPENCODE_PUBLIC_URL path). */
export function resolveFromExternal(code: string, state: string): boolean {
return settleAuth(state, (p) => p.resolve(code))
}

/** Reject a pending OAuth callback received by the main web server with an error. */
export function rejectFromExternal(state: string, errorMessage: string): boolean {
return settleAuth(state, (p) => p.reject(new Error(errorMessage)))
}

export async function ensureRunning(redirectUri?: string): Promise<void> {
// Parse the redirect URI to get port and path (uses defaults if not provided)
// If the redirect URI points to a non-localhost host, the callback will be
// received by an external handler (e.g. the main web server via
// OPENCODE_PUBLIC_URL). No local server needed.
if (redirectUri && !LOCALHOST_REDIRECT_RE.test(redirectUri)) return

const { port, path } = parseRedirectUri(redirectUri)

// If server is running on a different port/path, stop it first
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/mcp/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
if (this.config.redirectUri) {
return this.config.redirectUri
}
const publicUrl = process.env.OPENCODE_PUBLIC_URL
if (publicUrl) {
return `${publicUrl.replace(/\/$/, "")}${OAUTH_CALLBACK_PATH}`
}
const port = this.config.callbackPort ?? OAUTH_CALLBACK_PORT
return `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}`
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,17 @@ export const McpApi = HttpApi.make("mcp")
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(MCP.Status, "OAuth authentication completed"),
success: described(
Schema.Union([MCP.Status, AuthStartResponse]),
"OAuth authentication completed, or started (authorizationUrl) when it must be opened client-side",
),
error: [UnsupportedOAuthError, McpServerNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.authenticate",
summary: "Authenticate MCP OAuth",
description: "Start OAuth flow and wait for callback (opens browser).",
description:
"Start OAuth flow. Returns the authorization URL immediately if the client must open it (e.g. no local browser); the callback is completed in the background.",
}),
),
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
Expand Down
28 changes: 28 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { Installation } from "@/installation"
import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { McpOAuthCallback } from "@/mcp/oauth-callback"
import { OAUTH_CALLBACK_PATH } from "@/mcp/oauth-provider"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
Expand Down Expand Up @@ -190,6 +193,30 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe
Layer.provide(authOnlyRouterLayer),
)

const oauthCallbackRoute = HttpRouter.use((router) =>
router.add("GET", OAUTH_CALLBACK_PATH, (request) => {
const url = new URL(request.url, "http://localhost")
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const provider = "MCP"
let html: string
if (error) {
const reason = url.searchParams.get("error_description") ?? error
McpOAuthCallback.rejectFromExternal(state ?? "", reason)
html = OauthCallbackPage.error(reason, { provider })
} else if (code && state) {
const resolved = McpOAuthCallback.resolveFromExternal(code, state)
html = resolved
? OauthCallbackPage.success({ provider })
: OauthCallbackPage.error("Invalid or expired state parameter", { provider })
} else {
html = OauthCallbackPage.error("Invalid or expired state parameter", { provider })
}
return Effect.succeed(HttpServerResponse.html(html))
}),
).pipe(Layer.provide(authOnlyRouterLayer))

const uiRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
Expand Down Expand Up @@ -277,6 +304,7 @@ export function createRoutes(
instanceRoutes,
serverRoutes,
docRoute,
oauthCallbackRoute,
uiRoute,
).pipe(
Layer.provide([
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ class ListenerServerService extends Context.Service<ListenerServerService, Liste
export const Default = lazy(() => {
const handler = HttpApiApp.webHandler().handler
const app: ServerApp = {
fetch: (request: Request) => handler(request, HttpApiApp.context),
fetch: (request: Request) => {
return handler(request, HttpApiApp.context)
},
request(input, init) {
return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/server/shared/public-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const PUBLIC_UI_PATHS = new Set<string>([
"/site.webmanifest",
"/web-app-manifest-192x192.png",
"/web-app-manifest-512x512.png",
"/mcp/oauth/callback",
])

export function isPublicUIPath(method: string, pathname: string) {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/server/shared/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ProxyUtil } from "../proxy-util"

let embeddedUIPromise: Promise<Record<string, string> | null> | undefined

export const UI_UPSTREAM = new URL("https://app.opencode.ai")
export const UI_UPSTREAM = new URL(process.env.OPENCODE_UI_UPSTREAM ?? "https://app.opencode.ai")

export const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:`
Expand Down
Loading