@kinginsun/mcp-drugsea 0.7.0 → 0.8.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
@@ -178,13 +178,19 @@ xlsx export is not implemented in this MCP (v1 returns JSON samples only).
178
178
  | Tool | Parameters | Notes |
179
179
  |------|------------|--------|
180
180
  | `yaohai-catalog` | `category?`, `q?` | List databases |
181
- | `yaohai-search` | `dbname`, `query?`, `limit?`, `offset?` | Default limit 10, max 50 |
181
+ | `yaohai-search` | `dbname`, `query?`, `limit?`, `offset?` | Default limit 10, max 50; ≤ 1000 rows per query condition (offset+limit window cap) |
182
182
  | `yaohai-detail` | `dbname`, `id` | Skip DBs with `has_detail: false` |
183
183
  | `yaohai-facets` | `dbname?`, `query?`, `fields?` | Facets for `dbs`-route DBs. Omit `fields` → discover facet-capable DBs/fields; pass `fields` → fetch buckets |
184
184
  | `yaohai-global-search` | `q?`, `query?`, `limit?`, `offset?` | `q` fills `query.term` |
185
185
 
186
186
  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.
187
187
 
188
+ > **Retrieval cap (anti-scraping, enforced client + server):** one distinct query
189
+ > condition can return at most **1000 rows** (`offset + limit ≤ 1000`). A larger
190
+ > `offset` is rejected; `limit` is auto-shrunk near the edge of the window. To
191
+ > reach deeper slices, narrow the filters (date / province / ATC / enterprise)
192
+ > and query again — each *new* condition gets its own 1000-row window.
193
+
188
194
  `yaohai-facets` mirrors the ConditionSearch facet filters of the website for the `dbs`-route databases (医保 `yibao`, 基药 `jiyao`, 集采 `jicai`, sales `drugsales`, …). Two modes:
189
195
 
190
196
  - **Discovery** (no `fields`): omit `dbname` to list all 44 facet-capable databases, or pass `dbname` to list its facet-able fields (with `filter_type`).
package/dist/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import http from "node:http";
2
2
  import https from "node:https";
3
3
  import { URL } from "node:url";
4
- import { normalizeRecord, parseFacetList } from "./normalize.js";
4
+ import { normalizeRecord, parseFacetList, stripMcpFields } from "./normalize.js";
5
5
  export class MissingTokenError extends Error {
6
6
  constructor(message) {
7
7
  super(message ??
@@ -95,6 +95,19 @@ export function clampOffset(offset) {
95
95
  }
96
96
  return Math.max(0, Math.trunc(offset));
97
97
  }
98
+ /**
99
+ * 单组查询条件最多可获取的行数窗口(与服务端 yaohai_mcp_max_retrieve() 同步,
100
+ * 默认 1000):offset+limit 超出窗口则收缩 limit;offset 已达窗口直接报错,
101
+ * 提示收窄筛选条件而不是继续翻页。
102
+ */
103
+ export const MAX_RETRIEVE_PER_QUERY = 1000;
104
+ export function enforceRetrievalWindow(limit, offset, max = MAX_RETRIEVE_PER_QUERY) {
105
+ if (offset >= max) {
106
+ throw new ApiError(`A single query condition can return at most ${max} rows(单个查询条件最多获取 ${max} 条): ` +
107
+ `offset=${offset} 已超出窗口上限。请收窄筛选条件(如日期/省份/ATC/企业名)后分页查询。`);
108
+ }
109
+ return { limit: Math.min(limit, max - offset), offset };
110
+ }
98
111
  function httpRequest(method, urlStr, headers, body) {
99
112
  return new Promise((resolve, reject) => {
100
113
  const url = new URL(urlStr);
@@ -239,11 +252,16 @@ export async function mcpDbSearch(opts) {
239
252
  if (item && typeof item === "object" && "fields" in item) {
240
253
  const row = item;
241
254
  const flat = normalizeRecord(row.fields);
242
- if (row.detail_url &&
243
- flat &&
255
+ if (flat &&
244
256
  typeof flat === "object" &&
245
257
  !Array.isArray(flat)) {
246
- return { ...flat, detail_url: row.detail_url };
258
+ const stripped = stripMcpFields(flat, {
259
+ dbname: opts.dbname,
260
+ });
261
+ if (row.detail_url) {
262
+ return { ...stripped, detail_url: row.detail_url };
263
+ }
264
+ return stripped;
247
265
  }
248
266
  return flat;
249
267
  }
@@ -270,7 +288,14 @@ export async function mcpDbDetail(dbname, id) {
270
288
  dbname,
271
289
  id,
272
290
  }));
273
- return { dbname, id, ...content };
291
+ const detail = Array.isArray(content.detail)
292
+ ? content.detail.map((row) => row && typeof row === "object"
293
+ ? stripMcpFields(row, { dbname, isDetail: true })
294
+ : row)
295
+ : content.detail && typeof content.detail === "object"
296
+ ? stripMcpFields(content.detail, { dbname, isDetail: true })
297
+ : content.detail;
298
+ return { dbname, id, ...content, detail };
274
299
  }
275
300
  export async function listSearch(opts) {
276
301
  const params = {
@@ -285,6 +310,13 @@ export async function listSearch(opts) {
285
310
  const raw = result.raw;
286
311
  const content = raw.content ?? result.content;
287
312
  const items = normalizeRecord(content);
313
+ if (Array.isArray(items)) {
314
+ for (const it of items) {
315
+ if (it && typeof it === "object") {
316
+ stripMcpFields(it, { dbname: opts.dbname });
317
+ }
318
+ }
319
+ }
288
320
  const totalRaw = raw.tnum;
289
321
  const total = typeof totalRaw === "number"
290
322
  ? totalRaw
@@ -301,14 +333,25 @@ export async function listSearch(opts) {
301
333
  query_applied: opts.query,
302
334
  };
303
335
  }
304
- export async function fetchDetail(path, id) {
336
+ export async function fetchDetail(path, id, dbname = "") {
305
337
  const result = await yaohaiGet(path, {});
306
338
  if (!result.ok) {
307
339
  throw new ApiError(result.error, result.status, result.raw);
308
340
  }
341
+ const detail = normalizeRecord(result.content);
342
+ if (Array.isArray(detail)) {
343
+ for (const it of detail) {
344
+ if (it && typeof it === "object") {
345
+ stripMcpFields(it, { dbname, isDetail: true });
346
+ }
347
+ }
348
+ }
349
+ else if (detail && typeof detail === "object") {
350
+ stripMcpFields(detail, { dbname, isDetail: true });
351
+ }
309
352
  return {
310
353
  id,
311
- detail: normalizeRecord(result.content),
354
+ detail,
312
355
  };
313
356
  }
314
357
  export async function fetchFacets(opts) {
package/dist/index.js CHANGED
@@ -2,12 +2,12 @@
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 { clampLimit, clampOffset, fetchDetail, fetchFacets, listSearch, mcpDbDetail, mcpDbSearch, prefersMcpListApi, yaohaiPost, } from "./api.js";
5
+ import { clampLimit, clampOffset, enforceRetrievalWindow, 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
7
  import { EmptyObjectSchema, ProductCnDetailSchema, ProductCnFacetsSchema, ProductCnSearchSchema, RegCnDetailSchema, RegCnFacetsSchema, RegCnSearchSchema, YaohaiCatalogSchema, YaohaiDetailSchema, YaohaiFacetsSchema, YaohaiGlobalSearchSchema, YaohaiSearchSchema, } from "./types.js";
8
8
  import { DBS_FACET_CATALOG } from "./dbs-facets.js";
9
9
  import { checkForUpdate, formatUpdateMessage, } from "./update-check.js";
10
- const PACKAGE_VERSION = "0.7.0";
10
+ const PACKAGE_VERSION = "0.8.0";
11
11
  const YAOHAI_LIMIT_MAX = 50;
12
12
  const YAOHAI_LIMIT_DEFAULT = 10;
13
13
  const CN_LIMIT_MAX = 100;
@@ -18,6 +18,7 @@ const QUERY_PROP = {
18
18
  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\".",
19
19
  };
20
20
  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.";
21
+ const RETRIEVAL_CAP_HINT = "One distinct query condition can return at most 1000 rows total (offset+limit window cap, anti-scraping): paginate within that window, or narrow the filters (date / province / ATC / enterprise) to reach deeper slices — a too-large offset is rejected.";
21
22
  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.";
22
23
  /**
23
24
  * Derived from the generated facet catalog so the tool descriptions can never
@@ -74,6 +75,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
74
75
  {
75
76
  name: "yaohai-search",
76
77
  description: "Search a single Yaohai database by dbname (from yaohai-catalog). Default limit 10, max 50. " +
78
+ RETRIEVAL_CAP_HINT +
79
+ " " +
77
80
  "Use query fields from the catalog's search_fields. " +
78
81
  ROUTING_HINT +
79
82
  " " +
@@ -90,7 +93,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
90
93
  type: "number",
91
94
  description: `Row cap (default ${YAOHAI_LIMIT_DEFAULT}, max ${YAOHAI_LIMIT_MAX})`,
92
95
  },
93
- offset: { type: "number", description: "Pagination offset (default 0)" },
96
+ offset: { type: "number", description: "Pagination offset (default 0); offset+limit is capped at 1000 rows per query condition" },
94
97
  },
95
98
  required: ["dbname"],
96
99
  },
@@ -134,6 +137,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
134
137
  {
135
138
  name: "yaohai-global-search",
136
139
  description: "Global drug panorama search (global_search). Pass q as the search term, or query.term. " +
140
+ RETRIEVAL_CAP_HINT +
141
+ " " +
137
142
  PRESENTATION_HINT,
138
143
  inputSchema: {
139
144
  type: "object",
@@ -144,7 +149,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
144
149
  type: "number",
145
150
  description: `Row cap (default ${YAOHAI_LIMIT_DEFAULT}, max ${YAOHAI_LIMIT_MAX})`,
146
151
  },
147
- offset: { type: "number", description: "Pagination offset (default 0)" },
152
+ offset: { type: "number", description: "Pagination offset (default 0); offset+limit is capped at 1000 rows per query condition" },
148
153
  },
149
154
  },
150
155
  },
@@ -159,6 +164,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
159
164
  "Default search_mode=3 (partial). first_approve_date = first listing date; approve_date = latest re-registration (not first listing). " +
160
165
  ATC_HINT +
161
166
  " Default limit 20, max 100. " +
167
+ RETRIEVAL_CAP_HINT +
168
+ " " +
162
169
  "Not for R&D pipeline — use reg-cn-search. " +
163
170
  PRESENTATION_HINT,
164
171
  inputSchema: {
@@ -173,7 +180,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
173
180
  type: "number",
174
181
  description: `Row cap (default ${CN_LIMIT_DEFAULT}, max ${CN_LIMIT_MAX})`,
175
182
  },
176
- offset: { type: "number", description: "Pagination offset (default 0)" },
183
+ offset: { type: "number", description: "Pagination offset (default 0); offset+limit is capped at 1000 rows per query condition" },
177
184
  view_type: {
178
185
  type: "string",
179
186
  enum: [...PRODUCT_CN_VIEW_TYPES],
@@ -221,7 +228,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
221
228
  description: "Search China drug registration / CDE review (reg_cn): 在研, 受理号, 申报, 审评进度, not-yet-listed. " +
222
229
  "Default rows_excluded=1 (drop 备案), search_mode=1. " +
223
230
  ATC_HINT +
224
- " Default limit 20, max 100. Not for already-marketed products — use product-cn-search. " +
231
+ " Default limit 20, max 100. " +
232
+ RETRIEVAL_CAP_HINT +
233
+ " " +
234
+ "Not for already-marketed products — use product-cn-search. " +
225
235
  PRESENTATION_HINT,
226
236
  inputSchema: {
227
237
  type: "object",
@@ -235,7 +245,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
235
245
  type: "number",
236
246
  description: `Row cap (default ${CN_LIMIT_DEFAULT}, max ${CN_LIMIT_MAX})`,
237
247
  },
238
- offset: { type: "number", description: "Pagination offset (default 0)" },
248
+ offset: { type: "number", description: "Pagination offset (default 0); offset+limit is capped at 1000 rows per query condition" },
239
249
  view_type: {
240
250
  type: "string",
241
251
  enum: [...REG_CN_VIEW_TYPES],
@@ -319,11 +329,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
319
329
  }
320
330
  case "yaohai-search": {
321
331
  const validated = YaohaiSearchSchema.parse(args);
332
+ const window = enforceRetrievalWindow(clampLimit(validated.limit, YAOHAI_LIMIT_DEFAULT, YAOHAI_LIMIT_MAX), clampOffset(validated.offset));
322
333
  const content = await yaohaiPost("/g/mcp/yaohai/search", {
323
334
  dbname: validated.dbname,
324
335
  query: asQuery(validated.query),
325
- limit: clampLimit(validated.limit, YAOHAI_LIMIT_DEFAULT, YAOHAI_LIMIT_MAX),
326
- offset: clampOffset(validated.offset),
336
+ limit: window.limit,
337
+ offset: window.offset,
327
338
  });
328
339
  return ok(content);
329
340
  }
@@ -406,10 +417,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
406
417
  if (validated.q && (query.term === undefined || query.term === "")) {
407
418
  query.term = validated.q;
408
419
  }
420
+ const window = enforceRetrievalWindow(clampLimit(validated.limit, YAOHAI_LIMIT_DEFAULT, YAOHAI_LIMIT_MAX), clampOffset(validated.offset));
409
421
  const content = await yaohaiPost("/g/mcp/yaohai/global-search", {
410
422
  query,
411
- limit: clampLimit(validated.limit, YAOHAI_LIMIT_DEFAULT, YAOHAI_LIMIT_MAX),
412
- offset: clampOffset(validated.offset),
423
+ limit: window.limit,
424
+ offset: window.offset,
413
425
  });
414
426
  return ok(content);
415
427
  }
@@ -425,8 +437,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
425
437
  const validated = ProductCnSearchSchema.parse(args ?? {});
426
438
  const viewType = validated.view_type ?? "eslist";
427
439
  const query = applyProductCnDefaults(asQuery(validated.query));
428
- const limit = clampLimit(validated.limit, CN_LIMIT_DEFAULT, CN_LIMIT_MAX);
429
- const offset = clampOffset(validated.offset);
440
+ const window = enforceRetrievalWindow(clampLimit(validated.limit, CN_LIMIT_DEFAULT, CN_LIMIT_MAX), clampOffset(validated.offset));
441
+ const limit = window.limit;
442
+ const offset = window.offset;
430
443
  const content = prefersMcpListApi()
431
444
  ? await mcpDbSearch({
432
445
  dbname: "product_cn",
@@ -441,6 +454,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
441
454
  limit,
442
455
  offset,
443
456
  viewType,
457
+ dbname: "product_cn",
444
458
  });
445
459
  return ok(content);
446
460
  }
@@ -459,7 +473,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
459
473
  const validated = ProductCnDetailSchema.parse(args);
460
474
  const content = prefersMcpListApi()
461
475
  ? await mcpDbDetail("product_cn", validated.id)
462
- : await fetchDetail(`${PRODUCT_CN_DETAIL_PATH}/${encodeId(validated.id)}`, validated.id);
476
+ : await fetchDetail(`${PRODUCT_CN_DETAIL_PATH}/${encodeId(validated.id)}`, validated.id, "product_cn");
463
477
  return ok(content);
464
478
  }
465
479
  case "reg-cn-fields": {
@@ -474,8 +488,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
474
488
  const validated = RegCnSearchSchema.parse(args ?? {});
475
489
  const viewType = validated.view_type ?? "eslist";
476
490
  const query = applyRegCnDefaults(asQuery(validated.query));
477
- const limit = clampLimit(validated.limit, CN_LIMIT_DEFAULT, CN_LIMIT_MAX);
478
- const offset = clampOffset(validated.offset);
491
+ const window = enforceRetrievalWindow(clampLimit(validated.limit, CN_LIMIT_DEFAULT, CN_LIMIT_MAX), clampOffset(validated.offset));
492
+ const limit = window.limit;
493
+ const offset = window.offset;
479
494
  const content = prefersMcpListApi()
480
495
  ? await mcpDbSearch({
481
496
  dbname: "reg_cn",
@@ -490,6 +505,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
490
505
  limit,
491
506
  offset,
492
507
  viewType,
508
+ dbname: "reg_cn",
493
509
  });
494
510
  return ok(content);
495
511
  }
@@ -508,7 +524,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
508
524
  const validated = RegCnDetailSchema.parse(args);
509
525
  const content = prefersMcpListApi()
510
526
  ? await mcpDbDetail("reg_cn", validated.id)
511
- : await fetchDetail(`${REG_CN_DETAIL_PATH}/${encodeId(validated.id)}`, validated.id);
527
+ : await fetchDetail(`${REG_CN_DETAIL_PATH}/${encodeId(validated.id)}`, validated.id, "reg_cn");
512
528
  return ok(content);
513
529
  }
514
530
  default:
package/dist/normalize.js CHANGED
@@ -1,4 +1,53 @@
1
1
  /** Flatten DrugSea UI cell objects `{ text, title, url }` into plain values. */
2
+ /**
3
+ * Strip MCP-irrelevant fields from result rows (lists and details alike).
4
+ * These are internal join keys, ETL timestamps, request-echo flags, and the
5
+ * `related_drug_names` synonym dump (100+ pipe-separated aliases) — they cost
6
+ * tokens but carry no signal for an agent. Mirrors `yaohai_mcp_strip_fields`
7
+ * in the drugsea_api backend (`src/routes/ai_mcp/mcp.php`); the two layers are
8
+ * idempotent with each other.
9
+ */
10
+ const STRIP_ALWAYS = [
11
+ "related_drug_names",
12
+ // internal ids / join keys
13
+ "dp2_id", "gcid", "ProductID", "XUI", "DrugUID", "UniqueID", "es_index_key", "company_for_count",
14
+ // maintenance timestamps
15
+ "created_at", "updated_at", "in_sfda_updated_at",
16
+ // request-echo / internal flags
17
+ "rows_excluded", "has_new_data", "has_sms", "is_47", "linked_to_xui", "is_filing_ref_drug", "2018yizhi", "has_detail",
18
+ // raw change-log dump in product detail (semicolon-delimited ETL diffs)
19
+ "timeline",
20
+ ];
21
+ /** Stale index column that contradicts is_guojia_jicai (skill docs: ignore it). */
22
+ const STRIP_PRODUCT_CN = ["is_jicai"];
23
+ /** Backup / duplicate copies of conclusion / transact_status / slh. */
24
+ const STRIP_REG_CN = ["conclusion_bak", "orig_transact_status", "slh2"];
25
+ /** Labelled insert text: useless HTML blobs in list rows, informative in detail. */
26
+ const STRIP_PRODUCT_CN_LIST_ONLY = [
27
+ "indications",
28
+ "dosage_and_administration",
29
+ "pharmacological_and_toxicological",
30
+ ];
31
+ export function stripMcpFields(row, opts = {}) {
32
+ const { dbname = "", isDetail = false } = opts;
33
+ const strip = new Set(STRIP_ALWAYS);
34
+ if (dbname === "product_cn") {
35
+ for (const f of STRIP_PRODUCT_CN)
36
+ strip.add(f);
37
+ if (!isDetail) {
38
+ for (const f of STRIP_PRODUCT_CN_LIST_ONLY)
39
+ strip.add(f);
40
+ }
41
+ }
42
+ else if (dbname === "reg_cn") {
43
+ for (const f of STRIP_REG_CN)
44
+ strip.add(f);
45
+ }
46
+ for (const f of strip) {
47
+ delete row[f];
48
+ }
49
+ return row;
50
+ }
2
51
  function isPlainObject(v) {
3
52
  return v !== null && typeof v === "object" && !Array.isArray(v);
4
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinginsun/mcp-drugsea",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",