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