diff --git a/internal/mcp/server.go b/internal/mcp/server.go index a35ba1c242b26cc16099ffce4c659f857ec932dc..d1133725683d27ed9a9a31dd5bb4ae9f34298afd 100644 --- a/internal/mcp/server.go +++ b/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"` +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 6c1c47c192698fda6dc787c12f45f2c581ef0b83..8339eec4126d816cb04e3f49445b3ac3bc44f949 100644 --- a/internal/mcp/server_test.go +++ b/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 +}