Repository navigation
permissions: asks from MCP tools inside Code Mode never surface in the TUI, execute hangs until user interrupt #51223
Description
Activity
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.
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.jsonfireconfig.updatedbut don't apply to already-running sessions; (2) "Allow always" grants are stored project-scoped and didn't cover a session belonging to the legacyglobalproject. Sanity check for your repro: a config-allowed tool via Code Mode returns in ~31 ms, so the plumbing is fine — it's specifically theaskpath that never surfaces. @argszero did source-level analysis of the same pipeline over in #51224 (TUI renders onlylist()?.[0]per session), which may be relevant context.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
executesandbox, 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
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.jsonfireconfig.updatedbut don't apply to already-running sessions; (2) "Allow always" grants are stored project-scoped and didn't cover a session belonging to the legacyglobalproject. Sanity check for your repro: a config-allowed tool via Code Mode returns in ~31 ms, so the plumbing is fine — it's specifically theaskpath that never surfaces. @argszero did source-level analysis of the same pipeline over in #51224 (TUI renders onlylist()?.[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.
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_copyinvoked from Code Mode'sexecutesandbox:"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 forhyprland_clipboard_copywas written under that project — so it never matched the session'sglobalscoping. Timeline: two parallelexecute→ 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 queryGET /permissionwhile 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 asMCP error -32001: Request timed outafter 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.
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.Askedpublishes correctly from inside the Code Mode sandbox with the full session/action/tool source intact, so the drop is purely client-side rendering.Thanks @holny — withdrawal noted, and that's a valuable data point:
Permission.Askedpublishing 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.
Summary
Permission asks raised by MCP tools invoked from inside Code Mode (
execute) never render in the TUI. Theexecutecall 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 serve --service)Reproduction
opencode.json:{ "permissions": [ { "action": "hyprland_*", "resource": "*", "effect": "ask" } ] }await tools.hyprland.clipboard_copy({ text: "hello" })executecall hangs indefinitely (observed 5–44 minutes).{"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
MCP error -32001: Request timed out(~60 s) for an unanswered ask.opencode.jsonpermissions mid-session firesconfig.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.permissiontable with the project id). A long-running session that belongs to the legacyglobalproject 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):
allowed tool called via Code Mode returns in 31 ms (tools.hyprland.list_windows()), so Code Mode → MCP plumbing and permission allow-rules work.hyprland_clipboard_copyallow row was written to thepermissiontable — i.e. it had been blocked on the ask for 278 s and was released by the grant.config.updatedin the server log 4 s before the second hang).message=askingentries 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.