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
226type fakeBackend struct {
227	shoppingList     cooked.ShoppingList
228	recipes          []cooked.RecipeCard
229	searchRecipes    []cooked.RecipeCard
230	recipeMetadata   cooked.RecipeMetadata
231	recipeContent    cooked.RecipeContent
232	page             int
233	limit            int
234	searchQuery      string
235	searchPage       int
236	metadataRecipeID string
237	contentRecipeID  string
238	metadataCalls    int
239	contentCalls     int
240}
241
242func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
243	return f.shoppingList, nil
244}
245
246func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
247	f.page = page
248	f.limit = limit
249
250	return f.recipes, nil
251}
252
253func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
254	f.searchQuery = query
255	f.searchPage = page
256
257	return f.searchRecipes, nil
258}
259
260func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
261	f.metadataRecipeID = recipeID
262	f.metadataCalls++
263
264	return f.recipeMetadata, nil
265}
266
267func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
268	f.contentRecipeID = recipeID
269	f.contentCalls++
270
271	return f.recipeContent, nil
272}