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