Feature/streamable http mcp - #270
Merged
Merged
Conversation
- Name every known protocol revision in one header instead of scattering date literals across server, client and transport code - Add isSupportedVersion/latestSupportedVersion/negotiateProtocolVersion for peers that must agree on a revision, and versionAtLeast for code that gates behavior on a minimum revision - Document the two invariants callers depend on: version strings are ISO dates so they compare chronologically, and supported-version lists are ordered newest-first
- Introduce StreamableHttpConfig covering endpoint path, session and stream policy, resumability, keep-alives, origin and bind policy, and the protocol revisions the server can actually serve - Introduce a separate, much smaller StreamableHttpClientConfig; the server's session and stream settings have no client-side meaning and the two will keep diverging - Embed both in McpServerConfig and McpClientConfig
- Point the server and client protocol version defaults at the shared constant instead of the 2024-11-05 literal, which no current peer negotiates - Update the connection manager usage example to match
- Read the revision the client requests and echo it back when the server supports it, instead of always answering with the server's own default - Fall back to the newest supported revision when the request names one the server cannot serve, or names none at all - Negotiate against the configured version alone when no supported list is set, preserving existing single-version behavior A peer that requests a revision missing from the supported list now receives the newest one back and must decide whether it can continue; keep serving such peers by adding their revision to the list.
- Pin the server and client config defaults, including the newest-first ordering of the supported protocol revisions that the negotiation helpers depend on - Cover isSupportedVersion, latestSupportedVersion, versionAtLeast and negotiation for supported, unsupported, empty and single-version cases
- Replace the handler map with a route table whose entries either run a handler, pass the request to the next protocol layer, or reject it outright; registerHandler is now an adapter over addRoute - Render the Allow header from the table with allowedMethodsFor, listing only methods whose route would actually serve a request so a rejection is never advertised as supported - Answer rejections from the request headers, draining any body so the connection stays framed for the next request - Take response status text from the shared HTTP status table instead of a local switch that rendered anything past 500 as "Unknown" - Clear the per-request suppression and body-buffering flags when a request starts or errors; they previously survived an aborted request and silently dropped the next one's body
- Describe the MCP endpoint, the event stream and the transport aliases as explicit routes instead of a hardcoded pass-through list, deriving the endpoint paths from configuration - Answer GET and DELETE on the MCP endpoint with 405 and an Allow header rather than forwarding them to a protocol layer that has no reply, which left the connection hanging until the client gave up - Register preflight on the configured endpoint paths as well as the well-known literals, so the Allow header stays correct when the paths are overridden - Drop the MCP endpoint from the default handler's pass-through list now that the table describes it fully; an unlisted method on it gets a definitive 404 instead of hanging
- Drive a real server connection and assert on returned bytes, which is the only way to see status codes, the Allow header, and whether a request is answered at all rather than left hanging - Cover the rejected methods on the MCP endpoint, that a rejection with a body leaves the connection usable, pass-through on the endpoint and its alias, unknown paths, the event stream, health and preflight, and a configured endpoint path - Assert that no route answering 405 ever appears in its own Allow value
- Align the configured HTTP JSON-RPC path with the streamable HTTP endpoint so a default server serves the conventional path - "/rpc" stays routable as an alias, so existing deployments and clients pointing at it keep working
- Add formatSseEvent/formatSseField/formatSseComment/formatSseRetry - Functions append to a caller-supplied buffer and perform no I/O, so the same syntax can serve a raw stream and a framed HTTP response - Preserve the established edge cases: an empty value emits no field and a trailing newline adds no blank line - Cover the wire bytes with literal-string assertions
- Replace the encoder's inline event, comment and retry formatting with calls into the shared formatter - Keep SseCodecFilter::formatSseField as a thin alias so existing callers are unaffected - Encoder still writes to the connection; only byte production moved
- Add NotAcceptable and TooManyRequests enumerators - Add their reason phrases to httpStatusCodeToString, which otherwise falls back to "Unknown" and would emit an invalid status line
- Serialize unary responses with Content-Length and event streams with Transfer-Encoding, one chunk per event, so a recipient can always tell where the body ends - Refuse chunk syntax to HTTP/1.0 clients: answer 406 by default, or close-delimit the stream when the deployment allows it - Reject a second start on one writer and make finish idempotent - Drop caller-supplied framing headers rather than emit contradictory ones - Announce stream start and end to an observer so the owner can apply its connection policy; the writer itself performs no I/O - Cover the wire bytes literally and round-trip a generated stream back through the SDK's own parser
- Build the server response with the shared writer so it always carries a Content-Length, and honour the request's HTTP version and keep-alive - Delete the heuristic that guessed a response was an event stream when the payload happened to contain both "event:" and "data:". It emitted a body with no framing header at all, which a peer reads as a zero-length body followed by garbage, and it misfired on any JSON carrying those strings - Response mode is now the caller's explicit choice; the streaming path opens a stream through the writer instead - Render encoder reason phrases from the shared status table rather than a six-entry switch that fell back to "Unknown" The deleted branch was only reachable through the plain HTTP tail of the protocol filter's write path, which returns earlier for both event-stream and callback-proxy connections, so no stream ever relied on it.
- Open the GET /sse stream through the response writer so the prelude declares chunked framing and each event goes out as one chunk. The stream previously carried no framing header at all, which an HTTP/1.1 peer is required to read as an empty body - Route later events on the stream through the same writer, so their framing matches the prelude that opened it - Remove the first-SSE-write branch: the stream is fully opened when the request headers are handled, so that branch could never run - Build the 202 acknowledgements with the writer as well - Refuse to route a response into the connection currently being written; connection writes are not re-entrant and a stateful writer makes that collision corrupting rather than merely unlucky - Cover the framing bytes at the wire level, including that the declared chunk size actually matches where the chunk ends
- Add pauseRequestProcessing/resumeRequestProcessing so a connection can stop turning bytes into requests while a response is still going out. Responses are delivered in request order, so a request that arrives behind a stream cannot be answered until the stream finishes - Stop the parser from inside the callbacks that dispatch a request, so a pipelined request sharing the same read is held back too. Checking only on the next read would be too late: the bytes are already in hand - Hold gated input in a capped buffer and take custody of anything the parser left unparsed, so no later layer misreads it as body data - Report overflow once and let the owner close; a mid-stream HTTP error would land inside a response body - Report end-of-file while gated straight away. Reads are never disabled, because a peer hanging up is how a response in flight gets cancelled - Inert unless an owner arms it, so behaviour is unchanged for now
- Add StreamGatePolicy with a factory setter, defaulting to Off so existing chains behave exactly as before - Under DecoderGate, hold request processing for the life of an open response stream and release it when the stream ends - Under SingleUseClose, announce Connection: close on the stream and drop the connection once it finishes - Close from a later dispatcher turn, never inline. Stream lifecycle runs inside a connection write, and closing there would discard the bytes just serialized - Listen for connection events: end-of-file arrives as a close rather than a final empty read, so a stream would otherwise never learn its reader had gone - Cover the policy at the wire level; the gate's ordering and buffering semantics are covered against the codec directly
- Request ids are a variant with no comparison operators, so they cannot key an ordered container directly - Keep the tag as part of the key rather than stringifying: JSON-RPC treats the string id "5" and the number 5 as different requests, and collapsing them would let one request's response resolve another's - Order numbers ahead of strings so the ordering is total across kinds
- A dispatch context dies when its callback returns, which leaves nowhere to hang a response still being produced, a cancellation the peer has not sent yet, or a header decided after the handler ran. This is that place - Answer with a single response or a stream, never both, and refuse a second answer rather than putting contradictory bytes on the wire - Keep the ordinary 200 on its existing route so the HTTP codec frames it as it always has; frame here only when a status or header the codec cannot express is asked for - Separate the destination from the exchange so a response can outlive the connection it was born on, retaining events unframed and bounded so a returning client can be given what it missed - Refuse to write into a connection that is already inside a write; those are not re-entrant and the outer write would be corrupted - Confine the exchange to its dispatcher thread and assert it
- Record the exchanges a connection has in flight, so it can answer the two questions it has to answer about itself: whether a response is currently streaming, and what becomes of unfinished work when it dies - Cancel exchanges with nothing left to produce on connection death, and hand back the ones that asked to survive it - Add a store above the connection to hold survivors. The per-connection registry is destroyed with its connection, so holding them there would release them at the moment they were rescued - Both are dispatcher-confined and assert it
- Build an exchange per plain HTTP request to the MCP endpoint and track it for as long as it is unfinished. The legacy SSE transport keeps answering through its own machinery and gets none - Make the dispatch context a view onto that exchange. The view is still callback-scoped, which is what keeps a stale reply path unrepresentable; the exchange it points at is not, which is the point - Record how to answer when the exchange is made rather than when it writes: by then the connection may be handling something else - Carry the request's stated protocol version and its params._meta, the latter in the serialized form it arrives in - Hand exchanges that outlive their connection to the factory-held store, since the connection is what went away - The response still goes out exactly as before; only the object behind it is new
- Answer through the exchange, which knows what it has already committed to and can refuse a second response instead of putting two contradictory answers on one request - An ordinary 200 still goes out as a bare body for the HTTP codec to frame, so the bytes are unchanged; a status or header the codec cannot express makes the exchange frame the response itself - Hold every exchange off writing while the connection is inside a write. Connection writes are not re-entrant, and the outer one is still holding the buffer it was handed - Legacy SSE paths have no exchange and keep their existing reply route
- A response arriving from a client is the answer to something this server asked. It was being dropped on the floor by an empty handler, so a server-initiated request could never complete and nothing said why - Match answers to the request that asked, taking the waiter out of the map before running it so it may ask the next question from inside - Count and log answers nobody was waiting for, which mean either a confused peer or a waiter released too early - Record the negotiated protocol revision on the session; it was computed when initialize was answered and then thrown away
- Start a countdown once a detached exchange finishes producing, so a client whose connection dropped has a chance to come back for what it missed and one that never returns does not pin the result forever - Do not start it while work is still in progress; there is something to wait for until then - Release through the dispatcher's deferred delete rather than dropping the last reference inside the timer callback that decided to - Disable the timer as the store goes away, so a pending fire cannot run against something half destroyed - Fill in the connection mode's thread assertion, which was an empty stub guarding state that is read and written without a lock
- Add a Phase for where the request itself is, distinct from the framing Mode, since a connection's own mode is fixed for its whole life - Allow the request id to be set after construction, so an exchange can be made when the headers arrive rather than after the body parses - Add respondUnary for answers a JSON-RPC response cannot express: an empty 202, and an error whose id is null - Record what the peer said it accepts, for whoever later chooses between a streamed and an unstreamed response - Guard complete() on the phase so a request answered with a single response still reaches Done and still tells its completion observer
- One decision point per HTTP request: a request is answered with the handler's response, a notification or a client response with a bodiless 202, and anything unserveable with a 400 carrying an id-less error - Refuse a body holding more than one message before either of them runs, since one HTTP response cannot answer two - Record what the peer accepts and which protocol revision it states, for whoever later chooses how the response is framed - Requests for any other method or path are handed to the filter behind it
- Insert the endpoint filter between HTTP routing and the composite, so requests for the endpoint are answered there and everything else is handed straight back - Forward request headers to the composite either way, so its per-connection bookkeeping still sees every request on the connection - Supply the composite's connection and HTTP version through the host interface, so an answer is framed for the request that asked for it
- A request comes back as a length-delimited JSON response, a notification as an empty 202, and an unparseable body as a 400 with an id-less error - A body holding two messages runs neither of them - Two POSTs on one connection are both answered, which is what correct framing on the first one buys
- Decide which origins may reach the server: the local machine by default, an explicit list when one is configured, anyone behind a wildcard entry - Answer a request that carried no origin with no CORS headers at all, since there is no browser to read them and nothing to reflect - Reflect the origin rather than answering with a wildcard, so the header stays usable if credentials are ever allowed - Advertise every method and header the transport actually sends, and expose the session id so a browser can read the session it was given - Enumerate the request headers a tool designates for its parameters
- Refuse a request from an origin this server does not serve with a 403 carrying an id-less error, before its body reaches anything that could act on it, and end the connection - Answer that refusal without reflecting the origin, since doing so would tell the browser the request had been allowed - Resolve who a request is from through a replaceable hook, and let a denial keep the CORS headers so a browser can read why it was refused - Read request headers without regard to case, so a hook does not have to know how the codec spelled them
- The newest revision has no message for cancelling a request, so a client ends one by going away; but a stream was kept for whoever came back, and nobody there ever can — no event id to hold a place with and no session to look one up under - So such a stream is given up rather than kept, and giving up fires the cancellation an answer still being produced can now ask to be told about, that being the only way it will ever hear - The older era keeps the opposite policy, deliberately: its client can come back, and what was produced while it was gone is owed to it - A transport with no way to notice says so rather than accepting an observer it will never run
- A subscription ends when its client stops reading it, there being no message that ends one, so the close has to be what does it - Dropped rather than closed: the response that marks a graceful ending would be written to a stream with nobody at the far end - Ends that subscription alone, since one client may hold several and closing one is not closing the rest
- The shape a handler writes when it cannot finish, coming the other way: what is being asked, under the names the answers must come back under, and the state to hand back - A result that says nothing about its kind is an answer, since a server of an older revision cannot ask and reading its silence as a question would make every answer a retry - One that asks for nothing and carries nothing is not a question either: answering it would send the identical request again and be answered the same way, without end - The state is carried, never parsed, so what comes back is what was sent rather than something that meant the same
- It could recognise such a server and only ever refuse it: everything needed to talk to one was built and nothing ever entered the era - Which revision gets spoken is settled once, before the transport that reads it exists, from what the client accepts and the server serves — the client's order deciding, since that is what says which it prefers - Both ends have to be switched on, so neither can drag the other into an era it is not ready for - No introduction is sent, there being none to send; what one would have answered is asked of the method that answers it, and the version is not read back out of it because it was settled before anything was sent - A session that cannot be forgotten is never started again
- The newest revision has every request declare what its caller can do, and a server refuses to ask for anything not declared - Taken from the handlers actually registered, so the declaration and what can really be answered cannot drift apart — a client with a handler and no declaration would be refused the one question it could have answered - Refreshed as handlers are registered, since one registered after the era was settled would otherwise never be declared
- An answer that turns out to be a question is not the answer: the questions go to the same handlers that answer a server which asks by sending a request, and the whole request goes out again with what came back - Under an id of its own, because the two rounds are independent requests and a server must never be able to read a repeated id as a conversation it is expected to remember; the caller's wait moves across, so nothing needs telling that its request now has another name - Bounded, so a server that answers every round with another question cannot keep one request going forever - A state that would not survive being handed back byte for byte is refused rather than sent as something that merely meant the same - Failing any of that fails the request, rather than handing a caller a question in the shape of its answer
- The id a subscription answers to is one its own client chose, so two clients numbering their requests from one is the ordinary case; held under the id alone, whichever subscribed first could stop every other client from subscribing at all - Only the id travels on the wire, where the client it went to is the context; the caller is what this server needs to tell them apart - Ending one still ends that one, though another may be named alike
- Registered where every handler is, it was reachable by callers of every era — including being refused for not accepting a stream, which told a classic caller how a method it cannot call would be answered - Which era a request belongs to is read from the request, that being the only place the newest one says it: every earlier era settles a version at a handshake this one does not have, so a request declaring none is not of it - A caller of an older era now gets what it would get for anything else this server does not have
- Anything present under a capability's name counted as declaring it, so a string, a number or a list said yes — read that way, the word "no" declares yes - A capability is announced by an object saying how it is supported, or by true saying the same without detail; nothing else says anything this can act on - The permissive reading is the wrong way to be wrong here: what follows it is asking a client for something it may have no way to answer
- What a tool answers with is a list of content blocks; only a single string was read out of it, so anything else reached the caller as the JSON that list travelled in, presented as text the tool had written - Read as the shape it is instead, by the same code that reads one anywhere else, whether it arrives as JSON or through the flat map that stringifies what is nested - A server answering with one plain string is still understood, since one of an older revision may
- It keeps writing whatever its script says while a test tears the client down, so a write landing on a closed socket is ordinary here; the default for that is a signal that kills the process, which turned an ordinary race into a suite that failed at random - Reproduced once in twenty runs before, none in twenty after - Asked for as an error instead, so the write reports it and the script moves on
- A handler answering later gets its stream through a wrapper, and the wrapper forwarded four things and inherited two - Inherited, a refusal became an ordinary error response — right where a transport has no status to set, wrong here, where the one underneath does: a client reads a success containing a failure - And a cancellation reported that this transport cannot notice one, which of a wrapper is never the answer; a handler that asked to be told its work was no longer wanted was silently never going to be - The exchange also lets go of its connection when it gives up rather than keeping a pointer to one that may be destroyed, so a write after that is the refusal it should be instead of a crash
- The guard said the right thing too late: dispatch found the handler before reaching it, and answered for a method the caller cannot call — that this transport cannot hold an answer open, which describes the wrong thing entirely and says the method exists - The lookup is what skips it now, so the request falls through to the answer anything unknown gets - One place rather than two: a check behind a lookup that never reaches it is a check that reads as protection and is not
- Parsing it and rendering it are the two ends of one wire shape: a client writes what it wants to hear, a server reads it back, and the server writes what it will honour for the client to read in turn - Kept on the server alone, the client had nowhere to say what it wants except by building the request by hand — which is how the two ends of one shape drift apart
- A subscription's answer never arrives until it ends, so its response holds the connection it went out on for as long as it lasts; sharing one would queue every other request behind it, and several at once — which this revision expects — behind each other - The request goes out exactly as one on the shared connection would: it declares itself and its headers mirror its body, since which connection a request takes says nothing about what it has to carry - Ending one is letting go of its connection, there being no message that ends a subscription; its listener is unhooked first, because the connection outlives what held it here
- The revision has no standalone stream and no resources/subscribe: a client says what it wants to hear and the answer to that request never comes until the subscription ends, so this looks like a request whose answer takes as long as the subscription does - Several may be held at once, and every message carries the id of the one it belongs to — on a transport where several share a client there is nothing else to tell them apart by - A message naming a subscription this client does not hold is neither routed nor handed on: it was addressed to something, and that something is not the application's notification handlers - The two ends meet in the new suite: a client letting go is how the server finds out, and the server drops that subscription alone
) - Speaking the newest revision is not the same as being a server of it alone: one may serve older ones beside it - A client that cannot enter that era, or was told not to, was reported as unable to reach such a server at all — so a server turning the era on would have cut off every client that had not - It falls through to the rung below instead, which answers the question by being answered; a server with nothing in common is still refused, and still told what it serves
- Two tests pinned the revision being off, which was true while nothing served it and is what turning it on had to change - What is worth pinning outlives the default: the flag decides both ways, naming the revision in the list still does not turn it on, and a server told not to serve it does not advertise it
- Both ends now have everything behind it, so the switch that kept it hidden while it was being built has nothing left to hide - An older client is unaffected either way: it never asks for a revision it does not know, this one is settled per request rather than negotiated, and a client that declines it meets the server on one they both know - Checked against an implementation nobody here wrote — the official SDK's client and server both still pass in full, in the era they speak - What the project advertises now matches what it serves, and the reason there is no interop leg for this era is written where someone will look for one
- The base copies as many bytes as the option is wide, and a bool is not that wide: handed one, it read three bytes past the end of it - The bytes were thrown away and replaced with the right ones straight after, so the read was pointless as well as out of bounds — but reading off the end of an object is not excused by discarding what was read - Found by a sanitizer run over the new suites, where every server that binds a listener hit it; it is older than this work and not of it
- A client names the request a status belongs to by taking the next entry off what the session recorded going out, which is right only while one connection carries everything in turn - A subscription is neither: it records nothing there, and it finishes when its own request does rather than in order — so its refusal landed on whatever went out first elsewhere, and had that request retried or failed for something that never happened to it - A connection that exists for one request is told which, and answers for that one alone; the shared connection is unchanged
- A subscription replaces the older way of asking for one, and both were still being served: a caller of this era had two ways to ask for one thing, and this server would keep two sets of subscribers that know nothing of each other - Answered as what it is for that caller — a method its revision does not have — while a caller of an older era keeps it - A server may now end every subscription it holds without going away, which is what the older method's counterpart did
- A server may end one itself, and its answer is what says so; nothing here noticed, so the request completed like any other and left this client holding a callback nothing would call and a connection nobody read - Ended by the same path whichever end asked, so the two cannot drift - The connection is let go of on the dispatcher rather than where the ending is discovered: that is inside the parse of its own bytes, and closing it there tears down the buffer the parse is still walking - Waiting on the dispatcher is bounded and refused outright while this client is shutting down, since a loop being told to stop may never reach what was posted to it and waiting forever for that is a hang
- A stream ending mid-answer names the request whose answer it was carrying, taken from the front of what the session recorded going out - On a connection that carries one request that is somebody else's: letting go of a subscription was reported as an ordinary request's answer being severed, and that request then asked for again — over a stream, in an era that has none — though nothing had happened to it - Named from the connection instead, the same way its status already was
- Refused, failed or answered, what a subscription leaves behind is the same: a callback nothing will call and a connection nobody reads - Only the answered path let go of it, so a refusal kept both - Every way out goes through one place now, so the paths cannot drift - And the work that opens one is held by the post rather than by the caller's frame: a caller that stops waiting returns, and work reaching the dispatcher afterwards was reading locals that had gone - Told it did not open, that caller now ends the subscription rather than forgetting it, since it may yet open — an orphan nobody was told about is a connection held for a subscription no caller can end
- A refused subscription, an ended one severing nothing else, and one asked for by a client already shutting down - The severed case needs its stream to have been established before it is cut, and an unanswered request to be outstanding while it is; short of both, it closes a connection with no interrupted answer on it and proves nothing
caleb2h
approved these changes
Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.