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