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