Skip to content

permissions: asks from MCP tools inside Code Mode never surface in the TUI, execute hangs until user interrupt #51223

Description

@rissanssi

Summary

Permission asks raised by MCP tools invoked from inside Code Mode (execute) never render in the TUI. The execute call blocks indefinitely on the invisible ask, and the next user message (or Esc) aborts it with {"type":"aborted","message":"Tool execution interrupted"}. This silently deadlocks agent↔user workflows. Direct (non-Code-Mode) tool calls show their permission dialogs normally.

Environment

  • opencode version: 2.0.16
  • OS: Linux 7.2.6-1-omarchy-rc3 (x64, Arch-based Omarchy)
  • Terminal: ghostty (TERM=xterm-ghostty, COLORTERM=truecolor)
  • Shell: /bin/bash
  • Install/channel: latest (v2.0.16, background service via opencode serve --service)
  • Active plugins: superpowers (git+https://github.com/obra/superpowers.git)
  • MCP: two local stdio servers (a custom FastMCP Python server, desktop-commander)

Reproduction

  1. Configure an MCP tool to require an ask, e.g. in opencode.json:
    { "permissions": [ { "action": "hyprland_*", "resource": "*", "effect": "ask" } ] }
  2. In a session, call that tool from Code Mode:
    await tools.hyprland.clipboard_copy({ text: "hello" })
  3. No permission dialog appears anywhere in the TUI.
  4. The execute call hangs indefinitely (observed 5–44 minutes).
  5. Type any new message (or press Esc) → the tool result becomes:
    {"error":{"type":"aborted","message":"Tool execution interrupted"}}

Expected Behavior

The permission ask renders in the TUI like asks for direct tool calls, so the user can approve/deny and the Code Mode call proceeds.

Actual Behavior

  • No ask is ever displayed; the model believes the tool call is running while the user sees nothing. Both sides wait on each other.
  • A pending ask eventually exceeds the MCP request timeout in some cases: on v1.18.32→v2 era data we also captured MCP error -32001: Request timed out (~60 s) for an unanswered ask.
  • Additional quirks observed in the same timeline (may be the same root cause):
    • Editing opencode.json permissions mid-session fires config.updated (seen in the log), but the running session still hangs the newly-allowed tool — permission rules are only effective for sessions started after the change.
    • An "Allow always" answer is stored project-scoped (row in the permission table with the project id). A long-running session that belongs to the legacy global project never receives that grant, so its Code Mode calls keep asking (and hanging) forever.

Additional Context

Evidence that the hang is the invisible ask (not the MCP server):

  • A config-allowed tool called via Code Mode returns in 31 ms (tools.hyprland.list_windows()), so Code Mode → MCP plumbing and permission allow-rules work.
  • A hung call completed at 18:28:53 — the exact timestamp an hyprland_clipboard_copy allow row was written to the permission table — i.e. it had been blocked on the ask for 278 s and was released by the grant.
  • Reproduced twice in one session after a config change was confirmed applied (config.updated in the server log 4 s before the second hang).
  • The server log shows message=asking entries for direct tool-call asks (which users answer normally), but no asking entries for the Code Mode hangs.

Full investigation report with timeline available on request.

Activity

  1. LeoParkerOu commented on Sep 28, 2026

    @LeoParkerOu

    I'd like to take this on. I'll first reproduce the missing permission prompt with a minimal local MCP server, then trace the Code Mode permission request through to the TUI and work on a focused fix with regression coverage.

    If anyone is already working on this, please let me know so we can avoid duplicating work.

  2. rissanssi commented on Sep 28, 2026

    @rissanssi
    Author

    Not aware of anyone working on it — go ahead. Two adjacent observations from the original investigation that may save you time: (1) permission changes in opencode.json fire config.updated but don't apply to already-running sessions; (2) "Allow always" grants are stored project-scoped and didn't cover a session belonging to the legacy global project. Sanity check for your repro: a config-allowed tool via Code Mode returns in ~31 ms, so the plumbing is fine — it's specifically the ask path that never surfaces. @argszero did source-level analysis of the same pipeline over in #51224 (TUI renders only list()?.[0] per session), which may be relevant context.

  3. holny commented on Sep 29, 2026

    @holny
    Contributor

    I'll take this one (the sibling of #51224 — asks never surface at all vs parallel asks orphaning, two separate defects). Code Mode executes MCP tools inside the execute sandbox, and a permission ask raised there has no path back to the TUI's permission surface, so the call parks forever until abort.

    Plan: trace the permission ask raised from inside the Code Mode execution context through to where session-scope asks get surfaced, find where the MCP-server scope breaks the routing, and fix the routing so the ask reaches the TUI like a direct tool call does. Covered by a test that invokes an MCP tool via Code Mode with the permission gate on and asserts the ask is delivered.

    Fixes #51223

  4. LeoParkerOu commented on Sep 30, 2026

    @LeoParkerOu

    Not aware of anyone working on it — go ahead. Two adjacent observations from the original investigation that may save you time: (1) permission changes in opencode.json fire config.updated but don't apply to already-running sessions; (2) "Allow always" grants are stored project-scoped and didn't cover a session belonging to the legacy global project. Sanity check for your repro: a config-allowed tool via Code Mode returns in ~31 ms, so the plumbing is fine — it's specifically the ask path that never surfaces. @argszero did source-level analysis of the same pipeline over in #51224 (TUI renders only list()?.[0] per session), which may be relevant context.

    Thanks for the additional context. I have now tested the complete SessionRunner → Code Mode → stdio MCP → server/SSE → TUI → HTTP approval flow, including on v2.0.16.

    Two failures reproduce locally: a delayed empty permission-list response can erase a newer ask from the UI, and changing the config from ask to allow does not release an already-pending request even after the agent rules refresh. I have local fixes and regression tests for both. The same-location legacy-global session also successfully reuses an “always” grant in my test.

    However, the ordinary single-ask path works on unmodified v2.0.16 in my setup, so I cannot yet establish that these two failures explain the entire original report. Could you share the investigation report you mentioned, especially the minimal MCP/plugin configuration and the sequence involving the legacy-global session? Please redact credentials and private paths. The useful details are whether permission.asked is emitted, whether GET /api/session/{id}/permission still contains the request while the TUI is stuck, and whether a permission-list response arrives after the asked event.

    I would like to verify the original failure before claiming that #51223 is fully fixed.

  5. rissanssi commented on Oct 3, 2026

    @rissanssi
    Author

    Heads-up: @holny also claimed this on Sep 29 ("Fixes #51223"), so you two may want to coordinate to avoid duplicate PRs.

    To your questions — config and sequence, private paths redacted:

    Minimal config: one local stdio MCP server + wildcard ask rule with a few specific allow overrides. The failing call was clipboard_copy invoked from Code Mode's execute sandbox:

    "mcp": { "servers": { "hyprland": { "type": "local",
      "command": ["uv", "run", "--with", "fastmcp", "python", "~/.config/opencode/mcp/hyprland/server.py"],
      "environment": { "HYPRLAND_MCP_ALLOW_DESTRUCTIVE": "1" }, "enabled": true } } },
    "permissions": [
      { "action": "hyprland_*", "resource": "*", "effect": "ask" },
      { "action": "hyprland_list_windows", "resource": "*", "effect": "allow" },
      { "action": "hyprland_clipboard_copy", "resource": "*", "effect": "allow" }
    ]

    Legacy-global sequence (all UTC, DB-verified): session created Sep 16 14:32 with project_id = global (the legacy project with an empty name). On Sep 23 14:01 a new project row was created for the same directory, and on Sep 24 18:28:53 an "always allow" grant for hyprland_clipboard_copy was written under that project — so it never matched the session's global scoping. Timeline: two parallel execute → MCP calls at 18:24:15; #1 completed at 18:28:53 (exactly when the grant row was written, 278 s); #2 was orphaned and aborted at 18:48:39 (1462 s); subsequent single calls hung 297 s and 938 s, each aborting to the second with the user's next message.

    On your three instrumentation questions: I have to be honest that none were captured live — the persisted event log only contains message.part.updated / message.updated / session.updated, so permission events aren't recoverable retroactively, and I didn't query GET /permission while a call was stuck. Indirect evidence that the ask was live server-side: call #1 resolved to the second exactly when the grant row appeared, i.e. a pending ask existed and a grant could satisfy it. One more data point you may find useful: on Sep 16 (pre-migration, same root cause), the same invisible ask surfaced instead as MCP error -32001: Request timed out after the 60 s MCP timeout — so in that code path the ask was neither surfaced nor indefinitely parked.

    Happy to pull exact timestamps or part records for any specific call if useful.

  6. holny commented on Oct 4, 2026

    @holny
    Contributor

    Apologies — I missed this thread was already claimed when I picked it up. Withdrawing; @LeoParkerOu's approach (live permission updates during list refresh) sounds like the right layer to fix this from. One finding from my pass that might be useful either way: the server side is clean — Permission.Asked publishes correctly from inside the Code Mode sandbox with the full session/action/tool source intact, so the drop is purely client-side rendering.

  7. rissanssi commented on Oct 6, 2026

    @rissanssi
    Author

    Thanks @holny — withdrawal noted, and that's a valuable data point: Permission.Asked publishing correctly from inside the sandbox confirms the ask is live server-side (which matches the indirect evidence in my timeline above) and localizes the defect to the client, exactly where @LeoParkerOu's fix is aimed.

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

Metadata

Metadata

Assignees

No one assigned

    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