cooked: accept fractional recipe portions

Amolith created

Change summary

features/cooked-mcp/recipes.feature.yaml          |  3 
internal/cooked/client.go                         | 79 ++--------------
internal/cooked/client_preview_validation_test.go | 18 +-
internal/cooked/client_recipe_validation_test.go  | 24 ++++
internal/cooked/client_test.go                    | 15 +-
internal/mcp/server.go                            | 63 +++++++------
internal/mcp/server_save_draft_test.go            |  2 
internal/mcp/server_save_url_test.go              |  2 
internal/mcp/server_test.go                       | 48 +++++----
9 files changed, 117 insertions(+), 137 deletions(-)

Detailed changes

features/cooked-mcp/recipes.feature.yaml 🔗

@@ -15,6 +15,7 @@ components:
       6-2: A single recipe read includes owner or edit-permission information when Cooked provides it.
       7: A single recipe read includes recipe content.
       8: A single recipe read includes portions.
+      8-1: Recipe reads preserve fractional portions when Cooked provides them.
       9: Recipe reads omit image URLs unless a future feature explicitly exposes them.
   PREVIEW_TEXT:
     requirements:
@@ -24,6 +25,7 @@ components:
       2: The preview_recipe_text tool returns the preview title.
       3: The preview_recipe_text tool returns preview markdown.
       4: The preview_recipe_text tool returns preview portions.
+      4-1: The preview_recipe_text tool preserves fractional portions when Cooked provides them.
   SAVE:
     requirements:
       1: The save_recipe tool saves a recipe from raw text.
@@ -46,6 +48,7 @@ components:
       8-1: Updating an existing saved recipe requires a recipe ID.
       8-2: Updating an existing saved recipe requires recipe markdown.
       8-3: Updating an existing saved recipe requires portions.
+      8-4: Saving or updating recipes preserves fractional portions supplied by the agent.
       9: Recipe save and update results include the affected recipe ID.
       10: Recipe save requests use explicit source values.
       10-1: The recipe save source values are raw_text, prepared, url, draft, and existing.

internal/cooked/client.go 🔗

@@ -12,11 +12,9 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"math/big"
 	"net/http"
 	"net/http/cookiejar"
 	"net/url"
