Skip to content

Route replies and key sessions by a per-message dispatch context - #264

Merged
gophergogo merged 5 commits into
mainfrom
per-message-dispatch-context
Jul 7, 2026
Merged

Route replies and key sessions by a per-message dispatch context#264
gophergogo merged 5 commits into
mainfrom
per-message-dispatch-context

Conversation

@gophergogo

Copy link
Copy Markdown
Collaborator

Summary

Retires the server's ambient current_connection_ / current_transport_session_id_ members. A message's origin (connection + transport session id) and its reply path now travel with the message as a MessageDispatchContext, built stack-scoped by the filter that parsed it and passed through the callback chain to the server.

Fixes, in order of the commit stack:

  1. Introduce the contextMessageDispatchContext + context-carrying hooks (onRequestWithContext / onNotificationWithContext) on McpProtocolCallbacks and JsonRpcProtocolFilter::MessageHandler, with default forwarding to the legacy hooks. Distinct names keep all existing implementers clear of -Woverloaded-virtual. No behavior change.
  2. Producers propagate it — stdio / protocol-detection / enhanced / factory adapters, the JSON-RPC bridge, the application-base adapter, and the connection manager forward the context; the HTTP+SSE composite supplies its own (SSE stream id + origin POST connection). No behavior change.
  3. Server consumes it — session fallback keys on the origin connection, replies go out on the request's own return path, failed sends are logged and counted. Fixes cross-wired responses under concurrent connections (accept A, accept B, A's request answered on B), wrong-session attribution, the silent drop after an unrelated close, and the double-send of HTTP responses onto stdio.
  4. Remove the ambient members — accept-time stamping, close-path null-out, and the onTransportSessionBound announce-then-dispatch protocol are deleted; a stale binding is now unrepresentable rather than cleared by discipline.
  5. Regression tests — a real-server/real-TCP integration suite pins the routing contracts (response returns on origin, unrelated close doesn't drop a reply, interleaved rounds never leak across connections) plus filter-level tests for the context contract.

Testing

Full suite: 134/134 pass. New suites: McpServerRequestRoutingTest, context-contract cases in JsonRpcProtocolFilterTest, and the server-mode binding test now asserts the id that travels with each message's context.

Known follow-ups (from post-merge review, will be addressed separately)

  • ProtocolCallbackBridge (config-driven json_rpc.dispatcher chains) and the four chainable QoS filters do not yet forward the context, so those chains fall to the degraded legacy path.
  • Stdio sessions are now connection-keyed, which routes sendNotificationToSession into the "no push channel" branch — stdio server-push needs a conn-manager-aware route.
  • The two DirectJsonRpcCallbacks definitions (stdio factory / json_rpc_filter_factory.h) now diverge in declaration order — ODR cleanup needed.
  • Context sinks should check connection state so a reply to a closed origin reports an error instead of success.

gophergogo added 5 commits July 6, 2026 18:37
A message's origin (connection, transport session id) and its reply path
currently live in ambient server state stamped at accept time, which is
wrong whenever connections interleave. MessageDispatchContext makes the
origin travel with the message instead: the filter that parses a message
constructs a stack-scoped context and dispatches through new
context-carrying handler hooks, pairing decode and encode on the same
connection. Defaults forward to the existing context-free hooks, so this
commit changes no behavior; producers and the server migrate next.

Distinct method names (onRequestWithContext) rather than overloads keep
every existing implementation clear of -Woverloaded-virtual hiding.
Each adapter between the JSON-RPC filter and the application callbacks now
forwards the per-message context instead of dropping it: the stdio,
protocol-detection, enhanced and factory-header adapters, the
JSON-RPC-to-protocol bridge, the application-base adapter, and the
connection manager. The HTTP+SSE composite supplies its own richer context
carrying the SSE stream id and a reply sink that writes to the origin POST
connection, where its onWrite already decides the wire form (SSE-registry
reroute or HTTP framing).

No behavior change yet: the server still consumes the context-free hooks,
which every context-carrying default forwards to.
…264)

