18 Commits

Author SHA1 Message Date
Daniel Bauer
8e6aa73f50 feat: bump version 2025-12-04 13:48:23 +01:00
Daniel Bauer
a4d12d526b feat: remove resources for schemas and replace them with tool calls 2025-12-04 13:47:42 +01:00
Daniel Bauer
ad6dce005b feat: remove cordra object resource and made design object retrieval a tool call 2025-12-04 13:40:57 +01:00
Daniel Bauer
d42bb1a581 feat: bump version 2025-12-03 16:14:07 +01:00
Daniel Bauer
c3b202c998 feat: fix versioning 2025-12-03 16:12:58 +01:00
Daniel Bauer
0bee7f2775 Merge branch 'main' of github.com:dnlbauer/cordra-mcp 2025-12-03 16:11:34 +01:00
Daniel Bauer
adc24dd4ad feat: bump version 2025-12-03 16:11:28 +01:00
Daniel Bauer
99fdaadea6 Merge pull request #2 from jgrieb/main
feat: added configuration to run Docker in http mode
2025-12-03 16:10:18 +01:00
Jonas Grieb
8b0694dff1 feat: added configuration to run Docker in http mode 2025-12-03 15:14:00 +01:00
Daniel Bauer
ea9cf92b2c feat: bump version 2025-12-03 14:23:04 +01:00
Daniel Bauer
3c8367f804 feat: add get_object tool for retrieving Cordra digital objects by ID with error handling 2025-12-02 17:34:45 +01:00
Daniel Bauer
0a9ba538ef feat: include version in server initialization log message 2025-12-02 17:33:15 +01:00
Daniel Bauer
eaa300b8c2 feat: bump version 2025-12-02 16:54:05 +01:00
Daniel Bauer
7b77186ced feat: enhance tool documentation and add unit test for slash-prefixed properties 2025-12-02 16:53:25 +01:00
Daniel Bauer
5451883739 feat: bump version 2025-12-02 14:45:30 +01:00
Daniel Bauer
7d74d8d9af feat: update examples in mcp description to explain nested properties and correct usage of 'type' 2025-12-02 14:33:00 +01:00
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
8 changed files with 416 additions and 310 deletions

View File

@@ -1,5 +1,7 @@
FROM python:3.13-alpine FROM python:3.13-alpine
EXPOSE 8000
# Install package manager # Install package manager
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
@@ -18,4 +20,6 @@ COPY src src
RUN uv sync \ RUN uv sync \
--locked --locked
ENV CORDRA_RUN_MODE=http
CMD ["uv", "run", "cordra-mcp"] CMD ["uv", "run", "cordra-mcp"]

View File

@@ -18,32 +18,60 @@ ensuring safe exploration without risk of data modification or corruption.
## MCP Architecture ## MCP Architecture
### Resources
- `cordra://objects/{prefix}/{suffix}` - Retrieve a specific object by its handle identifier
- `cordra://schemas/{schema_name}` - Schema definition for a specific type.
- `cordra://design` - Design document containing the overall structure and configuration of the Cordra repository.
### Tools ### Tools
- `list_types` - List all available types in the Cordra repository.
- Returns a JSON array of type names that are defined in the repository
- Types are returned in sorted order
- `get_type_schema` - Retrieve the JSON schema definition for a specific type.
- `type_name` - The name of the type (e.g., "Person", "Document", "Project")
- Returns the full schema definition as JSON
- `get_object` - Retrieve a digital object by its complete ID/handle.
- `object_id` - Complete object ID (e.g., "test/abc123")
- `search_objects` - Search for digital objects using a query string with pagination support. - `search_objects` - Search for digital objects using a query string with pagination support.
- `query` - Lucene/Solr compatible search query - `query` - Lucene/Solr compatible search query
- `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: 25)
- `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. - `count_objects` - Count the total number of objects matching a query.
- `query` - Lucene/Solr compatible search query - `query` - Lucene/Solr compatible search query
- `type` - Optional filter by object type - `type` - Optional filter by object type
- `get_design_object` - Retrieve the Cordra design object containing repository configuration.
- Includes type definitions, workflow configurations, and system settings
- Administrative privileges are typically required to access this object
#### Query Syntax
**CRITICAL**: JSON properties MUST be prefixed with `/`
**Correct Examples:**
- `/title:*report*` - Wildcard search in title field
- `/author/name:Daniel` - Nested property access
- `/status:active AND /priority:high` - Boolean operators
- Use `type` parameter instead of including `type:` in query
**Wrong (will fail):**
- `name:John` - Missing `/` prefix
- `author/name:Daniel` - Missing leading `/`
- `type:Person` - Use the `type` parameter instead
**Operators:** `*` (wildcard), `?` (single char), `AND`, `OR`, `NOT`, `"phrases"`
## 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

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "cordra-mcp" name = "cordra-mcp"
version = "1.2.0" version = "1.4.0"
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"},

