docs(32.1-01): document the MCP scope list and call context
- READMEs and service pages name the new pact and wristband identifiers - an empty MCP scope list resolves to the five fixed names; OIDC discovery is unchanged
This commit is contained in:
@@ -143,7 +143,7 @@ To protect routes, return that middleware from `pact.HasMiddleware` under a name
|
||||
|
||||
A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`.
|
||||
|
||||
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` and cabana's `backend` middleware (the admin API and every route behind `backend`) honour a marker for the `backend` guard only when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route.
|
||||
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` and cabana's `backend` middleware (the admin API and every route behind `backend`) honour a marker for the `backend` guard only when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route. Immediately before the marker it stores a `pact.MCPCall` with `pact.WithMCPCall`. A route reads that call with `pact.MCPCallFrom`; any other request has none. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise. `pact.MCPSubject.Scopes` is the grant, not an implied expansion, and the read/write ceiling is unchanged. The scopes an MCP authorization server advertises are `pact.MCPAdvertisedScopes`, which pier copies when `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty and otherwise replaces with `pier.PRMScopes`; OpenID Connect discovery stays `openid`, `profile` and `email`.
|
||||
|
||||
### Accepting admin tokens on plugin routes
|
||||
|
||||
|
||||
@@ -128,7 +128,9 @@ What pier does with it at boot:
|
||||
|
||||
Every request is authorized afresh from its bearer; a session never replaces the bearer. An OAuth access token of the application's authorization server (`oas_at_…`) is verified through the `pact.MCPTokenVerifier` the OAuth server plugin publishes: the grant's user is the frontend subject, the grant's scopes are the ceiling. With the `backend` scope and a link the published `pact.MCPLinkedBackendResolver` confirms, the linked administrator is the backend subject, resolved on every request (cached for at most five seconds on one instance and dropped at once by a link event), so unlinking takes effect on the next call with no token surgery. The verifier also reports which administrator the user consented to (`pact.MCPGrant.BackendUserID`); while the link names anyone else, or when the grant records none, the grant has no backend subject, so a link that changes after the consent never re-targets it. The backend principal keeps the administrator's real permissions, superuser included, under the grant's ceiling.
|
||||
|
||||
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.
|
||||
The `read` and `write` scopes are a ceiling, and that ceiling is unchanged: 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. `pact.MCPSubject.Scopes` on a call is the grant, not an implied expansion of `pact.FrontendTokenScope.Implies`.
|
||||
|
||||
Every tool call stores a `pact.MCPCall` with `pact.WithMCPCall` immediately before the dispatch marker. A route reads it with `pact.MCPCallFrom`; a request pier did not dispatch has none. `pact.MCPCall.Subject` is the subject resolved for that tool's guard, `pact.MCPCall.Tool` is the exposed tool name and `pact.MCPCall.SessionID` is the MCP session id, empty on the stateless path. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise.
|
||||
|
||||
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.
|
||||
|
||||
@@ -136,7 +138,7 @@ Sessions follow the streamable HTTP transport: `initialize` opens one, `Mcp-Sess
|
||||
|
||||
## Protected resource metadata
|
||||
|
||||
pier serves the OAuth protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` and at `/.well-known/oauth-protected-resource<mcp.path>`: the resource, the authorization server (the verifier's resource origin, else `app.url`), the supported scopes (`read`, `write`, `ai`, `offline_access`, `backend`) and the bearer method. A 401 carries `WWW-Authenticate: Bearer resource_metadata="…", scope="read"`, which is how a client finds the authorization server. `mcp.resource` must equal the resource the OAuth server issues tokens for; a difference is logged as a warning at boot.
|
||||
pier serves the OAuth protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` and at `/.well-known/oauth-protected-resource<mcp.path>`: the resource, the authorization server (the verifier's resource origin, else `app.url`), the supported scopes and the bearer method. When the published verifier implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes` (`read`, `write`, `ai`, `offline_access`, `backend`). pier does not read `pact.MemberTokenScopeCatalog`. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`. A 401 carries `WWW-Authenticate: Bearer resource_metadata="…", scope="read"`, which is how a client finds the authorization server. `mcp.resource` must equal the resource the OAuth server issues tokens for; a difference is logged as a warning at boot.
|
||||
|
||||
## Security notes
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ func newServer() *wristband.Server {
|
||||
> [!WARNING]
|
||||
> Always set `wristband.Options.Resource` for your deployment. `wristband.DefaultOptions` leaves it empty, and while it is empty every authorization request that sends a `resource` parameter is refused with `invalid_target`.
|
||||
|
||||
The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. `wristband.Options.ScopesSupported` is both the advertised list and the authorize allow-list: a client may request exactly those scopes (the example above drops `ai`, so a request for it is `invalid_scope`; a host that appends a scope of its own accepts it), and an empty list keeps the default `read`, `write`, `ai` and `offline_access`. The metadata document serves the configured values:
|
||||
The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. `wristband.Options.ScopesSupported` is both the advertised list and the authorize allow-list, one resolved slice. A non-empty list is used as given: the example above drops `ai`, so a request for it is `invalid_scope`, and `wristband.DefaultOptions` (`read`, `write`, `ai`, `offline_access`) still refuses `backend`. An empty or nil list on the MCP flavour resolves to `read`, `write`, `ai`, `offline_access` and `backend`. OpenID Connect discovery stays `openid`, `profile` and `email`.
|
||||
|
||||
The MCP advertised set is that resolved list. The fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes (`pact.FrontendTokenScope`, including `pact.FrontendTokenScope.Implies`) come from the published `pact.MemberTokenScopeCatalog` and are filtered by the OAuth server plugin's key `golem15.oauthserver.mcp_scopes`. That key is the plugin's, not a framework config key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. The plugin publishes the list as `pact.MCPAdvertisedScopes`. pier copies `pact.MCPAdvertisedScopes.MCPScopesSupported` when it is non-empty and otherwise keeps `pier.PRMScopes`. The read/write ceiling is unchanged. A route the endpoint runs reads the caller with `pact.MCPCallFrom`: `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise, and `pact.MCPSubject.Scopes` is the grant, not an implied expansion. The metadata document serves the configured values:
|
||||
|
||||
```go src=modules/wristband/example_test.go#ExampleServer_Metadata
|
||||
srv := newServer()
|
||||
@@ -104,7 +106,7 @@ The metadata document advertises these paths under the issuer, so mount them at
|
||||
|
||||
The authorization endpoint sends the browser to `<issuer>/connect?request=<id>`. That page belongs to the application: it signs the user in, shows what the client asks for and posts the decision to the application's own consent handler, which calls:
|
||||
|
||||
- `wristband.Server.PendingRequest` to read what to show, as a `wristband.PendingRequestView`;
|
||||
- `wristband.Server.PendingRequest` to read what to show, as a `wristband.PendingRequestView`, including `wristband.PendingRequestView.OfflineAccess` (the pending row's flag; `offline_access` is not a scope on the view and `wristband.Server.IssueCode` is unchanged);
|
||||
- `wristband.Server.IssueCode` to grant, which returns the redirect URL carrying the code, `iss` and `state`;
|
||||
- `wristband.Server.DenyPending` to refuse, which returns the `access_denied` redirect URL.
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ Use cases:
|
||||
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
|
||||
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page. A `pact.NavigationItem.Visible` func hides a navigation entry at request time (nil = always listed).
|
||||
- Personal API token scopes: `pact.TokenScope` and `pact.HasTokenScopes`, so a plugin declares named scopes (permissions and HTTP methods) that administrators can assign when creating a personal API token.
|
||||
- Member token scopes: `pact.FrontendTokenScope` and `pact.HasFrontendTokenScopes`, the frontend counterpart of `pact.HasTokenScopes`, so a plugin declares scopes for the user plugin's member personal API tokens; the admin token catalog never reads them. `pact.ValidTokenScopeName` is the one scope-name rule both catalogs apply.
|
||||
- Member token scopes: `pact.FrontendTokenScope` and `pact.HasFrontendTokenScopes`, the frontend counterpart of `pact.HasTokenScopes`, so a plugin declares scopes for the user plugin's member personal API tokens; the admin token catalog never reads them. `pact.FrontendTokenScope.Implies` is only `""`, `read` or `write`, for a later OAuth MCP check; empty means the scope is not implied, and the route middleware is still what enforces a member scope. The plugin that owns member tokens publishes the compiled list as `pact.MemberTokenScopeCatalog` (`pact.MemberTokenScopeCatalog.MemberTokenScopes`). `pact.ValidTokenScopeName` is the one scope-name rule both catalogs apply.
|
||||
- Frontend permission codes: `pact.FrontendPermission` and `pact.HasFrontendPermissions`, the frontend counterpart of `pact.HasPermissions`, so a plugin guarding a route with a permission code also declares that code for the user plugin's permission editors.
|
||||
- MCP declarations, free of any MCP SDK: `pact.HasMCP` with `pact.MCP`, `pact.MCPTool` and `pact.MCPPrompt` bind tools and prompts to the plugin's own token-guarded routes; `pact.MCPGuard` and `pact.MCPGuards` let the owner of a route guard publish how its routes are guarded and dispatched; `pact.MCPTokenVerifier`, `pact.MCPPersonalTokenResolver` and `pact.MCPLinkedBackendResolver` are the services the MCP endpoint looks up; `pact.MCPSubjectChanged` is the event that refreshes a session's tool list.
|
||||
- MCP declarations, free of any MCP SDK: `pact.HasMCP` with `pact.MCP`, `pact.MCPTool` and `pact.MCPPrompt` bind tools and prompts to the plugin's own token-guarded routes; `pact.MCPGuard` and `pact.MCPGuards` let the owner of a route guard publish how its routes are guarded and dispatched; `pact.MCPTokenVerifier`, `pact.MCPPersonalTokenResolver` and `pact.MCPLinkedBackendResolver` are the services the MCP endpoint looks up; `pact.MCPAdvertisedScopes` (`pact.MCPAdvertisedScopes.MCPScopesSupported`) is the scope list that verifier publishes for protected-resource metadata, separate from `pact.MCPTokenVerifier` so that interface stays `MCPVerifyToken` and `MCPResource`; `pact.MCPSubjectChanged` is the event that refreshes a session's tool list. An in-process tool call carries a `pact.MCPCall`, stored with `pact.WithMCPCall` and read with `pact.MCPCallFrom` (absent on a plain HTTP request).
|
||||
- My account contributions: `pact.HasAccountContributions` with `pact.AccountContribution` and `pact.AccountAction`, so a plugin adds a section with a status line and buttons to the administrator's own account page, run for the signed-in administrator only.
|
||||
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
|
||||
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids; a `Detached` action runs outside the framework transaction), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), `pact.HasAdminActionExtensions` with `pact.AdminActionExtension` (a plugin adds record actions to any admin controller by controller ID, one it does not own included; they are offered on that controller's preview screen after the owner's) and `pact.AdminFormExtension` (a plugin places fields and tabs in another controller's form, before or after the owner's, and loads and saves their values itself inside the record's save transaction), and `pact.AdminPartialData` (the curated view model a partial template renders).
|
||||
@@ -124,8 +124,9 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.HasPermissions` | Declares backend permissions. |
|
||||
| `pact.TokenScope` | One personal API token scope: name, label, description, permission patterns and HTTP methods. |
|
||||
| `pact.HasTokenScopes` | Declares personal API token scopes compiled at admin boot. |
|
||||
| `pact.FrontendTokenScope` | One member personal API token scope: `Name` (what a route's `user.scope:<name>` middleware checks), `Label` and `Description`. |
|
||||
| `pact.FrontendTokenScope` | One member personal API token scope: `Name` (what a route's `user.scope:<name>` middleware checks), `Label`, `Description` and `Implies` (`""`, `read` or `write`; empty means not implied). The route middleware enforces the scope; `Implies` is data for a later OAuth MCP check and is not an expansion by itself. |
|
||||
| `pact.HasFrontendTokenScopes` | Declares member token scopes, the frontend counterpart of `pact.HasTokenScopes`; compiled by the plugin that owns member tokens, never by the admin token catalog. |
|
||||
| `pact.MemberTokenScopeCatalog` | The compiled member-token scope list, published on the application. `pact.MemberTokenScopeCatalog.MemberTokenScopes` returns `[]pact.FrontendTokenScope`. |
|
||||
| `pact.ValidTokenScopeName` | Reports whether a scope name is valid (`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`); shared by both token catalogs. |
|
||||
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu and an optional `Visible` func the admin runs on every navigation request. |
|
||||
| `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. |
|
||||
@@ -165,9 +166,13 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.MCPPrompt` | One prompt template: name, title, description, guard, permissions, `Arguments` (`pact.MCPPromptArgument`), the `Render` func returning `pact.MCPPromptMessage` values (`Role` user or assistant, `Text`) and `Writes`, set when the prompt directs tools that are not read-only so it is listed only for an identity that may write (zero value: offered to read-only grants too). |
|
||||
| `pact.MCPGuard` | A guard provider the owner of a route guard publishes: its name, whether a middleware list is token-guarded by it, the scopes and permissions a middleware list requires (`pact.MCPRequirements`), whether a subject may call a tool, and the pre-resolved principal for one in-process call (`pact.MCPDispatch`). |
|
||||
| `pact.MCPGuards` | The registry of guard providers published on the application; `pact.NewMCPGuards` creates it, `Register` refuses an empty or duplicate name with a `pact:` error naming the guard, `Lookup` finds one and `Names` lists them sorted. Safe for concurrent use. |
|
||||
| `pact.MCPSubject` | The identity an MCP session acts as under one guard: guard name, user id, granted scopes, OAuth client id and personal token id. |
|
||||
| `pact.MCPSubject` | The identity an MCP session acts as under one guard: guard name, user id, granted scopes, OAuth client id and token id. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise. `pact.MCPSubject.Scopes` is the grant, not an implied expansion. |
|
||||
| `pact.MCPCall` | One in-process MCP tool call: `pact.MCPCall.Subject`, `pact.MCPCall.SessionID` (empty on the stateless path) and `pact.MCPCall.Tool`. |
|
||||
| `pact.WithMCPCall` | Stores a `pact.MCPCall` on a context. A nil context is returned as nil. |
|
||||
| `pact.MCPCallFrom` | Returns the `pact.MCPCall` on a context. The boolean is false when the context is nil or carries none, including a plain HTTP request. |
|
||||
| `pact.MCPDispatch` | A guard provider's answer for one call: the principal (as `any`), an optional credential and the bouncer guard names that honour the dispatch marker. |
|
||||
| `pact.MCPTokenVerifier` | Published by the OAuth server plugin: verifies an access token at the MCP endpoint into a `pact.MCPGrant` (user, client, token, scopes, expiry and, for a `backend` grant, `BackendUserID`, the backend account the consent showed) and names its resource. A refused token is `pact.ErrMCPTokenInvalid`. |
|
||||
| `pact.MCPTokenVerifier` | Published by the OAuth server plugin: verifies an access token at the MCP endpoint into a `pact.MCPGrant` (user, client, token, scopes, expiry and, for a `backend` grant, `BackendUserID`, the backend account the consent showed) and names its resource. A refused token is `pact.ErrMCPTokenInvalid`. The interface is only `MCPVerifyToken` and `MCPResource`. |
|
||||
| `pact.MCPAdvertisedScopes` | Optional second interface on that verifier. `pact.MCPAdvertisedScopes.MCPScopesSupported` is the MCP scope list pier copies into protected-resource metadata when the slice is non-empty. |
|
||||
| `pact.MCPPersonalTokenResolver` | Published by the one plugin whose personal tokens may open an MCP session (one resolver per application): resolves a raw token into a `pact.MCPSubject` whose `TokenID` identifies the token row (it keys the session binding; a subject without one is refused); false when the prefix is not its own, which the endpoint answers as unauthorized. |
|
||||
| `pact.MCPLinkedBackendResolver` | Published by the plugin that owns the frontend-to-backend account link: the `pact.LinkedBackendUser` (id, login, display name) of a frontend user, false when unlinked. |
|
||||
| `pact.MCPSubjectChanged` | Event fired on the festival bus when a subject's permissions, link, grants or existence change (`Kind` is `pact.MCPSubjectPermissions`, `pact.MCPSubjectLink`, `pact.MCPSubjectRevoked` or `pact.MCPSubjectDeleted`), so the MCP endpoint re-lists tools or ends sessions. `TokenID` and `ClientID` narrow a revoked event: a token id ends only the sessions that personal token opened (subject with that `TokenID` and no `ClientID`), a client id only that OAuth client's sessions, neither every session of the subject; other kinds ignore them. |
|
||||
|
||||
@@ -10,7 +10,7 @@ Opt-in MCP endpoint that lists the tools and prompts plugins declare per caller
|
||||
|
||||
pier mounts a streamable HTTP MCP endpoint (POST, GET and DELETE at `mcp.path`, default `/mcp`) inside the application binary, on the official Go SDK. A plugin takes part by implementing `pact.HasMCP`: each `pact.MCPTool` names one of the plugin's own routes by method and path, an input struct whose tags say which fields fill the path, the query string and the body, and the guard the tool runs as. At boot pier compiles every declaration against the route table (an unknown route, a duplicate or invalid name, a route that is not token-guarded, a non-object input, a `path:` field the schema does not require, an embedded struct on a `GET` or `DELETE` tool all fail boot naming the plugin and the tool), reflects the input schema from the struct and prefixes the exposed name with the plugin's short code (`acme.blog` declaring `create_post` exposes `blog_create_post`).
|
||||
|
||||
Every request to the endpoint is authorized afresh from its bearer: an OAuth access token through the `pact.MCPTokenVerifier` the OAuth server plugin publishes, or, behind `mcp.allow_personal_tokens`, a personal API token (an admin `g15_` token through cabana, a frontend token through the published `pact.MCPPersonalTokenResolver`). The resulting `pier.Identity` names a frontend subject and, when the grant carries the `backend` scope and the published `pact.MCPLinkedBackendResolver` confirms a link, a backend subject, but only while that link names the administrator the grant was consented for (`pact.MCPGrant.BackendUserID`): a link changed to anyone else, or a grant that records no consented administrator, gets no backend subject. `tools/list` is computed for that identity: a tool needs a subject under its guard, a grant without `write` sees only read-only tools, and the guard provider's own check (the same logic its route middleware enforces) decides the rest. A `tools/call` runs the bound route's compiled middleware chain in-process with the `bouncer.Dispatch` marker carrying the principal the guard provider resolved; the inner request never carries an `Authorization` or `Cookie` header. A 2xx answer becomes text plus structured content, anything else a tool error carrying the route's own body.
|
||||
Every request to the endpoint is authorized afresh from its bearer: an OAuth access token through the `pact.MCPTokenVerifier` the OAuth server plugin publishes, or, behind `mcp.allow_personal_tokens`, a personal API token (an admin `g15_` token through cabana, a frontend token through the published `pact.MCPPersonalTokenResolver`). The resulting `pier.Identity` names a frontend subject and, when the grant carries the `backend` scope and the published `pact.MCPLinkedBackendResolver` confirms a link, a backend subject, but only while that link names the administrator the grant was consented for (`pact.MCPGrant.BackendUserID`): a link changed to anyone else, or a grant that records no consented administrator, gets no backend subject. `tools/list` is computed for that identity: a tool needs a subject under its guard, a grant without `write` sees only read-only tools, and the guard provider's own check (the same logic its route middleware enforces) decides the rest. A `tools/call` runs the bound route's compiled middleware chain in-process with the `bouncer.Dispatch` marker carrying the principal the guard provider resolved, and, immediately before that marker, a `pact.MCPCall` stored with `pact.WithMCPCall`. `pact.MCPCall.Subject` is the subject resolved for the tool's guard, `pact.MCPCall.Tool` is the exposed tool name and `pact.MCPCall.SessionID` is the MCP session id, empty on the stateless path and never the audit word used when a stateful call has no session. A route reads the call with `pact.MCPCallFrom`; a context pier did not dispatch answers false. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise. `pact.MCPSubject.Scopes` is the grant, not an implied expansion. The read/write ceiling is unchanged: a grant without `write` still sees only read-only tools. The inner request never carries an `Authorization` or `Cookie` header. A 2xx answer becomes text plus structured content, anything else a tool error carrying the route's own body.
|
||||
|
||||
Sessions (`Mcp-Session-Id`) are bound to the identity that opened them (client and user, or the personal token), live in a store with a sliding idle TTL, and are served by a per-session SDK server on whichever instance receives the request, rehydrated from the stored session state when it is not local. The memory store serves one instance; the Redis store shares sessions across instances and fans subject events out to every instance, so a permission or link change pushes `notifications/tools/list_changed` to the sessions it concerns and a revocation ends them everywhere. Clients of protocol 2026-07-28 and later, which have no sessions, are served statelessly from the same path by the SDK's stateless handler over per-identity servers, sharing identity, dispatch and filtering with the stateful path.
|
||||
|
||||
@@ -30,7 +30,7 @@ Use cases:
|
||||
- Route binding by exact method and pattern (D-10); a tool targets a route that carries its guard's token-accepting middleware or names its guard explicitly (D-15); the requirements a route's middleware implies come from the guard provider's `MCPRequirements` and are checked together with the tool's `Permissions` (D-16).
|
||||
- Input schema reflected from the struct with `jsonschema.ForType` (`jsonschema:"..."` tags describe fields, fields without `omitempty` are required, no additional properties); every call's arguments are validated against it, then split by the `path:"x"` and `query:"q"` tags into path values, query string and the raw JSON body minus those keys (D-11); a `GET` or `DELETE` tool may carry only path and query fields, and an embedded struct (whose promoted fields would be body fields) fails boot there. A `path:` field must be required (no `omitempty` or `omitzero`), and at call time pier delivers only path values the HTTP mux could: a missing value, an empty, `.` or `..` segment, or a `/` inside a single-segment value is a tool error before dispatch. Numbers in path and query values keep their literal digits, so integer ids above 2^53 stay exact. An optional `Output` struct produces the output schema.
|
||||
- Annotations from the method (D-14): `GET` read-only and idempotent, `PUT` idempotent, `DELETE` destructive; `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` override; the destructive and open-world hints are always explicit.
|
||||
- In-process dispatch through `pier.Host.Handler`, the route's compiled chain, with `bouncer.WithDispatch` carrying the guard provider's `MCPDispatch` principal and credential, path values set as the mux would, `RemoteAddr`, the `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Real-IP` and `Accept-Language` copied from the MCP request (never `Authorization` or `Cookie`), `mcp.call_timeout` on the context and the answer captured up to `mcp.max_result_bytes` (D-17). A failed guard dispatch answers the fixed tool error `pier: the tool could not be run for this caller` and logs the cause server-side, so no provider or database error reaches the agent (an invalid token, a deactivated account and an admin personal token whose stored scopes no longer resolve keep their own fixed texts); a dispatch whose guard list names `backend` for a frontend tool, or whose principal kind contradicts the tool's guard, is refused the same way.
|
||||
- In-process dispatch through `pier.Host.Handler`, the route's compiled chain, with `pact.WithMCPCall` immediately before `bouncer.WithDispatch` carrying the guard provider's `MCPDispatch` principal and credential, path values set as the mux would, `RemoteAddr`, the `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Real-IP` and `Accept-Language` copied from the MCP request (never `Authorization` or `Cookie`), `mcp.call_timeout` on the context and the answer captured up to `mcp.max_result_bytes` (D-17). A failed guard dispatch answers the fixed tool error `pier: the tool could not be run for this caller` and logs the cause server-side, so no provider or database error reaches the agent (an invalid token, a deactivated account and an admin personal token whose stored scopes no longer resolve keep their own fixed texts); a dispatch whose guard list names `backend` for a frontend tool, or whose principal kind contradicts the tool's guard, is refused the same way.
|
||||
- Results (D-13): 2xx is text plus structured content (an object as is, any other JSON wrapped as `{"data": …}`); a 4xx or 5xx is a tool error whose text is the route's body; a too-large answer is a tool error with a fixed text.
|
||||
- Built-in `backend` guard provider (`pier.BackendGuard`): routes behind cabana's `backend` middleware, listing through `cabana.Allows` on the administrator's principal from `cabana.BackendPrincipal`, an admin personal token's scope limit computed per dispatched route method with `cabana.ScopeLimitFor`; a plugin that registers its own `backend` provider wins.
|
||||
- Identity per request (`pier.Identity`): `oas_at_` tokens through the published verifier, personal tokens only with `mcp.allow_personal_tokens`, the backend subject through the published link resolver with an in-process cache of at most `pier.LinkCacheTTL`; the binding key `oauth:<client_id>:<user_id>`, `pat:user:<token_id>` or `pat:backend:<token_id>` survives access-token rotation. One `pact.MCPPersonalTokenResolver` serves the application; a subject it answers without a `TokenID` is refused, so personal-token users never share a binding.
|
||||
@@ -42,7 +42,7 @@ Use cases:
|
||||
- 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. The periodic bearer re-check of a GET or `subscriptions/listen` stream carries the address the stream was opened from.
|
||||
- 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. Past capacity the least recently used idle server is evicted (and its sessions closed); a server that is serving a request, a `subscriptions/listen` stream included, is never evicted, so capacity never ends a quiet listener or a tool call in flight, and the LRU stays above capacity while every server is busy. 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. 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. A guard provider error during this re-filtering is not a refusal: the cached server stays as it was and the next request retries (logged as `pier: widening deferred`); a fan-out recompute that meets one hides the failing items and leaves the server to be re-filtered by the binding's next request.
|
||||
- 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.
|
||||
- Protected resource metadata (RFC 9728): `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. When the published `pact.MCPTokenVerifier` also implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes`. pier does not read `pact.MemberTokenScopeCatalog` and does not widen the read/write ceiling. A `mcp.resource` that differs from the verifier's resource is a boot warning. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`.
|
||||
- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route. A `Render` error is logged server-side and answered with the fixed JSON-RPC error `pier: the prompt could not be rendered`, so no database or provider error reaches the agent. 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.
|
||||
- One audit line per tool call (`pier: tool call`: tool, guard, user id, admin id, client id, token kind, session prefix, status, duration) and one per session step (`pier: session`: `open`, `rehydrate`, `end`, `binding_refused`, `revoked`, `stream_closed`, `expired`), never arguments, bodies or tokens. A tool call served on the stateful path without a session logs the session `ephemeral`, one on the stateless path `stateless`.
|
||||
@@ -110,7 +110,7 @@ An MCP client then connects to `https://example.com/mcp` with an OAuth access to
|
||||
| `pier.Record` | One stored session: binding, session state JSON and creation time. |
|
||||
| `pier.Event` | One instance-to-instance event: a kind (`pier.EventEnded` or a `pact.MCPSubjectChanged` kind), guard, user id, session id, publishing instance, and the optional token id and client id that narrow a revocation. |
|
||||
| `pier.LogComponent` | `pier`, the diary component of the audit lines. |
|
||||
| `pier.PRMScopes` | The `scopes_supported` list of the protected resource metadata (`read`, `write`, `ai`, `offline_access`, `backend`). |
|
||||
| `pier.PRMScopes` | The `scopes_supported` fallback of the protected resource metadata (`read`, `write`, `ai`, `offline_access`, `backend`) when the verifier does not implement `pact.MCPAdvertisedScopes` or `pact.MCPAdvertisedScopes.MCPScopesSupported` is empty. |
|
||||
|
||||
Configuration keys are exported as constants: `pier.EnabledKey`, `pier.PathKey`, `pier.AllowPersonalTokensKey`, `pier.SessionStoreKey`, `pier.SessionIdleTTLKey`, `pier.SessionMaxPerBindingKey`, `pier.ResourceKey`, `pier.ThrottleKey`, `pier.AllowedOriginsKey`, `pier.CallTimeoutKey`, `pier.MaxResultBytesKey`, with defaults `pier.DefaultPath`, `pier.DefaultSessionStore`, `pier.DefaultIdleTTL`, `pier.DefaultMaxPerBinding`, `pier.DefaultThrottle`, `pier.DefaultCallTimeout` and `pier.DefaultMaxResultBytes`, and store names `pier.StoreMemory` and `pier.StoreRedis`.
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ Use cases:
|
||||
|
||||
- RFC 8414 metadata document (`wristband.Server.Metadata`) advertising the authorize, token and registration endpoints under the issuer, the `authorization_code` and `refresh_token` grants, S256 PKCE and the RFC 9207 `iss` response parameter.
|
||||
- RFC 7591 dynamic client registration (`wristband.Server.Register`): JSON only, a bounded request body, redirect URI validation (HTTPS, or loopback HTTP), public (`none`) and confidential (`client_secret_post`, `client_secret_basic`) clients, a cap on unrevoked clients and a sweep of old clients that never got consent, all in one transaction.
|
||||
- Authorization endpoint (`wristband.Server.Authorize`): the client and its exact registered redirect URI are validated before any redirect is sent (an unknown client gets a local plain-text 400, never an open redirect); then S256 PKCE, the client's scope ceiling and the RFC 8707 `resource` value are checked, a pending request is stored and the browser is sent to the application's consent page at `<issuer>/connect?request=<id>`. The accepted scopes are exactly `wristband.Options.ScopesSupported`, the values the metadata document advertises (`read`, `write`, `ai` and `offline_access` by default; an empty list keeps that default), so a host that lists an extra scope accepts it at authorize and any other value is `invalid_scope`.
|
||||
- Consent operations for the application's own consent screen: `wristband.Server.PendingRequest` (what to show), `wristband.Server.IssueCode` (grant, returning the redirect URL with `code`, `iss` and `state`) and `wristband.Server.DenyPending` (returning an `access_denied` redirect). Missing, foreign, used and expired requests all report the same `wristband.ErrPendingNotFound`.
|
||||
- Authorization endpoint (`wristband.Server.Authorize`): the client and its exact registered redirect URI are validated before any redirect is sent (an unknown client gets a local plain-text 400, never an open redirect); then S256 PKCE, the client's scope ceiling and the RFC 8707 `resource` value are checked, a pending request is stored and the browser is sent to the application's consent page at `<issuer>/connect?request=<id>`. The accepted scopes are the same resolved list `wristband.Server.Metadata` advertises. A non-empty `wristband.Options.ScopesSupported` is used as given (`read`, `write`, `ai` and `offline_access` from `wristband.DefaultOptions`, which still refuses `backend`; a host that lists an extra scope accepts it). An empty or nil list on the MCP flavour resolves to `read`, `write`, `ai`, `offline_access` and `backend`. Any other value is `invalid_scope`. OpenID Connect discovery stays `openid`, `profile` and `email` and does not use this list. The OAuth server plugin publishes that list as `pact.MCPAdvertisedScopes`; pier copies `pact.MCPAdvertisedScopes.MCPScopesSupported` when it is non-empty. A route the MCP endpoint runs reads the caller with `pact.MCPCallFrom`.
|
||||
- Consent operations for the application's own consent screen: `wristband.Server.PendingRequest` (what to show, including `wristband.PendingRequestView.OfflineAccess`, the pending row's flag; `offline_access` is not a scope on the view), `wristband.Server.IssueCode` (grant, returning the redirect URL with `code`, `iss` and `state`; the signature is unchanged) and `wristband.Server.DenyPending` (returning an `access_denied` redirect). Missing, foreign, used and expired requests all report the same `wristband.ErrPendingNotFound`.
|
||||
- Token endpoint (`wristband.Server.Token`): code exchange with PKCE verification, then an access token minted by the application plus a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the linked access tokens. Expired codes and refresh tokens are swept on each call.
|
||||
- Connected-app revocation: `wristband.Server.Revoke` kills an access token and the refresh lineage attached to it.
|
||||
- OpenID Connect provider flavour: `wristband.NewOIDCServer` builds a second `wristband.Server` on the same engine with discovery (`wristband.Server.Discovery`), a JWKS endpoint (`wristband.Server.JWKS`), authorization with a resumable request and consent (`wristband.Server.Authorize`, `wristband.Server.Resume`), a token endpoint that adds an `id_token` signed by the active key, and userinfo (`wristband.Server.UserInfo`). Storage arrives through `wristband.OIDCBackend` and `wristband.OIDCTx`, signing keys through `wristband.KeyStore`, and pages and the signed-in user through `wristband.OIDCHost`. The MCP flavour and its interfaces are unchanged.
|
||||
@@ -128,7 +128,7 @@ func ProviderRoutes(r pact.Router, srv *wristband.Server) {
|
||||
| `wristband.Server.Register` | Handler for `POST /oauth/mcp/register` (RFC 7591). |
|
||||
| `wristband.Server.Authorize` | Handler for `GET /oauth/mcp/authorize`; on an OIDC server `GET /oauth/authorize` (start) and `POST /oauth/authorize` (consent decision). |
|
||||
| `wristband.Server.Token` | Handler for `POST /oauth/mcp/token` (authorization code and refresh token grants); on an OIDC server `POST /oauth/token` with an `id_token`. |
|
||||
| `wristband.Server.PendingRequest` | Returns a `wristband.PendingRequestView` for a user's pending request. |
|
||||
| `wristband.Server.PendingRequest` | Returns a `wristband.PendingRequestView` for a user's pending request. `wristband.PendingRequestView.OfflineAccess` is the pending row's flag. |
|
||||
| `wristband.Server.IssueCode` | Grants consent and returns the redirect URL carrying the code. |
|
||||
| `wristband.Server.DenyPending` | Refuses consent and returns the `access_denied` redirect URL. |
|
||||
| `wristband.Server.Revoke` | Revokes an access token and its refresh-token lineage. |
|
||||
@@ -187,7 +187,7 @@ wristband reads no config keys or environment variables; the application passes
|
||||
| Field | Default |
|
||||
|-------|---------|
|
||||
| `wristband.Options.ServiceDocumentationPath` | `/help` |
|
||||
| `wristband.Options.ScopesSupported` | `read`, `write`, `ai`, `offline_access` (also the authorize allow-list; empty keeps this default) |
|
||||
| `wristband.Options.ScopesSupported` | `read`, `write`, `ai`, `offline_access` on `wristband.DefaultOptions`, used as given, so that list still refuses `backend`. An empty list on the MCP flavour resolves to those four plus `backend`. A non-empty list is not widened. |
|
||||
| `wristband.Options.TokenEndpointAuthMethodsSupported` | `none`, `client_secret_post`, `client_secret_basic` |
|
||||
| `wristband.Options.AuthorizationResponseIssParameterSupported` | `true` |
|
||||
| `wristband.Options.DCRClientCap` | `200` |
|
||||
|
||||
Reference in New Issue
Block a user