Files
sm-journal-plugin/mcp_smoke_test.go
Jakub Zych 4ab94da66c feat: MCP tools for posts, categories and tags, the draft_post prompt
- journal.Plugin.MCP: list_posts, show_post, list_categories, list_tags,
  create_post, update_post, delete_post and the draft_post prompt, guard
  backend, listed with golem15.journal.access_posts; update fields are
  optional so only sent keys reach the route
- optionalBackendPrincipal honours the MCP dispatch marker (no bearer
  short-circuit; anonymous reads unchanged)
- require summercms v0.4.4
- TestJournalMCPSmoke, TestOptionalBackendPrincipalDispatch
2026-10-23 12:54:03 +02:00

94 lines
4.0 KiB
Go

package journal_test
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
journal "git.golem15.com/golem15/sm-journal-plugin"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
)
// TestJournalMCPSmoke pins the Journal's MCP declaration (P-17, D-15, D-16):
// exactly the seven post, category and tag tools and the draft_post prompt,
// each with Guard backend and golem15.journal.access_posts, delete marked
// destructive, and every tool bound to a route the plugin registers (the
// same method and pattern in a router built from the booted plugins).
// Runs under -short (no database).
func TestJournalMCPSmoke(t *testing.T) {
var _ pact.HasMCP = (*journal.Plugin)(nil)
decl := (&journal.Plugin{}).MCP()
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post"}
var names []string
for _, tool := range decl.Tools {
names = append(names, tool.Name)
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, []string{"golem15.journal.access_posts"}) {
t.Errorf("tool %s guard %q permissions %v, want backend and access_posts", tool.Name, tool.Guard, tool.Permissions)
}
if tool.Title == "" || tool.Description == "" || tool.Input == nil {
t.Errorf("tool %s lacks a title, description or input", tool.Name)
}
if (tool.Destructive != nil && *tool.Destructive) != (tool.Name == "delete_post") {
t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive)
}
}
if !slices.Equal(names, wantTools) {
t.Fatalf("tools %v, want %v", names, wantTools)
}
if len(decl.Prompts) != 1 || decl.Prompts[0].Name != "draft_post" || decl.Prompts[0].Guard != "backend" ||
!slices.Equal(decl.Prompts[0].Permissions, []string{"golem15.journal.access_posts"}) {
t.Fatalf("prompts %+v, want draft_post (backend, access_posts)", decl.Prompts)
}
prompt := decl.Prompts[0]
if len(prompt.Arguments) != 2 || prompt.Arguments[0].Name != "topic" || !prompt.Arguments[0].Required || prompt.Arguments[1].Name != "tone" || prompt.Arguments[1].Required {
t.Fatalf("draft_post arguments %+v, want topic (required) and tone", prompt.Arguments)
}
msgs, err := prompt.Render(context.Background(), map[string]string{"topic": "Release notes", "tone": "formal"})
if err != nil || len(msgs) != 1 || msgs[0].Role != "user" ||
!strings.Contains(msgs[0].Text, "Release notes") || !strings.Contains(msgs[0].Text, "formal") ||
!strings.Contains(msgs[0].Text, "journal_create_post") || !strings.Contains(msgs[0].Text, "published set to false") {
t.Fatalf("draft_post render = %+v, %v", msgs, err)
}
// Every tool's method and path is a route the plugin registers.
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-mcp\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
if err != nil {
t.Fatal(err)
}
rt, err := surf.BuildRouter(app, plugins)
if err != nil {
t.Fatalf("surf.BuildRouter: %v", err)
}
routes := map[string]string{}
for _, r := range rt.Routes() {
routes[r.Method+" "+r.Pattern] = r.PluginID
}
for _, tool := range decl.Tools {
if owner, ok := routes[tool.Method+" "+tool.Path]; !ok || owner != "golem15.journal" {
t.Errorf("tool %s targets %s %s, which golem15.journal does not register (owner %q)", tool.Name, tool.Method, tool.Path, owner)
}
}
}