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