diff --git a/README.md b/README.md index 311092f..a019212 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ ensuring safe exploration without risk of data modification or corruption. - `type` - Optional filter by object type - `limit` - Number of results per page (default: 1) - `page_num` - Page number to retrieve, 0-based (default: 0) +- `count_objects` - Count the total number of objects matching a query. + - `query` - Lucene/Solr compatible search query + - `type` - Optional filter by object type ## Configuration diff --git a/src/cordra_mcp/server.py b/src/cordra_mcp/server.py index efbbc20..501f95a 100644 --- a/src/cordra_mcp/server.py +++ b/src/cordra_mcp/server.py @@ -87,6 +87,51 @@ async def search_objects( raise RuntimeError(f"Search failed: {e}") from e +@mcp.tool( + name="count_objects", + title="Count Cordra Objects matching a query", + description="""Count the total number of digital objects matching a search query. + +Examples: +- /title:report - Count objects with 'report' in title +- /author:smith - Count objects by author Smith +- /name:John AND type:Person - Complex queries + +Returns the count of objects as integer. +""", +) +async def count_objects( + query: str, + type: str | None = None, +) -> str: + """Count digital objects in the Cordra repository matching a search query. + + Args: + query: The search query string (Lucene/Solr compatible). Examples: + - "/title:report" - Count objects with "report" in title + - "/author:smith" - Count objects by author Smith + - "/name:John AND type:Person" - Complex queries + type: Optional filter by object type (e.g., "Person", "Document", "Project") + + Returns: + integer with the number of objects matching the criteria. + """ + try: + # Use page_size=1 to get minimal data, we only need the total count + search_result = await cordra_client.find( + query, object_type=type, page_size=1, page_num=0 + ) + + total_size: int = search_result["total_size"] + return str(total_size) + except ValueError as e: + raise RuntimeError(f"Invalid search parameters: {e}") from e + except CordraAuthenticationError as e: + raise RuntimeError(f"Authentication failed: {e}") from e + except CordraClientError as e: + raise RuntimeError(f"Count failed: {e}") from e + + @mcp.resource( "cordra://objects/{prefix}/{suffix}", name="cordra-object", @@ -220,7 +265,6 @@ async def register_schema_resources() -> None: logger.warning(f"Failed to register schema resources: {e}") - async def initialize_server() -> None: """Initialize server resources before starting.""" logger.info("Initializing Cordra MCP server...") diff --git a/tests/test_server.py b/tests/test_server.py index 1603dd0..4d4a90c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -604,3 +604,110 @@ class TestGetCordraDesign: assert "type" in parsed_result assert "content" in parsed_result assert "metadata" in parsed_result + + +class TestCountObjects: + """Test the count_objects tool.""" + + @patch("cordra_mcp.server.cordra_client") + async def test_count_objects_success(self, mock_client): + """Test successful object count.""" + mock_search_result = { + "results": [{"id": "people/john-doe", "type": "Person"}], + "total_size": 42, + "page_num": 0, + "page_size": 1, + } + mock_client.find = AsyncMock(return_value=mock_search_result) + + result = await count_objects("name:John") + + # Verify the result is a string representation of the count + assert result == "42" + + # Verify the client was called with correct parameters + mock_client.find.assert_called_once_with( + "name:John", object_type=None, page_size=1, page_num=0 + ) + + @patch("cordra_mcp.server.cordra_client") + async def test_count_objects_with_type_filter(self, mock_client): + """Test object count with type filter.""" + mock_search_result = { + "results": [{"id": "people/john-doe", "type": "Person"}], + "total_size": 15, + "page_num": 0, + "page_size": 1, + } + mock_client.find = AsyncMock(return_value=mock_search_result) + + result = await count_objects("name:John", type="Person") + + # Verify the result is a string representation of the count + assert result == "15" + + # Verify the client was called with type filter + mock_client.find.assert_called_once_with( + "name:John", object_type="Person", page_size=1, page_num=0 + ) + + @patch("cordra_mcp.server.cordra_client") + async def test_count_objects_zero_results(self, mock_client): + """Test object count with zero results.""" + mock_search_result = { + "results": [], + "total_size": 0, + "page_num": 0, + "page_size": 1, + } + mock_client.find = AsyncMock(return_value=mock_search_result) + + result = await count_objects("nonexistent:data") + + # Verify the result is "0" + assert result == "0" + + mock_client.find.assert_called_once_with( + "nonexistent:data", object_type=None, page_size=1, page_num=0 + ) + + @patch("cordra_mcp.server.cordra_client") + async def test_count_objects_client_error(self, mock_client): + """Test object count with client error.""" + mock_client.find = AsyncMock(side_effect=CordraClientError("Search failed")) + + with pytest.raises(RuntimeError) as exc_info: + await count_objects("test:query") + + assert "Count failed:" in str(exc_info.value) + mock_client.find.assert_called_once_with( + "test:query", object_type=None, page_size=1, page_num=0 + ) + + @patch("cordra_mcp.server.cordra_client") + async def test_count_objects_value_error(self, mock_client): + """Test object count with value error.""" + mock_client.find = AsyncMock(side_effect=ValueError("Invalid query")) + + with pytest.raises(RuntimeError) as exc_info: + await count_objects("invalid:query") + + assert "Invalid search parameters:" in str(exc_info.value) + mock_client.find.assert_called_once_with( + "invalid:query", object_type=None, page_size=1, page_num=0 + ) + + @patch("cordra_mcp.server.cordra_client") + async def test_count_objects_authentication_error(self, mock_client): + """Test object count with authentication error.""" + mock_client.find = AsyncMock( + side_effect=CordraAuthenticationError("Authentication failed") + ) + + with pytest.raises(RuntimeError) as exc_info: + await count_objects("test:query") + + assert "Authentication failed:" in str(exc_info.value) + mock_client.find.assert_called_once_with( + "test:query", object_type=None, page_size=1, page_num=0 + )