plaid-mcp
by wilderfield
README.md
# plaid-mcp
Persistent Plaid MCP server for an AI assistant (Elowen) running in an
ephemeral container.
`plaid-mcp` is a long-lived, externally hosted service that owns the Plaid
secret and the encrypted access tokens for every linked institution. The
assistant calls `mcp__plaid__*` tools at runtime; it never sees the raw
access tokens, only opaque `item_id` and `account_id` values that Plaid
already considers public.
```
Elowen (ephemeral container)
└─ calls mcp__plaid__* tools
└─ plaid-mcp (persistent, nanoclaw-hosted)
├─ Plaid SDK + PLAID_SECRET (never leaves this service)
├─ access_token store (SQLite, AES-256-GCM at rest)
└─ /link/start, /link/callback (HTTPS, browser-facing)
└─ Plaid REST API / Plaid Link JS
```
## Surfaces
A single Node.js process exposes two completely separate surfaces:
1. **MCP server.** Either `stdio` (the agent spawns this binary as a
subprocess) or `http` (Streamable HTTP at `POST /mcp`, bearer-gated).
Choose with `MCP_TRANSPORT`. For the family-budget use case described
above, you want `http` so a fleet of ephemeral agent containers can
share one persistent server.
2. **HTTPS link mini-app** at `/link/*`. Used **only during the one-time
bank link flow** — the user opens a URL the assistant gives them,
logs into their bank inside Plaid Link, and is done. After that the
browser is never needed again for that institution.
## MCP tools
| Tool | What it does |
|---|---|
| `list_linked_institutions()` | Every linked Item, with `needs_relink` health flag (calls `/item/get` per Item). |
| `list_accounts(item_id?)` | Cached account list (type, subtype, mask, last balance) for one or all institutions. |
| `get_balances(account_ids?)` | Real-time balances via `/accounts/balance/get` (paid Plaid endpoint). |
| `get_transactions(start_date, end_date, account_ids?, cursor?)` | Date-range transactions, ~250 per page, opaque pagination cursor. |
| `search_transactions(query, since?, until?, min_amount?, max_amount?, category?)` | Server-side filtered transaction search. Returns compact rows. |
| `get_monthly_summary(month, group_by?)` | Pre-aggregated monthly totals grouped by `category` or `merchant`. Keeps LLM context small. |
| `get_investment_holdings(account_ids?)` | Position snapshot (ticker, qty, market value, cost basis). |
| `get_investment_transactions(start_date, end_date, account_ids?)` | Buys/sells/dividends in a window. |
| `get_liabilities(account_ids?)` | Credit-card APRs/statements, student loans, mortgage details. |
| `initiate_link(institution_hint?)` | Returns `{ url, session_id, expires_at }` — give the URL to the user. |
| `link_status(session_id)` | Poll until `succeeded` (with new `item_id`), `failed`, or `expired`. |
| `remove_institution(item_id)` | Revoke the Plaid Item and delete the local token. |
All tool responses are JSON inside a single `text` content item (works
on every MCP client, including ones that don't surface
`structuredContent`).
## One-time link flow
1. Elowen calls `initiate_link({ institution_hint: "Chase" })`. The
server:
- calls Plaid `/link/token/create`,
- stores a `link_sessions` row (status `pending`),
- returns `{ url: "https://<LINK_BASE_URL>/link/start?s=<uuid>&sig=<hmac>", session_id, expires_at }`.
2. Elowen sends the URL to the user.
3. The user opens it in a browser. The page loads Plaid Link JS from
the official CDN with that `link_token` and presents an "Open Plaid
Link" button.
4. Plaid Link's `onSuccess` POSTs `{ public_token, institution }` plus
the signed session id back to `/link/callback`.
5. `/link/callback` exchanges `public_token` → `access_token` + `item_id`,
AES-256-GCM-encrypts the access token, persists it, and marks the
session `succeeded`.
6. Elowen polls `link_status(session_id)`, sees `succeeded` with the
`item_id`, and proceeds.
The signed URL params (`s`, `sig`) are HMAC-SHA256-keyed by
`LINK_SESSION_SECRET`. The DB row is the source of truth — the HMAC just
cheaply rejects garbage requests before we touch SQLite.
## Configuration
All config is via environment variables (loaded from `.env`).
| Variable | Required | Default | Description |
|---|---|---|---|
| `PLAID_CLIENT_ID` | Yes | — | From the Plaid dashboard |
| `PLAID_SECRET` | Yes | — | From the Plaid dashboard. Never leaves this service. |
| `PLAID_ENV` | No | `sandbox` | `sandbox` \| `development` \| `production` |
| `PLAID_API_VERSION` | No | `2020-09-14` | Pinned API version |
| `PLAID_PRODUCTS` | No | `transactions` | Comma list. Common: `transactions,investments,liabilities` |
| `PLAID_COUNTRY_CODES` | No | `US` | Comma list of ISO country codes |
| `PLAID_USER_ID` | No | `family-default` | Stable `client_user_id` sent to Plaid |
| `PLAID_ENCRYPTION_KEY` | Yes | — | 32 bytes hex (`openssl rand -hex 32`). AES-256-GCM key for tokens at rest. |
| `LINK_SESSION_SECRET` | Yes | — | ≥ 32 bytes hex. HMAC key for signed link URLs. |
| `LINK_SESSION_TTL_SECONDS` | No | `900` | Link session lifetime |
| `LINK_BASE_URL` | Yes | — | Public HTTPS base URL the browser will hit (e.g. `https://plaid.example.com`) |
| `PORT` | No | `3333` | HTTP port. TLS terminates upstream at nanoclaw. |
| `ADMIN_TOKEN` | No | — | If set, gates `/link/admin/*` introspection routes |
| `MCP_TRANSPORT` | No | `http` | `stdio` \| `http` |
| `MCP_BEARER_TOKEN` | Yes if `MCP_TRANSPORT=http` | — | Bearer required on `POST /mcp` |
| `DB_PATH` | No | `./data/plaid-mcp.sqlite` (Docker: `/data/plaid-mcp.sqlite`) | SQLite path. Mount a persistent volume here. |
| `LOG_LEVEL` | No | `info` | Pino log level. All logs go to stderr. |
Generate secrets with:
```bash
make keys
```
## Storage
SQLite (better-sqlite3) at `$DB_PATH`. Two tables matter:
- `items` — `item_id` PK, encrypted `access_token_blob` BLOB, institution
name/id, status, consent expiration.
- `link_sessions` — short-lived, expire automatically when read after
their `expires_at` and during a 60s background sweep.
Access tokens are stored as
`[1-byte version][12-byte IV][16-byte GCM tag][N-byte ciphertext]`.
Decryption fails closed if the GCM tag doesn't verify.
## Security model
- The MCP HTTP transport requires `Authorization: Bearer
$MCP_BEARER_TOKEN` on every request. Without it the agent fleet would
expose every linked bank account to the internet.
- The browser-facing `/link/*` routes are signed (HMAC) and bound to a
short-lived DB-backed session.
- TLS is expected to terminate **upstream** (at nanoclaw / Caddy /
whatever your edge is). The container speaks plain HTTP internally;
expose it only through the proxy.
- Every Plaid token is encrypted at rest. Even with the SQLite file in
hand, an attacker without `PLAID_ENCRYPTION_KEY` cannot use the
tokens.
- The MCP tools never return access tokens to the agent. Only opaque
`item_id` / `account_id` strings cross the MCP boundary.
## Local development
```bash
npm install
make setup # creates .env from env.example
make keys >> .env # append fresh PLAID_ENCRYPTION_KEY / LINK_SESSION_SECRET / MCP_BEARER_TOKEN
# edit .env: PLAID_CLIENT_ID, PLAID_SECRET, LINK_BASE_URL
npm run dev # tsx with hot reload
```
For local link testing you'll need an HTTPS tunnel (Plaid Link onSuccess
won't fire from `http://localhost`). `cloudflared`, `ngrok`, or a real
Caddy reverse proxy all work; whatever public hostname they give you
goes into `LINK_BASE_URL`.
## Docker
```bash
make build
make up
make logs
```
The compose file mounts `./data:/data` so the SQLite DB survives
restarts. In a nanoclaw deployment, replace that bind-mount with the
cluster-managed persistent volume.
## Wiring the agent to a hosted instance
Inside the agent container's MCP client config:
```json
{
"mcpServers": {
"plaid": {
"url": "https://plaid-mcp.your-domain.example/mcp",
"headers": {
"Authorization": "Bearer <MCP_BEARER_TOKEN>"
}
}
}
}
```
The agent gets the bearer token through whatever secret-injection
mechanism nanoclaw already uses for its other agent secrets. It does
**not** ever see `PLAID_SECRET` or any access token.
## License
Internal.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues