Skip to content

tui: MCP dialog closes after browser OAuth completes #51060

Closed

Description

@franjorub

Summary

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.

Environment

  • opencode version: 2.0.16
  • OS: macOS 27.0.0, Darwin arm64
  • Terminal: Ghostty (TERM_PROGRAM=ghostty, TERM=xterm-256color, COLORTERM=truecolor)
  • Shell: /bin/zsh
  • Install/channel: standalone binary at ~/.opencode/bin/opencode; channel not reported by the CLI
  • Active plugins: none

Reproduction

  1. Configure multiple remote MCP servers that require OAuth.
  2. Start the OpenCode TUI.
  3. Run /mcp.
  4. Select a server that needs authentication and press Enter.
  5. Complete the OAuth flow in the browser.
  6. 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

Activity

  1. BowTiedSwan commented on Sep 24, 2026

    @BowTiedSwan

    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.

  2. BowTiedSwan commented on Sep 24, 2026

    @BowTiedSwan

    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.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the clear report. I reproduced this on 2.0.16, and it still happens on the latest dev (fa1260d6c).

    Steps:

    1. Set up two remote MCP servers that need OAuth (a local mock that approves sign-in automatically).
    2. Run /mcp and press Enter on the first server.
    3. 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

    Screenshot

    Screenshot

    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).
    const port = Number(process.env.PORT ?? 4999)
    const base = `http://127.0.0.1:${port}`
    const json = (b: unknown, s = 200, h: Record<string, string> = {}) =>
      new Response(JSON.stringify(b), { status: s, headers: { "content-type": "application/json", ...h } })
    Bun.serve({
      port,
      hostname: "127.0.0.1",
      async fetch(req: Request) {
        const url = new URL(req.url)
        const p = url.pathname
        if (p.startsWith("/.well-known/oauth-protected-resource"))
          return json({ 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"))
          return json({
            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") {
          const body = (await req.json()) as any
          return json({ ...body, client_id: "client-" + Math.random().toString(36).slice(2), token_endpoint_auth_method: "none" }, 201)
        }
        if (p === "/authorize") {
          const r = new URL(url.searchParams.get("redirect_uri")!)
          r.searchParams.set("code", "code123")
          if (url.searchParams.get("state")) r.searchParams.set("state", url.searchParams.get("state")!)
          return new Response(null, { status: 302, headers: { location: r.toString() } })
        }
        if (p === "/token") return json({ access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "r" })
        if (p.startsWith("/mcp")) {
          const name = p.split("/")[2] ?? "mcp"
          if (req.headers.get("authorization") !== "Bearer tok")
            return new Response("unauthorized", {
              status: 401,
              headers: { "www-authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp/${name}"` },
            })
          if (req.method !== "POST") return new Response(null, { status: 405 })
          const msg = (await req.json()) as any
          if (msg.id === undefined) return new Response(null, { status: 202 })
          const result =
            msg.method === "initialize"
              ? { protocolVersion: msg.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name, version: "1" } }
              : msg.method === "tools/list"
                ? { tools: [] }
                : {}
          return json({ jsonrpc: "2.0", id: msg.id, result })
        }
        return new Response("nf", { status: 404 })
      },
    })
    
    const text = (frame: any) =>
      frame.lines.map((l: any) => l.spans.map((s: any) => s.text).join("")).join("\n")
    
    const signIn = (ui: any): Effect.Effect<void, unknown> =>
      (Effect.gen(function* () {
        const screen = text(yield* ui.capture())
        const lines = screen.split("\n").map((l: string) => l.trim())
        const start = lines.findIndex((l: string) => l.includes("http://127.0.0.1:4999/authorize"))
        let url = ""
        for (let i = 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()))
      }) as any)
    
    export default OpenCodeDriver.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" },
            },
          },
        } as any,
      },
      ({ 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-in
          yield* ui.enter()
          yield* Effect.sleep("2 seconds")
          yield* ui.screenshot("2-signin-started")
          // Simulated browser: follow the authorize URL to OpenCode's loopback callback
          yield* signIn(ui)
          yield* ui.waitFor("Connected", { timeout: 30_000 })
          yield* Effect.sleep("2 seconds")
          yield* ui.screenshot("3-after-oauth")
          const after = 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions