mcp: validate UUIDs and add resolve_recipe_id tool

Amolith created

Change summary

features/cooked-mcp/recipes.feature.yaml  |   6 
features/cooked-mcp/tools.feature.yaml    |   4 
internal/mcp/server.go                    | 390 ++++++++----------------
internal/mcp/server_fake_test.go          | 212 +++++++++++++
internal/mcp/server_format.go             | 148 +++++++++
internal/mcp/server_ids.go                | 165 ++++++++++
internal/mcp/server_ids_test.go           | 323 ++++++++++++++++++++
internal/mcp/server_save_default_test.go  |  60 +++
internal/mcp/server_save_draft.go         |   3 
internal/mcp/server_save_draft_test.go    |  61 ++-
internal/mcp/server_save_url_test.go      |  15 
internal/mcp/server_test.go               | 314 ++++----------------
internal/mcp/server_types.go              | 111 +++++++
internal/mcp/shopping_list_update_test.go |  37 +
14 files changed, 1,302 insertions(+), 547 deletions(-)

Detailed changes

features/cooked-mcp/recipes.feature.yaml 🔗

@@ -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:

features/cooked-mcp/tools.feature.yaml 🔗

@@ -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.

internal/mcp/server.go 🔗

@@ -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"`
-}

internal/mcp/server_fake_test.go 🔗

@@ -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
+}

internal/mcp/server_format.go 🔗

@@ -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 + ")."
+}

internal/mcp/server_ids.go 🔗

@@ -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"`
+}

internal/mcp/server_ids_test.go 🔗

@@ -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,
+				)
+			}
+		})
+	}
+}

internal/mcp/server_save_default_test.go 🔗

@@ -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)
+	}
+}

internal/mcp/server_save_draft.go 🔗

@@ -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
 		}

internal/mcp/server_save_draft_test.go 🔗

@@ -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)
+	}
+}

internal/mcp/server_save_url_test.go 🔗

@@ -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)

internal/mcp/server_test.go 🔗

@@ -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
-}

internal/mcp/server_types.go 🔗

@@ -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"`
+}

internal/mcp/shopping_list_update_test.go 🔗

@@ -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