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