diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 45ba863a81323d71f9b54e9efa4030475d17a44e..3c0d14c52ee37ab302fbaf722b805f7b74be17b0 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -25,6 +25,8 @@ type Backend interface { ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error) ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error) SearchRecipes(ctx context.Context, query string, page int) ([]cooked.RecipeCard, error) + ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error) + ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error) } // Server exposes Cooked as an MCP server. @@ -71,6 +73,32 @@ func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, argum } return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil + case "recipe": + recipeID := strings.TrimSpace(arguments.RecipeID) + if recipeID == "" { + return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe") + } + + metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID) + if err != nil { + return nil, ReadOutput{}, err + } + content, err := s.backend.ReadRecipeContent(ctx, recipeID) + if err != nil { + return nil, ReadOutput{}, err + } + + recipe := RecipeDetail{ + ID: recipeID, + Title: metadata.Title, + Owner: metadata.Owner, + EditPermission: metadata.EditPermission, + Content: content.Content, + Portions: content.Portions, + } + output := ReadOutput{Recipe: &recipe} + + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil case "recipes": page, limit := normalizeRecipePage(arguments.Page, arguments.Limit) query := strings.TrimSpace(arguments.Query) @@ -104,7 +132,7 @@ func readTool() *sdk.Tool { return &sdk.Tool{ Name: readToolName, Title: "Read Cooked data", - Description: "Read Cooked data. Use target shopping_list for the current shopping list. Use target recipes to list saved recipe IDs and titles, or include query to search saved recipes. Recipe results omit thumbnails. This slice does not read a single recipe.", + Description: "Read Cooked data. Use target shopping_list for the current shopping list, target recipes to list or search saved recipe IDs and titles, or target recipe with recipe_id to read a single recipe. Recipe results omit images and thumbnails.", Annotations: &sdk.ToolAnnotations{ Title: "Read Cooked data", ReadOnlyHint: true, @@ -181,6 +209,37 @@ func formatRecipes(recipes []cooked.RecipeCard) string { return strings.TrimRight(builder.String(), "\n") } +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, "Edit permission: %t\n", recipe.EditPermission) + fmt.Fprintf(&builder, "Portions: %d\n", 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 recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary { summaries := make([]RecipeSummary, 0, len(recipes)) for _, recipe := range recipes { @@ -192,10 +251,11 @@ func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary { // ReadArguments contains read tool arguments. type ReadArguments struct { - Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list or recipes."` - 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."` + Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."` + 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. @@ -203,6 +263,7 @@ 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"` } // RecipeSummary is the MCP-safe saved recipe summary. @@ -210,3 +271,13 @@ 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"` + EditPermission bool `json:"edit_permission"` + Content string `json:"content"` + Portions int `json:"portions"` +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index fec018b5d2c73f983188be3020629e01e414b222..b4541fe03f2633be8c728a2c38287d960d6fe7c2 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -6,8 +6,12 @@ package mcp import ( "context" + "encoding/json" + "strings" "testing" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + "git.secluded.site/cooked-mcp/internal/cooked" ) @@ -109,14 +113,92 @@ func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) { } } +func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) { + backend := &fakeBackend{ + recipeMetadata: cooked.RecipeMetadata{ + Title: "Pasta", + ImageURLs: []string{"https://example.invalid/pasta.jpg"}, + Owner: "returned-user", + EditPermission: true, + }, + recipeContent: cooked.RecipeContent{ + Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", + Portions: 2, + }, + } + server := NewServer(backend, "test") + + result, output, err := server.callReadTool(context.Background(), nil, ReadArguments{Target: "recipe", RecipeID: "recipe-1"}) + if err != nil { + t.Fatalf("callReadTool() error = %v", err) + } + + if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" { + t.Fatalf("backend recipe IDs = metadata %q content %q, want recipe-1", backend.metadataRecipeID, backend.contentRecipeID) + } + if backend.metadataCalls != 1 || backend.contentCalls != 1 { + t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls) + } + 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.Owner != "returned-user" || !output.Recipe.EditPermission { + t.Fatalf("structured recipe owner/edit = %q/%v, want returned-user/true", output.Recipe.Owner, output.Recipe.EditPermission) + } + if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 { + t.Fatalf("structured recipe content/portions = %q/%d, want markdown/2", output.Recipe.Content, output.Recipe.Portions) + } + + encodedOutput, err := json.Marshal(output) + if err != nil { + t.Fatalf("marshal output: %v", err) + } + if strings.Contains(string(encodedOutput), "image") || strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") { + t.Fatalf("structured output leaked image data: %s", encodedOutput) + } + if len(result.Content) != 1 { + t.Fatalf("text content length = %d, want 1", len(result.Content)) + } + textContent, ok := result.Content[0].(*sdk.TextContent) + if !ok { + t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0]) + } + if strings.Contains(textContent.Text, "https://example.invalid/pasta.jpg") { + t.Fatalf("text output leaked image URL: %s", textContent.Text) + } +} + +func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: " "}) + if err == nil { + t.Fatal("CallReadTool() error = nil, want missing recipe_id error") + } + + if backend.metadataCalls != 0 || backend.contentCalls != 0 { + t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls) + } +} + type fakeBackend struct { - shoppingList cooked.ShoppingList - recipes []cooked.RecipeCard - searchRecipes []cooked.RecipeCard - page int - limit int - searchQuery string - searchPage int + shoppingList cooked.ShoppingList + recipes []cooked.RecipeCard + searchRecipes []cooked.RecipeCard + recipeMetadata cooked.RecipeMetadata + recipeContent cooked.RecipeContent + page int + limit int + searchQuery string + searchPage int + metadataRecipeID string + contentRecipeID string + metadataCalls int + contentCalls int } func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) { @@ -136,3 +218,17 @@ func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ( 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 +}