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