package pier_test import ( "context" "encoding/json" "net/http" "slices" "strings" "sync/atomic" "testing" "time" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "github.com/modelcontextprotocol/go-sdk/mcp" ) // listFixture is the two-guard application of TestToolsList: the frontend // guard "acme" (user 8 lacks acme.echo) with a write and a read tool, the // "backend" guard with one read tool, the verifier's grants for users 7, 8, // 9 (read only), 10 (backend, linked to admin 42) and 11 (backend, no // link), and the hit counters of the write route and the backend route. type listFixture struct { in *instance echoHits *atomic.Int64 officeHits *atomic.Int64 } func newListFixture(t *testing.T) listFixture { t.Helper() echoHits, officeHits := &atomic.Int64{}, &atomic.Int64{} front := &fakeGuard{allow: func(s pact.MCPSubject, _ pact.MCPRequirements, perms []string) bool { return !(slices.Contains(perms, "acme.echo") && s.UserID == 8) }} back := &fakeGuard{name: "backend", tokenMW: "backend"} links := newFakeLinks() links.set(10, 42) verifier := newFakeVerifier(map[string]pact.MCPGrant{ "oas_at_admin": grant(10, "c4", 4, "read", "write", "backend"), "oas_at_nolink": grant(11, "c5", 5, "read", "write", "backend"), }) principalOf := func(w http.ResponseWriter, r *http.Request) { user, _ := bouncer.User(r.Context()) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"principal": user.ID, "backend": user.Backend}) } acme := routePlugin{ id: "test.acme", routes: []testRoute{ {method: http.MethodPost, path: "/acme/echo/{id}", mw: []string{"acme.auth"}, h: func(w http.ResponseWriter, r *http.Request) { echoHits.Add(1) principalOf(w, r) }}, {method: http.MethodGet, path: "/acme/items/{id}", mw: []string{"acme.auth"}, h: principalOf}, }, tools: []pact.MCPTool{ {Name: "echo", Description: "Echo.", Method: "POST", Path: "/acme/echo/{id}", Input: echoInput{}, Permissions: []string{"acme.echo"}}, {Name: "show", Description: "Show.", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}}, }, } office := routePlugin{ id: "test.office", guard: "backend", middleware: "backend", routes: []testRoute{{method: http.MethodGet, path: "/office/posts", mw: []string{"backend"}, h: func(w http.ResponseWriter, r *http.Request) { officeHits.Add(1) principalOf(w, r) }}}, tools: []pact.MCPTool{{Name: "posts", Description: "Lists posts.", Method: "GET", Path: "/office/posts", Input: struct{}{}}}, } app := newApp(t, appSpec{guards: []pact.MCPGuard{front, back}, verifier: verifier, links: links}) return listFixture{in: startInstance(t, app, []party.Plugin{acme, office}...), echoHits: echoHits, officeHits: officeHits} } // TestToolsList (SC-3, D-20, D-23): tools/list is computed per principal; // a read-only grant sees only read-only tools; a tool whose guard has no // subject for the caller is absent; calling a tool outside the caller's // list is a JSON-RPC error that never reaches the route; an identity with // both a frontend and a backend subject sees the union. func TestToolsList(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() f := newListFixture(t) t.Run("per principal", func(t *testing.T) { seven := listNames(t, ctx, sdkClient(t, ctx, f.in.url(), "oas_at_test", clientOpts{})) eight := listNames(t, ctx, sdkClient(t, ctx, f.in.url(), "oas_at_other", clientOpts{})) if !slices.Equal(seven, []string{"acme_echo", "acme_show"}) || !slices.Equal(eight, []string{"acme_show"}) { t.Fatalf("user 7 lists %v, user 8 lists %v", seven, eight) } }) t.Run("read grant lists read-only tools", func(t *testing.T) { cs := sdkClient(t, ctx, f.in.url(), "oas_at_read", clientOpts{}) list, err := cs.ListTools(ctx, nil) if err != nil { t.Fatal(err) } if got := toolNamesOf(list); !slices.Equal(got, []string{"acme_show"}) { t.Fatalf("read grant lists %v", got) } for _, tl := range list.Tools { if tl.Annotations == nil || !tl.Annotations.ReadOnlyHint { t.Fatalf("read grant lists %s without readOnlyHint", tl.Name) } } }) t.Run("no subject under the guard", func(t *testing.T) { for _, token := range []string{"oas_at_test", "oas_at_nolink"} { if got := listNames(t, ctx, sdkClient(t, ctx, f.in.url(), token, clientOpts{})); slices.Contains(got, "office_posts") { t.Fatalf("%s lists the backend tool: %v", token, got) } } }) t.Run("unlisted call never reaches the route", func(t *testing.T) { before, office := f.echoHits.Load(), f.officeHits.Load() for token, tool := range map[string]string{"oas_at_other": "acme_echo", "oas_at_read": "acme_echo", "oas_at_test": "office_posts"} { cs := sdkClient(t, ctx, f.in.url(), token, clientOpts{}) res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: map[string]any{"id": 1, "text": "x"}}) if err == nil || !strings.Contains(err.Error(), "not available to this caller") { t.Fatalf("%s calling %s: %+v, %v", token, tool, res, err) } } if f.echoHits.Load() != before || f.officeHits.Load() != office { t.Fatalf("a refused call reached a route: echo %d -> %d, office %d -> %d", before, f.echoHits.Load(), office, f.officeHits.Load()) } }) t.Run("union of both guards", func(t *testing.T) { cs := sdkClient(t, ctx, f.in.url(), "oas_at_admin", clientOpts{}) if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_echo", "acme_show", "office_posts"}) { t.Fatalf("frontend and backend subject lists %v", got) } front, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_show", Arguments: map[string]any{"id": 3}}) if err != nil || front.IsError { t.Fatalf("acme_show = %+v, %v", front, err) } if got := structured(t, front); got["principal"] != 10.0 || got["backend"] != false { t.Fatalf("acme_show ran as %+v, want frontend user 10", got) } back, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "office_posts"}) if err != nil || back.IsError { t.Fatalf("office_posts = %+v, %v", back, err) } if got := structured(t, back); got["principal"] != 42.0 || got["backend"] != true { t.Fatalf("office_posts ran as %+v, want linked admin 42", got) } }) }