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