Skip to content

stateless: one middleware chain above the method switch, wire differences as explicit branches #1625

Description

@panyam

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:

  1. the switch in server/stateless/dispatch.go, where every arm calls InvokeWithMiddleware and falls back to a built-in handler in handlers.go when ok == false;
  2. statelessBackend's terminal handler (server/stateless_backend.go, InvokeWithMiddleware), which switches on the method again;
  3. 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;
    • _meta decode and the version check;
    • WithRequestMeta, so middleware can read per-request caps.
  • After that, one InvokeWithMiddleware call for every method, the way default: 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-middleware once 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 writeAuthError unchanged, so both wires send the same 403 + WWW-Authenticate (issue 815).
  • Stamping: serverInfo / resultType stamping 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

Activity

  1. added
    P2Low priority — periodic review
    mission_wire_2026_07_28Mission: 2026-07-28 wire correctness on both wires
    on Oct 24, 2026
  2. panyam commented on Oct 24, 2026

    @panyam
    OwnerAuthor

    From #1627: the stateless resources/list, resources/templates/list and prompts/list terminals in statelessBackend.InvokeWithMiddleware ignore the cursor, while tools/list now goes through the shared toolsListResult and 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.

  3. panyam commented on Oct 26, 2026

    @panyam
    OwnerAuthor

    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 == wireStateless branches 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 wraps s.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.

  4. added a commit that references this issue on Oct 26, 2026
    d74b591
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Low priority — periodic reviewmission_wire_2026_07_28Mission: 2026-07-28 wire correctness on both wires

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions