codeocean-mcp-server 0.12.2__tar.gz → 0.13.0__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 (31) hide show
  1. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/CHANGELOG.md +7 -0
  2. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/PKG-INFO +4 -4
  3. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/README.md +1 -1
  4. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/pyproject.toml +4 -3
  5. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/tools/capsules.py +47 -1
  6. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/tools/data_assets.py +6 -0
  7. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/tests/test_http_transport.py +49 -21
  8. codeocean_mcp_server-0.13.0/uv.lock +3842 -0
  9. codeocean_mcp_server-0.12.2/uv.lock +0 -3274
  10. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/.gitignore +0 -0
  11. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/.python-version +0 -0
  12. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/LICENSE +0 -0
  13. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/RELEASE.md +0 -0
  14. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/__init__.py +0 -0
  15. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/client.py +0 -0
  16. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/file_utils.py +0 -0
  17. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/logging_config.py +0 -0
  18. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/models.py +0 -0
  19. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/search.py +0 -0
  20. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/server.py +0 -0
  21. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/tools/__init__.py +0 -0
  22. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/tools/computations.py +0 -0
  23. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/src/codeocean_mcp_server/tools/custom_metadata.py +0 -0
  24. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/tests/bedrock_call.py +0 -0
  25. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/tests/bedrock_tools_converter.py +0 -0
  26. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/tests/conftest.py +0 -0
  27. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/tests/mcp_client.py +0 -0
  28. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/tests/test_logging_config.py +0 -0
  29. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/tests/test_multi_tool_calling.py +0 -0
  30. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/tests/test_search.py +0 -0
  31. {codeocean_mcp_server-0.12.2 → codeocean_mcp_server-0.13.0}/tests/test_tool_calling.py +0 -0
@@ -1,6 +1,13 @@
1
1
  CHANGELOG
2
2
  =========
3
3
 
