Skip to content

feat(tui): explore a more capable MCP modal #36303

Open

Description

@kitlangton

Context

The V2 MCP modal is currently a minimal status list with an error-detail branch. It should become a useful control and diagnosis surface without turning into a dense dashboard.

@kitlangton is the design owner for this exploration.

Explore

  • Decide what users need to understand at a glance for pending, connected, failed, authentication-required, registration-required, and disabled servers.
  • Explore which actions belong directly in the list and which belong in a server detail view.
  • Consider enable/disable, retry or reconnect, authenticate, inspect capabilities, and error details without assuming all of them belong in the first version.
  • Design the focused-server detail treatment together with the progressive nested-dialog/back-stack work tracked in feat(tui): unify modal interaction and visual behavior #36302.
  • Make disabled and unavailable commands use the shared DialogSelect disabled-action treatment rather than disappearing or remaining executable.
  • Define loading, empty, and refresh states explicitly.
  • Verify narrow-terminal behavior once Drive supports deterministic viewport sizing.

Current source

  • packages/tui/src/component/dialog-mcp.tsx
  • packages/tui/src/ui/dialog-select.tsx
  • packages/tui/src/ui/dialog.tsx

Validation

Add deterministic opencode-drive states for pending, connected, failed with details, needs-auth, disabled, empty, and refresh behavior. The audit showed that pending-state capture needs a request barrier or controllable latency rather than screenshot sleeps.

