From 94340e2bb175233fce5277f0e8327624a9e4116b Mon Sep 17 00:00:00 2001 From: Daniel Bauer Date: Fri, 4 Jul 2025 09:30:38 +0200 Subject: [PATCH] feat: add search tool for digital object discovery Implement MCP tool to search Cordra repository using Lucene/Solr syntax. Enables AI assistants to discover and filter digital objects by content, type, and other metadata fields. Features: - Lucene/Solr compatible query syntax support - Optional type filtering (e.g., Person, Document) - Configurable result limits with sensible defaults - Comprehensive error handling and validation - JSON formatted results with proper indentation Technical changes: - Enhanced CordraClient.find() with type and limit parameters - Added search_objects MCP tool with detailed annotations - Removed unused tools module in favor of decorator approach - Added 12 comprehensive unit tests covering all scenarios --- src/cordra_mcp/client.py | 19 +++- src/cordra_mcp/server.py | 43 +++++++++ src/cordra_mcp/tools/__init__.py | 0 tests/test_client.py | 64 +++++++++++++ tests/test_server.py | 152 ++++++++++++++++++++++++++++++- 5 files changed, 273 insertions(+), 5 deletions(-) delete mode 100644 src/cordra_mcp/tools/__init__.py diff --git a/src/cordra_mcp/client.py b/src/cordra_mcp/client.py index 694792d..ff00512 100644 --- a/src/cordra_mcp/client.py +++ b/src/cordra_mcp/client.py @@ -129,11 +129,13 @@ class CordraClient: f"Failed to retrieve object {object_id}: {e}" ) from e - async def find(self, query: str) -> list[dict[str, Any]]: + async def find(self, query: str, object_type: str | None = None, limit: int | None = None) -> list[dict[str, Any]]: """Find objects using a Cordra query. Args: query: The query string to search for objects + object_type: Optional filter by object type + limit: Optional limit on number of results Returns: List of objects matching the query as dictionaries @@ -143,15 +145,24 @@ class CordraClient: CordraAuthenticationError: If authentication fails CordraClientError: For other API errors """ + # Construct the final query with type filter if specified + final_query = query + if object_type: + final_query = f"type:{object_type} AND ({query})" + url = f"{self.config.base_url}/search" - params = {"query": query} + params = {"query": final_query} + + # Add pageSize if limit is specified + if limit is not None: + params["pageSize"] = str(limit) try: response = self.session.get(url, params=params, timeout=self.config.timeout) if not response.ok: self._handle_http_error( - response, f"Failed to search with query '{query}'" + response, f"Failed to search with query '{final_query}'" ) search_result = response.json() @@ -164,7 +175,7 @@ class CordraClient: except requests.RequestException as e: raise CordraClientError( - f"Failed to search with query '{query}': {e}" + f"Failed to search with query '{final_query}': {e}" ) from e async def get_schema(self, schema_name: str) -> DigitalObject: diff --git a/src/cordra_mcp/server.py b/src/cordra_mcp/server.py index db44bc8..4c4998a 100644 --- a/src/cordra_mcp/server.py +++ b/src/cordra_mcp/server.py @@ -25,6 +25,49 @@ cordra_client = CordraClient(config) logger = logging.getLogger(__name__) +@mcp.tool( + name="search_objects", + title="Search Cordra Objects", + description="""Search for digital objects in the Cordra repository using Lucene/Solr query syntax. + +Examples: +- /title:report - Find objects with 'report' in title +- /author:smith - Find objects by author Smith +- /name:John AND type:Person - Complex queries + +Returns a JSON list of matching objects with their full metadata.""" +) +async def search_objects( + query: str, + type: str | None = None, + limit: int | None = None, +) -> str: + """Search for digital objects in the Cordra repository. + + Args: + query: The search query string (Lucene/Solr compatible). Examples: + - "/title:report" - Find objects with "report" in title + - "/author:smith" - Find objects by author Smith + - "/name:John AND type:Person" - Complex queries + type: Optional filter by object type (e.g., "Person", "Document", "Project") + limit: Optional limit on number of results (default: config max_search_results) + + Returns: + JSON string containing list of matching objects with their full metadata + """ + try: + effective_limit = limit if limit is not None else config.max_search_results + results = await cordra_client.find(query, object_type=type, limit=effective_limit) + return json.dumps(results, indent=2) + + 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"Search failed: {e}") from e + + @mcp.resource( "cordra://objects/{prefix}/{suffix}", name="cordra-object", diff --git a/src/cordra_mcp/tools/__init__.py b/src/cordra_mcp/tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_client.py b/tests/test_client.py index 8d17cb0..4274821 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -232,6 +232,70 @@ class TestCordraClient: assert "Failed to search with query 'invalid:query'" in str(exc_info.value) assert "Search failed" in str(exc_info.value) + @patch("cordra_mcp.client.requests.Session.get") + async def test_find_with_type_filter(self, mock_get, client): + """Test find operation with type filter constructs correct query.""" + mock_response = mock_get.return_value + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + mock_response.ok = True + + await client.find("name:John", object_type="Person") + + mock_get.assert_called_once_with( + "https://test.example.com/search", + params={"query": "type:Person AND (name:John)"}, + timeout=30, + ) + + @patch("cordra_mcp.client.requests.Session.get") + async def test_find_with_limit(self, mock_get, client): + """Test find operation with limit adds pageSize parameter.""" + mock_response = mock_get.return_value + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + mock_response.ok = True + + await client.find("type:Test", limit=50) + + mock_get.assert_called_once_with( + "https://test.example.com/search", + params={"query": "type:Test", "pageSize": "50"}, + timeout=30, + ) + + @patch("cordra_mcp.client.requests.Session.get") + async def test_find_with_type_and_limit(self, mock_get, client): + """Test find operation with both type filter and limit.""" + mock_response = mock_get.return_value + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + mock_response.ok = True + + await client.find("title:Report", object_type="Document", limit=25) + + mock_get.assert_called_once_with( + "https://test.example.com/search", + params={"query": "type:Document AND (title:Report)", "pageSize": "25"}, + timeout=30, + ) + + @patch("cordra_mcp.client.requests.Session.get") + async def test_find_no_optional_params(self, mock_get, client): + """Test find operation with no optional parameters.""" + mock_response = mock_get.return_value + mock_response.status_code = 200 + mock_response.json.return_value = {"results": []} + mock_response.ok = True + + await client.find("content:test") + + mock_get.assert_called_once_with( + "https://test.example.com/search", + params={"query": "content:test"}, + timeout=30, + ) + class TestCordraConfig: """Test the CordraConfig class.""" diff --git a/tests/test_server.py b/tests/test_server.py index b3104f7..fc3a1df 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch import pytest from cordra_mcp.client import CordraClientError, CordraNotFoundError, DigitalObject -from cordra_mcp.server import get_cordra_object +from cordra_mcp.server import get_cordra_object, search_objects @pytest.fixture @@ -230,3 +230,153 @@ class TestSchemaResourceFunctions: await register_schema_resources() # Should complete without raising mock_client.find.assert_called_once_with("type:Schema") + + +class TestSearchObjects: + """Test the search_objects tool.""" + + @patch('cordra_mcp.server.cordra_client') + @patch('cordra_mcp.server.config') + async def test_search_objects_success(self, mock_config, mock_client): + """Test successful object search.""" + mock_config.max_search_results = 1000 + mock_results = [ + {"id": "people/john-doe", "type": "Person", "content": {"name": "John Doe"}}, + {"id": "people/jane-smith", "type": "Person", "content": {"name": "Jane Smith"}}, + ] + mock_client.find = AsyncMock(return_value=mock_results) + + result = await search_objects("name:John") + + # Verify the result is valid JSON + parsed_result = json.loads(result) + assert len(parsed_result) == 2 + assert parsed_result[0]["id"] == "people/john-doe" + assert parsed_result[1]["id"] == "people/jane-smith" + + # Verify the client was called with correct parameters + mock_client.find.assert_called_once_with("name:John", object_type=None, limit=1000) + + @patch('cordra_mcp.server.cordra_client') + @patch('cordra_mcp.server.config') + async def test_search_objects_with_type_filter(self, mock_config, mock_client): + """Test object search with type filter.""" + mock_config.max_search_results = 1000 + mock_results = [ + {"id": "people/john-doe", "type": "Person", "content": {"name": "John Doe"}}, + ] + mock_client.find = AsyncMock(return_value=mock_results) + + result = await search_objects("name:John", type="Person") + + # Verify the result is valid JSON + parsed_result = json.loads(result) + assert len(parsed_result) == 1 + assert parsed_result[0]["type"] == "Person" + + # Verify the client was called with type filter + mock_client.find.assert_called_once_with("name:John", object_type="Person", limit=1000) + + @patch('cordra_mcp.server.cordra_client') + @patch('cordra_mcp.server.config') + async def test_search_objects_with_limit(self, mock_config, mock_client): + """Test object search with custom limit.""" + mock_config.max_search_results = 1000 + mock_results = [ + {"id": "people/john-doe", "type": "Person", "content": {"name": "John Doe"}}, + ] + mock_client.find = AsyncMock(return_value=mock_results) + + result = await search_objects("name:John", limit=50) + + # Verify the result is valid JSON + parsed_result = json.loads(result) + assert len(parsed_result) == 1 + + # Verify the client was called with custom limit + mock_client.find.assert_called_once_with("name:John", object_type=None, limit=50) + + @patch('cordra_mcp.server.cordra_client') + @patch('cordra_mcp.server.config') + async def test_search_objects_with_all_parameters(self, mock_config, mock_client): + """Test object search with all parameters.""" + mock_config.max_search_results = 1000 + mock_results = [ + {"id": "documents/report-123", "type": "Document", "content": {"title": "Report"}}, + ] + mock_client.find = AsyncMock(return_value=mock_results) + + result = await search_objects("title:Report", type="Document", limit=25) + + # Verify the result is valid JSON + parsed_result = json.loads(result) + assert len(parsed_result) == 1 + assert parsed_result[0]["type"] == "Document" + + # Verify the client was called with all parameters + mock_client.find.assert_called_once_with("title:Report", object_type="Document", limit=25) + + @patch('cordra_mcp.server.cordra_client') + @patch('cordra_mcp.server.config') + async def test_search_objects_empty_results(self, mock_config, mock_client): + """Test object search with no results.""" + mock_config.max_search_results = 1000 + mock_client.find = AsyncMock(return_value=[]) + + result = await search_objects("nonexistent:data") + + # Verify the result is valid JSON with empty array + parsed_result = json.loads(result) + assert parsed_result == [] + + mock_client.find.assert_called_once_with("nonexistent:data", object_type=None, limit=1000) + + @patch('cordra_mcp.server.cordra_client') + @patch('cordra_mcp.server.config') + async def test_search_objects_client_error(self, mock_config, mock_client): + """Test object search with client error.""" + mock_config.max_search_results = 1000 + mock_client.find = AsyncMock(side_effect=CordraClientError("Search failed")) + + with pytest.raises(RuntimeError) as exc_info: + await search_objects("test:query") + + assert "Search failed:" in str(exc_info.value) + mock_client.find.assert_called_once_with("test:query", object_type=None, limit=1000) + + @patch('cordra_mcp.server.cordra_client') + @patch('cordra_mcp.server.config') + async def test_search_objects_value_error(self, mock_config, mock_client): + """Test object search with value error.""" + mock_config.max_search_results = 1000 + mock_client.find = AsyncMock(side_effect=ValueError("Invalid query")) + + with pytest.raises(RuntimeError) as exc_info: + await search_objects("invalid:query") + + assert "Invalid search parameters:" in str(exc_info.value) + mock_client.find.assert_called_once_with("invalid:query", object_type=None, limit=1000) + + @patch('cordra_mcp.server.cordra_client') + @patch('cordra_mcp.server.config') + async def test_search_objects_json_formatting(self, mock_config, mock_client): + """Test that search results are properly formatted as JSON.""" + mock_config.max_search_results = 1000 + mock_results = [ + {"id": "test/object", "type": "Test", "content": {"data": "value"}}, + ] + mock_client.find = AsyncMock(return_value=mock_results) + + result = await search_objects("test:query") + + # Verify it's valid JSON with proper indentation + parsed_result = json.loads(result) + assert isinstance(parsed_result, list) + + # Check that the result contains indentation (pretty-printed) + assert " " in result # Should have 2-space indentation + + # Verify the content is correctly formatted + assert parsed_result[0]["id"] == "test/object" + assert parsed_result[0]["type"] == "Test" + assert parsed_result[0]["content"]["data"] == "value"