SnapTrade backend for OpenBB Workspace. Widgets (Connection Portal, Portfolio Overview, Activity, Reference Data, Order Ticket) backed by the SnapTrade REST API, plus an embedded MCP server that lets the Workspace AI agent query the same data on the user's behalf.
snaptrade-openbb.mov
docker compose up -d runs three containers:
- app — FastAPI on port
8069(compose-internal only). Hosts both the widget HTTP API and the MCP server mounted at/mcp. - tls-proxy — Caddy terminating TLS on
https://localhost:8443(the only host-published port) and reverse-proxying toapp. - redis — backs
pywry.state.redis.RedisSessionStore, which holds the per-user session record (encrypted SnapTrade credentials + sliding 15-minute TTL).
The app port is intentionally not published — requests must flow through Caddy so the auto-injected CSP frame-src rules work, Workspace can load the iframes, and the MCP DNS-rebinding protection can see the real Origin / Host.
-
Create
.envat the repo root:SNAPTRADE_STORE_ENCRYPTION_KEY_B64=$(python -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())") SNAPTRADE_CONNECTION_REDIRECT=https://localhost:8443/widgetThe SnapTrade client ID and consumer key are not env vars — they are sent per request by OpenBB Workspace as headers (see step 4).
-
Start the stack:
docker compose up -d --build
-
Trust Caddy's local CA once (browser will otherwise refuse the iframe):
docker compose exec tls-proxy cat /data/caddy/pki/authorities/local/root.crt > caddy-root.crt # macOS: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain caddy-root.crt # Linux: sudo cp caddy-root.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
-
In OpenBB Workspace, add the backend:
https://localhost:8443/And set these auth headers:
x-openbb-snaptrade-client-id→ your SnapTrade client IDx-openbb-snaptrade-consumer-key→ your SnapTrade consumer key
The MCP server is auto-attached. Each iframe widget advertises
storage.mcpUrl = https://<host>/mcp/u/<token>, where<token>is a freshly-minted HMAC-signed session token unique to this user.
Required (compose fails if missing):
| Variable | Purpose |
|---|---|
SNAPTRADE_STORE_ENCRYPTION_KEY_B64 |
Base64-encoded 32-byte key used to AES-256-CBC encrypt every Redis value (SnapTrade user_id/user_secret plus the per-session credential blob stored inside UserSession.metadata). Rotating it invalidates every stored credential and every live session token. |
SNAPTRADE_CONNECTION_REDIRECT |
Where SnapTrade's Connection Portal sends the browser back to after the user links a broker. Must point to the /widget route on this service. |
Optional:
| Variable | Default | Effect |
|---|---|---|
SNAPTRADE_AUTH_SECRET |
derived from SNAPTRADE_STORE_ENCRYPTION_KEY_B64 |
HMAC secret used by pywry.state.auth.generate_session_token / validate_session_token. Set explicitly if you want to rotate session-token signatures independently of the at-rest encryption key, or to share signatures across multiple app replicas. |
SNAPTRADE_DEBUG_HEADERS |
0 |
1 logs every incoming request's headers. Useful for diagnosing what Workspace actually forwards. Disable in production. |
SNAPTRADE_MCP_ALLOW_LOOPBACK |
1 |
Allow MCP requests whose Origin is http(s)://localhost / 127.0.0.1 / [::1]. Set to 0 in production so only https://*.openbb.<tld> can reach the MCP endpoint. |
This service has no application login and no env-var SnapTrade credentials. Every request must carry three headers from OpenBB Workspace:
x-openbb-snaptrade-client-idx-openbb-snaptrade-consumer-keyx-openbb-user— any stable per-user identifier
Without them every /snaptrade/* route returns 404. Each user's SnapTrade user_id/user_secret is encrypted with SNAPTRADE_STORE_ENCRYPTION_KEY_B64 and stored in Redis keyed by sha256(x-openbb-user).
If the supplied client ID starts with PERS- (case-insensitive), the app treats it as a personal-tier credential and skips per-user SnapTrade registration. Multi-tenant deployments must use a non-personal client ID.
Put your own access control (Workspace SSO, VPN, IP allowlist, etc.) in front of this service if it's reachable from anywhere untrusted.
The iframe HTML is rendered with zero credentials embedded. View Source on any widget shows only an opaque HMAC-signed session token in the URL path (/widget/s/<token> for iframes, /mcp/u/<token> for the MCP endpoint).
Sessions are managed by pywry.state.auth + pywry.state.redis.RedisSessionStore — see openbb_snaptrade/auth.py.
Flow per Workspace dashboard load:
- Workspace fetches
GET /widgets.jsonwith the threex-openbb-*headers. The backend callsSESSION_MANAGER.mint(client_id, consumer_key, x-openbb-user)which:- Generates a signed token via
pywry.state.auth.generate_session_token(user_id=sha256(email), secret=SNAPTRADE_AUTH_SECRET, expires_at=now+15min). Token shape:user_id:created_ts:expiry_ts:hmac_sha256_signature. - AES-256-CBC encrypts
{clientId, consumerKey, email}and writes it toRedisSessionStoreasUserSession.metadata.enc, keyed bysession_id = sha256(email), with a 15-minute TTL. - Rewrites every iframe
endpointtohttps://<host>/<widget>/s/<token>and everystorage.mcpUrltohttps://<host>/mcp/u/<token>.
- Generates a signed token via
- The iframe HTML loads. Its JS reads the token from
window.location.pathnameand sendsAuthorization: Bearer <token>on every backend call to/snaptrade/*. - The agent's MCP client POSTs to
https://<host>/mcp/u/<token>/. A path-extraction middleware lifts the token out of the URL into a per-requestContextVar, the gated ASGI wrapper validates theOrigin, andSESSION_MANAGER.resolve(token):- Verifies the HMAC signature with
validate_session_token(rejects forged or tampered tokens). - Loads the session from Redis, decrypts the credential blob, calls
RedisSessionStore.refresh_session(session_id, extend_ttl=15min)to keep the session alive, and returns aWorkspaceContext. - If the signature is invalid, the session is missing, or the TTL has expired, MCP tools return
{"error": "no_active_session"}.
- Verifies the HMAC signature with
Tokens are HMAC-SHA256 signed and embed their own expiry. They are scoped per-user (session_id = sha256(email)), so opening Workspace as a different x-openbb-user mints an entirely separate session. The original client_id / consumer_key never leave the backend's Redis once written, and they never reach the browser.
The MCP server is mounted onto the same FastAPI app and exposes four tools: list_connections, list_accounts, get_account_summaries, get_portfolio_exposure. The Workspace AI agent auto-connects to it because every iframe widget in widgets.json advertises storage.mcpUrl = https://<host>/mcp/u/<token> — a user-scoped URL minted by the backend.
Why the token is in the URL path: OpenBB Workspace does not forward the backend-configured headers (x-openbb-snaptrade-*, x-openbb-user) to a widget's mcpUrl. The agent's MCP client only sends standard MCP/CORS headers. Encoding the per-user session token directly in the URL is the only reliable channel.
The MCP endpoint enforces three independent gates before any tool runs:
- Origin allowlist — a custom ASGI wrapper rejects the request with HTTP 403 unless
Originis present and its hostname matches^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.openbb\.[a-z]{2,}$over HTTPS. Loopback origins are accepted only whileSNAPTRADE_MCP_ALLOW_LOOPBACK=1. The regex is anchored —openbb.dev.attacker.comis rejected. - FastMCP DNS-rebinding protection (built-in) — additionally validates
Hostagainstpro.openbb.co/ loopback. Configured inopenbb_snaptrade/mcp_server.pyviaTransportSecuritySettings; add your own Workspace host there if you deploy your own. - HMAC-signed session token —
SESSION_MANAGER.resolve(token)validates the signature withvalidate_session_token, looks up the session in Redis, and refreshes its 15-minute sliding TTL. Forged tokens, tokens for users who never opened the dashboard, and tokens whose session has expired all result inno_active_session.
Inside the request, MCP tools never see any custom HTTP headers — they read the resolved WorkspaceContext (containing the decrypted client_id / consumer_key) from a ContextVar set by the gated wrapper, and use it to call the SnapTrade SDK. The original client_id / consumer_key are written to Redis once at /widgets.json time and never leave the backend.
To verify the gates from a host shell:
# Mint a token (simulating Workspace's widgets.json fetch)
TOKEN=$(curl -sk https://localhost:8443/widgets.json \
-H 'x-openbb-snaptrade-client-id: PERS-…' \
-H 'x-openbb-snaptrade-consumer-key: …' \
-H 'x-openbb-user: someone@example.com' \
| python -c 'import sys,json,re; d=json.load(sys.stdin); print(re.search(r"/mcp/u/(.+)$", next(w["storage"]["mcpUrl"] for w in d.values() if isinstance(w,dict) and isinstance(w.get("storage"),dict) and "mcpUrl" in w["storage"])).group(1))')
# 403 — no Origin
curl -sk -o /dev/null -w '%{http_code}\n' \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'mcp-protocol-version: 2025-11-25' \
-X POST "https://localhost:8443/mcp/u/$TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# 200 — valid origin + valid token
curl -sk -X POST "https://localhost:8443/mcp/u/$TOKEN" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'Origin: https://pro.openbb.co' -H 'mcp-protocol-version: 2025-11-25' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_connections","arguments":{}}}'- Edit
Caddyfile: replacelocalhost:8443 { tls internal ... }with your domain (Caddy will get a real ACME cert automatically). Changetls-proxy.portsindocker-compose.ymlfrom8443:8443to443:443. - Update
SNAPTRADE_CONNECTION_REDIRECT(and the SnapTrade dashboard) tohttps://yourhost/widget. - Generate a fresh
SNAPTRADE_STORE_ENCRYPTION_KEY_B64per environment. Optionally setSNAPTRADE_AUTH_SECRETto a separate value if you want token-signature rotation to be independent of credential-encryption rotation, or if you run multiple app replicas behind a load balancer (all replicas must share the same secret to validate each other's tokens). - Set
SNAPTRADE_MCP_ALLOW_LOOPBACK=0in.envso the MCP endpoint only acceptshttps://*.openbb.<tld>origins. - If you're hosting OpenBB Workspace on a non-
openbb.*domain, editopenbb_snaptrade/mcp_server.py:- Add the hostname to
TransportSecuritySettings.allowed_hostsandallowed_origins. - Either extend the
_OPENBB_HOST_REregex or replace_origin_is_allowed()with an explicit allowlist of your Workspace origins.
- Add the hostname to
- Make sure Caddy (or your reverse proxy) forwards
Origin,Host,X-Forwarded-Proto, andX-Forwarded-Hostto the app — the absolute-URL rewriting in/widgets.jsonand the MCP origin check depend on them. The bundledCaddyfilealready does this; if you swap in nginx or ALB, replicate the headers. - Put your own SSO / VPN / IP allowlist in front of the whole service if your Workspace instance is multi-tenant. The MCP origin gate stops random cross-site callers but is not a substitute for authenticating who is on the other side of an allowed Origin.
The per-user session expires 15 minutes after the last MCP call or /widgets.json refresh. While the user is actively using the dashboard the TTL keeps sliding forward; the moment they leave, the agent loses access on its next call.