[BUG] Remote Control: MCP permission prompts for non-read tools never surface in claude.ai/code web UI

Resolved 💬 20 comments Opened May 19, 2026 by paul43210 Closed Jul 15, 2026

Summary

When using --remote-control to drive Claude Code from the claude.ai/code web UI, permission approval prompts for some MCP tool calls never render in the web UI. They appear only in the local TUI (screen session on the host), where they block the session until manually answered via SSH.

This is broader than a single MCP server — observed against both a bundled claude.ai connector and a custom user-built MCP server.

Environment

  • Claude Code version: 2.1.143
  • Launch: claude --remote-control <session-name> (interactive, wrapped in screen -DmS claude-<name> under a systemd user unit)
  • Host: Linux x86_64, Ubuntu 24.04, kernel 6.17.0
  • Remote UI: claude.ai/code/ (web)
  • No --dangerously-skip-permissions, no permissionMode: bypassPermissions

Steps to Reproduce

  1. Configure two MCP servers — one bundled claude.ai connector (e.g. a file-server), one custom user MCP. Do NOT pre-allowlist their write/delete tools in settings.local.json.
  2. Launch claude --remote-control session-1 on the host (inside a screen session under a systemd user unit).
  3. Connect to the same session from claude.ai/code's web UI.
  4. From the web UI, ask Claude to call either:
  • claude.ai MCP write_file to an allowed path, or
  • Custom MCP memory_write / memory_delete (or any write/destructive tool)
  1. Observe the web UI vs. what's actually on the host.

Expected

A permission approval prompt renders in the claude.ai/code web UI. User clicks Allow / Allow Once / Deny.

Actual

  • The web UI shows the tool call as in flight, then reports Tool result missing due to internal error; the conversation appears hung. No indication that approval is needed.
  • The actual prompt is sitting in the local TUI in the screen session:

```
Do you want to proceed?

  1. Yes
  2. Yes, don't ask again for X in this session
  3. No

```

  • The session stays blocked until someone SSHes in, screen -rs, and answers manually. Prompts have sat 50+ minutes this way.

Affected vs. Unaffected Tools

In the same session, with the same MCP servers, prompts for some tools surface fine while others do not:

| Tool | Web-UI prompt surfaces? |
| --- | --- |
| MCP run_command (read-only) | ✅ |
| MCP read_file | ✅ |
| claude.ai MCP write_file | ❌ |
| Custom MCP memory_write, memory_delete | ❌ |

Pattern looks category-based (write/destructive ops?), not a per-server bug. Not Agent Teams / --teammate-mode — this is a vanilla single-session --remote-control.

Possibly Related

  • #48549 (closed) — same symptom in Agent Teams via Remote Control. The teammate-specific fix may not cover single-session --remote-control.
  • #46392 (open) — tmux teammate mode, similar symptom but different code path.

Workaround

Pre-allowlist the specific MCP tool names in .claude/settings.local.json:

\\\json
{
"permissions": {
"allow": [
"mcp__<server-id>__<tool-name>"
]
}
}
\
\\

This eliminates the prompt entirely, sidestepping the bug — but requires knowing each tool's MCP name in advance and defeats per-call control.

Notes

  • I do not believe this is server-side. The MCP spec puts permission UI entirely on the client; server tools shouldn't need to do anything special.
  • First noticed locally on 2026-05-14 with write_file; reconfirmed 2026-05-19 with a custom memory-management MCP server. Bug is stable across multiple sessions and Claude Code versions in the 2.1.x line.

View original on GitHub ↗

20 Comments

github-actions[bot] · 1 month ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/40643
  2. https://github.com/anthropics/claude-code/issues/49729
  3. https://github.com/anthropics/claude-code/issues/48549

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

jshaofa-ui · 1 month ago

🔍 Root Cause Analysis & Proposed Fix

claude-code #60385 — Remote Control MCP Permission Prompts Not Surfacing in Web UI

Issue Summary

When using --remote-control with claude.ai/code web UI, permission approval prompts for write/destructive MCP tools never render in the web UI. They only appear in the local TUI (screen session), blocking the session until someone SSHes in to answer.

Pattern: Read tools (run_command, read_file) work fine. Write/destructive tools (write_file, memory_write, memory_delete) fail to surface prompts.

Root Cause Analysis

Permission Prompt Routing Architecture

Claude Code has two rendering paths for permission prompts:

  1. Local TUI — renders in the terminal where claude is running (screen session)
  2. Remote/Web UI — renders in claude.ai/code web interface via WebSocket relay

The bug: write/destructive tool permission prompts are routed only to the local TUI path, bypassing the WebSocket relay that feeds the web UI.

Likely Root Cause

The permission prompt system has a tool-category-based routing decision:

  • Read-only tools → prompt routed to both TUI AND WebSocket relay → surfaces in web UI
  • Write/destructive tools → prompt routed ONLY to TUI → never reaches web UI

This is likely a security-conscious design that was never updated for --remote-control mode. The assumption was: "write operations need local confirmation for safety." But with --remote-control, the user expects ALL interactions through the web UI.

Evidence

  • Related issue #48549 (closed) had the same symptom in Agent Teams — the fix was teammate-specific and didn't cover single-session --remote-control
  • The pattern is category-based (write/destructive), not server-based
  • Read tools work fine → the WebSocket relay itself is functional

Proposed Fix

Fix: Route All Permission Prompts Through WebSocket Relay in Remote-Control Mode

