Skip to main content
Glama
krhoyt
by krhoyt

Chart Generator MCP

A small, working reference example: an MCP server hosted on Adobe I/O Runtime (App Builder), exposing two "turn-by-turn" tools that render a finished chart image in one tool call — no interactive UI, no follow-up steps. You ask for a chart, you get a PNG back in the conversation and a link to the underlying SVG.

This exists to answer one question concretely: what does it actually take to host an MCP server on App Builder, correctly? Not the happy-path tutorial version — the actual constraints (an older Node runtime, how chat clients render images, what breaks silently) that only show up once you deploy and try it against a real client. Everything in here was hit and fixed against a real Adobe I/O Runtime deployment and a real MCP client (Claude Desktop), not just written and assumed to work.

What it does

Two tools:

  • generate_column_chart — a vertical bar chart from a list of values and labels.

  • generate_statistic_infographic — a single-stat card: a big number, a label, supporting text.

Call either one, and the response contains:

  1. A real inline image (the PNG), which displays directly in the conversation.

  2. A link to the SVG (a short-lived presigned URL), for whoever wants the vector source.

That split is deliberate — see Why PNG inline and SVG as a link below, it's the single most important design decision in this repo and the one most likely to matter for whatever you build next.

Related MCP server: Chart-Output MCP Server

Project structure

actions/
  mcp-chart-generator/
    index.js          # The MCP action entrypoint - wires everything together
    tools.js           # Tool registration: schemas, handlers, response shaping
  charts/
    column.js          # Renders the column chart to SVG, then PNG, then uploads both
  infographics/
    statistic.js        # Renders the stat-card infographic, same pipeline
  shared/
    svg.js, rect.js, rounded-rect.js, text.js, wrap-text.js
                        # Small SVG-string-building primitives - see below
    mcp/
      create-handler.js  # Generic "wire an MCP server into an OW web action" helper
      node18-web-globals.js  # Polyfills the MCP SDK needs on Node 18 (see below)
      validator.js        # Optional inbound auth (static key or Adobe IMS)
  utils/
    fonts.js            # Loads the bundled Roboto font once per warm container
    svg-to-png.js        # Rasterizes SVG -> PNG via @resvg/resvg-wasm
  fonts/
    Roboto-Regular.ttf, Roboto-Medium.ttf
app.config.yaml          # The one action this project deploys
package.json

The only files you'd actually need to read to understand this specific pattern are actions/mcp-chart-generator/index.js, tools.js, and actions/shared/mcp/create-handler.js. Everything else is chart-rendering implementation detail that happens to also be a reasonably clean small example of hand-built SVG generation, but isn't the point.

How it's structured, and why

create-handler.js is the reusable core. It takes a registerTools(server) function and gives back a complete OpenWhisk web-action main(params) — CORS, health checks, the Streamable-HTTP transport, optional auth, all handled once. If you're building a different MCP server on App Builder, this file (plus node18-web-globals.js) is what you'd actually copy. Everything else in this repo is specific to generating charts.

tools.js is a real, working example of registering MCP tools with Zod schemas against the actual @modelcontextprotocol/sdk McpServer API (server.registerTool(name, { title, description, inputSchema }, handler)), not a simplified version of it.

column.js/statistic.js render SVG by hand, as template strings and small composable primitives (rect(), roundedRect(), text(), wrappedText()), not a charting library. That's a deliberate choice for a small, fixed set of chart types you want full visual control over — not a recommendation against using a real charting library for anything more ambitious. wrappedText()/wrap-text.js in particular is worth a look if you need word-wrapped text in an SVG-only pipeline: it measures glyph widths manually with opentype.js rather than shaping text (see the comment in wrap-text.js for why).

Two separate lessons, learned the hard way, both baked into imageContent() in tools.js:

A plain link to an image does not display in most MCP chat clients. Returning { type: 'text', text: 'Here: https://...png' } just shows as a link the user has to click. To actually get an image to render in the conversation, the response needs a real { type: 'image', data: <base64>, mimeType: 'image/png' } content block. That means fetching the bytes and embedding them, not just handing back a URL — see fetchAsBase64().

Raw SVG markup should not go in the response text at all. It's tempting to just inline the SVG string the same way, since it's already text. Don't — MCP tool output becomes part of the model's own context, and an LLM that sees raw SVG markup in its context can end up "helpfully" rewriting, summarizing, or mistranscribing it the next time it's discussed or repeated. Keep it as an opaque link instead: the model can still hand the link back to the user on request (it's sitting right there in the text), but the actual markup is never something the model is asked to reproduce from memory.

The practical effect: column.js/statistic.js upload both the SVG and the PNG to blob storage (@adobe/aio-lib-files) and return presigned URLs for both; tools.js fetches only the PNG bytes to embed, and passes the SVG URL through as a link, untouched.

The presigned links expire in 10 minutes (PRESIGN_EXPIRY_SECONDS in both chart files) — intentional, not a bug to fix. If your use case needs the artifact to outlive that (e.g. referenced from a saved conversation later), you'll want to either extend that expiry or move to permanent storage instead of presigned URLs — that trade-off is yours to make per use case, this repo just shows the mechanism.

Node 18 gotchas (the part that isn't documented anywhere obvious)

Adobe I/O Runtime's actual deployed Node 18 is missing a few globals that modern SDKs assume exist. Both bit us building this, and both are fixed in ways worth understanding rather than copying blind:

  • @modelcontextprotocol/sdk references the global File class at module load time. Node only made File a global in v20. node18-web-globals.js polyfills it from node:buffer's Blob before anything requires an MCP SDK module — order matters here, which is why it's required first, at the top of create-handler.js.

  • The SDK, and separately @adobe/aio-lib-files (via @azure/storage-blob), call crypto.randomUUID() as a bare global. Node only exposed Web Crypto as an unflagged global starting in v19. Both column.js and statistic.js polyfill it locally from node:crypto's webcrypto.

  • Avoid requiring the @adobe/aio-sdk umbrella package. One of its bundled sub-SDKs (Target/Analytics/Campaign Standard, pulled in via swagger-client) references the global File class unconditionally at load time and crashes on Node 18 before the polyfill above even gets a chance to run, if something else requires it first. Require the narrower packages directly instead — @adobe/aio-lib-core-logging, @adobe/aio-lib-files — as this repo does everywhere.

If you're targeting nodejs:20 or later for your own action, none of this applies — those globals exist natively. This project intentionally stays on nodejs:18 (see app.config.yaml) specifically to prove the pattern works even on the older, more constrained runtime, since that's what you're more likely to inherit in an existing App Builder project.

Getting started

Prerequisites: Node 18+, the Adobe I/O CLI (npm install -g @adobe/aio-cli), and an Adobe Developer Console project/workspace with App Builder enabled.

npm install
aio app use          # link this project to a real Console workspace - creates .env/.aio (gitignored)
aio app dev           # run locally; prints local action URLs

column.js/statistic.js also accept local: 'true' as a parameter, which skips the Files/blob-storage upload and PNG render entirely and hands back raw SVG directly — useful for fast visual iteration on the template without needing Files credentials or a full deploy. Hitting an action's URL directly with ?local=true opens the SVG straight in a browser.

Deploy for real:

aio app deploy

This deploys one action, mcp-chart-generator, under whatever package name your workspace uses (see app.config.yaml) — the resulting URL is your MCP endpoint.

Testing the deployed endpoint

With the real MCP SDK client:

const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js');

const transport = new StreamableHTTPClientTransport(new URL('<your deployed URL>'));
const client = new Client({ name: 'test-client', version: '1.0.0' });
await client.connect(transport);

const result = await client.callTool({
  name: 'generate_column_chart',
  arguments: { values: [63, 80, 78, 37], labels: ['A', 'B', 'C', 'D'] }
});

Or connect it as a custom connector in Claude Desktop and just ask for a chart.

Auth (optional)

actions/shared/mcp/validator.js supports two opt-in modes, controlled by inputs in app.config.yaml (set via .env, not committed):

  • SERVICE_API_KEY — a static shared secret; callers send Authorization: Bearer <key> or x-api-key: <key>.

  • AUTH_VALIDATE_IMS=true — requires a valid Adobe IMS bearer token, validated against IMS userinfo.

Leave both unset for no auth (fine for local dev and demos; not for anything real).

Adapting this for a new use case

This is meant to be a starting point, not a finished product. A few notes if you're using it as a base:

  • Add a chart type by writing a new file alongside column.js/statistic.js that takes params and returns the same { statusCode, headers, body } shape (raw SVG for local: 'true', { svg, png } presigned URLs otherwise), then register a matching tool in tools.js. The SVG-building primitives in shared/ (rect, roundedRect, text, wrappedText) are there to reuse.

  • All the default/placeholder values you'll see (category labels, sample metrics, etc.) are generic placeholders, not real example data from anywhere — replace them freely.

  • column.js's lg size is intentionally incomplete — its dimensions table is missing chartWidth/chartHeight (it's a different aspect ratio from sm/md, not just a bigger version of them, so guessing plausible numbers risked shipping something subtly wrong rather than honestly unfinished). Requesting it returns a clean 400 rather than silently producing broken output. Finishing it — or deciding it should look different entirely — is a reasonable first exercise if you fork this.

  • If your use case needs interactivity (the user adjusts the chart, sees it update live, rather than one tool call producing one finished image), that's a materially different, more involved pattern — MCP Apps (SEP-1865), an interactive resource with its own UI rendered inline by the host — not covered by this repo. This one is specifically the simpler "one tool call, one finished artifact" shape.

License

Apache-2.0 (matches app.config.yaml's package license).

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    Renders 45+ interactive chart types, dashboards, and KPI widgets directly inside AI conversations. Supports drill-down, live API polling, 20 themes, and one-click export to PNG, PowerPoint, and A4 documents.
    40
    359 npm
    47
    Functional Source , Version 1.1, MIT Future
  • A
    license
    A
    quality
    C
    maintenance
    Enables AI agents to create data visualizations like bar charts, line charts, pie charts, scatter plots, and histograms, returning inline SVG or PNG files.
    5
    MIT
  • A
    license
    B
    quality
    D
    maintenance
    Enables AI agents to render branded charts as inline images and persistent hosted URLs, supporting explicit chart types and automatic chart suggestion from data.
    2
    60 npm
    MIT