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{
47 recipes: []cooked.RecipeCard{
48 {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
49 },
50 }
51 server := NewServer(backend, "test")
52
53 output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Page: 2, Limit: 5})
54 if err != nil {
55 t.Fatalf("CallReadTool() error = %v", err)
56 }
57
58 if backend.page != 2 {
59 t.Fatalf("backend page = %d, want 2", backend.page)
60 }
61 if backend.limit != 5 {
62 t.Fatalf("backend limit = %d, want 5", backend.limit)
63 }
64 if len(output.Recipes) != 1 {
65 t.Fatalf("structured recipes = %d, want 1", len(output.Recipes))
66 }
67 if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
68 t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
69 }
70}
71
72func TestCallToolACIDRecipesPagination4DefaultsAndCapsRecipeLimit(t *testing.T) {
73 backend := &fakeBackend{}
74 server := NewServer(backend, "test")
75
76 _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Limit: 99})
77 if err != nil {
78 t.Fatalf("CallReadTool() error = %v", err)
79 }
80
81 if backend.page != 1 {
82 t.Fatalf("backend page = %d, want default page 1", backend.page)
83 }
84 if backend.limit != 30 {
85 t.Fatalf("backend limit = %d, want capped limit 30", backend.limit)
86 }
87}
88
89func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
90 backend := &fakeBackend{searchRecipes: []cooked.RecipeCard{
91 {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
92 {ID: "recipe-2", Title: "Tomato Pasta", ThumbnailURL: "https://example.invalid/thumb2.jpg"},
93 }}
94 server := NewServer(backend, "test")
95
96 output, err := server.CallReadTool(context.Background(), ReadArguments{
97 Target: "recipes",
98 Query: " pasta ",
99 Page: 2,
100 Limit: 1,
101 })
102 if err != nil {
103 t.Fatalf("CallReadTool() error = %v", err)
104 }
105
106 if backend.searchQuery != "pasta" {
107 t.Fatalf("backend search query = %q, want pasta", backend.searchQuery)
108 }
109 if backend.searchPage != 2 {
110 t.Fatalf("backend search page = %d, want 2", backend.searchPage)
111 }
112 if len(output.Recipes) != 1 {
113 t.Fatalf("structured recipes = %d, want truncated result length 1", len(output.Recipes))
114 }
115 if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
116 t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
117 }
118}
119
120func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
121 backend := &fakeBackend{
122 recipeMetadata: cooked.RecipeMetadata{
123 Title: "Pasta",
124 ImageURLs: []string{"https://example.invalid/pasta.jpg"},
125 Owner: "returned-user",
126 EditPermission: true,
127 },
128 recipeContent: cooked.RecipeContent{
129 Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
130 Portions: 2,
131 },
132 }
133 server := NewServer(backend, "test")
134
135 result, output, err := server.callReadTool(
136 context.Background(),
137 nil,
138 ReadArguments{Target: "recipe", RecipeID: "recipe-1"},
139 )
140 if err != nil {
141 t.Fatalf("callReadTool() error = %v", err)
142 }
143
144 requireSingleRecipeBackendCalls(t, backend)
145 requireSingleRecipeOutput(t, output)
146 requireNoImageLeak(t, result, output)
147}
148
149func requireSingleRecipeBackendCalls(t *testing.T, backend *fakeBackend) {
150 t.Helper()
151
152 if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
153 t.Fatalf(
154 "backend recipe IDs = metadata %q content %q, want recipe-1",
155 backend.metadataRecipeID,
156 backend.contentRecipeID,
157 )
158 }
159 if backend.metadataCalls != 1 || backend.contentCalls != 1 {
160 t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
161 }
162}
163
164func requireSingleRecipeOutput(t *testing.T, output ReadOutput) {
165 t.Helper()
166
167 if output.Recipe == nil {
168 t.Fatal("structured recipe is nil")
169 }
170 if output.Recipe.ID != "recipe-1" || output.Recipe.Title != "Pasta" {
171 t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
172 }
173 if output.Recipe.Owner != "returned-user" || !output.Recipe.EditPermission {
174 t.Fatalf(
175 "structured recipe owner/edit = %q/%v, want returned-user/true",
176 output.Recipe.Owner,
177 output.Recipe.EditPermission,
178 )
179 }
180 if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 {
181 t.Fatalf(
182 "structured recipe content/portions = %q/%d, want markdown/2",
183 output.Recipe.Content,
184 output.Recipe.Portions,
185 )
186 }
187}
188
189func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOutput) {
190 t.Helper()
191
192 encodedOutput, err := json.Marshal(output)
193 if err != nil {
194 t.Fatalf("marshal output: %v", err)
195 }
196 if strings.Contains(string(encodedOutput), "image") ||
197 strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
198 t.Fatalf("structured output leaked image data: %s", encodedOutput)
199 }
200 if len(result.Content) != 1 {
201 t.Fatalf("text content length = %d, want 1", len(result.Content))
202 }
203 textContent, ok := result.Content[0].(*sdk.TextContent)
204 if !ok {
205 t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0])
206 }
207 if strings.Contains(textContent.Text, "https://example.invalid/pasta.jpg") {
208 t.Fatalf("text output leaked image URL: %s", textContent.Text)
209 }
210}
211
212func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) {
213 backend := &fakeBackend{}
214 server := NewServer(backend, "test")
215
216 _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: " "})
217 if err == nil {
218 t.Fatal("CallReadTool() error = nil, want missing recipe_id error")
219 }
220
221 if backend.metadataCalls != 0 || backend.contentCalls != 0 {
222 t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
223 }
224}
225
226func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) {
227 backend := &fakeBackend{preview: cooked.RecipeTextPreview{
228 Title: "Pasta with Tomato Sauce",
229 Markdown: "# Pasta\n\n1. Boil pasta.",
230 Portions: 2,
231 }}
232 server := NewServer(backend, "test")
233
234 _, output, err := server.callPreviewRecipeTextTool(
235 context.Background(),
236 nil,
237 PreviewRecipeTextArguments{Title: " Pasta ", Text: " Boil pasta.\n"},
238 )
239 if err != nil {
240 t.Fatalf("callPreviewRecipeTextTool() error = %v", err)
241 }
242
243 if backend.previewCalls != 1 {
244 t.Fatalf("preview calls = %d, want 1", backend.previewCalls)
245 }
246 if backend.previewTitle != "Pasta" {
247 t.Fatalf("backend preview title = %q, want trimmed Pasta", backend.previewTitle)
248 }
249 if backend.previewText != " Boil pasta.\n" {
250 t.Fatalf("backend preview text = %q, want raw text preserved", backend.previewText)
251 }
252 if output.Title != "Pasta with Tomato Sauce" || output.Markdown != "# Pasta\n\n1. Boil pasta." ||
253 output.Portions != 2 {
254 t.Fatalf("preview output = %#v, want title/markdown/portions", output)
255 }
256}
257
258func TestCallToolACIDToolsPreviewRecipeTextTool1RequiresTitle(t *testing.T) {
259 backend := &fakeBackend{}
260 server := NewServer(backend, "test")
261
262 _, _, err := server.callPreviewRecipeTextTool(
263 context.Background(),
264 nil,
265 PreviewRecipeTextArguments{Title: " ", Text: "Boil pasta."},
266 )
267 if err == nil {
268 t.Fatal("callPreviewRecipeTextTool() error = nil, want missing title error")
269 }
270 if backend.previewCalls != 0 {
271 t.Fatalf("preview calls = %d, want none", backend.previewCalls)
272 }
273}
274
275func TestCallToolACIDToolsPreviewRecipeTextTool2RequiresText(t *testing.T) {
276 backend := &fakeBackend{}
277 server := NewServer(backend, "test")
278
279 _, _, err := server.callPreviewRecipeTextTool(
280 context.Background(),
281 nil,
282 PreviewRecipeTextArguments{Title: "Pasta", Text: " "},
283 )
284 if err == nil {
285 t.Fatal("callPreviewRecipeTextTool() error = nil, want missing text error")
286 }
287 if backend.previewCalls != 0 {
288 t.Fatalf("preview calls = %d, want none", backend.previewCalls)
289 }
290}
291
292type fakeBackend struct {
293 shoppingList cooked.ShoppingList
294 recipes []cooked.RecipeCard
295 searchRecipes []cooked.RecipeCard
296 recipeMetadata cooked.RecipeMetadata
297 recipeContent cooked.RecipeContent
298 preview cooked.RecipeTextPreview
299 page int
300 limit int
301 searchQuery string
302 searchPage int
303 metadataRecipeID string
304 contentRecipeID string
305 previewTitle string
306 previewText string
307 metadataCalls int
308 contentCalls int
309 previewCalls int
310}
311
312func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
313 return f.shoppingList, nil
314}
315
316func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
317 f.page = page
318 f.limit = limit
319
320 return f.recipes, nil
321}
322
323func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
324 f.searchQuery = query
325 f.searchPage = page
326
327 return f.searchRecipes, nil
328}
329
330func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
331 f.metadataRecipeID = recipeID
332 f.metadataCalls++
333
334 return f.recipeMetadata, nil
335}
336
337func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
338 f.contentRecipeID = recipeID
339 f.contentCalls++
340
341 return f.recipeContent, nil
342}
343
344func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
345 f.previewTitle = title
346 f.previewText = text
347 f.previewCalls++
348
349 return f.preview, nil
350}