codeocean-mcp-server 0.12.0__tar.gz → 0.12.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.
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/CHANGELOG.md +8 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/PKG-INFO +2 -1
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/pyproject.toml +2 -1
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/logging_config.py +23 -3
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/server.py +34 -2
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/test_http_transport.py +22 -0
- codeocean_mcp_server-0.12.2/tests/test_logging_config.py +68 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/uv.lock +3 -1
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/.gitignore +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/.python-version +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/LICENSE +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/README.md +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/RELEASE.md +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/__init__.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/client.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/file_utils.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/models.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/search.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/tools/__init__.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/tools/capsules.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/tools/computations.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/tools/custom_metadata.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/tools/data_assets.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/bedrock_call.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/bedrock_tools_converter.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/conftest.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/mcp_client.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/test_multi_tool_calling.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/test_search.py +0 -0
- {codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/test_tool_calling.py +0 -0
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
CHANGELOG
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
+
## 0.12.2 (2026-09-03)
|
|
5
|
+
|
|
6
|
+
- [#42](https://github.com/codeocean/codeocean-mcp-server/pull/42) fix: run synchronous tool bodies in a worker thread
|
|
7
|
+
|
|
8
|
+
## 0.12.1 (2026-08-28)
|
|
9
|
+
|
|
10
|
+
- [#40](https://github.com/codeocean/codeocean-mcp-server/pull/40) fix: apply `LOG_FORMAT` to uvicorn's own log records
|
|
11
|
+
|
|
4
12
|
## 0.12.0 (2026-08-27)
|
|
5
13
|
|
|
6
14
|
- [#38](https://github.com/codeocean/codeocean-mcp-server/pull/38) feat: serve over streamable HTTP with per-request credentials
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codeocean-mcp-server
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.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
|
|
@@ -12,6 +12,7 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
12
12
|
Classifier: Operating System :: OS Independent
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
14
|
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: anyio>=4.5
|
|
15
16
|
Requires-Dist: codeocean<0.15.0,>=0.14.0
|
|
16
17
|
Requires-Dist: mcp<1.24.0,>=1.23.0
|
|
17
18
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "codeocean-mcp-server"
|
|
3
|
-
version = "0.12.
|
|
3
|
+
version = "0.12.2"
|
|
4
4
|
authors = [{ name = "Code Ocean", email = "dev@codeocean.com" }]
|
|
5
5
|
description = "Code Ocean MCP Server"
|
|
6
6
|
readme = "README.md"
|
|
@@ -11,6 +11,7 @@ classifiers = [
|
|
|
11
11
|
"Operating System :: OS Independent",
|
|
12
12
|
]
|
|
13
13
|
dependencies = [
|
|
14
|
+
"anyio>=4.5",
|
|
14
15
|
"codeocean>=0.14.0,<0.15.0",
|
|
15
16
|
"mcp>=1.23.0,<1.24.0",
|
|
16
17
|
]
|
|
@@ -3,16 +3,21 @@ import os
|
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def configure_logging() -> None:
|
|
6
|
+
def configure_logging(transport: str = "stdio") -> None:
|
|
7
7
|
"""Configure logging based on LOG_FORMAT environment variable.
|
|
8
8
|
|
|
9
9
|
If LOG_FORMAT is set, configures the root logger with a StreamHandler
|
|
10
|
-
using the specified format string
|
|
11
|
-
|
|
10
|
+
using the specified format string, and, for a transport uvicorn serves,
|
|
11
|
+
applies the same format to uvicorn's own loggers. This must be called
|
|
12
|
+
before FastMCP initialization to ensure our configuration takes
|
|
13
|
+
precedence.
|
|
12
14
|
|
|
13
15
|
If LOG_FORMAT is not set or is empty, does nothing and lets FastMCP
|
|
14
16
|
configure logging with its default settings.
|
|
15
17
|
|
|
18
|
+
Args:
|
|
19
|
+
transport: The transport the server is about to serve on.
|
|
20
|
+
|
|
16
21
|
Environment variables:
|
|
17
22
|
LOG_FORMAT: Python logging format string (optional)
|
|
18
23
|
|
|
@@ -45,3 +50,18 @@ def configure_logging() -> None:
|
|
|
45
50
|
# This must be done before FastMCP calls logging.basicConfig()
|
|
46
51
|
logging.root.addHandler(handler)
|
|
47
52
|
logging.root.setLevel(logging.INFO)
|
|
53
|
+
|
|
54
|
+
if transport == "stdio":
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Serving over HTTP, uvicorn logs through its own loggers, which it gives handlers that do not
|
|
58
|
+
# propagate to the root logger. It configures them from this dictionary, which FastMCP leaves
|
|
59
|
+
# at its default value, so its contents have to be replaced to be reached at all. The import
|
|
60
|
+
# is local because uvicorn, like in FastMCP itself, is only needed for the transports it serves.
|
|
61
|
+
from uvicorn.config import LOGGING_CONFIG
|
|
62
|
+
|
|
63
|
+
LOGGING_CONFIG["formatters"]["default"]["fmt"] = log_format
|
|
64
|
+
# Access records carry the request in fields of their own rather than in the message.
|
|
65
|
+
LOGGING_CONFIG["formatters"]["access"]["fmt"] = log_format.replace(
|
|
66
|
+
"%(message)s", '%(client_addr)s - "%(request_line)s" %(status_code)s'
|
|
67
|
+
)
|
{codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/server.py
RENAMED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import functools
|
|
3
|
+
import inspect
|
|
2
4
|
import os
|
|
5
|
+
from collections.abc import Callable
|
|
3
6
|
|
|
7
|
+
import anyio
|
|
4
8
|
from codeocean import CodeOcean
|
|
5
9
|
from mcp.server.fastmcp import FastMCP
|
|
6
10
|
|
|
@@ -14,6 +18,34 @@ from codeocean_mcp_server.tools import (
|
|
|
14
18
|
)
|
|
15
19
|
|
|
16
20
|
|
|
21
|
+
class ThreadedFastMCP(FastMCP):
|
|
22
|
+
"""A server that runs synchronous tool bodies in a worker thread.
|
|
23
|
+
|
|
24
|
+
FastMCP awaits an asynchronous tool but calls a synchronous one on the event loop, where it
|
|
25
|
+
blocks the whole process for as long as it runs: no other tool call, and no other session's
|
|
26
|
+
requests, are served meanwhile. The tools here are synchronous and some wait on a computation,
|
|
27
|
+
so they are handed to a thread instead. anyio copies the context into it, leaving each call's
|
|
28
|
+
per-request credentials in place.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def tool(self, *args, **kwargs) -> Callable[[Callable], Callable]:
|
|
32
|
+
"""Register a tool, moving a synchronous one off the event loop."""
|
|
33
|
+
register = super().tool(*args, **kwargs)
|
|
34
|
+
|
|
35
|
+
def decorator(fn: Callable) -> Callable:
|
|
36
|
+
if inspect.iscoroutinefunction(fn):
|
|
37
|
+
return register(fn)
|
|
38
|
+
|
|
39
|
+
@functools.wraps(fn)
|
|
40
|
+
async def in_worker_thread(**arguments):
|
|
41
|
+
return await anyio.to_thread.run_sync(functools.partial(fn, **arguments))
|
|
42
|
+
|
|
43
|
+
register(in_worker_thread)
|
|
44
|
+
return fn
|
|
45
|
+
|
|
46
|
+
return decorator
|
|
47
|
+
|
|
48
|
+
|
|
17
49
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
18
50
|
"""Parse command line arguments."""
|
|
19
51
|
parser = argparse.ArgumentParser(prog="codeocean-mcp-server", description="Code Ocean MCP Server")
|
|
@@ -31,8 +63,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
31
63
|
|
|
32
64
|
def main():
|
|
33
65
|
"""Run the MCP server."""
|
|
34
|
-
configure_logging()
|
|
35
66
|
args = parse_args()
|
|
67
|
+
configure_logging(args.transport)
|
|
36
68
|
stdio = args.transport == "stdio"
|
|
37
69
|
domain = os.getenv("CODEOCEAN_DOMAIN")
|
|
38
70
|
token = os.getenv("CODEOCEAN_TOKEN")
|
|
@@ -40,7 +72,7 @@ def main():
|
|
|
40
72
|
raise ValueError("Environment variable CODEOCEAN_DOMAIN must be set.")
|
|
41
73
|
agent_id = os.getenv("AGENT_ID", "AI Agent")
|
|
42
74
|
|
|
43
|
-
mcp =
|
|
75
|
+
mcp = ThreadedFastMCP(
|
|
44
76
|
name="Code Ocean",
|
|
45
77
|
instructions=(
|
|
46
78
|
f"MCP server for Code Ocean: search & run capsules, pipelines, and assets using Code Ocean domain {domain}."
|
|
@@ -23,12 +23,17 @@ from mcp_client import get_tools
|
|
|
23
23
|
|
|
24
24
|
SERVER_SCRIPT_PATH = str(Path(__file__).parent.parent / "src" / "codeocean_mcp_server" / "server.py")
|
|
25
25
|
|
|
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
|
|
28
|
+
|
|
26
29
|
|
|
27
30
|
class _EchoTokenHandler(BaseHTTPRequestHandler):
|
|
28
31
|
"""Answer the custom metadata endpoint with the basic-auth user, which is the API token."""
|
|
29
32
|
|
|
30
33
|
def do_GET(self): # noqa: D102, N802
|
|
31
34
|
user = base64.b64decode(self.headers["Authorization"].split(" ")[1]).decode().split(":")[0]
|
|
35
|
+
if user.startswith("slow-"):
|
|
36
|
+
time.sleep(SLOW_API_SECONDS)
|
|
32
37
|
body = json.dumps({"categories": [user]}).encode()
|
|
33
38
|
self.send_response(200)
|
|
34
39
|
self.send_header("Content-Type", "application/json")
|
|
@@ -110,6 +115,23 @@ def test_concurrent_requests_use_their_own_token(http_server):
|
|
|
110
115
|
assert bob.structuredContent["categories"] == ["bob-token"]
|
|
111
116
|
|
|
112
117
|
|
|
118
|
+
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()
|
|
125
|
+
|
|
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())
|
|
130
|
+
assert slow.structuredContent["categories"] == ["slow-token"]
|
|
131
|
+
assert fast.structuredContent["categories"] == ["fast-token"]
|
|
132
|
+
assert fast_finished < slow_finished - 1
|
|
133
|
+
|
|
134
|
+
|
|
113
135
|
def test_request_without_credential_is_refused(http_server):
|
|
114
136
|
"""A request carrying no token is refused rather than served with the environment's client."""
|
|
115
137
|
url, _ = http_server
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""End-to-end test for LOG_FORMAT, over the transport that brings a logger of its own along."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
SERVER_SCRIPT_PATH = str(Path(__file__).parent.parent / "src" / "codeocean_mcp_server" / "server.py")
|
|
14
|
+
LOG_FORMAT = "%(asctime)s server %(levelname)s [%(name)s] %(message)s"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _free_port() -> int:
|
|
18
|
+
with socket.socket() as sock:
|
|
19
|
+
sock.bind(("127.0.0.1", 0))
|
|
20
|
+
return sock.getsockname()[1]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _wait_until_listening(port: int, timeout: float = 30.0):
|
|
24
|
+
deadline = time.monotonic() + timeout
|
|
25
|
+
while time.monotonic() < deadline:
|
|
26
|
+
with socket.socket() as sock:
|
|
27
|
+
if sock.connect_ex(("127.0.0.1", port)) == 0:
|
|
28
|
+
return
|
|
29
|
+
time.sleep(0.1)
|
|
30
|
+
raise TimeoutError(f"MCP server did not start listening on port {port}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _serve_and_request(port: int) -> str:
|
|
34
|
+
"""Serve over streamable HTTP, make one request, and return everything the server logged."""
|
|
35
|
+
env = {**os.environ, "CODEOCEAN_DOMAIN": "test-domain", "LOG_FORMAT": LOG_FORMAT}
|
|
36
|
+
process = subprocess.Popen(
|
|
37
|
+
[sys.executable, SERVER_SCRIPT_PATH, "--transport", "streamable-http", "--port", str(port)],
|
|
38
|
+
env=env,
|
|
39
|
+
stdout=subprocess.PIPE,
|
|
40
|
+
stderr=subprocess.STDOUT,
|
|
41
|
+
text=True,
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
_wait_until_listening(port)
|
|
45
|
+
request = urllib.request.Request(f"http://127.0.0.1:{port}/mcp", data=b"{}")
|
|
46
|
+
try:
|
|
47
|
+
urllib.request.urlopen(request, timeout=30)
|
|
48
|
+
except urllib.error.HTTPError:
|
|
49
|
+
pass # The request is rejected; it is logged either way, which is what is under test.
|
|
50
|
+
finally:
|
|
51
|
+
process.terminate()
|
|
52
|
+
process.wait(timeout=30)
|
|
53
|
+
return process.stdout.read()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_log_format_applies_to_uvicorns_own_records():
|
|
57
|
+
"""Uvicorn brings its own logging configuration, in whose format these records would be."""
|
|
58
|
+
log = _serve_and_request(_free_port())
|
|
59
|
+
|
|
60
|
+
timestamp = r"^[\d-]{10} [\d:,]+"
|
|
61
|
+
assert re.search(rf"{timestamp} server INFO \[uvicorn\.error\] Uvicorn running", log, re.MULTILINE)
|
|
62
|
+
assert re.search(
|
|
63
|
+
# The status runs to the end of the line as code and phrase, which only uvicorn's own
|
|
64
|
+
# access formatter puts there; the raw record carries the code alone.
|
|
65
|
+
rf'{timestamp} server INFO \[uvicorn\.access\] 127\.0\.0\.1:\d+ - "POST /mcp HTTP/1.1" \d{{3}} \w+( \w+)*$',
|
|
66
|
+
log,
|
|
67
|
+
re.MULTILINE,
|
|
68
|
+
)
|
|
@@ -558,9 +558,10 @@ wheels = [
|
|
|
558
558
|
|
|
559
559
|
[[package]]
|
|
560
560
|
name = "codeocean-mcp-server"
|
|
561
|
-
version = "0.12.
|
|
561
|
+
version = "0.12.2"
|
|
562
562
|
source = { editable = "." }
|
|
563
563
|
dependencies = [
|
|
564
|
+
{ name = "anyio" },
|
|
564
565
|
{ name = "codeocean" },
|
|
565
566
|
{ name = "mcp" },
|
|
566
567
|
]
|
|
@@ -577,6 +578,7 @@ dev = [
|
|
|
577
578
|
|
|
578
579
|
[package.metadata]
|
|
579
580
|
requires-dist = [
|
|
581
|
+
{ name = "anyio", specifier = ">=4.5" },
|
|
580
582
|
{ name = "codeocean", specifier = ">=0.14.0,<0.15.0" },
|
|
581
583
|
{ name = "mcp", specifier = ">=1.23.0,<1.24.0" },
|
|
582
584
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/__init__.py
RENAMED
|
File without changes
|
{codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/client.py
RENAMED
|
File without changes
|
{codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/file_utils.py
RENAMED
|
File without changes
|
{codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/models.py
RENAMED
|
File without changes
|
{codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/src/codeocean_mcp_server/search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/bedrock_tools_converter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeocean_mcp_server-0.12.0 → codeocean_mcp_server-0.12.2}/tests/test_multi_tool_calling.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|