diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 982c4d44715002b3c6fc66e38344ed94628f32f1..8c8a66ef0b4a923a752ad874b942f7bb2ab897b1 100644 --- a/internal/mcp/server.go +++ b/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. diff --git a/internal/mcp/server_save_url_test.go b/internal/mcp/server_save_url_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cfa579b3c617a683ffc22f957c0156631379ad49 --- /dev/null +++ b/internal/mcp/server_save_url_test.go @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) + } +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 287b143280a98e9f7e2e749e3d682aa10675f1fe..3c6cf736e62e4ba15eb1a4b3f9a47fbbb9bc3d9b 100644 --- a/internal/mcp/server_test.go +++ b/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++