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 UpdateShoppingListProductGroup(
43 ctx context.Context,
44 productGroupID string,
45 update cooked.ShoppingListProductGroupUpdate,
46 ) error
47}
48
49// Server exposes Cooked as an MCP server.
50type Server struct {
51 backend Backend
52 sdk *sdk.Server
53}
54
55// NewServer returns an MCP server for a Cooked backend.
56func NewServer(backend Backend, version string) *Server {
57 server := &Server{backend: backend}
58 server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
59 sdk.AddTool(server.sdk, readTool(), server.callReadTool)
60 sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
61 sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
62 sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
63 sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)
64
65 return server
66}
67
68// RunStdio serves MCP over the official SDK stdio transport.
69func (s *Server) RunStdio(ctx context.Context) error {
70 return s.sdk.Run(ctx, &sdk.StdioTransport{})
71}
72
73// CallReadTool invokes the read tool without asserting MCP wire presentation.
74func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
75 _, output, err := s.callReadTool(ctx, nil, arguments)
76 if err != nil {
77 return ReadOutput{}, err
78 }
79
80 return output, nil
81}
82
83func (s *Server) callReadTool(
84 ctx context.Context,
85 _ *sdk.CallToolRequest,
86 arguments ReadArguments,
87) (*sdk.CallToolResult, ReadOutput, error) {
88 switch arguments.Target {
89 case "shopping_list":
90 return s.callShoppingListReadTool(ctx)
91 case "recipe":
92 return s.callRecipeReadTool(ctx, arguments.RecipeID)
93 case "recipes":
94 return s.callRecipesReadTool(ctx, arguments)
95 default:
96 return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
97 }
98}
99
100func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
101 shoppingList, err := s.backend.ReadShoppingList(ctx)
102 if err != nil {
103 return nil, ReadOutput{}, err
104 }
105
106 output := ReadOutput{
107 Aisles: shoppingList.Aisles,
108 ShoppingListRecipes: shoppingList.Recipes,
109 }
110
111 return &sdk.CallToolResult{
112 Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
113 }, output, nil
114}
115
116func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
117 recipeID := strings.TrimSpace(rawRecipeID)
118 if recipeID == "" {
119 return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
120 }
121
122 metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
123 if err != nil {
124 return nil, ReadOutput{}, err
125 }
126 content, err := s.backend.ReadRecipeContent(ctx, recipeID)
127 if err != nil {
128 return nil, ReadOutput{}, err
129 }
130
131 recipe := RecipeDetail{
132 ID: recipeID,
133 Title: metadata.Title,
134 Owner: metadata.Owner,
135 EditPermission: metadata.EditPermission,
136 Content: content.Content,
137 Portions: content.Portions,
138 }
139 output := ReadOutput{Recipe: &recipe}
140
141 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
142}
143
144func (s *Server) callRecipesReadTool(
145 ctx context.Context,
146 arguments ReadArguments,
147) (*sdk.CallToolResult, ReadOutput, error) {
148 page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
149 recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
150 if err != nil {
151 return nil, ReadOutput{}, err
152 }
153
154 output := ReadOutput{Recipes: recipeSummaries(recipes)}
155
156 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
157}
158
159func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
160 if query == "" {
161 return s.backend.ListRecipes(ctx, page, limit)
162 }
163
164 recipes, err := s.backend.SearchRecipes(ctx, query, page)
165 if err != nil {
166 return nil, err
167 }
168 if len(recipes) > limit {
169 recipes = recipes[:limit]
170 }
171
172 return recipes, nil
173}
174
175func (s *Server) callPreviewRecipeTextTool(
176 ctx context.Context,
177 _ *sdk.CallToolRequest,
178 arguments PreviewRecipeTextArguments,
179) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
180 title := strings.TrimSpace(arguments.Title)
181 if title == "" {
182 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
183 }
184 if strings.TrimSpace(arguments.Text) == "" {
185 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
186 }
187
188 preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
189 if err != nil {
190 return nil, PreviewRecipeTextOutput{}, err
191 }
192
193 output := PreviewRecipeTextOutput{
194 Title: preview.Title,
195 Markdown: preview.Markdown,
196 Portions: preview.Portions,
197 }
198
199 return &sdk.CallToolResult{
200 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
201 }, output, nil
202}
203
204func (s *Server) callSaveRecipeTool(
205 ctx context.Context,
206 _ *sdk.CallToolRequest,
207 arguments SaveRecipeArguments,
208) (*sdk.CallToolResult, SaveRecipeOutput, error) {
209 source := strings.TrimSpace(arguments.Source)
210 switch source {
211 case "raw_text":
212 return s.callRawTextSaveRecipeTool(ctx, arguments)
213 case "prepared":
214 return s.callPreparedSaveRecipeTool(ctx, arguments)
215 case "existing":
216 return s.callExistingSaveRecipeTool(ctx, arguments)
217 case "":
218 return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
219 default:
220 return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
221 }
222}
223
224func (s *Server) callRawTextSaveRecipeTool(
225 ctx context.Context,
226 arguments SaveRecipeArguments,
227) (*sdk.CallToolResult, SaveRecipeOutput, error) {
228 title := strings.TrimSpace(arguments.Title)
229 if title == "" {
230 return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
231 }
232 if strings.TrimSpace(arguments.Text) == "" {
233 return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
234 }
235
236 preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
237 if err != nil {
238 return nil, SaveRecipeOutput{}, err
239 }
240 saveTitle := strings.TrimSpace(preview.Title)
241 if saveTitle == "" {
242 saveTitle = title
243 }
244 if strings.TrimSpace(preview.Markdown) == "" {
245 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
246 }
247 if preview.Portions < 1 {
248 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
249 }
250
251 return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
252}
253
254func (s *Server) callPreparedSaveRecipeTool(
255 ctx context.Context,
256 arguments SaveRecipeArguments,
257) (*sdk.CallToolResult, SaveRecipeOutput, error) {
258 title := strings.TrimSpace(arguments.Title)
259 if title == "" {
260 return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
261 }
262 if strings.TrimSpace(arguments.Markdown) == "" {
263 return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
264 }
265 if arguments.Portions < 1 {
266 return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
267 }
268
269 return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
270}
271
272func (s *Server) callExistingSaveRecipeTool(
273 ctx context.Context,
274 arguments SaveRecipeArguments,
275) (*sdk.CallToolResult, SaveRecipeOutput, error) {
276 recipeID := strings.TrimSpace(arguments.RecipeID)
277 if recipeID == "" {
278 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
279 }
280 if strings.TrimSpace(arguments.Markdown) == "" {
281 return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing")
282 }
283 if arguments.Portions < 1 {
284 return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing")
285 }
286
287 if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
288 return nil, SaveRecipeOutput{}, err
289 }
290
291 return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
292}
293
294func (s *Server) savePreparedRecipe(
295 ctx context.Context,
296 title, markdown string,
297 portions int,
298) (*sdk.CallToolResult, SaveRecipeOutput, error) {
299 recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
300 if err != nil {
301 return nil, SaveRecipeOutput{}, err
302 }
303 if strings.TrimSpace(recipeID) == "" {
304 return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
305 }
306
307 return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
308}
309
310func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
311 output := SaveRecipeOutput{RecipeID: recipeID}
312
313 return &sdk.CallToolResult{
314 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
315 }
316}
317
318func (s *Server) callDeleteRecipeTool(
319 ctx context.Context,
320 _ *sdk.CallToolRequest,
321 arguments DeleteRecipeArguments,
322) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
323 recipeID := strings.TrimSpace(arguments.RecipeID)
324 if recipeID == "" {
325 return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
326 }
327
328 if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
329 return nil, DeleteRecipeOutput{}, err
330 }
331
332 output := DeleteRecipeOutput{RecipeID: recipeID}
333
334 return &sdk.CallToolResult{
335 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(output)}},
336 }, output, nil
337}
338
339func (s *Server) callChangeShoppingListTool(
340 ctx context.Context,
341 _ *sdk.CallToolRequest,
342 arguments ChangeShoppingListArguments,
343) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
344 action := strings.TrimSpace(arguments.Action)
345 switch action {
346 case "add":
347 return s.addShoppingListIngredients(ctx, arguments)
348 case "clear":
349 return s.clearShoppingList(ctx)
350 case "remove":
351 return s.removeShoppingListProductGroups(ctx, arguments)
352 case "replace_selection":
353 return s.replaceShoppingListSelection(ctx, arguments)
354 case "add_selection":
355 return s.addShoppingListSelection(ctx, arguments)
356 case "remove_selection":
357 return s.removeShoppingListSelection(ctx, arguments)
358 case "update_item":
359 return s.updateShoppingListProductGroup(ctx, arguments)
360 case "":
361 return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
362 default:
363 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
364 "unsupported change_shopping_list action %q in this slice",
365 action,
366 )
367 }
368}
369
370func (s *Server) addShoppingListIngredients(
371 ctx context.Context,
372 arguments ChangeShoppingListArguments,
373) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
374 if strings.TrimSpace(arguments.Ingredients) == "" {
375 return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
376 }
377
378 result, err := s.backend.AddShoppingListIngredients(
379 ctx,
380 arguments.Ingredients,
381 strings.TrimSpace(arguments.RecipeID),
382 )
383 if err != nil {
384 return nil, ChangeShoppingListOutput{}, err
385 }
386
387 output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
388
389 return &sdk.CallToolResult{
390 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
391 "Added %d shopping-list ingredients.",
392 result.AddedCount,
393 )}},
394 }, output, nil
395}
396
397func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
398 if err := s.backend.ClearShoppingList(ctx); err != nil {
399 return nil, ChangeShoppingListOutput{}, err
400 }
401
402 output := ChangeShoppingListOutput{Cleared: true}
403
404 return &sdk.CallToolResult{
405 Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
406 }, output, nil
407}
408
409func (s *Server) removeShoppingListProductGroups(
410 ctx context.Context,
411 arguments ChangeShoppingListArguments,
412) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
413 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
414 if len(ids) == 0 {
415 return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
416 }
417
418 if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
419 return nil, ChangeShoppingListOutput{}, err
420 }
421
422 output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
423
424 return &sdk.CallToolResult{
425 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
426 "Removed %d shopping-list product groups.",
427 len(ids),
428 )}},
429 }, output, nil
430}
431
432func (s *Server) replaceShoppingListSelection(
433 ctx context.Context,
434 arguments ChangeShoppingListArguments,
435) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
436 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
437 if len(ids) == 0 {
438 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
439 "product_group_ids is required when action is replace_selection",
440 )
441 }
442
443 return s.setShoppingListSelection(ctx, ids)
444}
445
446func (s *Server) addShoppingListSelection(
447 ctx context.Context,
448 arguments ChangeShoppingListArguments,
449) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
450 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
451 if len(ids) == 0 {
452 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
453 "product_group_ids is required when action is add_selection",
454 )
455 }
456
457 shoppingList, err := s.backend.ReadShoppingList(ctx)
458 if err != nil {
459 return nil, ChangeShoppingListOutput{}, err
460 }
461
462 return s.setShoppingListSelection(
463 ctx,
464 appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
465 )
466}
467
468func (s *Server) removeShoppingListSelection(
469 ctx context.Context,
470 arguments ChangeShoppingListArguments,
471) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
472 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
473 if len(ids) == 0 {
474 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
475 "product_group_ids is required when action is remove_selection",
476 )
477 }
478
479 shoppingList, err := s.backend.ReadShoppingList(ctx)
480 if err != nil {
481 return nil, ChangeShoppingListOutput{}, err
482 }
483
484 return s.setShoppingListSelection(
485 ctx,
486 subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
487 )
488}
489
490func (s *Server) setShoppingListSelection(
491 ctx context.Context,
492 ids []string,
493) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
494 if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
495 return nil, ChangeShoppingListOutput{}, err
496 }
497
498 output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
499
500 return &sdk.CallToolResult{
501 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
502 "Selected %d shopping-list product groups.",
503 len(ids),
504 )}},
505 }, output, nil
506}
507
508func (s *Server) updateShoppingListProductGroup(
509 ctx context.Context,
510 arguments ChangeShoppingListArguments,
511) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
512 productGroupID := strings.TrimSpace(arguments.ProductGroupID)
513 if productGroupID == "" {
514 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
515 "product_group_id is required when action is update_item",
516 )
517 }
518
519 shoppingList, err := s.backend.ReadShoppingList(ctx)
520 if err != nil {
521 return nil, ChangeShoppingListOutput{}, err
522 }
523
524 product, aisleID, ok := findShoppingListProductGroup(shoppingList, productGroupID)
525 if !ok {
526 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
527 "product_group_id %q was not found in the current shopping list",
528 productGroupID,
529 )
530 }
531
532 update := cooked.ShoppingListProductGroupUpdate{
533 Name: product.Name,
534 Quantity: product.Quantity,
535 AisleID: aisleID,
536 Selected: product.Selected,
537 }
538 if arguments.Name != nil {
539 update.Name = *arguments.Name
540 }
541 if arguments.Quantity != nil {
542 update.Quantity = *arguments.Quantity
543 }
544 if arguments.AisleID != nil {
545 update.AisleID = strings.TrimSpace(*arguments.AisleID)
546 }
547 if arguments.Selected != nil {
548 update.Selected = *arguments.Selected
549 }
550
551 if err := s.backend.UpdateShoppingListProductGroup(ctx, productGroupID, update); err != nil {
552 return nil, ChangeShoppingListOutput{}, err
553 }
554
555 output := ChangeShoppingListOutput{UpdatedProductGroupID: productGroupID}
556
557 return &sdk.CallToolResult{
558 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
559 "Updated shopping-list product group (id: %s).",
560 productGroupID,
561 )}},
562 }, output, nil
563}
564
565func readTool() *sdk.Tool {
566 openWorld := true
567 return &sdk.Tool{
568 Name: readToolName,
569 Title: "Read Cooked data",
570 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.",
571 Annotations: &sdk.ToolAnnotations{
572 Title: "Read Cooked data",
573 ReadOnlyHint: true,
574 OpenWorldHint: &openWorld,
575 },
576 }
577}
578
579func previewRecipeTextTool() *sdk.Tool {
580 openWorld := true
581 return &sdk.Tool{
582 Name: previewRecipeTextToolName,
583 Title: "Preview recipe text",
584 Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
585 Annotations: &sdk.ToolAnnotations{
586 Title: "Preview recipe text",
587 ReadOnlyHint: true,
588 OpenWorldHint: &openWorld,
589 },
590 }
591}
592
593func saveRecipeTool() *sdk.Tool {
594 openWorld := true
595 return &sdk.Tool{
596 Name: saveRecipeToolName,
597 Title: "Save recipe",
598 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.",
599 Annotations: &sdk.ToolAnnotations{
600 Title: "Save recipe",
601 OpenWorldHint: &openWorld,
602 },
603 }
604}
605
606func deleteRecipeTool() *sdk.Tool {
607 destructive := true
608 openWorld := true
609 return &sdk.Tool{
610 Name: deleteRecipeToolName,
611 Title: "Delete recipe",
612 Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
613 Annotations: &sdk.ToolAnnotations{
614 Title: "Delete recipe",
615 DestructiveHint: &destructive,
616 OpenWorldHint: &openWorld,
617 },
618 }
619}
620
621func changeShoppingListTool() *sdk.Tool {
622 destructive := true
623 openWorld := true
624 return &sdk.Tool{
625 Name: changeShoppingListToolName,
626 Title: "Change shopping list",
627 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, action update_item with product_group_id and optional name, quantity, aisle_id, or selected, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
628 Annotations: &sdk.ToolAnnotations{
629 Title: "Change shopping list",
630 DestructiveHint: &destructive,
631 OpenWorldHint: &openWorld,
632 },
633 }
634}
635
636func normalizeRecipePage(page, limit int) (int, int) {
637 if page < 1 {
638 page = 1
639 }
640 if limit < 1 {
641 limit = 10
642 }
643 if limit > 30 {
644 limit = 30
645 }
646
647 return page, limit
648}
649
650func normalizeProductGroupIDs(ids []string) []string {
651 normalized := make([]string, 0, len(ids))
652 for _, id := range ids {
653 id = strings.TrimSpace(id)
654 if id != "" {
655 normalized = append(normalized, id)
656 }
657 }
658
659 return normalized
660}
661
662func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
663 var ids []string
664 for _, aisle := range shoppingList.Aisles {
665 for _, product := range aisle.ProductGroups {
666 if product.Selected {
667 ids = append(ids, product.ID)
668 }
669 }
670 }
671
672 return ids
673}
674
675func appendMissingProductGroupIDs(existing, additions []string) []string {
676 seen := make(map[string]struct{}, len(existing)+len(additions))
677 result := make([]string, 0, len(existing)+len(additions))
678
679 for _, id := range existing {
680 if _, ok := seen[id]; ok {
681 continue
682 }
683 seen[id] = struct{}{}
684 result = append(result, id)
685 }
686
687 for _, id := range additions {
688 if _, ok := seen[id]; ok {
689 continue
690 }
691 seen[id] = struct{}{}
692 result = append(result, id)
693 }
694
695 return result
696}
697
698func subtractProductGroupIDs(existing, removals []string) []string {
699 removed := make(map[string]struct{}, len(removals))
700 for _, id := range removals {
701 removed[id] = struct{}{}
702 }
703
704 seen := make(map[string]struct{}, len(existing))
705 result := make([]string, 0, len(existing))
706 for _, id := range existing {
707 if _, ok := removed[id]; ok {
708 continue
709 }
710 if _, ok := seen[id]; ok {
711 continue
712 }
713 seen[id] = struct{}{}
714 result = append(result, id)
715 }
716
717 return result
718}
719
720func findShoppingListProductGroup(
721 shoppingList cooked.ShoppingList,
722 productGroupID string,
723) (cooked.ProductGroup, string, bool) {
724 for _, aisle := range shoppingList.Aisles {
725 for _, product := range aisle.ProductGroups {
726 if product.ID == productGroupID {
727 return product, aisle.ID, true
728 }
729 }
730 }
731
732 return cooked.ProductGroup{}, "", false
733}
734
735func formatShoppingList(shoppingList cooked.ShoppingList) string {
736 if len(shoppingList.Aisles) == 0 {
737 return "Shopping list is empty."
738 }
739
740 var builder strings.Builder
741 builder.WriteString("Shopping list:\n")
742 for _, aisle := range shoppingList.Aisles {
743 builder.WriteString("- ")
744 builder.WriteString(aisle.Name)
745 builder.WriteString(":")
746 if len(aisle.ProductGroups) == 0 {
747 builder.WriteString(" no items\n")
748 continue
749 }
750 builder.WriteByte('\n')
751
752 for _, product := range aisle.ProductGroups {
753 builder.WriteString(" - ")
754 builder.WriteString(product.Name)
755 if product.Quantity != "" {
756 builder.WriteString(" — ")
757 builder.WriteString(product.Quantity)
758 }
759 builder.WriteString(" (id: ")
760 builder.WriteString(product.ID)
761 builder.WriteString(")")
762 if product.Selected {
763 builder.WriteString(" selected")
764 }
765 builder.WriteByte('\n')
766 }
767 }
768
769 return strings.TrimRight(builder.String(), "\n")
770}
771
772func formatRecipes(recipes []cooked.RecipeCard) string {
773 if len(recipes) == 0 {
774 return "No saved recipes found."
775 }
776
777 var builder strings.Builder
778 builder.WriteString("Saved recipes:\n")
779 for _, recipe := range recipes {
780 builder.WriteString("- ")
781 builder.WriteString(recipe.Title)
782 builder.WriteString(" (id: ")
783 builder.WriteString(recipe.ID)
784 builder.WriteString(")\n")
785 }
786
787 return strings.TrimRight(builder.String(), "\n")
788}
789
790func formatRecipe(recipe RecipeDetail) string {
791 title := recipe.Title
792 if title == "" {
793 title = "(untitled recipe)"
794 }
795
796 var builder strings.Builder
797 builder.WriteString("Recipe: ")
798 builder.WriteString(title)
799 builder.WriteString(" (id: ")
800 builder.WriteString(recipe.ID)
801 builder.WriteString(")\n")
802 if recipe.Owner != "" {
803 builder.WriteString("Owner: ")
804 builder.WriteString(recipe.Owner)
805 builder.WriteByte('\n')
806 }
807 fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
808 fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
809
810 content := strings.TrimSpace(recipe.Content)
811 if content == "" {
812 builder.WriteString("\nNo recipe content returned.")
813 } else {
814 builder.WriteString("\n")
815 builder.WriteString(content)
816 }
817
818 return strings.TrimRight(builder.String(), "\n")
819}
820
821func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
822 title := preview.Title
823 if title == "" {
824 title = "(untitled preview)"
825 }
826
827 var builder strings.Builder
828 builder.WriteString("Recipe text preview: ")
829 builder.WriteString(title)
830 builder.WriteByte('\n')
831 fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
832
833 markdown := strings.TrimSpace(preview.Markdown)
834 if markdown == "" {
835 builder.WriteString("\nNo preview markdown returned.")
836 } else {
837 builder.WriteString("\n")
838 builder.WriteString(markdown)
839 }
840
841 return strings.TrimRight(builder.String(), "\n")
842}
843
844func formatRecipeSave(output SaveRecipeOutput) string {
845 return "Saved recipe (id: " + output.RecipeID + ")."
846}
847
848func formatRecipeDelete(output DeleteRecipeOutput) string {
849 return "Deleted recipe (id: " + output.RecipeID + ")."
850}
851
852func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
853 summaries := make([]RecipeSummary, 0, len(recipes))
854 for _, recipe := range recipes {
855 summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
856 }
857
858 return summaries
859}
860
861// ReadArguments contains read tool arguments.
862type ReadArguments struct {
863 Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
864 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
865 Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
866 Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
867 Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
868}
869
870// ReadOutput is the structured output for the current read tool slice.
871type ReadOutput struct {
872 Aisles []cooked.Aisle `json:"aisles,omitempty"`
873 ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
874 Recipes []RecipeSummary `json:"recipes,omitempty"`
875 Recipe *RecipeDetail `json:"recipe,omitempty"`
876}
877
878// RecipeSummary is the MCP-safe saved recipe summary.
879type RecipeSummary struct {
880 ID string `json:"id"`
881 Title string `json:"title"`
882}
883
884// RecipeDetail is the MCP-safe single recipe output.
885type RecipeDetail struct {
886 ID string `json:"id"`
887 Title string `json:"title"`
888 Owner string `json:"owner"`
889 EditPermission bool `json:"edit_permission"`
890 Content string `json:"content"`
891 Portions int `json:"portions"`
892}
893
894// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
895type PreviewRecipeTextArguments struct {
896 Title string `json:"title" jsonschema:"Recipe title for the preview."`
897 Text string `json:"text" jsonschema:"Raw recipe text to preview without saving."`
898}
899
900// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
901type PreviewRecipeTextOutput struct {
902 Title string `json:"title"`
903 Markdown string `json:"markdown"`
904 Portions int `json:"portions"`
905}
906
907// SaveRecipeArguments contains save_recipe tool arguments.
908type SaveRecipeArguments struct {
909 Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
910 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
911 Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."`
912 Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."`
913 Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
914 Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
915}
916
917// SaveRecipeOutput is the structured output for save_recipe.
918type SaveRecipeOutput struct {
919 RecipeID string `json:"recipe_id,omitempty"`
920}
921
922// DeleteRecipeArguments contains delete_recipe tool arguments.
923type DeleteRecipeArguments struct {
924 RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
925}
926
927// DeleteRecipeOutput is the structured output for delete_recipe.
928type DeleteRecipeOutput struct {
929 RecipeID string `json:"recipe_id"`
930}
931
932// ChangeShoppingListArguments contains change_shopping_list tool arguments.
933type ChangeShoppingListArguments struct {
934 Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, remove_selection, update_item, and clear."`
935 Ingredients string `json:"ingredients,omitempty" jsonschema:"Newline-separated ingredients for action add."`
936 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Optional saved recipe ID or import draft ID for action add."`
937 ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
938 ProductGroupID string `json:"product_group_id,omitempty" jsonschema:"Product group ID for action update_item."`
939 Name *string `json:"name,omitempty" jsonschema:"Replacement product name for action update_item."`
940 Quantity *string `json:"quantity,omitempty" jsonschema:"Replacement quantity for action update_item."`
941 AisleID *string `json:"aisle_id,omitempty" jsonschema:"Replacement aisle ID for action update_item."`
942 Selected *bool `json:"selected,omitempty" jsonschema:"Replacement selected state for action update_item."`
943}
944
945// ChangeShoppingListOutput is the structured output for change_shopping_list.
946type ChangeShoppingListOutput struct {
947 Cleared bool `json:"cleared,omitempty"`
948 AddedCount int `json:"added_count,omitempty"`
949 Ingredients []string `json:"ingredients,omitempty"`
950 RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"`
951 SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
952 UpdatedProductGroupID string `json:"updated_product_group_id,omitempty"`
953}