The LakehouseBox MCP server
docs/mcp.md, as written on 2026-09-20. It is installable from a checkout (uvx --from <checkout>/platform/mcp lakehousebox-mcp); the package name lakehousebox-mcp is reserved and not yet on PyPI. lhbox mcp install (CLI reference) writes the host configuration below for you, with the saved key and without showing it.lakehousebox-mcp gives an MCP host (Claude Desktop, Claude Code, Cursor, anything that speaks the protocol) the LakehouseBox account as tools: catalogs (list_catalogs, create_catalog, delete_catalog; a catalog is a warehouse in the API, and the former list_warehouses/create_warehouse/delete_warehouse tool names still work as aliases), 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 (https://lakehousebox.com/docs/api/), and it deliberately does not query data: the agent gets a connection recipe and runs DuckDB itself.
Source: platform/mcp/lakehousebox_mcp.py (one file). Transports: stdio on your machine (this page, lhbox mcp install) and Streamable HTTP at https://mcp.lakehousebox.com/mcp (the "Remote" section at the end; the same module as an OAuth 2.1 resource server). SDK: the official mcp Python package, pinned.
Install
The shortest path is the CLI, which already holds your key (lhbox signup --save or lhbox login --save wrote it to ~/.config/lhbox/credentials.json):
lhbox mcp install # Claude Code: `claude mcp add-json`, user scope
lhbox mcp install --client claude-desktop # Claude Desktop: merges into claude_desktop_config.json (backup first)
lhbox mcp install --print # the snippet for any other host, key masked
lhbox 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. lhbox 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 lakehousebox-mcp --list-tools
# install as a command (pipx)
pipx install /path/to/tablemere/platform/mcp
lakehousebox-mcp --version
# or build a wheel once and install that anywhere
cd platform/mcp && uv build && pipx install dist/lakehousebox_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 |
|---|---|---|
LHBOX_API_KEY |
a token's API key (al_live_…). Create one in the account page (Tokens) or with lhbox agent create --name claude --grant <warehouse_id>:write; the key is shown once |
none; every tool then answers missing_credential |
LHBOX_URL |
the API | https://api.lakehousebox.com |
Claude Desktop (claude_desktop_config.json) and Claude Code (.mcp.json in a project, or claude mcp add-json lhbox '<the object>') take the same mcpServers shape:
{
"mcpServers": {
"lhbox": {
"command": "uvx",
"args": ["--from", "/path/to/tablemere/platform/mcp", "lakehousebox-mcp"],
"env": {
"LHBOX_API_KEY": "al_live_...",
"LHBOX_URL": "https://api.lakehousebox.com"
}
}
}
}
With a pipx install, "command": "lakehousebox-mcp" and no args. lhbox 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 lhbox mcp install uses by default.
Tools
Every tool's arguments are typed and described in its schema (lakehousebox-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_catalogs |
the catalogs the token can see, grouped by project, with locations and catalog URI | GET /v1/projects + GET /v1/warehouses |
create_catalog |
a new catalog, in the organisation's default project unless one is named; optional default_format_version (2 default, or 3: the Iceberg format version create_table gives new tables there when none is passed); takes an idempotency key (one is generated if omitted) |
POST /v1/warehouses |
delete_catalog |
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} |
create_table |
an Iceberg table through the catalog: columns in order (name, type, required, identifier; field ids follow), the namespace created on demand; format_version optional -- without it the table gets the catalog's default_format_version (2 unless changed with lhbox catalog update), 3 for geometry/geography columns; the answer says format_version_source (request or catalog_default) and which engines write that version (writers, readers, note, from GET /v1/config/formats) |
POST /v1/tables |
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 the account holder accepted it (there is no accept tool: Terms are a person's act, the API answers 403 not_a_person to a token, and this server always acts as a token; the person accepts at signup or with lhbox terms --accept under their own login) |
GET /v1/terms |
The three catalog tools were named list_warehouses, create_warehouse and delete_warehouse until 2026-09-21 (the API's word); those names still work as aliases of the same tools, so a host configured against them keeps working. The measurements below were taken under the old names.
Tools carry the MCP annotations hosts use to decide when to ask the user: readOnlyHint on every read, destructiveHint on delete_catalog, 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": "limit_exceeded", "message": "This project already has its maximum warehouses.",
"limit": "warehouses", "limit_value": 3, "current": 3,
"remedy": "Delete a catalog, or ask for the limit to be raised.", "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 LHBOX_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_catalog). 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_catalog 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_catalogs, 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_catalog |
403 forbidden from the API: only an owner or admin may create a catalog |
list_catalogs |
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: lhbox login --save (or lhbox signup --save) writes one to the credentials file, and lhbox mcp install uses it by default; lhbox api-key create --label mcp mints a separate key for the server, shown once, revocable on its own (lhbox 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 toLHBOX_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
LHBOX_URLand to nothing else; the only thing it sends beyond the request is aUser-Agent: lakehousebox-mcp/<version>. - Destructive actions are refused client-side when the confirmation is wrong (
delete_catalogcomparesconfirmwith the resolved name before sending) and the API refuses them again server-side. - Use HTTPS.
LHBOX_URLdefaults tohttps://api.lakehousebox.com; 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 demo_data.<namespace>.<table>; -- the catalog is attached under its own nameNo schema-evolution tools, and
create_tableis the one create tool. DuckDB doesCREATE TABLE … AS SELECT * FROM read_parquet()against the attached catalog (https://lakehousebox.com/docs/engines/) and keeps the format version it chose;create_tableexists for what CTAS cannot say (required columns, identifier fields, a chosen format version) and is one of LakehouseBox's own create paths, so it follows the catalog'sdefault_format_version(the founder's decision, 2026-09-20). Altering a table is the engine's job.No signup, login, key recovery or account deletion. Those are a human's actions in the account page or the CLI (
lhbox 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 # 54 tests, no network
LHBOX_URL=http://localhost:8080 LHBOX_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 # lhbox 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.
Remote (Streamable HTTP): https://mcp.lakehousebox.com/mcp
The same module as a resource server (design and contract: docs/mcp-remote.md). It holds no credential: a host authorises once through the control plane's OAuth 2.1 flow (Client ID Metadata Documents; Dynamic Client Registration as a fallback), a consent creates a token in your Tokens tab, and every request carries that token, which the server verifies against the control plane's JWKS and forwards to the API unchanged. Add it in Claude.ai as a custom connector with the URL above, or claude mcp add --transport http lhbox https://mcp.lakehousebox.com/mcp. A request without a token answers 401 with the protected-resource metadata, which is the spec's own discovery step; the authorization server is the control plane's (https://api.lakehousebox.com/.well-known/oauth-authorization-server, live since 2026-09-20). Container: platform/mcp/Dockerfile, service mcp in the compose files, site https://{$AL_MCP_HOST} in the gateway.