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