diff --git a/docker-compose.yml b/docker-compose.yml index c36b8cb..8bdbf3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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"] diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index 89c7adf..c363c38 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -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 diff --git a/nextcloud_mcp_server/server/cookbook.py b/nextcloud_mcp_server/server/cookbook.py index 8f534ce..fdbcc43 100644 --- a/nextcloud_mcp_server/server/cookbook.py +++ b/nextcloud_mcp_server/server/cookbook.py @@ -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( diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index a241633..631ea7b 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -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")) diff --git a/tests/client/cookbook/test_cookbook_api.py b/tests/client/cookbook/test_cookbook_api.py index 65c1ca0..c94df2e 100644 --- a/tests/client/cookbook/test_cookbook_api.py +++ b/tests/client/cookbook/test_cookbook_api.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 66fc606..3e898cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/fixtures/nginx.conf b/tests/fixtures/nginx.conf new file mode 100644 index 0000000..14298e8 --- /dev/null +++ b/tests/fixtures/nginx.conf @@ -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; + } + } +} diff --git a/tests/server/test_cookbook_mcp.py b/tests/server/test_cookbook_mcp.py index aa11428..e4c90a3 100644 --- a/tests/server/test_cookbook_mcp.py +++ b/tests/server/test_cookbook_mcp.py @@ -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} )