Skip to main content
Glama
geodaai
by geodaai
README.md
# kepler-mcp-app

A demo of building an **MCP App** for [kepler.gl](https://kepler.gl) — the new
[MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview)
where a server returns an interactive HTML interface that the host (Claude
Desktop / Claude.ai) renders in a **sandboxed iframe inside the chat**.

This is distinct from:

- **WebMCP** (native `document.modelContext` registration), and
- the existing **kepler-mcp-demo** (`~/github/kepler-mcp-demo`), where the map
  renders on the kepler.gl *website* and a node daemon routes MCP tool calls to
  the page over a WebSocket bridge.

Here, **the kepler.gl map renders inside the MCP App iframe**, and the `map.*`
commands from [`@kepler.gl/mcp`](https://github.com/keplergl/kepler.gl/pull/3678)
execute **inside that iframe** against the iframe's own kepler.gl redux store.
No WebSocket bridge, no external website.

## Architecture

```
Claude Desktop / Claude.ai (host)
  │  MCP (Streamable HTTP, e.g. via cloudflared tunnel)
  ▼
kepler-mcp-app server (node, port 3001)
  │  • serves ui://kepler-map/mcp-app.html  (bundled kepler.gl app, single HTML file)
  │  • registers kepler_show_map tool with _meta.ui.resourceUri → the UI resource
  ▼
MCP App iframe (sandboxed, rendered by host inside the chat)
  ├── kepler.gl map (React 19 + redux store, @kepler.gl/components KeplerGl)
  ├── App instance (@modelcontextprotocol/ext-apps)
  └── registers every map.* command from @kepler.gl/mcp as an app tool
      (map_load_data, map_add_layer, map_set_basemap, …) + kepler_get_map_skill
```

**Flow:** the agent calls `kepler_show_map` → the host fetches the UI resource
and renders the kepler.gl app in a sandboxed iframe → the iframe boots
kepler.gl, builds a `KeplerContext` from its own redux store, and registers the
`map.*` commands via `App.registerTool()` → the agent can then call
`map_load_data` / `map_add_layer` / … and the host routes each call to the
iframe, which executes it against its redux store and returns the result. The
app also calls `app.updateModelContext()` to tell the model the map is ready.

This is the same pattern as the official `map-server` example (CesiumJS globe)
in `modelcontextprotocol/ext-apps`: the server registers the launch tool + UI
resource, and the app registers the interactive tools.

## Repo structure

```
kepler-mcp-app/
├── package.json            # deps: @kepler.gl/*, @modelcontextprotocol/ext-apps, react, redux, vite
├── tsconfig.json           # app + server typecheck
├── vite.config.ts          # vite + vite-plugin-singlefile → single mcp-app.html
├── server.ts               # MCP server: kepler_show_map tool + ui:// resource (with CSP)
├── main.ts                 # Streamable HTTP + stdio entry
├── mcp-app.html            # kepler.gl app HTML entry
├── src/
│   ├── mcp-app.tsx         # embeds <KeplerGl>, creates App, registers map.* tools
│   ├── kepler-store.ts     # redux store (keplerGlReducer + thunk)
│   ├── kepler-context.ts   # buildKeplerContext(store) — live accessors into the iframe's store
│   └── tool-utils.ts       # toMcpToolName, resultToText, toCallToolResult, skill tool
├── vendor/@kepler.gl/mcp/  # vendored @kepler.gl/mcp source (from PR #3678) + package.json
├── files/                  # sample data (random_points.csv) served at /files/*
└── README.md
```

## Run

```bash
npm install
npm run build     # vite → dist/mcp-app.html; esbuild → dist/index.js
npm start         # node dist/index.js → http://localhost:3001/mcp
```

`npm run dev` rebuilds the app on change and restarts the server.

## Test

**Server smoke test:**

```bash
curl -X POST http://localhost:3001/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
```

`tools/list` should return `kepler_show_map` with `_meta.ui.resourceUri`, and
`resources/read` of `ui://kepler-map/mcp-app.html` should return the bundled
HTML.

**basic-host** (from `modelcontextprotocol/ext-apps`):

```bash
SERVERS='["http://localhost:3001/mcp"]' npm start   # in examples/basic-host
```

Call `kepler_show_map`, confirm the kepler.gl map renders in the sandboxed
iframe, then call `map_load_data` (data URL) + `map_add_layer` and confirm the
map updates.

**Claude Desktop:**

```bash
npx cloudflared tunnel --url http://localhost:3001
```

Add the tunnel URL as a custom connector, then ask it to "show me a map of
these points" and confirm the map renders in-chat and the `map.*` tools drive
it.

## Loading data

The sandboxed iframe cannot fetch `localhost`, so load local files as **data
URLs** (≤2 MB):

```
data:text/csv;base64,<base64>
```

See [`files/README.md`](files/README.md) for a worked example. The server also
serves `files/` at `/files/*` with CORS for hosts whose iframe can reach it.

## Notes

- **Basemap**: kepler.gl 3.3.0-alpha.10 defaults to MapLibre with CARTO styles
  (`dark-matter` / `positron` / `voyager`) — no Mapbox token needed. The UI
  resource's CSP allows `basemaps.cartocdn.com` and
  `*.basemaps.cartocdn.com`. The wildcard matters: the CARTO vector TileJSON
  advertises its tiles on sharded hostnames
  (`tiles-a…d.basemaps.cartocdn.com`), so allowing only
  `tiles.basemaps.cartocdn.com` silently blocks the tile fetches and the map
  stays black (the `background` layer still paints, which looks like a working
  dark map). `data:` is also allowed in `connect-src` so `map_load_data` can
  fetch data URLs.
- **KeplerGl sizing**: the `<KeplerGl>` component's `width`/`height` props must
  be pixel numbers — the root div gets inline `width: ${width}px`. Passing
  `"100%"` produces invalid CSS, the div collapses to height 0, and kepler's
  ResizeObserver then reports a zero size forever. `src/mcp-app.tsx` tracks the
  iframe viewport (`window.innerWidth/Height` + a `resize` listener) instead.
- **DuckDB commands**: `map.create-table`, `map.add-column`, and `map.save-data`
  need a DuckDB connector and are filtered out of the catalog. The skill tool
  (`kepler_get_map_skill`) notes this.
- **`@kepler.gl/mcp`** is not on npm yet; it is vendored from PR #3678 into
  `vendor/@kepler.gl/mcp/` and depended on via `file:`.