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