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:
- MCP server. Either
stdio(the agent spawns this binary as a subprocess) orhttp(Streamable HTTP atPOST /mcp, bearer-gated). Choose withMCP_TRANSPORT. For the family-budget use case described above, you wanthttpso a fleet of ephemeral agent containers can share one persistent server. - 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
- Elowen calls
initiate_link({ institution_hint: "Chase" }). The server:- calls Plaid
/link/token/create, - stores a
link_sessionsrow (statuspending), - returns
{ url: "https://<LINK_BASE_URL>/link/start?s=<uuid>&sig=<hmac>", session_id, expires_at }.
- calls Plaid
- Elowen sends the URL to the user.
- The user opens it in a browser. The page loads Plaid Link JS from
the official CDN with that
link_tokenand presents an "Open Plaid Link" button. - Plaid Link's
onSuccessPOSTs{ public_token, institution }plus the signed session id back to/link/callback. /link/callbackexchangespublic_token→access_token+item_id, AES-256-GCM-encrypts the access token, persists it, and marks the sessionsucceeded.- Elowen polls
link_status(session_id), seessucceededwith theitem_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:
make keys
Storage
SQLite (better-sqlite3) at $DB_PATH. Two tables matter:
items—item_idPK, encryptedaccess_token_blobBLOB, institution name/id, status, consent expiration.link_sessions— short-lived, expire automatically when read after theirexpires_atand 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_TOKENon 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_KEYcannot use the tokens. - The MCP tools never return access tokens to the agent. Only opaque
item_id/account_idstrings cross the MCP boundary.
Local development
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
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:
{
"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.