Compare commits

...

10 Commits

Author SHA1 Message Date
Chris Coutinho b1207770ca docs: revert README 2025-10-17 04:47:46 +02:00
Chris Coutinho d694243723 test: Remove filter 2025-10-17 04:46:43 +02:00
Chris Coutinho 8e7191e0ea fix: Increase HTTP client timeout to 30s
The default 5s timeout was too short for Nextcloud Cookbook app to fetch and process recipes from external URLs, causing intermittent test failures with ReadTimeout errors.

Fixes intermittent CI failures in cookbook import tests.
2025-10-17 04:41:28 +02:00
Chris Coutinho dbcf9d93ca chore: Improve RequestError message details
Show exception type and cause when str(e) is empty for better debugging
2025-10-17 04:37:31 +02:00
Chris Coutinho 27519d0f62 test: Replace http server for recipes with nginx container 2025-10-17 04:30:03 +02:00
Chris Coutinho 2999d4b65e fix: Handle RequestError in mcp tools 2025-10-17 04:17:41 +02:00
Chris Coutinho 0fd32ecd34 test: Fix test networking 2025-10-17 03:58:36 +02:00
Chris Coutinho 604a2065cb chore: trigger 2025-10-17 03:40:40 +02:00
github-actions[bot] 0aeef1b87e bump: version 0.14.3 → 0.15.0 2025-10-17 01:25:56 +00:00
Chris Coutinho b65f10ed8e Merge pull request #215 from cbcoutinho/feature/cookbook-app
feat(cookbook): Add full Cookbook app support with 13 tools and 2 res…
2025-10-17 03:25:31 +02:00
11 changed files with 114 additions and 110 deletions
+6
View File
@@ -1,3 +1,9 @@
## v0.15.0 (2025-10-17)
### Feat
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
## v0.14.3 (2025-10-17)
### Fix
+7
View File
@@ -40,6 +40,13 @@ services:
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
recipes:
image: docker.io/library/nginx:alpine
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
mcp:
build: .
command: ["--transport", "streamable-http"]
+4
View File
@@ -9,6 +9,7 @@ from httpx import (
BasicAuth,
Request,
Response,
Timeout,
)
from ..controllers.notes_search import NotesSearchController
@@ -66,6 +67,9 @@ class NextcloudClient:
auth=auth,
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
event_hooks={"request": [log_request], "response": [log_response]},
timeout=Timeout(
30.0
), # 30 second timeout for all operations including recipe imports
)
# Initialize app clients
+13 -1
View File
@@ -1,6 +1,6 @@
import logging
from httpx import HTTPStatusError
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
@@ -83,6 +83,18 @@ def configure_cookbook_tools(mcp: FastMCP):
recipe=recipe,
recipe_id=recipe.id or "unknown",
)
except RequestError as e:
# RequestError can have empty str() - get details from exception attributes
error_detail = (
str(e)
or f"{type(e).__name__}: {getattr(e, '__cause__', 'unknown cause')}"
)
raise McpError(
ErrorData(
code=-1,
message=f"Network error importing recipe from {url}: {error_detail}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 400:
raise McpError(
+48 -1
View File
@@ -1,6 +1,6 @@
import logging
from httpx import HTTPStatusError
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
@@ -61,6 +61,13 @@ def configure_notes_tools(mcp: FastMCP):
try:
note_data = await client.notes.get_note(note_id)
return Note(**note_data)
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error retrieving note {note_id}: {str(e)}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
@@ -92,6 +99,10 @@ def configure_notes_tools(mcp: FastMCP):
return CreateNoteResponse(
id=note.id, title=note.title, category=note.category, etag=note.etag
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error creating note: {str(e)}")
)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
@@ -146,6 +157,12 @@ def configure_notes_tools(mcp: FastMCP):
return UpdateNoteResponse(
id=note.id, title=note.title, category=note.category, etag=note.etag
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error updating note {note_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
@@ -192,6 +209,13 @@ def configure_notes_tools(mcp: FastMCP):
return AppendContentResponse(
id=note.id, title=note.title, category=note.category, etag=note.etag
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error appending to note {note_id}: {str(e)}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
@@ -238,6 +262,10 @@ def configure_notes_tools(mcp: FastMCP):
return SearchNotesResponse(
results=results, query=query, total_found=len(results)
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error searching notes: {str(e)}")
)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
@@ -265,6 +293,12 @@ def configure_notes_tools(mcp: FastMCP):
try:
note_data = await client.notes.get_note(note_id)
return Note(**note_data)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting note {note_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
@@ -295,6 +329,13 @@ def configure_notes_tools(mcp: FastMCP):
"mimeType": mime_type,
"data": content,
}
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error getting attachment {attachment_filename} for note {note_id}: {str(e)}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
@@ -330,6 +371,12 @@ def configure_notes_tools(mcp: FastMCP):
message=f"Note {note_id} deleted successfully",
deleted_id=note_id,
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error deleting note {note_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.14.3"
version = "0.15.0"
description = ""
authors = [
{name = "Chris Coutinho",email = "chris@coutinho.io"}
+6 -18
View File
@@ -153,29 +153,17 @@ async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient):
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
):
async def test_cookbook_import_recipe_from_url(nc_client: NextcloudClient):
"""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.
Uses an nginx container to serve 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"
# Use the nginx container hostname within the Docker network
test_url = "http://recipes/black-pepper-tofu"
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}")
logger.info(f"Importing recipe from nginx container: {test_url}")
try:
imported_recipe = await nc_client.cookbook.import_recipe(test_url)
@@ -213,7 +201,7 @@ async def test_cookbook_import_recipe_from_url(
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"Failed to import recipe from nginx container: {test_url}. "
f"Status: {e.response.status_code}, Response: {e.response.text}"
)
raise
-74
View File
@@ -1319,77 +1319,3 @@ 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)
+24
View File
@@ -0,0 +1,24 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type text/html;
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
try_files $uri $uri.html =404;
}
# Serve test_recipe.html at /black-pepper-tofu
location = /black-pepper-tofu {
root /usr/share/nginx/html;
try_files /test_recipe.html =404;
}
}
}
+4 -14
View File
@@ -1,7 +1,6 @@
import asyncio
import json
import logging
import platform
import uuid
import pytest
@@ -209,29 +208,20 @@ async def test_mcp_cookbook_delete_recipe(
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.
Uses an nginx container to serve 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"
# Use the nginx container hostname within the Docker network
test_url = "http://recipes/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}"
)
logger.info(f"Importing recipe from nginx container via MCP: {test_url}")
import_result = await nc_mcp_client.call_tool(
"nc_cookbook_import_recipe", {"url": test_url}
)
Generated
+1 -1
View File
@@ -630,7 +630,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.14.3"
version = "0.15.0"
source = { editable = "." }
dependencies = [
{ name = "click" },