UNPKG

clinicaltrialsgov-mcp-server

Version:

ClinicalTrials.gov Model Context Protocol (MCP) Server that provides a suite of tools for interacting with the official ClinicalTrials.gov v2 API. Enables AI agents and LLMs to programmatically search, retrieve, and analyze clinical trial data.

210 lines (209 loc) 9.02 kB
/** * @fileoverview Service for interacting with the ClinicalTrials.gov API. * This module provides a singleton class `ClinicalTrialsGovService` that encapsulates * all network requests to the ClinicalTrials.gov API, handles response validation, * and implements backing up of API responses to the local filesystem. * @module src/services/clinical-trials-gov/ClinicalTrialsGovService */ import { writeFileSync } from "fs"; import path from "path"; import { config } from "../../config/index.js"; import { BaseErrorCode, McpError } from "../../types-global/errors.js"; import { logger } from "../../utils/index.js"; import { fetchWithTimeout } from "../../utils/network/fetchWithTimeout.js"; const BASE_URL = "https://clinicaltrials.gov/api/v2"; /** * A service class to interact with the ClinicalTrials.gov API. * It handles request construction, API communication, and response backup. */ export class ClinicalTrialsGovService { /** * Private constructor to prevent direct instantiation. */ constructor() { // The constructor is now private. } /** * Returns the singleton instance of the ClinicalTrialsGovService. * @returns The singleton instance. */ static getInstance() { if (!ClinicalTrialsGovService.instance) { ClinicalTrialsGovService.instance = new ClinicalTrialsGovService(); } return ClinicalTrialsGovService.instance; } /** * Fetches a single study by its NCT ID, with optional parameters. * @param nctId - The NCT ID of the study. * @param context - The request context for logging. * @param options - Optional parameters for the fetch request. * @param options.fields - A list of specific top-level fields to return. * @param options.markupFormat - The format for rich text fields ('markdown' or 'legacy'). * @returns A promise that resolves with the study data. */ async fetchStudy(nctId, context, options = {}) { const queryParams = new URLSearchParams(); if (options.fields && options.fields.length > 0) { queryParams.set("fields", options.fields.join(",")); } if (options.markupFormat) { queryParams.set("markupFormat", options.markupFormat); } const queryString = queryParams.toString(); const url = `${BASE_URL}/studies/${nctId}${queryString ? `?${queryString}` : ""}`; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileName = `study_${nctId}_${timestamp}.json`; return this.fetchAndBackup(url, fileName, context); } /** * Searches for studies based on a set of query parameters. * @param params - The query parameters for the search. * @param context - The request context for logging. * @returns A promise that resolves with a paged list of studies. */ async listStudies(params, context) { const queryParams = new URLSearchParams(); if (typeof params.query === "object" && params.query !== null) { for (const [key, value] of Object.entries(params.query)) { if (value) { queryParams.set(`query.${key}`, String(value)); } } } if (typeof params.filter === "object" && params.filter !== null) { for (const [key, value] of Object.entries(params.filter)) { if (value) { if (Array.isArray(value)) { queryParams.set(`filter.${key}`, value.join(",")); } else { queryParams.set(`filter.${key}`, String(value)); } } } } if (Array.isArray(params.fields)) { queryParams.set("fields", params.fields.join(",")); } if (Array.isArray(params.sort)) { queryParams.set("sort", params.sort.join(",")); } if (params.pageSize) { queryParams.set("pageSize", String(params.pageSize)); } if (typeof params.pageToken === "string") { queryParams.set("pageToken", params.pageToken); } if (params.countTotal) { queryParams.set("countTotal", "true"); } const url = `${BASE_URL}/studies?${queryParams.toString()}`; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileName = `studies_${timestamp}.json`; return this.fetchAndBackup(url, fileName, context); } /** * Fetches the study metadata. * @param params - Parameters for filtering metadata. * @param context - The request context for logging. * @returns A promise that resolves with the field node data. */ async getStudyMetadata(params, context) { const queryParams = new URLSearchParams(); if (params.includeIndexedOnly) queryParams.set("includeIndexedOnly", "true"); if (params.includeHistoricOnly) queryParams.set("includeHistoricOnly", "true"); const url = `${BASE_URL}/studies/metadata?${queryParams.toString()}`; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileName = `metadata_${timestamp}.json`; return this.fetchAndBackup(url, fileName, context); } /** * Fetches API statistics. * @param statType - The type of statistics to retrieve. * @param params - Additional parameters for the statistics request. * @param context - The request context for logging. * @returns A promise that resolves with the statistical data. */ async getApiStats(statType, params, context) { const queryParams = new URLSearchParams(); if (params.fields) { queryParams.set("fields", params.fields.join(",")); } if (params.types) { queryParams.set("types", params.types.join(",")); } let endpoint; switch (statType) { case "studySize": endpoint = "size"; break; case "fieldValues": endpoint = "field/values"; break; case "listFieldSizes": endpoint = "list/sizes"; break; default: throw new McpError(BaseErrorCode.INVALID_INPUT, `Invalid statType: ${statType}`); } let url = `${BASE_URL}/stats/${endpoint}`; if (queryParams.toString()) { url += `?${queryParams.toString()}`; } const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileName = `stats_${statType}_${timestamp}.json`; return this.fetchAndBackup(url, fileName, context); } /** * A generic fetch method that handles backing up the response. * It always fetches live data and writes it to a backup file if the data path is configured. * @param url - The URL to fetch. * @param fileName - The file name to use for the backup. * @param context - The request context for logging. * @returns A promise that resolves with the fetched data. */ async fetchAndBackup(url, fileName, context) { logger.debug(`[API] Fetching from ${url}`, context); const fetchOptions = { headers: { Accept: "application/json" }, }; logger.debug(`[API] Fetch options: ${JSON.stringify(fetchOptions)}`, context); const response = await fetchWithTimeout(url, 15000, // 15-second timeout for potentially complex queries context, fetchOptions); if (!response.ok) { const errorBody = await response.text(); logger.error(`[API] Error response body: ${errorBody}`, context); const message = response.status === 404 ? `Study not found. ${errorBody}` : `API request failed with status ${response.status}: ${response.statusText}`; throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, message, { url, status: response.status, body: errorBody, }); } const responseBody = await response.text(); logger.debug(`[API] Raw response body: ${responseBody}`, context); const data = JSON.parse(responseBody); if (config.clinicalTrialsDataPath) { const filePath = path.join(config.clinicalTrialsDataPath, fileName); try { writeFileSync(filePath, JSON.stringify(data, null, 2)); logger.debug(`[Backup] Wrote to ${filePath}`, context); } catch (error) { logger.error(`Failed to write backup file: ${filePath}`, { ...context, error, }); } } else { logger.debug("[Backup] Skipping backup because data path is not configured.", context); } return data; } }