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 DeleteRecipe(ctx context.Context, recipeID string) error
38 ClearShoppingList(ctx context.Context) error
39 AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
40 RemoveShoppingListProductGroups(ctx context.Context, productGroupIDs []string) error
41 ReplaceShoppingListSelection(ctx context.Context, productGroupIDs []string) error
42}
43
44// Server exposes Cooked as an MCP server.
45type Server struct {
46 backend Backend
47 sdk *sdk.Server
48}
49
50// NewServer returns an MCP server for a Cooked backend.
51func NewServer(backend Backend, version string) *Server {
52 server := &Server{backend: backend}
53 server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
54 sdk.AddTool(server.sdk, readTool(), server.callReadTool)
55 sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
56 sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
57 sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
58 sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)
59
60 return server
61}
62
63// RunStdio serves MCP over the official SDK stdio transport.
64func (s *Server) RunStdio(ctx context.Context) error {
65 return s.sdk.Run(ctx, &sdk.StdioTransport{})
66}
67
68// CallReadTool invokes the read tool without asserting MCP wire presentation.
69func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
70 _, output, err := s.callReadTool(ctx, nil, arguments)
71 if err != nil {
72 return ReadOutput{}, err
73 }
74
75 return output, nil
76}
77
78func (s *Server) callReadTool(
79 ctx context.Context,
80 _ *sdk.CallToolRequest,
81 arguments ReadArguments,
82) (*sdk.CallToolResult, ReadOutput, error) {
83 switch arguments.Target {
84 case "shopping_list":
85 return s.callShoppingListReadTool(ctx)
86 case "recipe":
87 return s.callRecipeReadTool(ctx, arguments.RecipeID)
88 case "recipes":
89 return s.callRecipesReadTool(ctx, arguments)
90 default:
91 return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
92 }
93}
94
95func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
96 shoppingList, err := s.backend.ReadShoppingList(ctx)
97 if err != nil {
98 return nil, ReadOutput{}, err
99 }
100
101 output := ReadOutput{
102 Aisles: shoppingList.Aisles,
103 ShoppingListRecipes: shoppingList.Recipes,
104 }
105
106 return &sdk.CallToolResult{
107 Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
108 }, output, nil
109}
110
111func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
112 recipeID := strings.TrimSpace(rawRecipeID)
113 if recipeID == "" {
114 return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
115 }
116
117 metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
118 if err != nil {
119 return nil, ReadOutput{}, err
120 }
121 content, err := s.backend.ReadRecipeContent(ctx, recipeID)
122 if err != nil {
123 return nil, ReadOutput{}, err
124 }
125
126 recipe := RecipeDetail{
127 ID: recipeID,
128 Title: metadata.Title,
129 Owner: metadata.Owner,
130 EditPermission: metadata.EditPermission,
131 Content: content.Content,
132 Portions: content.Portions,
133 }
134 output := ReadOutput{Recipe: &recipe}
135
136 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
137}
138
139func (s *Server) callRecipesReadTool(
140 ctx context.Context,
141 arguments ReadArguments,
142) (*sdk.CallToolResult, ReadOutput, error) {
143 page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
144 recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
145 if err != nil {
146 return nil, ReadOutput{}, err
147 }
148
149 output := ReadOutput{Recipes: recipeSummaries(recipes)}
150
151 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
152}
153
154func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
155 if query == "" {
156 return s.backend.ListRecipes(ctx, page, limit)
157 }
158
159 recipes, err := s.backend.SearchRecipes(ctx, query, page)
160 if err != nil {
161 return nil, err
162 }
163 if len(recipes) > limit {
164 recipes = recipes[:limit]
165 }
166
167 return recipes, nil
168}
169
170func (s *Server) callPreviewRecipeTextTool(
171 ctx context.Context,
172 _ *sdk.CallToolRequest,
173 arguments PreviewRecipeTextArguments,
174) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
175 title := strings.TrimSpace(arguments.Title)
176 if title == "" {
177 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
178 }
179 if strings.TrimSpace(arguments.Text) == "" {
180 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
181 }
182
183 preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
184 if err != nil {
185 return nil, PreviewRecipeTextOutput{}, err
186 }
187
188 output := PreviewRecipeTextOutput{
189 Title: preview.Title,
190 Markdown: preview.Markdown,
191 Portions: preview.Portions,
192 }
193
194 return &sdk.CallToolResult{
195 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
196 }, output, nil
197}
198
199func (s *Server) callSaveRecipeTool(
200 ctx context.Context,
201 _ *sdk.CallToolRequest,
202 arguments SaveRecipeArguments,
203) (*sdk.CallToolResult, SaveRecipeOutput, error) {
204 source := strings.TrimSpace(arguments.Source)
205 switch source {
206 case "raw_text":
207 return s.callRawTextSaveRecipeTool(ctx, arguments)
208 case "prepared":
209 return s.callPreparedSaveRecipeTool(ctx, arguments)
210 case "existing":
211 return s.callExistingSaveRecipeTool(ctx, arguments)
212 case "":
213 return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
214 default:
215 return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
216 }
217}
218
219func (s *Server) callRawTextSaveRecipeTool(
220 ctx context.Context,
221 arguments SaveRecipeArguments,
222) (*sdk.CallToolResult, SaveRecipeOutput, error) {
223 title := strings.TrimSpace(arguments.Title)
224 if title == "" {
225 return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
226 }
227 if strings.TrimSpace(arguments.Text) == "" {
228 return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
229 }
230
231 preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
232 if err != nil {
233 return nil, SaveRecipeOutput{}, err
234 }
235 saveTitle := strings.TrimSpace(preview.Title)
236 if saveTitle == "" {
237 saveTitle = title
238 }
239 if strings.TrimSpace(preview.Markdown) == "" {
240 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
241 }
242 if preview.Portions < 1 {
243 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
244 }
245
246 return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
247}
248
249func (s *Server) callPreparedSaveRecipeTool(
250 ctx context.Context,
251 arguments SaveRecipeArguments,
252) (*sdk.CallToolResult, SaveRecipeOutput, error) {
253 title := strings.TrimSpace(arguments.Title)
254 if title == "" {
255 return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
256 }
257 if strings.TrimSpace(arguments.Markdown) == "" {
258 return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
259 }
260 if arguments.Portions < 1 {
261 return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
262 }
263
264 return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
265}
266
267func (s *Server) callExistingSaveRecipeTool(
268 ctx context.Context,
269 arguments SaveRecipeArguments,
270) (*sdk.CallToolResult, SaveRecipeOutput, error) {
271 recipeID := strings.TrimSpace(arguments.RecipeID)
272 if recipeID == "" {
273 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
274 }
275 if strings.TrimSpace(arguments.Markdown) == "" {
276 return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing")
277 }
278 if arguments.Portions < 1 {
279 return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing")
280 }
281
282 if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
283 return nil, SaveRecipeOutput{}, err
284 }
285
286 return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
287}
288
289func (s *Server) savePreparedRecipe(
290 ctx context.Context,
291 title, markdown string,
292 portions int,
293) (*sdk.CallToolResult, SaveRecipeOutput, error) {
294 recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
295 if err != nil {
296 return nil, SaveRecipeOutput{}, err
297 }
298 if strings.TrimSpace(recipeID) == "" {
299 return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
300 }
301
302 return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
303}
304
305func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
306 output := SaveRecipeOutput{RecipeID: recipeID}
307
308 return &sdk.CallToolResult{
309 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
310 }
311}
312
313func (s *Server) callDeleteRecipeTool(
314 ctx context.Context,
315 _ *sdk.CallToolRequest,
316 arguments DeleteRecipeArguments,
317) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
318 recipeID := strings.TrimSpace(arguments.RecipeID)
319 if recipeID == "" {
320 return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
321 }
322
323 if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
324 return nil, DeleteRecipeOutput{}, err
325 }
326
327 output := DeleteRecipeOutput{RecipeID: recipeID}
328
329 return &sdk.CallToolResult{
330 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(output)}},
331 }, output, nil
332}
333
334func (s *Server) callChangeShoppingListTool(
335 ctx context.Context,
336 _ *sdk.CallToolRequest,
337 arguments ChangeShoppingListArguments,
338) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
339 action := strings.TrimSpace(arguments.Action)
340 switch action {
341 case "add":
342 return s.addShoppingListIngredients(ctx, arguments)
343 case "clear":
344 return s.clearShoppingList(ctx)
345 case "remove":
346 return s.removeShoppingListProductGroups(ctx, arguments)
347 case "replace_selection":
348 return s.replaceShoppingListSelection(ctx, arguments)
349 case "add_selection":
350 return s.addShoppingListSelection(ctx, arguments)
351 case "":
352 return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
353 default:
354 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
355 "unsupported change_shopping_list action %q in this slice",
356 action,
357 )
358 }
359}
360
361func (s *Server) addShoppingListIngredients(
362 ctx context.Context,
363 arguments ChangeShoppingListArguments,
364) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
365 if strings.TrimSpace(arguments.Ingredients) == "" {
366 return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
367 }
368
369 result, err := s.backend.AddShoppingListIngredients(
370 ctx,
371 arguments.Ingredients,
372 strings.TrimSpace(arguments.RecipeID),
373 )
374 if err != nil {
375 return nil, ChangeShoppingListOutput{}, err
376 }
377
378 output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
379
380 return &sdk.CallToolResult{
381 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
382 "Added %d shopping-list ingredients.",
383 result.AddedCount,
384 )}},
385 }, output, nil
386}
387
388func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
389 if err := s.backend.ClearShoppingList(ctx); err != nil {
390 return nil, ChangeShoppingListOutput{}, err
391 }
392
393 output := ChangeShoppingListOutput{Cleared: true}
394
395 return &sdk.CallToolResult{
396 Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
397 }, output, nil
398}
399
400func (s *Server) removeShoppingListProductGroups(
401 ctx context.Context,
402 arguments ChangeShoppingListArguments,
403) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
404 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
405 if len(ids) == 0 {
406 return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
407 }
408
409 if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
410 return nil, ChangeShoppingListOutput{}, err
411 }
412
413 output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
414
415 return &sdk.CallToolResult{
416 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
417 "Removed %d shopping-list product groups.",
418 len(ids),
419 )}},
420 }, output, nil
421}
422
423func (s *Server) replaceShoppingListSelection(
424 ctx context.Context,
425 arguments ChangeShoppingListArguments,
426) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
427 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
428 if len(ids) == 0 {
429 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
430 "product_group_ids is required when action is replace_selection",
431 )
432 }
433
434 return s.setShoppingListSelection(ctx, ids)
435}
436
437func (s *Server) addShoppingListSelection(
438 ctx context.Context,
439 arguments ChangeShoppingListArguments,
440) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
441 ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
442 if len(ids) == 0 {
443 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
444 "product_group_ids is required when action is add_selection",
445 )
446 }
447
448 shoppingList, err := s.backend.ReadShoppingList(ctx)
449 if err != nil {
450 return nil, ChangeShoppingListOutput{}, err
451 }
452
453 return s.setShoppingListSelection(
454 ctx,
455 appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
456 )
457}
458
459func (s *Server) setShoppingListSelection(
460 ctx context.Context,
461 ids []string,
462) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
463 if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
464 return nil, ChangeShoppingListOutput{}, err
465 }
466
467 output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
468
469 return &sdk.CallToolResult{
470 Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
471 "Selected %d shopping-list product groups.",
472 len(ids),
473 )}},
474 }, output, nil
475}
476
477func readTool() *sdk.Tool {
478 openWorld := true
479 return &sdk.Tool{
480 Name: readToolName,
481 Title: "Read Cooked data",
482 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.",
483 Annotations: &sdk.ToolAnnotations{
484 Title: "Read Cooked data",
485 ReadOnlyHint: true,
486 OpenWorldHint: &openWorld,
487 },
488 }
489}
490
491func previewRecipeTextTool() *sdk.Tool {
492 openWorld := true
493 return &sdk.Tool{
494 Name: previewRecipeTextToolName,
495 Title: "Preview recipe text",
496 Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
497 Annotations: &sdk.ToolAnnotations{
498 Title: "Preview recipe text",
499 ReadOnlyHint: true,
500 OpenWorldHint: &openWorld,
501 },
502 }
503}
504
505func saveRecipeTool() *sdk.Tool {
506 openWorld := true
507 return &sdk.Tool{
508 Name: saveRecipeToolName,
509 Title: "Save recipe",
510 Description: "Save a recipe. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, and source existing with recipe_id, markdown, and portions. Other source values are reserved for later slices.",
511 Annotations: &sdk.ToolAnnotations{
512 Title: "Save recipe",
513 OpenWorldHint: &openWorld,
514 },
515 }
516}
517
518func deleteRecipeTool() *sdk.Tool {
519 destructive := true
520 openWorld := true
521 return &sdk.Tool{
522 Name: deleteRecipeToolName,
523 Title: "Delete recipe",
524 Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
525 Annotations: &sdk.ToolAnnotations{
526 Title: "Delete recipe",
527 DestructiveHint: &destructive,
528 OpenWorldHint: &openWorld,
529 },
530 }
531}
532
533func changeShoppingListTool() *sdk.Tool {
534 destructive := true
535 openWorld := true
536 return &sdk.Tool{
537 Name: changeShoppingListToolName,
538 Title: "Change shopping list",
539 Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove, replace_selection, or add_selection with product_group_ids, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.",
540 Annotations: &sdk.ToolAnnotations{
541 Title: "Change shopping list",
542 DestructiveHint: &destructive,
543 OpenWorldHint: &openWorld,
544 },
545 }
546}
547
548func normalizeRecipePage(page, limit int) (int, int) {
549 if page < 1 {
550 page = 1
551 }
552 if limit < 1 {
553 limit = 10
554 }
555 if limit > 30 {
556 limit = 30
557 }
558
559 return page, limit
560}
561
562func normalizeProductGroupIDs(ids []string) []string {
563 normalized := make([]string, 0, len(ids))
564 for _, id := range ids {
565 id = strings.TrimSpace(id)
566 if id != "" {
567 normalized = append(normalized, id)
568 }
569 }
570
571 return normalized
572}
573
574func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
575 var ids []string
576 for _, aisle := range shoppingList.Aisles {
577 for _, product := range aisle.ProductGroups {
578 if product.Selected {
579 ids = append(ids, product.ID)
580 }
581 }
582 }
583
584 return ids
585}
586
587func appendMissingProductGroupIDs(existing, additions []string) []string {
588 seen := make(map[string]struct{}, len(existing)+len(additions))
589 result := make([]string, 0, len(existing)+len(additions))
590
591 for _, id := range existing {
592 if _, ok := seen[id]; ok {
593 continue
594 }
595 seen[id] = struct{}{}
596 result = append(result, id)
597 }
598
599 for _, id := range additions {
600 if _, ok := seen[id]; ok {
601 continue
602 }
603 seen[id] = struct{}{}
604 result = append(result, id)
605 }
606
607 return result
608}
609
610func formatShoppingList(shoppingList cooked.ShoppingList) string {
611 if len(shoppingList.Aisles) == 0 {
612 return "Shopping list is empty."
613 }
614
615 var builder strings.Builder
616 builder.WriteString("Shopping list:\n")
617 for _, aisle := range shoppingList.Aisles {
618 builder.WriteString("- ")
619 builder.WriteString(aisle.Name)
620 builder.WriteString(":")
621 if len(aisle.ProductGroups) == 0 {
622 builder.WriteString(" no items\n")
623 continue
624 }
625 builder.WriteByte('\n')
626
627 for _, product := range aisle.ProductGroups {
628 builder.WriteString(" - ")
629 builder.WriteString(product.Name)
630 if product.Quantity != "" {
631 builder.WriteString(" — ")
632 builder.WriteString(product.Quantity)
633 }
634 builder.WriteString(" (id: ")
635 builder.WriteString(product.ID)
636 builder.WriteString(")")
637 if product.Selected {
638 builder.WriteString(" selected")
639 }
640 builder.WriteByte('\n')
641 }
642 }
643
644 return strings.TrimRight(builder.String(), "\n")
645}
646
647func formatRecipes(recipes []cooked.RecipeCard) string {
648 if len(recipes) == 0 {
649 return "No saved recipes found."
650 }
651
652 var builder strings.Builder
653 builder.WriteString("Saved recipes:\n")
654 for _, recipe := range recipes {
655 builder.WriteString("- ")
656 builder.WriteString(recipe.Title)
657 builder.WriteString(" (id: ")
658 builder.WriteString(recipe.ID)
659 builder.WriteString(")\n")
660 }
661
662 return strings.TrimRight(builder.String(), "\n")
663}
664
665func formatRecipe(recipe RecipeDetail) string {
666 title := recipe.Title
667 if title == "" {
668 title = "(untitled recipe)"
669 }
670
671 var builder strings.Builder
672 builder.WriteString("Recipe: ")
673 builder.WriteString(title)
674 builder.WriteString(" (id: ")
675 builder.WriteString(recipe.ID)
676 builder.WriteString(")\n")
677 if recipe.Owner != "" {
678 builder.WriteString("Owner: ")
679 builder.WriteString(recipe.Owner)
680 builder.WriteByte('\n')
681 }
682 fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
683 fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
684
685 content := strings.TrimSpace(recipe.Content)
686 if content == "" {
687 builder.WriteString("\nNo recipe content returned.")
688 } else {
689 builder.WriteString("\n")
690 builder.WriteString(content)
691 }
692
693 return strings.TrimRight(builder.String(), "\n")
694}
695
696func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
697 title := preview.Title
698 if title == "" {
699 title = "(untitled preview)"
700 }
701
702 var builder strings.Builder
703 builder.WriteString("Recipe text preview: ")
704 builder.WriteString(title)
705 builder.WriteByte('\n')
706 fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
707
708 markdown := strings.TrimSpace(preview.Markdown)
709 if markdown == "" {
710 builder.WriteString("\nNo preview markdown returned.")
711 } else {
712 builder.WriteString("\n")
713 builder.WriteString(markdown)
714 }
715
716 return strings.TrimRight(builder.String(), "\n")
717}
718
719func formatRecipeSave(output SaveRecipeOutput) string {
720 return "Saved recipe (id: " + output.RecipeID + ")."
721}
722
723func formatRecipeDelete(output DeleteRecipeOutput) string {
724 return "Deleted recipe (id: " + output.RecipeID + ")."
725}
726
727func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
728 summaries := make([]RecipeSummary, 0, len(recipes))
729 for _, recipe := range recipes {
730 summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
731 }
732
733 return summaries
734}
735
736// ReadArguments contains read tool arguments.
737type ReadArguments struct {
738 Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
739 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
740 Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
741 Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
742 Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
743}
744
745// ReadOutput is the structured output for the current read tool slice.
746type ReadOutput struct {
747 Aisles []cooked.Aisle `json:"aisles,omitempty"`
748 ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
749 Recipes []RecipeSummary `json:"recipes,omitempty"`
750 Recipe *RecipeDetail `json:"recipe,omitempty"`
751}
752
753// RecipeSummary is the MCP-safe saved recipe summary.
754type RecipeSummary struct {
755 ID string `json:"id"`
756 Title string `json:"title"`
757}
758
759// RecipeDetail is the MCP-safe single recipe output.
760type RecipeDetail struct {
761 ID string `json:"id"`
762 Title string `json:"title"`
763 Owner string `json:"owner"`
764 EditPermission bool `json:"edit_permission"`
765 Content string `json:"content"`
766 Portions int `json:"portions"`
767}
768
769// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
770type PreviewRecipeTextArguments struct {
771 Title string `json:"title" jsonschema:"Recipe title for the preview."`
772 Text string `json:"text" jsonschema:"Raw recipe text to preview without saving."`
773}
774
775// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
776type PreviewRecipeTextOutput struct {
777 Title string `json:"title"`
778 Markdown string `json:"markdown"`
779 Portions int `json:"portions"`
780}
781
782// SaveRecipeArguments contains save_recipe tool arguments.
783type SaveRecipeArguments struct {
784 Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
785 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
786 Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."`
787 Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."`
788 Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
789 Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
790}
791
792// SaveRecipeOutput is the structured output for save_recipe.
793type SaveRecipeOutput struct {
794 RecipeID string `json:"recipe_id,omitempty"`
795}
796
797// DeleteRecipeArguments contains delete_recipe tool arguments.
798type DeleteRecipeArguments struct {
799 RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
800}
801
802// DeleteRecipeOutput is the structured output for delete_recipe.
803type DeleteRecipeOutput struct {
804 RecipeID string `json:"recipe_id"`
805}
806
807// ChangeShoppingListArguments contains change_shopping_list tool arguments.
808type ChangeShoppingListArguments struct {
809 Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, and clear."`
810 Ingredients string `json:"ingredients,omitempty" jsonschema:"Newline-separated ingredients for action add."`
811 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Optional saved recipe ID or import draft ID for action add."`
812 ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, or add_selection."`
813}
814
815// ChangeShoppingListOutput is the structured output for change_shopping_list.
816type ChangeShoppingListOutput struct {
817 Cleared bool `json:"cleared,omitempty"`
818 AddedCount int `json:"added_count,omitempty"`
819 Ingredients []string `json:"ingredients,omitempty"`
820 RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"`
821 SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
822}