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