server_test.go

  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}