diff --git a/src/mcp_cordra/server.py b/src/mcp_cordra/server.py index 80f4249..192bd23 100644 --- a/src/mcp_cordra/server.py +++ b/src/mcp_cordra/server.py @@ -1,12 +1,46 @@ """MCP server for Cordra digital object repository.""" import asyncio +import json from mcp.server.fastmcp import FastMCP +from .client import CordraClient, CordraClientError, CordraNotFoundError +from .config import CordraConfig + # Initialize the MCP server mcp = FastMCP("mcp-cordra") +# Initialize Cordra client at startup +config = CordraConfig() +cordra_client = CordraClient(config) + + +@mcp.resource("cordra://objects/{prefix}/{suffix}", name="cordra-object", description="Retrieve a Cordra digital object by ID") +async def get_cordra_object(prefix: str, suffix: str) -> str: + """Retrieve a Cordra digital object by its ID. + + Args: + prefix: The prefix part of the object ID (e.g., 'wildlive') + suffix: The suffix part of the object ID (e.g., '7a4b7b65f8bb155ad36d') + + Returns: + JSON representation of the digital object + + Raises: + RuntimeError: If the object is not found or there's an API error + """ + object_id = f"{prefix}/{suffix}" + try: + digital_object = await cordra_client.get_object(object_id) + object_dict = digital_object.model_dump() + return json.dumps(object_dict, indent=2) + + except CordraNotFoundError as e: + raise RuntimeError(f"Object not found: {object_id}") from e + except CordraClientError as e: + raise RuntimeError(f"Failed to retrieve object {object_id}: {e}") from e + @mcp.tool() async def ping() -> str: diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..4b9efe6 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,145 @@ +"""Unit tests for the MCP server.""" + +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from mcp_cordra.client import CordraClientError, CordraNotFoundError, DigitalObject +from mcp_cordra.server import get_cordra_object + + +@pytest.fixture +def sample_digital_object(): + """Create a sample DigitalObject for testing.""" + return DigitalObject( + id="people/john-doe-123", + type="Person", + content={ + "name": "John Doe", + "birthday": "1990-05-15", + "email": "john.doe@example.com", + }, + metadata={"created": "2023-01-01", "modified": "2023-06-15"}, + acl={"read": ["public"], "write": ["admin"]}, + payloads=[ + { + "name": "profile_photo", + "filename": "john_doe_profile.jpg", + "size": 125440, + "mediaType": "image/jpeg" + } + ] + ) + + +class TestGetCordraObject: + """Test the get_cordra_object resource handler.""" + + @patch('mcp_cordra.server.cordra_client') + async def test_get_object_success(self, mock_client, sample_digital_object): + """Test successful object retrieval.""" + mock_client.get_object = AsyncMock(return_value=sample_digital_object) + + result = await get_cordra_object("people", "john-doe-123") + + # Verify the result is valid JSON + parsed_result = json.loads(result) + assert parsed_result["id"] == "people/john-doe-123" + assert parsed_result["type"] == "Person" + assert parsed_result["content"]["name"] == "John Doe" + assert parsed_result["content"]["birthday"] == "1990-05-15" + assert parsed_result["metadata"]["created"] == "2023-01-01" + assert len(parsed_result["payloads"]) == 1 + assert parsed_result["payloads"][0]["name"] == "profile_photo" + + # Verify the client was called with the correct object ID + mock_client.get_object.assert_called_once_with("people/john-doe-123") + + @patch('mcp_cordra.server.cordra_client') + async def test_get_object_not_found(self, mock_client): + """Test object not found exception.""" + mock_client.get_object = AsyncMock( + side_effect=CordraNotFoundError("Object not found: people/nonexistent") + ) + + with pytest.raises(RuntimeError) as exc_info: + await get_cordra_object("people", "nonexistent") + + assert "Object not found: people/nonexistent" in str(exc_info.value) + mock_client.get_object.assert_called_once_with("people/nonexistent") + + @patch('mcp_cordra.server.cordra_client') + async def test_get_object_client_error(self, mock_client): + """Test general client error handling.""" + mock_client.get_object = AsyncMock( + side_effect=CordraClientError("Connection failed") + ) + + with pytest.raises(RuntimeError) as exc_info: + await get_cordra_object("people", "john-doe-123") + + assert "Failed to retrieve object people/john-doe-123" in str(exc_info.value) + assert "Connection failed" in str(exc_info.value) + mock_client.get_object.assert_called_once_with("people/john-doe-123") + + @patch('mcp_cordra.server.cordra_client') + async def test_object_id_construction(self, mock_client, sample_digital_object): + """Test that object ID is correctly constructed from prefix and suffix.""" + mock_client.get_object = AsyncMock(return_value=sample_digital_object) + + # Test various prefix/suffix combinations + test_cases = [ + ("people", "john-doe-123", "people/john-doe-123"), + ("documents", "report-2023", "documents/report-2023"), + ("items", "item_with_underscores", "items/item_with_underscores"), + ] + + for prefix, suffix, expected_id in test_cases: + await get_cordra_object(prefix, suffix) + mock_client.get_object.assert_called_with(expected_id) + + @patch('mcp_cordra.server.cordra_client') + async def test_json_formatting(self, mock_client, sample_digital_object): + """Test that the returned JSON is properly formatted.""" + mock_client.get_object = AsyncMock(return_value=sample_digital_object) + + result = await get_cordra_object("people", "john-doe-123") + + # Verify it's valid JSON with proper indentation + parsed_result = json.loads(result) + assert isinstance(parsed_result, dict) + + # Check that the result contains indentation (pretty-printed) + assert " " in result # Should have 2-space indentation + + # Verify all expected fields are present + assert "id" in parsed_result + assert "type" in parsed_result + assert "content" in parsed_result + assert "metadata" in parsed_result + assert "acl" in parsed_result + assert "payloads" in parsed_result + + @patch('mcp_cordra.server.cordra_client') + async def test_minimal_object(self, mock_client): + """Test handling of object with minimal data.""" + minimal_object = DigitalObject( + id="test/minimal", + type="", + content={"id": "test/minimal"}, + metadata=None, + acl=None, + payloads=None + ) + mock_client.get_object = AsyncMock(return_value=minimal_object) + + result = await get_cordra_object("test", "minimal") + parsed_result = json.loads(result) + + assert parsed_result["id"] == "test/minimal" + assert parsed_result["type"] == "" + assert parsed_result["content"]["id"] == "test/minimal" + assert parsed_result["metadata"] is None + assert parsed_result["acl"] is None + assert parsed_result["payloads"] is None \ No newline at end of file