codeocean-mcp-server 0.7.2__tar.gz → 0.8.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.
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/CHANGELOG.md +4 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/PKG-INFO +1 -1
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/pyproject.toml +3 -1
- codeocean_mcp_server-0.8.0/src/codeocean_mcp_server/search.py +136 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/capsules.py +15 -21
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/data_assets.py +15 -9
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/tests/bedrock_call.py +10 -2
- codeocean_mcp_server-0.8.0/tests/conftest.py +23 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/tests/test_multi_tool_calling.py +2 -0
- codeocean_mcp_server-0.8.0/tests/test_search.py +275 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/tests/test_tool_calling.py +1 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/uv.lock +1 -1
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/.gitignore +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/.python-version +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/LICENSE +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/README.md +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/RELEASE.md +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/__init__.py +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/file_utils.py +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/logging_config.py +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/models.py +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/server.py +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/__init__.py +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/computations.py +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/custom_metadata.py +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/tests/bedrock_tools_converter.py +0 -0
- {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/tests/mcp_client.py +0 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
CHANGELOG
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
+
## 0.8.0 (2026-01-27)
|
|
5
|
+
|
|
6
|
+
- [#24](https://github.com/codeocean/codeocean-mcp-server/pull/24) feat: Token efficient search responses
|
|
7
|
+
|
|
4
8
|
## 0.7.2 (2025-12-27)
|
|
5
9
|
|
|
6
10
|
- [#20](https://github.com/codeocean/codeocean-mcp-server/pull/20) feat: custom log formatting
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codeocean-mcp-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "codeocean-mcp-server"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
authors = [{ name = "Code Ocean", email = "dev@codeocean.com" }]
|
|
5
5
|
description = "Code Ocean MCP Server"
|
|
6
6
|
readme = "README.md"
|
|
@@ -66,3 +66,5 @@ filterwarnings = [
|
|
|
66
66
|
"ignore:datetime.datetime.utcnow() is deprecated:DeprecationWarning:botocore.auth",
|
|
67
67
|
"ignore::DeprecationWarning:botocore.*",
|
|
68
68
|
]
|
|
69
|
+
markers = ["integration: requires external services (AWS/Bedrock)"]
|
|
70
|
+
asyncio_mode = "strict"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, ClassVar, Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
# Constants
|
|
7
|
+
MAX_DESCRIPTION_LENGTH = 200
|
|
8
|
+
MAX_TAGS_COUNT = 10
|
|
9
|
+
TRUNCATION_SUFFIX = "...(more)"
|
|
10
|
+
TAGS_TRUNCATION_MARKER = "..more.."
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def truncate_description(description: Optional[str], max_length: int = MAX_DESCRIPTION_LENGTH) -> Optional[str]:
|
|
14
|
+
"""Truncate description with word-boundary-aware cutting."""
|
|
15
|
+
if not description:
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
# Light whitespace normalization: collapse 3+ consecutive whitespace to single space.
|
|
19
|
+
normalized = re.sub(r"\s{3,}", " ", description).strip()
|
|
20
|
+
if not normalized:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
if len(normalized) <= max_length:
|
|
24
|
+
return normalized
|
|
25
|
+
|
|
26
|
+
truncate_at = max_length - len(TRUNCATION_SUFFIX)
|
|
27
|
+
if truncate_at <= 0:
|
|
28
|
+
return TRUNCATION_SUFFIX[:max_length]
|
|
29
|
+
|
|
30
|
+
last_space = normalized.rfind(" ", 0, truncate_at)
|
|
31
|
+
cut_at = last_space if last_space > max_length // 2 else truncate_at
|
|
32
|
+
return normalized[:cut_at].rstrip() + TRUNCATION_SUFFIX
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def limit_tags(tags: Optional[list[str]], max_count: int = MAX_TAGS_COUNT) -> list[str]:
|
|
36
|
+
"""Limit tags to maximum count with truncation marker."""
|
|
37
|
+
if not tags:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
if len(tags) <= max_count:
|
|
41
|
+
return list(tags)
|
|
42
|
+
|
|
43
|
+
return list(tags[: max_count - 1]) + [TAGS_TRUNCATION_MARKER]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CompactCapsuleItem(BaseModel):
|
|
47
|
+
"""Compact capsule/pipeline item (id kept, other fields shortened)."""
|
|
48
|
+
|
|
49
|
+
id: str
|
|
50
|
+
n: str
|
|
51
|
+
s: str
|
|
52
|
+
d: Optional[str] = None
|
|
53
|
+
t: list[str]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CompactDataAssetItem(BaseModel):
|
|
57
|
+
"""Compact data asset item (id kept, other fields shortened)."""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
n: str
|
|
61
|
+
d: Optional[str] = None
|
|
62
|
+
t: list[str]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SearchMeta(BaseModel):
|
|
66
|
+
"""Base model providing search metadata fields."""
|
|
67
|
+
|
|
68
|
+
has_more: bool
|
|
69
|
+
next_token: Optional[str] = None
|
|
70
|
+
item_count: int
|
|
71
|
+
field_names: Optional[dict[str, str]] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CapsuleSearchResults(SearchMeta):
|
|
75
|
+
"""Compact results: {items: [{id, n, s, d, t}], has_more, next_token, total_count}.
|
|
76
|
+
|
|
77
|
+
Fields: id=id, n=name, s=slug, d=description (truncated), t=tags (limited).
|
|
78
|
+
Set include_field_names=true to add field_names with full labels.
|
|
79
|
+
Use get_capsule(id) if full details needed.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
items: list[CompactCapsuleItem]
|
|
83
|
+
FIELD_NAMES: ClassVar[dict[str, str]] = {"id": "id", "n": "name", "s": "slug", "d": "description", "t": "tags"}
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_sdk_results(cls, sdk_results: Any, include_field_names: bool = False) -> "CapsuleSearchResults":
|
|
87
|
+
"""Convert SDK search results to compact format."""
|
|
88
|
+
items = [
|
|
89
|
+
CompactCapsuleItem(
|
|
90
|
+
id=c.id,
|
|
91
|
+
n=c.name,
|
|
92
|
+
s=c.slug,
|
|
93
|
+
d=truncate_description(c.description),
|
|
94
|
+
t=limit_tags(c.tags),
|
|
95
|
+
)
|
|
96
|
+
for c in sdk_results.results
|
|
97
|
+
]
|
|
98
|
+
return cls(
|
|
99
|
+
items=items,
|
|
100
|
+
has_more=sdk_results.has_more,
|
|
101
|
+
next_token=getattr(sdk_results, "next_token", None),
|
|
102
|
+
item_count=len(items),
|
|
103
|
+
field_names=cls.FIELD_NAMES if include_field_names else None,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DataAssetSearchResults(SearchMeta):
|
|
108
|
+
"""Compact results: {items: [{id, n, d, t}], has_more, next_token, total_count}.
|
|
109
|
+
|
|
110
|
+
Fields: id=id, n=name, d=description (truncated), t=tags (limited).
|
|
111
|
+
Set include_field_names=true to add field_names with full labels.
|
|
112
|
+
Use get_data_asset(id) if full details needed.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
items: list[CompactDataAssetItem]
|
|
116
|
+
FIELD_NAMES: ClassVar[dict[str, str]] = {"id": "id", "n": "name", "d": "description", "t": "tags"}
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_sdk_results(cls, sdk_results: Any, include_field_names: bool = False) -> "DataAssetSearchResults":
|
|
120
|
+
"""Convert SDK search results to compact format."""
|
|
121
|
+
items = [
|
|
122
|
+
CompactDataAssetItem(
|
|
123
|
+
id=d.id,
|
|
124
|
+
n=d.name,
|
|
125
|
+
d=truncate_description(d.description),
|
|
126
|
+
t=limit_tags(d.tags),
|
|
127
|
+
)
|
|
128
|
+
for d in sdk_results.results
|
|
129
|
+
]
|
|
130
|
+
return cls(
|
|
131
|
+
items=items,
|
|
132
|
+
has_more=sdk_results.has_more,
|
|
133
|
+
next_token=getattr(sdk_results, "next_token", None),
|
|
134
|
+
item_count=len(items),
|
|
135
|
+
field_names=cls.FIELD_NAMES if include_field_names else None,
|
|
136
|
+
)
|
{codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/capsules.py
RENAMED
|
@@ -3,7 +3,6 @@ from codeocean.capsule import (
|
|
|
3
3
|
AppPanel,
|
|
4
4
|
Capsule,
|
|
5
5
|
CapsuleSearchParams,
|
|
6
|
-
CapsuleSearchResults,
|
|
7
6
|
Computation,
|
|
8
7
|
DataAssetAttachParams,
|
|
9
8
|
DataAssetAttachResults,
|
|
@@ -11,6 +10,7 @@ from codeocean.capsule import (
|
|
|
11
10
|
from mcp.server.fastmcp import FastMCP
|
|
12
11
|
|
|
13
12
|
from codeocean_mcp_server.models import dataclass_to_pydantic
|
|
13
|
+
from codeocean_mcp_server.search import CapsuleSearchResults
|
|
14
14
|
|
|
15
15
|
AppPanelModel = dataclass_to_pydantic(AppPanel)
|
|
16
16
|
CapsuleSearchParamsModel = dataclass_to_pydantic(CapsuleSearchParams)
|
|
@@ -20,31 +20,25 @@ DataAssetAttachParamsModel = dataclass_to_pydantic(DataAssetAttachParams)
|
|
|
20
20
|
def add_tools(mcp: FastMCP, client: CodeOcean):
|
|
21
21
|
"""Add capsule tools to the MCP server."""
|
|
22
22
|
|
|
23
|
-
@mcp.tool(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"like sort_by or sort_order unless requested."
|
|
29
|
-
)
|
|
30
|
-
)
|
|
31
|
-
def search_capsules(search_params: CapsuleSearchParamsModel) -> CapsuleSearchResults:
|
|
23
|
+
@mcp.tool(description=(str(client.capsules.search_capsules.__doc__) + " " + str(CapsuleSearchResults.__doc__)))
|
|
24
|
+
def search_capsules(
|
|
25
|
+
search_params: CapsuleSearchParamsModel,
|
|
26
|
+
include_field_names: bool = False,
|
|
27
|
+
) -> CapsuleSearchResults:
|
|
32
28
|
"""Search for capsules matching specified criteria."""
|
|
33
29
|
params = CapsuleSearchParams(**search_params.model_dump(exclude_none=True))
|
|
34
|
-
|
|
30
|
+
results = client.capsules.search_capsules(params)
|
|
31
|
+
return CapsuleSearchResults.from_sdk_results(results, include_field_names)
|
|
35
32
|
|
|
36
|
-
@mcp.tool(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"like sort_by or sort_order unless requested."
|
|
42
|
-
)
|
|
43
|
-
)
|
|
44
|
-
def search_pipelines(search_params: CapsuleSearchParamsModel) -> CapsuleSearchResults:
|
|
33
|
+
@mcp.tool(description=(str(client.capsules.search_pipelines.__doc__) + " " + str(CapsuleSearchResults.__doc__)))
|
|
34
|
+
def search_pipelines(
|
|
35
|
+
search_params: CapsuleSearchParamsModel,
|
|
36
|
+
include_field_names: bool = False,
|
|
37
|
+
) -> CapsuleSearchResults:
|
|
45
38
|
"""Search for pipelines matching specified criteria."""
|
|
46
39
|
params = CapsuleSearchParams(**search_params.model_dump(exclude_none=True))
|
|
47
|
-
|
|
40
|
+
results = client.capsules.search_pipelines(params)
|
|
41
|
+
return CapsuleSearchResults.from_sdk_results(results, include_field_names)
|
|
48
42
|
|
|
49
43
|
@mcp.tool(
|
|
50
44
|
description=(
|
|
@@ -5,7 +5,6 @@ from codeocean.data_asset import (
|
|
|
5
5
|
DataAsset,
|
|
6
6
|
DataAssetParams,
|
|
7
7
|
DataAssetSearchParams,
|
|
8
|
-
DataAssetSearchResults,
|
|
9
8
|
DataAssetUpdateParams,
|
|
10
9
|
FileURLs,
|
|
11
10
|
Folder,
|
|
@@ -14,6 +13,7 @@ from mcp.server.fastmcp import FastMCP
|
|
|
14
13
|
|
|
15
14
|
from codeocean_mcp_server.file_utils import download_and_read_file
|
|
16
15
|
from codeocean_mcp_server.models import dataclass_to_pydantic
|
|
16
|
+
from codeocean_mcp_server.search import DataAssetSearchResults
|
|
17
17
|
|
|
18
18
|
DataAssetModel = dataclass_to_pydantic(DataAsset)
|
|
19
19
|
DataAssetParamsModel = dataclass_to_pydantic(DataAssetParams)
|
|
@@ -25,17 +25,23 @@ def add_tools(mcp: FastMCP, client: CodeOcean):
|
|
|
25
25
|
"""Add data asset tools to the MCP server."""
|
|
26
26
|
|
|
27
27
|
@mcp.tool(
|
|
28
|
-
description=(
|
|
29
|
-
str(client.data_assets.search_data_assets.__doc__)
|
|
30
|
-
+ "Search for data assets (external or internal). You may filter by "
|
|
31
|
-
"fields such as `origin`, tags, or other criteria supported by the "
|
|
32
|
-
"SDK."
|
|
33
|
-
)
|
|
28
|
+
description=(str(client.data_assets.search_data_assets.__doc__) + " " + str(DataAssetSearchResults.__doc__))
|
|
34
29
|
)
|
|
35
|
-
def search_data_assets(
|
|
30
|
+
def search_data_assets(
|
|
31
|
+
search_params: DataAssetSearchParamsModel,
|
|
32
|
+
include_field_names: bool = False,
|
|
33
|
+
) -> DataAssetSearchResults:
|
|
36
34
|
"""Retrieve data assets matching search criteria for datasets."""
|
|
37
35
|
params = DataAssetSearchParams(**search_params.model_dump(exclude_none=True))
|
|
38
|
-
|
|
36
|
+
results = client.data_assets.search_data_assets(params)
|
|
37
|
+
return DataAssetSearchResults.from_sdk_results(results, include_field_names)
|
|
38
|
+
|
|
39
|
+
@mcp.tool(
|
|
40
|
+
description=("Get full details for a data asset by ID. Use after compact search to retrieve complete metadata.")
|
|
41
|
+
)
|
|
42
|
+
def get_data_asset(data_asset_id: str) -> DataAsset:
|
|
43
|
+
"""Retrieve a data asset by its ID."""
|
|
44
|
+
return client.data_assets.get_data_asset(data_asset_id)
|
|
39
45
|
|
|
40
46
|
@mcp.tool(
|
|
41
47
|
description=(
|
|
@@ -7,7 +7,15 @@ from bedrock_tools_converter import convert_tool_format
|
|
|
7
7
|
|
|
8
8
|
BEDROCK_MODEL = os.getenv("BEDROCK_MODEL") or "amazon.nova-pro-v1:0"
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
_client = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_client():
|
|
14
|
+
"""Lazily initialize the Bedrock client."""
|
|
15
|
+
global _client
|
|
16
|
+
if _client is None:
|
|
17
|
+
_client = boto3.client("bedrock-runtime")
|
|
18
|
+
return _client
|
|
11
19
|
|
|
12
20
|
|
|
13
21
|
def call_bedrock(
|
|
@@ -26,4 +34,4 @@ def call_bedrock(
|
|
|
26
34
|
if tools is not None:
|
|
27
35
|
payload["toolConfig"] = convert_tool_format(tools, model)
|
|
28
36
|
|
|
29
|
-
return
|
|
37
|
+
return _get_client().converse(**payload)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Pytest configuration for codeocean-mcp-server tests."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pytest_addoption(parser):
|
|
7
|
+
"""Add --integration flag to pytest."""
|
|
8
|
+
parser.addoption(
|
|
9
|
+
"--integration",
|
|
10
|
+
action="store_true",
|
|
11
|
+
default=False,
|
|
12
|
+
help="Run integration tests (requires AWS/Bedrock setup)",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pytest_collection_modifyitems(config, items):
|
|
17
|
+
"""Skip integration tests unless --integration flag is passed."""
|
|
18
|
+
if config.getoption("--integration"):
|
|
19
|
+
return
|
|
20
|
+
skip_integration = pytest.mark.skip(reason="need --integration flag to run")
|
|
21
|
+
for item in items:
|
|
22
|
+
if "integration" in item.keywords:
|
|
23
|
+
item.add_marker(skip_integration)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import pytest
|
|
1
2
|
from bedrock_call import call_bedrock
|
|
2
3
|
from deepdiff import DeepDiff
|
|
3
4
|
from mcp_client import get_tools
|
|
@@ -35,6 +36,7 @@ def check_diff(expected_response: dict, response: dict) -> None:
|
|
|
35
36
|
assert not diff, f"tool_usage diverges from expected:\n\n-----\n{diff!r}\n------"
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
@pytest.mark.integration
|
|
38
40
|
def test_prompt_generates_correct_tool_usage_sequence(
|
|
39
41
|
agentic_prompt_chain: dict = agentic_prompt_chain,
|
|
40
42
|
):
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Unit tests for search module."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from codeocean_mcp_server.search import (
|
|
7
|
+
TAGS_TRUNCATION_MARKER,
|
|
8
|
+
TRUNCATION_SUFFIX,
|
|
9
|
+
CapsuleSearchResults,
|
|
10
|
+
DataAssetSearchResults,
|
|
11
|
+
limit_tags,
|
|
12
|
+
truncate_description,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Mock dataclasses for SDK results (private to avoid docstring lint rules)
|
|
17
|
+
@dataclass
|
|
18
|
+
class _MockCapsule:
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
slug: str
|
|
22
|
+
description: Optional[str]
|
|
23
|
+
tags: Optional[list[str]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class _MockDataAsset:
|
|
28
|
+
id: str
|
|
29
|
+
name: str
|
|
30
|
+
description: Optional[str]
|
|
31
|
+
tags: Optional[list[str]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _MockCapsuleResults:
|
|
36
|
+
results: list[_MockCapsule]
|
|
37
|
+
has_more: bool
|
|
38
|
+
next_token: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class _MockDataAssetResults:
|
|
43
|
+
results: list[_MockDataAsset]
|
|
44
|
+
has_more: bool
|
|
45
|
+
next_token: Optional[str] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestTruncateDescription:
|
|
49
|
+
"""Tests for truncate_description function."""
|
|
50
|
+
|
|
51
|
+
def test_none_input(self):
|
|
52
|
+
"""None input returns None."""
|
|
53
|
+
assert truncate_description(None) is None
|
|
54
|
+
|
|
55
|
+
def test_empty_string(self):
|
|
56
|
+
"""Empty string input returns None."""
|
|
57
|
+
assert truncate_description("") is None
|
|
58
|
+
|
|
59
|
+
def test_whitespace_only(self):
|
|
60
|
+
"""Whitespace-only input returns None."""
|
|
61
|
+
assert truncate_description(" ") is None
|
|
62
|
+
|
|
63
|
+
def test_short_description(self):
|
|
64
|
+
"""Under 200 chars returns unchanged text."""
|
|
65
|
+
text = "This is a short description."
|
|
66
|
+
assert truncate_description(text) == text
|
|
67
|
+
|
|
68
|
+
def test_exact_length(self):
|
|
69
|
+
"""Exactly 200 chars returns unchanged."""
|
|
70
|
+
text = "a" * 200
|
|
71
|
+
assert truncate_description(text) == text
|
|
72
|
+
|
|
73
|
+
def test_whitespace_preserved_single(self):
|
|
74
|
+
"""Single newlines and spaces are preserved."""
|
|
75
|
+
text = "Line one\nLine two"
|
|
76
|
+
assert truncate_description(text) == text
|
|
77
|
+
|
|
78
|
+
def test_whitespace_collapsed_multiple(self):
|
|
79
|
+
"""3+ consecutive whitespace chars are collapsed."""
|
|
80
|
+
text = "Word one Word two" # 3 spaces
|
|
81
|
+
assert truncate_description(text) == "Word one Word two"
|
|
82
|
+
|
|
83
|
+
text_newlines = "Line one\n\n\nLine two" # 3 newlines
|
|
84
|
+
assert truncate_description(text_newlines) == "Line one Line two"
|
|
85
|
+
|
|
86
|
+
def test_truncation_at_word_boundary(self):
|
|
87
|
+
"""Truncation occurs at word boundary when possible."""
|
|
88
|
+
# Create text that's over 200 chars with spaces
|
|
89
|
+
text = "word " * 50 # 250 chars
|
|
90
|
+
result = truncate_description(text)
|
|
91
|
+
|
|
92
|
+
assert result is not None
|
|
93
|
+
assert result.endswith(TRUNCATION_SUFFIX)
|
|
94
|
+
assert len(result) <= 200
|
|
95
|
+
# Should end at a word boundary (not mid-word)
|
|
96
|
+
without_suffix = result[: -len(TRUNCATION_SUFFIX)]
|
|
97
|
+
assert without_suffix.endswith("word")
|
|
98
|
+
|
|
99
|
+
def test_truncation_no_space(self):
|
|
100
|
+
"""Long string without spaces hard-cuts."""
|
|
101
|
+
text = "a" * 250
|
|
102
|
+
result = truncate_description(text)
|
|
103
|
+
|
|
104
|
+
assert result is not None
|
|
105
|
+
assert result.endswith(TRUNCATION_SUFFIX)
|
|
106
|
+
assert len(result) == 200
|
|
107
|
+
|
|
108
|
+
def test_truncation_suffix_present(self):
|
|
109
|
+
"""Over 200 chars returns text ending with truncation suffix."""
|
|
110
|
+
text = "This is a long description. " * 20
|
|
111
|
+
result = truncate_description(text)
|
|
112
|
+
|
|
113
|
+
assert result is not None
|
|
114
|
+
assert result.endswith(TRUNCATION_SUFFIX)
|
|
115
|
+
|
|
116
|
+
def test_unicode_handled(self):
|
|
117
|
+
"""Unicode chars handled correctly."""
|
|
118
|
+
text = "Hello \u4e16\u754c! " * 30 # "Hello 世界! " repeated
|
|
119
|
+
result = truncate_description(text)
|
|
120
|
+
|
|
121
|
+
assert result is not None
|
|
122
|
+
# Should be valid UTF-8 string
|
|
123
|
+
result.encode("utf-8")
|
|
124
|
+
|
|
125
|
+
def test_custom_max_length(self):
|
|
126
|
+
"""Custom max_length is respected."""
|
|
127
|
+
text = "a" * 100
|
|
128
|
+
result = truncate_description(text, max_length=50)
|
|
129
|
+
|
|
130
|
+
assert result is not None
|
|
131
|
+
assert len(result) == 50
|
|
132
|
+
assert result.endswith(TRUNCATION_SUFFIX)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestLimitTags:
|
|
136
|
+
"""Tests for limit_tags function."""
|
|
137
|
+
|
|
138
|
+
def test_none_input(self):
|
|
139
|
+
"""None returns empty list."""
|
|
140
|
+
assert limit_tags(None) == []
|
|
141
|
+
|
|
142
|
+
def test_empty_list(self):
|
|
143
|
+
"""Empty list returns empty list."""
|
|
144
|
+
assert limit_tags([]) == []
|
|
145
|
+
|
|
146
|
+
def test_under_limit(self):
|
|
147
|
+
"""Under 10 tags returns unchanged."""
|
|
148
|
+
tags = ["tag1", "tag2", "tag3"]
|
|
149
|
+
result = limit_tags(tags)
|
|
150
|
+
assert result == tags
|
|
151
|
+
# Should be a copy, not the same object
|
|
152
|
+
assert result is not tags
|
|
153
|
+
|
|
154
|
+
def test_exact_limit(self):
|
|
155
|
+
"""Exactly 10 tags returns unchanged."""
|
|
156
|
+
tags = [f"tag{i}" for i in range(10)]
|
|
157
|
+
result = limit_tags(tags)
|
|
158
|
+
assert result == tags
|
|
159
|
+
|
|
160
|
+
def test_over_limit(self):
|
|
161
|
+
"""Over 10 tags returns first 9 + marker."""
|
|
162
|
+
tags = [f"tag{i}" for i in range(15)]
|
|
163
|
+
result = limit_tags(tags)
|
|
164
|
+
|
|
165
|
+
assert len(result) == 10
|
|
166
|
+
assert result[:9] == tags[:9]
|
|
167
|
+
assert result[9] == TAGS_TRUNCATION_MARKER
|
|
168
|
+
|
|
169
|
+
def test_custom_max_count(self):
|
|
170
|
+
"""Custom max_count is respected."""
|
|
171
|
+
tags = [f"tag{i}" for i in range(10)]
|
|
172
|
+
result = limit_tags(tags, max_count=5)
|
|
173
|
+
|
|
174
|
+
assert len(result) == 5
|
|
175
|
+
assert result[:4] == tags[:4]
|
|
176
|
+
assert result[4] == TAGS_TRUNCATION_MARKER
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TestCapsuleSearchResults:
|
|
180
|
+
"""Tests for CapsuleSearchResults model."""
|
|
181
|
+
|
|
182
|
+
def test_from_sdk_results(self):
|
|
183
|
+
"""from_sdk_results converts SDK results correctly."""
|
|
184
|
+
capsules = [
|
|
185
|
+
_MockCapsule(id="1", name="Capsule One", slug="capsule-one", description="First", tags=["a", "b"]),
|
|
186
|
+
_MockCapsule(id="2", name="Capsule Two", slug="capsule-two", description=None, tags=None),
|
|
187
|
+
]
|
|
188
|
+
sdk_results = _MockCapsuleResults(results=capsules, has_more=True, next_token="token123")
|
|
189
|
+
|
|
190
|
+
result = CapsuleSearchResults.from_sdk_results(sdk_results, include_field_names=False)
|
|
191
|
+
|
|
192
|
+
assert len(result.items) == 2
|
|
193
|
+
assert result.items[0].id == "1"
|
|
194
|
+
assert result.items[0].n == "Capsule One"
|
|
195
|
+
assert result.items[0].s == "capsule-one"
|
|
196
|
+
assert result.items[0].d == "First"
|
|
197
|
+
assert result.items[0].t == ["a", "b"]
|
|
198
|
+
assert result.items[1].d is None
|
|
199
|
+
assert result.items[1].t == []
|
|
200
|
+
assert result.has_more is True
|
|
201
|
+
assert result.next_token == "token123"
|
|
202
|
+
assert result.item_count == 2
|
|
203
|
+
assert result.field_names is None
|
|
204
|
+
|
|
205
|
+
def test_from_sdk_results_with_field_names(self):
|
|
206
|
+
"""from_sdk_results includes field_names when requested."""
|
|
207
|
+
sdk_results = _MockCapsuleResults(
|
|
208
|
+
results=[_MockCapsule(id="1", name="Test", slug="test", description=None, tags=None)],
|
|
209
|
+
has_more=False,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
result = CapsuleSearchResults.from_sdk_results(sdk_results, include_field_names=True)
|
|
213
|
+
|
|
214
|
+
assert result.field_names == CapsuleSearchResults.FIELD_NAMES
|
|
215
|
+
|
|
216
|
+
def test_serializable(self):
|
|
217
|
+
"""Result can be serialized to JSON."""
|
|
218
|
+
sdk_results = _MockCapsuleResults(
|
|
219
|
+
results=[_MockCapsule(id="1", name="Test", slug="test", description="Desc", tags=["t"])],
|
|
220
|
+
has_more=False,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
result = CapsuleSearchResults.from_sdk_results(sdk_results)
|
|
224
|
+
json_str = result.model_dump_json()
|
|
225
|
+
|
|
226
|
+
assert "items" in json_str
|
|
227
|
+
assert "has_more" in json_str
|
|
228
|
+
assert "item_count" in json_str
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TestDataAssetSearchResults:
|
|
232
|
+
"""Tests for DataAssetSearchResults model."""
|
|
233
|
+
|
|
234
|
+
def test_from_sdk_results(self):
|
|
235
|
+
"""from_sdk_results converts SDK results correctly."""
|
|
236
|
+
assets = [
|
|
237
|
+
_MockDataAsset(id="da-1", name="Asset One", description="First asset", tags=["x"]),
|
|
238
|
+
_MockDataAsset(id="da-2", name="Asset Two", description=None, tags=None),
|
|
239
|
+
]
|
|
240
|
+
sdk_results = _MockDataAssetResults(results=assets, has_more=False, next_token=None)
|
|
241
|
+
|
|
242
|
+
result = DataAssetSearchResults.from_sdk_results(sdk_results, include_field_names=False)
|
|
243
|
+
|
|
244
|
+
assert len(result.items) == 2
|
|
245
|
+
assert result.items[0].id == "da-1"
|
|
246
|
+
assert result.items[0].n == "Asset One"
|
|
247
|
+
assert result.items[0].d == "First asset"
|
|
248
|
+
assert result.items[0].t == ["x"]
|
|
249
|
+
assert result.items[1].d is None
|
|
250
|
+
assert result.items[1].t == []
|
|
251
|
+
assert result.has_more is False
|
|
252
|
+
assert result.next_token is None
|
|
253
|
+
assert result.item_count == 2
|
|
254
|
+
assert result.field_names is None
|
|
255
|
+
|
|
256
|
+
def test_truncation_applied(self):
|
|
257
|
+
"""Long descriptions and many tags are truncated."""
|
|
258
|
+
asset = _MockDataAsset(
|
|
259
|
+
id="da-1",
|
|
260
|
+
name="Test",
|
|
261
|
+
description="x" * 300, # Long description
|
|
262
|
+
tags=[f"tag{i}" for i in range(20)], # Many tags
|
|
263
|
+
)
|
|
264
|
+
sdk_results = _MockDataAssetResults(results=[asset], has_more=False)
|
|
265
|
+
|
|
266
|
+
result = DataAssetSearchResults.from_sdk_results(sdk_results)
|
|
267
|
+
|
|
268
|
+
# Description should be truncated
|
|
269
|
+
assert result.items[0].d is not None
|
|
270
|
+
assert result.items[0].d.endswith(TRUNCATION_SUFFIX)
|
|
271
|
+
assert len(result.items[0].d) <= 200
|
|
272
|
+
|
|
273
|
+
# Tags should be limited with marker
|
|
274
|
+
assert len(result.items[0].t) == 10
|
|
275
|
+
assert result.items[0].t[-1] == TAGS_TRUNCATION_MARKER
|
|
@@ -438,6 +438,7 @@ test_cases = [(test["prompt"], test["expected"]) for test in test_response]
|
|
|
438
438
|
ids = [test["id"] for test in test_response]
|
|
439
439
|
|
|
440
440
|
|
|
441
|
+
@pytest.mark.integration
|
|
441
442
|
@pytest.mark.parametrize("prompt,expected_response", test_cases, ids=ids)
|
|
442
443
|
def test_prompt_generating_the_right_tool_usage(prompt: str, expected_response: dict):
|
|
443
444
|
"""Test that the prompt generates the expected tool usage."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/__init__.py
RENAMED
|
File without changes
|
{codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/file_utils.py
RENAMED
|
File without changes
|
{codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/logging_config.py
RENAMED
|
File without changes
|
{codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/models.py
RENAMED
|
File without changes
|
{codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/server.py
RENAMED
|
File without changes
|
{codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|