Skip to content

Fail fast on server-to-client requests in JSON-response mode instead of hanging - #3195

Merged
maxisbey merged 6 commits into
mainfrom
fix/json-response-server-request-deadlock
Jul 28, 2026
Merged

Fail fast on server-to-client requests in JSON-response mode instead of hanging#3195
maxisbey merged 6 commits into
mainfrom
fix/json-response-server-request-deadlock

Conversation

@maxisbey

@maxisbey maxisbey commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

With json_response=True on a stateful streamable-HTTP server, a tool handler that calls ctx.elicit(...) (or any server-to-client request tied to the in-flight call) hangs forever. This makes it fail fast with NoBackChannelError instead, and makes the JSON-mode per-request queue carry only what a JSON body actually can.

Motivation and Context

The hang, traced end to end:

  1. Stateful sessions run serve_loop, whose JSONRPCDispatcher is built with no transport_builder, so every message got the default TransportContext(kind="jsonrpc", can_send_request=True). JSON-response mode didn't change that; only the stateless path said False.
  2. Context.elicit stamps related_request_id=self.request_id, so ServerSession.send_request picks the request-scoped channel. can_send_request was True, so the request was written with related_request_id=<tool call id> and a waiter parked in _pending.
  3. message_router routes it into _request_streams[str(tool_call_id)] — the tool call's own per-request queue.
  4. In JSON mode the POST handler drained that queue with a loop that broke only on a response and log-and-discarded everything else (the else arm was # pragma: no cover — no test ever pushed a request through it).
  5. The client never sees the elicitation, never answers, the waiter never wakes. The tool call's JSON response is never written, and call_tool hangs client-side too.

This is not a regression: the chain is byte-for-byte the same on v1.x (the drain loop's discard arm dates to #553, requests started riding related_request_id in #693, elicitation was born routing that way in #625). No SDK can deliver a server-initiated request inside a JSON response body — it's structurally one JSON object. The TypeScript SDK drops it (by design, modelcontextprotocol/typescript-sdk#299 and modelcontextprotocol/typescript-sdk#866) but is bounded by its 60s default request timeout; Go reroutes to the standalone GET stream and errors if none is attached. The intent was already written down here — TransportContext.can_send_request was documented as "False for … JSON response mode" and NoBackChannelError's docstring said the same — but the stateful path never wired it.

The fix.

The transport owns what a request's response can carry, and it already attaches ServerMessageMetadata to every message — the existing channel for transport-supplied, per-message facts the dispatcher consumes (related_request_id, close_sse_stream, and #3188's on_request_unanswered). So the verdict rides that channel: ServerMessageMetadata gains can_send_request, which StreamableHTTPServerTransport stamps False in JSON-response mode (the one fact it owns), and the dispatcher's default builder honors it. Effect: ctx.elicit() in stateful JSON mode raises NoBackChannelError immediately — an INVALID_REQUEST error at the client — the same fail-fast the stateless path and both 2026-07-28 entries already had. The session manager and its stateless leg are untouched: statelessness ("a fresh transport per request, so the client's reply has nowhere to land") stays the manager's fact, applied by its existing can_send_request=False builder, and a merely-sessionless long-lived transport keeps working over SSE as before. No driver-layer or manager wiring is needed at all, and a hand-driven JSON-mode transport is correct by construction. The session's standalone GET stream is untouched — unrelated notifications still ride it — and the two intent docstrings are tightened to say "request-scoped channel" rather than "no channel", since the connection does keep one. (An earlier revision on this branch reached the same behavior by threading a builder callback through serve_loop; the metadata form replaces it — no driver-layer parameter and no new transport API.)

Then, with requests stopped at the source, message_router also drops request-scoped non-response messages before storing or queueing in JSON mode (they have no wire form and no POST could replay them). The per-request queue carries exactly the one response, the JSON POST handler collapses from a discard loop to a single receive() with a real arm for a session torn down mid-request, and the four coverage pragmas the discard loop needed go away rather than being tested around. Its -32800 cancellation terminator from #3188 is unaffected: it is keyed by the request id, so it takes the router's response arm and lands in the queue as the one response.

How Has This Been Tested?

New tests:

  • test_json_response_streamable_http_rejects_request_scoped_server_requests — stateful json_response=True + a tool that does ctx.elicit(...), under anyio.fail_after(5); asserts a top-level MCPError with INVALID_REQUEST. Verified it fails at the 5s bound on unpatched src/ and passes in well under a second with the fix.
  • test_json_response_streamable_http_delivers_only_unrelated_notifications — the call's own log notification is dropped, the unrelated resources/updated still arrives over the standalone stream, the result comes back as the JSON body.
  • test_json_post_answers_500_when_session_terminates_mid_request — drives the transport directly and tears the session down mid-request; the JSON POST answers a clean 500.
  • test_transport_stamped_can_send_request_makes_the_request_channel_refuse — a message whose metadata says can_send_request=False gets a request-scoped channel that raises NoBackChannelError, with no driver wiring.

New requirement id transport:streamable-http:json-response-restrictions. Full suite: 5559 passed, 100% branch coverage, strict-no-cover clean.

Also driven live against a real uvicorn-served MCPServer over raw HTTP: the eliciting tools/call returns the -32600 no-back-channel body in ~0.00s with the fix and hits a curl 8s timeout with zero bytes on the unpatched source; echo, a notification-emitting tool, JSON-only Accept, and stateless mode all behave as before; the default SSE control leg still streams elicitation/create as the first event; and a cancelled slow call in JSON mode completes its POST with -32800.

Breaking Changes

None to the API. One behavior changes: a json_response=True tool that used to stall in ctx.elicit() now gets an immediate NoBackChannelError (INVALID_REQUEST at the client). Documented in docs/migration.md (in the existing NoBackChannelError section), docs/run/index.md, docs/run/legacy-clients.md, and docs/troubleshooting.md.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Two related shapes were surfaced while tracing this and are deliberately out of scope here — they share the symptom (a parked waiter nothing wakes) but not the cause (can_send_request can't express them, since undeliverability is decided at routing time, not context construction). Happy to file them separately: a standalone-channel request (ctx.session.elicit_form() with no related id, list_roots(), send_ping()) hangs whenever no GET stream is attached, in JSON and SSE mode; and in SSE mode a client that disconnects mid-call leaves the handler running with its later request-scoped writes landing nowhere (the modern path has watch_disconnect, the legacy path doesn't).

AI Disclaimer

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3195.mcp-python-docs.pages.dev
Deployment https://bc2b3886.mcp-python-docs.pages.dev
Commit ca1a848
Triggered by @maxisbey
Updated 2026-07-28 00:06:04 UTC

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 12 files

Re-trigger cubic

maxisbey added 3 commits July 27, 2026 23:04
In stateful JSON-response mode a tool handler calling ctx.elicit()
(or any server-to-client request tied to the in-flight call) hung
forever: the loop dispatcher reported can_send_request=True, the
request was routed into the call's own per-request queue, and the JSON
POST drain loop silently discarded it, so the parked waiter never woke.

The transport now supplies each message's TransportContext
(StreamableHTTPServerTransport.transport_context_for): it owns what a
request's response can carry, so can_send_request is False in
JSON-response mode and the request raises NoBackChannelError at once -
the same fail-fast the stateless path and the 2026-07-28 entries already
have. serve_loop takes the builder as a keyword and the session manager
passes it for both the stateful and stateless legs. The session's
standalone GET stream is untouched and still carries unrelated messages.
In JSON-response mode a message tied to an in-flight request has no
wire form and no POST could replay it, so message_router now drops it
before storing or queueing rather than leaving it for a consumer to
discard. The per-request queue then carries exactly the one response,
and the JSON POST handler collapses from a discard loop to a single
receive with a real arm for a session torn down mid-request. This
removes the four coverage pragmas the discard loop needed.
…lder

The JSON-response-mode refusal of request-scoped server requests takes
effect through the dispatcher's transport_builder, so a caller driving
the transport's streams by hand needs to pass transport_context_for
the way the session manager does.

No-Verification-Needed: docstring-only edit
@maxisbey
maxisbey force-pushed the fix/json-response-server-request-deadlock branch from 41f2302 to c8a4bb1 Compare July 27, 2026 23:07

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/mcp/server/streamable_http.py:584-604 — In JSON-response mode, a POST whose JSON-RPC id is the literal string "_GET_stream" registers its per-request stream under the same key as the standalone GET stream (GET_STREAM_KEY), so an unrelated server message (e.g. ctx.session.send_resource_updated(), a null-id error) can be routed into it — and the new single receive() serializes that non-response message as the HTTP body with no JSONRPCResponse | JSONRPCError check, dropping the real response. The key aliasing is pre-existing, but the removed drain loop used to skip non-responses, so returning a wrong body is new to this PR; a cheap guard (loop until a response arrives, or reject request ids equal to GET_STREAM_KEY) closes it.

    Extended reasoning...

    The bug. The JSON-mode POST handler now does a single await request_stream_reader.receive() and hands whatever arrives straight to self._create_json_response(event_message.message) with no check that the message is a JSONRPCResponse | JSONRPCError. The comment above the receive() asserts the invariant that message_router deposits only this request's own response here — but that invariant has a hole: per-request stream keys share a flat string namespace with the standalone-stream sentinel GET_STREAM_KEY = "_GET_stream" (line 62), and request_id = str(message.id) (line 575) is taken verbatim from the client's JSON-RPC id. RequestId admits arbitrary strings, and nothing anywhere validates or reserves the sentinel value.\n\nThe code path. A JSON-mode POST with "id": "_GET_stream" registers _request_streams["_GET_stream"] — the exact key message_router uses for unrelated traffic (request_stream_id = target_request_id if target_request_id is not None else GET_STREAM_KEY). The PR's new router drop branch only fires when related_request_id is not None, so it does not protect this path: messages with no id and no related id — ctx.session.send_resource_updated(), standalone-channel server messages, null-id errors (the router comment explicitly says these "fall through to the GET stream") — still route to GET_STREAM_KEY, i.e. into the parked POST's queue.\n\nStep-by-step proof. Take a stateful json_response=True server and this PR's own smoke-server announce tool (which calls ctx.session.send_resource_updated(...) before returning):\n1. Client POSTs {"jsonrpc":"2.0","id":"_GET_stream","method":"tools/call","params":{"name":"announce",...}}.\n2. _handle_post_request sets request_id = "_GET_stream" and registers _request_streams["_GET_stream"], then parks on the single receive().\n3. The tool runs; send_resource_updated emits a JSONRPCNotification with no id and no related_request_id. The router computes request_stream_id = GET_STREAM_KEY and deposits it into the parked POST's stream.\n4. receive() returns the notification; _create_json_response serializes it, so the POST's JSON body is a JSONRPCNotification — not a response to the request (protocol violation; client-side parse confusion).\n5. The tool's real JSONRPCResponse (id "_GET_stream") reaches the router after the finally: _clean_up_memory_streams(request_id) closed and removed the stream, so it is silently dropped. If a standalone GET stream was open, its _request_streams entry was also clobbered at step 2 and it starves.\n\nWhy this is new to the PR. The removed drain loop's else arm ("For notifications and requests, keep waiting") skipped non-responses and kept receiving until the real response arrived, so before this PR the identical collision still returned the correct body. The sentinel-key aliasing itself (and the GET-stream-entry hijack) is pre-existing and identical in SSE mode — only the wrong-body/dropped-response symptom is introduced here.\n\nWhy nit, not blocking. The trigger requires a client to deliberately choose the literal string "_GET_stream" as its JSON-RPC id — no conforming or accidental client does this — plus an unrelated server message in flight during that exact call. The damage is confined to the misbehaving client's own session (a mangled body for a request it crafted, plus starving its own GET stream, which was already possible pre-PR). It's an adversarial-robustness edge worth hardening, not a normal-operation failure.\n\nFix options. Any of: (a) in the JSON branch, loop until the received message is a JSONRPCResponse | JSONRPCError (restores the old safeguard cheaply); (b) reject inbound request ids equal to GET_STREAM_KEY before registering the stream; (c) key the standalone stream outside _request_streams so no client-controlled id can alias it.

Comment thread docs/run/index.md
* `host` / `port`: where to listen. Defaults `127.0.0.1` and `8000`.
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
* `json_response=True`: answer with plain JSON instead of an SSE stream.
* `json_response=True`: answer each POST with a single JSON body instead of an SSE stream. That body has room for the response and nothing else, so a tool that calls back into the client mid-request (`ctx.elicit()`, sampling) raises `NoBackChannelError` on this leg, and notifications tied to the in-flight call (progress from `ctx.report_progress()`, per-call log messages) are dropped; the standalone `GET` stream still carries unrelated ones.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new json_response=True bullet's parenthetical "(ctx.elicit(), sampling)" over-claims for sampling: the default sampling API (ctx.session.create_message()) sends with related_request_id=None, so it rides the connection's standalone channel — which never consults the can_send_request flag this PR wires up — and still routes to the GET stream (or silently hangs if none is attached, the out-of-scope case the PR description acknowledges) rather than raising NoBackChannelError. Only request-scoped calls (ctx.elicit(), Resolve-driven sampling, or an explicit related_request_id) get the new fail-fast; consider dropping "sampling" or qualifying it as request-scoped sampling.

Extended reasoning...

What the doc claims vs. what the code does

The new bullet in docs/run/index.md says that under json_response=True "a tool that calls back into the client mid-request (ctx.elicit(), sampling) raises NoBackChannelError on this leg". That is accurate for ctx.elicit() / ctx.elicit_url() and for Resolve-driven sampling, because those stamp related_request_id=ctx.request_id and therefore travel the request-scoped DispatchContext, whose can_send_request this PR sets to False in JSON-response mode. It is not accurate for the default sampling API.

The code path

ServerSession.create_message() defaults related_request_id=None, and ServerSession.send_request() selects the channel with channel = self._request_outbound if related is not None else self._connection.outbound (src/mcp/server/session.py:88-89). With no related id, sampling rides the connection's standalone channel. For a stateful loop session, Connection.for_loop installs the JSONRPCDispatcher itself as that outbound, and its send_raw_request never consults can_send_request — only the per-message DispatchContext does (src/mcp/shared/jsonrpc_dispatcher.py:167). The mcpserver Context has no sampling helper that stamps a related id (only elicit/elicit_url do, src/mcp/server/mcpserver/context.py:185,218), so the ordinary way a tool samples — ctx.session.create_message(...) — goes standalone by default.

Why the new guards don't fire on this path

Both new mechanisms in this PR are keyed to the request-scoped leg. The transport_context_for fail-fast is only checked in DispatchContext.send_raw_request, which the standalone path bypasses. And the new message_router drop branch in src/mcp/server/streamable_http.py requires related_request_id is not None before it fires; a sampling request with no related id falls through to GET_STREAM_KEY as before.

Step-by-step proof

  1. Deploy a stateful server with json_response=True and a tool that calls await ctx.session.create_message([...], max_tokens=10) mid-request.
  2. Connect a legacy (2025-11-25) client that has not opened the standalone GET stream, and call the tool.
  3. create_message builds ServerMessageMetadata(related_request_id=None) (session.py:266) → send_request picks self._connection.outbound → the dispatcher writes the request and parks a waiter, with no can_send_request check anywhere on that path.
  4. message_router sees a JSONRPCRequest with no related id → target_request_id stays None → routes to GET_STREAM_KEY. No GET stream is registered, so the message is logged and dropped.
  5. The waiter never wakes; the tool call and the POST stall indefinitely — the exact standalone-channel hang the PR description explicitly lists as out of scope. The developer who trusted the doc line expected an immediate NoBackChannelError.

(If the client has a GET stream attached, the request is delivered there instead — also not the documented NoBackChannelError.)

Why this is a nit

Nothing in the code is wrong — the PR's actual behavior change is correct, tested, and matches its description. The inaccuracy is one word in one new doc bullet; the neighboring legacy-clients.md note and troubleshooting.md entries are correctly scoped to the request-scoped channel. All verifiers who traced this agreed.

Suggested fix

Drop "sampling" from the parenthetical, or qualify it, e.g.: "a tool that calls back into the client on the request-scoped channel (ctx.elicit(), sampling with a related request id)".

Instead of the transport supplying a `transport_builder` that the
session manager has to ferry into the dispatcher, the transport now
states its verdict on the message it already emits:
ServerMessageMetadata grows `can_send_request`, which
StreamableHTTPServerTransport stamps as
`mcp_session_id is not None and not is_json_response_enabled`, and the
dispatcher's default builder honors it.

This drops the public transport_context_for method, the serve_loop
keyword, and the manager's stateless special-case builder: both legs
are now correct with no wiring at all, and so is a transport driven by
hand. The metadata already carries transport facts the dispatcher
consumes (related_request_id, on_request_unanswered), so the verdict
follows an existing channel rather than opening a new one.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/mcp/server/streamable_http_manager.py Outdated
The previous commit had the transport also refuse whenever it holds no
session id, but a missing session id is not what makes a client's
reply unroutable - the session manager creating a fresh transport per
request is. A single long-lived sessionless transport still receives
the reply on the same instance, so server-initiated requests over SSE
kept working there and must continue to.

The transport now states only the fact it owns (a JSON body carries
one response), and the manager keeps its own can_send_request=False
builder for the stateless leg. The manager module is unchanged from
main.
Comment on lines 613 to +632
await writer.send(session_message)
try:
# Process messages from the request-specific stream
# We need to collect all messages until we get a response
response_message = None

# Use similar approach to SSE writer for consistency
async for event_message in request_stream_reader: # pragma: no branch
# If it's a response, this is what we're waiting for
if isinstance(event_message.message, JSONRPCResponse | JSONRPCError):
response_message = event_message.message
break
# For notifications and requests, keep waiting
else: # pragma: no cover
logger.debug(f"received: {event_message.message.method}")

# At this point we should have a response
if response_message:
# Create JSON response
response = self._create_json_response(response_message)
await response(scope, receive, send)
else: # pragma: no cover
# This shouldn't happen in normal operation
logger.error("No response message received before stream closed")
response = self._create_error_response(
"Error processing request: No response received",
HTTPStatus.INTERNAL_SERVER_ERROR,
)
await response(scope, receive, send)
except Exception: # pragma: no cover
logger.exception("Error processing JSON response")
# `message_router` deposits only this request's own response
# here: anything else scoped to the request has no wire in
# JSON-response mode.
event_message = await request_stream_reader.receive()
except (anyio.EndOfStream, anyio.ClosedResourceError):
# The stream closed with no response: the session was
# terminated while this request was in flight.
logger.debug(f"Session terminated with request {request_id} in flight; no response to send")
response = self._create_error_response(
"Error processing request",
"Session terminated before the request completed",
HTTPStatus.INTERNAL_SERVER_ERROR,
INTERNAL_ERROR,
)
await response(scope, receive, send)
else:
response = self._create_json_response(event_message.message)
finally:
await self._clean_up_memory_streams(request_id)
await response(scope, receive, send)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A JSON-mode POST whose request id is the literal string "_GET_stream" registers its per-request queue under the same key the router uses for unrelated server messages, and the collapsed single-receive() path (which dropped the old loop's isinstance(JSONRPCResponse | JSONRPCError) guard) will then return the first unrelated notification/request as the HTTP 200 body while the real response is discarded in cleanup. Before this PR the drain loop skipped non-response messages, so the wrong-response-body observable is new here. Cheap hardening: loop until the received message is a JSONRPCResponse | JSONRPCError, or reject/namespace request ids equal to GET_STREAM_KEY.

Extended reasoning...

The bug

GET_STREAM_KEY = "_GET_stream" (src/mcp/server/streamable_http.py:62) lives in the same _request_streams dict namespace as client-chosen request ids, and JSON-RPC ids may be arbitrary strings (RequestId = int | str). Nothing validates a client id against the sentinel: _handle_post_request computes request_id = str(message.id) and registers _request_streams[request_id] unconditionally (SESSION_ID_PATTERN covers session ids only; jsonrpc_message_adapter accepts any string id). So a POST with "id": "_GET_stream" in JSON mode registers its response queue under the exact key message_router uses for unrelated server messages.

The code path

In message_router, an unrelated message (e.g. ctx.session.send_resource_updated(...), a tools/list_changed broadcast, or a standalone server request) has related_request_id is None, so it skips both the response branch and this PR's new JSON-mode drop branch (which is guarded by related_request_id is not None). It falls through to request_stream_id = GET_STREAM_KEY — which now names the colliding POST's own queue — and is deposited there (buffer size 16, so timing is not delicate). The PR's own test_json_response_streamable_http_delivers_only_unrelated_notifications demonstrates that unrelated messages survive the router in JSON mode via exactly this fall-through.

Why it's new to this PR

The removed drain loop (old lines ~590–612) checked isinstance(event_message.message, JSONRPCResponse | JSONRPCError) and log-and-skipped everything else, so pre-PR the same id collision still produced the correct JSON body: the stray unrelated message was discarded and the loop kept waiting for the real response. The collapsed path does one await request_stream_reader.receive() and unconditionally response = self._create_json_response(event_message.message) — the comment's invariant ("message_router deposits only this request's own response here") has this one hole, because the drop branch only covers related messages.

Impact

The client's POST is answered HTTP 200 whose body is a JSONRPCNotification (or a server-initiated JSONRPCRequest) — a protocol-violating response for its call. The finally then runs _clean_up_memory_streams("_GET_stream"), so when the handler's real response is routed moments later the stream is gone and the actual result is silently dropped. If a standalone GET stream was open, its registration is clobbered as collateral. The existing GET handler's 409 check guards a second GET, not a POST squatting the key.

Step-by-step proof

  1. Stateful server, json_response=True. Client POSTs {"jsonrpc":"2.0","id":"_GET_stream","method":"tools/call","params":{"name":"announce"}} — a legal JSON-RPC string id; request_id = str(message.id) = "_GET_stream" and _request_streams["_GET_stream"] is created (streamable_http.py:614).
  2. The announce handler runs await ctx.session.send_resource_updated("file:///watched.txt") — an unrelated notification with related_request_id=None.
  3. message_router: not a response, not related → the new JSON-mode drop branch does not fire → request_stream_id = GET_STREAM_KEY = "_GET_stream", which IS in _request_streams → the notification is deposited into the POST's queue.
  4. The POST handler's await request_stream_reader.receive() returns the notification; _create_json_response serializes it as the 200 body — the client's "response" is {"jsonrpc":"2.0","method":"notifications/resources/updated",...} with no id/result.
  5. finally tears down _request_streams["_GET_stream"]; the handler's real JSONRPCResponse arrives at the router afterwards, finds no stream, and is dropped.

Why nit

Three independent verifiers confirmed the full chain and all rated it nit: it requires an adversarial or fuzzing client to deliberately choose the SDK-internal sentinel as its request id, plus an unrelated server message racing ahead of the response, and the damage is confined to that client's own session — no well-behaved client can hit it and there is no cross-client or security impact. Worth closing with a cheap guard, but not merge-blocking.

Fix

Either re-add the response-type guard in the JSON receive path (loop until isinstance(event_message.message, JSONRPCResponse | JSONRPCError), discarding anything else), or keep the collapsed single-receive and instead reject/namespace request ids that stringify to GET_STREAM_KEY so the sentinel can never collide with a client id.

Route every ServerMessageMetadata the transport frames through a single
_message_metadata factory so the can_send_request stamp has one home a
future construction site cannot forget. The field becomes a plain bool
defaulting to True, dropping the None sentinel and the branch that read
it. Docstrings now defer to TransportContext.can_send_request for the
full rule instead of restating it, and the two direct-transport tests
share one fake-ASGI scaffold.
@maxisbey
maxisbey merged commit 528e366 into main Jul 28, 2026
37 checks passed
@maxisbey
maxisbey deleted the fix/json-response-server-request-deadlock branch July 28, 2026 10:04
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.

1 participant