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
This commit is contained in:
Daniel Bauer
2025-07-04 09:30:38 +02:00
parent 46e1a8057f
commit 94340e2bb1
5 changed files with 273 additions and 5 deletions

View File

@@ -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"