Skip to main content
Glama
wilderfield

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.

Maintenance

ActivityInactive
ResponsivenessNo issues