16 Commits

Author SHA1 Message Date
Daniel Bauer
f6bfb14b29 feat: bump version 2025-12-02 13:28:23 +01:00
Daniel Bauer
9c9594367c feat: add log level configuration and validation 2025-12-02 13:26:20 +01:00
Daniel Bauer
c75629e3ff feat: bump version 2025-07-09 11:10:02 +02:00
Daniel Bauer
f35305568c fix: typing errors in tests 2025-07-09 11:09:17 +02:00
Daniel Bauer
663e6c3064 chore: reformat code 2025-07-09 11:01:32 +02:00
Daniel Bauer
e01d732014 feat: add tool to count objects 2025-07-09 11:01:17 +02:00
Daniel Bauer
3dc4e664af feat: change search_objects to return only object IDs
- Changed search_objects function to return only object IDs instead of full objects
- Updated response format to include object_ids, total_count, page_num, and page_size
- Increased default page size from 1 to 25 for better performance
- Updated documentation to reflect new return format

This improves performance by reducing response size and encourages proper
use of cordra:// resources for full object retrieval.
2025-07-09 11:00:00 +02:00
Daniel Bauer
1eb7285132 chore: update lockfile 2025-07-09 09:53:44 +02:00
Daniel Bauer
906e387192 ci: assert lock file has been updated 2025-07-09 09:53:19 +02:00
Daniel Bauer
0a03fec5b8 fix: synchronize version 2025-07-09 09:49:17 +02:00
Daniel Bauer
e541a27d9f ci: validate version and only publish when tagged 2025-07-09 09:46:49 +02:00
Daniel Bauer
20b2f1b219 doc: add example gif 2025-07-08 13:58:13 +02:00
Daniel Bauer
38e1118167 chore: update lock file for version 1.1.0
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-05 12:53:39 +02:00
Daniel Bauer
d04bfd2f90 Merge branch 'main' of github.com:dnlbauer/cordra-mcp 2025-07-05 12:48:18 +02:00
Daniel Bauer
2499654bf4 bump: version 1.1.0
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-05 12:48:02 +02:00
Daniel Bauer
3e8205c098 Merge pull request #1 from dnlbauer/fix-schema-pagination
Fix schema pagination and enhance search functionality
2025-07-05 12:43:55 +02:00
10 changed files with 450 additions and 156 deletions

View File

