Detailed changes
@@ -25,7 +25,7 @@ components:
1: The extract_into_recipe tool extracts raw recipe text into Cooked recipe markdown and portions without saving a recipe.
1-1: Extracting raw recipe text requires a recipe title.
1-2: Extracting raw recipe text requires the raw recipe text.
- 1-3: Extracting raw recipe text accepts an optional portions hint for the extraction output.
+ 1-3: Extracting raw recipe text infers portions from Cooked's extraction output.
2: The extract_into_recipe tool returns the extracted title.
3: The extract_into_recipe tool returns extracted markdown.
4: The extract_into_recipe tool returns extracted portions.
@@ -92,8 +92,8 @@ constraints:
3: Recipe deletion requires an explicit recipe ID.
VALIDATION:
requirements:
- 1: Recipe IDs, draft IDs, and product group IDs are validated as UUIDs before calling Cooked.
- 2: A single malformed recipe ID, draft ID, or product group ID is rejected before calling Cooked.
+ 1: Recipe IDs and product group IDs are validated as UUIDs before calling Cooked.
+ 2: A single malformed recipe ID or product group ID is rejected before calling Cooked.
3: Malformed product group IDs in an array are reported and skipped rather than failing the whole call.
VERIFICATION:
requirements:
@@ -27,7 +27,7 @@ components:
requirements:
1: The extract_into_recipe tool requires title.
2: The extract_into_recipe tool requires text.
- 3: The extract_into_recipe tool accepts an optional portions hint.
+ 3: The extract_into_recipe tool describes that portions are inferred during extraction.
SAVE_RECIPE_TOOL:
requirements:
1: The save_recipe source values are raw_text, prepared, url, draft, and existing.
@@ -86,6 +86,6 @@ constraints:
2: Tool parameters validate bounded numeric inputs.
3: Tool descriptions state important behavioural limits and follow-up tools.
4: Tool schemas reject unsupported read targets, recipe save sources, and shopping-list actions before calling Cooked.
- 7: Tool schemas validate recipe IDs, draft IDs, and product group IDs as UUIDs before calling Cooked.
+ 7: Tool schemas validate recipe IDs and product group IDs as UUIDs before calling Cooked.
5: The shopping-list mutation tool description distinguishes destructive remove and clear actions from non-destructive actions.
6: The shopping-list mutation tool exposes product_group_ids as an optional array of strings that does not accept null.
@@ -9,7 +9,6 @@ import (
"context"
"fmt"
"reflect"
- "strconv"
"strings"
"github.com/google/jsonschema-go/jsonschema"
@@ -20,12 +19,14 @@ import (
const (
serverName = "Cooked"
+ defaultSaveRecipePortions = 1
maxShoppingListTextItems = 30
readToolName = "read"
extractIntoRecipeToolName = "extract_into_recipe"
saveRecipeToolName = "save_recipe"
deleteRecipeToolName = "delete_recipe"
changeShoppingListToolName = "change_shopping_list"
+ resolveRecipeIDToolName = "resolve_recipe_id"
)
// Backend provides the Cooked operations exposed as MCP tools.
@@ -67,6 +68,7 @@ func NewServer(backend Backend, version string) *Server {
sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)
+ sdk.AddTool(server.sdk, resolveRecipeIDTool(), server.callResolveRecipeIDTool)
return server
}
@@ -127,6 +129,9 @@ func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*s
if recipeID == "" {
return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
}
+ if err := validateRecipeID(recipeID); err != nil {
+ return nil, ReadOutput{}, err
+ }
metadata, content, err := s.readRecipeParts(ctx, recipeID)
if err != nil {
@@ -306,11 +311,12 @@ func (s *Server) callPreparedSaveRecipeTool(
if title == "" {
return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
}
- if err := validateSaveRecipeContent("prepared", arguments.Markdown, arguments.Portions); err != nil {
+ portions, err := normalizeSaveRecipeContent("prepared", arguments.Markdown, arguments.Portions)
+ if err != nil {
return nil, SaveRecipeOutput{}, err
}
- return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
+ return s.savePreparedRecipe(ctx, title, arguments.Markdown, portions)
}
func (s *Server) callURLSaveRecipeTool(
@@ -360,26 +366,33 @@ func (s *Server) callExistingSaveRecipeTool(
if recipeID == "" {
return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
}
- if err := validateSaveRecipeContent("existing", arguments.Markdown, arguments.Portions); err != nil {
+ if err := validateRecipeID(recipeID); err != nil {
+ return nil, SaveRecipeOutput{}, err
+ }
+ portions, err := normalizeSaveRecipeContent("existing", arguments.Markdown, arguments.Portions)
+ if err != nil {
return nil, SaveRecipeOutput{}, err
}
- if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
+ if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, portions); err != nil {
return nil, SaveRecipeOutput{}, err
}
return newRecipeSaveResult(recipeID)
}
-func validateSaveRecipeContent(source, markdown string, portions float64) error {
+func normalizeSaveRecipeContent(source, markdown string, portions float64) (float64, error) {
if strings.TrimSpace(markdown) == "" {
- return fmt.Errorf("markdown is required when source is %s", source)
+ return 0, fmt.Errorf("markdown is required when source is %s", source)
+ }
+ if portions == 0 {
+ return defaultSaveRecipePortions, nil
}
if portions < 1 {
- return fmt.Errorf("portions is required when source is %s", source)
+ return 0, fmt.Errorf("portions must be at least 1 when source is %s", source)
}
- return nil
+ return portions, nil
}
func (s *Server) savePreparedRecipe(
@@ -419,6 +432,9 @@ func (s *Server) callDeleteRecipeTool(
if recipeID == "" {
return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
}
+ if err := validateRecipeID(recipeID); err != nil {
+ return nil, DeleteRecipeOutput{}, err
+ }
if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
return nil, DeleteRecipeOutput{}, err
@@ -468,11 +484,12 @@ func (s *Server) addShoppingListIngredients(
if ingredients == "" {
return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
}
+ recipeID := strings.TrimSpace(arguments.RecipeID)
result, err := s.backend.AddShoppingListIngredients(
ctx,
ingredients,
- strings.TrimSpace(arguments.RecipeID),
+ recipeID,
)
if err != nil {
return nil, ChangeShoppingListOutput{}, err
@@ -498,8 +515,14 @@ func (s *Server) removeShoppingListProductGroups(
ctx context.Context,
arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
- ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+ ids, skippedIDs := normalizeProductGroupIDs(arguments.ProductGroupIDs)
if len(ids) == 0 {
+ if len(skippedIDs) > 0 {
+ result, output := skippedProductGroupIDsResult(skippedIDs)
+
+ return result, output, nil
+ }
+
return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
}
@@ -507,8 +530,9 @@ func (s *Server) removeShoppingListProductGroups(
return nil, ChangeShoppingListOutput{}, err
}
- output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
+ output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids, SkippedProductGroupIDs: skippedIDs}
text := fmt.Sprintf("Removed %d shopping-list product groups.", len(ids))
+ text = appendSkippedProductGroupIDs(text, skippedIDs)
return newToolResult(text, output), output, nil
}
@@ -517,22 +541,34 @@ func (s *Server) replaceShoppingListSelection(
ctx context.Context,
arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
- ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+ ids, skippedIDs := normalizeProductGroupIDs(arguments.ProductGroupIDs)
if len(ids) == 0 {
+ if len(skippedIDs) > 0 {
+ result, output := skippedProductGroupIDsResult(skippedIDs)
+
+ return result, output, nil
+ }
+
return nil, ChangeShoppingListOutput{}, fmt.Errorf(
"product_group_ids is required when action is replace_selection",
)
}
- return s.setShoppingListSelection(ctx, ids)
+ return s.setShoppingListSelection(ctx, ids, skippedIDs)
}
func (s *Server) addShoppingListSelection(
ctx context.Context,
arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
- ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+ ids, skippedIDs := normalizeProductGroupIDs(arguments.ProductGroupIDs)
if len(ids) == 0 {
+ if len(skippedIDs) > 0 {
+ result, output := skippedProductGroupIDsResult(skippedIDs)
+
+ return result, output, nil
+ }
+
return nil, ChangeShoppingListOutput{}, fmt.Errorf(
"product_group_ids is required when action is add_selection",
)
@@ -546,6 +582,7 @@ func (s *Server) addShoppingListSelection(
return s.setShoppingListSelection(
ctx,
appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
+ skippedIDs,
)
}
@@ -553,8 +590,14 @@ func (s *Server) removeShoppingListSelection(
ctx context.Context,
arguments ChangeShoppingListArguments,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
- ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
+ ids, skippedIDs := normalizeProductGroupIDs(arguments.ProductGroupIDs)
if len(ids) == 0 {
+ if len(skippedIDs) > 0 {
+ result, output := skippedProductGroupIDsResult(skippedIDs)
+
+ return result, output, nil
+ }
+
return nil, ChangeShoppingListOutput{}, fmt.Errorf(
"product_group_ids is required when action is remove_selection",
)
@@ -568,23 +611,33 @@ func (s *Server) removeShoppingListSelection(
return s.setShoppingListSelection(
ctx,
subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
+ skippedIDs,
)
}
func (s *Server) setShoppingListSelection(
ctx context.Context,
ids []string,
+ skippedIDs []string,
) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
return nil, ChangeShoppingListOutput{}, err
}
- output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
+ output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids, SkippedProductGroupIDs: skippedIDs}
text := fmt.Sprintf("Selected %d shopping-list product groups.", len(ids))
+ text = appendSkippedProductGroupIDs(text, skippedIDs)
return newToolResult(text, output), output, nil
}
+func skippedProductGroupIDsResult(skippedIDs []string) (*sdk.CallToolResult, ChangeShoppingListOutput) {
+ output := ChangeShoppingListOutput{SkippedProductGroupIDs: skippedIDs}
+ text := appendSkippedProductGroupIDs("No valid shopping-list product group IDs were provided.", skippedIDs)
+
+ return newToolResult(text, output), output
+}
+
func (s *Server) updateShoppingListProductGroup(
ctx context.Context,
arguments ChangeShoppingListArguments,
@@ -595,6 +648,9 @@ func (s *Server) updateShoppingListProductGroup(
"product_group_id is required when action is update_item",
)
}
+ if err := validateProductGroupID(productGroupID); err != nil {
+ return nil, ChangeShoppingListOutput{}, err
+ }
shoppingList, err := s.backend.ReadShoppingList(ctx)
if err != nil {
@@ -644,6 +700,7 @@ func readTool() *sdk.Tool {
Name: readToolName,
Title: "Read Cooked data",
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.",
+ InputSchema: readInputSchema(),
Annotations: &sdk.ToolAnnotations{
Title: "Read Cooked data",
ReadOnlyHint: true,
@@ -672,6 +729,7 @@ func saveRecipeTool() *sdk.Tool {
Name: saveRecipeToolName,
Title: "Save recipe",
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.",
+ InputSchema: saveRecipeInputSchema(),
Annotations: &sdk.ToolAnnotations{
Title: "Save recipe",
OpenWorldHint: &openWorld,
@@ -686,6 +744,7 @@ func deleteRecipeTool() *sdk.Tool {
Name: deleteRecipeToolName,
Title: "Delete recipe",
Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
+ InputSchema: deleteRecipeInputSchema(),
Annotations: &sdk.ToolAnnotations{
Title: "Delete recipe",
DestructiveHint: &destructive,
@@ -694,6 +753,27 @@ func deleteRecipeTool() *sdk.Tool {
}
}
+func readInputSchema() *jsonschema.Schema {
+ schema := inputSchemaFor[ReadArguments]("read input schema")
+ setPropertyPattern(schema, "recipe_id", uuidPattern)
+
+ return schema
+}
+
+func saveRecipeInputSchema() *jsonschema.Schema {
+ schema := inputSchemaFor[SaveRecipeArguments]("save_recipe input schema")
+ setPropertyPattern(schema, "recipe_id", uuidPattern)
+
+ return schema
+}
+
+func deleteRecipeInputSchema() *jsonschema.Schema {
+ schema := inputSchemaFor[DeleteRecipeArguments]("delete_recipe input schema")
+ setPropertyPattern(schema, "recipe_id", uuidPattern)
+
+ return schema
+}
+
func changeShoppingListTool() *sdk.Tool {
destructive := true
openWorld := true
@@ -722,10 +802,28 @@ func changeShoppingListInputSchema() *jsonschema.Schema {
if err != nil {
panic(fmt.Errorf("generate change_shopping_list input schema: %w", err))
}
+ setPropertyPattern(schema, "product_group_id", uuidPattern)
return schema
}
+func inputSchemaFor[T any](name string) *jsonschema.Schema {
+ schema, err := jsonschema.For[T](nil)
+ if err != nil {
+ panic(fmt.Errorf("generate %s: %w", name, err))
+ }
+
+ return schema
+}
+
+func setPropertyPattern(schema *jsonschema.Schema, propertyName, pattern string) {
+ property, ok := schema.Properties[propertyName]
+ if !ok {
+ panic(fmt.Errorf("%s schema property missing", propertyName))
+ }
+ property.Pattern = pattern
+}
+
func normalizeRecipePage(page, limit int) (int, int) {
if page < 1 {
page = 1
@@ -740,16 +838,30 @@ func normalizeRecipePage(page, limit int) (int, int) {
return page, limit
}
-func normalizeProductGroupIDs(ids []string) []string {
+func normalizeProductGroupIDs(ids []string) ([]string, []string) {
normalized := make([]string, 0, len(ids))
+ skipped := []string{}
for _, id := range ids {
id = strings.TrimSpace(id)
- if id != "" {
+ if id == "" {
+ continue
+ }
+ if uuidRegex.MatchString(id) {
normalized = append(normalized, id)
+ } else {
+ skipped = append(skipped, id)
}
}
- return normalized
+ return normalized, skipped
+}
+
+func appendSkippedProductGroupIDs(text string, skippedIDs []string) string {
+ if len(skippedIDs) == 0 {
+ return text
+ }
+
+ return fmt.Sprintf("%s Skipped malformed product_group_ids: %s.", text, strings.Join(skippedIDs, ", "))
}
func normalizeShoppingListAddIngredients(raw string) string {
@@ -941,141 +1053,6 @@ func shoppingListProductGroupCount(shoppingList cooked.ShoppingList) int {
return count
}
-func formatRecipes(recipes []cooked.RecipeCard) string {
- if len(recipes) == 0 {
- return "No saved recipes found."
- }
-
- var builder strings.Builder
- builder.WriteString("Saved recipes:\n")
- for _, recipe := range recipes {
- builder.WriteString("- ")
- builder.WriteString(recipe.Title)
- builder.WriteString(" (id: ")
- builder.WriteString(recipe.ID)
- builder.WriteString(")\n")
- }
-
- return strings.TrimRight(builder.String(), "\n")
-}
-
-func formatRecipeNextPageHint(query string, nextPage, limit int) string {
- if query == "" {
- return fmt.Sprintf(
- "More recipes may be available. Call read with target recipes, page %d, limit %d to continue.",
- nextPage,
- limit,
- )
- }
-
- return fmt.Sprintf(
- "More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to continue.",
- query,
- nextPage,
- limit,
- )
-}
-
-func formatRecipeSearchLimitHint(query string, page, nextPage, limit int) string {
- return fmt.Sprintf(
- "More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to include more results from this page before trying page %d.",
- query,
- page,
- limit,
- nextPage,
- )
-}
-
-func formatRecipe(recipe RecipeDetail) string {
- title := recipe.Title
- if title == "" {
- title = "(untitled recipe)"
- }
-
- var builder strings.Builder
- builder.WriteString("Recipe: ")
- builder.WriteString(title)
- builder.WriteString(" (id: ")
- builder.WriteString(recipe.ID)
- builder.WriteString(")\n")
- if recipe.Owner != "" {
- builder.WriteString("Owner: ")
- builder.WriteString(recipe.Owner)
- builder.WriteByte('\n')
- }
- fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(recipe.Portions))
-
- content := strings.TrimSpace(recipe.Content)
- if content == "" {
- builder.WriteString("\nNo recipe content returned.")
- } else {
- builder.WriteString("\n")
- builder.WriteString(content)
- }
-
- return strings.TrimRight(builder.String(), "\n")
-}
-
-func formatRecipeTextPreview(preview ExtractIntoRecipeOutput) string {
- title := preview.Title
- if title == "" {
- title = "(untitled extraction)"
- }
-
- var builder strings.Builder
- builder.WriteString("Extracted recipe text: ")
- builder.WriteString(title)
- builder.WriteByte('\n')
- fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(preview.Portions))
-
- markdown := strings.TrimSpace(preview.Markdown)
- if markdown == "" {
- builder.WriteString("\nNo preview markdown returned.")
- } else {
- builder.WriteString("\n")
- builder.WriteString(markdown)
- }
-
- return strings.TrimRight(builder.String(), "\n")
-}
-
-func formatPortions(portions float64) string { return strconv.FormatFloat(portions, 'f', -1, 64) }
-
-func formatRecipeSave(output SaveRecipeOutput) string {
- return "Saved recipe (id: " + output.RecipeID + ")."
-}
-
-func formatRecipeOverwrite(output SaveRecipeOutput) string {
- return "Saved draft into existing recipe (id: " + output.RecipeID + ")."
-}
-
-func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
- title := output.Title
- if strings.TrimSpace(title) == "" {
- title = "(untitled draft)"
- }
-
- var builder strings.Builder
- builder.WriteString("Imported recipe URL as draft: ")
- builder.WriteString(title)
- builder.WriteString(" (draft_id: ")
- builder.WriteString(output.DraftID)
- builder.WriteString("). Review the markdown and portions before saving.")
- if output.Portions > 0 {
- fmt.Fprintf(&builder, "\nPortions: %s", formatPortions(output.Portions))
- }
- if strings.TrimSpace(output.Markdown) != "" {
- builder.WriteString("\n\n")
- builder.WriteString(output.Markdown)
- }
-
- return strings.TrimRight(builder.String(), "\n")
-}
-
-func formatRecipeDelete(output DeleteRecipeOutput) string {
- return "Deleted recipe (id: " + output.RecipeID + ")."
-}
-
func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
summaries := make([]RecipeSummary, 0, len(recipes))
for _, recipe := range recipes {
@@ -1084,106 +1061,3 @@ func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
return summaries
}
-
-// ReadArguments contains read tool arguments.
-type ReadArguments struct {
- Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, recipe, or example_recipes."`
- RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
- Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
- Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
- Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
-}
-
-// ReadOutput is the structured output for the current read tool slice.
-type ReadOutput struct {
- Aisles []cooked.Aisle `json:"aisles,omitempty"`
- ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
- Recipes []RecipeSummary `json:"recipes,omitempty"`
- Recipe *RecipeDetail `json:"recipe,omitempty"`
- ExampleRecipes string `json:"example_recipes,omitempty"`
-}
-
-// RecipeSummary is the MCP-safe saved recipe summary.
-type RecipeSummary struct {
- ID string `json:"id"`
- Title string `json:"title"`
-}
-
-// RecipeDetail is the MCP-safe single recipe output.
-type RecipeDetail struct {
- ID string `json:"id"`
- Title string `json:"title"`
- Owner string `json:"owner"`
- Content string `json:"content"`
- Portions float64 `json:"portions"`
-}
-
-// ExtractIntoRecipeArguments contains extract_into_recipe tool arguments.
-type ExtractIntoRecipeArguments struct {
- Title string `json:"title" jsonschema:"Recipe title for the extraction."`
- Text string `json:"text" jsonschema:"Raw recipe text to extract into Cooked recipe markdown without saving."`
-}
-
-// ExtractIntoRecipeOutput is the structured output for extract_into_recipe.
-type ExtractIntoRecipeOutput struct {
- Title string `json:"title"`
- Markdown string `json:"markdown"`
- Portions float64 `json:"portions"`
-}
-
-// SaveRecipeArguments contains save_recipe tool arguments.
-type SaveRecipeArguments struct {
- Source string `json:"source" jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
- RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for source=existing updates or source=draft overwrite into an existing recipe."`
- DraftID string `json:"draft_id,omitempty" jsonschema:"Draft ID for reviewed URL import draft saves."`
- Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."`
- Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."`
- URL string `json:"url,omitempty" jsonschema:"Recipe URL for url imports."`
- Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves, optional reviewed URL import draft content, or existing recipe updates. Read target example_recipes first for Cooked's markdown dialect and examples."`
- Portions float64 `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves, optional reviewed URL import draft content, or existing recipe updates."`
-}
-
-// SaveRecipeOutput is the structured output for save_recipe.
-type SaveRecipeOutput struct {
- RecipeID string `json:"recipe_id,omitempty"`
- DraftID string `json:"draft_id,omitempty"`
- Title string `json:"title,omitempty"`
- Markdown string `json:"markdown,omitempty"`
- Portions float64 `json:"portions,omitempty"`
-}
-
-// DeleteRecipeArguments contains delete_recipe tool arguments.
-type DeleteRecipeArguments struct {
- RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
-}
-
-// DeleteRecipeOutput is the structured output for delete_recipe.
-type DeleteRecipeOutput struct {
- RecipeID string `json:"recipe_id"`
-}
-
-// ChangeShoppingListArguments contains change_shopping_list tool arguments.
-type ChangeShoppingListArguments struct {
- Action string `json:"action" jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
- Ingredients string `json:"ingredients,omitempty" jsonschema:"Newline-separated ingredients for action add. Put quantities before names, for example 1 milk."`
- RecipeID string `json:"recipe_id,omitempty" jsonschema:"Optional saved recipe ID or import draft ID for action add."`
- ProductGroupIDs ProductGroupIDs `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
- ProductGroupID string `json:"product_group_id,omitempty" jsonschema:"Product group ID for action update_item."`
- Name *string `json:"name,omitempty" jsonschema:"Replacement product name for action update_item."`
- Quantity *string `json:"quantity,omitempty" jsonschema:"Replacement quantity for action update_item."`
- AisleID *string `json:"aisle_id,omitempty" jsonschema:"Replacement aisle ID for action update_item."`
- Selected *bool `json:"selected,omitempty" jsonschema:"Replacement selected state for action update_item."`
-}
-
-// ProductGroupIDs are Cooked shopping-list product group IDs used by bulk actions.
-type ProductGroupIDs []string
-
-// ChangeShoppingListOutput is the structured output for change_shopping_list.
-type ChangeShoppingListOutput struct {
- Cleared bool `json:"cleared,omitempty"`
- AddedCount int `json:"added_count,omitempty"`
- Ingredients []string `json:"ingredients,omitempty"`
- RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"`
- SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
- UpdatedProductGroupID string `json:"updated_product_group_id,omitempty"`
-}
@@ -0,0 +1,212 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+ "context"
+
+ "git.secluded.site/cooked-mcp/internal/cooked"
+)
+
+const (
+ testRecipeID = "38f161dd-a514-4d5a-a924-0227695d9151"
+ testRecipeID2 = "38f161dd-a514-4d5a-a924-0227695d9152"
+ testOtherRecipeID = "8263f928-1111-4222-8333-123456789abc"
+ testDraftID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
+ testProductGroupID = "11111111-2222-4333-8444-555555555555"
+ testProductGroupID2 = "66666666-7777-4888-9999-aaaaaaaaaaaa"
+ testProductGroupID3 = "77777777-8888-4999-aaaa-bbbbbbbbbbbb"
+ testProductGroupID4 = "88888888-9999-4aaa-bbbb-cccccccccccc"
+)
+
+type fakeBackend struct {
+ shoppingList cooked.ShoppingList
+ recipes []cooked.RecipeCard
+ recipesByPage map[int][]cooked.RecipeCard
+ searchRecipes []cooked.RecipeCard
+ recipeMetadata cooked.RecipeMetadata
+ recipeContent cooked.RecipeContent
+ preview cooked.RecipeTextPreview
+ importRecipeURLResult cooked.RecipeURLImport
+ saveRecipeID string
+ draftSaveRecipeID string
+ page int
+ limit int
+ searchQuery string
+ searchPage int
+ metadataRecipeID string
+ contentRecipeID string
+ previewTitle string
+ previewText string
+ importURL string
+ saveTitle string
+ saveMarkdown string
+ savePortions float64
+ draftSaveID string
+ draftSaveMarkdown string
+ draftSavePortions float64
+ updateRecipeID string
+ updateMarkdown string
+ updatePortions float64
+ deleteRecipeID string
+ addShoppingListResult cooked.AddShoppingListResult
+ addIngredients string
+ addRecipeID string
+ removeProductGroupIDs []string
+ replaceSelectionProductGroupIDs []string
+ updateShoppingListProductGroupID string
+ updateShoppingListProductGroup cooked.ShoppingListProductGroupUpdate
+ metadataCalls int
+ listRecipeCalls int
+ readShoppingListCalls int
+ contentCalls int
+ previewCalls int
+ importRecipeURLCalls int
+ saveCalls int
+ draftSaveCalls int
+ updateCalls int
+ deleteCalls int
+ clearShoppingListCalls int
+ addShoppingListCalls int
+ removeShoppingListCalls int
+ replaceSelectionCalls int
+ updateShoppingListCalls int
+}
+
+func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
+ f.readShoppingListCalls++
+
+ return f.shoppingList, nil
+}
+
+func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
+ f.page = page
+ f.limit = limit
+ f.listRecipeCalls++
+ if f.recipesByPage != nil {
+ return f.recipesByPage[page], nil
+ }
+
+ return f.recipes, nil
+}
+
+func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
+ f.searchQuery = query
+ f.searchPage = page
+
+ return f.searchRecipes, nil
+}
+
+func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
+ f.metadataRecipeID = recipeID
+ f.metadataCalls++
+
+ return f.recipeMetadata, nil
+}
+
+func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
+ f.contentRecipeID = recipeID
+ f.contentCalls++
+
+ return f.recipeContent, nil
+}
+
+func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
+ f.previewTitle = title
+ f.previewText = text
+ f.previewCalls++
+
+ return f.preview, nil
+}
+
+func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions float64) (string, error) {
+ f.saveTitle = title
+ f.saveMarkdown = markdown
+ f.savePortions = portions
+ f.saveCalls++
+ if f.saveRecipeID != "" {
+ return f.saveRecipeID, nil
+ }
+
+ return testRecipeID, nil
+}
+
+func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown string, portions float64) (string, error) {
+ f.draftSaveID = draftID
+ f.draftSaveMarkdown = markdown
+ f.draftSavePortions = portions
+ f.draftSaveCalls++
+ if f.draftSaveRecipeID != "" {
+ return f.draftSaveRecipeID, nil
+ }
+
+ return testRecipeID, nil
+}
+
+func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions float64) error {
+ f.updateRecipeID = recipeID
+ f.updateMarkdown = markdown
+ f.updatePortions = portions
+ f.updateCalls++
+
+ return nil
+}
+
+func (f *fakeBackend) ImportRecipeURL(_ context.Context, recipeURL string) (cooked.RecipeURLImport, error) {
+ f.importURL = recipeURL
+ f.importRecipeURLCalls++
+
+ return f.importRecipeURLResult, nil
+}
+
+func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
+ f.deleteRecipeID = recipeID
+ f.deleteCalls++
+
+ return nil
+}
+
+func (f *fakeBackend) ClearShoppingList(context.Context) error {
+ f.clearShoppingListCalls++
+
+ return nil
+}
+
+func (f *fakeBackend) AddShoppingListIngredients(
+ _ context.Context,
+ ingredients, recipeID string,
+) (cooked.AddShoppingListResult, error) {
+ f.addIngredients = ingredients
+ f.addRecipeID = recipeID
+ f.addShoppingListCalls++
+
+ return f.addShoppingListResult, nil
+}
+
+func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
+ f.removeProductGroupIDs = ids
+ f.removeShoppingListCalls++
+
+ return nil
+}
+
+func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error {
+ f.replaceSelectionProductGroupIDs = ids
+ f.replaceSelectionCalls++
+
+ return nil
+}
+
+func (f *fakeBackend) UpdateShoppingListProductGroup(
+ _ context.Context,
+ productGroupID string,
+ update cooked.ShoppingListProductGroupUpdate,
+) error {
+ f.updateShoppingListProductGroupID = productGroupID
+ f.updateShoppingListProductGroup = update
+ f.updateShoppingListCalls++
+
+ return nil
+}
@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "git.secluded.site/cooked-mcp/internal/cooked"
+)
+
+func formatRecipes(recipes []cooked.RecipeCard) string {
+ if len(recipes) == 0 {
+ return "No saved recipes found."
+ }
+
+ var builder strings.Builder
+ builder.WriteString("Saved recipes:\n")
+ for _, recipe := range recipes {
+ builder.WriteString("- ")
+ builder.WriteString(recipe.Title)
+ builder.WriteString(" (id: ")
+ builder.WriteString(recipe.ID)
+ builder.WriteString(")\n")
+ }
+
+ return strings.TrimRight(builder.String(), "\n")
+}
+
+func formatRecipeNextPageHint(query string, nextPage, limit int) string {
+ if query == "" {
+ return fmt.Sprintf(
+ "More recipes may be available. Call read with target recipes, page %d, limit %d to continue.",
+ nextPage,
+ limit,
+ )
+ }
+
+ return fmt.Sprintf(
+ "More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to continue.",
+ query,
+ nextPage,
+ limit,
+ )
+}
+
+func formatRecipeSearchLimitHint(query string, page, nextPage, limit int) string {
+ return fmt.Sprintf(
+ "More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to include more results from this page before trying page %d.",
+ query,
+ page,
+ limit,
+ nextPage,
+ )
+}
+
+func formatRecipe(recipe RecipeDetail) string {
+ title := recipe.Title
+ if title == "" {
+ title = "(untitled recipe)"
+ }
+
+ var builder strings.Builder
+ builder.WriteString("Recipe: ")
+ builder.WriteString(title)
+ builder.WriteString(" (id: ")
+ builder.WriteString(recipe.ID)
+ builder.WriteString(")\n")
+ if recipe.Owner != "" {
+ builder.WriteString("Owner: ")
+ builder.WriteString(recipe.Owner)
+ builder.WriteByte('\n')
+ }
+ fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(recipe.Portions))
+
+ content := strings.TrimSpace(recipe.Content)
+ if content == "" {
+ builder.WriteString("\nNo recipe content returned.")
+ } else {
+ builder.WriteString("\n")
+ builder.WriteString(content)
+ }
+
+ return strings.TrimRight(builder.String(), "\n")
+}
+
+func formatRecipeTextPreview(preview ExtractIntoRecipeOutput) string {
+ title := preview.Title
+ if title == "" {
+ title = "(untitled extraction)"
+ }
+
+ var builder strings.Builder
+ builder.WriteString("Extracted recipe text: ")
+ builder.WriteString(title)
+ builder.WriteByte('\n')
+ fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(preview.Portions))
+
+ markdown := strings.TrimSpace(preview.Markdown)
+ if markdown == "" {
+ builder.WriteString("\nNo preview markdown returned.")
+ } else {
+ builder.WriteString("\n")
+ builder.WriteString(markdown)
+ }
+
+ return strings.TrimRight(builder.String(), "\n")
+}
+
+func formatPortions(portions float64) string { return strconv.FormatFloat(portions, 'f', -1, 64) }
+
+func formatRecipeSave(output SaveRecipeOutput) string {
+ return "Saved recipe (id: " + output.RecipeID + ")."
+}
+
+func formatRecipeOverwrite(output SaveRecipeOutput) string {
+ return "Saved draft into existing recipe (id: " + output.RecipeID + ")."
+}
+
+func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
+ title := output.Title
+ if strings.TrimSpace(title) == "" {
+ title = "(untitled draft)"
+ }
+
+ var builder strings.Builder
+ builder.WriteString("Imported recipe URL as draft: ")
+ builder.WriteString(title)
+ builder.WriteString(" (draft_id: ")
+ builder.WriteString(output.DraftID)
+ builder.WriteString("). Review the markdown and portions before saving.")
+ if output.Portions > 0 {
+ fmt.Fprintf(&builder, "\nPortions: %s", formatPortions(output.Portions))
+ }
+ if strings.TrimSpace(output.Markdown) != "" {
+ builder.WriteString("\n\n")
+ builder.WriteString(output.Markdown)
+ }
+
+ return strings.TrimRight(builder.String(), "\n")
+}
+
+func formatRecipeDelete(output DeleteRecipeOutput) string {
+ return "Deleted recipe (id: " + output.RecipeID + ")."
+}
@@ -0,0 +1,165 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+
+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+const uuidPattern = `^[ \t\r\n]*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}[ \t\r\n]*$`
+
+const (
+ uuidLength = len("00000000-0000-0000-0000-000000000000")
+ resolveRecipePageLimit = 30
+ maxResolveRecipePages = 100
+)
+
+var uuidRegex = regexp.MustCompile(uuidPattern)
+
+func validateRecipeID(recipeID string) error { return validateUUID("recipe_id", recipeID) }
+
+func validateProductGroupID(productGroupID string) error {
+ return validateUUID("product_group_id", productGroupID)
+}
+
+func validateUUID(field, value string) error {
+ if !uuidRegex.MatchString(strings.TrimSpace(value)) {
+ return fmt.Errorf("%s must be a UUID", field)
+ }
+
+ return nil
+}
+
+func validateUUIDPrefix(prefix string) error {
+ prefix = strings.TrimSpace(prefix)
+ if prefix == "" {
+ return fmt.Errorf("prefix is required")
+ }
+ if len(prefix) > uuidLength {
+ return fmt.Errorf("prefix must be a UUID prefix")
+ }
+ for index, char := range prefix {
+ switch index {
+ case 8, 13, 18, 23:
+ if char != '-' {
+ return fmt.Errorf("prefix must be a UUID prefix")
+ }
+ default:
+ if !isHex(char) {
+ return fmt.Errorf("prefix must be a UUID prefix")
+ }
+ }
+ }
+
+ return nil
+}
+
+func isHex(char rune) bool {
+ return char >= '0' && char <= '9' || char >= 'a' && char <= 'f' || char >= 'A' && char <= 'F'
+}
+
+func (s *Server) callResolveRecipeIDTool(
+ ctx context.Context,
+ _ *sdk.CallToolRequest,
+ arguments ResolveRecipeIDArguments,
+) (*sdk.CallToolResult, ResolveRecipeIDOutput, error) {
+ prefix := strings.TrimSpace(arguments.Prefix)
+ if err := validateUUIDPrefix(prefix); err != nil {
+ return nil, ResolveRecipeIDOutput{}, err
+ }
+
+ matches, err := s.resolveRecipeIDMatches(ctx, strings.ToLower(prefix))
+ if err != nil {
+ return nil, ResolveRecipeIDOutput{}, err
+ }
+
+ output := ResolveRecipeIDOutput{Recipes: matches}
+ if len(matches) > 1 {
+ output.Warning = "Multiple saved recipes match this UUID prefix. Use the full recipe_id before mutating or deleting a recipe."
+ }
+
+ return newToolResult(formatResolveRecipeID(prefix, output), output), output, nil
+}
+
+func (s *Server) resolveRecipeIDMatches(ctx context.Context, prefix string) ([]RecipeSummary, error) {
+ matches := []RecipeSummary{}
+ for page := 1; page <= maxResolveRecipePages+1; page++ {
+ recipes, err := s.backend.ListRecipes(ctx, page, resolveRecipePageLimit)
+ if err != nil {
+ return nil, err
+ }
+ if page > maxResolveRecipePages {
+ if len(recipes) > 0 {
+ return nil, fmt.Errorf(
+ "too many saved recipes to resolve recipe_id prefix; narrow the prefix or use the full recipe_id",
+ )
+ }
+
+ return matches, nil
+ }
+ for _, recipe := range recipes {
+ if strings.HasPrefix(strings.ToLower(recipe.ID), prefix) {
+ matches = append(matches, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
+ }
+ }
+ if len(recipes) < resolveRecipePageLimit {
+ return matches, nil
+ }
+ }
+
+ return matches, nil
+}
+
+func resolveRecipeIDTool() *sdk.Tool {
+ openWorld := true
+ return &sdk.Tool{
+ Name: resolveRecipeIDToolName,
+ Title: "Resolve recipe ID",
+ Description: "Resolve a UUID prefix to matching saved Cooked recipe IDs and titles. Use this when a recipe ID was copied partially or needs disambiguation.",
+ Annotations: &sdk.ToolAnnotations{
+ Title: "Resolve recipe ID",
+ ReadOnlyHint: true,
+ OpenWorldHint: &openWorld,
+ },
+ }
+}
+
+func formatResolveRecipeID(prefix string, output ResolveRecipeIDOutput) string {
+ if len(output.Recipes) == 0 {
+ return fmt.Sprintf("No saved recipes matched UUID prefix %q.", prefix)
+ }
+
+ var builder strings.Builder
+ fmt.Fprintf(&builder, "Saved recipes matching UUID prefix %q:\n", prefix)
+ for _, recipe := range output.Recipes {
+ builder.WriteString("- ")
+ builder.WriteString(recipe.Title)
+ builder.WriteString(" (id: ")
+ builder.WriteString(recipe.ID)
+ builder.WriteString(")\n")
+ }
+ if output.Warning != "" {
+ builder.WriteByte('\n')
+ builder.WriteString(output.Warning)
+ }
+
+ return strings.TrimRight(builder.String(), "\n")
+}
+
+// ResolveRecipeIDArguments contains resolve_recipe_id tool arguments.
+type ResolveRecipeIDArguments struct {
+ Prefix string `json:"prefix" jsonschema:"UUID prefix to resolve against saved recipe IDs."`
+}
+
+// ResolveRecipeIDOutput is the structured output for resolve_recipe_id.
+type ResolveRecipeIDOutput struct {
+ Recipes []RecipeSummary `json:"recipes,omitempty"`
+ Warning string `json:"warning,omitempty"`
+}
@@ -0,0 +1,323 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+ "context"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/google/jsonschema-go/jsonschema"
+
+ "git.secluded.site/cooked-mcp/internal/cooked"
+)
+
+// recipes.VALIDATION.1 recipes.VALIDATION.2 tools.SCHEMA.7
+func TestCallToolACIDRecipesValidation1And2RejectsMalformedRecipeReadIDBeforeCooked(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: "not-a-uuid"})
+ if err == nil {
+ t.Fatal("CallReadTool() error = nil, want malformed recipe_id error")
+ }
+ if backend.metadataCalls != 0 || backend.contentCalls != 0 {
+ t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
+ }
+}
+
+// recipes.VALIDATION.1 recipes.VALIDATION.2
+func TestCallToolACIDRecipesValidation1And2RejectsMalformedExistingRecipeIDBeforeCooked(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, _, err := server.callSaveRecipeTool(
+ context.Background(),
+ nil,
+ SaveRecipeArguments{Source: "existing", RecipeID: "not-a-uuid", Markdown: "# Pasta", Portions: 2},
+ )
+ if err == nil {
+ t.Fatal("callSaveRecipeTool() error = nil, want malformed recipe_id error")
+ }
+ if backend.updateCalls != 0 {
+ t.Fatalf("update calls = %d, want none", backend.updateCalls)
+ }
+}
+
+// recipes.VALIDATION.1 recipes.VALIDATION.2
+func TestCallToolACIDRecipesValidation1And2RejectsMalformedDeleteRecipeIDBeforeCooked(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, _, err := server.callDeleteRecipeTool(context.Background(), nil, DeleteRecipeArguments{RecipeID: "not-a-uuid"})
+ if err == nil {
+ t.Fatal("callDeleteRecipeTool() error = nil, want malformed recipe_id error")
+ }
+ if backend.deleteCalls != 0 {
+ t.Fatalf("delete calls = %d, want none", backend.deleteCalls)
+ }
+}
+
+// tools.SURFACE.9 tools.SCHEMA.7 recipes.RESOLVE.1 recipes.RESOLVE.3
+func TestCallToolACIDRecipesResolve1And3ResolvesRecipeIDPrefix(t *testing.T) {
+ backend := &fakeBackend{recipes: []cooked.RecipeCard{
+ {ID: testRecipeID, Title: "Pasta"},
+ {ID: testOtherRecipeID, Title: "Toast"},
+ }}
+ server := NewServer(backend, "test")
+
+ result, output, err := server.callResolveRecipeIDTool(
+ context.Background(),
+ nil,
+ ResolveRecipeIDArguments{Prefix: "38f161dd-a514"},
+ )
+ if err != nil {
+ t.Fatalf("callResolveRecipeIDTool() error = %v", err)
+ }
+
+ if backend.listRecipeCalls != 1 {
+ t.Fatalf("list recipe calls = %d, want 1", backend.listRecipeCalls)
+ }
+ if backend.page != 1 || backend.limit != resolveRecipePageLimit {
+ t.Fatalf("list page/limit = %d/%d, want 1/%d", backend.page, backend.limit, resolveRecipePageLimit)
+ }
+ if len(output.Recipes) != 1 || output.Recipes[0].ID != testRecipeID || output.Recipes[0].Title != "Pasta" {
+ t.Fatalf("resolve output = %#v, want Pasta match", output)
+ }
+ if !strings.Contains(requireTextContent(t, result), testRecipeID) {
+ t.Fatalf("text output = %q, want matching recipe ID", requireTextContent(t, result))
+ }
+}
+
+// recipes.RESOLVE.2
+func TestCallToolACIDRecipesResolve2RejectsMalformedPrefixBeforeCooked(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, _, err := server.callResolveRecipeIDTool(
+ context.Background(),
+ nil,
+ ResolveRecipeIDArguments{Prefix: "pasta"},
+ )
+ if err == nil {
+ t.Fatal("callResolveRecipeIDTool() error = nil, want malformed prefix error")
+ }
+ if backend.listRecipeCalls != 0 {
+ t.Fatalf("list recipe calls = %d, want none", backend.listRecipeCalls)
+ }
+}
+
+// recipes.RESOLVE.4
+func TestCallToolACIDRecipesResolve4WarnsWhenPrefixMatchesMultipleRecipes(t *testing.T) {
+ backend := &fakeBackend{recipes: []cooked.RecipeCard{
+ {ID: testRecipeID, Title: "Pasta"},
+ {ID: testRecipeID2, Title: "Tomato Pasta"},
+ }}
+ server := NewServer(backend, "test")
+
+ result, output, err := server.callResolveRecipeIDTool(
+ context.Background(),
+ nil,
+ ResolveRecipeIDArguments{Prefix: "38f161dd-a514-4d5a-a924-0227695d915"},
+ )
+ if err != nil {
+ t.Fatalf("callResolveRecipeIDTool() error = %v", err)
+ }
+ if len(output.Recipes) != 2 {
+ t.Fatalf("resolve matches = %d, want 2", len(output.Recipes))
+ }
+ if output.Warning == "" || !strings.Contains(requireTextContent(t, result), output.Warning) {
+ t.Fatalf("warning/text = %q/%q, want ambiguity warning", output.Warning, requireTextContent(t, result))
+ }
+}
+
+func TestCallToolACIDRecipesResolve4FailsWhenRecipeScanIsIncomplete(t *testing.T) {
+ fullPage := make([]cooked.RecipeCard, resolveRecipePageLimit)
+ for index := range fullPage {
+ fullPage[index] = cooked.RecipeCard{ID: testOtherRecipeID, Title: "Toast"}
+ }
+ pages := make(map[int][]cooked.RecipeCard, maxResolveRecipePages+1)
+ for page := 1; page <= maxResolveRecipePages+1; page++ {
+ pages[page] = fullPage
+ }
+ backend := &fakeBackend{recipesByPage: pages}
+ server := NewServer(backend, "test")
+
+ _, _, err := server.callResolveRecipeIDTool(
+ context.Background(),
+ nil,
+ ResolveRecipeIDArguments{Prefix: "38f161dd-a514"},
+ )
+ if err == nil {
+ t.Fatal("callResolveRecipeIDTool() error = nil, want incomplete scan error")
+ }
+ if !strings.Contains(err.Error(), "too many saved recipes") {
+ t.Fatalf("callResolveRecipeIDTool() error = %q, want scan cap error", err.Error())
+ }
+ if backend.listRecipeCalls != maxResolveRecipePages+1 {
+ t.Fatalf("list recipe calls = %d, want cap plus one", backend.listRecipeCalls)
+ }
+}
+
+func TestResolveRecipeIDToolACIDToolsSurface9ExposesReadOnlyOpenWorldTool(t *testing.T) {
+ tool := resolveRecipeIDTool()
+ if tool.Name != resolveRecipeIDToolName {
+ t.Fatalf("tool name = %q, want %q", tool.Name, resolveRecipeIDToolName)
+ }
+ if tool.Annotations == nil {
+ t.Fatal("tool annotations nil")
+ }
+ if !tool.Annotations.ReadOnlyHint {
+ t.Fatal("resolve_recipe_id read-only hint = false, want true")
+ }
+ if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
+ t.Fatalf("resolve_recipe_id open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
+ }
+ if tool.Annotations.Title == "" {
+ t.Fatal("resolve_recipe_id annotation title empty")
+ }
+}
+
+// tools.SCHEMA.7 recipes.VALIDATION.1 recipes.VALIDATION.2
+func TestInputSchemasACIDToolsSchema7RejectMalformedRecipeAndProductGroupIDs(t *testing.T) {
+ tests := []struct {
+ name string
+ schema *jsonschema.Schema
+ valid map[string]any
+ malformed map[string]any
+ }{
+ {
+ name: "read_recipe_id",
+ schema: readInputSchema(),
+ valid: map[string]any{"target": "recipe", "recipe_id": " " + testRecipeID + " "},
+ malformed: map[string]any{"target": "recipe", "recipe_id": "not-a-uuid"},
+ },
+ {
+ name: "save_recipe_id",
+ schema: saveRecipeInputSchema(),
+ valid: map[string]any{"source": "existing", "recipe_id": " " + testRecipeID + " "},
+ malformed: map[string]any{"source": "existing", "recipe_id": "not-a-uuid"},
+ },
+ {
+ name: "delete_recipe_id",
+ schema: deleteRecipeInputSchema(),
+ valid: map[string]any{"recipe_id": " " + testRecipeID + " "},
+ malformed: map[string]any{"recipe_id": "not-a-uuid"},
+ },
+ {
+ name: "change_product_group_id",
+ schema: changeShoppingListInputSchema(),
+ valid: map[string]any{"action": "update_item", "product_group_id": " " + testProductGroupID + " "},
+ malformed: map[string]any{"action": "update_item", "product_group_id": "not-a-uuid"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ resolved, err := tt.schema.Resolve(nil)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if err := resolved.Validate(tt.valid); err != nil {
+ t.Fatalf("Validate(valid) error = %v", err)
+ }
+ if err := resolved.Validate(tt.malformed); err == nil {
+ t.Fatal("Validate(malformed) error = nil, want UUID pattern rejection")
+ }
+ })
+ }
+}
+
+// tools.SCHEMA.7 recipes.VALIDATION.3
+func TestChangeShoppingListSchemaACIDToolsSchema7AllowsMalformedBulkProductGroupIDForSkipReporting(t *testing.T) {
+ resolved, err := changeShoppingListInputSchema().Resolve(nil)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ arguments := map[string]any{
+ "action": "remove",
+ "product_group_ids": []any{testProductGroupID, "not-a-uuid"},
+ }
+ if err := resolved.Validate(arguments); err != nil {
+ t.Fatalf("Validate() with malformed bulk product_group_ids error = %v", err)
+ }
+}
+
+// tools.SCHEMA.7
+func TestChangeShoppingListSchemaACIDToolsSchema7AllowsOpaqueAddRecipeID(t *testing.T) {
+ resolved, err := changeShoppingListInputSchema().Resolve(nil)
+ if err != nil {
+ t.Fatalf("Resolve() error = %v", err)
+ }
+ if err := resolved.Validate(
+ map[string]any{"action": "add", "ingredients": "200g pasta", "recipe_id": "draft-1"},
+ ); err != nil {
+ t.Fatalf("Validate() with opaque add recipe_id error = %v", err)
+ }
+}
+
+// recipes.VALIDATION.3
+func TestCallToolACIDRecipesValidation3SkipsMalformedProductGroupIDsInArrays(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ result, output, err := server.callChangeShoppingListTool(
+ context.Background(),
+ nil,
+ ChangeShoppingListArguments{
+ Action: "remove",
+ ProductGroupIDs: []string{testProductGroupID, "not-a-uuid", " ", testProductGroupID2},
+ },
+ )
+ if err != nil {
+ t.Fatalf("callChangeShoppingListTool() error = %v", err)
+ }
+ if !slices.Equal(backend.removeProductGroupIDs, []string{testProductGroupID, testProductGroupID2}) {
+ t.Fatalf("backend remove IDs = %#v, want valid IDs only", backend.removeProductGroupIDs)
+ }
+ if !slices.Equal(output.SkippedProductGroupIDs, []string{"not-a-uuid"}) {
+ t.Fatalf("skipped IDs = %#v, want malformed ID reported", output.SkippedProductGroupIDs)
+ }
+ if !strings.Contains(requireTextContent(t, result), "not-a-uuid") {
+ t.Fatalf("text output = %q, want skipped malformed ID", requireTextContent(t, result))
+ }
+}
+
+// recipes.VALIDATION.3
+func TestCallToolACIDRecipesValidation3ReportsMalformedOnlyProductGroupIDArraysWithoutMutation(t *testing.T) {
+ tests := []string{"remove", "replace_selection", "add_selection", "remove_selection"}
+
+ for _, action := range tests {
+ t.Run(action, func(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ result, output, err := server.callChangeShoppingListTool(
+ context.Background(),
+ nil,
+ ChangeShoppingListArguments{Action: action, ProductGroupIDs: []string{"not-a-uuid"}},
+ )
+ if err != nil {
+ t.Fatalf("callChangeShoppingListTool() error = %v", err)
+ }
+ if !slices.Equal(output.SkippedProductGroupIDs, []string{"not-a-uuid"}) {
+ t.Fatalf("skipped IDs = %#v, want malformed ID reported", output.SkippedProductGroupIDs)
+ }
+ if !strings.Contains(requireTextContent(t, result), "not-a-uuid") {
+ t.Fatalf("text output = %q, want skipped malformed ID", requireTextContent(t, result))
+ }
+ if backend.removeShoppingListCalls != 0 || backend.readShoppingListCalls != 0 ||
+ backend.replaceSelectionCalls != 0 {
+ t.Fatalf(
+ "backend calls remove/read/replace = %d/%d/%d, want none",
+ backend.removeShoppingListCalls,
+ backend.readShoppingListCalls,
+ backend.replaceSelectionCalls,
+ )
+ }
+ })
+ }
+}
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+ "context"
+ "testing"
+)
+
+// recipes.SAVE.2-3 tools.SAVE_RECIPE_TOOL.3
+func TestCallToolACIDRecipesSave2_3DefaultsPreparedPortionsToOne(t *testing.T) {
+ backend := &fakeBackend{saveRecipeID: testRecipeID}
+ server := NewServer(backend, "test")
+
+ _, output, err := server.callSaveRecipeTool(
+ context.Background(),
+ nil,
+ SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: "# Pasta\n\n1. Boil pasta."},
+ )
+ if err != nil {
+ t.Fatalf("callSaveRecipeTool() error = %v", err)
+ }
+
+ if backend.saveCalls != 1 {
+ t.Fatalf("save calls = %d, want 1", backend.saveCalls)
+ }
+ if backend.savePortions != 1 {
+ t.Fatalf("backend save portions = %g, want default 1", backend.savePortions)
+ }
+ if output.RecipeID != testRecipeID {
+ t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
+ }
+}
+
+// recipes.SAVE.8-3 tools.SAVE_RECIPE_TOOL.6
+func TestCallToolACIDRecipesSave8_3DefaultsExistingPortionsToOne(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, output, err := server.callSaveRecipeTool(
+ context.Background(),
+ nil,
+ SaveRecipeArguments{Source: "existing", RecipeID: testRecipeID, Markdown: "# Pasta\n\n1. Boil pasta."},
+ )
+ if err != nil {
+ t.Fatalf("callSaveRecipeTool() error = %v", err)
+ }
+
+ if backend.updateCalls != 1 {
+ t.Fatalf("update calls = %d, want 1", backend.updateCalls)
+ }
+ if backend.updatePortions != 1 {
+ t.Fatalf("backend update portions = %g, want default 1", backend.updatePortions)
+ }
+ if output.RecipeID != testRecipeID {
+ t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
+ }
+}
@@ -41,6 +41,9 @@ func (s *Server) callDraftSaveRecipeTool(
recipeID := strings.TrimSpace(arguments.RecipeID)
if recipeID != "" {
+ if err := validateRecipeID(recipeID); err != nil {
+ return nil, SaveRecipeOutput{}, err
+ }
if err := s.backend.UpdateRecipeContent(ctx, recipeID, markdown, portions); err != nil {
return nil, SaveRecipeOutput{}, err
}
@@ -13,7 +13,8 @@ import (
// recipes.SAVE.7 recipes.SAVE.7-2 recipes.SAVE.9 recipes.SAVE.16 recipes.SAFETY.1 tools.SAVE_RECIPE_TOOL.1 tools.SAVE_RECIPE_TOOL.5
func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) {
- backend := &fakeBackend{draftSaveRecipeID: "recipe-1"}
+ draftID := "draft/with space"
+ backend := &fakeBackend{draftSaveRecipeID: testRecipeID}
server := NewServer(backend, "test")
_, output, err := server.callSaveRecipeTool(
@@ -21,7 +22,7 @@ func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) {
nil,
SaveRecipeArguments{
Source: " draft ",
- DraftID: " draft-1 ",
+ DraftID: " " + draftID + " ",
Markdown: " # Pasta\n\n1. Boil pasta.\n",
Portions: 2,
},
@@ -33,8 +34,8 @@ func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) {
if backend.draftSaveCalls != 1 {
t.Fatalf("draft save calls = %d, want 1", backend.draftSaveCalls)
}
- if backend.draftSaveID != "draft-1" {
- t.Fatalf("backend draft ID = %q, want draft-1", backend.draftSaveID)
+ if backend.draftSaveID != draftID {
+ t.Fatalf("backend draft ID = %q, want draft ID", backend.draftSaveID)
}
if backend.draftSaveMarkdown != " # Pasta\n\n1. Boil pasta.\n" {
t.Fatalf("backend draft markdown = %q, want raw markdown preserved", backend.draftSaveMarkdown)
@@ -42,7 +43,7 @@ func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) {
if backend.draftSavePortions != 2 {
t.Fatalf("backend draft portions = %g, want 2", backend.draftSavePortions)
}
- if output.RecipeID != "recipe-1" || output.DraftID != "" || output.Markdown != "" || output.Portions != 0 {
+ if output.RecipeID != testRecipeID || output.DraftID != "" || output.Markdown != "" || output.Portions != 0 {
t.Fatalf("draft save output = %#v, want recipe_id only", output)
}
if backend.importRecipeURLCalls != 0 || backend.metadataCalls != 0 || backend.contentCalls != 0 {
@@ -68,8 +69,8 @@ func TestCallToolACIDRecipesSave7_5And9_1OverwritesExistingRecipeFromDraft(t *te
nil,
SaveRecipeArguments{
Source: " draft ",
- DraftID: " draft-1 ",
- RecipeID: " recipe-1 ",
+ DraftID: " " + testDraftID + " ",
+ RecipeID: " " + testRecipeID + " ",
Markdown: " # Pasta\n\n1. Boil pasta.\n",
Portions: 2,
},
@@ -81,8 +82,8 @@ func TestCallToolACIDRecipesSave7_5And9_1OverwritesExistingRecipeFromDraft(t *te
if backend.updateCalls != 1 {
t.Fatalf("update calls = %d, want 1", backend.updateCalls)
}
- if backend.updateRecipeID != "recipe-1" {
- t.Fatalf("backend update recipe ID = %q, want recipe-1", backend.updateRecipeID)
+ if backend.updateRecipeID != testRecipeID {
+ t.Fatalf("backend update recipe ID = %q, want test recipe ID", backend.updateRecipeID)
}
if backend.updateMarkdown != " # Pasta\n\n1. Boil pasta.\n" {
t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown)
@@ -90,8 +91,8 @@ func TestCallToolACIDRecipesSave7_5And9_1OverwritesExistingRecipeFromDraft(t *te
if backend.updatePortions != 2 {
t.Fatalf("backend update portions = %g, want 2", backend.updatePortions)
}
- if output.RecipeID != "recipe-1" {
- t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
+ if output.RecipeID != testRecipeID {
+ t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
}
if backend.draftSaveCalls != 0 || backend.saveCalls != 0 {
t.Fatalf("draft save/prepared calls = %d/%d, want none", backend.draftSaveCalls, backend.saveCalls)
@@ -101,7 +102,7 @@ func TestCallToolACIDRecipesSave7_5And9_1OverwritesExistingRecipeFromDraft(t *te
// recipes.SAVE.7 recipes.SAVE.7-3 recipes.SAVE.9 recipes.SAVE.16 tools.SAVE_RECIPE_TOOL.5
func TestCallToolACIDRecipesSave7_3SavesCurrentDraftContentWhenContentOmitted(t *testing.T) {
backend := &fakeBackend{
- draftSaveRecipeID: "recipe-1",
+ draftSaveRecipeID: testRecipeID,
recipeContent: cooked.RecipeContent{
Content: "# Pasta\n\n1. Boil pasta.",
Portions: 1.5,
@@ -112,14 +113,14 @@ func TestCallToolACIDRecipesSave7_3SavesCurrentDraftContentWhenContentOmitted(t
_, output, err := server.callSaveRecipeTool(
context.Background(),
nil,
- SaveRecipeArguments{Source: "draft", DraftID: " draft-1 "},
+ SaveRecipeArguments{Source: "draft", DraftID: " " + testDraftID + " "},
)
if err != nil {
t.Fatalf("callSaveRecipeTool() error = %v", err)
}
- if backend.contentCalls != 1 || backend.contentRecipeID != "draft-1" {
- t.Fatalf("content reads = %d for %q, want 1 for draft-1", backend.contentCalls, backend.contentRecipeID)
+ if backend.contentCalls != 1 || backend.contentRecipeID != testDraftID {
+ t.Fatalf("content reads = %d for %q, want 1 for test draft ID", backend.contentCalls, backend.contentRecipeID)
}
if backend.draftSaveCalls != 1 {
t.Fatalf("draft save calls = %d, want 1", backend.draftSaveCalls)
@@ -130,7 +131,7 @@ func TestCallToolACIDRecipesSave7_3SavesCurrentDraftContentWhenContentOmitted(t
if backend.draftSavePortions != 1.5 {
t.Fatalf("backend draft portions = %g, want current draft portions 1.5", backend.draftSavePortions)
}
- if output.RecipeID != "recipe-1" || output.DraftID != "" || output.Markdown != "" || output.Portions != 0 {
+ if output.RecipeID != testRecipeID || output.DraftID != "" || output.Markdown != "" || output.Portions != 0 {
t.Fatalf("draft save output = %#v, want recipe_id only", output)
}
if backend.metadataCalls != 0 || backend.importRecipeURLCalls != 0 {
@@ -154,11 +155,11 @@ func TestCallToolACIDRecipesSave7_1And7_4AndToolsSaveRecipeTool5RequiresDraftIDA
},
{
name: "markdown_without_portions",
- arguments: SaveRecipeArguments{Source: "draft", DraftID: "draft-1", Markdown: "# Pasta", Portions: 0},
+ arguments: SaveRecipeArguments{Source: "draft", DraftID: testDraftID, Markdown: "# Pasta", Portions: 0},
},
{
name: "portions_without_markdown",
- arguments: SaveRecipeArguments{Source: "draft", DraftID: "draft-1", Markdown: " ", Portions: 2},
+ arguments: SaveRecipeArguments{Source: "draft", DraftID: testDraftID, Markdown: " ", Portions: 2},
},
}
@@ -188,3 +189,27 @@ func TestCallToolACIDRecipesSave7_1And7_4AndToolsSaveRecipeTool5RequiresDraftIDA
})
}
}
+
+// recipes.VALIDATION.1 recipes.VALIDATION.2
+func TestCallToolACIDRecipesValidation1And2RejectsMalformedDraftOverwriteRecipeIDBeforeCooked(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, _, err := server.callSaveRecipeTool(
+ context.Background(),
+ nil,
+ SaveRecipeArguments{
+ Source: "draft",
+ DraftID: testDraftID,
+ RecipeID: "not-a-uuid",
+ Markdown: "# Pasta",
+ Portions: 2,
+ },
+ )
+ if err == nil {
+ t.Fatal("callSaveRecipeTool() error = nil, want malformed recipe_id error")
+ }
+ if backend.updateCalls != 0 || backend.draftSaveCalls != 0 {
+ t.Fatalf("update/draft save calls = %d/%d, want none", backend.updateCalls, backend.draftSaveCalls)
+ }
+}
@@ -13,7 +13,7 @@ import (
// recipes.SAVE.3 recipes.SAVE.4 recipes.SAVE.9 tools.SAVE_RECIPE_TOOL.1
func TestCallToolACIDRecipesSave3And4And9ImportsURLSavedRecipe(t *testing.T) {
- backend := &fakeBackend{importRecipeURLResult: cooked.RecipeURLImport{RecipeID: "recipe-1"}}
+ backend := &fakeBackend{importRecipeURLResult: cooked.RecipeURLImport{RecipeID: testRecipeID}}
server := NewServer(backend, "test")
_, output, err := server.callSaveRecipeTool(
@@ -31,7 +31,7 @@ func TestCallToolACIDRecipesSave3And4And9ImportsURLSavedRecipe(t *testing.T) {
if backend.importURL != "https://example.invalid/recipe" {
t.Fatalf("backend import URL = %q, want trimmed URL", backend.importURL)
}
- if output.RecipeID != "recipe-1" || output.DraftID != "" {
+ if output.RecipeID != testRecipeID || output.DraftID != "" {
t.Fatalf("save output = %#v, want recipe_id only", output)
}
if backend.metadataCalls != 0 || backend.contentCalls != 0 {
@@ -58,8 +58,9 @@ func TestCallToolACIDRecipesSave3_1AndToolsSaveRecipeTool4RequiresURL(t *testing
// recipes.SAVE.5 recipes.SAVE.6 recipes.SAVE.12 recipes.SAVE.14 recipes.SAVE.15 recipes.SAVE.16
func TestCallToolACIDRecipesSave5And12And14And15ReturnsURLImportDraft(t *testing.T) {
+ draftID := "draft-1"
backend := &fakeBackend{
- importRecipeURLResult: cooked.RecipeURLImport{DraftID: "draft-1"},
+ importRecipeURLResult: cooked.RecipeURLImport{DraftID: draftID},
recipeMetadata: cooked.RecipeMetadata{Title: "Pasta"},
recipeContent: cooked.RecipeContent{Content: "# Pasta\n\n1. Boil pasta.", Portions: 2},
}
@@ -80,9 +81,9 @@ func TestCallToolACIDRecipesSave5And12And14And15ReturnsURLImportDraft(t *testing
if backend.importURL != "https://example.invalid/pasta" {
t.Fatalf("backend import URL = %q, want trimmed URL", backend.importURL)
}
- if backend.metadataRecipeID != "draft-1" || backend.contentRecipeID != "draft-1" {
+ if backend.metadataRecipeID != draftID || backend.contentRecipeID != draftID {
t.Fatalf(
- "draft read IDs = metadata %q content %q, want draft-1",
+ "draft read IDs = metadata %q content %q, want draft ID",
backend.metadataRecipeID,
backend.contentRecipeID,
)
@@ -90,8 +91,8 @@ func TestCallToolACIDRecipesSave5And12And14And15ReturnsURLImportDraft(t *testing
if backend.metadataCalls != 1 || backend.contentCalls != 1 {
t.Fatalf("draft read calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
}
- if output.RecipeID != "" || output.DraftID != "draft-1" || output.Title != "Pasta" {
- t.Fatalf("draft output identity = %#v, want draft-1 Pasta with no recipe ID", output)
+ if output.RecipeID != "" || output.DraftID != draftID || output.Title != "Pasta" {
+ t.Fatalf("draft output identity = %#v, want draft ID Pasta with no recipe ID", output)
}
if output.Markdown != "# Pasta\n\n1. Boil pasta." || output.Portions != 2 {
t.Fatalf("draft output content = %q/%g, want markdown/2", output.Markdown, output.Portions)
@@ -46,7 +46,7 @@ func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) {
func TestCallToolACIDRecipesRead1ListsRecipes(t *testing.T) {
backend := &fakeBackend{
recipes: []cooked.RecipeCard{
- {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
+ {ID: testRecipeID, Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
},
}
server := NewServer(backend, "test")
@@ -65,8 +65,8 @@ func TestCallToolACIDRecipesRead1ListsRecipes(t *testing.T) {
if len(output.Recipes) != 1 {
t.Fatalf("structured recipes = %d, want 1", len(output.Recipes))
}
- if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
- t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
+ if output.Recipes[0].ID != testRecipeID || output.Recipes[0].Title != "Pasta" {
+ t.Fatalf("first recipe = %#v, want test recipe ID Pasta", output.Recipes[0])
}
}
@@ -89,8 +89,8 @@ func TestCallToolACIDRecipesPagination4DefaultsAndCapsRecipeLimit(t *testing.T)
func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
backend := &fakeBackend{searchRecipes: []cooked.RecipeCard{
- {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
- {ID: "recipe-2", Title: "Tomato Pasta", ThumbnailURL: "https://example.invalid/thumb2.jpg"},
+ {ID: testRecipeID, Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
+ {ID: testOtherRecipeID, Title: "Tomato Pasta", ThumbnailURL: "https://example.invalid/thumb2.jpg"},
}}
server := NewServer(backend, "test")
@@ -113,8 +113,8 @@ func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
if len(output.Recipes) != 1 {
t.Fatalf("structured recipes = %d, want truncated result length 1", len(output.Recipes))
}
- if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
- t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
+ if output.Recipes[0].ID != testRecipeID || output.Recipes[0].Title != "Pasta" {
+ t.Fatalf("first recipe = %#v, want test recipe ID Pasta", output.Recipes[0])
}
}
@@ -137,7 +137,7 @@ func TestCallToolACIDRecipesRead5And8_1ReadsSingleRecipe(t *testing.T) {
result, output, err := server.callReadTool(
context.Background(),
nil,
- ReadArguments{Target: "recipe", RecipeID: "recipe-1"},
+ ReadArguments{Target: "recipe", RecipeID: testRecipeID},
)
if err != nil {
t.Fatalf("callReadTool() error = %v", err)
@@ -152,9 +152,9 @@ func TestCallToolACIDRecipesRead5And8_1ReadsSingleRecipe(t *testing.T) {
func requireSingleRecipeBackendCalls(t *testing.T, backend *fakeBackend) {
t.Helper()
- if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
+ if backend.metadataRecipeID != testRecipeID || backend.contentRecipeID != testRecipeID {
t.Fatalf(
- "backend recipe IDs = metadata %q content %q, want recipe-1",
+ "backend recipe IDs = metadata %q content %q, want test recipe ID",
backend.metadataRecipeID,
backend.contentRecipeID,
)
@@ -170,8 +170,8 @@ func requireSingleRecipeOutput(t *testing.T, output ReadOutput) {
if output.Recipe == nil {
t.Fatal("structured recipe is nil")
}
- if output.Recipe.ID != "recipe-1" || output.Recipe.Title != "Pasta" {
- t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
+ if output.Recipe.ID != testRecipeID || output.Recipe.Title != "Pasta" {
+ t.Fatalf("structured recipe identity = %#v, want test recipe ID Pasta", output.Recipe)
}
if output.Recipe.Owner != "returned-user" {
t.Fatalf("structured recipe owner = %q, want returned-user", output.Recipe.Owner)
@@ -286,7 +286,7 @@ func TestCallToolACIDToolsExtractIntoRecipeTool2RequiresText(t *testing.T) {
// recipes.SAVE.2 recipes.SAVE.8-4 recipes.SAVE.9
func TestCallToolACIDRecipesSave2And8_4And9SavesPreparedRecipe(t *testing.T) {
- backend := &fakeBackend{saveRecipeID: "recipe-1"}
+ backend := &fakeBackend{saveRecipeID: testRecipeID}
server := NewServer(backend, "test")
result, output, err := server.callSaveRecipeTool(
@@ -315,13 +315,13 @@ func TestCallToolACIDRecipesSave2And8_4And9SavesPreparedRecipe(t *testing.T) {
if backend.savePortions != 2.5 {
t.Fatalf("backend save portions = %g, want 2.5", backend.savePortions)
}
- if output.RecipeID != "recipe-1" {
- t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
+ if output.RecipeID != testRecipeID {
+ t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
}
requireStructuredContent(t, result, output)
}
-func TestCallToolACIDRecipesSave2_1To2_3RequiresPreparedFields(t *testing.T) {
+func TestCallToolACIDRecipesSave2_1To2_2RequiresPreparedTitleAndMarkdown(t *testing.T) {
tests := []struct {
name string
arguments SaveRecipeArguments
@@ -334,10 +334,6 @@ func TestCallToolACIDRecipesSave2_1To2_3RequiresPreparedFields(t *testing.T) {
name: "markdown",
arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: " ", Portions: 2},
},
- {
- name: "portions",
- arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: "# Pasta", Portions: 0},
- },
}
for _, tt := range tests {
@@ -363,7 +359,7 @@ func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) {
Markdown: "# Pasta\n\n1. Boil pasta.",
Portions: 2,
},
- saveRecipeID: "recipe-1",
+ saveRecipeID: testRecipeID,
}
server := NewServer(backend, "test")
@@ -394,8 +390,8 @@ func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) {
if backend.savePortions != 2 {
t.Fatalf("save portions = %g, want preview portions 2", backend.savePortions)
}
- if output.RecipeID != "recipe-1" {
- t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
+ if output.RecipeID != testRecipeID {
+ t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
}
}
@@ -434,7 +430,7 @@ func TestCallToolACIDRecipesSave8And8_4And9UpdatesExistingRecipe(t *testing.T) {
nil,
SaveRecipeArguments{
Source: " existing ",
- RecipeID: " recipe-1 ",
+ RecipeID: " " + testRecipeID + " ",
Markdown: " # Pasta\n\n1. Boil pasta.\n",
Portions: 4.5,
},
@@ -446,8 +442,8 @@ func TestCallToolACIDRecipesSave8And8_4And9UpdatesExistingRecipe(t *testing.T) {
if backend.updateCalls != 1 {
t.Fatalf("update calls = %d, want 1", backend.updateCalls)
}
- if backend.updateRecipeID != "recipe-1" {
- t.Fatalf("backend update recipe ID = %q, want recipe-1", backend.updateRecipeID)
+ if backend.updateRecipeID != testRecipeID {
+ t.Fatalf("backend update recipe ID = %q, want test recipe ID", backend.updateRecipeID)
}
if backend.updateMarkdown != " # Pasta\n\n1. Boil pasta.\n" {
t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown)
@@ -455,12 +451,12 @@ func TestCallToolACIDRecipesSave8And8_4And9UpdatesExistingRecipe(t *testing.T) {
if backend.updatePortions != 4.5 {
t.Fatalf("backend update portions = %g, want 4.5", backend.updatePortions)
}
- if output.RecipeID != "recipe-1" {
- t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
+ if output.RecipeID != testRecipeID {
+ t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
}
}
-func TestCallToolACIDRecipesSave8_1To8_3RequiresExistingFields(t *testing.T) {
+func TestCallToolACIDRecipesSave8_1To8_2RequiresExistingRecipeIDAndMarkdown(t *testing.T) {
tests := []struct {
name string
arguments SaveRecipeArguments
@@ -471,11 +467,7 @@ func TestCallToolACIDRecipesSave8_1To8_3RequiresExistingFields(t *testing.T) {
},
{
name: "markdown",
- arguments: SaveRecipeArguments{Source: "existing", RecipeID: "recipe-1", Markdown: " ", Portions: 4},
- },
- {
- name: "portions",
- arguments: SaveRecipeArguments{Source: "existing", RecipeID: "recipe-1", Markdown: "# Pasta", Portions: 0},
+ arguments: SaveRecipeArguments{Source: "existing", RecipeID: testRecipeID, Markdown: " ", Portions: 4},
},
}
@@ -502,7 +494,7 @@ func TestCallToolACIDRecipesDelete1And2DeletesRecipe(t *testing.T) {
result, output, err := server.callDeleteRecipeTool(
context.Background(),
nil,
- DeleteRecipeArguments{RecipeID: " recipe-1 "},
+ DeleteRecipeArguments{RecipeID: " " + testRecipeID + " "},
)
if err != nil {
t.Fatalf("callDeleteRecipeTool() error = %v", err)
@@ -511,11 +503,11 @@ func TestCallToolACIDRecipesDelete1And2DeletesRecipe(t *testing.T) {
if backend.deleteCalls != 1 {
t.Fatalf("delete calls = %d, want 1", backend.deleteCalls)
}
- if backend.deleteRecipeID != "recipe-1" {
- t.Fatalf("backend delete recipe ID = %q, want recipe-1", backend.deleteRecipeID)
+ if backend.deleteRecipeID != testRecipeID {
+ t.Fatalf("backend delete recipe ID = %q, want test recipe ID", backend.deleteRecipeID)
}
- if output.RecipeID != "recipe-1" {
- t.Fatalf("delete output recipe ID = %q, want recipe-1", output.RecipeID)
+ if output.RecipeID != testRecipeID {
+ t.Fatalf("delete output recipe ID = %q, want test recipe ID", output.RecipeID)
}
requireStructuredContent(t, result, output)
}
@@ -623,7 +615,7 @@ func TestCallToolACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
ChangeShoppingListArguments{
Action: " add ",
Ingredients: "200g pasta\n1 cup tomato sauce",
- RecipeID: " recipe-1 ",
+ RecipeID: " draft-1 ",
},
)
if err != nil {
@@ -636,8 +628,8 @@ func TestCallToolACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
if backend.addIngredients != "200g pasta\n1 cup tomato sauce" {
t.Fatalf("backend ingredients = %q, want raw multiline ingredients", backend.addIngredients)
}
- if backend.addRecipeID != "recipe-1" {
- t.Fatalf("backend recipe ID = %q, want recipe-1", backend.addRecipeID)
+ if backend.addRecipeID != "draft-1" {
+ t.Fatalf("backend recipe ID = %q, want trimmed opaque draft ID", backend.addRecipeID)
}
if output.AddedCount != 2 {
t.Fatalf("output added count = %d, want 2", output.AddedCount)
@@ -673,7 +665,7 @@ func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) {
nil,
ChangeShoppingListArguments{
Action: " remove ",
- ProductGroupIDs: []string{" pasta ", "tomato", " "},
+ ProductGroupIDs: []string{" " + testProductGroupID + " ", testProductGroupID2, " "},
},
)
if err != nil {
@@ -683,13 +675,13 @@ func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) {
if backend.removeShoppingListCalls != 1 {
t.Fatalf("remove calls = %d, want 1", backend.removeShoppingListCalls)
}
- if len(backend.removeProductGroupIDs) != 2 || backend.removeProductGroupIDs[0] != "pasta" ||
- backend.removeProductGroupIDs[1] != "tomato" {
- t.Fatalf("backend remove IDs = %#v, want pasta and tomato", backend.removeProductGroupIDs)
+ if len(backend.removeProductGroupIDs) != 2 || backend.removeProductGroupIDs[0] != testProductGroupID ||
+ backend.removeProductGroupIDs[1] != testProductGroupID2 {
+ t.Fatalf("backend remove IDs = %#v, want valid test product group IDs", backend.removeProductGroupIDs)
}
- if len(output.RemovedProductGroupIDs) != 2 || output.RemovedProductGroupIDs[0] != "pasta" ||
- output.RemovedProductGroupIDs[1] != "tomato" {
- t.Fatalf("output removed IDs = %#v, want pasta and tomato", output.RemovedProductGroupIDs)
+ if len(output.RemovedProductGroupIDs) != 2 || output.RemovedProductGroupIDs[0] != testProductGroupID ||
+ output.RemovedProductGroupIDs[1] != testProductGroupID2 {
+ t.Fatalf("output removed IDs = %#v, want valid test product group IDs", output.RemovedProductGroupIDs)
}
}
@@ -702,7 +694,7 @@ func TestCallToolACIDShoppingListSelection1And5And6And8ReplacesSelectedSet(t *te
nil,
ChangeShoppingListArguments{
Action: " replace_selection ",
- ProductGroupIDs: []string{" pasta ", "tomato", " "},
+ ProductGroupIDs: []string{" " + testProductGroupID + " ", testProductGroupID2, " "},
},
)
if err != nil {
@@ -712,13 +704,17 @@ func TestCallToolACIDShoppingListSelection1And5And6And8ReplacesSelectedSet(t *te
if backend.replaceSelectionCalls != 1 {
t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
}
- if len(backend.replaceSelectionProductGroupIDs) != 2 || backend.replaceSelectionProductGroupIDs[0] != "pasta" ||
- backend.replaceSelectionProductGroupIDs[1] != "tomato" {
- t.Fatalf("backend selected IDs = %#v, want pasta and tomato", backend.replaceSelectionProductGroupIDs)
+ if len(backend.replaceSelectionProductGroupIDs) != 2 ||
+ backend.replaceSelectionProductGroupIDs[0] != testProductGroupID ||
+ backend.replaceSelectionProductGroupIDs[1] != testProductGroupID2 {
+ t.Fatalf(
+ "backend selected IDs = %#v, want valid test product group IDs",
+ backend.replaceSelectionProductGroupIDs,
+ )
}
- if len(output.SelectedProductGroupIDs) != 2 || output.SelectedProductGroupIDs[0] != "pasta" ||
- output.SelectedProductGroupIDs[1] != "tomato" {
- t.Fatalf("output selected IDs = %#v, want pasta and tomato", output.SelectedProductGroupIDs)
+ if len(output.SelectedProductGroupIDs) != 2 || output.SelectedProductGroupIDs[0] != testProductGroupID ||
+ output.SelectedProductGroupIDs[1] != testProductGroupID2 {
+ t.Fatalf("output selected IDs = %#v, want valid test product group IDs", output.SelectedProductGroupIDs)
}
}
@@ -753,9 +749,9 @@ func TestCallToolACIDShoppingListSelection7RequiresReplaceSelectionProductGroupI
func TestCallToolACIDShoppingListSelection2And4And5And6And9AddsSelectedSet(t *testing.T) {
backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
ProductGroups: []cooked.ProductGroup{
- {ID: "pasta", Selected: true},
- {ID: "tomato"},
- {ID: "salt", Selected: true},
+ {ID: testProductGroupID, Selected: true},
+ {ID: testProductGroupID2},
+ {ID: testProductGroupID3, Selected: true},
},
}}}}
server := NewServer(backend, "test")
@@ -765,7 +761,7 @@ func TestCallToolACIDShoppingListSelection2And4And5And6And9AddsSelectedSet(t *te
nil,
ChangeShoppingListArguments{
Action: " add_selection ",
- ProductGroupIDs: []string{" tomato ", "pasta", " ", "pepper"},
+ ProductGroupIDs: []string{" " + testProductGroupID2 + " ", testProductGroupID, " ", testProductGroupID4},
},
)
if err != nil {
@@ -778,7 +774,7 @@ func TestCallToolACIDShoppingListSelection2And4And5And6And9AddsSelectedSet(t *te
if backend.replaceSelectionCalls != 1 {
t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
}
- expectedIDs := []string{"pasta", "salt", "tomato", "pepper"}
+ expectedIDs := []string{testProductGroupID, testProductGroupID3, testProductGroupID2, testProductGroupID4}
if !slices.Equal(backend.replaceSelectionProductGroupIDs, expectedIDs) {
t.Fatalf("backend selected IDs = %#v, want %#v", backend.replaceSelectionProductGroupIDs, expectedIDs)
}
@@ -821,10 +817,10 @@ func TestCallToolACIDShoppingListSelection7RequiresAddSelectionProductGroupIDs(t
func TestCallToolACIDShoppingListSelection3And4And5And6And10RemovesSelectedSet(t *testing.T) {
backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
ProductGroups: []cooked.ProductGroup{
- {ID: "pasta", Selected: true},
- {ID: "tomato", Selected: true},
- {ID: "salt", Selected: true},
- {ID: "pepper"},
+ {ID: testProductGroupID, Selected: true},
+ {ID: testProductGroupID2, Selected: true},
+ {ID: testProductGroupID3, Selected: true},
+ {ID: testProductGroupID4},
},
}}}}
server := NewServer(backend, "test")
@@ -834,7 +830,7 @@ func TestCallToolACIDShoppingListSelection3And4And5And6And10RemovesSelectedSet(t
nil,
ChangeShoppingListArguments{
Action: " remove_selection ",
- ProductGroupIDs: []string{" tomato ", "pepper", " "},
+ ProductGroupIDs: []string{" " + testProductGroupID2 + " ", testProductGroupID4, " "},
},
)
if err != nil {
@@ -847,7 +843,7 @@ func TestCallToolACIDShoppingListSelection3And4And5And6And10RemovesSelectedSet(t
if backend.replaceSelectionCalls != 1 {
t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
}
- expectedIDs := []string{"pasta", "salt"}
+ expectedIDs := []string{testProductGroupID, testProductGroupID3}
if !slices.Equal(backend.replaceSelectionProductGroupIDs, expectedIDs) {
t.Fatalf("backend selected IDs = %#v, want %#v", backend.replaceSelectionProductGroupIDs, expectedIDs)
}
@@ -936,187 +932,3 @@ func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenW
t.Fatalf("change_shopping_list description = %q, want clear/destructive warning", tool.Description)
}
}
-
-type fakeBackend struct {
- shoppingList cooked.ShoppingList
- recipes []cooked.RecipeCard
- searchRecipes []cooked.RecipeCard
- recipeMetadata cooked.RecipeMetadata
- recipeContent cooked.RecipeContent
- preview cooked.RecipeTextPreview
- importRecipeURLResult cooked.RecipeURLImport
- saveRecipeID string
- draftSaveRecipeID string
- page int
- limit int
- searchQuery string
- searchPage int
- metadataRecipeID string
- contentRecipeID string
- previewTitle string
- previewText string
- importURL string
- saveTitle string
- saveMarkdown string
- savePortions float64
- draftSaveID string
- draftSaveMarkdown string
- draftSavePortions float64
- updateRecipeID string
- updateMarkdown string
- updatePortions float64
- deleteRecipeID string
- addShoppingListResult cooked.AddShoppingListResult
- addIngredients string
- addRecipeID string
- removeProductGroupIDs []string
- replaceSelectionProductGroupIDs []string
- updateShoppingListProductGroupID string
- updateShoppingListProductGroup cooked.ShoppingListProductGroupUpdate
- metadataCalls int
- readShoppingListCalls int
- contentCalls int
- previewCalls int
- importRecipeURLCalls int
- saveCalls int
- draftSaveCalls int
- updateCalls int
- deleteCalls int
- clearShoppingListCalls int
- addShoppingListCalls int
- removeShoppingListCalls int
- replaceSelectionCalls int
- updateShoppingListCalls int
-}
-
-func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
- f.readShoppingListCalls++
-
- return f.shoppingList, nil
-}
-
-func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
- f.page = page
- f.limit = limit
-
- return f.recipes, nil
-}
-
-func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
- f.searchQuery = query
- f.searchPage = page
-
- return f.searchRecipes, nil
-}
-
-func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
- f.metadataRecipeID = recipeID
- f.metadataCalls++
-
- return f.recipeMetadata, nil
-}
-
-func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
- f.contentRecipeID = recipeID
- f.contentCalls++
-
- return f.recipeContent, nil
-}
-
-func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
- f.previewTitle = title
- f.previewText = text
- f.previewCalls++
-
- return f.preview, nil
-}
-
-func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions float64) (string, error) {
- f.saveTitle = title
- f.saveMarkdown = markdown
- f.savePortions = portions
- f.saveCalls++
- if f.saveRecipeID != "" {
- return f.saveRecipeID, nil
- }
-
- return "recipe-1", nil
-}
-
-func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown string, portions float64) (string, error) {
- f.draftSaveID = draftID
- f.draftSaveMarkdown = markdown
- f.draftSavePortions = portions
- f.draftSaveCalls++
- if f.draftSaveRecipeID != "" {
- return f.draftSaveRecipeID, nil
- }
-
- return "recipe-1", nil
-}
-
-func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions float64) error {
- f.updateRecipeID = recipeID
- f.updateMarkdown = markdown
- f.updatePortions = portions
- f.updateCalls++
-
- return nil
-}
-
-func (f *fakeBackend) ImportRecipeURL(_ context.Context, recipeURL string) (cooked.RecipeURLImport, error) {
- f.importURL = recipeURL
- f.importRecipeURLCalls++
-
- return f.importRecipeURLResult, nil
-}
-
-func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
- f.deleteRecipeID = recipeID
- f.deleteCalls++
-
- return nil
-}
-
-func (f *fakeBackend) ClearShoppingList(context.Context) error {
- f.clearShoppingListCalls++
-
- return nil
-}
-
-func (f *fakeBackend) AddShoppingListIngredients(
- _ context.Context,
- ingredients, recipeID string,
-) (cooked.AddShoppingListResult, error) {
- f.addIngredients = ingredients
- f.addRecipeID = recipeID
- f.addShoppingListCalls++
-
- return f.addShoppingListResult, nil
-}
-
-func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
- f.removeProductGroupIDs = ids
- f.removeShoppingListCalls++
-
- return nil
-}
-
-func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error {
- f.replaceSelectionProductGroupIDs = ids
- f.replaceSelectionCalls++
-
- return nil
-}
-
-func (f *fakeBackend) UpdateShoppingListProductGroup(
- _ context.Context,
- productGroupID string,
- update cooked.ShoppingListProductGroupUpdate,
-) error {
- f.updateShoppingListProductGroupID = productGroupID
- f.updateShoppingListProductGroup = update
- f.updateShoppingListCalls++
-
- return nil
-}
@@ -0,0 +1,111 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import "git.secluded.site/cooked-mcp/internal/cooked"
+
+// ReadArguments contains read tool arguments.
+type ReadArguments struct {
+ Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, recipe, or example_recipes."`
+ RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
+ Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
+ Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
+ Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
+}
+
+// ReadOutput is the structured output for the current read tool slice.
+type ReadOutput struct {
+ Aisles []cooked.Aisle `json:"aisles,omitempty"`
+ ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
+ Recipes []RecipeSummary `json:"recipes,omitempty"`
+ Recipe *RecipeDetail `json:"recipe,omitempty"`
+ ExampleRecipes string `json:"example_recipes,omitempty"`
+}
+
+// RecipeSummary is the MCP-safe saved recipe summary.
+type RecipeSummary struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+}
+
+// RecipeDetail is the MCP-safe single recipe output.
+type RecipeDetail struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Owner string `json:"owner"`
+ Content string `json:"content"`
+ Portions float64 `json:"portions"`
+}
+
+// ExtractIntoRecipeArguments contains extract_into_recipe tool arguments.
+type ExtractIntoRecipeArguments struct {
+ Title string `json:"title" jsonschema:"Recipe title for the extraction."`
+ Text string `json:"text" jsonschema:"Raw recipe text to extract into Cooked recipe markdown without saving."`
+}
+
+// ExtractIntoRecipeOutput is the structured output for extract_into_recipe.
+type ExtractIntoRecipeOutput struct {
+ Title string `json:"title"`
+ Markdown string `json:"markdown"`
+ Portions float64 `json:"portions"`
+}
+
+// SaveRecipeArguments contains save_recipe tool arguments.
+type SaveRecipeArguments struct {
+ Source string `json:"source" jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
+ RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for source=existing updates or source=draft overwrite into an existing recipe."`
+ DraftID string `json:"draft_id,omitempty" jsonschema:"Draft ID for reviewed URL import draft saves."`
+ Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."`
+ Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."`
+ URL string `json:"url,omitempty" jsonschema:"Recipe URL for url imports."`
+ Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves, optional reviewed URL import draft content, or existing recipe updates. Read target example_recipes first for Cooked's markdown dialect and examples."`
+ Portions float64 `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves or existing recipe updates; defaults to 1 when omitted. For reviewed draft content, provide portions together with markdown."`
+}
+
+// SaveRecipeOutput is the structured output for save_recipe.
+type SaveRecipeOutput struct {
+ RecipeID string `json:"recipe_id,omitempty"`
+ DraftID string `json:"draft_id,omitempty"`
+ Title string `json:"title,omitempty"`
+ Markdown string `json:"markdown,omitempty"`
+ Portions float64 `json:"portions,omitempty"`
+}
+
+// DeleteRecipeArguments contains delete_recipe tool arguments.
+type DeleteRecipeArguments struct {
+ RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
+}
+
+// DeleteRecipeOutput is the structured output for delete_recipe.
+type DeleteRecipeOutput struct {
+ RecipeID string `json:"recipe_id"`
+}
+
+// ChangeShoppingListArguments contains change_shopping_list tool arguments.
+type ChangeShoppingListArguments struct {
+ Action string `json:"action" jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
+ Ingredients string `json:"ingredients,omitempty" jsonschema:"Newline-separated ingredients for action add. Put quantities before names, for example 1 milk."`
+ RecipeID string `json:"recipe_id,omitempty" jsonschema:"Optional saved recipe ID or import draft ID for action add."`
+ ProductGroupIDs ProductGroupIDs `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
+ ProductGroupID string `json:"product_group_id,omitempty" jsonschema:"Product group ID for action update_item."`
+ Name *string `json:"name,omitempty" jsonschema:"Replacement product name for action update_item."`
+ Quantity *string `json:"quantity,omitempty" jsonschema:"Replacement quantity for action update_item."`
+ AisleID *string `json:"aisle_id,omitempty" jsonschema:"Replacement aisle ID for action update_item."`
+ Selected *bool `json:"selected,omitempty" jsonschema:"Replacement selected state for action update_item."`
+}
+
+// ProductGroupIDs are Cooked shopping-list product group IDs used by bulk actions.
+type ProductGroupIDs []string
+
+// ChangeShoppingListOutput is the structured output for change_shopping_list.
+type ChangeShoppingListOutput struct {
+ Cleared bool `json:"cleared,omitempty"`
+ AddedCount int `json:"added_count,omitempty"`
+ Ingredients []string `json:"ingredients,omitempty"`
+ RemovedProductGroupIDs []string `json:"removed_product_group_ids,omitempty"`
+ SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
+ UpdatedProductGroupID string `json:"updated_product_group_id,omitempty"`
+ SkippedProductGroupIDs []string `json:"skipped_product_group_ids,omitempty"`
+}
@@ -17,7 +17,7 @@ func TestCallToolACIDShoppingListUpdateItem1To6And8And9UpdatesProductGroupWithMe
backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
ID: "pantry",
ProductGroups: []cooked.ProductGroup{{
- ID: "pasta",
+ ID: testProductGroupID,
Name: "Pasta",
Quantity: "200g",
Selected: true,
@@ -30,7 +30,7 @@ func TestCallToolACIDShoppingListUpdateItem1To6And8And9UpdatesProductGroupWithMe
nil,
ChangeShoppingListArguments{
Action: " update_item ",
- ProductGroupID: " pasta ",
+ ProductGroupID: " " + testProductGroupID + " ",
Name: &name,
Selected: &selected,
},
@@ -45,8 +45,8 @@ func TestCallToolACIDShoppingListUpdateItem1To6And8And9UpdatesProductGroupWithMe
if backend.updateShoppingListCalls != 1 {
t.Fatalf("update shopping-list calls = %d, want 1", backend.updateShoppingListCalls)
}
- if backend.updateShoppingListProductGroupID != "pasta" {
- t.Fatalf("updated product group ID = %q, want pasta", backend.updateShoppingListProductGroupID)
+ if backend.updateShoppingListProductGroupID != testProductGroupID {
+ t.Fatalf("updated product group ID = %q, want test product group ID", backend.updateShoppingListProductGroupID)
}
expectedUpdate := cooked.ShoppingListProductGroupUpdate{
Name: "Whole wheat pasta",
@@ -57,22 +57,22 @@ func TestCallToolACIDShoppingListUpdateItem1To6And8And9UpdatesProductGroupWithMe
if backend.updateShoppingListProductGroup != expectedUpdate {
t.Fatalf("update = %#v, want %#v", backend.updateShoppingListProductGroup, expectedUpdate)
}
- if output.UpdatedProductGroupID != "pasta" {
- t.Fatalf("output updated product group ID = %q, want pasta", output.UpdatedProductGroupID)
+ if output.UpdatedProductGroupID != testProductGroupID {
+ t.Fatalf("output updated product group ID = %q, want test product group ID", output.UpdatedProductGroupID)
}
}
func TestCallToolACIDShoppingListUpdateItem7ReportsMissingProductGroup(t *testing.T) {
backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
ID: "pantry",
- ProductGroups: []cooked.ProductGroup{{ID: "pasta"}},
+ ProductGroups: []cooked.ProductGroup{{ID: testProductGroupID}},
}}}}
server := NewServer(backend, "test")
_, _, err := server.callChangeShoppingListTool(
context.Background(),
nil,
- ChangeShoppingListArguments{Action: "update_item", ProductGroupID: "tomato"},
+ ChangeShoppingListArguments{Action: "update_item", ProductGroupID: testProductGroupID2},
)
if err == nil {
t.Fatal("callChangeShoppingListTool() error = nil, want missing product group error")
@@ -85,6 +85,27 @@ func TestCallToolACIDShoppingListUpdateItem7ReportsMissingProductGroup(t *testin
}
}
+// recipes.VALIDATION.1 recipes.VALIDATION.2
+func TestCallToolACIDRecipesValidation1And2RejectsMalformedUpdateItemProductGroupIDBeforeCooked(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, _, err := server.callChangeShoppingListTool(
+ context.Background(),
+ nil,
+ ChangeShoppingListArguments{Action: "update_item", ProductGroupID: "not-a-uuid"},
+ )
+ if err == nil {
+ t.Fatal("callChangeShoppingListTool() error = nil, want malformed product_group_id error")
+ }
+ if backend.readShoppingListCalls != 0 {
+ t.Fatalf("read shopping-list calls = %d, want none", backend.readShoppingListCalls)
+ }
+ if backend.updateShoppingListCalls != 0 {
+ t.Fatalf("update shopping-list calls = %d, want none", backend.updateShoppingListCalls)
+ }
+}
+
func TestCallToolACIDToolsChangeShoppingListTool4RequiresUpdateItemProductGroupID(t *testing.T) {
tests := []struct {
name string