diff --git a/pyproject.toml b/pyproject.toml index 927be4a..50f1ede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,11 +10,9 @@ license = "MIT" requires-python = ">=3.11" dependencies = [ "mcp>=1.2.0", - "httpx>=0.25.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", - "cordrapy", - "setuptools>=80.9.0", + "requests>=2.25.0", ] [project.optional-dependencies] @@ -56,8 +54,6 @@ asyncio_mode = "auto" testpaths = ["tests"] addopts = "-v" -[tool.uv.sources] -cordrapy = { git = "https://github.com/usnistgov/CordraPy.git" } [dependency-groups] dev = [ diff --git a/src/mcp_cordra/client.py b/src/mcp_cordra/client.py index 938c4d1..384e2f8 100644 --- a/src/mcp_cordra/client.py +++ b/src/mcp_cordra/client.py @@ -1,12 +1,15 @@ -"""Cordra client wrapper using CordraPy.""" +"""Cordra client wrapper using HTTP requests.""" +import logging from typing import Any -import cordra +import requests from pydantic import BaseModel, Field from .config import CordraConfig +logger = logging.getLogger(__name__) + class DigitalObject(BaseModel): """Model for a Cordra digital object.""" @@ -30,7 +33,7 @@ class CordraNotFoundError(CordraClientError): class CordraClient: - """Client for interacting with Cordra repository.""" + """Client for interacting with Cordra repository using HTTP requests.""" def __init__(self, config: CordraConfig) -> None: """Initialize the Cordra client. @@ -39,6 +42,14 @@ class CordraClient: config: Configuration settings for the Cordra connection """ self.config = config + self.session = requests.Session() + self.session.verify = config.verify_ssl + + # Set up authentication + if config.username and config.password: + self.session.auth = (config.username, config.password) + elif config.username or config.password: + logger.warning("Only username or password provided, not both. Authentication may fail.") async def get_object(self, object_id: str) -> DigitalObject: """Retrieve a digital object by its ID. @@ -54,28 +65,34 @@ class CordraClient: CordraClientError: For other API errors """ try: - cordra_obj: dict[str, Any] = cordra.CordraObject.read( - host=self.config.cordra_url, #type: ignore - obj_id=object_id, - username=self.config.username, - password=self.config.password, - verify=self.config.verify_ssl, - full=True # Get full object details including metadata, paylods, etc. - ) + # Build URL: cordra_base_url/objects/prefix/postfix + url = f"{self.config.cordra_url}/objects/{object_id}" + + # Add full=true parameter to get complete object details + params = {"full": "true"} + + response = self.session.get(url, params=params, timeout=self.config.timeout) + + if response.status_code == 404: + raise CordraNotFoundError(f"Object not found: {object_id}") + + response.raise_for_status() + cordra_obj = response.json() return DigitalObject( id=object_id, - type=cordra_obj['type'], - content=cordra_obj['content'], + type=cordra_obj.get('type', ''), + content=cordra_obj.get('content', cordra_obj), metadata=cordra_obj.get('metadata'), acl=cordra_obj.get('acl'), payloads=cordra_obj.get('payloads'), ) + except CordraNotFoundError: + raise + except requests.RequestException as e: + raise CordraClientError(f"Failed to retrieve object {object_id}: {e}") from e except Exception as e: - error_msg = str(e).lower() - if 'not found' in error_msg or '404' in error_msg: - raise CordraNotFoundError(f"Object not found: {object_id}") from e raise CordraClientError(f"Failed to retrieve object {object_id}: {e}") from e async def find(self, query: str) -> list[dict[str, Any]]: @@ -91,19 +108,22 @@ class CordraClient: CordraClientError: If there's an API error """ try: - # Use CordraPy to find objects - # TODO - need to handle pagination, but the CordraPy API does not support it. - response: dict[str, Any] = cordra.CordraObject.find( - self.config.cordra_url, # type: ignore - query - ) + # Use HTTP GET request to search endpoint + url = f"{self.config.cordra_url}/search" + params = {"query": query} + + response = self.session.get(url, params=params, timeout=self.config.timeout) + response.raise_for_status() + + search_result = response.json() # Extract the results array from the response - if isinstance(response, dict) and 'results' in response: - return response['results'] + if isinstance(search_result, dict) and 'results' in search_result: + return search_result['results'] else: return [] - except Exception as e: + except requests.RequestException as e: raise CordraClientError(f"Failed to search with query '{query}': {e}") from e - + except Exception as e: + raise CordraClientError(f"Failed to search with query '{query}': {e}") from e \ No newline at end of file diff --git a/src/mcp_cordra/server.py b/src/mcp_cordra/server.py index fe92ca0..aad3921 100644 --- a/src/mcp_cordra/server.py +++ b/src/mcp_cordra/server.py @@ -59,8 +59,8 @@ async def list_cordra_schemas() -> str: # Extract the names from the schema objects schema_names = [] for schema in schemas: - if isinstance(schema, dict) and 'name' in schema: - schema_names.append(schema['name']) + if isinstance(schema, dict) and 'content' in schema and 'name' in schema['content']: + schema_names.append(schema['content']['name']) result = { "schemas": schema_names, diff --git a/tests/test_client.py b/tests/test_client.py index d129f2c..07c3466 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -112,10 +112,13 @@ class TestCordraClient: client = CordraClient(config) assert client.config == config - @patch('mcp_cordra.client.cordra.CordraObject.read') - async def test_get_object_success(self, mock_read, client, mock_cordra_object): + @patch('mcp_cordra.client.requests.Session.get') + async def test_get_object_success(self, mock_get, client, mock_cordra_object): """Test successful object retrieval.""" - mock_read.return_value = mock_cordra_object + mock_response = mock_get.return_value + mock_response.status_code = 200 + mock_response.json.return_value = mock_cordra_object + mock_response.raise_for_status.return_value = None result = await client.get_object("test/123") @@ -127,39 +130,38 @@ class TestCordraClient: assert result.acl == {"read": ["public"], "write": ["admin"]} assert result.payloads and len(result.payloads) == 2 - mock_read.assert_called_once_with( - host="https://test.example.com", - obj_id="test/123", - username="testuser", - password="testpass", - verify=False, - full=True + mock_get.assert_called_once_with( + "https://test.example.com/objects/test/123", + params={"full": "true"}, + timeout=30 ) - @patch('mcp_cordra.client.cordra.CordraObject.read') - async def test_get_object_not_found(self, mock_read, client): + @patch('mcp_cordra.client.requests.Session.get') + async def test_get_object_not_found(self, mock_get, client): """Test object not found exception.""" - mock_read.side_effect = Exception("Object not found") + mock_response = mock_get.return_value + mock_response.status_code = 404 with pytest.raises(CordraNotFoundError) as exc_info: await client.get_object("test/nonexistent") assert "Object not found: test/nonexistent" in str(exc_info.value) - @patch('mcp_cordra.client.cordra.CordraObject.read') - async def test_get_object_general_error(self, mock_read, client): + @patch('mcp_cordra.client.requests.Session.get') + async def test_get_object_general_error(self, mock_get, client): """Test general error handling.""" - mock_read.side_effect = Exception("Connection failed") + from requests import RequestException + mock_get.side_effect = RequestException("Connection failed") with pytest.raises(CordraClientError) as exc_info: await client.get_object("test/123") assert "Failed to retrieve object test/123" in str(exc_info.value) - @patch('mcp_cordra.client.cordra.CordraObject.find') - async def test_find_success(self, mock_find, client): + @patch('mcp_cordra.client.requests.Session.get') + async def test_find_success(self, mock_get, client): """Test successful find operation.""" - mock_response = { + mock_response_data = { "results": [ {"name": "User", "identifier": "test/user-schema"}, {"name": "Project", "identifier": "test/project-schema"}, @@ -167,7 +169,10 @@ class TestCordraClient: ], "size": 3 } - mock_find.return_value = mock_response + mock_response = mock_get.return_value + mock_response.status_code = 200 + mock_response.json.return_value = mock_response_data + mock_response.raise_for_status.return_value = None result = await client.find("type:Schema") @@ -176,39 +181,48 @@ class TestCordraClient: assert result[1]["name"] == "Project" assert result[2]["name"] == "Document" - mock_find.assert_called_once_with( - client.config.cordra_url, - "type:Schema" + mock_get.assert_called_once_with( + "https://test.example.com/search", + params={"query": "type:Schema"}, + timeout=30 ) - @patch('mcp_cordra.client.cordra.CordraObject.find') - async def test_find_empty_results(self, mock_find, client): + @patch('mcp_cordra.client.requests.Session.get') + async def test_find_empty_results(self, mock_get, client): """Test find with empty results.""" - mock_response = {"results": [], "size": 0} - mock_find.return_value = mock_response + mock_response_data = {"results": [], "size": 0} + mock_response = mock_get.return_value + mock_response.status_code = 200 + mock_response.json.return_value = mock_response_data + mock_response.raise_for_status.return_value = None result = await client.find("type:NonExistent") assert result == [] - mock_find.assert_called_once_with( - client.config.cordra_url, - "type:NonExistent" + mock_get.assert_called_once_with( + "https://test.example.com/search", + params={"query": "type:NonExistent"}, + timeout=30 ) - @patch('mcp_cordra.client.cordra.CordraObject.find') - async def test_find_no_results_key(self, mock_find, client): + @patch('mcp_cordra.client.requests.Session.get') + async def test_find_no_results_key(self, mock_get, client): """Test find with response missing results key.""" - mock_response = {"size": 0} # No results key - mock_find.return_value = mock_response + mock_response_data = {"size": 0} # No results key + mock_response = mock_get.return_value + mock_response.status_code = 200 + mock_response.json.return_value = mock_response_data + mock_response.raise_for_status.return_value = None result = await client.find("type:Schema") assert result == [] - @patch('mcp_cordra.client.cordra.CordraObject.find') - async def test_find_error(self, mock_find, client): + @patch('mcp_cordra.client.requests.Session.get') + async def test_find_error(self, mock_get, client): """Test find error handling.""" - mock_find.side_effect = Exception("Search failed") + from requests import RequestException + mock_get.side_effect = RequestException("Search failed") with pytest.raises(CordraClientError) as exc_info: await client.find("invalid:query") diff --git a/tests/test_server.py b/tests/test_server.py index fe056b4..3654624 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -152,10 +152,10 @@ class TestListCordraSchemas: async def test_list_schemas_success(self, mock_client): """Test successful schema listing.""" mock_schemas = [ - {"name": "User", "identifier": "test/user-schema"}, - {"name": "Project", "identifier": "test/project-schema"}, - {"name": "Document", "identifier": "test/doc-schema"}, - {"name": "CaptureEvent", "identifier": "test/capture-schema"} + {"content": {"name": "User"}, "identifier": "test/user-schema"}, + {"content": {"name": "Project"}, "identifier": "test/project-schema"}, + {"content": {"name": "Document"}, "identifier": "test/doc-schema"}, + {"content": {"name": "CaptureEvent"}, "identifier": "test/capture-schema"} ] mock_client.find = AsyncMock(return_value=mock_schemas) @@ -193,10 +193,10 @@ class TestListCordraSchemas: async def test_list_schemas_missing_name_field(self, mock_client): """Test schema listing with objects missing name field.""" mock_schemas = [ - {"name": "User", "identifier": "test/user-schema"}, - {"identifier": "test/no-name-schema"}, # Missing name field - {"name": "Project", "identifier": "test/project-schema"}, - {"other": "field"} # No name or identifier + {"content": {"name": "User"}, "identifier": "test/user-schema"}, + {"content": {}, "identifier": "test/no-name-schema"}, # Missing name field + {"content": {"name": "Project"}, "identifier": "test/project-schema"}, + {"content": {"other": "field"}} # No name or identifier ] mock_client.find = AsyncMock(return_value=mock_schemas) @@ -226,7 +226,7 @@ class TestListCordraSchemas: async def test_list_schemas_json_format(self, mock_client): """Test that the returned JSON is properly formatted.""" mock_schemas = [ - {"name": "TestSchema", "identifier": "test/schema"} + {"content": {"name": "TestSchema"}, "identifier": "test/schema"} ] mock_client.find = AsyncMock(return_value=mock_schemas) diff --git a/uv.lock b/uv.lock index a4686bd..ce57388 100644 --- a/uv.lock +++ b/uv.lock @@ -112,14 +112,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] -[[package]] -name = "cordrapy" -version = "0.3.2" -source = { git = "https://github.com/usnistgov/CordraPy.git#7d73f3f58461a2dc7540970fe7defaa361b61740" } -dependencies = [ - { name = "requests" }, -] - [[package]] name = "coverage" version = "7.9.1" @@ -309,12 +301,10 @@ name = "mcp-cordra" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "cordrapy" }, - { name = "httpx" }, { name = "mcp" }, { name = "pydantic" }, { name = "pydantic-settings" }, - { name = "setuptools" }, + { name = "requests" }, ] [package.optional-dependencies] @@ -334,8 +324,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cordrapy", git = "https://github.com/usnistgov/CordraPy.git" }, - { name = "httpx", specifier = ">=0.25.0" }, { name = "loguru", marker = "extra == 'dev'", specifier = ">=0.7.0" }, { name = "mcp", specifier = ">=1.2.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.6.0" }, @@ -344,8 +332,8 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "requests", specifier = ">=2.25.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "setuptools", specifier = ">=80.9.0" }, ] provides-extras = ["dev"] @@ -711,15 +699,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209 }, ] -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, -] - [[package]] name = "sniffio" version = "1.3.1"