recipes: update existing recipe

Amolith created

Change summary

internal/cooked/client.go      | 17 +++++++
internal/cooked/client_test.go | 49 +++++++++++++++++++++
internal/mcp/server.go         | 44 ++++++++++++++++---
internal/mcp/server_test.go    | 83 ++++++++++++++++++++++++++++++++++++
4 files changed, 186 insertions(+), 7 deletions(-)

Detailed changes

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

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

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.

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