- 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
175 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|