Files
summercms/modules/pact/mcp.go
Jakub Zych 6887abf575 feat(19.7-01): SDK-free pact contracts for MCP, account contributions and prompted actions
- pact/mcp.go: HasMCP, MCP, MCPTool, MCPPrompt, MCPGuard, MCPGuards,
  MCPSubject, MCPDispatch, MCPTokenVerifier, MCPGrant, ErrMCPTokenInvalid,
  MCPPersonalTokenResolver, MCPLinkedBackendResolver, LinkedBackendUser,
  MCPSubjectChanged and its kinds, FrontendPermission, HasFrontendPermissions
- capabilities.go: AdminActionPrompt, AdminRecordAction.Prompt,
  AdminRecordActionInput.Value, HasAccountContributions, AccountContribution,
  AccountAction, AccountActionInput, AccountActionResult
- discovery tests, MCPGuards registry test and a go list -deps assertion
  that pact names neither the MCP SDK, jsonschema-go nor bouncer
- pact README and docs/plugins/registration.md rows
2026-10-22 16:16:35 +02:00

298 lines
10 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).
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)
}
// 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. The boolean is false when the token's prefix is not the
// resolver's, so the next resolver is tried; an error means the token has
// this resolver's prefix but is not usable.
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.
type MCPSubjectChanged struct {
Guard string
UserID uint
Kind 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
}