buildium-mcp
# buildium-mcp
An MCP server for the [Buildium](https://www.buildium.com/) Open API. All **462
operations** across 42 resource areas, exposed through **19 tools**.
Sandbox by default. Reaching production takes two deliberate settings, and the
read-only modes block writes in the transport rather than by policy.
**[COVERAGE.md](COVERAGE.md)** records what is actually proven against a live
sandbox: **218 of 462 operations verified, 0 broken.** That number is
178 GET operations + 40 writes across twelve entity families. The remaining
GETs are marked `needs-setup` — the sandbox holds no record of the required
type, so there was no id to call them with — and each of the 184 unattempted
writes carries its own stated reason.
> Unofficial. Not affiliated with, endorsed by, or sponsored by Buildium.
> "Buildium" is a trademark of its owner. See [NOTICE](NOTICE) for the
> provenance of the bundled OpenAPI document.
## Why 19 tools and not 462
The Buildium spec is 298 paths, 462 operations, 519 schemas. One-tool-per-operation
is the obvious approach and it fails at this size — the tool list alone burns tens
of thousands of context tokens before the model does any work, and selection
accuracy collapses past a few dozen tools.
So the spec is indexed at runtime instead:
```
search_endpoints("work orders") → ranked candidates
describe_endpoint("POST", "/v1/...") → params + body schema, $refs resolved
call_endpoint("POST", "/v1/...", …) → the actual call
```
Ten curated shortcuts (`list_leases`, `list_work_orders`, `list_gl_accounts`, …)
cover frequent reads so routine questions skip the three-step path. Two file
tools exist because Buildium's file flow cannot be driven through
`call_endpoint` at all — see below.
## Setup
You need a Buildium **Premium** subscription with the Open API enabled
(Settings → Application settings → Api settings) and an API key created under
Settings → Developer Tools.
### Claude Desktop: one click
Download `buildium-mcp-<version>.mcpb` from the releases page and double-click
it (or drag it onto the Claude Desktop window). Claude Desktop asks for your
Client ID and Client Secret in a settings form, stores them securely, and
installs everything else itself — including Python, if your machine has none.
Sandbox is the default; the same form has a "Connect to production" toggle for
when you are ready, and separate toggles for allowing changes and file
downloads there. The install dialog labels the bundle *unsigned* and says it
has access to your computer; both are standard for every local extension — see
[mcpb/](mcpb/) for what this one actually touches and why it is not signed.
### Any other MCP client
Requires Python 3.11+.
```bash
pip install buildium-mcp
```
Sandbox is a **separate Buildium account** from production — production keys do
not authenticate against `apisandbox.buildium.com`.
### Credentials
The preferred way is your MCP client's own `env` block, so the secret lives
with the rest of your client configuration:
```json
{
"mcpServers": {
"buildium": {
"command": "buildium-mcp",
"env": {
"BUILDIUM_CLIENT_ID": "...",
"BUILDIUM_CLIENT_SECRET": "..."
}
}
}
}
```
A `.env` file works too. Four locations are searched, highest priority first,
and the real process environment beats all of them:
1. `$BUILDIUM_ENV_FILE`
2. the nearest `.env` at or above the working directory
3. the root of a source checkout, if this is running from one — an MCP client
launches the server with a working directory of its choosing, which is often
not your checkout
4. your platform config directory — `buildium_health` reports which files were
actually read, under `env_files_loaded`
```
BUILDIUM_CLIENT_ID=...
BUILDIUM_CLIENT_SECRET=...
```
`chmod 600` it. If the server cannot find credentials it still starts, and
`buildium_health` reports exactly what is missing and where it looked — the
spec-only tools (`search_endpoints`, `describe_endpoint`, …) keep working
meanwhile.
### From a checkout
```bash
git clone https://github.com/blackwaxxx/Buildium-MCP && cd Buildium-MCP
uv venv --python 3.11 && uv pip install -e ".[dev]"
```
### Files it writes
| | Default | Override |
|---|---|---|
| `.env` | platform config dir | `BUILDIUM_ENV_FILE`, `BUILDIUM_CONFIG_DIR` |
| `run.log` (request audit) | platform state dir | `BUILDIUM_RUN_LOG`, `BUILDIUM_STATE_DIR` |
| `created-records.log` | platform state dir | `BUILDIUM_ARTIFACT_LOG` |
Set either log variable to `off` to disable it. If the state directory is not
writable the server still runs; `buildium_health` reports `audit_log: null`
with the reason rather than pretending to log.
## Run
```bash
.venv/bin/python -m buildium_mcp.server # stdio
```
Register it with any MCP client:
```json
{
"command": "buildium-mcp"
}
```
or, from a checkout:
```json
{
"command": "/path/to/buildium-mcp/.venv/bin/python",
"args": ["-m", "buildium_mcp.server"]
}
```
The OpenAPI spec ships inside the package, so it is found the same way in a
wheel and in an editable checkout. Nothing is resolved relative to a repo root.
## Write safety
`BUILDIUM_WRITE_MODE` — default `fixtures`:
| | `fixtures` | `open` |
|---|---|---|
| Create | name must start with `ZZ-MCPTEST-` | unrestricted |
| Update / delete | only records created this session | unrestricted |
| Delete | requires `confirm=true` | requires `confirm=true` |
| Audit | always | always |
`fixtures` is the posture for unattended or agent-driven use: it makes damage to
pre-existing records structurally impossible rather than merely unlikely. Switch
to `open` for real work.
Every request goes to `run.log`; every created record ID goes to
`created-records.log`. Credentials are never written to either.
## Deployment mode
`BUILDIUM_DEPLOYMENT_MODE` — default `sandbox`:
| | Reachable hosts | Writes | File downloads |
|---|---|---|---|
| `sandbox` (default) | sandbox only | allowed, further constrained by `BUILDIUM_WRITE_MODE` | yes |
| `production-readonly` | sandbox + production | **blocked in the transport** | no |
| `production-readonly-files` | sandbox + production | **blocked in the transport** | yes, 7 endpoints |
| `production-write` | sandbox + production | allowed | yes |
An unrecognized value is a startup error listing the valid ones — a typo must
not silently pick a mode.
**Reaching production takes two independent things**, and neither alone is
enough: this variable *and* a `BUILDIUM_BASE_URL` naming a production host.
Setting the mode changes what is *permitted*, never what is *targeted*, so a
stray mode variable cannot redirect a sandbox server at live data.
The read-only modes are not a policy check a caller can talk its way past.
`ReadOnlyTransportGuard` sits in the httpx transport slot — the last code that
runs before a socket is opened. It lets only `GET`, `HEAD` and `OPTIONS` through
(an allowlist, so an unknown or malformed verb is refused too), and it refuses
any request whose host is not a Buildium host over https, whatever the method.
Calling `client.post()` directly, hand-building an `httpx.Request`, or bypassing
`BuildiumClient` entirely all hit the same wall. Tested by doing exactly that.
### Why `production-readonly-files` exists
Buildium issues a file download by POSTing for a short-lived signed URL, so a
server that refuses every POST cannot read a lease PDF. Rather than weaken
`production-readonly`, this mode exempts **exactly seven** operations — the
`downloadrequest` and `downloadrequests` endpoints for files, bill files, task
files, rental and unit images, check attachments, and architectural-request
files. Everything else is still refused in the transport.
The exemption is scoped by an anchored pattern matched against the raw,
still-percent-encoded wire path, so `%2f`, `%2e%2e` and `%00` cannot smuggle a
different path through it, and the host is checked too — an absolute URL cannot
aim an allowlisted path at a server of the caller's choosing. A test iterates
every POST in the spec and asserts precisely these seven are reachable.
Run read-only for a while before considering `production-write`. The banner on
stderr names the active mode and where it came from on every start.
Verify the guarantee yourself — runs against the sandbox, writes nothing:
```bash
.venv/bin/python tests/demo_readonly.py
```
## Tests
```bash
pytest # offline, no credentials
.venv/bin/python tests/stdio_check.py # live sandbox
```
The unit suite (247 tests) covers spec indexing, path resolution, response
shaping, `allOf` flattening, auto-pagination, deprecation handling, error hints,
and every guardrail branch — all four deployment modes, the download allowlist
proved exhaustively against the spec, and the packaging and startup paths —
with no network access. `tests/conftest.py` isolates it from any `.env` on the
machine, so the offline suite cannot accidentally make a live call.
Coverage walks, which do hit the sandbox:
```bash
.venv/bin/python tests/coverage_matrix.py # all 238 GETs; read-only by construction
.venv/bin/python tests/coverage_writes.py # curated write scenarios, ~20 records
.venv/bin/python tests/render_coverage.py # regenerates COVERAGE.md
```
The integration suite drives the server over real stdio JSON-RPC and exercises reads, error mapping,
all four guardrail refusal paths, and a full create/read/update/delete cycle
against live sandbox records. Requires working sandbox credentials.
## Tools
All tools carry a `buildium_` prefix — this server is meant to run alongside
others, and bare names like `health` would collide.
**Gateway** — `buildium_health`, `buildium_list_tags`, `buildium_search_endpoints`,
`buildium_describe_endpoint`, `buildium_describe_schema`, `buildium_call_endpoint`,
`buildium_created_fixtures`
**Files** — `buildium_upload_file`, `buildium_download_file`
**Shortcuts** — `buildium_list_rentals`, `buildium_get_rental`, `buildium_list_units`,
`buildium_list_leases`, `buildium_get_lease`, `buildium_list_lease_transactions`,
`buildium_list_work_orders`, `buildium_list_tenants`, `buildium_list_gl_accounts`,
`buildium_lease_roster`
`buildium_lease_roster` answers "who is on lease X" and "how many leases have
co-tenants" in one call. Buildium's lease list does not reliably populate tenant
names and its tenant endpoint has no lease filter, so without this the join costs
one request per lease — measured at 24 calls for a single question before it
existed, 1 after.
Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`,
`idempotentHint`, `openWorldHint`) so a client can tell reads from writes without
parsing descriptions.
### Keeping responses small
Buildium records are fat — an owner carries tax IDs, fax numbers, and mailing
addresses. Pass `fields` to keep only what you need:
```json
{"method": "GET", "path": "/v1/rentals/owners",
"fields": ["Id", "FirstName", "LastName", "PropertyIds"]}
```
### Files
Buildium never moves bytes through its API. An upload request returns an AWS S3
**presigned PUT** URL and a set of `x-amz-meta-*` headers; the bytes go straight
to storage, and every signed header must be reproduced exactly or it fails with
`SignatureDoesNotMatch`. Downloads mirror it through a URL that expires after
five minutes. `buildium_upload_file` and `buildium_download_file` run both
halves. Each accepts a path for the resource the file belongs to, and each is
confined to Buildium's seven upload or seven download endpoints in every mode —
neither is a way to POST anywhere else.
The signed URL points at a third-party host, so the transfer carries **no
Buildium credentials** — sending the client secret to a host named by an API
response would leak it wherever that response pointed.
Note that `buildium_download_file` does not work under `PRODUCTION_READONLY`:
Buildium models a download request as a POST, and that mode blocks every POST
without exception. Keeping the guarantee absolute was worth more than the
exception; file metadata still reads fine over GET.
### Pagination
List tools return pagination metadata alongside the rows:
```json
{"ok": true, "count": 50, "limit": 50, "offset": 0,
"has_more": true, "next_offset": 50, "data": [...]}
```
`has_more` is inferred from a full page — Buildium returns no total count — so it
is a hint, not a guarantee.
Pass `all_pages=true` to follow pagination to the end in one call, which is what
you want whenever you are counting or aggregating. It returns `complete` rather
than `has_more`, caps at 1000 records, and says so explicitly if it truncated:
```json
{"ok": true, "count": 55, "complete": true, "pages_followed": true, "data": [...]}
```
### Test fixtures
Buildium supports `DELETE` on only 14 of its 462 operations, so any account
that has been tested against accumulates test records permanently — and every
count over it becomes ambiguous.
Rather than leave that to inference, list tools and `buildium_lease_roster`
report `fixture_count` whenever records matching the fixture prefix are
present, along with a note saying what it means. `buildium_lease_roster` also
precomputes `multi_tenant_leases_excluding_fixtures`. Pass
`exclude_fixtures=true` to filter them out.
Nothing is dropped unless you ask, and `next_offset` keeps counting the rows
the server returned rather than the ones left after filtering, so excluding
fixtures never causes the next page to skip records.
### Deprecated endpoints
Sixteen operations — every appliance path — **start returning 410 Gone on
2026-10-19**. `search_endpoints` and `describe_endpoint` report `deprecated:
true` with the retirement date and the replacement path, and deprecated
endpoints rank below equivalent live ones without being hidden: at the time of
writing the replacement API returns nothing, so the deprecated endpoints are
still the only place the records exist.
## Notes
Buildium authenticates with two static headers — `x-buildium-client-id` and
`x-buildium-client-secret`. There is no OAuth flow, no token endpoint, and no
refresh, despite what some third-party integrations claim.
Only 14 of the 462 operations support `DELETE`. Most resources — vendor
categories among them — can be created but never removed via the API.
Unaffiliated with Buildium, LLC.
TDQS
Scored across 19 tools
The convenience tools are carefully differentiated—list_leases, list_lease_transactions, and lease_roster each explicitly say when to use them—and the discovery tools occupy clear stages. The main overlap is buildium_call_endpoint, which can express any GET/list operation and could be selected instead of a typed wrapper, though the descriptions make the wrappers' specific conveniences clear.
Nearly all tools follow the buildium_<verb>_<noun> pattern, mostly list_, get_, describe_, and search_. A few outliers—buildium_health, buildium_created_fixtures, and buildium_lease_roster—break the verb_noun convention, but these are minor and the overall naming is predictable.
At 19 tools this is slightly above the typical sweet spot, but the count is justified: a generic endpoint-calling core, discovery/health tools, file transfer tools, and convenience wrappers for high-frequency Buildium entities. Each wrapper earns its place by simplifying pagination, schema depth, or join logic, so the set feels slightly large rather than bloated.
Because buildium_call_endpoint can reach all 462 Buildium API operations, and buildium_search_endpoints/buildium_describe_endpoint provide discovery and contract details, the server has no dead ends despite offering convenience wrappers for only common objects. Reads, writes, file upload/download, pagination, fixture tracking, and environment health are all covered.