schwab-mcp

A safety-first Model Context Protocol server for the
Charles Schwab Trader API. It gives an LLM (Claude Desktop, Claude Code, or any MCP client)
read access to your Schwab market data and accounts, and — only when you explicitly enable it —
the ability to place orders behind several independent safety gates.
⚠️ Real money. This server can place real trades against your brokerage account when
trading is enabled and dry-run is off. Read the Safety model before going live.
Use at your own risk; this is not financial advice and comes with no warranty.
Why it's built this way
Two verified Schwab/MCP realities drive the whole design:
- The 7-day refresh-token wall. Schwab refresh tokens expire exactly 7 days after the last
interactive login, with no programmatic renewal. The server treats weekly re-auth as a
first-class event: it warns you (T-24h / T-2h), exposes
schwab_token_status, and offers
one-click in-process re-auth (schwab_reauth).
- An LLM is an unreliable actor. Advisory "are you sure?" gates are weak. Here, placing an
order is structurally impossible unless the server itself previewed it:
place_* accept
only a single-use, short-lived token minted by the pure-read preview_order.
Architecture — three layers, one Schwab boundary
MCP edge (FastMCP tools) → domain facade (all safety logic) → core (the only schwab-py code)
The core layer is the only place that imports schwab-py, so a Schwab/library change is
contained. The domain layer is pure and fully unit-tested. 150+ tests, no network required.
Requirements
- macOS, Linux, or Windows; Python 3.13+ (managed by
uv).
- A Schwab brokerage account and a Trader API – Individual app on
developer.schwab.com, approved for both:
- Market Data Production (quotes, chains, history), and
- Accounts and Trading Production (accounts, orders).
- App callback URL set to exactly
https://127.0.0.1:8182 (https, the literal 127.0.0.1, port > 1024).
- A browser on the host for the one-time (and weekly) login.
Production approval is a manual review (often 1–3 business days). The build/tests don't need
it — only the live login does.
Install
Claude Code plugin (easiest):
claude plugin marketplace add gray584321/schwab-mcp
claude plugin install schwab-mcp@schwab-mcp
then run /schwab-mcp:setup inside Claude Code — it walks you through
credentials, the one-time login, and verification. Full walkthrough (including
Claude Desktop and Codex): docs/SETUP.md.
Clone (Claude Desktop, Codex, or development):
git clone <this repo> && cd schwab_mcp
uv sync
uv run schwab-mcp --help
Configure
Credentials can live in either of two .env files (local overrides global,
real environment variables override both; doctor prints both paths):
- global —
<config dir>/.env next to the token (macOS:
~/Library/Application Support/schwab-mcp/.env, Linux:
~/.config/schwab-mcp/.env, Windows:
%LOCALAPPDATA%\schwab-mcp\schwab-mcp\.env). Works no matter which
client launches the server; required for the plugin install, whose working
directory is replaced on every update.
- local —
.env in the repo checkout (clone installs).
Copy .env.example and fill in your app credentials:
SCHWAB_API_KEY=your-app-key
SCHWAB_APP_SECRET=your-app-secret
SCHWAB_CALLBACK_URL=https://127.0.0.1:8182 # must EXACTLY match the portal value
The OAuth token and the audit-ledger HMAC key are stored in the OS keychain
(macOS Keychain / Windows Credential Manager / Linux Secret Service) when
available, with a 0600-file fallback; the app credentials above live in .env —
keep it untracked (it is in .gitignore) and chmod 600 it on macOS/Linux (on
Windows it sits under your user profile, which is user-only by default).
Validate everything without touching Schwab:
uv run schwab-mcp doctor # checks creds, callback shape, token presence, paths
Trading policy (only needed if you enable trading)
Copy policy.example.toml to policy.toml and tune the limits. Trading fails closed if this
file is missing or unparseable. Every limit is enforced at both preview and submit time.
[limits]
max_notional_per_order = 2500.0
max_quantity_per_order = 100
max_orders_per_minute = 5
limit_only = true # reject MARKET orders (bounds the preview→confirm race)
market_hours_only = true
[symbols]
allow = [] # if non-empty, ONLY these underlyings may trade
deny = ["TSLA"]
First-run login