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