ictrp-mcp-server 0.1.0 → 0.1.1
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.
- package/CHANGELOG.md +25 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/sidecar/vendor/ictrp_mcp/ictrp/session.py +200 -39
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
Notable changes to the npm package `ictrp-mcp-server`. The Python package
|
|
4
4
|
`ictrp-mcp-service` is versioned separately in `pyproject.toml`.
|
|
5
5
|
|
|
6
|
+
## 0.1.1
|
|
7
|
+
|
|
8
|
+
**Retry on transient stalls** — the search POST intermittently stalls past a
|
|
9
|
+
minute. Measured: a `YL201` search returned `httpx.ReadTimeout` at a 60s budget
|
|
10
|
+
while a plain `GET /` on the same host answered HTTP 200 in 1.4s, so the stall
|
|
11
|
+
is in the POST chain rather than the host being down. Each of the three steps
|
|
12
|
+
(`load_form`, `search`, `export_csv`) is now retried with exponential backoff
|
|
13
|
+
and jitter, and the default per-request timeout went from 60s to 120s.
|
|
14
|
+
|
|
15
|
+
Only transport failures and `SESSION_FAILED` are retried. `UPSTREAM_BLOCKED` is
|
|
16
|
+
not: a refusal is information, and retrying into an active refusal both wastes
|
|
17
|
+
the budget and delays the diagnosis.
|
|
18
|
+
|
|
19
|
+
Two error-message fixes fell out of the same investigation:
|
|
20
|
+
|
|
21
|
+
- `httpx.ReadTimeout` stringifies to an empty message, which is how a real
|
|
22
|
+
failure reached a user as `Search request failed: ` with nothing after the
|
|
23
|
+
colon. Transport errors are now named when they carry no text.
|
|
24
|
+
- An exhausted retry used to repeat the per-attempt hint "then retry". It now
|
|
25
|
+
reports that the retries already happened and names `ICTRP_TIMEOUT` as the
|
|
26
|
+
next thing to raise.
|
|
27
|
+
|
|
28
|
+
New environment variables: `ICTRP_TIMEOUT` (default 120),
|
|
29
|
+
`ICTRP_ATTEMPTS` (default 3), `ICTRP_BACKOFF_SECONDS` (default 2, capped at 30).
|
|
30
|
+
|
|
6
31
|
## 0.1.0
|
|
7
32
|
|
|
8
33
|
First release.
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { getEnvReport } from "./runtime/env-probe.js";
|
|
|
21
21
|
import { callSidecar, pingSidecar, SEARCH_TIMEOUT_MS, toErrorText, } from "./runtime/sidecar-client.js";
|
|
22
22
|
import { ensureSidecar, installShutdownHooks, sidecarLogTail } from "./runtime/supervisor.js";
|
|
23
23
|
const SERVER_NAME = "ictrp-mcp-server";
|
|
24
|
-
const SERVER_VERSION = "0.1.
|
|
24
|
+
const SERVER_VERSION = "0.1.1";
|
|
25
25
|
/**
|
|
26
26
|
* Attached to every payload that returns rows.
|
|
27
27
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ictrp-mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "MCP server for the WHO International Clinical Trials Registry Platform (ICTRP). Plain HTTP, no browser. Every response is explicit about the export's known incompleteness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -19,6 +19,10 @@ Measured facts this module encodes (docs/MEASUREMENTS.md):
|
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
|
+
import asyncio
|
|
23
|
+
import os
|
|
24
|
+
import random
|
|
25
|
+
from collections.abc import Awaitable, Callable
|
|
22
26
|
from dataclasses import dataclass, field
|
|
23
27
|
|
|
24
28
|
import httpx
|
|
@@ -53,6 +57,145 @@ SEARCH_BUTTON = "Button1"
|
|
|
53
57
|
SEARCH_TEXTBOX = "TextBox1"
|
|
54
58
|
|
|
55
59
|
|
|
60
|
+
#: The portal's search POST intermittently stalls past a minute. Measured: a
|
|
61
|
+
#: `YL201` search returned `httpx.ReadTimeout` at a 60s budget while a plain
|
|
62
|
+
#: `GET /` on the same host answered HTTP 200 in 1.4s, so the stall is in the
|
|
63
|
+
#: POST chain rather than in the host being down. Widening the budget is the
|
|
64
|
+
#: cheap half of the fix; see `retry` for the half that actually recovers.
|
|
65
|
+
DEFAULT_TIMEOUT_SECONDS = 120.0
|
|
66
|
+
ENV_TIMEOUT = "ICTRP_TIMEOUT"
|
|
67
|
+
|
|
68
|
+
#: How many times a single step is attempted before its failure is surfaced.
|
|
69
|
+
#: Three is enough to ride out a transient stall without turning a genuine
|
|
70
|
+
#: outage into a multi-minute hang.
|
|
71
|
+
DEFAULT_ATTEMPTS = 3
|
|
72
|
+
ENV_ATTEMPTS = "ICTRP_ATTEMPTS"
|
|
73
|
+
|
|
74
|
+
#: Base delay between attempts, doubled each time and jittered. Jitter matters
|
|
75
|
+
#: because a client that retries on a fixed cadence can stay in lockstep with
|
|
76
|
+
#: whatever is stalling.
|
|
77
|
+
DEFAULT_BACKOFF_SECONDS = 2.0
|
|
78
|
+
ENV_BACKOFF = "ICTRP_BACKOFF_SECONDS"
|
|
79
|
+
|
|
80
|
+
#: The hard ceiling on a single backoff sleep, so a long search cannot turn
|
|
81
|
+
#: into an unbounded wait.
|
|
82
|
+
MAX_BACKOFF_SECONDS = 30.0
|
|
83
|
+
|
|
84
|
+
#: Transport failures and transient portal hiccups. A 429/503 raised as
|
|
85
|
+
#: UPSTREAM_BLOCKED is deliberately excluded: retrying into an active refusal
|
|
86
|
+
#: makes the block worse and hides the diagnosis.
|
|
87
|
+
RETRYABLE_CODES: frozenset = frozenset(
|
|
88
|
+
{ErrorCode.UPSTREAM_ERROR, ErrorCode.SESSION_FAILED}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _env_float(name: str, default: float) -> float:
|
|
93
|
+
raw = os.environ.get(name)
|
|
94
|
+
if raw:
|
|
95
|
+
try:
|
|
96
|
+
value = float(raw)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return default
|
|
99
|
+
return value if value > 0 else default
|
|
100
|
+
return default
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _env_int(name: str, default: int) -> int:
|
|
104
|
+
raw = os.environ.get(name)
|
|
105
|
+
if raw:
|
|
106
|
+
try:
|
|
107
|
+
value = int(raw)
|
|
108
|
+
except ValueError:
|
|
109
|
+
return default
|
|
110
|
+
return value if value > 0 else default
|
|
111
|
+
return default
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _describe(exc: httpx.HTTPError) -> str:
|
|
115
|
+
"""Text for a transport error that may legitimately be empty.
|
|
116
|
+
|
|
117
|
+
`httpx.ReadTimeout` stringifies to an empty message, which is how a real
|
|
118
|
+
failure reached a user as `Search request failed: ` with nothing after the
|
|
119
|
+
colon. Name the class when there is no text, so the message is never blank.
|
|
120
|
+
"""
|
|
121
|
+
text = str(exc).strip()
|
|
122
|
+
if text:
|
|
123
|
+
return text
|
|
124
|
+
return f"{type(exc).__name__} (no message)"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def timeout_seconds() -> float:
|
|
128
|
+
"""Per-request timeout, overridable for slow or poor links."""
|
|
129
|
+
return _env_float(ENV_TIMEOUT, DEFAULT_TIMEOUT_SECONDS)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def max_attempts() -> int:
|
|
133
|
+
"""Attempts per step, so a transient stall is ridden out rather than raised."""
|
|
134
|
+
return _env_int(ENV_ATTEMPTS, DEFAULT_ATTEMPTS)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def backoff_seconds() -> float:
|
|
138
|
+
return _env_float(ENV_BACKOFF, DEFAULT_BACKOFF_SECONDS)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sleep_for(attempt: int) -> float:
|
|
142
|
+
"""Exponential backoff with jitter, capped.
|
|
143
|
+
|
|
144
|
+
`attempt` is 1-based: the first retry waits ~base, the second ~2x base.
|
|
145
|
+
"""
|
|
146
|
+
delay = backoff_seconds() * (2 ** (attempt - 1))
|
|
147
|
+
delay = min(delay, MAX_BACKOFF_SECONDS)
|
|
148
|
+
return random.uniform(delay * 0.5, delay)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def retry(
|
|
152
|
+
step: str,
|
|
153
|
+
op: Callable[[], Awaitable[object]],
|
|
154
|
+
*,
|
|
155
|
+
attempts: int | None = None,
|
|
156
|
+
sleeper: Callable[[float], Awaitable[None]] | None = None,
|
|
157
|
+
) -> object:
|
|
158
|
+
"""Run `op`, retrying only failures that a retry could plausibly fix.
|
|
159
|
+
|
|
160
|
+
Non-retryable codes propagate on the first attempt. A refusal is
|
|
161
|
+
information -- the caller needs to see "blocked", not "slow" -- and
|
|
162
|
+
retrying it would both waste the budget and delay the diagnosis.
|
|
163
|
+
"""
|
|
164
|
+
total = attempts if attempts is not None else max_attempts()
|
|
165
|
+
sleep = sleeper or asyncio.sleep
|
|
166
|
+
last: IctrpError | None = None
|
|
167
|
+
|
|
168
|
+
for index in range(1, total + 1):
|
|
169
|
+
try:
|
|
170
|
+
return await op()
|
|
171
|
+
except IctrpError as exc:
|
|
172
|
+
if exc.code not in RETRYABLE_CODES:
|
|
173
|
+
raise
|
|
174
|
+
last = exc
|
|
175
|
+
if index == total:
|
|
176
|
+
break
|
|
177
|
+
await sleep(_sleep_for(index))
|
|
178
|
+
|
|
179
|
+
assert last is not None
|
|
180
|
+
# The per-attempt hint ("then retry") is wrong by the time we get here: we
|
|
181
|
+
# have already retried. Speak to what the caller can still do.
|
|
182
|
+
hint = (
|
|
183
|
+
f"Already retried {total} time(s) with backoff; every attempt failed. "
|
|
184
|
+
f"Raise {ENV_TIMEOUT} (currently {timeout_seconds():g}s) to give each "
|
|
185
|
+
f"attempt longer, or retry later -- the portal was answering plain GETs "
|
|
186
|
+
f"while this step stalled."
|
|
187
|
+
)
|
|
188
|
+
raise IctrpError(
|
|
189
|
+
last.code,
|
|
190
|
+
f"{step}: failed after {total} attempt(s) -- {last.message}",
|
|
191
|
+
upstream_status=last.upstream_status,
|
|
192
|
+
upstream_content_type=last.upstream_content_type,
|
|
193
|
+
upstream_body_excerpt=last.upstream_body_excerpt,
|
|
194
|
+
hint=hint,
|
|
195
|
+
detail=f"Retryable failure, exhausted {total} attempts: {last.detail or ''}".strip(),
|
|
196
|
+
) from last
|
|
197
|
+
|
|
198
|
+
|
|
56
199
|
def _check_present(state: htmlstate.FormState, *, step: str) -> None:
|
|
57
200
|
missing = state.missing_state()
|
|
58
201
|
if missing:
|
|
@@ -86,10 +229,13 @@ class IctrpSession:
|
|
|
86
229
|
steps: list[str] = field(default_factory=list)
|
|
87
230
|
|
|
88
231
|
@classmethod
|
|
89
|
-
def create(cls, *, timeout: float =
|
|
232
|
+
def create(cls, *, timeout: float | None = None) -> "IctrpSession":
|
|
233
|
+
# 60s was measured to be too tight: the search POST stalls past it while
|
|
234
|
+
# the host is answering GETs in ~1.4s. Callers that pass nothing get the
|
|
235
|
+
# current default; callers that pass a value still get that value.
|
|
90
236
|
client = httpx.AsyncClient(
|
|
91
237
|
headers=DEFAULT_HEADERS,
|
|
92
|
-
timeout=timeout,
|
|
238
|
+
timeout=timeout if timeout is not None else timeout_seconds(),
|
|
93
239
|
follow_redirects=False,
|
|
94
240
|
)
|
|
95
241
|
return cls(client=client)
|
|
@@ -106,14 +252,18 @@ class IctrpSession:
|
|
|
106
252
|
# ---- step 1 -----------------------------------------------------------
|
|
107
253
|
|
|
108
254
|
async def load_form(self) -> htmlstate.FormState:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
255
|
+
async def attempt_get():
|
|
256
|
+
try:
|
|
257
|
+
return await self.client.get(self.base_url)
|
|
258
|
+
except httpx.HTTPError as exc:
|
|
259
|
+
raise IctrpError(
|
|
260
|
+
ErrorCode.UPSTREAM_ERROR,
|
|
261
|
+
f"Could not reach the ICTRP search portal: {_describe(exc)}",
|
|
262
|
+
hint="Check network connectivity, then retry.",
|
|
263
|
+
) from exc
|
|
264
|
+
|
|
265
|
+
response = await retry("load_form", attempt_get)
|
|
266
|
+
assert isinstance(response, httpx.Response)
|
|
117
267
|
|
|
118
268
|
html = classify_form_page(status=response.status_code, body=response.content)
|
|
119
269
|
self.steps.append(f"GET {self.base_url} -> {response.status_code}")
|
|
@@ -138,20 +288,25 @@ class IctrpSession:
|
|
|
138
288
|
body = self.form_state.merged_with(
|
|
139
289
|
{SEARCH_TEXTBOX: keyword, SEARCH_BUTTON: "Search"}
|
|
140
290
|
)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
291
|
+
|
|
292
|
+
async def attempt_post():
|
|
293
|
+
try:
|
|
294
|
+
return await self.client.post(
|
|
295
|
+
self.base_url,
|
|
296
|
+
data=body,
|
|
297
|
+
headers={
|
|
298
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
299
|
+
"Referer": self.base_url,
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
except httpx.HTTPError as exc:
|
|
303
|
+
raise IctrpError(
|
|
304
|
+
ErrorCode.UPSTREAM_ERROR,
|
|
305
|
+
f"Search request failed: {_describe(exc)}",
|
|
306
|
+
) from exc
|
|
307
|
+
|
|
308
|
+
response = await retry(f"search {keyword!r}", attempt_post)
|
|
309
|
+
assert isinstance(response, httpx.Response)
|
|
155
310
|
|
|
156
311
|
location = response.headers.get("location")
|
|
157
312
|
if location and "/noaccess.aspx" in location.lower():
|
|
@@ -205,20 +360,24 @@ class IctrpSession:
|
|
|
205
360
|
# can never be reintroduced silently.
|
|
206
361
|
htmlstate.assert_export_body_excludes_search_controls(body)
|
|
207
362
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
self.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
363
|
+
async def attempt_export():
|
|
364
|
+
try:
|
|
365
|
+
return await self.client.post(
|
|
366
|
+
self.base_url,
|
|
367
|
+
data=body,
|
|
368
|
+
headers={
|
|
369
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
370
|
+
"Referer": self.base_url,
|
|
371
|
+
},
|
|
372
|
+
)
|
|
373
|
+
except httpx.HTTPError as exc:
|
|
374
|
+
raise IctrpError(
|
|
375
|
+
ErrorCode.UPSTREAM_ERROR,
|
|
376
|
+
f"Export request failed: {_describe(exc)}",
|
|
377
|
+
) from exc
|
|
378
|
+
|
|
379
|
+
response = await retry("export_csv", attempt_export)
|
|
380
|
+
assert isinstance(response, httpx.Response)
|
|
222
381
|
|
|
223
382
|
self.steps.append(f"POST export -> {response.status_code}")
|
|
224
383
|
self.response_date = response.headers.get("date")
|
|
@@ -236,7 +395,9 @@ class IctrpSession:
|
|
|
236
395
|
return list(self.steps)
|
|
237
396
|
|
|
238
397
|
|
|
239
|
-
async def run_chain(
|
|
398
|
+
async def run_chain(
|
|
399
|
+
keyword: str, *, timeout: float | None = None
|
|
400
|
+
) -> tuple[CsvPayload, IctrpSession]:
|
|
240
401
|
"""Run the full chain and return the payload plus the session for provenance."""
|
|
241
402
|
session = IctrpSession.create(timeout=timeout)
|
|
242
403
|
await session.load_form()
|