32 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
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
11 changed files with 777 additions and 397 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
@@ -35,6 +36,26 @@ 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
@@ -42,7 +63,7 @@ jobs:
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

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

@@ -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,
@@ -16,29 +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.
- `query` - Lucene/Solr compatible search query
- `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

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.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"},
@@ -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.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,56 +15,69 @@ 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: 1) - /status:active AND /priority:high
- Use 'page_num' to specify which page to retrieve (default: 0) - query="/title:report", type="Document"
Returns a JSON list of matching objects with their full metadata.""" ❌ WRONG:
- name:John (missing /)
- author/name:Daniel (missing /)
- type:Person (use type parameter)
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,
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.
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: 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,35 +87,78 @@ async def search_objects(
raise RuntimeError(f"Search failed: {e}") from e raise RuntimeError(f"Search failed: {e}") from e
@mcp.resource( @mcp.tool(
"cordra://objects/{prefix}/{suffix}", name="count_objects",
name="cordra-object", title="Count Cordra Objects matching a query",
title="Retrieve Cordra Digital Object", description="""Count the total number of digital objects matching a search query.
description="Retrieve a Digital Object and Metadata from Cordra by its ID/handle.",
mime_type="application/json", Examples:
- /title:report - Count objects with 'report' in title
- 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
Returns the count of objects as integer.
""",
) )
async def get_cordra_object(prefix: str, suffix: str) -> str: async def count_objects(
"""Retrieve a Cordra digital object by its ID. query: str,
type: str | None = None,
) -> str:
"""Count digital objects in the Cordra repository matching a search query.
Args: Args:
prefix: The prefix part of the object ID (e.g., 'wildlive') query: Search query (Lucene/Solr). Properties MUST start with '/'.
suffix: The suffix part of the object ID (e.g., '7a4b7b65f8bb155ad36d') ✅ CORRECT: /title:*report*, /author/name:Daniel
❌ WRONG: name:John, author/name:Daniel, type:Person
type: Optional filter by object type (e.g., "Person", "Document", "Project")
Returns: Returns:
JSON representation of the digital object 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.tool(
name="get_object",
title="Get Cordra Object by ID",
description="""Retrieve a digital object by its complete ID/handle.
Returns: Full object with metadata as JSON
Example: get_object("test/abc123")""",
)
async def get_object(object_id: str) -> str:
"""Retrieve a Cordra digital object by its complete ID.
Args:
object_id: The complete object ID/handle (e.g., "test/abc123" or "wildlive/7a4b7b65f8bb155ad36d")
Returns:
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:
@@ -112,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,
@@ -145,32 +204,38 @@ 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
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)
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:
@@ -178,46 +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}")
@mcp.tool(
name="get_type_schema",
title="Get Type Schema",
description="""Retrieve the JSON schema definition for a specific type.
async def initialize_server() -> None: Args:
"""Initialize server resources before starting.""" type_name: The name of the type (e.g., "Person", "Document", "Project")
logger.info("Initializing Cordra MCP server...")
await register_schema_resources() Returns: The full schema definition as JSON""",
logger.info("Server initialization complete") )
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__}...")
mcp.run() if config.run_mode == "stdio":
mcp.run()
else:
mcp.run(transport="streamable-http")
if __name__ == "__main__": if __name__ == "__main__":

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"

File diff suppressed because it is too large Load Diff

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.4.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp", extra = ["cli"] }, { name = "mcp", extra = ["cli"] },