Skip to content

MCP client doesn't not initialize new session when getting 404 session not found #1676

Description

@david-nominal

Initial Checks

Description

Per https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management

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.

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

mcp: 1.22.0
python: 3.14.0

Activity

  1. maxisbey commented on Dec 2, 2025

    @maxisbey
    Contributor

    Claude's reproduction:

    Issue Confirmed

    Bug: When the server returns HTTP 404 (session not found), the client raises McpError: Session terminated instead 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-315

    if 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 as McpError

    Expected behavior: Clear self.session_id and send a new InitializeRequest

    Server (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
  2. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Dec 2, 2025
  3. jayhemnani9910 commented on Dec 31, 2025

    @jayhemnani9910

    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.py is imported. This file imports from mcp.cli.claude, which triggers an import chain that reaches mcp/cli/cli.py. That module calls sys.exit(1) if typer is 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.
  4. felixweinberger commented on Feb 5, 2026

    @felixweinberger
    Contributor

    confirmed — the client sends a Session terminated error 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 as MCPError, but the spec requires the client to clear the session ID and send a new InitializeRequest.

    note: the TypeScript SDK also doesn't implement this yet (it throws a generic SdkError on 404), so this would make the Python SDK ahead on spec compliance.

    PR #1818 is open with a proposed fix.

  5. nankingjing commented on Aug 16, 2026

    @nankingjing

    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_FOUND behavior\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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingimproves spec complianceWhen a change improves ability of SDK users to comply with spec definitionready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions