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.
- package/CHANGELOG.md +65 -0
- package/LICENSE +37 -0
- package/README.md +208 -0
- package/README_ZH.md +189 -0
- package/dist/cli/setup-cli.d.ts +14 -0
- package/dist/cli/setup-cli.js +230 -0
- package/dist/cli/setup-cli.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +477 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/bootstrap.d.ts +99 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/bootstrap.js.map +1 -0
- package/dist/runtime/env-probe.d.ts +108 -0
- package/dist/runtime/env-probe.js +479 -0
- package/dist/runtime/env-probe.js.map +1 -0
- package/dist/runtime/sidecar-client.d.ts +50 -0
- package/dist/runtime/sidecar-client.js +120 -0
- package/dist/runtime/sidecar-client.js.map +1 -0
- package/dist/runtime/supervisor.d.ts +47 -0
- package/dist/runtime/supervisor.js +248 -0
- package/dist/runtime/supervisor.js.map +1 -0
- package/package.json +60 -0
- package/sidecar/ictrp_sidecar.py +602 -0
- package/sidecar/vendor/ictrp_mcp/__init__.py +3 -0
- package/sidecar/vendor/ictrp_mcp/cache/__init__.py +0 -0
- package/sidecar/vendor/ictrp_mcp/cache/store.py +313 -0
- package/sidecar/vendor/ictrp_mcp/data/__init__.py +0 -0
- package/sidecar/vendor/ictrp_mcp/data/columns.py +108 -0
- package/sidecar/vendor/ictrp_mcp/data/jsonio.py +213 -0
- package/sidecar/vendor/ictrp_mcp/data/normalize.py +348 -0
- package/sidecar/vendor/ictrp_mcp/data/query.py +307 -0
- package/sidecar/vendor/ictrp_mcp/errors.py +123 -0
- package/sidecar/vendor/ictrp_mcp/ictrp/__init__.py +0 -0
- package/sidecar/vendor/ictrp_mcp/ictrp/export_guard.py +269 -0
- package/sidecar/vendor/ictrp_mcp/ictrp/htmlstate.py +143 -0
- package/sidecar/vendor/ictrp_mcp/ictrp/session.py +245 -0
- package/sidecar/vendor/ictrp_mcp/offline.py +133 -0
- package/sidecar/vendor/ictrp_mcp/provenance.py +182 -0
- package/sidecar/vendor/ictrp_mcp/server.py +368 -0
- package/sidecar/vendor/ictrp_mcp/tools.py +712 -0
- 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()
|