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