The server previously resolved sessions and wrote responses against an
ambient current-connection pointer stamped at accept time, which is the
most recently accepted connection — not the one the message arrived on.
With concurrent connections that cross-wires replies (accept A, accept B,
A's request answered on B), attributes a request to another connection's
session on the connection-keyed fallback, and an unrelated close nulls the
pointer so a live connection's response is silently skipped.

onRequest/onNotification now consume the per-message context: the session
fallback keys on the origin connection, the reply goes out through the
context's return path, and a failed send is logged and counted instead of
dropped. This also retires the response broadcast to the first connected
stdio manager, which double-sent every HTTP response onto stdio when both
transports were active. The context-free hooks remain as an explicitly
degraded fallback for out-of-tree producers and warn when hit.
Both request-scoped ambient members are now dead: session keying and reply
routing read the per-message dispatch context, so the accept-time
current_connection_ stamp, its close-path null-out, and the
announce-then-dispatch onTransportSessionBound protocol (interface method,
server binding member, and the composite filter's three announce sites)
all go away. The server-mode integration test now asserts the binding that
travels with each message's context instead of the last announced value,
and seeds a sentinel that would surface if dispatch ever regressed to the
context-free path.
New integration suite drives a real server over real TCP sockets with two
concurrent plain-HTTP clients and pins the fixed failure modes: a request
is answered on the connection it arrived on while the other connection
stays silent (the accept-time ambient pointer answered on the most
recently accepted connection instead), an unrelated close no longer drops
a live connection's response, and interleaved rounds keep every
request/response pair on its own connection.

Filter-level tests pin the context contract at the source: a parsed
message dispatches through the context-carrying hook (never the
context-free fallback), an unwired filter reports a null origin and empty
transport session id rather than stale values, and a reply attempt with
no connection surfaces an error instead of silently succeeding.
@caleb2h caleb2h changed the title Route replies and key sessions by a per-message dispatch context (#264) Route replies and key sessions by a per-message dispatch context Jul 7, 2026
@gophergogo
gophergogo merged commit fc03317 into main Jul 7, 2026
1 check passed
gophergogo pushed a commit that referenced this pull request Jul 7, 2026
A message's origin (connection, transport session id) and its reply path
currently live in ambient server state stamped at accept time, which is
wrong whenever connections interleave. MessageDispatchContext makes the
origin travel with the message instead: the filter that parses a message
constructs a stack-scoped context and dispatches through new
context-carrying handler hooks, pairing decode and encode on the same
connection. Defaults forward to the existing context-free hooks, so this
commit changes no behavior; producers and the server migrate next.

Distinct method names (onRequestWithContext) rather than overloads keep
every existing implementation clear of -Woverloaded-virtual hiding.
gophergogo pushed a commit that referenced this pull request Jul 7, 2026
Each adapter between the JSON-RPC filter and the application callbacks now
forwards the per-message context instead of dropping it: the stdio,
protocol-detection, enhanced and factory-header adapters, the
JSON-RPC-to-protocol bridge, the application-base adapter, and the
connection manager. The HTTP+SSE composite supplies its own richer context
carrying the SSE stream id and a reply sink that writes to the origin POST
connection, where its onWrite already decides the wire form (SSE-registry
reroute or HTTP framing).

No behavior change yet: the server still consumes the context-free hooks,
which every context-carrying default forwards to.
gophergogo pushed a commit that referenced this pull request Jul 7, 2026
…264)

The server previously resolved sessions and wrote responses against an
ambient current-connection pointer stamped at accept time, which is the
most recently accepted connection — not the one the message arrived on.
With concurrent connections that cross-wires replies (accept A, accept B,
A's request answered on B), attributes a request to another connection's
session on the connection-keyed fallback, and an unrelated close nulls the
pointer so a live connection's response is silently skipped.

onRequest/onNotification now consume the per-message context: the session
fallback keys on the origin connection, the reply goes out through the
context's return path, and a failed send is logged and counted instead of
dropped. This also retires the response broadcast to the first connected
stdio manager, which double-sent every HTTP response onto stdio when both
transports were active. The context-free hooks remain as an explicitly
degraded fallback for out-of-tree producers and warn when hit.
gophergogo pushed a commit that referenced this pull request Jul 7, 2026
Both request-scoped ambient members are now dead: session keying and reply
routing read the per-message dispatch context, so the accept-time
current_connection_ stamp, its close-path null-out, and the
announce-then-dispatch onTransportSessionBound protocol (interface method,
server binding member, and the composite filter's three announce sites)
all go away. The server-mode integration test now asserts the binding that
travels with each message's context instead of the last announced value,
and seeds a sentinel that would surface if dispatch ever regressed to the
context-free path.
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.

2 participants