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