MCP Streamable HTTP servers that garbage-collect idle sessions on a short
TTL (e.g. Unreal Engine's editor MCP, ~15s) were unusable: the keepalive
was hardcoded at 180s, so the session was always dead by the time it ran,
and every idle tool call then landed on an expired session and paid the
full reconnect path (observed hangs of 113-143s until interrupt, bounded
only by the 300s tool_timeout).
Two coordinated, backward-compatible changes:
- Add per-server `keepalive_interval` (config.yaml, not an env var per the
contribution rubric). Default 180s — byte-identical to the old hardcoded
value when unset — floored at 5s. Servers with short session TTLs set it
below their TTL so the session stays warm.
- Switch the keepalive probe from `list_tools()` to `ping` (the MCP base
protocol liveness primitive). On large servers `list_tools` pulled ~1 MB
every cycle (830 tools = 1,068,041 bytes); `ping` is ~55 bytes and works
uniformly across tool/prompt/resource servers. Tool-list changes still
arrive out-of-band via notifications/tools/list_changed -> _refresh_tools.
`ping` is an OPTIONAL utility, so to guarantee zero regression for a
tool-capable server that doesn't implement it: the first -32601 latches
`_ping_unsupported` and the probe falls back to the pre-ping `list_tools`
path for that connection (no reconnect loop). The latch resets on each
fresh connection (_discover_tools, all transport paths) so a server that
gains ping support after a reconnect is re-probed with the cheap path.
Non-(-32601) ping errors propagate as genuine liveness failures.
Verified end-to-end against a live Unreal MCP server (idle 22s past the
~15s TTL -> post-idle tool call returns in 0.31s, no teardown) and with a
simulated ping-less tool server driving the real keepalive loop (ping once,
list_tools thereafter, no reconnect). 25/25 unit tests pass.
Note: a separate upstream defect (modelcontextprotocol/python-sdk#2604)
still tears down the whole session when one tool-call POST returns 4xx;
that is not addressed here.