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