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:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
297
modules/pact/mcp.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user