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) } }