mirror of
https://github.com/dnlbauer/cordra-mcp.git
synced 2026-09-10 21:55:30 +00:00
284 lines
9.7 KiB
Python
284 lines
9.7 KiB
Python
"""MCP server for Cordra digital object repository."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
from mcp.server.fastmcp.resources import FunctionResource
|
|
|
|
from .client import (
|
|
CordraAuthenticationError,
|
|
CordraClient,
|
|
CordraClientError,
|
|
CordraNotFoundError,
|
|
)
|
|
from .config import CordraConfig
|
|
|
|
# Initialize the MCP server
|
|
mcp = FastMCP("cordra-mcp")
|
|
|
|
# Initialize Cordra client at startup
|
|
config = CordraConfig()
|
|
cordra_client = CordraClient(config)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(config.log_level)
|
|
|
|
|
|
@mcp.tool(
|
|
name="search_objects",
|
|
title="Search Cordra Objects",
|
|
description="""Search for digital objects in the Cordra repository using Lucene/Solr query syntax.
|
|
|
|
Examples:
|
|
- /title:report - Find objects with 'report' in title
|
|
- /author:smith - Find objects by author Smith
|
|
- /name:John AND type:Person - Complex queries
|
|
|
|
Pagination:
|
|
- Results are paginated with 0-based page numbering
|
|
- Use 'limit' to control page size (default: 25)
|
|
- Use 'page_num' to specify which page to retrieve (default: 0)
|
|
|
|
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(
|
|
query: str,
|
|
type: str | None = None,
|
|
limit: int = 25,
|
|
page_num: int = 0,
|
|
) -> str:
|
|
"""Search for digital objects in the Cordra repository with pagination support.
|
|
|
|
Args:
|
|
query: The search query string (Lucene/Solr compatible). Examples:
|
|
- "/title:report" - Find objects with "report" in title
|
|
- "/author:smith" - Find objects by author Smith
|
|
- "/name:John AND type:Person" - Complex queries
|
|
type: Optional filter by object type (e.g., "Person", "Document", "Project")
|
|
limit: Page size - number of results per page (default: 25)
|
|
page_num: Page number to retrieve, 0-based (default: 0 for first page)
|
|
|
|
Returns:
|
|
JSON string containing object IDs and pagination info
|
|
"""
|
|
try:
|
|
search_result = await cordra_client.find(
|
|
query, object_type=type, page_size=limit, page_num=page_num
|
|
)
|
|
|
|
# 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:
|
|
raise RuntimeError(f"Invalid search parameters: {e}") from e
|
|
except CordraAuthenticationError as e:
|
|
raise RuntimeError(f"Authentication failed: {e}") from e
|
|
except CordraClientError as e:
|
|
raise RuntimeError(f"Search failed: {e}") from e
|
|
|
|
|
|
@mcp.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(
|
|
"cordra://objects/{prefix}/{suffix}",
|
|
name="cordra-object",
|
|
title="Retrieve Cordra Digital Object",
|
|
description="Retrieve a Digital Object and Metadata from Cordra by its ID/handle.",
|
|
mime_type="application/json",
|
|
)
|
|
async def get_cordra_object(prefix: str, suffix: str) -> str:
|
|
"""Retrieve a Cordra digital object by its ID.
|
|
|
|
Args:
|
|
prefix: The prefix part of the object ID (e.g., 'wildlive')
|
|
suffix: The suffix part of the object ID (e.g., '7a4b7b65f8bb155ad36d')
|
|
|
|
Returns:
|
|
JSON representation of the digital object
|
|
|
|
Raises:
|
|
RuntimeError: If the object is not found or there's an API error
|
|
"""
|
|
|
|
object_id = f"{prefix}/{suffix}"
|
|
try:
|
|
digital_object = await cordra_client.get_object(object_id)
|
|
object_dict = digital_object.model_dump()
|
|
return json.dumps(object_dict, indent=2)
|
|
|
|
except ValueError as e:
|
|
raise RuntimeError(f"Invalid parameters: {e}") from e
|
|
except CordraNotFoundError as e:
|
|
raise RuntimeError(f"Object not found: {object_id}") from e
|
|
except CordraAuthenticationError as e:
|
|
raise RuntimeError(f"Authentication failed: {e}") from e
|
|
except CordraClientError as e:
|
|
raise RuntimeError(f"Failed to retrieve object {object_id}: {e}") from e
|
|
|
|
|
|
@mcp.resource(
|
|
"cordra://design",
|
|
name="cordra-design",
|
|
title="Retrieve Cordra Design Object",
|
|
description="Retrieve the Cordra design object containing repository configuration. Administrative privileges are typically required to access this object.",
|
|
mime_type="application/json",
|
|
)
|
|
async def get_cordra_design() -> str:
|
|
"""Retrieve the Cordra design object containing repository configuration.
|
|
|
|
The design object is the central location where Cordra stores its configuration,
|
|
including type definitions, workflow configurations, and system settings.
|
|
Administrative privileges are typically required to access this object.
|
|
|
|
Returns:
|
|
JSON representation of the design object
|
|
|
|
Raises:
|
|
RuntimeError: If the design object is not found, authentication fails, or there's an API error
|
|
"""
|
|
try:
|
|
design_object = await cordra_client.get_design()
|
|
object_dict = design_object.model_dump()
|
|
return json.dumps(object_dict, indent=2)
|
|
|
|
except CordraNotFoundError as e:
|
|
raise RuntimeError("Design object 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 design object: {e}") from e
|
|
|
|
|
|
async def create_schema_resource(schema_name: str) -> str:
|
|
"""Create content for a specific schema resource."""
|
|
try:
|
|
schema_object = await cordra_client.get_schema(schema_name)
|
|
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
|
|
|
|
|
|
async def register_schema_resources() -> None:
|
|
"""Register individual schema resources dynamically."""
|
|
try:
|
|
# Get all available schemas using pagination
|
|
all_schemas = []
|
|
page_num = 0
|
|
page_size = 20
|
|
|
|
while True:
|
|
search_result = await cordra_client.find(
|
|
"type:Schema", page_size=page_size, page_num=page_num
|
|
)
|
|
schemas = search_result["results"]
|
|
all_schemas.extend(schemas)
|
|
|
|
# Check if we've retrieved all schemas
|
|
if len(schemas) < page_size:
|
|
break
|
|
|
|
page_num += 1
|
|
|
|
for schema in all_schemas:
|
|
schema_name = schema.get("content", {}).get("name")
|
|
if not schema_name:
|
|
logger.warning("Schema without a name found, skipping.")
|
|
continue
|
|
|
|
logger.info(f"Registering schema resource for cordra type {schema_name}")
|
|
|
|
async def schema_fn(name: str = schema_name) -> str:
|
|
return await create_schema_resource(name)
|
|
|
|
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:
|
|
"""Initialize server resources before starting."""
|
|
logger.info("Initializing Cordra MCP server...")
|
|
await register_schema_resources()
|
|
logger.info("Server initialization complete")
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point for the MCP server."""
|
|
asyncio.run(initialize_server())
|
|
mcp.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|