mcp-exports
README.md
# mcp-exports
Shared, single-use download host for the house MCP servers.
`https://exports.example.com` · host port **8114** · no customer login
```
MCP server --POST /put (service token)--> mcp-exports --> download URL
customer --GET /d/{token} (no auth) --> mcp-exports --> the file, once
```
## Why it exists
A tool that returns a week of call records inline costs **~530,000 tokens**; the
same data as CSV is 1 MB and builds in 1.6 seconds. So exports return a link.
The first version served that link from the MCP server itself, which broke three
ways:
1. **The MCP hosts sit behind an Anthropic-only IP allow-list**, but the thing
fetching a download is a customer's browser on an arbitrary address. A
separate hostname keeps the two access policies from fighting, instead of
carving a path exemption out of a proxy config that also carries the
streaming settings MCP needs.
2. **MCP containers redeploy on every push**, and an in-memory store died with
them — invalidating live links mid-conversation.
3. The store belonged to one process, so a second worker would 404 at random.
## Security model, stated plainly
A download URL is a **capability**: whoever holds it gets the file, no login.
Same trade as the NetSapiens recording URLs and Odoo portal PDFs we already hand
out, and it is what makes a link work when clicked from a chat window. It is
**not** authentication, and the weaknesses are real — a URL can be forwarded,
logged by a proxy, or sit in a transcript forever.
So the design leans on what actually helps:
| Control | Why |
|---|---|
| **Single use** (default `max_downloads=1`) | The bytes are deleted once collected, so a link leaked *afterwards* is already dead. Strongest control available, because a capability URL cannot be un-shared. |
| **Short TTL** — 1 h data, **15 min financial** | Shrinks the window. Money gets less of it. |
| 256-bit `secrets.token_urlsafe` tokens | Not guessable, not enumerable |
| **Identical 404** for missing / malformed / expired / spent | Nothing learnable from the difference |
| `Content-Disposition: attachment` always | An HTML or SVG export must never render on our origin — that would make this host a stored-XSS vector |
| `no-store`, `noindex`, `nosniff`, `no-referrer` | Not cached, not indexed, no referer leak |
| Access log with **token prefix only** | Enough to correlate upload↔download; not enough to rebuild a working URL from a log more people can read than the file |
**Uploading is authenticated** — a per-server bearer token, because publishing
files on a public host is not something the internet gets to do. The service
**refuses to start** with an empty allow-list.
## API
```
POST /put?filename=x.csv&sensitivity=data|financial&customer=Acme%20Movers
&ttl=3600&max_downloads=1
Authorization: Bearer <server token>
-> 201 {download_url, filename, size, expires_in_seconds, expires_at,
max_downloads, single_use}
GET /d/{token} -> 200 the file (then it is gone), or 404
GET /healthz -> counters + bytes held
```
`ttl` is **clamped, not obeyed** — ask for a week, get `MCP_EXPORTS_TTL_MAX`, and
the response tells you what you actually got.
## Using it from an MCP server
Copy [`client/export_client.py`](client/export_client.py) into the server and set
`MCP_EXPORTS_URL` + `MCP_EXPORTS_TOKEN`. `ExportClient.from_env()` returns `None`
when unconfigured, so a server can offer export tools only where the host is
actually wired up rather than advertising a tool that always fails.
It **raises rather than falling back** to inline rows: if the host is down there
is no safe degradation, because returning the rows is the failure mode exports
exist to prevent.
## Deploy
Portainer → Stacks → Repository, `refs/heads/main`, port 8114. Generate a token
per MCP server:
```bash
python -c "import secrets; print(secrets.token_urlsafe(32))"
```
NPM: `exports.example.com` → `10.0.0.10:8114`, **no Access List** (that is
the point), Websockets off, standard timeouts.
## Tests
```bash
python -m pytest tests -q
```
47 tests, no network. They cover the security properties specifically: single
use actually deletes bytes, every bad token shape is indistinguishable, filenames
cannot escape or inject headers, financial TTL is shorter, and the Dockerfile
chowns the volume mount point *before* dropping root — without which a named
volume arrives root-owned and every upload fails `EACCES`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues