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