codeocean-mcp-server 0.7.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/CHANGELOG.md +6 -1
  2. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/PKG-INFO +1 -1
  3. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/pyproject.toml +8 -7
  4. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/src/codeocean_mcp_server/models.py +38 -27
  5. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/src/codeocean_mcp_server/server.py +2 -5
  6. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/src/codeocean_mcp_server/tools/capsules.py +4 -10
  7. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/src/codeocean_mcp_server/tools/computations.py +3 -11
  8. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/src/codeocean_mcp_server/tools/data_assets.py +1 -5
  9. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/tests/bedrock_tools_converter.py +1 -5
  10. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/tests/test_multi_tool_calling.py +1 -1
  11. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/tests/test_tool_calling.py +4 -13
  12. codeocean_mcp_server-0.7.1/uv.lock +3210 -0
  13. codeocean_mcp_server-0.7.0/uv.lock +0 -2482
  14. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/.gitignore +0 -0
  15. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/.python-version +0 -0
  16. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/LICENSE +0 -0
  17. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/README.md +0 -0
  18. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/RELEASE.md +0 -0
  19. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/src/codeocean_mcp_server/__init__.py +0 -0
  20. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/src/codeocean_mcp_server/file_utils.py +0 -0
  21. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/src/codeocean_mcp_server/tools/__init__.py +0 -0
  22. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/src/codeocean_mcp_server/tools/custom_metadata.py +0 -0
  23. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/tests/bedrock_call.py +0 -0
  24. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.1}/tests/mcp_client.py +0 -0
@@ -1,7 +1,12 @@
1
1
  CHANGELOG
2
2
  =========
3
3
 
