Files
summercms/modules/pier/example_mcp_test.go
Jakub Zych 8c6c0a3654 feat(19.7-02): identity kinds, ceilings, prompts and the MCP services page
- personal tokens only behind mcp.allow_personal_tokens (401 without a
  lookup otherwise); an admin token yields a backend-only identity with
  its scope limit per dispatched method, a user token a frontend-only one
- read/write ceiling and prompts proven by the tracer's subtests; the
  audit line asserted to carry the tool and never the arguments or token
- the mounted *pier.Pier is published on the application (test seams in
  export_test.go)
- docs/services/mcp.md with the tested acme.blog example, site.yaml note,
  cross-links from authentication.md, registration.md and the surf README
2026-10-22 18:34:39 +02:00

175 lines
6.0 KiB
Go

package pier_test
import (
"context"
"errors"
"net/http"
"slices"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// The neutral acme.blog example docs/services/mcp.md shows: a plugin that
// declares two tools and one prompt bound to its own token-guarded routes.
// TestExampleBlogPlugin keeps it compiling and mounting.
// blogTools is the MCP declaration of the acme.blog plugin. A field of an
// input struct is a body field unless its path or query tag says
// otherwise; fields without omitempty are required in the schema.
func blogTools() pact.MCP {
type createPostInput struct {
Title string `json:"title" jsonschema:"the post title"`
Body string `json:"body" jsonschema:"the Markdown body"`
Published bool `json:"published,omitempty" jsonschema:"publish at once instead of saving a draft"`
}
type showPostInput struct {
ID int `json:"id" path:"id" jsonschema:"the post id"`
}
type listPostsInput struct {
Query string `json:"q,omitempty" query:"q" jsonschema:"a phrase the title or body contains"`
}
return pact.MCP{
Tools: []pact.MCPTool{
{
// Exposed as blog_create_post; the backend guard runs it as
// the linked administrator, listed only with access_posts.
Name: "create_post",
Title: "Create post",
Description: "Creates a post; a draft unless published is true.",
Guard: "backend",
Method: "POST",
Path: "/_blog/api/v1/posts",
Input: createPostInput{},
Permissions: []string{"acme.blog.access_posts"},
},
{
// Exposed as blog_show_post; the route's user.api_token
// middleware names the guard, GET makes it read-only.
Name: "show_post",
Title: "Show post",
Description: "Shows one post by id.",
Method: "GET",
Path: "/_blog/api/v1/posts/{id}",
Input: showPostInput{},
},
{
Name: "list_posts",
Title: "List posts",
Description: "Lists posts, optionally those matching a phrase.",
Method: "GET",
Path: "/_blog/api/v1/posts",
Input: listPostsInput{},
},
},
Prompts: []pact.MCPPrompt{
{
// Exposed as blog_summarize; prompts name their guard.
Name: "summarize",
Title: "Summarize a post",
Description: "Asks for a three-sentence summary of a post.",
Guard: "user",
Arguments: []pact.MCPPromptArgument{{Name: "slug", Description: "the post slug", Required: true}},
Render: func(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
return []pact.MCPPromptMessage{{Role: "user", Text: "Summarize the post " + args["slug"] + " in three sentences."}}, nil
},
},
},
}
}
// userTokenGuard stands in for a user plugin's API token guard: it refuses
// every HTTP request, so only an in-process dispatch reaches the routes.
type userTokenGuard struct{}
func (userTokenGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) {
return nil, nil, errors.New("blog: no token")
}
func (userTokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
http.Error(w, `{"error":"Invalid token"}`, http.StatusUnauthorized)
}
// userGuardProvider is the "user" guard provider such a plugin publishes.
type userGuardProvider struct{}
func (userGuardProvider) MCPGuardName() string { return "user" }
func (userGuardProvider) MCPTokenGuarded(mw []string) bool {
return slices.Contains(mw, "user.api_token")
}
func (userGuardProvider) MCPRequirements([]string) (pact.MCPRequirements, error) {
return pact.MCPRequirements{}, nil
}
func (userGuardProvider) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
return true, nil
}
func (userGuardProvider) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID}, Guards: []string{"api_token"}}, nil
}
// blogPlugin is the acme.blog plugin of the example.
type blogPlugin struct{}
func (blogPlugin) ID() string { return "acme.blog" }
func (blogPlugin) Requires() []string { return nil }
func (blogPlugin) Register(*backpack.App) error { return nil }
func (blogPlugin) MCP() pact.MCP { return blogTools() }
func (blogPlugin) Boot(app *backpack.App) error {
reg := pact.NewMCPGuards()
if err := reg.Register(userGuardProvider{}); err != nil {
return err
}
return app.Publish(reg)
}
func (p blogPlugin) Middlewares() map[string]pact.Middleware {
guards := bouncer.NewRegistry()
if err := guards.Register(p.ID(), "api_token", userTokenGuard{}); err != nil {
panic(err)
}
auth, err := guards.Middleware("api_token")
if err != nil {
panic(err)
}
return map[string]pact.Middleware{"user.api_token": auth, "backend": auth}
}
func (blogPlugin) Routes(r pact.Router) error {
ok := func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[]}`))
}
r.Group("/_blog/api/v1", []string{"user.api_token"}, func(g pact.Router) {
g.Get("/posts", ok)
g.Get("/posts/{id}", ok)
})
r.Group("/_blog/api/v1", []string{"backend"}, func(g pact.Router) {
g.Post("/posts", ok)
})
return nil
}
func TestExampleBlogPlugin(t *testing.T) {
if decl := blogTools(); len(decl.Tools) != 3 || len(decl.Prompts) != 1 {
t.Fatalf("declaration = %d tools, %d prompts", len(decl.Tools), len(decl.Prompts))
}
app := pierAppWith(t, map[string]string{"mcp.yaml": "enabled: false\n"}, nil)
plugin := blogPlugin{}
if err := plugin.Boot(app); err != nil {
t.Fatal(err)
}
host, err := mountPier(t, app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
names := host.pier.ToolNames()
want := []string{"blog_create_post", "blog_list_posts", "blog_show_post"}
if !slices.Equal(names, want) {
t.Fatalf("tool names = %v, want %v", names, want)
}
}