Skip to content

Session Expiry Causes "Server not initialized" Errors #53

@iujames

Description

@iujames

Issue: Session Expiry Causes "Server not initialized" Errors

Hi - first, thank you for all the work on this library. It has been really helpful to us getting started with an elixir MCP server for our project.

We've run into an issue with inactive client sessions / timeouts, using Cursor, Claude or ChatGPT with our server, and wanted to post here to make sure I was understanding things correctly, or see if you had any feedback on approach.

Problem Description

When using anubis_mcp with the streamable_http transport, sessions that expire due to inactivity cause subsequent requests to fail with "Server not initialized" errors, even though the client and server are both active and the connection is re-established.

Observed behavior:

  1. Session expires after session_idle_timeout (e.g., 30-60 minutes of inactivity) (as designed, in How to automatically close inactive sessions after a long period? cloudwalk/hermes-mcp#138)
  2. Client sends a new request (e.g., tools/call) with the same mcp-session-id
  3. Library creates a new session Agent for that session ID
  4. However, the new session is marked as initialized: false
  5. Request is rejected in handle_single_request with "Server not initialized" error
  6. Client is stuck - can't use the server without an explicit initialize handshake

Root Cause

The library couples session lifecycle with initialization state. When a session expires:

  • The session Agent is terminated
  • A new session is created on the next request via maybe_attach_session
  • But the new session defaults to initialized: false
  • The library expects an explicit initialize request to mark it as initialized
  • Non-initialize requests are blocked by the guard in handle_single_request:
    defguardp is_server_initialized(decoded, session)
              when Message.is_initialize_lifecycle(decoded) or
                     Session.is_initialized(session)

This design assumes sessions should always go through the full MCP initialization handshake, even after expiry. However, in practice:

  • The HTTP connection is persistent (SSE)
  • Authentication happens at the transport/plug level (before MCP)
  • Session expiry is an implementation detail, not a protocol event
  • Clients don't expect to re-initialize after timeouts

Our Workaround

Since we handle authentication in a Plug before the MCP server (via JWT/API key), we implemented auto-initialization in our McpAuth plug:

defp ensure_mcp_session_initialized(conn) do
  # Handle session expiry gracefully: when a session expires and a new request comes in,
  # we create/reinitialize the session automatically since authentication already happened.
  # This prevents "Server not initialized" errors after session timeout.

  # gets from the `mcp-session-id` header passed on the request
  mcp_session_id = get_mcp_session_id(conn)
  
  session_name =
    Anubis.Server.Registry.server_session(CustomerApi.AI.MCP.Server, mcp_session_id)

  case Anubis.Server.Registry.whereis_server_session(
          CustomerApi.AI.MCP.Server,
          mcp_session_id
        ) do
    nil ->
      # Session doesn't exist - create it and mark as initialized
      case Anubis.Server.Session.Supervisor.create_session(
              Anubis.Server.Registry,
              CustomerApi.AI.MCP.Server,
              mcp_session_id
            ) do
        {:ok, _pid} ->
          Anubis.Server.Session.mark_initialized(session_name)

        {:error, {:already_started, _pid}} ->
          # Race condition - session was just created
          Anubis.Server.Session.mark_initialized(session_name)

        error ->
          Logger.error("MCP session creation failed: #{inspect(error)}")
      end

    _pid ->
      # Session exists - check if it needs initialization
      session = Anubis.Server.Session.get(session_name)

      if not session.initialized do
        Anubis.Server.Session.mark_initialized(session_name)
      end
  end
end

We call this before the request reaches the MCP server in the auth plug, and after we've successfully authenticated the request. This seems to just work, because later on in the base server call to maybe_attach_session, it picks up the :already_started case and sets up process monitoring and expiry https://github.com/zoedsoupe/anubis-mcp/blob/v0.15.0/lib/anubis/server/base.ex#L630-L641

Library Options

Perhaps a couple options to consider:

Option 1: Auto-initialize on session recreation (simplest)
When maybe_attach_session creates a new session for an existing session_id, automatically mark it as initialized if the server has already seen an initialize request for that session ID in the past. This could be tracked in the server state. It seems like this would require persistent state using something like the proposal in #48

Option 2: Re-trigger init/2 on session recreation
When maybe_attach_session creates a new session for an existing session_id, if it is not initialized, it could call init/2 on the server to allow the implementation code to handle initialization if possible. In an app like ours where the only state we maintain is the auth layer on top of the server from the plug, this would be feasible.

Related Observations

  • The SSE handler reconnects automatically (sse_handler_down / sse_handler_registered events), which is great
  • But session expiry happens independently of connection state
  • There's no way for servers to intercept session creation to customize initialization behavior
  • The init/2 callback only runs on explicit initialize requests, not on session creation

Hope this helps! Happy to discuss further or test any fixes.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions