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:
Jakub Zych
2026-10-23 15:59:54 +02:00
parent c1c0c77ddc
commit 240457291f
10 changed files with 217 additions and 39 deletions

View File

@@ -130,9 +130,9 @@ Every request is authorized afresh from its bearer; a session never replaces the
The `read` and `write` scopes are a ceiling: a grant with only `read` sees tools whose read-only hint is true, `write` unlocks the rest, and permissions always filter on top. The `ai` and `offline_access` scopes play no part in it.
With `mcp.allow_personal_tokens` true, personal API tokens also open sessions, each capped by the token's own scope exactly as over HTTP: an administrator's `g15_` token yields backend tools only, with the token's scope limit computed per dispatched route method; a frontend user's token, resolved by the user plugin's published `pact.MCPPersonalTokenResolver`, yields frontend tools only. With the flag off (the default), any bearer that is not an OAuth token is refused without a lookup.
With `mcp.allow_personal_tokens` true, personal API tokens also open sessions, each capped by the token's own scope exactly as over HTTP: an administrator's `g15_` token yields backend tools only, with the token's scope limit computed per dispatched route method; a frontend user's token, resolved by the user plugin's published `pact.MCPPersonalTokenResolver`, yields frontend tools only. An application has one such resolver; a subject it answers must carry the token's `TokenID`, which keys the session binding, and a subject without one is refused. With the flag off (the default), any bearer that is not an OAuth token is refused without a lookup.
Sessions follow the streamable HTTP transport: `initialize` opens one, `Mcp-Session-Id` names it, DELETE ends it. A session is bound to the client and user that opened it (or to the personal token), so it survives access-token rotation, and another identity presenting its id is refused with 403. Sessions carry a sliding idle TTL and a cap per binding. With `mcp.sessions.store: redis` several instances share them: a session opened on one instance is served by another, which rehydrates it from the stored session state, and a permission change, a link change or a revocation fired on any instance reaches every session it concerns, as `notifications/tools/list_changed` or as the end of the session. Clients of protocol version 2026-07-28 and later, which have no sessions, are served statelessly from the same path with the same identity, dispatch and filtering.
Sessions follow the streamable HTTP transport: `initialize` opens one, `Mcp-Session-Id` names it, DELETE ends it. A session is bound to the client and user that opened it (or to the personal token), so it survives access-token rotation, and another identity presenting its id is refused with 403. Sessions carry a sliding idle TTL and a cap per binding; a session whose client went away without DELETE is closed on every instance once its idle TTL passes. When the same client and user come back with a wider grant (more scopes, the backend scope with a link), the session gains the newly allowed tools on that request. With `mcp.sessions.store: redis` several instances share them: a session opened on one instance is served by another, which rehydrates it from the stored session state, and a permission change, a link change or a revocation fired on any instance reaches every session it concerns, as `notifications/tools/list_changed` or as the end of the session. Clients of protocol version 2026-07-28 and later, which have no sessions, are served statelessly from the same path with the same identity, dispatch and filtering.
## Protected resource metadata