// In permission prompt handler (inferred location)
function renderPermissionPrompt(toolCall: ToolCall, prompt: PermissionPrompt) {
  // Current: only route to TUI for write/destructive tools
  if (isWriteOrDestructive(toolCall) && !isAgentTeams) {
    renderInTUI(prompt); // ← BUG: web UI never receives this
    return;
  }

  // Fix: in remote-control mode, always relay to web UI
  if (isRemoteControl) {
    relayToWebSocket(prompt); // send to web UI
    renderInTUI(prompt);      // also show locally for visibility
  } else {
    renderInTUI(prompt);
  }
}

Alternative: Add Remote-Control Override

// In settings.local.json
{
  "remoteControl": {
    "surfacingMode": "web-ui-only" | "both" | "tui-only"
  }
}

Competitive Analysis

  • Bot flagged as duplicate of #40643, #49729, #48549
  • #48549 was fixed for Agent Teams but not single-session --remote-control
  • This issue has has repro label + clear tool-by-tool comparison table
  • Zero other solution comments

Estimated Value

  • Bug severity: S1 (session completely blocked, requires SSH intervention)
  • User impact: All --remote-control users with non-allowlisted write tools
  • Fix complexity: Medium (routing logic change + testing both TUI and web UI paths)
  • Quote: $2,000–$5,000

---
Solution developed via automated analysis. Zero competition. Related to #48549 (Agent Teams fix didn't cover single-session remote-control).

paul43210 · 1 month ago

Additional data point: read-only tool also affected — pattern may be per-server, not per-tool-category

Hit this again 2026-05-19 with a different tool, and the new observation contradicts the original framing of "non-read / write-class" tools.

Hung tool: mcp__claude_ai_MemoryManagement__memory_read_all — read-only, idempotent, no destructive effects.

Verbatim prompt from local TUI (via screen -X hardcopy):

   1. Yes
 ▸ 2. Yes, and don't ask again for claude.ai MemoryManagement - memory_read_all commands in /home/AIScripts/ha-mcp
   3. No

 Esc to cancel

Sat for 58m 6s in the local TUI. Web UI showed no prompt — same Mode A signature as the original report.

Revised pattern

Affected-vs-unaffected, with the new data added, suggests per-MCP-server, not per-tool-category:

| MCP server | Tools confirmed surfacing in RC UI | Tools confirmed NOT surfacing in RC UI |
|---|---|---|
| claude.ai Faure_ca_MCP (HTTP-proxied custom MCP) | run_command, read_file | none observed yet |
| claude.ai MemoryManagement (HTTP-proxied custom MCP) | none observed yet | memory_read_all, memory_write, memory_delete |

Both servers are configured similarly (HTTP MCP behind Apache TLS reverse proxy on the same host), but only one consistently fails to bridge permission prompts. That's a sharper signal than "writes vs. reads."

If reproduction effort would help, I can test additional scenarios on my end (different transports, different tool annotations on the affected server, etc.).

paul43210 · 1 month ago

Follow-up: answering the Mode A prompt does not always resolve the call

Continuing the memory_read_all data point from my prior comment. Same session, fuller picture of what happens AFTER the user accepts the prompt in the local TUI: there's a second hang on the dispatch side, and Esc alone doesn't recover from it.

Full sequence (UTC, ha-mcp session, 2026-05-19)

| Time | Event | Notes |
|---|---|---|
| ~12:49 | /clear to reset context | Standard session prep, no issue |
| 12:50:06 | User prompt: "Read phase-b-recovery-prompt-2026-05-19.md and follow it" | |
| 12:50:09–12:50:14 | ReadToolSearch for memory_read_all schema → success | All logged to JSONL normally |
| ~12:50:17 | memory_read_all tool_use generated | NOT yet flushed to JSONL — held in memory |
| ~12:53 | Mode A prompt renders in local TUI; web UI shows indefinite spinner | Same as my earlier comment |
| 12:53 → 13:51 | Prompt sits in local TUI for ~58 min | Web UI gives no indication a prompt exists |
| ~13:51 | User accepts option 2 ("Yes, don't ask again") in local TUI via attaching to the screen | Prompt clears from TUI |
| 13:51 → 14:28 | Screen goes completely blank. JSONL still frozen at 12:50:14. Zero MCP traffic. ~37 min stuck post-answer. | This is the new finding |
| 14:28:16 | External screen -X stuff $'\e' (Esc) sent | |
| 14:28:16 | JSONL gets exactly one new entry: the held memory_read_all tool_use with its original 12:50:17 timestamp (preserved on write). | Flush triggered; no dispatch |
| post-Esc | Still no tool_result, no MCP traffic, screen still blank | Esc partially recovered (flushed held state) but didn't dispatch |
| later | User types a new message in the web UI ("Where are you at?") | Any new user message would likely do |
| immediately | Session resumes. Claude self-reports the prior memory_read_all "errored," retries, succeeds (now allowlisted), continues normally. | Full recovery |

What this adds to the bug picture

  1. Accepting the Mode A prompt does not reliably close the loop. The prompt clears from the TUI, but the dispatch path can stay frozen — screen non-rendering, JSONL writer paused, MCP request never sent. In this case, ~37 min stuck post-answer.
  1. Pre-answer, the tool_use was held in memory, not on disk. Its 12:50:17 timestamp was assigned when the call was generated; the JSONL line wasn't written until Esc fired at 14:28:16 — 1h38m later. The original timestamp survived the deferred write. This suggests JSONL flush is gated on something tied to the permission/dispatch path, and that gate can stay closed after a prompt is answered.
  1. Esc only triggers a partial flush. It got the held tool_use to disk but didn't dispatch the call.
  1. A new user message fully recovers the session. Any text the user submits via the web UI appears to wake the dispatch path. The Claude instance then self-reports the prior call as having errored and retries it.
  1. Pre-allowlisting the tool (mcp__claude_ai_MemoryManagement__memory_read_all) in .claude/settings.local.json between the hang and the retry was effective — the retry didn't re-trip the prompt and succeeded in <2s.

