codeocean-mcp-server 0.11.0__tar.gz → 0.12.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 (31) hide show
  1. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/CHANGELOG.md +8 -0
  2. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/PKG-INFO +16 -2
  3. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/README.md +14 -0
  4. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/pyproject.toml +1 -1
  5. codeocean_mcp_server-0.12.1/src/codeocean_mcp_server/client.py +54 -0
  6. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/logging_config.py +23 -3
  7. codeocean_mcp_server-0.12.1/src/codeocean_mcp_server/server.py +70 -0
  8. codeocean_mcp_server-0.12.1/tests/test_http_transport.py +140 -0
  9. codeocean_mcp_server-0.12.1/tests/test_logging_config.py +68 -0
  10. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/uv.lock +1 -1
  11. codeocean_mcp_server-0.11.0/src/codeocean_mcp_server/server.py +0 -41
  12. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/.gitignore +0 -0
  13. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/.python-version +0 -0
  14. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/LICENSE +0 -0
  15. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/RELEASE.md +0 -0
  16. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/__init__.py +0 -0
  17. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/file_utils.py +0 -0
  18. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/models.py +0 -0
  19. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/search.py +0 -0
  20. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/tools/__init__.py +0 -0
  21. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/tools/capsules.py +0 -0
  22. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/tools/computations.py +0 -0
  23. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/tools/custom_metadata.py +0 -0
  24. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/src/codeocean_mcp_server/tools/data_assets.py +0 -0
  25. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/tests/bedrock_call.py +0 -0
  26. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/tests/bedrock_tools_converter.py +0 -0
  27. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/tests/conftest.py +0 -0
  28. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/tests/mcp_client.py +0 -0
  29. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/tests/test_multi_tool_calling.py +0 -0
  30. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/tests/test_search.py +0 -0
  31. {codeocean_mcp_server-0.11.0 → codeocean_mcp_server-0.12.1}/tests/test_tool_calling.py +0 -0
@@ -1,6 +1,14 @@
1
1
  CHANGELOG
2
2
  =========
3
3
 
