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.
Files changed (27) hide show
  1. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/CHANGELOG.md +4 -0
  2. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/PKG-INFO +1 -1
  3. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/pyproject.toml +3 -1
  4. codeocean_mcp_server-0.8.0/src/codeocean_mcp_server/search.py +136 -0
  5. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/capsules.py +15 -21
  6. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/data_assets.py +15 -9
  7. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/tests/bedrock_call.py +10 -2
  8. codeocean_mcp_server-0.8.0/tests/conftest.py +23 -0
  9. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/tests/test_multi_tool_calling.py +2 -0
  10. codeocean_mcp_server-0.8.0/tests/test_search.py +275 -0
  11. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/tests/test_tool_calling.py +1 -0
  12. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/uv.lock +1 -1
  13. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/.gitignore +0 -0
  14. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/.python-version +0 -0
  15. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/LICENSE +0 -0
  16. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/README.md +0 -0
  17. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/RELEASE.md +0 -0
  18. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/__init__.py +0 -0
  19. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/file_utils.py +0 -0
  20. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/logging_config.py +0 -0
  21. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/models.py +0 -0
  22. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/server.py +0 -0
  23. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/__init__.py +0 -0
  24. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/computations.py +0 -0
  25. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/src/codeocean_mcp_server/tools/custom_metadata.py +0 -0
  26. {codeocean_mcp_server-0.7.2 → codeocean_mcp_server-0.8.0}/tests/bedrock_tools_converter.py +0 -0
  27. {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.7.2
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.7.2"
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
+ )
@@ -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
- description=(
25
- str(client.capsules.search_capsules.__doc__) + "Use only for capsule searches. "
26
- "Provide only the minimal required parameters (e.g. limit=10); "
27
- "do not include optional params "
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
- return client.capsules.search_capsules(params)
30
+ results = client.capsules.search_capsules(params)
31
+ return CapsuleSearchResults.from_sdk_results(results, include_field_names)
35
32
 
36
- @mcp.tool(
37
- description=(
38
- str(client.capsules.search_pipelines.__doc__) + "Use only for pipeline searches. "
39
- "Provide only the minimal required parameters (e.g. limit=10); "
40
- "do not include optional params "
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
- return client.capsules.search_pipelines(params)
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(search_params: DataAssetSearchParamsModel) -> DataAssetSearchResults:
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
- return client.data_assets.search_data_assets(params)
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
- client = boto3.client("bedrock-runtime")
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 client.converse(**payload)
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."""
@@ -529,7 +529,7 @@ wheels = [
529
529
 
530
530
  [[package]]
531
531
  name = "codeocean-mcp-server"
532
- version = "0.7.2"
532
+ version = "0.8.0"
533
533
  source = { editable = "." }
534
534
  dependencies = [
535
535
  { name = "codeocean" },