UNPKG

@cyanheads/pubmed-mcp-server

Version:

Search PubMed/Europe PMC, fetch articles and full text (PMC/EPMC/Unpaywall), citations, MeSH terms via MCP. STDIO or Streamable HTTP.

127 lines 5.85 kB
/** * @fileoverview Core HTTP client for NCBI E-utility requests. Handles URL construction, * API key injection, and GET/POST selection based on payload size. Single-attempt only; * retry logic lives in NcbiService.performRequest to cover both HTTP and XML-level errors. * @module src/services/ncbi/api-client */ import { McpError, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors'; import { fetchWithTimeout, httpErrorFromResponse, logger, requestContextService, } from '@cyanheads/mcp-ts-core/utils'; import { recoveryFor } from '../../services/error-contracts.js'; import { NCBI_EUTILS_BASE_URL } from './types.js'; /** Maximum encoded query-string length before automatically switching to POST. */ const POST_THRESHOLD = 2000; /** * Low-level HTTP client for NCBI E-utilities. Constructs URLs, injects credentials, * and chooses GET/POST based on payload size. Single-attempt — retry logic lives * in {@link NcbiService.performRequest} so it covers both HTTP and XML-level errors. */ export class NcbiApiClient { config; constructor(config) { this.config = config; } async makeRequest(endpoint, params, options) { const finalParams = this.buildParams(params); const usePost = this.shouldPost(finalParams, options); const suffix = endpoint.includes('.') ? '' : '.fcgi'; const url = `${NCBI_EUTILS_BASE_URL}/${endpoint}${suffix}`; try { logger.debug(`NCBI HTTP request: ${usePost ? 'POST' : 'GET'} ${url}`, requestContextService.createRequestContext({ operation: 'NcbiHttpRequest', endpoint })); const response = usePost ? await this.postRequest(url, finalParams, options?.signal) : await this.getRequest(url, finalParams, options?.signal); if (!response.ok) { throw await httpErrorFromResponse(response, { service: 'NCBI', data: { endpoint }, }); } return await response.text(); } catch (error) { if (error instanceof McpError) throw error; const msg = error instanceof Error ? error.message : String(error); throw serviceUnavailable(`NCBI request failed: ${msg}`, { reason: 'ncbi_unreachable', endpoint, ...recoveryFor('ncbi_unreachable') }, { cause: error }); } } /** * Make a GET request to a non-eutils NCBI endpoint (e.g., PMC ID Converter). * Uses plain fetch (not fetchWithTimeout) so we can capture response bodies on * error status codes — fetchWithTimeout throws before the body can be read. * Injects tool and email params but not api_key (eutils-specific). */ async makeExternalRequest(url, params, externalSignal) { const finalParams = { tool: this.config.toolIdentifier, ...(this.config.adminEmail && { email: this.config.adminEmail }), }; for (const [key, value] of Object.entries(params)) { if (value != null) finalParams[key] = String(value); } const qs = new URLSearchParams(finalParams).toString(); const fullUrl = qs ? `${url}?${qs}` : url; const timeoutSignal = AbortSignal.timeout(this.config.timeoutMs); const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal; try { logger.debug(`NCBI external request: GET ${fullUrl}`, requestContextService.createRequestContext({ operation: 'NcbiExternalRequest', url })); const response = await fetch(fullUrl, { signal }); const body = await response.text(); if (!response.ok) { throw await httpErrorFromResponse(response, { service: 'NCBI', captureBody: false, data: { url, body: body.substring(0, 500) }, }); } return body; } catch (error) { if (error instanceof McpError) throw error; const msg = error instanceof Error ? error.message : String(error); throw serviceUnavailable(`NCBI request failed: ${msg}`, { reason: 'ncbi_unreachable', url, ...recoveryFor('ncbi_unreachable') }, { cause: error }); } } buildParams(params) { const raw = { tool: this.config.toolIdentifier, email: this.config.adminEmail, api_key: this.config.apiKey, ...params, }; const result = {}; for (const [key, value] of Object.entries(raw)) { if (value != null) { result[key] = String(value); } } return result; } shouldPost(params, options) { if (options?.usePost) return true; const queryString = new URLSearchParams(params).toString(); return queryString.length > POST_THRESHOLD; } getRequest(url, params, signal) { const qs = new URLSearchParams(params).toString(); const fullUrl = qs ? `${url}?${qs}` : url; const ctx = requestContextService.createRequestContext({ operation: 'NcbiGet', url: fullUrl }); return fetchWithTimeout(fullUrl, this.config.timeoutMs, ctx, signal ? { signal } : undefined); } postRequest(url, params, signal) { const body = new URLSearchParams(params).toString(); const ctx = requestContextService.createRequestContext({ operation: 'NcbiPost', url }); return fetchWithTimeout(url, this.config.timeoutMs, ctx, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, ...(signal && { signal }), }); } } //# sourceMappingURL=api-client.js.map