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