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 return s.addShoppingListIngredients(ctx, arguments)
343 case "clear":
344 return s.clearShoppingList(ctx)
345 case "remove":
346 return s.removeShoppingListProductGroups(ctx, arguments)
347 case "replace_selection":
348 return s.replaceShoppingListSelection(ctx, arguments)
349 case "add_selection":
350 return s.addShoppingListSelection(ctx, arguments)
351 case "remove_selection":
352 return s.removeShoppingListSelection(ctx, arguments)
353 case "":
354 return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
355 default:
356 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
357 "unsupported change_shopping_list action %q in this slice",
358 action,
359 )
360 }
361}
362
363func (s *Server) addShoppingListIngredients(
364 ctx context.Context,
365 arguments ChangeShoppingListArguments,
366) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
367 if strings.TrimSpace(arguments.Ingredients) == "" {
368 return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
369 }
370
371 result, err := s.backend.AddShoppingListIngredients(
372 ctx,
373 arguments.Ingredients,
374 strings.TrimSpace(arguments.RecipeID),
375 )
376 if err != nil {
377 return nil, ChangeShoppingListOutput{}, err
378 }
379
380 output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
381
382 return &sdk.CallToolResult{
383 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
384 "Added %d shopping-list ingredients.",
385 result.AddedCount,
386 )}},
387 }, output, nil
388}
389
390func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
391 if err := s.backend.ClearShoppingList(ctx); err != nil {
392 return nil, ChangeShoppingListOutput{}, err
393 }
394
395 output := ChangeShoppingListOutput{Cleared: true}
396
397 return &sdk.CallToolResult{
398 Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
399 }, output, nil
400}
401
402func (s *Server) removeShoppingListProductGroups(
403 ctx context.Context,
404 arguments ChangeShoppingListArguments,
405) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
406 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
407 if len(ids) == 0 {
408 return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
409 }
410
411 if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
412 return nil, ChangeShoppingListOutput{}, err
413 }
414
415 output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
416
417 return &sdk.CallToolResult{
418 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
419 "Removed %d shopping-list product groups.",
420 len(ids),
421 )}},
422 }, output, nil
423}
424
425func (s *Server) replaceShoppingListSelection(
426 ctx context.Context,
427 arguments ChangeShoppingListArguments,
428) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
429 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
430 if len(ids) == 0 {
431 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
432 "product_group_ids is required when action is replace_selection",
433 )
434 }
435
436 return s.setShoppingListSelection(ctx, ids)
437}
438
439func (s *Server) addShoppingListSelection(
440 ctx context.Context,
441 arguments ChangeShoppingListArguments,
442) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
443 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
444 if len(ids) == 0 {
445 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
446 "product_group_ids is required when action is add_selection",
447 )
448 }
449
450 shoppingList, err := s.backend.ReadShoppingList(ctx)
451 if err != nil {
452 return nil, ChangeShoppingListOutput{}, err
453 }
454
455 return s.setShoppingListSelection(
456 ctx,
457 appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
458 )
459}
460
461func (s *Server) removeShoppingListSelection(
462 ctx context.Context,
463 arguments ChangeShoppingListArguments,
464) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
465 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
466 if len(ids) == 0 {
467 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
468 "product_group_ids is required when action is remove_selection",
469 )
470 }
471
472 shoppingList, err := s.backend.ReadShoppingList(ctx)
473 if err != nil {
474 return nil, ChangeShoppingListOutput{}, err
475 }
476
477 return s.setShoppingListSelection(
478 ctx,
479 subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
480 )
481}
482
483func (s *Server) setShoppingListSelection(
484 ctx context.Context,
485 ids []string,
486) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
487 if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
488 return nil, ChangeShoppingListOutput{}, err
489 }
490
491 output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
492
493 return &sdk.CallToolResult{
494 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
495 "Selected %d shopping-list product groups.",
496 len(ids),
497 )}},
498 }, output, nil
499}
500
501func readTool() *sdk.Tool {
502 openWorld := true
503 return &sdk.Tool{
504 Name: readToolName,
505 Title: "Read Cooked data",
506 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.",
507 Annotations: &sdk.ToolAnnotations{
508 Title: "Read Cooked data",
509 ReadOnlyHint: true,
510 OpenWorldHint: &openWorld,
511 },
512 }
513}
514
515func previewRecipeTextTool() *sdk.Tool {
516 openWorld := true
517 return &sdk.Tool{
518 Name: previewRecipeTextToolName,
519 Title: "Preview recipe text",
520 Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
521 Annotations: &sdk.ToolAnnotations{
522 Title: "Preview recipe text",
523 ReadOnlyHint: true,
524 OpenWorldHint: &openWorld,
525 },
526 }
527}
528
529func saveRecipeTool() *sdk.Tool {
530 openWorld := true
531 return &sdk.Tool{
532 Name: saveRecipeToolName,
533 Title: "Save recipe",
534 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.",
535 Annotations: &sdk.ToolAnnotations{
536 Title: "Save recipe",
537 OpenWorldHint: &openWorld,
538 },
539 }
540}
541
542func deleteRecipeTool() *sdk.Tool {
543 destructive := true
544 openWorld := true
545 return &sdk.Tool{
546 Name: deleteRecipeToolName,
547 Title: "Delete recipe",
548 Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
549 Annotations: &sdk.ToolAnnotations{
550 Title: "Delete recipe",
551 DestructiveHint: &destructive,
552 OpenWorldHint: &openWorld,
553 },
554 }
555}
556
557func changeShoppingListTool() *sdk.Tool {
558 destructive := true
559 openWorld := true
560 return &sdk.Tool{
561 Name: changeShoppingListToolName,
562 Title: "Change shopping list",
563 Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove, replace_selection, add_selection, or remove_selection with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
564 Annotations: &sdk.ToolAnnotations{
565 Title: "Change shopping list",
566 DestructiveHint: &destructive,
567 OpenWorldHint: &openWorld,
568 },
569 }
570}
571
572func normalizeRecipePage(page, limit int) (int, int) {
573 if page < 1 {
574 page = 1
575 }
576 if limit < 1 {
577 limit = 10
578 }
579 if limit > 30 {
580 limit = 30
581 }
582
583 return page, limit
584}
585
586func normalizeProductGroupIDs(ids []string) []string {
587 normalized := make([]string, 0, len(ids))
588 for _, id := range ids {
589 id = strings.TrimSpace(id)
590 if id != "" {
591 normalized = append(normalized, id)
592 }
593 }
594
595 return normalized
596}
597
598func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
599 var ids []string
600 for _, aisle := range shoppingList.Aisles {
601 for _, product := range aisle.ProductGroups {
602 if product.Selected {
603 ids = append(ids, product.ID)
604 }
605 }
606 }
607
608 return ids
609}
610
611func appendMissingProductGroupIDs(existing, additions []string) []string {
612 seen := make(map[string]struct{}, len(existing)+len(additions))
613 result := make([]string, 0, len(existing)+len(additions))
614
615 for _, id := range existing {
616 if _, ok := seen[id]; ok {
617 continue
618 }
619 seen[id] = struct{}{}
620 result = append(result, id)
621 }
622
623 for _, id := range additions {
624 if _, ok := seen[id]; ok {
625 continue
626 }
627 seen[id] = struct{}{}
628 result = append(result, id)
629 }
630
631 return result
632}
633
634func subtractProductGroupIDs(existing, removals []string) []string {
635 removed := make(map[string]struct{}, len(removals))
636 for _, id := range removals {
637 removed[id] = struct{}{}
638 }
639
640 seen := make(map[string]struct{}, len(existing))
641 result := make([]string, 0, len(existing))
642 for _, id := range existing {
643 if _, ok := removed[id]; ok {
644 continue
645 }
646 if _, ok := seen[id]; ok {
647 continue
648 }
649 seen[id] = struct{}{}
650 result = append(result, id)
651 }
652
653 return result
654}
655
656func formatShoppingList(shoppingList cooked.ShoppingList) string {
657 if len(shoppingList.Aisles) == 0 {
658 return "Shopping list is empty."
659 }
660
661 var builder strings.Builder
662 builder.WriteString("Shopping list:\n")
663 for _, aisle := range shoppingList.Aisles {
664 builder.WriteString("- ")
665 builder.WriteString(aisle.Name)
666 builder.WriteString(":")
667 if len(aisle.ProductGroups) == 0 {
668 builder.WriteString(" no items\n")
669 continue
670 }
671 builder.WriteByte('\n')
672
673 for _, product := range aisle.ProductGroups {
674 builder.WriteString(" - ")
675 builder.WriteString(product.Name)
676 if product.Quantity != "" {
677 builder.WriteString(" — ")
678 builder.WriteString(product.Quantity)
679 }
680 builder.WriteString(" (id: ")
681 builder.WriteString(product.ID)
682 builder.WriteString(")")
683 if product.Selected {
684 builder.WriteString(" selected")
685 }
686 builder.WriteByte('\n')
687 }
688 }
689
690 return strings.TrimRight(builder.String(), "\n")
691}
692
693func formatRecipes(recipes []cooked.RecipeCard) string {
694 if len(recipes) == 0 {
695 return "No saved recipes found."
696 }
697
698 var builder strings.Builder
699 builder.WriteString("Saved recipes:\n")
700 for _, recipe := range recipes {
701 builder.WriteString("- ")
702 builder.WriteString(recipe.Title)
703 builder.WriteString(" (id: ")
704 builder.WriteString(recipe.ID)
705 builder.WriteString(")\n")
706 }
707
708 return strings.TrimRight(builder.String(), "\n")
709}
710
711func formatRecipe(recipe RecipeDetail) string {
712 title := recipe.Title
713 if title == "" {
714 title = "(untitled recipe)"
715 }
716
717 var builder strings.Builder
718 builder.WriteString("Recipe: ")
719 builder.WriteString(title)
720 builder.WriteString(" (id: ")
721 builder.WriteString(recipe.ID)
722 builder.WriteString(")\n")
723 if recipe.Owner != "" {
724 builder.WriteString("Owner: ")
725 builder.WriteString(recipe.Owner)
726 builder.WriteByte('\n')
727 }
728 fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
729 fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
730
731 content := strings.TrimSpace(recipe.Content)
732 if content == "" {
733 builder.WriteString("\nNo recipe content returned.")
734 } else {
735 builder.WriteString("\n")
736 builder.WriteString(content)
737 }
738
739 return strings.TrimRight(builder.String(), "\n")
740}
741
742func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
743 title := preview.Title
744 if title == "" {
745 title = "(untitled preview)"
746 }
747
748 var builder strings.Builder
749 builder.WriteString("Recipe text preview: ")
750 builder.WriteString(title)
751 builder.WriteByte('\n')
752 fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
753
754 markdown := strings.TrimSpace(preview.Markdown)
755 if markdown == "" {
756 builder.WriteString("\nNo preview markdown returned.")
757 } else {
758 builder.WriteString("\n")
759 builder.WriteString(markdown)
760 }
761
762 return strings.TrimRight(builder.String(), "\n")
763}
764
765func formatRecipeSave(output SaveRecipeOutput) string {
766 return "Saved recipe (id: " + output.RecipeID + ")."
767}
768
769func formatRecipeDelete(output DeleteRecipeOutput) string {
770 return "Deleted recipe (id: " + output.RecipeID + ")."
771}
772
773func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
774 summaries := make([]RecipeSummary, 0, len(recipes))
775 for _, recipe := range recipes {
776 summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
777 }
778
779 return summaries
780}
781
782// ReadArguments contains read tool arguments.
783type ReadArguments struct {
784 Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
785 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
786 Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
787 Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
788 Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
789}
790
791// ReadOutput is the structured output for the current read tool slice.
792type ReadOutput struct {
793 Aisles []cooked.Aisle `json:"aisles,omitempty"`
794 ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
795 Recipes []RecipeSummary `json:"recipes,omitempty"`
796 Recipe *RecipeDetail `json:"recipe,omitempty"`
797}
798
799// RecipeSummary is the MCP-safe saved recipe summary.
800type RecipeSummary struct {
801 ID string `json:"id"`
802 Title string `json:"title"`
803}
804
805// RecipeDetail is the MCP-safe single recipe output.
806type RecipeDetail struct {
807 ID string `json:"id"`
808 Title string `json:"title"`
809 Owner string `json:"owner"`
810 EditPermission bool `json:"edit_permission"`
811 Content string `json:"content"`
812 Portions int `json:"portions"`
813}
814
815// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
816type PreviewRecipeTextArguments struct {
817 Title string `json:"title" jsonschema:"Recipe title for the preview."`
818 Text string `json:"text" jsonschema:"Raw recipe text to preview without saving."`
819}
820
821// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
822type PreviewRecipeTextOutput struct {
823 Title string `json:"title"`
824 Markdown string `json:"markdown"`
825 Portions int `json:"portions"`
826}
827
828// SaveRecipeArguments contains save_recipe tool arguments.
829type SaveRecipeArguments struct {
830 Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
831 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
832 Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."`
833 Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."`
834 Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
835 Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
836}
837
838// SaveRecipeOutput is the structured output for save_recipe.
839type SaveRecipeOutput struct {
840 RecipeID string `json:"recipe_id,omitempty"`
841}
842
843// DeleteRecipeArguments contains delete_recipe tool arguments.
844type DeleteRecipeArguments struct {
845 RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
846}
847
848// DeleteRecipeOutput is the structured output for delete_recipe.
849type DeleteRecipeOutput struct {
850 RecipeID string `json:"recipe_id"`
851}
852
853// ChangeShoppingListArguments contains change_shopping_list tool arguments.
854type ChangeShoppingListArguments struct {
855 Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, remove_selection, and clear."`
856 Ingredients string `json:"ingredients,omitempty" jsonschema:"Newline-separated ingredients for action add."`
857 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Optional saved recipe ID or import draft ID for action add."`
858 ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
859}
860
861// ChangeShoppingListOutput is the structured output for change_shopping_list.
862type ChangeShoppingListOutput struct {
863 Cleared bool `json:"cleared,omitempty"`
864 AddedCount int `json:"added_count,omitempty"`
865 Ingredients []string `json:"ingredients,omitempty"`
866 RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"`
867 SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
868}