UNPKG

@cyanheads/pubmed-mcp-server

Version:

A Model Context Protocol (MCP) server enabling AI agents to intelligently search, retrieve, and analyze biomedical literature from PubMed via NCBI E-utilities. Built on the mcp-ts-template for robust, production-ready performance.

178 lines (177 loc) 7.99 kB
/** * @fileoverview Logic for the searchPubMedArticles MCP tool. * Handles constructing ESearch and ESummary queries, interacting with * the NcbiService, and formatting the results. * @module src/mcp-server/tools/searchPubMedArticles/logic */ import { z } from "zod"; import { getNcbiService } from "../../../services/NCBI/ncbiService.js"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; import { logger, requestContextService, sanitizeInputForLogging, } from "../../../utils/index.js"; import { extractBriefSummaries } from "../../../utils/parsing/ncbi-parsing/index.js"; import { sanitization } from "../../../utils/security/sanitization.js"; export const SearchPubMedArticlesInputSchema = z.object({ queryTerm: z .string() .min(3, "Query term must be at least 3 characters") .describe("The primary keyword or phrase to search for in PubMed. Must be at least 3 characters long."), maxResults: z .number() .int() .positive() .max(1000, "Max results per query. ESearch's retmax is used.") .optional() .default(20) .describe("Maximum number of articles to retrieve. Corresponds to ESearch's 'retmax' parameter. Default is 20, max is 1000."), sortBy: z .enum(["relevance", "pub_date", "author", "journal_name"]) .optional() .default("relevance") .describe("Sorting criteria for results. Options: 'relevance' (default), 'pub_date', 'author', 'journal_name'. Note: Other sorting (e.g., last_author, title) may require client-side implementation or be future server enhancements."), dateRange: z .object({ minDate: z .string() .regex(/^\d{4}(\/\d{2}(\/\d{2})?)?$/, "Date must be YYYY, YYYY/MM, or YYYY/MM/DD") .optional() .describe("The start date for the search range (YYYY, YYYY/MM, or YYYY/MM/DD)."), maxDate: z .string() .regex(/^\d{4}(\/\d{2}(\/\d{2})?)?$/, "Date must be YYYY, YYYY/MM, or YYYY/MM/DD") .optional() .describe("The end date for the search range (YYYY, YYYY/MM, or YYYY/MM/DD)."), dateType: z .enum(["pdat", "mdat", "edat"]) .optional() .default("pdat") .describe("The type of date to filter by: 'pdat' (Publication Date), 'mdat' (Modification Date), 'edat' (Entrez Date). Default is 'pdat'."), }) .optional() .describe("Defines an optional date range for the search, including min/max dates and the type of date field to use."), filterByPublicationTypes: z .array(z.string()) .optional() .describe('An array of publication types to filter by (e.g., ["Review", "Clinical Trial"]). The server maps these to the appropriate Entrez query syntax (e.g., "Review"[Publication Type]).'), fetchBriefSummaries: z .number() .int() .min(0) .max(50) .optional() .default(0) .describe("Number of top PMIDs for which to fetch brief summaries using ESummary v2.0. Set to 0 to disable. Maximum is 50 for this tool. Default is 0."), }); export async function searchPubMedArticlesLogic(input, parentRequestContext) { const ncbiService = getNcbiService(); const toolLogicContext = requestContextService.createRequestContext({ parentRequestId: parentRequestContext.requestId, operation: "searchPubMedArticlesLogic", input: sanitizeInputForLogging(input), }); logger.info("Executing searchPubMedArticles tool", toolLogicContext); let effectiveQuery = sanitization.sanitizeString(input.queryTerm, { context: "text", }); if (input.dateRange) { const { minDate, maxDate, dateType } = input.dateRange; if (minDate && maxDate) { effectiveQuery += ` AND (${minDate}[${dateType}] : ${maxDate}[${dateType}])`; } else if (minDate) { effectiveQuery += ` AND ${minDate}[${dateType}]`; } else if (maxDate) { effectiveQuery += ` AND ${maxDate}[${dateType}]`; } } if (input.filterByPublicationTypes && input.filterByPublicationTypes.length > 0) { const ptQuery = input.filterByPublicationTypes .map((pt) => `"${sanitization.sanitizeString(pt, { context: "text" })}"[Publication Type]`) .join(" OR "); effectiveQuery += ` AND (${ptQuery})`; } const currentFetchBriefSummaries = input.fetchBriefSummaries ?? 0; const eSearchParams = { db: "pubmed", term: effectiveQuery, retmax: input.maxResults, sort: input.sortBy, usehistory: currentFetchBriefSummaries > 0 ? "y" : "n", }; const eSearchResponse = await ncbiService.eSearch(eSearchParams, toolLogicContext); const eSearchBase = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"; const eSearchQueryStringParams = {}; for (const key in eSearchParams) { if (eSearchParams[key] !== undefined) { eSearchQueryStringParams[key] = String(eSearchParams[key]); } } const eSearchQueryString = new URLSearchParams(eSearchQueryStringParams).toString(); const eSearchUrl = `${eSearchBase}?${eSearchQueryString}`; if (!eSearchResponse || !eSearchResponse.eSearchResult) { throw new McpError(BaseErrorCode.NCBI_PARSING_ERROR, "Invalid or empty ESearch response from NCBI.", { ...toolLogicContext, responsePreview: sanitizeInputForLogging(JSON.stringify(eSearchResponse).substring(0, 200)), }); } const esResult = eSearchResponse.eSearchResult; const pmids = esResult.IdList?.Id || []; const totalFound = parseInt(esResult.Count || "0", 10); const retrievedPmidCount = pmids.length; let briefSummaries = []; let eSummaryUrl; if (currentFetchBriefSummaries > 0 && pmids.length > 0) { const eSummaryParams = { db: "pubmed", version: "2.0", retmode: "xml", }; if (esResult.WebEnv && esResult.QueryKey) { eSummaryParams.WebEnv = esResult.WebEnv; eSummaryParams.query_key = esResult.QueryKey; eSummaryParams.retmax = currentFetchBriefSummaries; } else { const pmidsForSummary = pmids .slice(0, currentFetchBriefSummaries) .join(","); eSummaryParams.id = pmidsForSummary; } const eSummaryBase = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"; const eSummaryQueryStringParams = {}; for (const key in eSummaryParams) { if (eSummaryParams[key] !== undefined) { eSummaryQueryStringParams[key] = String(eSummaryParams[key]); } } const eSummaryQueryString = new URLSearchParams(eSummaryQueryStringParams).toString(); eSummaryUrl = `${eSummaryBase}?${eSummaryQueryString}`; const eSummaryResponseXml = await ncbiService.eSummary(eSummaryParams, toolLogicContext); if (eSummaryResponseXml && eSummaryResponseXml.eSummaryResult) { briefSummaries = await extractBriefSummaries(eSummaryResponseXml.eSummaryResult, toolLogicContext); } else if (eSummaryResponseXml && eSummaryResponseXml.ERROR) { logger.warning("ESummary returned a top-level error", { ...toolLogicContext, errorDetails: eSummaryResponseXml.ERROR, }); } } logger.notice("Successfully executed searchPubMedArticles tool.", { ...toolLogicContext, totalFound, retrievedPmidCount, summariesFetched: briefSummaries.length, }); return { searchParameters: input, effectiveESearchTerm: effectiveQuery, totalFound, retrievedPmidCount, pmids, briefSummaries, eSearchUrl, eSummaryUrl, }; }