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 // _, 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:), 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 }