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 "":
352		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
353	default:
354		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
355			"unsupported change_shopping_list action %q in this slice",
356			action,
357		)
358	}
359}
360
361func (s *Server) addShoppingListIngredients(
362	ctx context.Context,
363	arguments ChangeShoppingListArguments,
364) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
365	if strings.TrimSpace(arguments.Ingredients) == "" {
366		return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
367	}
368
369	result, err := s.backend.AddShoppingListIngredients(
370		ctx,
371		arguments.Ingredients,
372		strings.TrimSpace(arguments.RecipeID),
373	)
374	if err != nil {
375		return nil, ChangeShoppingListOutput{}, err
376	}
377
378	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
379
380	return &sdk.CallToolResult{
381		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
382			"Added %d shopping-list ingredients.",
383			result.AddedCount,
384		)}},
385	}, output, nil
386}
387
388func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
389	if err := s.backend.ClearShoppingList(ctx); err != nil {
390		return nil, ChangeShoppingListOutput{}, err
391	}
392
393	output := ChangeShoppingListOutput{Cleared: true}
394
395	return &sdk.CallToolResult{
396		Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
397	}, output, nil
398}
399
400func (s *Server) removeShoppingListProductGroups(
401	ctx context.Context,
402	arguments ChangeShoppingListArguments,
403) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
404	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
405	if len(ids) == 0 {
406		return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
407	}
408
409	if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
410		return nil, ChangeShoppingListOutput{}, err
411	}
412
413	output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
414
415	return &sdk.CallToolResult{
416		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
417			"Removed %d shopping-list product groups.",
418			len(ids),
419		)}},
420	}, output, nil
421}
422
423func (s *Server) replaceShoppingListSelection(
424	ctx context.Context,
425	arguments ChangeShoppingListArguments,
426) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
427	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
428	if len(ids) == 0 {
429		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
430			"product_group_ids is required when action is replace_selection",
431		)
432	}
433
434	return s.setShoppingListSelection(ctx, ids)
435}
436
437func (s *Server) addShoppingListSelection(
438	ctx context.Context,
439	arguments ChangeShoppingListArguments,
440) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
441	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
442	if len(ids) == 0 {
443		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
444			"product_group_ids is required when action is add_selection",
445		)
446	}
447
448	shoppingList, err := s.backend.ReadShoppingList(ctx)
449	if err != nil {
450		return nil, ChangeShoppingListOutput{}, err
451	}
452
453	return s.setShoppingListSelection(
454		ctx,
455		appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
456	)
457}
458
459func (s *Server) setShoppingListSelection(
460	ctx context.Context,
461	ids []string,
462) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
463	if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
464		return nil, ChangeShoppingListOutput{}, err
465	}
466
467	output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
468
469	return &sdk.CallToolResult{
470		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
471			"Selected %d shopping-list product groups.",
472			len(ids),
473		)}},
474	}, output, nil
475}
476
477func readTool() *sdk.Tool {
478	openWorld := true
479	return &sdk.Tool{
480		Name:        readToolName,
481		Title:       "Read Cooked data",
482		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.",
483		Annotations: &sdk.ToolAnnotations{
484			Title:         "Read Cooked data",
485			ReadOnlyHint:  true,
486			OpenWorldHint: &openWorld,
487		},
488	}
489}
490
491func previewRecipeTextTool() *sdk.Tool {
492	openWorld := true
493	return &sdk.Tool{
494		Name:        previewRecipeTextToolName,
495		Title:       "Preview recipe text",
496		Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
497		Annotations: &sdk.ToolAnnotations{
498			Title:         "Preview recipe text",
499			ReadOnlyHint:  true,
500			OpenWorldHint: &openWorld,
501		},
502	}
503}
504
505func saveRecipeTool() *sdk.Tool {
506	openWorld := true
507	return &sdk.Tool{
508		Name:        saveRecipeToolName,
509		Title:       "Save recipe",
510		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.",
511		Annotations: &sdk.ToolAnnotations{
512			Title:         "Save recipe",
513			OpenWorldHint: &openWorld,
514		},
515	}
516}
517
518func deleteRecipeTool() *sdk.Tool {
519	destructive := true
520	openWorld := true
521	return &sdk.Tool{
522		Name:        deleteRecipeToolName,
523		Title:       "Delete recipe",
524		Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
525		Annotations: &sdk.ToolAnnotations{
526			Title:           "Delete recipe",
527			DestructiveHint: &destructive,
528			OpenWorldHint:   &openWorld,
529		},
530	}
531}
532
533func changeShoppingListTool() *sdk.Tool {
534	destructive := true
535	openWorld := true
536	return &sdk.Tool{
537		Name:        changeShoppingListToolName,
538		Title:       "Change shopping list",
539		Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove, replace_selection, or add_selection with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
540		Annotations: &sdk.ToolAnnotations{
541			Title:           "Change shopping list",
542			DestructiveHint: &destructive,
543			OpenWorldHint:   &openWorld,
544		},
545	}
546}
547
548func normalizeRecipePage(page, limit int) (int, int) {
549	if page < 1 {
550		page = 1
551	}
552	if limit < 1 {
553		limit = 10
554	}
555	if limit > 30 {
556		limit = 30
557	}
558
559	return page, limit
560}
561
562func normalizeProductGroupIDs(ids []string) []string {
563	normalized := make([]string, 0, len(ids))
564	for _, id := range ids {
565		id = strings.TrimSpace(id)
566		if id != "" {
567			normalized = append(normalized, id)
568		}
569	}
570
571	return normalized
572}
573
574func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
575	var ids []string
576	for _, aisle := range shoppingList.Aisles {
577		for _, product := range aisle.ProductGroups {
578			if product.Selected {
579				ids = append(ids, product.ID)
580			}
581		}
582	}
583
584	return ids
585}
586
587func appendMissingProductGroupIDs(existing, additions []string) []string {
588	seen := make(map[string]struct{}, len(existing)+len(additions))
589	result := make([]string, 0, len(existing)+len(additions))
590
591	for _, id := range existing {
592		if _, ok := seen[id]; ok {
593			continue
594		}
595		seen[id] = struct{}{}
596		result = append(result, id)
597	}
598
599	for _, id := range additions {
600		if _, ok := seen[id]; ok {
601			continue
602		}
603		seen[id] = struct{}{}
604		result = append(result, id)
605	}
606
607	return result
608}
609
610func formatShoppingList(shoppingList cooked.ShoppingList) string {
611	if len(shoppingList.Aisles) == 0 {
612		return "Shopping list is empty."
613	}
614
615	var builder strings.Builder
616	builder.WriteString("Shopping list:\n")
617	for _, aisle := range shoppingList.Aisles {
618		builder.WriteString("- ")
619		builder.WriteString(aisle.Name)
620		builder.WriteString(":")
621		if len(aisle.ProductGroups) == 0 {
622			builder.WriteString(" no items\n")
623			continue
624		}
625		builder.WriteByte('\n')
626
627		for _, product := range aisle.ProductGroups {
628			builder.WriteString("  - ")
629			builder.WriteString(product.Name)
630			if product.Quantity != "" {
631				builder.WriteString(" — ")
632				builder.WriteString(product.Quantity)
633			}
634			builder.WriteString(" (id: ")
635			builder.WriteString(product.ID)
636			builder.WriteString(")")
637			if product.Selected {
638				builder.WriteString(" selected")
639			}
640			builder.WriteByte('\n')
641		}
642	}
643
644	return strings.TrimRight(builder.String(), "\n")
645}
646
647func formatRecipes(recipes []cooked.RecipeCard) string {
648	if len(recipes) == 0 {
649		return "No saved recipes found."
650	}
651
652	var builder strings.Builder
653	builder.WriteString("Saved recipes:\n")
654	for _, recipe := range recipes {
655		builder.WriteString("- ")
656		builder.WriteString(recipe.Title)
657		builder.WriteString(" (id: ")
658		builder.WriteString(recipe.ID)
659		builder.WriteString(")\n")
660	}
661
662	return strings.TrimRight(builder.String(), "\n")
663}
664
665func formatRecipe(recipe RecipeDetail) string {
666	title := recipe.Title
667	if title == "" {
668		title = "(untitled recipe)"
669	}
670
671	var builder strings.Builder
672	builder.WriteString("Recipe: ")
673	builder.WriteString(title)
674	builder.WriteString(" (id: ")
675	builder.WriteString(recipe.ID)
676	builder.WriteString(")\n")
677	if recipe.Owner != "" {
678		builder.WriteString("Owner: ")
679		builder.WriteString(recipe.Owner)
680		builder.WriteByte('\n')
681	}
682	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
683	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
684
685	content := strings.TrimSpace(recipe.Content)
686	if content == "" {
687		builder.WriteString("\nNo recipe content returned.")
688	} else {
689		builder.WriteString("\n")
690		builder.WriteString(content)
691	}
692
693	return strings.TrimRight(builder.String(), "\n")
694}
695
696func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
697	title := preview.Title
698	if title == "" {
699		title = "(untitled preview)"
700	}
701
702	var builder strings.Builder
703	builder.WriteString("Recipe text preview: ")
704	builder.WriteString(title)
705	builder.WriteByte('\n')
706	fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
707
708	markdown := strings.TrimSpace(preview.Markdown)
709	if markdown == "" {
710		builder.WriteString("\nNo preview markdown returned.")
711	} else {
712		builder.WriteString("\n")
713		builder.WriteString(markdown)
714	}
715
716	return strings.TrimRight(builder.String(), "\n")
717}
718
719func formatRecipeSave(output SaveRecipeOutput) string {
720	return "Saved recipe (id: " + output.RecipeID + ")."
721}
722
723func formatRecipeDelete(output DeleteRecipeOutput) string {
724	return "Deleted recipe (id: " + output.RecipeID + ")."
725}
726
727func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
728	summaries := make([]RecipeSummary, 0, len(recipes))
729	for _, recipe := range recipes {
730		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
731	}
732
733	return summaries
734}
735
736// ReadArguments contains read tool arguments.
737type ReadArguments struct {
738	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
739	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
740	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
741	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
742	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
743}
744
745// ReadOutput is the structured output for the current read tool slice.
746type ReadOutput struct {
747	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
748	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
749	Recipes             []RecipeSummary `json:"recipes,omitempty"`
750	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
751}
752
753// RecipeSummary is the MCP-safe saved recipe summary.
754type RecipeSummary struct {
755	ID    string `json:"id"`
756	Title string `json:"title"`
757}
758
759// RecipeDetail is the MCP-safe single recipe output.
760type RecipeDetail struct {
761	ID             string `json:"id"`
762	Title          string `json:"title"`
763	Owner          string `json:"owner"`
764	EditPermission bool   `json:"edit_permission"`
765	Content        string `json:"content"`
766	Portions       int    `json:"portions"`
767}
768
769// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
770type PreviewRecipeTextArguments struct {
771	Title string `json:"title" jsonschema:"Recipe title for the preview."`
772	Text  string `json:"text"  jsonschema:"Raw recipe text to preview without saving."`
773}
774
775// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
776type PreviewRecipeTextOutput struct {
777	Title    string `json:"title"`
778	Markdown string `json:"markdown"`
779	Portions int    `json:"portions"`
780}
781
782// SaveRecipeArguments contains save_recipe tool arguments.
783type SaveRecipeArguments struct {
784	Source   string `json:"source"              jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
785	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
786	Title    string `json:"title,omitempty"     jsonschema:"Recipe title for raw_text or prepared saves."`
787	Text     string `json:"text,omitempty"      jsonschema:"Raw recipe text for raw_text saves."`
788	Markdown string `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
789	Portions int    `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
790}
791
792// SaveRecipeOutput is the structured output for save_recipe.
793type SaveRecipeOutput struct {
794	RecipeID string `json:"recipe_id,omitempty"`
795}
796
797// DeleteRecipeArguments contains delete_recipe tool arguments.
798type DeleteRecipeArguments struct {
799	RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
800}
801
802// DeleteRecipeOutput is the structured output for delete_recipe.
803type DeleteRecipeOutput struct {
804	RecipeID string `json:"recipe_id"`
805}
806
807// ChangeShoppingListArguments contains change_shopping_list tool arguments.
808type ChangeShoppingListArguments struct {
809	Action          string   `json:"action"                      jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, and clear."`
810	Ingredients     string   `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add."`
811	RecipeID        string   `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
812	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, or add_selection."`
813}
814
815// ChangeShoppingListOutput is the structured output for change_shopping_list.
816type ChangeShoppingListOutput struct {
817	Cleared                 bool     `json:"cleared,omitempty"`
818	AddedCount              int      `json:"added_count,omitempty"`
819	Ingredients             []string `json:"ingredients,omitempty"`
820	RemovedProductGroupIDs  []string `json:"removed_product_group_ids,omitempty"`
821	SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
822}