From 6887abf575c1b16d63f7061a79d073b1e5ed6b55 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 16:16:35 +0200 Subject: [PATCH] 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 --- docs/plugins/registration.md | 4 + modules/pact/README.md | 27 ++- modules/pact/capabilities.go | 87 ++++++++- modules/pact/capabilities_test.go | 222 +++++++++++++++++++++- modules/pact/mcp.go | 297 ++++++++++++++++++++++++++++++ 5 files changed, 630 insertions(+), 7 deletions(-) create mode 100644 modules/pact/mcp.go diff --git a/docs/plugins/registration.md b/docs/plugins/registration.md index 2c7d262a..5d3fdf74 100644 --- a/docs/plugins/registration.md +++ b/docs/plugins/registration.md @@ -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 diff --git a/modules/pact/README.md b/modules/pact/README.md index 33ef5f74..0fcced40 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -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. | diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index cd591d37..b6e48d0c 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -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 diff --git a/modules/pact/capabilities_test.go b/modules/pact/capabilities_test.go index 9521e17d..e5ceea4d 100644 --- a/modules/pact/capabilities_test.go +++ b/modules/pact/capabilities_test.go @@ -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) + } + } + } +} diff --git a/modules/pact/mcp.go b/modules/pact/mcp.go new file mode 100644 index 00000000..cdd1d897 --- /dev/null +++ b/modules/pact/mcp.go @@ -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 +// _, 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 +}