tools: import recipes from URLs

Amolith created

Change summary

internal/mcp/server.go               | 102 +++++++++++++++++++++++++++--
internal/mcp/server_save_url_test.go | 102 ++++++++++++++++++++++++++++++
internal/mcp/server_test.go          |  12 +++
3 files changed, 206 insertions(+), 10 deletions(-)

Detailed changes

internal/mcp/server.go 🔗

@@ -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.

internal/mcp/server_save_url_test.go 🔗

@@ -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)
+	}
+}

internal/mcp/server_test.go 🔗

@@ -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++