tools: save prepared recipe

Amolith created

Change summary

internal/mcp/server.go      |  72 ++++++++++++++++++++++++++++
internal/mcp/server_test.go | 100 +++++++++++++++++++++++++++++++++++++++
2 files changed, 172 insertions(+)

Detailed changes

internal/mcp/server.go 🔗

@@ -19,6 +19,7 @@ const (
 	serverName                = "Cooked"
 	readToolName              = "read"
 	previewRecipeTextToolName = "preview_recipe_text"
+	saveRecipeToolName        = "save_recipe"
 )
 
 // Backend provides the Cooked operations exposed as MCP tools.
@@ -29,6 +30,7 @@ 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)
 }
 
 // Server exposes Cooked as an MCP server.
@@ -43,6 +45,7 @@ func NewServer(backend Backend, version string) *Server {
 	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
 	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
 	sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
+	sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
 
 	return server
 }
@@ -183,6 +186,45 @@ func (s *Server) callPreviewRecipeTextTool(
 	}, output, nil
 }
 
+func (s *Server) callSaveRecipeTool(
+	ctx context.Context,
+	_ *sdk.CallToolRequest,
+	arguments SaveRecipeArguments,
+) (*sdk.CallToolResult, SaveRecipeOutput, error) {
+	source := strings.TrimSpace(arguments.Source)
+	switch source {
+	case "prepared":
+		title := strings.TrimSpace(arguments.Title)
+		if title == "" {
+			return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
+		}
+		if strings.TrimSpace(arguments.Markdown) == "" {
+			return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
+		}
+		if arguments.Portions < 1 {
+			return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
+		}
+
+		recipeID, err := s.backend.SavePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
+		if err != nil {
+			return nil, SaveRecipeOutput{}, err
+		}
+		if strings.TrimSpace(recipeID) == "" {
+			return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
+		}
+
+		output := SaveRecipeOutput{RecipeID: recipeID}
+
+		return &sdk.CallToolResult{
+			Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
+		}, output, nil
+	case "":
+		return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
+	default:
+		return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
+	}
+}
+
 func readTool() *sdk.Tool {
 	openWorld := true
 	return &sdk.Tool{
@@ -211,6 +253,19 @@ func previewRecipeTextTool() *sdk.Tool {
 	}
 }
 
+func saveRecipeTool() *sdk.Tool {
+	openWorld := true
+	return &sdk.Tool{
+		Name:        saveRecipeToolName,
+		Title:       "Save recipe",
+		Description: "Save a recipe. This slice supports source prepared with title, markdown, and portions. Other source values are reserved for later slices.",
+		Annotations: &sdk.ToolAnnotations{
+			Title:         "Save recipe",
+			OpenWorldHint: &openWorld,
+		},
+	}
+}
+
 func normalizeRecipePage(page, limit int) (int, int) {
 	if page < 1 {
 		page = 1
@@ -334,6 +389,10 @@ func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
 	return strings.TrimRight(builder.String(), "\n")
 }
 
+func formatRecipeSave(output SaveRecipeOutput) string {
+	return "Saved recipe (id: " + output.RecipeID + ")."
+}
+
 func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
 	summaries := make([]RecipeSummary, 0, len(recipes))
 	for _, recipe := range recipes {
@@ -388,3 +447,16 @@ type PreviewRecipeTextOutput struct {
 	Markdown string `json:"markdown"`
 	Portions int    `json:"portions"`
 }
+
+// SaveRecipeArguments contains save_recipe tool arguments.
+type SaveRecipeArguments struct {
+	Source   string `json:"source"             jsonschema:"Recipe save source. Supported now: prepared."`
+	Title    string `json:"title,omitempty"    jsonschema:"Recipe title for prepared saves."`
+	Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves."`
+	Portions int    `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves."`
+}
+
+// SaveRecipeOutput is the structured output for save_recipe.
+type SaveRecipeOutput struct {
+	RecipeID string `json:"recipe_id,omitempty"`
+}

internal/mcp/server_test.go 🔗

@@ -289,6 +289,89 @@ func TestCallToolACIDToolsPreviewRecipeTextTool2RequiresText(t *testing.T) {
 	}
 }
 
+func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) {
+	backend := &fakeBackend{saveRecipeID: "recipe-1"}
+	server := NewServer(backend, "test")
+
+	_, output, err := server.callSaveRecipeTool(
+		context.Background(),
+		nil,
+		SaveRecipeArguments{
+			Source:   " prepared ",
+			Title:    " Pasta ",
+			Markdown: "  # Pasta\n\n1. Boil pasta.\n",
+			Portions: 2,
+		},
+	)
+	if err != nil {
+		t.Fatalf("callSaveRecipeTool() error = %v", err)
+	}
+
+	if backend.saveCalls != 1 {
+		t.Fatalf("save calls = %d, want 1", backend.saveCalls)
+	}
+	if backend.saveTitle != "Pasta" {
+		t.Fatalf("backend save title = %q, want trimmed Pasta", backend.saveTitle)
+	}
+	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 output.RecipeID != "recipe-1" {
+		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
+	}
+}
+
+func TestCallToolACIDRecipesSave2_1To2_3RequiresPreparedFields(t *testing.T) {
+	tests := []struct {
+		name      string
+		arguments SaveRecipeArguments
+	}{
+		{
+			name:      "title",
+			arguments: SaveRecipeArguments{Source: "prepared", Title: "   ", Markdown: "# Pasta", Portions: 2},
+		},
+		{
+			name:      "markdown",
+			arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: "   ", Portions: 2},
+		},
+		{
+			name:      "portions",
+			arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", 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 prepared field error")
+			}
+			if backend.saveCalls != 0 {
+				t.Fatalf("save calls = %d, want none", backend.saveCalls)
+			}
+		})
+	}
+}
+
+func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t *testing.T) {
+	backend := &fakeBackend{}
+	server := NewServer(backend, "test")
+
+	_, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "raw_text"})
+	if err == nil {
+		t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error")
+	}
+	if backend.saveCalls != 0 {
+		t.Fatalf("save calls = %d, want none", backend.saveCalls)
+	}
+}
+
 type fakeBackend struct {
 	shoppingList     cooked.ShoppingList
 	recipes          []cooked.RecipeCard
@@ -296,6 +379,7 @@ type fakeBackend struct {
 	recipeMetadata   cooked.RecipeMetadata
 	recipeContent    cooked.RecipeContent
 	preview          cooked.RecipeTextPreview
+	saveRecipeID     string
 	page             int
 	limit            int
 	searchQuery      string
@@ -304,9 +388,13 @@ type fakeBackend struct {
 	contentRecipeID  string
 	previewTitle     string
 	previewText      string
+	saveTitle        string
+	saveMarkdown     string
+	savePortions     int
 	metadataCalls    int
 	contentCalls     int
 	previewCalls     int
+	saveCalls        int
 }
 
 func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
@@ -348,3 +436,15 @@ 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) {
+	f.saveTitle = title
+	f.saveMarkdown = markdown
+	f.savePortions = portions
+	f.saveCalls++
+	if f.saveRecipeID != "" {
+		return f.saveRecipeID, nil
+	}
+
+	return "recipe-1", nil
+}