@@ -3,6 +3,7 @@ name: CI
on: on:
push: push:
branches: [ main ] branches: [ main ]
tags: [ 'v*' ]
pull_request: pull_request:
branches: [ main ] branches: [ main ]
@@ -25,7 +26,7 @@ jobs:
run: uv python install ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies
run: uv sync --dev run: uv sync --dev --locked
- name: Run ruff linting - name: Run ruff linting
run: uv run ruff check run: uv run ruff check
@@ -36,13 +37,33 @@ jobs:
- name: Run tests - name: Run tests
run: uv run pytest run: uv run pytest
- name: Validate version consistency
# Ensure that the version is consistent across files and matches the tag if applicable
run: |
PYPROJECT_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
INIT_VERSION=$(python3 -c "import re; print(re.search(r'__version__ = \"(.+?)\"', open('src/cordra_mcp/__init__.py').read()).group(1))")
if [ "$PYPROJECT_VERSION" != "$INIT_VERSION" ]; then
echo "Version mismatch: pyproject.toml=$PYPROJECT_VERSION, __init__.py=$INIT_VERSION"
exit 1
fi
# Additionally check tag version if this is a tag build
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
TAG_VERSION=${GITHUB_REF#refs/tags/v}
if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
echo "Version mismatch: tag=$TAG_VERSION, pyproject.toml=$PYPROJECT_VERSION"
exit 1
fi
fi
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test needs: test
permissions: permissions:
contents: read contents: read
packages: write packages: write
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -63,7 +84,7 @@ jobs:
with: with:
images: ghcr.io/${{ github.repository_owner }}/cordra-mcp images: ghcr.io/${{ github.repository_owner }}/cordra-mcp
tags: | tags: |
type=ref,event=branch type=ref,event=tag
type=raw,value=latest type=raw,value=latest
- name: Build and push Docker image - name: Build and push Docker image
@@ -81,7 +102,7 @@ jobs:
needs: test needs: test
permissions: permissions:
id-token: write id-token: write
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: startsWith(github.ref, 'refs/tags/v')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -7,6 +7,8 @@ access to explore and understand Cordra repositories.
This allows AI systems to quickly understand the data model and schema structure This allows AI systems to quickly understand the data model and schema structure
of a Cordra repository and to explore digital objects and their relationships. of a Cordra repository and to explore digital objects and their relationships.
![Example](example.gif)
## Features ## Features
- **Read-Only Access**: All operations are strictly read-only, - **Read-Only Access**: All operations are strictly read-only,
@@ -29,16 +31,20 @@ ensuring safe exploration without risk of data modification or corruption.
- `type` - Optional filter by object type - `type` - Optional filter by object type
- `limit` - Number of results per page (default: 1) - `limit` - Number of results per page (default: 1)
- `page_num` - Page number to retrieve, 0-based (default: 0) - `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 ## Configuration
The MCP server can be configured using environment variables with the `CORDRA_` prefix: The MCP server can be configured using environment variables:
- `CORDRA_BASE_URL` - Cordra server URL (default: `https://localhost:8443`) - `CORDRA_BASE_URL` - Cordra server URL (default: `https://localhost:8443`)
- `CORDRA_USERNAME` - Username for authentication (optional) - `CORDRA_USERNAME` - Username for authentication (optional)
- `CORDRA_PASSWORD` - Password for authentication (optional) - `CORDRA_PASSWORD` - Password for authentication (optional)
- `CORDRA_VERIFY_SSL` - SSL certificate verification (default: `true`) - `CORDRA_VERIFY_SSL` - SSL certificate verification (default: `true`)
- `CORDRA_TIMEOUT` - Request timeout in seconds (default: `30`) - `CORDRA_TIMEOUT` - Request timeout in seconds (default: `30`)
- `LOGLEVEL` - Logging level (default: `INFO`, options: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`)
## Usage ## Usage

BIN
example.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "cordra-mcp" name = "cordra-mcp"
version = "1.0.0" version = "1.2.1"
description = "MCP server for Cordra digital object repository" description = "MCP server for Cordra digital object repository"
authors = [ authors = [
{name = "Daniel Bauer", email = "github@dbauer.me"}, {name = "Daniel Bauer", email = "github@dbauer.me"},
@@ -61,6 +61,7 @@ ignore = ["E501"]
python_version = "3.11" python_version = "3.11"
strict = true strict = true
warn_return_any = true warn_return_any = true
files = ["src", "tests"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["cordra.*"] module = ["cordra.*"]

View File

@@ -1,3 +1,3 @@
"""MCP server for Cordra digital object repository.""" """MCP server for Cordra digital object repository."""
__version__ = "0.1.0" __version__ = "1.2.1"

View File

@@ -1,6 +1,6 @@
"""Configuration settings for the MCP Cordra server.""" """Configuration settings for the MCP Cordra server."""
from pydantic import Field from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -26,3 +26,21 @@ class CordraConfig(BaseSettings):
default=True, description="Whether to verify SSL certificates" default=True, description="Whether to verify SSL certificates"
) )
timeout: int = Field(default=30, description="Request timeout in seconds") timeout: int = Field(default=30, description="Request timeout in seconds")
log_level: str = Field(
default="INFO",
description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
validation_alias="LOGLEVEL",
)
@field_validator("log_level", mode="before")
@classmethod
def validate_log_level(cls, v: str) -> str:
"""Validate that log_level is a standard logging level."""
level_str = str(v).upper().strip()
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if level_str not in valid_levels:
raise ValueError(
f"Invalid log level '{v}'. Must be one of: {', '.join(valid_levels)}"
)
return level_str

View File

@@ -23,6 +23,7 @@ config = CordraConfig()
cordra_client = CordraClient(config) cordra_client = CordraClient(config)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(config.log_level)
@mcp.tool( @mcp.tool(
@@ -37,15 +38,21 @@ Examples:
Pagination: Pagination:
- Results are paginated with 0-based page numbering - Results are paginated with 0-based page numbering
- Use 'limit' to control page size (default: 1) - Use 'limit' to control page size (default: 25)
- Use 'page_num' to specify which page to retrieve (default: 0) - Use 'page_num' to specify which page to retrieve (default: 0)
Returns a JSON list of matching objects with their full metadata.""" Returns a JSON object containing:
- object_ids: List of object IDs that match the search
- total_count: Total number of objects matching the query
- page_num: Current page number
- page_size: Number of results per page
Use the cordra://objects/{prefix}/{suffix} resources to retrieve full object details.""",
) )
async def search_objects( async def search_objects(
query: str, query: str,
type: str | None = None, type: str | None = None,
limit: int = 1, limit: int = 25,
page_num: int = 0, page_num: int = 0,
) -> str: ) -> str:
"""Search for digital objects in the Cordra repository with pagination support. """Search for digital objects in the Cordra repository with pagination support.
@@ -56,16 +63,22 @@ async def search_objects(
- "/author:smith" - Find objects by author Smith - "/author:smith" - Find objects by author Smith
- "/name:John AND type:Person" - Complex queries - "/name:John AND type:Person" - Complex queries
type: Optional filter by object type (e.g., "Person", "Document", "Project") type: Optional filter by object type (e.g., "Person", "Document", "Project")
limit: Page size - number of results per page (default: 1) limit: Page size - number of results per page (default: 25)
page_num: Page number to retrieve, 0-based (default: 0 for first page) page_num: Page number to retrieve, 0-based (default: 0 for first page)
Returns: Returns:
JSON string containing list of matching objects with their full metadata JSON string containing object IDs and pagination info
""" """
try: try:
search_result = await cordra_client.find(query, object_type=type, page_size=limit, page_num=page_num) search_result = await cordra_client.find(
results = search_result["results"] query, object_type=type, page_size=limit, page_num=page_num
return json.dumps(results, indent=2) )
# Extract only the IDs from the results
search_result["results"] = [obj["id"] for obj in search_result["results"]]
# Rename for consistency with documentation
search_result["total_count"] = search_result.pop("total_size")
return json.dumps(search_result, indent=2)
except ValueError as e: except ValueError as e:
raise RuntimeError(f"Invalid search parameters: {e}") from e raise RuntimeError(f"Invalid search parameters: {e}") from e
@@ -75,6 +88,51 @@ async def search_objects(
raise RuntimeError(f"Search failed: {e}") from e 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( @mcp.resource(
"cordra://objects/{prefix}/{suffix}", "cordra://objects/{prefix}/{suffix}",
name="cordra-object", name="cordra-object",
@@ -168,7 +226,9 @@ async def register_schema_resources() -> None:
page_size = 20 page_size = 20
while True: while True:
search_result = await cordra_client.find("type:Schema", page_size=page_size, page_num=page_num) search_result = await cordra_client.find(
"type:Schema", page_size=page_size, page_num=page_num
)
schemas = search_result["results"] schemas = search_result["results"]
all_schemas.extend(schemas) all_schemas.extend(schemas)
@@ -206,7 +266,6 @@ async def register_schema_resources() -> None:
logger.warning(f"Failed to register schema resources: {e}") logger.warning(f"Failed to register schema resources: {e}")
async def initialize_server() -> None: async def initialize_server() -> None:
"""Initialize server resources before starting.""" """Initialize server resources before starting."""
logger.info("Initializing Cordra MCP server...") logger.info("Initializing Cordra MCP server...")

View File

@@ -1,5 +1,6 @@
"""Unit tests for the Cordra client.""" """Unit tests for the Cordra client."""
from typing import Any
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@@ -15,7 +16,7 @@ from cordra_mcp.config import CordraConfig
@pytest.fixture @pytest.fixture
def config(): def config() -> CordraConfig:
"""Create a test configuration.""" """Create a test configuration."""
return CordraConfig( return CordraConfig(
base_url="https://test.example.com", base_url="https://test.example.com",
@@ -26,13 +27,13 @@ def config():
@pytest.fixture @pytest.fixture
def client(config): def client(config: CordraConfig) -> CordraClient:
"""Create a test client.""" """Create a test client."""
return CordraClient(config) return CordraClient(config)
@pytest.fixture @pytest.fixture
def mock_cordra_object(): def mock_cordra_object() -> dict[str, Any]:
"""Create a mock CordraObject response (dictionary).""" """Create a mock CordraObject response (dictionary)."""
return { return {
"type": "TestType", "type": "TestType",
@@ -59,7 +60,7 @@ def mock_cordra_object():
class TestDigitalObject: class TestDigitalObject:
"""Test the DigitalObject model.""" """Test the DigitalObject model."""
def test_digital_object_creation(self): def test_digital_object_creation(self) -> None:
"""Test creating a DigitalObject.""" """Test creating a DigitalObject."""
obj = DigitalObject( obj = DigitalObject(
id="test/123", id="test/123",
@@ -89,7 +90,7 @@ class TestDigitalObject:
assert payload["size"] == 1024 assert payload["size"] == 1024
assert payload["filename"] == "file1.txt" assert payload["filename"] == "file1.txt"
def test_digital_object_optional_fields(self): def test_digital_object_optional_fields(self) -> None:
"""Test DigitalObject with only required fields.""" """Test DigitalObject with only required fields."""
obj = DigitalObject(id="test/123", type="TestType", content={"title": "Test"}) obj = DigitalObject(id="test/123", type="TestType", content={"title": "Test"})
@@ -104,13 +105,13 @@ class TestDigitalObject:
class TestCordraClient: class TestCordraClient:
"""Test the CordraClient class.""" """Test the CordraClient class."""
def test_client_initialization(self, config): def test_client_initialization(self, config: CordraConfig) -> None:
"""Test client initialization.""" """Test client initialization."""
client = CordraClient(config) client = CordraClient(config)
assert client.config == config assert client.config == config
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_get_object_success(self, mock_get, client, mock_cordra_object): async def test_get_object_success(self, mock_get: Any, client: CordraClient, mock_cordra_object: dict[str, Any]) -> None:
"""Test successful object retrieval.""" """Test successful object retrieval."""
mock_response = mock_get.return_value mock_response = mock_get.return_value
mock_response.status_code = 200 mock_response.status_code = 200
@@ -137,7 +138,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_get_object_not_found(self, mock_get, client): async def test_get_object_not_found(self, mock_get: Any, client: CordraClient) -> None:
"""Test object not found exception.""" """Test object not found exception."""
mock_response = mock_get.return_value mock_response = mock_get.return_value
mock_response.status_code = 404 mock_response.status_code = 404
@@ -149,7 +150,7 @@ class TestCordraClient:
assert "Resource not found" in str(exc_info.value) assert "Resource not found" in str(exc_info.value)
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_get_object_general_error(self, mock_get, client): async def test_get_object_general_error(self, mock_get: Any, client: CordraClient) -> None:
"""Test general error handling.""" """Test general error handling."""
from requests import RequestException from requests import RequestException
@@ -161,7 +162,7 @@ class TestCordraClient:
assert "Failed to retrieve object test/123" in str(exc_info.value) assert "Failed to retrieve object test/123" in str(exc_info.value)
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_find_success(self, mock_get, client): async def test_find_success(self, mock_get: Any, client: CordraClient) -> None:
"""Test successful find operation.""" """Test successful find operation."""
mock_response_data = { mock_response_data = {
"results": [ "results": [
@@ -196,7 +197,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_find_empty_results(self, mock_get, client): async def test_find_empty_results(self, mock_get: Any, client: CordraClient) -> None:
"""Test find with empty results.""" """Test find with empty results."""
mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 20} mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 20}
mock_response = mock_get.return_value mock_response = mock_get.return_value
@@ -218,7 +219,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_find_error(self, mock_get, client): async def test_find_error(self, mock_get: Any, client: CordraClient) -> None:
"""Test find error handling.""" """Test find error handling."""
from requests import RequestException from requests import RequestException
@@ -231,7 +232,7 @@ class TestCordraClient:
assert "Search failed" in str(exc_info.value) assert "Search failed" in str(exc_info.value)
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_find_with_type_filter(self, mock_get, client): async def test_find_with_type_filter(self, mock_get: Any, client: CordraClient) -> None:
"""Test find operation with type filter constructs correct query.""" """Test find operation with type filter constructs correct query."""
mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 20} mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 20}
mock_response = mock_get.return_value mock_response = mock_get.return_value
@@ -248,7 +249,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_find_with_page_size(self, mock_get, client): async def test_find_with_page_size(self, mock_get: Any, client: CordraClient) -> None:
"""Test find operation with custom page size.""" """Test find operation with custom page size."""
mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 50} mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 50}
mock_response = mock_get.return_value mock_response = mock_get.return_value
@@ -265,7 +266,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_find_with_type_and_page_size(self, mock_get, client): async def test_find_with_type_and_page_size(self, mock_get: Any, client: CordraClient) -> None:
"""Test find operation with both type filter and page size.""" """Test find operation with both type filter and page size."""
mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 25} mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 25}
mock_response = mock_get.return_value mock_response = mock_get.return_value
@@ -282,7 +283,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_find_default_params(self, mock_get, client): async def test_find_default_params(self, mock_get: Any, client: CordraClient) -> None:
"""Test find operation with default parameters.""" """Test find operation with default parameters."""
mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 20} mock_response_data = {"results": [], "size": 0, "pageNum": 0, "pageSize": 20}
mock_response = mock_get.return_value mock_response = mock_get.return_value
@@ -299,7 +300,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_find_with_page_num(self, mock_get, client): async def test_find_with_page_num(self, mock_get: Any, client: CordraClient) -> None:
"""Test find operation with specific page number.""" """Test find operation with specific page number."""
mock_response_data = {"results": [], "size": 100, "pageNum": 2, "pageSize": 20} mock_response_data = {"results": [], "size": 100, "pageNum": 2, "pageSize": 20}
mock_response = mock_get.return_value mock_response = mock_get.return_value
@@ -319,7 +320,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_find_with_custom_page_size_and_num(self, mock_get, client): async def test_find_with_custom_page_size_and_num(self, mock_get: Any, client: CordraClient) -> None:
"""Test find operation with custom page size and page number.""" """Test find operation with custom page size and page number."""
mock_response_data = {"results": [], "size": 500, "pageNum": 5, "pageSize": 10} mock_response_data = {"results": [], "size": 500, "pageNum": 5, "pageSize": 10}
mock_response = mock_get.return_value mock_response = mock_get.return_value
@@ -339,7 +340,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_get_design_success(self, mock_get, client): async def test_get_design_success(self, mock_get: Any, client: CordraClient) -> None:
"""Test successful design object retrieval.""" """Test successful design object retrieval."""
mock_design_data = { mock_design_data = {
"type": "CordraDesign", "type": "CordraDesign",
@@ -368,7 +369,7 @@ class TestCordraClient:
) )
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_get_design_authentication_error(self, mock_get, client): async def test_get_design_authentication_error(self, mock_get: Any, client: CordraClient) -> None:
"""Test design object retrieval with authentication error.""" """Test design object retrieval with authentication error."""
mock_response = mock_get.return_value mock_response = mock_get.return_value
mock_response.status_code = 403 mock_response.status_code = 403
@@ -380,7 +381,7 @@ class TestCordraClient:
assert "Authentication failed" in str(exc_info.value) assert "Authentication failed" in str(exc_info.value)
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_get_design_not_found(self, mock_get, client): async def test_get_design_not_found(self, mock_get: Any, client: CordraClient) -> None:
"""Test design object retrieval with not found error.""" """Test design object retrieval with not found error."""
mock_response = mock_get.return_value mock_response = mock_get.return_value
mock_response.status_code = 404 mock_response.status_code = 404
@@ -392,7 +393,7 @@ class TestCordraClient:
assert "Resource not found" in str(exc_info.value) assert "Resource not found" in str(exc_info.value)
@patch("cordra_mcp.client.requests.Session.get") @patch("cordra_mcp.client.requests.Session.get")
async def test_get_design_request_error(self, mock_get, client): async def test_get_design_request_error(self, mock_get: Any, client: CordraClient) -> None:
"""Test design object retrieval with request error.""" """Test design object retrieval with request error."""
from requests import RequestException from requests import RequestException
@@ -407,7 +408,7 @@ class TestCordraClient:
class TestCordraConfig: class TestCordraConfig:
"""Test the CordraConfig class.""" """Test the CordraConfig class."""
def test_default_config(self): def test_default_config(self) -> None:
"""Test default configuration values.""" """Test default configuration values."""
config = CordraConfig() config = CordraConfig()
assert config.base_url == "https://localhost:8443" assert config.base_url == "https://localhost:8443"

View File

@@ -1,6 +1,7 @@
"""Unit tests for the MCP server.""" """Unit tests for the MCP server."""
import json import json
from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
@@ -11,11 +12,16 @@ from cordra_mcp.client import (
CordraNotFoundError, CordraNotFoundError,
DigitalObject, DigitalObject,
) )
from cordra_mcp.server import get_cordra_design, get_cordra_object, search_objects from cordra_mcp.server import (
count_objects,
get_cordra_design,
get_cordra_object,
search_objects,
)
@pytest.fixture @pytest.fixture
def sample_digital_object(): def sample_digital_object() -> DigitalObject:
"""Create a sample DigitalObject for testing.""" """Create a sample DigitalObject for testing."""
return DigitalObject( return DigitalObject(
id="people/john-doe-123", id="people/john-doe-123",
@@ -32,17 +38,19 @@ def sample_digital_object():
"name": "profile_photo", "name": "profile_photo",
"filename": "john_doe_profile.jpg", "filename": "john_doe_profile.jpg",
"size": 125440, "size": 125440,
"mediaType": "image/jpeg" "mediaType": "image/jpeg",
} }
] ],
) )
class TestGetCordraObject: class TestGetCordraObject:
"""Test the get_cordra_object resource handler.""" """Test the get_cordra_object resource handler."""
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_get_object_success(self, mock_client, sample_digital_object): async def test_get_object_success(
self, mock_client: Any, sample_digital_object: DigitalObject
) -> None:
"""Test successful object retrieval.""" """Test successful object retrieval."""
mock_client.get_object = AsyncMock(return_value=sample_digital_object) mock_client.get_object = AsyncMock(return_value=sample_digital_object)
@@ -61,8 +69,8 @@ class TestGetCordraObject:
# Verify the client was called with the correct object ID # Verify the client was called with the correct object ID
mock_client.get_object.assert_called_once_with("people/john-doe-123") mock_client.get_object.assert_called_once_with("people/john-doe-123")
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_get_object_not_found(self, mock_client): async def test_get_object_not_found(self, mock_client: Any) -> None:
"""Test object not found exception.""" """Test object not found exception."""
mock_client.get_object = AsyncMock( mock_client.get_object = AsyncMock(
side_effect=CordraNotFoundError("Object not found: people/nonexistent") side_effect=CordraNotFoundError("Object not found: people/nonexistent")
@@ -74,8 +82,8 @@ class TestGetCordraObject:
assert "Object not found: people/nonexistent" in str(exc_info.value) assert "Object not found: people/nonexistent" in str(exc_info.value)
mock_client.get_object.assert_called_once_with("people/nonexistent") mock_client.get_object.assert_called_once_with("people/nonexistent")
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_get_object_client_error(self, mock_client): async def test_get_object_client_error(self, mock_client: Any) -> None:
"""Test general client error handling.""" """Test general client error handling."""
mock_client.get_object = AsyncMock( mock_client.get_object = AsyncMock(
side_effect=CordraClientError("Connection failed") side_effect=CordraClientError("Connection failed")
@@ -88,8 +96,10 @@ class TestGetCordraObject:
assert "Connection failed" in str(exc_info.value) assert "Connection failed" in str(exc_info.value)
mock_client.get_object.assert_called_once_with("people/john-doe-123") mock_client.get_object.assert_called_once_with("people/john-doe-123")
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_object_id_construction(self, mock_client, sample_digital_object): async def test_object_id_construction(
self, mock_client: Any, sample_digital_object: DigitalObject
) -> None:
"""Test that object ID is correctly constructed from prefix and suffix.""" """Test that object ID is correctly constructed from prefix and suffix."""
mock_client.get_object = AsyncMock(return_value=sample_digital_object) mock_client.get_object = AsyncMock(return_value=sample_digital_object)
@@ -104,8 +114,10 @@ class TestGetCordraObject:
await get_cordra_object(prefix, suffix) await get_cordra_object(prefix, suffix)
mock_client.get_object.assert_called_with(expected_id) mock_client.get_object.assert_called_with(expected_id)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_json_formatting(self, mock_client, sample_digital_object): async def test_json_formatting(
self, mock_client: Any, sample_digital_object: DigitalObject
) -> None:
"""Test that the returned JSON is properly formatted.""" """Test that the returned JSON is properly formatted."""
mock_client.get_object = AsyncMock(return_value=sample_digital_object) mock_client.get_object = AsyncMock(return_value=sample_digital_object)
@@ -126,8 +138,8 @@ class TestGetCordraObject:
assert "acl" in parsed_result assert "acl" in parsed_result
assert "payloads" in parsed_result assert "payloads" in parsed_result
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_minimal_object(self, mock_client): async def test_minimal_object(self, mock_client: Any) -> None:
"""Test handling of object with minimal data.""" """Test handling of object with minimal data."""
minimal_object = DigitalObject( minimal_object = DigitalObject(
id="test/minimal", id="test/minimal",
@@ -135,7 +147,7 @@ class TestGetCordraObject:
content={"id": "test/minimal"}, content={"id": "test/minimal"},
metadata=None, metadata=None,
acl=None, acl=None,
payloads=None payloads=None,
) )
mock_client.get_object = AsyncMock(return_value=minimal_object) mock_client.get_object = AsyncMock(return_value=minimal_object)
@@ -153,17 +165,18 @@ class TestGetCordraObject:
class TestSchemaResourceFunctions: class TestSchemaResourceFunctions:
"""Test the schema resource functions.""" """Test the schema resource functions."""
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_create_schema_resource_success(self, mock_client): async def test_create_schema_resource_success(self, mock_client: Any) -> None:
"""Test successful schema resource creation.""" """Test successful schema resource creation."""
mock_schema = DigitalObject( mock_schema = DigitalObject(
id="test/user-schema", id="test/user-schema",
type="Schema", type="Schema",
content={"name": "User", "type": "object", "properties": {}} content={"name": "User", "type": "object", "properties": {}},
) )
mock_client.get_schema = AsyncMock(return_value=mock_schema) mock_client.get_schema = AsyncMock(return_value=mock_schema)
from cordra_mcp.server import create_schema_resource from cordra_mcp.server import create_schema_resource
result = await create_schema_resource("User") result = await create_schema_resource("User")
# Verify the result is valid JSON # Verify the result is valid JSON
@@ -175,88 +188,105 @@ class TestSchemaResourceFunctions:
# Verify the client was called with correct schema name # Verify the client was called with correct schema name
mock_client.get_schema.assert_called_once_with("User") mock_client.get_schema.assert_called_once_with("User")
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_create_schema_resource_not_found(self, mock_client): async def test_create_schema_resource_not_found(self, mock_client: Any) -> None:
"""Test schema resource creation with schema not found.""" """Test schema resource creation with schema not found."""
mock_client.get_schema = AsyncMock(side_effect=CordraNotFoundError("Schema not found")) mock_client.get_schema = AsyncMock(
side_effect=CordraNotFoundError("Schema not found")
)
from cordra_mcp.server import create_schema_resource from cordra_mcp.server import create_schema_resource
with pytest.raises(RuntimeError) as exc_info: with pytest.raises(RuntimeError) as exc_info:
await create_schema_resource("NonExistent") await create_schema_resource("NonExistent")
assert "Schema not found: NonExistent" in str(exc_info.value) assert "Schema not found: NonExistent" in str(exc_info.value)
mock_client.get_schema.assert_called_once_with("NonExistent") mock_client.get_schema.assert_called_once_with("NonExistent")
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_register_schema_resources_success(self, mock_client): async def test_register_schema_resources_success(self, mock_client: Any) -> None:
"""Test successful schema resource registration.""" """Test successful schema resource registration."""
mock_search_result = { mock_search_result = {
"results": [ "results": [
{"content": {"name": "User"}, "id": "test/user-schema"}, {"content": {"name": "User"}, "id": "test/user-schema"},
{"content": {"name": "Project"}, "id": "test/project-schema"}, {"content": {"name": "Project"}, "id": "test/project-schema"},
{"content": {"name": "Document"}, "id": "test/doc-schema"} {"content": {"name": "Document"}, "id": "test/doc-schema"},
], ],
"total_size": 3, "total_size": 3,
"page_num": 0, "page_num": 0,
"page_size": 20 "page_size": 20,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
# Mock the mcp.add_resource method # Mock the mcp.add_resource method
with patch('cordra_mcp.server.mcp') as mock_mcp: with patch("cordra_mcp.server.mcp") as mock_mcp:
from cordra_mcp.server import register_schema_resources from cordra_mcp.server import register_schema_resources
await register_schema_resources() await register_schema_resources()
# Verify the client was called with correct query # Verify the client was called with correct query
mock_client.find.assert_called_once_with("type:Schema", page_size=20, page_num=0) mock_client.find.assert_called_once_with(
"type:Schema", page_size=20, page_num=0
)
# Verify add_resource was called for each schema # Verify add_resource was called for each schema
assert mock_mcp.add_resource.call_count == 3 assert mock_mcp.add_resource.call_count == 3
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_register_schema_resources_missing_name(self, mock_client): async def test_register_schema_resources_missing_name(
self, mock_client: Any
) -> None:
"""Test schema resource registration with objects missing name field.""" """Test schema resource registration with objects missing name field."""
mock_search_result = { mock_search_result = {
"results": [ "results": [
{"content": {"name": "User"}, "id": "test/user-schema"}, {"content": {"name": "User"}, "id": "test/user-schema"},
{"content": {}, "id": "test/no-name-schema"}, # Missing name field {"content": {}, "id": "test/no-name-schema"}, # Missing name field
{"content": {"name": "Project"}, "id": "test/project-schema"} {"content": {"name": "Project"}, "id": "test/project-schema"},
], ],
"total_size": 3, "total_size": 3,
"page_num": 0, "page_num": 0,
"page_size": 20 "page_size": 20,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
with patch('cordra_mcp.server.mcp') as mock_mcp: with patch("cordra_mcp.server.mcp") as mock_mcp:
from cordra_mcp.server import register_schema_resources from cordra_mcp.server import register_schema_resources
await register_schema_resources() await register_schema_resources()
# Only 2 schemas should be registered (those with name field) # Only 2 schemas should be registered (those with name field)
assert mock_mcp.add_resource.call_count == 2 assert mock_mcp.add_resource.call_count == 2
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_register_schema_resources_client_error(self, mock_client): async def test_register_schema_resources_client_error(
self, mock_client: Any
) -> None:
"""Test schema resource registration with client error.""" """Test schema resource registration with client error."""
mock_client.find = AsyncMock(side_effect=CordraClientError("Search failed")) mock_client.find = AsyncMock(side_effect=CordraClientError("Search failed"))
# Should not raise an exception, just log a warning # Should not raise an exception, just log a warning
from cordra_mcp.server import register_schema_resources from cordra_mcp.server import register_schema_resources
await register_schema_resources() # Should complete without raising await register_schema_resources() # Should complete without raising
mock_client.find.assert_called_once_with("type:Schema", page_size=20, page_num=0) mock_client.find.assert_called_once_with(
"type:Schema", page_size=20, page_num=0
)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_register_schema_resources_pagination(self, mock_client): async def test_register_schema_resources_pagination(self, mock_client: Any) -> None:
"""Test schema resource registration with pagination.""" """Test schema resource registration with pagination."""
# Mock multiple pages of results # Mock multiple pages of results
# First page with full 20 results (simulating more schemas) # First page with full 20 results (simulating more schemas)
first_page_schemas = [{"content": {"name": f"Schema{i}"}, "id": f"test/schema{i}"} for i in range(20)] first_page_schemas = [
{"content": {"name": f"Schema{i}"}, "id": f"test/schema{i}"}
for i in range(20)
]
first_page = { first_page = {
"results": first_page_schemas, "results": first_page_schemas,
"total_size": 25, "total_size": 25,
"page_num": 0, "page_num": 0,
"page_size": 20 "page_size": 20,
} }
# Second page with fewer results (indicating last page) # Second page with fewer results (indicating last page)
@@ -266,14 +296,15 @@ class TestSchemaResourceFunctions:
], ],
"total_size": 25, "total_size": 25,
"page_num": 1, "page_num": 1,
"page_size": 20 "page_size": 20,
} }
# Return first page, then second page (with fewer results indicating last page) # Return first page, then second page (with fewer results indicating last page)
mock_client.find = AsyncMock(side_effect=[first_page, second_page]) mock_client.find = AsyncMock(side_effect=[first_page, second_page])
with patch('cordra_mcp.server.mcp') as mock_mcp: with patch("cordra_mcp.server.mcp") as mock_mcp:
from cordra_mcp.server import register_schema_resources from cordra_mcp.server import register_schema_resources
await register_schema_resources() await register_schema_resources()
# Verify pagination calls # Verify pagination calls
@@ -288,17 +319,25 @@ class TestSchemaResourceFunctions:
class TestSearchObjects: class TestSearchObjects:
"""Test the search_objects tool.""" """Test the search_objects tool."""
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_success(self, mock_client): async def test_search_objects_success(self, mock_client: Any) -> None:
"""Test successful object search.""" """Test successful object search."""
mock_search_result = { mock_search_result = {
"results": [ "results": [
{"id": "people/john-doe", "type": "Person", "content": {"name": "John Doe"}}, {
{"id": "people/jane-smith", "type": "Person", "content": {"name": "Jane Smith"}}, "id": "people/john-doe",
"type": "Person",
"content": {"name": "John Doe"},
},
{
"id": "people/jane-smith",
"type": "Person",
"content": {"name": "Jane Smith"},
},
], ],
"total_size": 2, "total_size": 2,
"page_num": 0, "page_num": 0,
"page_size": 1000 "page_size": 1000,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
@@ -306,23 +345,30 @@ class TestSearchObjects:
# Verify the result is valid JSON # Verify the result is valid JSON
parsed_result = json.loads(result) parsed_result = json.loads(result)
assert len(parsed_result) == 2 assert parsed_result["results"] == ["people/john-doe", "people/jane-smith"]
assert parsed_result[0]["id"] == "people/john-doe" assert parsed_result["total_count"] == 2
assert parsed_result[1]["id"] == "people/jane-smith" assert parsed_result["page_num"] == 0
assert parsed_result["page_size"] == 1000
# Verify the client was called with correct parameters # 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) mock_client.find.assert_called_once_with(
"name:John", object_type=None, page_size=25, page_num=0
)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_with_type_filter(self, mock_client): async def test_search_objects_with_type_filter(self, mock_client: Any) -> None:
"""Test object search with type filter.""" """Test object search with type filter."""
mock_search_result = { mock_search_result = {
"results": [ "results": [
{"id": "people/john-doe", "type": "Person", "content": {"name": "John Doe"}}, {
"id": "people/john-doe",
"type": "Person",
"content": {"name": "John Doe"},
},
], ],
"total_size": 1, "total_size": 1,
"page_num": 0, "page_num": 0,
"page_size": 1000 "page_size": 1000,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
@@ -330,44 +376,57 @@ class TestSearchObjects:
# Verify the result is valid JSON # Verify the result is valid JSON
parsed_result = json.loads(result) parsed_result = json.loads(result)
assert len(parsed_result) == 1 assert parsed_result["results"] == ["people/john-doe"]
assert parsed_result[0]["type"] == "Person" assert parsed_result["total_count"] == 1
# Verify the client was called with type filter # 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) mock_client.find.assert_called_once_with(
"name:John", object_type="Person", page_size=25, page_num=0
)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_with_limit(self, mock_client): async def test_search_objects_with_limit(self, mock_client: Any) -> None:
"""Test object search with custom limit.""" """Test object search with custom limit."""
mock_search_result = { mock_search_result = {
"results": [ "results": [
{"id": "people/john-doe", "type": "Person", "content": {"name": "John Doe"}}, {
"id": "people/john-doe",
"type": "Person",
"content": {"name": "John Doe"},
},
], ],
"total_size": 1, "total_size": 1,
"page_num": 0, "page_num": 0,
"page_size": 50 "page_size": 50,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
result = await search_objects("name:John", limit=50) result = await search_objects("name:John", limit=50)
# Verify the result is valid JSON # Verify the result is valid JSON with new format
parsed_result = json.loads(result) parsed_result = json.loads(result)
assert len(parsed_result) == 1 assert parsed_result["results"] == ["people/john-doe"]
assert parsed_result["page_size"] == 50
# Verify the client was called with custom limit # Verify the client was called with custom limit
mock_client.find.assert_called_once_with("name:John", object_type=None, page_size=50, page_num=0) mock_client.find.assert_called_once_with(
"name:John", object_type=None, page_size=50, page_num=0
)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_with_all_parameters(self, mock_client): async def test_search_objects_with_all_parameters(self, mock_client: Any) -> None:
"""Test object search with all parameters.""" """Test object search with all parameters."""
mock_search_result = { mock_search_result = {
"results": [ "results": [
{"id": "documents/report-123", "type": "Document", "content": {"title": "Report"}}, {
"id": "documents/report-123",
"type": "Document",
"content": {"title": "Report"},
},
], ],
"total_size": 1, "total_size": 1,
"page_num": 0, "page_num": 0,
"page_size": 25 "page_size": 25,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
@@ -375,20 +434,22 @@ class TestSearchObjects:
# Verify the result is valid JSON # Verify the result is valid JSON
parsed_result = json.loads(result) parsed_result = json.loads(result)
assert len(parsed_result) == 1 assert parsed_result["results"] == ["documents/report-123"]
assert parsed_result[0]["type"] == "Document" assert parsed_result["total_count"] == 1
# Verify the client was called with all parameters # Verify the client was called with all parameters
mock_client.find.assert_called_once_with("title:Report", object_type="Document", page_size=25, page_num=0) mock_client.find.assert_called_once_with(
"title:Report", object_type="Document", page_size=25, page_num=0
)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_empty_results(self, mock_client): async def test_search_objects_empty_results(self, mock_client: Any) -> None:
"""Test object search with no results.""" """Test object search with no results."""
mock_search_result = { mock_search_result = {
"results": [], "results": [],
"total_size": 0, "total_size": 0,
"page_num": 0, "page_num": 0,
"page_size": 1000 "page_size": 1000,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
@@ -396,12 +457,15 @@ class TestSearchObjects:
# Verify the result is valid JSON with empty array # Verify the result is valid JSON with empty array
parsed_result = json.loads(result) parsed_result = json.loads(result)
assert parsed_result == [] assert parsed_result["results"] == []
assert parsed_result["total_count"] == 0
mock_client.find.assert_called_once_with("nonexistent:data", object_type=None, page_size=1, page_num=0) mock_client.find.assert_called_once_with(
"nonexistent:data", object_type=None, page_size=25, page_num=0
)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_client_error(self, mock_client): async def test_search_objects_client_error(self, mock_client: Any) -> None:
"""Test object search with client error.""" """Test object search with client error."""
mock_client.find = AsyncMock(side_effect=CordraClientError("Search failed")) mock_client.find = AsyncMock(side_effect=CordraClientError("Search failed"))
@@ -409,10 +473,12 @@ class TestSearchObjects:
await search_objects("test:query") await search_objects("test:query")
assert "Search failed:" in str(exc_info.value) assert "Search failed:" in str(exc_info.value)
mock_client.find.assert_called_once_with("test:query", object_type=None, page_size=1, page_num=0) mock_client.find.assert_called_once_with(
"test:query", object_type=None, page_size=25, page_num=0
)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_value_error(self, mock_client): async def test_search_objects_value_error(self, mock_client: Any) -> None:
"""Test object search with value error.""" """Test object search with value error."""
mock_client.find = AsyncMock(side_effect=ValueError("Invalid query")) mock_client.find = AsyncMock(side_effect=ValueError("Invalid query"))
@@ -420,10 +486,12 @@ class TestSearchObjects:
await search_objects("invalid:query") await search_objects("invalid:query")
assert "Invalid search parameters:" in str(exc_info.value) 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) mock_client.find.assert_called_once_with(
"invalid:query", object_type=None, page_size=25, page_num=0
)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_json_formatting(self, mock_client): async def test_search_objects_json_formatting(self, mock_client: Any) -> None:
"""Test that search results are properly formatted as JSON.""" """Test that search results are properly formatted as JSON."""
mock_search_result = { mock_search_result = {
"results": [ "results": [
@@ -431,7 +499,7 @@ class TestSearchObjects:
], ],
"total_size": 1, "total_size": 1,
"page_num": 0, "page_num": 0,
"page_size": 1000 "page_size": 1000,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
@@ -439,58 +507,71 @@ class TestSearchObjects:
# Verify it's valid JSON with proper indentation # Verify it's valid JSON with proper indentation
parsed_result = json.loads(result) parsed_result = json.loads(result)
assert isinstance(parsed_result, list) assert isinstance(parsed_result, dict)
# Check that the result contains indentation (pretty-printed) # Check that the result contains indentation (pretty-printed)
assert " " in result # Should have 2-space indentation assert " " in result # Should have 2-space indentation
# Verify the content is correctly formatted # Verify the content is correctly formatted
assert parsed_result[0]["id"] == "test/object" assert parsed_result["results"] == ["test/object"]
assert parsed_result[0]["type"] == "Test" assert parsed_result["total_count"] == 1
assert parsed_result[0]["content"]["data"] == "value"
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_with_page_num(self, mock_client): async def test_search_objects_with_page_num(self, mock_client: Any) -> None:
"""Test object search with page number parameter.""" """Test object search with page number parameter."""
mock_search_result = { mock_search_result = {
"results": [ "results": [
{"id": "documents/doc-21", "type": "Document", "content": {"title": "Page 2 Doc"}}, {
"id": "documents/doc-21",
"type": "Document",
"content": {"title": "Page 2 Doc"},
},
], ],
"total_size": 50, "total_size": 50,
"page_num": 1, "page_num": 1,
"page_size": 20 "page_size": 20,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
await search_objects("type:Document", page_num=1) await search_objects("type:Document", page_num=1)
# Verify the client was called with correct page number # Verify the client was called with correct page number
mock_client.find.assert_called_once_with("type:Document", object_type=None, page_size=1, page_num=1) mock_client.find.assert_called_once_with(
"type:Document", object_type=None, page_size=25, page_num=1
)
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_search_objects_with_all_pagination_params(self, mock_client): async def test_search_objects_with_all_pagination_params(
self, mock_client: Any
) -> None:
"""Test object search with all pagination parameters.""" """Test object search with all pagination parameters."""
mock_search_result = { mock_search_result = {
"results": [ "results": [
{"id": "reports/report-51", "type": "Report", "content": {"title": "Report 51"}}, {
"id": "reports/report-51",
"type": "Report",
"content": {"title": "Report 51"},
},
], ],
"total_size": 100, "total_size": 100,
"page_num": 5, "page_num": 5,
"page_size": 10 "page_size": 10,
} }
mock_client.find = AsyncMock(return_value=mock_search_result) mock_client.find = AsyncMock(return_value=mock_search_result)
await search_objects("type:Report", type="Report", limit=10, page_num=5) await search_objects("type:Report", type="Report", limit=10, page_num=5)
# Verify the client was called with all parameters # Verify the client was called with all parameters
mock_client.find.assert_called_once_with("type:Report", object_type="Report", page_size=10, page_num=5) mock_client.find.assert_called_once_with(
"type:Report", object_type="Report", page_size=10, page_num=5
)
class TestGetCordraDesign: class TestGetCordraDesign:
"""Test the get_cordra_design resource handler.""" """Test the get_cordra_design resource handler."""
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_get_design_success(self, mock_client): async def test_get_design_success(self, mock_client: Any) -> None:
"""Test successful design object retrieval.""" """Test successful design object retrieval."""
mock_design = DigitalObject( mock_design = DigitalObject(
id="design", id="design",
@@ -498,9 +579,9 @@ class TestGetCordraDesign:
content={ content={
"types": {"User": {}, "Project": {}}, "types": {"User": {}, "Project": {}},
"workflows": {}, "workflows": {},
"systemConfig": {"serverName": "test-cordra"} "systemConfig": {"serverName": "test-cordra"},
}, },
metadata={"created": "2023-01-01", "modified": "2023-06-15"} metadata={"created": "2023-01-01", "modified": "2023-06-15"},
) )
mock_client.get_design = AsyncMock(return_value=mock_design) mock_client.get_design = AsyncMock(return_value=mock_design)
@@ -517,8 +598,8 @@ class TestGetCordraDesign:
# Verify the client was called # Verify the client was called
mock_client.get_design.assert_called_once() mock_client.get_design.assert_called_once()
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_get_design_not_found(self, mock_client): async def test_get_design_not_found(self, mock_client: Any) -> None:
"""Test design object not found exception.""" """Test design object not found exception."""
mock_client.get_design = AsyncMock( mock_client.get_design = AsyncMock(
side_effect=CordraNotFoundError("Design object not found") side_effect=CordraNotFoundError("Design object not found")
@@ -530,8 +611,8 @@ class TestGetCordraDesign:
assert "Design object not found" in str(exc_info.value) assert "Design object not found" in str(exc_info.value)
mock_client.get_design.assert_called_once() mock_client.get_design.assert_called_once()
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_get_design_authentication_error(self, mock_client): async def test_get_design_authentication_error(self, mock_client: Any) -> None:
"""Test design object authentication error.""" """Test design object authentication error."""
mock_client.get_design = AsyncMock( mock_client.get_design = AsyncMock(
side_effect=CordraAuthenticationError("Authentication failed") side_effect=CordraAuthenticationError("Authentication failed")
@@ -543,8 +624,8 @@ class TestGetCordraDesign:
assert "Authentication failed" in str(exc_info.value) assert "Authentication failed" in str(exc_info.value)
mock_client.get_design.assert_called_once() mock_client.get_design.assert_called_once()
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_get_design_client_error(self, mock_client): async def test_get_design_client_error(self, mock_client: Any) -> None:
"""Test design object general client error.""" """Test design object general client error."""
mock_client.get_design = AsyncMock( mock_client.get_design = AsyncMock(
side_effect=CordraClientError("Connection failed") side_effect=CordraClientError("Connection failed")
@@ -557,14 +638,14 @@ class TestGetCordraDesign:
assert "Connection failed" in str(exc_info.value) assert "Connection failed" in str(exc_info.value)
mock_client.get_design.assert_called_once() mock_client.get_design.assert_called_once()
@patch('cordra_mcp.server.cordra_client') @patch("cordra_mcp.server.cordra_client")
async def test_get_design_json_formatting(self, mock_client): async def test_get_design_json_formatting(self, mock_client: Any) -> None:
"""Test that the design object is properly formatted as JSON.""" """Test that the design object is properly formatted as JSON."""
mock_design = DigitalObject( mock_design = DigitalObject(
id="design", id="design",
type="CordraDesign", type="CordraDesign",
content={"data": "value"}, content={"data": "value"},
metadata={"created": "2023-01-01"} metadata={"created": "2023-01-01"},
) )
mock_client.get_design = AsyncMock(return_value=mock_design) mock_client.get_design = AsyncMock(return_value=mock_design)
@@ -582,3 +663,110 @@ class TestGetCordraDesign:
assert "type" in parsed_result assert "type" in parsed_result
assert "content" in parsed_result assert "content" in parsed_result
assert "metadata" 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: Any) -> None:
"""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: Any) -> None:
"""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: Any) -> None:
"""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: Any) -> None:
"""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: Any) -> None:
"""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: Any) -> None:
"""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
)

2
uv.lock generated
View File

@@ -113,7 +113,7 @@ wheels = [
[[package]] [[package]]
name = "cordra-mcp" name = "cordra-mcp"
version = "1.0.0" version = "1.2.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp", extra = ["cli"] }, { name = "mcp", extra = ["cli"] },