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 extractIntoRecipeToolName = "extract_into_recipe"
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, extractIntoRecipeTool(), server.callExtractIntoRecipeTool)
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) callExtractIntoRecipeTool(
218 ctx context.Context,
219 _ *sdk.CallToolRequest,
220 arguments ExtractIntoRecipeArguments,
221) (*sdk.CallToolResult, ExtractIntoRecipeOutput, error) {
222 title := strings.TrimSpace(arguments.Title)
223 if title == "" {
224 return nil, ExtractIntoRecipeOutput{}, fmt.Errorf("title is required")
225 }
226 if strings.TrimSpace(arguments.Text) == "" {
227 return nil, ExtractIntoRecipeOutput{}, fmt.Errorf("text is required")
228 }
229
230 extraction, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
231 if err != nil {
232 return nil, ExtractIntoRecipeOutput{}, err
233 }
234
235 output := ExtractIntoRecipeOutput{
236 Title: extraction.Title,
237 Markdown: extraction.Markdown,
238 Portions: extraction.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 newRecipeOverwriteResult(recipeID string) (*sdk.CallToolResult, SaveRecipeOutput, error) {
408 output := SaveRecipeOutput{RecipeID: recipeID}
409
410 return newToolResult(formatRecipeOverwrite(output), output), output, nil
411}
412
413func (s *Server) callDeleteRecipeTool(
414 ctx context.Context,
415 _ *sdk.CallToolRequest,
416 arguments DeleteRecipeArguments,
417) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
418 recipeID := strings.TrimSpace(arguments.RecipeID)
419 if recipeID == "" {
420 return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
421 }
422
423 if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
424 return nil, DeleteRecipeOutput{}, err
425 }
426
427 output := DeleteRecipeOutput{RecipeID: recipeID}
428
429 return newToolResult(formatRecipeDelete(output), output), output, nil
430}
431
432func (s *Server) callChangeShoppingListTool(
433 ctx context.Context,
434 _ *sdk.CallToolRequest,
435 arguments ChangeShoppingListArguments,
436) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
437 action := strings.TrimSpace(arguments.Action)
438 switch action {
439 case "add":
440 return s.addShoppingListIngredients(ctx, arguments)
441 case "clear":
442 return s.clearShoppingList(ctx)
443 case "remove":
444 return s.removeShoppingListProductGroups(ctx, arguments)
445 case "replace_selection":
446 return s.replaceShoppingListSelection(ctx, arguments)
447 case "add_selection":
448 return s.addShoppingListSelection(ctx, arguments)
449 case "remove_selection":
450 return s.removeShoppingListSelection(ctx, arguments)
451 case "update_item":
452 return s.updateShoppingListProductGroup(ctx, arguments)
453 case "":
454 return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
455 default:
456 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
457 "unsupported change_shopping_list action %q (supported: add, update_item, replace_selection, add_selection, remove_selection, remove, clear)",
458 action,
459 )
460 }
461}
462
463func (s *Server) addShoppingListIngredients(
464 ctx context.Context,
465 arguments ChangeShoppingListArguments,
466) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
467 ingredients := normalizeShoppingListAddIngredients(arguments.Ingredients)
468 if ingredients == "" {
469 return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
470 }
471
472 result, err := s.backend.AddShoppingListIngredients(
473 ctx,
474 ingredients,
475 strings.TrimSpace(arguments.RecipeID),
476 )
477 if err != nil {
478 return nil, ChangeShoppingListOutput{}, err
479 }
480
481 output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
482 text := fmt.Sprintf("Added %d shopping-list ingredients.", result.AddedCount)
483
484 return newToolResult(text, output), output, nil
485}
486
487func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
488 if err := s.backend.ClearShoppingList(ctx); err != nil {
489 return nil, ChangeShoppingListOutput{}, err
490 }
491
492 output := ChangeShoppingListOutput{Cleared: true}
493
494 return newToolResult("Cleared shopping list.", output), output, nil
495}
496
497func (s *Server) removeShoppingListProductGroups(
498 ctx context.Context,
499 arguments ChangeShoppingListArguments,
500) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
501 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
502 if len(ids) == 0 {
503 return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
504 }
505
506 if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
507 return nil, ChangeShoppingListOutput{}, err
508 }
509
510 output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
511 text := fmt.Sprintf("Removed %d shopping-list product groups.", len(ids))
512
513 return newToolResult(text, output), output, nil
514}
515
516func (s *Server) replaceShoppingListSelection(
517 ctx context.Context,
518 arguments ChangeShoppingListArguments,
519) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
520 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
521 if len(ids) == 0 {
522 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
523 "product_group_ids is required when action is replace_selection",
524 )
525 }
526
527 return s.setShoppingListSelection(ctx, ids)
528}
529
530func (s *Server) addShoppingListSelection(
531 ctx context.Context,
532 arguments ChangeShoppingListArguments,
533) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
534 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
535 if len(ids) == 0 {
536 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
537 "product_group_ids is required when action is add_selection",
538 )
539 }
540
541 shoppingList, err := s.backend.ReadShoppingList(ctx)
542 if err != nil {
543 return nil, ChangeShoppingListOutput{}, err
544 }
545
546 return s.setShoppingListSelection(
547 ctx,
548 appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
549 )
550}
551
552func (s *Server) removeShoppingListSelection(
553 ctx context.Context,
554 arguments ChangeShoppingListArguments,
555) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
556 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
557 if len(ids) == 0 {
558 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
559 "product_group_ids is required when action is remove_selection",
560 )
561 }
562
563 shoppingList, err := s.backend.ReadShoppingList(ctx)
564 if err != nil {
565 return nil, ChangeShoppingListOutput{}, err
566 }
567
568 return s.setShoppingListSelection(
569 ctx,
570 subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
571 )
572}
573
574func (s *Server) setShoppingListSelection(
575 ctx context.Context,
576 ids []string,
577) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
578 if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
579 return nil, ChangeShoppingListOutput{}, err
580 }
581
582 output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
583 text := fmt.Sprintf("Selected %d shopping-list product groups.", len(ids))
584
585 return newToolResult(text, output), output, nil
586}
587
588func (s *Server) updateShoppingListProductGroup(
589 ctx context.Context,
590 arguments ChangeShoppingListArguments,
591) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
592 productGroupID := strings.TrimSpace(arguments.ProductGroupID)
593 if productGroupID == "" {
594 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
595 "product_group_id is required when action is update_item",
596 )
597 }
598
599 shoppingList, err := s.backend.ReadShoppingList(ctx)
600 if err != nil {
601 return nil, ChangeShoppingListOutput{}, err
602 }
603
604 product, aisleID, ok := findShoppingListProductGroup(shoppingList, productGroupID)
605 if !ok {
606 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
607 "product_group_id %q was not found in the current shopping list",
608 productGroupID,
609 )
610 }
611
612 update := cooked.ShoppingListProductGroupUpdate{
613 Name: product.Name,
614 Quantity: product.Quantity,
615 AisleID: aisleID,
616 Selected: product.Selected,
617 }
618 if arguments.Name != nil {
619 update.Name = *arguments.Name
620 }
621 if arguments.Quantity != nil {
622 update.Quantity = *arguments.Quantity
623 }
624 if arguments.AisleID != nil {
625 update.AisleID = strings.TrimSpace(*arguments.AisleID)
626 }
627 if arguments.Selected != nil {
628 update.Selected = *arguments.Selected
629 }
630
631 if err := s.backend.UpdateShoppingListProductGroup(ctx, productGroupID, update); err != nil {
632 return nil, ChangeShoppingListOutput{}, err
633 }
634
635 output := ChangeShoppingListOutput{UpdatedProductGroupID: productGroupID}
636 text := fmt.Sprintf("Updated shopping-list product group (id: %s).", productGroupID)
637
638 return newToolResult(text, output), output, nil
639}
640
641func readTool() *sdk.Tool {
642 openWorld := true
643 return &sdk.Tool{
644 Name: readToolName,
645 Title: "Read Cooked data",
646 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.",
647 Annotations: &sdk.ToolAnnotations{
648 Title: "Read Cooked data",
649 ReadOnlyHint: true,
650 OpenWorldHint: &openWorld,
651 },
652 }
653}
654
655func extractIntoRecipeTool() *sdk.Tool {
656 openWorld := true
657 return &sdk.Tool{
658 Name: extractIntoRecipeToolName,
659 Title: "Extract raw recipe text into Cooked recipe",
660 Description: "Extract raw recipe text into Cooked recipe markdown and portions without saving or updating a recipe. Extraction rewrites ingredients and steps and infers portions; review the result before saving. If the extracted result is incorrect, save the correct version with source=prepared and your own markdown and portions.",
661 Annotations: &sdk.ToolAnnotations{
662 Title: "Extract raw recipe text into Cooked recipe",
663 ReadOnlyHint: true,
664 OpenWorldHint: &openWorld,
665 },
666 }
667}
668
669func saveRecipeTool() *sdk.Tool {
670 openWorld := true
671 return &sdk.Tool{
672 Name: saveRecipeToolName,
673 Title: "Save recipe",
674 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.",
675 Annotations: &sdk.ToolAnnotations{
676 Title: "Save recipe",
677 OpenWorldHint: &openWorld,
678 },
679 }
680}
681
682func deleteRecipeTool() *sdk.Tool {
683 destructive := true
684 openWorld := true
685 return &sdk.Tool{
686 Name: deleteRecipeToolName,
687 Title: "Delete recipe",
688 Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
689 Annotations: &sdk.ToolAnnotations{
690 Title: "Delete recipe",
691 DestructiveHint: &destructive,
692 OpenWorldHint: &openWorld,
693 },
694 }
695}
696
697func changeShoppingListTool() *sdk.Tool {
698 destructive := true
699 openWorld := true
700 return &sdk.Tool{
701 Name: changeShoppingListToolName,
702 Title: "Change shopping list",
703 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.",
704 InputSchema: changeShoppingListInputSchema(),
705 Annotations: &sdk.ToolAnnotations{
706 Title: "Change shopping list",
707 DestructiveHint: &destructive,
708 OpenWorldHint: &openWorld,
709 },
710 }
711}
712
713func changeShoppingListInputSchema() *jsonschema.Schema {
714 schema, err := jsonschema.For[ChangeShoppingListArguments](&jsonschema.ForOptions{
715 TypeSchemas: map[reflect.Type]*jsonschema.Schema{
716 reflect.TypeFor[ProductGroupIDs](): {
717 Type: "array",
718 Items: &jsonschema.Schema{Type: "string"},
719 },
720 },
721 })
722 if err != nil {
723 panic(fmt.Errorf("generate change_shopping_list input schema: %w", err))
724 }
725
726 return schema
727}
728
729func normalizeRecipePage(page, limit int) (int, int) {
730 if page < 1 {
731 page = 1
732 }
733 if limit < 1 {
734 limit = 10
735 }
736 if limit > 30 {
737 limit = 30
738 }
739
740 return page, limit
741}
742
743func normalizeProductGroupIDs(ids []string) []string {
744 normalized := make([]string, 0, len(ids))
745 for _, id := range ids {
746 id = strings.TrimSpace(id)
747 if id != "" {
748 normalized = append(normalized, id)
749 }
750 }
751
752 return normalized
753}
754
755func normalizeShoppingListAddIngredients(raw string) string {
756 var lines []string
757 for line := range strings.SplitSeq(raw, "\n") {
758 line = strings.TrimSpace(line)
759 if line == "" {
760 continue
761 }
762
763 lines = append(lines, normalizeShoppingListAddIngredientLine(line))
764 }
765
766 return strings.Join(lines, "\n")
767}
768
769func normalizeShoppingListAddIngredientLine(line string) string {
770 const separator = " — "
771
772 index := strings.LastIndex(line, separator)
773 if index < 0 {
774 return line
775 }
776
777 name := strings.TrimSpace(line[:index])
778 quantity := strings.TrimSpace(line[index+len(separator):])
779 if name == "" || quantity == "" || !startsWithDigit(quantity) {
780 return line
781 }
782
783 return quantity + " " + name
784}
785
786func startsWithDigit(value string) bool {
787 return value[0] >= '0' && value[0] <= '9'
788}
789
790func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
791 var ids []string
792 for _, aisle := range shoppingList.Aisles {
793 for _, product := range aisle.ProductGroups {
794 if product.Selected {
795 ids = append(ids, product.ID)
796 }
797 }
798 }
799
800 return ids
801}
802
803func appendMissingProductGroupIDs(existing, additions []string) []string {
804 seen := make(map[string]struct{}, len(existing)+len(additions))
805 result := make([]string, 0, len(existing)+len(additions))
806
807 for _, id := range existing {
808 if _, ok := seen[id]; ok {
809 continue
810 }
811 seen[id] = struct{}{}
812 result = append(result, id)
813 }
814
815 for _, id := range additions {
816 if _, ok := seen[id]; ok {
817 continue
818 }
819 seen[id] = struct{}{}
820 result = append(result, id)
821 }
822
823 return result
824}
825
826func subtractProductGroupIDs(existing, removals []string) []string {
827 removed := make(map[string]struct{}, len(removals))
828 for _, id := range removals {
829 removed[id] = struct{}{}
830 }
831
832 seen := make(map[string]struct{}, len(existing))
833 result := make([]string, 0, len(existing))
834 for _, id := range existing {
835 if _, ok := removed[id]; ok {
836 continue
837 }
838 if _, ok := seen[id]; ok {
839 continue
840 }
841 seen[id] = struct{}{}
842 result = append(result, id)
843 }
844
845 return result
846}
847
848func findShoppingListProductGroup(
849 shoppingList cooked.ShoppingList,
850 productGroupID string,
851) (cooked.ProductGroup, string, bool) {
852 for _, aisle := range shoppingList.Aisles {
853 for _, product := range aisle.ProductGroups {
854 if product.ID == productGroupID {
855 return product, aisle.ID, true
856 }
857 }
858 }
859
860 return cooked.ProductGroup{}, "", false
861}
862
863func formatShoppingList(shoppingList cooked.ShoppingList) string {
864 if len(shoppingList.Aisles) == 0 {
865 return "Shopping list is empty."
866 }
867
868 totalItems := shoppingListProductGroupCount(shoppingList)
869 var builder strings.Builder
870 if totalItems > maxShoppingListTextItems {
871 fmt.Fprintf(&builder, "Shopping list (showing first %d of %d items):\n",
872 maxShoppingListTextItems,
873 totalItems)
874 } else {
875 builder.WriteString("Shopping list:\n")
876 }
877
878 shownItems := 0
879 for _, aisle := range shoppingList.Aisles {
880 if shownItems >= maxShoppingListTextItems {
881 break
882 }
883 shownItems = writeShoppingListAisle(&builder, aisle, shownItems)
884 }
885 if totalItems > maxShoppingListTextItems {
886 fmt.Fprintf(
887 &builder,
888 "Additional shopping-list items are available in structured_content from this tool response: %d.\n",
889 totalItems-maxShoppingListTextItems,
890 )
891 }
892
893 return strings.TrimRight(builder.String(), "\n")
894}
895
896func writeShoppingListAisle(builder *strings.Builder, aisle cooked.Aisle, shownItems int) int {
897 builder.WriteString("- ")
898 builder.WriteString(aisle.Name)
899 builder.WriteString(":")
900 if len(aisle.ProductGroups) == 0 {
901 builder.WriteString(" no items\n")
902
903 return shownItems
904 }
905 builder.WriteByte('\n')
906
907 for _, product := range aisle.ProductGroups {
908 if shownItems >= maxShoppingListTextItems {
909 break
910 }
911
912 writeShoppingListProductGroup(builder, product)
913 shownItems++
914 }
915
916 return shownItems
917}
918
919func writeShoppingListProductGroup(builder *strings.Builder, product cooked.ProductGroup) {
920 builder.WriteString(" - ")
921 builder.WriteString(product.Name)
922 if product.Quantity != "" {
923 builder.WriteString(" — ")
924 builder.WriteString(product.Quantity)
925 }
926 builder.WriteString(" (id: ")
927 builder.WriteString(product.ID)
928 builder.WriteString(")")
929 if product.Selected {
930 builder.WriteString(" selected")
931 }
932 builder.WriteByte('\n')
933}
934
935func shoppingListProductGroupCount(shoppingList cooked.ShoppingList) int {
936 count := 0
937 for _, aisle := range shoppingList.Aisles {
938 count += len(aisle.ProductGroups)
939 }
940
941 return count
942}
943
944func formatRecipes(recipes []cooked.RecipeCard) string {
945 if len(recipes) == 0 {
946 return "No saved recipes found."
947 }
948
949 var builder strings.Builder
950 builder.WriteString("Saved recipes:\n")
951 for _, recipe := range recipes {
952 builder.WriteString("- ")
953 builder.WriteString(recipe.Title)
954 builder.WriteString(" (id: ")
955 builder.WriteString(recipe.ID)
956 builder.WriteString(")\n")
957 }
958
959 return strings.TrimRight(builder.String(), "\n")
960}
961
962func formatRecipeNextPageHint(query string, nextPage, limit int) string {
963 if query == "" {
964 return fmt.Sprintf(
965 "More recipes may be available. Call read with target recipes, page %d, limit %d to continue.",
966 nextPage,
967 limit,
968 )
969 }
970
971 return fmt.Sprintf(
972 "More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to continue.",
973 query,
974 nextPage,
975 limit,
976 )
977}
978
979func formatRecipeSearchLimitHint(query string, page, nextPage, limit int) string {
980 return fmt.Sprintf(
981 "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.",
982 query,
983 page,
984 limit,
985 nextPage,
986 )
987}
988
989func formatRecipe(recipe RecipeDetail) string {
990 title := recipe.Title
991 if title == "" {
992 title = "(untitled recipe)"
993 }
994
995 var builder strings.Builder
996 builder.WriteString("Recipe: ")
997 builder.WriteString(title)
998 builder.WriteString(" (id: ")
999 builder.WriteString(recipe.ID)
1000 builder.WriteString(")\n")
1001 if recipe.Owner != "" {
1002 builder.WriteString("Owner: ")
1003 builder.WriteString(recipe.Owner)
1004 builder.WriteByte('\n')
1005 }
1006 fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(recipe.Portions))
1007
1008 content := strings.TrimSpace(recipe.Content)
1009 if content == "" {
1010 builder.WriteString("\nNo recipe content returned.")
1011 } else {
1012 builder.WriteString("\n")
1013 builder.WriteString(content)
1014 }
1015
1016 return strings.TrimRight(builder.String(), "\n")
1017}
1018
1019func formatRecipeTextPreview(preview ExtractIntoRecipeOutput) string {
1020 title := preview.Title
1021 if title == "" {
1022 title = "(untitled extraction)"
1023 }
1024
1025 var builder strings.Builder
1026 builder.WriteString("Extracted recipe text: ")
1027 builder.WriteString(title)
1028 builder.WriteByte('\n')
1029 fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(preview.Portions))
1030
1031 markdown := strings.TrimSpace(preview.Markdown)
1032 if markdown == "" {
1033 builder.WriteString("\nNo preview markdown returned.")
1034 } else {
1035 builder.WriteString("\n")
1036 builder.WriteString(markdown)
1037 }
1038
1039 return strings.TrimRight(builder.String(), "\n")
1040}
1041
1042func formatPortions(portions float64) string { return strconv.FormatFloat(portions, 'f', -1, 64) }
1043
1044func formatRecipeSave(output SaveRecipeOutput) string {
1045 return "Saved recipe (id: " + output.RecipeID + ")."
1046}
1047
1048func formatRecipeOverwrite(output SaveRecipeOutput) string {
1049 return "Saved draft into existing recipe (id: " + output.RecipeID + ")."
1050}
1051
1052func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
1053 title := output.Title
1054 if strings.TrimSpace(title) == "" {
1055 title = "(untitled draft)"
1056 }
1057
1058 var builder strings.Builder
1059 builder.WriteString("Imported recipe URL as draft: ")
1060 builder.WriteString(title)
1061 builder.WriteString(" (draft_id: ")
1062 builder.WriteString(output.DraftID)
1063 builder.WriteString("). Review the markdown and portions before saving.")
1064 if output.Portions > 0 {
1065 fmt.Fprintf(&builder, "\nPortions: %s", formatPortions(output.Portions))
1066 }
1067 if strings.TrimSpace(output.Markdown) != "" {
1068 builder.WriteString("\n\n")
1069 builder.WriteString(output.Markdown)
1070 }
1071
1072 return strings.TrimRight(builder.String(), "\n")
1073}
1074
1075func formatRecipeDelete(output DeleteRecipeOutput) string {
1076 return "Deleted recipe (id: " + output.RecipeID + ")."
1077}
1078
1079func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
1080 summaries := make([]RecipeSummary, 0, len(recipes))
1081 for _, recipe := range recipes {
1082 summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
1083 }
1084
1085 return summaries
1086}
1087
1088// ReadArguments contains read tool arguments.
1089type ReadArguments struct {
1090 Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, recipe, or example_recipes."`
1091 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
1092 Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
1093 Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
1094 Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
1095}
1096
1097// ReadOutput is the structured output for the current read tool slice.
1098type ReadOutput struct {
1099 Aisles []cooked.Aisle `json:"aisles,omitempty"`
1100 ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
1101 Recipes []RecipeSummary `json:"recipes,omitempty"`
1102 Recipe *RecipeDetail `json:"recipe,omitempty"`
1103 ExampleRecipes string `json:"example_recipes,omitempty"`
1104}
1105
1106// RecipeSummary is the MCP-safe saved recipe summary.
1107type RecipeSummary struct {
1108 ID string `json:"id"`
1109 Title string `json:"title"`
1110}
1111
1112// RecipeDetail is the MCP-safe single recipe output.
1113type RecipeDetail struct {
1114 ID string `json:"id"`
1115 Title string `json:"title"`
1116 Owner string `json:"owner"`
1117 Content string `json:"content"`
1118 Portions float64 `json:"portions"`
1119}
1120
1121// ExtractIntoRecipeArguments contains extract_into_recipe tool arguments.
1122type ExtractIntoRecipeArguments struct {
1123 Title string `json:"title" jsonschema:"Recipe title for the extraction."`
1124 Text string `json:"text" jsonschema:"Raw recipe text to extract into Cooked recipe markdown without saving."`
1125}
1126
1127// ExtractIntoRecipeOutput is the structured output for extract_into_recipe.
1128type ExtractIntoRecipeOutput struct {
1129 Title string `json:"title"`
1130 Markdown string `json:"markdown"`
1131 Portions float64 `json:"portions"`
1132}
1133
1134// SaveRecipeArguments contains save_recipe tool arguments.
1135type SaveRecipeArguments struct {
1136 Source string `json:"source" jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
1137 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for source=existing updates or source=draft overwrite into an existing recipe."`
1138 DraftID string `json:"draft_id,omitempty" jsonschema:"Draft ID for reviewed URL import draft saves."`
1139 Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."`
1140 Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."`
1141 URL string `json:"url,omitempty" jsonschema:"Recipe URL for url imports."`
1142 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."`
1143 Portions float64 `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves, optional reviewed URL import draft content, or existing recipe updates."`
1144}
1145
1146// SaveRecipeOutput is the structured output for save_recipe.
1147type SaveRecipeOutput struct {
1148 RecipeID string `json:"recipe_id,omitempty"`
1149 DraftID string `json:"draft_id,omitempty"`
1150 Title string `json:"title,omitempty"`
1151 Markdown string `json:"markdown,omitempty"`
1152 Portions float64 `json:"portions,omitempty"`
1153}
1154
1155// DeleteRecipeArguments contains delete_recipe tool arguments.
1156type DeleteRecipeArguments struct {
1157 RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
1158}
1159
1160// DeleteRecipeOutput is the structured output for delete_recipe.
1161type DeleteRecipeOutput struct {
1162 RecipeID string `json:"recipe_id"`
1163}
1164
1165// ChangeShoppingListArguments contains change_shopping_list tool arguments.
1166type ChangeShoppingListArguments struct {
1167 Action string `json:"action" jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
1168 Ingredients string `json:"ingredients,omitempty" jsonschema:"Newline-separated ingredients for action add. Put quantities before names, for example 1 milk."`
1169 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Optional saved recipe ID or import draft ID for action add."`
1170 ProductGroupIDs ProductGroupIDs `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
1171 ProductGroupID string `json:"product_group_id,omitempty" jsonschema:"Product group ID for action update_item."`
1172 Name *string `json:"name,omitempty" jsonschema:"Replacement product name for action update_item."`
1173 Quantity *string `json:"quantity,omitempty" jsonschema:"Replacement quantity for action update_item."`
1174 AisleID *string `json:"aisle_id,omitempty" jsonschema:"Replacement aisle ID for action update_item."`
1175 Selected *bool `json:"selected,omitempty" jsonschema:"Replacement selected state for action update_item."`
1176}
1177
1178// ProductGroupIDs are Cooked shopping-list product group IDs used by bulk actions.
1179type ProductGroupIDs []string
1180
1181// ChangeShoppingListOutput is the structured output for change_shopping_list.
1182type ChangeShoppingListOutput struct {
1183 Cleared bool `json:"cleared,omitempty"`
1184 AddedCount int `json:"added_count,omitempty"`
1185 Ingredients []string `json:"ingredients,omitempty"`
1186 RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"`
1187 SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
1188 UpdatedProductGroupID string `json:"updated_product_group_id,omitempty"`
1189}