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.

217 lines 12.1 kB
/** * @fileoverview Europe PMC search tool. Surfaces records PubMed-only search * can't reach: preprints (source `PPR`), Agricola (`AGR`), patents (`PAT`), * and EPMC-only OA articles. Uses EPMC's cursor-based pagination * (`cursorMark`) — unlike `pubmed_search_articles`'s offset-based paging, * because EPMC's search API doesn't support offset. * * Only registered when `EUROPEPMC_ENABLED=true` (the default). The handler * fails fast with a configuration error if the service is unset, since the * tool wouldn't be registered in that case. * * @module src/mcp-server/tools/definitions/pubmed-europepmc-search.tool */ import { tool, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; import { EUROPEPMC_SERVICE_ERRORS } from '../../../services/error-contracts.js'; import { getEuropePmcService } from '../../../services/europe-pmc/europe-pmc-service.js'; import { EUROPEPMC_ALL_SOURCES, EUROPEPMC_DEFAULT_SOURCES, } from '../../../services/europe-pmc/types.js'; import { conceptMeta, EDAM_DATABASE_SEARCH, EDAM_PUBMED_ID, SCHEMA_SEARCH_ACTION, } from './_concepts.js'; const SourceEnum = z.enum(['MED', 'PMC', 'PPR', 'PAT', 'AGR']); // ─── Tool Definition ───────────────────────────────────────────────────────── export const pubmedEuropepmcSearchTool = tool('pubmed_europepmc_search', { description: 'Search Europe PMC, a broad open-access biomedical corpus. Surfaces preprints (`source: PPR`), patents (`source: PAT`), Agricola (`source: AGR`), plus everything in PubMed (`MED`) and PMC. Use when additional coverage is needed — preprints and EPMC-only OA records are the typical recovery. Paginate via `cursorMark`. Defaults to `MED`, `PMC`, and `PPR`; pass `sources` to include `PAT` / `AGR`.', annotations: { readOnlyHint: true, openWorldHint: true }, _meta: conceptMeta([SCHEMA_SEARCH_ACTION, EDAM_DATABASE_SEARCH, EDAM_PUBMED_ID]), sourceUrl: 'https://github.com/cyanheads/pubmed-mcp-server/blob/main/src/mcp-server/tools/definitions/pubmed-europepmc-search.tool.ts', errors: [ ...EUROPEPMC_SERVICE_ERRORS, { reason: 'europepmc_disabled', code: JsonRpcErrorCode.ConfigurationError, when: 'Europe PMC service is disabled via EUROPEPMC_ENABLED=false.', recovery: 'Set EUROPEPMC_ENABLED=true (the default) and restart the server to use this tool.', }, ], input: z.object({ query: z .string() .min(1) .describe('Europe PMC search query. Supports field tokens like `AUTH:"<name>"`, `JOURNAL:"<title>"`, `TITLE:"<words>"`, `PUB_YEAR:[2020 TO 2024]`, `DOI:"..."`, `EXT_ID:"<pmid>" AND SRC:MED`. Free text is matched broadly across abstract/title/keywords.'), pageSize: z .number() .int() .min(1) .max(100) .default(25) .describe('Results per page. Max 100 per EPMC API.'), cursorMark: z .string() .default('*') .describe("Pagination cursor. Use `*` (default) for the first page; pass the previous response's `nextCursorMark` for subsequent pages."), sources: z .array(SourceEnum) .min(1) .optional() .describe(`Filter to specific EPMC sources. Defaults to ${EUROPEPMC_DEFAULT_SOURCES.join(', ')} when omitted. Pass an explicit array including PAT or AGR to broaden coverage. Allowed values: ${EUROPEPMC_ALL_SOURCES.join(', ')}.`), resultType: z .enum(['core', 'lite']) .default('core') .describe('`core` returns abstract, IDs, dates, license; `lite` is a smaller payload with IDs and titles only.'), sort: z .string() .optional() .describe('Optional EPMC sort: `<field> asc|desc`. Documented sortable fields: `P_PDATE_D` (publication date), `CITED` (citation count), `AUTH_FIRST` (first author surname), `PUB_YEAR` (publication year). Examples: `P_PDATE_D desc` (newest first), `CITED desc` (most cited). Omit for relevance ranking. Fields outside the documented set are rejected by EPMC.'), }), output: z.object({ query: z.string().describe('Effective query string echoed by Europe PMC'), hits: z .array(z .object({ source: SourceEnum.describe('Europe PMC source — `MED` (PubMed), `PMC` (PubMed Central), `PPR` (preprint), `PAT` (patent), `AGR` (Agricola)'), epmcId: z .string() .describe("Europe PMC's internal record id; key for `fullTextXML` lookup"), title: z.string().optional().describe('Article title'), authors: z.string().optional().describe('Formatted author string'), journal: z.string().optional().describe('Journal title'), pubYear: z.string().optional().describe('Publication year'), firstPublicationDate: z .string() .optional() .describe('First publication date (ISO YYYY-MM-DD)'), pmid: z.string().optional().describe('PMID when present in PubMed'), pmcId: z.string().optional().describe('PMC ID when present in PMC'), doi: z.string().optional().describe('DOI when present'), isOpenAccess: z .boolean() .optional() .describe('Whether EPMC reports the record as open access'), hasFullTextXml: z .boolean() .optional() .describe('Whether Europe PMC publishes a fullTextXML for this record. Derived from `inPMC` — only records with a PMC counterpart have JATS via EPMC; preprints (`PPR`) and MED-only records return false.'), abstractSnippet: z .string() .optional() .describe('First few hundred characters of the abstract when `resultType: "core"` is requested'), citedByCount: z.number().optional().describe('Citation count reported by Europe PMC'), epmcUrl: z.string().describe('Europe PMC article URL'), }) .describe('Single Europe PMC record returned by the search')) .describe('Matching Europe PMC records, in the order EPMC returned them'), hitCount: z.number().describe('Total matching records across all pages'), cursorMark: z.string().describe('Cursor used for this response (echoed from the request)'), nextCursorMark: z .string() .optional() .describe('Cursor to pass back as `cursorMark` for the next page. Absent on the final page.'), appliedSources: z .array(SourceEnum) .describe('Sources the query was filtered against (defaults applied)'), searchUrl: z.string().describe("Europe PMC's website search URL for this query"), notice: z .string() .optional() .describe('Optional guidance when results are empty or paging overshot'), }), async handler(input, ctx) { ctx.log.info('Executing pubmed_europepmc_search', { query: input.query }); const epmc = getEuropePmcService(); if (!epmc) { throw ctx.fail('europepmc_disabled', 'Europe PMC service is not available. Set EUROPEPMC_ENABLED=true to use this tool.', { ...ctx.recoveryFor('europepmc_disabled') }); } const sources = (input.sources ?? EUROPEPMC_DEFAULT_SOURCES); const result = await epmc.search({ query: input.query, resultType: input.resultType, pageSize: input.pageSize, cursorMark: input.cursorMark, sources, ...(input.sort && { sort: input.sort }), ...(ctx.signal && { signal: ctx.signal }), }); const hits = result.hits.map((h) => ({ source: h.source, epmcId: h.id, ...(h.title && { title: h.title }), ...(h.authorString && { authors: h.authorString }), ...(h.journalTitle && { journal: h.journalTitle }), ...(h.pubYear && { pubYear: h.pubYear }), ...(h.firstPublicationDate && { firstPublicationDate: h.firstPublicationDate }), ...(h.pmid && { pmid: h.pmid }), ...(h.pmcid && { pmcId: h.pmcid }), ...(h.doi && { doi: h.doi }), ...(h.isOpenAccess !== undefined && { isOpenAccess: h.isOpenAccess === 'Y' }), ...(h.inPMC !== undefined && { hasFullTextXml: h.inPMC === 'Y' }), ...(h.abstractText && { abstractSnippet: h.abstractText.length > 400 ? `${h.abstractText.slice(0, 400)}…` : h.abstractText, }), ...(typeof h.citedByCount === 'number' && { citedByCount: h.citedByCount }), epmcUrl: `https://europepmc.org/article/${h.source}/${h.id}`, })); const notice = result.hitCount === 0 ? 'No results matched your Europe PMC query. Try broadening the query, removing source filters, or running pubmed_spell_check on the term.' : undefined; ctx.log.info('pubmed_europepmc_search completed', { hitCount: result.hitCount, returnedHits: hits.length, hasNextPage: !!result.nextCursorMark, }); return { query: result.query, hits, hitCount: result.hitCount, cursorMark: result.cursorMark ?? '*', ...(result.nextCursorMark && { nextCursorMark: result.nextCursorMark }), appliedSources: [...sources], searchUrl: `https://europepmc.org/search?query=${encodeURIComponent(input.query)}`, ...(notice && { notice }), }; }, format: (result) => { const lines = [ '## Europe PMC Search Results', `**Query:** ${result.query}`, `**Total Hits:** ${result.hitCount} | **Returned:** ${result.hits.length}`, `**Sources:** ${result.appliedSources.join(', ')}`, `**Cursor:** ${result.cursorMark}${result.nextCursorMark ? ` → \`${result.nextCursorMark}\` (next page)` : ' (final page)'}`, `**Search URL:** ${result.searchUrl}`, ]; if (result.notice) lines.push(`\n> ${result.notice}`); if (result.hits.length > 0) { lines.push('\n### Hits'); for (const h of result.hits) { lines.push(`\n#### ${h.title ?? h.epmcId}`); lines.push(`**Source:** ${h.source} | **EPMC ID:** ${h.epmcId}`); if (h.authors) lines.push(`**Authors:** ${h.authors}`); if (h.journal) lines.push(`**Journal:** ${h.journal}`); if (h.firstPublicationDate) lines.push(`**Published:** ${h.firstPublicationDate}`); if (h.pubYear) lines.push(`**Year:** ${h.pubYear}`); if (h.pmid) lines.push(`**PMID:** ${h.pmid}`); if (h.pmcId) lines.push(`**PMCID:** ${h.pmcId}`); if (h.doi) lines.push(`**DOI:** ${h.doi}`); if (h.isOpenAccess !== undefined) lines.push(`**Open Access:** ${h.isOpenAccess ? 'yes' : 'no'}`); if (h.hasFullTextXml !== undefined) { lines.push(`**Full-text XML in EPMC:** ${h.hasFullTextXml ? 'yes' : 'no'}`); } if (typeof h.citedByCount === 'number') lines.push(`**Cited by:** ${h.citedByCount}`); lines.push(`**URL:** ${h.epmcUrl}`); if (h.abstractSnippet) lines.push(`\n${h.abstractSnippet}`); } } return [{ type: 'text', text: lines.join('\n') }]; }, }); //# sourceMappingURL=pubmed-europepmc-search.tool.js.map