@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.
246 lines • 12.8 kB
JavaScript
/**
* @fileoverview Citation lookup tool. Resolves partial bibliographic references
* to PubMed IDs using NCBI's ECitMatch service, then verifies author agreement
* against ESummary to catch cases where ECitMatch's journal+volume+page weighting
* returns a PMID whose author roster doesn't contain the queried author.
* @module src/mcp-server/tools/definitions/lookup-citation.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 { extractBriefSummaries } from '../../../services/ncbi/parsing/esummary-parser.js';
import { conceptMeta, EDAM_DATA_RETRIEVAL, EDAM_PUBMED_ID, SCHEMA_SCHOLARLY_ARTICLE, } from './_concepts.js';
/** Extract the surname token (first whitespace-separated part) from an author string. */
function surname(name) {
return name.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
}
/** Split a formatted authors string ("Lastname FI, Lastname FI, et al.") into surnames. */
function authorSurnames(authors) {
return authors
.split(', ')
.map((a) => surname(a))
.filter((s) => s.length > 0 && s !== 'et');
}
export const lookupCitationTool = tool('pubmed_lookup_citation', {
description: `Look up PubMed IDs from partial bibliographic citations. Useful when you have a reference (journal, year, volume, page, author) and need the PMID — deterministic citation matching, more reliable than free-text search for structured references. Each citation must include at least one bibliographic field (journal, year, volume, firstPage, or authorName); more fields = better match accuracy.`,
annotations: { readOnlyHint: true, openWorldHint: true },
_meta: conceptMeta([SCHEMA_SCHOLARLY_ARTICLE, EDAM_DATA_RETRIEVAL, EDAM_PUBMED_ID]),
sourceUrl: 'https://github.com/cyanheads/pubmed-mcp-server/blob/main/src/mcp-server/tools/definitions/lookup-citation.tool.ts',
errors: [...NCBI_SERVICE_ERRORS],
input: z.object({
citations: z
.array(z
.object({
journal: z
.string()
.optional()
.describe('Journal title or ISO abbreviation (e.g., "proc natl acad sci u s a")'),
year: z.string().optional().describe('Publication year (e.g., "1991")'),
volume: z.string().optional().describe('Volume number'),
firstPage: z.string().optional().describe('First page number'),
authorName: z
.string()
.optional()
.describe('Author name, typically "lastname initials" (e.g., "mann bj")'),
key: z
.string()
.optional()
.describe('Arbitrary label to track this citation in results. Auto-assigned if omitted.'),
})
.describe('Citation to match against PubMed. Must include at least one bibliographic field (journal, year, volume, firstPage, or authorName).')
.refine((c) => !!(c.journal || c.year || c.volume || c.firstPage || c.authorName), {
message: 'Each citation must include at least one bibliographic field (journal, year, volume, firstPage, or authorName).',
}))
.min(1)
.max(25)
.describe('Citations to look up. More fields = better match accuracy.'),
}),
output: z.object({
results: z
.array(z
.object({
key: z.string().describe('Citation tracking key'),
pmid: z.string().optional().describe('Matched PubMed ID'),
matched: z.boolean().describe('Whether a PMID was found'),
status: z
.enum(['matched', 'not_found', 'ambiguous'])
.describe('Lookup outcome classification for this citation'),
detail: z
.string()
.optional()
.describe('Additional detail returned by ECitMatch for non-exact matches'),
candidatePmids: z
.array(z.string().describe('PMID'))
.optional()
.describe('Candidate PMIDs returned when the citation matched ambiguously. Add more bibliographic fields and retry to disambiguate, or fetch each candidate via pubmed_fetch_articles to pick the intended one.'),
matchedFirstAuthor: z
.string()
.optional()
.describe('First author of the matched article (e.g., "Husain M"). Useful eyeball signal for verifying a match.'),
warnings: z
.array(z
.object({
code: z
.enum(['author_mismatch', 'year_mismatch'])
.describe('Machine-readable warning code'),
message: z.string().describe('Human-readable description of the warning'),
})
.describe('Non-fatal warning about the match'))
.optional()
.describe('Non-fatal warnings about this match. A PMID may be returned even when the queried author or year disagrees with the matched article — verify before treating the PMID as authoritative.'),
})
.describe('Per-citation match result'))
.describe('Match results, one per input citation'),
totalMatched: z.number().describe('Number of citations with PMID matches'),
totalSubmitted: z.number().describe('Number of citations submitted'),
totalWarnings: z
.number()
.describe('Number of matched citations that carry at least one warning'),
}),
async handler(input, ctx) {
ctx.log.info('Executing pubmed_lookup_citation', { count: input.citations.length });
const citations = input.citations.map((c, i) => ({
journal: c.journal,
year: c.year,
volume: c.volume,
firstPage: c.firstPage,
authorName: c.authorName,
key: c.key ?? String(i + 1),
}));
const ncbi = getNcbiService();
const results = await ncbi.eCitMatch(citations, { signal: ctx.signal });
const queriedAuthorByKey = new Map();
const queriedYearByKey = new Map();
for (const c of citations) {
if (c.authorName)
queriedAuthorByKey.set(c.key, c.authorName);
if (c.year)
queriedYearByKey.set(c.key, c.year);
}
const matchedPmids = Array.from(new Set(results.filter((r) => r.matched && r.pmid).map((r) => r.pmid)));
const summaryByPmid = new Map();
if (matchedPmids.length > 0) {
const summaryResult = await ncbi.eSummary({ db: 'pubmed', id: matchedPmids.join(',') }, { signal: ctx.signal });
const summaries = await extractBriefSummaries(summaryResult);
for (const s of summaries) {
summaryByPmid.set(s.pmid, {
...(s.authors && { authors: s.authors }),
...(s.pubDate && { pubDate: s.pubDate }),
});
}
}
const mapped = results.map((r) => {
const base = {
key: r.key,
matched: r.matched,
status: r.status,
...(r.pmid && { pmid: r.pmid }),
...(r.detail && { detail: r.detail }),
...(r.candidatePmids?.length && { candidatePmids: r.candidatePmids }),
};
if (!r.matched || !r.pmid)
return base;
const summary = summaryByPmid.get(r.pmid);
if (!summary)
return base;
const warnings = [];
const { authors, pubDate } = summary;
if (authors) {
const firstAuthor = authors.split(', ')[0]?.trim();
if (firstAuthor && firstAuthor !== 'et al.')
base.matchedFirstAuthor = firstAuthor;
const queried = queriedAuthorByKey.get(r.key);
if (queried) {
const querySurname = surname(queried);
const articleSurnames = authorSurnames(authors);
if (querySurname && !articleSurnames.includes(querySurname)) {
warnings.push({
code: 'author_mismatch',
message: `Queried author "${queried}" not found in matched article authors (${authors}). ECitMatch weights journal+volume+page and may return a PMID whose authors disagree with the query — verify before using this PMID.`,
});
ctx.log.warning('Citation match returned PMID with author mismatch', {
key: r.key,
pmid: r.pmid,
queriedAuthor: queried,
matchedAuthors: authors,
});
}
}
}
const queriedYear = queriedYearByKey.get(r.key);
if (queriedYear && pubDate) {
const matchedYear = pubDate.slice(0, 4);
if (/^\d{4}$/.test(matchedYear) && matchedYear !== queriedYear.trim()) {
warnings.push({
code: 'year_mismatch',
message: `Queried year "${queriedYear}" does not match matched article year "${matchedYear}" (pubDate ${pubDate}). ECitMatch tolerates year disagreement — verify before using this PMID.`,
});
ctx.log.warning('Citation match returned PMID with year mismatch', {
key: r.key,
pmid: r.pmid,
queriedYear,
matchedYear,
});
}
}
if (warnings.length > 0)
base.warnings = warnings;
return base;
});
const totalMatched = mapped.filter((r) => r.matched).length;
const totalWarnings = mapped.filter((r) => r.warnings?.length).length;
ctx.log.info('pubmed_lookup_citation completed', {
totalMatched,
totalSubmitted: citations.length,
totalWarnings,
});
return { results: mapped, totalMatched, totalSubmitted: citations.length, totalWarnings };
},
format: (result) => {
const lines = [
`## Citation Lookup Results`,
`**Matched:** ${result.totalMatched}/${result.totalSubmitted}`,
];
if (result.totalWarnings > 0) {
lines.push(`**Warnings:** ${result.totalWarnings}`);
}
for (const r of result.results) {
lines.push(`\n### ${r.key}`);
if (r.pmid)
lines.push(`**PMID:** ${r.pmid}`);
if (r.matchedFirstAuthor)
lines.push(`**First Author:** ${r.matchedFirstAuthor}`);
if (r.candidatePmids?.length) {
lines.push(`**Candidate PMIDs:** ${r.candidatePmids.join(', ')}`);
}
if (r.detail)
lines.push(`**Detail:** ${r.detail}`);
if (r.warnings?.length) {
lines.push(`**Warnings:**`);
for (const w of r.warnings) {
lines.push(`- [${w.code}] ${w.message}`);
}
}
if (r.status === 'matched') {
const mismatches = r.warnings?.map((w) => w.code) ?? [];
const hasMismatch = mismatches.length > 0;
lines.push(`**Status:** Matched`);
lines.push(hasMismatch
? `**Next Step:** ${mismatches.join(' + ')} detected — confirm this PMID is the intended article before citing. ECitMatch weights journal+volume+page and can resolve despite author/year disagreement.`
: `**Next Step:** PMID is ready for downstream PubMed fetch or citation tools.`);
continue;
}
if (r.status === 'ambiguous') {
lines.push(`**Status:** Ambiguous`);
lines.push(r.candidatePmids?.length
? `**Next Step:** Add more citation fields to disambiguate, or fetch the candidate PMIDs above via pubmed_fetch_articles to pick the intended one manually.`
: `**Next Step:** Add more citation fields such as journal, year, volume, firstPage, or authorName, then retry.`);
continue;
}
lines.push(`**Status:** No match`);
lines.push(`**Next Step:** Verify the citation details or try pubmed_search_articles.`);
}
return [{ type: 'text', text: lines.join('\n') }];
},
});
//# sourceMappingURL=lookup-citation.tool.js.map