Repository navigation
MCP client doesn't not initialize new session when getting 404 session not found #1676
Description
Activity
Claude's reproduction:
Issue Confirmed
Bug: When the server returns HTTP 404 (session not found), the client raises
McpError: Session terminatedinstead of automatically re-initializing with a new session.Spec requirement (from https://modelcontextprotocol.io/specification/draft/basic/transports):
When a client receives HTTP 404 in response to a request containing an MCP-Session-Id, it MUST start a new session by sending a new InitializeRequest without a session ID attached.
Bug location:
src/mcp/client/streamable_http.py:309-315if response.status_code == 404: if isinstance(message.root, JSONRPCRequest): await self._send_session_terminated_error( ctx.read_stream_writer, message.root.id, ) return
Current behavior: Calls
_send_session_terminated_error()which propagates asMcpErrorExpected behavior: Clear
self.session_idand send a newInitializeRequestServer (
server.py)""" Reproduction server for issue #1676: MCP client doesn't initialize new session when getting 404 session not found This server simulates a session being terminated/expired between requests. Call GET /invalidate to mark all sessions as terminated, then subsequent MCP requests with the old session ID will receive 404 Not Found. Spec reference (https://modelcontextprotocol.io/specification/draft/basic/transports): > When a client receives HTTP 404 in response to a request containing an > MCP-Session-Id, it MUST start a new session by sending a new InitializeRequest > without a session ID attached. Run with: uv run python scratch_server_1676.py """ import contextlib import logging from collections.abc import AsyncIterator from typing import Any from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Mount, Route import mcp.types as types from mcp.server.lowlevel import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) # Create the MCP server mcp_server = Server("session-termination-demo") # Store reference to session manager for termination _session_manager: StreamableHTTPSessionManager | None = None @mcp_server.list_tools() async def list_tools() -> list[types.Tool]: return [ types.Tool( name="get_greeting", description="Returns a simple greeting", inputSchema={"type": "object", "properties": {}}, ), types.Tool( name="get_time", description="Returns the current server time", inputSchema={"type": "object", "properties": {}}, ), ] @mcp_server.call_tool() async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: if name == "get_greeting": return [types.TextContent(type="text", text="Hello from the server!")] elif name == "get_time": import datetime now = datetime.datetime.now().isoformat() return [types.TextContent(type="text", text=f"Current time: {now}")] return [types.TextContent(type="text", text=f"Unknown tool: {name}")] async def invalidate_sessions(request: Request) -> JSONResponse: """ Endpoint to invalidate all sessions. Simulates server restart, session timeout, or session revocation. After calling this, requests with the old session ID will get 404. """ if _session_manager is None: return JSONResponse({"error": "Session manager not initialized"}, status_code=500) invalidated = [] for session_id, transport in list(_session_manager._server_instances.items()): logger.info(f"Marking session as terminated: {session_id}") # Mark as terminated - transport.handle_request will return 404 transport._terminated = True invalidated.append(session_id) return JSONResponse({ "message": f"Invalidated {len(invalidated)} sessions", "sessions": invalidated, }) async def list_sessions(request: Request) -> JSONResponse: """Endpoint to list all active sessions.""" if _session_manager is None: return JSONResponse({"error": "Session manager not initialized"}, status_code=500) sessions = [] for session_id, transport in _session_manager._server_instances.items(): sessions.append({ "session_id": session_id, "terminated": transport.is_terminated, }) return JSONResponse({"sessions": sessions}) # Create session manager session_manager = StreamableHTTPSessionManager( app=mcp_server, json_response=True, # Use JSON for simpler debugging ) _session_manager = session_manager @contextlib.asynccontextmanager async def lifespan(app: Starlette) -> AsyncIterator[None]: async with session_manager.run(): logger.info("=" * 60) logger.info("Server started!") logger.info("MCP endpoint: http://localhost:8000/mcp") logger.info("List sessions: GET http://localhost:8000/sessions") logger.info("Invalidate all: GET http://localhost:8000/invalidate") logger.info("=" * 60) yield logger.info("Server shutting down...") # Create the Starlette app app = Starlette( debug=True, routes=[ Mount("/mcp", app=session_manager.handle_request), Route("/invalidate", invalidate_sessions), Route("/sessions", list_sessions), ], lifespan=lifespan, ) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="127.0.0.1", port=8000)
Client (
client.py)""" Reproduction client for issue #1676: MCP client doesn't initialize new session when getting 404 session not found This client demonstrates the bug where the MCP client fails to re-initialize a new session when the server returns 404 for an expired/invalid session. Steps to reproduce: 1. Start the server: uv run python scratch_server_1676.py 2. Run this client: uv run python scratch_client_1676.py Expected behavior (per spec): - After receiving 404, client should automatically re-initialize with a new session Actual behavior (bug): - Client raises McpError with "Session terminated" instead of re-initializing Spec reference (https://modelcontextprotocol.io/specification/draft/basic/transports): > When a client receives HTTP 404 in response to a request containing an > MCP-Session-Id, it MUST start a new session by sending a new InitializeRequest > without a session ID attached. Bug location in code: - src/mcp/client/streamable_http.py:309-315 - When 404 is received, _send_session_terminated_error() is called instead of clearing the session ID and sending a new InitializeRequest """ import asyncio import logging import httpx from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client # Set to DEBUG to see all HTTP traffic, INFO for cleaner output logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) async def invalidate_server_sessions(): """Call the server's invalidate endpoint to simulate session expiry.""" async with httpx.AsyncClient() as client: response = await client.get("http://localhost:8000/invalidate") return response.json() async def main(): print("=" * 70) print("Issue #1676 Reproduction: Client doesn't re-init on 404") print("=" * 70) try: async with streamablehttp_client("http://localhost:8000/mcp") as ( read_stream, write_stream, get_session_id, ): async with ClientSession(read_stream, write_stream) as session: # Step 1: Initialize and make a successful request print("\n[Step 1] Initializing session...") await session.initialize() session_id = get_session_id() print(f" Session ID: {session_id}") # Step 2: Call a tool to verify everything works print("\n[Step 2] Calling get_greeting tool (should succeed)...") result = await session.call_tool("get_greeting", {}) print(f" Result: {result.content[0].text}") # Step 3: Invalidate the session on the server side # This simulates a server restart or session timeout print("\n[Step 3] Invalidating session on server (simulating expiry)...") invalidate_result = await invalidate_server_sessions() print(f" {invalidate_result['message']}") # Step 4: Try to make another request - this should trigger re-init print("\n[Step 4] Calling get_time tool after session invalidation...") print(" Expected: Client should re-initialize and succeed") print(" Actual (bug): Client raises 'Session terminated' error") print() try: result = await session.call_tool("get_time", {}) print(f" SUCCESS: {result.content[0].text}") print("\n BUG IS FIXED! Client properly re-initialized the session.") except Exception as e: print(f" ERROR: {type(e).__name__}: {e}") print("\n BUG CONFIRMED!") print(" Per the spec, on 404 the client MUST send a new InitializeRequest") print(" without a session ID attached, but instead it just errors out.") print() print(" Relevant code: src/mcp/client/streamable_http.py:309-315") print(" Current behavior: Calls _send_session_terminated_error()") print(" Expected behavior: Clear session_id and send new InitializeRequest") except Exception as e: logger.exception(f"Unexpected error: {e}") raise print("\n" + "=" * 70) if __name__ == "__main__": asyncio.run(main())
To reproduce
# Terminal 1: Start server uv run python server.py # Terminal 2: Run client uv run python client.py
- addedbugSomething isn't workingSomething isn't workingready for workEnough information for someone to start working onEnough information for someone to start working onP2Moderate issues affecting some users, edge cases, potentially valuable featureModerate issues affecting some users, edge cases, potentially valuable feature
on Dec 2, 2025 CI Failures Investigation
I've investigated the CI test failures and they appear to be unrelated to the changes in this PR.
Root cause: The test suite fails during collection when
tests/client/test_config.pyis imported. This file imports frommcp.cli.claude, which triggers an import chain that reachesmcp/cli/cli.py. That module callssys.exit(1)iftyperis not installed:try: import typer except ImportError: print("Error: typer is required. Install with 'pip install mcp[cli]'") sys.exit(1) Local test verification: All tests related to this PR pass locally: - tests/client/test_session_recovery.py: 4 passed ✅ - tests/client/test_session.py: 11 passed ✅ - tests/shared/test_streamable_http.py: 51 passed ✅ - Pyright: 0 errors ✅ - Ruff: All checks passed ✅ The session recovery implementation and tests are working correctly. The CI failures are due to a test environment configuration issue with the typer dependency, not the changes in this PR. Please let me know if you'd like me to investigate further or make any adjustments.
- addedimproves spec complianceWhen a change improves ability of SDK users to comply with spec definitionWhen a change improves ability of SDK users to comply with spec definition
on Feb 5, 2026 confirmed — the client sends a
Session terminatederror on 404 instead of re-initializing per the spec.the bug is in
src/mcp/client/streamable_http.py:260-263— on 404,_send_session_terminated_error()is called which propagates asMCPError, but the spec requires the client to clear the session ID and send a newInitializeRequest.note: the TypeScript SDK also doesn't implement this yet (it throws a generic
SdkErroron 404), so this would make the Python SDK ahead on spec compliance.PR #1818 is open with a proposed fix.
- added a commit that references this issue
on Mar 13, 2026 I'd like to take this on with a focused v2 fix. I reproduced the current behavior on main: after an established Streamable HTTP session receives a bare 404, the pending request gets
MCPError(-32600, "Session terminated")and the client never re-initializes.\n\nProposed scope:\n- preserve pre-session 404 ->METHOD_NOT_FOUNDbehavior\n- turn a post-session 404 into an SDK-internal, request-correlated session-expired signal\n- serialize one re-initialization, retry the original request exactly once, and fail normally if the retry also 404s\n- add deterministic in-process tests for the exact session/header/method sequence and the repeated-404 bound\n\nI will not reuse reserved-32002; the old #1818 approach is stale against the current dispatcher/session architecture.\n\nAI assistance disclosure: I am using Claude for codebase research and test scaffolding; I understand and will own the design, implementation, and review responses.
Initial Checks
Description
Per https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
This is not the case with the python client. It just sends a session terminated error in streamable_http.py
Example Code
Python & MCP Python SDK