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