fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID
- Identity.fingerprint covers scopes, both subjects, the ceiling and the token kind; a stateful lookup or stateless hit whose fingerprint changed re-filters the server with the event diff under its own mutex (A-WR-04) - Mount warms a Redis store in the background with a 1 s to 30 s backoff and every request kicks a pending open without waiting, so stateless-only instances subscribe to fan-out (A-WR-05) - a stateless subscriptions/listen POST re-verifies its bearer every recheck interval; an LRU-evicted stateless server closes its sessions (A-WR-06) - resolvePersonalToken refuses a subject with TokenID 0, and the pact.MCPPersonalTokenResolver contract says one resolver per application, false means unauthorized and TokenID keys the binding (A-WR-09, A-IN-03)
This commit is contained in:
@@ -3,11 +3,13 @@ package pier
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
@@ -27,10 +29,11 @@ type statelessEntry struct {
|
||||
server *mcp.Server
|
||||
elem *list.Element
|
||||
|
||||
mu sync.Mutex
|
||||
identity Identity
|
||||
tools map[string]bool
|
||||
prompts map[string]bool
|
||||
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,
|
||||
@@ -71,8 +74,53 @@ func newStatelessPath(p *Pier) *statelessPath {
|
||||
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) {
|
||||
sp.handler.ServeHTTP(w, r)
|
||||
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,
|
||||
@@ -83,23 +131,24 @@ func (sp *statelessPath) getServer(r *http.Request) *mcp.Server {
|
||||
return nil
|
||||
}
|
||||
if e := sp.hit(id.Binding); e != nil {
|
||||
sp.refresh(e, id)
|
||||
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, tools: tools, prompts: prompts}
|
||||
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(cur, id)
|
||||
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 {
|
||||
@@ -108,8 +157,14 @@ func (sp *statelessPath) getServer(r *http.Request) *mcp.Server {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -125,13 +180,22 @@ func (sp *statelessPath) hit(binding string) *statelessEntry {
|
||||
return e
|
||||
}
|
||||
|
||||
// refresh stores the request's identity on an entry.
|
||||
func (sp *statelessPath) refresh(e *statelessEntry, id Identity) {
|
||||
// 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()
|
||||
@@ -158,9 +222,7 @@ func (sp *statelessPath) evict(guard string, userID uint) {
|
||||
delete(sp.entries, e.binding)
|
||||
}
|
||||
sp.mu.Unlock()
|
||||
for ss := range e.server.Sessions() {
|
||||
_ = ss.Close()
|
||||
}
|
||||
closeSessions(e.server)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +231,12 @@ func (sp *statelessPath) evict(guard string, userID uint) {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user