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.

123 lines 5.48 kB
/** * @fileoverview Low-level HTTP client for Europe PMC's REST API. Builds URLs, * injects the optional contact email, and exposes single-attempt search and * fullTextXML calls. Retry logic lives in `EuropePmcService`. * @module src/services/europe-pmc/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 { EUROPEPMC_API_BASE } from './types.js'; const USER_AGENT = 'pubmed-mcp-server (+https://github.com/cyanheads/pubmed-mcp-server)'; /** Low-level HTTP client for Europe PMC. Single-attempt — retries upstream. */ export class EuropePmcApiClient { config; constructor(config) { this.config = config; } /** * Execute a search. Returns the raw JSON response body as a string so * `EuropePmcService` can parse and surface SerializationError consistently * when the body is malformed. */ async search(params) { const url = this.buildSearchUrl(params); const ctx = requestContextService.createRequestContext({ operation: 'EuropePmcSearch', query: params.query, }); let response; try { response = await fetchWithTimeout(url, this.config.timeoutMs, ctx, { headers: { Accept: 'application/json', 'User-Agent': USER_AGENT }, ...(params.signal && { signal: params.signal }), }); } catch (error) { if (error instanceof McpError) throw error; const msg = error instanceof Error ? error.message : String(error); throw serviceUnavailable(`Europe PMC search request failed: ${msg}`, { reason: 'europepmc_unreachable', ...recoveryFor('europepmc_unreachable') }, { cause: error }); } if (!response.ok) { throw await httpErrorFromResponse(response, { service: 'Europe PMC', data: { url, reason: 'europepmc_unreachable', ...recoveryFor('europepmc_unreachable') }, }); } return response.text(); } /** * Fetch the JATS full-text XML for an EPMC record by its internal id. * Returns `{ kind: 'not-available' }` for 404 — EPMC has the record but * doesn't publish a full-text XML for it (very common for preprints). */ async fullTextXml(epmcId, signal) { const url = `${EUROPEPMC_API_BASE}/${encodeURIComponent(epmcId)}/fullTextXML`; const ctx = requestContextService.createRequestContext({ operation: 'EuropePmcFullTextXml', epmcId, }); let response; try { response = await fetchWithTimeout(url, this.config.timeoutMs, ctx, { headers: { Accept: 'application/xml, text/xml, */*;q=0.5', 'User-Agent': USER_AGENT, }, ...(signal && { signal }), }); } catch (error) { if (error instanceof McpError) throw error; const msg = error instanceof Error ? error.message : String(error); throw serviceUnavailable(`Europe PMC fullTextXML request failed: ${msg}`, { reason: 'europepmc_unreachable', epmcId, ...recoveryFor('europepmc_unreachable') }, { cause: error }); } if (response.status === 404) { return { kind: 'not-available', reason: 'EPMC has no fullTextXML for this record' }; } if (!response.ok) { throw await httpErrorFromResponse(response, { service: 'Europe PMC fullTextXML', data: { epmcId, reason: 'europepmc_unreachable', ...recoveryFor('europepmc_unreachable') }, }); } const xml = await response.text(); if (!xml.trim()) { logger.debug('Europe PMC returned an empty fullTextXML body.', requestContextService.createRequestContext({ operation: 'EuropePmcFullTextXmlEmpty', epmcId, })); return { kind: 'not-available', reason: 'EPMC returned an empty fullTextXML body' }; } return { kind: 'found', xml }; } buildSearchUrl(params) { const finalParams = { query: this.buildQueryString(params), format: 'json', resultType: params.resultType ?? 'core', pageSize: String(params.pageSize ?? 25), cursorMark: params.cursorMark ?? '*', }; if (params.sort) finalParams.sort = params.sort; if (this.config.email) finalParams.email = this.config.email; return `${EUROPEPMC_API_BASE}/search?${new URLSearchParams(finalParams).toString()}`; } /** * Combine the caller's query with an optional source filter. EPMC's query * syntax supports `SRC:"X"` field tokens — we OR-join the requested sources * into a parenthesized clause and AND it with the user's query. */ buildQueryString(params) { const base = params.query.trim(); if (!params.sources || params.sources.length === 0) return base; const sourceClause = params.sources.map((s) => `SRC:"${s}"`).join(' OR '); return `(${base}) AND (${sourceClause})`; } } //# sourceMappingURL=api-client.js.map