Skip to main content
Glama
README.md
# metabase-mcp

A self-hosted MCP server that puts Metabase behind a **static bearer token** instead of OAuth.

Metabase v0.63+ ships its own MCP server at `/api/metabase-mcp`, but it is OAuth-only. On the
Claude client side the token exchange (`POST /oauth/token`) is currently never performed:
discovery, dynamic client registration and user authorization all succeed, and the fourth leg
never fires, so the connector can never authenticate
(metabase/metabase [#75084](https://github.com/metabase/metabase/issues/75084),
[#74962](https://github.com/metabase/metabase/issues/74962),
[#80389](https://github.com/metabase/metabase/issues/80389)).

This server sidesteps that entirely: it speaks Metabase's ordinary REST API using an API key held
server-side, and exposes those capabilities over MCP behind a single static token — the same
pattern that already works for self-hosted Firefly III, Obsidian and n8n MCP servers.

```
Claude  ──Authorization: Bearer <MCP_AUTH_TOKEN>──▶  metabase-mcp  ──X-API-KEY: <METABASE_API_KEY>──▶  Metabase
```

- **Transport:** streamable HTTP at `POST /mcp`, listening on `0.0.0.0:3000`
- **Inbound auth:** `Authorization: Bearer <MCP_AUTH_TOKEN>`, compared in constant time
- **Outbound auth:** `X-API-KEY: <METABASE_API_KEY>` against `METABASE_URL`
- **Health check:** `GET /health` (unauthenticated, returns service name, version and tool count)

## Environment variables

| Variable | Required | Default | Purpose |
| --- | --- | --- | --- |
| `METABASE_URL` | yes | — | Base URL of the Metabase instance, e.g. `https://insights.example.com`. Trailing slashes are trimmed. |
| `METABASE_API_KEY` | yes | — | Metabase API key, sent outbound as `X-API-KEY`. |
| `MCP_AUTH_TOKEN` | yes | — | Static token MCP clients must send as `Authorization: Bearer …`. `MCP_TOKEN` is accepted as an alias. |
| `PORT` | no | `3000` | Listen port. |
| `METABASE_TIMEOUT_MS` | no | `60000` | Per-request timeout against Metabase. 60s is deliberate: some queries scan thousands of rows. |
| `MAX_RESPONSE_ROWS` | no | `2000` | Ceiling on rows returned by the query tools, so one query cannot flood the conversation. |

If `METABASE_URL`, `METABASE_API_KEY` or `MCP_AUTH_TOKEN` is missing the server logs a single
line naming **every** missing variable and exits with status 1 — it never starts half-configured:

```
[metabase-mcp] FATAL: Missing required environment variable(s): METABASE_API_KEY. ...
```

## docker-compose

```yaml
services:
  metabase-mcp:
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    environment:
      METABASE_URL: ${METABASE_URL}
      METABASE_API_KEY: ${METABASE_API_KEY}
      MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN}
      PORT: 3000
      METABASE_TIMEOUT_MS: ${METABASE_TIMEOUT_MS:-60000}
      MAX_RESPONSE_ROWS: ${MAX_RESPONSE_ROWS:-2000}
    expose:
      - "3000"
    healthcheck:
      test:
        - CMD
        - node
        - -e
        - "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    networks:
      - dokploy-network

networks:
  dokploy-network:
    external: true
```

The container only needs `expose: 3000`; Dokploy's Traefik adds the router and the certificate
when a domain is attached to the service, so no ports are published to the host.

Running it anywhere else is the same minus the external network:

```bash
docker build -t metabase-mcp .
docker run --rm -p 3000:3000 \
  -e METABASE_URL=https://insights.example.com \
  -e METABASE_API_KEY=mb_xxx \
  -e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
  metabase-mcp
```

## Connecting Claude

Settings → Connectors → **Add custom connector**:

- **URL:** `https://metabase-mcp.example.com/mcp`
- **Authentication:** None
- **Custom header:** name `Authorization`, value `Bearer <MCP_AUTH_TOKEN>`

Claude reads the tool list when a conversation starts, so start a **fresh chat** after connecting
or after changing the tools.

## Tools

All 27 tools use JSON Schema draft-07 with `additionalProperties: false`. Metabase's own error
body is passed through verbatim on any non-2xx — its validation messages are the useful part.

### Databases and schema

| Tool | Metabase endpoint |
| --- | --- |
| `list_databases` | `GET /api/database` |
| `get_database` | `GET /api/database/:id` |
| `list_tables` | `GET /api/database/:id/metadata` |
| `get_table` | `GET /api/table/:id/query_metadata` |
| `get_table_field_values` | `GET /api/field/:id/values` |

### Querying

| Tool | Metabase endpoint |
| --- | --- |
| `execute_query` | `POST /api/dataset` — native SQL or MBQL, returns rows |
| `execute_saved_question` | `POST /api/card/:id/query` |
| `construct_query` | none — builds an MBQL `dataset_query` from a structured description |
| `visualize_query` | none, or `PUT /api/card/:id` when `question_id` is given |

### Questions (cards)

| Tool | Metabase endpoint |
| --- | --- |
| `create_question` | `POST /api/card` |
| `update_question` | `PUT /api/card/:id` |
| `get_question` | `GET /api/card/:id` |
| `list_questions` | `GET /api/card` |
| `archive_question` | `PUT /api/card/:id` with `{archived: true}` |

### Dashboards

| Tool | Metabase endpoint |
| --- | --- |
| `create_dashboard` | `POST /api/dashboard` |
| `update_dashboard` | `PUT /api/dashboard/:id` |
| `get_dashboard` | `GET /api/dashboard/:id` |
| `list_dashboards` | `GET /api/dashboard` |
| `add_card_to_dashboard` | `GET /api/dashboard/:id` then `PUT /api/dashboard/:id` |
| `archive_dashboard` | `PUT /api/dashboard/:id` with `{archived: true}` |

### Collections, search and metrics

| Tool | Metabase endpoint |
| --- | --- |
| `create_collection` | `POST /api/collection` |
| `list_collections` | `GET /api/collection` |
| `get_collection_items` | `GET /api/collection/:id/items` |
| `search` | `GET /api/search` |
| `list_metrics` | `GET /api/metric` |
| `get_metric` | `GET /api/metric/:id` |
| `get_metric_field_values` | `GET /api/metric/:id/dimension/:key/values` |

### Charts in one call

`create_question` takes `display` and `visualization_settings` as first-class parameters, so a
chart is created correctly without a follow-up edit:

```json
{
  "name": "Spend by month",
  "database_id": 2,
  "sql": "SELECT DATE_FORMAT(date, '%Y-%m') AS month, SUM(amount) AS total FROM transactions GROUP BY 1",
  "display": "line",
  "visualization_settings": {
    "graph.dimensions": ["month"],
    "graph.metrics": ["total"],
    "card.title": "Spend by month"
  }
}
```

`visualize_query` builds that `visualization_settings` object for you from `display`,
`dimension_column` and `metric_columns`, and applies it to an existing question if you pass
`question_id`.

### Dashboard layout

Metabase lays dashboards out on a 24-column grid. `add_card_to_dashboard` defaults to a
half-width card (`size_x: 12`, `size_y: 8`) placed in the first free slot — scanning rows top to
bottom, columns left to right — so two cards land side by side and the third starts a new row.
Pass `row`, `col`, `size_x` and `size_y` to place a card yourself.

Because Metabase replaces a dashboard's entire card set on update, the tool first reads the
dashboard, re-sends every existing card unchanged and appends the new one with a negative id
(Metabase's convention for "create this"). Existing positions are preserved.

## Notes on the Metabase API

Verified against the `release-x.63.x` branch of `metabase/metabase`
(`src/metabase/api_routes/routes.clj` and the API namespaces it references). Differences worth
knowing:

- **`POST /api/card` has no top-level `database_id`.** Its schema requires `name`,
  `dataset_query`, `display` and `visualization_settings`; the database belongs inside
  `dataset_query.database`. This server accepts the friendlier `database_id` argument and folds
  it into the query for you.
- **`PUT /api/dashboard/:id/cards` is deprecated** in 0.63 in favour of `PUT /api/dashboard/:id`
  with a `dashcards` array, and it is a full-set replacement rather than an append.
  `add_card_to_dashboard` uses the current endpoint.
- **`GET /api/dashboard` is marked deprecated** but still served; it only supports the filter
  modes `all`, `mine` and `archived`. For anything richer use `search` with
  `models: ["dashboard"]`.
- **`GET /api/card` has no collection filter.** Its only filters are `f`
  (`all`, `mine`, `bookmarked`, `database`, `table`, `using_model`, `using_segment`, `archived`)
  plus `model_id`. To list one collection use `get_collection_items` or `search`.
- **`/api/metric` is the modern metrics API.** A metric is a card with `type: "metric"`;
  `GET /api/metric` returns `{total, limit, offset, data}`. Field values for a metric come from
  `GET /api/metric/:id/dimension/:dimension-key/values`, where the key is a UUID from
  `get_metric` — not from `/api/field/:id/values`.
- **`POST /api/collection` names the parent `parent_id`**, not `collection_id`.
- **A broken query is not an HTTP error.** `POST /api/dataset` answers with HTTP 202 and
  `status: "failed"` in the body. The query tools detect that and raise it as a tool error
  carrying Metabase's message, rather than reporting zero rows.

`get_database` and `list_tables` strip the `details` object from responses: it holds warehouse
connection settings, which an admin API key can read and which have no business reaching a model.

## Development

```bash
npm install
npm run typecheck   # tsc --noEmit, strict mode
npm test            # compiles, then runs the offline unit tests
npm run build       # compile to dist/
npm start           # run the compiled server
```

The tests are fully offline: HTTP is mocked by injecting a `fetch` implementation into the
Metabase client, and they assert on schema shape, argument validation, URL and body construction,
error bubbling, the MBQL builder and the dashboard layout packer. No Metabase instance is needed.

## Security

- The Metabase API key never leaves the server; clients only ever hold `MCP_AUTH_TOKEN`.
- The token is compared with `timingSafeEqual`, and `/mcp` returns 401 before any MCP handling.
- Anything the API key can do, a connected client can do. Create the key against a Metabase user
  with only the permissions you want exposed.
- Terminate TLS in front of the server (Traefik/Dokploy does this); the token is a bearer
  credential and must not travel over plain HTTP.

## Licence

MIT