@cyanheads/pubchem-mcp-server 0.6.4 → 0.6.5

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.
Files changed (49) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +4 -2
  4. package/changelog/0.6.x/0.6.5.md +22 -0
  5. package/dist/mcp-server/resources/definitions/assay.resource.js +2 -2
  6. package/dist/mcp-server/resources/definitions/assay.resource.js.map +1 -1
  7. package/dist/mcp-server/resources/definitions/compound-bioactivity.resource.js +2 -2
  8. package/dist/mcp-server/resources/definitions/compound-bioactivity.resource.js.map +1 -1
  9. package/dist/mcp-server/resources/definitions/compound-image.resource.js +2 -2
  10. package/dist/mcp-server/resources/definitions/compound-image.resource.js.map +1 -1
  11. package/dist/mcp-server/resources/definitions/compound-safety.resource.js +2 -2
  12. package/dist/mcp-server/resources/definitions/compound-safety.resource.js.map +1 -1
  13. package/dist/mcp-server/resources/definitions/compound-xrefs.resource.js +2 -2
  14. package/dist/mcp-server/resources/definitions/compound-xrefs.resource.js.map +1 -1
  15. package/dist/mcp-server/resources/definitions/compound.resource.js +2 -2
  16. package/dist/mcp-server/resources/definitions/compound.resource.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/get-bioactivity.tool.js +1 -1
  18. package/dist/mcp-server/tools/definitions/get-bioactivity.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/get-compound-3d-structure.tool.js +2 -2
  20. package/dist/mcp-server/tools/definitions/get-compound-3d-structure.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/get-compound-details.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/get-compound-details.tool.js +4 -4
  23. package/dist/mcp-server/tools/definitions/get-compound-details.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/definitions/get-compound-image.tool.js +1 -1
  25. package/dist/mcp-server/tools/definitions/get-compound-image.tool.js.map +1 -1
  26. package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.js +1 -1
  28. package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.js.map +1 -1
  29. package/dist/mcp-server/tools/definitions/get-compound-safety.tool.js +1 -1
  30. package/dist/mcp-server/tools/definitions/get-compound-safety.tool.js.map +1 -1
  31. package/dist/mcp-server/tools/definitions/get-compound-xrefs.tool.js +1 -1
  32. package/dist/mcp-server/tools/definitions/get-compound-xrefs.tool.js.map +1 -1
  33. package/dist/mcp-server/tools/definitions/get-summary.tool.js +1 -1
  34. package/dist/mcp-server/tools/definitions/get-summary.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/index.d.ts +11 -0
  36. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  37. package/dist/mcp-server/tools/definitions/search-assays.tool.js +1 -1
  38. package/dist/mcp-server/tools/definitions/search-assays.tool.js.map +1 -1
  39. package/dist/mcp-server/tools/definitions/search-compounds.tool.d.ts +11 -0
  40. package/dist/mcp-server/tools/definitions/search-compounds.tool.d.ts.map +1 -1
  41. package/dist/mcp-server/tools/definitions/search-compounds.tool.js +85 -37
  42. package/dist/mcp-server/tools/definitions/search-compounds.tool.js.map +1 -1
  43. package/dist/services/pubchem/pubchem-client.d.ts +40 -26
  44. package/dist/services/pubchem/pubchem-client.d.ts.map +1 -1
  45. package/dist/services/pubchem/pubchem-client.js +206 -99
  46. package/dist/services/pubchem/pubchem-client.js.map +1 -1
  47. package/manifest.json +1 -1
  48. package/package.json +2 -2
  49. package/server.json +3 -3
@@ -3,9 +3,35 @@
3
3
  * Wraps both PUG REST and PUG View APIs behind a shared rate limiter.
4
4
  * @module services/pubchem/pubchem-client
5
5
  */
6
- import { JsonRpcErrorCode, McpError, notFound, serviceUnavailable, } from '@cyanheads/mcp-ts-core/errors';
6
+ import { JsonRpcErrorCode, McpError, notFound, requestCancelled, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
7
7
  import { httpErrorFromResponse, logger, requestContextService } from '@cyanheads/mcp-ts-core/utils';
8
8
  const isNotFound = (error) => error instanceof McpError && error.code === JsonRpcErrorCode.NotFound;
9
+ const isBadRequest = (error) => error instanceof McpError && error.data?.status === 400;
10
+ /** The parsed upstream fault `fetchResponse` attaches to every HTTP failure. */
11
+ const faultOf = (error) => {
12
+ const fault = error instanceof McpError ? error.data?.fault : undefined;
13
+ return typeof fault === 'string' ? fault : undefined;
14
+ };
15
+ /** PubChem's answer when a fast search cannot run on the query itself: a malformed SMILES or
16
+ * formula, a SMILES with a "*" wildcard atom, a CID with no record. Every re-send of a
17
+ * faulting request has faulted again, and no valid query has drawn it — a slow search gets
18
+ * 504 `PUGREST.Timeout`, a search with no hits 404 `PUGREST.NotFound`. So `fetchResponse`
19
+ * spends no retry on it, and the search methods, which know the query form, turn it into a
20
+ * typed `search_query_rejected`. */
21
+ const SEARCH_REJECTED_FAULT = 'PUGREST.ServerError: Search status indicates failure';
22
+ /** Re-throws a rejected-query fault from a fast search as `search_query_rejected`, with a
23
+ * hint for the query form the caller sent. Every other failure passes through unchanged. */
24
+ function mapRejectedSearch(search, message, hint) {
25
+ return search.catch((error) => {
26
+ if (faultOf(error) !== SEARCH_REJECTED_FAULT)
27
+ throw error;
28
+ throw validationError(message, { reason: 'search_query_rejected', fault: SEARCH_REJECTED_FAULT, recovery: { hint } }, { cause: error });
29
+ });
30
+ }
31
+ /** CID 0 is PubChem's "no such structure" placeholder, never a compound: a SMILES lookup for a
32
+ * structure that parses but is not in the database answers 200 with `CID: [0]`. Dropped
33
+ * wherever a CID list is read, so no route can report it as a match. */
34
+ const realCids = (cids) => cids.filter((cid) => cid > 0);
9
35
  /** Distinguishes the two outcomes PUG View hides behind a single HTTP 404.
10
36
  *
11
37
  * `heading=`-filtered PUG View requests answer both "this CID has no record" and "this CID's
@@ -17,12 +43,11 @@ const isNotFound = (error) => error instanceof McpError && error.code === JsonRp
17
43
  * Defaults to false when the message is missing or unrecognized: claiming a compound has no
18
44
  * data understates what is known, while wrongly claiming a CID does not exist sends the caller
19
45
  * chasing a correct identifier. */
20
- const isMissingRecord = (error) => {
21
- if (!isNotFound(error))
22
- return false;
23
- const { fault } = (error.data ?? {});
24
- return typeof fault === 'string' && fault.includes('No record found');
25
- };
46
+ const isMissingRecord = (error) => isNotFound(error) && (faultOf(error)?.includes('No record found') ?? false);
47
+ /** What a caller's cancellation surfaces as. `RequestCancelled` is never retried and the
48
+ * framework logs it at `info`; raising it here keeps a withdrawn call from reading as the 30 s
49
+ * timeout, and from matching the not-found checks that turn a 404 into "no data". */
50
+ const cancellation = (reason) => requestCancelled('PubChem request cancelled by the caller.', undefined, { cause: reason });
26
51
  // ── Rate Limiter ─────────────────────────────────────────────────────
27
52
  /** Sliding-window rate limiter. Queues requests exceeding maxPerSecond. */
28
53
  class RateLimiter {
@@ -33,9 +58,23 @@ class RateLimiter {
33
58
  constructor(maxPerSecond) {
34
59
  this.max = maxPerSecond;
35
60
  }
36
- acquire() {
37
- return new Promise((resolve) => {
38
- this.queue.push(resolve);
61
+ /** Waits for a slot. A caller that cancels while queued leaves the queue without taking one. */
62
+ acquire(signal) {
63
+ return new Promise((resolve, reject) => {
64
+ if (signal?.aborted) {
65
+ reject(cancellation(signal.reason));
66
+ return;
67
+ }
68
+ const grant = () => {
69
+ signal?.removeEventListener('abort', withdraw);
70
+ resolve();
71
+ };
72
+ const withdraw = () => {
73
+ this.queue.splice(this.queue.indexOf(grant), 1);
74
+ reject(cancellation(signal?.reason));
75
+ };
76
+ signal?.addEventListener('abort', withdraw, { once: true });
77
+ this.queue.push(grant);
39
78
  if (!this.draining)
40
79
  void this.drain();
41
80
  });
@@ -61,8 +100,34 @@ class RateLimiter {
61
100
  this.draining = false;
62
101
  }
63
102
  }
64
- // ── Helpers ──────────────────────────────────────────────────────────
65
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
103
+ /** Waits `ms`, or rejects with the cancellation as soon as `signal` aborts. */
104
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
105
+ if (signal?.aborted) {
106
+ reject(cancellation(signal.reason));
107
+ return;
108
+ }
109
+ const cancel = () => {
110
+ clearTimeout(timer);
111
+ reject(cancellation(signal?.reason));
112
+ };
113
+ const timer = setTimeout(() => {
114
+ signal?.removeEventListener('abort', cancel);
115
+ resolve();
116
+ }, ms);
117
+ signal?.addEventListener('abort', cancel, { once: true });
118
+ });
119
+ /** Reads a response body. The platform fetch aborts the body with the request's signal, so a
120
+ * caller abort mid-read surfaces as the cancellation instead of a raw abort. */
121
+ async function readBody(read, signal) {
122
+ try {
123
+ return await read;
124
+ }
125
+ catch (error) {
126
+ if (signal?.aborted)
127
+ throw cancellation(signal.reason);
128
+ throw error;
129
+ }
130
+ }
66
131
  /** Records a failed PubChem request, with its URL, in the server's own log. The error the
67
132
  * caller receives deliberately omits the URL, so this line is the only place an operator can
68
133
  * see which route failed. The module `logger` writes server-side only — `ctx.log` would also
@@ -412,19 +477,26 @@ export class PubChemClient {
412
477
  sdqBase = 'https://pubchem.ncbi.nlm.nih.gov/sdq/sdqagent.cgi';
413
478
  rateLimiter = new RateLimiter(5);
414
479
  // ── Core HTTP ────────────────────────────────────────────────────
415
- /** Shared HTTP core: rate-limit, 30s timeout, retry once on 5xx and once on a transient
416
- * network error, and surface a clean timeout message. Returns the ok Response; callers
417
- * extract the body (JSON, bytes, or text). Centralizing this keeps every fetch variant on
418
- * one resilience contract — the divergence that left fetchBinary without retry or a clean
419
- * timeout message (#16) cannot recur. */
420
- async fetchResponse(url, init) {
421
- const method = init?.method ?? 'GET';
480
+ /** Shared HTTP core: rate-limit, 30s timeout, retry once on 5xx (except a rejected search
481
+ * query) and once on a transient network error, and surface a clean timeout message. Returns
482
+ * the ok Response; callers extract the body (JSON, bytes, or text). Centralizing this keeps
483
+ * every fetch variant on one resilience contract — the divergence that left fetchBinary
484
+ * without retry or a clean timeout message (#16) cannot recur.
485
+ *
486
+ * `init.signal` is the caller's cancellation, combined with the per-attempt timeout. Once it
487
+ * aborts — queued for a slot, mid-fetch, or in a retry backoff — the request ends with
488
+ * `RequestCancelled`: no retry, and no failed-request log, since nothing failed upstream. */
489
+ async fetchResponse(url, { signal, ...init }) {
490
+ const method = init.method ?? 'GET';
422
491
  for (let attempt = 0;; attempt++) {
423
- await this.rateLimiter.acquire();
424
- const controller = new AbortController();
425
- const timeoutId = setTimeout(() => controller.abort(), 30_000);
492
+ await this.rateLimiter.acquire(signal);
493
+ const timeout = new AbortController();
494
+ const timeoutId = setTimeout(() => timeout.abort(), 30_000);
426
495
  try {
427
- const response = await fetch(url, { ...init, signal: controller.signal });
496
+ const response = await fetch(url, {
497
+ ...init,
498
+ signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
499
+ });
428
500
  if (response.ok)
429
501
  return response;
430
502
  const text = await response.text();
@@ -439,9 +511,13 @@ export class PubChemClient {
439
511
  service: 'PubChem',
440
512
  data: { fault },
441
513
  });
442
- // Retry once on 5xx unless the framework marks the failure as permanent.
443
- if (response.status >= 500 && error.data?.retryable !== false && attempt < 1) {
444
- await sleep(1000 * 2 ** attempt);
514
+ // Retry once on 5xx unless the framework marks the failure as permanent or PubChem
515
+ // has rejected the search query itself, which faults identically on every re-send.
516
+ if (response.status >= 500 &&
517
+ error.data?.retryable !== false &&
518
+ fault !== SEARCH_REJECTED_FAULT &&
519
+ attempt < 1) {
520
+ await sleep(1000 * 2 ** attempt, signal);
445
521
  continue;
446
522
  }
447
523
  // A 404 is routine — most callers read it as "no data" — so it logs below warning.
@@ -452,14 +528,18 @@ export class PubChemClient {
452
528
  throw error;
453
529
  }
454
530
  catch (error) {
531
+ // The caller withdrew the call: whatever this attempt rejected with, it is over.
532
+ if (signal?.aborted)
533
+ throw cancellation(signal.reason);
455
534
  // HTTP errors are already classified and logged — surface them, don't retry.
456
535
  if (error instanceof McpError)
457
536
  throw error;
458
537
  // Retry once on network errors
459
538
  if (attempt < 1) {
460
- await sleep(1000 * 2 ** attempt);
539
+ await sleep(1000 * 2 ** attempt, signal);
461
540
  continue;
462
541
  }
542
+ // With the caller's signal ruled out, an abort can only be this attempt's timeout.
463
543
  const failure = error instanceof Error && error.name === 'AbortError'
464
544
  ? new Error('PubChem request timed out (30s)')
465
545
  : error;
@@ -475,16 +555,16 @@ export class PubChemClient {
475
555
  }
476
556
  async fetchJson(url, init) {
477
557
  const response = await this.fetchResponse(url, init);
478
- return (await response.json());
558
+ return (await readBody(response.json(), init.signal));
479
559
  }
480
- async fetchBinary(url) {
481
- const response = await this.fetchResponse(url);
482
- return response.arrayBuffer();
560
+ async fetchBinary(url, signal) {
561
+ const response = await this.fetchResponse(url, { signal });
562
+ return readBody(response.arrayBuffer(), signal);
483
563
  }
484
564
  /** Fetch a text/plain body (e.g. an SDF record). Non-2xx is classified and thrown. */
485
- async fetchText(url) {
486
- const response = await this.fetchResponse(url);
487
- return response.text();
565
+ async fetchText(url, signal) {
566
+ const response = await this.fetchResponse(url, { signal });
567
+ return readBody(response.text(), signal);
488
568
  }
489
569
  // ── CID Resolution ──────────────────────────────────────────────
490
570
  /** Fetch CID list, with automatic ListKey polling for async searches.
@@ -498,8 +578,8 @@ export class PubChemClient {
498
578
  try {
499
579
  const data = await this.fetchJson(url, init);
500
580
  if ('Waiting' in data)
501
- return this.pollListKey(data.Waiting.ListKey, maxRecords);
502
- return data.IdentifierList.CID;
581
+ return this.pollListKey(data.Waiting.ListKey, maxRecords, init.signal);
582
+ return realCids(data.IdentifierList.CID);
503
583
  }
504
584
  catch (error) {
505
585
  if (isNotFound(error))
@@ -510,17 +590,18 @@ export class PubChemClient {
510
590
  /** Poll a PubChem ListKey until results are ready.
511
591
  *
512
592
  * `listkey_count` asks PubChem to trim the page; the slice enforces the same bound
513
- * locally, so the caller's saturation test holds whether or not the parameter is honored. */
514
- async pollListKey(listKey, maxRecords, maxAttempts = 20) {
593
+ * locally, so the caller's saturation test holds whether or not the parameter is honored.
594
+ * A cancellation ends the wait before the next poll. */
595
+ async pollListKey(listKey, maxRecords, signal, maxAttempts = 20) {
515
596
  const query = maxRecords === undefined ? '' : `?listkey_count=${maxRecords}`;
516
597
  const pollUrl = `${this.pugBase}/compound/listkey/${listKey}/cids/JSON${query}`;
517
598
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
518
- await sleep(1500);
599
+ await sleep(1500, signal);
519
600
  try {
520
- const data = await this.fetchJson(pollUrl);
601
+ const data = await this.fetchJson(pollUrl, { signal });
521
602
  if ('Waiting' in data)
522
603
  continue;
523
- const cids = data.IdentifierList.CID;
604
+ const cids = realCids(data.IdentifierList.CID);
524
605
  return maxRecords === undefined ? cids : cids.slice(0, maxRecords);
525
606
  }
526
607
  catch (error) {
@@ -531,18 +612,36 @@ export class PubChemClient {
531
612
  }
532
613
  throw new Error('PubChem async search timed out after polling');
533
614
  }
534
- searchByName(name) {
535
- return this.fetchCids(`${this.pugBase}/compound/name/${encodeURIComponent(name)}/cids/JSON`);
615
+ /** Resolve one caller-supplied identifier to its CIDs; [] when PubChem has no match.
616
+ *
617
+ * The identifier is the only variable in these requests, so an HTTP 400 is PubChem rejecting
618
+ * that identifier — for SMILES, a string it cannot standardize into a structure, including
619
+ * well-formed SMILES it cannot process such as a "*" wildcard atom. It is re-thrown as a
620
+ * ValidationError tagged `identifier_rejected`, so a batch caller can set that one input
621
+ * aside. Every other failure — 5xx, rate limit, timeout — keeps its own classification. */
622
+ async lookupIdentifier(url, init) {
623
+ try {
624
+ return await this.fetchCids(url, init);
625
+ }
626
+ catch (error) {
627
+ if (!isBadRequest(error))
628
+ throw error;
629
+ throw validationError('PubChem could not interpret the identifier.', { reason: 'identifier_rejected', fault: faultOf(error) }, { cause: error });
630
+ }
631
+ }
632
+ searchByName(name, signal) {
633
+ return this.lookupIdentifier(`${this.pugBase}/compound/name/${encodeURIComponent(name)}/cids/JSON`, { signal });
536
634
  }
537
- searchBySmiles(smiles) {
538
- return this.fetchCids(`${this.pugBase}/compound/smiles/cids/JSON`, {
635
+ searchBySmiles(smiles, signal) {
636
+ return this.lookupIdentifier(`${this.pugBase}/compound/smiles/cids/JSON`, {
539
637
  method: 'POST',
540
638
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
541
639
  body: new URLSearchParams({ smiles }).toString(),
640
+ signal,
542
641
  });
543
642
  }
544
- searchByInchiKey(inchikey) {
545
- return this.fetchCids(`${this.pugBase}/compound/inchikey/${encodeURIComponent(inchikey)}/cids/JSON`);
643
+ searchByInchiKey(inchikey, signal) {
644
+ return this.lookupIdentifier(`${this.pugBase}/compound/inchikey/${encodeURIComponent(inchikey)}/cids/JSON`, { signal });
546
645
  }
547
646
  /** Formula search, bounded server-side by `maxRecords`.
548
647
  *
@@ -555,15 +654,16 @@ export class PubChemClient {
555
654
  * `maxRecords` is saturated, and the true total is not recoverable — PubChem returns the
556
655
  * CID list alone, with no match count beside it. Callers that need to distinguish the two
557
656
  * compare the returned length against the cap they passed. */
558
- searchByFormula(formula, allowOther, maxRecords) {
657
+ searchByFormula(formula, allowOther, maxRecords, signal) {
559
658
  const params = new URLSearchParams({ MaxRecords: String(maxRecords) });
560
659
  if (allowOther)
561
660
  params.set('AllowOtherElements', 'true');
562
- return this.fetchCids(`${this.pugBase}/compound/fastformula/${encodeURIComponent(formula)}/cids/JSON?${params}`, undefined, maxRecords);
661
+ return mapRejectedSearch(this.fetchCids(`${this.pugBase}/compound/fastformula/${encodeURIComponent(formula)}/cids/JSON?${params}`, { signal }, maxRecords), 'PubChem could not run the formula search on this formula.', 'Write the formula in Hill notation with valid element symbols, for example "C6H12O6" or "CaH2O2".');
563
662
  }
564
663
  /** Substructure, superstructure, and 2D-similarity search, bounded server-side by
565
- * `maxRecords`. Same cap contract as {@link searchByFormula}. */
566
- searchByStructure(mode, query, queryType, threshold, maxRecords) {
664
+ * `maxRecords`. Same cap contract as {@link searchByFormula}; a query PubChem cannot search
665
+ * on throws `search_query_rejected` the same way. */
666
+ searchByStructure(mode, query, queryType, threshold, maxRecords, signal) {
567
667
  const endpoint = mode === 'similarity'
568
668
  ? 'fastsimilarity_2d'
569
669
  : mode === 'substructure'
@@ -573,18 +673,19 @@ export class PubChemClient {
573
673
  if (mode === 'similarity')
574
674
  params.set('Threshold', String(threshold ?? 90));
575
675
  if (queryType === 'cid') {
576
- return this.fetchCids(`${this.pugBase}/compound/${endpoint}/cid/${query}/cids/JSON?${params}`, undefined, maxRecords);
676
+ return mapRejectedSearch(this.fetchCids(`${this.pugBase}/compound/${endpoint}/cid/${query}/cids/JSON?${params}`, { signal }, maxRecords), `PubChem could not run the ${mode} search on CID ${query}.`, `Confirm CID ${query} exists with pubchem_get_compound_details, or pass the structure as a SMILES query with queryType "smiles".`);
577
677
  }
578
678
  // POST for SMILES to avoid encoding issues
579
679
  const url = `${this.pugBase}/compound/${endpoint}/smiles/cids/JSON?${params}`;
580
- return this.fetchCids(url, {
680
+ return mapRejectedSearch(this.fetchCids(url, {
581
681
  method: 'POST',
582
682
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
583
683
  body: new URLSearchParams({ smiles: query }).toString(),
584
- }, maxRecords);
684
+ signal,
685
+ }, maxRecords), `PubChem could not run the ${mode} search on this SMILES.`, 'Check the SMILES syntax (balanced ring closures and parentheses, valid element symbols) and remove any "*" wildcard atoms, which PubChem structure search does not accept.');
585
686
  }
586
687
  // ── Compound Data ───────────────────────────────────────────────
587
- async getProperties(cids, properties) {
688
+ async getProperties(cids, properties, signal) {
588
689
  if (cids.length === 0 || properties.length === 0)
589
690
  return [];
590
691
  const propsPath = properties.join(',');
@@ -595,15 +696,16 @@ export class PubChemClient {
595
696
  method: 'POST',
596
697
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
597
698
  body: new URLSearchParams({ cid: cidStr }).toString(),
699
+ signal,
598
700
  })
599
- : await this.fetchJson(`${this.pugBase}/compound/cid/${cidStr}/property/${propsPath}/JSON`);
701
+ : await this.fetchJson(`${this.pugBase}/compound/cid/${cidStr}/property/${propsPath}/JSON`, { signal });
600
702
  // PubChem returns different field names than the request names for some properties.
601
703
  // Normalize so consumers see the names they requested.
602
704
  return data.PropertyTable.Properties.map(normalizePropertyNames);
603
705
  }
604
- async getSynonyms(cid) {
706
+ async getSynonyms(cid, signal) {
605
707
  try {
606
- const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/synonyms/JSON`);
708
+ const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/synonyms/JSON`, { signal });
607
709
  return data.InformationList.Information[0]?.Synonym ?? [];
608
710
  }
609
711
  catch (error) {
@@ -612,10 +714,10 @@ export class PubChemClient {
612
714
  throw error;
613
715
  }
614
716
  }
615
- async getImage(cid, size = 'large') {
717
+ async getImage(cid, size = 'large', signal) {
616
718
  const sizeParam = size === 'small' ? '?image_size=small' : '?image_size=large';
617
719
  try {
618
- return await this.fetchBinary(`${this.pugBase}/compound/cid/${cid}/PNG${sizeParam}`);
720
+ return await this.fetchBinary(`${this.pugBase}/compound/cid/${cid}/PNG${sizeParam}`, signal);
619
721
  }
620
722
  catch (error) {
621
723
  // The image endpoint returns binary, so absence can't be a structured success like
@@ -630,9 +732,9 @@ export class PubChemClient {
630
732
  throw error;
631
733
  }
632
734
  }
633
- async getXrefs(cid, xrefType) {
735
+ async getXrefs(cid, xrefType, signal) {
634
736
  try {
635
- const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/xrefs/${xrefType}/JSON`);
737
+ const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/xrefs/${xrefType}/JSON`, { signal });
636
738
  const info = data.InformationList.Information[0];
637
739
  if (!info)
638
740
  return [];
@@ -646,9 +748,9 @@ export class PubChemClient {
646
748
  }
647
749
  }
648
750
  // ── PUG View ────────────────────────────────────────────────────
649
- async getDescription(cid) {
751
+ async getDescription(cid, signal) {
650
752
  try {
651
- const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Record+Description`);
753
+ const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Record+Description`, { signal });
652
754
  const sections = data.Record.Section;
653
755
  if (!sections)
654
756
  return [];
@@ -679,9 +781,9 @@ export class PubChemClient {
679
781
  * GHS classification" — a nonexistent CID and a real compound without safety data are
680
782
  * different answers that call for different follow-up, and both used to surface as an
681
783
  * absent result. */
682
- async getSafetyData(cid) {
784
+ async getSafetyData(cid, signal) {
683
785
  try {
684
- const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Safety+and+Hazards`);
786
+ const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Safety+and+Hazards`, { signal });
685
787
  const sections = data.Record.Section;
686
788
  if (!sections)
687
789
  return { status: 'no_ghs_data' };
@@ -741,9 +843,9 @@ export class PubChemClient {
741
843
  throw error;
742
844
  }
743
845
  }
744
- async getClassification(cid) {
846
+ async getClassification(cid, signal) {
745
847
  try {
746
- const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Pharmacology+and+Biochemistry`);
848
+ const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Pharmacology+and+Biochemistry`, { signal });
747
849
  const sections = data.Record.Section;
748
850
  if (!sections)
749
851
  return null;
@@ -812,9 +914,9 @@ export class PubChemClient {
812
914
  }
813
915
  }
814
916
  // ── Bioactivity ─────────────────────────────────────────────────
815
- async getAssaySummary(cid) {
917
+ async getAssaySummary(cid, signal) {
816
918
  try {
817
- const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/assaysummary/JSON`);
919
+ const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/assaysummary/JSON`, { signal });
818
920
  return this.parseAssayTable(data);
819
921
  }
820
922
  catch (error) {
@@ -891,11 +993,11 @@ export class PubChemClient {
891
993
  return [...byAid.values()];
892
994
  }
893
995
  // ── Assay Search ────────────────────────────────────────────────
894
- async searchAssaysByTarget(targetType, query) {
996
+ async searchAssaysByTarget(targetType, query, signal) {
895
997
  // PubChem API expects "accession" not "proteinaccession"
896
998
  const apiTargetType = targetType === 'proteinaccession' ? 'accession' : targetType;
897
999
  try {
898
- const data = await this.fetchJson(`${this.pugBase}/assay/target/${apiTargetType}/${encodeURIComponent(query)}/aids/JSON`);
1000
+ const data = await this.fetchJson(`${this.pugBase}/assay/target/${apiTargetType}/${encodeURIComponent(query)}/aids/JSON`, { signal });
899
1001
  return data.IdentifierList.AID;
900
1002
  }
901
1003
  catch (error) {
@@ -905,7 +1007,7 @@ export class PubChemClient {
905
1007
  }
906
1008
  }
907
1009
  // ── Entity Summaries ────────────────────────────────────────────
908
- async getEntitySummary(entityType, identifier) {
1010
+ async getEntitySummary(entityType, identifier, signal) {
909
1011
  const pathMap = {
910
1012
  assay: `/assay/aid/${identifier}/summary/JSON`,
911
1013
  gene: `/gene/geneid/${identifier}/summary/JSON`,
@@ -916,7 +1018,9 @@ export class PubChemClient {
916
1018
  if (!path)
917
1019
  throw new Error(`Unknown entity type: ${entityType}`);
918
1020
  try {
919
- const data = await this.fetchJson(`${this.pugBase}${path}`);
1021
+ const data = await this.fetchJson(`${this.pugBase}${path}`, {
1022
+ signal,
1023
+ });
920
1024
  // Response shape: { XxxSummaries: { XxxSummary: [{...}] } }
921
1025
  const summariesKey = Object.keys(data).find((k) => k.endsWith('Summaries'));
922
1026
  if (!summariesKey)
@@ -932,9 +1036,8 @@ export class PubChemClient {
932
1036
  if (isNotFound(error))
933
1037
  return null;
934
1038
  // PubChem returns HTTP 400 (not 404) for nonexistent entity IDs in some endpoints
935
- if (error instanceof McpError && error.data?.status === 400) {
1039
+ if (isBadRequest(error))
936
1040
  return null;
937
- }
938
1041
  throw error;
939
1042
  }
940
1043
  }
@@ -943,13 +1046,17 @@ export class PubChemClient {
943
1046
  * Drug-drug and target data live in PubChem SDQ external tables (drugbankddi, bioactivity);
944
1047
  * drug-food is inline PUG View text. Each kind reads a window of `maxEntries` entries
945
1048
  * starting at `offset` records into that kind's own stream; absent data for a kind
946
- * contributes an empty page rather than erroring. */
947
- async getInteractions(cid, kinds, maxEntries, offset) {
1049
+ * contributes an empty page rather than erroring. A cancellation fails the whole call. */
1050
+ async getInteractions(cid, kinds, maxEntries, offset, signal) {
948
1051
  // Per-kind isolation: a failure in one source (upstream parse error, timeout, network)
949
1052
  // must not discard the kinds that succeeded. Failures are reported, not thrown (#21).
950
1053
  // Page state is recorded per kind from that kind's own result, so a failing kind leaves
951
1054
  // the others' continuation signals untouched rather than zeroing them.
952
- const settled = await Promise.allSettled(kinds.map((kind) => this.getInteractionsForKind(cid, kind, maxEntries, offset)));
1055
+ const settled = await Promise.allSettled(kinds.map((kind) => this.getInteractionsForKind(cid, kind, maxEntries, offset, signal)));
1056
+ // A cancelled kind is not a failed source — reporting it as one would hand a withdrawn
1057
+ // call a partial result.
1058
+ if (signal?.aborted)
1059
+ throw cancellation(signal.reason);
953
1060
  const entries = [];
954
1061
  const pages = [];
955
1062
  const failedKinds = [];
@@ -970,18 +1077,18 @@ export class PubChemClient {
970
1077
  });
971
1078
  return { entries, pages, failedKinds };
972
1079
  }
973
- getInteractionsForKind(cid, kind, maxEntries, offset) {
1080
+ getInteractionsForKind(cid, kind, maxEntries, offset, signal) {
974
1081
  switch (kind) {
975
1082
  case 'drug-drug':
976
- return this.getDrugDrugInteractions(cid, maxEntries, offset);
1083
+ return this.getDrugDrugInteractions(cid, maxEntries, offset, signal);
977
1084
  case 'drug-food':
978
- return this.getDrugFoodInteractions(cid, maxEntries, offset);
1085
+ return this.getDrugFoodInteractions(cid, maxEntries, offset, signal);
979
1086
  case 'target':
980
- return this.getTargetInteractions(cid, maxEntries, offset);
1087
+ return this.getTargetInteractions(cid, maxEntries, offset, signal);
981
1088
  }
982
1089
  }
983
- async getDrugDrugInteractions(cid, maxEntries, offset) {
984
- const { rows, totalCount } = await this.fetchSdq('drugbankddi', cid, ['cid', 'name2', 'descr'], maxEntries, offset);
1090
+ async getDrugDrugInteractions(cid, maxEntries, offset, signal) {
1091
+ const { rows, totalCount } = await this.fetchSdq('drugbankddi', cid, ['cid', 'name2', 'descr'], maxEntries, offset, signal);
985
1092
  const entries = [];
986
1093
  for (const row of rows) {
987
1094
  const text = typeof row.descr === 'string' ? row.descr : '';
@@ -994,7 +1101,7 @@ export class PubChemClient {
994
1101
  }
995
1102
  return {
996
1103
  entries,
997
- totalRecords: await this.resolveSdqTotal('drugbankddi', cid, rows.length, totalCount, offset),
1104
+ totalRecords: await this.resolveSdqTotal('drugbankddi', cid, rows.length, totalCount, offset, signal),
998
1105
  recordsConsumed: rows.length,
999
1106
  };
1000
1107
  }
@@ -1008,11 +1115,11 @@ export class PubChemClient {
1008
1115
  * Because most rows are dropped, the page position is an index into the activity records, not
1009
1116
  * into the entries returned — the walk records exactly how many rows it read so the next page
1010
1117
  * resumes at the first unread one. */
1011
- async getTargetInteractions(cid, maxEntries, offset) {
1118
+ async getTargetInteractions(cid, maxEntries, offset, signal) {
1012
1119
  // `targetname` is sparse (most rows are untargeted assay outcomes), so oversample and keep
1013
1120
  // the target-bearing rows, most-potent-first.
1014
1121
  const window = Math.min(Math.max(maxEntries * 4, 20), 100);
1015
- const { rows, totalCount } = await this.fetchSdq('bioactivity', cid, ['cid', 'targetname', 'acname', 'acqualifier', 'acvalue', 'aidsrcname'], window, offset, ['acvalue,asc']);
1122
+ const { rows, totalCount } = await this.fetchSdq('bioactivity', cid, ['cid', 'targetname', 'acname', 'acqualifier', 'acvalue', 'aidsrcname'], window, offset, signal, ['acvalue,asc']);
1016
1123
  const entries = [];
1017
1124
  // Collapse duplicate measurements of the same target/value reported across assays.
1018
1125
  const seen = new Set();
@@ -1047,14 +1154,14 @@ export class PubChemClient {
1047
1154
  }
1048
1155
  return {
1049
1156
  entries,
1050
- totalRecords: await this.resolveSdqTotal('bioactivity', cid, rows.length, totalCount, offset),
1157
+ totalRecords: await this.resolveSdqTotal('bioactivity', cid, rows.length, totalCount, offset, signal),
1051
1158
  recordsConsumed,
1052
1159
  };
1053
1160
  }
1054
- async getDrugFoodInteractions(cid, maxEntries, offset) {
1161
+ async getDrugFoodInteractions(cid, maxEntries, offset, signal) {
1055
1162
  const empty = { entries: [], totalRecords: 0, recordsConsumed: 0 };
1056
1163
  try {
1057
- const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Drug-Food+Interactions`);
1164
+ const data = await this.fetchJson(`${this.viewBase}/data/compound/${cid}/JSON?heading=Drug-Food+Interactions`, { signal });
1058
1165
  const sections = data.Record.Section;
1059
1166
  if (!sections)
1060
1167
  return empty;
@@ -1098,7 +1205,7 @@ export class PubChemClient {
1098
1205
  * throws, and the `status.error` check covers the same rejection arriving inside a 2xx body.
1099
1206
  * An unparseable body throws with the collection and a body snippet attached, so per-kind
1100
1207
  * isolation can name what failed. */
1101
- async fetchSdq(collection, cid, columns, limit, offset = 0, order) {
1208
+ async fetchSdq(collection, cid, columns, limit, offset, signal, order) {
1102
1209
  const empty = { rows: [], totalCount: 0 };
1103
1210
  const query = JSON.stringify({
1104
1211
  select: columns,
@@ -1111,7 +1218,7 @@ export class PubChemClient {
1111
1218
  const url = `${this.sdqBase}?infmt=json&outfmt=json&query=${encodeURIComponent(query)}`;
1112
1219
  let body;
1113
1220
  try {
1114
- body = await this.fetchText(url);
1221
+ body = await this.fetchText(url, signal);
1115
1222
  }
1116
1223
  catch (error) {
1117
1224
  if (isNotFound(error))
@@ -1148,18 +1255,18 @@ export class PubChemClient {
1148
1255
  * answers `totalCount` 0 — so an offset that overshoots would otherwise report "no records"
1149
1256
  * for a compound that has thousands. A one-row probe from the top recovers the real bound,
1150
1257
  * and only runs on that case. */
1151
- async resolveSdqTotal(collection, cid, rowsReturned, reportedTotal, offset) {
1258
+ async resolveSdqTotal(collection, cid, rowsReturned, reportedTotal, offset, signal) {
1152
1259
  if (rowsReturned > 0 || offset === 0)
1153
1260
  return reportedTotal;
1154
- const { totalCount } = await this.fetchSdq(collection, cid, ['cid'], 1);
1261
+ const { totalCount } = await this.fetchSdq(collection, cid, ['cid'], 1, 0, signal);
1155
1262
  return totalCount;
1156
1263
  }
1157
1264
  // ── 3D Structure ────────────────────────────────────────────────
1158
1265
  /** Fetch the default 3D conformer as raw V2000 SDF text. Throws a typed not-found when
1159
1266
  * PubChem has no computed 3D coordinates (large molecules, mixtures, undefined salts). */
1160
- async getSdf3d(cid) {
1267
+ async getSdf3d(cid, signal) {
1161
1268
  try {
1162
- return await this.fetchText(`${this.pugBase}/compound/cid/${cid}/record/SDF?record_type=3d`);
1269
+ return await this.fetchText(`${this.pugBase}/compound/cid/${cid}/record/SDF?record_type=3d`, signal);
1163
1270
  }
1164
1271
  catch (error) {
1165
1272
  if (isNotFound(error)) {
@@ -1175,9 +1282,9 @@ export class PubChemClient {
1175
1282
  }
1176
1283
  }
1177
1284
  /** List the conformer IDs PubChem has computed for a compound. Returns [] on not-found. */
1178
- async getConformerIds(cid) {
1285
+ async getConformerIds(cid, signal) {
1179
1286
  try {
1180
- const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/conformers/JSON`);
1287
+ const data = await this.fetchJson(`${this.pugBase}/compound/cid/${cid}/conformers/JSON`, { signal });
1181
1288
  return data.InformationList.Information[0]?.ConformerID ?? [];
1182
1289
  }
1183
1290
  catch (error) {