package pier_test import ( "context" "errors" "slices" "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/pact" "github.com/modelcontextprotocol/go-sdk/mcp" ) // promptNames lists the prompt names a session sees, sorted. func promptNames(t *testing.T, ctx context.Context, cs *mcp.ClientSession) []string { t.Helper() list, err := cs.ListPrompts(ctx, nil) if err != nil { t.Fatalf("prompts/list: %v", err) } var out []string for _, p := range list.Prompts { out = append(out, p.Name) } slices.Sort(out) return out } // TestPromptWriteCeiling pins plan 06's prompt ceiling (C-IN-01, P-28): a // prompt declaring Writes is listed for a read and write grant and hidden // from a read-only grant (and cannot be fetched by it), while a prompt // without Writes is listed for both. func TestPromptWriteCeiling(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() draft := summarizePrompt() draft.Name = "draft" draft.Writes = true in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}}), acmePlugin{tools: []pact.MCPTool{echoTool()}, prompts: []pact.MCPPrompt{summarizePrompt(), draft}}) t.Cleanup(in.pier().StopReaper) for _, c := range []struct { name string token string want []string }{ {"read and write grant", "oas_at_test", []string{"acme_draft", "acme_summarize"}}, {"read-only grant", "oas_at_read", []string{"acme_summarize"}}, } { t.Run(c.name, func(t *testing.T) { for _, version := range []string{"", "newest"} { cs := sdkClient(t, ctx, in.url(), c.token, clientOpts{version: version}) if got := promptNames(t, ctx, cs); !slices.Equal(got, c.want) { t.Fatalf("version %q: prompts = %v, want %v", version, got, c.want) } _, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "acme_draft", Arguments: map[string]string{"topic": "x"}}) if allowed := slices.Contains(c.want, "acme_draft"); (err == nil) != allowed { t.Fatalf("version %q: get acme_draft err = %v, allowed %v", version, err, allowed) } } }) } } // TestPromptRenderError pins RA-WR-05: a prompt whose Render fails answers a // fixed JSON-RPC error on both paths; the cause (here a database error // naming its host and user) is logged server-side and never reaches the // client. func TestPromptRenderError(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() const cause = `failed to connect to host=db.internal user=summer database=app: dial tcp 10.0.0.5:5432` broken := summarizePrompt() broken.Name = "broken" broken.Render = func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return nil, errors.New(cause) } in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&acmeGuard{}}}), acmePlugin{tools: []pact.MCPTool{echoTool()}, prompts: []pact.MCPPrompt{broken}}) t.Cleanup(in.pier().StopReaper) logs := captureLogs(t) for _, version := range []string{"", "newest"} { cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{version: version}) _, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "acme_broken", Arguments: map[string]string{"topic": "x"}}) if err == nil { t.Fatalf("version %q: a failing Render answered no error", version) } if strings.Contains(err.Error(), "db.internal") || strings.Contains(err.Error(), "10.0.0.5") { t.Fatalf("version %q: the Render cause reached the client: %v", version, err) } if !strings.Contains(err.Error(), "pier: the prompt could not be rendered") { t.Fatalf("version %q: error = %v, want the fixed text", version, err) } } if !strings.Contains(logs.String(), "db.internal") { t.Fatalf("the Render cause was not logged: %s", logs.String()) } }