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
This commit is contained in:
Jakub Zych
2026-10-22 16:16:35 +02:00
parent 5c15dea569
commit 6887abf575
5 changed files with 630 additions and 7 deletions

View File

@@ -83,7 +83,11 @@ A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS pl
| `pact.HasMailTemplates` | Mail templates and layouts. |
| `pact.HasPermissions`, `pact.HasNavigation`, `pact.HasSettings` | Backend permissions, navigation and settings screens. |
| `pact.HasTokenScopes` | Personal API token scopes (`pact.TokenScope`) compiled at admin boot. |
| `pact.HasFrontendPermissions` | Frontend permission codes (`pact.FrontendPermission`) a route's permission middleware checks, offered in the user plugin's permission editors. |
| `pact.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. |
| `pact.HasAdminActionExtensions` | Record actions added to any admin controller by ID (`pact.AdminActionExtension`); a `pact.AdminRecordAction` with a `Prompt` asks the administrator for one text value first and receives it as `pact.AdminRecordActionInput.Value`. |
| `pact.HasAccountContributions` | Sections with a status line and buttons on the administrator's My account page (`pact.AccountContribution`, `pact.AccountAction`), run for the signed-in administrator only. |
| `pact.HasMCP` | MCP tools and prompts (`pact.MCP`, `pact.MCPTool`, `pact.MCPPrompt`) bound to the plugin's own token-guarded routes, served by the framework's opt-in MCP endpoint. |
| `pact.HasModels` | The plugin's GORM models. No framework package reads it yet. |
## Embedded files

View File

@@ -26,6 +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.
- 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.
- 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.AdminPartialData` (the curated view model a partial template renders).
- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices.
@@ -133,12 +136,32 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.AdminBulkActionInput` | What a bulk action receives: `Records`, the selected records loaded and row-locked through the list scope (without row locks for a `Detached` action). |
| `pact.AdminBulkActionResult` | What a bulk action returns: an optional message for the toast and `Affected`, the number of records it changed. |
| `pact.HasAdminBulkActions` | Registers a controller's bulk actions for the `bulkActions` list of `config_list.yaml`. |
| `pact.AdminRecordAction` | One named record action: name, label, optional confirm text, extra permissions, the `Danger` flag (the admin confirms it in the danger variant), the optional `Applies` rule and the Go `Run` function. |
| `pact.AdminRecordActionInput` | What a record action receives: `RecordID` and `Record`, the record loaded and row-locked through the form scope. |
| `pact.AdminRecordAction` | One named record action: name, label, optional confirm text, extra permissions, the `Danger` flag (the admin confirms it in the danger variant), an optional `Prompt` (the confirm dialog asks for one text value), the optional `Applies` rule and the Go `Run` function. |
| `pact.AdminActionPrompt` | The text input of a prompted record action: `Label` (required), `Placeholder`, `Comment` (phrase keys or text) and `MaxLength` in runes (0 means 255). |
| `pact.AdminRecordActionInput` | What a record action receives: `RecordID` and `Record`, the record loaded and row-locked through the form scope, and `Value`, the trimmed prompt answer (empty without a `Prompt`). |
| `pact.AdminRecordActionResult` | What a record action returns: an optional message for the toast. |
| `pact.HasAdminRecordActions` | Registers a controller's record actions for the `recordActions` list of `config_form.yaml`. |
| `pact.AdminActionExtension` | Record actions a plugin adds to one admin controller by its `Controller` ID; offered on the target's preview screen after the owner's actions, each checked against the target's `RequiredPermissions` and its own `Permissions`. A contribution to `summercms.cabana.users` is refused with `user.outranked` when the target administrator outranks the caller. |
| `pact.HasAdminActionExtensions` | Implemented by a plugin (not a controller) that adds actions to admin controllers, its own or another plugin's. An unknown controller ID, a target without a preview block or a duplicate name stops the boot. |
| `pact.HasAccountContributions` | Implemented by a plugin that adds sections to the admin's My account page. |
| `pact.AccountContribution` | One My account section: `Name` (an identifier unique across plugins), `Label`, `Description`, an optional pure-read `Status` func for the signed-in administrator and its `Actions`. A non-identifier or taken name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot. |
| `pact.AccountAction` | One button of a section: name, label, optional `Confirm` text, `Danger`, extra `Permissions`, an optional pure-read `Applies` rule and the Go `Run` function, which acts for the signed-in administrator only. |
| `pact.AccountActionInput` | What an account action receives: `AdminID`, the signed-in administrator, and nothing else. |
| `pact.AccountActionResult` | What an account action returns: a `Message` for the toast and an optional `URL` the admin opens in a new tab. |
| `pact.HasMCP` | Implemented by a plugin that declares MCP tools and prompts for the framework's MCP endpoint. |
| `pact.MCP` | The declaration: `Tools` and `Prompts` (resources are a later field of the same struct). |
| `pact.MCPTool` | One tool bound to one of the plugin's own token-guarded routes by `Method` and `Path`: unprefixed `Name`, static `Title` and `Description`, the `Guard` it runs as, the `Input` struct (zero value; `path`, `query`, `json` and `jsonschema` tags map and describe the arguments), an optional `Output` struct, extra `Permissions` for the listing and the `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` overrides of the annotations derived from the method (nil means derived). |
| `pact.MCPPrompt` | One prompt template: name, title, description, guard, permissions, `Arguments` (`pact.MCPPromptArgument`) and the `Render` func returning `pact.MCPPromptMessage` values (`Role` user or assistant, `Text`). |
| `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.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 names its resource. A refused token is `pact.ErrMCPTokenInvalid`. |
| `pact.MCPPersonalTokenResolver` | Published by a plugin whose personal tokens may open an MCP session: resolves a raw token into a `pact.MCPSubject`, false when the prefix is not its own. |
| `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. |
| `pact.FrontendPermission` | One frontend permission code a plugin declares: `Code`, `Label`, `Tab` and `Comment`, offered in the user plugin's permission editors next to the seeded codes. |
| `pact.HasFrontendPermissions` | Declares frontend permission codes, the frontend counterpart of `pact.HasPermissions`. |
| `pact.ReorderAfterSave` | Optional controller hook `ReorderAfterSave(ctx, partition)`: runs after a reorder save wrote the new order, inside the same transaction (`cabana.TxFromContext` works); an error rolls the reorder back. |
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
| `pact.HasFormFieldTypes` | Implemented by a plugin (not a controller) that registers admin form field types. |

View File

@@ -348,18 +348,39 @@ type AdminRecordAction struct {
// Danger marks an action that moves money out or cannot be undone: the
// admin's confirm dialog then uses its danger variant (serialized as
// `danger` in the record response). The footer button itself is unchanged.
Danger bool
Danger bool
// Prompt, when set, asks the administrator for one text value in the
// confirm dialog before the action runs; the trimmed answer reaches Run
// as AdminRecordActionInput.Value. An action without a Prompt carries no
// input beyond the record and refuses a posted value.
Prompt *AdminActionPrompt
Applies func(ctx context.Context, record any) (bool, error) `json:"-"`
Run func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) `json:"-"`
}
// AdminActionPrompt describes the one text input a prompted record action
// shows in its confirm dialog. Label, Placeholder and Comment are phrase keys
// (containing "::") or literal text; Label is required. MaxLength caps the
// value in runes, 0 meaning 255. The admin refuses an empty or over-long
// value with a validation error on the input before the action runs.
type AdminActionPrompt struct {
Label string
Placeholder string
Comment string
MaxLength int
}
// AdminRecordActionInput is what the framework hands an AdminRecordAction.
// Record is the record the framework loaded through the controller's
// FormExtendQuery scope with a row lock, inside the action's transaction;
// RecordID is its primary key.
// RecordID is its primary key. Value is the trimmed prompt answer of a
// prompted action; empty for an action without a Prompt.
type AdminRecordActionInput struct {
RecordID uint64
Record any
Value string
}
// AdminRecordActionResult is a record action's answer. Message is a phrase key
@@ -392,7 +413,10 @@ type HasAdminRecordActions interface {
// its outranking check inside the action transaction before the
// contributor's Run; a non-superuser whose grants do not cover the
// target's effective grants gets 403 with backend::lang.user.outranked and
// Run is not called. A record action carries no input beyond the record.
// Run is not called. A record action carries no input beyond the record,
// unless it declares a Prompt: then the admin asks for one text value and
// hands the trimmed answer to Run as AdminRecordActionInput.Value; a Prompt
// with an empty Label stops the boot.
//
// The admin compiles extensions at boot, only when it is active (some
// plugin registers admin controllers). An unknown controller ID, a target
@@ -411,6 +435,63 @@ type HasAdminActionExtensions interface {
AdminActionExtensions() []AdminActionExtension
}
// HasAccountContributions is implemented by plugins that add a section to
// the admin's My account page: a titled card with a status line and
// buttons, shown to the signed-in administrator only.
type HasAccountContributions interface {
AccountContributions() []AccountContribution
}
// AccountContribution is one section a plugin adds to the admin's My account
// page. Name is an identifier unique across plugins; Label and Description
// are phrase keys (containing "::") or literal text. Status, when set, is a
// pure read that answers the line shown under the description for the
// signed-in administrator (an empty string shows none). Actions are the
// section's buttons.
//
// The admin compiles contributions at boot, only when it is active. A name
// that is not an identifier, a name another plugin already contributed, an
// empty Label, an action whose name is not an identifier or repeats within
// the section, a nil Run or an empty action Label stops the boot.
type AccountContribution struct {
Name string
Label string
Description string
Status func(ctx context.Context, adminID uint) (string, error) `json:"-"`
Actions []AccountAction
}
// AccountAction is one button of an AccountContribution. Confirm, when set,
// is the confirmation text the admin shows first (Danger selects its danger
// variant). Permissions are extra backend permission codes the administrator
// needs. Applies, when set, must be a pure read: it decides whether the
// button is offered and is checked again before Run. Run acts for the
// signed-in administrator only, inside a transaction (cabana.TxFromContext
// reads it), and may answer a URL the admin opens in a new tab.
type AccountAction struct {
Name string
Label string
Confirm string
Danger bool
Permissions []string
Applies func(ctx context.Context, adminID uint) (bool, error) `json:"-"`
Run func(ctx context.Context, in AccountActionInput) (AccountActionResult, error) `json:"-"`
}
// AccountActionInput is what the framework hands an AccountAction: the id of
// the signed-in administrator and nothing else.
type AccountActionInput struct {
AdminID uint
}
// AccountActionResult is an account action's answer. Message is a phrase key
// or text, localized by the framework and shown as a toast; URL, when set,
// is opened by the admin in a new tab and shown as a link.
type AccountActionResult struct {
Message string
URL string
}
// AdminPartialData supplies the view model a controller partial template
// renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name
// is the partial name; record is the scoped record for a form partial on an

View File

@@ -3,7 +3,9 @@ package pact
import (
"context"
"errors"
"fmt"
"io/fs"
"os/exec"
"reflect"
"strings"
"testing"
@@ -392,8 +394,9 @@ func TestPhase121ContractsOnSampleController(t *testing.T) {
if err != nil || out.Message != "acme::lang.reinstated" || one.Confirm != "" {
t.Fatalf("record result = %+v err=%v", out, err)
}
if fields := reflect.TypeOf(AdminRecordActionInput{}).NumField(); fields != 2 {
t.Fatalf("AdminRecordActionInput has %d fields, want RecordID and Record", fields)
// RecordID, Record and (phase 19.7) the prompt Value: still no id list.
if fields := reflect.TypeOf(AdminRecordActionInput{}).NumField(); fields != 3 {
t.Fatalf("AdminRecordActionInput has %d fields, want RecordID, Record and Value", fields)
}
if strings.Join(ran, ",") != "bulk,record" {
t.Fatalf("ran = %v", ran)
@@ -430,3 +433,218 @@ func TestPhase121ContractsOnSampleController(t *testing.T) {
}
}
}
// samplePier is a plugin value that implements the phase 19.7 contracts:
// MCP tools and prompts, frontend permission codes and a My account
// contribution with a prompted record action on another controller.
type samplePier struct{}
type samplePostInput struct {
ID uint `path:"id"`
Query string `query:"q"`
Title string `json:"title" jsonschema:"the post title"`
}
func (samplePier) MCP() MCP {
ro := true
return MCP{
Tools: []MCPTool{{
Name: "show_post", Title: "Show a post", Description: "Returns one post.",
Guard: "backend", Method: "GET", Path: "/_blog/api/v1/posts/{id}",
Input: samplePostInput{}, Permissions: []string{"acme.blog.access_posts"}, ReadOnly: &ro,
}},
Prompts: []MCPPrompt{{
Name: "draft_post", Title: "Draft a post", Guard: "backend",
Arguments: []MCPPromptArgument{{Name: "topic", Required: true}},
Render: func(_ context.Context, args map[string]string) ([]MCPPromptMessage, error) {
return []MCPPromptMessage{{Role: "user", Text: "Draft a post about " + args["topic"]}}, nil
},
}},
}
}
func (samplePier) FrontendPermissions() []FrontendPermission {
return []FrontendPermission{{Code: "acme.blog.comment", Label: "acme::lang.comment", Tab: "acme::lang.tab"}}
}
func (samplePier) AccountContributions() []AccountContribution {
return []AccountContribution{{
Name: "site_link", Label: "acme::lang.site_link", Description: "acme::lang.site_link_hint",
Status: func(_ context.Context, adminID uint) (string, error) { return "linked", nil },
Actions: []AccountAction{{
Name: "link", Label: "acme::lang.link", Confirm: "acme::lang.link_confirm",
Applies: func(_ context.Context, adminID uint) (bool, error) { return adminID != 0, nil },
Run: func(_ context.Context, in AccountActionInput) (AccountActionResult, error) {
return AccountActionResult{Message: "acme::lang.linked", URL: "https://blog.example.com/link"}, nil
},
}},
}}
}
func (samplePier) AdminActionExtensions() []AdminActionExtension {
return []AdminActionExtension{{
Controller: "summercms.cabana.users",
RecordActions: []AdminRecordAction{{
Name: "link_site_account", Label: "acme::lang.link_site_account",
Prompt: &AdminActionPrompt{Label: "acme::lang.site_login", MaxLength: 64},
Run: func(_ context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) {
return AdminRecordActionResult{Message: "linked " + in.Value}, nil
},
}},
}}
}
var (
_ HasMCP = samplePier{}
_ HasFrontendPermissions = samplePier{}
_ HasAccountContributions = samplePier{}
_ HasAdminActionExtensions = samplePier{}
)
// sampleGuard is a guard provider with a fixed name.
type sampleGuard struct{ name string }
func (g sampleGuard) MCPGuardName() string { return g.name }
func (sampleGuard) MCPTokenGuarded(middleware []string) bool {
return strings.Contains(strings.Join(middleware, " "), "api_token")
}
func (sampleGuard) MCPRequirements(middleware []string) (MCPRequirements, error) {
return MCPRequirements{Scopes: []string{"read"}}, nil
}
func (sampleGuard) MCPAllows(context.Context, MCPSubject, MCPRequirements, []string) (bool, error) {
return true, nil
}
func (sampleGuard) MCPDispatch(context.Context, MCPSubject, string) (MCPDispatch, error) {
return MCPDispatch{Principal: "principal", Guards: []string{"api_token"}}, nil
}
// TestPhase197ContractsOnSamplePlugin: the MCP, frontend permission, account
// contribution and prompted record action contracts are discovered by type
// assertion on a plugin value and carry the documented fields.
func TestPhase197ContractsOnSamplePlugin(t *testing.T) {
var plugin any = samplePier{}
mcp, ok := plugin.(HasMCP)
if !ok {
t.Fatal("HasMCP is not discovered")
}
decl := mcp.MCP()
if len(decl.Tools) != 1 || len(decl.Prompts) != 1 {
t.Fatalf("declaration = %+v", decl)
}
tool := decl.Tools[0]
if tool.Name != "show_post" || tool.Guard != "backend" || tool.Method != "GET" || tool.Path != "/_blog/api/v1/posts/{id}" || tool.ReadOnly == nil || !*tool.ReadOnly || tool.Destructive != nil {
t.Fatalf("tool = %+v", tool)
}
if _, isStruct := tool.Input.(samplePostInput); !isStruct || tool.Output != nil {
t.Fatalf("tool input/output = %T %T", tool.Input, tool.Output)
}
prompt := decl.Prompts[0]
msgs, err := prompt.Render(t.Context(), map[string]string{"topic": "beaches"})
if err != nil || len(msgs) != 1 || msgs[0].Role != "user" || !strings.HasSuffix(msgs[0].Text, "beaches") || !prompt.Arguments[0].Required {
t.Fatalf("prompt = %+v msgs=%v err=%v", prompt, msgs, err)
}
fp, ok := plugin.(HasFrontendPermissions)
if !ok || len(fp.FrontendPermissions()) != 1 || fp.FrontendPermissions()[0].Code != "acme.blog.comment" {
t.Fatal("HasFrontendPermissions is not discovered")
}
ac, ok := plugin.(HasAccountContributions)
if !ok || len(ac.AccountContributions()) != 1 {
t.Fatal("HasAccountContributions is not discovered")
}
section := ac.AccountContributions()[0]
status, err := section.Status(t.Context(), 7)
if err != nil || status != "linked" || section.Name != "site_link" || len(section.Actions) != 1 {
t.Fatalf("section = %+v status=%q err=%v", section, status, err)
}
action := section.Actions[0]
if applies, err := action.Applies(t.Context(), 0); err != nil || applies {
t.Fatalf("Applies(0) = %v err=%v", applies, err)
}
res, err := action.Run(t.Context(), AccountActionInput{AdminID: 7})
if err != nil || res.URL == "" || res.Message != "acme::lang.linked" {
t.Fatalf("account action result = %+v err=%v", res, err)
}
// The input of an account action is the signed-in admin and nothing else.
if n := reflect.TypeOf(AccountActionInput{}).NumField(); n != 1 {
t.Fatalf("AccountActionInput has %d fields, want only AdminID", n)
}
ext := plugin.(HasAdminActionExtensions).AdminActionExtensions()[0].RecordActions[0]
if ext.Prompt == nil || ext.Prompt.Label == "" || ext.Prompt.MaxLength != 64 {
t.Fatalf("prompted action = %+v", ext)
}
out, err := ext.Run(t.Context(), AdminRecordActionInput{RecordID: 3, Value: "editor"})
if err != nil || out.Message != "linked editor" {
t.Fatalf("prompted run = %+v err=%v", out, err)
}
if len(MCPSubjectChanged{Guard: "backend", UserID: 7, Kind: MCPSubjectLink}.Kind) == 0 || MCPSubjectPermissions == MCPSubjectRevoked || MCPSubjectDeleted == "" {
t.Fatal("subject change kinds")
}
if grant := (MCPGrant{UserID: 1, Scopes: []string{"read"}}); grant.ExpiresAt.IsZero() != true || !errors.Is(fmt.Errorf("wrap: %w", ErrMCPTokenInvalid), ErrMCPTokenInvalid) {
t.Fatal("grant and sentinel")
}
var _ LinkedBackendUser = LinkedBackendUser{ID: 1, Login: "admin", DisplayName: "Admin"}
var _ MCPSubject = MCPSubject{Guard: "user", UserID: 1, Scopes: []string{"read"}, ClientID: "cli", TokenID: 0}
}
// TestMCPGuardsRegistry: register, duplicate and empty names refused with a
// pact error naming the guard, lookup, and sorted names.
func TestMCPGuardsRegistry(t *testing.T) {
reg := NewMCPGuards()
for _, name := range []string{"user", "backend"} {
if err := reg.Register(sampleGuard{name: name}); err != nil {
t.Fatal(err)
}
}
err := reg.Register(sampleGuard{name: "user"})
if err == nil || !strings.Contains(err.Error(), `pact: mcp guard "user"`) {
t.Fatalf("duplicate: %v", err)
}
if err := reg.Register(sampleGuard{}); err == nil || !strings.HasPrefix(err.Error(), "pact: ") {
t.Fatalf("empty name: %v", err)
}
if err := reg.Register(nil); err == nil || !strings.HasPrefix(err.Error(), "pact: ") {
t.Fatalf("nil provider: %v", err)
}
if got := reg.Names(); strings.Join(got, ",") != "backend,user" {
t.Fatalf("Names = %v", got)
}
g, ok := reg.Lookup("backend")
if !ok || g.MCPGuardName() != "backend" || !g.MCPTokenGuarded([]string{"user.api_token"}) {
t.Fatalf("Lookup(backend) = %v %v", g, ok)
}
if _, ok := reg.Lookup("other"); ok {
t.Fatal("Lookup found an unregistered guard")
}
d, err := g.MCPDispatch(t.Context(), MCPSubject{Guard: "backend", UserID: 1}, "GET")
if err != nil || d.Principal != "principal" || len(d.Guards) != 1 {
t.Fatalf("dispatch = %+v err=%v", d, err)
}
var nilReg *MCPGuards
if _, ok := nilReg.Lookup("user"); ok || nilReg.Names() != nil || nilReg.Register(sampleGuard{name: "x"}) == nil {
t.Fatal("nil registry is not inert")
}
}
// TestPactStaysSDKFree: the package's dependency list names neither the MCP
// SDK, the JSON schema library nor bouncer, so plugins compile without them.
func TestPactStaysSDKFree(t *testing.T) {
goTool, err := exec.LookPath("go")
if err != nil {
t.Skip("go tool not on PATH")
}
out, err := exec.Command(goTool, "list", "-deps", ".").Output()
if err != nil {
t.Fatalf("go list -deps: %v", err)
}
for _, dep := range strings.Split(string(out), "\n") {
for _, banned := range []string{"modelcontextprotocol", "jsonschema-go", "modules/bouncer"} {
if strings.Contains(dep, banned) {
t.Fatalf("pact depends on %s", dep)
}
}
}
}

297
modules/pact/mcp.go Normal file
View File

@@ -0,0 +1,297 @@
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
}