Skip to content

Feature/streamable http mcp - #270

Merged
gophergogo merged 163 commits into
mainfrom
feature/streamable-http-mcp
Aug 14, 2026
Merged

Feature/streamable http mcp#270
gophergogo merged 163 commits into
mainfrom
feature/streamable-http-mcp

Conversation

@viksachdev

Copy link
Copy Markdown
Collaborator

No description provided.

- 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
@gophergogo
gophergogo merged commit 1ddecea into main Aug 14, 2026
1 check passed
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.

3 participants