@@ -34,6 +34,7 @@ type Backend interface {
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
+ ImportRecipeURL(ctx context.Context, recipeURL string) (cooked.RecipeURLImport, error)
DeleteRecipe(ctx context.Context, recipeID string) error
ClearShoppingList(ctx context.Context) error
AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
@@ -119,11 +120,7 @@ func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*s
return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
}
- metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
- if err != nil {
- return nil, ReadOutput{}, err
- }
- content, err := s.backend.ReadRecipeContent(ctx, recipeID)
+ metadata, content, err := s.readRecipeParts(ctx, recipeID)
if err != nil {
return nil, ReadOutput{}, err
}
@@ -141,6 +138,22 @@ func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*s
return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
}
+func (s *Server) readRecipeParts(
+ ctx context.Context,
+ recipeID string,
+) (cooked.RecipeMetadata, cooked.RecipeContent, error) {
+ metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
+ if err != nil {
+ return cooked.RecipeMetadata{}, cooked.RecipeContent{}, err
+ }
+ content, err := s.backend.ReadRecipeContent(ctx, recipeID)
+ if err != nil {
+ return cooked.RecipeMetadata{}, cooked.RecipeContent{}, err
+ }
+
+ return metadata, content, nil
+}
+
func (s *Server) callRecipesReadTool(
ctx context.Context,
arguments ReadArguments,
@@ -212,6 +225,8 @@ func (s *Server) callSaveRecipeTool(
return s.callRawTextSaveRecipeTool(ctx, arguments)
case "prepared":
return s.callPreparedSaveRecipeTool(ctx, arguments)
+ case "url":
+ return s.callURLSaveRecipeTool(ctx, arguments)
case "existing":
return s.callExistingSaveRecipeTool(ctx, arguments)
case "":
@@ -269,6 +284,47 @@ func (s *Server) callPreparedSaveRecipeTool(
return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
}
+func (s *Server) callURLSaveRecipeTool(
+ ctx context.Context,
+ arguments SaveRecipeArguments,
+) (*sdk.CallToolResult, SaveRecipeOutput, error) {
+ recipeURL := strings.TrimSpace(arguments.URL)
+ if recipeURL == "" {
+ return nil, SaveRecipeOutput{}, fmt.Errorf("url is required when source is url")
+ }
+
+ imported, err := s.backend.ImportRecipeURL(ctx, recipeURL)
+ if err != nil {
+ return nil, SaveRecipeOutput{}, err
+ }
+
+ recipeID := strings.TrimSpace(imported.RecipeID)
+ if recipeID != "" {
+ return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
+ }
+
+ draftID := strings.TrimSpace(imported.DraftID)
+ if draftID == "" {
+ return nil, SaveRecipeOutput{}, fmt.Errorf("import recipe URL response missing recipe or draft ID")
+ }
+
+ metadata, content, err := s.readRecipeParts(ctx, draftID)
+ if err != nil {
+ return nil, SaveRecipeOutput{}, err
+ }
+
+ output := SaveRecipeOutput{
+ DraftID: draftID,
+ Title: metadata.Title,
+ Markdown: content.Content,
+ Portions: content.Portions,
+ }
+
+ return &sdk.CallToolResult{
+ Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeURLImportDraft(output)}},
+ }, output, nil
+}
+
func (s *Server) callExistingSaveRecipeTool(
ctx context.Context,
arguments SaveRecipeArguments,
@@ -595,7 +651,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, source prepared with title, markdown, and portions, and source existing with recipe_id, 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, source url with url, 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,
@@ -845,6 +901,29 @@ func formatRecipeSave(output SaveRecipeOutput) string {
return "Saved recipe (id: " + output.RecipeID + ")."
}
+func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
+ title := output.Title
+ if strings.TrimSpace(title) == "" {
+ title = "(untitled draft)"
+ }
+
+ var builder strings.Builder
+ builder.WriteString("Imported recipe URL as draft: ")
+ builder.WriteString(title)
+ builder.WriteString(" (draft_id: ")
+ builder.WriteString(output.DraftID)
+ builder.WriteString("). Review the markdown and portions before saving.")
+ if output.Portions > 0 {
+ fmt.Fprintf(&builder, "\nPortions: %d", output.Portions)
+ }
+ if strings.TrimSpace(output.Markdown) != "" {
+ builder.WriteString("\n\n")
+ builder.WriteString(output.Markdown)
+ }
+
+ return strings.TrimRight(builder.String(), "\n")
+}
+
func formatRecipeDelete(output DeleteRecipeOutput) string {
return "Deleted recipe (id: " + output.RecipeID + ")."
}
@@ -906,17 +985,22 @@ type PreviewRecipeTextOutput struct {
// SaveRecipeArguments contains save_recipe tool arguments.
type SaveRecipeArguments struct {
- Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
+ Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, url, 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."`
+ URL string `json:"url,omitempty" jsonschema:"Recipe URL for url imports."`
+ Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves, URL import drafts, or existing recipe updates."`
+ Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves, 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"`
}
// DeleteRecipeArguments contains delete_recipe tool arguments.
@@ -0,0 +1,102 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
+
+package mcp
+
+import (
+ "context"
+ "testing"
+
+ "git.secluded.site/cooked-mcp/internal/cooked"
+)
+
+// recipes.SAVE.3 recipes.SAVE.4 recipes.SAVE.9 tools.SAVE_RECIPE_TOOL.1
+func TestCallToolACIDRecipesSave3And4And9ImportsURLSavedRecipe(t *testing.T) {
+ backend := &fakeBackend{importRecipeURLResult: cooked.RecipeURLImport{RecipeID: "recipe-1"}}
+ server := NewServer(backend, "test")
+
+ _, output, err := server.callSaveRecipeTool(
+ context.Background(),
+ nil,
+ SaveRecipeArguments{Source: " url ", URL: " https://example.invalid/recipe "},
+ )
+ if err != nil {
+ t.Fatalf("callSaveRecipeTool() error = %v", err)
+ }
+
+ if backend.importRecipeURLCalls != 1 {
+ t.Fatalf("import calls = %d, want 1", backend.importRecipeURLCalls)
+ }
+ if backend.importURL != "https://example.invalid/recipe" {
+ t.Fatalf("backend import URL = %q, want trimmed URL", backend.importURL)
+ }
+ if output.RecipeID != "recipe-1" || output.DraftID != "" {
+ t.Fatalf("save output = %#v, want recipe_id only", output)
+ }
+ if backend.metadataCalls != 0 || backend.contentCalls != 0 {
+ t.Fatalf("draft read calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
+ }
+ if backend.saveCalls != 0 || backend.updateCalls != 0 {
+ t.Fatalf("save/update calls = %d/%d, want none", backend.saveCalls, backend.updateCalls)
+ }
+}
+
+// recipes.SAVE.3-1 tools.SAVE_RECIPE_TOOL.4
+func TestCallToolACIDRecipesSave3_1AndToolsSaveRecipeTool4RequiresURL(t *testing.T) {
+ backend := &fakeBackend{}
+ server := NewServer(backend, "test")
+
+ _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "url", URL: " "})
+ if err == nil {
+ t.Fatal("callSaveRecipeTool() error = nil, want missing URL error")
+ }
+ if backend.importRecipeURLCalls != 0 {
+ t.Fatalf("import calls = %d, want none", backend.importRecipeURLCalls)
+ }
+}
+
+// recipes.SAVE.5 recipes.SAVE.6 recipes.SAVE.12 recipes.SAVE.14 recipes.SAVE.15 recipes.SAVE.16
+func TestCallToolACIDRecipesSave5And12And14And15ReturnsURLImportDraft(t *testing.T) {
+ backend := &fakeBackend{
+ importRecipeURLResult: cooked.RecipeURLImport{DraftID: "draft-1"},
+ recipeMetadata: cooked.RecipeMetadata{Title: "Pasta"},
+ recipeContent: cooked.RecipeContent{Content: "# Pasta\n\n1. Boil pasta.", Portions: 2},
+ }
+ server := NewServer(backend, "test")
+
+ _, output, err := server.callSaveRecipeTool(
+ context.Background(),
+ nil,
+ SaveRecipeArguments{Source: "url", URL: " https://example.invalid/pasta "},
+ )
+ if err != nil {
+ t.Fatalf("callSaveRecipeTool() error = %v", err)
+ }
+
+ if backend.importRecipeURLCalls != 1 {
+ t.Fatalf("import calls = %d, want 1", backend.importRecipeURLCalls)
+ }
+ if backend.importURL != "https://example.invalid/pasta" {
+ t.Fatalf("backend import URL = %q, want trimmed URL", backend.importURL)
+ }
+ if backend.metadataRecipeID != "draft-1" || backend.contentRecipeID != "draft-1" {
+ t.Fatalf(
+ "draft read IDs = metadata %q content %q, want draft-1",
+ backend.metadataRecipeID,
+ backend.contentRecipeID,
+ )
+ }
+ if backend.metadataCalls != 1 || backend.contentCalls != 1 {
+ t.Fatalf("draft read calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
+ }
+ if output.RecipeID != "" || output.DraftID != "draft-1" || output.Title != "Pasta" {
+ 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)
+ }
+ if backend.saveCalls != 0 || backend.updateCalls != 0 {
+ t.Fatalf("save/update calls = %d/%d, want none", backend.saveCalls, backend.updateCalls)
+ }
+}
@@ -364,7 +364,7 @@ func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t
backend := &fakeBackend{}
server := NewServer(backend, "test")
- _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "url"})
+ _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "draft"})
if err == nil {
t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error")
}
@@ -950,6 +950,7 @@ type fakeBackend struct {
recipeMetadata cooked.RecipeMetadata
recipeContent cooked.RecipeContent
preview cooked.RecipeTextPreview
+ importRecipeURLResult cooked.RecipeURLImport
saveRecipeID string
page int
limit int
@@ -959,6 +960,7 @@ type fakeBackend struct {
contentRecipeID string
previewTitle string
previewText string
+ importURL string
saveTitle string
saveMarkdown string
savePortions int
@@ -977,6 +979,7 @@ type fakeBackend struct {
readShoppingListCalls int
contentCalls int
previewCalls int
+ importRecipeURLCalls int
saveCalls int
updateCalls int
deleteCalls int
@@ -1050,6 +1053,13 @@ func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown
return nil
}
+func (f *fakeBackend) ImportRecipeURL(_ context.Context, recipeURL string) (cooked.RecipeURLImport, error) {
+ f.importURL = recipeURL
+ f.importRecipeURLCalls++
+
+ return f.importRecipeURLResult, nil
+}
+
func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
f.deleteRecipeID = recipeID
f.deleteCalls++