4
- ## 0.7.0 (2025-10-21)
4
+ ## 0.7.1 (2024-12-23)
5
+
6
+ - [#18](https://github.com/codeocean/codeocean-mcp-server/pull/18) chore: ruff format and dep update
7
+ - [#17](https://github.com/codeocean/codeocean-mcp-server/pull/17) fix: Handle dataclasses.MISSING properly in dataclass_to_pydantic
8
+
9
+ ## 0.7.0 (2024-10-21)
5
10
 
6
11
  - [#15](https://github.com/codeocean/codeocean-mcp-server/pull/15) feat: update codeocean sdk and search_pipelines tool
7
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeocean-mcp-server
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Code Ocean MCP Server
5
5
  Project-URL: Homepage, https://github.com/codeocean/codeocean-mcp-server
6
6
  Project-URL: Issues, https://github.com/codeocean/codeocean-mcp-server/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codeocean-mcp-server"
3
- version = "0.7.0"
3
+ version = "0.7.1"
4
4
  authors = [{ name = "Code Ocean", email = "dev@codeocean.com" }]
5
5
  description = "Code Ocean MCP Server"
6
6
  readme = "README.md"
@@ -18,12 +18,12 @@ license = "MIT"
18
18
 
19
19
  [dependency-groups]
20
20
  dev = [
21
- "boto3>=1.39.0",
22
- "deepdiff>=8.5.0",
23
- "hatch>=1.14.1",
21
+ "boto3>=1.42.16",
22
+ "deepdiff>=8.6.1",
23
+ "hatch>=1.16.2",
24
24
  "mcp-python-client>=0.1.9",
25
- "pytest>=8.4.1",
26
- "ruff>=0.12.1",
25
+ "pytest>=9.0.2",
26
+ "ruff>=0.14.10",
27
27
  ]
28
28
 
29
29
  [project.urls]
@@ -58,7 +58,8 @@ line-length = 120
58
58
 
59
59
  [tool.ruff.lint]
60
60
  select = ["C", "D", "E", "F", "I", "W"]
61
- ignore = ["D100", "D104"]
61
+ ignore = ["D100", "D104", "D203", "D213"]
62
+
62
63
 
63
64
  [tool.pytest.ini_options]
64
65
  filterwarnings = [
@@ -1,12 +1,40 @@
1
- from dataclasses import fields, is_dataclass
1
+ from dataclasses import MISSING, fields, is_dataclass
2
+ from dataclasses import Field as DataclassField
2
3
  from typing import Any, List, Type, get_args, get_origin, get_type_hints
3
4
 
4
5
  from pydantic import BaseModel, Field, create_model
5
6
 
6
7
 
7
- def dataclass_to_pydantic(
8
- data_class: Type[Any], cache: dict[Type[Any], Type[BaseModel]] = None
9
- ) -> Type[BaseModel]:
8
+ def _get_field_info(field: DataclassField) -> Any:
9
+ """Get Pydantic field info from dataclass field.
10
+
11
+ Args:
12
+ field: Dataclass field to convert
13
+
14
+ Returns:
15
+ Pydantic field info (Field, Ellipsis, or default value)
16
+
17
+ """
18
+ default = field.default
19
+ has_description = field.metadata and "description" in field.metadata
20
+
21
+ if has_description:
22
+ description = field.metadata["description"]
23
+ if default is MISSING:
24
+ # Required field with description
25
+ return Field(description=description)
26
+ else:
27
+ # Optional field with default and description
28
+ return Field(default=default, description=description)
29
+ elif default is MISSING:
30
+ # Required field without description
31
+ return ...
32
+ else:
33
+ # Optional field with default but no description
34
+ return default
35
+
36
+
37
+ def dataclass_to_pydantic(data_class: Type[Any], cache: dict[Type[Any], Type[BaseModel]] = None) -> Type[BaseModel]:
10
38
  """Convert a dataclass to Pydantic model.
11
39
 
12
40
  Recursively convert a frozen @dataclass (and nested dataclasses)
@@ -17,23 +45,16 @@ def dataclass_to_pydantic(
17
45
  cache = {}
18
46
  if data_class in cache:
19
47
  return cache[data_class]
20
- assert is_dataclass(data_class), (
21
- f"{data_class.__name__} is not a dataclass"
22
- )
48
+ assert is_dataclass(data_class), f"{data_class.__name__} is not a dataclass"
23
49
 
24
50
  # 1) Resolve all annotations to real types (no strings)
25
- module_ns = vars(
26
- __import__(data_class.__module__, fromlist=["*"])
27
- )
28
- type_hints = get_type_hints(
29
- data_class, globalns=module_ns, localns=module_ns
30
- )
51
+ module_ns = vars(__import__(data_class.__module__, fromlist=["*"]))
52
+ type_hints = get_type_hints(data_class, globalns=module_ns, localns=module_ns)
31
53
 
32
54
  definitions: dict[str, tuple[type, Any]] = {}
33
55
  for field in fields(data_class):
34
56
  # Use the evaluated hint if available, else the raw annotation
35
57
  typ = type_hints.get(field.name, field.type)
36
- default = field.default
37
58
  field_type = typ
38
59
  origin = get_origin(typ)
39
60
  args = get_args(typ)
@@ -48,23 +69,13 @@ def dataclass_to_pydantic(
48
69
  nested_model = dataclass_to_pydantic(args[0], cache)
49
70
  field_type = List[nested_model]
50
71
 
51
- # 4) Handle field with description from metadata
52
- field_info = default
53
- if field.metadata and "description" in field.metadata:
54
- # Create a Pydantic Field with description
55
- field_info = Field(
56
- default=default, description=field.metadata["description"]
57
- )
72
+ # 4) Get Pydantic field info
73
+ field_info = _get_field_info(field)
58
74
 
59
75
  definitions[field.name] = (field_type, field_info)
60
76
 
61
77
  # 5) Dynamically create the Pydantic model
62
- model = create_model(
63
- f"{data_class.__name__}Model",
64
- __base__=BaseModel,
65
- __doc__=data_class.__doc__,
66
- **definitions
67
- )
78
+ model = create_model(f"{data_class.__name__}Model", __base__=BaseModel, __doc__=data_class.__doc__, **definitions)
68
79
 
69
80
  # 6) Override the schema generation to include description from docstring
70
81
  if data_class.__doc__:
@@ -16,17 +16,14 @@ def main():
16
16
  domain = os.getenv("CODEOCEAN_DOMAIN")
17
17
  token = os.getenv("CODEOCEAN_TOKEN")
18
18
  if not domain or not token:
19
- raise ValueError(
20
- "Environment variables CODEOCEAN_DOMAIN and " "CODEOCEAN_TOKEN must be set."
21
- )
19
+ raise ValueError("Environment variables CODEOCEAN_DOMAIN and CODEOCEAN_TOKEN must be set.")
22
20
  agent_id = os.getenv("AGENT_ID", "AI Agent")
23
21
  client = CodeOcean(domain=domain, token=token, agent_id=agent_id)
24
22
 
25
23
  mcp = FastMCP(
26
24
  name="Code Ocean",
27
25
  instructions=(
28
- f"MCP server for Code Ocean: search & run capsules, "
29
- f"pipelines, and assets using Code Ocean domain {domain}."
26
+ f"MCP server for Code Ocean: search & run capsules, pipelines, and assets using Code Ocean domain {domain}."
30
27
  ),
31
28
  )
32
29
 
@@ -22,8 +22,7 @@ def add_tools(mcp: FastMCP, client: CodeOcean):
22
22
 
23
23
  @mcp.tool(
24
24
  description=(
25
- str(client.capsules.search_capsules.__doc__)
26
- + "Use only for capsule searches. "
25
+ str(client.capsules.search_capsules.__doc__) + "Use only for capsule searches. "
27
26
  "Provide only the minimal required parameters (e.g. limit=10); "
28
27
  "do not include optional params "
29
28
  "like sort_by or sort_order unless requested."
@@ -36,8 +35,7 @@ def add_tools(mcp: FastMCP, client: CodeOcean):
36
35
 
37
36
  @mcp.tool(
38
37
  description=(
39
- str(client.capsules.search_pipelines.__doc__)
40
- + "Use only for pipeline searches. "
38
+ str(client.capsules.search_pipelines.__doc__) + "Use only for pipeline searches. "
41
39
  "Provide only the minimal required parameters (e.g. limit=10); "
42
40
  "do not include optional params "
43
41
  "like sort_by or sort_order unless requested."
@@ -60,16 +58,12 @@ def add_tools(mcp: FastMCP, client: CodeOcean):
60
58
  attach_params: list[DataAssetAttachParamsModel],
61
59
  ) -> list[DataAssetAttachResults]:
62
60
  """Attach data assets to a capsule."""
63
- params = [
64
- DataAssetAttachParams(**p.model_dump(exclude_none=True))
65
- for p in attach_params
66
- ]
61
+ params = [DataAssetAttachParams(**p.model_dump(exclude_none=True)) for p in attach_params]
67
62
  return client.capsules.attach_data_assets(capsule_id, params)
68
63
 
69
64
  @mcp.tool(
70
65
  description=(
71
- str(client.capsules.get_capsule.__doc__)
72
- + "Use only to fetch metadata for a known capsule ID. "
66
+ str(client.capsules.get_capsule.__doc__) + "Use only to fetch metadata for a known capsule ID. "
73
67
  "Do not use for searching."
74
68
  )
75
69
  )
@@ -23,8 +23,7 @@ def add_tools(mcp: FastMCP, client: CodeOcean):
23
23
 
24
24
  @mcp.tool(
25
25
  description=(
26
- str(client.computations.run_capsule.__doc__)
27
- + "Typical workflow: 1) run_capsule() to start execution "
26
+ str(client.computations.run_capsule.__doc__) + "Typical workflow: 1) run_capsule() to start execution "
28
27
  "2) wait_until_completed() to monitor progress "
29
28
  "3) list_computation_results() and get_result_file_urls() "
30
29
  "to retrieve outputs."
@@ -41,28 +40,21 @@ def add_tools(mcp: FastMCP, client: CodeOcean):
41
40
  computation = client.computations.get_computation(computation_id)
42
41
  return client.computations.wait_until_completed(computation)
43
42
 
44
-
45
43
  @mcp.tool(
46
44
  description=(
47
- str(client.computations.list_computation_results.__doc__)
48
- + " computation_id is required as string"
45
+ str(client.computations.list_computation_results.__doc__) + " computation_id is required as string"
49
46
  )
50
47
  )
51
48
  def list_computation_results(computation_id: str) -> Folder:
52
49
  """List the output files generated by a completed computation."""
53
50
  return client.computations.list_computation_results(computation_id)
54
51
 
55
-
56
52
  @mcp.tool(description=(client.computations.get_result_file_urls.__doc__))
57
53
  def get_result_file_urls(computation_id: str, file_path: str) -> FileURLs:
58
54
  """Get view and download URLs for a specific result file from computation."""
59
55
  return client.computations.get_result_file_urls(computation_id, file_path)
60
56
 
61
- @mcp.tool(
62
- description=(
63
- "Use when you want to read the content of a file from a computation"
64
- )
65
- )
57
+ @mcp.tool(description=("Use when you want to read the content of a file from a computation"))
66
58
  def download_and_read_a_file_from_computation(computation_id: str, file_path: str) -> str:
67
59
  """Download a file using the provided URL and return its content."""
68
60
  file_urls = client.computations.get_result_file_urls(computation_id, file_path)
@@ -50,11 +50,7 @@ def add_tools(mcp: FastMCP, client: CodeOcean):
50
50
  """Get view and download URLs for a specific file in a data asset."""
51
51
  return client.data_assets.get_data_asset_file_urls(data_asset_id, file_path)
52
52
 
53
- @mcp.tool(
54
- description=(
55
- "Use when you want to read the content of a file from a data asset"
56
- )
57
- )
53
+ @mcp.tool(description=("Use when you want to read the content of a file from a data asset"))
58
54
  def download_and_read_a_file_from_data_asset(data_asset_id: str, file_path: str) -> str:
59
55
  """Download a file using the provided URL and return its content."""
60
56
  file_urls = client.data_assets.get_data_asset_file_urls(data_asset_id, file_path)
@@ -11,8 +11,6 @@ from mcp.types import Tool
11
11
  _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
12
12
 
13
13
 
14
-
15
-
16
14
  def prune_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
17
15
  """Recursively prune forbidden keys from a schema."""
18
16
  forbidden_keys = {"anyOf", "$schema", "$ref"}
@@ -47,9 +45,7 @@ def validate_schema(schema: Dict[str, Any]) -> None:
47
45
  Draft202012Validator.check_schema(schema)
48
46
 
49
47
 
50
- def convert_tool_format(
51
- tools: List[Tool], model: str
52
- ) -> Dict[str, List[Dict[str, Any]]]:
48
+ def convert_tool_format(tools: List[Tool], model: str) -> Dict[str, List[Dict[str, Any]]]:
53
49
  """Convert MCP tools into a Bedrock Draft 2020-12-compliant toolConfig."""
54
50
  # For other models, we need to convert tools to the Bedrock format
55
51
  converted = []
@@ -36,7 +36,7 @@ def check_diff(expected_response: dict, response: dict) -> None:
36
36
 
37
37
 
38
38
  def test_prompt_generates_correct_tool_usage_sequence(
39
- agentic_prompt_chain:dict = agentic_prompt_chain,
39
+ agentic_prompt_chain: dict = agentic_prompt_chain,
40
40
  ):
41
41
  """Test that the prompt generates the expected sequence of tool calls in the correct order."""
42
42
  responses = []
@@ -67,9 +67,7 @@ test_response = [
67
67
  "prompt": "get capsules - filter capsules where tags = 'brain'",
68
68
  "expected": {
69
69
  "name": "search_capsules",
70
- "input": {
71
- "search_params": {"filters": [{"key": "tags", "value": "brain"}]}
72
- },
70
+ "input": {"search_params": {"filters": [{"key": "tags", "value": "brain"}]}},
73
71
  },
74
72
  },
75
73
  # --------------------------------------------------------------
@@ -193,9 +191,7 @@ test_response = [
193
191
  "prompt": "get data assets - filter data assets where tags = 'genomics'",
194
192
  "expected": {
195
193
  "name": "search_data_assets",
196
- "input": {
197
- "search_params": {"filters": [{"key": "tags", "value": "genomics"}]}
198
- },
194
+ "input": {"search_params": {"filters": [{"key": "tags", "value": "genomics"}]}},
199
195
  },
200
196
  },
201
197
  {
@@ -219,10 +215,7 @@ test_response = [
219
215
  },
220
216
  {
221
217
  "id": "search_data_assets_complex_external_ml",
222
- "prompt": (
223
- "find 15 external data assets that, "
224
- "sorted by size descending, query for 'machine learning'"
225
- ),
218
+ "prompt": ("find 15 external data assets that, sorted by size descending, query for 'machine learning'"),
226
219
  "expected": {
227
220
  "name": "search_data_assets",
228
221
  "input": {
@@ -407,9 +400,7 @@ test_response = [
407
400
  "name": "update_metadata",
408
401
  "input": {
409
402
  "data_asset_id": "data_asset102",
410
- "update_params": {
411
- "custom_metadata": {"key1": "value1", "key2": "value2"}
412
- },
403
+ "update_params": {"custom_metadata": {"key1": "value1", "key2": "value2"}},
413
404
  },
414
405
  },
415
406
  },