diff --git a/nextcloud_mcp_server/models/base.py b/nextcloud_mcp_server/models/base.py index d9303a7..40f091f 100644 --- a/nextcloud_mcp_server/models/base.py +++ b/nextcloud_mcp_server/models/base.py @@ -1,23 +1,39 @@ """Base Pydantic models for common response patterns.""" -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_serializer + + +def _utc_now() -> datetime: + """Generate UTC timestamp for responses.""" + return datetime.now(timezone.utc) class BaseResponse(BaseModel): """Base response model for all MCP tool responses.""" - model_config = {"json_encoders": {datetime: lambda v: v.isoformat()}} - success: bool = Field( default=True, description="Whether the operation was successful" ) timestamp: datetime = Field( - default_factory=datetime.now, description="Response timestamp" + default_factory=_utc_now, description="Response timestamp" ) + @field_serializer("timestamp") + def serialize_timestamp(self, timestamp: datetime) -> str: + """Serialize timestamp to RFC3339 format for MCP compliance.""" + if timestamp.tzinfo is None: + # If somehow we get a naive datetime, assume UTC + timestamp = timestamp.replace(tzinfo=timezone.utc) + # Use isoformat() which produces RFC3339 compliant format + # For UTC times, replace '+00:00' with 'Z' as preferred by many systems + iso_string = timestamp.isoformat() + if iso_string.endswith("+00:00"): + return iso_string[:-6] + "Z" + return iso_string + class ErrorResponse(BaseResponse): """Response model for error cases.""" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..304f916 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,82 @@ +"""Unit tests for Pydantic models and serialization.""" + +import json +import re +from datetime import datetime, timezone + + +from nextcloud_mcp_server.models.base import BaseResponse, SuccessResponse + + +def test_timestamp_format_validation(): + """Test that timestamps in BaseResponse are RFC3339 compliant for MCP validation. + + This test should initially fail, demonstrating the timestamp validation error + seen in MCP inspector. MCP expects RFC3339 format with timezone information. + """ + # Create a response object + response = SuccessResponse(message="Test message") + + # Serialize to JSON (mimics what MCP inspector sees) + json_str = response.model_dump_json() + data = json.loads(json_str) + + timestamp_str = data["timestamp"] + + # RFC3339 regex pattern (what MCP expects) + # Format: YYYY-MM-DDTHH:MM:SS[.ffffff][Z|±HH:MM] + rfc3339_pattern = ( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$" + ) + + # This assertion should FAIL with current implementation + assert re.match(rfc3339_pattern, timestamp_str), ( + f"Timestamp '{timestamp_str}' is not RFC3339 compliant. " + f"MCP expects format like '2025-08-30T19:22:58.862377Z' or '2025-08-30T19:22:58.862377+00:00'" + ) + + +def test_base_response_timestamp_is_utc(): + """Test that BaseResponse timestamps are in UTC timezone.""" + response = BaseResponse() + + # The timestamp should be timezone-aware and in UTC + assert response.timestamp.tzinfo is not None, ( + "Timestamp should have timezone information" + ) + assert response.timestamp.tzinfo == timezone.utc, ( + "Timestamp should be in UTC timezone" + ) + + +def test_serialized_timestamp_ends_with_z_or_offset(): + """Test that serialized timestamps have proper timezone suffix.""" + response = BaseResponse() + json_str = response.model_dump_json() + data = json.loads(json_str) + + timestamp_str = data["timestamp"] + + # Should end with 'Z' (UTC) or timezone offset like '+00:00' + assert timestamp_str.endswith("Z") or re.search( + r"[+-]\d{2}:\d{2}$", timestamp_str + ), ( + f"Timestamp '{timestamp_str}' should end with 'Z' or timezone offset like '+00:00'" + ) + + +def test_current_broken_format(): + """Test showing the current broken timestamp format that causes MCP validation errors.""" + # This demonstrates what the current code produces + current_naive_dt = datetime.now() + current_format = current_naive_dt.isoformat() + + # Show that current format lacks timezone info + assert "Z" not in current_format + assert "+" not in current_format + assert "-" not in current_format[-6:] # Check last 6 chars for timezone + + print(f"Current broken format: {current_format}") + print( + "This format causes MCP validation errors because it lacks timezone information" + )