@kinginsun/mcp-drugsea 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kinginsun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # mcp-drugsea
2
+
3
+ MCP (Model Context Protocol) stdio server for [DrugSea / Yaohai](https://db.drugsea.cn) pharmaceutical databases.
4
+
5
+ The server forwards tool calls to `https://api2.drugsea.cn` with header `X-Yaohai-Api-Key`. It covers:
6
+
7
+ - **yaohai-*** — cross-database catalog / search / detail / global / smart-search (`POST /g/mcp/yaohai/*`)
8
+ - **product-cn-*** — already-marketed China products (`GET /product/cn/...`)
9
+ - **reg-cn-*** — CDE registration / review pipeline (`GET /b/drugreg/cn/...` and facet prefix `/b/es/drugreg/cn/list`)
10
+
11
+ **GitHub:** [github.com/kinginsun/mcp-drugsea](https://github.com/kinginsun/mcp-drugsea)
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install -g @kinginsun/mcp-drugsea
17
+ ```
18
+
19
+ Or run without installing:
20
+
21
+ ```bash
22
+ npx -y @kinginsun/mcp-drugsea
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ ### `YAOHAI_MCP_API_KEY` (required)
28
+
29
+ The server accepts either:
30
+
31
+ | Credential | Format | HTTP header |
32
+ |------------|--------|-------------|
33
+ | Shared MCP key | opaque string (server env `YAOHAI_MCP_API_KEY`) | `X-Yaohai-Api-Key` |
34
+ | Personal user token | `ysk_` + 32 hex chars | `Authorization: Bearer ysk_…` |
35
+
36
+ This client auto-selects the header from the value shape. You may set `YAOHAI_MCP_TOKEN` instead of `YAOHAI_MCP_API_KEY` for a user token.
37
+
38
+ **Note:** Production MCP with personal `ysk_` tokens is deployed at **`https://db3.drugsea.cn/api/g/mcp/*`**. Legacy `api2.drugsea.cn` accepts only the shared `X-Yaohai-Api-Key`. On db3, direct GET list/facet routes return encrypted payloads; this client auto-routes `product-cn-search` / `reg-cn-search` / detail through MCP POST when the base URL contains `db3.drugsea.cn`.
39
+
40
+ Obtain a key from the DrugSea / Yaohai operator, then:
41
+
42
+ ```bash
43
+ export YAOHAI_MCP_API_KEY=your_api_key_here
44
+ ```
45
+
46
+ ### Optional
47
+
48
+ | Variable | Default | Description |
49
+ |----------|---------|-------------|
50
+ | `YAOHAI_BASE_URL` | `https://db3.drugsea.cn/api` | API origin (no trailing slash). MCP: `/g/mcp/yaohai/*` |
51
+ | `YAOHAI_VERIFY_SSL` | `true` | Set `false` / `0` / `off` to skip TLS certificate verification (needed on some prod hosts) |
52
+ | `YAOHAI_USE_MCP_LIST` | auto on db3 | Force product/reg search via MCP POST instead of GET |
53
+
54
+ ### Cursor MCP (`~/.cursor/mcp.json`)
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "drugsea": {
60
+ "command": "npx",
61
+ "args": ["-y", "@kinginsun/mcp-drugsea"],
62
+ "env": {
63
+ "YAOHAI_MCP_API_KEY": "your_api_key_here"
64
+ }
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ### Claude Desktop (`claude_desktop_config.json`)
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "drugsea": {
76
+ "command": "npx",
77
+ "args": ["-y", "@kinginsun/mcp-drugsea"],
78
+ "env": {
79
+ "YAOHAI_MCP_API_KEY": "your_api_key_here"
80
+ }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ ## Which tools to use
87
+
88
+ | User intent | Tools |
89
+ |-------------|--------|
90
+ | Already listed in China (国药准字, 批准文号, 上市, 医保/集采状态) | `product-cn-fields` → `product-cn-search` / `product-cn-facets` → `product-cn-detail` |
91
+ | R&D / CDE (在研, 受理号, 审评, 尚未上市) | `reg-cn-fields` → `reg-cn-search` / `reg-cn-facets` → `reg-cn-detail` |
92
+ | Other DBs (医保 `yibao`, 基药 `jiyao`, 集采 `jicai`, trials, DMF, …) | `yaohai-catalog` → `yaohai-search` → `yaohai-detail` |
93
+ | Global panorama | `yaohai-global-search` |
94
+ | Unclear which DB | `yaohai-smart-search` |
95
+
96
+ Therapeutic-class queries (抗癌, 心血管, …): prefer ConditionSearch `ATC_code` (letter, e.g. `L` oncology, `C` cardiovascular, `J` anti-infectives, `N` nervous system). Confirm values with a facets tool when unsure.
97
+
98
+ If `total > 20`, summarize in chat (about 5–10 sample rows) instead of pasting the full table.
99
+
100
+ xlsx export is not implemented in this MCP (v1 returns JSON samples only).
101
+
102
+ `product-cn-*` and `reg-cn-*` list/facet/detail calls use the same GET routes as the website. Some API2 deployments IP-allowlist those paths and respond with `非法IP地址`. The `yaohai-*` tools use `POST /g/mcp/yaohai/*`, which is the MCP-keyed surface and is not subject to that list GET allowlist.
103
+
104
+ ## Tools
105
+
106
+ ### Yaohai (cross-DB)
107
+
108
+ | Tool | Parameters | Notes |
109
+ |------|------------|--------|
110
+ | `yaohai-catalog` | `category?`, `q?` | List databases |
111
+ | `yaohai-search` | `dbname`, `query?`, `limit?`, `offset?` | Default limit 10, max 50 |
112
+ | `yaohai-detail` | `dbname`, `id` | Skip DBs with `has_detail: false` |
113
+ | `yaohai-global-search` | `q?`, `query?`, `limit?`, `offset?` | `q` fills `query.term` |
114
+ | `yaohai-smart-search` | `q`, `query?`, `limit?` | Auto-routes up to 3 DBs |
115
+
116
+ ### product_cn (marketed)
117
+
118
+ | Tool | Parameters | Notes |
119
+ |------|------------|--------|
120
+ | `product-cn-fields` | _(none)_ | Local field catalog |
121
+ | `product-cn-search` | `query?`, `limit?`, `offset?`, `view_type?` | Default `search_mode=3`; limit default 20, max 100 |
122
+ | `product-cn-facets` | `query?`, `facets` (required) | Do not request all 22 dimensions. Recommended: `ATC_code`, `drug_type`, `national_yibao`, `std_dosage_form`, `source` |
123
+ | `product-cn-detail` | `id` | Encrypted id from search rows |
124
+
125
+ `first_approve_date` is first listing date; `approve_date` is latest re-registration (not first listing).
126
+
127
+ `view_type`: `eslist` (default) / `list_by_drug_name` / `list_by_manufacture`.
128
+
129
+ ### reg_cn (CDE review)
130
+
131
+ | Tool | Parameters | Notes |
132
+ |------|------------|--------|
133
+ | `reg-cn-fields` | _(none)_ | Local field catalog |
134
+ | `reg-cn-search` | `query?`, `limit?`, `offset?`, `view_type?` | Default `rows_excluded=1`, `search_mode=1`; limit default 20, max 100 |
135
+ | `reg-cn-facets` | `query?`, `facets` (required) | Recommended: `ATC_code`, `rd_status`, `drug_type`, `transact_status`, `register_type` |
136
+ | `reg-cn-detail` | `id` | Encrypted id from search rows |
137
+
138
+ `view_type`: `eslist` (default) / `list_by_drug_name` / `list_by_enterprise`.
139
+
140
+ `query` values may be string, number, or string arrays (ConditionSearch `multiple`). Dates: `"YYYY-MM-DD to YYYY-MM-DD"`. Ranges: `"min to max"`.
141
+
142
+ ## Manual stdio test
143
+
144
+ ```bash
145
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
146
+ | YAOHAI_MCP_API_KEY=your_key node dist/index.js
147
+ ```
148
+
149
+ ```bash
150
+ echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"yaohai-catalog","arguments":{"q":"医保"}}}' \
151
+ | YAOHAI_MCP_API_KEY=your_key node dist/index.js
152
+ ```
153
+
154
+ ```bash
155
+ echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"product-cn-search","arguments":{"query":{"drug_name":"阿司匹林"},"limit":3}}}' \
156
+ | YAOHAI_MCP_API_KEY=your_key node dist/index.js
157
+ ```
158
+
159
+ ## Requirements
160
+
161
+ - Node.js >= 18.0.0
162
+
163
+ ## License
164
+
165
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,331 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import { URL } from "node:url";
4
+ import { normalizeRecord, parseFacetList } from "./normalize.js";
5
+ export class MissingApiKeyError extends Error {
6
+ constructor() {
7
+ super("Set YAOHAI_MCP_API_KEY (shared key) or YAOHAI_MCP_TOKEN (ysk_… user token)");
8
+ this.name = "MissingApiKeyError";
9
+ }
10
+ }
11
+ export class ApiError extends Error {
12
+ status;
13
+ raw;
14
+ constructor(message, status = 0, raw) {
15
+ super(message);
16
+ this.name = "ApiError";
17
+ this.status = status;
18
+ this.raw = raw;
19
+ }
20
+ }
21
+ export function getApiKey() {
22
+ const token = process.env.YAOHAI_MCP_TOKEN?.trim() || "";
23
+ const key = process.env.YAOHAI_MCP_API_KEY?.trim() || "";
24
+ const value = token || key;
25
+ if (!value) {
26
+ throw new MissingApiKeyError();
27
+ }
28
+ return value;
29
+ }
30
+ /** Shared server key → X-Yaohai-Api-Key; user token ysk_… → Authorization Bearer. */
31
+ export function getAuthHeaders(extra = {}) {
32
+ const credential = getApiKey();
33
+ const headers = { ...extra };
34
+ if (credential.startsWith("ysk_")) {
35
+ headers.Authorization = `Bearer ${credential}`;
36
+ }
37
+ else {
38
+ headers["X-Yaohai-Api-Key"] = credential;
39
+ }
40
+ return headers;
41
+ }
42
+ export function getBaseUrl() {
43
+ return (process.env.YAOHAI_BASE_URL || "https://db3.drugsea.cn/api").replace(/\/$/, "");
44
+ }
45
+ /** db3 serves list/facet GET as encrypted payloads; use POST /g/mcp/yaohai/search instead. */
46
+ export function prefersMcpListApi() {
47
+ const flag = process.env.YAOHAI_USE_MCP_LIST?.trim().toLowerCase();
48
+ if (["1", "true", "yes", "on"].includes(flag ?? "")) {
49
+ return true;
50
+ }
51
+ if (["0", "false", "no", "off"].includes(flag ?? "")) {
52
+ return false;
53
+ }
54
+ return getBaseUrl().includes("db3.drugsea.cn");
55
+ }
56
+ export function getVerifySsl() {
57
+ const v = process.env.YAOHAI_VERIFY_SSL;
58
+ if (v === undefined || v === "") {
59
+ return true;
60
+ }
61
+ return !["0", "false", "no", "off"].includes(v.trim().toLowerCase());
62
+ }
63
+ export function flattenQuery(params) {
64
+ const sp = new URLSearchParams();
65
+ for (const [k, v] of Object.entries(params)) {
66
+ if (v === undefined || v === null || v === "") {
67
+ continue;
68
+ }
69
+ if (Array.isArray(v)) {
70
+ for (const item of v) {
71
+ sp.append(k, String(item));
72
+ }
73
+ }
74
+ else if (typeof v === "boolean") {
75
+ sp.append(k, v ? "1" : "0");
76
+ }
77
+ else {
78
+ sp.append(k, String(v));
79
+ }
80
+ }
81
+ return sp;
82
+ }
83
+ export function clampLimit(limit, fallback, max) {
84
+ const n = limit ?? fallback;
85
+ if (!Number.isFinite(n)) {
86
+ return fallback;
87
+ }
88
+ return Math.max(1, Math.min(Math.trunc(n), max));
89
+ }
90
+ export function clampOffset(offset) {
91
+ if (offset === undefined || !Number.isFinite(offset)) {
92
+ return 0;
93
+ }
94
+ return Math.max(0, Math.trunc(offset));
95
+ }
96
+ function httpRequest(method, urlStr, headers, body) {
97
+ return new Promise((resolve, reject) => {
98
+ const url = new URL(urlStr);
99
+ const lib = url.protocol === "https:" ? https : http;
100
+ const options = {
101
+ method,
102
+ hostname: url.hostname,
103
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
104
+ path: `${url.pathname}${url.search}`,
105
+ headers,
106
+ timeout: 60_000,
107
+ };
108
+ if (url.protocol === "https:" && !getVerifySsl()) {
109
+ options.agent = new https.Agent({ rejectUnauthorized: false });
110
+ }
111
+ const req = lib.request(options, (res) => {
112
+ const chunks = [];
113
+ res.on("data", (chunk) => {
114
+ chunks.push(chunk);
115
+ });
116
+ res.on("end", () => {
117
+ resolve({
118
+ status: res.statusCode ?? 0,
119
+ text: Buffer.concat(chunks).toString("utf8"),
120
+ });
121
+ });
122
+ });
123
+ req.on("timeout", () => {
124
+ req.destroy(new Error("API request timed out after 60s"));
125
+ });
126
+ req.on("error", reject);
127
+ if (body) {
128
+ req.write(body);
129
+ }
130
+ req.end();
131
+ });
132
+ }
133
+ function parseApiJson(status, text) {
134
+ let data;
135
+ try {
136
+ data = JSON.parse(text);
137
+ }
138
+ catch (error) {
139
+ return {
140
+ ok: false,
141
+ status,
142
+ error: `Failed to parse API response: ${String(error)}`,
143
+ };
144
+ }
145
+ if (data &&
146
+ typeof data === "object" &&
147
+ "data" in data &&
148
+ !("api_status" in data)) {
149
+ return {
150
+ ok: false,
151
+ status,
152
+ error: "Encrypted or non-JSON API payload (db3 list/facet GET). Use YAOHAI_BASE_URL=https://db3.drugsea.cn/api so product-cn-search/reg-cn-search route via MCP POST, or set YAOHAI_USE_MCP_LIST=true.",
153
+ raw: data,
154
+ };
155
+ }
156
+ if (status < 200 || status >= 300) {
157
+ return {
158
+ ok: false,
159
+ status,
160
+ error: `API request failed with status ${status}: ${JSON.stringify(data)}`,
161
+ raw: data,
162
+ };
163
+ }
164
+ if (data &&
165
+ typeof data === "object" &&
166
+ "api_status" in data &&
167
+ data.api_status !== "success") {
168
+ const payload = data;
169
+ const content = payload.content;
170
+ const message = typeof content === "string" ? content : JSON.stringify(content ?? data);
171
+ return { ok: false, status, error: message, raw: data };
172
+ }
173
+ const content = data && typeof data === "object" && "content" in data
174
+ ? data.content
175
+ : data;
176
+ return {
177
+ ok: true,
178
+ status,
179
+ content,
180
+ raw: (data && typeof data === "object"
181
+ ? data
182
+ : { content: data }),
183
+ };
184
+ }
185
+ function unwrap(result) {
186
+ if (!result.ok) {
187
+ throw new ApiError(result.error, result.status, result.raw);
188
+ }
189
+ return result.content;
190
+ }
191
+ export async function yaohaiPost(path, body) {
192
+ const payload = JSON.stringify(body);
193
+ const url = `${getBaseUrl()}${path}`;
194
+ const { status, text } = await httpRequest("POST", url, getAuthHeaders({
195
+ "Content-Type": "application/json",
196
+ "Content-Length": String(Buffer.byteLength(payload)),
197
+ }), payload);
198
+ return unwrap(parseApiJson(status, text));
199
+ }
200
+ export async function yaohaiGet(path, query = {}) {
201
+ const qs = flattenQuery(query).toString();
202
+ const url = `${getBaseUrl()}${path}${qs ? `?${qs}` : ""}`;
203
+ const { status, text } = await httpRequest("GET", url, getAuthHeaders());
204
+ return parseApiJson(status, text);
205
+ }
206
+ export async function mcpDbSearch(opts) {
207
+ const query = { ...opts.query };
208
+ if (opts.viewType && opts.viewType !== "eslist") {
209
+ query.view_type = opts.viewType;
210
+ }
211
+ const content = (await yaohaiPost("/g/mcp/yaohai/search", {
212
+ dbname: opts.dbname,
213
+ query,
214
+ limit: opts.limit,
215
+ offset: opts.offset,
216
+ }));
217
+ const rawItems = content.items;
218
+ const items = Array.isArray(rawItems)
219
+ ? rawItems.map((item) => {
220
+ if (item && typeof item === "object" && "fields" in item) {
221
+ const row = item;
222
+ const flat = normalizeRecord(row.fields);
223
+ if (row.detail_url &&
224
+ flat &&
225
+ typeof flat === "object" &&
226
+ !Array.isArray(flat)) {
227
+ return { ...flat, detail_url: row.detail_url };
228
+ }
229
+ return flat;
230
+ }
231
+ return normalizeRecord(item);
232
+ })
233
+ : rawItems;
234
+ return {
235
+ dbname: content.dbname,
236
+ title: content.title,
237
+ category: content.category,
238
+ view_type: opts.viewType ?? "eslist",
239
+ total: content.total,
240
+ offset: opts.offset,
241
+ limit: opts.limit,
242
+ count: Array.isArray(items) ? items.length : undefined,
243
+ items,
244
+ query_applied: opts.query,
245
+ field_labels: content.field_labels,
246
+ via: "mcp",
247
+ };
248
+ }
249
+ export async function mcpDbDetail(dbname, id) {
250
+ const content = (await yaohaiPost("/g/mcp/yaohai/detail", {
251
+ dbname,
252
+ id,
253
+ }));
254
+ return { dbname, id, ...content };
255
+ }
256
+ export async function listSearch(opts) {
257
+ const params = {
258
+ ...opts.query,
259
+ limit: opts.limit,
260
+ offset: opts.offset,
261
+ };
262
+ const result = await yaohaiGet(opts.path, params);
263
+ if (!result.ok) {
264
+ throw new ApiError(result.error, result.status, result.raw);
265
+ }
266
+ const raw = result.raw;
267
+ const content = raw.content ?? result.content;
268
+ const items = normalizeRecord(content);
269
+ const totalRaw = raw.tnum;
270
+ const total = typeof totalRaw === "number"
271
+ ? totalRaw
272
+ : Array.isArray(items)
273
+ ? items.length
274
+ : 0;
275
+ return {
276
+ view_type: opts.viewType,
277
+ total,
278
+ offset: opts.offset,
279
+ limit: opts.limit,
280
+ count: Array.isArray(items) ? items.length : undefined,
281
+ items,
282
+ query_applied: opts.query,
283
+ };
284
+ }
285
+ export async function fetchDetail(path, id) {
286
+ const result = await yaohaiGet(path, {});
287
+ if (!result.ok) {
288
+ throw new ApiError(result.error, result.status, result.raw);
289
+ }
290
+ return {
291
+ id,
292
+ detail: normalizeRecord(result.content),
293
+ };
294
+ }
295
+ export async function fetchFacets(opts) {
296
+ const unknown = opts.fields.filter((f) => !(f in opts.catalog));
297
+ if (unknown.length > 0) {
298
+ throw new ApiError(`Unknown facet fields: ${unknown.join(", ")}`);
299
+ }
300
+ const distributions = {};
301
+ for (const field of opts.fields) {
302
+ const meta = opts.catalog[field];
303
+ const facetQuery = { ...opts.query };
304
+ delete facetQuery[field];
305
+ const result = await yaohaiGet(`${opts.prefix}/${encodeURIComponent(field)}`, facetQuery);
306
+ if (!result.ok) {
307
+ distributions[field] = {
308
+ success: false,
309
+ error: result.error,
310
+ title: meta.title,
311
+ };
312
+ continue;
313
+ }
314
+ const data = isRecord(result.content) ? result.content : {};
315
+ const list = data.list;
316
+ distributions[field] = {
317
+ success: true,
318
+ title: meta.title,
319
+ filter_type: data.type ?? meta.filter_type,
320
+ items: parseFacetList(list, field),
321
+ };
322
+ }
323
+ return {
324
+ query_applied: opts.query,
325
+ total_fields: opts.fields.length,
326
+ distributions,
327
+ };
328
+ }
329
+ function isRecord(v) {
330
+ return v !== null && typeof v === "object" && !Array.isArray(v);
331
+ }
package/dist/fields.js ADDED
@@ -0,0 +1,116 @@
1
+ export const PRODUCT_CN_COMMON_FIELDS = [
2
+ "item",
3
+ "drug_name",
4
+ "manufacture",
5
+ "license_holder",
6
+ "specification",
7
+ "std_specification",
8
+ "auth_num",
9
+ "indication",
10
+ "general_name_cn",
11
+ "only_active",
12
+ "search_mode",
13
+ ];
14
+ export const PRODUCT_CN_FACET_FIELDS = {
15
+ listing_date: { title: "上市年份", filter_type: "multiple" },
16
+ source: { title: "国产进口", filter_type: "multiple" },
17
+ province: { title: "省份", filter_type: "multiple" },
18
+ drug_type: { title: "药品类型", filter_type: "multiple" },
19
+ register_type: { title: "注册分类", filter_type: "multiple" },
20
+ national_yibao: { title: "医保类型", filter_type: "multiple" },
21
+ national_jiyao: { title: "基药类型", filter_type: "multiple" },
22
+ state_OTC: { title: "非处方药", filter_type: "multiple" },
23
+ std_dosage_form: { title: "药品剂型", filter_type: "multiple" },
24
+ administration_route: { title: "给药途径", filter_type: "multiple" },
25
+ new_drug_type: { title: "新药类型", filter_type: "multiple" },
26
+ ATC_code: { title: "ATC分类", filter_type: "multiple" },
27
+ is_orange_book: { title: "目录集收录", filter_type: "multiple" },
28
+ is_passed_yizhi: { title: "通过一致性评价", filter_type: "multiple" },
29
+ is_guojia_jicai: { title: "是否已国家集采", filter_type: "multiple" },
30
+ related_jc_projects: { title: "国家集采相关项目", filter_type: "multiple" },
31
+ approve_date: { title: "最近再注册批准日期", filter_type: "date" },
32
+ first_approve_date: { title: "品种首次上市日期", filter_type: "date" },
33
+ general_name_count: { title: "同成分厂家数", filter_type: "range" },
34
+ product_count: { title: "同品种厂家数", filter_type: "range" },
35
+ dosage_count: { title: "同规格厂家数", filter_type: "range" },
36
+ in_sfda: { title: "是否有效", filter_type: "multiple" },
37
+ };
38
+ export const PRODUCT_CN_VIEW_TYPES = [
39
+ "eslist",
40
+ "list_by_drug_name",
41
+ "list_by_manufacture",
42
+ ];
43
+ export const REG_CN_COMMON_FIELDS = [
44
+ "item",
45
+ "drug_name",
46
+ "enterprise",
47
+ "slh",
48
+ "indication",
49
+ "rows_excluded",
50
+ "search_mode",
51
+ ];
52
+ export const REG_CN_FACET_FIELDS = {
53
+ rd_status: { title: "研发状态", filter_type: "multiple" },
54
+ innovation_degree: { title: "创新程度", filter_type: "multiple" },
55
+ first_generic_drug: { title: "首仿状态", filter_type: "multiple" },
56
+ apply_type: { title: "申请类型", filter_type: "multiple" },
57
+ conclusion: { title: "审评结论", filter_type: "multiple" },
58
+ transact_status: { title: "办理状态", filter_type: "multiple" },
59
+ register_type: { title: "注册分类", filter_type: "multiple" },
60
+ special_list: { title: "特殊品种", filter_type: "multiple" },
61
+ applyTypeCde: { title: "审评序列", filter_type: "multiple" },
62
+ drug_type: { title: "药品类型", filter_type: "multiple" },
63
+ drug_category: { title: "药品小类", filter_type: "multiple" },
64
+ std_dosage_form: { title: "药品剂型", filter_type: "multiple" },
65
+ yibao_dosage: { title: "医保剂型", filter_type: "multiple" },
66
+ administration_route: { title: "给药途径", filter_type: "multiple" },
67
+ slh_types: { title: "申报类型", filter_type: "multiple" },
68
+ ATC_code: { title: "ATC一级分类", filter_type: "multiple" },
69
+ provinces: { title: "来源省份", filter_type: "multiple" },
70
+ undertake_date: { title: "承办日期", filter_type: "date" },
71
+ status_start_date: { title: "状态日期", filter_type: "date" },
72
+ apply_ctc_num: { title: "申报临床厂家数", filter_type: "range" },
73
+ apply_listing_num: { title: "申报生产厂家数", filter_type: "range" },
74
+ market_num: { title: "已上市厂家数", filter_type: "range" },
75
+ };
76
+ export const REG_CN_VIEW_TYPES = [
77
+ "eslist",
78
+ "list_by_drug_name",
79
+ "list_by_enterprise",
80
+ ];
81
+ export const PRODUCT_CN_SEARCH_PATH = "/product/cn/eslist";
82
+ export const PRODUCT_CN_DETAIL_PATH = "/product/cn/detail";
83
+ export const PRODUCT_CN_FACET_PREFIX = "/product/cn/eslist";
84
+ export const REG_CN_SEARCH_PATH = "/b/drugreg/cn/eslist";
85
+ export const REG_CN_DETAIL_PATH = "/b/drugreg/cn/detail";
86
+ export const REG_CN_FACET_PREFIX = "/b/es/drugreg/cn/list";
87
+ export const ATC_HINT = "For disease/therapeutic-class queries prefer ConditionSearch ATC_code (letter): L=oncology, C=cardiovascular, J=anti-infectives, N=nervous system, R=respiratory, A=alimentary/metabolism (e.g. diabetes), H=hormones, G=genito-urinary, M=musculo-skeletal, D=dermatologicals, B=blood, S=sensory, P=antiparasitic, V=various.";
88
+ export function productCnSearchPath(viewType) {
89
+ if (viewType === "eslist") {
90
+ return PRODUCT_CN_SEARCH_PATH;
91
+ }
92
+ return `/product/cn/${viewType}`;
93
+ }
94
+ export function regCnSearchPath(viewType) {
95
+ if (viewType === "eslist") {
96
+ return REG_CN_SEARCH_PATH;
97
+ }
98
+ return `/b/drugreg/cn/${viewType}`;
99
+ }
100
+ export function applyProductCnDefaults(query) {
101
+ const merged = { ...query };
102
+ if (merged.search_mode === undefined || merged.search_mode === "") {
103
+ merged.search_mode = "3";
104
+ }
105
+ return merged;
106
+ }
107
+ export function applyRegCnDefaults(query) {
108
+ const merged = { ...query };
109
+ if (merged.rows_excluded === undefined || merged.rows_excluded === "") {
110
+ merged.rows_excluded = 1;
111
+ }
112
+ if (merged.search_mode === undefined || merged.search_mode === "") {
113
+ merged.search_mode = "1";
114
+ }
115
+ return merged;
116
+ }
package/dist/index.js ADDED
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { clampLimit, clampOffset, fetchDetail, fetchFacets, listSearch, mcpDbDetail, mcpDbSearch, prefersMcpListApi, yaohaiPost, } from "./api.js";
6
+ import { ATC_HINT, PRODUCT_CN_COMMON_FIELDS, PRODUCT_CN_DETAIL_PATH, PRODUCT_CN_FACET_FIELDS, PRODUCT_CN_FACET_PREFIX, PRODUCT_CN_VIEW_TYPES, REG_CN_COMMON_FIELDS, REG_CN_DETAIL_PATH, REG_CN_FACET_FIELDS, REG_CN_FACET_PREFIX, REG_CN_VIEW_TYPES, applyProductCnDefaults, applyRegCnDefaults, productCnSearchPath, regCnSearchPath, } from "./fields.js";
7
+ import { EmptyObjectSchema, ProductCnDetailSchema, ProductCnFacetsSchema, ProductCnSearchSchema, RegCnDetailSchema, RegCnFacetsSchema, RegCnSearchSchema, YaohaiCatalogSchema, YaohaiDetailSchema, YaohaiGlobalSearchSchema, YaohaiSearchSchema, YaohaiSmartSearchSchema, } from "./types.js";
8
+ const PACKAGE_VERSION = "0.1.0";
9
+ const YAOHAI_LIMIT_MAX = 50;
10
+ const YAOHAI_LIMIT_DEFAULT = 10;
11
+ const CN_LIMIT_MAX = 100;
12
+ const CN_LIMIT_DEFAULT = 20;
13
+ const QUERY_PROP = {
14
+ type: "object",
15
+ additionalProperties: true,
16
+ description: "Search/filter fields. Values may be string, number, or string[] (repeat the key for ConditionSearch multiple). Date: \"YYYY-MM-DD to YYYY-MM-DD\". Range: \"min to max\".",
17
+ };
18
+ const PRESENTATION_HINT = "If total > 20, summarize in chat (about 5–10 sample rows) instead of dumping the full table. Include frontend source links when present.";
19
+ const ROUTING_HINT = "Already-marketed China products (国药准字, 批准文号, 上市, 医保/集采) → product-cn-* tools. R&D / CDE pipeline (在研, 受理号, 审评, 尚未上市) → reg-cn-* tools. Other DBs (医保 yibao, 基药 jiyao, 集采 jicai, trials, global) → yaohai-*. Do not use yaohai-search with dbname product_cn or reg_cn when the dedicated tools apply.";
20
+ process.on("uncaughtException", (error) => {
21
+ console.error("Uncaught Exception:", error);
22
+ process.exit(1);
23
+ });
24
+ process.on("unhandledRejection", (reason, promise) => {
25
+ console.error("Unhandled Rejection at:", promise, "reason:", reason);
26
+ process.exit(1);
27
+ });
28
+ const server = new Server({
29
+ name: "mcp-drugsea",
30
+ version: PACKAGE_VERSION,
31
+ }, {
32
+ capabilities: {
33
+ resources: {},
34
+ tools: {},
35
+ },
36
+ });
37
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
38
+ return {
39
+ tools: [
40
+ {
41
+ name: "yaohai-catalog",
42
+ description: "List Yaohai / DrugSea databases (60+). Filter by category or keyword to pick a dbname for yaohai-search. " +
43
+ ROUTING_HINT,
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ category: {
48
+ type: "string",
49
+ description: "Optional category, e.g. 市场准入, 上市情报, 注册情报, 临床试验, NMPA基础库",
50
+ },
51
+ q: {
52
+ type: "string",
53
+ description: "Optional keyword filter on title, id, or keywords",
54
+ },
55
+ },
56
+ },
57
+ },
58
+ {
59
+ name: "yaohai-search",
60
+ description: "Search a single Yaohai database by dbname (from yaohai-catalog). Default limit 10, max 50. " +
61
+ "Use query fields from the catalog's search_fields. " +
62
+ ROUTING_HINT +
63
+ " " +
64
+ PRESENTATION_HINT,
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: {
68
+ dbname: {
69
+ type: "string",
70
+ description: "Database id, e.g. yibao, jiyao, jicai, jicai_mulu, fda_dmf. Prefer product-cn-search / reg-cn-search instead of product_cn / reg_cn.",
71
+ },
72
+ query: QUERY_PROP,
73
+ limit: {
74
+ type: "number",
75
+ description: `Row cap (default ${YAOHAI_LIMIT_DEFAULT}, max ${YAOHAI_LIMIT_MAX})`,
76
+ },
77
+ offset: { type: "number", description: "Pagination offset (default 0)" },
78
+ },
79
+ required: ["dbname"],
80
+ },
81
+ },
82
+ {
83
+ name: "yaohai-detail",
84
+ description: "Fetch one record's detail from a Yaohai database. Requires dbname + encrypted id from yaohai-search items. Skip if catalog says has_detail is false (use list fields instead).",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ dbname: { type: "string", description: "Database id" },
89
+ id: { type: "string", description: "Record id from search results" },
90
+ },
91
+ required: ["dbname", "id"],
92
+ },
93
+ },
94
+ {
95
+ name: "yaohai-global-search",
96
+ description: "Global drug panorama search (global_search). Pass q as the search term, or query.term. " +
97
+ PRESENTATION_HINT,
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ q: { type: "string", description: "Search term (maps to query.term)" },
102
+ query: QUERY_PROP,
103
+ limit: {
104
+ type: "number",
105
+ description: `Row cap (default ${YAOHAI_LIMIT_DEFAULT}, max ${YAOHAI_LIMIT_MAX})`,
106
+ },
107
+ offset: { type: "number", description: "Pagination offset (default 0)" },
108
+ },
109
+ },
110
+ },
111
+ {
112
+ name: "yaohai-smart-search",
113
+ description: "Natural-language Yaohai search: auto-routes the question to up to 3 databases. Use when the user question is broad or the target DB is unclear. " +
114
+ ROUTING_HINT,
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: {
118
+ q: { type: "string", description: "Natural language question" },
119
+ query: QUERY_PROP,
120
+ limit: {
121
+ type: "number",
122
+ description: `Row cap (default ${YAOHAI_LIMIT_DEFAULT}, max ${YAOHAI_LIMIT_MAX})`,
123
+ },
124
+ },
125
+ required: ["q"],
126
+ },
127
+ },
128
+ {
129
+ name: "product-cn-fields",
130
+ description: "List CommonSearch and ConditionSearch field keys for 国内上市药品 (product_cn). Call before product-cn-search / product-cn-facets if unsure which filters exist.",
131
+ inputSchema: { type: "object", properties: {} },
132
+ },
133
+ {
134
+ name: "product-cn-search",
135
+ description: "Search already-marketed China drugs (product_cn / 国药准字 / 批准文号 / NMPA listed). " +
136
+ "Default search_mode=3 (partial). first_approve_date = first listing date; approve_date = latest re-registration (not first listing). " +
137
+ ATC_HINT +
138
+ " Default limit 20, max 100. " +
139
+ "Not for R&D pipeline — use reg-cn-search. " +
140
+ PRESENTATION_HINT,
141
+ inputSchema: {
142
+ type: "object",
143
+ properties: {
144
+ query: {
145
+ ...QUERY_PROP,
146
+ description: QUERY_PROP.description +
147
+ " Common: item, drug_name, manufacture, license_holder, specification, std_specification, auth_num, indication, general_name_cn, only_active (1), search_mode (1/2/3). Condition examples: ATC_code, national_yibao, std_dosage_form, source, first_approve_date.",
148
+ },
149
+ limit: {
150
+ type: "number",
151
+ description: `Row cap (default ${CN_LIMIT_DEFAULT}, max ${CN_LIMIT_MAX})`,
152
+ },
153
+ offset: { type: "number", description: "Pagination offset (default 0)" },
154
+ view_type: {
155
+ type: "string",
156
+ enum: [...PRODUCT_CN_VIEW_TYPES],
157
+ description: "eslist (by approval, default), list_by_drug_name, list_by_manufacture",
158
+ },
159
+ },
160
+ },
161
+ },
162
+ {
163
+ name: "product-cn-facets",
164
+ description: "Facet distributions for 国内上市药品 after a keyword query. facets is required (do not request all 22 — slow). " +
165
+ "Recommended: ATC_code, drug_type, national_yibao, std_dosage_form, source. " +
166
+ ATC_HINT,
167
+ inputSchema: {
168
+ type: "object",
169
+ properties: {
170
+ query: QUERY_PROP,
171
+ facets: {
172
+ type: "array",
173
+ items: { type: "string" },
174
+ description: "Facet field keys to fetch. Recommended: ATC_code, drug_type, national_yibao, std_dosage_form, source.",
175
+ },
176
+ },
177
+ required: ["facets"],
178
+ },
179
+ },
180
+ {
181
+ name: "product-cn-detail",
182
+ description: "Detail for one product_cn row. id is the encrypted id from product-cn-search items (not the raw 批准文号).",
183
+ inputSchema: {
184
+ type: "object",
185
+ properties: {
186
+ id: { type: "string", description: "Encrypted record id from search results" },
187
+ },
188
+ required: ["id"],
189
+ },
190
+ },
191
+ {
192
+ name: "reg-cn-fields",
193
+ description: "List CommonSearch and ConditionSearch field keys for 药品注册审评 (reg_cn / CDE). Call before reg-cn-search / reg-cn-facets if unsure which filters exist.",
194
+ inputSchema: { type: "object", properties: {} },
195
+ },
196
+ {
197
+ name: "reg-cn-search",
198
+ description: "Search China drug registration / CDE review (reg_cn): 在研, 受理号, 申报, 审评进度, not-yet-listed. " +
199
+ "Default rows_excluded=1 (drop 备案), search_mode=1. " +
200
+ ATC_HINT +
201
+ " Default limit 20, max 100. Not for already-marketed products — use product-cn-search. " +
202
+ PRESENTATION_HINT,
203
+ inputSchema: {
204
+ type: "object",
205
+ properties: {
206
+ query: {
207
+ ...QUERY_PROP,
208
+ description: QUERY_PROP.description +
209
+ " Common: item, drug_name, enterprise, slh, indication, rows_excluded, search_mode. Condition examples: ATC_code, rd_status, transact_status, register_type, drug_type, undertake_date.",
210
+ },
211
+ limit: {
212
+ type: "number",
213
+ description: `Row cap (default ${CN_LIMIT_DEFAULT}, max ${CN_LIMIT_MAX})`,
214
+ },
215
+ offset: { type: "number", description: "Pagination offset (default 0)" },
216
+ view_type: {
217
+ type: "string",
218
+ enum: [...REG_CN_VIEW_TYPES],
219
+ description: "eslist (default), list_by_drug_name, list_by_enterprise",
220
+ },
221
+ },
222
+ },
223
+ },
224
+ {
225
+ name: "reg-cn-facets",
226
+ description: "Facet distributions for 药品注册审评. facets is required (do not request all dimensions — slow). " +
227
+ "Recommended: ATC_code, rd_status, drug_type, transact_status, register_type. " +
228
+ ATC_HINT,
229
+ inputSchema: {
230
+ type: "object",
231
+ properties: {
232
+ query: QUERY_PROP,
233
+ facets: {
234
+ type: "array",
235
+ items: { type: "string" },
236
+ description: "Facet field keys to fetch. Recommended: ATC_code, rd_status, drug_type, transact_status, register_type.",
237
+ },
238
+ },
239
+ required: ["facets"],
240
+ },
241
+ },
242
+ {
243
+ name: "reg-cn-detail",
244
+ description: "Detail for one reg_cn acceptance/review row. id is the encrypted id from reg-cn-search items (not the raw 受理号).",
245
+ inputSchema: {
246
+ type: "object",
247
+ properties: {
248
+ id: { type: "string", description: "Encrypted record id from search results" },
249
+ },
250
+ required: ["id"],
251
+ },
252
+ },
253
+ ],
254
+ };
255
+ });
256
+ function encodeId(id) {
257
+ return encodeURIComponent(id).replace(/!/g, "%21");
258
+ }
259
+ function asQuery(query) {
260
+ return { ...(query ?? {}) };
261
+ }
262
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
263
+ const { name, arguments: args } = request.params;
264
+ try {
265
+ switch (name) {
266
+ case "yaohai-catalog": {
267
+ const validated = YaohaiCatalogSchema.parse(args ?? {});
268
+ const body = {};
269
+ if (validated.category)
270
+ body.category = validated.category;
271
+ if (validated.q)
272
+ body.q = validated.q;
273
+ const content = await yaohaiPost("/g/mcp/yaohai/catalog", body);
274
+ return ok(content);
275
+ }
276
+ case "yaohai-search": {
277
+ const validated = YaohaiSearchSchema.parse(args);
278
+ const content = await yaohaiPost("/g/mcp/yaohai/search", {
279
+ dbname: validated.dbname,
280
+ query: asQuery(validated.query),
281
+ limit: clampLimit(validated.limit, YAOHAI_LIMIT_DEFAULT, YAOHAI_LIMIT_MAX),
282
+ offset: clampOffset(validated.offset),
283
+ });
284
+ return ok(content);
285
+ }
286
+ case "yaohai-detail": {
287
+ const validated = YaohaiDetailSchema.parse(args);
288
+ const content = await yaohaiPost("/g/mcp/yaohai/detail", {
289
+ dbname: validated.dbname,
290
+ id: validated.id,
291
+ });
292
+ return ok(content);
293
+ }
294
+ case "yaohai-global-search": {
295
+ const validated = YaohaiGlobalSearchSchema.parse(args ?? {});
296
+ const query = asQuery(validated.query);
297
+ if (validated.q && (query.term === undefined || query.term === "")) {
298
+ query.term = validated.q;
299
+ }
300
+ const content = await yaohaiPost("/g/mcp/yaohai/global-search", {
301
+ query,
302
+ limit: clampLimit(validated.limit, YAOHAI_LIMIT_DEFAULT, YAOHAI_LIMIT_MAX),
303
+ offset: clampOffset(validated.offset),
304
+ });
305
+ return ok(content);
306
+ }
307
+ case "yaohai-smart-search": {
308
+ const validated = YaohaiSmartSearchSchema.parse(args);
309
+ const body = {
310
+ q: validated.q,
311
+ limit: clampLimit(validated.limit, YAOHAI_LIMIT_DEFAULT, YAOHAI_LIMIT_MAX),
312
+ };
313
+ if (validated.query) {
314
+ body.query = validated.query;
315
+ }
316
+ const content = await yaohaiPost("/g/mcp/yaohai/smart-search", body);
317
+ return ok(content);
318
+ }
319
+ case "product-cn-fields": {
320
+ EmptyObjectSchema.parse(args ?? {});
321
+ return ok({
322
+ common_search: [...PRODUCT_CN_COMMON_FIELDS],
323
+ condition_search: PRODUCT_CN_FACET_FIELDS,
324
+ view_types: [...PRODUCT_CN_VIEW_TYPES],
325
+ });
326
+ }
327
+ case "product-cn-search": {
328
+ const validated = ProductCnSearchSchema.parse(args ?? {});
329
+ const viewType = validated.view_type ?? "eslist";
330
+ const query = applyProductCnDefaults(asQuery(validated.query));
331
+ const limit = clampLimit(validated.limit, CN_LIMIT_DEFAULT, CN_LIMIT_MAX);
332
+ const offset = clampOffset(validated.offset);
333
+ const content = prefersMcpListApi()
334
+ ? await mcpDbSearch({
335
+ dbname: "product_cn",
336
+ query,
337
+ limit,
338
+ offset,
339
+ viewType,
340
+ })
341
+ : await listSearch({
342
+ path: productCnSearchPath(viewType),
343
+ query,
344
+ limit,
345
+ offset,
346
+ viewType,
347
+ });
348
+ return ok(content);
349
+ }
350
+ case "product-cn-facets": {
351
+ const validated = ProductCnFacetsSchema.parse(args);
352
+ const query = applyProductCnDefaults(asQuery(validated.query));
353
+ const content = await fetchFacets({
354
+ prefix: PRODUCT_CN_FACET_PREFIX,
355
+ query,
356
+ fields: validated.facets,
357
+ catalog: PRODUCT_CN_FACET_FIELDS,
358
+ });
359
+ return ok(content);
360
+ }
361
+ case "product-cn-detail": {
362
+ const validated = ProductCnDetailSchema.parse(args);
363
+ const content = prefersMcpListApi()
364
+ ? await mcpDbDetail("product_cn", validated.id)
365
+ : await fetchDetail(`${PRODUCT_CN_DETAIL_PATH}/${encodeId(validated.id)}`, validated.id);
366
+ return ok(content);
367
+ }
368
+ case "reg-cn-fields": {
369
+ EmptyObjectSchema.parse(args ?? {});
370
+ return ok({
371
+ common_search: [...REG_CN_COMMON_FIELDS],
372
+ condition_search: REG_CN_FACET_FIELDS,
373
+ view_types: [...REG_CN_VIEW_TYPES],
374
+ });
375
+ }
376
+ case "reg-cn-search": {
377
+ const validated = RegCnSearchSchema.parse(args ?? {});
378
+ const viewType = validated.view_type ?? "eslist";
379
+ const query = applyRegCnDefaults(asQuery(validated.query));
380
+ const limit = clampLimit(validated.limit, CN_LIMIT_DEFAULT, CN_LIMIT_MAX);
381
+ const offset = clampOffset(validated.offset);
382
+ const content = prefersMcpListApi()
383
+ ? await mcpDbSearch({
384
+ dbname: "reg_cn",
385
+ query,
386
+ limit,
387
+ offset,
388
+ viewType,
389
+ })
390
+ : await listSearch({
391
+ path: regCnSearchPath(viewType),
392
+ query,
393
+ limit,
394
+ offset,
395
+ viewType,
396
+ });
397
+ return ok(content);
398
+ }
399
+ case "reg-cn-facets": {
400
+ const validated = RegCnFacetsSchema.parse(args);
401
+ const query = applyRegCnDefaults(asQuery(validated.query));
402
+ const content = await fetchFacets({
403
+ prefix: REG_CN_FACET_PREFIX,
404
+ query,
405
+ fields: validated.facets,
406
+ catalog: REG_CN_FACET_FIELDS,
407
+ });
408
+ return ok(content);
409
+ }
410
+ case "reg-cn-detail": {
411
+ const validated = RegCnDetailSchema.parse(args);
412
+ const content = prefersMcpListApi()
413
+ ? await mcpDbDetail("reg_cn", validated.id)
414
+ : await fetchDetail(`${REG_CN_DETAIL_PATH}/${encodeId(validated.id)}`, validated.id);
415
+ return ok(content);
416
+ }
417
+ default:
418
+ throw new Error(`Unknown tool: ${name}`);
419
+ }
420
+ }
421
+ catch (error) {
422
+ const message = error instanceof Error ? error.message : String(error);
423
+ return {
424
+ content: [{ type: "text", text: message }],
425
+ isError: true,
426
+ };
427
+ }
428
+ });
429
+ function ok(content) {
430
+ return {
431
+ content: [{ type: "text", text: JSON.stringify(content) }],
432
+ isError: false,
433
+ };
434
+ }
435
+ async function main() {
436
+ const transport = new StdioServerTransport();
437
+ await server.connect(transport);
438
+ }
439
+ main().catch((error) => {
440
+ console.error("Fatal error in main():", error);
441
+ process.exit(1);
442
+ });
@@ -0,0 +1,137 @@
1
+ /** Flatten DrugSea UI cell objects `{ text, title, url }` into plain values. */
2
+ function isPlainObject(v) {
3
+ return v !== null && typeof v === "object" && !Array.isArray(v);
4
+ }
5
+ export function normalizeValue(v) {
6
+ if (v === null || v === undefined) {
7
+ return v;
8
+ }
9
+ if (typeof v === "boolean" || typeof v === "number") {
10
+ return v;
11
+ }
12
+ if (typeof v === "string") {
13
+ const trimmed = v.trim();
14
+ if (trimmed && (trimmed[0] === "[" || trimmed[0] === "{")) {
15
+ try {
16
+ return normalizeValue(JSON.parse(trimmed));
17
+ }
18
+ catch {
19
+ // keep original string
20
+ }
21
+ }
22
+ return v;
23
+ }
24
+ if (isPlainObject(v)) {
25
+ if ("text" in v && !("0" in v)) {
26
+ return normalizeValue(v.text);
27
+ }
28
+ const out = {};
29
+ for (const [k, val] of Object.entries(v)) {
30
+ out[k] = normalizeValue(val);
31
+ }
32
+ return out;
33
+ }
34
+ if (Array.isArray(v)) {
35
+ const texts = [];
36
+ for (const part of v) {
37
+ const t = isPlainObject(part) && "text" in part
38
+ ? normalizeValue(part.text)
39
+ : normalizeValue(part);
40
+ if (t === null || t === undefined || t === "") {
41
+ continue;
42
+ }
43
+ texts.push(String(t));
44
+ }
45
+ if (texts.length === 0) {
46
+ return "";
47
+ }
48
+ return texts.length === 1 ? texts[0] : texts.join("; ");
49
+ }
50
+ return String(v);
51
+ }
52
+ export function flattenRecord(record) {
53
+ const out = {};
54
+ for (const [k, v] of Object.entries(record)) {
55
+ if (k === "detail") {
56
+ continue;
57
+ }
58
+ out[k] = normalizeValue(v);
59
+ }
60
+ return out;
61
+ }
62
+ export function normalizeRecord(record) {
63
+ if (Array.isArray(record)) {
64
+ return record.map((row) => isPlainObject(row) ? flattenRecord(row) : row);
65
+ }
66
+ if (!isPlainObject(record)) {
67
+ return record;
68
+ }
69
+ const keys = Object.keys(record);
70
+ if (keys.length > 0 &&
71
+ keys.every((k, i) => k === String(i))) {
72
+ return keys.map((k) => {
73
+ const row = record[k];
74
+ return isPlainObject(row) ? flattenRecord(row) : row;
75
+ });
76
+ }
77
+ return flattenRecord(record);
78
+ }
79
+ function parseMaybeDict(item) {
80
+ try {
81
+ const parsed = JSON.parse(item);
82
+ if (isPlainObject(parsed)) {
83
+ return parsed;
84
+ }
85
+ }
86
+ catch {
87
+ // try python-ish single quotes
88
+ }
89
+ try {
90
+ const parsed = JSON.parse(item.replace(/'/g, '"'));
91
+ if (isPlainObject(parsed)) {
92
+ return parsed;
93
+ }
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ return null;
99
+ }
100
+ export function parseFacetList(raw, field) {
101
+ let items = [];
102
+ if (Array.isArray(raw)) {
103
+ items = raw;
104
+ }
105
+ else if (typeof raw === "string") {
106
+ items = raw.split(";").map((p) => p.trim()).filter(Boolean);
107
+ }
108
+ else {
109
+ return [];
110
+ }
111
+ const out = [];
112
+ for (const item of items) {
113
+ let row = null;
114
+ if (isPlainObject(item)) {
115
+ row = item;
116
+ }
117
+ else if (typeof item === "string") {
118
+ row = parseMaybeDict(item);
119
+ }
120
+ if (!row) {
121
+ continue;
122
+ }
123
+ let value = row[field] ?? row.value ?? "";
124
+ if (isPlainObject(value)) {
125
+ value = value.title ?? value.value ?? value;
126
+ }
127
+ let count = row.ct ?? row.count ?? 0;
128
+ const n = typeof count === "number" ? count : Number.parseInt(String(count), 10);
129
+ out.push({
130
+ value,
131
+ count: Number.isFinite(n) ? n : 0,
132
+ raw: row,
133
+ });
134
+ }
135
+ out.sort((a, b) => b.count - a.count || String(a.value).localeCompare(String(b.value)));
136
+ return out.map(({ value, count }) => ({ value, count }));
137
+ }
package/dist/types.js ADDED
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ export const QueryValueSchema = z.union([
3
+ z.string(),
4
+ z.number(),
5
+ z.boolean(),
6
+ z.array(z.union([z.string(), z.number()])),
7
+ ]);
8
+ export const QueryObjectSchema = z.record(QueryValueSchema);
9
+ export const YaohaiCatalogSchema = z.object({
10
+ category: z.string().optional(),
11
+ q: z.string().optional(),
12
+ });
13
+ export const YaohaiSearchSchema = z.object({
14
+ dbname: z.string().min(1),
15
+ query: QueryObjectSchema.optional(),
16
+ limit: z.coerce.number().int().optional(),
17
+ offset: z.coerce.number().int().optional(),
18
+ });
19
+ export const YaohaiDetailSchema = z.object({
20
+ dbname: z.string().min(1),
21
+ id: z.string().min(1),
22
+ });
23
+ export const YaohaiGlobalSearchSchema = z.object({
24
+ q: z.string().optional(),
25
+ query: QueryObjectSchema.optional(),
26
+ limit: z.coerce.number().int().optional(),
27
+ offset: z.coerce.number().int().optional(),
28
+ });
29
+ export const YaohaiSmartSearchSchema = z.object({
30
+ q: z.string().min(1),
31
+ query: QueryObjectSchema.optional(),
32
+ limit: z.coerce.number().int().optional(),
33
+ });
34
+ export const ProductViewTypeSchema = z.enum([
35
+ "eslist",
36
+ "list_by_drug_name",
37
+ "list_by_manufacture",
38
+ ]);
39
+ export const RegViewTypeSchema = z.enum([
40
+ "eslist",
41
+ "list_by_drug_name",
42
+ "list_by_enterprise",
43
+ ]);
44
+ export const ProductCnSearchSchema = z.object({
45
+ query: QueryObjectSchema.optional(),
46
+ limit: z.coerce.number().int().optional(),
47
+ offset: z.coerce.number().int().optional(),
48
+ view_type: ProductViewTypeSchema.optional(),
49
+ });
50
+ export const ProductCnFacetsSchema = z.object({
51
+ query: QueryObjectSchema.optional(),
52
+ facets: z.array(z.string().min(1)).min(1),
53
+ });
54
+ export const ProductCnDetailSchema = z.object({
55
+ id: z.string().min(1),
56
+ });
57
+ export const RegCnSearchSchema = z.object({
58
+ query: QueryObjectSchema.optional(),
59
+ limit: z.coerce.number().int().optional(),
60
+ offset: z.coerce.number().int().optional(),
61
+ view_type: RegViewTypeSchema.optional(),
62
+ });
63
+ export const RegCnFacetsSchema = z.object({
64
+ query: QueryObjectSchema.optional(),
65
+ facets: z.array(z.string().min(1)).min(1),
66
+ });
67
+ export const RegCnDetailSchema = z.object({
68
+ id: z.string().min(1),
69
+ });
70
+ export const EmptyObjectSchema = z.object({}).passthrough();
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@kinginsun/mcp-drugsea",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for DrugSea / Yaohai pharmaceutical databases: cross-db search, China marketed products (product_cn), and CDE registration review (reg_cn).",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mcp-drugsea": "dist/index.js"
9
+ },
10
+ "homepage": "https://github.com/kinginsun/mcp-drugsea",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/kinginsun/mcp-drugsea.git"
14
+ },
15
+ "license": "MIT",
16
+ "author": "kinginsun@CUHK",
17
+ "scripts": {
18
+ "build": "tsc && shx chmod +x dist/*.js",
19
+ "prepare": "npm run build",
20
+ "dev": "tsc --watch",
21
+ "start": "node dist/index.js"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "model-context-protocol",
26
+ "cli",
27
+ "drugsea",
28
+ "yaohai",
29
+ "nmpa",
30
+ "cde",
31
+ "pharmaceutical"
32
+ ],
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.0.4",
35
+ "zod": "^3.24.1"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.10.2",
39
+ "shx": "^0.3.4",
40
+ "typescript": "^5.7.2"
41
+ },
42
+ "engines": {
43
+ "node": ">=18.0.0"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "files": [
49
+ "dist",
50
+ "README.md",
51
+ "LICENSE"
52
+ ]
53
+ }