The Tablemere MCP server
docs/mcp.md, as written on 2026-09-20. It is installable from a checkout (uvx --from <checkout>/platform/mcp tablemere-mcp); the package name tablemere-mcp is reserved and not yet on PyPI. tablemere mcp install (CLI reference) writes the host configuration below for you, with the saved key and without showing it.tablemere-mcp gives an MCP host (Claude Desktop, Claude Code, Cursor, anything that speaks the protocol) the Tablemere account as tools: catalogs (a catalog is a warehouse in the API and in the tool names), connection recipes and short-lived table credentials, grants, tokens, members, the organisation, usage, the audit log, Terms. It is the third face of the same REST API as the CLI and the account page (docs/permissions-and-access.md), and it deliberately does not query data: the agent gets a connection recipe and runs DuckDB itself.
Source: platform/mcp/tablemere_mcp.py (one file). Transport: stdio. SDK: the official mcp Python package, pinned.
Install
The shortest path is the CLI, which already holds your key (tablemere signup --save or tablemere login --save wrote it to ~/.config/tablemere/credentials.json):
tablemere mcp install # Claude Code: `claude mcp add-json`, user scope
tablemere mcp install --client claude-desktop # Claude Desktop: merges into claude_desktop_config.json (backup first)
tablemere mcp install --print # the snippet for any other host, key masked
tablemere mcp run -- --list-tools # start the server yourself, key in its environment
The key travels from the credentials file into the host's configuration and is never printed; the output shows it masked (al_live_<key id>_********). --api-key installs a different key, for instance a token's; --from says what uvx installs (a checkout's platform/mcp, the default when the CLI runs from one, or the package name once it is published); --url picks the API and the saved key for it. tablemere mcp install --help has the rest.
The server itself is a Python package in the repo; there is no PyPI release yet. By hand, any of:
# run without installing (uv), from a checkout
uvx --from /path/to/tablemere/platform/mcp tablemere-mcp --list-tools
# install as a command (pipx)
pipx install /path/to/tablemere/platform/mcp
tablemere-mcp --version
# or build a wheel once and install that anywhere
cd platform/mcp && uv build && pipx install dist/tablemere_mcp-0.1.0-py3-none-any.whl
Python 3.10 or newer. --list-tools prints every tool with its JSON schema and exits; without it the server speaks MCP on stdin/stdout and is meant to be started by the host, not by hand.
Configure the host
Two environment variables, nothing else:
| variable | meaning | default |
|---|---|---|
TABLEMERE_API_KEY |
a token's API key (al_live_…). Create one in the account page (Tokens) or with tablemere agent create --name claude --grant <warehouse_id>:write; the key is shown once |
none; every tool then answers missing_credential |
TABLEMERE_URL |
the API | https://api.tablemere.eu |
Claude Desktop (claude_desktop_config.json) and Claude Code (.mcp.json in a project, or claude mcp add-json tablemere '<the object>') take the same mcpServers shape:
{
"mcpServers": {
"tablemere": {
"command": "uvx",
"args": ["--from", "/path/to/tablemere/platform/mcp", "tablemere-mcp"],
"env": {
"TABLEMERE_API_KEY": "al_live_...",
"TABLEMERE_URL": "https://api.tablemere.eu"
}
}
}
}
With a pipx install, "command": "tablemere-mcp" and no args. tablemere mcp install writes exactly this object (with uvx by absolute path, because hosts start servers with a short PATH). Give the server a token created for it, not a human's key, once tokens can hold organisation roles (below): a token can be confined to the catalogs it needs and revoked on its own, and its actions appear under its own name in the audit log. Today the account-management tools need a human member's key, which is what tablemere mcp install uses by default.
Tools
Every tool's arguments are typed and described in its schema (tablemere-mcp --list-tools). Where the API wants an id, the tools also accept the name (catalog, project, organisation) and resolve it; an ambiguous name is an error listing the candidates, never a guess. When the token belongs to one organisation or sees one project, org / project can be omitted.
| tool | what it does | API |
|---|---|---|
whoami |
principal id, organisations and role in each, projects, Terms status | GET /v1/usage, /v1/orgs, /v1/terms |
usage |
every limit, current value and headroom; storage bytes per organisation and catalog | GET /v1/usage |
list_warehouses |
the catalogs the token can see, grouped by project, with locations and catalog URI | GET /v1/projects + GET /v1/warehouses |
create_warehouse |
a new catalog, in the organisation's default project unless one is named; takes an idempotency key (one is generated if omitted) | POST /v1/warehouses |
delete_warehouse |
irreversible: drops every table, both buckets, both identities, grants and usage; needs the catalog's exact name as confirm, checked before anything is sent; admins only |
DELETE /v1/warehouses/{id} |
connection |
the paste-ready DuckDB / PyIceberg / Spark recipe for a catalog | GET /v1/connection |
table_credentials |
short-lived S3 credentials scoped to one table's prefix (about an hour) | POST /v1/credentials |
grants_list |
who holds read or write on a catalog and via what (membership, creator, admin, grant) | GET /v1/warehouses/{id}/grants |
grants_set |
give a member or token read or write on a catalog | PUT /v1/warehouses/{id}/grants/{principal} |
grants_revoke |
remove a grant | DELETE /v1/warehouses/{id}/grants/{principal} |
tokens_list |
the organisation's tokens (agents): id, name, grants, creator, last used | GET /v1/orgs/{org}/agents |
tokens_create |
a new token with grants [{warehouse, level}]; its key is returned once; at least one grant |
POST /v1/orgs/{org}/agents |
tokens_update |
rename a token and/or replace its grants | PATCH /v1/orgs/{org}/agents/{id} |
tokens_revoke |
revoke a token: every key stops at once | DELETE /v1/orgs/{org}/agents/{id} |
members_list |
members with role and join date | GET /v1/orgs/{org}/members |
members_invite |
invite an address as admin or member; the invitation token is returned once | POST /v1/orgs/{org}/invitations |
members_set_role |
admin or member; the last admin cannot be demoted | PATCH /v1/orgs/{org}/members/{id} |
members_remove |
remove a member | DELETE /v1/orgs/{org}/members/{id} |
org_get |
the organisation(s): name, your role, domains, member count | GET /v1/orgs |
org_rename |
rename the organisation (admins) | PATCH /v1/orgs/{org} |
audit |
who did what: own events, or the organisation's for admins; since, action, limit |
GET /v1/audit |
terms_status |
the Terms version in force and whether it is accepted | GET /v1/terms |
terms_accept |
record acceptance, after the human has read them | POST /v1/terms/accept |
Tools carry the MCP annotations hosts use to decide when to ask the user: readOnlyHint on every read, destructiveHint on delete_warehouse, grants_revoke, tokens_revoke, members_remove.
Errors
A failed call is an isError result whose content is the API's error envelope, unchanged, plus the HTTP status:
{"error": {"code": "quota_exceeded", "message": "3 of 3 warehouses.", "limit": 3, "current": 3,
"remedy": "Delete one, or ask for a higher limit.", "request_id": "…"},
"http_status": 409}
The code says whether to change the request or the plan; the remedy says how. Three envelopes are the server's own: missing_credential (no key configured), unreachable (no answer from TABLEMERE_URL; safe to retry), and route_missing — the deployed control plane predates a route of the 2026-09-20 contract (the grants routes, tokens_update, delete_warehouse). Nothing was changed in that case; the envelope names the method and path.
Today's control plane (measured 2026-09-20 against the local stack)
The server is written against the 2026-09-20 contract; the control plane is catching up. Driven with a human member's key (the signup principal), every tool answered, and grants_list, tokens_update and delete_warehouse came back route_missing (their routes do not exist yet; nothing was changed). Driven with an agent token's key created by that human:
| tool | as an agent token, today |
|---|---|
whoami, usage, list_warehouses, connection, table_credentials, audit (own), terms_status |
work |
org_get |
answers, with an empty list: the token is not a member of the organisation that created it |
tokens_*, members_*, org_rename, audit with org |
no_organization (this server's envelope), for the same reason |
create_warehouse |
403 forbidden from the API: only an owner or admin may create a catalog |
list_warehouses |
lists every catalog of the project, including those the token is confined out of; the API answers 404 on use, as it does for a stranger |
The measured limitation, in one sentence: on today's control plane a token (principal kind agent) cannot call the organisation routes (/v1/orgs/…: tokens, members, the organisation itself, the organisation's audit log), because a token is not a member of the organisation that created it and the routes check membership. Nothing in this server can change that; it is a contract follow-up: docs/permissions-and-access.md already writes the MCP server as "authenticated with a token's API key", so either tokens gain organisation roles or the organisation routes accept a token as acting for its organisation within its grants, and the control plane has to implement it before that sentence is true for the account tools.
So until that lands, the account-management tools want a human member's API key: tablemere login --save (or tablemere signup --save) writes one to the credentials file, and tablemere mcp install uses it by default; tablemere api-key create --label mcp mints a separate key for the server, shown once, revocable on its own (tablemere api-key revoke). A confined agent token serves the data-side tools (connection, table_credentials, usage, own audit). The no_organization remedy says this to the agent.
Security model
- The token's grants bound every tool. The server adds no permission of its own and holds no second credential; a read-only token gets read-only recipes and credentials, and a
403from the API is returned as such. What an admin can do through the tools is exactly what that admin's token can do withcurl. - The key is configuration, not conversation. It reaches the process through the environment and leaves it only as the
Authorizationheader toTABLEMERE_URL. It is never logged, never part of a result, never in a traceback (Config.__repr__printsset/unset). Keys the API creates on request (tokens_create,members_invite's invitation token) are returned once because that is what was asked for; store them where they belong, not in the chat. - Nothing is cached. Every tool call is one or a few HTTP requests made now; there is no local state, no credentials file, no memory between calls. Revoking the token ends the server's access at the next call.
- No telemetry. The server talks to
TABLEMERE_URLand to nothing else; the only thing it sends beyond the request is aUser-Agent: tablemere-mcp/<version>. - Destructive actions are refused client-side when the confirmation is wrong (
delete_warehousecomparesconfirmwith the resolved name before sending) and the API refuses them again server-side. - Use HTTPS.
TABLEMERE_URLdefaults tohttps://api.tablemere.eu; a plainhttp://URL is for a local stack only.
What is not there, on purpose
No query tool, no
peek, no natural-language-to-SQL. Rows in a tool response are tokens in a context window and compute on our side; both are the wrong place for them (research/13 §1). The agent callsconnectionand runs the recipe in its own DuckDB:INSTALL iceberg; LOAD iceberg; INSTALL httpfs; LOAD httpfs; -- then the CREATE SECRET / ATTACH statements exactly as `connection` returned them SELECT count(*) FROM lake.<namespace>.<table>;No table creation or schema tools. DuckDB does
CREATE TABLE … AS SELECT * FROM read_parquet()against the attached catalog (platform/docs/import-parquet.md); a tool would only add a hop.No signup, login, key recovery or account deletion. Those are a human's actions in the account page or the CLI (
tablemere signup|login|recover|account), not something an agent should do with someone else's key.No metadata plane yet (row counts, partitions, freshness,
plan_queryfrom research/13 §2): it needs API routes first; the MCP server only ever projects what the API offers.
Tests
/tmp/mcp-venv/bin/python -m pytest platform/mcp/tests -q # 34 tests, no network
TABLEMERE_URL=http://localhost:8080 TABLEMERE_API_KEY=al_live_... \
/tmp/mcp-venv/bin/python platform/mcp/tests/live_smoke.py # skipped when the key is unset
python3 -m pytest platform/cli/tests -q # tablemere mcp install|run: 17 tests, temp HOME
The unit tests drive every tool through the MCP layer against an httpx.MockTransport that plays the API, and assert the request shape (method, path, query, JSON body, Authorization, Idempotency-Key) and the error paths. The live smoke creates one catalog, one token and one invitation under the configured principal, then revokes and deletes what the deployed API lets it; run it under a throwaway principal.