Skip to content

Commit 19c6566

Browse files
committed
Bug fix
1 parent 25138fe commit 19c6566

File tree

10 files changed

+287
-405
lines changed

10 files changed

+287
-405
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
44

55
[project]
66
name = "protocol-mcp"
7-
version = "0.1.1"
7+
version = "0.1.2"
88
description = "MCP that connects to wetlab protocol resources, including protocols.io"
99
readme = "README.md"
1010
license = { file = "LICENSE" }

server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "protocol-mcp",
33
"description": "MCP server that connects to wetlab protocol resources, including protocols.io",
4-
"version": "0.1.1",
4+
"version": "0.1.2",
55
"repository": "https://github.com/biocontext-ai/protocol-mcp",
66
"tools": [
77
{

src/protocol_mcp/clients/protocols_io.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ def _get_headers(self) -> dict[str, str]:
3030
Returns
3131
-------
3232
dict[str, str]
33-
Headers dict with Authorization if token is configured.
33+
Headers dict with Authorization.
3434
"""
35-
headers = {"Accept": "application/json"}
36-
if self.config.access_token:
37-
headers["Authorization"] = f"Bearer {self.config.access_token.get_secret_value()}"
38-
return headers
35+
return {
36+
"Accept": "application/json",
37+
"Authorization": f"Bearer {self.config.access_token.get_secret_value()}",
38+
}
3939

4040
async def __aenter__(self) -> "ProtocolsIOClient":
4141
"""Enter async context manager."""

src/protocol_mcp/config.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class ProtocolsIOConfig(BaseModel):
1111

1212
base_url_v3: str = "https://www.protocols.io/api/v3"
1313
base_url_v4: str = "https://www.protocols.io/api/v4"
14-
access_token: SecretStr | None = None
14+
access_token: SecretStr
1515

1616

1717
class Settings(BaseModel):
@@ -28,11 +28,21 @@ def get_settings() -> Settings:
2828
-------
2929
Settings
3030
Application settings loaded from environment.
31+
32+
Raises
33+
------
34+
ValueError
35+
If PROTOCOLS_IO_ACCESS_TOKEN is not set.
3136
"""
3237
access_token = os.environ.get("PROTOCOLS_IO_ACCESS_TOKEN")
38+
if not access_token:
39+
raise ValueError(
40+
"PROTOCOLS_IO_ACCESS_TOKEN environment variable is required. "
41+
"Get your token from https://www.protocols.io/developers"
42+
)
3343

3444
return Settings(
3545
protocols_io=ProtocolsIOConfig(
36-
access_token=SecretStr(access_token) if access_token else None,
46+
access_token=SecretStr(access_token),
3747
)
3848
)

src/protocol_mcp/services/protocols_io.py

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Business logic for protocols.io operations."""
22

3+
import json
34
import re
45
from typing import Any
56

@@ -15,6 +16,43 @@
1516
)
1617

1718

19+
def _parse_draftjs_content(content: str | dict | None) -> str:
20+
"""Parse Draft.js JSON content to plain text.
21+
22+
The protocols.io API returns rich text fields as Draft.js JSON.
23+
This function extracts the plain text from those blocks.
24+
25+
Parameters
26+
----------
27+
content : str | dict | None
28+
Raw content, either as JSON string or parsed dict.
29+
30+
Returns
31+
-------
32+
str
33+
Plain text extracted from Draft.js blocks.
34+
"""
35+
if not content:
36+
return ""
37+
38+
if isinstance(content, str):
39+
# Check if it's JSON
40+
if content.startswith("{") or content.startswith("["):
41+
try:
42+
content = json.loads(content)
43+
except json.JSONDecodeError:
44+
return content
45+
else:
46+
return content
47+
48+
if isinstance(content, dict):
49+
blocks = content.get("blocks", [])
50+
if blocks:
51+
return "\n\n".join(block.get("text", "") for block in blocks if block.get("text"))
52+
53+
return str(content) if content else ""
54+
55+
1856
def _parse_search_item(item: dict[str, Any]) -> ProtocolSearchItem:
1957
"""Parse a single search result item.
2058
@@ -52,6 +90,8 @@ def _parse_search_item(item: dict[str, Any]) -> ProtocolSearchItem:
5290
def _parse_step(step: dict[str, Any]) -> ProtocolStep:
5391
"""Parse a single protocol step.
5492
93+
Handles both v3 and v4 API response formats.
94+
5595
Parameters
5696
----------
5797
step : dict[str, Any]
@@ -62,11 +102,15 @@ def _parse_step(step: dict[str, Any]) -> ProtocolStep:
62102
ProtocolStep
63103
Parsed step.
64104
"""
105+
# v4 API uses 'number' and 'step' (content), v3 uses 'step_number' and 'description'
106+
step_number = step.get("number") or step.get("step_number")
107+
description = _parse_draftjs_content(step.get("step") or step.get("description"))
108+
65109
return ProtocolStep(
66110
id=step.get("id", 0),
67-
step_number=step.get("step_number"),
111+
step_number=step_number,
68112
title=step.get("title"),
69-
description=step.get("description"),
113+
description=description,
70114
section=step.get("section"),
71115
duration=step.get("duration"),
72116
duration_unit=step.get("duration_unit"),
@@ -101,7 +145,6 @@ async def search_protocols(
101145
client: ProtocolsIOClient,
102146
query: str,
103147
max_results: int = 10,
104-
peer_reviewed_only: bool = False,
105148
) -> ProtocolSearchResponse:
106149
"""Search for protocols.
107150
@@ -113,20 +156,15 @@ async def search_protocols(
113156
Search query string.
114157
max_results : int
115158
Maximum number of results to return.
116-
peer_reviewed_only : bool
117-
Filter to peer-reviewed protocols only.
118159
119160
Returns
120161
-------
121162
ProtocolSearchResponse
122163
Search results with pagination info.
123164
"""
124-
filter_type = "peer_reviewed" if peer_reviewed_only else "public"
125-
126165
response = await client.search_protocols(
127166
query=query,
128167
page_size=max_results,
129-
filter_type=filter_type,
130168
)
131169

132170
items = [_parse_search_item(item) for item in response.get("items", [])]
@@ -199,7 +237,8 @@ async def get_protocol_detail(
199237
normalized_id = _normalize_protocol_id(protocol_id)
200238
response = await client.get_protocol(normalized_id)
201239

202-
protocol = response.get("protocol", response)
240+
# v4 API returns data in 'payload', v3 uses 'protocol'
241+
protocol = response.get("payload") or response.get("protocol") or response
203242

204243
creator = None
205244
if protocol.get("creator"):
@@ -213,10 +252,10 @@ async def get_protocol_detail(
213252
title=protocol.get("title", ""),
214253
uri=protocol.get("uri", ""),
215254
doi=protocol.get("doi"),
216-
description=protocol.get("description"),
217-
before_start=protocol.get("before_start"),
218-
warning=protocol.get("warning"),
219-
guidelines=protocol.get("guidelines"),
255+
description=_parse_draftjs_content(protocol.get("description")),
256+
before_start=_parse_draftjs_content(protocol.get("before_start")),
257+
warning=_parse_draftjs_content(protocol.get("warning")),
258+
guidelines=_parse_draftjs_content(protocol.get("guidelines")),
220259
steps=[],
221260
materials=[],
222261
creator=creator,
@@ -244,7 +283,15 @@ async def get_protocol_steps(
244283
"""
245284
normalized_id = _normalize_protocol_id(protocol_id)
246285
response = await client.get_protocol_steps(normalized_id)
247-
steps_data = response.get("steps", response.get("items", []))
286+
287+
# v4 API returns steps directly in 'payload' as a list
288+
# v3 API returns them in 'steps' or 'items'
289+
payload = response.get("payload")
290+
if isinstance(payload, list):
291+
steps_data = payload
292+
else:
293+
steps_data = response.get("steps", response.get("items", []))
294+
248295
return [_parse_step(step) for step in steps_data]
249296

250297

@@ -268,7 +315,15 @@ async def get_protocol_materials(
268315
"""
269316
normalized_id = _normalize_protocol_id(protocol_id)
270317
response = await client.get_protocol_materials(normalized_id)
271-
materials_data = response.get("materials", response.get("items", []))
318+
319+
# v4 API returns full protocol in 'payload', materials are in payload.materials
320+
# v3 API returns them in 'materials' or 'items'
321+
payload = response.get("payload")
322+
if isinstance(payload, dict):
323+
materials_data = payload.get("materials", [])
324+
else:
325+
materials_data = response.get("materials", response.get("items", []))
326+
272327
return [_parse_material(mat) for mat in materials_data]
273328

274329

src/protocol_mcp/tools/_protocols_io.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
async def search_protocols(
2121
query: Annotated[str, "Search term (e.g., 'RNA extraction', 'CRISPR knockout')"],
2222
max_results: Annotated[int, "Number of results to return (1-50)"] = 10,
23-
peer_reviewed_only: Annotated[bool, "Filter to peer-reviewed protocols only"] = False,
2423
) -> str:
2524
"""Search protocols.io for laboratory protocols.
2625
@@ -30,8 +29,6 @@ async def search_protocols(
3029
Search term to find protocols.
3130
max_results : int
3231
Number of results (1-50), defaults to 10.
33-
peer_reviewed_only : bool
34-
If True, only return peer-reviewed protocols.
3532
3633
Returns
3734
-------
@@ -45,7 +42,6 @@ async def search_protocols(
4542
client=client,
4643
query=query,
4744
max_results=max_results,
48-
peer_reviewed_only=peer_reviewed_only,
4945
)
5046

5147
return format_search_results(response)

tests/fixtures/__init__.py

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)