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
15 changes: 3 additions & 12 deletions packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import path from "path"
import { exec } from "child_process"
import * as prompts from "@clack/prompts"
import { map, pipe, sortBy, values } from "remeda"
import { Octokit } from "@octokit/rest"
Expand All @@ -17,6 +16,7 @@ import type {
} from "@octokit/webhooks-types"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Browser } from "@/util/browser"
import { ModelsDev } from "../../provider/models"
import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap"
Expand Down Expand Up @@ -311,17 +311,8 @@ export const GithubInstallCommand = cmd({

// Open browser
const url = "https://github.com/apps/opencode-agent"
const command =
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "" "${url}"`
: `xdg-open "${url}"`

exec(command, (error) => {
if (error) {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
}
Browser.open(url).catch(() => {
prompts.log.warn(`Could not open browser. Please visit: ${url}`)
})

// Wait for installation
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { UI } from "../ui"
import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { McpOAuthCallback } from "../../mcp/oauth-callback"
import { Config } from "../../config/config"
import { Instance } from "../../project/instance"
import { Installation } from "../../installation"
Expand Down Expand Up @@ -682,6 +683,7 @@ export const McpDebugCommand = cmd({

// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
await McpOAuthCallback.ensureRunning()
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
Expand All @@ -693,6 +695,7 @@ export const McpDebugCommand = cmd({
{
onRedirect: async () => {},
},
McpOAuthCallback.port(),
)

prompts.log.info("Testing OAuth flow (without completing authorization)...")
Expand Down
15 changes: 1 addition & 14 deletions packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export function DialogMcp() {
const [loading, setLoading] = createSignal<string | null>(null)

const options = createMemo(() => {
// Track sync data and loading state to trigger re-render when they change
const mcpData = sync.data.mcp
const loadingMcp = loading()

Expand All @@ -50,13 +49,11 @@ export function DialogMcp() {
keybind: Keybind.parse("space")[0],
title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress
if (loading() !== null) return

setLoading(option.value)
try {
await local.mcp.toggle(option.value)
// Refresh MCP status from server
const status = await sdk.client.mcp.status()
if (status.data) {
sync.set("mcp", status.data)
Expand All @@ -72,15 +69,5 @@ export function DialogMcp() {
},
])

return (
<DialogSelect
ref={setRef}
title="MCPs"
options={options()}
keybind={keybinds()}
onSelect={(option) => {
// Don't close on select, only on escape
}}
/>
)
return <DialogSelect ref={setRef} title="MCPs" options={options()} keybind={keybinds()} onSelect={(option) => {}} />
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function DialogStatus() {
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
<Match when={(item.status as string) === "needs_auth"}>
Needs authentication (run: opencode mcp auth {key})
Needs authentication (enable to start auth flow)
</Match>
<Match when={(item.status as string) === "needs_client_registration" && item}>
{(val) => (val() as { error: string }).error}
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/cli/cmd/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { UI } from "../ui"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import open from "open"
import { Browser } from "@/util/browser"
import { networkInterfaces } from "os"

function getNetworkIPs() {
Expand Down Expand Up @@ -68,11 +68,11 @@ export const WebCommand = cmd({
}

// Open localhost in browser
open(localhostUrl.toString()).catch(() => {})
Browser.open(localhostUrl.toString())
} else {
const displayUrl = server.url.toString()
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
open(displayUrl).catch(() => {})
Browser.open(displayUrl)
}

await new Promise(() => {})
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export namespace McpAuth {
codeVerifier: z.string().optional(),
oauthState: z.string().optional(),
serverUrl: z.string().optional(), // Track the URL these credentials are for
redirectUri: z.string().optional(), // Track redirect URI used during client registration
})
export type Entry = z.infer<typeof Entry>

Expand Down Expand Up @@ -80,9 +81,15 @@ export namespace McpAuth {
await set(mcpName, entry, serverUrl)
}

export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise<void> {
export async function updateClientInfo(
mcpName: string,
clientInfo: ClientInfo,
serverUrl?: string,
redirectUri?: string,
): Promise<void> {
const entry = (await get(mcpName)) ?? {}
entry.clientInfo = clientInfo
if (redirectUri) entry.redirectUri = redirectUri
await set(mcpName, entry, serverUrl)
}

Expand Down
59 changes: 39 additions & 20 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
type CallToolResult,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
Expand All @@ -22,7 +23,7 @@ import { McpAuth } from "./auth"
import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
import { Browser } from "@/util/browser"

export namespace MCP {
const log = Log.create({ service: "mcp" })
Expand Down Expand Up @@ -132,7 +133,7 @@ export namespace MCP {
description: mcpTool.description ?? "",
inputSchema: jsonSchema(schema),
execute: async (args: unknown) => {
return client.callTool(
const result = (await client.callTool(
{
name: mcpTool.name,
arguments: (args || {}) as Record<string, unknown>,
Expand All @@ -142,7 +143,17 @@ export namespace MCP {
resetTimeoutOnProgress: true,
timeout,
},
)
)) as CallToolResult
const content = Array.isArray(result?.content) ? result.content : []
const text = content
.filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("\n")
return {
output: text || JSON.stringify(result ?? {}),
title: mcpTool.name,
metadata: { isError: result?.isError },
}
},
})
}
Expand Down Expand Up @@ -308,6 +319,7 @@ export namespace MCP {
let authProvider: McpOAuthProvider | undefined

if (!oauthDisabled) {
await McpOAuthCallback.ensureRunning()
authProvider = new McpOAuthProvider(
key,
mcp.url,
Expand All @@ -322,6 +334,7 @@ export namespace MCP {
// Store the URL - actual browser opening is handled by startAuth
},
},
McpOAuthCallback.port(),
)
}

Expand Down Expand Up @@ -380,18 +393,17 @@ export namespace MCP {
// Store transport for later finishAuth call
pendingOAuthTransports.set(key, transport)
status = { status: "needs_auth" as const }
// Show toast for needs_auth
Bus.publish(TuiEvent.ToastShow, {
title: "MCP Authentication Required",
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
message: `Server "${key}" requires authentication. Enable it to start the auth flow.`,
variant: "warning",
duration: 8000,
}).catch((e) => log.debug("failed to show toast", { error: e }))
}
break
}

log.debug("transport connection failed", {
log.info("transport connection failed", {
key,
transport: name,
url: mcp.url,
Expand Down Expand Up @@ -537,6 +549,17 @@ export namespace MCP {
return
}

if (result.status.status === "needs_auth") {
try {
const authResult = await authenticate(name)
const s = await state()
s.status[name] = authResult
return
} catch (error) {
log.error("auto-auth failed during connect", { name, error })
}
}

const s = await state()
s.status[name] = result.status
if (result.mcpClient) {
Expand Down Expand Up @@ -729,8 +752,9 @@ export namespace MCP {
// Start the callback server
await McpOAuthCallback.ensureRunning()

// Generate and store a cryptographically secure state parameter BEFORE creating the provider
// The SDK will call provider.state() to read this value
// Clear stale auth data so we start fresh
await McpAuth.remove(mcpName)

const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
Expand All @@ -753,6 +777,7 @@ export namespace MCP {
capturedUrl = url
},
},
McpOAuthCallback.port(),
)

// Create transport with auth provider
Expand Down Expand Up @@ -806,14 +831,10 @@ export namespace MCP {
// when the IdP has an active SSO session and redirects immediately
const callbackPromise = McpOAuthCallback.waitForCallback(oauthState)

try {
const subprocess = await open(authorizationUrl)
// The open package spawns a detached process and returns immediately.
// We need to listen for errors which fire asynchronously:
// - "error" event: command not found (ENOENT)
// - "exit" with non-zero code: command exists but failed (e.g., no display)
const subprocess = await Browser.open(authorizationUrl, { callbackPort: McpOAuthCallback.port() })
if (subprocess) {
// Not in VS Code context — wait for subprocess errors
await new Promise<void>((resolve, reject) => {
// Give the process a moment to fail if it's going to
const timeout = setTimeout(() => resolve(), 500)
subprocess.on("error", (error) => {
clearTimeout(timeout)
Expand All @@ -825,12 +846,10 @@ export namespace MCP {
reject(new Error(`Browser open failed with exit code ${code}`))
}
})
}).catch((error) => {
log.warn("failed to open browser, user must open URL manually", { mcpName, error })
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
})
} catch (error) {
// Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
// Emit event so CLI can display the URL for manual opening
log.warn("failed to open browser, user must open URL manually", { mcpName, error })
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
}

// Wait for callback using the already-registered promise
Expand Down
39 changes: 8 additions & 31 deletions packages/opencode/src/mcp/oauth-callback.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
import { OAUTH_CALLBACK_PATH } from "./oauth-provider"

const log = Log.create({ service: "mcp.oauth-callback" })

Expand Down Expand Up @@ -56,17 +56,16 @@ export namespace McpOAuthCallback {

const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes

export function port(): number {
if (!server?.port) throw new Error("OAuth callback server not running")
return server.port
}

export async function ensureRunning(): Promise<void> {
if (server) return

const running = await isPortInUse()
if (running) {
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
return
}

server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
port: 0,
fetch(req) {
const url = new URL(req.url)

Expand Down Expand Up @@ -133,7 +132,7 @@ export namespace McpOAuthCallback {
},
})

log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
log.info("oauth callback server started", { port: server.port })
}

export function waitForCallback(oauthState: string): Promise<string> {
Expand All @@ -158,28 +157,6 @@ export namespace McpOAuthCallback {
}
}

export async function isPortInUse(): Promise<boolean> {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
port: OAUTH_CALLBACK_PORT,
socket: {
open(socket) {
socket.end()
resolve(true)
},
error() {
resolve(false)
},
data() {},
close() {},
},
}).catch(() => {
resolve(false)
})
})
}

export async function stop(): Promise<void> {
if (server) {
server.stop()
Expand Down
Loading
Loading