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.

175 lines 8.13 kB
/** * @fileoverview Unpaywall service. Resolves a DOI to an open-access location * and fetches the raw content (HTML or PDF) for downstream extraction. Enabled * only when `UNPAYWALL_EMAIL` is set — absence leaves the fallback disabled. * * Philosophy: best-effort. Upstream 404s and non-OA DOIs return a `no-oa` * resolution; only genuine service failures (5xx, network, timeout) throw. * @module src/services/unpaywall/unpaywall-service */ import { JsonRpcErrorCode, McpError, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors'; import { fetchWithTimeout, httpErrorFromResponse, logger, requestContextService, } from '@cyanheads/mcp-ts-core/utils'; import { getServerConfig } from '../../config/server-config.js'; import { recoveryFor } from '../../services/error-contracts.js'; import { UNPAYWALL_API_BASE, } from './types.js'; const USER_AGENT = 'pubmed-mcp-server (+https://github.com/cyanheads/pubmed-mcp-server)'; /** * Resolves a DOI to an open-access copy via Unpaywall and fetches its bytes. * Constructed only when `UNPAYWALL_EMAIL` is present; absent config → no service. */ export class UnpaywallService { email; timeoutMs; constructor(email, timeoutMs) { this.email = email; this.timeoutMs = timeoutMs; } /** * Look up a DOI in Unpaywall. Returns a structured outcome — never throws * for a DOI that simply has no OA copy or that Unpaywall doesn't know. * Throws `McpError(ServiceUnavailable)` only for network/server failures. */ async resolve(doi, signal) { const normalized = normalizeDoi(doi); if (!normalized) return { kind: 'no-oa', reason: 'Invalid DOI' }; const url = `${UNPAYWALL_API_BASE}/${encodeURIComponent(normalized)}?email=${encodeURIComponent(this.email)}`; const ctx = requestContextService.createRequestContext({ operation: 'UnpaywallResolve', doi }); // `fetchWithTimeout` throws on any non-2xx response, so the explicit // `response.status === 404` / `=== 422` checks the API actually returns // arrive as `McpError(NotFound)` / `McpError(ValidationError)` here, not as // a response object. Translate those into the documented `no-oa` outcomes // before falling back to the service-unavailable wrap. let response; try { response = await fetchWithTimeout(url, this.timeoutMs, ctx, { headers: { Accept: 'application/json', 'User-Agent': USER_AGENT }, ...(signal && { signal }), }); } catch (error) { if (error instanceof McpError) { if (error.code === JsonRpcErrorCode.NotFound) { return { kind: 'no-oa', reason: 'DOI unknown to Unpaywall' }; } if (error.code === JsonRpcErrorCode.ValidationError) { return { kind: 'no-oa', reason: 'Invalid DOI format' }; } } const msg = error instanceof Error ? error.message : String(error); throw serviceUnavailable(`Unpaywall request failed: ${msg}`, { reason: 'unpaywall_unreachable', doi: normalized, ...recoveryFor('unpaywall_unreachable'), }, { cause: error }); } const data = (await response.json()); if (!data.is_oa) return { kind: 'no-oa', reason: 'No open-access copy indexed' }; const location = data.best_oa_location ?? data.oa_locations?.[0]; if (!location?.url) return { kind: 'no-oa', reason: 'OA flagged but no usable location URL' }; logger.debug('Unpaywall resolved DOI', requestContextService.createRequestContext({ operation: 'UnpaywallResolved', doi: normalized, hostType: location.host_type ?? null, license: location.license ?? null, version: location.version ?? null, })); return { kind: 'found', location }; } /** * Fetch the content at an Unpaywall location. Prefers `url_for_pdf` when * present (direct PDF bytes) and falls back to `url` (HTML landing page). * Throws `McpError(ServiceUnavailable)` on network/server failures or * unreadable responses — caller handles partial failures. */ async fetchContent(location, signal) { const pdfUrl = location.url_for_pdf ?? undefined; const htmlUrl = location.url; if (pdfUrl) { try { return await this.fetchAs(pdfUrl, 'pdf', signal); } catch (pdfErr) { logger.debug('Unpaywall PDF fetch failed; falling back to HTML URL', requestContextService.createRequestContext({ operation: 'UnpaywallPdfFallback', url: pdfUrl, error: pdfErr instanceof Error ? pdfErr.message : String(pdfErr), })); } } return this.fetchAs(htmlUrl, 'auto', signal); } async fetchAs(url, expected, signal) { const ctx = requestContextService.createRequestContext({ operation: 'UnpaywallFetch', url, expected, }); const accept = expected === 'pdf' ? 'application/pdf,*/*;q=0.5' : 'text/html,application/pdf;q=0.9,*/*;q=0.5'; let response; try { response = await fetchWithTimeout(url, this.timeoutMs, ctx, { headers: { Accept: accept, 'User-Agent': USER_AGENT }, redirect: 'follow', ...(signal && { signal }), }); } catch (error) { const msg = error instanceof Error ? error.message : String(error); throw serviceUnavailable(`Unpaywall content fetch failed: ${msg}`, { reason: 'unpaywall_unreachable', url, ...recoveryFor('unpaywall_unreachable') }, { cause: error }); } if (!response.ok) { throw await httpErrorFromResponse(response, { service: 'Unpaywall content fetch', data: { url }, }); } const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; const fetchedUrl = response.url || url; if (contentType.includes('pdf') || expected === 'pdf') { const bytes = new Uint8Array(await response.arrayBuffer()); return { kind: 'pdf', fetchedUrl, body: bytes }; } const text = await response.text(); return { kind: 'html', fetchedUrl, body: text }; } } /** * Strip a leading `doi:` prefix or URL wrapping and return a clean DOI, or * undefined when the input can't be coerced into one. */ function normalizeDoi(input) { const trimmed = input.trim(); if (!trimmed) return; const withoutScheme = trimmed.replace(/^doi:/i, '').replace(/^https?:\/\/(dx\.)?doi\.org\//i, ''); return withoutScheme.startsWith('10.') ? withoutScheme : undefined; } // ─── Init / Accessor ──────────────────────────────────────────────────────── let _service; /** * Initialize the Unpaywall service if `UNPAYWALL_EMAIL` is configured. * Safe to call regardless — absence of the env var leaves the service unset, * and `getUnpaywallService()` returns `undefined`. */ export function initUnpaywallService() { const config = getServerConfig(); if (!config.unpaywallEmail) { logger.info('Unpaywall fallback disabled (UNPAYWALL_EMAIL not set).'); return; } _service = new UnpaywallService(config.unpaywallEmail, config.unpaywallTimeoutMs); logger.info('Unpaywall service initialized.', requestContextService.createRequestContext({ operation: 'UnpaywallInit', timeoutMs: config.unpaywallTimeoutMs, })); } /** Returns the initialized service, or `undefined` when the fallback is disabled. */ export function getUnpaywallService() { return _service; } //# sourceMappingURL=unpaywall-service.js.map