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{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}