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