diff --git a/internal/cooked/client.go b/internal/cooked/client.go index 7299bca1fd1b6fb3cb8d8269b06ec694d0966654..fb6b184a40d1bf7c7822f6241756c1c05b813d36 100644 --- a/internal/cooked/client.go +++ b/internal/cooked/client.go @@ -56,6 +56,13 @@ type ProductGroup struct { Selected bool `json:"selected"` } +// RecipeCard is a saved recipe summary returned by Cooked list and search endpoints. +type RecipeCard struct { + ID string `json:"id"` + Title string `json:"title"` + ThumbnailURL string `json:"thumbnail-url,omitempty"` +} + // NewClient returns a Cooked client with an in-memory cookie jar. func NewClient(baseURL *url.URL, username, password string) (*Client, error) { jar, err := newCookieJar() @@ -88,6 +95,24 @@ func (c *Client) ReadShoppingList(ctx context.Context) (ShoppingList, error) { return response.ShoppingList, nil } +// ListRecipes logs in when needed and returns one page of saved recipes. +func (c *Client) ListRecipes(ctx context.Context, page, limit int) ([]RecipeCard, error) { + query := url.Values{} + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("page-count", fmt.Sprintf("%d", limit)) + + var response recipeListResponse + decodeRecipes := func(decoder *json.Decoder) error { + return decoder.Decode(&response) + } + path := "/api/user/{username}/recipes?" + query.Encode() + if err := c.doAuthenticated(ctx, http.MethodGet, path, nil, decodeRecipes); err != nil { + return nil, err + } + + return response.Recipes, nil +} + func (c *Client) doAuthenticated(ctx context.Context, method, path string, body []byte, decodeResponse responseDecoder) error { c.requestMu.Lock() defer c.requestMu.Unlock() @@ -175,7 +200,11 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte, decod } func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) { - requestURL := c.baseURL.ResolveReference(&url.URL{Path: path}) + relativeURL, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("parse Cooked request path: %w", err) + } + requestURL := c.baseURL.ResolveReference(relativeURL) var reader io.Reader if body != nil { @@ -207,6 +236,10 @@ type shoppingListResponse struct { Recipes []string `json:"recipes"` } +type recipeListResponse struct { + Recipes []RecipeCard `json:"recipes"` +} + type loginRequest struct { Username string `json:"username"` Password string `json:"password"` diff --git a/internal/cooked/client_test.go b/internal/cooked/client_test.go index 4dccd4b18277029ae9f9fca913ad50f2e93a17c9..908d4437c7f7cdefafe164a3a2d7e0a41eaec36e 100644 --- a/internal/cooked/client_test.go +++ b/internal/cooked/client_test.go @@ -84,6 +84,54 @@ func TestReadShoppingListACIDAuthenticationLogin3StoresCookies(t *testing.T) { } } +func TestListRecipesACIDRecipesRead1ListsSavedRecipes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/public/login": + http.SetCookie(w, &http.Cookie{Name: "cooked_session", Value: "session-value", Path: "/"}) + writeJSON(t, w, loginResponse{Username: "returned-user"}) + case "/api/user/returned-user/recipes": + if got := r.URL.Query().Get("page"); got != "2" { + t.Fatalf("page query = %q, want 2", got) + } + if got := r.URL.Query().Get("page-count"); got != "5" { + t.Fatalf("page-count query = %q, want 5", got) + } + writeJSON(t, w, recipeListResponse{ + Recipes: []RecipeCard{{ + ID: "recipe-1", + Title: "Pasta", + ThumbnailURL: "https://example.invalid/thumb.jpg", + }}, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + client, err := NewClient(baseURL, "configured-user", "configured-password") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + recipes, err := client.ListRecipes(context.Background(), 2, 5) + if err != nil { + t.Fatalf("ListRecipes() error = %v", err) + } + + if len(recipes) != 1 { + t.Fatalf("recipes length = %d, want 1", len(recipes)) + } + if recipes[0].ID != "recipe-1" || recipes[0].Title != "Pasta" { + t.Fatalf("recipe = %#v, want recipe-1 Pasta", recipes[0]) + } +} + func TestUserPathEscapesAuthenticatedUsername(t *testing.T) { client := &Client{authenticatedUsername: "user/name"} @@ -93,7 +141,24 @@ func TestUserPathEscapesAuthenticatedUsername(t *testing.T) { } } -func writeJSON[T loginResponse | shoppingListResponse](t *testing.T, w http.ResponseWriter, value T) { +func TestNewRequestPreservesEscapedUserPathWithQuery(t *testing.T) { + baseURL, err := url.Parse("https://example.invalid") + if err != nil { + t.Fatalf("parse base URL: %v", err) + } + client := &Client{baseURL: baseURL} + + request, err := client.newRequest(context.Background(), http.MethodGet, "/api/user/user%2Fname/recipes?page=2&page-count=5", nil) + if err != nil { + t.Fatalf("newRequest() error = %v", err) + } + + if request.URL.String() != "https://example.invalid/api/user/user%2Fname/recipes?page=2&page-count=5" { + t.Fatalf("request URL = %q, want escaped username preserved", request.URL.String()) + } +} + +func writeJSON[T loginResponse | shoppingListResponse | recipeListResponse](t *testing.T, w http.ResponseWriter, value T) { t.Helper() w.Header().Set("Content-Type", "application/json") diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 9dfca27e3c1d3242975187db3c048e35a7d3e530..92ce44810ebcfb7b1f9a2b8ae9f9db985f10712f 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -23,6 +23,7 @@ const ( // Backend provides the Cooked operations exposed as MCP tools. type Backend interface { ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error) + ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error) } // Server exposes Cooked as an MCP server. @@ -56,21 +57,32 @@ func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (Rea } func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, arguments ReadArguments) (*sdk.CallToolResult, ReadOutput, error) { - if arguments.Target != "shopping_list" { - return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target) - } + switch arguments.Target { + case "shopping_list": + shoppingList, err := s.backend.ReadShoppingList(ctx) + if err != nil { + return nil, ReadOutput{}, err + } - shoppingList, err := s.backend.ReadShoppingList(ctx) - if err != nil { - return nil, ReadOutput{}, err - } + output := ReadOutput{ + Aisles: shoppingList.Aisles, + ShoppingListRecipes: shoppingList.Recipes, + } - output := ReadOutput{ - Aisles: shoppingList.Aisles, - Recipes: shoppingList.Recipes, - } + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil + case "recipes": + page, limit := normalizeRecipePage(arguments.Page, arguments.Limit) + recipes, err := s.backend.ListRecipes(ctx, page, limit) + if err != nil { + return nil, ReadOutput{}, err + } - return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil + output := ReadOutput{Recipes: recipeSummaries(recipes)} + + return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil + default: + return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target) + } } func readTool() *sdk.Tool { @@ -78,7 +90,7 @@ func readTool() *sdk.Tool { return &sdk.Tool{ Name: readToolName, Title: "Read Cooked data", - Description: "Read Cooked data. This slice supports target shopping_list.", + Description: "Read Cooked data. Use target shopping_list for the current shopping list or target recipes to list saved recipe IDs and titles without thumbnails. This slice does not search recipes or read a single recipe.", Annotations: &sdk.ToolAnnotations{ Title: "Read Cooked data", ReadOnlyHint: true, @@ -87,6 +99,20 @@ func readTool() *sdk.Tool { } } +func normalizeRecipePage(page, limit int) (int, int) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 10 + } + if limit > 30 { + limit = 30 + } + + return page, limit +} + func formatShoppingList(shoppingList cooked.ShoppingList) string { if len(shoppingList.Aisles) == 0 { return "Shopping list is empty." @@ -124,13 +150,49 @@ func formatShoppingList(shoppingList cooked.ShoppingList) string { return strings.TrimRight(builder.String(), "\n") } +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 recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary { + summaries := make([]RecipeSummary, 0, len(recipes)) + for _, recipe := range recipes { + summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title}) + } + + return summaries +} + // ReadArguments contains read tool arguments. type ReadArguments struct { - Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list in this slice."` + Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list or 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"` - Recipes []string `json:"recipes"` + Aisles []cooked.Aisle `json:"aisles,omitempty"` + ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"` + Recipes []RecipeSummary `json:"recipes,omitempty"` +} + +// RecipeSummary is the MCP-safe saved recipe summary. +type RecipeSummary struct { + ID string `json:"id"` + Title string `json:"title"` } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index f1d5c866264b7b08034a12e8b1902791339ecd79..d6716438f7cd884dd75f9dc3cfb9abc8ef6de023 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -12,7 +12,7 @@ import ( ) func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) { - backend := fakeBackend{shoppingList: cooked.ShoppingList{ + backend := &fakeBackend{shoppingList: cooked.ShoppingList{ Aisles: []cooked.Aisle{{ ID: "pantry", Name: "Pantry", @@ -38,10 +38,60 @@ 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"}}} + server := NewServer(backend, "test") + + output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Page: 2, Limit: 5}) + if err != nil { + t.Fatalf("CallReadTool() error = %v", err) + } + + if backend.page != 2 { + t.Fatalf("backend page = %d, want 2", backend.page) + } + if backend.limit != 5 { + t.Fatalf("backend limit = %d, want 5", backend.limit) + } + 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]) + } +} + +func TestCallToolACIDRecipesPagination4DefaultsAndCapsRecipeLimit(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Limit: 99}) + if err != nil { + t.Fatalf("CallReadTool() error = %v", err) + } + + if backend.page != 1 { + t.Fatalf("backend page = %d, want default page 1", backend.page) + } + if backend.limit != 30 { + t.Fatalf("backend limit = %d, want capped limit 30", backend.limit) + } +} + type fakeBackend struct { shoppingList cooked.ShoppingList + recipes []cooked.RecipeCard + page int + limit int } -func (f fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) { +func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) { 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 +}