@kinginsun/mcp-drugsea 0.2.1 → 0.3.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 +163 -3
- package/dist/api.js +17 -0
- package/dist/index.js +3 -235
- package/dist/types.js +0 -5
- package/package.json +1 -1
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
|
|
7
|
+
- **yaohai-*** — cross-database catalog / search / detail / global (`POST /g/mcp/yaohai/*`)
|
|
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
|
|
@@ -88,7 +102,7 @@ On db3, direct GET list routes may return encrypted payloads; this client auto-r
|
|
|
88
102
|
| R&D / CDE (在研, 受理号, 审评, 尚未上市) | `reg-cn-fields` → `reg-cn-search` / `reg-cn-facets` → `reg-cn-detail` |
|
|
89
103
|
| Other DBs (医保 `yibao`, 基药 `jiyao`, 集采 `jicai`, trials, DMF, …) | `yaohai-catalog` → `yaohai-search` → `yaohai-detail` |
|
|
90
104
|
| Global panorama | `yaohai-global-search` |
|
|
91
|
-
| Unclear which DB | `yaohai-
|
|
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
|
|
|
@@ -108,7 +122,8 @@ xlsx export is not implemented in this MCP (v1 returns JSON samples only).
|
|
|
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` |
|
|
110
124
|
| `yaohai-global-search` | `q?`, `query?`, `limit?`, `offset?` | `q` fills `query.term` |
|
|
111
|
-
|
|
125
|
+
|
|
126
|
+
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.
|
|
112
127
|
|
|
113
128
|
### product_cn (marketed)
|
|
114
129
|
|
|
@@ -136,6 +151,138 @@ xlsx export is not implemented in this MCP (v1 returns JSON samples only).
|
|
|
136
151
|
|
|
137
152
|
`query` values may be string, number, or string arrays (ConditionSearch `multiple`). Dates: `"YYYY-MM-DD to YYYY-MM-DD"`. Ranges: `"min to max"`.
|
|
138
153
|
|
|
154
|
+
## Quick start for AI Agents (install, configure, test)
|
|
155
|
+
|
|
156
|
+
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.
|
|
157
|
+
|
|
158
|
+
### Prerequisites
|
|
159
|
+
|
|
160
|
+
- Node.js >= 18 (`node -v`)
|
|
161
|
+
- npm (`npm -v`)
|
|
162
|
+
- 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)
|
|
163
|
+
|
|
164
|
+
### Step 0 — Register the server with your MCP client
|
|
165
|
+
|
|
166
|
+
Add the server to your client config so it auto-starts. Example for Cursor (`~/.cursor/mcp.json`) — see [Configuration](#configuration) for other clients:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"mcpServers": {
|
|
171
|
+
"drugsea": {
|
|
172
|
+
"command": "npx",
|
|
173
|
+
"args": ["-y", "@kinginsun/mcp-drugsea"],
|
|
174
|
+
"env": {
|
|
175
|
+
"YAOHAI_MCP_TOKEN": "ysk_your_token_here"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Then reload MCP servers in the client (Cursor: Settings → MCP → refresh). The client should list **12 tools**.
|
|
183
|
+
|
|
184
|
+
### Step 1 — Install (optional, for local/CLI use)
|
|
185
|
+
|
|
186
|
+
Either run via npx on demand (no install needed), or install globally / from source:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
# Option A: run without installing (what the MCP configs above do)
|
|
190
|
+
npx -y @kinginsun/mcp-drugsea
|
|
191
|
+
|
|
192
|
+
# Option B: global install
|
|
193
|
+
npm install -g @kinginsun/mcp-drugsea
|
|
194
|
+
npm ls -g @kinginsun/mcp-drugsea
|
|
195
|
+
|
|
196
|
+
# Option C: from source (when developing)
|
|
197
|
+
git clone https://github.com/kinginsun/mcp-drugsea.git
|
|
198
|
+
cd mcp-drugsea
|
|
199
|
+
npm install
|
|
200
|
+
npm run build
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Step 2 — Configure the token
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
export YAOHAI_MCP_TOKEN=ysk_your_token_here
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
For MCP client usage, put the token in the client config `env` instead (Step 0). Sanity-check the format:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
node -e "console.log(/^ysk_[0-9a-f]{32}$/i.test(process.env.YAOHAI_MCP_TOKEN) ? 'token format OK' : 'token format BAD')"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Step 3 — Smoke test over stdio (JSON-RPC)
|
|
216
|
+
|
|
217
|
+
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):
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
printf '%s\n' \
|
|
221
|
+
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1.0"}}}' \
|
|
222
|
+
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
|
|
223
|
+
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
|
|
224
|
+
| YAOHAI_MCP_TOKEN=$YAOHAI_MCP_TOKEN npx -y @kinginsun/mcp-drugsea \
|
|
225
|
+
| 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)})"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Expected: `tools: 12`.
|
|
229
|
+
|
|
230
|
+
One-liner variant without the handshake (also works with this server):
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
|
|
234
|
+
| YAOHAI_MCP_TOKEN=ysk_your_token_here npx -y @kinginsun/mcp-drugsea
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Step 4 — Test real tool calls
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# Catalog lookup (no external DB data needed)
|
|
241
|
+
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"yaohai-catalog","arguments":{"q":"医保"}}}' \
|
|
242
|
+
| YAOHAI_MCP_TOKEN=ysk_your_token_here npx -y @kinginsun/mcp-drugsea
|
|
243
|
+
|
|
244
|
+
# China marketed products search
|
|
245
|
+
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"product-cn-search","arguments":{"query":{"drug_name":"阿司匹林"},"limit":3}}}' \
|
|
246
|
+
| YAOHAI_MCP_TOKEN=ysk_your_token_here npx -y @kinginsun/mcp-drugsea
|
|
247
|
+
|
|
248
|
+
# Global panorama search
|
|
249
|
+
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"yaohai-global-search","arguments":{"q":"阿司匹林","limit":3}}}' \
|
|
250
|
+
| YAOHAI_MCP_TOKEN=ysk_your_token_here npx -y @kinginsun/mcp-drugsea
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Expected: each response has `"isError":false` and non-empty `content`.
|
|
254
|
+
|
|
255
|
+
### Step 5 — Full 12-tool suite (from source)
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
git clone https://github.com/kinginsun/mcp-drugsea.git
|
|
259
|
+
cd mcp-drugsea
|
|
260
|
+
npm install && npm run build
|
|
261
|
+
YAOHAI_MCP_TOKEN=ysk_your_token_here node scripts/test-all-tools.mjs
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Expected final line: `--- Summary: 12 passed, 0 failed / 12 tool calls ---`.
|
|
265
|
+
|
|
266
|
+
### Step 6 — Verify inside the MCP client
|
|
267
|
+
|
|
268
|
+
After reloading MCP servers in the client, ask the agent:
|
|
269
|
+
|
|
270
|
+
1. "List the drugsea tools" → should see 12 tools.
|
|
271
|
+
2. "Search 阿司匹林 in product-cn" → should return rows with `total > 0`.
|
|
272
|
+
3. "Global search: PD-1" → should return panorama results without error.
|
|
273
|
+
|
|
274
|
+
### Troubleshooting
|
|
275
|
+
|
|
276
|
+
| Symptom | Cause / fix |
|
|
277
|
+
|---------|-------------|
|
|
278
|
+
| `Set YAOHAI_MCP_TOKEN to your personal DrugSea token…` | Token env var missing/empty — set it (Step 2 / client `env`) |
|
|
279
|
+
| `YAOHAI_MCP_TOKEN must be a personal user token` | Token not `ysk_` + 32 hex — regenerate in personal center → API Token |
|
|
280
|
+
| `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`. |
|
|
281
|
+
| Permission/forbidden on a specific DB | Token inherits account permissions — check the account's subscription on db.drugsea.cn |
|
|
282
|
+
| `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` |
|
|
283
|
+
| Empty/encrypted payload from product/reg GET | Use default db3 base URL (auto MCP POST routing) or set `YAOHAI_USE_MCP_LIST=true` |
|
|
284
|
+
| TLS errors on some hosts | Set `YAOHAI_VERIFY_SSL=false` |
|
|
285
|
+
|
|
139
286
|
## Manual stdio test
|
|
140
287
|
|
|
141
288
|
```bash
|
|
@@ -153,6 +300,19 @@ echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"product-cn
|
|
|
153
300
|
| YAOHAI_MCP_TOKEN=ysk_your_token_here node dist/index.js
|
|
154
301
|
```
|
|
155
302
|
|
|
303
|
+
## Releasing (maintainers)
|
|
304
|
+
|
|
305
|
+
`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.
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
npm login # once, with rights on the @kinginsun scope
|
|
309
|
+
./publish.sh --dry-run # full rehearsal, no side effects
|
|
310
|
+
./publish.sh --minor # real release (0.2.1 → 0.3.0)
|
|
311
|
+
./publish.sh --help # all flags (--major, --version, --otp, --skip-tests, --note, --no-push)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
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.
|
|
315
|
+
|
|
156
316
|
## Requirements
|
|
157
317
|
|
|
158
318
|
- 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,
|
package/dist/index.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
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 {
|
|
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,
|
|
8
|
-
const PACKAGE_VERSION = "0.
|
|
7
|
+
import { EmptyObjectSchema, ProductCnDetailSchema, ProductCnFacetsSchema, ProductCnSearchSchema, RegCnDetailSchema, RegCnFacetsSchema, RegCnSearchSchema, YaohaiCatalogSchema, YaohaiDetailSchema, YaohaiGlobalSearchSchema, YaohaiSearchSchema, } from "./types.js";
|
|
8
|
+
const PACKAGE_VERSION = "0.3.0";
|
|
9
9
|
const YAOHAI_LIMIT_MAX = 50;
|
|
10
10
|
const YAOHAI_LIMIT_DEFAULT = 10;
|
|
11
11
|
const CN_LIMIT_MAX = 100;
|
|
@@ -108,24 +108,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
108
108
|
},
|
|
109
109
|
},
|
|
110
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
|
-
"Falls back to global search when the router matches nothing or finds no rows. " +
|
|
115
|
-
ROUTING_HINT,
|
|
116
|
-
inputSchema: {
|
|
117
|
-
type: "object",
|
|
118
|
-
properties: {
|
|
119
|
-
q: { type: "string", description: "Natural language question" },
|
|
120
|
-
query: QUERY_PROP,
|
|
121
|
-
limit: {
|
|
122
|
-
type: "number",
|
|
123
|
-
description: `Row cap (default ${YAOHAI_LIMIT_DEFAULT}, max ${YAOHAI_LIMIT_MAX})`,
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
required: ["q"],
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
111
|
{
|
|
130
112
|
name: "product-cn-fields",
|
|
131
113
|
description: "List CommonSearch and ConditionSearch field keys for 国内上市药品 (product_cn). Call before product-cn-search / product-cn-facets if unsure which filters exist.",
|
|
@@ -260,170 +242,6 @@ function encodeId(id) {
|
|
|
260
242
|
function asQuery(query) {
|
|
261
243
|
return { ...(query ?? {}) };
|
|
262
244
|
}
|
|
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
|
-
/**
|
|
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.
|
|
298
|
-
*/
|
|
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
|
-
}
|
|
421
|
-
return {
|
|
422
|
-
...content,
|
|
423
|
-
results,
|
|
424
|
-
note: "Smart-search found no rows for the full question; showing sample rows from the matched databases instead.",
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
245
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
428
246
|
const { name, arguments: args } = request.params;
|
|
429
247
|
try {
|
|
@@ -469,56 +287,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
469
287
|
});
|
|
470
288
|
return ok(content);
|
|
471
289
|
}
|
|
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
290
|
case "product-cn-fields": {
|
|
523
291
|
EmptyObjectSchema.parse(args ?? {});
|
|
524
292
|
return ok({
|
package/dist/types.js
CHANGED
|
@@ -26,11 +26,6 @@ export const YaohaiGlobalSearchSchema = z.object({
|
|
|
26
26
|
limit: z.coerce.number().int().optional(),
|
|
27
27
|
offset: z.coerce.number().int().optional(),
|
|
28
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
29
|
export const ProductViewTypeSchema = z.enum([
|
|
35
30
|
"eslist",
|
|
36
31
|
"list_by_drug_name",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kinginsun/mcp-drugsea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|