@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.
190 lines (189 loc) • 9.23 kB
JavaScript
/**
* @fileoverview Handles parsing of NCBI E-utility responses and NCBI-specific error extraction.
* @module src/services/NCBI/ncbiResponseHandler
*/
import { XMLParser, XMLValidator } from "fast-xml-parser";
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
import { logger, requestContextService, sanitizeInputForLogging, } from "../../utils/index.js";
export class NcbiResponseHandler {
constructor() {
this.xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
parseTagValue: true, // auto-convert numbers, booleans if possible
isArray: (name, jpath, isLeafNode, isAttribute) => {
// Common NCBI list tags - expand as needed
const arrayTags = [
"IdList.Id",
"eSearchResult.IdList.Id",
"PubmedArticleSet.PubmedArticle",
"PubmedArticleSet.DeleteCitation.PMID",
"AuthorList.Author",
"MeshHeadingList.MeshHeading",
"GrantList.Grant",
"KeywordList.Keyword",
"PublicationTypeList.PublicationType",
"LinkSet.LinkSetDb.Link",
"Link.Id",
"DbInfo.FieldList.Field",
"DbInfo.LinkList.Link",
"DocSum.Item", // For ESummary v2.0 JSON-like XML
];
return arrayTags.includes(jpath);
},
});
}
extractNcbiErrorMessages(parsedXml) {
const messages = [];
// Order matters for specificity if multiple error types could exist
const errorPaths = [
"eLinkResult.ERROR",
"eSummaryResult.ERROR",
"eSearchResult.ErrorList.PhraseNotFound",
"eSearchResult.ErrorList.FieldNotFound",
"PubmedArticleSet.ErrorList.CannotRetrievePMID", // More specific error
"ERROR", // Generic top-level error
];
for (const path of errorPaths) {
let errorSource = parsedXml;
const parts = path.split(".");
for (const part of parts) {
if (errorSource &&
typeof errorSource === "object" &&
part in errorSource) {
errorSource = errorSource[part];
}
else {
errorSource = undefined;
break;
}
}
if (errorSource) {
const items = Array.isArray(errorSource) ? errorSource : [errorSource];
for (const item of items) {
if (typeof item === "string") {
messages.push(item);
}
else if (item && typeof item["#text"] === "string") {
messages.push(item["#text"]);
}
}
}
}
// Handle warnings if no primary errors found
if (messages.length === 0 && parsedXml.eSearchResult?.WarningList) {
const warningPaths = [
"eSearchResult.WarningList.QuotedPhraseNotFound",
"eSearchResult.WarningList.OutputMessage",
];
for (const path of warningPaths) {
let warningSource = parsedXml;
const parts = path.split(".");
for (const part of parts) {
if (warningSource &&
typeof warningSource === "object" &&
part in warningSource) {
warningSource = warningSource[part];
}
else {
warningSource = undefined;
break;
}
}
if (warningSource) {
const items = Array.isArray(warningSource)
? warningSource
: [warningSource];
for (const item of items) {
if (typeof item === "string") {
messages.push(`Warning: ${item}`);
}
else if (item && typeof item["#text"] === "string") {
messages.push(`Warning: ${item["#text"]}`);
}
}
}
}
}
return messages.length > 0
? messages
: ["Unknown NCBI API error structure."];
}
/**
* Parses the raw AxiosResponse data based on retmode and checks for NCBI-specific errors.
* @param response The raw AxiosResponse from an NCBI E-utility call.
* @param endpoint The E-utility endpoint for context.
* @param context The request context for logging.
* @param options The original request options, particularly `retmode`.
* @returns The parsed data (object for XML/JSON, string for text).
* @throws {McpError} If parsing fails or NCBI reports an error in the response body.
*/
parseAndHandleResponse(response, endpoint, context, options) {
const responseData = response.data;
const operationContext = requestContextService.createRequestContext({
...context,
operation: "NCBI_ParseResponse",
endpoint,
retmode: options.retmode,
});
if (options.retmode === "text") {
logger.debug("Received text response from NCBI.", operationContext);
return responseData;
}
if (options.retmode === "xml") {
logger.debug("Attempting to parse XML response from NCBI.", operationContext);
if (typeof responseData !== "string" ||
XMLValidator.validate(responseData) !== true) {
logger.error("Invalid or non-string XML response from NCBI", new Error("Invalid XML structure"), {
...operationContext,
responseSnippet: String(responseData).substring(0, 500),
});
throw new McpError(BaseErrorCode.NCBI_PARSING_ERROR, "Received invalid XML from NCBI.", { endpoint, responseSnippet: String(responseData).substring(0, 200) });
}
// Always parse for error checking, even if returning raw XML
const parsedXml = this.xmlParser.parse(responseData);
// Check for error indicators within the parsed XML structure
if (parsedXml.eSearchResult?.ErrorList ||
parsedXml.eLinkResult?.ERROR ||
parsedXml.eSummaryResult?.ERROR ||
parsedXml.PubmedArticleSet?.ErrorList || // Check for ErrorList specifically
parsedXml.ERROR // Generic top-level error
) {
const errorMessages = this.extractNcbiErrorMessages(parsedXml);
logger.error("NCBI API returned an error in XML response", new Error(errorMessages.join("; ")), {
...operationContext,
errors: errorMessages,
parsedXml: sanitizeInputForLogging(parsedXml), // Log the parsed structure for error diagnosis
});
throw new McpError(BaseErrorCode.NCBI_API_ERROR, `NCBI API Error: ${errorMessages.join("; ")}`, { endpoint, ncbiErrors: errorMessages });
}
// If raw XML is requested and no errors were found, return the original string
if (options.returnRawXml) {
logger.debug("Successfully validated XML response. Returning raw XML string as requested.", operationContext);
return responseData; // responseData is the raw XML string
}
logger.debug("Successfully parsed XML response. Returning parsed object.", operationContext);
return parsedXml; // Return the parsed object by default
}
if (options.retmode === "json") {
logger.debug("Handling JSON response from NCBI.", operationContext);
// Assuming responseData is already parsed by Axios if Content-Type was application/json
if (typeof responseData === "object" &&
responseData !== null &&
responseData.error) {
const errorMessage = String(responseData.error);
logger.error("NCBI API returned an error in JSON response", new Error(errorMessage), {
...operationContext,
error: errorMessage,
responseData: sanitizeInputForLogging(responseData),
});
throw new McpError(BaseErrorCode.NCBI_API_ERROR, `NCBI API Error: ${errorMessage}`, { endpoint, ncbiError: errorMessage });
}
logger.debug("Successfully processed JSON response.", operationContext);
return responseData;
}
// Fallback for unknown retmode or if retmode is undefined
logger.warning(`Response received with unspecified or unhandled retmode: ${options.retmode}. Returning raw data.`, operationContext);
return responseData;
}
}