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}
 42
 43// Server exposes Cooked as an MCP server.
 44type Server struct {
 45	backend Backend
 46	sdk     *sdk.Server
 47}
 48
 49// NewServer returns an MCP server for a Cooked backend.
 50func NewServer(backend Backend, version string) *Server {
 51	server := &Server{backend: backend}
 52	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
 53	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
 54	sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
 55	sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
 56	sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
 57	sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)
 58
 59	return server
 60}
 61
 62// RunStdio serves MCP over the official SDK stdio transport.
 63func (s *Server) RunStdio(ctx context.Context) error {
 64	return s.sdk.Run(ctx, &sdk.StdioTransport{})
 65}
 66
 67// CallReadTool invokes the read tool without asserting MCP wire presentation.
 68func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
 69	_, output, err := s.callReadTool(ctx, nil, arguments)
 70	if err != nil {
 71		return ReadOutput{}, err
 72	}
 73
 74	return output, nil
 75}
 76
 77func (s *Server) callReadTool(
 78	ctx context.Context,
 79	_ *sdk.CallToolRequest,
 80	arguments ReadArguments,
 81) (*sdk.CallToolResult, ReadOutput, error) {
 82	switch arguments.Target {
 83	case "shopping_list":
 84		return s.callShoppingListReadTool(ctx)
 85	case "recipe":
 86		return s.callRecipeReadTool(ctx, arguments.RecipeID)
 87	case "recipes":
 88		return s.callRecipesReadTool(ctx, arguments)
 89	default:
 90		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
 91	}
 92}
 93
 94func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
 95	shoppingList, err := s.backend.ReadShoppingList(ctx)
 96	if err != nil {
 97		return nil, ReadOutput{}, err
 98	}
 99
