@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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +4 -2
- package/changelog/0.6.x/0.6.5.md +22 -0
- package/dist/mcp-server/resources/definitions/assay.resource.js +2 -2
- package/dist/mcp-server/resources/definitions/assay.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound-bioactivity.resource.js +2 -2
- package/dist/mcp-server/resources/definitions/compound-bioactivity.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound-image.resource.js +2 -2
- package/dist/mcp-server/resources/definitions/compound-image.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound-safety.resource.js +2 -2
- package/dist/mcp-server/resources/definitions/compound-safety.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound-xrefs.resource.js +2 -2
- package/dist/mcp-server/resources/definitions/compound-xrefs.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/compound.resource.js +2 -2
- package/dist/mcp-server/resources/definitions/compound.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-bioactivity.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/get-bioactivity.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-3d-structure.tool.js +2 -2
- package/dist/mcp-server/tools/definitions/get-compound-3d-structure.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-details.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-details.tool.js +4 -4
- package/dist/mcp-server/tools/definitions/get-compound-details.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-image.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-image.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-interactions.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-safety.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-safety.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-xrefs.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/get-compound-xrefs.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-summary.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/get-summary.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +11 -0
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-assays.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/search-assays.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-compounds.tool.d.ts +11 -0
- package/dist/mcp-server/tools/definitions/search-compounds.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-compounds.tool.js +85 -37
- package/dist/mcp-server/tools/definitions/search-compounds.tool.js.map +1 -1
- package/dist/services/pubchem/pubchem-client.d.ts +40 -26
- package/dist/services/pubchem/pubchem-client.d.ts.map +1 -1
- package/dist/services/pubchem/pubchem-client.js +206 -99
- package/dist/services/pubchem/pubchem-client.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -2
- 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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
65
|
-
const sleep = (ms) => new Promise((
|
|
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
|
|
416
|
-
* network error, and surface a clean timeout message. Returns
|
|
417
|
-
* extract the body (JSON, bytes, or text). Centralizing this keeps
|
|
418
|
-
* one resilience contract — the divergence that left fetchBinary
|
|
419
|
-
* timeout message (#16) cannot recur.
|
|
420
|
-
|
|
421
|
-
|
|
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
|
|
425
|
-
const timeoutId = setTimeout(() =>
|
|
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, {
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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.
|
|
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.
|
|
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}`,
|
|
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
|
-
|
|
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}`,
|
|
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
|
-
|
|
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
|
|
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
|
|
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) {
|