@cyanheads/pubmed-mcp-server
Version:
A Model Context Protocol (MCP) server enabling AI agents to intelligently search, retrieve, and analyze biomedical literature from PubMed via NCBI E-utilities. Built on the mcp-ts-template for robust, production-ready performance.
194 lines (193 loc) • 8.07 kB
JavaScript
/**
* @fileoverview Core logic for the generate_pubmed_chart tool.
* Generates charts from parameterized input by creating Chart.js configurations
* and rendering them on the server using chartjs-node-canvas.
* @module src/mcp-server/tools/generatePubMedChart/logic
*/
import { ChartJSNodeCanvas } from "chartjs-node-canvas";
import { z } from "zod";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
import { logger, requestContextService, sanitizeInputForLogging, } from "../../../utils/index.js";
export const GeneratePubMedChartInputSchema = z.object({
chartType: z
.enum([
"bar",
"line",
"scatter",
"pie",
"doughnut",
"bubble",
"radar",
"polarArea",
])
.describe("Required. Specifies the type of chart to generate. Options: 'bar', 'line', 'scatter', 'pie', 'doughnut', 'bubble', 'radar', 'polarArea'."),
title: z
.string()
.optional()
.describe("Optional. The main title displayed above the chart. If omitted, no title is shown."),
width: z
.number()
.int()
.positive()
.optional()
.default(800)
.describe("Optional. The width of the chart canvas in pixels. Must be a positive integer. Default: 800."),
height: z
.number()
.int()
.positive()
.optional()
.default(600)
.describe("Optional. The height of the chart canvas in pixels. Must be a positive integer. Default: 600."),
dataValues: z
.array(z.record(z.string(), z.any()))
.min(1)
.describe("Required. An array of data objects used to plot the chart. Each object represents a data point or bar, structured as key-value pairs (e.g., [{ 'year': '2020', 'articles': 150 }, { 'year': '2021', 'articles': 180 }]). Must contain at least one data object."),
outputFormat: z
.enum(["png"])
.default("png")
.describe("Specifies the output format for the chart. Currently, only 'png' (Portable Network Graphics) is supported and is the default."),
xField: z
.string()
.describe("Required. The name of the field in `dataValues` to be used for the X-axis (horizontal). This field determines the categories or values along the bottom of the chart (e.g., 'year', 'geneName', 'publicationCount')."),
yField: z
.string()
.describe("Required. The name of the field in `dataValues` to be used for the Y-axis (vertical). This field determines the values plotted upwards on the chart (e.g., 'articles', 'expressionLevel', 'citationCount')."),
seriesField: z
.string()
.optional()
.describe("Optional. The name of the field in `dataValues` used to create multiple distinct lines or bar groups (series) on the same chart. Each unique value in this field will correspond to a separate dataset."),
sizeField: z
.string()
.optional()
.describe("Optional. For bubble charts. The name of the field in `dataValues` to use for encoding the size of the bubbles. Larger values in this field will result in larger bubbles (e.g., 'sampleSize', 'effectMagnitude')."),
});
// Helper to group data by a series field
function groupDataBySeries(data, xField, yField, seriesField) {
const series = new Map();
for (const item of data) {
const seriesName = item[seriesField];
if (!series.has(seriesName)) {
series.set(seriesName, []);
}
series.get(seriesName).push({ x: item[xField], y: item[yField] });
}
return series;
}
export async function generatePubMedChartLogic(input, parentRequestContext) {
const operationContext = requestContextService.createRequestContext({
parentRequestId: parentRequestContext.requestId,
operation: "generatePubMedChartLogicExecution",
input: sanitizeInputForLogging(input),
});
logger.info(`Executing 'generate_pubmed_chart' with Chart.js. Chart type: ${input.chartType}`, operationContext);
const { width, height, chartType, dataValues, xField, yField, title, seriesField, sizeField, } = input;
const chartJSNodeCanvas = new ChartJSNodeCanvas({
width,
height,
chartCallback: (ChartJS) => {
ChartJS.defaults.responsive = false;
ChartJS.defaults.maintainAspectRatio = false;
},
});
const labels = [...new Set(dataValues.map((item) => item[xField]))];
let datasets;
if (seriesField) {
const groupedData = groupDataBySeries(dataValues, xField, yField, seriesField);
datasets = Array.from(groupedData.entries()).map(([seriesName, data]) => ({
label: seriesName,
data: labels.map(label => {
const point = data.find(p => p.x === label);
return point ? point.y : null;
}),
// You can add backgroundColor, borderColor etc. here for styling
}));
}
else {
datasets = [
{
label: yField,
data: labels.map(label => {
const item = dataValues.find(d => d[xField] === label);
return item ? item[yField] : null;
}),
},
];
}
// For scatter and bubble charts, the data format is different
if (chartType === 'scatter' || chartType === 'bubble') {
if (seriesField) {
const groupedData = groupDataBySeries(dataValues, xField, yField, seriesField);
datasets = Array.from(groupedData.entries()).map(([seriesName, data]) => ({
label: seriesName,
data: data.map(point => ({
x: point.x,
y: point.y,
r: chartType === 'bubble' && sizeField ? dataValues.find(d => d[xField] === point.x)[sizeField] : undefined
})),
}));
}
else {
datasets = [{
label: yField,
data: dataValues.map(item => ({
x: item[xField],
y: item[yField],
r: chartType === 'bubble' && sizeField ? item[sizeField] : undefined
})),
}];
}
}
const configuration = {
type: chartType,
data: {
labels: (chartType !== 'scatter' && chartType !== 'bubble') ? labels : undefined,
datasets: datasets,
},
options: {
plugins: {
title: {
display: !!title,
text: title,
},
},
scales: chartType === "pie" || chartType === "doughnut" || chartType === "polarArea"
? undefined
: {
x: {
title: {
display: true,
text: xField,
},
},
y: {
title: {
display: true,
text: yField,
},
},
},
},
};
try {
const imageBuffer = await chartJSNodeCanvas.renderToBuffer(configuration);
const base64Data = imageBuffer.toString("base64");
logger.notice("Successfully generated chart with Chart.js.", {
...operationContext,
chartType: input.chartType,
dataPoints: input.dataValues.length,
});
return {
base64Data,
chartType: input.chartType,
dataPoints: input.dataValues.length,
};
}
catch (error) {
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Chart generation failed: ${error.message || "Internal server error during chart generation."}`, {
...operationContext,
originalErrorName: error.name,
originalErrorMessage: error.message,
});
}
}