UNPKG

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
/** * @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