codeocean-mcp-server 0.7.0__tar.gz → 0.7.2__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 (25) hide show
  1. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/CHANGELOG.md +10 -1
  2. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/PKG-INFO +7 -1
  3. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/README.md +6 -0
  4. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/pyproject.toml +8 -7
  5. codeocean_mcp_server-0.7.2/src/codeocean_mcp_server/logging_config.py +47 -0
  6. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/src/codeocean_mcp_server/models.py +38 -27
  7. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/src/codeocean_mcp_server/server.py +4 -5
  8. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/src/codeocean_mcp_server/tools/capsules.py +4 -10
  9. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/src/codeocean_mcp_server/tools/computations.py +3 -11
  10. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/src/codeocean_mcp_server/tools/data_assets.py +1 -5
  11. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/tests/bedrock_tools_converter.py +1 -5
  12. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/tests/test_multi_tool_calling.py +1 -1
  13. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/tests/test_tool_calling.py +4 -13
  14. codeocean_mcp_server-0.7.2/uv.lock +3210 -0
  15. codeocean_mcp_server-0.7.0/uv.lock +0 -2482
  16. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/.gitignore +0 -0
  17. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/.python-version +0 -0
  18. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/LICENSE +0 -0
  19. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/RELEASE.md +0 -0
  20. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/src/codeocean_mcp_server/__init__.py +0 -0
  21. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/src/codeocean_mcp_server/file_utils.py +0 -0
  22. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/src/codeocean_mcp_server/tools/__init__.py +0 -0
  23. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/src/codeocean_mcp_server/tools/custom_metadata.py +0 -0
  24. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/tests/bedrock_call.py +0 -0
  25. {codeocean_mcp_server-0.7.0 → codeocean_mcp_server-0.7.2}/tests/mcp_client.py +0 -0
@@ -1,7 +1,16 @@
1
1
  CHANGELOG
2
2
  =========
3
3
 
4
- ## 0.7.0 (2025-10-21)
4
+ ## 0.7.2 (2025-12-27)
5
+
6
+ - [#20](https://github.com/codeocean/codeocean-mcp-server/pull/20) feat: custom log formatting
7
+
8
+ ## 0.7.1 (2024-12-23)
9
+
10
+ - [#18](https://github.com/codeocean/codeocean-mcp-server/pull/18) chore: ruff format and dep update
11
+ - [#17](https://github.com/codeocean/codeocean-mcp-server/pull/17) fix: Handle dataclasses.MISSING properly in dataclass_to_pydantic
12
+
13
+ ## 0.7.0 (2024-10-21)
5
14
 
6
15
  - [#15](https://github.com/codeocean/codeocean-mcp-server/pull/15) feat: update codeocean sdk and search_pipelines tool
7
16
 
@@ -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.2
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
@@ -228,3 +228,9 @@ This will start a web server where you can:
228
228
  - View available tools and resources
229
229
  - Test tool calls interactively
230
230
  - See server logs and responses
231
+
232
+ ## Log Formatting (Optional)
233
+
234
+ The MCP server supports custom log formatting through the `LOG_FORMAT` environment variable. This allows you to control the format of log messages output by the server.
235
+ **Example Format Strings:** `"%(asctime)s %(levelname)s [%(name)s] %(message)s"`.
236
+ If `LOG_FORMAT` is not set, the server uses FastMCP's default logging configuration.
@@ -210,3 +210,9 @@ This will start a web server where you can:
210
210
  - View available tools and resources
211
211
  - Test tool calls interactively
212
212
  - See server logs and responses
213
+
214
+ ## Log Formatting (Optional)
215
+
216
+ The MCP server supports custom log formatting through the `LOG_FORMAT` environment variable. This allows you to control the format of log messages output by the server.
217
+ **Example Format Strings:** `"%(asctime)s %(levelname)s [%(name)s] %(message)s"`.
218
+ If `LOG_FORMAT` is not set, the server uses FastMCP's default logging configuration.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codeocean-mcp-server"
3
- version = "0.7.0"
3
+ version = "0.7.2"
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.17",
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 = [
@@ -0,0 +1,47 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+
5
+
6
+ def configure_logging() -> None:
7
+ """Configure logging based on LOG_FORMAT environment variable.
8
+
9
+ If LOG_FORMAT is set, configures the root logger with a StreamHandler
10
+ using the specified format string. This must be called before FastMCP
11
+ initialization to ensure our configuration takes precedence.
12
+
13
+ If LOG_FORMAT is not set or is empty, does nothing and lets FastMCP
14
+ configure logging with its default settings.
15
+
16
+ Environment variables:
17
+ LOG_FORMAT: Python logging format string (optional)
18
+
19
+ Examples:
20
+ - "%(asctime)s agent %(levelname)s [%(name)s] %(message)s"
21
+ - "%(levelname)s: %(message)s"
22
+ - "[%(name)s] %(message)s"
23
+
24
+ Note:
25
+ Invalid format strings will cause errors when log records are formatted,
26
+ not during initialization. This typically results in ValueError, KeyError,
27
+ or AttributeError being raised when logging occurs.
28
+
29
+ """
30
+ log_format = os.getenv("LOG_FORMAT", "").strip()
31
+
32
+ # If LOG_FORMAT is not set or empty, do nothing
33
+ if not log_format:
34
+ return
35
+
36
+ # Create handler for stderr (same as FastMCP default)
37
+ handler = logging.StreamHandler(sys.stderr)
38
+
39
+ # Create formatter with the specified format string
40
+ # This will raise an error if the format string is invalid (fail fast)
41
+ formatter = logging.Formatter(log_format)
42
+ handler.setFormatter(formatter)
43
+
44
+ # Configure root logger
45
+ # This must be done before FastMCP calls logging.basicConfig()
46
+ logging.root.addHandler(handler)
47
+ logging.root.setLevel(logging.INFO)
@@ -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__:
@@ -3,6 +3,7 @@ import os
3
3
  from codeocean import CodeOcean
4
4
  from mcp.server.fastmcp import FastMCP
5
5
 
6
+ from codeocean_mcp_server.logging_config import configure_logging
6
7
  from codeocean_mcp_server.tools import (
7
8
  capsules,
8
9
  computations,
@@ -13,20 +14,18 @@ from codeocean_mcp_server.tools import (
13
14
 
14
15
  def main():
15
16
  """Run the MCP server."""
17
+ configure_logging()
16
18
  domain = os.getenv("CODEOCEAN_DOMAIN")
17
19
  token = os.getenv("CODEOCEAN_TOKEN")
18
20
  if not domain or not token:
19
- raise ValueError(
20
- "Environment variables CODEOCEAN_DOMAIN and " "CODEOCEAN_TOKEN must be set."
21
- )
21
+ raise ValueError("Environment variables CODEOCEAN_DOMAIN and CODEOCEAN_TOKEN must be set.")
22
22
  agent_id = os.getenv("AGENT_ID", "AI Agent")
23
23
  client = CodeOcean(domain=domain, token=token, agent_id=agent_id)
24
24
 
25
25
  mcp = FastMCP(
26
26
  name="Code Ocean",
27
27
  instructions=(
28
- f"MCP server for Code Ocean: search & run capsules, "
29
- f"pipelines, and assets using Code Ocean domain {domain}."
28
+ f"MCP server for Code Ocean: search & run capsules, pipelines, and assets using Code Ocean domain {domain}."
30
29
  ),
31
30
  )
32
31
 
@@ -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
  },