View File

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

View File

@@ -1,6 +1,8 @@
"""Configuration settings for the MCP Cordra server.""" """Configuration settings for the MCP Cordra server."""
from pydantic import Field from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -16,6 +18,10 @@ class CordraConfig(BaseSettings):
default="https://localhost:8443", default="https://localhost:8443",
description="Base URL of the Cordra repository", description="Base URL of the Cordra repository",
) )
host: str = Field(
default="0.0.0.0",
description="The host under which the MCP server runs when deployed as http run_mode",
)
username: str | None = Field( username: str | None = Field(
default=None, description="Username for Cordra authentication" default=None, description="Username for Cordra authentication"
) )
@@ -26,3 +32,24 @@ 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")
run_mode: Literal["stdio", "http"] | None = Field(
default="stdio", description="Run mode for the MCP client"
)
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

@@ -1,12 +1,11 @@
"""MCP server for Cordra digital object repository.""" """MCP server for Cordra digital object repository."""
import asyncio
import json import json
import logging import logging
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.resources import FunctionResource
from . import __version__
from .client import ( from .client import (
CordraAuthenticationError, CordraAuthenticationError,
CordraClient, CordraClient,
@@ -16,37 +15,39 @@ from .client import (
from .config import CordraConfig from .config import CordraConfig
# Initialize the MCP server # Initialize the MCP server
mcp = FastMCP("cordra-mcp") config = CordraConfig()
mcp = FastMCP("cordra-mcp", host=config.host, port=8000)
# Initialize Cordra client at startup # Initialize Cordra client at startup
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(
name="search_objects", name="search_objects",
title="Search Cordra Objects", title="Search Cordra Objects",
description="""Search for digital objects in the Cordra repository using Lucene/Solr query syntax. description="""Search for digital objects using Lucene/Solr query syntax.
Examples: CRITICAL SYNTAX RULES:
- /title:report - Find objects with 'report' in title 1. Properties MUST start with '/' - Example: /title:report
- /author:smith - Find objects by author Smith 2. Nested properties: /parent/child:value
- /name:John AND type:Person - Complex queries 3. Use 'type' parameter - NEVER 'type:' in query
4. Operators: * ? AND OR NOT "phrases"
Pagination: ✅ CORRECT:
- Results are paginated with 0-based page numbering - /title:*report* /author/name:Daniel
- Use 'limit' to control page size (default: 25) - /status:active AND /priority:high
- Use 'page_num' to specify which page to retrieve (default: 0) - query="/title:report", type="Document"
Returns a JSON object containing: ❌ WRONG:
- object_ids: List of object IDs that match the search - name:John (missing /)
- total_count: Total number of objects matching the query - author/name:Daniel (missing /)
- page_num: Current page number - type:Person (use type parameter)
- page_size: Number of results per page
Use the cordra://objects/{prefix}/{suffix} resources to retrieve full object details.""", Returns: {results: [ids], total_count, page_num, page_size}
Pagination: limit (default 25), page_num (0-based)""",
) )
async def search_objects( async def search_objects(
query: str, query: str,
@@ -57,10 +58,9 @@ async def search_objects(
"""Search for digital objects in the Cordra repository with pagination support. """Search for digital objects in the Cordra repository with pagination support.
Args: Args:
query: The search query string (Lucene/Solr compatible). Examples: query: Search query (Lucene/Solr). Properties MUST start with '/'.
- "/title:report" - Find objects with "report" in title ✅ CORRECT: /title:*report*, /author/name:Daniel
- "/author:smith" - Find objects by author Smith ❌ WRONG: name:John, author/name:Daniel, type:Person
- "/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: 25) 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)
@@ -94,7 +94,8 @@ async def search_objects(
Examples: Examples:
- /title:report - Count objects with 'report' in title - /title:report - Count objects with 'report' in title
- /author:smith - Count objects by author Smith - type:Person - Find all Persons. Note that "type" is special and uses no slash "/"
- /author/name:Daniel - Find objects with author Daniel as nested property.
- /name:John AND type:Person - Complex queries - /name:John AND type:Person - Complex queries
Returns the count of objects as integer. Returns the count of objects as integer.
@@ -107,10 +108,9 @@ async def count_objects(
"""Count digital objects in the Cordra repository matching a search query. """Count digital objects in the Cordra repository matching a search query.
Args: Args:
query: The search query string (Lucene/Solr compatible). Examples: query: Search query (Lucene/Solr). Properties MUST start with '/'.
- "/title:report" - Count objects with "report" in title ✅ CORRECT: /title:*report*, /author/name:Daniel
- "/author:smith" - Count objects by author Smith ❌ WRONG: name:John, author/name:Daniel, type:Person
- "/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")
Returns: Returns:
@@ -132,35 +132,33 @@ async def count_objects(
raise RuntimeError(f"Count failed: {e}") from e raise RuntimeError(f"Count failed: {e}") from e
@mcp.resource( @mcp.tool(
"cordra://objects/{prefix}/{suffix}", name="get_object",
name="cordra-object", title="Get Cordra Object by ID",
title="Retrieve Cordra Digital Object", description="""Retrieve a digital object by its complete ID/handle.
description="Retrieve a Digital Object and Metadata from Cordra by its ID/handle.",
mime_type="application/json", Returns: Full object with metadata as JSON
Example: get_object("test/abc123")""",
) )
async def get_cordra_object(prefix: str, suffix: str) -> str: async def get_object(object_id: str) -> str:
"""Retrieve a Cordra digital object by its ID. """Retrieve a Cordra digital object by its complete ID.
Args: Args:
prefix: The prefix part of the object ID (e.g., 'wildlive') object_id: The complete object ID/handle (e.g., "test/abc123" or "wildlive/7a4b7b65f8bb155ad36d")
suffix: The suffix part of the object ID (e.g., '7a4b7b65f8bb155ad36d')
Returns: Returns:
JSON representation of the digital object JSON string containing the complete digital object with all metadata
Raises: Raises:
RuntimeError: If the object is not found or there's an API error RuntimeError: If the object is not found or there's an API error
""" """
object_id = f"{prefix}/{suffix}"
try: try:
digital_object = await cordra_client.get_object(object_id) digital_object = await cordra_client.get_object(object_id)
object_dict = digital_object.model_dump() object_dict = digital_object.model_dump()
return json.dumps(object_dict, indent=2) return json.dumps(object_dict, indent=2)
except ValueError as e: except ValueError as e:
raise RuntimeError(f"Invalid parameters: {e}") from e raise RuntimeError(f"Invalid object ID: {e}") from e
except CordraNotFoundError as e: except CordraNotFoundError as e:
raise RuntimeError(f"Object not found: {object_id}") from e raise RuntimeError(f"Object not found: {object_id}") from e
except CordraAuthenticationError as e: except CordraAuthenticationError as e:
@@ -169,14 +167,18 @@ async def get_cordra_object(prefix: str, suffix: str) -> str:
raise RuntimeError(f"Failed to retrieve object {object_id}: {e}") from e raise RuntimeError(f"Failed to retrieve object {object_id}: {e}") from e
@mcp.resource( @mcp.tool(
"cordra://design", name="get_design_object",
name="cordra-design", title="Get Cordra Design Object",
title="Retrieve Cordra Design Object", description="""
description="Retrieve the Cordra design object containing repository configuration. Administrative privileges are typically required to access this object.", The design object is the central location where Cordra stores its configuration,
mime_type="application/json", including type definitions, workflow configurations, and system settings.
Administrative privileges are typically required to access this object.
Returns: The design object as JSON
""",
) )
async def get_cordra_design() -> str: async def get_cordra_design_object() -> str:
"""Retrieve the Cordra design object containing repository configuration. """Retrieve the Cordra design object containing repository configuration.
The design object is the central location where Cordra stores its configuration, The design object is the central location where Cordra stores its configuration,
@@ -202,25 +204,25 @@ async def get_cordra_design() -> str:
raise RuntimeError(f"Failed to retrieve design object: {e}") from e raise RuntimeError(f"Failed to retrieve design object: {e}") from e
async def create_schema_resource(schema_name: str) -> str: @mcp.tool(
"""Create content for a specific schema resource.""" name="list_types",
try: title="List Available Types",
schema_object = await cordra_client.get_schema(schema_name) description="""List all available object types in the Cordra repository.
schema_dict = schema_object.model_dump()
return json.dumps(schema_dict, indent=2)
except CordraNotFoundError as e:
raise RuntimeError(f"Schema not found: {schema_name}") from e
except CordraAuthenticationError as e:
raise RuntimeError(f"Authentication failed: {e}") from e
except CordraClientError as e:
raise RuntimeError(f"Failed to retrieve schema {schema_name}: {e}") from e
Returns a list of type names that are defined in the repository as json array.""",
)
async def list_types() -> str:
"""List all available types in the Cordra repository.
async def register_schema_resources() -> None: Returns:
"""Register individual schema resources dynamically.""" JSON string containing a list of type names
Raises:
RuntimeError: If there's an API error or authentication failure
"""
try: try:
# Get all available schemas using pagination # Get all available types using pagination
all_schemas = [] all_types = []
page_num = 0 page_num = 0
page_size = 20 page_size = 20
@@ -229,7 +231,11 @@ async def register_schema_resources() -> None:
"type:Schema", page_size=page_size, page_num=page_num "type:Schema", page_size=page_size, page_num=page_num
) )
schemas = search_result["results"] schemas = search_result["results"]
all_schemas.extend(schemas)
for schema in schemas:
type_name = schema.get("content", {}).get("name")
if type_name:
all_types.append(type_name)
# Check if we've retrieved all schemas # Check if we've retrieved all schemas
if len(schemas) < page_size: if len(schemas) < page_size:
@@ -237,45 +243,58 @@ async def register_schema_resources() -> None:
page_num += 1 page_num += 1
for schema in all_schemas: all_types.sort()
schema_name = schema.get("content", {}).get("name") return json.dumps(all_types, indent=2)
if not schema_name:
logger.warning("Schema without a name found, skipping.")
continue
logger.info(f"Registering schema resource for cordra type {schema_name}") except CordraAuthenticationError as e:
raise RuntimeError(f"Authentication failed: {e}") from e
async def schema_fn(name: str = schema_name) -> str: except CordraClientError as e:
return await create_schema_resource(name) raise RuntimeError(f"Failed to list types: {e}") from e
mcp.add_resource(
FunctionResource.from_function(
uri=f"cordra://schemas/{schema_name}",
fn=schema_fn,
name=f"cordra-type-schema-{schema_name}",
title=f"Cordra Type Schema: {schema_name}",
description=f"Retrieve the JSON schema for the Cordra Type {schema_name}",
mime_type="application/json",
)
)
logger.info(f"Registered {len(all_schemas)} schema resources")
except Exception as e:
logger.warning(f"Failed to register schema resources: {e}")
async def initialize_server() -> None: @mcp.tool(
"""Initialize server resources before starting.""" name="get_type_schema",
logger.info("Initializing Cordra MCP server...") title="Get Type Schema",
await register_schema_resources() description="""Retrieve the JSON schema definition for a specific type.
logger.info("Server initialization complete")
Args:
type_name: The name of the type (e.g., "Person", "Document", "Project")
Returns: The full schema definition as JSON""",
)
async def get_type_schema(type_name: str) -> str:
"""Retrieve the JSON schema definition for a specific object type.
Args:
type_name: The name of the type to retrieve the schema for
Returns:
JSON string containing the schema definition
Raises:
RuntimeError: If the type is not found, authentication fails, or there's an API error
"""
try:
schema_object = await cordra_client.get_schema(type_name)
schema_dict = schema_object.model_dump()
return json.dumps(schema_dict, indent=2)
except CordraNotFoundError as e:
raise RuntimeError(f"Type '{type_name}' not found") from e
except CordraAuthenticationError as e:
raise RuntimeError(f"Authentication failed: {e}") from e
except CordraClientError as e:
raise RuntimeError(
f"Failed to retrieve schema for type '{type_name}': {e}"
) from e
def main() -> None: def main() -> None:
"""Main entry point for the MCP server.""" """Main entry point for the MCP server."""
asyncio.run(initialize_server()) logger.info(f"Starting Cordra MCP server v{__version__}...")
if config.run_mode == "stdio":
mcp.run() mcp.run()
else:
mcp.run(transport="streamable-http")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -14,8 +14,10 @@ from cordra_mcp.client import (
) )
from cordra_mcp.server import ( from cordra_mcp.server import (
count_objects, count_objects,
get_cordra_design, get_cordra_design_object,
get_cordra_object, get_object,
get_type_schema,
list_types,
search_objects, search_objects,
) )
@@ -44,27 +46,23 @@ def sample_digital_object() -> DigitalObject:
) )
class TestGetCordraObject: class TestGetObject:
"""Test the get_cordra_object resource handler.""" """Test the get_object tool."""
@patch("cordra_mcp.server.cordra_client") @patch("cordra_mcp.server.cordra_client")
async def test_get_object_success( async def test_get_object_success(
self, mock_client: Any, sample_digital_object: DigitalObject self, mock_client: Any, sample_digital_object: DigitalObject
) -> None: ) -> None:
"""Test successful object retrieval.""" """Test successful object retrieval with complete ID."""
mock_client.get_object = AsyncMock(return_value=sample_digital_object) mock_client.get_object = AsyncMock(return_value=sample_digital_object)
result = await get_cordra_object("people", "john-doe-123") result = await get_object("people/john-doe-123")
# Verify the result is valid JSON # Verify the result is valid JSON
parsed_result = json.loads(result) parsed_result = json.loads(result)
assert parsed_result["id"] == "people/john-doe-123" assert parsed_result["id"] == "people/john-doe-123"
assert parsed_result["type"] == "Person" assert parsed_result["type"] == "Person"
assert parsed_result["content"]["name"] == "John Doe" assert parsed_result["content"]["name"] == "John Doe"
assert parsed_result["content"]["birthday"] == "1990-05-15"
assert parsed_result["metadata"]["created"] == "2023-01-01"
assert len(parsed_result["payloads"]) == 1
assert parsed_result["payloads"][0]["name"] == "profile_photo"
# 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")
@@ -73,14 +71,14 @@ class TestGetCordraObject:
async def test_get_object_not_found(self, mock_client: Any) -> None: 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: test/nonexistent")
) )
with pytest.raises(RuntimeError) as exc_info: with pytest.raises(RuntimeError) as exc_info:
await get_cordra_object("people", "nonexistent") await get_object("test/nonexistent")
assert "Object not found: people/nonexistent" in str(exc_info.value) assert "Object not found: test/nonexistent" in str(exc_info.value)
mock_client.get_object.assert_called_once_with("people/nonexistent") mock_client.get_object.assert_called_once_with("test/nonexistent")
@patch("cordra_mcp.server.cordra_client") @patch("cordra_mcp.server.cordra_client")
async def test_get_object_client_error(self, mock_client: Any) -> None: async def test_get_object_client_error(self, mock_client: Any) -> None:
@@ -90,84 +88,156 @@ class TestGetCordraObject:
) )
with pytest.raises(RuntimeError) as exc_info: with pytest.raises(RuntimeError) as exc_info:
await get_cordra_object("people", "john-doe-123") await get_object("test/obj123")
assert "Failed to retrieve object people/john-doe-123" in str(exc_info.value) assert "Failed to retrieve object test/obj123" in str(exc_info.value)
assert "Connection failed" in str(exc_info.value) mock_client.get_object.assert_called_once_with("test/obj123")
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( async def test_get_object_authentication_error(self, mock_client: Any) -> None:
self, mock_client: Any, sample_digital_object: DigitalObject """Test authentication error handling."""
) -> None: mock_client.get_object = AsyncMock(
"""Test that object ID is correctly constructed from prefix and suffix.""" side_effect=CordraAuthenticationError("Authentication failed")
mock_client.get_object = AsyncMock(return_value=sample_digital_object)
# Test various prefix/suffix combinations
test_cases = [
("people", "john-doe-123", "people/john-doe-123"),
("documents", "report-2023", "documents/report-2023"),
("items", "item_with_underscores", "items/item_with_underscores"),
]
for prefix, suffix, expected_id in test_cases:
await get_cordra_object(prefix, suffix)
mock_client.get_object.assert_called_with(expected_id)
@patch("cordra_mcp.server.cordra_client")
async def test_json_formatting(
self, mock_client: Any, sample_digital_object: DigitalObject
) -> None:
"""Test that the returned JSON is properly formatted."""
mock_client.get_object = AsyncMock(return_value=sample_digital_object)
result = await get_cordra_object("people", "john-doe-123")
# Verify it's valid JSON with proper indentation
parsed_result = json.loads(result)
assert isinstance(parsed_result, dict)
# Check that the result contains indentation (pretty-printed)
assert " " in result # Should have 2-space indentation
# Verify all expected fields are present
assert "id" in parsed_result
assert "type" in parsed_result
assert "content" in parsed_result
assert "metadata" in parsed_result
assert "acl" in parsed_result
assert "payloads" in parsed_result
@patch("cordra_mcp.server.cordra_client")
async def test_minimal_object(self, mock_client: Any) -> None:
"""Test handling of object with minimal data."""
minimal_object = DigitalObject(
id="test/minimal",
type="",
content={"id": "test/minimal"},
metadata=None,
acl=None,
payloads=None,
) )
mock_client.get_object = AsyncMock(return_value=minimal_object)
result = await get_cordra_object("test", "minimal") with pytest.raises(RuntimeError) as exc_info:
parsed_result = json.loads(result) await get_object("test/obj123")
assert parsed_result["id"] == "test/minimal" assert "Authentication failed" in str(exc_info.value)
assert parsed_result["type"] == "" mock_client.get_object.assert_called_once_with("test/obj123")
assert parsed_result["content"]["id"] == "test/minimal"
assert parsed_result["metadata"] is None
assert parsed_result["acl"] is None
assert parsed_result["payloads"] is None
class TestSchemaResourceFunctions: class TestListTypes:
"""Test the schema resource functions.""" """Test the list_types tool."""
@patch("cordra_mcp.server.cordra_client") @patch("cordra_mcp.server.cordra_client")
async def test_create_schema_resource_success(self, mock_client: Any) -> None: async def test_list_types_success(self, mock_client: Any) -> None:
"""Test successful schema resource creation.""" """Test successful listing of available types."""
mock_search_result = {
"results": [
{"content": {"name": "User"}, "id": "test/user-schema"},
{"content": {"name": "Project"}, "id": "test/project-schema"},
{"content": {"name": "Document"}, "id": "test/doc-schema"},
],
"total_size": 3,
"page_num": 0,
"page_size": 20,
}
mock_client.find = AsyncMock(return_value=mock_search_result)
result = await list_types()
# Verify the result is valid JSON
parsed_result = json.loads(result)
assert parsed_result == ["Document", "Project", "User"] # Should be sorted
# Verify the client was called with correct query
mock_client.find.assert_called_once_with(
"type:Schema", page_size=20, page_num=0
)
@patch("cordra_mcp.server.cordra_client")
async def test_list_types_with_pagination(self, mock_client: Any) -> None:
"""Test listing types with pagination."""
first_page = {
"results": [
{"content": {"name": f"Type{i}"}, "id": f"test/schema{i}"}
for i in range(20)
],
"total_size": 25,
"page_num": 0,
"page_size": 20,
}
second_page = {
"results": [
{"content": {"name": "ZType"}, "id": "test/zschema"},
],
"total_size": 25,
"page_num": 1,
"page_size": 20,
}
mock_client.find = AsyncMock(side_effect=[first_page, second_page])
result = await list_types()
parsed_result = json.loads(result)
# Should contain all 21 types and be sorted
assert len(parsed_result) == 21
assert parsed_result == sorted(parsed_result)
assert "Type0" in parsed_result
assert "ZType" in parsed_result
# Verify pagination calls
assert mock_client.find.call_count == 2
@patch("cordra_mcp.server.cordra_client")
async def test_list_types_missing_name(self, mock_client: Any) -> None:
"""Test listing types when some schemas have missing name field."""
mock_search_result = {
"results": [
{"content": {"name": "User"}, "id": "test/user-schema"},
{"content": {}, "id": "test/no-name-schema"}, # Missing name
{"content": {"name": "Project"}, "id": "test/project-schema"},
],
"total_size": 3,
"page_num": 0,
"page_size": 20,
}
mock_client.find = AsyncMock(return_value=mock_search_result)
result = await list_types()
# Only 2 types should be returned (those with name)
parsed_result = json.loads(result)
assert parsed_result == ["Project", "User"]
@patch("cordra_mcp.server.cordra_client")
async def test_list_types_client_error(self, mock_client: Any) -> None:
"""Test listing types with client error."""
mock_client.find = AsyncMock(side_effect=CordraClientError("Search failed"))
with pytest.raises(RuntimeError) as exc_info:
await list_types()
assert "Failed to list types:" in str(exc_info.value)
@patch("cordra_mcp.server.cordra_client")
async def test_list_types_authentication_error(self, mock_client: Any) -> None:
"""Test listing types with authentication error."""
mock_client.find = AsyncMock(
side_effect=CordraAuthenticationError("Authentication failed")
)
with pytest.raises(RuntimeError) as exc_info:
await list_types()
assert "Authentication failed:" in str(exc_info.value)
@patch("cordra_mcp.server.cordra_client")
async def test_list_types_empty(self, mock_client: Any) -> None:
"""Test listing types when no types are available."""
mock_search_result = {
"results": [],
"total_size": 0,
"page_num": 0,
"page_size": 20,
}
mock_client.find = AsyncMock(return_value=mock_search_result)
result = await list_types()
parsed_result = json.loads(result)
assert parsed_result == []
class TestGetTypeSchema:
"""Test the get_type_schema tool."""
@patch("cordra_mcp.server.cordra_client")
async def test_get_type_schema_success(self, mock_client: Any) -> None:
"""Test successful schema retrieval."""
mock_schema = DigitalObject( mock_schema = DigitalObject(
id="test/user-schema", id="test/user-schema",
type="Schema", type="Schema",
@@ -175,9 +245,7 @@ class TestSchemaResourceFunctions:
) )
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 result = await get_type_schema("User")
result = await create_schema_resource("User")
# Verify the result is valid JSON # Verify the result is valid JSON
parsed_result = json.loads(result) parsed_result = json.loads(result)
@@ -189,131 +257,58 @@ class TestSchemaResourceFunctions:
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: Any) -> None: async def test_get_type_schema_not_found(self, mock_client: Any) -> None:
"""Test schema resource creation with schema not found.""" """Test schema retrieval with type not found."""
mock_client.get_schema = AsyncMock( mock_client.get_schema = AsyncMock(
side_effect=CordraNotFoundError("Schema not found") side_effect=CordraNotFoundError("Schema not found")
) )
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 get_type_schema("NonExistent")
assert "Schema not found: NonExistent" in str(exc_info.value) assert "Type 'NonExistent' not found" 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: Any) -> None: async def test_get_type_schema_authentication_error(self, mock_client: Any) -> None:
"""Test successful schema resource registration.""" """Test schema retrieval with authentication error."""
mock_search_result = { mock_client.get_schema = AsyncMock(
"results": [ side_effect=CordraAuthenticationError("Authentication failed")
{"content": {"name": "User"}, "id": "test/user-schema"},
{"content": {"name": "Project"}, "id": "test/project-schema"},
{"content": {"name": "Document"}, "id": "test/doc-schema"},
],
"total_size": 3,
"page_num": 0,
"page_size": 20,
}
mock_client.find = AsyncMock(return_value=mock_search_result)
# Mock the mcp.add_resource method
with patch("cordra_mcp.server.mcp") as mock_mcp:
from cordra_mcp.server import register_schema_resources
await register_schema_resources()
# Verify the client was called with correct query
mock_client.find.assert_called_once_with(
"type:Schema", page_size=20, page_num=0
) )
# Verify add_resource was called for each schema with pytest.raises(RuntimeError) as exc_info:
assert mock_mcp.add_resource.call_count == 3 await get_type_schema("User")
assert "Authentication failed:" in str(exc_info.value)
@patch("cordra_mcp.server.cordra_client") @patch("cordra_mcp.server.cordra_client")
async def test_register_schema_resources_missing_name( async def test_get_type_schema_client_error(self, mock_client: Any) -> None:
self, mock_client: Any """Test schema retrieval with client error."""
) -> None: mock_client.get_schema = AsyncMock(
"""Test schema resource registration with objects missing name field.""" side_effect=CordraClientError("Connection failed")
mock_search_result = {
"results": [
{"content": {"name": "User"}, "id": "test/user-schema"},
{"content": {}, "id": "test/no-name-schema"}, # Missing name field
{"content": {"name": "Project"}, "id": "test/project-schema"},
],
"total_size": 3,
"page_num": 0,
"page_size": 20,
}
mock_client.find = AsyncMock(return_value=mock_search_result)
with patch("cordra_mcp.server.mcp") as mock_mcp:
from cordra_mcp.server import register_schema_resources
await register_schema_resources()
# Only 2 schemas should be registered (those with name field)
assert mock_mcp.add_resource.call_count == 2
@patch("cordra_mcp.server.cordra_client")
async def test_register_schema_resources_client_error(
self, mock_client: Any
) -> None:
"""Test schema resource registration with client error."""
mock_client.find = AsyncMock(side_effect=CordraClientError("Search failed"))
# Should not raise an exception, just log a warning
from cordra_mcp.server import register_schema_resources
await register_schema_resources() # Should complete without raising
mock_client.find.assert_called_once_with(
"type:Schema", page_size=20, page_num=0
) )
with pytest.raises(RuntimeError) as exc_info:
await get_type_schema("User")
assert "Failed to retrieve schema for type 'User':" in str(exc_info.value)
@patch("cordra_mcp.server.cordra_client") @patch("cordra_mcp.server.cordra_client")
async def test_register_schema_resources_pagination(self, mock_client: Any) -> None: async def test_get_type_schema_json_formatting(self, mock_client: Any) -> None:
"""Test schema resource registration with pagination.""" """Test that schema is properly formatted as JSON."""
# Mock multiple pages of results mock_schema = DigitalObject(
# First page with full 20 results (simulating more schemas) id="test/schema",
first_page_schemas = [ type="Schema",
{"content": {"name": f"Schema{i}"}, "id": f"test/schema{i}"} content={"name": "Test", "properties": {"field": "value"}},
for i in range(20) )
] mock_client.get_schema = AsyncMock(return_value=mock_schema)
first_page = {
"results": first_page_schemas,
"total_size": 25,
"page_num": 0,
"page_size": 20,
}
# Second page with fewer results (indicating last page) result = await get_type_schema("Test")
second_page = {
"results": [
{"content": {"name": "Document"}, "id": "test/doc-schema"},
],
"total_size": 25,
"page_num": 1,
"page_size": 20,
}
# Return first page, then second page (with fewer results indicating last page) # Verify it's valid JSON with proper indentation
mock_client.find = AsyncMock(side_effect=[first_page, second_page]) parsed_result = json.loads(result)
assert isinstance(parsed_result, dict)
with patch("cordra_mcp.server.mcp") as mock_mcp: assert " " in result # Should have 2-space indentation
from cordra_mcp.server import register_schema_resources
await register_schema_resources()
# Verify pagination calls
assert mock_client.find.call_count == 2
mock_client.find.assert_any_call("type:Schema", page_size=20, page_num=0)
mock_client.find.assert_any_call("type:Schema", page_size=20, page_num=1)
# Verify all 21 schemas were registered (20 from first page + 1 from second page)
assert mock_mcp.add_resource.call_count == 21
class TestSearchObjects: class TestSearchObjects:
@@ -464,6 +459,39 @@ class TestSearchObjects:
"nonexistent:data", object_type=None, page_size=25, page_num=0 "nonexistent:data", object_type=None, page_size=25, page_num=0
) )
@patch("cordra_mcp.server.cordra_client")
async def test_search_objects_with_slash_prefixed_properties(
self, mock_client: Any
) -> None:
"""Test object search with correct slash-prefixed property syntax."""
mock_search_result = {
"results": [
{
"id": "reports/2024-annual",
"type": "Document",
"content": {"title": "Annual Report 2024"},
},
],
"total_size": 1,
"page_num": 0,
"page_size": 25,
}
mock_client.find = AsyncMock(return_value=mock_search_result)
# Test with slash-prefixed property and nested property
result = await search_objects("/title:*report* AND /author/name:Daniel")
parsed_result = json.loads(result)
assert parsed_result["results"] == ["reports/2024-annual"]
assert parsed_result["total_count"] == 1
mock_client.find.assert_called_once_with(
"/title:*report* AND /author/name:Daniel",
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: Any) -> None: async def test_search_objects_client_error(self, mock_client: Any) -> None:
"""Test object search with client error.""" """Test object search with client error."""
@@ -568,7 +596,7 @@ class TestSearchObjects:
class TestGetCordraDesign: class TestGetCordraDesign:
"""Test the get_cordra_design resource handler.""" """Test the get_cordra_design_object tool."""
@patch("cordra_mcp.server.cordra_client") @patch("cordra_mcp.server.cordra_client")
async def test_get_design_success(self, mock_client: Any) -> None: async def test_get_design_success(self, mock_client: Any) -> None:
@@ -585,7 +613,7 @@ class TestGetCordraDesign:
) )
mock_client.get_design = AsyncMock(return_value=mock_design) mock_client.get_design = AsyncMock(return_value=mock_design)
result = await get_cordra_design() result = await get_cordra_design_object()
# Verify the result is valid JSON # Verify the result is valid JSON
parsed_result = json.loads(result) parsed_result = json.loads(result)
@@ -606,7 +634,7 @@ class TestGetCordraDesign:
) )
with pytest.raises(RuntimeError) as exc_info: with pytest.raises(RuntimeError) as exc_info:
await get_cordra_design() await get_cordra_design_object()
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()
@@ -619,7 +647,7 @@ class TestGetCordraDesign:
) )
with pytest.raises(RuntimeError) as exc_info: with pytest.raises(RuntimeError) as exc_info:
await get_cordra_design() await get_cordra_design_object()
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()
@@ -632,7 +660,7 @@ class TestGetCordraDesign:
) )
with pytest.raises(RuntimeError) as exc_info: with pytest.raises(RuntimeError) as exc_info:
await get_cordra_design() await get_cordra_design_object()
assert "Failed to retrieve design object" in str(exc_info.value) assert "Failed to retrieve design object" in str(exc_info.value)
assert "Connection failed" in str(exc_info.value) assert "Connection failed" in str(exc_info.value)
@@ -649,7 +677,7 @@ class TestGetCordraDesign:
) )
mock_client.get_design = AsyncMock(return_value=mock_design) mock_client.get_design = AsyncMock(return_value=mock_design)
result = await get_cordra_design() result = await get_cordra_design_object()
# 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)

2
uv.lock generated
View File

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