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}