4
+ ## 0.12.1 (2026-08-28)
5
+
6
+ - [#40](https://github.com/codeocean/codeocean-mcp-server/pull/40) fix: apply `LOG_FORMAT` to uvicorn's own log records
7
+
8
+ ## 0.12.0 (2026-08-27)
9
+
10
+ - [#38](https://github.com/codeocean/codeocean-mcp-server/pull/38) feat: serve over streamable HTTP with per-request credentials
11
+
4
12
  ## 0.11.0 (2026-03-09)
5
13
 
6
14
  - [#35](https://github.com/codeocean/codeocean-mcp-server/pull/35) feat: add `delete_computation` tool to stop and delete computations
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: codeocean-mcp-server
3
- Version: 0.11.0
3
+ Version: 0.12.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
@@ -33,6 +33,7 @@ This MCP server provides tools to search and run capsules and pipelines, and man
33
33
  - [Roo Code](#roo-code)
34
34
  - [Cursor](#cursor)
35
35
  - [Windsurf](#windsurf)
36
+ - [Streamable HTTP Transport](#streamable-http-transport)
36
37
  - [Local Testing](#local-testing)
37
38
 
38
39
  ## Prerequisites
@@ -215,6 +216,19 @@ Windsurf (Cascade) uses mcp_config.json under ~/.codeium/windsurf/ (or via the C
215
216
 
216
217
  3. Save and restart Windsurf (or hit “Refresh” in the MCP panel).
217
218
 
219
+ ## Streamable HTTP Transport
220
+
221
+ By default the server runs over stdio and authenticates with the `CODEOCEAN_TOKEN` environment variable, as
222
+ described above. It can also serve multiple users from a single process over streamable HTTP, taking each
223
+ caller's API token from the request instead:
224
+
225
+ ```bash
226
+ CODEOCEAN_DOMAIN=https://acmecorp.codeocean.com codeocean-mcp-server --transport streamable-http --host 127.0.0.1 --port 8000
227
+ ```
228
+
229
+ Clients then pass their own token as `Authorization: Bearer <YOUR_API_KEY>` on every request; `CODEOCEAN_TOKEN`
230
+ is not used, and a request without a token is refused. The endpoint is `http://<host>:<port>/mcp`.
231
+
218
232
  ## Local Testing
219
233
 
220
234
  You can test the MCP server locally during development with [MCP Inspector](https://modelcontextprotocol.io/legacy/tools/inspector):
@@ -15,6 +15,7 @@ This MCP server provides tools to search and run capsules and pipelines, and man
15
15
  - [Roo Code](#roo-code)
16
16
  - [Cursor](#cursor)
17
17
  - [Windsurf](#windsurf)
18
+ - [Streamable HTTP Transport](#streamable-http-transport)
18
19
  - [Local Testing](#local-testing)
19
20
 
20
21
  ## Prerequisites
@@ -197,6 +198,19 @@ Windsurf (Cascade) uses mcp_config.json under ~/.codeium/windsurf/ (or via the C
197
198
 
198
199
  3. Save and restart Windsurf (or hit “Refresh” in the MCP panel).
199
200
 
201
+ ## Streamable HTTP Transport
202
+
203
+ By default the server runs over stdio and authenticates with the `CODEOCEAN_TOKEN` environment variable, as
204
+ described above. It can also serve multiple users from a single process over streamable HTTP, taking each
205
+ caller's API token from the request instead:
206
+
207
+ ```bash
208
+ CODEOCEAN_DOMAIN=https://acmecorp.codeocean.com codeocean-mcp-server --transport streamable-http --host 127.0.0.1 --port 8000
209
+ ```
210
+
211
+ Clients then pass their own token as `Authorization: Bearer <YOUR_API_KEY>` on every request; `CODEOCEAN_TOKEN`
212
+ is not used, and a request without a token is refused. The endpoint is `http://<host>:<port>/mcp`.
213
+
200
214
  ## Local Testing
201
215
 
202
216
  You can test the MCP server locally during development with [MCP Inspector](https://modelcontextprotocol.io/legacy/tools/inspector):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codeocean-mcp-server"
3
- version = "0.11.0"
3
+ version = "0.12.1"
4
4
  authors = [{ name = "Code Ocean", email = "dev@codeocean.com" }]
5
5
  description = "Code Ocean MCP Server"
6
6
  readme = "README.md"
@@ -0,0 +1,54 @@
1
+ """Per-request Code Ocean client resolution for the streamable-HTTP transport."""
2
+
3
+ from functools import lru_cache
4
+
5
+ from codeocean import CodeOcean
6
+ from mcp.server.fastmcp import FastMCP
7
+ from starlette.requests import Request
8
+
9
+
10
+ @lru_cache(maxsize=32)
11
+ def _cached_client(domain: str, token: str, agent_id: str | None) -> CodeOcean:
12
+ """Return a client for the given credentials, reusing its HTTP connection pool across requests."""
13
+ return CodeOcean(domain=domain, token=token, agent_id=agent_id)
14
+
15
+
16
+ def _bearer_token(request: Request) -> str | None:
17
+ """Return the token from an 'Authorization: Bearer <token>' header, or None for any other scheme."""
18
+ scheme, _, token = request.headers.get("authorization", "").partition(" ")
19
+ if scheme.lower() != "bearer":
20
+ return None
21
+ return token.strip() or None
22
+
23
+
24
+ class RequestScopedClient:
25
+ """A `CodeOcean` stand-in that resolves to the calling request's own client on every attribute access.
26
+
27
+ Tools reach the SDK as `client.<sub_client>.<method>(...)`, evaluated when the tool runs, so wrapping
28
+ the client is enough to give each request its own credentials without touching the tools themselves.
29
+
30
+ Outside a request — tool registration, where descriptions are read from SDK docstrings — attribute
31
+ access resolves to a client built from the environment's token, which issues no network call.
32
+ """
33
+
34
+ def __init__(self, mcp: FastMCP, domain: str, token: str | None, agent_id: str | None):
35
+ """Wrap the credentials that are fixed for the process, around the token that varies per request."""
36
+ self._mcp = mcp
37
+ self._domain = domain
38
+ self._token = token
39
+ self._agent_id = agent_id
40
+
41
+ def __getattr__(self, name: str):
42
+ """Delegate to the calling request's client."""
43
+ return getattr(self._resolve(), name)
44
+
45
+ def _resolve(self) -> CodeOcean:
46
+ try:
47
+ request = self._mcp.get_context().request_context.request
48
+ except ValueError:
49
+ # Registration happens outside any request, so it needs no credential of its own.
50
+ return _cached_client(self._domain, self._token or "", self._agent_id)
51
+ token = _bearer_token(request) if request is not None else None
52
+ if not token:
53
+ raise ValueError("Missing Code Ocean API token: send it as an 'Authorization: Bearer <token>' header.")
54
+ return _cached_client(self._domain, token, self._agent_id)
@@ -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. This must be called before FastMCP
11
- initialization to ensure our configuration takes precedence.
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
+ )
@@ -0,0 +1,70 @@
1
+ import argparse
2
+ import os
3
+
4
+ from codeocean import CodeOcean
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from codeocean_mcp_server.client import RequestScopedClient
8
+ from codeocean_mcp_server.logging_config import configure_logging
9
+ from codeocean_mcp_server.tools import (
10
+ capsules,
11
+ computations,
12
+ custom_metadata,
13
+ data_assets,
14
+ )
15
+
16
+
17
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
18
+ """Parse command line arguments."""
19
+ parser = argparse.ArgumentParser(prog="codeocean-mcp-server", description="Code Ocean MCP Server")
20
+ parser.add_argument(
21
+ "--transport",
22
+ choices=["stdio", "streamable-http"],
23
+ default="stdio",
24
+ help="Transport to serve on. Over streamable-HTTP the API token is taken per request from the "
25
+ "'Authorization: Bearer <token>' header instead of from CODEOCEAN_TOKEN.",
26
+ )
27
+ parser.add_argument("--host", default="127.0.0.1", help="Host to bind to when serving over streamable-HTTP.")
28
+ parser.add_argument("--port", type=int, default=8000, help="Port to bind to when serving over streamable-HTTP.")
29
+ return parser.parse_args(argv)
30
+
31
+
32
+ def main():
33
+ """Run the MCP server."""
34
+ args = parse_args()
35
+ configure_logging(args.transport)
36
+ stdio = args.transport == "stdio"
37
+ domain = os.getenv("CODEOCEAN_DOMAIN")
38
+ token = os.getenv("CODEOCEAN_TOKEN")
39
+ if not domain:
40
+ raise ValueError("Environment variable CODEOCEAN_DOMAIN must be set.")
41
+ agent_id = os.getenv("AGENT_ID", "AI Agent")
42
+
43
+ mcp = FastMCP(
44
+ name="Code Ocean",
45
+ instructions=(
46
+ f"MCP server for Code Ocean: search & run capsules, pipelines, and assets using Code Ocean domain {domain}."
47
+ ),
48
+ host=args.host,
49
+ port=args.port,
50
+ stateless_http=True,
51
+ )
52
+
53
+ if stdio:
54
+ # Over stdio the process serves a single user, so the environment's client is used directly, as before.
55
+ if not token:
56
+ raise ValueError("Environment variable CODEOCEAN_TOKEN must be set when serving over stdio.")
57
+ client = CodeOcean(domain=domain, token=token, agent_id=agent_id)
58
+ else:
59
+ client = RequestScopedClient(mcp, domain, token, agent_id)
60
+
61
+ capsules.add_tools(mcp, client)
62
+ data_assets.add_tools(mcp, client)
63
+ computations.add_tools(mcp, client)
64
+ custom_metadata.add_tools(mcp, client)
65
+
66
+ mcp.run(transport=args.transport)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,140 @@
1
+ """End-to-end tests for the streamable-HTTP transport and its per-request credentials.
2
+
3
+ A stub Code Ocean API echoes back the token it was called with, so each assertion follows a token
4
+ from the MCP request header all the way to the outgoing API call.
5
+ """
6
+
7
+ import asyncio
8
+ import base64
9
+ import json
10
+ import os
11
+ import socket
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time
16
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+ from mcp import ClientSession, StdioServerParameters
21
+ from mcp.client.streamable_http import streamablehttp_client
22
+ from mcp_client import get_tools
23
+
24
+ SERVER_SCRIPT_PATH = str(Path(__file__).parent.parent / "src" / "codeocean_mcp_server" / "server.py")
25
+
26
+
27
+ class _EchoTokenHandler(BaseHTTPRequestHandler):
28
+ """Answer the custom metadata endpoint with the basic-auth user, which is the API token."""
29
+
30
+ def do_GET(self): # noqa: D102, N802
31
+ user = base64.b64decode(self.headers["Authorization"].split(" ")[1]).decode().split(":")[0]
32
+ body = json.dumps({"categories": [user]}).encode()
33
+ self.send_response(200)
34
+ self.send_header("Content-Type", "application/json")
35
+ self.send_header("Content-Length", str(len(body)))
36
+ self.end_headers()
37
+ self.wfile.write(body)
38
+
39
+ def log_message(self, *args): # noqa: D102
40
+ pass
41
+
42
+
43
+ def _free_port() -> int:
44
+ with socket.socket() as sock:
45
+ sock.bind(("127.0.0.1", 0))
46
+ return sock.getsockname()[1]
47
+
48
+
49
+ def _wait_until_listening(port: int, timeout: float = 30.0):
50
+ deadline = time.monotonic() + timeout
51
+ while time.monotonic() < deadline:
52
+ with socket.socket() as sock:
53
+ if sock.connect_ex(("127.0.0.1", port)) == 0:
54
+ return
55
+ time.sleep(0.1)
56
+ raise TimeoutError(f"MCP server did not start listening on port {port}")
57
+
58
+
59
+ @pytest.fixture(scope="module")
60
+ def http_server():
61
+ """Serve the MCP server over streamable-HTTP against a stub Code Ocean API, and yield URL and domain."""
62
+ api = ThreadingHTTPServer(("127.0.0.1", 0), _EchoTokenHandler)
63
+ threading.Thread(target=api.serve_forever, daemon=True).start()
64
+
65
+ port = _free_port()
66
+ env = {**os.environ, "CODEOCEAN_DOMAIN": f"http://127.0.0.1:{api.server_address[1]}"}
67
+ env.pop("CODEOCEAN_TOKEN", None)
68
+ process = subprocess.Popen(
69
+ [sys.executable, SERVER_SCRIPT_PATH, "--transport", "streamable-http", "--port", str(port)],
70
+ env=env,
71
+ )
72
+ try:
73
+ _wait_until_listening(port)
74
+ yield f"http://127.0.0.1:{port}/mcp", env["CODEOCEAN_DOMAIN"]
75
+ finally:
76
+ process.terminate()
77
+ process.wait(timeout=30)
78
+ api.shutdown()
79
+
80
+
81
+ def _stdio_server(domain: str) -> StdioServerParameters:
82
+ """Describe the same server run over stdio, against the same Code Ocean domain."""
83
+ return StdioServerParameters(
84
+ command=sys.executable,
85
+ args=[SERVER_SCRIPT_PATH],
86
+ env={"CODEOCEAN_DOMAIN": domain, "CODEOCEAN_TOKEN": "token"},
87
+ )
88
+
89
+
90
+ async def _call_get_custom_metadata(url: str, token: str | None, scheme: str = "Bearer"):
91
+ headers = {"Authorization": f"{scheme} {token}"} if token else None
92
+ async with streamablehttp_client(url, headers=headers) as (read_stream, write_stream, _):
93
+ async with ClientSession(read_stream, write_stream) as session:
94
+ await session.initialize()
95
+ return await session.call_tool("get_custom_metadata", {})
96
+
97
+
98
+ def test_concurrent_requests_use_their_own_token(http_server):
99
+ """Each request acts with the token from its own authorization header."""
100
+ url, _ = http_server
101
+
102
+ async def both():
103
+ return await asyncio.gather(
104
+ _call_get_custom_metadata(url, "alice-token"),
105
+ _call_get_custom_metadata(url, "bob-token"),
106
+ )
107
+
108
+ alice, bob = asyncio.run(both())
109
+ assert alice.structuredContent["categories"] == ["alice-token"]
110
+ assert bob.structuredContent["categories"] == ["bob-token"]
111
+
112
+
113
+ def test_request_without_credential_is_refused(http_server):
114
+ """A request carrying no token is refused rather than served with the environment's client."""
115
+ url, _ = http_server
116
+ result = asyncio.run(_call_get_custom_metadata(url, None))
117
+ assert result.isError
118
+ assert "Missing Code Ocean API token" in result.content[0].text
119
+
120
+
121
+ def test_non_bearer_credential_is_refused(http_server):
122
+ """A token offered under any scheme other than Bearer is refused rather than used."""
123
+ url, _ = http_server
124
+ result = asyncio.run(_call_get_custom_metadata(url, "alice-token", scheme="Basic"))
125
+ assert result.isError
126
+ assert "Missing Code Ocean API token" in result.content[0].text
127
+
128
+
129
+ def test_tool_definitions_match_stdio(http_server):
130
+ """Serving over HTTP leaves tool names, schemas and descriptions exactly as stdio serves them."""
131
+ url, domain = http_server
132
+
133
+ async def list_over_http():
134
+ async with streamablehttp_client(url) as (read_stream, write_stream, _):
135
+ async with ClientSession(read_stream, write_stream) as session:
136
+ await session.initialize()
137
+ return (await session.list_tools()).tools
138
+
139
+ over_stdio = get_tools(_stdio_server(domain))
140
+ assert [t.model_dump() for t in asyncio.run(list_over_http())] == [t.model_dump() for t in over_stdio]
@@ -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,7 +558,7 @@ wheels = [
558
558
 
559
559
  [[package]]
560
560
  name = "codeocean-mcp-server"
561
- version = "0.11.0"
561
+ version = "0.12.1"
562
562
  source = { editable = "." }
563
563
  dependencies = [
564
564
  { name = "codeocean" },
@@ -1,41 +0,0 @@
1
- import os
2
-
3
- from codeocean import CodeOcean
4
- from mcp.server.fastmcp import FastMCP
5
-
6
- from codeocean_mcp_server.logging_config import configure_logging
7
- from codeocean_mcp_server.tools import (
8
- capsules,
9
- computations,
10
- custom_metadata,
11
- data_assets,
12
- )
13
-
14
-
15
- def main():
16
- """Run the MCP server."""
17
- configure_logging()
18
- domain = os.getenv("CODEOCEAN_DOMAIN")
19
- token = os.getenv("CODEOCEAN_TOKEN")
20
- if not domain or not token:
21
- raise ValueError("Environment variables CODEOCEAN_DOMAIN and CODEOCEAN_TOKEN must be set.")
22
- agent_id = os.getenv("AGENT_ID", "AI Agent")
23
- client = CodeOcean(domain=domain, token=token, agent_id=agent_id)
24
-
25
- mcp = FastMCP(
26
- name="Code Ocean",
27
- instructions=(
28
- f"MCP server for Code Ocean: search & run capsules, pipelines, and assets using Code Ocean domain {domain}."
29
- ),
30
- )
31
-
32
- capsules.add_tools(mcp, client)
33
- data_assets.add_tools(mcp, client)
34
- computations.add_tools(mcp, client)
35
- custom_metadata.add_tools(mcp, client)
36
-
37
- mcp.run()
38
-
39
-
40
- if __name__ == "__main__":
41
- main()