Skip to main content
Glama
krhoyt
by krhoyt
README.md
# 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](#why-png-inline-and-svg-as-a-link-not-the-other-way-around) below, it's the single most important design decision in this repo and the one most likely to matter for whatever you build next.

## 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).

## Why PNG inline, and SVG as a link (not the other way around)

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](https://github.com/adobe/aio-cli) (`npm install -g @adobe/aio-cli`), and an Adobe Developer Console project/workspace with App Builder enabled.

```bash
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:

```bash
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:

```js
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).