vibedasher-mcp
An MCP server that hands your coding agent (Claude Code, Codex, Cursor, Gemini) the tools to build and ship a dashboard on Vibedasher — the home for the dashboards your AI built.
Your agent writes the dashboard as React + SQL; Vibedasher hosts it at a URL behind your login, with per-viewer row filtering, embeds, refresh schedules, alerts and subscriptions — and ejects it into your own repo whenever you want the frontend back. Managed cloud (EU, Frankfurt) or self-hosted.
The whole loop is driven from here: datasets, CSV upload + ETL, SQL query, and
dashboard (viz) creation, build and publish. It wraps the published
vibedasher Python SDK 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. |
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 |
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)
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:
{
"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, non-interactive 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:
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)
{
"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:
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:
{ "mcpServers": { "vibedasher": { "command": "vibedasher-mcp" } } }
Details worth knowing before you rely on it:
- The environment wins. A variable set in the
envblock is used and the file is not consulted for it, so an existing setup keeps behaving identically.whoamireports which of the two answered (credential.sourceisenv:VIBEDASHER_API_KEYorfile:<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
chmodto 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_FILEpoints 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
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:
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:
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.
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
404and names the path in the body. A bareForbiddennever 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
Forbiddenis 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:
python -m pytest tests/ -q
Note:
fastmcpis a client-side dependency of this standalone package; it is not bundled into the API Lambdas, so theexport_requirements.pystep in the rootCLAUDE.mddoes not apply here.
Metadata
Release files for vibedasher-mcp 0.9.37
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| vibedasher_mcp-0.9.37.tar.gz | 563.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| vibedasher_mcp-0.9.37-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.1 MB
Release files / vibedasher_mcp-0.9.37.tar.gz
| Download URL | vibedasher_mcp-0.9.37.tar.gz |
|---|---|
| Size | 563.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
53efdfd4d42ca16ca11261518686f980151de5d9dba2f028fcf2a94b21df009d
|
|
BLAKE2b-256 checksum How to use checksums |
1f8d5f74639bdae64810f7b6e6f430ce321e0109c6dbe0d6c19234ba4408da1f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / vibedasher_mcp-0.9.37-py3-none-any.whl
| Download URL | vibedasher_mcp-0.9.37-py3-none-any.whl |
|---|---|
| Size | 577.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b7ecfb5e2b92beee70f5be45f5b2535f2bf3870399aeeab8e0172212e3cbbb24
|
|
BLAKE2b-256 checksum How to use checksums |
1b64f8d899b634dcc8f20db1fe54b690a6e3049076a9ae81e38ee92736046228
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Security for vibedasher-mcp 0.9.37
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.