From 9de59db7187313c107870d60297a3c06e24ae4ab Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 03:08:16 +0200 Subject: [PATCH 1/3] feat(cookbook): Add full Cookbook app support with 13 tools and 2 resources - Import recipes from URLs using schema.org metadata - Full CRUD operations for recipes - Search, categorize, and organize recipes - Manage keywords/tags and categories - Configure app settings and trigger reindexing --- README.md | 10 + .../post-installation/install-cookbook-app.sh | 5 + docs/cookbook.md | 189 ++++++ nextcloud_mcp_server/app.py | 6 +- nextcloud_mcp_server/client/__init__.py | 2 + nextcloud_mcp_server/client/cookbook.py | 245 ++++++++ nextcloud_mcp_server/models/cookbook.py | 220 +++++++ nextcloud_mcp_server/server/__init__.py | 2 + nextcloud_mcp_server/server/cookbook.py | 582 ++++++++++++++++++ tests/client/cookbook/test_cookbook_api.py | 398 ++++++++++++ tests/conftest.py | 74 +++ tests/fixtures/test_recipe.html | 133 ++++ tests/server/test_cookbook_mcp.py | 564 +++++++++++++++++ tests/server/test_mcp.py | 21 +- 14 files changed, 2449 insertions(+), 2 deletions(-) create mode 100755 app-hooks/post-installation/install-cookbook-app.sh create mode 100644 docs/cookbook.md create mode 100644 nextcloud_mcp_server/client/cookbook.py create mode 100644 nextcloud_mcp_server/models/cookbook.py create mode 100644 nextcloud_mcp_server/server/cookbook.py create mode 100644 tests/client/cookbook/test_cookbook_api.py create mode 100644 tests/fixtures/test_recipe.html create mode 100644 tests/server/test_cookbook_mcp.py diff --git a/README.md b/README.md index 02a7830..d311391 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models l | **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. | | **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. | | **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. | +| **Cookbook** | ✅ Full | Manage recipes with schema.org metadata. Import from URLs, search, categorize. | | **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. | | **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. | | **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. | @@ -140,6 +141,7 @@ Or connect from: - [Notes API](docs/notes.md) - [Calendar (CalDAV)](docs/calendar.md) - [Contacts (CardDAV)](docs/contacts.md) +- [Cookbook](docs/cookbook.md) - [Deck](docs/deck.md) - [Tables](docs/table.md) - [WebDAV](docs/webdav.md) @@ -151,6 +153,7 @@ The server exposes Nextcloud functionality through MCP tools (for actions) and r ### Tools Tools enable AI assistants to perform actions: - `nc_notes_create_note` - Create a new note +- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata - `deck_create_card` - Create a Deck card - `nc_calendar_create_event` - Create a calendar event - `nc_contacts_create_contact` - Create a contact @@ -159,6 +162,7 @@ Tools enable AI assistants to perform actions: ### Resources Resources provide read-only access to Nextcloud data: - `nc://capabilities` - Server capabilities +- `cookbook://version` - Cookbook app version info - `nc://Deck/boards/{board_id}` - Deck board data - `notes://settings` - Notes app settings - And more... @@ -173,6 +177,12 @@ AI: "Create a note called 'Meeting Notes' with today's agenda" → Uses nc_notes_create_note tool ``` +### Manage Recipes +``` +AI: "Import the recipe from this URL: https://www.example.com/recipe/chocolate-cake" +→ Uses nc_cookbook_import_recipe tool to extract schema.org metadata +``` + ### Manage Calendar ``` AI: "Schedule a team meeting for next Tuesday at 2pm" diff --git a/app-hooks/post-installation/install-cookbook-app.sh b/app-hooks/post-installation/install-cookbook-app.sh new file mode 100755 index 0000000..e637213 --- /dev/null +++ b/app-hooks/post-installation/install-cookbook-app.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euox pipefail + +php /var/www/html/occ app:enable cookbook diff --git a/docs/cookbook.md b/docs/cookbook.md new file mode 100644 index 0000000..04910ed --- /dev/null +++ b/docs/cookbook.md @@ -0,0 +1,189 @@ +# Cookbook App + +### Cookbook Tools + +| Tool | Description | +|------|-------------| +| `nc_cookbook_import_recipe` | Import a recipe from a URL using schema.org metadata | +| `nc_cookbook_create_recipe` | Create a new recipe with all schema.org fields | +| `nc_cookbook_get_recipe` | Get a specific recipe by ID | +| `nc_cookbook_update_recipe` | Update an existing recipe | +| `nc_cookbook_delete_recipe` | Delete a recipe permanently | +| `nc_cookbook_list_recipes` | Get all recipes in the database | +| `nc_cookbook_search_recipes` | Search for recipes by keywords, tags, and categories | +| `nc_cookbook_list_categories` | Get all known recipe categories | +| `nc_cookbook_get_recipes_in_category` | Get all recipes in a specific category | +| `nc_cookbook_list_keywords` | Get all known recipe keywords/tags | +| `nc_cookbook_get_recipes_with_keywords` | Get all recipes that have specific keywords | +| `nc_cookbook_set_config` | Set Cookbook app configuration | +| `nc_cookbook_reindex` | Trigger a rescan of all recipes into the search database | + +### Cookbook Resources + +| Resource | Description | +|----------|-------------| +| `cookbook://version` | Get Cookbook app and API version information | +| `cookbook://config` | Get Cookbook app configuration | +| `nc://Cookbook/{recipe_id}` | Get a specific recipe by ID | + +## Recipe Management + +The server provides complete Nextcloud Cookbook integration, enabling you to manage your recipe collection: + +- **Import recipes from websites** using schema.org metadata +- Full CRUD operations for recipes +- Search and organize with categories and keywords +- Support for structured recipe data (ingredients, instructions, nutrition, etc.) +- Configure app settings and trigger reindexing + +### Schema.org Recipe Format + +The Cookbook app uses the [schema.org/Recipe](https://schema.org/Recipe) specification for structured recipe data. This standard format includes: + +- **Basic info**: Name, description, image, URL +- **Timing**: Preparation time, cooking time, total time (ISO8601 format like `PT30M`) +- **Ingredients**: List of ingredients with quantities +- **Instructions**: Step-by-step cooking instructions +- **Metadata**: Category, keywords/tags, yield (servings) +- **Nutrition**: Optional nutrition information + +### Usage Examples + +#### Import Recipe from URL + +Many recipe websites include schema.org metadata. The import tool automatically extracts this data: + +```python +# Import from a recipe website +await nc_cookbook_import_recipe( + url="https://www.example.com/recipes/chocolate-cake" +) +# Returns: Recipe object with all extracted data +``` + +#### Create Recipe Manually + +```python +# Create a new recipe from scratch +await nc_cookbook_create_recipe( + name="Homemade Pizza", + description="Classic homemade pizza with fresh ingredients", + ingredients=[ + "500g pizza dough", + "200g tomato sauce", + "300g mozzarella cheese", + "Fresh basil leaves", + "Olive oil" + ], + instructions=[ + "Preheat oven to 250°C (480°F)", + "Roll out the pizza dough", + "Spread tomato sauce evenly", + "Add mozzarella cheese", + "Bake for 10-12 minutes", + "Top with fresh basil and olive oil" + ], + category="Main Course", + keywords="italian,vegetarian,quick", + prep_time="PT20M", # 20 minutes + cook_time="PT12M", # 12 minutes + total_time="PT32M", # 32 minutes + recipe_yield=4 # 4 servings +) +``` + +#### Update Recipe + +```python +# Update recipe details (only specified fields are changed) +await nc_cookbook_update_recipe( + recipe_id=123, + description="Updated: Classic homemade pizza - now with video tutorial!", + url="https://example.com/videos/pizza-tutorial", + keywords="italian,vegetarian,quick,video" +) +``` + +#### Search and Filter + +```python +# Search recipes by keyword +results = await nc_cookbook_search_recipes(query="chocolate") + +# List all categories +categories = await nc_cookbook_list_categories() +# Returns: [{"name": "Desserts", "recipe_count": 15}, ...] + +# Get recipes in a category +desserts = await nc_cookbook_get_recipes_in_category(category="Desserts") + +# List all keywords/tags +keywords = await nc_cookbook_list_keywords() +# Returns: [{"name": "chocolate", "recipe_count": 8}, ...] + +# Get recipes with specific tags +quick_meals = await nc_cookbook_get_recipes_with_keywords(keywords=["quick", "30min"]) +``` + +#### Manage Configuration + +```python +# Configure the Cookbook app +await nc_cookbook_set_config( + folder="Recipes", # Folder path in user's files + update_interval=15, # Auto-rescan every 15 minutes + print_image=True # Print images with recipes +) + +# Trigger manual reindex after file changes +await nc_cookbook_reindex() +``` + +### Time Format (ISO8601 Duration) + +Recipe times use ISO8601 duration format: + +| Duration | Format | Example | +|----------|--------|---------| +| 15 minutes | `PT15M` | Prep time | +| 1 hour | `PT1H` | Baking time | +| 1 hour 30 minutes | `PT1H30M` | Total time | +| 45 seconds | `PT45S` | Mixing time | +| 2 hours 15 minutes | `PT2H15M` | Slow cooking | + +### Tips for Recipe Import + +**Best practices for importing recipes from URLs:** + +1. **Look for schema.org support**: Most modern recipe sites include schema.org metadata +2. **Check import quality**: Review imported recipes for completeness +3. **Handle duplicates**: The API prevents duplicate imports by recipe name +4. **Edit after import**: Update imported recipes with personal notes or adjustments + +**Common recipe websites with good schema.org support:** +- AllRecipes +- Food Network +- BBC Good Food +- Serious Eats +- Bon Appétit +- Many food blogs using recipe plugins + +### Organizing Your Recipes + +**Categories**: Organize recipes by type (Appetizers, Main Course, Desserts, etc.) +- Use `nc_cookbook_list_categories` to see all categories +- Filter by category with `nc_cookbook_get_recipes_in_category` + +**Keywords/Tags**: Tag recipes with searchable terms (vegetarian, quick, spicy, etc.) +- Use `nc_cookbook_list_keywords` to see all tags +- Filter by tags with `nc_cookbook_get_recipes_with_keywords` +- Search across all fields with `nc_cookbook_search_recipes` + +**Reindexing**: The Cookbook app maintains a search index +- Automatically scans at configured intervals +- Manually trigger with `nc_cookbook_reindex` after bulk changes +- Required after modifying recipe files directly in WebDAV + +## API Reference + +For detailed API documentation, see the [Nextcloud Cookbook OpenAPI specification](https://github.com/nextcloud/cookbook/tree/master/docs/dev/api/0.1.2). diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index e4f9b3c..c305cce 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -19,6 +19,7 @@ from nextcloud_mcp_server.context import get_client as get_nextcloud_client from nextcloud_mcp_server.server import ( configure_calendar_tools, configure_contacts_tools, + configure_cookbook_tools, configure_deck_tools, configure_notes_tools, configure_sharing_tools, @@ -379,6 +380,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): "sharing": configure_sharing_tools, "calendar": configure_calendar_tools, "contacts": configure_contacts_tools, + "cookbook": configure_cookbook_tools, "deck": configure_deck_tools, } @@ -444,7 +446,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): "--enable-app", "-e", multiple=True, - type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]), + type=click.Choice( + ["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"] + ), help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.", ) @click.option( diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index c292b52..89c7adf 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -14,6 +14,7 @@ from httpx import ( from ..controllers.notes_search import NotesSearchController from .calendar import CalendarClient from .contacts import ContactsClient +from .cookbook import CookbookClient from .deck import DeckClient from .groups import GroupsClient from .notes import NotesClient @@ -73,6 +74,7 @@ class NextcloudClient: self.tables = TablesClient(self._client, username) self.calendar = CalendarClient(self._client, username) self.contacts = ContactsClient(self._client, username) + self.cookbook = CookbookClient(self._client, username) self.deck = DeckClient(self._client, username) self.users = UsersClient(self._client, username) self.groups = GroupsClient(self._client, username) diff --git a/nextcloud_mcp_server/client/cookbook.py b/nextcloud_mcp_server/client/cookbook.py new file mode 100644 index 0000000..5b1459b --- /dev/null +++ b/nextcloud_mcp_server/client/cookbook.py @@ -0,0 +1,245 @@ +"""Client for Nextcloud Cookbook app operations.""" + +import logging +from typing import Any, Dict, List + +from .base import BaseNextcloudClient + +logger = logging.getLogger(__name__) + + +class CookbookClient(BaseNextcloudClient): + """Client for Nextcloud Cookbook app operations.""" + + async def get_version(self) -> Dict[str, Any]: + """Get Cookbook app and API version.""" + response = await self._make_request("GET", "/apps/cookbook/api/version") + return response.json() + + async def get_config(self) -> Dict[str, Any]: + """Get current Cookbook app configuration.""" + response = await self._make_request("GET", "/apps/cookbook/api/v1/config") + return response.json() + + async def set_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Set Cookbook app configuration. + + Args: + config: Configuration dictionary with fields like: + - folder: Recipe folder path + - update_interval: Auto-rescan interval in minutes + - print_image: Whether to print images with recipes + - visibleInfoBlocks: Visible info blocks configuration + + Returns: + Response with status message + """ + response = await self._make_request( + "POST", "/apps/cookbook/api/v1/config", json=config + ) + return response.json() + + async def reindex(self) -> str: + """Trigger a rescan of all recipes into the caching database. + + Returns: + Success message + """ + response = await self._make_request("POST", "/apps/cookbook/api/v1/reindex") + return response.json() + + async def list_recipes(self) -> List[Dict[str, Any]]: + """Get all recipes in the database. + + Returns: + List of recipe stubs with basic information + """ + response = await self._make_request("GET", "/apps/cookbook/api/v1/recipes") + return response.json() + + async def get_recipe(self, recipe_id: int) -> Dict[str, Any]: + """Get a single recipe by ID. + + Args: + recipe_id: The recipe ID + + Returns: + Full recipe data + """ + response = await self._make_request( + "GET", f"/apps/cookbook/api/v1/recipes/{recipe_id}" + ) + return response.json() + + async def create_recipe(self, recipe_data: Dict[str, Any]) -> int: + """Create a new recipe. + + Args: + recipe_data: Recipe data following schema.org/Recipe format. + Required: name + Optional: description, ingredients, instructions, etc. + + Returns: + ID of the newly created recipe + """ + response = await self._make_request( + "POST", "/apps/cookbook/api/v1/recipes", json=recipe_data + ) + return response.json() + + async def update_recipe(self, recipe_id: int, recipe_data: Dict[str, Any]) -> int: + """Update an existing recipe. + + Args: + recipe_id: The recipe ID to update + recipe_data: Updated recipe data + + Returns: + ID of the updated recipe + """ + response = await self._make_request( + "PUT", f"/apps/cookbook/api/v1/recipes/{recipe_id}", json=recipe_data + ) + return response.json() + + async def delete_recipe(self, recipe_id: int) -> str: + """Delete a recipe. + + Args: + recipe_id: The recipe ID to delete + + Returns: + Success message + """ + response = await self._make_request( + "DELETE", f"/apps/cookbook/api/v1/recipes/{recipe_id}" + ) + return response.json() + + async def import_recipe(self, url: str) -> Dict[str, Any]: + """Import a recipe from a URL using schema.org metadata. + + Args: + url: URL of the recipe to import + + Returns: + Full imported recipe data + """ + logger.info(f"Importing recipe from URL: {url}") + response = await self._make_request( + "POST", "/apps/cookbook/api/v1/import", json={"url": url} + ) + return response.json() + + async def get_recipe_image(self, recipe_id: int, size: str = "full") -> bytes: + """Get the main image of a recipe. + + Args: + recipe_id: The recipe ID + size: Image size - "full", "thumb" (250px), or "thumb16" (16px) + + Returns: + Image bytes + """ + response = await self._make_request( + "GET", + f"/apps/cookbook/api/v1/recipes/{recipe_id}/image", + params={"size": size}, + ) + return response.content + + async def search_recipes(self, query: str) -> List[Dict[str, Any]]: + """Search for recipes by keywords, tags, and categories. + + Args: + query: Search string (URL-encoded, space/comma separated) + + Returns: + List of matching recipe stubs + """ + # URL encode the query + from urllib.parse import quote + + encoded_query = quote(query) + response = await self._make_request( + "GET", f"/apps/cookbook/api/v1/search/{encoded_query}" + ) + return response.json() + + async def list_categories(self) -> List[Dict[str, Any]]: + """Get all known categories. + + Note: A category name of '*' indicates recipes with no category. + + Returns: + List of categories with recipe counts + """ + response = await self._make_request("GET", "/apps/cookbook/api/v1/categories") + return response.json() + + async def get_recipes_in_category(self, category: str) -> List[Dict[str, Any]]: + """Get all recipes in a specific category. + + Args: + category: Category name (use "_" for recipes with no category) + + Returns: + List of recipe stubs in the category + """ + from urllib.parse import quote + + encoded_category = quote(category) + response = await self._make_request( + "GET", f"/apps/cookbook/api/v1/category/{encoded_category}" + ) + return response.json() + + async def rename_category(self, old_name: str, new_name: str) -> str: + """Rename a category. + + Args: + old_name: Current category name + new_name: New category name + + Returns: + New category name + """ + from urllib.parse import quote + + encoded_old_name = quote(old_name) + response = await self._make_request( + "PUT", + f"/apps/cookbook/api/v1/category/{encoded_old_name}", + json={"name": new_name}, + ) + return response.json() + + async def list_keywords(self) -> List[Dict[str, Any]]: + """Get all known keywords/tags. + + Returns: + List of keywords with recipe counts + """ + response = await self._make_request("GET", "/apps/cookbook/api/v1/keywords") + return response.json() + + async def get_recipes_with_keywords( + self, keywords: List[str] + ) -> List[Dict[str, Any]]: + """Get all recipes associated with certain keywords. + + Args: + keywords: List of keywords to filter by + + Returns: + List of recipe stubs matching the keywords + """ + from urllib.parse import quote + + # Join keywords with commas + keywords_str = ",".join(keywords) + encoded_keywords = quote(keywords_str) + response = await self._make_request( + "GET", f"/apps/cookbook/api/v1/tags/{encoded_keywords}" + ) + return response.json() diff --git a/nextcloud_mcp_server/models/cookbook.py b/nextcloud_mcp_server/models/cookbook.py new file mode 100644 index 0000000..0a367c7 --- /dev/null +++ b/nextcloud_mcp_server/models/cookbook.py @@ -0,0 +1,220 @@ +"""Pydantic models for Cookbook app responses.""" + +from typing import List, Optional, Union + +from pydantic import BaseModel, Field + +from .base import BaseResponse, IdResponse, StatusResponse + + +class Nutrition(BaseModel): + """Nutrition information following schema.org/NutritionInformation.""" + + type: str = Field( + default="NutritionInformation", + alias="@type", + description="Schema.org object type", + ) + calories: Optional[str] = Field(None, description="Calories (e.g., '650 kcal')") + carbohydrateContent: Optional[str] = Field( + None, description="Carbohydrates (e.g., '300 g')" + ) + cholesterolContent: Optional[str] = Field( + None, description="Cholesterol (e.g., '10 g')" + ) + fatContent: Optional[str] = Field(None, description="Fat (e.g., '45 g')") + fiberContent: Optional[str] = Field(None, description="Fiber (e.g., '50 g')") + proteinContent: Optional[str] = Field(None, description="Protein (e.g., '80 g')") + saturatedFatContent: Optional[str] = Field( + None, description="Saturated fat (e.g., '5 g')" + ) + servingSize: Optional[str] = Field( + None, description="Serving size description (e.g., 'One plate')" + ) + sodiumContent: Optional[str] = Field(None, description="Sodium (e.g., '10 mg')") + sugarContent: Optional[str] = Field(None, description="Sugar (e.g., '5 g')") + transFatContent: Optional[str] = Field(None, description="Trans fat (e.g., '10 g')") + unsaturatedFatContent: Optional[str] = Field( + None, description="Unsaturated fat (e.g., '40 g')" + ) + + class Config: + populate_by_name = True + + +class RecipeStub(BaseModel): + """Stub of a recipe with basic information.""" + + id: str = Field(description="Recipe ID as string") + recipe_id: int = Field(description="Recipe ID as integer (deprecated)") + name: str = Field(description="Recipe name") + keywords: Optional[str] = Field(default="", description="Comma-separated keywords") + dateCreated: str = Field(description="Creation date (ISO8601)") + dateModified: Optional[str] = Field( + None, description="Last modified date (ISO8601)" + ) + imageUrl: str = Field(default="", description="URL of the recipe image") + imagePlaceholderUrl: str = Field(default="", description="URL of placeholder image") + + +class Recipe(BaseModel): + """Full recipe following schema.org/Recipe specification.""" + + type: str = Field(default="Recipe", alias="@type", description="Schema.org type") + id: Optional[str] = Field(None, description="Recipe ID") + name: str = Field(description="Recipe name") + description: str = Field(default="", description="Recipe description") + url: str = Field(default="", description="Original recipe URL") + image: str = Field(default="", description="URL of original recipe image") + imageUrl: Optional[str] = Field( + None, description="URL of the recipe image in Nextcloud" + ) + imagePlaceholderUrl: Optional[str] = Field( + None, description="URL of placeholder image" + ) + keywords: str = Field(default="", description="Comma-separated keywords") + dateCreated: Optional[str] = Field(None, description="Creation date (ISO8601)") + dateModified: Optional[str] = Field( + None, description="Last modified date (ISO8601)" + ) + prepTime: Optional[str] = Field(None, description="Preparation time (ISO8601)") + cookTime: Optional[str] = Field(None, description="Cooking time (ISO8601)") + totalTime: Optional[str] = Field(None, description="Total time (ISO8601)") + recipeYield: Union[int, str] = Field(default=1, description="Number of servings") + recipeCategory: str = Field(default="", description="Recipe category") + tool: List[str] = Field(default_factory=list, description="Required tools") + recipeIngredient: List[str] = Field( + default_factory=list, description="List of ingredients" + ) + recipeInstructions: List[str] = Field( + default_factory=list, description="Cooking instructions" + ) + nutrition: Optional[Nutrition] = Field(None, description="Nutrition information") + + class Config: + populate_by_name = True + extra = "allow" # Allow additional schema.org fields + + +class Category(BaseModel): + """A recipe category.""" + + name: str = Field(description="Category name") + recipe_count: int = Field(description="Number of recipes in category") + + +class Keyword(BaseModel): + """A recipe keyword/tag.""" + + name: str = Field(description="Keyword name") + recipe_count: int = Field(description="Number of recipes with this keyword") + + +class VisibleInfoBlocks(BaseModel): + """Configuration for visible information blocks in the UI.""" + + preparation_time: Optional[bool] = Field( + None, alias="preparation-time", description="Show preparation time" + ) + cooking_time: Optional[bool] = Field( + None, alias="cooking-time", description="Show cooking time" + ) + total_time: Optional[bool] = Field( + None, alias="total-time", description="Show total time" + ) + nutrition_information: Optional[bool] = Field( + None, alias="nutrition-information", description="Show nutrition info" + ) + tools: Optional[bool] = Field(None, description="Show tools list") + + class Config: + populate_by_name = True + + +class CookbookConfig(BaseModel): + """Cookbook app configuration.""" + + folder: Optional[str] = Field(None, description="Recipe folder path") + update_interval: Optional[int] = Field( + None, description="Auto-rescan interval in minutes" + ) + print_image: Optional[bool] = Field(None, description="Print images with recipes") + visibleInfoBlocks: Optional[VisibleInfoBlocks] = Field( + None, description="Visible info blocks configuration" + ) + + +class APIVersion(BaseModel): + """API version information.""" + + epoch: int = Field(description="API epoch") + major: int = Field(description="Major version") + minor: int = Field(description="Minor version") + + +class Version(BaseModel): + """Version information for Cookbook app and API.""" + + cookbook_version: List[int] = Field(description="Cookbook app version") + api_version: APIVersion = Field(description="API version") + + +# Response models for MCP tools + + +class ImportRecipeResponse(BaseResponse): + """Response model for recipe import.""" + + recipe: Recipe = Field(description="The imported recipe") + recipe_id: str = Field(description="ID of the imported recipe") + + +class CreateRecipeResponse(IdResponse): + """Response model for recipe creation.""" + + pass + + +class UpdateRecipeResponse(IdResponse): + """Response model for recipe update.""" + + pass + + +class DeleteRecipeResponse(StatusResponse): + """Response model for recipe deletion.""" + + deleted_id: int = Field(description="ID of deleted recipe") + + +class ListRecipesResponse(BaseResponse): + """Response model for listing recipes.""" + + recipes: List[RecipeStub] = Field(description="List of recipe stubs") + total_count: int = Field(description="Total number of recipes") + + +class SearchRecipesResponse(BaseResponse): + """Response model for recipe search.""" + + recipes: List[RecipeStub] = Field(description="Matching recipes") + query: str = Field(description="Search query used") + total_found: int = Field(description="Number of recipes found") + + +class ListCategoriesResponse(BaseResponse): + """Response model for listing categories.""" + + categories: List[Category] = Field(description="List of categories") + + +class ListKeywordsResponse(BaseResponse): + """Response model for listing keywords.""" + + keywords: List[Keyword] = Field(description="List of keywords") + + +class ReindexResponse(StatusResponse): + """Response model for reindex operation.""" + + pass diff --git a/nextcloud_mcp_server/server/__init__.py b/nextcloud_mcp_server/server/__init__.py index f30b0d2..0a2c455 100644 --- a/nextcloud_mcp_server/server/__init__.py +++ b/nextcloud_mcp_server/server/__init__.py @@ -1,5 +1,6 @@ from .calendar import configure_calendar_tools from .contacts import configure_contacts_tools +from .cookbook import configure_cookbook_tools from .deck import configure_deck_tools from .notes import configure_notes_tools from .sharing import configure_sharing_tools @@ -9,6 +10,7 @@ from .webdav import configure_webdav_tools __all__ = [ "configure_calendar_tools", "configure_contacts_tools", + "configure_cookbook_tools", "configure_deck_tools", "configure_notes_tools", "configure_sharing_tools", diff --git a/nextcloud_mcp_server/server/cookbook.py b/nextcloud_mcp_server/server/cookbook.py new file mode 100644 index 0000000..8f534ce --- /dev/null +++ b/nextcloud_mcp_server/server/cookbook.py @@ -0,0 +1,582 @@ +import logging + +from httpx import HTTPStatusError +from mcp.server.fastmcp import Context, FastMCP +from mcp.shared.exceptions import McpError +from mcp.types import ErrorData + +from nextcloud_mcp_server.context import get_client +from nextcloud_mcp_server.models.cookbook import ( + Category, + CookbookConfig, + CreateRecipeResponse, + DeleteRecipeResponse, + ImportRecipeResponse, + Keyword, + ListCategoriesResponse, + ListKeywordsResponse, + ListRecipesResponse, + Recipe, + RecipeStub, + ReindexResponse, + SearchRecipesResponse, + UpdateRecipeResponse, + Version, +) + +logger = logging.getLogger(__name__) + + +def configure_cookbook_tools(mcp: FastMCP): + @mcp.resource("cookbook://version") + async def cookbook_get_version(): + """Get the Cookbook app and API version""" + ctx: Context = mcp.get_context() + client = get_client(ctx) + version_data = await client.cookbook.get_version() + return Version(**version_data) + + @mcp.resource("cookbook://config") + async def cookbook_get_config(): + """Get the Cookbook app configuration""" + ctx: Context = mcp.get_context() + client = get_client(ctx) + config_data = await client.cookbook.get_config() + return CookbookConfig(**config_data) + + @mcp.resource("nc://Cookbook/{recipe_id}") + async def nc_cookbook_get_recipe_resource(recipe_id: int): + """Get a recipe by ID using resource URI""" + ctx: Context = mcp.get_context() + client = get_client(ctx) + try: + recipe_data = await client.cookbook.get_recipe(recipe_id) + return Recipe(**recipe_data) + except HTTPStatusError as e: + if e.response.status_code == 404: + raise McpError( + ErrorData(code=-1, message=f"Recipe {recipe_id} not found") + ) + elif e.response.status_code == 403: + raise McpError( + ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}") + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}", + ) + ) + + @mcp.tool() + async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse: + """Import a recipe from a URL using schema.org metadata. + + This extracts recipe data from websites that use schema.org Recipe markup. + Many popular recipe sites support this standard.""" + client = get_client(ctx) + try: + recipe_data = await client.cookbook.import_recipe(url) + recipe = Recipe(**recipe_data) + return ImportRecipeResponse( + recipe=recipe, + recipe_id=recipe.id or "unknown", + ) + except HTTPStatusError as e: + if e.response.status_code == 400: + raise McpError( + ErrorData( + code=-1, + message=f"Invalid URL or missing 'url' field: {url}", + ) + ) + elif e.response.status_code == 409: + raise McpError( + ErrorData( + code=-1, + message="A recipe with this name already exists. Import aborted.", + ) + ) + elif e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to import recipes", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to import recipe from {url}: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse: + """Get all recipes in the database""" + client = get_client(ctx) + try: + recipes_data = await client.cookbook.list_recipes() + recipes = [RecipeStub(**r) for r in recipes_data] + return ListRecipesResponse(recipes=recipes, total_count=len(recipes)) + except HTTPStatusError as e: + if e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to list recipes", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to list recipes: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe: + """Get a specific recipe by its ID""" + client = get_client(ctx) + try: + recipe_data = await client.cookbook.get_recipe(recipe_id) + return Recipe(**recipe_data) + except HTTPStatusError as e: + if e.response.status_code == 404: + raise McpError( + ErrorData(code=-1, message=f"Recipe {recipe_id} not found") + ) + elif e.response.status_code == 403: + raise McpError( + ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}") + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}", + ) + ) + + @mcp.tool() + async def nc_cookbook_create_recipe( + name: str, + description: str | None = None, + ingredients: list[str] | None = None, + instructions: list[str] | None = None, + url: str | None = None, + prep_time: str | None = None, + cook_time: str | None = None, + total_time: str | None = None, + recipe_yield: int | None = None, + category: str | None = None, + keywords: str | None = None, + ctx: Context = None, + ) -> CreateRecipeResponse: + """Create a new recipe. + + Required: name + Optional: All other recipe fields following schema.org/Recipe format. + + Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes).""" + client = get_client(ctx) + + recipe_data = {"name": name} + if description: + recipe_data["description"] = description + if ingredients: + recipe_data["recipeIngredient"] = ingredients + if instructions: + recipe_data["recipeInstructions"] = instructions + if url: + recipe_data["url"] = url + if prep_time: + recipe_data["prepTime"] = prep_time + if cook_time: + recipe_data["cookTime"] = cook_time + if total_time: + recipe_data["totalTime"] = total_time + if recipe_yield: + recipe_data["recipeYield"] = recipe_yield + if category: + recipe_data["recipeCategory"] = category + if keywords: + recipe_data["keywords"] = keywords + + try: + recipe_id = await client.cookbook.create_recipe(recipe_data) + return CreateRecipeResponse(id=recipe_id) + except HTTPStatusError as e: + if e.response.status_code == 409: + raise McpError( + ErrorData( + code=-1, + message=f"A recipe with name '{name}' already exists", + ) + ) + elif e.response.status_code == 422: + raise McpError( + ErrorData( + code=-1, + message="Recipe name is required and cannot be empty", + ) + ) + elif e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to create recipes", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to create recipe: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_update_recipe( + recipe_id: int, + name: str | None = None, + description: str | None = None, + ingredients: list[str] | None = None, + instructions: list[str] | None = None, + url: str | None = None, + prep_time: str | None = None, + cook_time: str | None = None, + total_time: str | None = None, + recipe_yield: int | None = None, + category: str | None = None, + keywords: str | None = None, + ctx: Context = None, + ) -> UpdateRecipeResponse: + """Update an existing recipe. + + Provide only the fields you want to update. Unspecified fields remain unchanged.""" + client = get_client(ctx) + + # First get the current recipe + try: + current_recipe = await client.cookbook.get_recipe(recipe_id) + except HTTPStatusError as e: + if e.response.status_code == 404: + raise McpError( + ErrorData(code=-1, message=f"Recipe {recipe_id} not found") + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to fetch recipe {recipe_id}: {e.response.reason_phrase}", + ) + ) + + # Update only specified fields + recipe_data = current_recipe.copy() + if name is not None: + recipe_data["name"] = name + if description is not None: + recipe_data["description"] = description + if ingredients is not None: + recipe_data["recipeIngredient"] = ingredients + if instructions is not None: + recipe_data["recipeInstructions"] = instructions + if url is not None: + recipe_data["url"] = url + if prep_time is not None: + recipe_data["prepTime"] = prep_time + if cook_time is not None: + recipe_data["cookTime"] = cook_time + if total_time is not None: + recipe_data["totalTime"] = total_time + if recipe_yield is not None: + recipe_data["recipeYield"] = recipe_yield + if category is not None: + recipe_data["recipeCategory"] = category + if keywords is not None: + recipe_data["keywords"] = keywords + + try: + updated_id = await client.cookbook.update_recipe(recipe_id, recipe_data) + return UpdateRecipeResponse(id=updated_id) + except HTTPStatusError as e: + if e.response.status_code == 422: + raise McpError( + ErrorData( + code=-1, + message="Recipe name is required and cannot be empty", + ) + ) + elif e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message=f"Access denied: insufficient permissions to update recipe {recipe_id}", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to update recipe {recipe_id}: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_delete_recipe( + recipe_id: int, ctx: Context + ) -> DeleteRecipeResponse: + """Delete a recipe permanently""" + logger.info("Deleting recipe %s", recipe_id) + client = get_client(ctx) + try: + message = await client.cookbook.delete_recipe(recipe_id) + return DeleteRecipeResponse( + status_code=200, + message=message, + deleted_id=recipe_id, + ) + except HTTPStatusError as e: + if e.response.status_code == 404: + raise McpError( + ErrorData(code=-1, message=f"Recipe {recipe_id} not found") + ) + elif e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message=f"Access denied: insufficient permissions to delete recipe {recipe_id}", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to delete recipe {recipe_id}: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_search_recipes( + query: str, ctx: Context + ) -> SearchRecipesResponse: + """Search for recipes by keywords, tags, and categories""" + client = get_client(ctx) + try: + recipes_data = await client.cookbook.search_recipes(query) + recipes = [RecipeStub(**r) for r in recipes_data] + return SearchRecipesResponse( + recipes=recipes, query=query, total_found=len(recipes) + ) + except HTTPStatusError as e: + if e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to search recipes", + ) + ) + elif e.response.status_code == 500: + raise McpError( + ErrorData( + code=-1, + message="Search failed: server error", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Search failed: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse: + """Get all known categories. + + Note: A category name of '*' indicates recipes with no category.""" + client = get_client(ctx) + try: + categories_data = await client.cookbook.list_categories() + categories = [Category(**c) for c in categories_data] + return ListCategoriesResponse(categories=categories) + except HTTPStatusError as e: + if e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to list categories", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to list categories: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_get_recipes_in_category( + category: str, ctx: Context + ) -> ListRecipesResponse: + """Get all recipes in a specific category. + + Use '_' as the category name to get recipes with no category.""" + client = get_client(ctx) + try: + recipes_data = await client.cookbook.get_recipes_in_category(category) + recipes = [RecipeStub(**r) for r in recipes_data] + return ListRecipesResponse(recipes=recipes, total_count=len(recipes)) + except HTTPStatusError as e: + if e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to access recipes", + ) + ) + elif e.response.status_code == 500: + raise McpError( + ErrorData( + code=-1, + message=f"Could not find category '{category}'", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to get recipes in category: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse: + """Get all known keywords/tags""" + client = get_client(ctx) + try: + keywords_data = await client.cookbook.list_keywords() + keywords = [Keyword(**k) for k in keywords_data] + return ListKeywordsResponse(keywords=keywords) + except HTTPStatusError as e: + if e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to list keywords", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to list keywords: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_get_recipes_with_keywords( + keywords: list[str], ctx: Context + ) -> ListRecipesResponse: + """Get all recipes that have specific keywords/tags""" + client = get_client(ctx) + try: + recipes_data = await client.cookbook.get_recipes_with_keywords(keywords) + recipes = [RecipeStub(**r) for r in recipes_data] + return ListRecipesResponse(recipes=recipes, total_count=len(recipes)) + except HTTPStatusError as e: + if e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to access recipes", + ) + ) + elif e.response.status_code == 500: + raise McpError( + ErrorData( + code=-1, + message="Failed to get recipes with keywords: server error", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to get recipes with keywords: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_set_config( + folder: str | None = None, + update_interval: int | None = None, + print_image: bool | None = None, + ctx: Context = None, + ) -> ReindexResponse: + """Set Cookbook app configuration. + + Args: + folder: Recipe folder path in user's files + update_interval: Automatic rescan interval in minutes + print_image: Whether to print images with recipes""" + client = get_client(ctx) + + config_data = {} + if folder is not None: + config_data["folder"] = folder + if update_interval is not None: + config_data["update_interval"] = update_interval + if print_image is not None: + config_data["print_image"] = print_image + + try: + result = await client.cookbook.set_config(config_data) + return ReindexResponse(status_code=200, message=str(result)) + except HTTPStatusError as e: + if e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to set configuration", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to set configuration: server error ({e.response.status_code})", + ) + ) + + @mcp.tool() + async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse: + """Trigger a rescan of all recipes into the caching database. + + This rebuilds the search index and should be used after manual file changes.""" + client = get_client(ctx) + try: + message = await client.cookbook.reindex() + return ReindexResponse(status_code=200, message=message) + except HTTPStatusError as e: + if e.response.status_code == 403: + raise McpError( + ErrorData( + code=-1, + message="Access denied: insufficient permissions to reindex", + ) + ) + else: + raise McpError( + ErrorData( + code=-1, + message=f"Failed to reindex: server error ({e.response.status_code})", + ) + ) diff --git a/tests/client/cookbook/test_cookbook_api.py b/tests/client/cookbook/test_cookbook_api.py new file mode 100644 index 0000000..65c1ca0 --- /dev/null +++ b/tests/client/cookbook/test_cookbook_api.py @@ -0,0 +1,398 @@ +import asyncio +import logging +import uuid + +import pytest +from httpx import HTTPStatusError + +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) + +# Mark all tests in this module as integration tests +pytestmark = pytest.mark.integration + + +async def test_cookbook_version(nc_client: NextcloudClient): + """Test getting Cookbook app version.""" + logger.info("Getting Cookbook app version") + version_data = await nc_client.cookbook.get_version() + + assert "cookbook_version" in version_data + assert "api_version" in version_data + logger.info(f"Cookbook version: {version_data}") + + +async def test_cookbook_config(nc_client: NextcloudClient): + """Test getting Cookbook app configuration.""" + logger.info("Getting Cookbook app configuration") + config_data = await nc_client.cookbook.get_config() + + # Config may be empty initially, just verify we can get it + assert isinstance(config_data, dict) + logger.info(f"Cookbook config: {config_data}") + + +async def test_cookbook_list_recipes(nc_client: NextcloudClient): + """Test listing all recipes.""" + logger.info("Listing all recipes") + recipes = await nc_client.cookbook.list_recipes() + + assert isinstance(recipes, list) + logger.info(f"Found {len(recipes)} recipes") + + +async def test_cookbook_create_and_read_recipe(nc_client: NextcloudClient): + """Test creating a recipe and reading it back.""" + # Create a test recipe + recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}" + recipe_data = { + "name": recipe_name, + "description": "A test recipe for integration testing", + "recipeIngredient": ["100g flour", "2 eggs", "200ml milk"], + "recipeInstructions": [ + "Mix ingredients", + "Cook for 20 minutes", + "Serve hot", + ], + "recipeCategory": "Test", + "keywords": "test,integration", + "recipeYield": 4, + "prepTime": "PT15M", + "cookTime": "PT20M", + "totalTime": "PT35M", + } + + logger.info(f"Creating recipe: {recipe_name}") + recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + logger.info(f"Created recipe with ID: {recipe_id}") + + try: + # Read the recipe back + logger.info(f"Reading recipe ID: {recipe_id}") + retrieved_recipe = await nc_client.cookbook.get_recipe(recipe_id) + + assert retrieved_recipe["name"] == recipe_name + assert ( + retrieved_recipe["description"] == "A test recipe for integration testing" + ) + assert len(retrieved_recipe["recipeIngredient"]) == 3 + assert len(retrieved_recipe["recipeInstructions"]) == 3 + assert retrieved_recipe["recipeCategory"] == "Test" + assert retrieved_recipe["recipeYield"] == 4 + logger.info(f"Successfully verified recipe: {recipe_name}") + + finally: + # Clean up + logger.info(f"Deleting recipe ID: {recipe_id}") + await nc_client.cookbook.delete_recipe(recipe_id) + logger.info(f"Successfully deleted recipe ID: {recipe_id}") + + +async def test_cookbook_update_recipe(nc_client: NextcloudClient): + """Test updating a recipe.""" + # Create a test recipe + recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}" + recipe_data = { + "name": recipe_name, + "description": "Original description", + "recipeIngredient": ["100g flour"], + "recipeInstructions": ["Mix ingredients"], + "recipeCategory": "Original", + } + + logger.info(f"Creating recipe for update test: {recipe_name}") + recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + + try: + # Get the current recipe first + current_recipe = await nc_client.cookbook.get_recipe(recipe_id) + + # Update the recipe with all required fields + updated_data = current_recipe.copy() + updated_data["description"] = "Updated description" + updated_data["recipeIngredient"] = ["100g flour", "2 eggs"] + updated_data["recipeInstructions"] = ["Mix ingredients", "Cook"] + updated_data["recipeCategory"] = "Updated" + + logger.info(f"Updating recipe ID: {recipe_id}") + updated_id = await nc_client.cookbook.update_recipe(recipe_id, updated_data) + assert updated_id == recipe_id + + # Verify the update + await asyncio.sleep(1) # Allow propagation + updated_recipe = await nc_client.cookbook.get_recipe(recipe_id) + assert updated_recipe["description"] == "Updated description" + assert len(updated_recipe["recipeIngredient"]) == 2 + assert len(updated_recipe["recipeInstructions"]) == 2 + assert updated_recipe["recipeCategory"] == "Updated" + logger.info(f"Successfully updated recipe ID: {recipe_id}") + + finally: + # Clean up + logger.info(f"Deleting recipe ID: {recipe_id}") + await nc_client.cookbook.delete_recipe(recipe_id) + + +async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient): + """Test deleting a non-existent recipe. + + Note: The Cookbook API may return 502 or succeed silently for non-existent IDs + rather than 404. This test verifies the behavior.""" + non_existent_id = 999999999 + + logger.info(f"Attempting to delete non-existent recipe ID: {non_existent_id}") + try: + result = await nc_client.cookbook.delete_recipe(non_existent_id) + logger.info(f"Delete returned: {result}") + # API may succeed silently or return an error message + assert isinstance(result, str) + except HTTPStatusError as e: + # API may return 404 or 502 for non-existent recipes + assert e.response.status_code in [404, 502] + logger.info(f"Delete correctly failed with {e.response.status_code}") + + +async def test_cookbook_import_recipe_from_url( + nc_client: NextcloudClient, test_recipe_server: str +): + """Test importing a recipe from a URL. + + This is the key feature test - importing recipes from URLs using schema.org metadata. + Uses a local test server to provide reliable, controlled test data. + """ + # Replace localhost with Docker bridge gateway IP so the Nextcloud container can reach it + # The test_recipe_server runs on the host, but Nextcloud runs in Docker + # On Linux, 172.17.0.1 is the default Docker bridge gateway + # On Mac/Windows, try host.docker.internal first + import platform + + if platform.system() == "Linux": + docker_host = "172.17.0.1" + else: + docker_host = "host.docker.internal" + + docker_accessible_url = test_recipe_server.replace("localhost", docker_host) + test_url = f"{docker_accessible_url}/black-pepper-tofu" + + logger.info(f"Importing recipe from local test URL (Docker-accessible): {test_url}") + + try: + imported_recipe = await nc_client.cookbook.import_recipe(test_url) + logger.info(f"Successfully imported recipe: {imported_recipe.get('name')}") + + # Verify basic recipe structure + assert "name" in imported_recipe + assert imported_recipe["name"] == "Black Pepper Tofu" + assert "id" in imported_recipe + + # Verify schema.org fields were imported correctly + assert imported_recipe.get("description") + assert len(imported_recipe.get("recipeIngredient", [])) > 0 + assert len(imported_recipe.get("recipeInstructions", [])) > 0 + assert imported_recipe.get("recipeCategory") == "Main Course" + assert "tofu" in imported_recipe.get("keywords", "").lower() + + recipe_id = int(imported_recipe["id"]) + + # Verify we can read it back + retrieved = await nc_client.cookbook.get_recipe(recipe_id) + assert retrieved["name"] == imported_recipe["name"] + logger.info(f"Verified imported recipe ID: {recipe_id}") + + # Clean up + logger.info(f"Deleting imported recipe ID: {recipe_id}") + await nc_client.cookbook.delete_recipe(recipe_id) + logger.info("Successfully deleted imported recipe") + + except HTTPStatusError as e: + if e.response.status_code == 409: + # Recipe already exists - this is acceptable in tests + logger.warning("Recipe already exists (409 conflict)") + pytest.skip("Recipe already exists in test environment") + elif e.response.status_code == 400: + # URL couldn't be imported + logger.error( + f"Failed to import recipe from local test URL: {test_url}. " + f"Status: {e.response.status_code}, Response: {e.response.text}" + ) + raise + else: + raise + + +async def test_cookbook_search_recipes(nc_client: NextcloudClient): + """Test searching for recipes.""" + # Create a test recipe with unique keywords + unique_keyword = f"testkeyword{uuid.uuid4().hex[:8]}" + recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}" + recipe_data = { + "name": recipe_name, + "description": f"Recipe for testing search with {unique_keyword}", + "keywords": unique_keyword, + "recipeIngredient": ["test ingredient"], + "recipeInstructions": ["test instruction"], + } + + logger.info(f"Creating recipe for search test with keyword: {unique_keyword}") + recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + + try: + # Allow time for indexing + await asyncio.sleep(2) + + # Search for the recipe + logger.info(f"Searching for recipes with keyword: {unique_keyword}") + search_results = await nc_client.cookbook.search_recipes(unique_keyword) + + assert isinstance(search_results, list) + # Should find at least our recipe + assert len(search_results) > 0 + + # Verify our recipe is in the results + found = any(str(r.get("id")) == str(recipe_id) for r in search_results) + assert found, f"Recipe {recipe_id} not found in search results" + logger.info(f"Successfully found recipe {recipe_id} in search results") + + finally: + # Clean up + logger.info(f"Deleting recipe ID: {recipe_id}") + await nc_client.cookbook.delete_recipe(recipe_id) + + +async def test_cookbook_list_categories(nc_client: NextcloudClient): + """Test listing recipe categories.""" + logger.info("Listing recipe categories") + categories = await nc_client.cookbook.list_categories() + + assert isinstance(categories, list) + logger.info(f"Found {len(categories)} categories") + + # Each category should have name and recipe_count + if categories: + assert "name" in categories[0] + assert "recipe_count" in categories[0] + + +async def test_cookbook_get_recipes_in_category(nc_client: NextcloudClient): + """Test getting recipes in a specific category.""" + # Create a recipe in a test category + unique_category = f"TestCategory{uuid.uuid4().hex[:8]}" + recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}" + recipe_data = { + "name": recipe_name, + "recipeCategory": unique_category, + "recipeIngredient": ["test"], + "recipeInstructions": ["test"], + } + + logger.info(f"Creating recipe in category: {unique_category}") + recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + + try: + # Allow time for indexing + await asyncio.sleep(2) + + # Get recipes in this category + logger.info(f"Getting recipes in category: {unique_category}") + recipes_in_category = await nc_client.cookbook.get_recipes_in_category( + unique_category + ) + + assert isinstance(recipes_in_category, list) + assert len(recipes_in_category) > 0 + + # Verify our recipe is in the results + found = any(str(r.get("id")) == str(recipe_id) for r in recipes_in_category) + assert found, f"Recipe {recipe_id} not found in category {unique_category}" + logger.info(f"Successfully found recipe in category {unique_category}") + + finally: + # Clean up + logger.info(f"Deleting recipe ID: {recipe_id}") + await nc_client.cookbook.delete_recipe(recipe_id) + + +async def test_cookbook_list_keywords(nc_client: NextcloudClient): + """Test listing recipe keywords.""" + logger.info("Listing recipe keywords") + keywords = await nc_client.cookbook.list_keywords() + + assert isinstance(keywords, list) + logger.info(f"Found {len(keywords)} keywords") + + # Each keyword should have name and recipe_count + if keywords: + assert "name" in keywords[0] + assert "recipe_count" in keywords[0] + + +async def test_cookbook_get_recipes_with_keywords(nc_client: NextcloudClient): + """Test getting recipes with specific keywords. + + Note: The keywords filtering may require exact keyword matches and sufficient + indexing time. This test uses a longer wait time.""" + # Create a recipe with unique keywords + unique_keyword = f"testtag{uuid.uuid4().hex[:8]}" + recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}" + recipe_data = { + "name": recipe_name, + "keywords": f"{unique_keyword},integration", + "recipeIngredient": ["test"], + "recipeInstructions": ["test"], + } + + logger.info(f"Creating recipe with keyword: {unique_keyword}") + recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + + try: + # Allow extra time for indexing + await asyncio.sleep(3) + + # Trigger a reindex to ensure the recipe is indexed + await nc_client.cookbook.reindex() + await asyncio.sleep(2) + + # Get recipes with this keyword + logger.info(f"Getting recipes with keyword: {unique_keyword}") + recipes_with_keywords = await nc_client.cookbook.get_recipes_with_keywords( + [unique_keyword] + ) + + assert isinstance(recipes_with_keywords, list) + # Keyword filtering might not find recipes immediately due to indexing + # Log the results for debugging + logger.info( + f"Found {len(recipes_with_keywords)} recipes with keyword {unique_keyword}" + ) + + if len(recipes_with_keywords) > 0: + # Verify our recipe is in the results if any are found + found = any( + str(r.get("id")) == str(recipe_id) for r in recipes_with_keywords + ) + if found: + logger.info(f"Successfully found recipe with keyword {unique_keyword}") + else: + logger.warning( + f"Recipe {recipe_id} not in keyword results, but other recipes found" + ) + else: + logger.warning( + f"No recipes found with keyword {unique_keyword} - may be indexing delay" + ) + + finally: + # Clean up + logger.info(f"Deleting recipe ID: {recipe_id}") + await nc_client.cookbook.delete_recipe(recipe_id) + + +async def test_cookbook_reindex(nc_client: NextcloudClient): + """Test triggering a reindex of recipes.""" + logger.info("Triggering recipe reindex") + result = await nc_client.cookbook.reindex() + + # Should return a success message + assert isinstance(result, str) + logger.info(f"Reindex result: {result}") diff --git a/tests/conftest.py b/tests/conftest.py index 3e898cc..66fc606 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1319,3 +1319,77 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group): logger.debug(f"Added user {user_config['userid']} to group {groupid}") yield (user_config, groupid) + + +@pytest.fixture(scope="session") +def test_recipe_server(): + """ + Fixture to create a local HTTP server serving test recipe HTML pages. + + This serves static HTML files with schema.org Recipe JSON-LD data for testing + recipe import functionality without relying on external websites. + + Yields the server URL (e.g., "http://localhost:8082") + """ + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + from pathlib import Path + + httpd = None + server_thread = None + + # Get the path to the fixtures directory + fixtures_dir = Path(__file__).parent / "fixtures" + + class RecipeServerHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + # Suppress default HTTP logging + pass + + def do_GET(self): + # Map URL paths to fixture files + if self.path == "/black-pepper-tofu": + file_path = fixtures_dir / "test_recipe.html" + else: + # 404 for unknown paths + self.send_response(404) + self.end_headers() + return + + if file_path.exists(): + with open(file_path, "rb") as f: + content = f.read() + + self.send_response(200) + self.send_header("Content-type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + else: + self.send_response(404) + self.end_headers() + + try: + # Start the HTTP server on all interfaces (0.0.0.0) so Docker can reach it + httpd = HTTPServer(("0.0.0.0", 8082), RecipeServerHandler) + server_thread = threading.Thread(target=httpd.serve_forever) + server_thread.daemon = True + server_thread.start() + logger.info( + "Test recipe server started on http://0.0.0.0:8082 (accessible from Docker)" + ) + + # Yield the server URL (use localhost for test code, will be replaced with Docker-accessible IP) + yield "http://localhost:8082" + + finally: + # Clean up the server + if httpd: + logger.info("Shutting down test recipe server...") + shutdown_thread = threading.Thread(target=httpd.shutdown) + shutdown_thread.start() + shutdown_thread.join(timeout=2) + httpd.server_close() + logger.info("Test recipe server shut down successfully") + if server_thread: + server_thread.join(timeout=1) diff --git a/tests/fixtures/test_recipe.html b/tests/fixtures/test_recipe.html new file mode 100644 index 0000000..260736a --- /dev/null +++ b/tests/fixtures/test_recipe.html @@ -0,0 +1,133 @@ + + + + + Black Pepper Tofu Recipe - Test Recipe + + + +
+

