server.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
  4
  5// Package mcp implements the MCP surface for cooked-mcp.
  6package mcp
  7
  8import (
  9	"context"
 10	"fmt"
 11	"strings"
 12
 13	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
 14
 15	"git.secluded.site/cooked-mcp/internal/cooked"
 16)
 17
 18const (
 19	serverName                 = "Cooked"
 20	readToolName               = "read"
 21	previewRecipeTextToolName  = "preview_recipe_text"
 22	saveRecipeToolName         = "save_recipe"
 23	deleteRecipeToolName       = "delete_recipe"
 24	changeShoppingListToolName = "change_shopping_list"
 25)
 26
 27// Backend provides the Cooked operations exposed as MCP tools.
 28type Backend interface {
 29	ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
 30	ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error)
 31	SearchRecipes(ctx context.Context, query string, page int) ([]cooked.RecipeCard, error)
 32	ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error)
 33	ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error)
 34	PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error)
 35	SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error)
 36	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error
 37	DeleteRecipe(ctx context.Context, recipeID string) error
 38	ClearShoppingList(ctx context.Context) error
 39	AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
 40	RemoveShoppingListProductGroups(ctx context.Context, productGroupIDs []string) error
 41	ReplaceShoppingListSelection(ctx context.Context, productGroupIDs []string) error
 42}
 43
 44// Server exposes Cooked as an MCP server.
 45type Server struct {
 46	backend Backend
 47	sdk     *sdk.Server
 48}
 49
 50// NewServer returns an MCP server for a Cooked backend.
 51func NewServer(backend Backend, version string) *Server {
 52	server := &Server{backend: backend}
 53	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
 54	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
 55	sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
 56	sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
 57	sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
 58	sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)
 59
 60	return server
 61}
 62
 63// RunStdio serves MCP over the official SDK stdio transport.
 64func (s *Server) RunStdio(ctx context.Context) error {
 65	return s.sdk.Run(ctx, &sdk.StdioTransport{})
 66}
 67
 68// CallReadTool invokes the read tool without asserting MCP wire presentation.
 69func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
 70	_, output, err := s.callReadTool(ctx, nil, arguments)
 71	if err != nil {
 72		return ReadOutput{}, err
 73	}
 74
 75	return output, nil
 76}
 77
 78func (s *Server) callReadTool(
 79	ctx context.Context,
 80	_ *sdk.CallToolRequest,
 81	arguments ReadArguments,
 82) (*sdk.CallToolResult, ReadOutput, error) {
 83	switch arguments.Target {
 84	case "shopping_list":
 85		return s.callShoppingListReadTool(ctx)
 86	case "recipe":
 87		return s.callRecipeReadTool(ctx, arguments.RecipeID)
 88	case "recipes":
 89		return s.callRecipesReadTool(ctx, arguments)
 90	default:
 91		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
 92	}
 93}
 94
 95func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
 96	shoppingList, err := s.backend.ReadShoppingList(ctx)
 97	if err != nil {
 98		return nil, ReadOutput{}, err
 99	}
