Repository navigation
stateless: one middleware chain above the method switch, wire differences as explicit branches #1625
Description
Problem
On the session wire the middleware chain wraps d.Dispatch once, so every method passes through it and nothing below can opt out. On the SEP-2575 wire the order is the other way round. server/stateless/dispatch.go validates the _meta envelope, switches on the method, and each handler then has to remember to call Backend.InvokeWithMiddleware. Forgetting is silent, which is how #1352 (resources/read) and #1355 (the list methods and completion/complete) happened, and why C7 needs make check-stateless-middleware to catch the next one. server/discover is still answered above the chain today (noted on #1552).
After #1619 the method is looked up in three places:
- the switch in
server/stateless/dispatch.go, where every arm callsInvokeWithMiddlewareand falls back to a built-in handler inhandlers.gowhenok == false; statelessBackend's terminal handler (server/stateless_backend.go,InvokeWithMiddleware), which switches on the method again;- the session dispatcher's method table, which (2) re-implements.
In production every arm of (1) takes the middleware path, so the fallback handlers only run for fakeBackend in dispatch_test.go.
Design principle for the fix
We split the wires into separate packages so the session backend could be dropped later. A deprecation window is around 18 months, so both wires live side by side until then, and over that time seeing where they differ matters more than having code that never branches. Two parallel implementations hide divergence, because a feature missing on one wire is just an absence nobody reads. That's how #1355 lived.
So prefer one shared path with the wire difference written out where it happens:
// shared code ...
if wire == session {
// X
} else {
// Y
}
// more shared code ...It looks messier, but grep for the wire check finds every parity difference, and anything not inside one is the same on both wires by construction. Retiring the session wire then means deleting the session arms.
Proposed change
- Stays above the chain, as wire-specific code:
- the removed-methods
-32601; _metadecode and the version check;WithRequestMeta, so middleware can read per-request caps.
- the removed-methods
- After that, one
InvokeWithMiddlewarecall for every method, the waydefault:already works. - At the end of the chain, one method table shared by both wires. Where a method behaves differently per wire, that's an explicit branch inside the shared handler, not a second handler.
- Delete the per-method fallbacks in
server/stateless/handlers.go, and give the tests a backend that answers every method. - Retire C7 and
check-stateless-middlewareonce the bypass can't be written. Or keep a cheaper check that every wire branch carries a comment pointing at its parity row.
Watch
- Auth errors: chain short-circuits must still reach
writeAuthErrorunchanged, so both wires send the same 403 +WWW-Authenticate(issue 815). - Stamping:
serverInfo/resultTypestamping stays outside the chain, as it is now. server/discover: decide whether discovery should be gated before moving it under the chain, since a scope-gating middleware would then hide it from callers with no token.
Acceptance
make exercise-wire(exercise-wire: a wire-parity check that fails on #1355, #1515 and #1546 today #1555) gives the same output before and after. That's why this sits behind exercise-wire: a wire-parity check that fails on #1355, #1515 and #1546 today #1555.- No method on the stateless wire is reachable without going through the chain. A test registers a middleware that records methods and checks it saw every method the dispatcher routes.
testconf-stateless,testconf,testconf-agentsunchanged.
Activity
panyam commentedon Oct 24, 2026
From #1627: the stateless
resources/list,resources/templates/listandprompts/listterminals instatelessBackend.InvokeWithMiddlewareignore the cursor, whiletools/listnow goes through the sharedtoolsListResultand paginates like the session wire. Harmless today because nothing paginates (#1356), but the shared method table here should take all four through one helper each.make exercise-wire's "same result on both wires" row is the before/after check.panyam commentedon Oct 26, 2026
The first slice landed in #1638. tools/call, prompts/get, resources/read and completion/complete now go through the Dispatcher's own handlers on the stateless wire, with
wire == wireStatelessbranches where the wires differ, and the four copies in stateless_backend.go are gone.Something I found while auditing that belongs here rather than there: the stateless wire never runs the per-request context setup the session path does in
dispatchWithNotifyAndRequest. InvokeWithMiddleware only wrapss.options.middleware, so the metrics and trace middleware never see a stateless request, and contentChunkMethod, SetNotifyResourceUpdated and SetAllowedRoots are never set there. It fits with moving the chain.server: one method table for both wires