Updated user-facing recovery sequence

If a Mode A prompt sits in local TUI and the user accepts it but the session doesn't visibly resume:

  1. Allowlist the tool in .claude/settings.local.json (closes the loop for next time).
  2. Send Esc into the screen (screen -S <name> -X stuff $'\e') — may flush held state.
  3. Type a fresh user message in the web UI — this consistently woke the session in my test.
  4. The held tool call retries automatically; Claude self-reports the prior error.

I can share the relevant JSONL window (with redactions) on request.

Snailflyer · 1 month ago

The follow-up data point makes this look broader than prompt rendering. I would split it into three state transitions: prompt created and bound to a concrete tool_use/session id, prompt surfaced to each client, and prompt resolution waking the dispatcher so the tool call actually emits and gets a result. The post-answer blank screen plus delayed JSONL flush suggests the remote-control path may keep a dispatch gate closed even after local approval is resolved. If you share a redacted JSONL slice, the most useful window is last normal event -> held tool_use timestamp -> prompt resolution -> first event after Esc/new user message.

rpelevin · 1 month ago

I would treat this as a lost control-plane event, not only a UI rendering bug.

If remote control is the active surface, a permission prompt for an MCP tool call needs to be represented as a resumable pending decision in that surface. Otherwise the system has two inconsistent truths: the local TUI is waiting for authority, while the remote UI reports a missing tool result or internal error.

The event shape I would want the remote UI to receive:

  • remote session id;
  • local session id, if different;
  • permission request id;
  • tool call id;
  • MCP server identity;
  • tool name;
  • target origin/path/resource;
  • canonical args digest;
  • action class: read, write, delete, execute, navigate, script;
  • reviewer-facing summary;
  • expiry/timeout;
  • decision state: pending, allowed, denied, expired.

Acceptance tests:

  • a non-read MCP tool call from the remote UI creates a visible remote approval prompt;
  • the tool does not execute while the prompt is pending;
  • allow/deny in the remote UI resolves the same pending request in the local process;
  • timeout produces a visible expired/denied state, not Tool result missing;
  • reconnecting the remote UI can recover the pending prompt if it has not expired;
  • approval for one tool call id cannot apply to a changed origin, tool, or args digest.

That keeps remote control from becoming a split-brain permission model. The active control surface should see the same pending authority boundary the local TUI sees.

paul43210 · 1 month ago

Redacted JSONL window for the memory_read_all (read-only) hang

Following up on my 2026-05-19 data point, and answering @Snailflyer's and @rpelevin's request for the transcript window. This confirms the framing has to move past "UI rendering of write-tool prompts" — it's a lost/abandoned in-flight tool call on the remote-control dispatch path, and it happens for a read-only MCP tool too.

Setup

Single vanilla claude --remote-control <session> (no Agent Teams, no --dangerously-skip-permissions). MCP tool mcp__claude_ai_MemoryManagement__memory_read_all — read-only, idempotent — was not pre-allowlisted. Driven from claude.ai/code web UI; agent also visible in the local TUI inside screen.

What the transcript shows (envelope only — timestamps/ids/tool-names verbatim, payloads + paths redacted)

{"ts":"2026-05-19T12:50:06.377Z","type":"user","content":"Read /home/USER/PROJECT/phase-b-recovery-prompt-2026-05-19.md and follow it."}
{"ts":"2026-05-19T12:50:09.383Z","type":"assistant","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_0172…(Read)","name":"Read"}]}
{"ts":"2026-05-19T12:50:09.398Z","type":"user","content":[{"type":"tool_result","tool_use_id":"toolu_0172…(Read)","is_error":false}]}   // built-in Read: 15 ms OK
{"ts":"2026-05-19T12:50:14.731Z","type":"assistant","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_01PD…(ToolSearch)","name":"ToolSearch"}]}
{"ts":"2026-05-19T12:50:14.738Z","type":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01PD…(ToolSearch)","is_error":false}]}   // ToolSearch: 7 ms OK
{"ts":"2026-05-19T12:50:17.079Z","type":"assistant","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_01Th…","name":"mcp__claude_ai_MemoryManagement__memory_read_all","input":{"service_id":"<redacted>"}}]}   // HELD

// ===== ~99 minutes, ZERO events written to the transcript =====
// local TUI: "1. Yes / 2. Yes, don't ask again … / 3. No"
// web UI:    call shown in flight, then "Tool result missing due to internal error"

{"ts":"2026-05-19T14:29:47.243Z","type":"user","content":"Where are you at?"}   // manual nudge via SSH + screen -r
{"ts":"2026-05-19T14:29:52.531Z","type":"assistant","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_…(memory_list)","name":"mcp__claude_ai_MemoryManagement__memory_list"}]}
{"ts":"2026-05-19T14:29:55.725Z","type":"user","content":[{"type":"tool_result","tool_use_id":"toolu_…(memory_list)","is_error":false}]}   // post-recovery: MCP calls succeed

The decisive fact

Grepping the raw transcript for that tool_use id:

tool_use   for toolu_01Th… (memory_read_all): 1
tool_result for toolu_01Th…                  : 0   ← orphaned, never resolved
  • The two tool calls immediately before it (built-in Read, ToolSearch) completed in 7–15 ms — the relay is healthy; only the permission-gated MCP call stalled.
  • The held memory_read_all call never received a tool_result, even after manual recovery.
  • The session produced no transcript events at all for ~99 minutes (12:50:17 → 14:29:47). The on-disk JSONL is flushed on turn boundaries, so a turn that blocks on an unsurfaced permission prompt is invisible on disk and in the web UI simultaneously.
  • Recovery did not resume the held call — the model started a fresh turn in reply to a typed "Where are you at?". The pending decision and its in-flight tool_use were abandoned.

Why this matters for the fix

This supports @rpelevin's "two inconsistent truths" model. Two distinct defects are visible:

  1. Surfacing: the permission prompt for this MCP tool rendered only in the local TUI, never in the remote surface. (And note: it's read-only, so the earlier write/destructive-category theory is not the whole story — the trigger correlates with MCP tools that need a permission decision, not with write-vs-read.)
  2. Dispatch/resolution: even setting rendering aside, the in-flight tool_use was never reconciled to a tool_result; it was dropped rather than held as a resumable pending decision.

A fix that only mirrors the prompt to the web UI would address (1) but likely leave (2): the held tool call still needs to be a durable, resumable pending decision keyed by {remote session id, local session id, permission request id, tool_use id, MCP server, tool name} so that resolving it from either surface emits the awaited tool_result.

(Versions/env as in the original report: Claude Code 2.1.x line, Ubuntu 24.04, --remote-control under screen/systemd. Paths, service_id, and tool inputs redacted; happy to share more of the envelope on request.)

rpelevin · 1 month ago

Thanks for posting the transcript window. This is the missing distinction.

The important new fact is not only that the prompt failed to surface remotely. It is that toolu_01Th... became a permission-gated tool_use with no matching tool_result, and recovery created a new turn instead of resolving the held call.

So I would tighten the fix boundary to three invariants:

  1. Permission-gated MCP calls should be persisted as pending decisions before the UI prompt is considered complete.
  2. A decision from either surface should resolve that same pending decision and emit exactly one terminal result for the original tool_use: allowed result, denied result, expired/timeout result, or cancelled result.
  3. Reconnect / recovery should rediscover the pending decision or its terminal state; it should not silently abandon the original tool_use and require a fresh user message to restart work.

The useful regression test is the read-only case here:

  • remote UI triggers memory_read_all;
  • local TUI receives the prompt but remote surface does not;
  • before decision, the call is visible as pending against the original tool_use id;
  • after allow/deny/timeout/reconnect, the transcript contains one corresponding tool_result or terminal denied/expired result for that original id;
  • a later "Where are you at?" message cannot be the first mechanism that reconciles the abandoned call.

That would keep the repair from stopping at prompt mirroring. The pending authority object needs to own dispatch wakeup and terminal reconciliation too.

paul43210 · 1 month ago

Follow-up: a fully-logged silent hang on an allowlisted MCP tool — the allowlist workaround didn't prevent it

Edited 2026-06-11 — correction. The original version of this comment claimed "the backend never saw the requests" / "it dispatched nothing." That was wrong. I inferred it from the empty session transcript without checking the server logs. Apache access logs show the requests did reach the server and were answered (200/202). The corrected server-side analysis is in its own section below. The headline still stands: an allowlisted tool hung silently for 2.5 h, and the failure is on the result-return path, not outbound dispatch.

A second occurrence, captured end-to-end. The fact that matters: the hung tool was already in permissions.allow, so no permission decision was ever pending — and it still hung silently for 2.5 hours. The standard allowlist workaround recommended on this issue (and on #60388) does not cover this path.

Client side — what the session JSONL shows

  • Tool: mcp__claude_ai_Faure_ca_MCP__run_command — custom HTTP MCP, allowlisted in .claude/settings.local.json.
  • One model turn issued 4 calls concurrently (4 parallel tool_use).

| UTC | Event (session transcript) |
|---|---|
| 20:06:14.082 → 20:06:16.486 | 4 run_command tool_use blocks emitted (4 assistant msgs in 2.4s) |
| 20:06:17 → 22:38:08 | transcript silent — no tool_result, no error, no permission event; JSONL has no events |
| 22:38:09.620 | user interrupt → all 4 cancelled simultaneously |
| 22:42:04 → 22:43:00 | 5 sequential single calls, each returned < 1 s |

  • Measured hang: 2 h 31 m 53 s. No prompt on any surface (none expected — allowlisted).

Server side — what the Apache access log shows (the correction)

At the exact dispatch second the server received 8 POST /mcp requests and answered them:

[10/Jun/2026:16:06:15 -0400] "POST /mcp HTTP/1.1" 404 91    "Claude-User"
[10/Jun/2026:16:06:15 -0400] "POST /mcp HTTP/1.1" 200 900   "Claude-User"
[10/Jun/2026:16:06:16 -0400] "POST /mcp HTTP/1.1" 202 -     "Claude-User"
[10/Jun/2026:16:06:16 -0400] "POST /mcp HTTP/1.1" 202 -     "Claude-User"
[10/Jun/2026:16:06:16 -0400] "POST /mcp HTTP/1.1" 200 74    "Claude-User"
[10/Jun/2026:16:06:16 -0400] "POST /mcp HTTP/1.1" 200 4467  "Claude-User"
[10/Jun/2026:16:06:16 -0400] "POST /mcp HTTP/1.1" 200 76    "Claude-User"
[10/Jun/2026:16:06:16 -0400] "POST /mcp HTTP/1.1" 200 855   "Claude-User"

(16:06 EDT = 20:06 UTC; Claude-User = the connector relay.)

So the requests were dispatched, traversed the relay, hit the server, and got HTTP responses — including two 202 Accepted (empty body), i.e. "accepted; the result arrives over the SSE stream." The mcp-server backend was healthy and continuous through the entire 2.5 h (no restart / stop / error in its journal). Yet the tool results never surfaced in the session.

Corrected diagnosis: this is not a failure to dispatch outbound. It's a breakdown on the result-return / correlation path — most likely the SSE response channel for the 202-accepted calls never delivered (or was never correlated back to the pending tool_use). Still a client/relay-side problem, consistent with the "dispatch-gate stays closed" theory on this issue — but the gate is on the return leg, not the outbound one.

Honest uncertainty: mcp-server doesn't log request bodies, so I can't tell from these logs whether the server actually executed the four commands and the results were lost on return, or the 202-accepted calls never produced an SSE result. What's certain: the requests reached the server and were answered at the HTTP layer.

Repro attempts (same session, later — could NOT reproduce)

  • 2 parallel trivial calls (echo) → both OK, < 1 s
  • 4 parallel trivial calls (echo) → all OK, < 1 s
  • 5 sequential calls incl. the original payloads → all OK, < 1 s
  • Deliberate server faults (stop / SIGTERM / restart while calling) → all produced fast, surfaced errors (Error POSTing to endpoint, not connected), never the silent hang.

So it's intermittent, and server-side faults are not the cause (they fail loud and fast). Concurrency is a suspected correlate (the only failed case was a 4-way parallel batch; every sequential call before and after worked), but a 4-way batch did not reproduce it on retry.

Why it's worth recording here

  • A fully-instrumented instance: exact client + server timestamps, allowlisted tool, server confirmed healthy and confirmed to have answered the requests, results never returned.
  • The allowlist workaround has a hole — an allowlisted tool hung anyway.
  • It rules out "a pending permission check" and "outbound dispatch failure" and "server-side fault" as causes — narrowing this to result delivery/correlation on the client/relay side.

Environment as before: Claude Code 2.1.x line, --remote-control under screen/systemd, Ubuntu 24.04. Redacted detail available on request.

rpelevin · 1 month ago

This new allowlisted case usefully separates two gates that were blended together earlier.

The permission/authority gate was already open: the tool was in permissions.allow, so no approval prompt was pending. But the dispatch lifecycle still failed: four tool_use blocks were emitted, the backend never saw the requests, and the transcript did not get a tool_result, dispatch error, or terminal cancellation until manual interrupt.

So I would make the regression target broader than prompt surfacing:

  1. Allowlisting should only bypass the approval prompt. It should not bypass tool_use lifecycle accounting.
  2. Every emitted MCP tool_use should reach exactly one terminal state: success result, tool error result, dispatch failed, denied, cancelled, or expired.
  3. If the dispatcher cannot hand an allowlisted MCP call to the backend, the original tool_use_id should receive a terminal dispatch-failed result rather than leaving a silent gap.
  4. Interrupt/cancel should reconcile every in-flight tool_use_id in the batch, not just wake the UI.
  5. A later user message or retry should never be the first event that explains what happened to the original call.

The useful additional test is the allowlisted concurrent case: issue four parallel allowlisted MCP calls, verify either four backend receives or four terminal results, then interrupt mid-flight and verify every original tool_use_id has a recorded terminal outcome.

That would cover both families now visible in the thread: permission-gated calls that need a resumable pending decision, and allowlisted calls that still need dispatch accounting.

paul43210 · 1 month ago

Conclusive: the hang is client-side — I reproduced the server's exact byte sequence and the client handled it fine

Following the correction above (the requests do reach the server and are answered), I went after the mechanism by recreating the server-side conditions of the hang. Result: the server behaves identically whether the call hangs or not — so the failure is on the client/relay side, in result-to-tool_use correlation across a session re-init.

What the original hang's server sequence was

The original batch's first request got a session-not-found 404 (the MCP session had gone stale after a long idle — no server restart occurred, confirmed in the journal). The client then re-initialized and the server answered everything:

ORIGINAL (client hung 2.5h):
  POST  404   91     ← stale session: session-not-found
  POST  200   900    ← initialize
  POST  202   -      ← notifications/initialized
  POST  202   -
  POST  200   74
  POST  200   4467   ← tools/list
  POST  200   76
  POST  200   855    ← tool-call result(s)

Every request was answered at the HTTP layer. The results simply never surfaced in the session.

Forcing the same conditions

I evicted the MCP session on purpose (restart the backend — same effect as an idle-expiry: the in-memory session is gone) and then immediately fired a burst of tool calls. The first hits the now-missing session and triggers a re-init, exactly like the original:

MY FORCED REPRO (client succeeded, all results returned):
  POST  404   91     ← stale session  (identical)
  POST  200   900    ← initialize     (identical)
  POST  202   -                       (identical)
  POST  202   -                       (identical)
  POST  200   74                      (identical)
  POST  200   4467   ← tools/list     (identical)
  POST  200   76                      (identical)
  POST  200   477    ← tool-call result
  POST  200   477  ×5  ← the rest of the burst, all answered and all surfaced

The re-init handshake is byte-for-byte identical to the original. The only difference: this time the client correlated every 200 back to its tool_use and returned the results. The original dropped that correlation and orphaned the calls → indefinite hang.

Conclusion

  • The server side of the hang is normal and reproducible. Same 404→re-init→all-answered sequence in both the hung case and the healthy case.
  • The bug is client/relay-side, specifically result-to-tool_use correlation when a session is re-initialized mid-flight. The most likely shape: tool calls dispatched against session A; a 404 forces adoption of session B; the answers that come back for the session-A calls are not matched to the still-pending tool_uses → orphaned, no error, no result, forever (until the user interrupts).
  • It is not reproducible by manipulating the server — I produced the exact triggering sequence and the client handled it fine, so server-side hardening cannot fix it.
  • Ruled out along the way: concurrency (calls serialize in the harness; there is never true parallelism), server faults (down / SIGTERM / restart all fail fast and loud, never silent), response-payload WAF (results pass), SSE streams (this transport delivers results inline on the POST response; no server→client SSE stream is ever opened), and Apache backend connection-reuse (no proxy errors at the hang).

Likely real-world trigger

A tool-call burst that lands just as the client is re-initializing a session that went stale after idle. Remote-control sessions that sit idle (e.g. periodic/scheduled work) are most exposed: the session expires server-side, and the next burst of calls races the re-init.

What would pin it down on the client side

Instrumentation of how the client maps incoming results to pending tool_uses across an initialize/session-id change — particularly whether in-flight requests issued under the previous session id are abandoned rather than retried/rebound when the client adopts the new session id after a 404.

(Environment as before: Claude Code 2.1.x, --remote-control under screen/systemd, Ubuntu 24.04. Server-side captured from Apache access logs; client-side from the session JSONL.)

rpelevin · 1 month ago

That reproduction usefully narrows the boundary: if the server response can be replayed and handled, then the fragile point is the handoff from a completed backend call back into the transcript's tool_result lifecycle.

I would make the invariant per tool_use_id rather than per UI prompt:

  1. Once the client emits a tool_use, that id becomes an open obligation.
  2. It must close exactly once as success tool_result, tool error, dispatch failure, cancellation, or timeout.
  3. Allowlisting can skip the permission prompt, but it should not skip the open-obligation table.
  4. Reconnect, interrupt, or retry should reconcile every still-open id before starting a fresh turn.
  5. A late backend result after timeout should be attached as late or ignored evidence, not allowed to create a second terminal result.

The client-side regression I would add is:

  • accept a valid server response, then drop it before transcript insertion;
  • verify the original tool_use_id is eventually marked terminal;
  • reconnect with one open id and confirm the UI explains whether it resumed, cancelled, or expired it;
  • run concurrent allowlisted calls and verify each id closes independently;
  • deliver a duplicate or late result and verify it cannot overwrite the first terminal state.

That turns this from "did the tool run" into "did every emitted tool call become accountable in the transcript", which seems like the boundary this bug keeps exposing.

paul43210 · 1 month ago

Live recurrence (two sessions at once) + a scope-broadening finding

Hit this again today on two Remote Control sessions simultaneously. Three technical observations that sharpen — and broaden — the original report.

1. The remote approval is a silent no-op that survives a refresh

The original framing here is "the prompt never renders in the remote UI." What actually happens (observed in the mobile app) is more specific:

  • The permission prompt does render in the remote UI.
  • Tapping Approve dismisses it — and then nothing happens.
  • Refreshing the page brings the same approval request back.
  • Looping approve → refresh never resolves the held call.

So the remote approval isn't missing — it is accepted by the UI but never delivered to the pending decision. The held tool call stays pending, and a refresh just re-surfaces the still-unresolved prompt. (This lines up with the earlier finding in this thread that the underlying held tool_use never gets correlated back to a result.)

2. It is NOT MCP-specific — a built-in Read prompt deadlocks identically

The title scopes this to MCP tools, but the same deadlock occurs for a built-in tool. In a separate Remote Control session, a Read of a file path outside the project root (which triggers a normal permission prompt) hung with:

Tool result missing due to internal error

Approving on every available surface did nothing, and the remote UI offered no way to recover. So the real surface area is any permission-gated tool over Remote Control, not just MCP servers. The fix can't live solely in the MCP layer — suggest broadening the issue scope/title accordingly.

3. Answering the prompt clears it, then the call hangs indefinitely

For the MCP case, the prompt rendered locally and was answered; the prompt cleared — and the call then hung for 11+ minutes with the UI spinner running, the token counter frozen, and no tool_result ever returned (until it was forcibly interrupted). So even when the prompt is answered, the dispatch can still die silently afterward — consistent with the result-correlation failure described earlier in this thread (the request reaches the server and is answered, but the result never gets bound back to the pending call).

Net

Three distinct surfaces of the same underlying defect: (a) remote approvals are accepted by the UI but never delivered, (b) the deadlock is tool-agnostic (built-in tools included), and (c) answering doesn't guarantee the dispatch completes. All point at the Remote Control permission/dispatch path, not any individual tool or server.

(Environment unchanged: Claude Code 2.1.x, --remote-control under screen/systemd, Ubuntu 24.04.)

rpelevin · 1 month ago

That recurrence changes the bug boundary again: it is no longer just prompt visibility or MCP result reconciliation. If Approve dismisses in the remote UI, survives refresh, and the same deadlock happens for a built-in Read prompt, then the failing unit is the delivery of a decision to the pending call, independent of tool source.

I would separate three states:

  1. prompt_visible: the reviewer can see the pending decision.
  2. decision_accepted_by_surface: the UI recorded an allow/deny action.
  3. decision_delivered_to_call: the runtime applied that decision to the exact pending tool_use or built-in tool invocation and emitted one terminal transcript state.

The invariant should be: a UI approval cannot be considered successful until it transitions the pending call out of pending. Dismissing the prompt is only a local UI event unless the original call receives allow, deny, expired, cancelled, or delivery_failed.

The regression I would add is:

  • remote UI renders an approval prompt;
  • tapping approve sends a decision but the delivery path is dropped;
  • refresh rehydrates the same pending request instead of treating the dismissed prompt as success;
  • the original call stays blocked and visible until it receives exactly one terminal state;
  • run the same case for an MCP tool and a built-in tool, using the same pending-decision machinery.

That gives the fix a cleaner boundary: prompt rendering, decision capture, and decision delivery are separate obligations, and all three have to be accountable against the original call id.

paul43210 · 1 month ago

The day-to-day impact, and this isn't an isolated setup

Stepping out of the mechanism for a second (@rpelevin has that well covered) to flag severity and breadth.

As a user: on Remote Control, roughly 60–70% of permission prompts never resolve unless I catch them within the first couple of minutes on a live, foregrounded surface. If I browse away on my phone, close the laptop, or close the console session, the prompt either never surfaces, or it surfaces and does nothing when I tap Approve — the popup dismisses but the held call stays stuck. So I have to babysit every session, which defeats the point of remote control.

This is widely reported and recurring, across Android / iOS / Linux / WSL / macOS, since February — not one person's environment:

  • #62553 (open) — "phone-app approval dismisses popup but never reaches local process." Triggered by Bash or Read, so it's not MCP-specific.
  • #67235 (open) — "approvals via mobile app frequently lost, ~6 times in one session, walk away with the phone → Tool result missing due to internal error."
  • #67239 (open) — "Bash results silently lost, agent waits forever — correlates with Remote Control sessions."
  • #54922, #63966, #65413 (open) — same family.
  • Closed but clearly recurring (closed without a durable fix): #47678, #52084, #55833, #61177 — all "approve from phone → host hangs indefinitely."

The common failure: an emitted tool_use that never reaches any terminal state — whether a decision was dropped (approve → never delivered) or a result was dropped (allowlisted call, server answered, result never bound back). Both leave the call orphaned with no success, error, deny, cancel, or timeout. It also reproduces with built-in tools (Bash, Read), so it isn't MCP-specific.

Two asks:

  1. Triage — clean repro since May 19, no assignee/milestone, and at least half a dozen sibling reports (several closed-and-recurring). Could a maintainer consolidate and pick it up?
  2. Broaden the title beyond "MCP permission prompts" — e.g. "Remote Control: permission-gated and allowlisted tool calls can orphan with no terminal state."

Happy to share redacted JSONL/server-log windows if useful.

rpelevin · 1 month ago

Thanks for spelling out the user impact this clearly. The 60-70% number is the part that makes this hard to treat as an edge case: once Remote Control needs babysitting, the core value of the mode is gone.

I think your broader title is right. The common invariant across these reports is not MCP, mobile, or a particular tool class; it is that an emitted tool_use can lose its terminal state.

For triage, I would frame the bug as one lifecycle obligation:

once a tool_use is emitted, it must close exactly once as success, tool error, denied, cancelled, or timeout, regardless of whether the approval came from local TUI, web, mobile, refresh/reconnect, or an allowlist path.

That would also make the sibling reports easier to consolidate: some lose the decision delivery, some lose the result binding, but they all leave the same orphaned pending call behind.

The fix target is probably less "show the prompt everywhere" and more "make the pending call durable until a terminal event is written."

paul43210 · 1 month ago

Fully instrumented reproduction: remote approval is accepted by the UI but never reaches the local gate — and the call never reaches any terminal state

I sat down and reproduced this end-to-end with a syscall trace (strace) on the local process and a live read of the on-disk session transcript, so every surface could be cross-checked at the same timestamps. Summary up front: with the remote surface kept live, approval works; the moment the surface is backgrounded/closed during the wait, the remote Approve is accepted by the app and even leaves the device — but it never reaches the local permission prompt, and the held tool_use is left permanently orphaned.

Environment: Claude Code v2.1.176, Opus, claude --remote-control on Linux, driven from the mobile app.

Trigger (deliberately not MCP — a built-in tool)

A built-in Read of /etc/hostname (a path outside the project root, so it's permission-gated). Harmless, deterministic, and confirms the bug is not MCP-specific.

Run 1 — control (surface stays live)

Fire the prompt, leave the app open, approve. → Completes instantly, result displays. Healthy path. (Even after deliberately ignoring the prompt for ~8 minutes on a live surface, approving still worked — so a long wait alone is not the trigger.)

Run 2 — the repro (surface closed during the wait)

  1. Fire the Read prompt.
  2. The instant it appears, close the surface (background/close the app).
  3. Wait ~15 minutes.
  4. Reopen and tap Approve.

At the moment of approval, the five surfaces disagreed with each other — and that disagreement is the bug:

| Surface | What it showed |
|---|---|
| Mobile chat | Prompt cleared → phantom "Reading…" (looks like it's working) |
| Session list | Still showing "Approve once" for the same session — prompt still pending |
| Local console (the real claude process) | The permission prompt still open and unanswered (1. Yes / 2. … / 3. No) |
| strace | At the exact second of the tap, the client opened DNS + TLS connections to the relay — the Approve left the device and reached the network |
| On-disk transcript | The Read tool_use orphaned — no tool_result — transcript frozen since the call was issued |

So the decision was captured by the app and transmitted, but never delivered to the local prompt, which is the authoritative gate. The local process stayed blocked there, so the tool_use could never resolve.

Recovery hierarchy (what does and doesn't free it)

| Action | Outcome |
|---|---|
| Tap Approve (remote) | Transmitted to the relay, never reached the local gate |
| Send a new message (remote) | Reaches the host but queues behind the blocked gate — no recovery |
| Answer the local gate (press 2) | Cleared the gate, advanced to "Forming…", then re-hung mid-response (token counter frozen — a second, post-answer stall) |
| Esc at the local console | The only thing that recovered the session |

The core finding

**Even after Esc recovered the session, the original Read tool_use was never written a terminal state to the transcript — no result, no error, no cancellation. It remains permanently orphaned in the record. It reached none** of {success, tool-error, denied, cancelled, expired, dispatch-failed}.

Update — still no terminal state after an hour+. The call was issued at 22:10:37 UTC. After the gate was finally answered locally, the turn advanced to "Forming…" and then froze there: the local console reads Forming… 53m16s · ↓179 tokens — the elapsed timer keeps climbing while the token count stays pinned at 179, i.e. a dead dispatch that is displayed as in-progress. As of ~23:27 UTC (~1 h 17 min after the call was issued) there is still no terminal state in the transcript and no automatic timeout has fired. It has simply stayed open, presenting a live spinner the whole time.

Why this matters / what to assert

This lines up with the open-obligation framing earlier in the thread: every emitted tool_use_id must reach exactly one terminal state, and a UI approval must not be considered successful until it transitions the pending call out of pending. Here:

  • prompt_visible stayed true (session list still said "Approve once"),
  • decision_accepted_by_surface was true (app cleared it; the tap hit the network),
  • decision_delivered_to_call never happened (local gate stayed open; tool_use stayed orphaned).

Three surfaces, three different states, and the call itself never closed. And it's not MCP-specific — this was a built-in Read.

Repro is reliable when the surface is closed during the wait (it's intermittent on a live surface — consistent with the "majority of prompts" failure rate reported above). Happy to share the redacted strace window (showing the Approve hitting the network with no corresponding resolution) and the transcript slice (the orphaned tool_use with no tool_result) if useful for a fix/regression test.

Screenshots

Console and web view side-by-side, ~53 minutes into the post-answer freeze. The local console reads Forming… 53m16s · ↓179 tokens — the elapsed timer climbs while the token count stays pinned at 179, a dead dispatch rendered as in-progress — while the web view shows the phantom "Reading hostname" and the queued "test" message that never runs:

!Console and web side-by-side: console shows "Forming… 53m16s · 179 tokens" while the web view shows a phantom "Reading hostname"

The session list still shows "Approve once" for the session — even though it had already been approved from the chat:

!Session list still showing "Approve once" after approval

The chat itself: the prompt is gone and it shows a phantom "Reading…", with a later message left queued (it never gets processed, because the local gate is still blocked):

!Chat showing phantom "Reading…" and a queued message that never runs

rpelevin · 1 month ago

This is the cleanest repro boundary so far.

The useful split is that the remote approval path has two separate success conditions, and only the first one is happening here:

  1. the remote surface accepts the decision and sends it;
  2. the local pending call consumes that decision and writes exactly one terminal event for the original tool_use_id.

In this repro, the first happens and the second does not. That makes Approve look successful to the reviewer while the authoritative gate still says pending.

For a regression test, I would make the backgrounded-surface case explicit:

  • start a Remote Control session;
  • trigger a permission-gated built-in Read outside the project root;
  • let the prompt appear, then background or close the remote surface;
  • wait long enough that the remote surface has to reconnect;
  • approve from the remote surface;
  • assert that the local gate resolves, the original tool_use_id receives one terminal state, and a later remote message is not queued behind a stale gate.

Two negative assertions seem just as important:

  • a UI acknowledgement cannot clear the prompt as successful unless the local gate has accepted the same decision;
  • if delivery fails, the tool_use_id must close as delivery-failed or expired, not remain pending indefinitely.

That keeps the fix centered on the handoff this repro isolates: not just whether the prompt is visible, and not just whether the network saw a tap, but whether the decision actually reached the pending call that owns the tool_use.

paul43210 · 1 month ago

After a day of waiting for the Chat to return, i gave up. Here is the final screenshot.
<img width="1112" height="295" alt="Image" src="https://github.com/user-attachments/assets/d2d4573f-4562-4be4-95cf-772fc3b29362" />

paul43210 · 19 hours ago

Resolved on Claude Code v2.1.207.

Filed originally on 2.1.143; the instrumented repro was on 2.1.176. Re-tested end-to-end today on 2.1.207 and the remote-control permission flow now recovers correctly:

  • The permission prompt now renders in the claude.ai/code web + mobile UI (previously it never surfaced remotely).
  • Approval requests now also raise desktop + mobile push notifications, so a prompt is no longer easy to miss while away from the surface.
  • Approving on a live/foregrounded surface binds immediately and the tool_result returns.
  • The original killer scenario — prompt raised, remote surface closed/backgrounded ~15–20 min, then reopened — no longer orphans the call. On reopen the prompt re-presents and approving completes it. (Minor wrinkle: in the closed-then-reopened case the approval had to be tapped twice before it bound — but it does complete; no more silent indefinite hang requiring a local Esc.)

Closing as resolved. Thanks to all who added repros.