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