---
name: storage-broker
description: Per-account Cloudflare D1/R2 storage broker. The only agent-reachable path to storage; the account-wide credential stays house-only and every operation is scoped to the caller's account.
account-owned-dirs: [{"dir": "pages", "description": "Cloudflare Pages deploy staging for the account's public sites."}]
tools:
  - name: storage-d1-list
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: storage-d1-create
    publicAllowlist: false
    adminAllowlist: true
    riskClass: external
  - name: storage-d1-query
    publicAllowlist: false
    adminAllowlist: true
    riskClass: external
  - name: storage-r2-bucket-list
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: storage-r2-bucket-create
    publicAllowlist: false
    adminAllowlist: true
    riskClass: external
  - name: storage-r2-object-list
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: storage-r2-object-get
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: storage-r2-object-put
    publicAllowlist: false
    adminAllowlist: true
    riskClass: external
  - name: storage-r2-object-delete
    publicAllowlist: false
    adminAllowlist: true
    riskClass: external
  - name: storage-pages-create
    publicAllowlist: false
    adminAllowlist: true
    riskClass: external
  - name: storage-pages-deploy
    publicAllowlist: false
    adminAllowlist: true
    riskClass: external
  - name: storage-pages-adopt
    publicAllowlist: false
    adminAllowlist: true
    riskClass: external
metadata: {"platform":{"embed":["admin"]}}
mcp:
  command: node
  args:
    - ${PLATFORM_ROOT}/lib/mcp-spawn-tee/dist/index.js
    - ${PLATFORM_ROOT}/plugins/storage-broker/mcp/dist/index.js
  env:
    MCP_SPAWN_TEE_NAME: storage-broker
    LOG_DIR: ${LOG_DIR}
    PLATFORM_ROOT: ${PLATFORM_ROOT}
    ACCOUNT_ID: ${ACCOUNT_ID}
    SESSION_ID: ${SESSION_ID}
    PLATFORM_PORT: ${PLATFORM_PORT}
mcp-manifest: auto
---

# storage-broker

The isolation boundary for per-account Cloudflare D1 and R2 on a multi-tenant install. A sub-account agent is issued no Cloudflare credential: its `cloudflare.env` carries no account-wide token, and `cf-token.sh` refuses to mint one for a non-house caller. The broker is therefore the only *sanctioned* route to D1 and R2, and it denies cross-account access before calling Cloudflare. It is not an OS-enforced boundary: all accounts share one unix user, so an agent holding `Bash` can read the house credential and reach `wrangler` outside the broker. Closing that requires OS user separation, which was costed and declined as disproportionate — so this stays a sanctioned-path boundary permanently, and nothing tracks closing it. Every D1 and R2 operation goes through these tools, which forward the caller's account as the platform-stamped `x-maxy-caller-account` header (never a tool argument) to the house-level broker service. The service holds the sole account-wide credential in `config/cloudflare-house.env`, resolves each resource's recorded owner, and denies any cross-account access before it ever calls Cloudflare.

## Tools

- `storage-d1-list` — the caller account's D1 databases.
- `storage-d1-create` — create a D1 database, registered to the caller.
- `storage-d1-query` — run SQL against a database the caller owns.
- `storage-r2-bucket-list` — the caller account's R2 buckets.
- `storage-r2-bucket-create` — create an R2 bucket, registered to the caller.
- `storage-r2-object-list` — the objects in a bucket the caller owns.
- `storage-r2-object-get` — download an object to a file in the caller's account directory. Objects over 100 MiB are rejected.
- `storage-r2-object-put` — upload a file from the caller's account directory. Files over 100 MiB are rejected.
- `storage-r2-object-delete` — delete an object from a bucket the caller owns.
- `storage-pages-create` — create a brand-new Cloudflare Pages project, registered to the caller. Mirrors `storage-d1-create`: a name that is unowned and does not yet exist on Cloudflare is unambiguously the caller's, so it is created and claimed in one call. A name that already exists on Cloudflare but has no owner on record is refused (`exists-unregistered`), because that is the house-adopt case.
- `storage-pages-deploy` — publish a folder under the caller's `sites/` to a Pages project the caller owns. House-run: the Pages credential never enters the caller's account.
- `storage-pages-adopt` — house admin only: record which account owns a Pages project that already exists on Cloudflare. Ownership is never reassigned silently; adopting a project owned by someone else is refused.

