- 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
94 lines
4.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|