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