1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
4
5package mcp
6
7import (
8 "context"
9 "encoding/json"
10 "strings"
11 "testing"
12
13 sdk "github.com/modelcontextprotocol/go-sdk/mcp"
14
15 "git.secluded.site/cooked-mcp/internal/cooked"
16)
17
18func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) {
19 backend := &fakeBackend{shoppingList: cooked.ShoppingList{
20 Aisles: []cooked.Aisle{{
21 ID: "pantry",
22 Name: "Pantry",
23 ProductGroups: []cooked.ProductGroup{{
24 ID: "pasta",
25 Name: "Pasta",
26 Quantity: "200g",
27 }},
28 }},
29 }}
30 server := NewServer(backend, "test")
31
32 output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "shopping_list"})
33 if err != nil {
34 t.Fatalf("CallReadTool() error = %v", err)
35 }
36
37 if len(output.Aisles) != 1 {
38 t.Fatalf("structured aisles = %d, want 1", len(output.Aisles))
39 }
40 if output.Aisles[0].ProductGroups[0].Name != "Pasta" {
41 t.Fatalf("first item = %q, want Pasta", output.Aisles[0].ProductGroups[0].Name)
42 }
43}
44
45func TestCallToolACIDRecipesRead1ListsRecipes(t *testing.T) {
46 backend := &fakeBackend{recipes: []cooked.RecipeCard{{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"}}}
47 server := NewServer(backend, "test")
48
49 output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Page: 2, Limit: 5})
50 if err != nil {
51 t.Fatalf("CallReadTool() error = %v", err)
52 }
53
54 if backend.page != 2 {
55 t.Fatalf("backend page = %d, want 2", backend.page)
56 }
57 if backend.limit != 5 {
58 t.Fatalf("backend limit = %d, want 5", backend.limit)
59 }
60 if len(output.Recipes) != 1 {
61 t.Fatalf("structured recipes = %d, want 1", len(output.Recipes))
62 }
63 if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
64 t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
65 }
66}
67
68func TestCallToolACIDRecipesPagination4DefaultsAndCapsRecipeLimit(t *testing.T) {
69 backend := &fakeBackend{}
70 server := NewServer(backend, "test")
71
72 _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Limit: 99})
73 if err != nil {
74 t.Fatalf("CallReadTool() error = %v", err)
75 }
76
77 if backend.page != 1 {
78 t.Fatalf("backend page = %d, want default page 1", backend.page)
79 }
80 if backend.limit != 30 {
81 t.Fatalf("backend limit = %d, want capped limit 30", backend.limit)
82 }
83}
84
85func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
86 backend := &fakeBackend{searchRecipes: []cooked.RecipeCard{
87 {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
88 {ID: "recipe-2", Title: "Tomato Pasta", ThumbnailURL: "https://example.invalid/thumb2.jpg"},
89 }}
90 server := NewServer(backend, "test")
91
92 output, err := server.CallReadTool(context.Background(), ReadArguments{
93 Target: "recipes",
94 Query: " pasta ",
95 Page: 2,
96 Limit: 1,
97 })
98 if err != nil {
99 t.Fatalf("CallReadTool() error = %v", err)
100 }
101
102 if backend.searchQuery != "pasta" {
103 t.Fatalf("backend search query = %q, want pasta", backend.searchQuery)
104 }
105 if backend.searchPage != 2 {
106 t.Fatalf("backend search page = %d, want 2", backend.searchPage)
107 }
108 if len(output.Recipes) != 1 {
109 t.Fatalf("structured recipes = %d, want truncated result length 1", len(output.Recipes))
110 }
111 if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
112 t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
113 }
114}
115
116func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
117 backend := &fakeBackend{
118 recipeMetadata: cooked.RecipeMetadata{
119 Title: "Pasta",
120 ImageURLs: []string{"https://example.invalid/pasta.jpg"},
121 Owner: "returned-user",
122 EditPermission: true,
123 },
124 recipeContent: cooked.RecipeContent{
125 Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
126 Portions: 2,
127 },
128 }
129 server := NewServer(backend, "test")
130
131 result, output, err := server.callReadTool(context.Background(), nil, ReadArguments{Target: "recipe", RecipeID: "recipe-1"})
132 if err != nil {
133 t.Fatalf("callReadTool() error = %v", err)
134 }
135
136 if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
137 t.Fatalf("backend recipe IDs = metadata %q content %q, want recipe-1", backend.metadataRecipeID, backend.contentRecipeID)
138 }
139 if backend.metadataCalls != 1 || backend.contentCalls != 1 {
140 t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
141 }
142 if output.Recipe == nil {
143 t.Fatal("structured recipe is nil")
144 }
145 if output.Recipe.ID != "recipe-1" || output.Recipe.Title != "Pasta" {
146 t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
147 }
148 if output.Recipe.Owner != "returned-user" || !output.Recipe.EditPermission {
149 t.Fatalf("structured recipe owner/edit = %q/%v, want returned-user/true", output.Recipe.Owner, output.Recipe.EditPermission)
150 }
151 if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 {
152 t.Fatalf("structured recipe content/portions = %q/%d, want markdown/2", output.Recipe.Content, output.Recipe.Portions)
153 }
154
155 encodedOutput, err := json.Marshal(output)
156 if err != nil {
157 t.Fatalf("marshal output: %v", err)
158 }
159 if strings.Contains(string(encodedOutput), "image") || strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
160 t.Fatalf("structured output leaked image data: %s", encodedOutput)
161 }
162 if len(result.Content) != 1 {
163 t.Fatalf("text content length = %d, want 1", len(result.Content))
164 }
165 textContent, ok := result.Content[0].(*sdk.TextContent)
166 if !ok {
167 t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0])
168 }
169 if strings.Contains(textContent.Text, "https://example.invalid/pasta.jpg") {
170 t.Fatalf("text output leaked image URL: %s", textContent.Text)
171 }
172}
173
174func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) {
175 backend := &fakeBackend{}
176 server := NewServer(backend, "test")
177
178 _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: " "})
179 if err == nil {
180 t.Fatal("CallReadTool() error = nil, want missing recipe_id error")
181 }
182
183 if backend.metadataCalls != 0 || backend.contentCalls != 0 {
184 t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
185 }
186}
187
188type fakeBackend struct {
189 shoppingList cooked.ShoppingList
190 recipes []cooked.RecipeCard
191 searchRecipes []cooked.RecipeCard
192 recipeMetadata cooked.RecipeMetadata
193 recipeContent cooked.RecipeContent
194 page int
195 limit int
196 searchQuery string
197 searchPage int
198 metadataRecipeID string
199 contentRecipeID string
200 metadataCalls int
201 contentCalls int
202}
203
204func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
205 return f.shoppingList, nil
206}
207
208func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
209 f.page = page
210 f.limit = limit
211
212 return f.recipes, nil
213}
214
215func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
216 f.searchQuery = query
217 f.searchPage = page
218
219 return f.searchRecipes, nil
220}
221
222func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
223 f.metadataRecipeID = recipeID
224 f.metadataCalls++
225
226 return f.recipeMetadata, nil
227}
228
229func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
230 f.contentRecipeID = recipeID
231 f.contentCalls++
232
233 return f.recipeContent, nil
234}