package pier_test import ( "context" "net/http" "slices" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "github.com/modelcontextprotocol/go-sdk/mcp" ) // noContent answers 204 for the catalog's routes (nothing is called). func noContent(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } // guarded is one route behind acme.auth. func guarded(method, path string) testRoute { return testRoute{method: method, path: path, mw: []string{"acme.auth"}, h: noContent} } type itemInput struct { ID int `json:"id" path:"id"` } type listInput struct { Page int `json:"page,omitempty" query:"page"` } func boolPtr(b bool) *bool { return &b } // catalogMount mounts pier for plugins on a fresh app with the acme guard // and answers the mount error. func catalogMount(t *testing.T, plugins ...party.Plugin) (*instance, error) { t.Helper() app := newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}}) host, err := mountPier(t, app, plugins) if err != nil { return nil, err } return &instance{app: app, host: host}, nil } // TestCatalog (SC-2, D-10..D-12, D-14): tool and prompt names are prefixed // with the plugin's short code; every boot error names what is wrong // (colliding short codes and duplicate names name both owners, a long or // dashed name, a route nobody registers, a route without a token guard and // no Guard, a non-struct Input, a path tag without its segment, a body field // on a GET); annotations follow the HTTP method with declared overrides. func TestCatalog(t *testing.T) { list := testRoute{method: http.MethodGet, path: "/acme/list", mw: []string{"acme.auth"}, h: noContent} listTool := pact.MCPTool{Name: "list", Description: "Lists.", Method: "GET", Path: "/acme/list", Input: listInput{}} t.Run("prefix is the plugin's short code", func(t *testing.T) { for id, want := range map[string]string{"acme.blog": "blog_list", "acme.My-Blog": "my_blog_list", "Vendor.Shop2": "shop2_list"} { in, err := catalogMount(t, routePlugin{id: id, routes: []testRoute{list}, tools: []pact.MCPTool{listTool}}) if err != nil { t.Fatalf("%s: %v", id, err) } if got := in.pier().ToolNames(); !slices.Equal(got, []string{want}) { t.Fatalf("%s exposes %v, want [%s]", id, got, want) } } }) refusals := []struct { name string plugins []party.Plugin want []string }{ {"same short code in two plugins", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool}}, routePlugin{id: "other.blog", guard: "other", tools: []pact.MCPTool{listTool}}, }, []string{"other.blog", "acme.blog", "blog_list"}}, {"duplicate name in one plugin", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool, listTool}}, }, []string{"acme.blog", "blog_list", "already taken"}}, {"name over 128 characters", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: strings.Repeat("x", 124), Method: "GET", Path: "/acme/list", Input: listInput{}}}}, }, []string{"acme.blog", "must match"}}, {"name with a dash", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list-all", Method: "GET", Path: "/acme/list", Input: listInput{}}}}, }, []string{"blog_list-all", "must match"}}, {"unregistered route", []party.Plugin{ routePlugin{id: "acme.blog", tools: []pact.MCPTool{{Name: "gone", Method: "post", Path: "/acme/gone", Input: struct{}{}}}}, }, []string{"POST /acme/gone", "no plugin registers"}}, {"route without a token guard and no Guard", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{{method: http.MethodGet, path: "/acme/open", h: noContent}}, tools: []pact.MCPTool{{Name: "open", Method: "GET", Path: "/acme/open", Input: struct{}{}}}}, }, []string{"acme.blog", "not token-guarded"}}, {"unknown declared guard", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Guard: "nobody", Method: "GET", Path: "/acme/list", Input: listInput{}}}}, }, []string{"unknown guard \"nobody\""}}, {"input is not a struct", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: "page"}}}, }, []string{"Input must be a struct"}}, {"input is nil", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list"}}}, }, []string{"Input is nil"}}, {"path tag without its segment", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: itemInput{}}}}, }, []string{`path:"id"`, "no {id} segment"}}, {"segment without an input field", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{guarded(http.MethodGet, "/acme/items/{id}")}, tools: []pact.MCPTool{{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: struct{}{}}}}, }, []string{"segment {id}", "no input field"}}, {"body field on a GET", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: struct { Title string `json:"title"` }{}}}}, }, []string{`"title"`, "GET route takes only path and query fields"}}, {"prompt named like a tool", []party.Plugin{ routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool}, prompts: []pact.MCPPrompt{{Name: "list", Guard: "acme", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return nil, nil }}}}, }, []string{"prompt \"list\"", "blog_list", "already taken"}}, {"prompt without a guard", []party.Plugin{ routePlugin{id: "acme.blog", prompts: []pact.MCPPrompt{{Name: "draft", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return nil, nil }}}}, }, []string{"prompt \"draft\"", "must name its Guard"}}, } for _, c := range refusals { t.Run(c.name, func(t *testing.T) { _, err := catalogMount(t, c.plugins...) if err == nil { t.Fatal("mount succeeded, want a boot error") } for _, w := range c.want { if !strings.Contains(err.Error(), w) { t.Fatalf("err = %v, want it to name %q", err, w) } } }) } t.Run("a declared backend guard binds a route without a token guard", func(t *testing.T) { in, err := catalogMount(t, routePlugin{id: "acme.blog", routes: []testRoute{{method: http.MethodGet, path: "/acme/posts", h: noContent}}, tools: []pact.MCPTool{{Name: "posts", Guard: "backend", Method: "GET", Path: "/acme/posts", Input: struct{}{}}}}) if err != nil { t.Fatal(err) } if got := in.pier().ToolNames(); !slices.Equal(got, []string{"blog_posts"}) { t.Fatalf("exposed %v", got) } }) t.Run("annotations follow the method", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() plugin := routePlugin{id: "acme.blog", routes: []testRoute{guarded(http.MethodGet, "/acme/items/{id}"), guarded(http.MethodPut, "/acme/items/{id}"), guarded(http.MethodDelete, "/acme/items/{id}"), guarded(http.MethodPost, "/acme/items"), list}, tools: []pact.MCPTool{ {Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: itemInput{}}, {Name: "replace", Method: "PUT", Path: "/acme/items/{id}", Input: itemInput{}}, {Name: "remove", Method: "DELETE", Path: "/acme/items/{id}", Input: itemInput{}}, {Name: "create", Method: "POST", Path: "/acme/items", Input: struct { Title string `json:"title"` }{}}, {Name: "export", Method: "GET", Path: "/acme/list", Input: listInput{}, ReadOnly: boolPtr(false), OpenWorld: boolPtr(true)}, }} in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}}), plugin) cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{}) list, err := cs.ListTools(ctx, nil) if err != nil { t.Fatal(err) } type hints struct{ read, idem, destr, open bool } want := map[string]hints{ "blog_show": {read: true, idem: true}, "blog_replace": {idem: true}, "blog_remove": {destr: true}, "blog_create": {}, "blog_export": {idem: true, open: true}, } if len(list.Tools) != len(want) { t.Fatalf("listed %v", toolNamesOf(list)) } for _, tl := range list.Tools { a := tl.Annotations if a == nil || a.DestructiveHint == nil || a.OpenWorldHint == nil { t.Fatalf("%s annotations = %+v, want explicit destructive and open-world hints", tl.Name, a) } got := hints{read: a.ReadOnlyHint, idem: a.IdempotentHint, destr: *a.DestructiveHint, open: *a.OpenWorldHint} if got != want[tl.Name] { t.Fatalf("%s hints = %+v, want %+v", tl.Name, got, want[tl.Name]) } } var schema map[string]any for _, tl := range list.Tools { if tl.Name == "blog_show" { schema, _ = tl.InputSchema.(map[string]any) } } if props, _ := schema["properties"].(map[string]any); props["id"] == nil { t.Fatalf("blog_show input schema = %+v, want an id property", schema) } }) t.Run("prompts are prefixed", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() plugin := routePlugin{id: "acme.blog", prompts: []pact.MCPPrompt{{Name: "draft", Guard: "acme", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return []pact.MCPPromptMessage{{Role: "user", Text: "Draft."}}, nil }}}} in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}}), plugin) cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{}) prompts, err := cs.ListPrompts(ctx, nil) if err != nil || len(prompts.Prompts) != 1 || prompts.Prompts[0].Name != "blog_draft" { t.Fatalf("prompts/list = %+v, %v", prompts, err) } if _, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "draft"}); err == nil { t.Fatal("the unprefixed prompt name answered") } }) }