A request against a database, bucket, or Pages project the caller does not own returns an authorization error, not another account's data. There are two ways a Pages project gains an owner: the caller creates a brand-new one with `storage-pages-create` and owns it directly, exactly as a new D1 database is claimed; or, for a project that already exists on Cloudflare, the house records ownership with `storage-pages-adopt` — the project-to-folder mapping is not derivable, so a pre-existing project is never self-claimed or guessed.

## Where the object tools do their file I/O, and why

`storage-r2-object-get` writes to a file and `storage-r2-object-put` reads from one, rather than passing bytes through the tool result, because these objects are photos and scans and base64 in a result would push megabytes into the agent's context window.

That file I/O happens in this MCP process, under the caller's own account, and never in the house-level broker service. The service holds the account-wide credential, so a caller-supplied path resolved there would be a house-privileged arbitrary read and write reachable from any sub-account — a `put` sourced from `config/cloudflare-house.env` would copy the house master into the caller's own bucket, to be read back legitimately afterwards. Keeping the service free of any filesystem access removes that class entirely, and it needs no `Bash`, so OS user separation would not have closed it.

The paths are confined to the caller's account directory by realpath, which a symlink cannot escape. That is a correctness guardrail rather than a boundary: all accounts share one unix user, so an agent holding `Bash` can already read whatever this process can.

## The 100 MiB object cap

`storage-r2-object-get` and `storage-r2-object-put` refuse any object over 100 MiB. The number lives once, in `platform/lib/storage-broker/src/object-limits.ts`, and both the tools and the broker routes read it from there.

It is set independently of the email plugin's 25 MiB attachment cap. Both bound the same class of payload, but for different reasons, and so they move for different reasons: email's tracks an SMTP-side rejection threshold, this one tracks memory in the house process. R2 does not bind either — a single-part upload may be up to 5 GiB — so this cap is the only bound on either transfer.

Both directions are checked before anything is buffered, in two places that are not redundant:

- `put` is size-checked in this MCP process with `statSync` before the file is read. That is what produces the error naming the file and the limit.
- The house routes enforce independently, because they are plain loopback HTTP and every agent holds `Bash`: a cap enforced only here is bypassed by one `curl`, and the house process is the one holding the account-wide credential. Every object route rejects an over-cap body at Hono's `bodyLimit`, before the body is buffered — `/r2/object/put` against a base64 object ceiling, and `list`, `get` and `delete` against a 64 KiB metadata envelope, since those three carry a bucket and a key or prefix and nothing else. `get` is additionally sized from `r2ObjectFind`'s recorded `size` before any bytes move, which stops at the page carrying the exact key rather than listing every object sharing it as a prefix.

A `get` whose key has no recorded size in the listing is refused, not attempted. No recorded size means no bound, and a stale or lagging listing would otherwise transfer an unbounded object. The cost is that a genuinely missing object reports "no recorded size" rather than Cloudflare's 404.

The cap bounds the spike; it does not make it small. At 100 MiB a `get` still leaves the house process holding the object and its base64 form, roughly 2.4x. Streaming and multipart transfer are the real fix for large objects and are deliberately not implemented; the cap stands in for them.

## Wrangler resolution

The broker service shells wrangler through `npx wrangler`, never bare `wrangler`, for every D1 operation and R2 bucket creation. The brand server PATH carries no global wrangler install; only `npx wrangler` resolves, so a bare `wrangler` call ENOENTs those operations on a real install. R2 bucket listing and every R2 object operation are exempt: they read the Cloudflare REST API directly, not wrangler.
