ictrp-mcp-server 0.1.0 → 0.1.2

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 CHANGED
@@ -3,6 +3,56 @@
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.2
7
+
8
+ **Default search view is now the clinician-triage view.** `ictrp_search` used to
9
+ return the 10 most common columns; it now returns the columns a clinician needs
10
+ to triage a target: title and scientific title, sponsor and secondary sponsor,
11
+ contact (PI) name/affiliation/email, recruitment status, phase, registration
12
+ date, condition, countries, age bounds and gender, target size, and a
13
+ `line_of_therapy_hint`. A new derived field, `line_of_therapy_hint`, is a
14
+ non-authoritative extraction of treatment line from the free-text
15
+ inclusion/exclusion criteria and the scientific title. It returns
16
+ `first-line`/`second-line`/`third-line`/`fourth-line`/`later-line`, or `null`
17
+ when nothing is stated -- `null` is documented as "not stated", never as
18
+ "first-line", because the export is incomplete and the column does not exist.
19
+
20
+ **Line-hint fix** — the hint missed trials whose line is stated only as a number
21
+ or only in the scientific title (e.g. "Received >=2 Prior Lines of Therapy"). It
22
+ now scans the scientific title and recognizes numeric line statements; a minimum
23
+ of N lines resolves to `first-line` only when N<=1, otherwise `later-line`.
24
+
25
+ Documented structural limits surfaced by the wider view: ICTRP has no "PI" and
26
+ no "participating-hospital list" column -- sponsor/PI come from
27
+ `primary_sponsor` + `contact_*`, and "participating hospitals" degrades to
28
+ `countries` + `contact_affiliation`. CT.gov `exclusion_criteria` is frequently
29
+ blank upstream, which is a source gap, not a parse failure.
30
+
31
+ ## 0.1.1
32
+
33
+ **Retry on transient stalls** — the search POST intermittently stalls past a
34
+ minute. Measured: a `YL201` search returned `httpx.ReadTimeout` at a 60s budget
35
+ while a plain `GET /` on the same host answered HTTP 200 in 1.4s, so the stall
36
+ is in the POST chain rather than the host being down. Each of the three steps
37
+ (`load_form`, `search`, `export_csv`) is now retried with exponential backoff
38
+ and jitter, and the default per-request timeout went from 60s to 120s.
39
+
40
+ Only transport failures and `SESSION_FAILED` are retried. `UPSTREAM_BLOCKED` is
41
+ not: a refusal is information, and retrying into an active refusal both wastes
42
+ the budget and delays the diagnosis.
43
+
44
+ Two error-message fixes fell out of the same investigation:
45
+
46
+ - `httpx.ReadTimeout` stringifies to an empty message, which is how a real
47
+ failure reached a user as `Search request failed: ` with nothing after the
48
+ colon. Transport errors are now named when they carry no text.
49
+ - An exhausted retry used to repeat the per-attempt hint "then retry". It now
50
+ reports that the retries already happened and names `ICTRP_TIMEOUT` as the
51
+ next thing to raise.
52
+
53
+ New environment variables: `ICTRP_TIMEOUT` (default 120),
54
+ `ICTRP_ATTEMPTS` (default 3), `ICTRP_BACKOFF_SECONDS` (default 2, capped at 30).
55
+
6
56
  ## 0.1.0
7
57
 
8
58
  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.0";
24
+ const SERVER_VERSION = "0.1.2";
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.0",
3
+ "version": "0.1.2",
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",
@@ -323,6 +323,18 @@ def to_trial(row: list[str], header: tuple[str, ...]) -> dict[str, Any]:
323
323
  trial["inclusion_age_min"] = _as_int(age_min)
324
324
  trial["inclusion_age_max"] = _as_int(age_max)
325
325
 
326
+ # ICTRP has no structured "line of therapy" column. Treatment line is stated
327
+ # only inside the free-text inclusion/exclusion criteria (e.g. "first-line",
328
+ # "previously treated", "一线") and, frequently, the scientific title
329
+ # ("...Received >=2 Prior Lines of Therapy"). Derive a non-authoritative hint
330
+ # from that text so a caller can surface it without re-parsing. It is a hint,
331
+ # never a fact: absence here does NOT mean the trial is treatment-naive.
332
+ trial["line_of_therapy_hint"] = derive_line_of_therapy_hint(
333
+ raw.get("inclusion_criteria"),
334
+ raw.get("exclusion_criteria"),
335
+ raw.get("scientific_title"),
336
+ )
337
+
326
338
  # `results yes no` is a flag, not content, so it is excluded from the test.
327
339
  # Measured: the substantive `results *` columns are ~0.0% populated for
328
340
  # ChiCTR records, so this is a property of the registry rather than of an
@@ -346,3 +358,66 @@ def _as_int(value: str | None) -> int | None:
346
358
  return None
347
359
  m = re.search(r"-?\d+", text)
348
360
  return int(m.group(0)) if m else None
