mirror of
https://github.com/dnlbauer/cordra-mcp.git
synced 2026-09-10 13:45:30 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user