Context.session_id changes on every tool call in 4.x, breaking session state
Summary
Under fastmcp 4.0.3 and 4.0.4, Context.session_id returns a new UUID for every tool call, on stdio, streamable-HTTP and in-memory transports alike. Anything keyed to it stops working between calls, including fastmcp's own ctx.set_state / ctx.get_state, whose docstring advertises exactly that use ("share data between tool calls within the same client session").
Under 3.4.7 the same code sees one session id for the life of the connection.
Reproduction
Two tools: one stores a value in session state, one reads it back. No dependency beyond fastmcp.
# repro_server.py
import inspect
from fastmcp import Context, FastMCP
mcp = FastMCP("session-state-repro")
async def _maybe(value): # set_state/get_state are async in 4.x
return await value if inspect.isawaitable(value) else value
@mcp.tool
async def remember(value: str, ctx: Context) -> str:
await _maybe(ctx.set_state("value", value))
return f"stored; session_id={ctx.session_id}"
@mcp.tool
async def recall(ctx: Context) -> str:
got = await _maybe(ctx.get_state("value"))
return f"session_id={ctx.session_id} value={got!r}"
if __name__ == "__main__":
mcp.run()# repro_client.py
import asyncio, sys, fastmcp
from fastmcp import Client
from fastmcp.client.transports import StdioTransport
async def main():
async with Client(StdioTransport(sys.executable, ["repro_server.py"])) as c:
print("fastmcp", fastmcp.__version__)
print(" ", (await c.call_tool("remember", {"value": "hello"})).content[0].text)
print(" ", (await c.call_tool("recall", {})).content[0].text)
asyncio.run(main())Same machine, Linux x86_64:
fastmcp 3.4.7
stored; session_id=d2968c89-4f5e-4816-8bed-746180337609
session_id=d2968c89-4f5e-4816-8bed-746180337609 value='hello'
fastmcp 4.0.4
stored; session_id=946c6124-aee2-48d0-a503-e4b90c19f0d6
session_id=500f46db-7480-4f21-aa37-608eafcc7976 value=NoneSession ids observed per call:
| Transport | 3.4.7 | 4.0.4 |
|---|---|---|
| stdio | one id for the connection | a new id per call |
| streamable-http | one id for the connection | a new id per call |
in-memory (Client(server)) |
one id for the connection | a new id per call |
Where it seems to come from
Context.session_id in fastmcp/server/context.py caches a generated id on the connection, with this comment:
In SDK v2 the ServerSession is constructed fresh per request, so the stable per-client identity lives on the underlying Connection, which persists for the whole client session.
The connection does not appear to persist. Printing id(session._connection) on three consecutive stdio calls gives three different objects, each arriving with its own _fastmcp_state_prefix already set:
A1 id(conn)=138474642270272 state={'_fastmcp_state_prefix': 'f4a578da'} sid=f4a578da
A2 id(conn)=138474622867344 state={'_fastmcp_state_prefix': '87faed8e'} sid=87faed8e
A3 id(conn)=138474622875984 state={'_fastmcp_state_prefix': '2d2b44b3'} sid=2d2b44b3So connection.state["_fastmcp_state_prefix"] = session_id is written to an object discarded before the next call, and the connection.state.get(...) read on the next call never hits. For streamable-HTTP, connection.session_id is also None and no mcp-session-id header is present on the request, so it falls through to the same generated-UUID branch.
Impact
Any server that keeps per-client state between tool calls. In our case (a SageMath MCP server where each client gets its own interpreter worker), a client's assignment and its follow-up read land in different workers, so variables vanish between calls. We are pinned to fastmcp>=3.4.7,<4 until this is resolved.
Environment
- fastmcp 4.0.3 and 4.0.4 (3.4.7 unaffected)
- Python 3.12 and 3.13, Linux x86_64
Happy to test a candidate fix against a stateful server if that would help.
Source: PrefectHQ/fastmcp