361
+
362
+
363
+ #: Treatment-line vocabulary, as it actually appears in live inclusion/exclusion
364
+ #: text. Both English and Chinese markers are matched; "refractory"/"relapsed"/
365
+ #: "previously treated" indicate a later line without naming a number.
366
+ _LINE_PATTERNS: tuple[tuple[str, str], ...] = (
367
+ (r"first[-\s]?line|1st[-\s]?line|一线|treatment[-\s]?naive|previously untreated", "first-line"),
368
+ (r"second[-\s]?line|2nd[-\s]?line|二线", "second-line"),
369
+ (r"third[-\s]?line|3rd[-\s]?line|三线", "third-line"),
370
+ (r"fourth[-\s]?line|4th[-\s]?line|四线", "fourth-line"),
371
+ (r"relapsed|refractory|previously treated|prior (therapy|treatment)|已接受.*治疗|经治", "later-line"),
372
+ )
373
+
374
+ #: Numeric "line" statements, e.g. ">=2 Prior Lines of Therapy" or "2-line",
375
+ #: where a word may sit between the number and "line" ("2 prior lines"). The
376
+ #: captured number is the *minimum* line; ">=2" means second-line or later, never
377
+ #: first-line, so it resolves to `later-line` rather than a specific line.
378
+ _LINE_NUMBER = re.compile(r"(\d+)\s*(?:[-\w.,]+\s)*?(?:line|lines|线)")
379
+
380
+ _WORD_NUMBERS = {
381
+ "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
382
+ "一": 1, "二": 2, "三": 3, "四": 4, "五": 5,
383
+ }
384
+ _WORD_NUMBER = re.compile(r"(one|two|three|four|five|一|二|三|四|五)\s*(?:line|lines|线)")
385
+
386
+
387
+ def derive_line_of_therapy_hint(
388
+ inclusion: str | None, exclusion: str | None, scientific_title: str | None = None
389
+ ) -> str | None:
390
+ """Best-effort treatment-line label from free-text criteria.
391
+
392
+ Returns the earliest explicit line if one is named (first > second > third >
393
+ fourth), else "later-line" when a numeric "N lines" (N>=2) or relapse/
394
+ refractory language is present, else `None`. `None` is deliberate: it means
395
+ "not stated in the criteria we hold", which must not be read as "first-line"
396
+ -- the export is incomplete and the column does not exist.
397
+
398
+ The scientific title is included because the line is often stated only there
399
+ (e.g. "...Received >=2 Prior Lines of Therapy"), not in the criteria body.
400
+ """
401
+ text = f"{inclusion or ''} {exclusion or ''} {scientific_title or ''}"
402
+ if not text.strip():
403
+ return None
404
+ from .query import _coerce # local import avoids a cycle at module load
405
+
406
+ lowered = _coerce(text)
407
+
408
+ # Explicit worded lines take precedence -- they name a specific line.
409
+ for pattern, label in _LINE_PATTERNS:
410
+ if re.search(pattern, lowered):
411
+ return label
412
+
413
+ # Numeric "N line(s)": a minimum of N, so only the first-line case is exact;
414
+ # anything >=2 is "later-line", never a specific later number.
415
+ for match in _LINE_NUMBER.finditer(lowered):
416
+ if int(match.group(1)) <= 1:
417
+ return "first-line"
418
+ return "later-line"
419
+ for match in _WORD_NUMBER.finditer(lowered):
420
+ if _WORD_NUMBERS[match.group(1)] <= 1:
421
+ return "first-line"
422
+ return "later-line"
423
+ return None
@@ -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 = 60.0) -> "IctrpSession":
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
- try:
110
- response = await self.client.get(self.base_url)
111
- except httpx.HTTPError as exc:
112
- raise IctrpError(
113
- ErrorCode.UPSTREAM_ERROR,
114
- f"Could not reach the ICTRP search portal: {exc}",
115
- hint="Check network connectivity, then retry.",
116
- ) from exc
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
- try:
142
- response = await self.client.post(
143
- self.base_url,
144
- data=body,
145
- headers={
146
- "Content-Type": "application/x-www-form-urlencoded",
147
- "Referer": self.base_url,
148
- },
149
- )
150
- except httpx.HTTPError as exc:
151
- raise IctrpError(
152
- ErrorCode.UPSTREAM_ERROR,
153
- f"Search request failed: {exc}",
154
- ) from exc
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
- try:
209
- response = await self.client.post(
210
- self.base_url,
211
- data=body,
212
- headers={
213
- "Content-Type": "application/x-www-form-urlencoded",
214
- "Referer": self.base_url,
215
- },
216
- )
217
- except httpx.HTTPError as exc:
218
- raise IctrpError(
219
- ErrorCode.UPSTREAM_ERROR,
220
- f"Export request failed: {exc}",
221
- ) from exc
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(keyword: str, *, timeout: float = 120.0) -> tuple[CsvPayload, IctrpSession]:
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()
@@ -35,16 +35,33 @@ from .provenance import INCOMPLETENESS_NOTICE, Provenance, derive_set_provenance
35
35
 
36
36
  #: Fields shown by default when a caller does not ask for specific ones. Chosen to
37
37
  #: be the high-coverage, decision-relevant columns rather than all 58.
38
+ #: The default view. Deliberately NOT just the 10 most common columns: it is the
39
+ #: view a clinician actually needs to triage a target -- name, sponsor/PI, where
40
+ #: it runs, eligibility shape, and a treatment-line hint. Sponsor/PI come from the
41
+ #: `primary_sponsor` + `contact_*` columns (ICTRP has no dedicated "PI" or
42
+ #: "participating-hospital" field; hospitals exist only as `countries` and the
43
+ #: contact's affiliation), so those are surfaced here. `line_of_therapy_hint` is a
44
+ #: non-authoritative extraction from the free-text criteria -- see normalize.py.
38
45
  DEFAULT_FIELDS: tuple[str, ...] = (
39
46
  "trial_id",
40
47
  "source_register",
41
48
  "public_title",
49
+ "scientific_title",
50
+ "primary_sponsor",
51
+ "secondary_sponsor",
52
+ "contact_firstname",
53
+ "contact_lastname",
54
+ "contact_affiliation",
42
55
  "recruitment_status",
43
56
  "phase_code",
44
57
  "registration_date",
45
58
  "condition",
46
59
  "countries",
60
+ "inclusion_age_min",
61
+ "inclusion_age_max",
62
+ "inclusion_gender",
47
63
  "target_size_total",
64
+ "line_of_therapy_hint",
48
65
  "last_refreshed_display",
49
66
  )
50
67