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.

190 lines (189 loc) 9.23 kB
/** * @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; } }