4
+ ## 0.13.0 (2026-09-16)
5
+
6
+ - [#46](https://github.com/codeocean/codeocean-mcp-server/pull/46) fix: bump mcp to 1.29.x and refresh locked dependencies
7
+ - [#37](https://github.com/codeocean/codeocean-mcp-server/pull/37) feat: add capsule and pipeline release tools
8
+ - [#47](https://github.com/codeocean/codeocean-mcp-server/pull/47) feat: add permissions and Git sync tools
9
+ - **Minimum Code Ocean platform version updated to `4.8.0`.**
10
+
4
11
  ## 0.12.2 (2026-09-03)
5
12
 
6
13
  - [#42](https://github.com/codeocean/codeocean-mcp-server/pull/42) fix: run synchronous tool bodies in a worker thread
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: codeocean-mcp-server
3
- Version: 0.12.2
3
+ Version: 0.13.0
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
@@ -13,15 +13,15 @@ Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Requires-Python: >=3.10
15
15
  Requires-Dist: anyio>=4.5
16
- Requires-Dist: codeocean<0.15.0,>=0.14.0
17
- Requires-Dist: mcp<1.24.0,>=1.23.0
16
+ Requires-Dist: codeocean<0.18.0,>=0.17.0
17
+ Requires-Dist: mcp<1.30.0,>=1.29.1
18
18
  Description-Content-Type: text/markdown
19
19
 
20
20
  # Code Ocean MCP Server
21
21
 
22
22
  Model Context Protocol (MCP) server for Code Ocean.
23
23
 
24
- This MCP server provides tools to search and run capsules and pipelines, and manage data assets.
24
+ This MCP server provides tools to search, run, and release capsules and pipelines, and manage data assets.
25
25
 
26
26
  ## Table of Contents
27
27
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Model Context Protocol (MCP) server for Code Ocean.
4
4
 
5
- This MCP server provides tools to search and run capsules and pipelines, and manage data assets.
5
+ This MCP server provides tools to search, run, and release capsules and pipelines, and manage data assets.
6
6
 
7
7
  ## Table of Contents
8
8
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codeocean-mcp-server"
3
- version = "0.12.2"
3
+ version = "0.13.0"
4
4
  authors = [{ name = "Code Ocean", email = "dev@codeocean.com" }]
5
5
  description = "Code Ocean MCP Server"
6
6
  readme = "README.md"
@@ -12,8 +12,8 @@ classifiers = [
12
12
  ]
13
13
  dependencies = [
14
14
  "anyio>=4.5",
15
- "codeocean>=0.14.0,<0.15.0",
16
- "mcp>=1.23.0,<1.24.0",
15
+ "codeocean>=0.17.0,<0.18.0",
16
+ "mcp>=1.29.1,<1.30.0",
17
17
  ]
18
18
  license = "MIT"
19
19
 
@@ -22,6 +22,7 @@ dev = [
22
22
  "boto3>=1.42.17",
23
23
  "deepdiff>=8.6.1",
24
24
  "hatch>=1.16.2",
25
+ "httpx>=0.28.1",
25
26
  "mcp-python-client>=0.1.9",
26
27
  "pytest>=9.0.2",
27
28
  "ruff>=0.14.10",
@@ -2,11 +2,14 @@ from codeocean import CodeOcean
2
2
  from codeocean.capsule import (
3
3
  AppPanel,
4
4
  Capsule,
5
+ CapsuleReleaseJob,
5
6
  CapsuleSearchParams,
6
7
  Computation,
7
8
  DataAssetAttachParams,
8
9
  DataAssetAttachResults,
10
+ GitSyncResults,
9
11
  )
12
+ from codeocean.components import Permissions
10
13
  from mcp.server.fastmcp import FastMCP
11
14
 
12
15
  from codeocean_mcp_server.models import dataclass_to_pydantic
@@ -17,7 +20,7 @@ CapsuleSearchParamsModel = dataclass_to_pydantic(CapsuleSearchParams)
17
20
  DataAssetAttachParamsModel = dataclass_to_pydantic(DataAssetAttachParams)
18
21
 
19
22
 
20
- def add_tools(mcp: FastMCP, client: CodeOcean):
23
+ def add_tools(mcp: FastMCP, client: CodeOcean): # noqa: C901
21
24
  """Add capsule tools to the MCP server."""
22
25
 
23
26
  @mcp.tool(description=(str(client.capsules.search_capsules.__doc__) + " " + str(CapsuleSearchResults.__doc__)))
@@ -86,3 +89,46 @@ def add_tools(mcp: FastMCP, client: CodeOcean):
86
89
  def get_capsule_app_panel(capsule_id: str, version: int | None = None) -> AppPanelModel:
87
90
  """Retrieve the app panel for a capsule, optionally for a specific version."""
88
91
  return client.capsules.get_capsule_app_panel(capsule_id, version)
92
+
93
+ @mcp.tool(description=(str(client.capsules.get_permissions.__doc__) + " Accepts a capsule ID or a pipeline ID."))
94
+ def get_capsule_permissions(capsule_id: str) -> Permissions:
95
+ """Get the users, groups and everyone-role permissions of a capsule or pipeline."""
96
+ return client.capsules.get_permissions(capsule_id)
97
+
98
+ @mcp.tool(
99
+ description=(
100
+ str(client.capsules.sync_capsule.__doc__)
101
+ + " Accepts a capsule ID or a pipeline ID. This pushes and pulls commits against the external"
102
+ " Git remote, so call it only when the user asked to sync."
103
+ )
104
+ )
105
+ def sync_capsule(capsule_id: str) -> GitSyncResults:
106
+ """Sync a capsule or pipeline with its linked external Git repository."""
107
+ return client.capsules.sync_capsule(capsule_id)
108
+
109
+ @mcp.tool(description=(str(client.capsules.release_capsule.__doc__) + " Accepts a capsule ID or a pipeline ID."))
110
+ def release_capsule(capsule_id: str) -> CapsuleReleaseJob:
111
+ """Start releasing a new version of an already-released capsule or pipeline."""
112
+ return client.capsules.release_capsule(capsule_id)
113
+
114
+ @mcp.tool(description=(str(client.capsules.get_release_job.__doc__) + " Accepts a capsule ID or a pipeline ID."))
115
+ def get_release_job(capsule_id: str, job_id: str) -> CapsuleReleaseJob:
116
+ """Get the status of a capsule or pipeline release job."""
117
+ return client.capsules.get_release_job(capsule_id, job_id)
118
+
119
+ @mcp.tool(
120
+ description=(
121
+ str(client.capsules.wait_until_release_completed.__doc__)
122
+ + " Accepts a capsule ID or a pipeline ID. Set `timeout` to bound the wait - without one this"
123
+ " polls until the release reaches a terminal state, however long that takes."
124
+ )
125
+ )
126
+ def wait_until_release_completed(
127
+ capsule_id: str,
128
+ job_id: str,
129
+ polling_interval: float = 5,
130
+ timeout: float | None = None,
131
+ ) -> CapsuleReleaseJob:
132
+ """Wait until a capsule or pipeline release job reaches a terminal state."""
133
+ job = client.capsules.get_release_job(capsule_id, job_id)
134
+ return client.capsules.wait_until_release_completed(capsule_id, job, polling_interval, timeout)
@@ -1,6 +1,7 @@
1
1
  import os
2
2
 
3
3
  from codeocean import CodeOcean
4
+ from codeocean.components import Permissions
4
5
  from codeocean.data_asset import (
5
6
  DataAsset,
6
7
  DataAssetParams,
@@ -62,6 +63,11 @@ def add_tools(mcp: FastMCP, client: CodeOcean):
62
63
  file_urls = client.data_assets.get_data_asset_file_urls(data_asset_id, file_path)
63
64
  return download_and_read_file(file_urls.download_url)
64
65
 
66
+ @mcp.tool(description=client.data_assets.get_permissions.__doc__)
67
+ def get_data_asset_permissions(data_asset_id: str) -> Permissions:
68
+ """Get the users, groups and everyone-role permissions of a data asset."""
69
+ return client.data_assets.get_permissions(data_asset_id)
70
+
65
71
  @mcp.tool(description=client.data_assets.list_data_asset_files.__doc__)
66
72
  def list_data_asset_files(data_asset_id: str, path: str = "") -> Folder:
67
73
  """List files in a data asset."""
@@ -16,24 +16,40 @@ import time
16
16
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
17
  from pathlib import Path
18
18
 
19
+ import httpx
19
20
  import pytest
20
21
  from mcp import ClientSession, StdioServerParameters
21
- from mcp.client.streamable_http import streamablehttp_client
22
+ from mcp.client.streamable_http import streamable_http_client
22
23
  from mcp_client import get_tools
23
24
 
24
25
  SERVER_SCRIPT_PATH = str(Path(__file__).parent.parent / "src" / "codeocean_mcp_server" / "server.py")
25
26
 
26
- # How long the stub API takes to answer a token named "slow-...", standing in for a tool that waits.
27
- SLOW_API_SECONDS = 3.0
27
+ # Upper bound on the test's own waits, so a regression fails the test instead of hanging it.
28
+ RENDEZVOUS_TIMEOUT = 5.0
29
+
30
+ # The stub API's escape hatch from a release that never comes, far enough past the test's own waits
31
+ # that it cannot be what frees the server first.
32
+ HELD_REQUEST_TIMEOUT = 60.0
28
33
 
29
34
 
30
35
  class _EchoTokenHandler(BaseHTTPRequestHandler):
31
- """Answer the custom metadata endpoint with the basic-auth user, which is the API token."""
36
+ """Answer the custom metadata endpoint with the basic-auth user, which is the API token.
37
+
38
+ A token named "slow-..." stands in for a tool that waits on Code Ocean: the handler reports that
39
+ the request has arrived, then holds the response until the test releases it. Only a released
40
+ request is answered, so the test cannot mistake a lapsed hold for a server that stayed free.
41
+ """
42
+
43
+ slow_started = threading.Event()
44
+ slow_released = threading.Event()
32
45
 
33
46
  def do_GET(self): # noqa: D102, N802
34
47
  user = base64.b64decode(self.headers["Authorization"].split(" ")[1]).decode().split(":")[0]
35
48
  if user.startswith("slow-"):
36
- time.sleep(SLOW_API_SECONDS)
49
+ self.slow_started.set()
50
+ if not self.slow_released.wait(HELD_REQUEST_TIMEOUT):
51
+ self.send_error(500, "the held request was never released")
52
+ return
37
53
  body = json.dumps({"categories": [user]}).encode()
38
54
  self.send_response(200)
39
55
  self.send_header("Content-Type", "application/json")
@@ -94,10 +110,11 @@ def _stdio_server(domain: str) -> StdioServerParameters:
94
110
 
95
111
  async def _call_get_custom_metadata(url: str, token: str | None, scheme: str = "Bearer"):
96
112
  headers = {"Authorization": f"{scheme} {token}"} if token else None
97
- async with streamablehttp_client(url, headers=headers) as (read_stream, write_stream, _):
98
- async with ClientSession(read_stream, write_stream) as session:
99
- await session.initialize()
100
- return await session.call_tool("get_custom_metadata", {})
113
+ async with httpx.AsyncClient(headers=headers) as http_client:
114
+ async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream, _):
115
+ async with ClientSession(read_stream, write_stream) as session:
116
+ await session.initialize()
117
+ return await session.call_tool("get_custom_metadata", {})
101
118
 
102
119
 
103
120
  def test_concurrent_requests_use_their_own_token(http_server):
@@ -116,20 +133,31 @@ def test_concurrent_requests_use_their_own_token(http_server):
116
133
 
117
134
 
118
135
  def test_a_slow_tool_does_not_hold_up_other_requests(http_server):
119
- """A tool call still waiting on Code Ocean leaves the server free to serve another session."""
120
- url, _ = http_server
121
-
122
- async def call_and_time(token: str):
123
- result = await _call_get_custom_metadata(url, token)
124
- return result, time.monotonic()
136
+ """A second session is served while a tool call is still waiting on Code Ocean.
125
137
 
126
- async def both():
127
- return await asyncio.gather(call_and_time("slow-token"), call_and_time("fast-token"))
128
-
129
- (slow, slow_finished), (fast, fast_finished) = asyncio.run(both())
138
+ Fails if the synchronous tool runs on the event loop: the fast request then cannot even
139
+ initialize until the slow one is released, and times out.
140
+ """
141
+ url, _ = http_server
142
+ handler = _EchoTokenHandler
143
+
144
+ async def fast_while_slow_is_blocked():
145
+ slow = asyncio.create_task(_call_get_custom_metadata(url, "slow-token"))
146
+ try:
147
+ assert await asyncio.to_thread(handler.slow_started.wait, RENDEZVOUS_TIMEOUT), (
148
+ "the slow tool call never reached the stub API"
149
+ )
150
+ try:
151
+ fast = await asyncio.wait_for(_call_get_custom_metadata(url, "fast-token"), RENDEZVOUS_TIMEOUT)
152
+ except asyncio.TimeoutError:
153
+ pytest.fail("the fast request did not complete while the slow tool call was blocked")
154
+ finally:
155
+ handler.slow_released.set()
156
+ return await slow, fast
157
+
158
+ slow, fast = asyncio.run(fast_while_slow_is_blocked())
130
159
  assert slow.structuredContent["categories"] == ["slow-token"]
131
160
  assert fast.structuredContent["categories"] == ["fast-token"]
132
- assert fast_finished < slow_finished - 1
133
161
 
134
162
 
135
163
  def test_request_without_credential_is_refused(http_server):
@@ -153,7 +181,7 @@ def test_tool_definitions_match_stdio(http_server):
153
181
  url, domain = http_server
154
182
 
155
183
  async def list_over_http():
156
- async with streamablehttp_client(url) as (read_stream, write_stream, _):
184
+ async with streamable_http_client(url) as (read_stream, write_stream, _):
157
185
  async with ClientSession(read_stream, write_stream) as session:
158
186
  await session.initialize()
159
187
  return (await session.list_tools()).tools