[BUG] MCP progress notifications are received but discarded once a call is auto-backgrounded at 120s
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
When an MCP tool call exceeds the 120s foreground limit, Claude Code moves it to the background — and from that moment the progress the server is streaming becomes unobservable. Opening the background task manager (↓ to manage) gives one static row per task:
Background
MCP tasks (1)
❯ ⏳ slowstream/slow_stream · klxdvixp · working
↑/↓ to select · x to stop · Esc to close
A tool name, an opaque task ID, and the frozen word working. The server is emitting a notifications/progress every two seconds with a human-readable message — 600 of them over a twenty-minute call — and none of it appears here.
The notifications are received; they are then deliberately discarded. The onprogress callback on the call stays live for the whole call and is actively used — it disarms the transport watchdog and refreshes the idle-timeout timestamp on every notification. It then forwards the message to a sink that backgrounding switches off: onBackgrounded sets the exact latch that makes the forwarder a no-op, and nothing is wired up in its place. So the text arrives, is acted on for timeout purposes, and is dropped. Meanwhile the task row's renderer already knows how to display a status string, and the formatter that renders exactly the desired output already exists on the foreground path. Details and excerpts in Additional Information.
There is also nowhere further to drill in. Every other backgrounded task type offers enter to view a detail pane; MCP tasks are excluded by an explicit type check, so the affordances really are select, stop, and close. That one row is the whole surface a backgrounded MCP call gets.
Why this matters. Backgrounding is triggered by duration, so the behavior is inverted relative to need: short calls, where progress is least interesting, stream their progress; long calls, where it is essential, go dark after the first two minutes. The practical outcome is that the only way to react to a slow MCP call is to kill it with x and lose the work. Distinguishing "still working, on the fourth of seven targets" from "wedged on fixture setup for eighteen minutes" is impossible. With several calls backgrounded at once, every row reads working and they are indistinguishable from each other.
What Should Happen?
Each row should carry the latest message from the server instead of a fixed working:
MCP tasks (2)
› slowstream/slow_stream · kslinnvg · run: step 412 - 824s elapsed, still working
slowstream/slow_stream · kuw8p5hj · batch-2: compiling fixtures (3 of 7 targets)
One constraint on any fix: progress/total are optional in MCP and real servers frequently omit total, so message has to be the primary content — show the counter when the server provides one. A fix that renders only numeric progress would leave those servers exactly as opaque as they are now.
Conveniently, that exact ladder is already implemented for the foreground path, so this is closer to re-pointing an existing formatter than writing a new one. See item 6 below.
Error Messages/Logs
# 1. What the user actually sees when the call is backgrounded. Note the TUI does
# not render the tool result verbatim -- the model paraphrases it, so the exact
# wording varies per run. Observed:
Fourth run started - backgrounded as task klxdvixpt. I'll report the result when it completes.
# The underlying tool result, from the 2.1.231 binary, is a fixed template:
MCP tool "${serverName}/${toolName}" is still running after ${N}s. It was moved to the
background as task ${id} and keeps running; you'll receive a notification with the result
when it completes. You can keep working in the meantime. To stop it, use TaskStop with
task_id "${id}". Note: it does not survive exiting this session.
# 2. smoke_test.py drives the same server directly over stdio with no MCP client
# involved. The notifications are on the wire, on schedule, with `message`
# populated — nothing is missing server-side; the gap is client-side:
[ 0.0s] {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}, "logging": {}}, "serverInfo": {"name": "slow-stream", "ver
[ 0.0s] {"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "slow_stream", "description": "Runs for a long time (default 20 minutes) and streams a progress notifi
[ 1.0s] progress 1/8 smoke: step 1/8 - 1s elapsed, still working
[ 2.0s] progress 2/8 smoke: step 2/8 - 2s elapsed, still working
[ 3.0s] progress 3/8 smoke: step 3/8 - 3s elapsed, still working
[ 4.0s] progress 4/8 smoke: step 4/8 - 4s elapsed, still working
[ 5.0s] progress 5/8 smoke: step 5/8 - 5s elapsed, still working
[ 6.0s] progress 6/8 smoke: step 6/8 - 6s elapsed, still working
[ 7.0s] progress 7/8 smoke: step 7/8 - 7s elapsed, still working
[ 8.1s] progress 8/8 smoke: step 8/8 - 8s elapsed, still working
[ 8.1s] RESULT: smoke: finished after 8.0s and 8 progress notifications.
Steps to Reproduce
A dependency-free reproduction is attached as mcp-progress-repro.zip, and every file is also inlined below so nothing needs downloading. The server is plain line-delimited JSON-RPC 2.0 over stdin/stdout — no SDK, no npm install, no venv. .mcp.json uses a relative path that Claude Code resolves against the project directory, so the files work wherever you put them with nothing to edit.
The tool is slow_stream(duration_seconds=1200, interval_seconds=2, label="run", include_total=True). It sleeps for duration_seconds, and every interval_seconds it emits a notifications/progress echoing the call's _meta.progressToken, with an incrementing progress, a human-readable message, and — unless include_total is false — a total. It also emits a notifications/message info log carrying the same text, so both notification channels are exercised.
The 90-second version. From the directory holding the files:
CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS=10000 claude
- Prompt:
Call the slowstream slow_stream tool with duration_seconds 60.Approve the tool and the server when prompted. - The call starts in the foreground and progress is visible, rendered by the formatter in item 6 below:
````
● Calling slowstream · 6s…
└ run: step 3/30 - 6s elapsed, still working (10%)
- At 10s the call is backgrounded and the model says so (message #1 above; with default settings this happens at 120s instead). The footer changes to
1 MCP task. - Press
↓to open the background task manager. (Escinterrupts instead.) - Select the running
slowstream/slow_streamentry and try to see its progress. Observed:slowstream/slow_stream · <id> · working, unchanged for the remaining 50 seconds. Enter does nothing. Expected: the latest streamedmessage.
Plain claude with default arguments is the real-world shape — a 1200s call backgrounded at 120s, then eighteen more minutes of progress notifications arriving and none of them shown. Behavior is identical from step 3 onward.
Variants. Two calls with different label values reproduce the multi-task case; the server threads each tools/call, so they stream independently and both rows read working. Adding include_total false drops the optional total, leaving message as the only renderable content — this is the shape a fix has to handle.
Conditions that suppress auto-backgrounding, worth checking if this doesn't reproduce: it is off for sse-ide/ws-ide transports; off in non-interactive sessions unless CLAUDE_AUTO_BACKGROUND_TASKS is set; off when CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set; and off for MCP calls made by subagents. Setting CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS explicitly also forces the feature on regardless of its gate, which is why the recipe above uses it.
<details>
<summary><code>slow_stream_mcp.py</code> (click to expand)</summary>
#!/usr/bin/env python3
"""
Minimal MCP server (stdio, zero dependencies) that reproduces:
"A long-running MCP tool call gets auto-backgrounded, and once backgrounded
its streamed progress is no longer visible anywhere in the UI."
The single tool `slow_stream` runs for `duration_seconds` (default 1200, i.e. 20
minutes) and emits a `notifications/progress` every `interval_seconds` (default
2), each with an incrementing progress value and a human-readable `message`. It
also emits a `notifications/message` (logging) at every tick, so the repro
covers both notification channels.
The 20-minute default matches the real-world calls this was filed against; the
bug itself is observable from the 120s mark onward, so pass a smaller
`duration_seconds` for a faster confirmation.
Nothing here is Python-specific -- it is plain line-delimited JSON-RPC 2.0
over stdin/stdout, per the MCP stdio transport.
"""
import json
import sys
import threading
import time
PROTOCOL_VERSION = "2025-11-25"
# First spec revision with `message` on progress notifications. Version strings
# are dates, so they compare lexically.
MIN_PROTOCOL_VERSION = "2025-03-26"
SERVER_INFO = {"name": "slow-stream", "version": "1.0.0"}
def negotiate(requested):
"""Echo the client's protocol version when this server can speak it.
Only tools, progress notifications and logging are used here, all stable
since 2025-03-26, so echoing avoids a needless downgrade.
"""
if isinstance(requested, str) and requested >= MIN_PROTOCOL_VERSION:
return requested
return PROTOCOL_VERSION
_stdout_lock = threading.Lock()
def send(payload):
"""Write one JSON-RPC message to stdout. Thread-safe."""
with _stdout_lock:
sys.stdout.write(json.dumps(payload) + "\n")
sys.stdout.flush()
def log(msg):
"""Server-side diagnostics go to stderr; stdout is the protocol channel."""
print(f"[slow-stream] {msg}", file=sys.stderr, flush=True)
TOOLS = [
{
"name": "slow_stream",
"description": (
"Runs for a long time (default 20 minutes) and streams a progress "
"notification every couple of seconds. Used to reproduce a UI bug "
"with backgrounded MCP calls."
),
"inputSchema": {
"type": "object",
"properties": {
"duration_seconds": {
"type": "number",
"description": "How long the tool should run. Default 1200 (20 minutes).",
"default": 1200,
},
"interval_seconds": {
"type": "number",
"description": "Seconds between progress notifications. Default 2.",
"default": 2,
},
"label": {
"type": "string",
"description": "Free-form label echoed into every progress message, so concurrent calls are distinguishable.",
"default": "run",
},
"include_total": {
"type": "boolean",
"description": (
"Whether to send the optional `total` field. Set false to match "
"servers that stream free-form status with no known end point, "
"leaving `message` as the only renderable content."
),
"default": True,
},
},
},
}
]
def run_slow_stream(request_id, arguments, progress_token):
"""Executed on its own thread so the server keeps serving during the call."""
duration = float(arguments.get("duration_seconds", 1200))
interval = float(arguments.get("interval_seconds", 2))
label = str(arguments.get("label", "run"))
include_total = bool(arguments.get("include_total", True))
total_ticks = max(1, int(duration / interval))
started = time.monotonic()
log(f"{label}: starting, {duration}s in {total_ticks} ticks, progressToken={progress_token!r}")
for tick in range(1, total_ticks + 1):
time.sleep(interval)
elapsed = time.monotonic() - started
if include_total:
message = f"{label}: step {tick}/{total_ticks} - {elapsed:.0f}s elapsed, still working"
else:
# No denominator anywhere: `message` is the only thing a client can show.
message = f"{label}: step {tick} - {elapsed:.0f}s elapsed, still working"
if progress_token is not None:
# `progress` is required by the spec; `total` and `message` are optional.
params = {"progressToken": progress_token, "progress": tick, "message": message}
if include_total:
params["total"] = total_ticks
send({"jsonrpc": "2.0", "method": "notifications/progress", "params": params})
# Second channel: MCP logging notification.
send(
{
"jsonrpc": "2.0",
"method": "notifications/message",
"params": {
"level": "info",
"logger": "slow_stream",
"data": message,
},
}
)
log(message)
elapsed = time.monotonic() - started
send(
{
"jsonrpc": "2.0",
"id": request_id,
"result": {
"content": [
{
"type": "text",
"text": (
f"{label}: finished after {elapsed:.1f}s and "
f"{total_ticks} progress notifications."
),
}
]
},
}
)
log(f"{label}: done after {elapsed:.1f}s")
def handle(message):
method = message.get("method")
request_id = message.get("id")
params = message.get("params") or {}
# Notifications from the client carry no id and expect no response.
if request_id is None:
log(f"notification: {method}")
return
if method == "initialize":
# Claude Code surfaces server stderr in its MCP log, so this records the
# client's requested protocol version for the bug report.
log(
f"initialize from {params.get('clientInfo')} "
f"requesting protocolVersion={params.get('protocolVersion')!r}"
)
send(
{
"jsonrpc": "2.0",
"id": request_id,
"result": {
"protocolVersion": negotiate(params.get("protocolVersion")),
"capabilities": {"tools": {}, "logging": {}},
"serverInfo": SERVER_INFO,
},
}
)
elif method == "tools/list":
send({"jsonrpc": "2.0", "id": request_id, "result": {"tools": TOOLS}})
elif method == "tools/call":
progress_token = (params.get("_meta") or {}).get("progressToken")
arguments = params.get("arguments") or {}
threading.Thread(
target=run_slow_stream,
args=(request_id, arguments, progress_token),
daemon=True,
).start()
elif method in ("ping", "logging/setLevel"):
# `logging/setLevel` is answered because the server declares the logging
# capability; an error here would be the server's bug, not the client's.
send({"jsonrpc": "2.0", "id": request_id, "result": {}})
else:
send(
{
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
)
def main():
log("ready on stdio")
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
log(f"skipping non-JSON line: {line[:120]!r}")
continue
handle(message)
if __name__ == "__main__":
main()
</details>
<details>
<summary><code>.mcp.json</code></summary>
{
"mcpServers": {
"slowstream": {
"command": "python3",
"args": ["slow_stream_mcp.py"]
}
}
}
</details>
<details>
<summary><code>smoke_test.py</code> — confirms the server is not at fault</summary>
#!/usr/bin/env python3
"""
Smoke test: drives slow_stream_mcp.py over stdio without any MCP client and
prints every notification it emits. Confirms the server really is streaming
progress, so any missing progress in the Claude Code UI is a client-side issue.
python3 smoke_test.py # 10s run, ticks every 1s
python3 smoke_test.py 1200 2 # full repro timing (20 minutes)
"""
import json
import subprocess
import sys
import threading
import time
DURATION = float(sys.argv[1]) if len(sys.argv) > 1 else 10
INTERVAL = float(sys.argv[2]) if len(sys.argv) > 2 else 1
proc = subprocess.Popen(
[sys.executable, "-u", "slow_stream_mcp.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
)
def send(msg):
proc.stdin.write(json.dumps(msg) + "\n")
proc.stdin.flush()
def reader():
started = time.monotonic()
for line in proc.stdout:
msg = json.loads(line)
stamp = f"{time.monotonic() - started:6.1f}s"
if msg.get("method") == "notifications/progress":
p = msg["params"]
print(f"[{stamp}] progress {p['progress']}/{p['total']} {p.get('message', '')}")
elif msg.get("method") == "notifications/message":
pass # duplicate of the progress text; skip for readability
elif msg.get("id") == 3:
print(f"[{stamp}] RESULT: {msg['result']['content'][0]['text']}")
else:
print(f"[{stamp}] {json.dumps(msg)[:160]}")
threading.Thread(target=reader, daemon=True).start()
send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "smoke", "version": "0"}}})
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
send({"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "slow_stream",
"arguments": {"duration_seconds": DURATION,
"interval_seconds": INTERVAL,
"label": "smoke"},
"_meta": {"progressToken": "tok-1"}}})
time.sleep(DURATION + 3)
proc.terminate()
</details>
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
2.1.231
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
<img width="617" height="180" alt="Image" src="https://github.com/user-attachments/assets/97ed0319-8141-4d80-998c-9f435b917c90" />
<img width="629" height="215" alt="Image" src="https://github.com/user-attachments/assets/93b3cdeb-e900-4c2d-81ad-c93c69405023" />
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