[BUG] MCP progress notifications are received but discarded once a call is auto-backgrounded at 120s

Status Open
Reported on v2.1.231
Maintainer reply None cached
Activity 1 comment · opened Aug 13, 2026

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
  1. Prompt: Call the slowstream slow_stream tool with duration_seconds 60. Approve the tool and the server when prompted.
  2. 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%)
``

  1. 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.
  2. Press to open the background task manager. (Esc interrupts instead.)
  3. Select the running slowstream/slow_stream entry and try to see its progress. Observed: slowstream/slow_stream · <id> · working, unchanged for the remaining 50 seconds. Enter does nothing. Expected: the latest streamed message.

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" />

mcp-progress-repro.zip

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