Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1207770ca | |||
| d694243723 | |||
| 8e7191e0ea | |||
| dbcf9d93ca | |||
| 27519d0f62 | |||
| 2999d4b65e | |||
| 0fd32ecd34 | |||
| 604a2065cb | |||
| 0aeef1b87e | |||
| b65f10ed8e |
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user