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