kepler-mcp-app
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:`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues