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