fix(server): Replace ErrorResponses with standard McpErrors
This commit is contained in:
@@ -3,7 +3,6 @@
|
|||||||
# Base models
|
# Base models
|
||||||
from .base import (
|
from .base import (
|
||||||
BaseResponse,
|
BaseResponse,
|
||||||
ErrorResponse,
|
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
IdResponse,
|
IdResponse,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
@@ -82,7 +81,6 @@ from .webdav import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
# Base models
|
# Base models
|
||||||
"BaseResponse",
|
"BaseResponse",
|
||||||
"ErrorResponse",
|
|
||||||
"SuccessResponse",
|
"SuccessResponse",
|
||||||
"IdResponse",
|
"IdResponse",
|
||||||
"StatusResponse",
|
"StatusResponse",
|
||||||
|
|||||||
@@ -35,17 +35,6 @@ class BaseResponse(BaseModel):
|
|||||||
return iso_string
|
return iso_string
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseResponse):
|
|
||||||
"""Response model for error cases."""
|
|
||||||
|
|
||||||
success: bool = Field(default=False, description="Always False for error responses")
|
|
||||||
error: str = Field(description="Error message")
|
|
||||||
error_code: Optional[str] = Field(None, description="Optional error code")
|
|
||||||
details: Optional[Dict[str, Any]] = Field(
|
|
||||||
None, description="Additional error details"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SuccessResponse(BaseResponse):
|
class SuccessResponse(BaseResponse):
|
||||||
"""Generic success response."""
|
"""Generic success response."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
from mcp.shared.exceptions import McpError
|
||||||
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.models.base import ErrorResponse
|
|
||||||
from nextcloud_mcp_server.models.notes import (
|
from nextcloud_mcp_server.models.notes import (
|
||||||
Note,
|
Note,
|
||||||
NotesSettings,
|
NotesSettings,
|
||||||
@@ -54,8 +55,6 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
@mcp.resource("nc://Notes/{note_id}")
|
@mcp.resource("nc://Notes/{note_id}")
|
||||||
async def nc_get_note(note_id: int):
|
async def nc_get_note(note_id: int):
|
||||||
"""Get user note using note id"""
|
"""Get user note using note id"""
|
||||||
from mcp.shared.exceptions import McpError
|
|
||||||
from mcp.types import ErrorData
|
|
||||||
|
|
||||||
ctx: Context = mcp.get_context()
|
ctx: Context = mcp.get_context()
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
@@ -80,7 +79,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_notes_create_note(
|
async def nc_notes_create_note(
|
||||||
title: str, content: str, category: str, ctx: Context
|
title: str, content: str, category: str, ctx: Context
|
||||||
) -> CreateNoteResponse | ErrorResponse:
|
) -> CreateNoteResponse:
|
||||||
"""Create a new note"""
|
"""Create a new note"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
try:
|
try:
|
||||||
@@ -95,18 +94,27 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 403:
|
if e.response.status_code == 403:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error="Access denied: insufficient permissions to create notes"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to create notes",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif e.response.status_code == 413:
|
elif e.response.status_code == 413:
|
||||||
return ErrorResponse(error="Note content too large")
|
raise McpError(ErrorData(code=-1, message="Note content too large"))
|
||||||
elif e.response.status_code == 409:
|
elif e.response.status_code == 409:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"A note with title '{title}' already exists in this category"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"A note with title '{title}' already exists in this category",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"Failed to create note: server error ({e.response.status_code})"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to create note: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -117,7 +125,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
content: str | None,
|
content: str | None,
|
||||||
category: str | None,
|
category: str | None,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
) -> UpdateNoteResponse | ErrorResponse:
|
) -> UpdateNoteResponse:
|
||||||
"""Update an existing note's title, content, or category.
|
"""Update an existing note's title, content, or category.
|
||||||
|
|
||||||
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
||||||
@@ -140,26 +148,37 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
return ErrorResponse(error=f"Note {note_id} not found")
|
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||||
elif e.response.status_code == 412:
|
elif e.response.status_code == 412:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"Note {note_id} has been modified by someone else. Please refresh and try again."
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Note {note_id} has been modified by someone else. Please refresh and try again.",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif e.response.status_code == 403:
|
elif e.response.status_code == 403:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"Access denied: insufficient permissions to update note {note_id}"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Access denied: insufficient permissions to update note {note_id}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif e.response.status_code == 413:
|
elif e.response.status_code == 413:
|
||||||
return ErrorResponse(error="Updated note content is too large")
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message="Updated note content is too large")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"Failed to update note {note_id}: server error ({e.response.status_code})"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to update note {note_id}: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_notes_append_content(
|
async def nc_notes_append_content(
|
||||||
note_id: int, content: str, ctx: Context
|
note_id: int, content: str, ctx: Context
|
||||||
) -> AppendContentResponse | ErrorResponse:
|
) -> AppendContentResponse:
|
||||||
"""Append content to an existing note with a clear separator. The tool automatically adds separators between existing and new content - do not include separators in your content."""
|
"""Append content to an existing note with a clear separator. The tool automatically adds separators between existing and new content - do not include separators in your content."""
|
||||||
logger.info("Appending content to note %s", note_id)
|
logger.info("Appending content to note %s", note_id)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
@@ -173,24 +192,31 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
return ErrorResponse(error=f"Note {note_id} not found")
|
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||||
elif e.response.status_code == 403:
|
elif e.response.status_code == 403:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"Access denied: insufficient permissions to modify note {note_id}"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Access denied: insufficient permissions to modify note {note_id}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif e.response.status_code == 413:
|
elif e.response.status_code == 413:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error="Content to append would make the note too large"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Content to append would make the note too large",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"Failed to append content to note {note_id}: server error ({e.response.status_code})"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to append content to note {note_id}: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_notes_search_notes(
|
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||||
query: str, ctx: Context
|
|
||||||
) -> SearchNotesResponse | ErrorResponse:
|
|
||||||
"""Search notes by title or content, returning only id, title, and category."""
|
"""Search notes by title or content, returning only id, title, and category."""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
try:
|
try:
|
||||||
@@ -212,20 +238,26 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 403:
|
if e.response.status_code == 403:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error="Access denied: insufficient permissions to search notes"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to search notes",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif e.response.status_code == 400:
|
elif e.response.status_code == 400:
|
||||||
return ErrorResponse(error="Invalid search query format")
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message="Invalid search query format")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"Search failed: server error ({e.response.status_code})"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Search failed: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_notes_delete_note(
|
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||||
note_id: int, ctx: Context
|
|
||||||
) -> DeleteNoteResponse | ErrorResponse:
|
|
||||||
"""Delete a note permanently"""
|
"""Delete a note permanently"""
|
||||||
logger.info("Deleting note %s", note_id)
|
logger.info("Deleting note %s", note_id)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
@@ -238,12 +270,18 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
return ErrorResponse(error=f"Note {note_id} not found")
|
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||||
elif e.response.status_code == 403:
|
elif e.response.status_code == 403:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"Access denied: insufficient permissions to delete note {note_id}"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Access denied: insufficient permissions to delete note {note_id}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return ErrorResponse(
|
raise McpError(
|
||||||
error=f"Failed to delete note {note_id}: server error ({e.response.status_code})"
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to delete note {note_id}: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
Examples:
|
Examples:
|
||||||
# Read a text file
|
# Read a text file
|
||||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||||
print(result['content']) # Decoded text content
|
logger.info(result['content']) # Decoded text content
|
||||||
|
|
||||||
# Read a binary file
|
# Read a binary file
|
||||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||||
print(result['encoding']) # 'base64'
|
logger.info(result['encoding']) # 'base64'
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
content, content_type = await client.webdav.read_file(path)
|
content, content_type = await client.webdav.read_file(path)
|
||||||
|
|||||||
@@ -20,23 +20,15 @@ async def test_missing_note_resource_error(nc_mcp_client: ClientSession):
|
|||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_delete_missing_note_tool_error(nc_mcp_client: ClientSession):
|
async def test_delete_missing_note_tool_error(nc_mcp_client: ClientSession):
|
||||||
"""Test that deleting a non-existent note returns proper error."""
|
"""Test that deleting a non-existent note returns proper error."""
|
||||||
# Try to delete a non-existent note
|
# Try to delete a non-existent note - should return error response
|
||||||
response = await nc_mcp_client.call_tool(
|
response = await nc_mcp_client.call_tool(
|
||||||
"nc_notes_delete_note", {"note_id": 999999}
|
"nc_notes_delete_note", {"note_id": 999999}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Delete missing note response: {response}")
|
# Should return error response (not raise exception) for tools
|
||||||
|
|
||||||
# Should return structured error response with improved message
|
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert (
|
assert response.isError is True
|
||||||
response.isError is False
|
assert "Note 999999 not found" in response.content[0].text
|
||||||
) # Tools now return structured responses, not MCP errors
|
|
||||||
|
|
||||||
# Check structured content for error
|
|
||||||
assert "success" in response.structuredContent["result"]
|
|
||||||
assert response.structuredContent["result"]["success"] is False
|
|
||||||
assert "Note 999999 not found" in response.structuredContent["result"]["error"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -81,7 +73,7 @@ async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_cl
|
|||||||
note_id = note_data["id"]
|
note_id = note_data["id"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try to update with invalid ETag
|
# Try to update with invalid ETag - should return error response
|
||||||
response = await nc_mcp_client.call_tool(
|
response = await nc_mcp_client.call_tool(
|
||||||
"nc_notes_update_note",
|
"nc_notes_update_note",
|
||||||
{
|
{
|
||||||
@@ -93,16 +85,10 @@ async def test_update_note_with_invalid_etag(nc_mcp_client: ClientSession, nc_cl
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Invalid ETag response: {response}")
|
# Should return error response (not raise exception) for tools
|
||||||
|
|
||||||
# Should return structured error response with improved message
|
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert response.isError is False # Tools now return structured responses
|
assert response.isError is True
|
||||||
assert "success" in response.structuredContent["result"]
|
assert "modified by someone else" in response.content[0].text
|
||||||
assert response.structuredContent["result"]["success"] is False
|
|
||||||
assert (
|
|
||||||
"modified by someone else" in response.structuredContent["result"]["error"]
|
|
||||||
)
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up
|
# Clean up
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""Unit tests for Pydantic models and serialization."""
|
"""Unit tests for Pydantic models and serialization."""
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from nextcloud_mcp_server.models.base import BaseResponse, SuccessResponse
|
from nextcloud_mcp_server.models.base import BaseResponse, SuccessResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def test_timestamp_format_validation():
|
def test_timestamp_format_validation():
|
||||||
"""Test that timestamps in BaseResponse are RFC3339 compliant for MCP validation.
|
"""Test that timestamps in BaseResponse are RFC3339 compliant for MCP validation.
|
||||||
@@ -76,7 +78,7 @@ def test_current_broken_format():
|
|||||||
assert "+" not in current_format
|
assert "+" not in current_format
|
||||||
assert "-" not in current_format[-6:] # Check last 6 chars for timezone
|
assert "-" not in current_format[-6:] # Check last 6 chars for timezone
|
||||||
|
|
||||||
print(f"Current broken format: {current_format}")
|
logger.info(f"Current broken format: {current_format}")
|
||||||
print(
|
logger.info(
|
||||||
"This format causes MCP validation errors because it lacks timezone information"
|
"This format causes MCP validation errors because it lacks timezone information"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user