From a9a9b8098dbe5c0cf6390cbc963852a01220dd42 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 23 Oct 2026 14:06:46 +0200 Subject: [PATCH] test: TestJournalMCP compiles the MCP declaration through pier and pins its input keys (phase 19.7 plan 05) - the seven tools compile in pier's catalog against the plugin's routes - every input field is a path segment, a list query key or a create/update body key the routes read - the draft prompt renders with and without its optional tone --- mcp_test.go | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 mcp_test.go diff --git a/mcp_test.go b/mcp_test.go new file mode 100644 index 0000000..a60fcca --- /dev/null +++ b/mcp_test.go @@ -0,0 +1,149 @@ +package journal_test + +import ( + "context" + "net/http" + "os" + "path/filepath" + "reflect" + "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/party" + "git.golem15.com/golem15/summercms/modules/pier" + "git.golem15.com/golem15/summercms/modules/surf" +) + +// The JSON keys the post routes read (controllers/api/posts_helpers.go: +// body["..."] in the create and update validation, and the list query). +// A tool input field outside these would be sent and silently ignored. +var ( + postBodyKeys = []string{"categories", "content", "excerpt", "is_pinned", "metadata", "published", "published_at", "slug", "sources", "tags", "title", "translations"} + postQueryKeys = []string{"author", "category", "include_empty", "page", "per_page", "pinned_first", "published", "search", "sort", "tag"} +) + +// mountHost adapts a built surf router to pier.Host for a compile-only mount. +type mountHost struct{ r *surf.Router } + +func (h mountHost) Routes() []pier.RouteInfo { + var out []pier.RouteInfo + for _, ri := range h.r.Routes() { + out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware}) + } + return out +} +func (h mountHost) Handler(method, pattern string) (http.Handler, bool) { + return h.r.Handler(method, pattern) +} +func (mountHost) MountStream(string, string, http.Handler, ...string) error { return nil } +func (mountHost) MountJSON(string, string, http.Handler) error { return nil } +func (h mountHost) Owner(method, path string) (string, bool) { + for _, ri := range h.r.Routes() { + if ri.Method == method && ri.Pattern == path { + return ri.PluginID, true + } + } + return "", false +} + +// journalApp boots the translate and journal plugins on a config without a +// database. +func journalApp(t *testing.T) (*backpack.App, []party.Plugin) { + t.Helper() + 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) + } + return app, plugins +} + +// TestJournalMCP (D-15, D-16, SC-2) extends TestJournalMCPSmoke: the +// declaration compiles through pier's own catalog against the routes the +// plugin registers (prefix journal_, the backend guard, path and query +// tags matching the patterns); every input field is a key the route reads +// (path segment, list query key or create/update body key); the draft +// prompt renders with and without its optional tone. The dispatch marker +// on the optional-principal list and show routes is pinned by +// controllers/api TestOptionalBackendPrincipalDispatch. Runs under -short. +func TestJournalMCP(t *testing.T) { + decl := (&journal.Plugin{}).MCP() + + t.Run("compiles through pier against the plugin's routes", func(t *testing.T) { + app, plugins := journalApp(t) + router, err := surf.BuildRouter(app, plugins) + if err != nil { + t.Fatal(err) + } + if err := app.Config.Set(pier.EnabledKey, true); err != nil { + t.Fatal(err) + } + if err := pier.Mount(mountHost{r: router}, app, plugins); err != nil { + t.Fatalf("pier.Mount: %v", err) + } + p, ok := app.Lookup[*pier.Pier]() + if !ok { + t.Fatal("pier was not mounted") + } + want := []string{"journal_create_post", "journal_delete_post", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_show_post", "journal_update_post"} + if got := p.ToolNames(); !slices.Equal(got, want) { + t.Fatalf("compiled tools %v, want %v", got, want) + } + }) + + t.Run("input fields are keys the routes read", func(t *testing.T) { + for _, tool := range decl.Tools { + rt := reflect.TypeOf(tool.Input) + for rt.Kind() == reflect.Pointer { + rt = rt.Elem() + } + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + key, _, _ := strings.Cut(f.Tag.Get("json"), ",") + switch { + case f.Tag.Get("path") != "": + if !strings.Contains(tool.Path, "{"+f.Tag.Get("path")+"}") { + t.Errorf("%s.%s: path tag %q not in %s", tool.Name, f.Name, f.Tag.Get("path"), tool.Path) + } + case f.Tag.Get("query") != "": + if tool.Method != http.MethodGet || !slices.Contains(postQueryKeys, f.Tag.Get("query")) { + t.Errorf("%s.%s: query key %q is not one the list reads", tool.Name, f.Name, f.Tag.Get("query")) + } + default: + if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(postBodyKeys, key) { + t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path) + } + } + } + } + }) + + t.Run("draft prompt with and without tone", func(t *testing.T) { + prompt := decl.Prompts[0] + with, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release", "tone": "playful"}) + if err != nil || len(with) != 1 || !strings.Contains(with[0].Text, "Spring release") || !strings.Contains(with[0].Text, "playful") { + t.Fatalf("with tone = %+v, %v", with, err) + } + without, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release"}) + if err != nil || len(without) != 1 || !strings.Contains(without[0].Text, "Spring release") || strings.Contains(without[0].Text, "playful") { + t.Fatalf("without tone = %+v, %v", without, err) + } + }) +}