Activity

  1. self-assigned this
    on Jul 10, 2026
  2. big-andy-coates commented on Aug 19, 2026

    @big-andy-coates

    Hey @kitlangton, +1 for being to re-authenticate mcp servers from within an active session :)

  3. zhiganov commented on Sep 15, 2026

    @zhiganov

    Reproduction evidence: optional OAuth server appears connected but cannot authenticate in the TUI

    Environment

    • OpenCode: v2.0.3
    • OS: Windows 11 10.0.26200.9445
    • Terminal: Windows Terminal, TERM=xterm-256color
    • Shell: C:\WINDOWS\system32\cmd.exe
    • Install: npm global, latest channel
    • Active plugins: none found

    Reproduction

    • Configure an OAuth-capable remote MCP server that also exposes public tools.
    • Start OpenCode without stored OAuth credentials.
    • opencode mcp list and /mcps report the server as connected because anonymous initialization and public tools succeed.
    • Call an account-protected tool; it returns Unauthorized.
    • Open /mcps. The server can only be connected or disconnected with Space. Enter exposes no detail view or Authenticate action.
    • Run opencode mcp auth <server> externally and complete browser authorization.
    • Call the same protected tool again; it succeeds.

    Expected behavior

    The MCP modal should expose Authenticate/Re-authenticate for OAuth-capable remote servers even when their transport status is connected. Alternatively, a protected-tool authorization failure should transition the server to an actionable authentication-required state.

    Actual behavior

    Anonymous connectivity masks the missing OAuth session. The TUI reports connected and offers no authentication path, leaving opencode mcp auth <server> as the only working route.

    This reproduces consistently with Avails. It is also the same detection ambiguity noted in #26195: an MCP server can return HTTP 200 without authentication while protected operations still require OAuth.

  4. ggbdpq commented on Oct 4, 2026

    @ggbdpq

    Context: I opened #53065 proposing an MCP observability surface before finding this exploration (and #34435, closed as completed). This is the right design track, so consolidating — closing mine with a pointer.

    What I can usefully contribute to the "decide what users need to understand at a glance" checklist is failure-mode evidence: there are 10+ open MCP lifecycle issues whose diagnosis is blocked by exactly the facts a panel could surface:

    Concrete data suggestion: the service already tracks per-server status internally; extending the status payload with transport type, pid, child-process count, uptime, and last error/time requires no new architecture and would give this modal a factual floor to design the states on. Happy to draft that status-payload extension as a PR if it's useful to the exploration.

  5. VlexWest commented on Oct 17, 2026

    @VlexWest

    Opened #56452 for the enable/disable part: space in /mcps toggles a server in any state (failed & pending too) & saves it to the config file that defines it.

  6. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks @kitlangton! This is a design exploration and feature request, not a bug report, so there was nothing to reproduce: the current MCP modal (packages/tui/src/component/dialog-mcp.tsx) works as built today, a status list with an error-detail view. I've left this for the normal feature and design flow, along with the related nested-dialog work in #36302.

  7. hiethos00-creator commented on Nov 3, 2026

    @hiethos00-creator

    Narrow API prerequisite: non-booting lifecycle observations and generation-scoped terminal receipts

    The V2 connect/disconnect/status APIs requested in #34435 exist; this is not another request for those endpoints or a duplicate broad observability dashboard (#53065).

    On core 2.2.5 (3ba597f), the public Mcp.Server schema exposes name/status/title/integrationID, not the effective configured/client-hosted owner, connection generation or in-flight request termination. Configured server listings and effective tool routing are different: a same-name attached client may shadow configured tools, and detaching the newest client can expose an older connection. A name or tool catalog is therefore insufficient to identify the connection being stopped.

    A normal status read is also not a documented no-start observation. OpenChamber 2.6.0 explicitly documents in its source that status reads can boot a directory's stdio fleet; its management UI refreshes on entry and after disconnect. This makes a passive-looking diagnosis unsuitable when no new connection is permitted.

    Could the API provide a narrowly scoped read of ALREADY-loaded state, without loading a location, spawning stdio, listing tools, starting a relay, or reconnecting? Return not_loaded/unknown instead of activating anything. Expose only the authorized caller's relevant host-instance identity, effective owner kind, opaque connection generation and request phase/terminal state; do not enumerate other clients, tabs or credentials. A session ID alone must not be treated as authorization.

    Pair that observation with a generation-conditional cancellation/disconnect receipt. Distinguish request acceptance, host loop interruption and actual terminal cleanup. A stale owner/generation must cause zero changes; stopping one generation must not fall back or retry onto an older same-name connection, nor affect a newer one. Unknown termination should remain unknown rather than being rendered as success. This may require server API work below the modal; it cannot be fabricated by a UI-only wrapper.

    Suggested synthetic acceptance: a cold location with spawn/connect counters stays cold after observation; same-name configured/attached test generations resolve unambiguously; stale stop handles affect nothing; cancel/ready races cannot dispatch the cancelled request; unrelated synthetic pending forms and MCP connections remain unchanged. No real credentials or user-browser content are needed. These tests are proposed, not claimed executed here.

    Sources: https://opencode.ai/v2/openapi.json ;

    const detach = Effect.fnUntraced(function* (name: ServerName, entry: AttachedEntry) {
    const stack = attached.get(name) ?? []
    if (!stack.includes(entry)) return
    const remaining = stack.filter((item) => item !== entry)
    const next = remaining.at(-1)
    if (!next) {
    attached.delete(name)
    yield* Scope.close(entry.scope, Exit.void)
    return yield* bus.publish(McpEvent.ToolsChanged, { server: name })
    }
    // Unlocked reads must never pick a closing connection, so update the stack before closing it.
    attached.set(name, remaining)
    yield* Scope.close(entry.scope, Exit.void)
    // Closing an idle connection changes nothing visible.
    if (stack.at(-1) !== entry) return
    // Idle connections ignore tool list changes, so the one taking over lists its tools again. A short
    // timeout keeps a half-open connection from holding the lock; it then keeps its stored tools.
    const previous = next.tools
    yield* next.client.tools().pipe(
    Effect.timeout(FALLBACK_LIST_TIMEOUT),
    Effect.map((tools) => {
    next.tools = toAttachedTools(name, tools)
    }),
    Effect.ignore,
    )
    // Readers may have seen the stored tools in the meantime, so compare against both.
    if (!isDeepStrictEqual(entry.tools, next.tools) || !isDeepStrictEqual(previous, next.tools))
    yield* bus.publish(McpEvent.ToolsChanged, { server: name })
    ;
    servers: Effect.fn("MCP.servers")(function* () {
    return Array.from(entries)
    .toSorted(([a], [b]) => a.localeCompare(b))
    .map(
    ([name, entry]): ServerInfo => ({
    name,
    status: entry.status,
    title: entry.connection.client?.title,
    integrationID: entry.integrationID,
    }),
    )
    }),
    add: Effect.fn("MCP.add")(function* (server, config) {
    const name = ServerName.make(server)
    overrides.set(name, config)
    yield* state.reload()
    }),
    connect: Effect.fn("MCP.connect")(function* (server) {
    const name = ServerName.make(server)
    yield* Effect.gen(function* () {
    const entry = yield* requireServer(name)
    yield* entry.connection.stop
    yield* entry.connection.start
    }).pipe(locks.withLock(name))
    }),
    disconnect: Effect.fn("MCP.disconnect")(function* (server) {
    const name = ServerName.make(server)
    yield* Effect.gen(function* () {
    const entry = yield* requireServer(name)
    yield* entry.connection.stop
    entry.status = { status: "disabled" }
    yield* bus.publish(McpEvent.StatusChanged, { server: name })
    }).pipe(locks.withLock(name))
    }),
    remove: Effect.fn("MCP.remove")(function* (server) {
    const name = ServerName.make(server)
    yield* requireServer(name)
    overrides.set(name, false)
    yield* state.reload()
    }),
    attach: Effect.fn("MCP.attach")(function* (client, transport) {
    yield* attach(ServerName.make(client), transport)
    }),
    // Reads report what is connected now; servers still starting contribute once they publish a change.
    tools: Effect.fn("MCP.tools")(function* () {
    const clients = Array.from(attached.values()).flatMap((stack) => stack.at(-1)?.tools ?? [])
    // Match callTool: an attached client's tool shadows a configured server's tool of the same name.
    const shadowed = new Set(clients.map((tool) => `${tool.server}\0${tool.name}`))
    return [
    ...Array.from(entries)
    .flatMap(([name, entry]) => serverTools(name, entry))
    .filter((tool) => !shadowed.has(`${tool.server}\0${tool.name}`)),
    ...clients,
    ].toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
    }),
    awaitStartup: Effect.fn("MCP.awaitStartup")(function* () {
    const now = yield* Clock.currentTimeMillis
    yield* Effect.all(
    Array.from(entries.values()).flatMap((entry) => {
    if (entry.status.status !== "pending" || !entry.attempt) return []
    const remaining = entry.attempt.started + STARTUP_GRACE_MS - now
    return remaining > 0 ? [entry.attempt.settled.await.pipe(Effect.raceFirst(Effect.sleep(remaining)))] : []
    }),
    { concurrency: "unbounded", discard: true },
    )
    }),
    callTool: Effect.fn("MCP.callTool")(function* (input) {
    const server = ServerName.make(input.server)
    const call = (connection: McpClient.Connection) =>
    connection.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
    const failed = (owner: string) =>
    Effect.mapError(
    (error: Error) =>
    new ToolCallError({
    server,
    tool: input.name,
    message: `MCP tool "${input.name}" on ${owner} "${server}" failed: ${error.message}`,
    }),
    )
    // An attached client owns the tools it lists, even when a configured server shares its name, and
    // owns its name outright when no configured server has it.
    const client = attached.get(server)?.at(-1)
    if (client && (client.tools.some((tool) => tool.name === input.name) || !entries.has(server)))
    return yield* call(client.client).pipe(failed("client"))
    ; https://github.com/openchamber/openchamber/blob/5731991edf78d498876a0d2824f782886d30eb36/packages/ui/src/components/mcp/McpDropdown.tsx#L241-L251 ; https://github.com/openchamber/openchamber/blob/5731991edf78d498876a0d2824f782886d30eb36/packages/ui/src/stores/useMcpStore.ts#L253-L256

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions