@cyanheads/pubchem-mcp-server 0.6.3 → 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 (50) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +6 -3
  4. package/changelog/0.6.x/0.6.4.md +23 -0
  5. package/changelog/0.6.x/0.6.5.md +22 -0
  6. package/dist/mcp-server/resources/definitions/assay.resource.js +3 -3
  7. package/dist/mcp-server/resources/definitions/assay.resource.js.map +1 -1
  8. package/dist/mcp-server/resources/definitions/compound-bioactivity.resource.js +3 -3
  9. package/dist/mcp-server/resources/definitions/compound-bioactivity.resource.js.map +1 -1
  10. package/dist/mcp-server/resources/definitions/compound-image.resource.js +3 -3
  11. package/dist/mcp-server/resources/definitions/compound-image.resource.js.map +1 -1
  12. package/dist/mcp-server/resources/definitions/compound-safety.resource.js +3 -3
  13. package/dist/mcp-server/resources/definitions/compound-safety.resource.js.map +1 -1
  14. package/dist/mcp-server/resources/definitions/compound-xrefs.resource.js +3 -3
  15. package/dist/mcp-server/resources/definitions/compound-xrefs.resource.js.map +1 -1
  16. package/dist/mcp-server/resources/definitions/compound.resource.js +3 -3
  17. package/dist/mcp-server/resources/definitions/compound.resource.js.map +1 -1
  18. package/dist/mcp-server/tools/definitions/get-bioactivity.tool.js +3 -3
  19. package/dist/mcp-server/tools/definitions/get-bioactivity.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/get-compound-3d-structure.tool.js +5 -5
  21. package/dist/mcp-server/tools/definitions/get-compound-3d-structure.tool.js.map +1 -1
  22. package/dist/mcp-server/tools/definitions/get-compound-details.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/get-compound-details.tool.js +5 -5
  24. package/dist/mcp-server/tools/definitions/get-compound-details.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/definitions/get-compound-image.tool.js +2 -2
  26. package/dist/mcp-server/tools/definitions/get-compound-image.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.d.ts.map +1 -1
  28. package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.js +2 -2
  29. package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.js.map +1 -1
  30. package/dist/mcp-server/tools/definitions/get-compound-safety.tool.js +2 -2
  31. package/dist/mcp-server/tools/definitions/get-compound-safety.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/get-compound-xrefs.tool.js +2 -2
  33. package/dist/mcp-server/tools/definitions/get-compound-xrefs.tool.js.map +1 -1
  34. package/dist/mcp-server/tools/definitions/get-summary.tool.js +1 -1
  35. package/dist/mcp-server/tools/definitions/get-summary.tool.js.map +1 -1
  36. package/dist/mcp-server/tools/definitions/index.d.ts +13 -2
  37. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  38. package/dist/mcp-server/tools/definitions/search-assays.tool.js +1 -1
  39. package/dist/mcp-server/tools/definitions/search-assays.tool.js.map +1 -1
  40. package/dist/mcp-server/tools/definitions/search-compounds.tool.d.ts +13 -2
  41. package/dist/mcp-server/tools/definitions/search-compounds.tool.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/definitions/search-compounds.tool.js +93 -42
  43. package/dist/mcp-server/tools/definitions/search-compounds.tool.js.map +1 -1
  44. package/dist/services/pubchem/pubchem-client.d.ts +40 -26
  45. package/dist/services/pubchem/pubchem-client.d.ts.map +1 -1
  46. package/dist/services/pubchem/pubchem-client.js +242 -106
  47. package/dist/services/pubchem/pubchem-client.js.map +1 -1
  48. package/manifest.json +1 -1
  49. package/package.json +4 -4
  50. 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';
7
- import { httpErrorFromResponse } from '@cyanheads/mcp-ts-core/utils';
6
+ import { JsonRpcErrorCode, McpError, notFound, requestCancelled, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
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,45 @@ 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
+ }
131
+ /** Records a failed PubChem request, with its URL, in the server's own log. The error the
132
+ * caller receives deliberately omits the URL, so this line is the only place an operator can
133
+ * see which route failed. The module `logger` writes server-side only — `ctx.log` would also
134
+ * forward the line to the client as `notifications/message`. */
135
+ function logFailedRequest(level, url, method, detail) {
136
+ const summary = 'status' in detail ? `HTTP ${detail.status}` : detail.error;
137
+ logger[level](`PubChem request failed: ${summary}`, requestContextService.createRequestContext({
138
+ operation: 'PubChemClient.fetch',
139
+ additionalContext: { url, method, ...detail },
140
+ }));
141
+ }
66
142
  /** PubChem returns different field names than the request parameter names for some properties.
67
143
  * Request IsomericSMILES → response key "SMILES" (includes stereochemistry).
68
144
  * Request CanonicalSMILES → response key "ConnectivitySMILES" (connectivity only). */
@@ -401,18 +477,26 @@ export class PubChemClient {
401
477
  sdqBase = 'https://pubchem.ncbi.nlm.nih.gov/sdq/sdqagent.cgi';
402
478
  rateLimiter = new RateLimiter(5);
403
479
  // ── Core HTTP ────────────────────────────────────────────────────
404
- /** Shared HTTP core: rate-limit, 30s timeout, retry once on 5xx and once on a transient
405
- * network error, and surface a clean timeout message. Returns the ok Response; callers
406
- * extract the body (JSON, bytes, or text). Centralizing this keeps every fetch variant on
407
- * one resilience contract — the divergence that left fetchBinary without retry or a clean
408
- * timeout message (#16) cannot recur. */
409
- async fetchResponse(url, init) {
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';
410
491
  for (let attempt = 0;; attempt++) {
411
- await this.rateLimiter.acquire();
412
- const controller = new AbortController();
413
- 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);
414
495
  try {
415
- 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
+ });
416
500
  if (response.ok)
417
501
  return response;
418
502
  const text = await response.text();
@@ -427,26 +511,42 @@ export class PubChemClient {
427
511
  service: 'PubChem',
428
512
  data: { fault },
429
513
  });
430
- // Retry once on 5xx unless the framework marks the failure as permanent.
431
- if (response.status >= 500 && error.data?.retryable !== false && attempt < 1) {
432
- 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);
433
521
  continue;
434
522
  }
523
+ // A 404 is routine — most callers read it as "no data" — so it logs below warning.
524
+ logFailedRequest(response.status === 404 ? 'debug' : 'warning', url, method, {
525
+ status: response.status,
526
+ fault,
527
+ });
435
528
  throw error;
436
529
  }
437
530
  catch (error) {
438
- // HTTP errors are already classified — surface them, don't retry.
531
+ // The caller withdrew the call: whatever this attempt rejected with, it is over.
532
+ if (signal?.aborted)
533
+ throw cancellation(signal.reason);
534
+ // HTTP errors are already classified and logged — surface them, don't retry.
439
535
  if (error instanceof McpError)
440
536
  throw error;
441
537
  // Retry once on network errors
442
538
  if (attempt < 1) {
443
- await sleep(1000 * 2 ** attempt);
539
+ await sleep(1000 * 2 ** attempt, signal);
444
540
  continue;
445
541
  }
446
- if (error instanceof Error && error.name === 'AbortError') {
447
- throw new Error('PubChem request timed out (30s)');
448
- }
449
- throw error;
542
+ // With the caller's signal ruled out, an abort can only be this attempt's timeout.
543
+ const failure = error instanceof Error && error.name === 'AbortError'
544
+ ? new Error('PubChem request timed out (30s)')
545
+ : error;
546
+ logFailedRequest('warning', url, method, {
547
+ error: failure instanceof Error ? failure.message : String(failure),
548
+ });
549
+ throw failure;
450
550
  }
451
551
  finally {
452
552
  clearTimeout(timeoutId);
@@ -455,16 +555,16 @@ export class PubChemClient {
455
555
  }
456
556
  async fetchJson(url, init) {
457
557
  const response = await this.fetchResponse(url, init);
458
- return (await response.json());
558
+ return (await readBody(response.json(), init.signal));
459
559
  }
460
- async fetchBinary(url) {
461
- const response = await this.fetchResponse(url);
462
- return response.arrayBuffer();
560
+ async fetchBinary(url, signal) {
561
+ const response = await this.fetchResponse(url, { signal });
562
+ return readBody(response.arrayBuffer(), signal);
463
563
  }
464
564
  /** Fetch a text/plain body (e.g. an SDF record). Non-2xx is classified and thrown. */
465
- async fetchText(url) {
466
- const response = await this.fetchResponse(url);
467
- return response.text();
565
+ async fetchText(url, signal) {
566
+ const response = await this.fetchResponse(url, { signal });
567
+ return readBody(response.text(), signal);
468
568
  }
469
569
  // ── CID Resolution ──────────────────────────────────────────────
470
570
  /** Fetch CID list, with automatic ListKey polling for async searches.
@@ -478,8 +578,8 @@ export class PubChemClient {
478
578
  try {
479
579
  const data = await this.fetchJson(url, init);
480
580
  if ('Waiting' in data)
481
- return this.pollListKey(data.Waiting.ListKey, maxRecords);
482
- return data.IdentifierList.CID;
581
+ return this.pollListKey(data.Waiting.ListKey, maxRecords, init.signal);
582
+ return realCids(data.IdentifierList.CID);
483
583
  }
484
584
  catch (error) {
485
585
  if (isNotFound(error))
@@ -490,17 +590,18 @@ export class PubChemClient {
490
590
  /** Poll a PubChem ListKey until results are ready.
491
591
  *
492
592
  * `listkey_count` asks PubChem to trim the page; the slice enforces the same bound
493
- * locally, so the caller's saturation test holds whether or not the parameter is honored. */
494
- 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) {
495
596
  const query = maxRecords === undefined ? '' : `?listkey_count=${maxRecords}`;
496
597
  const pollUrl = `${this.pugBase}/compound/listkey/${listKey}/cids/JSON${query}`;
497
598
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
498
- await sleep(1500);
599
+ await sleep(1500, signal);
499
600
  try {
500
- const data = await this.fetchJson(pollUrl);
601
+ const data = await this.fetchJson(pollUrl, { signal });
501
602
  if ('Waiting' in data)
502
603
  continue;
503
- const cids = data.IdentifierList.CID;
604
+ const cids = realCids(data.IdentifierList.CID);
504
605
  return maxRecords === undefined ? cids : cids.slice(0, maxRecords);
505
606
  }
506
607
  catch (error) {
@@ -511,18 +612,36 @@ export class PubChemClient {
511
612
  }
512
613
  throw new Error('PubChem async search timed out after polling');
513
614
  }
514
- searchByName(name) {
515
- 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
+ }
516
631
  }
517
- searchBySmiles(smiles) {
518
- return this.fetchCids(`${this.pugBase}/compound/smiles/cids/JSON`, {
632
+ searchByName(name, signal) {
633
+ return this.lookupIdentifier(`${this.pugBase}/compound/name/${encodeURIComponent(name)}/cids/JSON`, { signal });
634
+ }
635
+ searchBySmiles(smiles, signal) {
636
+ return this.lookupIdentifier(`${this.pugBase}/compound/smiles/cids/JSON`, {
519
637
  method: 'POST',
520
638
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
521
639
  body: new URLSearchParams({ smiles }).toString(),
640
+ signal,
522
641
  });
523
642
  }
524
- searchByInchiKey(inchikey) {
525
- 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 });
526
645
  }
527
646
  /** Formula search, bounded server-side by `maxRecords`.
528
647
  *
@@ -535,15 +654,16 @@ export class PubChemClient {
535
654
  * `maxRecords` is saturated, and the true total is not recoverable — PubChem returns the
536
655
  * CID list alone, with no match count beside it. Callers that need to distinguish the two
537
656
  * compare the returned length against the cap they passed. */
538
- searchByFormula(formula, allowOther, maxRecords) {
657
+ searchByFormula(formula, allowOther, maxRecords, signal) {
539
658
  const params = new URLSearchParams({ MaxRecords: String(maxRecords) });
540
659
  if (allowOther)
541
660
  params.set('AllowOtherElements', 'true');
542
- 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".');
543
662
  }
544
663
  /** Substructure, superstructure, and 2D-similarity search, bounded server-side by
545
- * `maxRecords`. Same cap contract as {@link searchByFormula}. */
546
- 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) {
547
667
  const endpoint = mode === 'similarity'
548
668
  ? 'fastsimilarity_2d'
549
669
  : mode === 'substructure'
@@ -553,18 +673,19 @@ export class PubChemClient {
553
673
  if (mode === 'similarity')
554
674
  params.set('Threshold', String(threshold ?? 90));
555
675
  if (queryType === 'cid') {
556
- 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".`);
557
677
  }
558
678
  // POST for SMILES to avoid encoding issues
559
679
  const url = `${this.pugBase}/compound/${endpoint}/smiles/cids/JSON?${params}`;
560
- return this.fetchCids(url, {
680
+ return mapRejectedSearch(this.fetchCids(url, {
561
681
  method: 'POST',
562
682
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
563
683
  body: new URLSearchParams({ smiles: query }).toString(),
564
- }, 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.');
565
686
  }
566
687
  // ── Compound Data ───────────────────────────────────────────────
567
- async getProperties(cids, properties) {
688
+ async getProperties(cids, properties, signal) {
568
689
  if (cids.length === 0 || properties.length === 0)
569
690
  return [];
570
691
  const propsPath = properties.join(',');
@@ -575,15 +696,16 @@ export class PubChemClient {
575
696
  method: 'POST',
576
697
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
577
698
  body: new URLSearchParams({ cid: cidStr }).toString(),
699
+ signal,
578
700
  })
579
- : await this.fetchJson(`${this.pugBase}/compound/cid/${cidStr}/property/${propsPath}/JSON`);
701
+ : await this.fetchJson(`${this.pugBase}/compound/cid/${cidStr}/property/${propsPath}/JSON`, { signal });
580
702
  // PubChem returns different field names than the request names for some properties.
581
703
  // Normalize so consumers see the names they requested.
582
704
  return data.PropertyTable.Properties.map(normalizePropertyNames);
583
705
  }
584
- async getSynonyms(cid) {
706
+ async getSynonyms(cid, signal) {
585
707
  try {
586
- 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 });
587
709
  return data.InformationList.Information[0]?.Synonym ?? [];
588
710
  }
589
711
  catch (error) {
@@ -592,10 +714,10 @@ export class PubChemClient {
592
714
  throw error;
593
715
  }
594
716
  }
595
- async getImage(cid, size = 'large') {
717
+ async getImage(cid, size = 'large', signal) {
596
718
  const sizeParam = size === 'small' ? '?image_size=small' : '?image_size=large';
597
719
  try {
598
- return await this.fetchBinary(`${this.pugBase}/compound/cid/${cid}/PNG${sizeParam}`);
720
+ return await this.fetchBinary(`${this.pugBase}/compound/cid/${cid}/PNG${sizeParam}`, signal);
599
721
  }
600
722
  catch (error) {
601
723
  // The image endpoint returns binary, so absence can't be a structured success like
@@ -610,9 +732,9 @@ export class PubChemClient {
610
732
  throw error;
611
733
  }
612
734
  }
613
- async getXrefs(cid, xrefType) {
735
+ async getXrefs(cid, xrefType, signal) {
614
736
  try {
615
- 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 });
616
738
  const info = data.InformationList.Information[0];
617
739
  if (!info)
618
740
  return [];
@@ -626,9 +748,9 @@ export class PubChemClient {
626
748
  }
627
749
  }
628
750
  // ── PUG View ────────────────────────────────────────────────────
629
- async getDescription(cid) {
751
+ async getDescription(cid, signal) {
630
752
  try {
631
- 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 });
632
754
  const sections = data.Record.Section;
633
755
  if (!sections)
634
756
  return [];
@@ -659,9 +781,9 @@ export class PubChemClient {
659
781
  * GHS classification" — a nonexistent CID and a real compound without safety data are
660
782
  * different answers that call for different follow-up, and both used to surface as an
661
783
  * absent result. */
662
- async getSafetyData(cid) {
784
+ async getSafetyData(cid, signal) {
663
785
  try {
664
- 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 });
665
787
  const sections = data.Record.Section;
666
788
  if (!sections)
667
789
  return { status: 'no_ghs_data' };
@@ -721,9 +843,9 @@ export class PubChemClient {
721
843
  throw error;
722
844
  }
723
845
  }
724
- async getClassification(cid) {
846
+ async getClassification(cid, signal) {
725
847
  try {
726
- 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 });
727
849
  const sections = data.Record.Section;
728
850
  if (!sections)
729
851
  return null;
@@ -792,9 +914,9 @@ export class PubChemClient {
792
914
  }
793
915
  }
794
916
  // ── Bioactivity ─────────────────────────────────────────────────
795
- async getAssaySummary(cid) {
917
+ async getAssaySummary(cid, signal) {
796
918
  try {
797
- 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 });
798
920
  return this.parseAssayTable(data);
799
921
  }
800
922
  catch (error) {
@@ -871,11 +993,11 @@ export class PubChemClient {
871
993
  return [...byAid.values()];
872
994
  }
873
995
  // ── Assay Search ────────────────────────────────────────────────
874
- async searchAssaysByTarget(targetType, query) {
996
+ async searchAssaysByTarget(targetType, query, signal) {
875
997
  // PubChem API expects "accession" not "proteinaccession"
876
998
  const apiTargetType = targetType === 'proteinaccession' ? 'accession' : targetType;
877
999
  try {
878
- 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 });
879
1001
  return data.IdentifierList.AID;
880
1002
  }
881
1003
  catch (error) {
@@ -885,7 +1007,7 @@ export class PubChemClient {
885
1007
  }
886
1008
  }
887
1009
  // ── Entity Summaries ────────────────────────────────────────────
888
- async getEntitySummary(entityType, identifier) {
1010
+ async getEntitySummary(entityType, identifier, signal) {
889
1011
  const pathMap = {
890
1012
  assay: `/assay/aid/${identifier}/summary/JSON`,
891
1013
  gene: `/gene/geneid/${identifier}/summary/JSON`,
@@ -896,7 +1018,9 @@ export class PubChemClient {
896
1018
  if (!path)
897
1019
  throw new Error(`Unknown entity type: ${entityType}`);
898
1020
  try {
899
- const data = await this.fetchJson(`${this.pugBase}${path}`);
1021
+ const data = await this.fetchJson(`${this.pugBase}${path}`, {
1022
+ signal,
1023
+ });
900
1024
  // Response shape: { XxxSummaries: { XxxSummary: [{...}] } }
901
1025
  const summariesKey = Object.keys(data).find((k) => k.endsWith('Summaries'));
902
1026
  if (!summariesKey)
@@ -912,9 +1036,8 @@ export class PubChemClient {
912
1036
  if (isNotFound(error))
913
1037
  return null;
914
1038
  // PubChem returns HTTP 400 (not 404) for nonexistent entity IDs in some endpoints
915
- if (error instanceof McpError && error.data?.status === 400) {
1039
+ if (isBadRequest(error))
916
1040
  return null;
917
- }
918
1041
  throw error;
919
1042
  }
920
1043
  }
@@ -923,13 +1046,17 @@ export class PubChemClient {
923
1046
  * Drug-drug and target data live in PubChem SDQ external tables (drugbankddi, bioactivity);
924
1047
  * drug-food is inline PUG View text. Each kind reads a window of `maxEntries` entries
925
1048
  * starting at `offset` records into that kind's own stream; absent data for a kind
926
- * contributes an empty page rather than erroring. */
927
- 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) {
928
1051
  // Per-kind isolation: a failure in one source (upstream parse error, timeout, network)
929
1052
  // must not discard the kinds that succeeded. Failures are reported, not thrown (#21).
930
1053
  // Page state is recorded per kind from that kind's own result, so a failing kind leaves
931
1054
  // the others' continuation signals untouched rather than zeroing them.
932
- 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);
933
1060
  const entries = [];
934
1061
  const pages = [];
935
1062
  const failedKinds = [];
@@ -950,18 +1077,18 @@ export class PubChemClient {
950
1077
  });
951
1078
  return { entries, pages, failedKinds };
952
1079
  }
953
- getInteractionsForKind(cid, kind, maxEntries, offset) {
1080
+ getInteractionsForKind(cid, kind, maxEntries, offset, signal) {
954
1081
  switch (kind) {
955
1082
  case 'drug-drug':
956
- return this.getDrugDrugInteractions(cid, maxEntries, offset);
1083
+ return this.getDrugDrugInteractions(cid, maxEntries, offset, signal);
957
1084
  case 'drug-food':
958
- return this.getDrugFoodInteractions(cid, maxEntries, offset);
1085
+ return this.getDrugFoodInteractions(cid, maxEntries, offset, signal);
959
1086
  case 'target':
960
- return this.getTargetInteractions(cid, maxEntries, offset);
1087
+ return this.getTargetInteractions(cid, maxEntries, offset, signal);
961
1088
  }
962
1089
  }
963
- async getDrugDrugInteractions(cid, maxEntries, offset) {
964
- 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);
965
1092
  const entries = [];
966
1093
  for (const row of rows) {
967
1094
  const text = typeof row.descr === 'string' ? row.descr : '';
@@ -974,7 +1101,7 @@ export class PubChemClient {
974
1101
  }
975
1102
  return {
976
1103
  entries,
977
- totalRecords: await this.resolveSdqTotal('drugbankddi', cid, rows.length, totalCount, offset),
1104
+ totalRecords: await this.resolveSdqTotal('drugbankddi', cid, rows.length, totalCount, offset, signal),
978
1105
  recordsConsumed: rows.length,
979
1106
  };
980
1107
  }
@@ -988,11 +1115,11 @@ export class PubChemClient {
988
1115
  * Because most rows are dropped, the page position is an index into the activity records, not
989
1116
  * into the entries returned — the walk records exactly how many rows it read so the next page
990
1117
  * resumes at the first unread one. */
991
- async getTargetInteractions(cid, maxEntries, offset) {
1118
+ async getTargetInteractions(cid, maxEntries, offset, signal) {
992
1119
  // `targetname` is sparse (most rows are untargeted assay outcomes), so oversample and keep
993
1120
  // the target-bearing rows, most-potent-first.
994
1121
  const window = Math.min(Math.max(maxEntries * 4, 20), 100);
995
- 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']);
996
1123
  const entries = [];
997
1124
  // Collapse duplicate measurements of the same target/value reported across assays.
998
1125
  const seen = new Set();
@@ -1027,14 +1154,14 @@ export class PubChemClient {
1027
1154
  }
1028
1155
  return {
1029
1156
  entries,
1030
- totalRecords: await this.resolveSdqTotal('bioactivity', cid, rows.length, totalCount, offset),
1157
+ totalRecords: await this.resolveSdqTotal('bioactivity', cid, rows.length, totalCount, offset, signal),
1031
1158
  recordsConsumed,
1032
1159
  };
1033
1160
  }
1034
- async getDrugFoodInteractions(cid, maxEntries, offset) {
1161
+ async getDrugFoodInteractions(cid, maxEntries, offset, signal) {
1035
1162
  const empty = { entries: [], totalRecords: 0, recordsConsumed: 0 };
1036
1163
  try {
1037
- 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 });
1038
1165
  const sections = data.Record.Section;
1039
1166
  if (!sections)
1040
1167
  return empty;
@@ -1078,7 +1205,7 @@ export class PubChemClient {
1078
1205
  * throws, and the `status.error` check covers the same rejection arriving inside a 2xx body.
1079
1206
  * An unparseable body throws with the collection and a body snippet attached, so per-kind
1080
1207
  * isolation can name what failed. */
1081
- async fetchSdq(collection, cid, columns, limit, offset = 0, order) {
1208
+ async fetchSdq(collection, cid, columns, limit, offset, signal, order) {
1082
1209
  const empty = { rows: [], totalCount: 0 };
1083
1210
  const query = JSON.stringify({
1084
1211
  select: columns,
@@ -1091,25 +1218,34 @@ export class PubChemClient {
1091
1218
  const url = `${this.sdqBase}?infmt=json&outfmt=json&query=${encodeURIComponent(query)}`;
1092
1219
  let body;
1093
1220
  try {
1094
- body = await this.fetchText(url);
1221
+ body = await this.fetchText(url, signal);
1095
1222
  }
1096
1223
  catch (error) {
1097
1224
  if (isNotFound(error))
1098
1225
  return empty;
1099
1226
  throw error;
1100
1227
  }
1228
+ // Both rejections below arrive inside a 2xx, past fetchResponse's failure logging.
1229
+ const reject = (message, data) => {
1230
+ logFailedRequest('warning', url, 'GET', { error: message });
1231
+ throw serviceUnavailable(message, data);
1232
+ };
1101
1233
  let parsed;
1102
1234
  try {
1103
1235
  parsed = JSON.parse(body);
1104
1236
  }
1105
1237
  catch {
1106
- throw serviceUnavailable(`PubChem SDQ returned unparseable JSON for collection "${collection}"`, { collection, cid, snippet: body.slice(0, 200) });
1238
+ return reject(`PubChem SDQ returned unparseable JSON for collection "${collection}"`, {
1239
+ collection,
1240
+ cid,
1241
+ snippet: body.slice(0, 200),
1242
+ });
1107
1243
  }
1108
1244
  const set = parsed.SDQOutputSet?.[0];
1109
1245
  if (!set)
1110
1246
  return empty;
1111
1247
  if (set.status?.error) {
1112
- throw serviceUnavailable(`PubChem SDQ rejected the query for collection "${collection}": ${set.status.error}`, { collection, cid, sdqError: set.status.error });
1248
+ return reject(`PubChem SDQ rejected the query for collection "${collection}": ${set.status.error}`, { collection, cid, sdqError: set.status.error });
1113
1249
  }
1114
1250
  return { rows: set.rows ?? [], totalCount: set.totalCount ?? 0 };
1115
1251
  }
@@ -1119,18 +1255,18 @@ export class PubChemClient {
1119
1255
  * answers `totalCount` 0 — so an offset that overshoots would otherwise report "no records"
1120
1256
  * for a compound that has thousands. A one-row probe from the top recovers the real bound,
1121
1257
  * and only runs on that case. */
1122
- async resolveSdqTotal(collection, cid, rowsReturned, reportedTotal, offset) {
1258
+ async resolveSdqTotal(collection, cid, rowsReturned, reportedTotal, offset, signal) {
1123
1259
  if (rowsReturned > 0 || offset === 0)
1124
1260
  return reportedTotal;
1125
- const { totalCount } = await this.fetchSdq(collection, cid, ['cid'], 1);
1261
+ const { totalCount } = await this.fetchSdq(collection, cid, ['cid'], 1, 0, signal);
1126
1262
  return totalCount;
1127
1263
  }
1128
1264
  // ── 3D Structure ────────────────────────────────────────────────
1129
1265
  /** Fetch the default 3D conformer as raw V2000 SDF text. Throws a typed not-found when
1130
1266
  * PubChem has no computed 3D coordinates (large molecules, mixtures, undefined salts). */
1131
- async getSdf3d(cid) {
1267
+ async getSdf3d(cid, signal) {
1132
1268
  try {
1133
- 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);
1134
1270
  }
1135
1271
  catch (error) {
1136
1272
  if (isNotFound(error)) {
@@ -1146,9 +1282,9 @@ export class PubChemClient {
1146
1282
  }
1147
1283
  }
1148
1284
  /** List the conformer IDs PubChem has computed for a compound. Returns [] on not-found. */
1149
- async getConformerIds(cid) {
1285
+ async getConformerIds(cid, signal) {
1150
1286
  try {
1151
- 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 });
1152
1288
  return data.InformationList.Information[0]?.ConformerID ?? [];
1153
1289
  }
1154
1290
  catch (error) {