The stateful GET stream and the stateless subscriptions/listen stream capture the client address when they open and resolve every periodic re-check with towel.WithClientIP, as the bearer middleware does, so a failing re-check (a revoked admin token, for example) is logged with the remote address. TestStreamRecheckClientIP records the address the verifier sees on every verification of both streams.
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package pier
|
|
|
|
import (
|
|
"bytes"
|
|
"container/list"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/towel"
|
|
"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
|
|
// active counts the requests being served on server (a
|
|
// subscriptions/listen stream for its whole life); guarded by
|
|
// statelessPath.mu. The LRU never evicts an active entry (RA-WR-04).
|
|
active int
|
|
|
|
// 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
|
|
|
|
// entryUseKey keys the entryUse of one stateless request on its context.
|
|
type entryUseKey struct{}
|
|
|
|
// entryUse lists the entries one stateless request is served by, so
|
|
// ServeHTTP releases them when the request ends; guarded by
|
|
// statelessPath.mu.
|
|
type entryUse struct {
|
|
entries []*statelessEntry
|
|
}
|
|
|
|
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) {
|
|
use := &entryUse{}
|
|
r = r.WithContext(context.WithValue(r.Context(), entryUseKey{}, use))
|
|
defer sp.release(use)
|
|
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)
|
|
ip := sp.p.clientIP(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(towel.WithClientIP(ctx, ip), 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(r, 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.track(r, cur)
|
|
sp.mu.Unlock()
|
|
sp.refresh(r.Context(), cur, id)
|
|
return cur.server
|
|
}
|
|
e.elem = sp.order.PushFront(e)
|
|
sp.entries[id.Binding] = e
|
|
sp.track(r, e)
|
|
// Past capacity the least recently used idle entries go. An entry that
|
|
// is serving a request (a quiet subscriptions/listen stream, a tool call
|
|
// in flight) is never evicted, so capacity never cuts a stream or a call
|
|
// off and a revocation still finds every open stream (A-WR-06, RA-WR-04);
|
|
// while every entry is busy the LRU stays above capacity.
|
|
var evicted []*statelessEntry
|
|
for el := sp.order.Back(); el != nil && sp.order.Len() > sp.capacity; {
|
|
prev := el.Prev()
|
|
if old := el.Value.(*statelessEntry); old.active == 0 {
|
|
sp.order.Remove(el)
|
|
delete(sp.entries, old.binding)
|
|
evicted = append(evicted, old)
|
|
}
|
|
el = prev
|
|
}
|
|
sp.mu.Unlock()
|
|
for _, old := range evicted {
|
|
closeSessions(old.server)
|
|
}
|
|
return srv
|
|
}
|
|
|
|
// hit answers the entry of a binding, most recently used now and counted as
|
|
// serving r, or nil.
|
|
func (sp *statelessPath) hit(r *http.Request, binding string) *statelessEntry {
|
|
sp.mu.Lock()
|
|
defer sp.mu.Unlock()
|
|
e, ok := sp.entries[binding]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
sp.order.MoveToFront(e.elem)
|
|
sp.track(r, e)
|
|
return e
|
|
}
|
|
|
|
// track counts e as serving r until ServeHTTP releases r's entries; the
|
|
// caller holds sp.mu. A request reaching getServer without ServeHTTP's
|
|
// entryUse is not counted.
|
|
func (sp *statelessPath) track(r *http.Request, e *statelessEntry) {
|
|
use, _ := r.Context().Value(entryUseKey{}).(*entryUse)
|
|
if use == nil || slices.Contains(use.entries, e) {
|
|
return
|
|
}
|
|
e.active++
|
|
use.entries = append(use.entries, e)
|
|
}
|
|
|
|
// release ends the request's use of its entries.
|
|
func (sp *statelessPath) release(use *entryUse) {
|
|
sp.mu.Lock()
|
|
for _, e := range use.entries {
|
|
e.active--
|
|
}
|
|
use.entries = nil
|
|
sp.mu.Unlock()
|
|
}
|
|
|
|
// 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)
|
|
}
|