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

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

View File

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

View File

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

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"