A remote MCP server speaks Streamable HTTP: every message is a POST, and the answer is either one JSON body or, for anything that takes a while, a stream of server-sent events — progress notifications as the work goes, then the result. Put a reverse proxy in front of it and the proxy decides how that stream travels. nginx’s defaults are made for pages, not streams: it collects the upstream’s answer in buffers before passing it on, and it gives up on an upstream that stays silent for longer than its read timeout. Either one alone ruins a long tool call. This lab had both, and a server that could be reached around the proxy as well.
proxy_buffering and proxy_read_timeout do to an event stream, and measure it.The reports server answers build_report with Content-Type: text/event-stream: one data: line per
event, a progress notification every three seconds, the result after nine. The first bytes leave at
once — the status line and the first event — so a client knows the call is alive long before it is done.
That is the point of streaming, and the thing a proxy can take away.
With proxy_buffering on (nginx’s default), nginx reads the upstream response into memory and sends it
to the client in large pieces — for a response that is never large, only when it ends. The client sees
nothing for nine seconds and then everything at once: no progress, and no sign of life to tell a slow
call from a hung one. proxy_buffering off passes each piece on as it arrives. (An upstream can ask for
the same per response with an X-Accel-Buffering: no header; this server does not send one.)
proxy_read_timeout is the longest nginx waits between two reads from the upstream, not for the whole
response. Here it was two seconds and the server writes every three, so nginx closes the connection
after the first gap — the error log says upstream timed out … while reading upstream — and the client
gets a response with a status line and no result. The value has to exceed the longest silence a tool
can have: the gap between progress events, or the whole call when a server sends none.
A Streamable HTTP client that loses a stream before its result can wait, or try to resume it. Claude
Code 2.1.270 waited, and the job hung until something killed it; the job now sets MCP_TOOL_TIMEOUT
(milliseconds) so a call that has gone quiet fails after twenty seconds. That bounds the damage; it is
not the fix. The fix is a proxy that passes the stream through.
A proxy in front of a server is where access is logged, rate-limited and, eventually, encrypted. A
server that also listens on every address makes all of that optional: anyone who can reach the machine
can go around it. The server binds 127.0.0.1, and only nginx — on the same machine — can reach it.
Replayed on the lab’s container (Ubuntu 26.04, nginx from Ubuntu’s archive, Claude Code 2.1.270; the site trimmed to its location block, the settings on one line). The proxy’s site and the server’s settings:
$ cat /etc/nginx/conf.d/reports.conf; cat /etc/reports-mcp/config.json
location /mcp {
proxy_pass http://127.0.0.1:8931;
proxy_set_header Host $host;
proxy_read_timeout 2s;
proxy_buffering on;
}
{"bind": "0.0.0.0", "port": 8931, "step_seconds": 3}
A small client that posts tools/call build_report and prints each line as it arrives, with the time.
Straight to the server:
0.0s HTTP/1.0 200 OK
0.0s data: {"jsonrpc": "2.0", "method": "notifications/progress", ...
3.0s data: {"jsonrpc": "2.0", "method": "notifications/progress", ...
6.0s data: {"jsonrpc": "2.0", "method": "notifications/progress", ...
9.0s data: {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "Weekly report W42-6c00: 3 i
9.0s (connection closed)
Through nginx on :8080, one setting at a time, nginx restarted from scratch for each:
== proxy_buffering on; proxy_read_timeout 2s
2.0s HTTP/1.1 200 OK
2.0s (connection closed)
== proxy_buffering on; proxy_read_timeout 300s
9.0s HTTP/1.1 200 OK
9.0s data: … progress (×3, all at once)
9.0s data: … result
9.0s (connection closed)
== proxy_buffering off; proxy_read_timeout 2s
0.0s HTTP/1.1 200 OK
0.0s data: … progress
2.0s (connection closed)
== proxy_buffering off; proxy_read_timeout 300s
0.0s HTTP/1.1 200 OK
0.0s data: … progress
3.0s data: … progress
6.0s data: … progress
9.0s data: … result
9.0s (connection closed)
and nginx’s error log for the first: upstream timed out (110: Connection timed out) while reading upstream. The job, with the broken proxy and then the fixed one:
21s build_report: MCP server "reports" tool "build_report" timed out after 20s
10s build_report: Weekly report W42-6c00: 3 incidents, 2 changes, 0 open.
The way around the proxy, before and after "bind": "127.0.0.1" and sudo reports-mcp restart:
$ ss -Hltn "sport = :8931"; hostname -I
LISTEN 0 5 0.0.0.0:8931 0.0.0.0:*
172.17.0.6
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST … http://172.17.0.6:8931/mcp
200
$ ss -Hltn "sport = :8931"
LISTEN 0 5 127.0.0.1:8931 0.0.0.0:*
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST -d "{}" http://172.17.0.6:8931/mcp
000
.mcp.json at :8931. The report arrives; the proxy, its log and everything it will do
are bypassed. The grader measures through :8080.MCP_TOOL_TIMEOUT. The client waits longer for a stream the proxy has already closed.nginx -t && nginx -s reload # here: sudo proxy-restart
proxy_buffering off; # pass the stream on as it arrives
proxy_read_timeout 300s; # longest silence between two reads
proxy_http_version 1.1; proxy_set_header Connection "";
sudo tail /var/log/nginx/error.log # "upstream timed out … while reading upstream"
ss -Hltn "sport = :8931" # who listens, on which address
curl -N -X POST -H 'Accept: application/json, text/event-stream' … # -N: do not buffer either
Where this lab’s hints send you for reading, beyond this journal. In the TUI, h shows a
hint’s reading and l opens a journal section; the lab’s page on the site lists the same.
mcp) — TransportsManual pages: man 8 ss.
Documentation:
The whole subject, end to end: the topic journals The Model Context Protocol (mcp), Resolves here, listens there, routes until Tuesday (networking) —
the mechanism, the symptoms and their causes, exercises, and sources.
How does a Streamable HTTP server answer a tool call that takes a while?
With a server-sent event stream: progress notifications as it goes, then the JSON-RPC result, all on the response to that POST.
What does proxy_buffering on do to that stream in nginx?
nginx collects the response and sends it on in large pieces — for a short stream, only at the end — so the client gets no progress until the call is over.
proxy_read_timeout is 60 seconds and a tool call takes five minutes. When is that a problem?
Only if the upstream is ever silent for more than 60 seconds; the timeout is between reads, so progress events every few seconds keep the connection alive.
Why is setting a client tool timeout not a fix here?
It makes a lost stream fail sooner instead of hanging, but the proxy still cuts or holds back every long call.
Why must the server listen on loopback when it sits behind a proxy on the same machine?
Otherwise clients can reach it directly and bypass the proxy’s logging, limits and TLS.