to be moved to geocontext after qualifying
Status: open — reproduced against geocontext 0.10.x dev server (TRANSPORT_TYPE=http npm run start, http://localhost:3000/mcp).
Symptom
When running the whole suite against the dev HTTP server:
GEOCONTEXT_DEV=1 MCP_SERVERS_PATH=config/mcp-servers-dev.json uv run scripts/run_tests.py config/models-anthropic.yaml --model=claude-haiku-4-5
random tests fail with a schema validation error raised by the MCP client:
RuntimeError: Invalid structured content returned by tool geocode: 'lon' is a required property
On instance['results'][8]:
{'id': 'BDTOPO_V3:troncon_de_route', 'title': 'Tronçon de route', 'description': '', 'score': 5.072623806839074}
RuntimeError: Invalid structured content returned by tool adminexpress: 'type' is a required property
On instance['results'][3]:
{'id': 'BDTOPO_V3:reservoir', 'title': 'Réservoir', 'description': 'Réservoir (eau, matières industrielles,…)', 'score': 3.5601892294451347}
The same test passes when run alone. The set of failing tests changes from run to run.
Root cause
The payload is not malformed: {id, title, description, score} is the output of gpf_search_types. It was delivered to the client as the answer of geocode / adminexpress. The MCP client validates the structuredContent it receives against the outputSchema of the tool it called (mcp/client/session.py, _validate_tool_result), so it reports a validation error — that is only the symptom. Two concurrent tool calls got their responses swapped.
The chain of events:
- The model emits several tool calls in a single assistant message. Example in
reports/claude-haiku-4-5/test_count_batiment_saint_mande.txt: adminexpress and gpf_search_types are requested in the same turn.
- LangGraph's
ToolNode executes them concurrently (asyncio.gather, langgraph/prebuilt/tool_node.py).
- The tools were built with
client.get_tools() without a session, so langchain-mcp-adapters opens a new MCP session per tool call (langchain_mcp_adapters/client.py: "A new session will be created for each tool call"). This is still the topology used by test_mcp_concurrency.py; the mcp_tools fixture no longer uses it (see below).
- Both sessions hit the same server process on
http://localhost:3000/mcp, which routes at least one response to the wrong stream.
Point 4 is a server-side bug. Steps 1–3 are legitimate client behaviour: MCP explicitly allows concurrent requests, and nothing in the protocol forbids several sessions from the same client.
Likely origin to check in geocontext (to be confirmed in the source): with the TypeScript SDK, a stateless streamable-HTTP endpoint must create a new McpServer + new StreamableHTTPServerTransport for every incoming request. Reusing a single shared transport (or a single server instance connected to a shared transport) across concurrent requests makes responses land on whichever response stream is currently bound — exactly the observed crossing. The stateful alternative is to keep one transport per Mcp-Session-Id and look it up on each request.
Why the stdio config never shows it
With the default config/mcp-servers.json (stdio, npx -y @ignfab/geocontext), each tool call spawns its own server process, so concurrent calls are fully isolated and cannot cross. The bug only appears with the shared HTTP process (config/mcp-servers-dev.json, config/mcp-servers-http.json).
Why a single test usually passes
It is a race, not a test-isolation problem. Two conditions must coincide: the model must batch two tool calls in one turn, and the race must be lost (~40 % of the attempts made by test_mcp_concurrency.py). Over 53 tests it happens regularly; on a single test it usually does not.
Impact
Beyond the red tests, the dangerous case is silent: when the two crossed responses both happen to validate against their respective schemas (two gpf_* tools, for instance), no error is raised and the agent reasons on data belonging to another call.
Reproducing
Start the dev server (in the geocontext repository):
PROXY_URL_SECRET=$(openssl rand -hex 32) docker compose up -d
Then run test_mcp_concurrency.py, which reproduces the bug without any model: it issues a geocode and a gpf_search_types call concurrently — the way LangGraph does when the model emits several tool calls in one turn — and checks that each call gets its own answer. The crossing is a race, lost in roughly 40 % of the rounds, so 10 rounds are run over HTTP.
The test loads its tools with client.get_tools(), i.e. one MCP session per call: that is the topology the server trips on, and the one the mcp_tools fixture deliberately avoids while this issue is open.
GEOCONTEXT_DEV=1 MCP_SERVERS_PATH=config/mcp-servers-dev.json uv run pytest test_mcp_concurrency.py
E Failed: attempt 2/10: concurrent calls crossed, see https://github.com/ignfab/geocontext-test/issues/33
Invalid structured content returned by tool geocode: 'lon' is a required property
It fails in about one second while the server bug is open, which gives the bug one explicit red test instead of random failures scattered across the agent tests.
Under the default stdio config it passes, and runs a single round — each tool call spawns its own server process there, so the crossing cannot happen and each round costs a process start:
uv run pytest test_mcp_concurrency.py
Proposed fixes
1. Server side (geocontext) — the actual fix
Make the streamable-HTTP endpoint safe for concurrent requests, either by creating a new McpServer + StreamableHTTPServerTransport per HTTP request (stateless mode), or by keeping one transport per Mcp-Session-Id and dispatching each request to its own (stateful mode). test_mcp_concurrency.py must pass against the HTTP transport afterwards.
2. Test side — workaround in place
The mcp_tools fixture in conftest.py now loads the tools from one long-lived MCP session per server (load_mcp_tools(session)) instead of client.get_tools(). Concurrent calls are multiplexed over that single session and stop crossing, while the agents keep batching tool calls — so the suite still exercises realistic behaviour, and the client topology is closer to how a real MCP client connects.
Verified on claude-haiku-4-5 against the dev HTTP server: full suite run with no Invalid structured content error left in the agent tests, and the trace of test_count_batiment_saint_mande still shows a turn carrying two tool calls.
Implementation note: the session is opened and closed by a dedicated task, and the fixture teardown only signals it. The MCP transports rely on anyio cancel scopes, which must be exited by the task that entered them, while pytest-asyncio finalizes async fixtures from a different task than the one that set them up — otherwise teardown raises RuntimeError: Attempted to exit cancel scope in a different task than it was entered in.
The suite is also pinned to sequential execution in pyproject.toml (addopts = "-p no:xdist -p no:xdist.looponfail"). pytest-xdist was declared as a dependency but never configured, so tests already ran one at a time; the option makes it explicit, since distributing tests over workers would reopen one MCP session per worker and bring the concurrent-sessions topology straight back.
Alternatives tried and dropped:
- disabling parallel tool calls on the model — a
DisableParallelToolCalls middleware setting parallel_tool_calls=False when the provider exposes it (Anthropic, OpenAI; not Gemini, Mistral nor Ollama). Verified working, but it removes a realistic agent behaviour from the suite and only covers some providers;
- per-test fixture scope (one MCP session per test instead of per run): does not help. The crossing happens between two calls of the same turn, so a per-test session protects exactly like a per-run one; the dedicated-task handling is still required (the cancel-scope error shows up at function scope too, with either loop scope); and it costs one MCP session per test — one
npx server process per test on stdio;
- raising
reruns in pyproject.toml (pytest-rerunfailures is installed, currently reruns = 0), which would retry the races without touching the agent behaviour — but would also retry genuine failures, and would not protect against the silent crossing described in Impact.
Status: open — reproduced against geocontext
0.10.xdev server (TRANSPORT_TYPE=http npm run start,http://localhost:3000/mcp).Symptom
When running the whole suite against the dev HTTP server:
random tests fail with a schema validation error raised by the MCP client:
The same test passes when run alone. The set of failing tests changes from run to run.
Root cause
The payload is not malformed:
{id, title, description, score}is the output ofgpf_search_types. It was delivered to the client as the answer ofgeocode/adminexpress. The MCP client validates thestructuredContentit receives against theoutputSchemaof the tool it called (mcp/client/session.py,_validate_tool_result), so it reports a validation error — that is only the symptom. Two concurrent tool calls got their responses swapped.The chain of events:
reports/claude-haiku-4-5/test_count_batiment_saint_mande.txt:adminexpressandgpf_search_typesare requested in the same turn.ToolNodeexecutes them concurrently (asyncio.gather,langgraph/prebuilt/tool_node.py).client.get_tools()without a session, so langchain-mcp-adapters opens a new MCP session per tool call (langchain_mcp_adapters/client.py: "A new session will be created for each tool call"). This is still the topology used bytest_mcp_concurrency.py; themcp_toolsfixture no longer uses it (see below).http://localhost:3000/mcp, which routes at least one response to the wrong stream.Point 4 is a server-side bug. Steps 1–3 are legitimate client behaviour: MCP explicitly allows concurrent requests, and nothing in the protocol forbids several sessions from the same client.
Likely origin to check in
geocontext(to be confirmed in the source): with the TypeScript SDK, a stateless streamable-HTTP endpoint must create a newMcpServer+ newStreamableHTTPServerTransportfor every incoming request. Reusing a single shared transport (or a single server instance connected to a shared transport) across concurrent requests makes responses land on whichever response stream is currently bound — exactly the observed crossing. The stateful alternative is to keep one transport perMcp-Session-Idand look it up on each request.Why the stdio config never shows it
With the default
config/mcp-servers.json(stdio,npx -y @ignfab/geocontext), each tool call spawns its own server process, so concurrent calls are fully isolated and cannot cross. The bug only appears with the shared HTTP process (config/mcp-servers-dev.json,config/mcp-servers-http.json).Why a single test usually passes
It is a race, not a test-isolation problem. Two conditions must coincide: the model must batch two tool calls in one turn, and the race must be lost (~40 % of the attempts made by
test_mcp_concurrency.py). Over 53 tests it happens regularly; on a single test it usually does not.Impact
Beyond the red tests, the dangerous case is silent: when the two crossed responses both happen to validate against their respective schemas (two
gpf_*tools, for instance), no error is raised and the agent reasons on data belonging to another call.Reproducing
Start the dev server (in the
geocontextrepository):PROXY_URL_SECRET=$(openssl rand -hex 32) docker compose up -dThen run
test_mcp_concurrency.py, which reproduces the bug without any model: it issues ageocodeand agpf_search_typescall concurrently — the way LangGraph does when the model emits several tool calls in one turn — and checks that each call gets its own answer. The crossing is a race, lost in roughly 40 % of the rounds, so 10 rounds are run over HTTP.The test loads its tools with
client.get_tools(), i.e. one MCP session per call: that is the topology the server trips on, and the one themcp_toolsfixture deliberately avoids while this issue is open.It fails in about one second while the server bug is open, which gives the bug one explicit red test instead of random failures scattered across the agent tests.
Under the default stdio config it passes, and runs a single round — each tool call spawns its own server process there, so the crossing cannot happen and each round costs a process start:
Proposed fixes
1. Server side (
geocontext) — the actual fixMake the streamable-HTTP endpoint safe for concurrent requests, either by creating a new
McpServer+StreamableHTTPServerTransportper HTTP request (stateless mode), or by keeping one transport perMcp-Session-Idand dispatching each request to its own (stateful mode).test_mcp_concurrency.pymust pass against the HTTP transport afterwards.2. Test side — workaround in place
The
mcp_toolsfixture inconftest.pynow loads the tools from one long-lived MCP session per server (load_mcp_tools(session)) instead ofclient.get_tools(). Concurrent calls are multiplexed over that single session and stop crossing, while the agents keep batching tool calls — so the suite still exercises realistic behaviour, and the client topology is closer to how a real MCP client connects.Verified on
claude-haiku-4-5against the dev HTTP server: full suite run with noInvalid structured contenterror left in the agent tests, and the trace oftest_count_batiment_saint_mandestill shows a turn carrying two tool calls.Implementation note: the session is opened and closed by a dedicated task, and the fixture teardown only signals it. The MCP transports rely on anyio cancel scopes, which must be exited by the task that entered them, while pytest-asyncio finalizes async fixtures from a different task than the one that set them up — otherwise teardown raises
RuntimeError: Attempted to exit cancel scope in a different task than it was entered in.The suite is also pinned to sequential execution in
pyproject.toml(addopts = "-p no:xdist -p no:xdist.looponfail"). pytest-xdist was declared as a dependency but never configured, so tests already ran one at a time; the option makes it explicit, since distributing tests over workers would reopen one MCP session per worker and bring the concurrent-sessions topology straight back.Alternatives tried and dropped:
DisableParallelToolCallsmiddleware settingparallel_tool_calls=Falsewhen the provider exposes it (Anthropic, OpenAI; not Gemini, Mistral nor Ollama). Verified working, but it removes a realistic agent behaviour from the suite and only covers some providers;npxserver process per test on stdio;rerunsinpyproject.toml(pytest-rerunfailures is installed, currentlyreruns = 0), which would retry the races without touching the agent behaviour — but would also retry genuine failures, and would not protect against the silent crossing described in Impact.