ictrp-mcp-server 0.1.0

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 (42) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +37 -0
  3. package/README.md +208 -0
  4. package/README_ZH.md +189 -0
  5. package/dist/cli/setup-cli.d.ts +14 -0
  6. package/dist/cli/setup-cli.js +230 -0
  7. package/dist/cli/setup-cli.js.map +1 -0
  8. package/dist/index.d.ts +18 -0
  9. package/dist/index.js +477 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/runtime/bootstrap.d.ts +99 -0
  12. package/dist/runtime/bootstrap.js +350 -0
  13. package/dist/runtime/bootstrap.js.map +1 -0
  14. package/dist/runtime/env-probe.d.ts +108 -0
  15. package/dist/runtime/env-probe.js +479 -0
  16. package/dist/runtime/env-probe.js.map +1 -0
  17. package/dist/runtime/sidecar-client.d.ts +50 -0
  18. package/dist/runtime/sidecar-client.js +120 -0
  19. package/dist/runtime/sidecar-client.js.map +1 -0
  20. package/dist/runtime/supervisor.d.ts +47 -0
  21. package/dist/runtime/supervisor.js +248 -0
  22. package/dist/runtime/supervisor.js.map +1 -0
  23. package/package.json +60 -0
  24. package/sidecar/ictrp_sidecar.py +602 -0
  25. package/sidecar/vendor/ictrp_mcp/__init__.py +3 -0
  26. package/sidecar/vendor/ictrp_mcp/cache/__init__.py +0 -0
  27. package/sidecar/vendor/ictrp_mcp/cache/store.py +313 -0
  28. package/sidecar/vendor/ictrp_mcp/data/__init__.py +0 -0
  29. package/sidecar/vendor/ictrp_mcp/data/columns.py +108 -0
  30. package/sidecar/vendor/ictrp_mcp/data/jsonio.py +213 -0
  31. package/sidecar/vendor/ictrp_mcp/data/normalize.py +348 -0
  32. package/sidecar/vendor/ictrp_mcp/data/query.py +307 -0
  33. package/sidecar/vendor/ictrp_mcp/errors.py +123 -0
  34. package/sidecar/vendor/ictrp_mcp/ictrp/__init__.py +0 -0
  35. package/sidecar/vendor/ictrp_mcp/ictrp/export_guard.py +269 -0
  36. package/sidecar/vendor/ictrp_mcp/ictrp/htmlstate.py +143 -0
  37. package/sidecar/vendor/ictrp_mcp/ictrp/session.py +245 -0
  38. package/sidecar/vendor/ictrp_mcp/offline.py +133 -0
  39. package/sidecar/vendor/ictrp_mcp/provenance.py +182 -0
  40. package/sidecar/vendor/ictrp_mcp/server.py +368 -0
  41. package/sidecar/vendor/ictrp_mcp/tools.py +712 -0
  42. package/sidecar/vendor/pyproject.toml +25 -0
