Merge pull request #216 from cbcoutinho/feature/trigger

Fix timeouts (in CI)
This commit is contained in:
Chris Coutinho
2025-10-17 04:49:17 +02:00
committed by GitHub
8 changed files with 106 additions and 108 deletions
+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"))
+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}
)