100
101	output := ReadOutput{
102		Aisles:              shoppingList.Aisles,
103		ShoppingListRecipes: shoppingList.Recipes,
104	}
105
106	return &sdk.CallToolResult{
107		Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
108	}, output, nil
109}
110
111func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
112	recipeID := strings.TrimSpace(rawRecipeID)
113	if recipeID == "" {
114		return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
115	}
116
117	metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
118	if err != nil {
119		return nil, ReadOutput{}, err
120	}
121	content, err := s.backend.ReadRecipeContent(ctx, recipeID)
122	if err != nil {
123		return nil, ReadOutput{}, err
124	}
125
126	recipe := RecipeDetail{
127		ID:             recipeID,
128		Title:          metadata.Title,
129		Owner:          metadata.Owner,
130		EditPermission: metadata.EditPermission,
131		Content:        content.Content,
132		Portions:       content.Portions,
133	}
134	output := ReadOutput{Recipe: &recipe}
135
136	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
137}
138
139func (s *Server) callRecipesReadTool(
140	ctx context.Context,
141	arguments ReadArguments,
142) (*sdk.CallToolResult, ReadOutput, error) {
143	page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
144	recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
145	if err != nil {
146		return nil, ReadOutput{}, err
147	}
148
149	output := ReadOutput{Recipes: recipeSummaries(recipes)}
150
151	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
152}
153
154func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
155	if query == "" {
156		return s.backend.ListRecipes(ctx, page, limit)
157	}
158
159	recipes, err := s.backend.SearchRecipes(ctx, query, page)
160	if err != nil {
161		return nil, err
162	}
163	if len(recipes) > limit {
164		recipes = recipes[:limit]
165	}
166
167	return recipes, nil
168}
169
170func (s *Server) callPreviewRecipeTextTool(
171	ctx context.Context,
172	_ *sdk.CallToolRequest,
173	arguments PreviewRecipeTextArguments,
174) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
175	title := strings.TrimSpace(arguments.Title)
176	if title == "" {
177		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
178	}
179	if strings.TrimSpace(arguments.Text) == "" {
180		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
181	}
182
183	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
184	if err != nil {
185		return nil, PreviewRecipeTextOutput{}, err
186	}
187
188	output := PreviewRecipeTextOutput{
189		Title:    preview.Title,
190		Markdown: preview.Markdown,
191		Portions: preview.Portions,
192	}
193
194	return &sdk.CallToolResult{
195		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
196	}, output, nil
197}
198
199func (s *Server) callSaveRecipeTool(
200	ctx context.Context,
201	_ *sdk.CallToolRequest,
202	arguments SaveRecipeArguments,
203) (*sdk.CallToolResult, SaveRecipeOutput, error) {
204	source := strings.TrimSpace(arguments.Source)
205	switch source {
206	case "raw_text":
207		return s.callRawTextSaveRecipeTool(ctx, arguments)
208	case "prepared":
209		return s.callPreparedSaveRecipeTool(ctx, arguments)
210	case "existing":
211		return s.callExistingSaveRecipeTool(ctx, arguments)
212	case "":
213		return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
214	default:
215		return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
216	}
217}
218
219func (s *Server) callRawTextSaveRecipeTool(
220	ctx context.Context,
221	arguments SaveRecipeArguments,
222) (*sdk.CallToolResult, SaveRecipeOutput, error) {
223	title := strings.TrimSpace(arguments.Title)
224	if title == "" {
225		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
226	}
227	if strings.TrimSpace(arguments.Text) == "" {
228		return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
229	}
230
231	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
232	if err != nil {
233		return nil, SaveRecipeOutput{}, err
234	}
235	saveTitle := strings.TrimSpace(preview.Title)
236	if saveTitle == "" {
237		saveTitle = title
238	}
239	if strings.TrimSpace(preview.Markdown) == "" {
240		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
241	}
242	if preview.Portions < 1 {
243		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
244	}
245
246	return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
247}
248
249func (s *Server) callPreparedSaveRecipeTool(
250	ctx context.Context,
251	arguments SaveRecipeArguments,
252) (*sdk.CallToolResult, SaveRecipeOutput, error) {
253	title := strings.TrimSpace(arguments.Title)
254	if title == "" {
255		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
256	}
257	if strings.TrimSpace(arguments.Markdown) == "" {
258		return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
259	}
260	if arguments.Portions < 1 {
261		return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
262	}
263
264	return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
265}
266
267func (s *Server) callExistingSaveRecipeTool(
268	ctx context.Context,
269	arguments SaveRecipeArguments,
270) (*sdk.CallToolResult, SaveRecipeOutput, error) {
271	recipeID := strings.TrimSpace(arguments.RecipeID)
272	if recipeID == "" {
273		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
274	}
275	if strings.TrimSpace(arguments.Markdown) == "" {
276		return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing")
277	}
278	if arguments.Portions < 1 {
279		return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing")
280	}
281
282	if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
283		return nil, SaveRecipeOutput{}, err
284	}
285
286	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
287}
288
289func (s *Server) savePreparedRecipe(
290	ctx context.Context,
291	title, markdown string,
292	portions int,
293) (*sdk.CallToolResult, SaveRecipeOutput, error) {
294	recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
295	if err != nil {
296		return nil, SaveRecipeOutput{}, err
297	}
298	if strings.TrimSpace(recipeID) == "" {
299		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
300	}
301
302	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
303}
304
305func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
306	output := SaveRecipeOutput{RecipeID: recipeID}
307
308	return &sdk.CallToolResult{
309		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
310	}
311}
312
313func (s *Server) callDeleteRecipeTool(
314	ctx context.Context,
315	_ *sdk.CallToolRequest,
316	arguments DeleteRecipeArguments,
317) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
318	recipeID := strings.TrimSpace(arguments.RecipeID)
319	if recipeID == "" {
320		return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
321	}
322
323	if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
324		return nil, DeleteRecipeOutput{}, err
325	}
326
327	output := DeleteRecipeOutput{RecipeID: recipeID}
328
329	return &sdk.CallToolResult{
330		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(output)}},
331	}, output, nil
332}
333
334func (s *Server) callChangeShoppingListTool(
335	ctx context.Context,
336	_ *sdk.CallToolRequest,
337	arguments ChangeShoppingListArguments,
338) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
339	action := strings.TrimSpace(arguments.Action)
340	switch action {
341	case "add":
342		return s.addShoppingListIngredients(ctx, arguments)
343	case "clear":
344		return s.clearShoppingList(ctx)
345	case "remove":
346		return s.removeShoppingListProductGroups(ctx, arguments)
347	case "replace_selection":
348		return s.replaceShoppingListSelection(ctx, arguments)
349	case "add_selection":
350		return s.addShoppingListSelection(ctx, arguments)
351	case "remove_selection":
352		return s.removeShoppingListSelection(ctx, arguments)
353	case "":
354		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
355	default:
356		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
357			"unsupported change_shopping_list action %q in this slice",
358			action,
359		)
360	}
361}
362
363func (s *Server) addShoppingListIngredients(
364	ctx context.Context,
365	arguments ChangeShoppingListArguments,
366) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
367	if strings.TrimSpace(arguments.Ingredients) == "" {
368		return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
369	}
370
371	result, err := s.backend.AddShoppingListIngredients(
372		ctx,
373		arguments.Ingredients,
374		strings.TrimSpace(arguments.RecipeID),
375	)
376	if err != nil {
377		return nil, ChangeShoppingListOutput{}, err
378	}
379
380	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
381
382	return &sdk.CallToolResult{
383		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
384			"Added %d shopping-list ingredients.",
385			result.AddedCount,
386		)}},
387	}, output, nil
388}
389
390func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
391	if err := s.backend.ClearShoppingList(ctx); err != nil {
392		return nil, ChangeShoppingListOutput{}, err
393	}
394
395	output := ChangeShoppingListOutput{Cleared: true}
396
397	return &sdk.CallToolResult{
398		Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
399	}, output, nil
400}
401
402func (s *Server) removeShoppingListProductGroups(
403	ctx context.Context,
404	arguments ChangeShoppingListArguments,
405) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
406	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
407	if len(ids) == 0 {
408		return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
409	}
410
411	if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
412		return nil, ChangeShoppingListOutput{}, err
413	}
414
415	output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
416
417	return &sdk.CallToolResult{
418		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
419			"Removed %d shopping-list product groups.",
420			len(ids),
421		)}},
422	}, output, nil
423}
424
425func (s *Server) replaceShoppingListSelection(
426	ctx context.Context,
427	arguments ChangeShoppingListArguments,
428) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
429	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
430	if len(ids) == 0 {
431		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
432			"product_group_ids is required when action is replace_selection",
433		)
434	}
435
436	return s.setShoppingListSelection(ctx, ids)
437}
438
439func (s *Server) addShoppingListSelection(
440	ctx context.Context,
441	arguments ChangeShoppingListArguments,
442) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
443	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
444	if len(ids) == 0 {
445		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
446			"product_group_ids is required when action is add_selection",
447		)
448	}
449
450	shoppingList, err := s.backend.ReadShoppingList(ctx)
451	if err != nil {
452		return nil, ChangeShoppingListOutput{}, err
453	}
454
455	return s.setShoppingListSelection(
456		ctx,
457		appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
458	)
459}
460
461func (s *Server) removeShoppingListSelection(
462	ctx context.Context,
463	arguments ChangeShoppingListArguments,
464) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
465	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
466	if len(ids) == 0 {
467		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
468			"product_group_ids is required when action is remove_selection",
469		)
470	}
471
472	shoppingList, err := s.backend.ReadShoppingList(ctx)
473	if err != nil {
474		return nil, ChangeShoppingListOutput{}, err
475	}
476
477	return s.setShoppingListSelection(
478		ctx,
479		subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
480	)
481}
482
483func (s *Server) setShoppingListSelection(
484	ctx context.Context,
485	ids []string,
486) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
487	if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
488		return nil, ChangeShoppingListOutput{}, err
489	}
490
491	output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
492
493	return &sdk.CallToolResult{
494		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
495			"Selected %d shopping-list product groups.",
496			len(ids),
497		)}},
498	}, output, nil
499}
500
501func readTool() *sdk.Tool {
502	openWorld := true
503	return &sdk.Tool{
504		Name:        readToolName,
505		Title:       "Read Cooked data",
506		Description: "Read Cooked data. Use target shopping_list for the current shopping list, target recipes to list or search saved recipe IDs and titles, or target recipe with recipe_id to read a single recipe. Recipe results omit images and thumbnails.",
507		Annotations: &sdk.ToolAnnotations{
508			Title:         "Read Cooked data",
509			ReadOnlyHint:  true,
510			OpenWorldHint: &openWorld,
511		},
512	}
513}
514
515func previewRecipeTextTool() *sdk.Tool {
516	openWorld := true
517	return &sdk.Tool{
518		Name:        previewRecipeTextToolName,
519		Title:       "Preview recipe text",
520		Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
521		Annotations: &sdk.ToolAnnotations{
522			Title:         "Preview recipe text",
523			ReadOnlyHint:  true,
524			OpenWorldHint: &openWorld,
525		},
526	}
527}
528
529func saveRecipeTool() *sdk.Tool {
530	openWorld := true
531	return &sdk.Tool{
532		Name:        saveRecipeToolName,
533		Title:       "Save recipe",
534		Description: "Save a recipe. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, and source existing with recipe_id, markdown, and portions. Other source values are reserved for later slices.",
535		Annotations: &sdk.ToolAnnotations{
536			Title:         "Save recipe",
537			OpenWorldHint: &openWorld,
538		},
539	}
540}
541
542func deleteRecipeTool() *sdk.Tool {
543	destructive := true
544	openWorld := true
545	return &sdk.Tool{
546		Name:        deleteRecipeToolName,
547		Title:       "Delete recipe",
548		Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
549		Annotations: &sdk.ToolAnnotations{
550			Title:           "Delete recipe",
551			DestructiveHint: &destructive,
552			OpenWorldHint:   &openWorld,
553		},
554	}
555}
556
557func changeShoppingListTool() *sdk.Tool {
558	destructive := true
559	openWorld := true
560	return &sdk.Tool{
561		Name:        changeShoppingListToolName,
562		Title:       "Change shopping list",
563		Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove, replace_selection, add_selection, or remove_selection with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
564		Annotations: &sdk.ToolAnnotations{
565			Title:           "Change shopping list",
566			DestructiveHint: &destructive,
567			OpenWorldHint:   &openWorld,
568		},
569	}
570}
571
572func normalizeRecipePage(page, limit int) (int, int) {
573	if page < 1 {
574		page = 1
575	}
576	if limit < 1 {
577		limit = 10
578	}
579	if limit > 30 {
580		limit = 30
581	}
582
583	return page, limit
584}
585
586func normalizeProductGroupIDs(ids []string) []string {
587	normalized := make([]string, 0, len(ids))
588	for _, id := range ids {
589		id = strings.TrimSpace(id)
590		if id != "" {
591			normalized = append(normalized, id)
592		}
593	}
594
595	return normalized
596}
597
598func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
599	var ids []string
600	for _, aisle := range shoppingList.Aisles {
601		for _, product := range aisle.ProductGroups {
602			if product.Selected {
603				ids = append(ids, product.ID)
604			}
605		}
606	}
607
608	return ids
609}
610
611func appendMissingProductGroupIDs(existing, additions []string) []string {
612	seen := make(map[string]struct{}, len(existing)+len(additions))
613	result := make([]string, 0, len(existing)+len(additions))
614
615	for _, id := range existing {
616		if _, ok := seen[id]; ok {
617			continue
618		}
619		seen[id] = struct{}{}
620		result = append(result, id)
621	}
622
623	for _, id := range additions {
624		if _, ok := seen[id]; ok {
625			continue
626		}
627		seen[id] = struct{}{}
628		result = append(result, id)
629	}
630
631	return result
632}
633
634func subtractProductGroupIDs(existing, removals []string) []string {
635	removed := make(map[string]struct{}, len(removals))
636	for _, id := range removals {
637		removed[id] = struct{}{}
638	}
639
640	seen := make(map[string]struct{}, len(existing))
641	result := make([]string, 0, len(existing))
642	for _, id := range existing {
643		if _, ok := removed[id]; ok {
644			continue
645		}
646		if _, ok := seen[id]; ok {
647			continue
648		}
649		seen[id] = struct{}{}
650		result = append(result, id)
651	}
652
653	return result
654}
655
656func formatShoppingList(shoppingList cooked.ShoppingList) string {
657	if len(shoppingList.Aisles) == 0 {
658		return "Shopping list is empty."
659	}
660
661	var builder strings.Builder
662	builder.WriteString("Shopping list:\n")
663	for _, aisle := range shoppingList.Aisles {
664		builder.WriteString("- ")
665		builder.WriteString(aisle.Name)
666		builder.WriteString(":")
667		if len(aisle.ProductGroups) == 0 {
668			builder.WriteString(" no items\n")
669			continue
670		}
671		builder.WriteByte('\n')
672
673		for _, product := range aisle.ProductGroups {
674			builder.WriteString("  - ")
675			builder.WriteString(product.Name)
676			if product.Quantity != "" {
677				builder.WriteString(" — ")
678				builder.WriteString(product.Quantity)
679			}
680			builder.WriteString(" (id: ")
681			builder.WriteString(product.ID)
682			builder.WriteString(")")
683			if product.Selected {
684				builder.WriteString(" selected")
685			}
686			builder.WriteByte('\n')
687		}
688	}
689
690	return strings.TrimRight(builder.String(), "\n")
691}
692
693func formatRecipes(recipes []cooked.RecipeCard) string {
694	if len(recipes) == 0 {
695		return "No saved recipes found."
696	}
697
698	var builder strings.Builder
699	builder.WriteString("Saved recipes:\n")
700	for _, recipe := range recipes {
701		builder.WriteString("- ")
702		builder.WriteString(recipe.Title)
703		builder.WriteString(" (id: ")
704		builder.WriteString(recipe.ID)
705		builder.WriteString(")\n")
706	}
707
708	return strings.TrimRight(builder.String(), "\n")
709}
710
711func formatRecipe(recipe RecipeDetail) string {
712	title := recipe.Title
713	if title == "" {
714		title = "(untitled recipe)"
715	}
716
717	var builder strings.Builder
718	builder.WriteString("Recipe: ")
719	builder.WriteString(title)
720	builder.WriteString(" (id: ")
721	builder.WriteString(recipe.ID)
722	builder.WriteString(")\n")
723	if recipe.Owner != "" {
724		builder.WriteString("Owner: ")
725		builder.WriteString(recipe.Owner)
726		builder.WriteByte('\n')
727	}
728	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
729	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
730
731	content := strings.TrimSpace(recipe.Content)
732	if content == "" {
733		builder.WriteString("\nNo recipe content returned.")
734	} else {
735		builder.WriteString("\n")
736		builder.WriteString(content)
737	}
738
739	return strings.TrimRight(builder.String(), "\n")
740}
741
742func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
743	title := preview.Title
744	if title == "" {
745		title = "(untitled preview)"
746	}
747
748	var builder strings.Builder
749	builder.WriteString("Recipe text preview: ")
750	builder.WriteString(title)
751	builder.WriteByte('\n')
752	fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
753
754	markdown := strings.TrimSpace(preview.Markdown)
755	if markdown == "" {
756		builder.WriteString("\nNo preview markdown returned.")
757	} else {
758		builder.WriteString("\n")
759		builder.WriteString(markdown)
760	}
761
762	return strings.TrimRight(builder.String(), "\n")
763}
764
765func formatRecipeSave(output SaveRecipeOutput) string {
766	return "Saved recipe (id: " + output.RecipeID + ")."
767}
768
769func formatRecipeDelete(output DeleteRecipeOutput) string {
770	return "Deleted recipe (id: " + output.RecipeID + ")."
771}
772
773func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
774	summaries := make([]RecipeSummary, 0, len(recipes))
775	for _, recipe := range recipes {
776		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
777	}
778
779	return summaries
780}
781
782// ReadArguments contains read tool arguments.
783type ReadArguments struct {
784	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
785	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
786	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
787	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
788	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
789}
790
791// ReadOutput is the structured output for the current read tool slice.
792type ReadOutput struct {
793	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
794	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
795	Recipes             []RecipeSummary `json:"recipes,omitempty"`
796	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
797}
798
799// RecipeSummary is the MCP-safe saved recipe summary.
800type RecipeSummary struct {
801	ID    string `json:"id"`
802	Title string `json:"title"`
803}
804
805// RecipeDetail is the MCP-safe single recipe output.
806type RecipeDetail struct {
807	ID             string `json:"id"`
808	Title          string `json:"title"`
809	Owner          string `json:"owner"`
810	EditPermission bool   `json:"edit_permission"`
811	Content        string `json:"content"`
812	Portions       int    `json:"portions"`
813}
814
815// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
816type PreviewRecipeTextArguments struct {
817	Title string `json:"title" jsonschema:"Recipe title for the preview."`
818	Text  string `json:"text"  jsonschema:"Raw recipe text to preview without saving."`
819}
820
821// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
822type PreviewRecipeTextOutput struct {
823	Title    string `json:"title"`
824	Markdown string `json:"markdown"`
825	Portions int    `json:"portions"`
826}
827
828// SaveRecipeArguments contains save_recipe tool arguments.
829type SaveRecipeArguments struct {
830	Source   string `json:"source"              jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
831	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
832	Title    string `json:"title,omitempty"     jsonschema:"Recipe title for raw_text or prepared saves."`
833	Text     string `json:"text,omitempty"      jsonschema:"Raw recipe text for raw_text saves."`
834	Markdown string `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
835	Portions int    `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
836}
837
838// SaveRecipeOutput is the structured output for save_recipe.
839type SaveRecipeOutput struct {
840	RecipeID string `json:"recipe_id,omitempty"`
841}
842
843// DeleteRecipeArguments contains delete_recipe tool arguments.
844type DeleteRecipeArguments struct {
845	RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
846}
847
848// DeleteRecipeOutput is the structured output for delete_recipe.
849type DeleteRecipeOutput struct {
850	RecipeID string `json:"recipe_id"`
851}
852
853// ChangeShoppingListArguments contains change_shopping_list tool arguments.
854type ChangeShoppingListArguments struct {
855	Action          string   `json:"action"                      jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, remove_selection, and clear."`
856	Ingredients     string   `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add."`
857	RecipeID        string   `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
858	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
859}
860
861// ChangeShoppingListOutput is the structured output for change_shopping_list.
862type ChangeShoppingListOutput struct {
863	Cleared                 bool     `json:"cleared,omitempty"`
864	AddedCount              int      `json:"added_count,omitempty"`
865	Ingredients             []string `json:"ingredients,omitempty"`
866	RemovedProductGroupIDs  []string `json:"removed_product_group_ids,omitempty"`
867	SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
868}