Metadata-Version: 2.5
Name: vibedasher-mcp
Version: 0.9.35
Summary: MCP server exposing the Vibedasher data engine (datasets, query, viz) to a customer's AI.
Project-URL: Homepage, https://vibedasher.com
Project-URL: Source, https://github.com/JulienGdnr/vibedasher
Author: Vibedasher
License: MIT
Keywords: analytics,bi,eject,mcp,model-context-protocol,vibedasher
Requires-Python: >=3.10
Requires-Dist: fastmcp>=2.0.0
Requires-Dist: httpx<0.29.0,>=0.23.0
Requires-Dist: keyring>=24.0.0
Requires-Dist: msgpack>=1.0.0
Requires-Dist: uvicorn>=0.30.0
Requires-Dist: vibedasher<5,>=4.3.16
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Provides-Extra: render
Requires-Dist: playwright>=1.40; extra == 'render'
Description-Content-Type: text/markdown

# vibedasher-mcp

An [MCP](https://modelcontextprotocol.io) server that exposes the **Vibedasher data
engine** — datasets, CSV upload + ETL, headless SQL query, and dashboard (viz)
management — as tools a customer's AI (Claude Code, Cursor, ...) can drive directly.

This is the **control plane**. It wraps the published
[`vibedasher` Python SDK](https://pypi.org/project/vibedasher/) and calls only the public, metered `/api/v1/*`
API.

**Install:** `pip install 'vibedasher-mcp>=0.6.0'`, then `vibedasher login` — no API
key to mint. Live on PyPI since 2026-08-09, as are
`vibedasher` (PyPI) and `@vibedasher/client` (npm).

The **eject tools ship in this package**, not separately: `eject_viz`,
`eject_viz_files`, `eject_instructions` and `get_viz_files` are registered by
`register_eject_tools()` in `eject.py`. They pull a viz's code plus an SDK-wiring
manifest so your AI can recreate the dashboard natively in your stack.

## Tools

| Tool | What it does |
|------|--------------|
| `list_datasets` | List datasets the key can read (id, name, `cleanSQLName`, status). |
| `get_dataset(dataset_id)` | One dataset's metadata + column schema. |
| `set_primary_keys(dataset_id, columns, ...)` | **Destroys rows.** Declare which columns identify a row and deduplicate the table so that is true — the rows that share a key are deleted, not flagged. Count them first with `run_query`. This is what clears the refusal an `upsert` incremental `create_refresh` gives a dataset with no key. |
| `upload_dataset(name, csv_content, ...)` | **Inline.** Create a CSV dataset from CSV *text* you pass as an argument, upload, load, poll to READY. Every byte is a tool argument — ~260k tokens per MB — so this is the lane for a small table you are writing by hand. |
| `upload_dataset_from_file(name, csv_path, ...)` | **From disk.** Same flow, but the bytes are read from an absolute local path and never enter the caller's context; you get back `datasetId`, `cleanSQLName`, `sourcePath`, `sourceBytes`. Use this for any CSV that is already a file. stdio only; absolute paths only; refuses a directory, a device, a missing or unreadable path, and anything over 512 MiB (`VIBEDASHER_MCP_MAX_UPLOAD_BYTES`). |
| `run_query(sql, dataset_ids, params, type)` | **Explore.** Inline alias-only SQL across your datasets (plural, joins allowed) → typed columns + rows in the response. `type="wasm"` returns a plan of presigned Parquet URLs instead of rows — the credential-free data lane for a standalone build, and the only call that builds a dataset's wasm extract. |
| `run_query_to_file(sql, output_path, dataset_ids, params, format, overwrite)` | **Extract.** Same query, but the rows are written to a local CSV/JSONL file and never enter the response — you get back `path`, `rowCount`, `columns`, `bytes`, `truncated`. stdio only; absolute paths only; never clobbers without `overwrite=True`. |
| `list_vizzes(include_unpublished=False)` | List dashboards. |
| `get_viz(viz_id)` | One viz's metadata. |
| `create_viz(name, dataset_ids, view_mode, folder_id, idempotency_key)` | Create the dashboard's home in this account — name, datasets, slug, empty draft. Push the code next. |
| `update_viz(viz_id, name, description, slug, confirm_link_break)` | Rename or re-describe a dashboard that already exists. A rename leaves the **link** alone — the slug is the address in every share link and bookmark, so moving it is a separate, explicitly-confirmed act. |
| `build_viz(viz_id, branch)` | Compile a viz branch into a renderable bundle. |
| `eject_viz(viz_id, branch, out_dir, overwrite, max_chars)` | **(eject)** The viz's runnable source tree + package.json + Tailwind config + wiring manifest. Pass `out_dir` — see [Delivery](#delivery-an-ejected-tree-does-not-fit-in-one-response). |
| `eject_viz_files(viz_id, paths, branch, out_dir, ...)` | **(eject)** Ejected file contents on demand — the follow-up call when `eject_viz` came back paged. |
| `eject_instructions()` | **(eject)** How to recreate an ejected dashboard in your app — the query contract, auth modes, region sharding, and the host-bundler traps. |
| `get_viz_files(viz_id, branch, out_dir, ...)` | **(eject)** Raw file tree for a viz branch. Same delivery budget as `eject_viz`. |
| `begin_connection(connector_type, name)` | **(connections)** Start connecting a warehouse, database or SaaS source. Returns a **link**; the person you are working with opens it and types the credentials into Vibedasher's own page, in their own browser. Takes **no credential** — nothing in its schema is a password, and the secret goes browser → server, never through the agent's context. |
| `await_connection(request_id, timeout_s)` | **(connections)** Wait for that browser step. `waiting` is an ordinary answer — call again. Returns the new `connectionId` and the row, credentials masked. The link is good for 15 minutes; the browser must be on the same machine as this server. |
| `list_connections()` | **(connections)** Every data connection in the account — the warehouses, databases and SaaS sources this account can already read. Credentials come back masked by the server; a `viewer` sees an empty list rather than a 403. |
| `get_connection(connection_id)` | **(connections)** One connection: type, and the address it points at. Reads our stored row — it does not prove the connection works. |
| `test_connection(connection_id)` | **(connections)** Opens the connection for real and closes it. Takes **no credential**: the secret is merged server-side behind its own mask. |
| `get_connection_schema(connection_id, database)` | **(connections)** The databases and tables inside a connection. Dials the source live; not paginated. |
| `list_connection_tables(connection_id, database)` | **(connections)** Tables only — the fallback when `get_connection_schema` is refused because the credential cannot list databases. |
| `get_connection_table_schema(connection_id, table, database)` | **(connections)** One table's columns as the source's `information_schema` spells them. |
| `validate_connection_query(connection_id, sql, limit)` | **(connections)** Run a SELECT against the source itself and see real rows. Refuses anything that is not SELECT/WITH before it reaches the wire. |
| `create_dataset_from_connection(name, connection_id, sql, ...)` | **(connections)** Store the result of a SELECT as a dataset that re-reads its source. Takes **no credential** — the connection id references secrets the server already holds — and it is the only ingestion here whose dataset `create_refresh` will schedule. |
| `delete_connection(connection_id, force)` | **(connections)** Delete one connection and verify by read-back. **Refuses** one that still feeds datasets, naming them: nothing downstream carries a foreign key to a connection, so the server would delete it without a word and leave every dataset on it holding its last rows, still feeding dashboards, and unable to ever refresh again. `force=True` deletes anyway and reports what it stranded. |
| `list_dbt_projects()` | **(dbt)** Every dbt project — and the only source of the `project_id` that `create_refresh` takes. Note the endpoint CREATES a "Default Project" when the account has none; the response says when that may have just happened. |
| `get_dbt_project(project_id)` | **(dbt)** One project: model/dataset counts, last run status, its own `cronSchedule`. Creates nothing. |
| `list_dbt_models(project_id)` | **(dbt)** Every model, with `datasetId` (what it produces) and `sourceDatasetIds` (what it reads) — the join between dbt and the rest of the surface. |
| `get_dbt_model(model_id)` | **(dbt)** One model with its `sql` and `compiledSql`. |
| `list_dbt_runs(project_id)` | **(dbt)** Run history: status, timing, model counters. `logs` is excluded here on purpose. |
| `get_dbt_run(project_id, run_id)` | **(dbt)** One run in full, with the tail of dbt's own output and a route to the per-model cause. |

## Delivery: an ejected tree does not fit in one response

Measured against prod on 2026-08-18: `eject_viz(1275)` is **44 files / 517,351
characters** (~129k tokens) and `eject_viz(1282)` is **45 files / 556,363
characters** (~139k tokens). MCP clients cap a tool result, so the single-shot
payload was either dropped outright by a strict client or ate the agent's entire
context. Every eject response now carries a `delivery` block, and the three file
tools take the same three parameters.

| Mode | How | Cost |
|------|-----|------|
| **disk** (preferred) | `out_dir=<ABSOLUTE path>` | ~30k characters regardless of tree size — measured 32,308 for viz 1275 |
| **paged** (default) | omit `out_dir`; `files` fills to `max_chars` (60,000), the rest land in `delivery.pendingPaths` | 10 calls for viz 1275, none over budget |
| **unbounded** | `max_chars=0` | the pre-0.2.0 payload, on request only |

```python
eject_viz(viz_id=1275, out_dir="/abs/path/to/app/dashboard")   # one call, whole tree on disk
```

`out_dir` resolves on the machine running the MCP server; with the standard
stdio launch (`command: vibedasher-mcp`) that is your machine, because the client
spawns the server as a local subprocess. Relative paths are refused, existing
files are refused unless `overwrite=True`, and any path escaping `out_dir` aborts
the write before a byte lands.

Paging is the fallback, not the goal: round trips do not reduce total tokens (the
content still crosses the model), they only keep each individual response
deliverable. Hand `delivery.pendingPaths` straight back to
`eject_viz_files(viz_id, paths=...)` and repeat until it is empty. **A response
with a non-empty `pendingPaths` is a page, not a tree.**

Override the budget per call with `max_chars` or globally with
`VIBEDASHER_MCP_MAX_RESPONSE_CHARS`.

## Three data lanes

Pick by what you will do with the rows:

| Lane | Call | What comes back | Use it for |
|------|------|-----------------|------------|
| Explore | `run_query(sql, ...)` | the rows, inline | aggregates, samples, schema checks — anything the agent will read |
| Extract | `run_query_to_file(sql, output_path=<absolute>, format="csv"\|"jsonl")` | `{path, rowCount, columns, bytes, truncated}` — never the rows | anything loaded, charted or shipped from disk; thousands of rows and up |
| Static asset | `run_query(sql, type="wasm")` | a plan of presigned Parquet URLs, no rows | a dashboard you host yourself, querying in-browser with DuckDB-WASM and no key in the bundle |

`run_query_to_file` writes on the machine running the MCP server, which with the
standard stdio launch is the agent's own machine (the client spawns the server as a
local subprocess). It is refused over `--http`, where the path would resolve on the
server's disk. Both lanes that return counts honour the backend's 100,000-row cap:
`truncated: true` means the rows — in the response or in the file — are a prefix.
Parquet output is deliberately not offered: the wasm lane already returns Parquet
the server built, and `pyarrow` is a 40 MB wheel to install for one option.

`run_query` reuses the SDK's hand-written `query()` transport: the caller
never sees inline-vs-presigned delivery, MessagePack, or retries — one call in,
typed rows out. Each dataset id resolves server-side to that dataset's
`cleanSQLName` alias under the RLS/alias-rewrite; SQL references only aliases.

## Auth

Two lanes. The first one is the default because it asks nothing of you.

### Sign in through your browser (first run)

```bash
pip install 'vibedasher-mcp>=0.6.0'   # `login` does not exist below 0.6.0
vibedasher login                      # opens a browser; approve; done
```

`vibedasher login` opens your browser, captures the authorization code on a
loopback listener bound to `127.0.0.1`, and stores a short-lived refresh token in
your OS keyring (with a `0600` file fallback where no keyring is available).
There is no key to mint, copy, paste or rotate, and nothing lands in a config
file or your shell history. Later runs are silent until the session lapses, at
which point `login` asks again. The refresh token is single-use and rotates on
every refresh; if a rotated-away copy is ever presented again — someone read the
keyring or the credentials file — the whole session is revoked on the spot and
the next run asks you to `login` again.

Two companions: `vibedasher whoami` prints the account, user, region and the date
you will be asked to sign in again; `vibedasher logout` revokes this machine's
session and forgets it.

The MCP client block then carries no credential at all:

```json
{
  "mcpServers": {
    "vibedasher": {
      "command": "vibedasher-mcp",
      "env": { "VIBEDASHER_REGION": "eu-central-1" }
    }
  }
}
```

`VIBEDASHER_REGION` is optional here — `login` records the region the account
actually lives in — but pinning it costs nothing and makes the shard explicit.

### API key (CI, servers, headless agents)

Anywhere a browser cannot open, an API key is still the answer. Mint one in the
console (Settings → API keys) or via `POST /v1/api-keys`, and give it to the
server as an environment variable:

```bash
export VIBEDASHER_API_KEY=...           # X-Api-Key, handled by the SDK's create_client
export VIBEDASHER_REGION=eu-central-1   # or us-east-1
# export VIBEDASHER_BASE_URL=...        # optional override (on-prem/staging)
```

```json
{
  "mcpServers": {
    "vibedasher": {
      "command": "vibedasher-mcp",
      "env": {
        "VIBEDASHER_API_KEY": "...",
        "VIBEDASHER_REGION": "eu-central-1"
      }
    }
  }
}
```

An API key authenticates as the user who created it, has no expiry unless one is
set, and is a bearer secret in plaintext on whatever disk holds that config. That
is a fine trade for a build server and a poor one for your laptop — which is why
it is the second lane and not the first.

### Keeping the key out of your editor's config

`claude mcp add -e VIBEDASHER_API_KEY=...` writes the key, in plaintext, into the
editor's own config file — which gets backed up, synced between machines, and
occasionally pasted into a bug report. So the server also reads an env file,
`~/.vibedasher-mcp.env`, whenever the variable is absent from its environment:

```bash
printf 'VIBEDASHER_API_KEY=...\nVIBEDASHER_REGION=eu-central-1\n' > ~/.vibedasher-mcp.env
chmod 600 ~/.vibedasher-mcp.env
```

Then the editor config carries no secret at all:

```json
{ "mcpServers": { "vibedasher": { "command": "vibedasher-mcp" } } }
```

Details worth knowing before you rely on it:

- **The environment wins.** A variable set in the `env` block is used and the
  file is not consulted for it, so an existing setup keeps behaving identically.
  `whoami` reports which of the two answered (`credential.source` is
  `env:VIBEDASHER_API_KEY` or `file:<path>`) — a precedence rule whose loser is
  silent is how a session ends up acting in an account nobody chose.
- **Owner-only, enforced.** A file readable by group or other is refused with the
  `chmod` to run, not quietly used: the whole point is that the config file it
  replaces was already owner-only.
- **Four names only** — `VIBEDASHER_API_KEY`, `VIBEDASHER_REGION`,
  `VIBEDASHER_BASE_URL`, `VIBEDASHER_AUTH_URL`. Anything else in the file is
  ignored rather than exported, and values are taken literally: no
  interpolation, no `$(...)`. A credential store that evaluates its own contents
  would be worse than the config file it replaces.
- `VIBEDASHER_MCP_ENV_FILE` points at a different path.

### Either way, the server checks before it serves

`vibedasher-mcp` resolves a credential at startup — `VIBEDASHER_API_KEY` first
(from the environment, then from the env file), and a stored `vibedasher login`
session only when no key is set. With neither, it refuses to start and prints
what to do on stderr, so a credential-less server never connects and never
advertises tools it cannot serve. Neither credential is ever logged or echoed in
a tool result.

## Metering

Every tool hits the public, metered endpoints (`create_dataset` is credit-gated;
`run_query` is metered via the usage/credits event). The MCP adds no side channel
and bypasses no metering.

## Run

```bash
pip install 'vibedasher-mcp>=0.6.0'   # (monorepo dev: also make `vibedasher` importable)
vibedasher login                       # sign in once, in a browser
vibedasher-mcp                         # stdio MCP server
# or:  python -m vibedasher_mcp
```

Wiring Claude Code in one line, on a machine that has signed in:

```bash
claude mcp add vibedasher --env VIBEDASHER_REGION=eu-central-1 -- vibedasher-mcp
```

Then ask your agent for something. It has 85 tools; a good first prompt is
"run `whoami`, then `list_datasets`, and build me a dashboard from the most
interesting one — verify with `get_build_status` and `probe_viz` before telling
me it's done."

Two verifiers, two worlds, and they do not overlap:

| | `probe_viz` | `render_probe` |
|---|---|---|
| reads | the GENERATED tree (`config.ts` + `pages/*.tsx`) | nothing — it opens the published page |
| runs | each tile's own SQL, server-side | a real browser |
| answers | does the emitted SQL match the declared binding | did it paint, with what numbers, with what errors, and did it leak into the host |
| on a hand-authored tree | reports `hand_authored_tree` and checks nothing | this is the one |
| never claims | that a viewer saw anything | that a number is correct |

`render_probe` walks the WHOLE dashboard, not one screenful: it scrolls the
frame to the bottom in steps (re-settling after each one, so a lazily-mounted
tile is counted and a permanently skeletal one still goes red) and, when the
tree exposes the generated page control `[data-selector="page"] [data-value]`,
opens every page and sweeps each. Tiles are unioned by node identity, so a tile
carrying three conventions is one tile and two conventions marking different
tiles add up. `tiles.covers` states in one sentence what the count is a count
OF — all pages fully scrolled, one page fully scrolled, or one viewport — and
whatever it could not walk stays in `notVerified`. Pass `scroll=False` /
`traverse_pages=False` to narrow it; the caveat comes back when you do.

`render_probe` needs a browser, so Playwright is an extra:

```bash
pip install 'vibedasher-mcp[render]>=0.6.0' && python -m playwright install chromium
```

Without it the tool returns `checked: false` and that install command. It never
reports a pass it did not earn.

For the JSON `mcpServers` form of both lanes, see [Auth](#auth).

## A bare `403 {"message":"Forbidden"}` is always the credential

The edge rejects a request before the application sees it, so its refusal body
is one word and nothing else. That one word stands for three different
mistakes, and the response cannot tell you which:

| what happened | what to do |
|---|---|
| no credential sent | set `X-Api-Key` |
| the key was sent as `Authorization: Bearer` | move it to `X-Api-Key` — `Authorization` is read only as a login session token, so a key placed there is never seen |
| the key was sent in `X-Api-Key` and refused | it is unknown, revoked or expired; mint a new one |

Two conclusions this response does **not** support, and both are the ones
usually drawn from it:

- **It is not a path problem.** With an accepted credential, an unknown path
  answers `404` and names the path in the body. A bare `Forbidden` never means
  the URL was wrong.
- **It is not a permissions problem.** Roles and resource access are decided by
  the application, which answers with a reason. The edge only ever asks whether
  a credential is valid, so a bare `Forbidden` is never "ask your human for
  access".

The single most common first-contact mistake is the second row: `Authorization:
Bearer <key>` returns `403` on *every* route, including plain reads, with a
perfectly good key.

Fix the header first, then read the body — once a credential is accepted, the
API distinguishes all four conditions on its own.

## Development

The package depends on the sibling SDK at `../sdk/py`. Tests wire that path
automatically (`tests/conftest.py`), so from `packages/mcp`:

```bash
python -m pytest tests/ -q
```

> Note: `fastmcp` is a **client-side** dependency of this standalone package; it is
> not bundled into the API Lambdas, so the `export_requirements.py` step in the root
> `CLAUDE.md` does not apply here.
