Session tracking
How Spanly groups MCP requests into sessions, including synthetic session IDs for sessionless servers.
MCP's Streamable HTTP transport has an optional session mechanism: the
server assigns an Mcp-Session-Id header on the initialize response,
and the client echoes it on every subsequent request. When that header
is present, Spanly groups requests into sessions, so you can follow a
single client's conversation (initialize, tool calls, prompts) as one
thread instead of a flat list of requests.
Servers with sessions
If your server assigns session IDs (for example a stateful
StreamableHTTPServerTransport with a sessionIdGenerator), there is
nothing to configure. Spanly picks the ID up from the captured headers
and session grouping works out of the box.
Sessionless servers: synthetic session IDs
Many production MCP servers run stateless: a fresh server instance per request, no session ID assigned, every request self-contained. That is a perfectly valid deployment shape, but without a session ID Spanly cannot tell which requests belong to the same client conversation.
To close that gap, every Spanly instrumentation method assigns a
synthetic session ID (prefixed spanly-) when the server doesn't. How
that ID reaches the wire differs by surface:
- TypeScript SDK: the middleware never sends a response or mutates a header. It mints the synthetic ID internally (a "sessionizer" keyed on the request's bearer token or remote address) and stamps it only into the transport context it sends to Spanly for telemetry. Your server's actual response to the client is untouched, and the client never sees or echoes this ID. Grouping still works, because the same key produces the same synthetic ID for the life of the idle timeout.
- Python SDK and CLI (and the Docker sidecar, which wraps the CLI):
these inject a real
Mcp-Session-Idresponse header on an initialize response that doesn't already carry one. Per the MCP spec, the client then echoes that header on its subsequent requests, which is what groups them. This is a real, on-the-wire header. The CLI and Docker sidecar strip it back out of requests before forwarding them upstream, so the server itself never sees a header it didn't create; Python's ASGI middleware also owns the DELETE handshake that terminates a synthetic session, answering it directly instead of forwarding it to a server that never issued that ID.
Both approaches are invisible to your server: it serves each request as usual, and a synthetic session is a grouping label only. It does not make the server stateful and does not enable server-to-client notifications or resumability.
Stdio transports are unaffected: a stdio connection is a single conversation already, and there are no HTTP headers to carry a session ID.
Turning it off
The Python SDK, the CLI, and the Docker sidecar mutate a response header to do this, so each offers a toggle. The TypeScript SDK never mutates a response in the first place, so there is nothing to turn off: its synthetic grouping is telemetry-only and always on.
| Surface | Toggle |
|---|---|
| TypeScript SDK | Not applicable. Synthetic grouping never touches the wire. |
| Python SDK | SpanlyMiddleware(app, inject_session_id=False) |
CLI (spanly run / spanly proxy) | --inject-session-id=false |
| Docker sidecar | add --inject-session-id=false to the proxy args |
With injection off (Python, CLI, Docker), requests to sessionless servers are still captured and attributed; they just aren't grouped into sessions.
Notes and edge cases
- Synthetic IDs injected by the Python SDK or the CLI are visible to
MCP clients (that is how they get echoed back). The
spanly-prefix makes them easy to identify in client logs. The TypeScript SDK's synthetic IDs never leave your process, so this does not apply to it. - Clients that don't implement the session part of the Streamable HTTP spec won't echo a real, injected header, and those requests stay ungrouped on the surfaces that rely on the echo (Python, CLI, Docker). All mainstream MCP clients echo it. The TypeScript SDK's telemetry-only grouping does not depend on the client echoing anything.
- Load-balanced, multi-replica servers work fine on every surface: the Python/CLI/Docker session lives in the client's echo, not in server state; the TypeScript SDK's sessionizer keys on the request itself (bearer token or remote address), so it doesn't matter which replica serves each request within the idle timeout.
- In the Python SDK, injection works on any ASGI app, including the
one
streamable_http_app()(FastMCP) returns; see the Python SDK reference.