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 sync.Mutex identity Identity fingerprint string // of the identity the tool set was last filtered for 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: 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 re-filters its // server when the identity's fingerprint changed (A-WR-04). func (sp *statelessPath) refresh(ctx context.Context, e *statelessEntry, id Identity) { e.mu.Lock() e.identity = id sp.p.widen(ctx, e.server, id, &e.fingerprint, e.tools, e.prompts) e.mu.Unlock() } // 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 { sp.mu.Lock() defer sp.mu.Unlock() var out []*statelessEntry for _, e := range sp.entries { e.mu.Lock() hit := e.identity.has(guard, userID) e.mu.Unlock() if hit { 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) { for _, e := range sp.matching(guard, userID) { 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) }