You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
tui: MCP dialog closes after browser OAuth completes #51060
When authenticating a remote MCP server from the TUI MCP dialog, the browser OAuth flow completes successfully but the dialog closes when focus returns to OpenCode. Authenticating multiple OAuth MCP servers therefore requires reopening the dialog for every server.
Install/channel: standalone binary at ~/.opencode/bin/opencode; channel not reported by the CLI
Active plugins: none
Reproduction
Configure multiple remote MCP servers that require OAuth.
Start the OpenCode TUI.
Run /mcp.
Select a server that needs authentication and press Enter.
Complete the OAuth flow in the browser.
Return focus to OpenCode.
Expected Behavior
The MCP dialog should remain open, update the selected server to its authenticated/connected state, and allow the next server to be selected without reopening it. It should close only when the user explicitly dismisses it.
Actual Behavior
The MCP dialog is closed after the browser OAuth flow completes. The user must run /mcp again before authenticating the next server.
Additional Context
Reproduced consistently across five OAuth MCP servers.
Authentication itself succeeds and the servers connect afterward; no OAuth or token error is shown.
Current workaround: reopen /mcp after authenticating each server.
I traced this on v2 to the MCP dialog handing off to DialogIntegration without an onConnected callback, so successful authorization takes the default dialog.clear() path. I’m working on a focused fix to return to the MCP list with the selected server preserved, plus a regression test for authenticating multiple servers.
Fix submitted in #51079: successful OAuth now returns to the MCP list with the same server selected. Regression tests cover Enter/Space, authenticating two servers sequentially, status updates, and Escape dismissal. Before/after TUI captures and verification results are included in the PR.
Thanks for the clear report. I reproduced this on 2.0.16, and it still happens on the latest dev (fa1260d6c).
Steps:
Set up two remote MCP servers that need OAuth (a local mock that approves sign-in automatically).
Run /mcp and press Enter on the first server.
Complete the authorize step in the "browser".
Sign-in succeeds: the "Connected alpha" toast appears and the server connects. But every dialog closes, so signing in to the second server needs /mcp again. The recording below shows the whole flow, and the script below reproduces it on its own.
The likely cause: the MCP list hands off to the sign-in dialog without a way to come back, so sign-in falls back to closing all dialogs. The /connect and model flows do return to the dialog they came from. PR #51079 tried to fix this but was closed without being merged.
Related: #36303 and #44087 (wider MCP dialog work).
recording.mp4
Reproduction script (drive.ts)
// Repro for #51060: the /mcp dialog closes after an MCP OAuth sign-in completes.// Self-contained: starts an OAuth-protected MCP mock on 127.0.0.1:4999 (auto-approves sign-in).// The script plays the browser: it reads the authorize URL shown in the sign-in dialog and follows it,// which redirects to OpenCode's loopback callback and completes OAuth.import{Effect}from"effect"import{OpenCodeDriver}from"opencode-drive"// Embedded mock: minimal remote MCP server that requires OAuth (DCR + PKCE auto-approve).constport=Number(process.env.PORT??4999)constbase=`http://127.0.0.1:${port}`constjson=(b: unknown,s=200,h: Record<string,string>={})=>newResponse(JSON.stringify(b),{status: s,headers: {"content-type": "application/json", ...h}})Bun.serve({
port,hostname: "127.0.0.1",asyncfetch(req: Request){consturl=newURL(req.url)constp=url.pathnameif(p.startsWith("/.well-known/oauth-protected-resource"))returnjson({resource: base+p.replace("/.well-known/oauth-protected-resource",""),authorization_servers: [base]})if(p.startsWith("/.well-known/oauth-authorization-server")||p.startsWith("/.well-known/openid-configuration"))returnjson({issuer: base,authorization_endpoint: `${base}/authorize`,token_endpoint: `${base}/token`,registration_endpoint: `${base}/register`,response_types_supported: ["code"],grant_types_supported: ["authorization_code","refresh_token"],code_challenge_methods_supported: ["S256"],token_endpoint_auth_methods_supported: ["none"],})if(p==="/register"){constbody=(awaitreq.json())asanyreturnjson({ ...body,client_id: "client-"+Math.random().toString(36).slice(2),token_endpoint_auth_method: "none"},201)}if(p==="/authorize"){constr=newURL(url.searchParams.get("redirect_uri")!)r.searchParams.set("code","code123")if(url.searchParams.get("state"))r.searchParams.set("state",url.searchParams.get("state")!)returnnewResponse(null,{status: 302,headers: {location: r.toString()}})}if(p==="/token")returnjson({access_token: "tok",token_type: "Bearer",expires_in: 3600,refresh_token: "r"})if(p.startsWith("/mcp")){constname=p.split("/")[2]??"mcp"if(req.headers.get("authorization")!=="Bearer tok")returnnewResponse("unauthorized",{status: 401,headers: {"www-authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp/${name}"`},})if(req.method!=="POST")returnnewResponse(null,{status: 405})constmsg=(awaitreq.json())asanyif(msg.id===undefined)returnnewResponse(null,{status: 202})constresult=msg.method==="initialize"
? {protocolVersion: msg.params.protocolVersion,capabilities: {tools: {}},serverInfo: { name,version: "1"}}
: msg.method==="tools/list"
? {tools: []}
: {}returnjson({jsonrpc: "2.0",id: msg.id, result })}returnnewResponse("nf",{status: 404})},})consttext=(frame: any)=>frame.lines.map((l: any)=>l.spans.map((s: any)=>s.text).join("")).join("\n")constsignIn=(ui: any): Effect.Effect<void,unknown>=>(Effect.gen(function*(){constscreen=text(yield*ui.capture())constlines=screen.split("\n").map((l: string)=>l.trim())conststart=lines.findIndex((l: string)=>l.includes("http://127.0.0.1:4999/authorize"))leturl=""for(leti=start;i<lines.length&&lines[i];i++)url+=lines[i].replace(/^.*?(http:)/,"$1")url=url.slice(url.indexOf("http://"))yield*Effect.log(`browser opens ${url}`)yield*Effect.promise(()=>fetch(url).then((r)=>r.text()))})asany)exportdefaultOpenCodeDriver.use({
...(process.env.OC_DEV ? {opencode: {dev: process.env.OC_DEV}} : {}),tui: {recording: true,keypressOverlay: true},config: {mcp: {servers: {alpha: {type: "remote",url: "http://127.0.0.1:4999/mcp/alpha"},beta: {type: "remote",url: "http://127.0.0.1:4999/mcp/beta"},},},}asany,},({ ui })=>Effect.gen(function*(){yield*Effect.sleep("3 seconds")yield*ui.type("/mcp")yield*Effect.sleep("1 second")yield*ui.enter()yield*ui.waitFor("MCP servers")yield*Effect.sleep("2 seconds")yield*ui.screenshot("1-mcp-dialog-open")// Select "alpha" (needs auth) and start sign-inyield*ui.enter()yield*Effect.sleep("2 seconds")yield*ui.screenshot("2-signin-started")// Simulated browser: follow the authorize URL to OpenCode's loopback callbackyield*signIn(ui)yield*ui.waitFor("Connected",{timeout: 30_000})yield*Effect.sleep("2 seconds")yield*ui.screenshot("3-after-oauth")constafter=text(yield*ui.capture())yield*Effect.log(`MCP dialog still open after OAuth: ${after.includes("MCP servers")}`)yield*Effect.log(after)}),)
Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.
Activity
BowTiedSwan commentedon Sep 24, 2026
I traced this on
v2to the MCP dialog handing off toDialogIntegrationwithout anonConnectedcallback, so successful authorization takes the defaultdialog.clear()path. I’m working on a focused fix to return to the MCP list with the selected server preserved, plus a regression test for authenticating multiple servers.BowTiedSwan commentedon Sep 24, 2026
Fix submitted in #51079: successful OAuth now returns to the MCP list with the same server selected. Regression tests cover Enter/Space, authenticating two servers sequentially, status updates, and Escape dismissal. Before/after TUI captures and verification results are included in the PR.
opencodejr commentedon Oct 28, 2026
Thanks for the clear report. I reproduced this on 2.0.16, and it still happens on the latest
dev(fa1260d6c).Steps:
/mcpand press Enter on the first server.Sign-in succeeds: the "Connected alpha" toast appears and the server connects. But every dialog closes, so signing in to the second server needs
/mcpagain. The recording below shows the whole flow, and the script below reproduces it on its own.The likely cause: the MCP list hands off to the sign-in dialog without a way to come back, so sign-in falls back to closing all dialogs. The
/connectand model flows do return to the dialog they came from. PR #51079 tried to fix this but was closed without being merged.Related: #36303 and #44087 (wider MCP dialog work).
recording.mp4
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.