diff --git a/internal/cooked/client.go b/internal/cooked/client.go index d4fb3b4295bf060893e5000e7c670258c226f594..2cbb5af4eaa37bba440912d6990e16ce201d8efc 100644 --- a/internal/cooked/client.go +++ b/internal/cooked/client.go @@ -204,6 +204,18 @@ func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, return response.RecipeID, nil } +// UpdateRecipeContent logs in when needed and updates an existing recipe's content. +func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error { + body, err := json.Marshal(updateRecipeContentRequest{Description: markdown, Portions: portions}) + if err != nil { + return fmt.Errorf("encode Cooked recipe update request: %w", err) + } + + path := "/api/recipe/" + url.PathEscape(recipeID) + "/content" + + return c.doAuthenticated(ctx, http.MethodPost, path, body, nil) +} + func (c *Client) getRecipes(ctx context.Context, path string) ([]RecipeCard, error) { var response recipeListResponse decodeRecipes := func(decoder *json.Decoder) error { @@ -359,6 +371,11 @@ type savePreparedRecipeRequest struct { Portions int `json:"portions"` } +type updateRecipeContentRequest struct { + Description string `json:"description"` + Portions int `json:"portions"` +} + type saveRecipeResponse struct { RecipeID string `json:"recipe-id"` } diff --git a/internal/cooked/client_test.go b/internal/cooked/client_test.go index 427fc7a3a484bd679b85f83b4dfbef83f624cb92..d5e65754b171db5abbdbf55fbfb4c155bcc8c688 100644 --- a/internal/cooked/client_test.go +++ b/internal/cooked/client_test.go @@ -144,6 +144,21 @@ func TestSavePreparedRecipeACIDRecipesSave2And9And13SavesMarkdownAsDescription(t } } +func TestUpdateRecipeContentACIDRecipesSave8And9And13UpdatesExistingRecipe(t *testing.T) { + var updateCalls int + client, closeServer := newTestClient(t, updateRecipeContentTestHandler(t, &updateCalls)) + defer closeServer() + + err := client.UpdateRecipeContent(context.Background(), "recipe/with space", "# Pasta\n\n1. Boil pasta.", 4) + if err != nil { + t.Fatalf("UpdateRecipeContent() error = %v", err) + } + + if updateCalls != 1 { + t.Fatalf("update calls = %d, want 1", updateCalls) + } +} + func TestUserPathEscapesAuthenticatedUsername(t *testing.T) { client := &Client{authenticatedUsername: "user/name"} @@ -322,6 +337,25 @@ func savePreparedRecipeTestHandler(t *testing.T, saveCalls *int) http.HandlerFun } } +func updateRecipeContentTestHandler(t *testing.T, updateCalls *int) http.HandlerFunc { + t.Helper() + + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.EscapedPath() { + case "/api/public/login": + writeLoginResponse(t, w) + case "/api/recipe/recipe%2Fwith%20space/content": + requireMethod(t, r, http.MethodPost, "update recipe content") + requireSessionCookie(t, r) + requireUpdateRecipeContentRequest(t, r) + (*updateCalls)++ + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected path %s", r.URL.EscapedPath()) + } + } +} + func requirePreviewRecipeTextRequest(t *testing.T, r *http.Request) { t.Helper() @@ -352,6 +386,21 @@ func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) { } } +func requireUpdateRecipeContentRequest(t *testing.T, r *http.Request) { + t.Helper() + + var request updateRecipeContentRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode update recipe content request: %v", err) + } + if request.Description != "# Pasta\n\n1. Boil pasta." { + t.Fatalf("update description = %q, want markdown", request.Description) + } + if request.Portions != 4 { + t.Fatalf("update portions = %d, want 4", request.Portions) + } +} + func requireLoginRequest(t *testing.T, r *http.Request) { t.Helper() requireMethod(t, r, http.MethodPost, "login") diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 9a0aef9531dbf7b9265bc304c963e7a8499ff39e..9d705dc5ab85d1d3567dd18c00a6964c93d366ae 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -31,6 +31,7 @@ type Backend interface { ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error) PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error) SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) + UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error } // Server exposes Cooked as an MCP server. @@ -197,6 +198,8 @@ func (s *Server) callSaveRecipeTool( return s.callRawTextSaveRecipeTool(ctx, arguments) case "prepared": return s.callPreparedSaveRecipeTool(ctx, arguments) + case "existing": + return s.callExistingSaveRecipeTool(ctx, arguments) case "": return nil, SaveRecipeOutput{}, fmt.Errorf("source is required") default: @@ -252,6 +255,28 @@ func (s *Server) callPreparedSaveRecipeTool( return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions) } +func (s *Server) callExistingSaveRecipeTool( + ctx context.Context, + arguments SaveRecipeArguments, +) (*sdk.CallToolResult, SaveRecipeOutput, error) { + recipeID := strings.TrimSpace(arguments.RecipeID) + if recipeID == "" { + return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing") + } + if strings.TrimSpace(arguments.Markdown) == "" { + return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing") + } + if arguments.Portions < 1 { + return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing") + } + + if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil { + return nil, SaveRecipeOutput{}, err + } + + return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil +} + func (s *Server) savePreparedRecipe( ctx context.Context, title, markdown string, @@ -265,11 +290,15 @@ func (s *Server) savePreparedRecipe( return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID") } + return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil +} + +func newRecipeSaveResult(recipeID string) *sdk.CallToolResult { output := SaveRecipeOutput{RecipeID: recipeID} return &sdk.CallToolResult{ Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}}, - }, output, nil + } } func readTool() *sdk.Tool { @@ -305,7 +334,7 @@ func saveRecipeTool() *sdk.Tool { return &sdk.Tool{ Name: saveRecipeToolName, Title: "Save recipe", - Description: "Save a recipe. This slice supports source raw_text with title and text, and source prepared with title, markdown, and portions. Other source values are reserved for later slices.", + Description: "Save a recipe. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, and source existing with recipe_id, markdown, and portions. Other source values are reserved for later slices.", Annotations: &sdk.ToolAnnotations{ Title: "Save recipe", OpenWorldHint: &openWorld, @@ -497,11 +526,12 @@ type PreviewRecipeTextOutput struct { // SaveRecipeArguments contains save_recipe tool arguments. type SaveRecipeArguments struct { - Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text and prepared."` - 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."` - Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves."` - Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves."` + Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."` + RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."` + 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."` + Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves or existing recipe updates."` + Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves or existing recipe updates."` } // SaveRecipeOutput is the structured output for save_recipe. diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index f5d932a73bc92ba2d89c6bcba61555ffb94f3dfb..ea4c72ae4b246ddac41aa57742620379db75f63f 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -440,6 +440,76 @@ func TestCallToolACIDRecipesSave1_1To1_2RequiresRawTextFields(t *testing.T) { } } +func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, output, err := server.callSaveRecipeTool( + context.Background(), + nil, + SaveRecipeArguments{ + Source: " existing ", + RecipeID: " recipe-1 ", + Markdown: " # Pasta\n\n1. Boil pasta.\n", + Portions: 4, + }, + ) + if err != nil { + t.Fatalf("callSaveRecipeTool() error = %v", err) + } + + 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.updateMarkdown != " # Pasta\n\n1. Boil pasta.\n" { + t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown) + } + if backend.updatePortions != 4 { + t.Fatalf("backend update portions = %d, want 4", backend.updatePortions) + } + if output.RecipeID != "recipe-1" { + t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID) + } +} + +func TestCallToolACIDRecipesSave8_1To8_3RequiresExistingFields(t *testing.T) { + tests := []struct { + name string + arguments SaveRecipeArguments + }{ + { + name: "recipe ID", + arguments: SaveRecipeArguments{Source: "existing", RecipeID: " ", Markdown: "# Pasta", Portions: 4}, + }, + { + 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}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments) + if err == nil { + t.Fatal("callSaveRecipeTool() error = nil, want missing existing field error") + } + if backend.updateCalls != 0 { + t.Fatalf("update calls = %d, want none", backend.updateCalls) + } + }) + } +} + type fakeBackend struct { shoppingList cooked.ShoppingList recipes []cooked.RecipeCard @@ -459,10 +529,14 @@ type fakeBackend struct { saveTitle string saveMarkdown string savePortions int + updateRecipeID string + updateMarkdown string + updatePortions int metadataCalls int contentCalls int previewCalls int saveCalls int + updateCalls int } func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) { @@ -516,3 +590,12 @@ func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown stri return "recipe-1", nil } + +func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error { + f.updateRecipeID = recipeID + f.updateMarkdown = markdown + f.updatePortions = portions + f.updateCalls++ + + return nil +}