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