Detailed changes
@@ -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"`
}
@@ -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")
@@ -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.
@@ -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
+}