100	output := ReadOutput{
101		Aisles:              shoppingList.Aisles,
102		ShoppingListRecipes: shoppingList.Recipes,
103	}
104
105	return &sdk.CallToolResult{
106		Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
107	}, output, nil
108}
109
110func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
111	recipeID := strings.TrimSpace(rawRecipeID)
112	if recipeID == "" {
113		return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
114	}
115
116	metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
117	if err != nil {
118		return nil, ReadOutput{}, err
119	}
120	content, err := s.backend.ReadRecipeContent(ctx, recipeID)
121	if err != nil {
122		return nil, ReadOutput{}, err
123	}
124
125	recipe := RecipeDetail{
126		ID:             recipeID,
127		Title:          metadata.Title,
128		Owner:          metadata.Owner,
129		EditPermission: metadata.EditPermission,
130		Content:        content.Content,
131		Portions:       content.Portions,
132	}
133	output := ReadOutput{Recipe: &recipe}
134
135	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
136}
137
138func (s *Server) callRecipesReadTool(
139	ctx context.Context,
140	arguments ReadArguments,
141) (*sdk.CallToolResult, ReadOutput, error) {
142	page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
143	recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
144	if err != nil {
145		return nil, ReadOutput{}, err
146	}
147
148	output := ReadOutput{Recipes: recipeSummaries(recipes)}
149
150	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
151}
152
153func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
154	if query == "" {
155		return s.backend.ListRecipes(ctx, page, limit)
156	}
157
158	recipes, err := s.backend.SearchRecipes(ctx, query, page)
159	if err != nil {
160		return nil, err
161	}
162	if len(recipes) > limit {
163		recipes = recipes[:limit]
164	}
165
166	return recipes, nil
167}
168
169func (s *Server) callPreviewRecipeTextTool(
170	ctx context.Context,
171	_ *sdk.CallToolRequest,
172	arguments PreviewRecipeTextArguments,
173) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
174	title := strings.TrimSpace(arguments.Title)
175	if title == "" {
176		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
177	}
178	if strings.TrimSpace(arguments.Text) == "" {
179		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
180	}
181
182	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
183	if err != nil {
184		return nil, PreviewRecipeTextOutput{}, err
185	}
186
187	output := PreviewRecipeTextOutput{
188		Title:    preview.Title,
189		Markdown: preview.Markdown,
190		Portions: preview.Portions,
191	}
192
193	return &sdk.CallToolResult{
194		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
195	}, output, nil
196}
197
198func (s *Server) callSaveRecipeTool(
199	ctx context.Context,
200	_ *sdk.CallToolRequest,
201	arguments SaveRecipeArguments,
202) (*sdk.CallToolResult, SaveRecipeOutput, error) {
203	source := strings.TrimSpace(arguments.Source)
204	switch source {
205	case "raw_text":
206		return s.callRawTextSaveRecipeTool(ctx, arguments)
207	case "prepared":
208		return s.callPreparedSaveRecipeTool(ctx, arguments)
209	case "existing":
210		return s.callExistingSaveRecipeTool(ctx, arguments)
211	case "":
212		return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
213	default:
214		return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
215	}
216}
217
218func (s *Server) callRawTextSaveRecipeTool(
219	ctx context.Context,
220	arguments SaveRecipeArguments,
221) (*sdk.CallToolResult, SaveRecipeOutput, error) {
222	title := strings.TrimSpace(arguments.Title)
223	if title == "" {
224		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
225	}
226	if strings.TrimSpace(arguments.Text) == "" {
227		return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
228	}
229
230	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
231	if err != nil {
232		return nil, SaveRecipeOutput{}, err
233	}
234	saveTitle := strings.TrimSpace(preview.Title)
235	if saveTitle == "" {
236		saveTitle = title
237	}
238	if strings.TrimSpace(preview.Markdown) == "" {
239		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
240	}
241	if preview.Portions < 1 {
242		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
243	}
244
245	return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
246}
247
248func (s *Server) callPreparedSaveRecipeTool(
249	ctx context.Context,
250	arguments SaveRecipeArguments,
251) (*sdk.CallToolResult, SaveRecipeOutput, error) {
252	title := strings.TrimSpace(arguments.Title)
253	if title == "" {
254		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
255	}
256	if strings.TrimSpace(arguments.Markdown) == "" {
257		return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
258	}
259	if arguments.Portions < 1 {
260		return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
261	}
262
263	return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
264}
265
266func (s *Server) callExistingSaveRecipeTool(
267	ctx context.Context,
268	arguments SaveRecipeArguments,
269) (*sdk.CallToolResult, SaveRecipeOutput, error) {
270	recipeID := strings.TrimSpace(arguments.RecipeID)
271	if recipeID == "" {
272		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
273	}
274	if strings.TrimSpace(arguments.Markdown) == "" {
275		return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing")
276	}
277	if arguments.Portions < 1 {
278		return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing")
279	}
280
281	if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
282		return nil, SaveRecipeOutput{}, err
283	}
284
285	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
286}
287
288func (s *Server) savePreparedRecipe(
289	ctx context.Context,
290	title, markdown string,
291	portions int,
292) (*sdk.CallToolResult, SaveRecipeOutput, error) {
293	recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
294	if err != nil {
295		return nil, SaveRecipeOutput{}, err
296	}
297	if strings.TrimSpace(recipeID) == "" {
298		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
299	}
300
301	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
302}
303
304func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
305	output := SaveRecipeOutput{RecipeID: recipeID}
306
307	return &sdk.CallToolResult{
308		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
309	}
310}
311
312func (s *Server) callDeleteRecipeTool(
313	ctx context.Context,
314	_ *sdk.CallToolRequest,
315	arguments DeleteRecipeArguments,
316) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
317	recipeID := strings.TrimSpace(arguments.RecipeID)
318	if recipeID == "" {
319		return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
320	}
321
322	if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
323		return nil, DeleteRecipeOutput{}, err
324	}
325
326	output := DeleteRecipeOutput{RecipeID: recipeID}
327
328	return &sdk.CallToolResult{
329		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(output)}},
330	}, output, nil
331}
332
333func (s *Server) callChangeShoppingListTool(
334	ctx context.Context,
335	_ *sdk.CallToolRequest,
336	arguments ChangeShoppingListArguments,
337) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
338	action := strings.TrimSpace(arguments.Action)
339	switch action {
340	case "add":
341		if strings.TrimSpace(arguments.Ingredients) == "" {
342			return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
343		}
344
345		result, err := s.backend.AddShoppingListIngredients(
346			ctx,
347			arguments.Ingredients,
348			strings.TrimSpace(arguments.RecipeID),
349		)
350		if err != nil {
351			return nil, ChangeShoppingListOutput{}, err
352		}
353
354		output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
355
356		return &sdk.CallToolResult{
357			Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
358				"Added %d shopping-list ingredients.",
359				result.AddedCount,
360			)}},
361		}, output, nil
362	case "clear":
363		if err := s.backend.ClearShoppingList(ctx); err != nil {
364			return nil, ChangeShoppingListOutput{}, err
365		}
366
367		output := ChangeShoppingListOutput{Cleared: true}
368
369		return &sdk.CallToolResult{
370			Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
371		}, output, nil
372	case "remove":
373		ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
374		if len(ids) == 0 {
375			return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
376		}
377
378		if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
379			return nil, ChangeShoppingListOutput{}, err
380		}
381
382		output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
383
384		return &sdk.CallToolResult{
385			Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
386				"Removed %d shopping-list product groups.",
387				len(ids),
388			)}},
389		}, output, nil
390	case "":
391		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
392	default:
393		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
394			"unsupported change_shopping_list action %q in this slice",
395			action,
396		)
397	}
398}
399
400func readTool() *sdk.Tool {
401	openWorld := true
402	return &sdk.Tool{
403		Name:        readToolName,
404		Title:       "Read Cooked data",
405		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.",
406		Annotations: &sdk.ToolAnnotations{
407			Title:         "Read Cooked data",
408			ReadOnlyHint:  true,
409			OpenWorldHint: &openWorld,
410		},
411	}
412}
413
414func previewRecipeTextTool() *sdk.Tool {
415	openWorld := true
416	return &sdk.Tool{
417		Name:        previewRecipeTextToolName,
418		Title:       "Preview recipe text",
419		Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
420		Annotations: &sdk.ToolAnnotations{
421			Title:         "Preview recipe text",
422			ReadOnlyHint:  true,
423			OpenWorldHint: &openWorld,
424		},
425	}
426}
427
428func saveRecipeTool() *sdk.Tool {
429	openWorld := true
430	return &sdk.Tool{
431		Name:        saveRecipeToolName,
432		Title:       "Save recipe",
433		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.",
434		Annotations: &sdk.ToolAnnotations{
435			Title:         "Save recipe",
436			OpenWorldHint: &openWorld,
437		},
438	}
439}
440
441func deleteRecipeTool() *sdk.Tool {
442	destructive := true
443	openWorld := true
444	return &sdk.Tool{
445		Name:        deleteRecipeToolName,
446		Title:       "Delete recipe",
447		Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
448		Annotations: &sdk.ToolAnnotations{
449			Title:           "Delete recipe",
450			DestructiveHint: &destructive,
451			OpenWorldHint:   &openWorld,
452		},
453	}
454}
455
456func changeShoppingListTool() *sdk.Tool {
457	destructive := true
458	openWorld := true
459	return &sdk.Tool{
460		Name:        changeShoppingListToolName,
461		Title:       "Change shopping list",
462		Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
463		Annotations: &sdk.ToolAnnotations{
464			Title:           "Change shopping list",
465			DestructiveHint: &destructive,
466			OpenWorldHint:   &openWorld,
467		},
468	}
469}
470
471func normalizeRecipePage(page, limit int) (int, int) {
472	if page < 1 {
473		page = 1
474	}
475	if limit < 1 {
476		limit = 10
477	}
478	if limit > 30 {
479		limit = 30
480	}
481
482	return page, limit
483}
484
485func normalizeProductGroupIDs(ids []string) []string {
486	normalized := make([]string, 0, len(ids))
487	for _, id := range ids {
488		id = strings.TrimSpace(id)
489		if id != "" {
490			normalized = append(normalized, id)
491		}
492	}
493
494	return normalized
495}
496
497func formatShoppingList(shoppingList cooked.ShoppingList) string {
498	if len(shoppingList.Aisles) == 0 {
499		return "Shopping list is empty."
500	}
501
502	var builder strings.Builder
503	builder.WriteString("Shopping list:\n")
504	for _, aisle := range shoppingList.Aisles {
505		builder.WriteString("- ")
506		builder.WriteString(aisle.Name)
507		builder.WriteString(":")
508		if len(aisle.ProductGroups) == 0 {
509			builder.WriteString(" no items\n")
510			continue
511		}
512		builder.WriteByte('\n')
513
514		for _, product := range aisle.ProductGroups {
515			builder.WriteString("  - ")
516			builder.WriteString(product.Name)
517			if product.Quantity != "" {
518				builder.WriteString(" — ")
519				builder.WriteString(product.Quantity)
520			}
521			builder.WriteString(" (id: ")
522			builder.WriteString(product.ID)
523			builder.WriteString(")")
524			if product.Selected {
525				builder.WriteString(" selected")
526			}
527			builder.WriteByte('\n')
528		}
529	}
530
531	return strings.TrimRight(builder.String(), "\n")
532}
533
534func formatRecipes(recipes []cooked.RecipeCard) string {
535	if len(recipes) == 0 {
536		return "No saved recipes found."
537	}
538
539	var builder strings.Builder
540	builder.WriteString("Saved recipes:\n")
541	for _, recipe := range recipes {
542		builder.WriteString("- ")
543		builder.WriteString(recipe.Title)
544		builder.WriteString(" (id: ")
545		builder.WriteString(recipe.ID)
546		builder.WriteString(")\n")
547	}
548
549	return strings.TrimRight(builder.String(), "\n")
550}
551
552func formatRecipe(recipe RecipeDetail) string {
553	title := recipe.Title
554	if title == "" {
555		title = "(untitled recipe)"
556	}
557
558	var builder strings.Builder
559	builder.WriteString("Recipe: ")
560	builder.WriteString(title)
561	builder.WriteString(" (id: ")
562	builder.WriteString(recipe.ID)
563	builder.WriteString(")\n")
564	if recipe.Owner != "" {
565		builder.WriteString("Owner: ")
566		builder.WriteString(recipe.Owner)
567		builder.WriteByte('\n')
568	}
569	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
570	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
571
572	content := strings.TrimSpace(recipe.Content)
573	if content == "" {
574		builder.WriteString("\nNo recipe content returned.")
575	} else {
576		builder.WriteString("\n")
577		builder.WriteString(content)
578	}
579
580	return strings.TrimRight(builder.String(), "\n")
581}
582
583func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
584	title := preview.Title
585	if title == "" {
586		title = "(untitled preview)"
587	}
588
589	var builder strings.Builder
590	builder.WriteString("Recipe text preview: ")
591	builder.WriteString(title)
592	builder.WriteByte('\n')
593	fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
594
595	markdown := strings.TrimSpace(preview.Markdown)
596	if markdown == "" {
597		builder.WriteString("\nNo preview markdown returned.")
598	} else {
599		builder.WriteString("\n")
600		builder.WriteString(markdown)
601	}
602
603	return strings.TrimRight(builder.String(), "\n")
604}
605
606func formatRecipeSave(output SaveRecipeOutput) string {
607	return "Saved recipe (id: " + output.RecipeID + ")."
608}
609
610func formatRecipeDelete(output DeleteRecipeOutput) string {
611	return "Deleted recipe (id: " + output.RecipeID + ")."
612}
613
614func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
615	summaries := make([]RecipeSummary, 0, len(recipes))
616	for _, recipe := range recipes {
617		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
618	}
619
620	return summaries
621}
622
623// ReadArguments contains read tool arguments.
624type ReadArguments struct {
625	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
626	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
627	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
628	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
629	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
630}
631
632// ReadOutput is the structured output for the current read tool slice.
633type ReadOutput struct {
634	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
635	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
636	Recipes             []RecipeSummary `json:"recipes,omitempty"`
637	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
638}
639
640// RecipeSummary is the MCP-safe saved recipe summary.
641type RecipeSummary struct {
642	ID    string `json:"id"`
643	Title string `json:"title"`
644}
645
646// RecipeDetail is the MCP-safe single recipe output.
647type RecipeDetail struct {
648	ID             string `json:"id"`
649	Title          string `json:"title"`
650	Owner          string `json:"owner"`
651	EditPermission bool   `json:"edit_permission"`
652	Content        string `json:"content"`
653	Portions       int    `json:"portions"`
654}
655
656// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
657type PreviewRecipeTextArguments struct {
658	Title string `json:"title" jsonschema:"Recipe title for the preview."`
659	Text  string `json:"text"  jsonschema:"Raw recipe text to preview without saving."`
660}
661
662// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
663type PreviewRecipeTextOutput struct {
664	Title    string `json:"title"`
665	Markdown string `json:"markdown"`
666	Portions int    `json:"portions"`
667}
668
669// SaveRecipeArguments contains save_recipe tool arguments.
670type SaveRecipeArguments struct {
671	Source   string `json:"source"              jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
672	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
673	Title    string `json:"title,omitempty"     jsonschema:"Recipe title for raw_text or prepared saves."`
674	Text     string `json:"text,omitempty"      jsonschema:"Raw recipe text for raw_text saves."`
675	Markdown string `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
676	Portions int    `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
677}
678
679// SaveRecipeOutput is the structured output for save_recipe.
680type SaveRecipeOutput struct {
681	RecipeID string `json:"recipe_id,omitempty"`
682}
683
684// DeleteRecipeArguments contains delete_recipe tool arguments.
685type DeleteRecipeArguments struct {
686	RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
687}
688
689// DeleteRecipeOutput is the structured output for delete_recipe.
690type DeleteRecipeOutput struct {
691	RecipeID string `json:"recipe_id"`
692}
693
694// ChangeShoppingListArguments contains change_shopping_list tool arguments.
695type ChangeShoppingListArguments struct {
696	Action          string   `json:"action"                      jsonschema:"Shopping-list action. Supported now: add, remove, and clear."`
697	Ingredients     string   `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add."`
698	RecipeID        string   `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
699	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove."`
700}
701
702// ChangeShoppingListOutput is the structured output for change_shopping_list.
703type ChangeShoppingListOutput struct {
704	Cleared                bool     `json:"cleared,omitempty"`
705	AddedCount             int      `json:"added_count,omitempty"`
706	Ingredients            []string `json:"ingredients,omitempty"`
707	RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"`
708}