- pact.MCPSubjectChanged and pier.Event gain TokenID and ClientID; a narrowed revoked event closes only matching sessions and stateless servers - cabana fires the admin token revoke with the token row id - towel.WithClientIP/ClientIP; pier puts the address (surf applies http.trusted_proxies) on the resolve context and ResolveBackendToken logs it
315 lines
11 KiB
Go
315 lines
11 KiB
Go
package pact
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// The MCP contracts below are what a plugin compiles against to take part
|
|
// in the framework's MCP endpoint (the pier module, phase 19.7). They name
|
|
// no MCP SDK, no JSON schema library and no bouncer type, so a plugin that
|
|
// declares tools builds the same with and without the summercms_mcp tag.
|
|
|
|
// HasMCP is implemented by plugins that declare MCP tools and prompts.
|
|
type HasMCP interface {
|
|
MCP() MCP
|
|
}
|
|
|
|
// MCP is a plugin's MCP declaration. Resources (a URI template bound to a
|
|
// GET route) are a later field: the struct form keeps that addition
|
|
// non-breaking, so plugins written against Tools and Prompts keep compiling.
|
|
type MCP struct {
|
|
Tools []MCPTool
|
|
Prompts []MCPPrompt
|
|
}
|
|
|
|
// MCPTool binds one MCP tool to one of the plugin's own token-guarded routes.
|
|
//
|
|
// Name is the tool's name without the plugin prefix: the module exposes it as
|
|
// <code>_<name>, where code is the last dot-segment of the plugin ID (acme.blog
|
|
// declares create_post, clients see blog_create_post). Title and Description
|
|
// are static text (never built from data). Guard names the guard provider the
|
|
// tool runs as ("user" or "backend"); empty means the one the route's
|
|
// middleware implies. Method and Path are the route as registered
|
|
// (POST /_blog/api/v1/posts). Input is the zero value of the input struct:
|
|
// a path:"id" field fills the {id} segment, a query:"q" field goes to the
|
|
// query string, the remaining json fields form the request body, and a
|
|
// jsonschema:"..." tag describes a field; its JSON schema is reflected from
|
|
// the struct. Output, when not nil, is the zero value of the struct the 2xx
|
|
// body is described by (an outputSchema). Permissions filter the listing on
|
|
// top of the requirements the route's middleware implies; the route still
|
|
// enforces on every call. ReadOnly, Destructive, Idempotent and OpenWorld
|
|
// override the annotations derived from Method (GET read-only and
|
|
// idempotent, PUT idempotent, DELETE destructive); nil means derived.
|
|
//
|
|
// Boot fails for a tool whose route is not registered, whose name is empty,
|
|
// not an identifier or a duplicate within the plugin (prompts included),
|
|
// whose route is not token-guarded and declares no Guard, whose Input is not
|
|
// a struct, whose path:"x" tag has no {x} segment or whose segment has no
|
|
// tag. A 2xx answer is returned as text plus structured content; a 4xx or
|
|
// 5xx answer is returned as the tool's error carrying the route's own body.
|
|
type MCPTool struct {
|
|
Name string
|
|
Title string
|
|
Description string
|
|
Guard string
|
|
Method string
|
|
Path string
|
|
Input any
|
|
Output any
|
|
Permissions []string
|
|
ReadOnly *bool
|
|
Destructive *bool
|
|
Idempotent *bool
|
|
OpenWorld *bool
|
|
}
|
|
|
|
// MCPPrompt is one MCP prompt template. Name is prefixed and checked like a
|
|
// tool name; Guard and Permissions filter who is offered it, as for a tool.
|
|
// Render builds the messages for the given arguments (missing required
|
|
// arguments are refused before Render runs). Writes says the prompt directs
|
|
// tools that are not read-only, so the endpoint lists it only for an
|
|
// identity that may write, under the same read/write ceiling as such a
|
|
// tool; false (the zero value) offers it to read-only grants too.
|
|
type MCPPrompt struct {
|
|
Name string
|
|
Title string
|
|
Description string
|
|
Guard string
|
|
Permissions []string
|
|
Arguments []MCPPromptArgument
|
|
Render func(ctx context.Context, args map[string]string) ([]MCPPromptMessage, error)
|
|
Writes bool
|
|
}
|
|
|
|
// MCPPromptArgument describes one argument of a prompt.
|
|
type MCPPromptArgument struct {
|
|
Name string
|
|
Description string
|
|
Required bool
|
|
}
|
|
|
|
// MCPPromptMessage is one rendered prompt message. Role is "user" or
|
|
// "assistant".
|
|
type MCPPromptMessage struct {
|
|
Role string
|
|
Text string
|
|
}
|
|
|
|
// MCPGuard is a guard provider: the owner of a route guard ("user" for a
|
|
// frontend user plugin, "backend" for the admin) publishes one so the MCP
|
|
// module can bind tools to its routes without naming its middleware.
|
|
//
|
|
// MCPGuardName is the name tools reference in MCPTool.Guard. MCPTokenGuarded
|
|
// reports whether a route's middleware list carries this guard's
|
|
// token-accepting middleware (a tool target must, unless it declares the
|
|
// guard). MCPRequirements parses the guard's own middleware names into the
|
|
// scopes and permission codes the route enforces. MCPAllows answers whether
|
|
// subject may call a tool with those requirements and the tool's own
|
|
// Permissions, with the same logic the route enforces. MCPDispatch builds
|
|
// the pre-resolved principal and credential for one in-process call of a
|
|
// route with the given HTTP method.
|
|
type MCPGuard interface {
|
|
MCPGuardName() string
|
|
MCPTokenGuarded(middleware []string) bool
|
|
MCPRequirements(middleware []string) (MCPRequirements, error)
|
|
MCPAllows(ctx context.Context, subject MCPSubject, req MCPRequirements, permissions []string) (bool, error)
|
|
MCPDispatch(ctx context.Context, subject MCPSubject, method string) (MCPDispatch, error)
|
|
}
|
|
|
|
// MCPRequirements is what a route's middleware demands of the caller.
|
|
type MCPRequirements struct {
|
|
Scopes []string
|
|
Permissions []string
|
|
}
|
|
|
|
// MCPSubject is the identity an MCP session acts as under one guard: the
|
|
// guard name, the user id in that guard's user table, the scopes granted to
|
|
// the session (OAuth or personal-token scopes), and the OAuth client and
|
|
// personal token the session was opened with (zero when not applicable).
|
|
type MCPSubject struct {
|
|
Guard string
|
|
UserID uint
|
|
Scopes []string
|
|
ClientID string
|
|
TokenID uint
|
|
}
|
|
|
|
// MCPDispatch is a guard provider's answer for one in-process call: the
|
|
// principal (a *bouncer.Principal, carried as any so pact stays free of
|
|
// bouncer), the optional credential stored next to it, and the bouncer
|
|
// registry guard names whose middleware honours the dispatch marker.
|
|
type MCPDispatch struct {
|
|
Principal any
|
|
Credential any
|
|
Guards []string
|
|
}
|
|
|
|
// MCPGuards is the registry of guard providers, published on the
|
|
// application (find-or-publish: the first party to need it creates it).
|
|
// Safe for concurrent use.
|
|
type MCPGuards struct {
|
|
mu sync.RWMutex
|
|
guards map[string]MCPGuard
|
|
}
|
|
|
|
// NewMCPGuards returns an empty guard provider registry.
|
|
func NewMCPGuards() *MCPGuards {
|
|
return &MCPGuards{guards: make(map[string]MCPGuard)}
|
|
}
|
|
|
|
// Register adds a guard provider under its MCPGuardName. A nil provider, an
|
|
// empty name or a name already registered fails with a "pact: ..." error
|
|
// naming the guard.
|
|
func (g *MCPGuards) Register(provider MCPGuard) error {
|
|
if g == nil {
|
|
return errors.New("pact: mcp guard registry is nil")
|
|
}
|
|
if provider == nil {
|
|
return errors.New("pact: mcp guard provider is nil")
|
|
}
|
|
name := provider.MCPGuardName()
|
|
if name == "" {
|
|
return errors.New("pact: mcp guard provider has an empty name")
|
|
}
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if g.guards == nil {
|
|
g.guards = make(map[string]MCPGuard)
|
|
}
|
|
if _, taken := g.guards[name]; taken {
|
|
return fmt.Errorf("pact: mcp guard %q is already registered", name)
|
|
}
|
|
g.guards[name] = provider
|
|
return nil
|
|
}
|
|
|
|
// Lookup returns the provider registered under name.
|
|
func (g *MCPGuards) Lookup(name string) (MCPGuard, bool) {
|
|
if g == nil {
|
|
return nil, false
|
|
}
|
|
g.mu.RLock()
|
|
defer g.mu.RUnlock()
|
|
p, ok := g.guards[name]
|
|
return p, ok
|
|
}
|
|
|
|
// Names returns the registered guard names, sorted.
|
|
func (g *MCPGuards) Names() []string {
|
|
if g == nil {
|
|
return nil
|
|
}
|
|
g.mu.RLock()
|
|
defer g.mu.RUnlock()
|
|
names := make([]string, 0, len(g.guards))
|
|
for name := range g.guards {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// MCPTokenVerifier verifies an OAuth access token at the MCP endpoint. The
|
|
// OAuth server plugin publishes one; MCPVerifyToken answers ErrMCPTokenInvalid
|
|
// (wrapped or bare) for a token that is not its own, revoked, expired or
|
|
// issued to a client of another kind. MCPResource is the resource indicator
|
|
// the tokens are issued for.
|
|
type MCPTokenVerifier interface {
|
|
MCPVerifyToken(ctx context.Context, raw string) (MCPGrant, error)
|
|
MCPResource() string
|
|
}
|
|
|
|
// MCPGrant is a verified OAuth access token: the frontend user, the client,
|
|
// the token row, the granted scopes and the expiry.
|
|
type MCPGrant struct {
|
|
UserID uint
|
|
ClientID string
|
|
TokenID uint
|
|
Scopes []string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// ErrMCPTokenInvalid is what an MCPTokenVerifier answers for a token it does
|
|
// not accept.
|
|
var ErrMCPTokenInvalid = errors.New("pact: mcp token invalid")
|
|
|
|
// MCPPersonalTokenResolver resolves a personal API token (a frontend user
|
|
// token; the admin's g15_ tokens are resolved by the framework) into an MCP
|
|
// subject. An application has one resolver, published through app.Lookup
|
|
// by the plugin that owns the frontend tokens. The boolean is false when
|
|
// the token's prefix is not the resolver's, and the endpoint then answers
|
|
// unauthorized; an error means the token has this resolver's prefix but is
|
|
// not usable. The subject's UserID and TokenID must both be set: TokenID
|
|
// identifies the token row and keys the session binding
|
|
// (pat:user:<TokenID>), so a subject without a TokenID is refused.
|
|
type MCPPersonalTokenResolver interface {
|
|
MCPResolvePersonalToken(ctx context.Context, raw string) (MCPSubject, bool, error)
|
|
}
|
|
|
|
// MCPLinkedBackendResolver answers the verified backend account linked to a
|
|
// frontend user, published by the plugin that owns the link. The boolean is
|
|
// false when the user has no link.
|
|
type MCPLinkedBackendResolver interface {
|
|
LinkedBackendUser(ctx context.Context, frontendUserID uint) (LinkedBackendUser, bool, error)
|
|
}
|
|
|
|
// LinkedBackendUser is the backend account a frontend user is linked to.
|
|
type LinkedBackendUser struct {
|
|
ID uint
|
|
Login string
|
|
DisplayName string
|
|
}
|
|
|
|
// MCPSubjectChanged is fired through the festival bus by whoever changes
|
|
// what a subject may do over MCP: a frontend or backend permission change
|
|
// (MCPSubjectPermissions), a backend link set or removed (MCPSubjectLink), a
|
|
// grant or personal token revoked (MCPSubjectRevoked) and a user deleted or
|
|
// deactivated (MCPSubjectDeleted). The MCP module re-lists the subject's
|
|
// tools or ends its sessions; every call re-checks anyway, so a missed event
|
|
// only leaves a stale list for longer.
|
|
//
|
|
// TokenID and ClientID narrow an MCPSubjectRevoked event. A non-zero TokenID
|
|
// ends only the sessions whose subject under Guard is UserID with that
|
|
// TokenID and no ClientID: the sessions one personal token opened. A
|
|
// non-empty ClientID ends only the sessions whose subject under Guard is
|
|
// UserID with that ClientID: one OAuth client's grant. With neither, every
|
|
// session of the subject ends. Other kinds ignore both fields.
|
|
type MCPSubjectChanged struct {
|
|
Guard string
|
|
UserID uint
|
|
Kind string
|
|
TokenID uint
|
|
ClientID string
|
|
}
|
|
|
|
// Kinds of MCPSubjectChanged.
|
|
const (
|
|
MCPSubjectPermissions = "permissions"
|
|
MCPSubjectLink = "link"
|
|
MCPSubjectRevoked = "revoked"
|
|
MCPSubjectDeleted = "deleted"
|
|
)
|
|
|
|
// FrontendPermission is one frontend permission code a plugin declares, the
|
|
// frontend counterpart of Permission: the code a route's permission
|
|
// middleware checks, with the label, tab and comment the user plugin's
|
|
// permission editors show next to the seeded codes. Label, Tab and Comment
|
|
// are phrase keys (containing "::") or literal text.
|
|
type FrontendPermission struct {
|
|
Code string
|
|
Label string
|
|
Tab string
|
|
Comment string
|
|
}
|
|
|
|
// HasFrontendPermissions is implemented by plugins that declare frontend
|
|
// permission codes.
|
|
type HasFrontendPermissions interface {
|
|
FrontendPermissions() []FrontendPermission
|
|
}
|