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.
637 lines • 27.8 kB
JavaScript
/**
* @fileoverview Extract outcomes, adverse events, participant flow, baseline, and results metadata from completed studies.
* @module mcp-server/tools/definitions/get-study-results.tool
*/
import { tool, z } from '@cyanheads/mcp-ts-core';
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
import { getClinicalTrialsService } from '../../../services/clinical-trials/clinical-trials-service.js';
import { nctIdSchema } from '../utils/_schemas.js';
import { toArray } from '../utils/query-helpers.js';
import { RECOVERY_HINTS } from '../utils/recovery-hints.js';
const VALID_SECTIONS = [
'outcomes',
'adverseEvents',
'participantFlow',
'baseline',
'moreInfo',
];
/** Map section names to resultsSection module keys. */
const SECTION_MAP = {
outcomes: 'outcomeMeasuresModule',
adverseEvents: 'adverseEventsModule',
participantFlow: 'participantFlowModule',
baseline: 'baselineCharacteristicsModule',
moreInfo: 'moreInfoModule',
};
/**
* Resolve a measurement value for the condensed/text channels. The caller's
* `!= null` guard drops genuinely-empty cells; the `"NA"`/`"NR"` sentinels are
* NOT dropped — for time-to-event MEDIAN measures they encode "median not
* reached", and silently dropping them removes an entire arm from the rendered
* measure (the comparator then reads as the headline result). Surface that
* explicitly for MEDIAN; pass other sentinel-bearing values through unchanged.
*/
function displayMeasurementValue(value, paramType) {
const v = String(value);
return (v === 'NA' || v === 'NR') && paramType === 'MEDIAN' ? 'not reached' : v;
}
/**
* Extract top-line per-group stats from a raw outcome object (full mode).
* Returns undefined if no measurement values are present.
*/
function extractTopStats(o) {
const groups = o.groups;
const classes = o.classes;
if (!groups?.length || !classes?.length)
return;
const firstClass = classes[0];
const categories = firstClass.categories;
const measurements = categories?.[0]?.measurements;
if (!measurements?.length)
return;
const groupMap = new Map(groups.map((g) => [g.id, (g.title ?? g.id)]));
const stats = measurements
.filter((m) => m.value != null)
.map((m) => ({
group: groupMap.get(m.groupId) ?? m.groupId,
value: displayMeasurementValue(m.value, o.paramType),
...(m.spread != null ? { spread: m.spread } : {}),
}));
return stats.length ? stats : undefined;
}
/**
* Extract topline statistical analysis from a raw outcome object. Returns the
* first analysis (typically the primary one), keeping only present fields so
* the summary stays compact.
*/
function extractTopAnalysis(o) {
const analyses = o.analyses;
const a = analyses?.[0];
if (!a)
return;
const keys = [
'statisticalMethod',
'pValue',
'pValueComment',
'paramType',
'paramValue',
'ciPctValue',
'ciNumSides',
'ciLowerLimit',
'ciUpperLimit',
'nonInferiorityType',
'estimateComment',
'groupIds',
];
const out = {};
for (const k of keys)
if (a[k] != null)
out[k] = a[k];
return Object.keys(out).length > 0 ? out : undefined;
}
/** Condense a full outcome measure to its essential metadata plus top-line per-group stats. */
function summarizeOutcome(o) {
const groups = o.groups;
const classes = o.classes;
const topStats = extractTopStats(o);
const topAnalysis = extractTopAnalysis(o);
return {
type: o.type,
title: o.title,
timeFrame: o.timeFrame,
paramType: o.paramType,
unitOfMeasure: o.unitOfMeasure,
reportingStatus: o.reportingStatus,
groupCount: groups?.length,
classCount: classes?.length,
...(topStats ? { topStats } : {}),
...(topAnalysis ? { topAnalysis } : {}),
};
}
const TOP_EVENTS_LIMIT = 20;
/** Sum an adverse event's per-group stats into trial-wide affected/at-risk totals. */
function aggregateEventStats(ev) {
const stats = ev.stats ?? [];
let numAffected = 0;
let numAtRisk = 0;
for (const s of stats) {
numAffected += Number(s.numAffected) || 0;
numAtRisk += Number(s.numAtRisk) || 0;
}
return { numAffected, numAtRisk };
}
/**
* Rank the most frequent adverse events across serious and other groups,
* aggregating each term's affected/at-risk counts across all arms. Trial-wide
* incidence view for summary mode — "which AEs and how common" in ~5KB rather
* than the full ~450KB nested structure.
*/
function topAdverseEvents(ae) {
const collect = (events, kind) => Array.isArray(events)
? events.map((ev) => ({
term: ev.term ?? 'Unspecified',
organSystem: ev.organSystem ?? '',
kind,
...aggregateEventStats(ev),
}))
: [];
return [...collect(ae.seriousEvents, 'serious'), ...collect(ae.otherEvents, 'other')]
.sort((a, b) => b.numAffected - a.numAffected)
.slice(0, TOP_EVENTS_LIMIT);
}
/** Condense the adverse events module to counts plus a ranked top-events view. */
function summarizeAdverseEvents(ae) {
const events = ae.eventGroups;
const topEvents = topAdverseEvents(ae);
return {
timeFrame: ae.timeFrame,
groupCount: Array.isArray(events) ? events.length : undefined,
seriousEventCount: Array.isArray(ae.seriousEvents) ? ae.seriousEvents.length : undefined,
otherEventCount: Array.isArray(ae.otherEvents) ? ae.otherEvents.length : undefined,
...(topEvents.length > 0 ? { topEvents } : {}),
};
}
/** Condense participant flow to period/group counts. */
function summarizeParticipantFlow(pf) {
const groups = pf.groups;
const periods = pf.periods;
return {
groupCount: Array.isArray(groups) ? groups.length : undefined,
periodCount: Array.isArray(periods) ? periods.length : undefined,
};
}
/** Condense baseline characteristics to measure count. */
function summarizeBaseline(bl) {
const groups = bl.groups;
const measures = bl.measures;
return {
groupCount: Array.isArray(groups) ? groups.length : undefined,
measureCount: Array.isArray(measures) ? measures.length : undefined,
measures: Array.isArray(measures)
? measures.map((m) => ({
title: m.title,
paramType: m.paramType,
unitOfMeasure: m.unitOfMeasure,
}))
: undefined,
};
}
/**
* Condense the more-info module — keep the small metadata (limitations, contact,
* agreement flags) and drop only the verbose `certainAgreement.otherDetails` prose.
*/
function summarizeMoreInfo(mi) {
const agreement = mi.certainAgreement;
return {
...(mi.limitationsAndCaveats ? { limitationsAndCaveats: mi.limitationsAndCaveats } : {}),
...(agreement
? {
certainAgreement: {
piSponsorEmployee: agreement.piSponsorEmployee,
restrictiveAgreement: agreement.restrictiveAgreement,
restrictionType: agreement.restrictionType,
},
}
: {}),
...(mi.pointOfContact ? { pointOfContact: mi.pointOfContact } : {}),
};
}
/** Build a groupId→title lookup from a groups array. */
function groupMap(obj) {
const groups = (obj.groups ?? obj.eventGroups);
return new Map((groups ?? []).map((g) => [g.id, (g.title ?? g.id)]));
}
/** Truncate a group title to keep tables readable. */
function shortGroup(title, max = 40) {
return title.length <= max ? title : `${title.slice(0, max - 1)}…`;
}
/** Render one analysis (summary or full) as a single-line bullet. */
function formatAnalysisLine(a) {
const parts = [
a.statisticalMethod ? `Method: ${a.statisticalMethod}` : '',
a.pValue ? `p=${a.pValue}` : '',
a.paramValue ? `${a.paramType ?? 'estimate'}=${a.paramValue}` : '',
a.ciLowerLimit != null && a.ciUpperLimit != null
? `${a.ciPctValue ?? 95}% CI [${a.ciLowerLimit}, ${a.ciUpperLimit}]`
: '',
a.nonInferiorityType ? `(${a.nonInferiorityType})` : '',
].filter(Boolean);
return parts.length ? ` Analysis: ${parts.join(', ')}` : '';
}
function formatOutcomes(outcomes, lines) {
lines.push(`\n### Outcomes (${outcomes.length} measures)`);
for (const o of outcomes) {
const type = o.type ?? '';
const title = o.title ?? 'Untitled';
const timeFrame = o.timeFrame ?? '';
const paramType = o.paramType ?? '';
const unitOfMeasure = o.unitOfMeasure ?? '';
const groupCount = o.groupCount ??
(Array.isArray(o.groups) ? o.groups.length : undefined);
const meta = [type, paramType, unitOfMeasure, groupCount != null ? `${groupCount} groups` : '']
.filter(Boolean)
.join(', ');
const tf = timeFrame ? ` [${timeFrame}]` : '';
lines.push(`- **${title}**${meta ? ` (${meta})` : ''}${tf}`);
const topStats = o.topStats ??
extractTopStats(o);
if (topStats?.length) {
lines.push(` ${topStats.map((s) => `${s.group}: ${s.value}${s.spread ? ` ±${s.spread}` : ''}`).join(' | ')}`);
}
// Summary mode: single condensed analysis lifted from analyses[0].
const topAnalysis = o.topAnalysis;
if (topAnalysis) {
const line = formatAnalysisLine(topAnalysis);
if (line)
lines.push(line);
}
// Full mode: render every analysis on the measure.
const analyses = o.analyses;
if (analyses?.length) {
for (const a of analyses) {
const line = formatAnalysisLine(a);
if (line)
lines.push(line);
}
}
}
}
function formatAdverseEvents(ae, lines) {
lines.push('\n### Adverse Events');
const gm = groupMap(ae);
const timeFrame = ae.timeFrame;
if (timeFrame)
lines.push(`Assessment period: ${timeFrame}`);
const serious = ae.seriousEvents;
const other = ae.otherEvents;
// Summary shape — counts plus the ranked top-events view, no raw event arrays
if (!serious && !other) {
const parts = [
gm.size ? `${gm.size} groups` : '',
ae.seriousEventCount != null ? `${ae.seriousEventCount} serious events` : '',
ae.otherEventCount != null ? `${ae.otherEventCount} other events` : '',
].filter(Boolean);
if (parts.length)
lines.push(parts.join(' | '));
const topEvents = ae.topEvents;
if (topEvents?.length) {
lines.push(`\n**Most frequent events** (top ${topEvents.length} by participants affected)`);
for (const ev of topEvents) {
const sys = ev.organSystem ? ` _(${ev.organSystem})_` : '';
lines.push(`- ${ev.term}${sys} — ${ev.numAffected}/${ev.numAtRisk} affected [${ev.kind}]`);
}
}
return;
}
// Full shape — render actual events with per-group stats
// Render every event the handler returned — payload control is summary mode
// (which condenses per item), never a format()-side row cap (channel parity).
const renderEvents = (label, events) => {
lines.push(`\n**${label}** (${events.length})`);
for (const ev of events) {
const stats = ev.stats;
const statStr = (stats ?? [])
.map((s) => {
const gName = shortGroup(gm.get(s.groupId) ?? s.groupId);
return `${gName}: ${s.numAffected}/${s.numAtRisk}`;
})
.join(', ');
lines.push(`- ${ev.term}${statStr ? ` — ${statStr}` : ''}`);
}
};
if (serious?.length)
renderEvents('Serious Events', serious);
if (other?.length)
renderEvents('Other Events', other);
}
function formatParticipantFlow(pf, lines) {
lines.push('\n### Participant Flow');
const gm = groupMap(pf);
const periods = pf.periods;
// Summary shape — only counts
if (!periods) {
const parts = [
pf.groupCount != null ? `${pf.groupCount} groups` : '',
pf.periodCount != null ? `${pf.periodCount} periods` : '',
].filter(Boolean);
if (parts.length)
lines.push(parts.join(' | '));
return;
}
// Full shape — render milestones with per-group counts
for (const period of periods) {
if (periods.length > 1)
lines.push(`\n**${period.title ?? 'Period'}**`);
const milestones = period.milestones;
for (const ms of milestones ?? []) {
const achievements = ms.achievements;
const achStr = (achievements ?? [])
.map((a) => {
const gName = shortGroup(gm.get(a.groupId) ?? a.groupId);
return `${gName}: ${a.numSubjects ?? a.numUnits ?? '?'}`;
})
.join(', ');
lines.push(`- **${ms.type ?? 'Milestone'}**: ${achStr}`);
}
const drops = period.dropWithdraws;
if (drops?.length) {
for (const d of drops) {
const reasons = d.reasons;
const rStr = (reasons ?? [])
.map((r) => {
const gName = shortGroup(gm.get(r.groupId) ?? r.groupId);
return `${gName}: ${r.numSubjects ?? '?'}`;
})
.join(', ');
lines.push(`- Drop/Withdraw — ${d.type ?? 'reason'}: ${rStr}`);
}
}
}
}
function formatBaseline(bl, lines) {
lines.push('\n### Baseline Characteristics');
const gm = groupMap(bl);
const measures = bl.measures;
// Summary shape — just titles (no classes array on first measure means summarized)
const firstMeasure = measures?.[0];
if (measures?.length &&
firstMeasure &&
!firstMeasure.classes?.length) {
if (gm.size)
lines.push(`${gm.size} groups`);
for (const m of measures) {
const unit = m.unitOfMeasure ? ` (${m.unitOfMeasure})` : '';
lines.push(`- ${m.title ?? 'Measure'}${unit}`);
}
return;
}
// Full shape — render per-group values
if (gm.size)
lines.push(`Groups: ${[...gm.values()].map((g) => shortGroup(g)).join(', ')}`);
for (const m of measures ?? []) {
const title = m.title ?? 'Measure';
const unit = m.unitOfMeasure ? ` (${m.unitOfMeasure})` : '';
const paramType = m.paramType ?? '';
const dispersion = m.dispersionType ?? '';
const desc = [paramType, dispersion].filter(Boolean).join(', ');
lines.push(`- **${title}**${unit}${desc ? ` [${desc}]` : ''}`);
const classes = m.classes;
for (const cls of classes ?? []) {
const clsTitle = cls.title;
const categories = cls.categories;
for (const cat of categories ?? []) {
const catTitle = cat.title;
const prefix = clsTitle ?? catTitle ?? '';
const measurements = cat.measurements;
const vals = (measurements ?? [])
.filter((v) => v.value != null)
.map((v) => {
const gName = shortGroup(gm.get(v.groupId) ?? v.groupId);
const spread = v.spread != null ? ` ±${v.spread}` : '';
return `${gName}: ${displayMeasurementValue(v.value, paramType)}${spread}`;
})
.join(', ');
if (vals)
lines.push(` ${prefix ? `${prefix}: ` : ''}${vals}`);
}
}
}
}
function formatMoreInfo(mi, lines) {
// Header is unconditional, matching the sibling section renderers — keeps the
// section visible on the content[] channel for format-parity, whose synthetic
// sample for an opaque record carries none of the named sub-keys below.
lines.push('\n### More Info');
const lim = mi.limitationsAndCaveats;
const agr = mi.certainAgreement;
const poc = mi.pointOfContact;
if (lim?.description)
lines.push(`**Limitations & Caveats:** ${lim.description}`);
if (agr) {
const parts = [
agr.restrictiveAgreement != null
? `Restrictive agreement: ${agr.restrictiveAgreement ? 'yes' : 'no'}`
: '',
agr.restrictionType ? `Type: ${agr.restrictionType}` : '',
agr.piSponsorEmployee != null
? `PI is sponsor employee: ${agr.piSponsorEmployee ? 'yes' : 'no'}`
: '',
].filter(Boolean);
if (parts.length)
lines.push(`**Certain Agreement:** ${parts.join(' | ')}`);
if (agr.otherDetails)
lines.push(` ${agr.otherDetails}`);
}
if (poc) {
const parts = [poc.title, poc.organization, poc.email, poc.phone].filter(Boolean);
if (parts.length)
lines.push(`**Point of Contact:** ${parts.join(' | ')}`);
}
}
export const getStudyResults = tool('clinicaltrials_get_study_results', {
description: `Fetch clinical trial results data from ClinicalTrials.gov for completed studies — outcome measures with statistics, adverse events, participant flow, baseline characteristics, and results metadata (limitations & caveats, certain-agreement disclosure restrictions, results point of contact). Only available for studies where hasResults is true. Use clinicaltrials_search_studies first to find studies with results.`,
annotations: {
readOnlyHint: true,
idempotentHint: true,
openWorldHint: true,
},
errors: [
{
reason: 'study_not_found',
code: JsonRpcErrorCode.NotFound,
when: 'A provided NCT ID does not match any study at ClinicalTrials.gov.',
recovery: RECOVERY_HINTS.study_not_found,
},
{
reason: 'rate_limited',
code: JsonRpcErrorCode.RateLimited,
when: 'ClinicalTrials.gov returned 429 after retry budget exhausted.',
recovery: RECOVERY_HINTS.rate_limited,
retryable: true,
},
],
input: z.object({
nctIds: z
.union([
nctIdSchema.describe('A single NCT ID.'),
z.array(nctIdSchema).min(1).max(20).describe('Multiple NCT IDs (max 20).'),
])
.describe('One or more NCT IDs (max 20). E.g., "NCT12345678" or ["NCT12345678", "NCT87654321"]. Use summary=true for large batches to avoid large payloads.'),
sections: z
.union([
z.enum(VALID_SECTIONS).describe('A single section name.'),
z.array(z.enum(VALID_SECTIONS)).describe('Multiple section names.'),
])
.optional()
.describe(`Filter which sections to return. Values: outcomes, adverseEvents, participantFlow, baseline, moreInfo. Omit for all sections.`),
summary: z
.boolean()
.default(false)
.describe('Return condensed summaries instead of full data. Reduces payload from ~200KB to ~5KB per study. Summaries include outcome titles, types, timeframes, group counts, and top-level stats — omitting individual measurements, analyses, and per-group data.'),
}),
output: z.object({
results: z
.array(z
.object({
nctId: z.string().describe('NCT identifier.'),
title: z.string().describe('Study title.'),
hasResults: z.boolean().describe('Whether study has posted results.'),
outcomes: z
.array(z.record(z.string(), z.unknown()))
.optional()
.describe('Outcome measures with per-group statistics. Summary mode (compact): type, title, timeFrame, paramType, unitOfMeasure, group/class counts, plus topStats (per-group measurements) and topAnalysis (statisticalMethod, pValue, paramType/Value, ciPctValue/Lower/Upper, nonInferiorityType, groupIds — lifted from analyses[0]) when present. Full mode (default): adds raw groups, classes, categories, measurements, and analyses arrays.'),
adverseEvents: z
.record(z.string(), z.unknown())
.optional()
.describe('Adverse events. Summary mode: timeFrame, groupCount, seriousEventCount, otherEventCount, plus topEvents — the most frequent events ranked by participants affected, aggregated across arms (term, organSystem, kind, numAffected, numAtRisk). Full mode: adds eventGroups, seriousEvents, otherEvents with per-event term and per-group affected/at-risk stats.'),
participantFlow: z
.record(z.string(), z.unknown())
.optional()
.describe('Participant flow milestones and drop-outs. Summary mode: groupCount, periodCount. Full mode: adds groups and periods with per-period milestones, achievements, and dropWithdraws.'),
baseline: z
.record(z.string(), z.unknown())
.optional()
.describe('Baseline characteristics. Summary mode: groupCount, measureCount, and measures (title, paramType, unitOfMeasure). Full mode: adds groups and measures with per-group classes/categories/measurements.'),
moreInfo: z
.record(z.string(), z.unknown())
.optional()
.describe('Results metadata from moreInfoModule. Summary mode: limitationsAndCaveats, certainAgreement flags (piSponsorEmployee, restrictiveAgreement, restrictionType), and pointOfContact. Full mode: adds certainAgreement.otherDetails.'),
})
.describe('Extracted results for one study.'))
.describe('Results per study.'),
studiesWithoutResults: z
.array(z.string())
.optional()
.describe('NCT IDs that do not have results data.'),
fetchErrors: z
.array(z
.object({
nctId: z.string().describe('NCT ID.'),
error: z.string().describe('Error message.'),
})
.describe('A single fetch error.'))
.optional()
.describe('Studies that could not be fetched.'),
}),
async handler(input, ctx) {
const nctIds = toArray(input.nctIds);
const sections = input.sections
? Array.isArray(input.sections)
? input.sections
: [input.sections]
: [...VALID_SECTIONS];
const service = getClinicalTrialsService();
const results = [];
const studiesWithoutResults = [];
const fetchErrors = [];
const erroredIds = new Set();
let fetched;
try {
fetched = (await service.getStudiesBatch(nctIds, ctx));
}
catch (err) {
// The batch endpoint rejects the whole request if any single ID is
// malformed or nonexistent. Fall back to per-ID fetches so valid IDs
// still succeed and only failing IDs land in fetchErrors. Sequential
// to honor the service's rate limit (~1 req/sec).
const batchMessage = err instanceof Error ? err.message : String(err);
ctx.log.warning('Batch fetch rejected; falling back to per-ID fetches', {
count: nctIds.length,
error: batchMessage,
});
fetched = [];
for (const nctId of nctIds) {
try {
fetched.push((await service.getStudy(nctId, ctx)));
}
catch (perIdErr) {
const perIdMessage = perIdErr instanceof Error ? perIdErr.message : String(perIdErr);
fetchErrors.push({ nctId, error: perIdMessage });
erroredIds.add(nctId);
}
}
}
const studyMap = new Map(fetched
.map((s) => [s.protocolSection?.identificationModule?.nctId, s])
.filter((e) => e[0] != null));
for (const nctId of nctIds) {
if (erroredIds.has(nctId))
continue;
const study = studyMap.get(nctId);
if (!study) {
fetchErrors.push({ nctId, error: 'Study not found' });
continue;
}
const title = study.protocolSection?.identificationModule?.briefTitle ?? 'Unknown';
const hasResults = study.hasResults === true;
if (!hasResults) {
studiesWithoutResults.push(nctId);
results.push({ nctId, title, hasResults: false });
continue;
}
const rs = study.resultsSection ?? {};
const entry = { nctId, title, hasResults: true };
for (const section of sections) {
const moduleKey = SECTION_MAP[section];
const data = rs[moduleKey];
if (data) {
if (section === 'outcomes') {
const measures = data.outcomeMeasures ?? [];
entry.outcomes = input.summary ? measures.map(summarizeOutcome) : measures;
}
else if (input.summary) {
if (section === 'adverseEvents')
entry.adverseEvents = summarizeAdverseEvents(data);
else if (section === 'participantFlow')
entry.participantFlow = summarizeParticipantFlow(data);
else if (section === 'baseline')
entry.baseline = summarizeBaseline(data);
else if (section === 'moreInfo')
entry.moreInfo = summarizeMoreInfo(data);
}
else {
entry[section] = data;
}
}
}
results.push(entry);
}
ctx.log.info('Results extracted', {
resultCount: results.length,
withoutResults: studiesWithoutResults.length,
errors: fetchErrors.length,
});
return {
results,
...(studiesWithoutResults.length > 0 ? { studiesWithoutResults } : {}),
...(fetchErrors.length > 0 ? { fetchErrors } : {}),
};
},
format: (result) => {
const lines = [];
for (const r of result.results) {
lines.push(`## ${r.nctId}: ${r.title}`);
if (!r.hasResults) {
lines.push('No results available.\n');
continue;
}
if (r.outcomes?.length)
formatOutcomes(r.outcomes, lines);
if (r.adverseEvents)
formatAdverseEvents(r.adverseEvents, lines);
if (r.participantFlow)
formatParticipantFlow(r.participantFlow, lines);
if (r.baseline)
formatBaseline(r.baseline, lines);
if (r.moreInfo)
formatMoreInfo(r.moreInfo, lines);
lines.push('');
}
if (result.studiesWithoutResults?.length)
lines.push(`Without results: ${result.studiesWithoutResults.join(', ')}`);
if (result.fetchErrors?.length)
lines.push(`Fetch errors: ${result.fetchErrors.map((e) => `${e.nctId}: ${e.error}`).join(', ')}`);
return [{ type: 'text', text: lines.join('\n') }];
},
});
//# sourceMappingURL=get-study-results.tool.js.map