Files
summercms/modules/pier/stateless.go
Jakub Zych f604e6de64 fix(19.7): RA-WR-02 stop a shared stateless server flapping its tool set
The identity fingerprint no longer includes token ids: guards decide on
scopes, subjects and the ceiling, and an OAuth access token's id changes on
every refresh, so a rotation alone no longer re-filters a cached server.

The stateless path's refresh only adds tools and prompts for a fingerprint
new to the entry (remembered until a fan-out recompute) and never removes:
one server serves every live token of a binding, and removing on each
alternation between a wider and a narrower token pulled a tool from under
the wider token's call. The receiving middleware still filters every list
and call per request; fan-out recomputes still narrow. The stateful path
keeps its exact widen, a session serves one client.

TestFingerprintIgnoresTokenID pins the fingerprint; TestStatelessSharedBinding
blocks the wide token's call after its server was chosen, sends a request
of the narrow token and asserts the call still succeeds (it failed with
"unknown tool" before).
2026-10-23 20:05:08 +02:00

303 lines
9.2 KiB
Go

package pier
import (
"bytes"
"container/list"
"context"
"errors"
"io"
"net/http"
"slices"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// serverFeatures is what applyDiff needs from an SDK server.
type serverFeatures interface {
AddTool(t *mcp.Tool, h mcp.ToolHandler)
RemoveTools(names ...string)
AddPrompt(p *mcp.Prompt, h mcp.PromptHandler)
RemovePrompts(names ...string)
}
// statelessEntry is one per-identity server of the stateless path, keyed
// by binding, with the names it holds for fan-out diffs.
type statelessEntry struct {
binding string
server *mcp.Server
elem *list.Element
// mu serializes re-filtering and guards fingerprint, widened, tools and
// prompts (see localSession); identity has its own box.
mu sync.Mutex
identity identityBox
fingerprint string // of the identity the tool set was last filtered for
// widened lists the other fingerprints whose tools and prompts were
// added since (RA-WR-02); a recompute clears it.
widened map[string]bool
tools map[string]bool
prompts map[string]bool
}
// statelessPath serves clients of protocol 2026-07-28 and later (D-32,
// P-08): the SDK's stateless handler over an in-process LRU of
// per-identity servers (capacity mcp.sessions.max_per_binding * 16),
// evicted on revocation, mutated on fan-out events so subscriptions/listen
// clients receive the list changes. Every request is authorized afresh by
// the bearer middleware in front of it.
type statelessPath struct {
p *Pier
handler http.Handler
capacity int
mu sync.Mutex
entries map[string]*statelessEntry
order *list.List // front = most recently used
}
// statelessCapacityFactor multiplies mcp.sessions.max_per_binding into the
// LRU capacity.
const statelessCapacityFactor = 16
func newStatelessPath(p *Pier) *statelessPath {
sp := &statelessPath{
p: p,
capacity: p.cfg.MaxPerBinding * statelessCapacityFactor,
entries: map[string]*statelessEntry{},
order: list.New(),
}
sp.handler = mcp.NewStreamableHTTPHandler(sp.getServer, &mcp.StreamableHTTPOptions{
Stateless: true,
// pier runs behind a reverse proxy and checks Origin itself; the
// SDK's loopback rule would refuse every proxied request of a
// binary listening on 127.0.0.1.
DisableLocalhostProtection: true,
MaxRequestBodyBytes: maxRequestBody,
})
return sp
}
// ServeHTTP serves one stateless request. A subscriptions/listen POST is
// long-lived, so like the stateful GET stream it re-verifies its bearer
// every recheck interval and ends when the bearer no longer resolves to
// the binding that opened it (A-WR-06).
func (sp *statelessPath) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.Body == nil {
sp.handler.ServeHTTP(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
id, ok := identityFromRequest(r)
if !ok || !slices.Contains(peekMethods(body), methodListen) {
sp.handler.ServeHTTP(w, r)
return
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
raw := bearerOf(r)
go func() {
ticker := time.NewTicker(sp.p.recheck)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cur, err := sp.p.identities.resolve(ctx, raw)
if err != nil || cur.Binding != id.Binding {
logSession(logger(), "stream_closed", "", id.Binding)
cancel()
return
}
}
}
}()
sp.handler.ServeHTTP(w, r.WithContext(ctx))
}
// getServer returns the per-identity server of the request's binding,
// building it on a miss and refreshing the identity on a hit.
func (sp *statelessPath) getServer(r *http.Request) *mcp.Server {
id, ok := identityFromRequest(r)
if !ok {
return nil
}
if e := sp.hit(id.Binding); e != nil {
sp.refresh(r.Context(), e, id)
return e.server
}
// Built with no lock held: the guard checks may query a database
// (A-WR-07). A concurrent request of the same binding may have
// inserted an entry meanwhile; that one is adopted.
srv, tools, prompts := sp.p.newServer(r.Context(), id, nil)
e := &statelessEntry{binding: id.Binding, server: srv, identity: identityBox{id: id}, fingerprint: id.fingerprint(), tools: tools, prompts: prompts}
sp.mu.Lock()
if cur, ok := sp.entries[id.Binding]; ok {
sp.order.MoveToFront(cur.elem)
sp.mu.Unlock()
sp.refresh(r.Context(), cur, id)
return cur.server
}
e.elem = sp.order.PushFront(e)
sp.entries[id.Binding] = e
var evicted []*statelessEntry
for sp.order.Len() > sp.capacity {
last := sp.order.Back()
if last == nil {
break
}
old := last.Value.(*statelessEntry)
sp.order.Remove(last)
delete(sp.entries, old.binding)
evicted = append(evicted, old)
}
sp.mu.Unlock()
// An evicted server's listen streams end with it, so none outlives the
// entry a later revocation would look for (A-WR-06).
for _, old := range evicted {
closeSessions(old.server)
}
return srv
}
// hit answers the entry of a binding, most recently used now, or nil.
func (sp *statelessPath) hit(binding string) *statelessEntry {
sp.mu.Lock()
defer sp.mu.Unlock()
e, ok := sp.entries[binding]
if !ok {
return nil
}
sp.order.MoveToFront(e.elem)
return e
}
// refresh stores the request's identity on an entry and, when the
// identity's fingerprint is new to the entry, adds the tools and prompts
// it allows (A-WR-04). It never removes any (RA-WR-02): one server serves
// every live token of the binding, and two of them may differ in reach (two
// devices, an older narrower token next to a re-consented one), so removing
// on every alternation would pull a tool from under another token's call in
// flight. The receiving middleware hides, per request, whatever the
// request's identity may not use; a fan-out recompute still narrows.
func (sp *statelessPath) refresh(ctx context.Context, e *statelessEntry, id Identity) {
e.mu.Lock()
defer e.mu.Unlock()
e.identity.set(id)
fp := id.fingerprint()
if fp == e.fingerprint || e.widened[fp] {
return
}
ctx, cancel := sp.p.guardContext(ctx)
defer cancel()
tools, prompts := sp.p.catalog.allowed(ctx, id)
addMissing(e.server, sp.p, id, tools, prompts, e.tools, e.prompts)
if e.widened == nil {
e.widened = map[string]bool{}
}
e.widened[fp] = true
}
// closeSessions closes every session of a server.
func closeSessions(srv *mcp.Server) {
for ss := range srv.Sessions() {
_ = ss.Close()
}
}
// matching lists the entries whose identity includes the subject.
func (sp *statelessPath) matching(guard string, userID uint) []*statelessEntry {
return sp.matchingFunc(func(id Identity) bool { return id.has(guard, userID) })
}
// matchingFunc lists the entries whose identity satisfies match, matched
// after the path lock is released (see sessionRouter.matchingFunc).
func (sp *statelessPath) matchingFunc(match func(Identity) bool) []*statelessEntry {
sp.mu.Lock()
all := make([]*statelessEntry, 0, len(sp.entries))
for _, e := range sp.entries {
all = append(all, e)
}
sp.mu.Unlock()
var out []*statelessEntry
for _, e := range all {
if match(e.identity.get()) {
out = append(out, e)
}
}
return out
}
// evict drops the entries of a subject (a revocation or deletion) and
// closes their sessions, so a subscriptions/listen stream ends.
func (sp *statelessPath) evict(guard string, userID uint) {
sp.evictFunc(func(id Identity) bool { return id.has(guard, userID) })
}
// evictFunc drops the entries whose identity satisfies match and closes
// their sessions.
func (sp *statelessPath) evictFunc(match func(Identity) bool) {
for _, e := range sp.matchingFunc(match) {
sp.mu.Lock()
if cur, ok := sp.entries[e.binding]; ok && cur == e {
sp.order.Remove(e.elem)
delete(sp.entries, e.binding)
}
sp.mu.Unlock()
closeSessions(e.server)
}
}
// route sends a request to the stateless path when its protocol version
// header is 2026-07-28 or later, or when the first JSON-RPC method of a
// POST is server/discover; everything else goes to the stateful router
// (P-08).
func (p *Pier) route(w http.ResponseWriter, r *http.Request) {
// The first request on any path opens a Redis store that the mount
// warm-up has not opened yet, without waiting for it, so an instance
// serving only stateless clients still subscribes to fan-out (A-WR-05).
if ls, ok := p.store.(*lazyStore); ok {
ls.kick()
}
if p.stateless == nil {
p.sessions.ServeHTTP(w, r)
return
}
if pv := r.Header.Get(protocolVersionHeader); pv >= protocolVersion202607 {
p.stateless.ServeHTTP(w, r)
return
}
if r.Method == http.MethodPost && r.Body != nil {
body, err := io.ReadAll(r.Body)
if err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
if slices.Contains(peekMethods(body), methodDiscover) {
p.stateless.ServeHTTP(w, r)
return
}
}
p.sessions.ServeHTTP(w, r)
}