From 7b5f1116553141ae257bb4159b0374d3de7a94c3 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 14 Jun 2026 23:17:50 -0600 Subject: [PATCH] mcp: validate UUIDs and add resolve_recipe_id tool --- 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, 1302 insertions(+), 547 deletions(-) create mode 100644 internal/mcp/server_fake_test.go create mode 100644 internal/mcp/server_format.go create mode 100644 internal/mcp/server_ids.go create mode 100644 internal/mcp/server_ids_test.go create mode 100644 internal/mcp/server_save_default_test.go create mode 100644 internal/mcp/server_types.go diff --git a/features/cooked-mcp/recipes.feature.yaml b/features/cooked-mcp/recipes.feature.yaml index 7dacc37e38828ba7b179f82da962439c08b33d4f..a075694bf0892bc00f29fed8fa0e60f90a44cae1 100644 --- a/features/cooked-mcp/recipes.feature.yaml +++ b/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: diff --git a/features/cooked-mcp/tools.feature.yaml b/features/cooked-mcp/tools.feature.yaml index f14367ab3b6ebac220e6cad327229d712ad374af..2506e07dc81c9cf30e07e8a7f301875af707101e 100644 --- a/features/cooked-mcp/tools.feature.yaml +++ b/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. diff --git a/internal/mcp/server.go b/internal/mcp/server.go index d5d99d29ad9db4235672d47cb5d1e8ad00171611..a142882e48bc8ddbfba2fabe1ddd8b92b46157c4 100644 --- a/internal/mcp/server.go +++ b/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"` -} diff --git a/internal/mcp/server_fake_test.go b/internal/mcp/server_fake_test.go new file mode 100644 index 0000000000000000000000000000000000000000..efb31283b2a55b067730683fb083dee8fd29f265 --- /dev/null +++ b/internal/mcp/server_fake_test.go @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/server_format.go b/internal/mcp/server_format.go new file mode 100644 index 0000000000000000000000000000000000000000..4c68ea1da98a4f02b8ea44cbe5b38bb489f3ea94 --- /dev/null +++ b/internal/mcp/server_format.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 + ")." +} diff --git a/internal/mcp/server_ids.go b/internal/mcp/server_ids.go new file mode 100644 index 0000000000000000000000000000000000000000..81b617d25f0f180d47b0a8456501334cb4f0387c --- /dev/null +++ b/internal/mcp/server_ids.go @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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"` +} diff --git a/internal/mcp/server_ids_test.go b/internal/mcp/server_ids_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a2353b1889780012aec080d16c380bf6f6ceb205 --- /dev/null +++ b/internal/mcp/server_ids_test.go @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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, + ) + } + }) + } +} diff --git a/internal/mcp/server_save_default_test.go b/internal/mcp/server_save_default_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7ff8b5d6b75827a7d32935cd8213fc91fb5f7286 --- /dev/null +++ b/internal/mcp/server_save_default_test.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) + } +} diff --git a/internal/mcp/server_save_draft.go b/internal/mcp/server_save_draft.go index 84f583b41b5bcd0f3b37a78303acaaf5da4e825a..18a30d8cda765c74ebf18892e78cdecfdd39bd31 100644 --- a/internal/mcp/server_save_draft.go +++ b/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 } diff --git a/internal/mcp/server_save_draft_test.go b/internal/mcp/server_save_draft_test.go index 00a8df4f942c6382728da42a9b1a38373317f1b5..4ed8d3ce888b05d4827c2523bac8d114eb626c9e 100644 --- a/internal/mcp/server_save_draft_test.go +++ b/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) + } +} diff --git a/internal/mcp/server_save_url_test.go b/internal/mcp/server_save_url_test.go index d5bd8964b0b2142e90520478fe6c372dedd85bc3..5465024e20176e77a7229a3f138c94d819226cb4 100644 --- a/internal/mcp/server_save_url_test.go +++ b/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) diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 7b5df02403b94a173c827a55b3b181a98187dfe0..b9ee6ddb5f3fe44311954b7409f55cc1264d6b37 100644 --- a/internal/mcp/server_test.go +++ b/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 -} diff --git a/internal/mcp/server_types.go b/internal/mcp/server_types.go new file mode 100644 index 0000000000000000000000000000000000000000..c91efe459e4538475b3426afc6d58a386ebb2069 --- /dev/null +++ b/internal/mcp/server_types.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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"` +} diff --git a/internal/mcp/shopping_list_update_test.go b/internal/mcp/shopping_list_update_test.go index 77242fd1176898146d37268935bace7608535657..7a485e98e18b3d00c02c70c16326104a15435017 100644 --- a/internal/mcp/shopping_list_update_test.go +++ b/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