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,368 @@
1
+ """MCP server exposing the WHO ICTRP tools.
2
+
3
+ Transport is stdio, matching how MCP clients launch a server. Tool failures are
4
+ returned as structured error payloads (`isError: True`) carrying the error code,
5
+ never as empty success results -- the distinction between "no matching trials"
6
+ and "we could not retrieve the data" is the central guarantee of this service.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ from typing import Any
14
+
15
+ from mcp.server import Server
16
+ from mcp.server.stdio import stdio_server
17
+ from mcp.types import TextContent, Tool
18
+
19
+ from .errors import ErrorCode, IctrpError
20
+ from .tools import IctrpService
21
+
22
+ SERVER_NAME = "ictrp-mcp-service"
23
+ SERVER_VERSION = "0.1.0"
24
+
25
+ _FILTER_SCHEMA = {
26
+ "type": "array",
27
+ "description": (
28
+ "All filters must match (AND). Each filter is "
29
+ "{field, op, value}. Aliases are accepted for common fields: title, status, "
30
+ "register, reg_date, phase, age_min, age_max, target_size."
31
+ ),
32
+ "items": {
33
+ "type": "object",
34
+ "properties": {
35
+ "field": {"type": "string"},
36
+ "op": {
37
+ "type": "string",
38
+ "enum": [
39
+ "eq", "ne", "contains", "not_contains", "in", "not_in",
40
+ "gt", "gte", "lt", "lte", "exists", "not_exists", "is_null",
41
+ "is_not_null",
42
+ ],
43
+ },
44
+ "value": {},
45
+ },
46
+ "required": ["field", "op"],
47
+ },
48
+ }
49
+
50
+ _INCOMPLETENESS_WARNING = (
51
+ "Counts describe retrieved rows only. The ICTRP CSV export is known to omit "
52
+ "records the portal itself reports as matches, so absence is not evidence of "
53
+ "nonexistence."
54
+ )
55
+
56
+
57
+ def _tool_specs() -> list[Tool]:
58
+ return [
59
+ Tool(
60
+ name="ictrp_search",
61
+ description=(
62
+ "Search the WHO ICTRP and materialize the result set locally. "
63
+ "Returns a page plus a set_id that other tools can query without "
64
+ "further upstream requests. "
65
+ f"IMPORTANT: {_INCOMPLETENESS_WARNING}"
66
+ ),
67
+ inputSchema={
68
+ "type": "object",
69
+ "properties": {
70
+ "keyword": {
71
+ "type": "string",
72
+ "description": (
73
+ "Search terms. Multi-word input is an implicit AND. "
74
+ "Boolean AND/OR and quoted phrases are supported."
75
+ ),
76
+ },
77
+ "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50},
78
+ "offset": {"type": "integer", "minimum": 0, "default": 0},
79
+ "fields": {
80
+ "type": "array",
81
+ "items": {"type": "string"},
82
+ "description": "Fields to return per trial.",
83
+ },
84
+ "filters": _FILTER_SCHEMA,
85
+ "sort_by": {"type": "string"},
86
+ "descending": {"type": "boolean", "default": False},
87
+ "refresh": {
88
+ "type": "boolean",
89
+ "default": False,
90
+ "description": "Re-run the upstream search even if a cached set exists.",
91
+ },
92
+ },
93
+ "required": ["keyword"],
94
+ },
95
+ ),
96
+ Tool(
97
+ name="ictrp_filter",
98
+ description=(
99
+ "Filter, sort and page a previously materialized result set. Runs "
100
+ "entirely locally; makes no upstream request."
101
+ ),
102
+ inputSchema={
103
+ "type": "object",
104
+ "properties": {
105
+ "set_id": {"type": "string"},
106
+ "filters": _FILTER_SCHEMA,
107
+ "sort_by": {"type": "string"},
108
+ "descending": {"type": "boolean", "default": False},
109
+ "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 50},
110
+ "offset": {"type": "integer", "minimum": 0, "default": 0},
111
+ "fields": {"type": "array", "items": {"type": "string"}},
112
+ },
113
+ "required": ["set_id"],
114
+ },
115
+ ),
116
+ Tool(
117
+ name="ictrp_field_query",
118
+ description=(
119
+ "Distinct values and population coverage for one field in a cached "
120
+ "set. Runs locally. Coverage is reported so sparse fields are not "
121
+ "mistaken for absent data."
122
+ ),
123
+ inputSchema={
124
+ "type": "object",
125
+ "properties": {
126
+ "field": {"type": "string"},
127
+ "set_id": {"type": "string"},
128
+ "keyword": {"type": "string"},
129
+ "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
130
+ },
131
+ "required": ["field"],
132
+ },
133
+ ),
134
+ Tool(
135
+ name="ictrp_registry_summary",
136
+ description=(
137
+ "Composition of a cached set by registry, phase, status, year or "
138
+ "country, plus per-field coverage. Runs locally."
139
+ ),
140
+ inputSchema={
141
+ "type": "object",
142
+ "properties": {
143
+ "set_id": {"type": "string"},
144
+ "keyword": {"type": "string"},
145
+ "group_by": {"type": "array", "items": {"type": "string"}},
146
+ },
147
+ },
148
+ ),
149
+ Tool(
150
+ name="ictrp_find_duplicates",
151
+ description=(
152
+ "Find records likely describing the same trial, using identifier "
153
+ "cross-references across registries. Runs locally."
154
+ ),
155
+ inputSchema={
156
+ "type": "object",
157
+ "properties": {
158
+ "set_id": {"type": "string"},
159
+ "keyword": {"type": "string"},
160
+ },
161
+ },
162
+ ),
163
+ Tool(
164
+ name="ictrp_export",
165
+ description=(
166
+ "Export a cached set as csv, json, jsonl or markdown, with a "
167
+ "provenance header. Runs locally."
168
+ ),
169
+ inputSchema={
170
+ "type": "object",
171
+ "properties": {
172
+ "set_id": {"type": "string"},
173
+ "keyword": {"type": "string"},
174
+ "format": {"type": "string", "enum": ["csv", "json", "jsonl", "markdown"], "default": "json"},
175
+ "fields": {"type": "array", "items": {"type": "string"}},
176
+ "filters": _FILTER_SCHEMA,
177
+ "include_provenance_header": {"type": "boolean", "default": True},
178
+ },
179
+ },
180
+ ),
181
+ Tool(
182
+ name="ictrp_cache_status",
183
+ description="List or purge cached result sets. Runs locally.",
184
+ inputSchema={
185
+ "type": "object",
186
+ "properties": {
187
+ "action": {"type": "string", "enum": ["list", "purge"], "default": "list"},
188
+ "set_id": {"type": "string"},
189
+ },
190
+ },
191
+ ),
192
+ Tool(
193
+ name="ictrp_snapshot",
194
+ description=(
195
+ "Write a cached result set to a canonical JSON snapshot file, for "
196
+ "shipping inside a packaged application or refreshing such a dataset. "
197
+ "Runs locally. Redistribution of ICTRP data is subject to WHO terms; "
198
+ "see docs/BUNDLE.md."
199
+ ),
200
+ inputSchema={
201
+ "type": "object",
202
+ "properties": {
203
+ "set_id": {"type": "string"},
204
+ "keyword": {"type": "string"},
205
+ "path": {
206
+ "type": "string",
207
+ "description": (
208
+ "Destination file. Defaults to ICTRP_BUNDLE_DIR, or the "
209
+ "cache directory's snapshots/ folder."
210
+ ),
211
+ },
212
+ "if_stale": {
213
+ "type": "boolean",
214
+ "default": True,
215
+ "description": (
216
+ "Skip writing when a fresh snapshot already exists. Set "
217
+ "false in a release pipeline to always rewrite."
218
+ ),
219
+ },
220
+ },
221
+ },
222
+ ),
223
+ Tool(
224
+ name="ictrp_bundle_status",
225
+ description=(
226
+ "Report which local snapshot would serve a keyword, where it was "
227
+ "looked for, and how old it is. Runs locally and makes no request."
228
+ ),
229
+ inputSchema={
230
+ "type": "object",
231
+ "properties": {"keyword": {"type": "string"}},
232
+ "required": ["keyword"],
233
+ },
234
+ ),
235
+ ]
236
+
237
+
238
+ def _as_error(exc: IctrpError) -> Exception:
239
+ """Render a structured failure as text and raise it through a private type.
240
+
241
+ The MCP framework sets `isError=True` only when the handler raises; content
242
+ returned normally is always flagged as success. Since a failure must never be
243
+ presented as a successful (empty) result, we raise -- but carry the structured
244
+ payload in the message so clients still get the error code.
245
+
246
+ Returning a bare `CallToolResult` here would be overwritten by the framework,
247
+ which is why this does not try to construct one.
248
+ """
249
+ return _ToolFailure(json.dumps({"status": "error", **exc.to_dict()}, ensure_ascii=False, indent=2))
250
+
251
+
252
+ class _ToolFailure(Exception):
253
+ """Carries an already-formatted error payload for the MCP framework."""
254
+
255
+
256
+ def _dispatch(service: IctrpService, name: str, args: dict[str, Any]) -> Any:
257
+ if name == "ictrp_search":
258
+ return service.search(
259
+ args["keyword"],
260
+ limit=args.get("limit", 50),
261
+ offset=args.get("offset", 0),
262
+ fields=args.get("fields"),
263
+ filters=args.get("filters"),
264
+ sort_by=args.get("sort_by"),
265
+ descending=args.get("descending", False),
266
+ refresh=args.get("refresh", False),
267
+ )
268
+ if name == "ictrp_filter":
269
+ return service.filter_set(
270
+ args["set_id"],
271
+ filters=args.get("filters"),
272
+ sort_by=args.get("sort_by"),
273
+ descending=args.get("descending", False),
274
+ limit=args.get("limit", 50),
275
+ offset=args.get("offset", 0),
276
+ fields=args.get("fields"),
277
+ )
278
+ if name == "ictrp_field_query":
279
+ return service.field_query(
280
+ field=args["field"],
281
+ set_id=args.get("set_id"),
282
+ keyword=args.get("keyword"),
283
+ limit=args.get("limit", 50),
284
+ )
285
+ if name == "ictrp_registry_summary":
286
+ return service.registry_summary(
287
+ set_id=args.get("set_id"),
288
+ keyword=args.get("keyword"),
289
+ group_by=args.get("group_by"),
290
+ )
291
+ if name == "ictrp_find_duplicates":
292
+ return service.find_duplicates(set_id=args.get("set_id"), keyword=args.get("keyword"))
293
+ if name == "ictrp_export":
294
+ return service.export_records(
295
+ set_id=args.get("set_id"),
296
+ keyword=args.get("keyword"),
297
+ fmt=args.get("format", "json"),
298
+ fields=args.get("fields"),
299
+ filters=args.get("filters"),
300
+ include_provenance_header=args.get("include_provenance_header", True),
301
+ )
302
+ if name == "ictrp_cache_status":
303
+ return service.cache_status(action=args.get("action", "list"), set_id=args.get("set_id"))
304
+ if name == "ictrp_snapshot":
305
+ return service.snapshot(
306
+ set_id=args.get("set_id"),
307
+ keyword=args.get("keyword"),
308
+ path=args.get("path"),
309
+ if_stale=args.get("if_stale", True),
310
+ )
311
+ if name == "ictrp_bundle_status":
312
+ return service.bundle_status(keyword=args.get("keyword"))
313
+ raise IctrpError(ErrorCode.INVALID_ARGUMENT, f"unknown tool {name!r}")
314
+
315
+
316
+ def build_server(service: IctrpService | None = None) -> Server:
317
+ # Pin the version explicitly rather than letting the framework report its own,
318
+ # so clients see this project's version.
319
+ server = Server(SERVER_NAME, version=SERVER_VERSION)
320
+ svc = service or IctrpService()
321
+
322
+ @server.list_tools()
323
+ async def list_tools() -> list[Tool]:
324
+ return _tool_specs()
325
+
326
+ @server.call_tool()
327
+ async def call_tool(name: str, arguments: dict[str, Any] | None):
328
+ args = arguments or {}
329
+ try:
330
+ result = _dispatch(svc, name, args)
331
+ # Only `ictrp_search` is a coroutine; the local tools are synchronous.
332
+ # Awaiting here keeps error handling in one place, so an IctrpError
333
+ # raised inside an async tool is caught by the same handler.
334
+ if asyncio.iscoroutine(result):
335
+ result = await result
336
+ return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
337
+ except IctrpError as exc:
338
+ raise _as_error(exc) from exc
339
+ except Exception as exc: # noqa: BLE001 - surfaced to the caller, not swallowed
340
+ raise _ToolFailure(
341
+ json.dumps(
342
+ {
343
+ "status": "error",
344
+ "error_code": "INTERNAL_ERROR",
345
+ "message": str(exc),
346
+ "message_type": type(exc).__name__,
347
+ "hint": "This is a bug in the service, not an upstream failure.",
348
+ },
349
+ ensure_ascii=False,
350
+ indent=2,
351
+ )
352
+ ) from exc
353
+
354
+ return server
355
+
356
+
357
+ async def _run() -> None:
358
+ server = build_server()
359
+ async with stdio_server() as (read_stream, write_stream):
360
+ await server.run(read_stream, write_stream, server.create_initialization_options())
361
+
362
+
363
+ def main() -> None:
364
+ asyncio.run(_run())
365
+
366
+
367
+ if __name__ == "__main__":
368
+ main()