Files
summercms/modules/pier/stateless.go
Jakub Zych e7630e6762 fix(19.7): RA-WR-03 a guard error while re-filtering no longer narrows a session
catalog.allowedReport returns the allowed lists together with the first
provider error. Widening (stateful) and the stateless refresh leave the
cached server and its recorded fingerprint untouched on an error, so the
next request retries instead of the tools staying hidden until the next
rotation or event. A fan-out recompute still applies its fail-closed diff
but records no fingerprint, so the binding's next request re-filters.

TestWideningGuardError fails the guard during a widening on both paths and
during a permissions recompute, then asserts that the next request lists
every tool once the provider answers again.
2026-10-23 20:07:51 +02:00

308 lines
9.3 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, err := sp.p.catalog.allowedReport(ctx, id)
if err != nil {
// Not recorded as widened: the next request retries (RA-WR-03).
logger().Warn("pier: widening deferred", "binding", id.Binding, "error", err)
return
}
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)
}