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.
+
+
+
+
+ Ingredients
+
+ - 400g firm tofu, pressed and cubed
+ - 2 tablespoons black peppercorns, coarsely ground
+ - 3 tablespoons soy sauce
+ - 2 tablespoons rice vinegar
+ - 1 tablespoon maple syrup
+ - 2 tablespoons cornstarch
+ - 3 tablespoons vegetable oil
+ - 4 cloves garlic, minced
+ - 1 tablespoon fresh ginger, grated
+ - 2 spring onions, sliced
+ - 1 red bell pepper, sliced
+ - Sesame seeds for garnish
+
+
+ Instructions
+
+ - Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes.
+ - Toss tofu cubes with cornstarch until evenly coated.
+ - 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.
+ - In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant.
+ - Add bell pepper and cook for 2-3 minutes until slightly softened.
+ - Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer.
+ - Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes.
+ - Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles.
+
+
+ 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, (