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
JavaScript
/**
* @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;
}
}