Skip to main content
Glama

ClinicalTrials.gov MCP Server

clinicaltrials-compare-studies.tool.test.ts•16.7 kB
/** * @fileoverview Tests for the clinicaltrials_compare_studies tool definition. * @module tests/mcp-server/tools/definitions/clinicaltrials-compare-studies.tool.test */ import { container } from 'tsyringe'; import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { ClinicalTrialsProvider } from '../../../../src/container/tokens.js'; import { compareStudiesTool } from '../../../../src/mcp-server/tools/definitions/clinicaltrials-compare-studies.tool.js'; import type { IClinicalTrialsProvider } from '../../../../src/services/clinical-trials-gov/core/IClinicalTrialsProvider.js'; import type { Study } from '../../../../src/services/clinical-trials-gov/types.js'; import { JsonRpcErrorCode, McpError, } from '../../../../src/types-global/errors.js'; import { requestContextService } from '../../../../src/utils/index.js'; describe('compareStudiesTool', () => { let mockProvider: IClinicalTrialsProvider; beforeEach(() => { mockProvider = { fetchStudy: vi.fn(), listStudies: vi.fn(), getStudyMetadata: vi.fn(), getApiStats: vi.fn(), } as unknown as IClinicalTrialsProvider; container.clearInstances(); container.registerInstance(ClinicalTrialsProvider, mockProvider); }); it('should have the correct name, title, and description', () => { expect(compareStudiesTool.name).toBe('clinicaltrials_compare_studies'); expect(compareStudiesTool.title).toBe('Compare Clinical Studies'); expect(compareStudiesTool.description).toContain('side-by-side comparison'); }); it('should have correct annotations', () => { expect(compareStudiesTool.annotations).toEqual({ readOnlyHint: true, idempotentHint: true, openWorldHint: true, }); }); it('should compare two studies successfully', async () => { const mockStudy1: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT11111111', officialTitle: 'Study One', }, statusModule: { overallStatus: 'Recruiting', }, designModule: { studyType: 'Interventional', phases: ['Phase 2'], }, }, }; const mockStudy2: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT22222222', officialTitle: 'Study Two', }, statusModule: { overallStatus: 'Completed', }, designModule: { studyType: 'Interventional', phases: ['Phase 3'], }, }, }; (mockProvider.fetchStudy as Mock) .mockResolvedValueOnce(mockStudy1 as Study) .mockResolvedValueOnce(mockStudy2 as Study); const input = { nctIds: ['NCT11111111', 'NCT22222222'], compareFields: 'all', }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; const result = await compareStudiesTool.logic( parsedInput, context, sdkContext, ); expect(result.comparisons).toHaveLength(2); expect(result.comparisons[0]!.nctId).toBe('NCT11111111'); expect(result.comparisons[1]!.nctId).toBe('NCT22222222'); expect(result.summary.totalStudies).toBe(2); }); it('should compare specific fields only', async () => { const mockStudy1: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT11111111', officialTitle: 'Study One', }, designModule: { studyType: 'Interventional', phases: ['Phase 2'], }, eligibilityModule: { sex: 'ALL', minimumAge: '18 Years', }, }, }; const mockStudy2: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT22222222', officialTitle: 'Study Two', }, designModule: { studyType: 'Observational', phases: ['Phase 3'], }, eligibilityModule: { sex: 'FEMALE', minimumAge: '21 Years', }, }, }; (mockProvider.fetchStudy as Mock) .mockResolvedValueOnce(mockStudy1 as Study) .mockResolvedValueOnce(mockStudy2 as Study); const input = { nctIds: ['NCT11111111', 'NCT22222222'], compareFields: ['design', 'eligibility'], }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; const result = await compareStudiesTool.logic( parsedInput, context, sdkContext, ); expect(result.comparisons[0]!.design).toBeDefined(); expect(result.comparisons[0]!.eligibility).toBeDefined(); expect(result.comparisons[0]!.interventions).toBeUndefined(); expect(result.comparisons[0]!.sponsors).toBeUndefined(); }); it('should extract eligibility information', async () => { const mockStudy: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT11111111', officialTitle: 'Test Study', }, eligibilityModule: { sex: 'ALL', minimumAge: '18 Years', healthyVolunteers: true, stdAges: ['ADULT', 'OLDER_ADULT'], eligibilityCriteria: 'Must be over 18 and healthy', }, }, }; (mockProvider.fetchStudy as Mock).mockResolvedValue(mockStudy as Study); const input = { nctIds: ['NCT11111111', 'NCT11111111'], compareFields: 'eligibility', }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; const result = await compareStudiesTool.logic( parsedInput, context, sdkContext, ); const comparison = result.comparisons[0]!; expect(comparison.eligibility?.sex).toBe('ALL'); expect(comparison.eligibility?.minimumAge).toBe('18 Years'); expect(comparison.eligibility?.healthyVolunteers).toBe(true); expect(comparison.eligibility?.stdAges).toEqual(['ADULT', 'OLDER_ADULT']); expect(comparison.eligibility?.criteria).toContain('Must be over 18'); }); it('should extract intervention information', async () => { const mockStudy: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT11111111', officialTitle: 'Test Study', }, armsInterventionsModule: { interventions: [ { type: 'Drug', name: 'Aspirin', description: 'Daily aspirin treatment', }, { type: 'Behavioral', name: 'Exercise', description: 'Daily exercise routine', }, ], }, }, }; (mockProvider.fetchStudy as Mock).mockResolvedValue(mockStudy as Study); const input = { nctIds: ['NCT11111111', 'NCT11111111'], compareFields: 'interventions', }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; const result = await compareStudiesTool.logic( parsedInput, context, sdkContext, ); const interventions = result.comparisons[0]!.interventions; expect(interventions).toHaveLength(2); expect(interventions![0]!.type).toBe('Drug'); expect(interventions![0]!.name).toBe('Aspirin'); expect(interventions![1]!.type).toBe('Behavioral'); }); it('should extract sponsor information', async () => { const mockStudy: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT11111111', officialTitle: 'Test Study', }, sponsorCollaboratorsModule: { leadSponsor: { name: 'Big Pharma Inc', class: 'INDUSTRY', }, collaborators: [{ name: 'University Hospital', class: 'OTHER' }], }, }, }; (mockProvider.fetchStudy as Mock).mockResolvedValue(mockStudy as Study); const input = { nctIds: ['NCT11111111', 'NCT11111111'], compareFields: 'sponsors', }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; const result = await compareStudiesTool.logic( parsedInput, context, sdkContext, ); const sponsors = result.comparisons[0]!.sponsors; expect(sponsors?.leadSponsor?.name).toBe('Big Pharma Inc'); expect(sponsors?.leadSponsor?.class).toBe('INDUSTRY'); expect(sponsors?.collaborators).toHaveLength(1); expect(sponsors?.collaborators![0]!.name).toBe('University Hospital'); }); it('should extract location summaries', async () => { const mockStudy: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT11111111', officialTitle: 'Test Study', }, contactsLocationsModule: { locations: [ { country: 'United States', city: 'New York' }, { country: 'United States', city: 'New York' }, { country: 'United States', city: 'Boston' }, { country: 'Canada', city: 'Toronto' }, ], }, }, }; (mockProvider.fetchStudy as Mock).mockResolvedValue(mockStudy as Study); const input = { nctIds: ['NCT11111111', 'NCT11111111'], compareFields: 'locations', }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; const result = await compareStudiesTool.logic( parsedInput, context, sdkContext, ); const locations = result.comparisons[0]!.locations; expect(locations?.totalCount).toBe(4); expect(locations?.countries).toContain('United States'); expect(locations?.countries).toContain('Canada'); expect(locations?.topCities).toContain('New York'); }); it('should detect commonalities across studies', async () => { const mockStudy1: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT11111111' }, statusModule: { overallStatus: 'Recruiting' }, designModule: { phases: ['Phase 2'] }, sponsorCollaboratorsModule: { leadSponsor: { name: 'Sponsor A' }, }, }, }; const mockStudy2: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT22222222' }, statusModule: { overallStatus: 'Recruiting' }, designModule: { phases: ['Phase 2'] }, sponsorCollaboratorsModule: { leadSponsor: { name: 'Sponsor A' }, }, }, }; (mockProvider.fetchStudy as Mock) .mockResolvedValueOnce(mockStudy1 as Study) .mockResolvedValueOnce(mockStudy2 as Study); const input = { nctIds: ['NCT11111111', 'NCT22222222'], compareFields: 'all', }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; const result = await compareStudiesTool.logic( parsedInput, context, sdkContext, ); expect(result.summary.commonalities).toBeDefined(); expect( result.summary.commonalities?.some((c) => c.includes('Phase 2')), ).toBe(true); expect( result.summary.commonalities?.some((c) => c.includes('Recruiting')), ).toBe(true); }); it('should detect differences across studies', async () => { const mockStudy1: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT11111111' }, statusModule: { overallStatus: 'Recruiting' }, designModule: { phases: ['Phase 2'] }, }, }; const mockStudy2: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT22222222' }, statusModule: { overallStatus: 'Completed' }, designModule: { phases: ['Phase 3'] }, }, }; (mockProvider.fetchStudy as Mock) .mockResolvedValueOnce(mockStudy1 as Study) .mockResolvedValueOnce(mockStudy2 as Study); const input = { nctIds: ['NCT11111111', 'NCT22222222'], compareFields: 'all', }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; const result = await compareStudiesTool.logic( parsedInput, context, sdkContext, ); expect(result.summary.differences).toBeDefined(); expect(result.summary.differences?.some((d) => d.includes('phases'))).toBe( true, ); expect( result.summary.differences?.some((d) => d.includes('statuses')), ).toBe(true); }); it('should throw when less than 2 studies can be fetched', async () => { (mockProvider.fetchStudy as Mock) .mockResolvedValueOnce({ protocolSection: { identificationModule: { nctId: 'NCT11111111' } }, } as Study) .mockRejectedValueOnce(new McpError(-32602, 'Not found')); const input = { nctIds: ['NCT11111111', 'NCT22222222'], compareFields: 'all', }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; await expect( compareStudiesTool.logic(parsedInput, context, sdkContext), ).rejects.toThrow('Insufficient studies for comparison'); }); it('should handle partial failures with errors array', async () => { const mockStudy1: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT11111111' }, }, }; const mockStudy2: Partial<Study> = { protocolSection: { identificationModule: { nctId: 'NCT22222222' }, }, }; (mockProvider.fetchStudy as Mock) .mockResolvedValueOnce(mockStudy1 as Study) .mockResolvedValueOnce(mockStudy2 as Study) .mockRejectedValueOnce( new McpError(JsonRpcErrorCode.NotFound, 'Not found'), ); const input = { nctIds: ['NCT11111111', 'NCT22222222', 'NCT99999999'], compareFields: 'all', }; const parsedInput = compareStudiesTool.inputSchema.parse(input); const context = requestContextService.createRequestContext(); const sdkContext = {} as any; const result = await compareStudiesTool.logic( parsedInput, context, sdkContext, ); expect(result.comparisons).toHaveLength(2); expect(result.errors).toHaveLength(1); expect(result.errors![0]!.nctId).toBe('NCT99999999'); }); it('should validate NCT ID format', () => { expect(() => compareStudiesTool.inputSchema.parse({ nctIds: ['INVALID123', 'NCT12345678'], }), ).toThrow(); expect(() => compareStudiesTool.inputSchema.parse({ nctIds: ['NCT12345678', 'NCT87654321'], }), ).not.toThrow(); }); it('should require at least 2 NCT IDs', () => { expect(() => compareStudiesTool.inputSchema.parse({ nctIds: ['NCT12345678'] }), ).toThrow(); }); it('should enforce maximum of 5 NCT IDs', () => { const tooMany = Array.from({ length: 6 }, (_, i) => `NCT1234567${i}`); expect(() => compareStudiesTool.inputSchema.parse({ nctIds: tooMany }), ).toThrow(); }); it('should format response with markdown', () => { const mockOutput = { comparisons: [ { nctId: 'NCT11111111', title: 'Study One', status: { overallStatus: 'Recruiting' }, }, { nctId: 'NCT22222222', title: 'Study Two', status: { overallStatus: 'Completed' }, }, ], summary: { totalStudies: 2, comparedFields: ['all'], differences: ['Different statuses: Recruiting, Completed'], }, }; const formatted = compareStudiesTool.responseFormatter!(mockOutput as any); expect(formatted).toHaveLength(1); expect(formatted[0]!.type).toBe('text'); const text = (formatted[0]! as any).text; expect(text).toContain('# Comparison of 2 Clinical Trials'); expect(text).toContain('NCT11111111'); expect(text).toContain('Study One'); expect(text).toContain('Key Differences'); }); });

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