@@ -0,0 +1,602 @@
1
+ #!/usr/bin/env python3
2
+ """HTTP sidecar for the WHO ICTRP search chain.
3
+
4
+ The MCP server is a Node process. The ICTRP access logic is Python. This module
5
+ is the narrow bridge between them: a stdlib-only HTTP server that exposes the
6
+ three-step postback chain (load form -> search -> export CSV) plus the local
7
+ query operations over a materialized set.
8
+
9
+ Why a sidecar rather than a port of the Python package to TypeScript:
10
+
11
+ * The chain's hard-won details -- which HTML controls to carry, which redirect
12
+ means "blocked" versus "contract drift", how a legitimate empty CSV differs
13
+ from a failed one -- are already implemented and tested in ``ictrp_mcp``.
14
+ A second implementation would drift.
15
+ * The Node layer must stay small. It is the MCP surface; the Python layer is
16
+ the domain.
17
+
18
+ Design constraints that shape everything below:
19
+
20
+ 1. **Standard library only.** No Flask, no FastAPI, no uvicorn. The only
21
+ third-party dependency is ``httpx``, which the vendor install step provides
22
+ into a private venv. A sidecar that needs a web framework is a sidecar that
23
+ fails to start on a machine with an unusual Python.
24
+
25
+ 2. **A failure never returns zero rows.** The whole point of this service is
26
+ that ICTRP's CSV export silently omits records. An error must therefore be
27
+ an HTTP error carrying a machine-readable code, never a 200 with an empty
28
+ result list. The caller distinguishes "0 matches" from "could not ask" by
29
+ status code, and nothing else.
30
+
31
+ 3. **Sets are process state, not client state.** ``ictrp_mcp``'s materialized
32
+ sets live in memory keyed by ``set_id``. They do not survive a restart. The
33
+ sidecar keeps them for its own lifetime and says so honestly: an unknown
34
+ ``set_id`` is ``409 set_expired``, and the caller re-searches. Pretending
35
+ otherwise would silently under-report.
36
+
37
+ 4. **No browser, ever.** ICTRP needs no JavaScript execution to yield data.
38
+ There is deliberately no Playwright, no Chromium, no solver. If a check for
39
+ that ever appears here, it is a mistake -- see docs/DESIGN notes on the
40
+ measured chain.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import asyncio
47
+ import json
48
+ import logging
49
+ import os
50
+ import sys
51
+ import threading
52
+ import traceback
53
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
54
+ from typing import Any
55
+ from urllib.parse import parse_qs, urlparse
56
+
57
+ # --------------------------------------------------------------------------
58
+ # Import the ICTRP implementation.
59
+ #
60
+ # Three resolutions, tried in order, because the same file runs in three
61
+ # different layouts:
62
+ # 1. vendored -- ``sidecar/vendor/`` inside a published npm package. First,
63
+ # so a published install never silently picks up an unrelated
64
+ # ``ictrp_mcp`` that happens to be importable;
65
+ # 2. development -- the repository root is the grandparent of this file and
66
+ # the package lives under ``src/``;
67
+ # 3. installed -- pip put ``ictrp_mcp`` into the private venv that
68
+ # ``ictrp-setup setup`` builds.
69
+ # --------------------------------------------------------------------------
70
+
71
+ _HERE = os.path.dirname(os.path.abspath(__file__))
72
+
73
+ _CANDIDATES = [
74
+ os.path.join(_HERE, "vendor"), # published
75
+ os.path.join(os.path.dirname(os.path.dirname(_HERE)), "src"), # checkout
76
+ ]
77
+
78
+ for _candidate in _CANDIDATES:
79
+ if os.path.isdir(os.path.join(_candidate, "ictrp_mcp")) and _candidate not in sys.path:
80
+ sys.path.insert(0, _candidate)
81
+ break
82
+
83
+ from ictrp_mcp import offline # noqa: E402
84
+ from ictrp_mcp.data import query as query_mod # noqa: E402
85
+ from ictrp_mcp.errors import ErrorCode, IctrpError # noqa: E402
86
+ from ictrp_mcp.cache.store import SetStore, default_cache_dir, normalize_query # noqa: E402
87
+ from ictrp_mcp.ictrp.session import run_chain # noqa: E402
88
+ from ictrp_mcp.provenance import Provenance # noqa: E402
89
+ from ictrp_mcp.tools import DEFAULT_FIELDS, IctrpService # noqa: E402
90
+
91
+ LOG = logging.getLogger("ictrp-sidecar")
92
+
93
+ # Repeated verbatim on every row-bearing response. The Node layer also attaches
94
+ # it, but the sidecar must not depend on the caller remembering: anything that
95
+ # reads this HTTP API directly deserves the same warning.
96
+ _INCOMPLETENESS_NOTICE = (
97
+ "Counts describe retrieved rows only. The ICTRP CSV export is known to omit "
98
+ "records the portal itself reports as matches, so absence is not evidence "
99
+ "of nonexistence."
100
+ )
101
+
102
+ DEFAULT_HOST = "127.0.0.1"
103
+ DEFAULT_PORT = 8849
104
+
105
+ # The sidecar holds one IctrpService for its whole life. The service owns the
106
+ # upstream session and the set store; recreating it per request would discard
107
+ # the warm connection pool for no benefit.
108
+ _SERVICE_LOCK = threading.Lock()
109
+ _SERVICE: IctrpService | None = None
110
+
111
+ # One event loop per worker thread. ``IctrpService`` binds an
112
+ # ``httpx.AsyncClient`` to whatever loop first drives it; using that client
113
+ # from a second loop raises. Since ``ThreadingHTTPServer`` spawns a thread per
114
+ # connection, each thread gets its own loop and the client is re-created on
115
+ # demand inside the service.
116
+ _LOOP_LOCAL = threading.local()
117
+
118
+
119
+ def _run_sync(coro: Any) -> Any:
120
+ """Drive ``coro`` to completion on this thread's event loop."""
121
+ loop = getattr(_LOOP_LOCAL, "loop", None)
122
+ if loop is None or loop.is_closed():
123
+ loop = asyncio.new_event_loop()
124
+ _LOOP_LOCAL.loop = loop
125
+ return loop.run_until_complete(coro)
126
+
127
+
128
+ def get_service() -> IctrpService:
129
+ """Return the process-wide service, creating it on first use."""
130
+ global _SERVICE
131
+ with _SERVICE_LOCK:
132
+ if _SERVICE is None:
133
+ _SERVICE = IctrpService()
134
+ LOG.info("ictrp service initialized (cache=%s)", default_cache_dir())
135
+ return _SERVICE
136
+
137
+
138
+ # --------------------------------------------------------------------------
139
+ # Error mapping
140
+ #
141
+ # The wire contract is: HTTP status says how the caller should react; the JSON
142
+ # body's ``error_code`` says what happened. Both are needed. A 502 tells the
143
+ # caller "retry or report an outage"; ``upstream_blocked`` tells it "do not
144
+ # hammer this, a human may need to look".
145
+ # --------------------------------------------------------------------------
146
+
147
+ _STATUS_FOR_CODE = {
148
+ ErrorCode.INVALID_ARGUMENT: 400,
149
+ ErrorCode.CACHE_MISS: 409,
150
+ ErrorCode.NO_RESULTS: 200, # not an error -- a legitimate empty answer
151
+ ErrorCode.UPSTREAM_BLOCKED: 503,
152
+ ErrorCode.UPSTREAM_CONTRACT_DRIFT: 502,
153
+ ErrorCode.UPSTREAM_ERROR: 502,
154
+ ErrorCode.SESSION_FAILED: 502,
155
+ }
156
+
157
+
158
+ def error_payload(exc: IctrpError) -> dict[str, Any]:
159
+ return {"ok": False, **exc.to_dict()}
160
+
161
+
162
+ def http_status_for(exc: IctrpError) -> int:
163
+ return _STATUS_FOR_CODE.get(exc.code, 502)
164
+
165
+
166
+ class SetExpired(Exception):
167
+ """Raised when a ``set_id`` is not held by this process.
168
+
169
+ Distinct from ``CACHE_MISS`` because the remedy is different: the caller
170
+ must re-run the search, not fix its arguments.
171
+ """
172
+
173
+ def __init__(self, set_id: str) -> None:
174
+ super().__init__(f"set {set_id!r} is not held by this sidecar process")
175
+ self.set_id = set_id
176
+
177
+
178
+ # --------------------------------------------------------------------------
179
+ # Operations
180
+ # --------------------------------------------------------------------------
181
+
182
+
183
+ def op_health() -> dict[str, Any]:
184
+ """Liveness plus a description of what this sidecar can actually do.
185
+
186
+ Deliberately reports no upstream contact: a health check that performs a
187
+ search would drive a 39 MB export every time an installer polls it.
188
+ """
189
+ service = get_service()
190
+ sets = service.store.list_sets()
191
+ return {
192
+ "ok": True,
193
+ "service": "ictrp-sidecar",
194
+ "version": _version(),
195
+ "python": sys.version.split()[0],
196
+ "cache_dir": str(default_cache_dir()),
197
+ "sets_held": len(sets),
198
+ "upstream": "https://trialsearch.who.int/",
199
+ "browser_required": False,
200
+ }
201
+
202
+
203
+ def _version() -> str:
204
+ try:
205
+ from ictrp_mcp import __version__ # type: ignore[attr-defined]
206
+
207
+ return str(__version__)
208
+ except Exception:
209
+ return "0.0.0"
210
+
211
+
212
+ def op_search(
213
+ keyword: str,
214
+ limit: int = 50,
215
+ offset: int = 0,
216
+ refresh: bool = False,
217
+ filters: list[dict[str, Any]] | None = None,
218
+ sort_by: str | None = None,
219
+ descending: bool = False,
220
+ fields: list[str] | None = None,
221
+ ) -> dict[str, Any]:
222
+ """Run the full chain and materialize the result set.
223
+
224
+ This is the only operation that touches the network in a costly way. It
225
+ always materializes the *entire* result set, not just the requested page:
226
+ the export is a single ~39 MB response, so fetching it partially is not
227
+ possible and re-fetching it per page would be absurd.
228
+
229
+ ``IctrpService.search`` is the one async method on the service, because it
230
+ owns an ``httpx.AsyncClient``. ``ThreadingHTTPServer`` hands each request a
231
+ plain thread, so the coroutine is driven here on a dedicated loop.
232
+
233
+ The loop is **per thread**, not per process. A single shared loop would be
234
+ entered from whichever worker thread happened to serve the request, and an
235
+ ``httpx.AsyncClient`` bound to one loop raises when driven from another.
236
+ """
237
+ service = get_service()
238
+ coro = service.search(
239
+ keyword=keyword,
240
+ limit=limit,
241
+ offset=offset,
242
+ refresh=refresh,
243
+ filters=filters,
244
+ sort_by=sort_by,
245
+ descending=descending,
246
+ fields=fields,
247
+ )
248
+ return _run_sync(coro)
249
+
250
+
251
+ def op_filter(
252
+ set_id: str,
253
+ filters: list[dict[str, Any]] | None = None,
254
+ sort_by: str | None = None,
255
+ descending: bool = False,
256
+ limit: int = 50,
257
+ offset: int = 0,
258
+ fields: list[str] | None = None,
259
+ ) -> dict[str, Any]:
260
+ return get_service().filter_set(
261
+ set_id=set_id,
262
+ filters=filters,
263
+ sort_by=sort_by,
264
+ descending=descending,
265
+ limit=limit,
266
+ offset=offset,
267
+ fields=fields,
268
+ )
269
+
270
+
271
+ def op_field_query(
272
+ field: str,
273
+ set_id: str | None = None,
274
+ keyword: str | None = None,
275
+ limit: int = 50,
276
+ ) -> dict[str, Any]:
277
+ return get_service().field_query(field=field, set_id=set_id, keyword=keyword, limit=limit)
278
+
279
+
280
+ def op_registry_summary(set_id: str) -> dict[str, Any]:
281
+ return get_service().registry_summary(set_id=set_id)
282
+
283
+
284
+ def op_find_duplicates(set_id: str) -> dict[str, Any]:
285
+ return get_service().find_duplicates(set_id=set_id)
286
+
287
+
288
+ def op_export(
289
+ set_id: str | None = None,
290
+ keyword: str | None = None,
291
+ fmt: str = "json",
292
+ filters: list[dict[str, Any]] | None = None,
293
+ fields: list[str] | None = None,
294
+ include_provenance_header: bool = True,
295
+ ) -> dict[str, Any]:
296
+ return get_service().export_records(
297
+ set_id=set_id,
298
+ keyword=keyword,
299
+ fmt=fmt,
300
+ filters=filters,
301
+ fields=fields,
302
+ include_provenance_header=include_provenance_header,
303
+ )
304
+
305
+
306
+ def op_cache_status(action: str = "list", set_id: str | None = None) -> dict[str, Any]:
307
+ """Inspect or purge the local set cache.
308
+
309
+ ``action`` is ``"list"`` or ``"purge"`` -- the service names the operation
310
+ rather than taking a boolean, so the wire parameter follows it.
311
+ """
312
+ return get_service().cache_status(action=action, set_id=set_id)
313
+
314
+
315
+ def op_snapshot(
316
+ keyword: str | None = None,
317
+ set_id: str | None = None,
318
+ path: str | None = None,
319
+ if_stale: bool = True,
320
+ ) -> dict[str, Any]:
321
+ return get_service().snapshot(keyword=keyword, set_id=set_id, path=path, if_stale=if_stale)
322
+
323
+
324
+ def op_bundle_status(keyword: str | None = None) -> dict[str, Any]:
325
+ """Report which offline bundle, if any, serves this keyword.
326
+
327
+ ``keyword`` is optional at the HTTP boundary even though the service
328
+ requires it. Omitting it is a legitimate question -- "what bundles ship
329
+ with this install?" -- so we answer it from the bundle directory directly
330
+ rather than letting the service raise.
331
+ """
332
+ service = get_service()
333
+ if keyword:
334
+ return service.bundle_status(keyword=keyword)
335
+ return {
336
+ "status": "ok",
337
+ "keyword": None,
338
+ "bundle_configured": {
339
+ "ICTRP_BUNDLE_PATH": os.environ.get(offline.ENV_BUNDLE_PATH),
340
+ "ICTRP_BUNDLE_DIR": os.environ.get(offline.ENV_BUNDLE_DIR),
341
+ "max_age_days": offline.DEFAULT_MAX_AGE_DAYS,
342
+ },
343
+ "note": "Pass keyword=... to check whether a bundle can answer that query.",
344
+ }
345
+
346
+
347
+ def op_offline_lookup(keyword: str) -> dict[str, Any]:
348
+ """Answer a keyword from a bundled snapshot without any network access.
349
+
350
+ Returns ``{"ok": true, "found": false}`` rather than an error when no
351
+ bundle matches: "this installation ships no snapshot for that keyword" is
352
+ the normal state of a live install, not a fault.
353
+ """
354
+ loaded = offline.load_bundle(keyword)
355
+ if loaded is None:
356
+ return {
357
+ "ok": True,
358
+ "found": False,
359
+ "keyword": keyword,
360
+ "bundle_configured": {
361
+ "ICTRP_BUNDLE_PATH": os.environ.get(offline.ENV_BUNDLE_PATH),
362
+ "ICTRP_BUNDLE_DIR": os.environ.get(offline.ENV_BUNDLE_DIR),
363
+ "max_age_days": offline.max_age_days(),
364
+ },
365
+ }
366
+ snapshot, path = loaded
367
+ trials = [t.to_dict() if hasattr(t, "to_dict") else t for t in snapshot.trials]
368
+ return {
369
+ "ok": True,
370
+ "found": True,
371
+ "keyword": keyword,
372
+ "path": str(path),
373
+ "rows_returned": len(trials),
374
+ "records_incomplete": True,
375
+ "incompleteness_notice": _INCOMPLETENESS_NOTICE,
376
+ "provenance": offline.snapshot_provenance(snapshot, path),
377
+ "trials": trials,
378
+ }
379
+
380
+
381
+ # --------------------------------------------------------------------------
382
+ # HTTP layer
383
+ # --------------------------------------------------------------------------
384
+
385
+ _ROUTES = {
386
+ "/health": op_health,
387
+ "/cache-status": op_cache_status,
388
+ "/bundle-status": op_bundle_status,
389
+ "/offline-lookup": op_offline_lookup,
390
+ "/search": op_search,
391
+ "/filter": op_filter,
392
+ "/field-query": op_field_query,
393
+ "/registry-summary": op_registry_summary,
394
+ "/find-duplicates": op_find_duplicates,
395
+ "/export": op_export,
396
+ "/snapshot": op_snapshot,
397
+ }
398
+
399
+ # Operations that must never be triggered by a bare GET poll. Search drives a
400
+ # multi-megabyte upstream export; an installer or supervisor probing "/" must
401
+ # not cause one.
402
+ _EXPENSIVE = {"/search", "/snapshot"}
403
+
404
+
405
+ def _as_bool(raw: Any, default: bool = False) -> bool:
406
+ if raw is None:
407
+ return default
408
+ if isinstance(raw, bool):
409
+ return raw
410
+ return str(raw).strip().lower() in {"1", "true", "yes", "on"}
411
+
412
+
413
+ def _as_int(raw: Any, default: int) -> int:
414
+ try:
415
+ return int(raw)
416
+ except (TypeError, ValueError):
417
+ return default
418
+
419
+
420
+ def _json_body(value: Any) -> bytes:
421
+ return json.dumps(value, ensure_ascii=False, default=str).encode("utf-8")
422
+
423
+
424
+ class Handler(BaseHTTPRequestHandler):
425
+ protocol_version = "HTTP/1.1"
426
+ server_version = "ictrp-sidecar"
427
+
428
+ # -- plumbing ---------------------------------------------------------
429
+
430
+ def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
431
+ LOG.debug("%s - %s", self.address_string(), fmt % args)
432
+
433
+ def _send(self, status: int, payload: Any) -> None:
434
+ body = _json_body(payload)
435
+ self.send_response(status)
436
+ self.send_header("Content-Type", "application/json; charset=utf-8")
437
+ self.send_header("Content-Length", str(len(body)))
438
+ self.end_headers()
439
+ self.wfile.write(body)
440
+
441
+ def _params(self) -> dict[str, Any]:
442
+ """Merge query-string and JSON-body parameters.
443
+
444
+ Both are accepted because GET is convenient for curl-based debugging
445
+ while POST keeps a long filter array out of the URL.
446
+ """
447
+ merged: dict[str, Any] = {}
448
+ parsed = urlparse(self.path)
449
+ for key, values in parse_qs(parsed.query).items():
450
+ merged[key] = values[0] if len(values) == 1 else values
451
+
452
+ length = _as_int(self.headers.get("Content-Length"), 0)
453
+ if length > 0:
454
+ raw = self.rfile.read(length)
455
+ try:
456
+ decoded = json.loads(raw.decode("utf-8"))
457
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
458
+ raise ValueError(f"request body is not valid JSON: {exc}") from exc
459
+ if not isinstance(decoded, dict):
460
+ raise ValueError("request body must be a JSON object")
461
+ merged.update(decoded)
462
+ return merged
463
+
464
+ # -- dispatch ---------------------------------------------------------
465
+
466
+ def do_GET(self) -> None: # noqa: N802
467
+ self._dispatch("GET")
468
+
469
+ def do_POST(self) -> None: # noqa: N802
470
+ self._dispatch("POST")
471
+
472
+ def _dispatch(self, method: str) -> None:
473
+ path = urlparse(self.path).path.rstrip("/") or "/"
474
+
475
+ if path == "/":
476
+ self._send(
477
+ 200,
478
+ {
479
+ "service": "ictrp-sidecar",
480
+ "endpoints": sorted(_ROUTES),
481
+ "hint": "POST /search?keyword=... starts the chain.",
482
+ },
483
+ )
484
+ return
485
+
486
+ handler = _ROUTES.get(path)
487
+ if handler is None:
488
+ self._send(404, {"ok": False, "error": "not found", "path": path})
489
+ return
490
+
491
+ if method == "GET" and path in _EXPENSIVE:
492
+ # Refuse rather than run: a GET that triggers a 39 MB export is
493
+ # almost certainly a supervisor probe, and it would be a very
494
+ # expensive misunderstanding.
495
+ self._send(
496
+ 405,
497
+ {
498
+ "ok": False,
499
+ "error": f"{path} must be called with POST",
500
+ "reason": "this operation drives a large upstream request",
501
+ },
502
+ )
503
+ return
504
+
505
+ try:
506
+ params = self._params()
507
+ except ValueError as exc:
508
+ self._send(400, {"ok": False, "error": str(exc)})
509
+ return
510
+
511
+ try:
512
+ result = handler(**params)
513
+ except IctrpError as exc:
514
+ status = http_status_for(exc)
515
+ LOG.info("%s -> %s %s", path, status, exc.code.value)
516
+ self._send(status, error_payload(exc))
517
+ return
518
+ except SetExpired as exc:
519
+ self._send(
520
+ 409,
521
+ {
522
+ "ok": False,
523
+ "error_code": "set_expired",
524
+ "message": str(exc),
525
+ "set_id": exc.set_id,
526
+ "hint": "Re-run /search; materialized sets do not survive a sidecar restart.",
527
+ },
528
+ )
529
+ return
530
+ except TypeError as exc:
531
+ # Almost always a caller passing an unexpected keyword. Surfacing
532
+ # it as 400 is far more useful than a 502 traceback.
533
+ self._send(
534
+ 400,
535
+ {
536
+ "ok": False,
537
+ "error_code": "INVALID_ARGUMENT",
538
+ "message": f"bad parameters for {path}: {exc}",
539
+ },
540
+ )
541
+ return
542
+ except Exception as exc: # noqa: BLE001 - the boundary must not leak
543
+ LOG.error("unhandled error in %s:\n%s", path, traceback.format_exc())
544
+ self._send(
545
+ 500,
546
+ {
547
+ "ok": False,
548
+ "error_code": "INTERNAL_ERROR",
549
+ "message": f"{type(exc).__name__}: {exc}",
550
+ "path": path,
551
+ "hint": "This is a bug in the sidecar, not an upstream failure.",
552
+ },
553
+ )
554
+ return
555
+
556
+ # The success path. Without this the handler would compute a perfect
557
+ # answer and then close the connection without writing anything, which
558
+ # every client reports as an opaque connection failure.
559
+ self._send(200, result)
560
+
561
+
562
+ def serve(host: str, port: int) -> None:
563
+ httpd = ThreadingHTTPServer((host, port), Handler)
564
+ httpd.daemon_threads = True
565
+ LOG.info("ictrp sidecar listening on http://%s:%d", host, port)
566
+ try:
567
+ httpd.serve_forever()
568
+ except KeyboardInterrupt:
569
+ LOG.info("interrupted; shutting down")
570
+ finally:
571
+ httpd.server_close()
572
+
573
+
574
+ def main(argv: list[str] | None = None) -> int:
575
+ parser = argparse.ArgumentParser(prog="ictrp_sidecar", description=__doc__)
576
+ parser.add_argument("--host", default=os.environ.get("ICTRP_SIDECAR_HOST", DEFAULT_HOST))
577
+ parser.add_argument(
578
+ "--port", type=int, default=_as_int(os.environ.get("ICTRP_SIDECAR_PORT"), DEFAULT_PORT)
579
+ )
580
+ parser.add_argument("--log-level", default=os.environ.get("ICTRP_SIDECAR_LOG", "INFO"))
581
+ parser.add_argument(
582
+ "--check",
583
+ action="store_true",
584
+ help="Import and exit. Used by the installer to validate the venv.",
585
+ )
586
+ args = parser.parse_args(argv)
587
+
588
+ logging.basicConfig(
589
+ level=getattr(logging, str(args.log_level).upper(), logging.INFO),
590
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
591
+ )
592
+
593
+ if args.check:
594
+ print(json.dumps({"ok": True, "version": _version(), "python": sys.version.split()[0]}))
595
+ return 0
596
+
597
+ serve(args.host, args.port)
598
+ return 0
599
+
600
+
601
+ if __name__ == "__main__":
602
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ """MCP server for the WHO ICTRP clinical trials database, over plain HTTP."""
2
+
3
+ __version__ = "0.1.0"
File without changes