Skip to content

fix(mcp): make stateless_http configurable and session-capable by default - #230

Open
street1983nk wants to merge 1 commit into
nextcloud:mainfrom
street1983nk:fix/stateless-http-session-compat
Open

fix(mcp): make stateless_http configurable and session-capable by default#230
street1983nk wants to merge 1 commit into
nextcloud:mainfrom
street1983nk:fix/stateless-http-session-compat

Conversation

@street1983nk

Copy link
Copy Markdown

Problem

Clients built on the MCP Python SDK >= 1.28 (Claude Code, Hermes Agent, Cursor and other
Streamable HTTP clients) cannot stay connected to the context_agent MCP server. The
initialize call succeeds and the tools are discovered, but the next request fails:

McpError: Session terminated
MCP server 'Nextcloud' failed initial connection after 3 attempts, parking

Cause

ex_app/lib/main.py mounts the MCP app with a stateless transport:

http_mcp_app = mcp.http_app("/", transport="http", stateless_http=True)

With fastmcp 2.14.7 that flag makes the session manager build a throwaway transport per
request and call terminate() once the request is answered
(StreamableHTTPSessionManager._handle_stateless_request). Every POST becomes an
independent transaction, so a client that keeps the ClientSession it created during
initialize is talking to a session that no longer exists.

The same setting also costs both server to client channels on that leg: server initiated
requests raise NoBackChannelError and notifications are dropped silently.

Fix

One functional change, no other scope:

# Session-capable by default: SDK >= 1.28 clients keep the session after initialize
# and fail with "Session terminated" when the transport is stateless. See #227.
_stateless_http = os.getenv("MCP_STATELESS_HTTP", "0").lower() in ("1", "true", "yes")
http_mcp_app = mcp.http_app("/", transport="http", stateless_http=_stateless_http)

os is already imported in that module, so the diff stays at one file and four lines.
The call keeps the existing fastmcp 2.14.7 http_app signature, so this is independent of
the pending fastmcp 3.x bump in #177.

Backwards compatibility

The default flips from stateless to session-capable, which is what fixes the bug. Nobody
loses the old behaviour: a deployment that deliberately wants a stateless transport, for
example to spread the legacy leg over several workers without sticky routing, sets
MCP_STATELESS_HTTP=1 and gets exactly what it has today. Session-capable transports keep
their session state in process, so a multi worker deployment that does not set the variable
needs sticky routing.

Reproduction

The failure only shows with a client on the 1.x SDK line, because a mcp >= 2 client using
the 2026-07-28 protocol era is sessionless by construction and never reaches the code path
that reads stateless_http. That asymmetry is why the server looks healthy in some setups.

  1. Run context_agent (2.8.0 or current main) on a Nextcloud instance and note the
    Streamable HTTP endpoint URL your MCP client is configured with.

  2. Save this as legacy_client_check.py:

    import asyncio
    import sys
    
    from mcp import ClientSession
    from mcp.client.streamable_http import streamablehttp_client
    
    
    async def check(url: str) -> int:
        headers = {"Authorization": "<the same auth header your MCP client sends>"}
        async with (
            streamablehttp_client(url, headers=headers) as (read, write, _session_id),
            ClientSession(read, write) as session,
        ):
            await session.initialize()
            result = await session.list_tools()
        print(f"tools/list returned {len(result.tools)} tools")
        return 0
    
    
    sys.exit(asyncio.run(check(sys.argv[1])))
  3. Run it against the endpoint with the legacy SDK pinned into an isolated environment:

    uv run --isolated --no-project --with "mcp>=1.29,<2" \
        python legacy_client_check.py https://<nextcloud>/<mcp-endpoint>

Before the fix: initialize returns, then tools/list raises McpError: Session terminated
and the script exits non zero.

After the fix (or with MCP_STATELESS_HTTP unset on a patched deployment): the script prints
the number of tools and exits 0. Setting MCP_STATELESS_HTTP=1 reproduces the old failure,
which is a convenient way to confirm that the switch really is the cause.

Where the automated regression test lives

Automating this inside this repository would need a second client environment on the 1.x SDK
line talking to a running ExApp container, on top of an already heavy server version matrix
(master, stable33, stable32, stable31 plus the llm2 app). That would add a lot of CI weight
and flakiness for one flag, so the check is automated in our project instead, and it is the
source of the reproduction above:

  • Repository: https://github.com/street1983nk/nextcloud-mcp-connector
  • tests/compat/legacy_client_check.py performs initialize plus tools/list under
    mcp>=1.29,<2 in its own environment and exits 1 on "Session terminated".
  • tests/compat/test_client_matrix.py runs that legacy client and a mcp>=2,<3 client
    against the same endpoint, so a stateless transport regression fails the build.

Happy to switch this to a plain stateless_http=False without the environment variable if
you prefer the smaller surface.

Fixes #227

…ault

Clients built on the MCP SDK >= 1.28 keep the session that initialize
creates. With stateless_http=True the fastmcp transport throws that
session away per request, so the next call fails with "Session
terminated" and both server-to-client channels are gone.

Default to a session-capable transport, which is what those clients
expect, and keep the previous behaviour available for deployments that
want it by setting MCP_STATELESS_HTTP=1.

Fixes nextcloud#227

Signed-off-by: street1983nk <k.cherif@outlook.de>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP Server incompatible with MCP SDK ≥1.28 clients: stateless_http=True causes immediate session termination

1 participant