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.

166 lines 7.55 kB
/** * @fileoverview MeSH (Medical Subject Headings) vocabulary lookup tool. * Searches the NCBI MeSH database and optionally retrieves detailed records. * @module src/mcp-server/tools/definitions/lookup-mesh.tool */ import { tool, z } from '@cyanheads/mcp-ts-core'; import { NCBI_SERVICE_ERRORS } from '../../../services/error-contracts.js'; import { getNcbiService } from '../../../services/ncbi/ncbi-service.js'; import { ensureArray, getText } from '../../../services/ncbi/parsing/xml-helpers.js'; import { conceptMeta, EDAM_DATA_RETRIEVAL, EDAM_ONTOLOGY_TERMINOLOGY, SCHEMA_DEFINED_TERM, SCHEMA_DEFINED_TERM_SET, } from './_concepts.js'; function findItem(items, name) { return items.find((it) => getText(it['@_Name']) === name); } function getItemText(item) { if (!item) return ''; const direct = getText(item, ''); if (direct) return direct; const subItems = ensureArray(item.Item); return subItems.length > 0 ? getText(subItems[0]) : ''; } function getItemTexts(item) { if (!item) return []; const subItems = ensureArray(item.Item); return subItems.map((si) => getText(si)).filter((s) => s.length > 0); } function extractTreeNumbers(items) { const idxLinks = findItem(items, 'DS_IdxLinks'); if (!idxLinks) return []; const linkStructures = ensureArray(idxLinks.Item); const treeNums = []; for (const struct of linkStructures) { const structItems = ensureArray(struct.Item); const treeItem = findItem(structItems, 'TreeNum'); const val = treeItem ? getText(treeItem) : ''; if (val) treeNums.push(val); } return treeNums; } function parseSummaryRecords(data, ids, includeDetails) { if (!data || typeof data !== 'object') return ids.map((id) => ({ meshId: id, name: id })); const root = data; const summaryResult = root.eSummaryResult; const docSums = ensureArray((summaryResult ?? root).DocSum); if (docSums.length === 0) return ids.map((id) => ({ meshId: id, name: id })); return docSums.map((doc) => { const meshId = getText(doc.Id); const items = ensureArray(doc.Item); const name = getItemText(findItem(items, 'DS_MeshTerms')) || meshId; const record = { meshId, name }; if (includeDetails) { const scopeNote = getItemText(findItem(items, 'DS_ScopeNote')); if (scopeNote) record.scopeNote = scopeNote; const entryTerms = getItemTexts(findItem(items, 'DS_MeshTerms')); if (entryTerms.length > 0) record.entryTerms = entryTerms; const treeNumbers = extractTreeNumbers(items); if (treeNumbers.length > 0) record.treeNumbers = treeNumbers; } return record; }); } // ─── Tool Definition ───────────────────────────────────────────────────────── export const lookupMeshTool = tool('pubmed_lookup_mesh', { description: 'Search and explore the MeSH (Medical Subject Headings) controlled vocabulary. Returns descriptor records with tree numbers, scope notes, and entry terms.', annotations: { readOnlyHint: true, openWorldHint: true }, _meta: conceptMeta([ SCHEMA_DEFINED_TERM, SCHEMA_DEFINED_TERM_SET, EDAM_ONTOLOGY_TERMINOLOGY, EDAM_DATA_RETRIEVAL, ]), sourceUrl: 'https://github.com/cyanheads/pubmed-mcp-server/blob/main/src/mcp-server/tools/definitions/lookup-mesh.tool.ts', errors: [...NCBI_SERVICE_ERRORS], input: z.object({ query: z.string().min(1).describe('MeSH descriptor name or free-text term to look up'), maxResults: z.number().int().min(1).max(50).default(10).describe('Maximum results'), includeDetails: z .boolean() .default(true) .describe('Fetch full MeSH records (scope notes, tree numbers, entry terms)'), }), output: z.object({ query: z.string().describe('Original search query'), results: z .array(z .object({ meshId: z.string().describe('MeSH descriptor unique identifier'), name: z.string().describe('Descriptor name'), treeNumbers: z.array(z.string()).optional().describe('MeSH tree numbers'), scopeNote: z.string().optional().describe('Scope note'), entryTerms: z.array(z.string()).optional().describe('Synonyms / entry terms'), }) .describe('Matching MeSH descriptor record')) .describe('Matching MeSH records'), notice: z .string() .optional() .describe('Optional guidance when no descriptors matched — suggests spell-check or free-text search. Absent on successful results.'), }), async handler(input, ctx) { const { query, maxResults, includeDetails } = input; const ncbi = getNcbiService(); ctx.log.debug('MeSH lookup started', { query, maxResults, includeDetails }); const hasFieldTag = /\[.+\]/.test(query); const callOpts = { signal: ctx.signal }; const broadSearch = ncbi.eSearch({ db: 'mesh', term: query, retmax: maxResults }, callOpts); const exactSearch = hasFieldTag ? undefined : ncbi.eSearch({ db: 'mesh', term: `${query}[MH]`, retmax: 1 }, callOpts); const [broadResult, exactResult] = await Promise.all([broadSearch, exactSearch]); const seen = new Set(); const ids = []; for (const id of [...(exactResult?.idList ?? []), ...broadResult.idList]) { if (!seen.has(id)) { seen.add(id); ids.push(id); } } ids.length = Math.min(ids.length, maxResults); if (ids.length === 0) { return { query, results: [], notice: `No MeSH descriptors matched "${query}". Try \`pubmed_spell_check\` for a suggested correction, broaden the term, or use \`pubmed_search_articles\` for free-text discovery against article metadata.`, }; } const summaryData = await ncbi.eSummary({ db: 'mesh', id: ids.join(',') }, callOpts); const results = parseSummaryRecords(summaryData, ids, includeDetails); const queryLower = query.toLowerCase(); results.sort((a, b) => { const aExact = a.name.toLowerCase() === queryLower ? 0 : 1; const bExact = b.name.toLowerCase() === queryLower ? 0 : 1; return aExact - bExact; }); return { query, results }; }, format: (result) => { const lines = [ `# MeSH Lookup: "${result.query}"`, `Found **${result.results.length}** result(s).`, ]; if (result.notice) lines.push(`\n> ${result.notice}`); for (const r of result.results) { lines.push(`\n## ${r.name}`); lines.push(`- **MeSH ID:** ${r.meshId}`); if (r.treeNumbers?.length) lines.push(`- **Tree Numbers:** ${r.treeNumbers.join(', ')}`); if (r.scopeNote) lines.push(`- **Scope Note:** ${r.scopeNote}`); if (r.entryTerms?.length) lines.push(`- **Entry Terms:** ${r.entryTerms.join('; ')}`); } return [{ type: 'text', text: lines.join('\n') }]; }, }); //# sourceMappingURL=lookup-mesh.tool.js.map