-	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -89,37 +87,15 @@ type RecipeMetadata struct {
 
 // RecipeContent is the Markdown-style body and portions for a Cooked recipe.
 type RecipeContent struct {
-	Content  string `json:"content"`
-	Portions int    `json:"portions"`
+	Content  string  `json:"content"`
+	Portions float64 `json:"portions"`
 }
 
 // RecipeTextPreview is Cooked's preview of raw recipe text before saving.
 type RecipeTextPreview struct {
-	Title    string `json:"title"`
-	Markdown string `json:"markdown"`
-	Portions int    `json:"portions"`
-}
-
-// UnmarshalJSON accepts Cooked's integer-valued decimal portions, such as 1.0.
-func (p *RecipeTextPreview) UnmarshalJSON(data []byte) error {
-	type raw RecipeTextPreview
-	var response struct {
-		raw
-		Portions json.Number `json:"portions"`
-	}
-	if err := json.Unmarshal(data, &response); err != nil {
-		return err
-	}
-
-	portions, err := jsonNumberToInt(response.Portions)
-	if err != nil {
-		return fmt.Errorf("portions: %w", err)
-	}
-
-	*p = RecipeTextPreview(response.raw)
-	p.Portions = portions
-
-	return nil
+	Title    string  `json:"title"`
+	Markdown string  `json:"markdown"`
+	Portions float64 `json:"portions"`
 }
 
 // RecipeURLImport is the result of importing a recipe URL.
@@ -316,7 +292,7 @@ func (c *Client) PreviewRecipeText(ctx context.Context, title, text string) (Rec
 }
 
 // SavePreparedRecipe logs in when needed and saves prepared recipe markdown as a new recipe.
-func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) {
+func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions float64) (string, error) {
 	body, err := json.Marshal(savePreparedRecipeRequest{Title: title, Description: markdown, Portions: portions})
 	if err != nil {
 		return "", fmt.Errorf("encode Cooked prepared recipe save request: %w", err)
@@ -337,7 +313,7 @@ func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string,
 }
 
 // SaveRecipeDraft logs in when needed and saves a reviewed URL import draft.
-func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions int) (string, error) {
+func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions float64) (string, error) {
 	body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
 	if err != nil {
 		return "", fmt.Errorf("encode Cooked recipe draft save request: %w", err)
@@ -359,7 +335,7 @@ func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string,
 }
 
 // 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 {
+func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions float64) error {
 	body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions})
 	if err != nil {
 		return fmt.Errorf("encode Cooked recipe update request: %w", err)
@@ -498,35 +474,6 @@ func validateShoppingListProductGroup(aisleIndex, productIndex int, product Prod
 	return nil
 }
 
-func jsonNumberToInt(number json.Number) (int, error) {
-	text := number.String()
-	if text == "" {
-		return 0, fmt.Errorf("missing")
-	}
-
-	rational, ok := new(big.Rat).SetString(text)
-	if !ok {
-		return 0, fmt.Errorf("invalid JSON number %q", text)
-	}
-	if !rational.IsInt() {
-		return 0, fmt.Errorf("must be integer-valued, got %s", text)
-	}
-
-	integer := rational.Num()
-	if !integer.IsInt64() {
-		return 0, fmt.Errorf("out of range, got %s", text)
-	}
-
-	value := integer.Int64()
-	maxInt := int64(1<<(strconv.IntSize-1) - 1)
-	minInt := -maxInt - 1
-	if value < minInt || value > maxInt {
-		return 0, fmt.Errorf("out of range for int, got %s", text)
-	}
-
-	return int(value), nil
-}
-
 func (c *Client) doAuthenticated(
 	ctx context.Context,
 	method, path string,
@@ -686,14 +633,14 @@ type previewRecipeTextRequest struct {
 }
 
 type savePreparedRecipeRequest struct {
-	Title       string `json:"title"`
-	Description string `json:"description"`
-	Portions    int    `json:"portions"`
+	Title       string  `json:"title"`
+	Description string  `json:"description"`
+	Portions    float64 `json:"portions"`
 }
 
 type recipeContentRequest struct {
-	Description string `json:"description"`
-	Portions    int    `json:"portions"`
+	Description string  `json:"description"`
+	Portions    float64 `json:"portions"`
 }
 
 type importRecipeURLRequest struct {

internal/cooked/client_preview_validation_test.go 🔗

@@ -7,7 +7,6 @@ package cooked
 import (
 	"context"
 	"net/http"
-	"strings"
 	"testing"
 )
 
@@ -28,11 +27,12 @@ func TestPreviewRecipeTextACIDAPIClientResponses1AcceptsIntegerDecimalPortions(t
 		t.Fatalf("preview calls = %d, want 1", previewCalls)
 	}
 	if preview.Portions != 1 {
-		t.Fatalf("preview portions = %d, want 1", preview.Portions)
+		t.Fatalf("preview portions = %g, want 1", preview.Portions)
 	}
 }
 
-func TestPreviewRecipeTextACIDAPIClientResponses1RejectsFractionalPortions(t *testing.T) {
+// api-client.RESPONSES.1 recipes.PREVIEW_TEXT.4-1
+func TestPreviewRecipeTextACIDAPIClientResponses1AndRecipesPreviewText4_1PreservesFractionalPortions(t *testing.T) {
 	var previewCalls int
 	client, closeServer := newTestClient(t, rawPreviewRecipeTextTestHandler(
 		t,
@@ -41,16 +41,16 @@ func TestPreviewRecipeTextACIDAPIClientResponses1RejectsFractionalPortions(t *te
 	))
 	defer closeServer()
 
-	_, err := client.PreviewRecipeText(context.Background(), "Pasta", "Boil pasta.")
-	if err == nil {
-		t.Fatal("PreviewRecipeText() error = nil, want fractional portions error")
-	}
-	if !strings.Contains(err.Error(), "must be integer-valued") {
-		t.Fatalf("PreviewRecipeText() error = %v, want integer-valued portions error", err)
+	preview, err := client.PreviewRecipeText(context.Background(), "Pasta", "Boil pasta.")
+	if err != nil {
+		t.Fatalf("PreviewRecipeText() error = %v", err)
 	}
 	if previewCalls != 1 {
 		t.Fatalf("preview calls = %d, want 1", previewCalls)
 	}
+	if preview.Portions != 1.5 {
+		t.Fatalf("preview portions = %g, want 1.5", preview.Portions)
+	}
 }
 
 func rawPreviewRecipeTextTestHandler(t *testing.T, previewCalls *int, body string) http.HandlerFunc {

internal/cooked/client_recipe_validation_test.go 🔗

@@ -111,7 +111,7 @@ func TestSavePreparedRecipeACIDAPIClientResponses1RejectsMissingOrBlankRecipeID(
 				context.Background(),
 				"Pasta",
 				"# Pasta\n\n1. Boil pasta.",
-				2,
+				2.5,
 			)
 			if err == nil {
 				t.Fatal("SavePreparedRecipe() error = nil, want missing recipe ID error")
@@ -126,6 +126,28 @@ func TestSavePreparedRecipeACIDAPIClientResponses1RejectsMissingOrBlankRecipeID(
 	}
 }
 
+// api-client.RESPONSES.1 recipes.READ.8-1
+func TestReadRecipeContentACIDAPIClientResponses1AndRecipesRead8_1PreservesFractionalPortions(t *testing.T) {
+	var contentCalls int
+	client, closeServer := newTestClient(t, rawRecipeContentTestHandler(
+		t,
+		&contentCalls,
+		`{"content":"# Pasta","portions":1.5}`,
+	))
+	defer closeServer()
+
+	content, err := client.ReadRecipeContent(context.Background(), "recipe/with space")
+	if err != nil {
+		t.Fatalf("ReadRecipeContent() error = %v", err)
+	}
+	if contentCalls != 1 {
+		t.Fatalf("content calls = %d, want 1", contentCalls)
+	}
+	if content.Portions != 1.5 {
+		t.Fatalf("content portions = %g, want 1.5", content.Portions)
+	}
+}
+
 // api-client.RESPONSES.1 recipes.READ.7 recipes.READ.8
 func TestReadRecipeContentACIDAPIClientResponses1RejectsMissingContentOrPortions(t *testing.T) {
 	tests := []struct {

internal/cooked/client_test.go 🔗

@@ -99,7 +99,7 @@ func TestReadRecipeContentACIDRecipesRead7And8ReadsContentAndPortions(t *testing
 		t.Fatalf("content = %q, want recipe markdown", content.Content)
 	}
 	if content.Portions != 2 {
-		t.Fatalf("portions = %d, want 2", content.Portions)
+		t.Fatalf("portions = %g, want 2", content.Portions)
 	}
 }
 
@@ -123,16 +123,17 @@ func TestPreviewRecipeTextACIDRecipesPreviewText1To4PreviewsWithoutSaving(t *tes
 		t.Fatalf("preview markdown = %q, want recipe markdown", preview.Markdown)
 	}
 	if preview.Portions != 2 {
-		t.Fatalf("preview portions = %d, want 2", preview.Portions)
+		t.Fatalf("preview portions = %g, want 2", preview.Portions)
 	}
 }
 
-func TestSavePreparedRecipeACIDRecipesSave2And9And13SavesMarkdownAsDescription(t *testing.T) {
+// recipes.SAVE.2 recipes.SAVE.8-4 recipes.SAVE.9 recipes.SAVE.13
+func TestSavePreparedRecipeACIDRecipesSave2And8_4And9And13SavesMarkdownAsDescription(t *testing.T) {
 	var saveCalls int
 	client, closeServer := newTestClient(t, savePreparedRecipeTestHandler(t, &saveCalls))
 	defer closeServer()
 
-	recipeID, err := client.SavePreparedRecipe(context.Background(), "Pasta", "# Pasta\n\n1. Boil pasta.", 2)
+	recipeID, err := client.SavePreparedRecipe(context.Background(), "Pasta", "# Pasta\n\n1. Boil pasta.", 2.5)
 	if err != nil {
 		t.Fatalf("SavePreparedRecipe() error = %v", err)
 	}
@@ -833,8 +834,8 @@ func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) {
 	if request.Description != "# Pasta\n\n1. Boil pasta." {
 		t.Fatalf("save description = %q, want markdown", request.Description)
 	}
-	if request.Portions != 2 {
-		t.Fatalf("save portions = %d, want 2", request.Portions)
+	if request.Portions != 2.5 {
+		t.Fatalf("save portions = %g, want 2.5", request.Portions)
 	}
 }
 
@@ -849,7 +850,7 @@ func requireRecipeContentRequest(t *testing.T, r *http.Request, description stri
 		t.Fatalf("%s description = %q, want markdown", description, request.Description)
 	}
 	if request.Portions != 4 {
-		t.Fatalf("%s portions = %d, want 4", description, request.Portions)
+		t.Fatalf("%s portions = %g, want 4", description, request.Portions)
 	}
 }
 

internal/mcp/server.go 🔗

@@ -8,6 +8,7 @@ package mcp
 import (
 	"context"
 	"fmt"
+	"strconv"
 	"strings"
 
 	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -33,9 +34,9 @@ type Backend interface {
 	ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error)
 	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)
-	SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions int) (string, error)
-	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error
+	SavePreparedRecipe(ctx context.Context, title, markdown string, portions float64) (string, error)
+	SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions float64) (string, error)
+	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions float64) error
 	ImportRecipeURL(ctx context.Context, recipeURL string) (cooked.RecipeURLImport, error)
 	DeleteRecipe(ctx context.Context, recipeID string) error
 	ClearShoppingList(ctx context.Context) error
@@ -390,7 +391,7 @@ func (s *Server) callExistingSaveRecipeTool(
 	return newRecipeSaveResult(recipeID)
 }
 
-func validateSaveRecipeContent(source, markdown string, portions int) error {
+func validateSaveRecipeContent(source, markdown string, portions float64) error {
 	if strings.TrimSpace(markdown) == "" {
 		return fmt.Errorf("markdown is required when source is %s", source)
 	}
@@ -404,7 +405,7 @@ func validateSaveRecipeContent(source, markdown string, portions int) error {
 func (s *Server) savePreparedRecipe(
 	ctx context.Context,
 	title, markdown string,
-	portions int,
+	portions float64,
 ) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 	recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
 	if err != nil {
@@ -1007,7 +1008,7 @@ func formatRecipe(recipe RecipeDetail) string {
 		builder.WriteByte('\n')
 	}
 	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
-	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
+	fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(recipe.Portions))
 
 	content := strings.TrimSpace(recipe.Content)
 	if content == "" {
@@ -1030,7 +1031,7 @@ func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
 	builder.WriteString("Recipe text preview: ")
 	builder.WriteString(title)
 	builder.WriteByte('\n')
-	fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
+	fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(preview.Portions))
 
 	markdown := strings.TrimSpace(preview.Markdown)
 	if markdown == "" {
@@ -1043,6 +1044,8 @@ func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
 	return strings.TrimRight(builder.String(), "\n")
 }
 
+func formatPortions(portions float64) string { return strconv.FormatFloat(portions, 'f', -1, 64) }
+
 func formatRecipeSave(output SaveRecipeOutput) string {
 	return "Saved recipe (id: " + output.RecipeID + ")."
 }
@@ -1060,7 +1063,7 @@ func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
 	builder.WriteString(output.DraftID)
 	builder.WriteString("). Review the markdown and portions before saving.")
 	if output.Portions > 0 {
-		fmt.Fprintf(&builder, "\nPortions: %d", output.Portions)
+		fmt.Fprintf(&builder, "\nPortions: %s", formatPortions(output.Portions))
 	}
 	if strings.TrimSpace(output.Markdown) != "" {
 		builder.WriteString("\n\n")
@@ -1108,12 +1111,12 @@ type RecipeSummary struct {
 
 // 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"`
+	ID             string  `json:"id"`
+	Title          string  `json:"title"`
+	Owner          string  `json:"owner"`
+	EditPermission bool    `json:"edit_permission"`
+	Content        string  `json:"content"`
+	Portions       float64 `json:"portions"`
 }
 
 // PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
@@ -1124,30 +1127,30 @@ type PreviewRecipeTextArguments struct {
 
 // PreviewRecipeTextOutput is the structured output for preview_recipe_text.
 type PreviewRecipeTextOutput struct {
-	Title    string `json:"title"`
-	Markdown string `json:"markdown"`
-	Portions int    `json:"portions"`
+	Title    string  `json:"title"`
+	Markdown string  `json:"markdown"`
+	Portions float64 `json:"portions"`
 }
 
 // SaveRecipeArguments contains save_recipe tool arguments.
 type SaveRecipeArguments struct {
-	Source   string `json:"source"              jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
-	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
-	DraftID  string `json:"draft_id,omitempty"  jsonschema:"Draft ID for reviewed URL import draft saves."`
-	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."`
-	URL      string `json:"url,omitempty"       jsonschema:"Recipe URL for url imports."`
-	Markdown string `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates."`
-	Portions int    `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."`
+	Source   string  `json:"source"              jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
+	RecipeID string  `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
+	DraftID  string  `json:"draft_id,omitempty"  jsonschema:"Draft ID for reviewed URL import draft saves."`
+	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."`
+	URL      string  `json:"url,omitempty"       jsonschema:"Recipe URL for url imports."`
+	Markdown string  `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates."`
+	Portions float64 `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."`
 }
 
 // SaveRecipeOutput is the structured output for save_recipe.
 type SaveRecipeOutput struct {
-	RecipeID string `json:"recipe_id,omitempty"`
-	DraftID  string `json:"draft_id,omitempty"`
-	Title    string `json:"title,omitempty"`
-	Markdown string `json:"markdown,omitempty"`
-	Portions int    `json:"portions,omitempty"`
+	RecipeID string  `json:"recipe_id,omitempty"`
+	DraftID  string  `json:"draft_id,omitempty"`
+	Title    string  `json:"title,omitempty"`
+	Markdown string  `json:"markdown,omitempty"`
+	Portions float64 `json:"portions,omitempty"`
 }
 
 // DeleteRecipeArguments contains delete_recipe tool arguments.

internal/mcp/server_save_draft_test.go 🔗

@@ -38,7 +38,7 @@ func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) {
 		t.Fatalf("backend draft markdown = %q, want raw markdown preserved", backend.draftSaveMarkdown)
 	}
 	if backend.draftSavePortions != 2 {
-		t.Fatalf("backend draft portions = %d, want 2", backend.draftSavePortions)
+		t.Fatalf("backend draft portions = %g, want 2", backend.draftSavePortions)
 	}
 	if output.RecipeID != "recipe-1" || output.DraftID != "" || output.Markdown != "" || output.Portions != 0 {
 		t.Fatalf("draft save output = %#v, want recipe_id only", output)

internal/mcp/server_save_url_test.go 🔗

@@ -94,7 +94,7 @@ func TestCallToolACIDRecipesSave5And12And14And15ReturnsURLImportDraft(t *testing
 		t.Fatalf("draft output identity = %#v, want draft-1 Pasta with no recipe ID", output)
 	}
 	if output.Markdown != "# Pasta\n\n1. Boil pasta." || output.Portions != 2 {
-		t.Fatalf("draft output content = %q/%d, want markdown/2", output.Markdown, output.Portions)
+		t.Fatalf("draft output content = %q/%g, want markdown/2", output.Markdown, output.Portions)
 	}
 	if backend.saveCalls != 0 || backend.updateCalls != 0 {
 		t.Fatalf("save/update calls = %d/%d, want none", backend.saveCalls, backend.updateCalls)

internal/mcp/server_test.go 🔗

@@ -118,7 +118,8 @@ func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
 	}
 }
 
-func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
+// recipes.READ.5 recipes.READ.8-1
+func TestCallToolACIDRecipesRead5And8_1ReadsSingleRecipe(t *testing.T) {
 	backend := &fakeBackend{
 		recipeMetadata: cooked.RecipeMetadata{
 			Title:          "Pasta",
@@ -128,7 +129,7 @@ func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
 		},
 		recipeContent: cooked.RecipeContent{
 			Content:  "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
-			Portions: 2,
+			Portions: 1.5,
 		},
 	}
 	server := NewServer(backend, "test")
@@ -179,9 +180,9 @@ func requireSingleRecipeOutput(t *testing.T, output ReadOutput) {
 			output.Recipe.EditPermission,
 		)
 	}
-	if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 {
+	if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 1.5 {
 		t.Fatalf(
-			"structured recipe content/portions = %q/%d, want markdown/2",
+			"structured recipe content/portions = %q/%g, want markdown/1.5",
 			output.Recipe.Content,
 			output.Recipe.Portions,
 		)
@@ -219,11 +220,12 @@ func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T)
 	}
 }
 
-func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) {
+// recipes.PREVIEW_TEXT.1 recipes.PREVIEW_TEXT.4-1
+func TestCallToolACIDRecipesPreviewText1And4_1PreviewsRawText(t *testing.T) {
 	backend := &fakeBackend{preview: cooked.RecipeTextPreview{
 		Title:    "Pasta with Tomato Sauce",
 		Markdown: "# Pasta\n\n1. Boil pasta.",
-		Portions: 2,
+		Portions: 2.5,
 	}}
 	server := NewServer(backend, "test")
 
@@ -246,7 +248,7 @@ func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) {
 		t.Fatalf("backend preview text = %q, want raw text preserved", backend.previewText)
 	}
 	if output.Title != "Pasta with Tomato Sauce" || output.Markdown != "# Pasta\n\n1. Boil pasta." ||
-		output.Portions != 2 {
+		output.Portions != 2.5 {
 		t.Fatalf("preview output = %#v, want title/markdown/portions", output)
 	}
 	requireStructuredContent(t, result, output)
@@ -286,7 +288,8 @@ func TestCallToolACIDToolsPreviewRecipeTextTool2RequiresText(t *testing.T) {
 	}
 }
 
-func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) {
+// recipes.SAVE.2 recipes.SAVE.8-4 recipes.SAVE.9
+func TestCallToolACIDRecipesSave2And8_4And9SavesPreparedRecipe(t *testing.T) {
 	backend := &fakeBackend{saveRecipeID: "recipe-1"}
 	server := NewServer(backend, "test")
 
@@ -297,7 +300,7 @@ func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) {
 			Source:   " prepared ",
 			Title:    " Pasta ",
 			Markdown: "  # Pasta\n\n1. Boil pasta.\n",
-			Portions: 2,
+			Portions: 2.5,
 		},
 	)
 	if err != nil {
@@ -313,8 +316,8 @@ func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) {
 	if backend.saveMarkdown != "  # Pasta\n\n1. Boil pasta.\n" {
 		t.Fatalf("backend save markdown = %q, want raw markdown preserved", backend.saveMarkdown)
 	}
-	if backend.savePortions != 2 {
-		t.Fatalf("backend save portions = %d, want 2", backend.savePortions)
+	if backend.savePortions != 2.5 {
+		t.Fatalf("backend save portions = %g, want 2.5", backend.savePortions)
 	}
 	if output.RecipeID != "recipe-1" {
 		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
@@ -393,7 +396,7 @@ func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) {
 		t.Fatalf("save markdown = %q, want preview markdown", backend.saveMarkdown)
 	}
 	if backend.savePortions != 2 {
-		t.Fatalf("save portions = %d, want preview portions 2", backend.savePortions)
+		t.Fatalf("save portions = %g, want preview portions 2", backend.savePortions)
 	}
 	if output.RecipeID != "recipe-1" {
 		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
@@ -425,7 +428,8 @@ func TestCallToolACIDRecipesSave1_1To1_2RequiresRawTextFields(t *testing.T) {
 	}
 }
 
-func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) {
+// recipes.SAVE.8 recipes.SAVE.8-4 recipes.SAVE.9
+func TestCallToolACIDRecipesSave8And8_4And9UpdatesExistingRecipe(t *testing.T) {
 	backend := &fakeBackend{}
 	server := NewServer(backend, "test")
 
@@ -436,7 +440,7 @@ func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) {
 			Source:   " existing ",
 			RecipeID: " recipe-1 ",
 			Markdown: "  # Pasta\n\n1. Boil pasta.\n",
-			Portions: 4,
+			Portions: 4.5,
 		},
 	)
 	if err != nil {
@@ -452,8 +456,8 @@ func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) {
 	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 backend.updatePortions != 4.5 {
+		t.Fatalf("backend update portions = %g, want 4.5", backend.updatePortions)
 	}
 	if output.RecipeID != "recipe-1" {
 		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
@@ -958,13 +962,13 @@ type fakeBackend struct {
 	importURL                        string
 	saveTitle                        string
 	saveMarkdown                     string
-	savePortions                     int
+	savePortions                     float64
 	draftSaveID                      string
 	draftSaveMarkdown                string
-	draftSavePortions                int
+	draftSavePortions                float64
 	updateRecipeID                   string
 	updateMarkdown                   string
-	updatePortions                   int
+	updatePortions                   float64
 	deleteRecipeID                   string
 	addShoppingListResult            cooked.AddShoppingListResult
 	addIngredients                   string
@@ -1031,7 +1035,7 @@ func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (
 	return f.preview, nil
 }
 
-func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions int) (string, error) {
+func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions float64) (string, error) {
 	f.saveTitle = title
 	f.saveMarkdown = markdown
 	f.savePortions = portions
@@ -1043,7 +1047,7 @@ func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown stri
 	return "recipe-1", nil
 }
 
-func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown string, portions int) (string, error) {
+func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown string, portions float64) (string, error) {
 	f.draftSaveID = draftID
 	f.draftSaveMarkdown = markdown
 	f.draftSavePortions = portions
@@ -1055,7 +1059,7 @@ func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown strin
 	return "recipe-1", nil
 }
 
-func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error {
+func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions float64) error {
 	f.updateRecipeID = recipeID
 	f.updateMarkdown = markdown
 	f.updatePortions = portions