clinicaltrialsgov-mcp-server
Version:
Search ClinicalTrials.gov trials, retrieve study details and results, and match patients to eligible trials via MCP. STDIO or Streamable HTTP.
544 lines • 28.8 kB
JavaScript
/**
* @fileoverview ClinicalTrials.gov REST API v2 client with retry, rate limiting, and timeout.
* @module services/clinical-trials/clinical-trials-service
*/
import { JsonRpcErrorCode, McpError, notFound, rateLimited, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
import { httpErrorFromResponse } from '@cyanheads/mcp-ts-core/utils';
import { getServerConfig } from '../../config/server-config.js';
import { flattenMetadata, nearestPieces, searchFields, } from './field-search.js';
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_BASE_BACKOFF_MS = 1000;
const DEFAULT_MAX_BACKOFF_MS = 30_000;
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
const MIN_INTERVAL_MS = 1000;
/**
* Maps the trailing segment of an Essie field path (lowercased) to the
* corresponding tool param name. Used to translate "Allowed values for enum
* field `…path…`" errors into human-readable param references.
*/
const ESSIE_ENUM_PARAM_MAP = {
phases: 'phaseFilter',
};
/**
* ClinicalTrials.gov uses 99999999 as a sentinel for "unknown enrollment
* count". The filter below excludes studies carrying the sentinel by
* default — `RANGE[5000, MAX]` and `EnrollmentCount:desc` otherwise surface
* sentinel-polluted results that look like the largest trials but aren't.
*/
const ENROLLMENT_SENTINEL_FILTER = 'AREA[EnrollmentCount]RANGE[0, 99999998]';
/**
* Colloquial / legacy field labels that map unambiguously to a canonical v2
* piece. ClinicalTrials.gov's UI and legacy facets surface labels like
* "Recruitment Status" that aren't API v2 piece names, so models reach for them
* by reflex. Applied in normalizeFields as an auto-correct (mirroring the
* case/whitespace fix) so the call succeeds instead of erroring with a
* did-you-mean. Keyed by lowercased, separator-stripped input.
*/
const KNOWN_FIELD_RENAMES = {
recruitmentstatus: 'OverallStatus',
recruitingstatus: 'OverallStatus',
};
export class ClinicalTrialsService {
baseUrl;
timeoutMs;
maxPageSize;
maxRetries;
baseBackoffMs;
maxBackoffMs;
validateFieldsLocally;
lastRequestAt = 0;
fieldIndexPromise;
constructor(config, options = {}) {
this.baseUrl = config.apiBaseUrl;
this.timeoutMs = config.requestTimeoutMs;
this.maxPageSize = config.maxPageSize;
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
this.baseBackoffMs = options.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
this.maxBackoffMs = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
this.validateFieldsLocally = options.validateFieldsLocally ?? true;
}
/** Search studies with query, filters, pagination, and field selection. */
async searchStudies(params, ctx) {
if (this.validateFieldsLocally && params.fields?.length) {
const normalized = await this.normalizeFields(params.fields, ctx);
await this.validateFields(normalized, ctx);
params = { ...params, fields: normalized };
}
const q = this.buildSearchQuery(params);
ctx.log.debug('searchStudies', { paramKeys: Object.keys(q) });
return this.fetchJson('/studies', q, ctx);
}
/** Fetch a single study by NCT ID. */
getStudy(nctId, ctx) {
ctx.log.debug('getStudy', { nctId });
return this.fetchJson(`/studies/${encodeURIComponent(nctId)}`, {}, ctx);
}
/** Fetch multiple studies by NCT IDs in a single request. Returns identification and results section data. */
async getStudiesBatch(nctIds, ctx) {
ctx.log.debug('getStudiesBatch', { count: nctIds.length });
const response = await this.searchStudies({
filterIds: nctIds,
fields: ['NCTId', 'BriefTitle', 'HasResults', 'ResultsSection'],
pageSize: nctIds.length,
// ID-targeted lookups must never filter the caller's selection.
includeUnknownEnrollment: true,
}, ctx);
return response.studies;
}
/** Get field definitions (metadata tree) from the data model. */
getMetadata(includeIndexedOnly, ctx) {
ctx.log.debug('getMetadata', { includeIndexedOnly });
const params = {};
if (includeIndexedOnly)
params.includeIndexedOnly = 'true';
return this.fetchJson('/studies/metadata', params, ctx, { jsonFormat: false });
}
/** Get field value statistics for the specified fields. */
async getFieldValues(fields, ctx) {
ctx.log.debug('getFieldValues', { fields });
if (this.validateFieldsLocally) {
fields = await this.normalizeFields(fields, ctx);
await this.validateFields(fields, ctx);
}
try {
const stats = await this.fetchJson('/stats/field/values', { fields: fields.join('|') }, ctx, { jsonFormat: false });
// Flag multi-valued fields so callers can read the per-value counts
// correctly — array-type fields (e.g. Phase, Condition) let one study carry
// several values, so buckets sum above the study total. The metadata index
// is already cached by validateFields above, so this adds no round-trip;
// skipped when local validation is disabled (no metadata available).
if (this.validateFieldsLocally) {
await this.annotateMultiValued(stats, ctx);
}
return stats;
}
catch (err) {
if (err instanceof McpError && err.code === JsonRpcErrorCode.NotFound) {
// Upstream reports only the first bad name ("Unknown piece name of field
// path: X"); extract X so we blame only that field. When multi-field
// requests fail, other inputs may also be bad — upstream doesn't say,
// so we note that in the error text.
const match = err.message.match(/Unknown piece name of field path:\s*(\S+)/i);
const badName = match?.[1];
const message = badName
? fields.length > 1 && !fields.every((f) => f === badName)
? `Invalid field name: '${badName}'. Other submitted fields (${fields
.filter((f) => f !== badName)
.join(', ')}) may also be invalid — upstream reports only the first offender. Re-run without '${badName}' to verify the rest.`
: `Invalid field name: '${badName}'.`
: `Invalid field name(s): ${fields.join(', ')}.`;
throw validationError(`${message} Use PascalCase piece names like OverallStatus, Phase, StudyType, InterventionType, LeadSponsorClass, Sex, StdAge. Call clinicaltrials_get_field_definitions to browse the full field tree.`, { reason: 'field_invalid', ...ctx.recoveryFor('field_invalid') });
}
throw err;
}
}
/**
* Mark each stat as multi-valued by checking the metadata node `type` for an
* array marker (`[]`, e.g. `Phase[]`, `text[]`) — the durable source of
* cardinality. Note: the stat's own `type` (`ENUM`/`STRING`) is the value
* domain, not the array marker, so the metadata node type is the only signal.
* Reuses the cached field index (no round-trip); fails open silently if the
* metadata index is unavailable.
*/
async annotateMultiValued(stats, ctx) {
let entries;
try {
({ entries } = await this.getFieldIndex(ctx));
}
catch {
return;
}
const arrayPieces = new Set(entries.filter((e) => e.type?.endsWith('[]')).map((e) => e.piece));
for (const stat of stats) {
if (arrayPieces.has(stat.piece))
stat.multiValued = true;
}
}
/**
* Search the field model by keyword, returning ranked matches with paths and
* types plus the pre-cap match total for accurate truncation disclosure.
*/
async searchFieldDefinitions(query, limit, ctx) {
ctx.log.debug('searchFieldDefinitions', { query, limit });
const { entries } = await this.getFieldIndex(ctx);
return searchFields(query, entries, limit);
}
/* ------------------------------------------------------------------ */
/* Internal */
/* ------------------------------------------------------------------ */
/** Lazy-load and memoize the flattened field index from /studies/metadata. */
getFieldIndex(ctx) {
if (!this.fieldIndexPromise) {
this.fieldIndexPromise = (async () => {
const tree = await this.getMetadata(false, ctx);
const entries = flattenMetadata(tree);
const pieceSet = new Set(entries.map((e) => e.piece));
// Case-fold index — skip any lowered form that collides across canonicals
// so normalization stays ambiguity-free.
const lowerCounts = new Map();
for (const p of pieceSet) {
const lp = p.toLowerCase();
lowerCounts.set(lp, (lowerCounts.get(lp) ?? 0) + 1);
}
const caseFold = new Map();
for (const p of pieceSet) {
const lp = p.toLowerCase();
if (lowerCounts.get(lp) === 1)
caseFold.set(lp, p);
}
ctx.log.debug('Field index built', {
entryCount: entries.length,
caseFoldable: caseFold.size,
});
return { entries, pieceSet, caseFold };
})().catch((err) => {
// Reset on failure so the next call can retry the metadata fetch
this.fieldIndexPromise = undefined;
throw err;
});
}
return this.fieldIndexPromise;
}
/**
* Apply unambiguous fixes (whitespace, case-only) to field names before
* validation. Returns the corrected list — anything still invalid falls
* through to validateFields and surfaces the structured did-you-mean error.
* Logs corrections via ctx.log.notice so operators can spot recurring LLM
* mistakes without forcing a tool-call round-trip.
*/
async normalizeFields(fields, ctx) {
let pieceSet;
let caseFold;
try {
({ pieceSet, caseFold } = await this.getFieldIndex(ctx));
}
catch {
// Fall through silently — validateFields runs next and emits the
// metadata-unavailable warning + fail-open behavior on the same failure.
return fields;
}
const corrections = [];
const normalized = fields.map((f) => {
if (pieceSet.has(f))
return f;
const trimmed = f.trim();
if (trimmed !== f && pieceSet.has(trimmed)) {
corrections.push({ from: f, to: trimmed });
return trimmed;
}
const folded = caseFold.get(trimmed.toLowerCase());
if (folded) {
corrections.push({ from: f, to: folded });
return folded;
}
const renamed = KNOWN_FIELD_RENAMES[trimmed.toLowerCase().replace(/[\s_]+/g, '')];
if (renamed) {
corrections.push({ from: f, to: renamed });
return renamed;
}
return f;
});
if (corrections.length > 0) {
ctx.log.notice('Field names auto-corrected', { corrections });
}
return normalized;
}
/** Reject invalid field names locally with did-you-mean suggestions. */
async validateFields(fields, ctx) {
let entries;
let pieceSet;
try {
({ entries, pieceSet } = await this.getFieldIndex(ctx));
}
catch (err) {
// Fail open — if the metadata index can't be built, fall through to the
// upstream API and let its error handling surface any problem. Worst case,
// the agent gets the same error it would have without pre-validation.
ctx.log.warning('Field validation skipped — metadata unavailable', {
error: err instanceof Error ? err.message : String(err),
});
return;
}
const invalid = fields.filter((f) => !pieceSet.has(f));
if (invalid.length === 0)
return;
const suggestions = {};
for (const f of invalid) {
const near = nearestPieces(f, entries, 3);
if (near.length > 0)
suggestions[f] = near;
}
const header = invalid.length === 1
? `Invalid field name: '${invalid[0]}'.`
: `Invalid field names: ${invalid.map((f) => `'${f}'`).join(', ')}.`;
const hintParts = Object.entries(suggestions).map(([f, near]) => `'${f}' — did you mean ${near.map((p) => `'${p}'`).join(', ')}?`);
const message = hintParts.length > 0 ? `${header} ${hintParts.join(' ')}` : header;
throw validationError(message, {
reason: 'field_invalid',
invalid,
...(Object.keys(suggestions).length > 0 ? { suggestions } : {}),
...ctx.recoveryFor('field_invalid'),
});
}
buildSearchQuery(params) {
const q = {};
if (params.queryTerm)
q['query.term'] = params.queryTerm;
if (params.queryCond)
q['query.cond'] = params.queryCond;
if (params.queryIntr)
q['query.intr'] = params.queryIntr;
if (params.queryLocn)
q['query.locn'] = params.queryLocn;
if (params.querySpons)
q['query.spons'] = params.querySpons;
if (params.queryTitles)
q['query.titles'] = params.queryTitles;
if (params.queryOutc)
q['query.outc'] = params.queryOutc;
if (params.filterOverallStatus?.length)
q['filter.overallStatus'] = params.filterOverallStatus.join('|');
if (params.filterGeo)
q['filter.geo'] = params.filterGeo;
if (params.filterIds?.length)
q['filter.ids'] = params.filterIds.join('|');
const advancedParts = [];
if (params.filterAdvanced)
advancedParts.push(params.filterAdvanced);
if (!params.includeUnknownEnrollment)
advancedParts.push(ENROLLMENT_SENTINEL_FILTER);
if (advancedParts.length > 0) {
q['filter.advanced'] =
advancedParts.length === 1
? advancedParts.join('')
: advancedParts.map((p) => `(${p})`).join(' AND ');
}
if (params.fields?.length)
q.fields = params.fields.join('|');
if (params.sort)
q.sort = params.sort;
if (params.countTotal !== undefined)
q.countTotal = String(params.countTotal);
if (params.pageSize !== undefined)
q.pageSize = String(Math.min(params.pageSize, this.maxPageSize));
if (params.pageToken)
q.pageToken = params.pageToken;
return q;
}
async throttle() {
const wait = MIN_INTERVAL_MS - (Date.now() - this.lastRequestAt);
if (wait > 0)
await new Promise((r) => setTimeout(r, wait));
this.lastRequestAt = Date.now();
}
async fetchJson(path, params, ctx, { jsonFormat = true } = {}) {
const url = new URL(`${this.baseUrl}${path}`);
if (jsonFormat)
url.searchParams.set('format', 'json');
for (const [k, v] of Object.entries(params)) {
if (v)
url.searchParams.set(k, v);
}
let lastError;
let lastStatus;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
if (ctx.signal.aborted)
throw new Error('Request cancelled');
if (attempt > 0) {
const base = Math.min(this.baseBackoffMs * 2 ** (attempt - 1), this.maxBackoffMs);
const delay = base * (0.75 + 0.5 * Math.random());
ctx.log.debug('Retrying', {
attempt,
delay: Math.round(delay),
path,
lastStatus,
});
await new Promise((r) => setTimeout(r, delay));
}
await this.throttle();
try {
const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(this.timeoutMs)]);
const res = await fetch(url, {
signal,
headers: { Accept: 'application/json' },
});
if (res.ok) {
const ct = res.headers.get('content-type') ?? '';
if (!ct.includes('json')) {
const text = await res.text();
if (text.includes('<html') || text.includes('