@kinginsun/mcp-drugsea 0.2.1 → 0.4.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/README.md CHANGED
@@ -4,7 +4,7 @@ MCP (Model Context Protocol) stdio server for [DrugSea / Yaohai](https://db.drug
4
4
 
5
5
  The server forwards tool calls to **`https://db3.drugsea.cn/api`** with personal user token auth (`Authorization: Bearer ysk_…`). It covers:
6
6
 
7
- - **yaohai-*** — cross-database catalog / search / detail / global / smart-search (`POST /g/mcp/yaohai/*`)
7
+ - **yaohai-*** — cross-database catalog / search / detail / facets / global (`POST /g/mcp/yaohai/*`; facets via GET)
8
8
  - **product-cn-*** — already-marketed China products (search/detail via MCP on db3; facets via GET)
9
9
  - **reg-cn-*** — CDE registration / review pipeline (search/detail via MCP on db3; facets via GET)
10
10
 
@@ -38,6 +38,20 @@ Obtain a token from DrugSea / Yaohai (user account settings), then:
38
38
  export YAOHAI_MCP_TOKEN=ysk_your_token_here
39
39
  ```
40
40
 
41
+ #### How to get a token from DrugSea (Yaohai)
42
+
43
+ 1. Open [https://db.drugsea.cn](https://db.drugsea.cn) in a browser.
44
+ 2. Log in with **WeChat QR scan** (微信扫码登录).
45
+ 3. Enter the **personal center** (个人中心).
46
+ 4. In the left sidebar, click **API Token**.
47
+ 5. Click **generate token** (生成 Token) and copy the result — it looks like `ysk_` + 32 hex characters.
48
+
49
+ Notes:
50
+
51
+ - Each account can generate up to **10 tokens**.
52
+ - A token inherits the **same database permissions as its Yaohai account** — if your account cannot see a database, the token cannot either. If a tool call returns a permission error, check your account's subscription/permissions on db.drugsea.cn, not the MCP client.
53
+ - Store the token in your MCP client's `env` (see below) or export it as `YAOHAI_MCP_TOKEN`. Never commit it to a repository.
54
+
41
55
  On db3, direct GET list routes may 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`.
42
56
 
43
57
  ### Optional
@@ -86,9 +100,9 @@ On db3, direct GET list routes may return encrypted payloads; this client auto-r
86
100
  |-------------|--------|
87
101
  | Already listed in China (国药准字, 批准文号, 上市, 医保/集采状态) | `product-cn-fields` → `product-cn-search` / `product-cn-facets` → `product-cn-detail` |
88
102
  | R&D / CDE (在研, 受理号, 审评, 尚未上市) | `reg-cn-fields` → `reg-cn-search` / `reg-cn-facets` → `reg-cn-detail` |
89
- | Other DBs (医保 `yibao`, 基药 `jiyao`, 集采 `jicai`, trials, DMF, …) | `yaohai-catalog` → `yaohai-search` → `yaohai-detail` |
103
+ | Other DBs (医保 `yibao`, 基药 `jiyao`, 集采 `jicai`, trials, DMF, …) | `yaohai-catalog` → `yaohai-search` / `yaohai-facets` → `yaohai-detail` |
90
104
  | Global panorama | `yaohai-global-search` |
91
- | Unclear which DB | `yaohai-smart-search` |
105
+ | Unclear which DB | `yaohai-catalog` (list DBs by keyword/category) → `yaohai-search`, or `yaohai-global-search` |
92
106
 
93
107
  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.
94
108
 
@@ -107,8 +121,17 @@ xlsx export is not implemented in this MCP (v1 returns JSON samples only).
107
121
  | `yaohai-catalog` | `category?`, `q?` | List databases |
108
122
  | `yaohai-search` | `dbname`, `query?`, `limit?`, `offset?` | Default limit 10, max 50 |
109
123
  | `yaohai-detail` | `dbname`, `id` | Skip DBs with `has_detail: false` |
124
+ | `yaohai-facets` | `dbname?`, `query?`, `fields?` | Facets for `dbs`-route DBs. Omit `fields` → discover facet-capable DBs/fields; pass `fields` → fetch buckets |
110
125
  | `yaohai-global-search` | `q?`, `query?`, `limit?`, `offset?` | `q` fills `query.term` |
111
- | `yaohai-smart-search` | `q`, `query?`, `limit?` | Auto-routes up to 3 DBs |
126
+
127
+ When the target database is unclear, use `yaohai-catalog` (filter by `category` / `q`) to pick a `dbname`, then `yaohai-search`; or use `yaohai-global-search` for a cross-database panorama query.
128
+
129
+ `yaohai-facets` mirrors the ConditionSearch facet filters of the website for the `dbs`-route databases (医保 `yibao`, 基药 `jiyao`, 集采 `jicai`, sales `drugsales`, …). Two modes:
130
+
131
+ - **Discovery** (no `fields`): omit `dbname` to list all 44 facet-capable databases, or pass `dbname` to list its facet-able fields (with `filter_type`).
132
+ - **Fetch** (`dbname` + `fields`): returns aggregation buckets (`value`/`count`) for the named terms fields, optionally narrowed by `query`. Only `terms`-type fields are exposed (44 DBs, 129 fields); date/range/tree filters are not faceted here.
133
+
134
+ For the two dedicated ES routes use `product-cn-facets` / `reg-cn-facets` instead — `yaohai-facets` returns an actionable hint if you pass `product_cn` / `reg_cn`.
112
135
 
113
136
  ### product_cn (marketed)
114
137
 
@@ -136,6 +159,138 @@ xlsx export is not implemented in this MCP (v1 returns JSON samples only).
136
159
 
137
160
  `query` values may be string, number, or string arrays (ConditionSearch `multiple`). Dates: `"YYYY-MM-DD to YYYY-MM-DD"`. Ranges: `"min to max"`.
138
161
 
162
+ ## Quick start for AI Agents (install, configure, test)
163
+
164
+ This section is a step-by-step playbook an AI agent (or a human) can follow to install, configure, and verify this MCP server end to end.
165
+
166
+ ### Prerequisites
167
+
168
+ - Node.js >= 18 (`node -v`)
169
+ - npm (`npm -v`)
170
+ - A DrugSea / Yaohai personal token (`ysk_` + 32 hex chars) — see [How to get a token from DrugSea (Yaohai)](#how-to-get-a-token-from-drugsea-yaohai)
171
+
172
+ ### Step 0 — Register the server with your MCP client
173
+
174
+ Add the server to your client config so it auto-starts. Example for Cursor (`~/.cursor/mcp.json`) — see [Configuration](#configuration) for other clients:
175
+
176
+ ```json
177
+ {
178
+ "mcpServers": {
179
+ "drugsea": {
180
+ "command": "npx",
181
+ "args": ["-y", "@kinginsun/mcp-drugsea"],
182
+ "env": {
183
+ "YAOHAI_MCP_TOKEN": "ysk_your_token_here"
184
+ }
185
+ }
186
+ }
187
+ }
188
+ ```
189
+
190
+ Then reload MCP servers in the client (Cursor: Settings → MCP → refresh). The client should list **13 tools**.
191
+
192
+ ### Step 1 — Install (optional, for local/CLI use)
193
+
194
+ Either run via npx on demand (no install needed), or install globally / from source:
195
+
196
+ ```bash
197
+ # Option A: run without installing (what the MCP configs above do)
198
+ npx -y @kinginsun/mcp-drugsea
199
+
200
+ # Option B: global install
201
+ npm install -g @kinginsun/mcp-drugsea
202
+ npm ls -g @kinginsun/mcp-drugsea
203
+
204
+ # Option C: from source (when developing)
205
+ git clone https://github.com/kinginsun/mcp-drugsea.git
206
+ cd mcp-drugsea
207
+ npm install
208
+ npm run build
209
+ ```
210
+
211
+ ### Step 2 — Configure the token
212
+
213
+ ```bash
214
+ export YAOHAI_MCP_TOKEN=ysk_your_token_here
215
+ ```
216
+
217
+ For MCP client usage, put the token in the client config `env` instead (Step 0). Sanity-check the format:
218
+
219
+ ```bash
220
+ node -e "console.log(/^ysk_[0-9a-f]{32}$/i.test(process.env.YAOHAI_MCP_TOKEN) ? 'token format OK' : 'token format BAD')"
221
+ ```
222
+
223
+ ### Step 3 — Smoke test over stdio (JSON-RPC)
224
+
225
+ The server speaks MCP over stdio. The recommended handshake sequence is `initialize` → `notifications/initialized` → request. Run this **outside** the package source directory (or use `node dist/index.js` inside it):
226
+
227
+ ```bash
228
+ printf '%s\n' \
229
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1.0"}}}' \
230
+ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
231
+ '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
232
+ | YAOHAI_MCP_TOKEN=$YAOHAI_MCP_TOKEN npx -y @kinginsun/mcp-drugsea \
233
+ | tail -1 | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const m=JSON.parse(d);console.log('tools:',m.result.tools.length)})"
234
+ ```
235
+
236
+ Expected: `tools: 12`.
237
+
238
+ One-liner variant without the handshake (also works with this server):
239
+
240
+ ```bash
241
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
242
+ | YAOHAI_MCP_TOKEN=ysk_your_token_here npx -y @kinginsun/mcp-drugsea
243
+ ```
244
+
245
+ ### Step 4 — Test real tool calls
246
+
247
+ ```bash
248
+ # Catalog lookup (no external DB data needed)
249
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"yaohai-catalog","arguments":{"q":"医保"}}}' \
250
+ | YAOHAI_MCP_TOKEN=ysk_your_token_here npx -y @kinginsun/mcp-drugsea
251
+
252
+ # China marketed products search
253
+ echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"product-cn-search","arguments":{"query":{"drug_name":"阿司匹林"},"limit":3}}}' \
254
+ | YAOHAI_MCP_TOKEN=ysk_your_token_here npx -y @kinginsun/mcp-drugsea
255
+
256
+ # Global panorama search
257
+ echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"yaohai-global-search","arguments":{"q":"阿司匹林","limit":3}}}' \
258
+ | YAOHAI_MCP_TOKEN=ysk_your_token_here npx -y @kinginsun/mcp-drugsea
259
+ ```
260
+
261
+ Expected: each response has `"isError":false` and non-empty `content`.
262
+
263
+ ### Step 5 — Full 12-tool suite (from source)
264
+
265
+ ```bash
266
+ git clone https://github.com/kinginsun/mcp-drugsea.git
267
+ cd mcp-drugsea
268
+ npm install && npm run build
269
+ YAOHAI_MCP_TOKEN=ysk_your_token_here node scripts/test-all-tools.mjs
270
+ ```
271
+
272
+ Expected final line: `--- Summary: 14 passed, 0 failed / 14 tool calls ---`.
273
+
274
+ ### Step 6 — Verify inside the MCP client
275
+
276
+ After reloading MCP servers in the client, ask the agent:
277
+
278
+ 1. "List the drugsea tools" → should see 13 tools.
279
+ 2. "Search 阿司匹林 in product-cn" → should return rows with `total > 0`.
280
+ 3. "Global search: PD-1" → should return panorama results without error.
281
+
282
+ ### Troubleshooting
283
+
284
+ | Symptom | Cause / fix |
285
+ |---------|-------------|
286
+ | `Set YAOHAI_MCP_TOKEN to your personal DrugSea token…` | Token env var missing/empty — set it (Step 2 / client `env`) |
287
+ | `YAOHAI_MCP_TOKEN must be a personal user token` | Token not `ysk_` + 32 hex — regenerate in personal center → API Token |
288
+ | `401` / `Unauthorized` (incl. backend's `invalid or missing X-Yaohai-Api-Key`) | Token expired or revoked — regenerate at db.drugsea.cn (personal center → API Token). The backend returns that `X-Yaohai-Api-Key` wording for **any** rejected credential; this client only ever sends `Authorization: Bearer`, so ignore the header name and replace the token. If you rotated the token, also `unset YAOHAI_MCP_TOKEN` — a stale exported value shadows the updated `.env`. |
289
+ | Permission/forbidden on a specific DB | Token inherits account permissions — check the account's subscription on db.drugsea.cn |
290
+ | `mcp-drugsea: command not found` when running npx | You are inside the package source dir — run from another directory or use `node dist/index.js` |
291
+ | Empty/encrypted payload from product/reg GET | Use default db3 base URL (auto MCP POST routing) or set `YAOHAI_USE_MCP_LIST=true` |
292
+ | TLS errors on some hosts | Set `YAOHAI_VERIFY_SSL=false` |
293
+
139
294
  ## Manual stdio test
140
295
 
141
296
  ```bash
@@ -153,6 +308,19 @@ echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"product-cn
153
308
  | YAOHAI_MCP_TOKEN=ysk_your_token_here node dist/index.js
154
309
  ```
155
310
 
311
+ ## Releasing (maintainers)
312
+
313
+ `publish.sh` releases the package to npm (which is what makes `npx -y @kinginsun/mcp-drugsea` work). It syncs `src/index.ts`'s `PACKAGE_VERSION` with `package.json`, builds clean, audits the tarball for leaked tokens, runs the 12-tool suite, commits + tags, then publishes and pushes.
314
+
315
+ ```bash
316
+ npm login # once, with rights on the @kinginsun scope
317
+ ./publish.sh --dry-run # full rehearsal, no side effects
318
+ ./publish.sh --minor # real release (0.2.1 → 0.3.0)
319
+ ./publish.sh --help # all flags (--major, --version, --otp, --skip-tests, --note, --no-push)
320
+ ```
321
+
322
+ The suite needs a **live** token: `publish.sh` probes the API first and, on rejection, reports it as a credential problem rather than a code regression. If you rotated `YAOHAI_MCP_TOKEN`, run `unset YAOHAI_MCP_TOKEN` first so the new `.env` value isn't shadowed by a stale exported one.
323
+
156
324
  ## Requirements
157
325
 
158
326
  - Node.js >= 18.0.0
package/dist/api.js CHANGED
@@ -156,6 +156,23 @@ function parseApiJson(status, text) {
156
156
  };
157
157
  }
158
158
  if (status < 200 || status >= 300) {
159
+ // The DrugSea gateway answers every rejected credential with a generic
160
+ // "invalid or missing X-Yaohai-Api-Key" string, even though this client
161
+ // only ever sends `Authorization: Bearer <YAOHAI_MCP_TOKEN>`. Translate
162
+ // that misleading 401 into actionable advice instead of leaking the
163
+ // backend's wording.
164
+ if (status === 401 || status === 403) {
165
+ return {
166
+ ok: false,
167
+ status,
168
+ error: `YAOHAI_MCP_TOKEN was rejected by the DrugSea API (HTTP ${status}). ` +
169
+ "The token is missing, expired, or revoked — regenerate it at " +
170
+ "db.drugsea.cn (personal center → API Token) and update YAOHAI_MCP_TOKEN. " +
171
+ "This client authenticates with `Authorization: Bearer` only; the backend's " +
172
+ "'X-Yaohai-Api-Key' wording is a generic message and does not apply here.",
173
+ raw: data,
174
+ };
175
+ }
159
176
  return {
160
177
  ok: false,
161
178
  status,
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Facet (条件筛选) catalog for dbs-route databases — GENERATED FILE.
3
+ *
4
+ * Source of truth: drugsea frontend
5
+ * frontend/src/routes/more/DBS/components/commonSearch/ConditionSearchPanel.js
6
+ * which hardcodes the per-db condition filters the web UI renders.
7
+ *
8
+ * Regenerate (do not hand-edit):
9
+ * node scripts/extract-dbs-facets.mjs --emit-ts
10
+ *
11
+ * Scope: `terms` fields only — the ones that return aggregated bucket lists
12
+ * (`content.list[]` with `ct` counts). `date`/`range`/`tree` fields are UI
13
+ * pickers and are excluded, which also drops the two 器械备案 dbs entirely.
14
+ *
15
+ * Note on prefixes: taken verbatim from the frontend URL. They currently equal
16
+ * the catalog `api_path` for all 44 dbs, but the frontend is
17
+ * authoritative — `reg_cn` already demonstrates that a facet prefix can diverge
18
+ * from api_path.
19
+ */
20
+ export const DBS_FACET_CATALOG = {
21
+ cde_yfb_registration: {
22
+ title: "原料药、药用辅料和药包材登记信息公示",
23
+ category: "注册情报",
24
+ prefix: "/c/cde_yfb_registration/eslist",
25
+ fields: {
26
+ reg_num_year: { title: "批准年份", filter_type: "multiple" },
27
+ local_or_import: { title: "产品来源", filter_type: "multiple" },
28
+ yfb_type: { title: "原辅包类型", filter_type: "multiple" },
29
+ },
30
+ },
31
+ cmchk_pcm: {
32
+ title: "香港注册中成药",
33
+ category: "上市情报",
34
+ prefix: "/cmchk_pcm/eslist",
35
+ fields: {
36
+ formula_type: { title: "注册类型", filter_type: "multiple" },
37
+ package_label_cn: { title: "药材组成", filter_type: "multiple" },
38
+ pmpw_flag: { title: "PMPW标记", filter_type: "multiple" },
39
+ dosage_form_cn: { title: "剂型", filter_type: "multiple" },
40
+ is_export: { title: "是否出口", filter_type: "multiple" },
41
+ is_coexist_cp: { title: "是否药典收录", filter_type: "multiple" },
42
+ },
43
+ },
44
+ cn_orange_book: {
45
+ title: "中国上市化学药品目录集",
46
+ category: "药政参考",
47
+ prefix: "/c/cn_orange_book/eslist",
48
+ fields: {
49
+ dosage_form: { title: "剂型", filter_type: "multiple" },
50
+ administration_route: { title: "给药途径", filter_type: "multiple" },
51
+ is_reference_drug: { title: "参比制剂", filter_type: "multiple" },
52
+ is_standard_drug: { title: "标准制剂", filter_type: "multiple" },
53
+ category: { title: "收录类别", filter_type: "multiple" },
54
+ marketing_status: { title: "上市销售状态", filter_type: "multiple" },
55
+ },
56
+ },
57
+ cn_reference_drugs: {
58
+ title: "仿制药参比制剂目录",
59
+ category: "药政参考",
60
+ prefix: "/c/cn_reference_drugs/eslist",
61
+ fields: {
62
+ dosage_form: { title: "药品剂型", filter_type: "multiple" },
63
+ batch_no: { title: "公布批次", filter_type: "multiple" },
64
+ },
65
+ },
66
+ cn_reference_drugs_publicity: {
67
+ title: "仿制药参比制剂目录(征求意见稿)",
68
+ category: "药政参考",
69
+ prefix: "/c/cn_reference_drugs_publicity/eslist",
70
+ fields: {
71
+ publicity_type: { title: "公示类型", filter_type: "multiple" },
72
+ dosage_form: { title: "药品剂型", filter_type: "multiple" },
73
+ batch_no: { title: "公布批次", filter_type: "multiple" },
74
+ },
75
+ },
76
+ dpd: {
77
+ title: "加拿大上市药品",
78
+ category: "上市情报",
79
+ prefix: "/dpd/eslist",
80
+ fields: {
81
+ dosage_form: { title: "剂型", filter_type: "multiple" },
82
+ current_status: { title: "最新状态", filter_type: "multiple" },
83
+ route_of_administration: { title: "给药途径", filter_type: "multiple" },
84
+ class: { title: "适用对象", filter_type: "multiple" },
85
+ schedule: { title: "药品类别", filter_type: "multiple" },
86
+ has_sms: { title: "是否有说明书", filter_type: "multiple" },
87
+ },
88
+ },
89
+ drug_law: {
90
+ title: "药品法规知识库",
91
+ category: "药闻速递",
92
+ prefix: "/h/drug_law/eslist",
93
+ fields: {
94
+ source: { title: "法规来源", filter_type: "multiple" },
95
+ main_category: { title: "一级分类", filter_type: "multiple" },
96
+ category: { title: "公告栏目", filter_type: "multiple" },
97
+ },
98
+ },
99
+ drugsales: {
100
+ title: "全终端药品销售",
101
+ category: "市场情报",
102
+ prefix: "/drugsales/eslist",
103
+ defaultQuery: { "groupid": "205" },
104
+ fields: {
105
+ region: { title: "区域", filter_type: "multiple" },
106
+ country: { title: "国家", filter_type: "multiple" },
107
+ dosage_form: { title: "剂型", filter_type: "multiple" },
108
+ year: { title: "年份", filter_type: "multiple" },
109
+ sales_channel: { title: "销售渠道", filter_type: "multiple" },
110
+ drug_type: { title: "药品类型", filter_type: "multiple" },
111
+ administration_route: { title: "给药途径", filter_type: "multiple" },
112
+ },
113
+ },
114
+ fda_dmf: {
115
+ title: "美国DMF数据库",
116
+ category: "上市情报",
117
+ prefix: "/fda_dmf/eslist",
118
+ fields: {
119
+ status: { title: "DMF状态", filter_type: "multiple" },
120
+ type: { title: "DMF类型", filter_type: "multiple" },
121
+ },
122
+ },
123
+ fda_ndc: {
124
+ title: "美国药品NDC数据库",
125
+ category: "上市情报",
126
+ prefix: "/fda_ndc/eslist",
127
+ fields: {
128
+ has_sms: { title: "是否有说明书", filter_type: "multiple" },
129
+ MARKETINGCATEGORYNAME: { title: "市场分类", filter_type: "multiple" },
130
+ PRODUCTTYPENAME: { title: "产品类型", filter_type: "multiple" },
131
+ DOSAGEFORMNAME: { title: "药品剂型", filter_type: "multiple" },
132
+ ROUTENAME: { title: "给药途径", filter_type: "multiple" },
133
+ NDC_EXCLUDE_FLAG: { title: "NDC排除标记", filter_type: "multiple" },
134
+ PHARM_CLASSES: { title: "药理分类", filter_type: "multiple" },
135
+ },
136
+ },
137
+ gov_drugs: {
138
+ title: "政府用药目录",
139
+ category: "药政参考",
140
+ prefix: "/c/gov_drugs/eslist",
141
+ fields: {
142
+ province: { title: "省份", filter_type: "multiple" },
143
+ drug_type: { title: "药品类型", filter_type: "multiple" },
144
+ },
145
+ },
146
+ herb_formulas: {
147
+ title: "中药方剂数据库",
148
+ category: "行业参考",
149
+ prefix: "/herb_formulas/eslist",
150
+ fields: {
151
+ herbs: { title: "组成药材", filter_type: "multiple" },
152
+ },
153
+ },
154
+ herbs: {
155
+ title: "中药材数据库",
156
+ category: "行业参考",
157
+ prefix: "/herbs/eslist",
158
+ fields: {
159
+ efficacy_class: { title: "功效分类", filter_type: "multiple" },
160
+ family_classification: { title: "科属分类", filter_type: "multiple" },
161
+ },
162
+ },
163
+ hk_doh: {
164
+ title: "香港上市药品",
165
+ category: "上市情报",
166
+ prefix: "/hk_doh/eslist",
167
+ fields: {
168
+ legal_classification: { title: "法律分类", filter_type: "multiple" },
169
+ sale_requirement: { title: "销售要求", filter_type: "multiple" },
170
+ },
171
+ },
172
+ hma: {
173
+ title: "欧盟HMA上市药品",
174
+ category: "上市情报",
175
+ prefix: "/hma/eslist",
176
+ fields: {
177
+ Ph_form: { title: "药品剂型", filter_type: "multiple" },
178
+ RMS: { title: "参考成员国", filter_type: "multiple" },
179
+ product_outcome: { title: "市场状态", filter_type: "multiple" },
180
+ },
181
+ },
182
+ isaf_drugs: {
183
+ title: "澳门上市药品",
184
+ category: "上市情报",
185
+ prefix: "/isaf_drugs/eslist",
186
+ fields: {
187
+ dosage_form: { title: "剂型", filter_type: "multiple" },
188
+ administration_route: { title: "给药途径", filter_type: "multiple" },
189
+ ingredients: { title: "活性成分", filter_type: "multiple" },
190
+ type: { title: "法定类别", filter_type: "multiple" },
191
+ },
192
+ },
193
+ isaf_tcm: {
194
+ title: "澳门中成药与天然药物",
195
+ category: "上市情报",
196
+ prefix: "/isaf_tcm/eslist",
197
+ fields: {
198
+ dosage_form: { title: "剂型", filter_type: "multiple" },
199
+ administration_route: { title: "给药途径", filter_type: "multiple" },
200
+ formula: { title: "配方", filter_type: "multiple" },
201
+ type: { title: "法定类别", filter_type: "multiple" },
202
+ },
203
+ },
204
+ japan_dmf: {
205
+ title: "日本DMF数据库",
206
+ category: "上市情报",
207
+ prefix: "/japan_dmf/eslist",
208
+ fields: {
209
+ registration_type_cn: { title: "注册类型", filter_type: "multiple" },
210
+ },
211
+ },
212
+ jicai: {
213
+ title: "国家与地方集采数据库",
214
+ category: "市场情报",
215
+ prefix: "/jicai/eslist",
216
+ fields: {
217
+ jc_type: { title: "集采类型", filter_type: "multiple" },
218
+ jc_project: { title: "集采项目", filter_type: "multiple" },
219
+ region: { title: "中选区域", filter_type: "multiple" },
220
+ execution_status: { title: "执行状态", filter_type: "multiple" },
221
+ },
222
+ },
223
+ jicai_mulu: {
224
+ title: "jicai_mulu",
225
+ category: "其他",
226
+ prefix: "/jicai_mulu/eslist",
227
+ fields: {
228
+ jc_type: { title: "集采类型", filter_type: "multiple" },
229
+ jc_project: { title: "集采项目", filter_type: "multiple" },
230
+ },
231
+ },
232
+ jiyao: {
233
+ title: "基药目录",
234
+ category: "市场准入",
235
+ prefix: "/jiyao/eslist",
236
+ fields: {
237
+ province: { title: "地区", filter_type: "multiple" },
238
+ dosage_form: { title: "剂型", filter_type: "multiple" },
239
+ drug_type: { title: "药品类型", filter_type: "multiple" },
240
+ std_catalog_version: { title: "基药版本", filter_type: "multiple" },
241
+ },
242
+ },
243
+ medical_device: {
244
+ title: "国产器械(注册)",
245
+ category: "NMPA基础库",
246
+ prefix: "/medical_device/eslist",
247
+ fields: {
248
+ std_product_type: { title: "管理类别", filter_type: "multiple" },
249
+ },
250
+ },
251
+ medical_device_jinkou: {
252
+ title: "进口器械(注册)",
253
+ category: "NMPA基础库",
254
+ prefix: "/medical_device_jinkou/eslist",
255
+ fields: {
256
+ std_product_type: { title: "管理类别", filter_type: "multiple" },
257
+ },
258
+ },
259
+ nhsa_code: {
260
+ title: "医保药品分类与代码",
261
+ category: "市场准入",
262
+ prefix: "/c/nhsa_code/eslist",
263
+ fields: {
264
+ insurance_type: { title: "医保类型", filter_type: "multiple" },
265
+ registered_dosage_form: { title: "注册剂型", filter_type: "multiple" },
266
+ min_preparation_unit: { title: "最小制剂单位", filter_type: "multiple" },
267
+ min_package_unit: { title: "最小包装单位", filter_type: "multiple" },
268
+ },
269
+ },
270
+ nhsa_herbs: {
271
+ title: "中药饮片信息",
272
+ category: "市场准入",
273
+ prefix: "/c/nhsa_herbs/eslist",
274
+ fields: {
275
+ efficacy_classification: { title: "功效分类", filter_type: "multiple" },
276
+ region_name: { title: "地区", filter_type: "multiple" },
277
+ },
278
+ },
279
+ nhsa_hospital_prepration: {
280
+ title: "医疗机构制剂信息",
281
+ category: "市场准入",
282
+ prefix: "/c/nhsa_hospital_prepration/eslist",
283
+ fields: {
284
+ preparation_type: { title: "制剂类别", filter_type: "multiple" },
285
+ region: { title: "地区", filter_type: "multiple" },
286
+ },
287
+ },
288
+ nmpa_buchongbeian: {
289
+ title: "境内生产药品备案信息公示",
290
+ category: "NMPA基础库",
291
+ prefix: "/nmpa_buchongbeian/eslist",
292
+ fields: {
293
+ dosage_form: { title: "剂型", filter_type: "multiple" },
294
+ drug_type: { title: "药品类型", filter_type: "multiple" },
295
+ record_office: { title: "备案机关", filter_type: "multiple" },
296
+ },
297
+ },
298
+ nmpa_gmp: {
299
+ title: "GMP认证",
300
+ category: "NMPA基础库",
301
+ prefix: "/nmpa_gmp/eslist",
302
+ fields: {
303
+ std_province: { title: "省市", filter_type: "multiple" },
304
+ std_gmp_status: { title: "证书状态", filter_type: "multiple" },
305
+ in_sfda: { title: "是否有效", filter_type: "multiple" },
306
+ },
307
+ },
308
+ nmpa_guochan: {
309
+ title: "国产药品",
310
+ category: "NMPA基础库",
311
+ prefix: "/f/nmpa_guochan/eslist",
312
+ fields: {
313
+ dosage_form: { title: "药品剂型", filter_type: "multiple" },
314
+ drug_type: { title: "产品类别", filter_type: "multiple" },
315
+ in_sfda: { title: "是否有效", filter_type: "multiple" },
316
+ },
317
+ },
318
+ nmpa_jinkou: {
319
+ title: "进口药品",
320
+ category: "NMPA基础库",
321
+ prefix: "/f/nmpa_jinkou/eslist",
322
+ fields: {
323
+ dosage_form: { title: "药品剂型", filter_type: "multiple" },
324
+ drug_type: { title: "产品类别", filter_type: "multiple" },
325
+ in_sfda: { title: "是否有效", filter_type: "multiple" },
326
+ },
327
+ },
328
+ nmpa_reg_patent: {
329
+ title: "药品注册相关专利信息",
330
+ category: "NMPA基础库",
331
+ prefix: "/f/nmpa_reg_patent/eslist",
332
+ fields: {
333
+ patent_type: { title: "专利类型", filter_type: "multiple" },
334
+ },
335
+ },
336
+ nmpa_tcm_granules: {
337
+ title: "中药配方颗粒备案信息",
338
+ category: "NMPA基础库",
339
+ prefix: "/f/nmpa_tcm_granules/eslist",
340
+ fields: {
341
+ filing_status: { title: "备案状态", filter_type: "multiple" },
342
+ record_province: { title: "备案省局", filter_type: "multiple" },
343
+ },
344
+ },
345
+ nmpa_tcm_protection: {
346
+ title: "中药保护品种",
347
+ category: "NMPA基础库",
348
+ prefix: "/nmpa_tcm_protection/eslist",
349
+ fields: {
350
+ dosage_form: { title: "剂型", filter_type: "multiple" },
351
+ protect_period: { title: "保护期限", filter_type: "multiple" },
352
+ },
353
+ },
354
+ nmpa_tsspxx_gc: {
355
+ title: "国产保健食品注册",
356
+ category: "NMPA基础库",
357
+ prefix: "/f/nmpa_tsspxx_gc/eslist",
358
+ fields: {
359
+ in_sfda: { title: "是否有效", filter_type: "multiple" },
360
+ },
361
+ },
362
+ nmpa_tsspxx_jk: {
363
+ title: "进口保健食品注册",
364
+ category: "NMPA基础库",
365
+ prefix: "/f/nmpa_tsspxx_jk/eslist",
366
+ fields: {
367
+ in_sfda: { title: "是否有效", filter_type: "multiple" },
368
+ },
369
+ },
370
+ se_notice: {
371
+ title: "医药上市公司公告",
372
+ category: "药闻速递",
373
+ prefix: "/h/se_notice/eslist",
374
+ fields: {
375
+ se: { title: "公告来源", filter_type: "multiple" },
376
+ is_transferred_to_references: { title: "文献标记", filter_type: "multiple" },
377
+ },
378
+ },
379
+ targets: {
380
+ title: "药物靶点数据库",
381
+ category: "行业参考",
382
+ prefix: "/targets/eslist",
383
+ fields: {
384
+ target_type: { title: "靶点类型", filter_type: "multiple" },
385
+ kind: { title: "种类", filter_type: "multiple" },
386
+ organism: { title: "生物体", filter_type: "multiple" },
387
+ },
388
+ },
389
+ tw_fda: {
390
+ title: "台湾上市药品",
391
+ category: "上市情报",
392
+ prefix: "/tw_fda/eslist",
393
+ fields: {
394
+ mixture: { title: "单复方", filter_type: "multiple" },
395
+ drug_clasify_code: { title: "药品分类", filter_type: "multiple" },
396
+ lblLicknd: { title: "许可证种类", filter_type: "multiple" },
397
+ },
398
+ },
399
+ uk_emc: {
400
+ title: "英国上市药品",
401
+ category: "上市情报",
402
+ prefix: "/uk_emc/eslist",
403
+ fields: {
404
+ drug_type: { title: "药品类型", filter_type: "multiple" },
405
+ legal_category: { title: "法律类别", filter_type: "multiple" },
406
+ ATC_code: { title: "治疗领域", filter_type: "multiple" },
407
+ },
408
+ },
409
+ yibao: {
410
+ title: "医保目录",
411
+ category: "市场准入",
412
+ prefix: "/yibao/eslist",
413
+ fields: {
414
+ province: { title: "医保地区", filter_type: "multiple" },
415
+ drug_type: { title: "药品类型", filter_type: "multiple" },
416
+ insurance_level: { title: "医保类型", filter_type: "multiple" },
417
+ std_catalog_version: { title: "医保版本", filter_type: "multiple" },
418
+ },
419
+ },
420
+ yzpj_products: {
421
+ title: "一致性评价产品",
422
+ category: "注册情报",
423
+ prefix: "/b/yzpj_products/list",
424
+ fields: {
425
+ latest_status: { title: "最高进展", filter_type: "multiple" },
426
+ },
427
+ },
428
+ zb_news: {
429
+ title: "全国招标动态",
430
+ category: "药闻速递",
431
+ prefix: "/c/zb_news/eslist",
432
+ fields: {
433
+ city: { title: "省份", filter_type: "multiple" },
434
+ website: { title: "网站", filter_type: "multiple" },
435
+ },
436
+ },
437
+ zldj: {
438
+ title: "药品专利信息公示",
439
+ category: "行业参考",
440
+ prefix: "/c/zldj/eslist",
441
+ fields: {
442
+ registration_status: { title: "登记状态", filter_type: "multiple" },
443
+ form_type: { title: "登记表类型", filter_type: "multiple" },
444
+ is_public: { title: "专利信息公开", filter_type: "multiple" },
445
+ drug_type: { title: "药品类型", filter_type: "multiple" },
446
+ dosage_form: { title: "药品剂型", filter_type: "multiple" },
447
+ },
448
+ },
449
+ zlsm: {
450
+ title: "药品专利声明",
451
+ category: "行业参考",
452
+ prefix: "/c/zlsm/eslist",
453
+ fields: {
454
+ drug_type: { title: "药品类型", filter_type: "multiple" },
455
+ dosage_form: { title: "药品剂型", filter_type: "multiple" },
456
+ register_type: { title: "注册分类", filter_type: "multiple" },
457
+ },
458
+ },
459
+ };
460
+ /** Databases that expose facets, sorted. Handy for validation and tool docs. */
461
+ export const DBS_FACET_DBNAME_LIST = Object.keys(DBS_FACET_CATALOG).sort();
package/dist/index.js CHANGED
@@ -2,10 +2,11 @@
2
2
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
- import { ApiError, clampLimit, clampOffset, fetchDetail, fetchFacets, listSearch, mcpDbDetail, mcpDbSearch, prefersMcpListApi, yaohaiPost, } from "./api.js";
5
+ import { clampLimit, clampOffset, fetchDetail, fetchFacets, listSearch, mcpDbDetail, mcpDbSearch, prefersMcpListApi, yaohaiPost, } from "./api.js";
6
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.2.0";
7
+ import { EmptyObjectSchema, ProductCnDetailSchema, ProductCnFacetsSchema, ProductCnSearchSchema, RegCnDetailSchema, RegCnFacetsSchema, RegCnSearchSchema, YaohaiCatalogSchema, YaohaiDetailSchema, YaohaiFacetsSchema, YaohaiGlobalSearchSchema, YaohaiSearchSchema, } from "./types.js";
8
+ import { DBS_FACET_CATALOG } from "./dbs-facets.js";
9
+ const PACKAGE_VERSION = "0.4.0";
9
10
  const YAOHAI_LIMIT_MAX = 50;
10
11
  const YAOHAI_LIMIT_DEFAULT = 10;
11
12
  const CN_LIMIT_MAX = 100;
@@ -17,6 +18,17 @@ const QUERY_PROP = {
17
18
  };
18
19
  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
20
  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.";
21
+ /**
22
+ * Derived from the generated facet catalog so the tool descriptions can never
23
+ * drift from the data they describe.
24
+ */
25
+ const DBS_FACET_DBS = Object.keys(DBS_FACET_CATALOG).sort();
26
+ const DBS_FACET_DB_COUNT = DBS_FACET_DBS.length;
27
+ const DBS_FACET_FIELD_COUNT = DBS_FACET_DBS.reduce((n, db) => n + Object.keys(DBS_FACET_CATALOG[db].fields).length, 0);
28
+ /** A few well-known dbs, used to hint coverage without listing all 44. */
29
+ const DBS_FACET_EXAMPLES = ["yibao", "jiyao", "jicai", "dpd", "fda_ndc", "uk_emc", "nmpa_gmp"]
30
+ .filter((db) => db in DBS_FACET_CATALOG)
31
+ .join(", ");
20
32
  process.on("uncaughtException", (error) => {
21
33
  console.error("Uncaught Exception:", error);
22
34
  process.exit(1);
@@ -92,38 +104,44 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
92
104
  },
93
105
  },
94
106
  {
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,
107
+ name: "yaohai-facets",
108
+ description: "Facet distributions (条件筛选) for a dbs database — aggregated value+count buckets for filterable fields. " +
109
+ `Works for ${DBS_FACET_DB_COUNT} dbs reached via yaohai-search (${DBS_FACET_EXAMPLES}, …). ` +
110
+ `DISCOVERY MODE: omit \`fields\` to list available facet fields (add \`dbname\` for one db, omit it for all ${DBS_FACET_DB_COUNT}). ` +
111
+ "FETCH MODE: pass `fields` (required with `dbname`) to get buckets for those fields. One HTTP request fires per field, so request only the 2–4 you need. " +
112
+ "Pass the same `query` filters you used in yaohai-search to facet within that result set. " +
113
+ "Not available for product_cn / reg_cn — use product-cn-facets / reg-cn-facets instead.",
98
114
  inputSchema: {
99
115
  type: "object",
100
116
  properties: {
101
- q: { type: "string", description: "Search term (maps to query.term)" },
117
+ dbname: {
118
+ type: "string",
119
+ description: "Database id from yaohai-catalog, e.g. yibao, jiyao, dpd, uk_emc. Required when `fields` is given. Omit to list all facet-capable databases.",
120
+ },
102
121
  query: QUERY_PROP,
103
- limit: {
104
- type: "number",
105
- description: `Row cap (default ${YAOHAI_LIMIT_DEFAULT}, max ${YAOHAI_LIMIT_MAX})`,
122
+ fields: {
123
+ type: "array",
124
+ items: { type: "string" },
125
+ description: "Facet field keys (see discovery mode). Omit to get the catalog instead of buckets. Example for yibao: [\"province\", \"drug_type\"].",
106
126
  },
107
- offset: { type: "number", description: "Pagination offset (default 0)" },
108
127
  },
109
128
  },
110
129
  },
111
130
  {
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
- "Falls back to global search when the router matches nothing or finds no rows. " +
115
- ROUTING_HINT,
131
+ name: "yaohai-global-search",
132
+ description: "Global drug panorama search (global_search). Pass q as the search term, or query.term. " +
133
+ PRESENTATION_HINT,
116
134
  inputSchema: {
117
135
  type: "object",
118
136
  properties: {
119
- q: { type: "string", description: "Natural language question" },
137
+ q: { type: "string", description: "Search term (maps to query.term)" },
120
138
  query: QUERY_PROP,
121
139
  limit: {
122
140
  type: "number",
123
141
  description: `Row cap (default ${YAOHAI_LIMIT_DEFAULT}, max ${YAOHAI_LIMIT_MAX})`,
124
142
  },
143
+ offset: { type: "number", description: "Pagination offset (default 0)" },
125
144
  },
126
- required: ["q"],
127
145
  },
128
146
  },
129
147
  {
@@ -260,168 +278,25 @@ function encodeId(id) {
260
278
  function asQuery(query) {
261
279
  return { ...(query ?? {}) };
262
280
  }
263
- async function globalSearchContent(q, query, limit) {
264
- const gquery = asQuery(query);
265
- if (q && (gquery.term === undefined || gquery.term === "")) {
266
- gquery.term = q;
267
- }
268
- const content = await yaohaiPost("/g/mcp/yaohai/global-search", {
269
- query: gquery,
270
- limit,
271
- offset: 0,
272
- });
273
- return content;
274
- }
275
- function allSmartResultsEmpty(content) {
276
- if (!content || typeof content !== "object") {
277
- return true;
278
- }
279
- const results = content.results;
280
- if (!Array.isArray(results) || results.length === 0) {
281
- return true;
282
- }
283
- return results.every((entry) => {
284
- const result = entry?.result;
285
- if (!result || typeof result !== "object") {
286
- return true;
287
- }
288
- const total = result.total;
289
- return typeof total === "number" ? total === 0 : true;
290
- });
291
- }
292
281
  /**
293
- * The backend router searches the entire question string in one field per DB,
294
- * which rarely matches (e.g. item="医保目录 阿司匹林" → 0 rows), and sometimes
295
- * picks a field the DB does not support (e.g. `item` on jicai). Retry each
296
- * empty matched DB with the individual tokens of the question across the
297
- * router's field plus the catalog's search_fields for that DB.
282
+ * Structured "not facet-capable" response.
283
+ *
284
+ * Returned as a normal (non-error) payload so an agent can self-correct in one
285
+ * step instead of having to parse an exception message. Covers the common cases:
286
+ * a dbname that is not in the catalog at all, and one that exists but is not a
287
+ * dbs-route database (product_cn / reg_cn have dedicated facet tools; the other
288
+ * custom/aggs routes expose no condition filters).
298
289
  */
299
- async function retrySmartResultsWithTokens(content, q, limit) {
300
- const results = content.results;
301
- if (!Array.isArray(results)) {
302
- return null;
303
- }
304
- const tokens = q.split(/\s+/).filter((t) => t && t !== q);
305
- if (tokens.length === 0) {
306
- return null;
307
- }
308
- const catalogFields = new Map();
309
- async function searchFieldsFor(dbname) {
310
- if (catalogFields.has(dbname)) {
311
- return catalogFields.get(dbname);
312
- }
313
- let keys = [];
314
- try {
315
- const cat = (await yaohaiPost("/g/mcp/yaohai/catalog", { q: dbname }));
316
- const db = (cat.databases ?? []).find((d) => d.id === dbname);
317
- keys = (db?.search_fields ?? [])
318
- .map((f) => f.key)
319
- .filter((k) => typeof k === "string");
320
- }
321
- catch {
322
- keys = [];
323
- }
324
- catalogFields.set(dbname, keys);
325
- return keys;
326
- }
327
- let changed = false;
328
- const patched = [];
329
- for (const entry of results) {
330
- const inner = entry?.result;
331
- if (!inner || typeof inner !== "object") {
332
- patched.push(entry);
333
- continue;
334
- }
335
- const r = inner;
336
- if (r.total !== 0 || typeof r.dbname !== "string") {
337
- patched.push(entry);
338
- continue;
339
- }
340
- const queryApplied = r.query_applied;
341
- const appliedKeys = queryApplied ? Object.keys(queryApplied) : [];
342
- const singleFieldWholeQuestion = appliedKeys.length === 1 && queryApplied[appliedKeys[0]] === q;
343
- if (!singleFieldWholeQuestion) {
344
- patched.push(entry);
345
- continue;
346
- }
347
- const routerField = appliedKeys[0];
348
- const fields = [routerField, ...(await searchFieldsFor(r.dbname))];
349
- const uniqueFields = [...new Set(fields)].slice(0, 6);
350
- let replaced = false;
351
- outer: for (const token of tokens.slice(0, 3)) {
352
- for (const field of uniqueFields) {
353
- try {
354
- const retry = (await yaohaiPost("/g/mcp/yaohai/search", {
355
- dbname: r.dbname,
356
- query: { [field]: token },
357
- limit,
358
- offset: 0,
359
- }));
360
- if (typeof retry.total === "number" && retry.total > 0) {
361
- patched.push({
362
- ...entry,
363
- result: { ...retry, retry: { token, field } },
364
- });
365
- changed = true;
366
- replaced = true;
367
- break outer;
368
- }
369
- }
370
- catch {
371
- // ignore per-token retry errors; try next field/token
372
- }
373
- }
374
- }
375
- if (!replaced) {
376
- patched.push(entry);
377
- }
378
- }
379
- if (!changed) {
380
- return null;
381
- }
382
- return { ...content, results: patched };
383
- }
384
- /**
385
- * Last resort when the router matched databases but every search came back
386
- * empty (e.g. the question is just a DB keyword like 集采): browse the matched
387
- * databases without a query so the caller still sees representative rows.
388
- */
389
- async function browseMatchedDatabases(content, limit) {
390
- const matched = content.matched_databases;
391
- if (!Array.isArray(matched) || matched.length === 0) {
392
- return null;
393
- }
394
- const results = [];
395
- for (const db of matched.slice(0, 3)) {
396
- const id = db?.id;
397
- if (typeof id !== "string") {
398
- continue;
399
- }
400
- try {
401
- const browse = (await yaohaiPost("/g/mcp/yaohai/search", {
402
- dbname: id,
403
- query: {},
404
- limit,
405
- offset: 0,
406
- }));
407
- if (typeof browse.total === "number" && browse.total > 0) {
408
- results.push({
409
- score: db.score,
410
- result: { ...browse, browse: true },
411
- });
412
- }
413
- }
414
- catch {
415
- // DB not browsable — skip
416
- }
417
- }
418
- if (results.length === 0) {
419
- return null;
420
- }
290
+ function facetDbUnknown(dbname) {
291
+ const near = DBS_FACET_DBS.filter((db) => db.includes(dbname) || dbname.includes(db)).slice(0, 5);
421
292
  return {
422
- ...content,
423
- results,
424
- note: "Smart-search found no rows for the full question; showing sample rows from the matched databases instead.",
293
+ dbname,
294
+ supported: false,
295
+ error: `No facet fields known for dbname "${dbname}".`,
296
+ hint: "Only dbs-route databases support yaohai-facets. " +
297
+ "For product_cn use product-cn-facets; for reg_cn use reg-cn-facets. " +
298
+ `Call yaohai-facets with no arguments to list all ${DBS_FACET_DB_COUNT} facet-capable databases.`,
299
+ ...(near.length > 0 ? { did_you_mean: near } : {}),
425
300
  };
426
301
  }
427
302
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -456,6 +331,71 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
456
331
  });
457
332
  return ok(content);
458
333
  }
334
+ case "yaohai-facets": {
335
+ const validated = YaohaiFacetsSchema.parse(args ?? {});
336
+ // Discovery mode: no `fields` -> describe what can be facetted.
337
+ // Without this, agents would have to guess field names and would hit
338
+ // "Unknown facet fields" from fetchFacets.
339
+ if (!validated.fields) {
340
+ if (validated.dbname) {
341
+ const entry = DBS_FACET_CATALOG[validated.dbname];
342
+ if (!entry) {
343
+ return ok(facetDbUnknown(validated.dbname));
344
+ }
345
+ return ok({
346
+ dbname: validated.dbname,
347
+ title: entry.title,
348
+ category: entry.category,
349
+ facet_prefix: entry.prefix,
350
+ facet_count: Object.keys(entry.fields).length,
351
+ facets: entry.fields,
352
+ });
353
+ }
354
+ return ok({
355
+ facet_capable_databases: DBS_FACET_DB_COUNT,
356
+ total_facet_fields: DBS_FACET_FIELD_COUNT,
357
+ note: "Call yaohai-facets with a dbname to list its fields, or pass dbname + fields to fetch buckets. " +
358
+ "product_cn and reg_cn use product-cn-facets / reg-cn-facets instead.",
359
+ databases: DBS_FACET_DBS.map((db) => ({
360
+ dbname: db,
361
+ title: DBS_FACET_CATALOG[db].title,
362
+ category: DBS_FACET_CATALOG[db].category,
363
+ facet_count: Object.keys(DBS_FACET_CATALOG[db].fields).length,
364
+ fields: Object.keys(DBS_FACET_CATALOG[db].fields),
365
+ })),
366
+ });
367
+ }
368
+ // Fetch mode: both dbname and fields are required.
369
+ if (!validated.dbname) {
370
+ throw new Error("dbname is required when fetching facets. Omit `fields` to list facet-capable databases.");
371
+ }
372
+ const entry = DBS_FACET_CATALOG[validated.dbname];
373
+ if (!entry) {
374
+ return ok(facetDbUnknown(validated.dbname));
375
+ }
376
+ // Report unknown fields up front with the valid list, rather than letting
377
+ // fetchFacets throw on the first one and lose the rest.
378
+ const known = Object.keys(entry.fields);
379
+ const unknown = validated.fields.filter((f) => !known.includes(f));
380
+ if (unknown.length > 0) {
381
+ throw new Error(`Unknown facet field(s) for ${validated.dbname}: ${unknown.join(", ")}. ` +
382
+ `Valid fields: ${known.join(", ")}.`);
383
+ }
384
+ // Per-db required params (e.g. drugsales needs groupid=205) go first so
385
+ // caller-supplied values can still override them.
386
+ const query = { ...(entry.defaultQuery ?? {}), ...asQuery(validated.query) };
387
+ const content = await fetchFacets({
388
+ prefix: entry.prefix,
389
+ query,
390
+ fields: validated.fields,
391
+ catalog: entry.fields,
392
+ });
393
+ return ok({
394
+ dbname: validated.dbname,
395
+ title: entry.title,
396
+ ...content,
397
+ });
398
+ }
459
399
  case "yaohai-global-search": {
460
400
  const validated = YaohaiGlobalSearchSchema.parse(args ?? {});
461
401
  const query = asQuery(validated.query);
@@ -469,56 +409,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
469
409
  });
470
410
  return ok(content);
471
411
  }
472
- case "yaohai-smart-search": {
473
- const validated = YaohaiSmartSearchSchema.parse(args);
474
- const limit = clampLimit(validated.limit, YAOHAI_LIMIT_DEFAULT, YAOHAI_LIMIT_MAX);
475
- const body = { q: validated.q, limit };
476
- if (validated.query) {
477
- body.query = validated.query;
478
- }
479
- try {
480
- const content = await yaohaiPost("/g/mcp/yaohai/smart-search", body);
481
- if (allSmartResultsEmpty(content)) {
482
- // Router matched DBs but searched the whole question in one field
483
- // (or picked an unsupported field). Retry with individual tokens
484
- // across the router's field + the DB's catalog search_fields.
485
- const retried = await retrySmartResultsWithTokens(content, validated.q, limit);
486
- if (retried) {
487
- return ok(retried);
488
- }
489
- // Keyword-only question (e.g. "集采"): show sample rows from the
490
- // matched databases.
491
- const browsed = await browseMatchedDatabases(content, limit);
492
- if (browsed) {
493
- return ok(browsed);
494
- }
495
- // Still nothing — try the global panorama.
496
- const fallback = await globalSearchContent(validated.q, validated.query, limit);
497
- return ok({
498
- question: validated.q,
499
- matched_databases: content.matched_databases,
500
- fallback: "global-search",
501
- note: "Smart-search router matched databases but returned no rows; fell back to global search.",
502
- ...fallback,
503
- });
504
- }
505
- return ok(content);
506
- }
507
- catch (error) {
508
- if (error instanceof ApiError) {
509
- // Backend router could not match any database (e.g. plain drug-name
510
- // question) — fall back to the global panorama search.
511
- const fallback = await globalSearchContent(validated.q, validated.query, limit);
512
- return ok({
513
- question: validated.q,
514
- fallback: "global-search",
515
- note: `Smart-search router failed (${error.message}); fell back to global search.`,
516
- ...fallback,
517
- });
518
- }
519
- throw error;
520
- }
521
- }
522
412
  case "product-cn-fields": {
523
413
  EmptyObjectSchema.parse(args ?? {});
524
414
  return ok({
package/dist/types.js CHANGED
@@ -20,17 +20,30 @@ export const YaohaiDetailSchema = z.object({
20
20
  dbname: z.string().min(1),
21
21
  id: z.string().min(1),
22
22
  });
23
+ /**
24
+ * Facet request for a dbs-route database.
25
+ *
26
+ * Dual-mode, so one tool covers both discovery and fetching:
27
+ * - `fields` omitted -> return the facet catalog (available fields). Add
28
+ * `dbname` to narrow to one database, omit it to list all 44.
29
+ * - `fields` given -> fetch those facet distributions for `dbname`.
30
+ *
31
+ * Discovery mode exists because agents otherwise have to guess field names and
32
+ * would hit "Unknown facet fields". Fetching requires `fields` to be explicit:
33
+ * one HTTP request fires per field, so an implicit "all of them" would be a slow
34
+ * 129-request fan-out.
35
+ */
36
+ export const YaohaiFacetsSchema = z.object({
37
+ dbname: z.string().optional(),
38
+ query: QueryObjectSchema.optional(),
39
+ fields: z.array(z.string().min(1)).min(1).optional(),
40
+ });
23
41
  export const YaohaiGlobalSearchSchema = z.object({
24
42
  q: z.string().optional(),
25
43
  query: QueryObjectSchema.optional(),
26
44
  limit: z.coerce.number().int().optional(),
27
45
  offset: z.coerce.number().int().optional(),
28
46
  });
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
47
  export const ProductViewTypeSchema = z.enum([
35
48
  "eslist",
36
49
  "list_by_drug_name",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinginsun/mcp-drugsea",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for DrugSea / Yaohai pharmaceutical databases: cross-db search, China marketed products (product_cn), and CDE registration review (reg_cn).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",