Black Pepper Tofu

+

By Yotam Ottolenghi

+

+ A flavorful black pepper tofu dish with aromatic spices and crispy texture. + Inspired by Yotam Ottolenghi's signature style. +

+ +
+

Prep Time: 15 minutes

+

Cook Time: 20 minutes

+

Total Time: 35 minutes

+

Servings: 4

+
+ +

Ingredients

+ + +

Instructions

+
    +
  1. Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes.
  2. +
  3. Toss tofu cubes with cornstarch until evenly coated.
  4. +
  5. Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside.
  6. +
  7. In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant.
  8. +
  9. Add bell pepper and cook for 2-3 minutes until slightly softened.
  10. +
  11. Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer.
  12. +
  13. Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes.
  14. +
  15. Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles.
  16. +
+ +

Nutrition Information

+

Per serving: 280 calories, 18g protein, 16g fat, 18g carbohydrates, 3g fiber

+
+ + diff --git a/tests/server/test_cookbook_mcp.py b/tests/server/test_cookbook_mcp.py new file mode 100644 index 0000000..aa11428 --- /dev/null +++ b/tests/server/test_cookbook_mcp.py @@ -0,0 +1,564 @@ +import asyncio +import json +import logging +import platform +import uuid + +import pytest +from mcp import ClientSession + +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) +pytestmark = pytest.mark.integration + + +async def test_mcp_cookbook_create_and_read_recipe( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test creating and reading a recipe via MCP tools with verification via NextcloudClient.""" + + unique_suffix = uuid.uuid4().hex[:8] + recipe_name = f"MCP Test Recipe {unique_suffix}" + recipe_data = { + "name": recipe_name, + "description": "A test recipe created via MCP tools", + "recipeIngredient": ["100g flour", "2 eggs", "200ml milk"], + "recipeInstructions": ["Mix ingredients", "Cook for 20 minutes", "Serve hot"], + "recipeCategory": "MCPTesting", + "keywords": f"mcp,testing,{unique_suffix}", + "recipeYield": 4, + "prepTime": "PT15M", + "cookTime": "PT20M", + "totalTime": "PT35M", + } + + created_recipe_id = None + + try: + # 1. Create recipe via MCP + logger.info(f"Creating recipe via MCP: {recipe_name}") + create_result = await nc_mcp_client.call_tool( + "nc_cookbook_create_recipe", + { + "name": recipe_name, + "description": recipe_data["description"], + "ingredients": recipe_data["recipeIngredient"], + "instructions": recipe_data["recipeInstructions"], + "category": recipe_data["recipeCategory"], + "keywords": recipe_data["keywords"], + "recipe_yield": recipe_data["recipeYield"], + "prep_time": recipe_data["prepTime"], + "cook_time": recipe_data["cookTime"], + "total_time": recipe_data["totalTime"], + }, + ) + + assert create_result.isError is False, ( + f"MCP recipe creation failed: {create_result.content}" + ) + + create_response = json.loads(create_result.content[0].text) + created_recipe_id = create_response["id"] + logger.info(f"Recipe created via MCP with ID: {created_recipe_id}") + + # 2. Verify creation via direct NextcloudClient + direct_recipe = await nc_client.cookbook.get_recipe(created_recipe_id) + assert direct_recipe["name"] == recipe_name + assert direct_recipe["description"] == "A test recipe created via MCP tools" + assert len(direct_recipe["recipeIngredient"]) == 3 + assert len(direct_recipe["recipeInstructions"]) == 3 + assert direct_recipe["recipeCategory"] == "MCPTesting" + + # 3. Read recipe via MCP + logger.info(f"Reading recipe via MCP: {created_recipe_id}") + read_result = await nc_mcp_client.call_tool( + "nc_cookbook_get_recipe", {"recipe_id": created_recipe_id} + ) + + assert read_result.isError is False, ( + f"MCP recipe read failed: {read_result.content}" + ) + + read_recipe = json.loads(read_result.content[0].text) + assert read_recipe["name"] == recipe_name + assert read_recipe["description"] == "A test recipe created via MCP tools" + assert len(read_recipe["recipeIngredient"]) == 3 + + logger.info(f"Successfully verified recipe {created_recipe_id} via MCP") + + finally: + # Cleanup + if created_recipe_id is not None: + try: + await nc_client.cookbook.delete_recipe(created_recipe_id) + logger.info(f"Cleaned up recipe {created_recipe_id}") + except Exception as e: + logger.warning(f"Failed to cleanup recipe: {e}") + + +async def test_mcp_cookbook_update_recipe( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test updating a recipe via MCP tools.""" + + unique_suffix = uuid.uuid4().hex[:8] + recipe_name = f"MCP Update Test {unique_suffix}" + recipe_data = { + "name": recipe_name, + "description": "Original description", + "recipeIngredient": ["100g flour"], + "recipeInstructions": ["Mix ingredients"], + "recipeCategory": "Original", + } + + created_recipe_id = None + + try: + # 1. Create recipe via direct client + logger.info(f"Creating recipe for update test: {recipe_name}") + created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + + # 2. Update recipe via MCP (tool handles fetching current recipe internally) + logger.info(f"Updating recipe via MCP: {created_recipe_id}") + update_result = await nc_mcp_client.call_tool( + "nc_cookbook_update_recipe", + { + "recipe_id": created_recipe_id, + "description": "Updated via MCP", + "ingredients": ["100g flour", "2 eggs"], + "instructions": ["Mix ingredients", "Cook"], + "category": "Updated", + }, + ) + + assert update_result.isError is False, ( + f"MCP recipe update failed: {update_result.content}" + ) + + # 4. Verify update via direct NextcloudClient + await asyncio.sleep(1) # Allow propagation + updated_recipe = await nc_client.cookbook.get_recipe(created_recipe_id) + assert updated_recipe["description"] == "Updated via MCP" + assert len(updated_recipe["recipeIngredient"]) == 2 + assert len(updated_recipe["recipeInstructions"]) == 2 + assert updated_recipe["recipeCategory"] == "Updated" + + logger.info(f"Successfully updated recipe {created_recipe_id} via MCP") + + finally: + # Cleanup + if created_recipe_id is not None: + try: + await nc_client.cookbook.delete_recipe(created_recipe_id) + logger.info(f"Cleaned up recipe {created_recipe_id}") + except Exception as e: + logger.warning(f"Failed to cleanup recipe: {e}") + + +async def test_mcp_cookbook_delete_recipe( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test deleting a recipe via MCP tools.""" + + unique_suffix = uuid.uuid4().hex[:8] + recipe_name = f"MCP Delete Test {unique_suffix}" + recipe_data = { + "name": recipe_name, + "description": "Recipe to be deleted", + "recipeIngredient": ["test"], + "recipeInstructions": ["test"], + } + + created_recipe_id = None + + try: + # 1. Create recipe via direct client + logger.info(f"Creating recipe for delete test: {recipe_name}") + created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + + # 2. Delete recipe via MCP + logger.info(f"Deleting recipe via MCP: {created_recipe_id}") + delete_result = await nc_mcp_client.call_tool( + "nc_cookbook_delete_recipe", {"recipe_id": created_recipe_id} + ) + + assert delete_result.isError is False, ( + f"MCP recipe deletion failed: {delete_result.content}" + ) + + # 3. Verify deletion via direct NextcloudClient + try: + await nc_client.cookbook.get_recipe(created_recipe_id) + pytest.fail("Recipe should have been deleted but was still found") + except Exception: + # Expected - recipe should be deleted + logger.info(f"Successfully verified recipe {created_recipe_id} was deleted") + created_recipe_id = None # Mark as cleaned up + + finally: + # Cleanup in case of test failure + if created_recipe_id is not None: + try: + await nc_client.cookbook.delete_recipe(created_recipe_id) + logger.info(f"Cleaned up recipe {created_recipe_id}") + except Exception as e: + logger.warning(f"Failed to cleanup recipe: {e}") + + +async def test_mcp_cookbook_import_recipe_from_url( + nc_mcp_client: ClientSession, + nc_client: NextcloudClient, + test_recipe_server: str, +): + """Test importing a recipe from a URL via MCP tools. + + This is the key feature test - importing recipes from URLs using schema.org metadata. + Uses a local test server to provide reliable, controlled test data. + """ + # Replace localhost with Docker bridge gateway IP so the Nextcloud container can reach it + if platform.system() == "Linux": + docker_host = "172.17.0.1" + else: + docker_host = "host.docker.internal" + + docker_accessible_url = test_recipe_server.replace("localhost", docker_host) + test_url = f"{docker_accessible_url}/black-pepper-tofu" + + created_recipe_id = None + + try: + # 1. Import recipe via MCP + logger.info( + f"Importing recipe from local test URL via MCP (Docker-accessible): {test_url}" + ) + import_result = await nc_mcp_client.call_tool( + "nc_cookbook_import_recipe", {"url": test_url} + ) + + assert import_result.isError is False, ( + f"MCP recipe import failed: {import_result.content}" + ) + + import_response = json.loads(import_result.content[0].text) + created_recipe_id = int(import_response["recipe_id"]) + imported_recipe = import_response["recipe"] + + logger.info(f"Successfully imported recipe via MCP: {imported_recipe['name']}") + + # 2. Verify basic recipe structure + assert imported_recipe["name"] == "Black Pepper Tofu" + assert imported_recipe.get("description") + assert len(imported_recipe.get("recipeIngredient", [])) > 0 + assert len(imported_recipe.get("recipeInstructions", [])) > 0 + assert imported_recipe.get("recipeCategory") == "Main Course" + assert "tofu" in imported_recipe.get("keywords", "").lower() + + # 3. Verify we can read it back via direct NextcloudClient + retrieved = await nc_client.cookbook.get_recipe(created_recipe_id) + assert retrieved["name"] == imported_recipe["name"] + logger.info(f"Verified imported recipe ID: {created_recipe_id}") + + finally: + # Cleanup + if created_recipe_id is not None: + try: + await nc_client.cookbook.delete_recipe(created_recipe_id) + logger.info(f"Cleaned up imported recipe {created_recipe_id}") + except Exception as e: + logger.warning(f"Failed to cleanup imported recipe: {e}") + + +async def test_mcp_cookbook_search_recipes( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test searching recipes via MCP tools.""" + + unique_keyword = f"mcptestkeyword{uuid.uuid4().hex[:8]}" + recipe_name = f"MCP Search Test {uuid.uuid4().hex[:8]}" + recipe_data = { + "name": recipe_name, + "description": f"Recipe for testing MCP search with {unique_keyword}", + "keywords": unique_keyword, + "recipeIngredient": ["test ingredient"], + "recipeInstructions": ["test instruction"], + } + + created_recipe_id = None + + try: + # 1. Create recipe via direct client + logger.info(f"Creating recipe for search test with keyword: {unique_keyword}") + created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + + # 2. Allow time for indexing + await asyncio.sleep(2) + + # 3. Search for the recipe via MCP + logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}") + search_result = await nc_mcp_client.call_tool( + "nc_cookbook_search_recipes", {"query": unique_keyword} + ) + + assert search_result.isError is False, ( + f"MCP recipe search failed: {search_result.content}" + ) + + search_response = json.loads(search_result.content[0].text) + search_results = search_response["recipes"] + + assert isinstance(search_results, list) + assert len(search_results) > 0 + + # 4. Verify our recipe is in the results + found = any(str(r.get("id")) == str(created_recipe_id) for r in search_results) + assert found, f"Recipe {created_recipe_id} not found in search results" + logger.info( + f"Successfully found recipe {created_recipe_id} in MCP search results" + ) + + finally: + # Cleanup + if created_recipe_id is not None: + try: + await nc_client.cookbook.delete_recipe(created_recipe_id) + logger.info(f"Cleaned up recipe {created_recipe_id}") + except Exception as e: + logger.warning(f"Failed to cleanup recipe: {e}") + + +async def test_mcp_cookbook_list_recipes( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test listing all recipes via MCP tools.""" + + logger.info("Listing all recipes via MCP") + list_result = await nc_mcp_client.call_tool("nc_cookbook_list_recipes", {}) + + assert list_result.isError is False, ( + f"MCP list recipes failed: {list_result.content}" + ) + + list_response = json.loads(list_result.content[0].text) + recipes = list_response["recipes"] + + assert isinstance(recipes, list) + logger.info(f"Found {len(recipes)} recipes via MCP") + + +async def test_mcp_cookbook_categories_workflow( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test category listing and filtering via MCP tools.""" + + unique_category = f"MCPTestCategory{uuid.uuid4().hex[:8]}" + recipe_name = f"MCP Category Test {uuid.uuid4().hex[:8]}" + recipe_data = { + "name": recipe_name, + "recipeCategory": unique_category, + "recipeIngredient": ["test"], + "recipeInstructions": ["test"], + } + + created_recipe_id = None + + try: + # 1. Create recipe in test category + logger.info(f"Creating recipe in category: {unique_category}") + created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + + # 2. Allow time for indexing + await asyncio.sleep(2) + + # 3. List categories via MCP + logger.info("Listing categories via MCP") + categories_result = await nc_mcp_client.call_tool( + "nc_cookbook_list_categories", {} + ) + + assert categories_result.isError is False, ( + f"MCP list categories failed: {categories_result.content}" + ) + + categories_response = json.loads(categories_result.content[0].text) + categories = categories_response["categories"] + + assert isinstance(categories, list) + logger.info(f"Found {len(categories)} categories via MCP") + + # 4. Get recipes in this category via MCP + logger.info(f"Getting recipes in category via MCP: {unique_category}") + category_recipes_result = await nc_mcp_client.call_tool( + "nc_cookbook_get_recipes_in_category", {"category": unique_category} + ) + + assert category_recipes_result.isError is False, ( + f"MCP get recipes in category failed: {category_recipes_result.content}" + ) + + category_recipes_response = json.loads(category_recipes_result.content[0].text) + recipes_in_category = category_recipes_response["recipes"] + + assert isinstance(recipes_in_category, list) + assert len(recipes_in_category) > 0 + + # 5. Verify our recipe is in the results + found = any( + str(r.get("id")) == str(created_recipe_id) for r in recipes_in_category + ) + assert found, ( + f"Recipe {created_recipe_id} not found in category {unique_category}" + ) + logger.info(f"Successfully found recipe in category {unique_category} via MCP") + + finally: + # Cleanup + if created_recipe_id is not None: + try: + await nc_client.cookbook.delete_recipe(created_recipe_id) + logger.info(f"Cleaned up recipe {created_recipe_id}") + except Exception as e: + logger.warning(f"Failed to cleanup recipe: {e}") + + +async def test_mcp_cookbook_keywords_workflow( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test keyword listing and filtering via MCP tools.""" + + unique_keyword = f"mcptesttag{uuid.uuid4().hex[:8]}" + recipe_name = f"MCP Keyword Test {uuid.uuid4().hex[:8]}" + recipe_data = { + "name": recipe_name, + "keywords": f"{unique_keyword},mcptesting", + "recipeIngredient": ["test"], + "recipeInstructions": ["test"], + } + + created_recipe_id = None + + try: + # 1. Create recipe with test keywords + logger.info(f"Creating recipe with keyword: {unique_keyword}") + created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data) + + # 2. Allow extra time for indexing and trigger reindex + await asyncio.sleep(3) + await nc_client.cookbook.reindex() + await asyncio.sleep(2) + + # 3. List keywords via MCP + logger.info("Listing keywords via MCP") + keywords_result = await nc_mcp_client.call_tool("nc_cookbook_list_keywords", {}) + + assert keywords_result.isError is False, ( + f"MCP list keywords failed: {keywords_result.content}" + ) + + keywords_response = json.loads(keywords_result.content[0].text) + keywords = keywords_response["keywords"] + + assert isinstance(keywords, list) + logger.info(f"Found {len(keywords)} keywords via MCP") + + # 4. Get recipes with this keyword via MCP + logger.info(f"Getting recipes with keyword via MCP: {unique_keyword}") + keyword_recipes_result = await nc_mcp_client.call_tool( + "nc_cookbook_get_recipes_with_keywords", {"keywords": [unique_keyword]} + ) + + assert keyword_recipes_result.isError is False, ( + f"MCP get recipes with keywords failed: {keyword_recipes_result.content}" + ) + + keyword_recipes_response = json.loads(keyword_recipes_result.content[0].text) + recipes_with_keywords = keyword_recipes_response["recipes"] + + assert isinstance(recipes_with_keywords, list) + + # Keyword filtering might not find recipes immediately due to indexing + if len(recipes_with_keywords) > 0: + # Verify our recipe is in the results if any are found + found = any( + str(r.get("id")) == str(created_recipe_id) + for r in recipes_with_keywords + ) + if found: + logger.info( + f"Successfully found recipe with keyword {unique_keyword} via MCP" + ) + else: + logger.warning( + f"Recipe {created_recipe_id} not in keyword results via MCP, but other recipes found" + ) + else: + logger.warning( + f"No recipes found with keyword {unique_keyword} via MCP - may be indexing delay" + ) + + finally: + # Cleanup + if created_recipe_id is not None: + try: + await nc_client.cookbook.delete_recipe(created_recipe_id) + logger.info(f"Cleaned up recipe {created_recipe_id}") + except Exception as e: + logger.warning(f"Failed to cleanup recipe: {e}") + + +async def test_mcp_cookbook_config_and_version( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test getting Cookbook configuration and version via MCP resources.""" + + # 1. Get version via MCP resource + logger.info("Getting Cookbook version via MCP resource") + version_result = await nc_mcp_client.read_resource("cookbook://version") + + assert len(version_result.contents) > 0 + version_response = json.loads(version_result.contents[0].text) + assert "cookbook_version" in version_response + assert "api_version" in version_response + logger.info(f"Cookbook version from MCP: {version_response}") + + # 2. Verify version via direct NextcloudClient + direct_version = await nc_client.cookbook.get_version() + assert direct_version["cookbook_version"] == version_response["cookbook_version"] + assert ( + direct_version["api_version"]["epoch"] + == version_response["api_version"]["epoch"] + ) + + # 3. Get config via MCP resource + logger.info("Getting Cookbook config via MCP resource") + config_result = await nc_mcp_client.read_resource("cookbook://config") + + assert len(config_result.contents) > 0 + config_response = json.loads(config_result.contents[0].text) + assert isinstance(config_response, dict) + logger.info(f"Cookbook config from MCP: {config_response}") + + # 4. Verify config via direct NextcloudClient + direct_config = await nc_client.cookbook.get_config() + # Both should be dicts - exact match may vary based on config + assert isinstance(config_response, dict) + assert isinstance(direct_config, dict) + + logger.info("Successfully verified Cookbook version and config via MCP") + + +async def test_mcp_cookbook_reindex( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test triggering a recipe reindex via MCP tools.""" + + logger.info("Triggering recipe reindex via MCP") + reindex_result = await nc_mcp_client.call_tool("nc_cookbook_reindex", {}) + + assert reindex_result.isError is False, ( + f"MCP reindex failed: {reindex_result.content}" + ) + + reindex_response = json.loads(reindex_result.content[0].text) + assert isinstance(reindex_response["message"], str) + logger.info(f"Reindex result from MCP: {reindex_response['message']}") diff --git a/tests/server/test_mcp.py b/tests/server/test_mcp.py index c3074fe..5cbc1a7 100644 --- a/tests/server/test_mcp.py +++ b/tests/server/test_mcp.py @@ -52,6 +52,19 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): "nc_calendar_bulk_operations", "nc_calendar_manage_calendar", "deck_create_board", + "nc_cookbook_import_recipe", + "nc_cookbook_list_recipes", + "nc_cookbook_get_recipe", + "nc_cookbook_create_recipe", + "nc_cookbook_update_recipe", + "nc_cookbook_delete_recipe", + "nc_cookbook_search_recipes", + "nc_cookbook_list_categories", + "nc_cookbook_get_recipes_in_category", + "nc_cookbook_list_keywords", + "nc_cookbook_get_recipes_with_keywords", + "nc_cookbook_set_config", + "nc_cookbook_reindex", ] for expected_tool in expected_tools: @@ -85,7 +98,13 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): resource_uris.append(str(resource.uri)) # Convert to string for comparison # Verify expected resources - expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"] + expected_resources = [ + "nc://capabilities", + "notes://settings", + "nc://Deck/boards", + "cookbook://version", + "cookbook://config", + ] for expected_resource in expected_resources: assert expected_resource in resource_uris, ( From 394b27ee4a172bf0fd16571120fa98074ea7113c Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 03:21:54 +0200 Subject: [PATCH 2/3] docs: Update README with experimental warnings of OIDC support --- README.md | 61 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d311391..0359dfa 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language. +> [!WARNING] +> **OAuth Support is Experimental**: OAuth/OIDC authentication requires manual patches to upstream Nextcloud apps and is not production-ready. For most users, **Basic Auth is recommended**. See [OAuth Upstream Status](docs/oauth-upstream-status.md) for details on required patches. + > [!NOTE] > **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. See our [detailed comparison](docs/comparison-context-agent.md) to understand which approach fits your use case. @@ -30,8 +33,16 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/ | Mode | Security | Best For | |------|----------|----------| -| **OAuth2/OIDC** ✅ | 🔒 High | Production, multi-user deployments | -| **Basic Auth** ⚠️ | Lower | Development, testing | +| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patches) | +| **Basic Auth** ✅ | Lower | Development, testing, production | + +> [!IMPORTANT] +> **OAuth is experimental** and requires manual patches to upstream Nextcloud apps. Specifically: +> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221)) +> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors +> - **Production use**: Wait for upstream patches to be merged into official releases +> +> See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds. OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details. @@ -62,29 +73,35 @@ Create a `.env` file: cp env.sample .env ``` -**For OAuth (recommended):** -```dotenv -NEXTCLOUD_HOST=https://your.nextcloud.instance.com -``` - -**For Basic Auth:** +**For Basic Auth (recommended for most users):** ```dotenv NEXTCLOUD_HOST=https://your.nextcloud.instance.com NEXTCLOUD_USERNAME=your_username NEXTCLOUD_PASSWORD=your_app_password ``` +**For OAuth (experimental - requires patches):** +```dotenv +NEXTCLOUD_HOST=https://your.nextcloud.instance.com +``` + See [Configuration Guide](docs/configuration.md) for all options. ### 3. Set Up Authentication -**OAuth Setup (recommended):** -1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`) -2. Enable dynamic client registration -3. Configure Bearer token validation -4. Start the server +**Basic Auth Setup (recommended):** +1. Create an app password in Nextcloud (Settings → Security → Devices & sessions) +2. Add credentials to `.env` file +3. Start the server -See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for production deployment. +**OAuth Setup (experimental):** +1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`) +2. **Apply required patches** to `user_oidc` app (see [OAuth Upstream Status](docs/oauth-upstream-status.md)) +3. Enable dynamic client registration +4. Configure Bearer token validation +5. Start the server + +See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions. ### 4. Run the Server @@ -92,12 +109,15 @@ See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth S # Load environment variables export $(grep -v '^#' .env | xargs) -# Start the server +# Start with Basic Auth (default) +uv run nextcloud-mcp-server + +# Or start with OAuth (experimental - requires patches) uv run nextcloud-mcp-server --oauth # Or with Docker docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ - ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth + ghcr.io/cbcoutinho/nextcloud-mcp-server:latest ``` The server starts on `http://127.0.0.1:8000` by default. @@ -127,12 +147,12 @@ Or connect from: ### Architecture - **[Comparison with Context Agent](docs/comparison-context-agent.md)** - How this MCP server differs from Nextcloud's Context Agent -### OAuth Documentation +### OAuth Documentation (Experimental) - **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute setup guide -- **[OAuth Setup Guide](docs/oauth-setup.md)** - Production deployment +- **[OAuth Setup Guide](docs/oauth-setup.md)** - Detailed setup instructions - **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works - **[OAuth Troubleshooting](docs/oauth-troubleshooting.md)** - OAuth-specific issues -- **[Upstream Status](docs/oauth-upstream-status.md)** - Required patches and PRs +- **[Upstream Status](docs/oauth-upstream-status.md)** - **Required patches and PRs** ⚠️ ### Reference - **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions @@ -230,7 +250,8 @@ Contributions are welcome! [![MseeP.ai Security Assessment](https://mseep.net/pr/cbcoutinho-nextcloud-mcp-server-badge.png)](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server) This project takes security seriously: -- OAuth2/OIDC support for secure authentication +- OAuth2/OIDC support (experimental - requires upstream patches) +- Basic Auth with app-specific passwords (recommended) - No credential storage with OAuth mode - Per-user access tokens - Regular security assessments From 038fcddd4811b275e488d20a3d2658844d1c8867 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 03:24:23 +0200 Subject: [PATCH 3/3] docs: remove duplicate --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 0359dfa..db53b38 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,6 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language. -> [!WARNING] -> **OAuth Support is Experimental**: OAuth/OIDC authentication requires manual patches to upstream Nextcloud apps and is not production-ready. For most users, **Basic Auth is recommended**. See [OAuth Upstream Status](docs/oauth-upstream-status.md) for details on required patches. - > [!NOTE] > **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. See our [detailed comparison](docs/comparison-context-agent.md) to understand which approach fits your use case.