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).
This commit is contained in:
2026-10-23 20:05:08 +02:00
parent cccf4025ea
commit f604e6de64
6 changed files with 183 additions and 20 deletions

View File

@@ -41,7 +41,7 @@ Use cases:
- Fan-out (D-26): every `pact.MCPSubjectChanged` fired on the application is published through the store and applied on every instance: a permission or link change recomputes the tools and prompts of the subject's sessions and stateless servers (the SDK then sends `notifications/tools/list_changed` and `notifications/prompts/list_changed`), a revocation or deletion closes those sessions, drops their records and evicts the stateless servers; a link event also drops the link cache at once. A revocation naming `TokenID` ends only the sessions whose subject under the event's guard is that user with that token id and no client id (one personal token); one naming `ClientID` ends only that OAuth client's sessions of the user; one naming neither, and every deletion, ends all sessions of the subject.
- Client address: before resolving a bearer, pier puts the client address on the resolvers' context with `towel.WithClientIP`, taken from the host when it offers one (surf applies `http.trusted_proxies`) or from the request's `RemoteAddr`, so a resolver can log a failed attempt with it.
- Hybrid protocol (D-32): a request whose `Mcp-Protocol-Version` is 2026-07-28 or later, or whose first JSON-RPC method is `server/discover`, goes to the SDK's stateless handler over an in-process LRU of per-identity servers keyed by binding (capacity `mcp.sessions.max_per_binding` times 16); every other request goes to the stateful router. Both run behind the same bearer verification. A server evicted from the LRU closes its sessions, and a stateless `subscriptions/listen` stream re-verifies its bearer every minute and ends when it no longer resolves to its binding, as the stateful GET stream does.
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event.
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event. Token ids play no part in this, so an access-token rotation alone re-filters nothing. A stateless server is shared by every live token of its binding, so a return with less reach only adds there and never removes: the receiving middleware hides, per request, what that request's identity may not use, and a call of a wider token is never cut off by a narrower one.
- Protected resource metadata (RFC 9728, `pier.PRMScopes`): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method; a `mcp.resource` that differs from the verifier's resource is a boot warning.
- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route. A prompt declared with `Writes` (it directs tools that are not read-only) sits under the read/write ceiling and is hidden from an identity without `write`.
- A 401 carries `WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>", scope="read"`; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read.

View File

@@ -124,15 +124,18 @@ func (p *Pier) recomputeStateless(ctx context.Context, e *statelessEntry, relink
tools, prompts := p.catalog.allowed(ctx, id)
applyDiff(e.server, p, id, tools, prompts, e.tools, e.prompts)
e.fingerprint = id.fingerprint()
e.widened = nil
e.mu.Unlock()
}
// widen re-filters a cached server for the identity of the request that
// reached it when that identity's fingerprint differs from the one the
// server was last filtered for (A-WR-04): a binding that returns with more
// scope, or with a backend subject added, gains the newly allowed tools and
// prompts at once, and one that returns with less loses them. The caller
// holds the session's or entry's own mutex, never a router-wide one.
// widen re-filters a stateful session's server for the identity of the
// request that reached it when that identity's fingerprint differs from
// the one the server was last filtered for (A-WR-04): a binding that
// returns with more scope, or with a backend subject added, gains the newly
// allowed tools and prompts at once, and one that returns with less loses
// them. A session serves one client; the stateless path, whose server every
// token of a binding shares, only adds (statelessPath.refresh). The caller
// holds the session's own mutex, never a router-wide one.
func (p *Pier) widen(ctx context.Context, srv serverFeatures, id Identity, fp *string, haveTools, havePrompts map[string]bool) {
next := id.fingerprint()
if *fp == next {
@@ -156,6 +159,23 @@ func (p *Pier) guardContext(ctx context.Context) (context.Context, context.Cance
return context.WithTimeout(context.WithoutCancel(ctx), p.cfg.CallTimeout)
}
// addMissing adds the allowed tools and prompts a server lacks, updating
// the tracking maps in place; it removes nothing.
func addMissing(srv serverFeatures, p *Pier, id Identity, tools []*tool, prompts []*prompt, haveTools, havePrompts map[string]bool) {
for _, t := range tools {
if !haveTools[t.name] {
srv.AddTool(t.desc, p.toolHandler(t, id))
haveTools[t.name] = true
}
}
for _, pr := range prompts {
if !havePrompts[pr.name] {
srv.AddPrompt(pr.desc, p.promptHandler(pr))
havePrompts[pr.name] = true
}
}
}
// applyDiff brings a server's tool and prompt sets to the allowed ones,
// updating the tracking maps in place.
func applyDiff(srv serverFeatures, p *Pier, id Identity, tools []*tool, prompts []*prompt, haveTools, havePrompts map[string]bool) {

View File

@@ -0,0 +1,34 @@
package pier
import (
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestFingerprintIgnoresTokenID pins RA-WR-02: an OAuth refresh changes the
// access-token id only, which must not re-filter a cached server, while a
// change of scopes, subjects or ceiling still does.
func TestFingerprintIgnoresTokenID(t *testing.T) {
id := func(token uint, scopes ...string) Identity {
return Identity{
Scopes: scopes,
Binding: "oauth:c1:7",
TokenKind: TokenKindOAuth,
ceiling: true,
Frontend: &pact.MCPSubject{Guard: "user", UserID: 7, ClientID: "c1", TokenID: token, Scopes: scopes},
Backend: &pact.MCPSubject{Guard: BackendGuard, UserID: 42, ClientID: "c1", TokenID: token, Scopes: scopes},
}
}
if a, b := id(1, "read", "backend").fingerprint(), id(2, "read", "backend").fingerprint(); a != b {
t.Fatalf("a token rotation changed the fingerprint:\n%s\n%s", a, b)
}
if a, b := id(1, "read", "backend").fingerprint(), id(1, "read", "write", "backend").fingerprint(); a == b {
t.Fatal("a scope change kept the fingerprint")
}
noBackend := id(1, "read", "backend")
noBackend.Backend = nil
if noBackend.fingerprint() == id(1, "read", "backend").fingerprint() {
t.Fatal("dropping the backend subject kept the fingerprint")
}
}

View File

@@ -78,10 +78,13 @@ func (id Identity) writes() bool {
}
// fingerprint encodes the authorization-relevant parts of the identity
// (P-26): the sorted scopes, both subjects (guard, user, client, token and
// their sorted scopes), the ceiling and the token kind. Two requests of one
// (P-26): the sorted scopes, both subjects (guard, user, client and their
// sorted scopes), the ceiling and the token kind. Two requests of one
// binding with different fingerprints may see different tools, so a cached
// server is re-filtered when it changes (A-WR-04).
// server is re-filtered when it changes (A-WR-04). Token ids are left out:
// a guard decides on scopes, subjects and the ceiling, not on which token
// row carried them, and an OAuth access token's id changes with every
// refresh, so a rotation alone re-filters nothing (RA-WR-02).
func (id Identity) fingerprint() string {
var b strings.Builder
sorted := func(in []string) string {
@@ -100,8 +103,6 @@ func (id Identity) fingerprint() string {
b.WriteByte(':')
b.WriteString(s.ClientID)
b.WriteByte(':')
b.WriteString(strconv.FormatUint(uint64(s.TokenID), 10))
b.WriteByte(':')
b.WriteString(sorted(s.Scopes))
b.WriteByte('|')
}

View File

@@ -29,13 +29,16 @@ type statelessEntry struct {
server *mcp.Server
elem *list.Element
// mu serializes re-filtering and guards fingerprint, tools and prompts
// (see localSession); identity has its own box.
// 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
tools map[string]bool
prompts map[string]bool
// 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,
@@ -182,13 +185,30 @@ func (sp *statelessPath) hit(binding string) *statelessEntry {
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).
// 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)
sp.p.widen(ctx, e.server, id, &e.fingerprint, e.tools, e.prompts)
e.mu.Unlock()
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.

View File

@@ -4,10 +4,12 @@ import (
"context"
"net/http"
"slices"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// setGrant replaces (or adds) the grant a fake verifier answers for raw.
@@ -91,3 +93,89 @@ func TestScopeWidening(t *testing.T) {
})
}
}
// tokenBlockGuard is the acme guard whose first MCPAllows for the subject
// carrying token after arm blocks until release.
type tokenBlockGuard struct {
acmeGuard
token uint
armed atomic.Bool
entered chan struct{}
release chan struct{}
}
func (g *tokenBlockGuard) MCPAllows(ctx context.Context, s pact.MCPSubject, _ pact.MCPRequirements, _ []string) (bool, error) {
if s.TokenID == g.token && g.armed.CompareAndSwap(true, false) {
select {
case g.entered <- struct{}{}:
default:
}
select {
case <-g.release:
case <-ctx.Done():
}
}
return true, nil
}
// TestStatelessSharedBinding pins RA-WR-02: two live tokens of one binding
// with different scopes share the binding's stateless server. A request of
// the narrower token must not remove a tool the wider token is calling at
// that moment: the call finishes instead of failing as an unknown tool, and
// each token still lists only its own tools.
func TestStatelessSharedBinding(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
verifier := newFakeVerifier(map[string]pact.MCPGrant{
"oas_at_wide": grant(13, "c13", 131, "read", "write"),
"oas_at_narrow": grant(13, "c13", 132, "read"),
})
guard := &tokenBlockGuard{token: 131, entered: make(chan struct{}, 1), release: make(chan struct{})}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}, verifier: verifier}),
acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}})
t.Cleanup(in.pier().StopReaper)
wide := sdkClient(t, ctx, in.url(), "oas_at_wide", clientOpts{version: "newest"})
narrow := sdkClient(t, ctx, in.url(), "oas_at_narrow", clientOpts{version: "newest"})
if got := listNames(t, ctx, wide); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
t.Fatalf("the wide token lists %v", got)
}
if got := listNames(t, ctx, narrow); !slices.Equal(got, []string{"acme_show"}) {
t.Fatalf("the narrow token lists %v", got)
}
// The wide token returns last, so its call re-filters nothing and blocks
// in the receiving middleware's check, after the server was chosen.
if got := listNames(t, ctx, wide); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
t.Fatalf("the wide token lists %v", got)
}
if n := in.pier().StatelessServers(); n != 1 {
t.Fatalf("stateless servers = %d, want one for the shared binding", n)
}
guard.armed.Store(true)
type answer struct {
res *mcp.CallToolResult
err error
}
called := make(chan answer, 1)
go func() {
res, err := wide.CallTool(ctx, &mcp.CallToolParams{Name: "acme_echo", Arguments: map[string]any{"id": 9, "text": "hey"}})
called <- answer{res, err}
}()
select {
case <-guard.entered:
case <-time.After(10 * time.Second):
t.Fatal("the wide token's call never reached the guard")
}
if got := listNames(t, ctx, narrow); !slices.Equal(got, []string{"acme_show"}) {
t.Fatalf("the narrow token lists %v while the wide call runs", got)
}
close(guard.release)
a := <-called
if a.err != nil {
t.Fatalf("the wide token's call failed after the narrow token's request: %v", a.err)
}
if a.res.IsError {
t.Fatalf("the wide token's call answered a tool error: %s", resultText(t, a.res))
}
}