Skip to main content
Glama

ClinicalTrials.gov MCP Server

clinicaltrials-compare-studies.tool.ts•24.5 kB
/** * @fileoverview Complete, declarative definition for the 'clinicaltrials_compare_studies' tool. * Performs side-by-side comparison of 2-5 clinical trials. * * @module src/mcp-server/tools/definitions/clinicaltrials-compare-studies.tool */ import type { ContentBlock } from '@modelcontextprotocol/sdk/types.js'; import { container } from 'tsyringe'; import { z } from 'zod'; import { ClinicalTrialsProvider } from '@/container/tokens.js'; import type { SdkContext, ToolAnnotations, ToolDefinition, } from '@/mcp-server/tools/utils/toolDefinition.js'; import { withToolAuth } from '@/mcp-server/transports/auth/lib/withAuth.js'; import type { IClinicalTrialsProvider } from '@/services/clinical-trials-gov/core/IClinicalTrialsProvider.js'; import type { Study } from '@/services/clinical-trials-gov/types.js'; import { JsonRpcErrorCode, McpError } from '@/types-global/errors.js'; import { logger, type RequestContext } from '@/utils/index.js'; /** --------------------------------------------------------- */ /** Programmatic tool name (must be unique). */ const TOOL_NAME = 'clinicaltrials_compare_studies'; /** --------------------------------------------------------- */ /** Human-readable title used by UIs. */ const TOOL_TITLE = 'Compare Clinical Studies'; /** --------------------------------------------------------- */ /** * LLM-facing description of the tool. */ const TOOL_DESCRIPTION = 'Performs side-by-side comparison of 2-5 clinical trial studies. Compares eligibility criteria, design, interventions, outcomes, sponsors, and other key aspects.'; /** --------------------------------------------------------- */ /** UI/behavior hints for clients. */ const TOOL_ANNOTATIONS: ToolAnnotations = { readOnlyHint: true, idempotentHint: true, openWorldHint: true, // Accesses external ClinicalTrials.gov API }; /** --------------------------------------------------------- */ // // Schemas (input and output) // -------------------------- /** * Defines the comparison categories available. */ const ComparisonCategorySchema = z.enum([ 'eligibility', 'design', 'interventions', 'outcomes', 'sponsors', 'locations', 'status', 'all', ]); const InputSchema = z .object({ nctIds: z .array(z.string().regex(/^[Nn][Cc][Tt]\d{8}$/, 'NCT ID must be 8 digits')) .min(2, 'At least 2 NCT IDs are required for comparison.') .max(5, 'Maximum 5 NCT IDs allowed for comparison.') .describe('An array of 2-5 NCT IDs to compare.'), compareFields: z .union([ ComparisonCategorySchema.describe( 'A single comparison category to analyze.', ), z .array(ComparisonCategorySchema) .min(1) .describe('An array of comparison categories to analyze.'), ]) .default('all') .describe( 'Specify which aspects to compare: eligibility, design, interventions, outcomes, sponsors, locations, status, or all.', ), }) .describe('Input parameters for comparing clinical trial studies.'); const StudyComparisonSchema = z .object({ nctId: z.string().describe('The NCT identifier.'), title: z.string().optional().describe('The study title.'), eligibility: z .object({ criteria: z.string().optional().describe('Eligibility criteria text.'), sex: z.string().optional().describe('Sex requirement.'), minimumAge: z.string().optional().describe('Minimum age.'), healthyVolunteers: z .boolean() .optional() .describe('Accepts healthy volunteers.'), stdAges: z .array(z.string()) .optional() .describe('Standard age groups.'), }) .optional() .describe('Eligibility criteria details.'), design: z .object({ studyType: z.string().optional().describe('Type of study.'), phases: z.array(z.string()).optional().describe('Trial phases.'), allocation: z.string().optional().describe('Allocation method.'), interventionModel: z .string() .optional() .describe('Intervention model.'), primaryPurpose: z.string().optional().describe('Primary purpose.'), masking: z.string().optional().describe('Masking/blinding approach.'), }) .optional() .describe('Study design details.'), interventions: z .array( z.object({ type: z.string().optional().describe('Intervention type.'), name: z.string().optional().describe('Intervention name.'), description: z.string().optional().describe('Description.'), }), ) .optional() .describe('List of interventions.'), outcomes: z .object({ primary: z .array( z.object({ measure: z.string().optional().describe('Outcome measure.'), timeFrame: z.string().optional().describe('Time frame.'), }), ) .optional() .describe('Primary outcomes.'), secondary: z .array( z.object({ measure: z.string().optional().describe('Outcome measure.'), timeFrame: z.string().optional().describe('Time frame.'), }), ) .optional() .describe('Secondary outcomes.'), }) .optional() .describe('Outcome measures.'), sponsors: z .object({ leadSponsor: z .object({ name: z.string().optional().describe('Sponsor name.'), class: z.string().optional().describe('Sponsor class.'), }) .optional() .describe('Lead sponsor.'), collaborators: z .array( z.object({ name: z.string().optional().describe('Collaborator name.'), class: z.string().optional().describe('Collaborator class.'), }), ) .optional() .describe('Collaborators.'), }) .optional() .describe('Sponsor information.'), locations: z .object({ totalCount: z .number() .optional() .describe('Total number of locations.'), countries: z .array(z.string()) .optional() .describe('List of countries.'), topCities: z .array(z.string()) .optional() .describe('Top cities by location count.'), }) .optional() .describe('Location summary.'), status: z .object({ overallStatus: z.string().optional().describe('Overall status.'), startDate: z.string().optional().describe('Start date.'), completionDate: z.string().optional().describe('Completion date.'), lastUpdateDate: z.string().optional().describe('Last update date.'), }) .optional() .describe('Status and timeline information.'), }) .describe('Structured comparison data for a single study.'); const OutputSchema = z .object({ comparisons: z .array(StudyComparisonSchema) .describe('Array of study comparisons.'), summary: z .object({ totalStudies: z.number().describe('Number of studies compared.'), comparedFields: z .array(z.string()) .describe('Fields that were compared.'), commonalities: z .array(z.string()) .optional() .describe('Key commonalities found across studies.'), differences: z .array(z.string()) .optional() .describe('Key differences found across studies.'), }) .describe('Summary of the comparison.'), errors: z .array( z.object({ nctId: z.string().describe('The NCT ID that failed.'), error: z.string().describe('Error message.'), }), ) .optional() .describe('Any errors encountered during comparison.'), }) .describe('Comparison results for clinical trial studies.'); type CompareStudiesInput = z.infer<typeof InputSchema>; type StudyComparison = z.infer<typeof StudyComparisonSchema>; type CompareStudiesOutput = z.infer<typeof OutputSchema>; // // Helper functions // -------------------------- /** * Extracts eligibility data from a study. */ function extractEligibility(study: Study): StudyComparison['eligibility'] { const eligibility = study.protocolSection?.eligibilityModule; if (!eligibility) return undefined; return { criteria: eligibility.eligibilityCriteria, sex: eligibility.sex, minimumAge: eligibility.minimumAge, healthyVolunteers: eligibility.healthyVolunteers, stdAges: eligibility.stdAges, }; } /** * Extracts design data from a study. */ function extractDesign(study: Study): StudyComparison['design'] { const design = study.protocolSection?.designModule; if (!design) return undefined; return { studyType: design.studyType, phases: design.phases, allocation: design.designInfo?.allocation, interventionModel: design.designInfo?.interventionModel, primaryPurpose: design.designInfo?.primaryPurpose, masking: design.designInfo?.maskingInfo?.masking, }; } /** * Extracts intervention data from a study. */ function extractInterventions(study: Study): StudyComparison['interventions'] { const interventions = study.protocolSection?.armsInterventionsModule?.interventions; if (!interventions) return undefined; return interventions.map((i) => ({ type: i.type, name: i.name, description: i.description, })); } /** * Extracts outcome measures from a study. */ function extractOutcomes(study: Study): StudyComparison['outcomes'] { const outcomes = study.protocolSection?.outcomesModule; if (!outcomes) return undefined; return { primary: outcomes.primaryOutcomes?.map((o) => ({ measure: o.measure, timeFrame: o.timeFrame, })), secondary: outcomes.secondaryOutcomes?.map((o) => ({ measure: o.measure, timeFrame: o.timeFrame, })), }; } /** * Extracts sponsor information from a study. */ function extractSponsors(study: Study): StudyComparison['sponsors'] { const sponsors = study.protocolSection?.sponsorCollaboratorsModule; if (!sponsors) return undefined; return { leadSponsor: sponsors.leadSponsor ? { name: sponsors.leadSponsor.name, class: sponsors.leadSponsor.class, } : undefined, collaborators: sponsors.collaborators?.map((c) => ({ name: c.name, class: c.class, })), }; } /** * Extracts location summary from a study. */ function extractLocations(study: Study): StudyComparison['locations'] { const locations = study.protocolSection?.contactsLocationsModule?.locations; if (!locations || locations.length === 0) return undefined; const countries = [ ...new Set(locations.map((l) => l.country).filter(Boolean)), ] as string[]; const cityCount: Record<string, number> = {}; locations.forEach((loc) => { if (loc.city) { cityCount[loc.city] = (cityCount[loc.city] ?? 0) + 1; } }); const topCities = Object.entries(cityCount) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([city]) => city); return { totalCount: locations.length, countries, topCities, }; } /** * Extracts status and timeline information from a study. */ function extractStatus(study: Study): StudyComparison['status'] { const status = study.protocolSection?.statusModule; if (!status) return undefined; return { overallStatus: status.overallStatus, startDate: status.startDateStruct?.date, completionDate: status.completionDateStruct?.date, lastUpdateDate: status.lastUpdatePostDateStruct?.date, }; } /** * Creates a study comparison based on selected fields. */ function createStudyComparison( study: Study, compareFields: string[], ): StudyComparison { const nctId = study.protocolSection?.identificationModule?.nctId ?? 'Unknown'; const title = study.protocolSection?.identificationModule?.officialTitle ?? study.protocolSection?.identificationModule?.briefTitle; const comparison: StudyComparison = { nctId, title }; const shouldInclude = (field: string) => compareFields.includes('all') || compareFields.includes(field); if (shouldInclude('eligibility')) { comparison.eligibility = extractEligibility(study); } if (shouldInclude('design')) { comparison.design = extractDesign(study); } if (shouldInclude('interventions')) { comparison.interventions = extractInterventions(study); } if (shouldInclude('outcomes')) { comparison.outcomes = extractOutcomes(study); } if (shouldInclude('sponsors')) { comparison.sponsors = extractSponsors(study); } if (shouldInclude('locations')) { comparison.locations = extractLocations(study); } if (shouldInclude('status')) { comparison.status = extractStatus(study); } return comparison; } /** * Analyzes commonalities and differences across studies. */ function analyzeSummary( comparisons: StudyComparison[], compareFields: string[], ): CompareStudiesOutput['summary'] { const commonalities: string[] = []; const differences: string[] = []; // Check for common phases if (compareFields.includes('all') || compareFields.includes('design')) { const allPhases = comparisons.map((c) => c.design?.phases).filter(Boolean); if (allPhases.length > 0) { const firstPhases = allPhases[0]?.join(','); const allSame = allPhases.every((p) => p?.join(',') === firstPhases); if (allSame && firstPhases) { commonalities.push(`All studies are in phase: ${firstPhases}`); } else { differences.push('Studies are in different trial phases'); } } } // Check for common sponsors if (compareFields.includes('all') || compareFields.includes('sponsors')) { const sponsors = comparisons .map((c) => c.sponsors?.leadSponsor?.name) .filter(Boolean); const uniqueSponsors = [...new Set(sponsors)]; if (uniqueSponsors.length === 1 && sponsors.length === comparisons.length) { commonalities.push(`All studies sponsored by: ${uniqueSponsors[0]}`); } else if (uniqueSponsors.length > 1) { differences.push(`Different lead sponsors: ${uniqueSponsors.join(', ')}`); } } // Check for common status if (compareFields.includes('all') || compareFields.includes('status')) { const statuses = comparisons .map((c) => c.status?.overallStatus) .filter(Boolean); const uniqueStatuses = [...new Set(statuses)]; if (uniqueStatuses.length === 1 && statuses.length === comparisons.length) { commonalities.push(`All studies have status: ${uniqueStatuses[0]}`); } else if (uniqueStatuses.length > 1) { differences.push(`Different statuses: ${uniqueStatuses.join(', ')}`); } } // Check for geographic overlap if (compareFields.includes('all') || compareFields.includes('locations')) { const allCountries = comparisons .map((c) => c.locations?.countries ?? []) .filter((countries) => countries.length > 0); if (allCountries.length > 1) { const commonCountries = allCountries.reduce((acc, countries) => acc.filter((c) => countries.includes(c)), ); if (commonCountries.length > 0) { commonalities.push( `Common countries: ${commonCountries.slice(0, 5).join(', ')}`, ); } } } return { totalStudies: comparisons.length, comparedFields: compareFields, commonalities: commonalities.length > 0 ? commonalities : undefined, differences: differences.length > 0 ? differences : undefined, }; } // // Pure business logic (no try/catch; throw McpError on failure) // ------------------------------------------------------------- /** * Compares 2-5 clinical studies side-by-side. */ async function compareStudiesLogic( input: CompareStudiesInput, appContext: RequestContext, _sdkContext: SdkContext, ): Promise<CompareStudiesOutput> { logger.debug( `Executing compareStudiesLogic for NCT IDs: ${input.nctIds.join(', ')}`, { ...appContext, toolInput: input, }, ); const provider = container.resolve<IClinicalTrialsProvider>( ClinicalTrialsProvider, ); const compareFields = Array.isArray(input.compareFields) ? input.compareFields : [input.compareFields]; const comparisons: StudyComparison[] = []; const errors: { nctId: string; error: string }[] = []; // Fetch all studies const studyPromises = input.nctIds.map(async (nctId) => { try { const study = await provider.fetchStudy(nctId, appContext); logger.info(`Successfully fetched study ${nctId} for comparison`, { ...appContext, }); return { nctId, study }; } catch (error) { const errorMessage = error instanceof McpError ? error.message : 'An unexpected error occurred'; logger.warning(`Failed to fetch study ${nctId}: ${errorMessage}`, { ...appContext, nctId, error, }); errors.push({ nctId, error: errorMessage }); return null; } }); const results = await Promise.all(studyPromises); // Create comparisons for successfully fetched studies results.forEach((result) => { if (result) { const comparison = createStudyComparison(result.study, compareFields); comparisons.push(comparison); } }); // Need at least 2 studies to compare if (comparisons.length < 2) { throw new McpError( JsonRpcErrorCode.ValidationError, `Insufficient studies for comparison. Need at least 2, got ${comparisons.length}. ${errors.length > 0 ? `Errors: ${errors.map((e) => `${e.nctId}: ${e.error}`).join('; ')}` : ''}`, { errors, successfulFetches: comparisons.length }, ); } const summary = analyzeSummary(comparisons, compareFields); logger.info(`Successfully compared ${comparisons.length} studies`, { ...appContext, comparedFields: compareFields, }); const result: CompareStudiesOutput = { comparisons, summary }; if (errors.length > 0) { result.errors = errors; } return result; } /** * Formats the comparison with both summary analysis and full structured details. */ function responseFormatter(result: CompareStudiesOutput): ContentBlock[] { const { comparisons, summary, errors } = result; // Build summary section const summaryParts: string[] = [ `# Comparison of ${summary.totalStudies} Clinical Trials`, '', '## Studies', ...comparisons.map((c) => `- **${c.nctId}**: ${c.title ?? 'No title'}`), '', ]; if (summary.commonalities && summary.commonalities.length > 0) { summaryParts.push('## Commonalities'); summaryParts.push(...summary.commonalities.map((c) => `- ${c}`)); summaryParts.push(''); } if (summary.differences && summary.differences.length > 0) { summaryParts.push('## Key Differences'); summaryParts.push(...summary.differences.map((d) => `- ${d}`)); summaryParts.push(''); } if (errors && errors.length > 0) { summaryParts.push('## Errors'); summaryParts.push(...errors.map((e) => `- **${e.nctId}**: ${e.error}`)); summaryParts.push(''); } summaryParts.push('---'); summaryParts.push(''); // Build detailed comparison sections const detailParts: string[] = ['## Detailed Comparison', '']; comparisons.forEach((comp, idx) => { if (idx > 0) detailParts.push('---', ''); detailParts.push(`### ${comp.nctId}: ${comp.title ?? 'No title'}`, ''); if (comp.status) { detailParts.push('**Status:**'); detailParts.push( `- Overall Status: ${comp.status.overallStatus ?? 'N/A'}`, ); detailParts.push(`- Start Date: ${comp.status.startDate ?? 'N/A'}`); detailParts.push( `- Completion Date: ${comp.status.completionDate ?? 'N/A'}`, ); detailParts.push(''); } if (comp.design) { detailParts.push('**Design:**'); detailParts.push(`- Study Type: ${comp.design.studyType ?? 'N/A'}`); detailParts.push(`- Phases: ${comp.design.phases?.join(', ') ?? 'N/A'}`); detailParts.push(`- Allocation: ${comp.design.allocation ?? 'N/A'}`); detailParts.push( `- Intervention Model: ${comp.design.interventionModel ?? 'N/A'}`, ); detailParts.push( `- Primary Purpose: ${comp.design.primaryPurpose ?? 'N/A'}`, ); detailParts.push(`- Masking: ${comp.design.masking ?? 'N/A'}`); detailParts.push(''); } if (comp.eligibility) { detailParts.push('**Eligibility:**'); detailParts.push(`- Sex: ${comp.eligibility.sex ?? 'N/A'}`); detailParts.push( `- Minimum Age: ${comp.eligibility.minimumAge ?? 'N/A'}`, ); detailParts.push( `- Healthy Volunteers: ${comp.eligibility.healthyVolunteers ?? 'N/A'}`, ); if (comp.eligibility.stdAges?.length) { detailParts.push( `- Age Groups: ${comp.eligibility.stdAges.join(', ')}`, ); } if (comp.eligibility.criteria) { detailParts.push( `- Criteria: ${comp.eligibility.criteria.substring(0, 200)}${comp.eligibility.criteria.length > 200 ? '...' : ''}`, ); } detailParts.push(''); } if (comp.interventions && comp.interventions.length > 0) { detailParts.push('**Interventions:**'); comp.interventions.forEach((int) => { detailParts.push(`- ${int.type ?? 'Unknown'}: ${int.name ?? 'N/A'}`); if (int.description) { detailParts.push( ` ${int.description.substring(0, 150)}${int.description.length > 150 ? '...' : ''}`, ); } }); detailParts.push(''); } if (comp.outcomes) { if (comp.outcomes.primary && comp.outcomes.primary.length > 0) { detailParts.push('**Primary Outcomes:**'); comp.outcomes.primary.forEach((out) => { detailParts.push(`- ${out.measure ?? 'N/A'}`); if (out.timeFrame) { detailParts.push(` Time Frame: ${out.timeFrame}`); } }); detailParts.push(''); } if (comp.outcomes.secondary && comp.outcomes.secondary.length > 0) { detailParts.push('**Secondary Outcomes:**'); comp.outcomes.secondary.slice(0, 3).forEach((out) => { detailParts.push(`- ${out.measure ?? 'N/A'}`); }); if (comp.outcomes.secondary.length > 3) { detailParts.push( ` ...and ${comp.outcomes.secondary.length - 3} more`, ); } detailParts.push(''); } } if (comp.sponsors) { detailParts.push('**Sponsors:**'); if (comp.sponsors.leadSponsor) { detailParts.push( `- Lead: ${comp.sponsors.leadSponsor.name ?? 'N/A'} (${comp.sponsors.leadSponsor.class ?? 'N/A'})`, ); } if ( comp.sponsors.collaborators && comp.sponsors.collaborators.length > 0 ) { detailParts.push( `- Collaborators: ${comp.sponsors.collaborators.length}`, ); comp.sponsors.collaborators.slice(0, 3).forEach((collab) => { detailParts.push(` - ${collab.name ?? 'N/A'}`); }); if (comp.sponsors.collaborators.length > 3) { detailParts.push( ` ...and ${comp.sponsors.collaborators.length - 3} more`, ); } } detailParts.push(''); } if (comp.locations) { detailParts.push('**Locations:**'); detailParts.push(`- Total: ${comp.locations.totalCount ?? 0}`); if (comp.locations.countries?.length) { detailParts.push(`- Countries: ${comp.locations.countries.join(', ')}`); } if (comp.locations.topCities?.length) { detailParts.push( `- Top Cities: ${comp.locations.topCities.join(', ')}`, ); } detailParts.push(''); } }); return [ { type: 'text', text: [...summaryParts, ...detailParts].join('\n'), }, ]; } /** * The complete tool definition for comparing clinical trial studies. */ export const compareStudiesTool: ToolDefinition< typeof InputSchema, typeof OutputSchema > = { name: TOOL_NAME, title: TOOL_TITLE, description: TOOL_DESCRIPTION, inputSchema: InputSchema, outputSchema: OutputSchema, annotations: TOOL_ANNOTATIONS, logic: withToolAuth(['tool:clinicaltrials:read'], compareStudiesLogic), responseFormatter, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cyanheads/clinicaltrialsgov-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server