Install
$ agentstack add mcp-kevinkda-schwab-marketdata-mcp ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ● Filesystem access Used
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
schwab-marketdata-mcp
[English](./README.md) | [简体中文](./README_zh.md)
> v0.3 sprint (in flight) — Sprint A focuses on Windows Tier A real-hardware > verification, dependency hygiene (Dependabot), schwab-py drift log, and a > cumulative hour ledger. See: > > - [docs/WINDOWS_VERIFICATION.md](docs/WINDOWSVERIFICATION.md) — 8-criterion > acceptance checklist for a volunteer with a real Windows box. > - [docs/THREAT_MODEL.md §6.6](docs/THREATMODEL.md) — schwab-py upgrade > drift log (must be filled in before any schwab-py bump merges). > - [docs/HOURS.md](docs/HOURS.md) — cumulative hours vs the 480 h project > budget; current usage ≈ 5%.
Production-grade Model Context Protocol (MCP) server that exposes the Charles Schwab Market Data Production API as 17 tools (10 endpoints + 2 derived option-analytics + 3 meta tools + 1 cached-history analytics + 1 experimental streaming snapshot tool) for use inside Cursor, Claude Code, and any other MCP-aware agent.
> Read-only — this project calls only the Schwab Market Data API. It does > NOT call the Schwab Trader API and does NOT place orders. See > [Responsible Use](#responsible-use) for the Schwab Terms of Service impact.
Overview
schwab-marketdata-mcp is the server-side half of a two-repo system:
- This repo — the MCP server. Owns OAuth, rate limiting, retry/backoff,
token rotation, structured error mapping, and stdio framing.
- Companion repo — [
schwab-marketdata-skill](../schwab-marketdata-skill)
ships two Cursor / Claude Skills that document how to call this server (single-tool ops skill and multi-step workflows skill).
The server wraps the unofficial schwab-py SDK with the production hardening required for an always-on MCP host:
- Atomic refresh-token rotation with
fcntl.flock(cross-process safe). - Token-on-disk permission audit (
0600enforced on every read). - Per-process rate limiter (default 120 req/min, configurable per call).
- Adaptive 429 / 5xx retry with exponential backoff and
Retry-Afterparsing. - Structured error hierarchy (
SchwabAuthError,SchwabRateLimitError, etc.)
so agents can surface actionable messages instead of stack traces.
- stdio hardening so log lines never corrupt the JSON-RPC stream.
See [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) for the full architecture and threat model.
Quick Start
> Step 0 (mandatory) — install pre-commit hooks before copying any > secrets into the working tree: > > ``bash > uv sync --extra dev > uv run pre-commit install > ``
# 1. Sync deps (uses the committed uv.lock)
uv sync --extra dev
# 2. Install pre-commit hooks (gitleaks + detect-secrets + ruff + mypy)
uv run pre-commit install
# 3. Configure your Schwab Developer Portal app credentials
cp .env.example .env
# then edit .env and replace `dummy-not-a-real-secret` placeholders with
# real values from https://developer.schwab.com/dashboard/apps
# 4. One-time OAuth login (browser opens; click through the self-signed cert)
uv run python -m schwab_marketdata_mcp.auth login_flow
# Or (containerized / headless): manual_flow
uv run python -m schwab_marketdata_mcp.auth manual_flow
# 5. Schedule the health probe (once per project, see docs/cron.example)
# macOS: copy the launchd plist into ~/Library/LaunchAgents/
# Linux: append the crontab snippet via `crontab -e`
# 6. Verify everything wires up
uv run python -m schwab_marketdata_mcp.health # exit 0 if healthy
uv run pytest --cov # ≥85% overall, 100% on critical modules
For full client registration walkthroughs (Cursor / Claude Code / VS Code / Claude Desktop), see [docs/REGISTER.md](docs/REGISTER.md).
Features
Authentication & token lifecycle
- OAuth 2.0 authorization code flow with two ergonomic CLIs:
login_flow (auto-opens a browser, captures the redirect) and manual_flow (paste-the-URL, for headless / containerized environments).
- Atomic refresh-token rotation — every refresh writes to a tempfile and
os.replaces the live token, guarded by an fcntl.flock so concurrent agents never race or corrupt the on-disk token.
- Permission audit — refuses to load a token file that is group- or
world-readable; emits SchwabAuthError(reason="insecure_token_perms") with a copy-pasteable chmod 600 hint.
- 7-day refresh window detection — translates Schwab's opaque
invalid_grant into SchwabAuthError(reason="refresh_token_expired") so agents can surface a "reconnect" UX instead of looping.
Reliability
- Per-process rate limiter — token-bucket, default 120 req/min,
configurable via SCHWAB_RATE_LIMIT_PER_MIN.
- Adaptive retry on
429and5xx(default: 2 retries with exponential
backoff and Retry-After parsing).
- Pluggable cache — a backend-agnostic response + derived-analysis cache
short-circuits repeat reads of the 5 cacheable tools (get_quote, get_price_history, get_option_chain, search_instruments, get_instrument_by_cusip). Per-table TTLs (60 s quotes / 5 m option chains / 24 h instruments / 60 s recent price history) cut Schwab API pressure. Disabled by default (opt-in) — every call hits Schwab live. Enable with SCHWAB_CACHE_ENABLED=true (also accepts 1 / yes / on); force fresh reads via SCHWAB_CACHE_BYPASS=1. When disabled, cacheable tools report _cache_status: "disabled"; the response shape is unchanged.
⚠️ BREAKING (v0.5.0): the embedded DuckDB cache is removed in favour of a pluggable backend selected via SCHWAB_CACHE_BACKEND:
| Backend | Default | Dependency | Notes | | --- | --- | --- | --- | | memory | ✅ | none (stdlib) | In-process LRU + TTL, concurrency-safe, non-blocking, no files. Derived-analysis history (option_chain_snapshots / iv_history / candle OLAP) keeps no durable store — those degrade gracefully (get_iv_percentile returns a cache_disabled/empty payload, snapshot writes report _cached_rows: 0). | | clickhouse | — | pip install schwab-marketdata-mcp[clickhouse] + SCHWAB_CLICKHOUSE_URL | Durably persists the derived-analysis time series and serves the real ATM-IV / IV-percentile / candle-OLAP analytics. |
All 17 tools keep working out of the box on the default memory backend; only the IV-history-backed analytics require the ClickHouse extra to retain cross-session history.
- Health probe (
schwab_marketdata_mcp.health) returns distinct exit
codes for token age, missing/malformed token, and insecure permissions — ready for cron / launchd alerting.
- Rotating file logs under
${XDG_STATE_HOME}/schwab-marketdata-mcp/logs/
(10 MB × 5), regardless of whether the MCP host honors a stderr field.
Security
- stdio hardening —
bootstrap_dotenv()runs before anyprint()could
fire, so .env loading never corrupts the JSON-RPC stream.
- Path-injection prevention —
SCHWAB_TOKEN_PATHenv var is intentionally
unsupported; use the --config-dir CLI flag instead.
- Pre-commit hardening —
gitleaks,detect-secrets,ruff,mypy,
and markdownlint run on every commit; .secrets.baseline is committed.
- Non-redistributable data guardrail — the companion workflows skill
refuses to write Schwab data into a public repo (it calls gh repo view --json isPrivate first).
Tooling surface — 17 MCP tools
At-a-glance map of name → endpoint:
| # | Tool | Endpoint | | -- | ----------------------------- | ------------------------------------------ | | 1 | get_quote | GET /{symbol_id}/quotes | | 2 | get_quotes | GET /quotes | | 3 | get_price_history | GET /pricehistory | | 4 | get_option_chain | GET /chains | | 5 | get_option_expiration_chain | GET /expirationchain | | 6 | get_option_greeks_summary | derived — net Greeks from GET /chains (no ClickHouse needed) | | 7 | get_market_hours | GET /markets | | 8 | get_market_hour_single | GET /markets/{market_id} | | 9 | get_movers | GET /movers/{symbol_id} | | 10 | search_instruments | GET /instruments | | 11 | get_instrument_by_cusip | GET /instruments/{cusip_id} | | 12 | health_check | local — token age + cache health | | 13 | get_server_info | local — versions + supported tool list | | 14 | get_cache_stats | local — cache backend (memory/clickhouse) + live entry count | | 15 | get_iv_percentile | local — ATM IV percentile rank from cached iv_history (refresh=True pulls fresh chain) | | 16 | get_iv_surface | local — ATM IV surface across 30d/60d/90d buckets from cached iv_history | | 17 | get_streaming_snapshot 🧪 | Streamer WebSocket — bounded snapshot |
Detailed per-tool reference is below.
Business tools (10) — Schwab Market Data Production endpoints
get_quote — single-symbol real-time quote
- When: fetch the latest bid / ask / last for one symbol; quick P&L
read or one-off decision; not a batch.
- Input:
symbol(str — stock, ETF, index$XYZ, or OSI option),
fields? (list of field groups, e.g. ["quote", "fundamental"]).
- Returns:
{: {quote: {bidPrice, askPrice, lastPrice, totalVolume, ...}, fundamental: {...}, reference: {...}}}
- Example:
{"symbol": "VOO"}
get_quotes — batch quote for ≤ 50 symbols
- When: watchlist refresh, portfolio-wide scan, ETF / stock
comparison; one HTTP round-trip instead of N.
- Input:
symbols(list[str], len ≤ 50),fields?,
indicative? (bool — include indicative quotes for indices).
- Returns:
{: {quote: {...}, fundamental: {...}}, : {...}, ...} - Example:
{"symbols": ["VOO", "QQQ", "SPY"]}
get_price_history — OHLC candles (minute → month)
- When: technical analysis, backtests, charting, computing SMA / RSI
/ ATR or any windowed indicator.
- Input:
symbol,period_type(DAY/MONTH/YEAR/YTD),
period, frequency_type (MINUTE / DAILY / WEEKLY / MONTHLY), frequency. Pydantic pre-validates the legal cartesian product.
- Returns: `{candles: [{open, high, low, close, volume, datetime}, ...],
symbol, empty}`
- Example:
{"symbol": "VOO", "period_type": "DAY", "period": "FIVE_DAYS", "frequency_type": "MINUTE", "frequency": "EVERY_FIVE_MINUTES"}
get_option_chain — option chain snapshot with Greeks
- When: option research, IV-rank analysis, covered-call /
cash-secured-put strike selection, vertical-spread modeling.
- Input:
symbol, plus a dozen optional filters —
contract_type? (CALL / PUT / ALL), strike_count?, from_date?, to_date?, strategy?, range?, volatility?, interest_rate?, days_to_expiration? …
- Returns:
{status, callExpDateMap: {...}, putExpDateMap: {...}, underlying: {...}, numberOfContracts}
- Example:
{"symbol": "VOO", "strike_count": 3, "contract_type": "ALL"}
get_option_expiration_chain — list of available expirations
- When: pre-flight before
get_option_chain; check whether weekly /
monthly / LEAPS expiries exist for an underlying.
- Input:
symbol. - Returns:
{expirationList: [{expirationDate, daysToExpiration, expirationType, settlementType}, ...]}
- Example:
{"symbol": "VOO"}
get_option_greeks_summary — net Greeks aggregation (no ClickHouse needed)
- When: read net dealer/book positioning at a glance — aggregate
delta / gamma / theta / vega / rho across the live chain instead of eyeballing hundreds of contracts; split by call/put and by expiry; isolate a single expiration.
- Works without ClickHouse: the Greeks are computed live from the
freshly-fetched chain (the data is already in get_option_chain), so this tool is useful on the default memory backend.
- Input:
underlying,expiry?(YYYY-MM-DD— restrict to one
expiration), weighting? (open_interest default / equal). open_interest weights each Greek by open interest and falls back to equal weighting (with a warning) when no contract reports OI.
- Returns:
{underlying, expiry_filter, weighting, requested_weighting, contract_count, net: {delta, gamma, theta, vega, rho}, by_side: {CALL: {...}, PUT: {...}}, by_expiry: {: {...}}, warning}
- Example:
{"underlying": "AAPL", "weighting": "open_interest"}
get_market_hours — multi-market session hours
- When: pre-market / after-hours dashboards; decide whether a market
is open before kicking off a scan; cross-market view.
- Input:
markets(list[str] — any ofEQUITY/OPTION/BOND/
FUTURE / FOREX), date? (YYYY-MM-DD, defaults to today).
- Returns:
{: {: {date, marketType, isOpen, sessionHours: {...}}}}
- Example:
{"markets": ["EQUITY", "OPTION"]}
get_market_hour_single — single-market session hours
- When: same intent as
get_market_hoursbut only one market —
smaller payload, path-param flavor.
- Input:
market_id(EQUITY/OPTION/BOND/FUTURE/
FOREX), date?.
- Returns: same shape as
get_market_hours, scoped to a single market. - Example:
{"market_id": "EQUITY"}
get_movers — top movers for an index
- When: intraday / end-of-day market-tone read; spot unusual movers
in $SPX / $DJI / $COMPX / NYSE / NASDAQ.
- Input:
index($SPX/$DJI/$COMPX/NYSE/NASDAQ/
OTCBB / INDEX_ALL / EQUITY_ALL / OPTION_ALL / OPTION_PUT / OPTION_CALL), sort?, frequency?.
- Returns:
{screeners: [{symbol, description, lastPrice, netChange, netPercentChange, volume, ...}, ...]}
- Example:
{"index": "$SPX"}
search_instruments — instrument search / fundamentals
- When: resolve user-typed strings (
"AAPL","Apple") to canonical
instruments; bulk-validate tickers; pull fundamental block (PE, market cap, dividend yield, …).
- Input:
symbols(list[str]),projection
(SYMBOL_SEARCH / SYMBOL_REGEX / DESC_SEARCH / DESC_REGEX / SEARCH / FUNDAMENTAL).
- Returns:
{instruments: [{symbol, description, exchange, assetType, fundamental?: {...}}, ...]}
- Example:
{"symbols": ["VOO"], "projection": "FUNDAMENTAL"}
get_instrument_by_cusip — reverse lookup by CUSIP
- When: a brokerage statement / SEC filing gave you a 9-character
CUSIP and you need the matching ticker / description.
- Input:
cusip(str, 9 chars, must match^[A-Z0-9]{9}$). - Returns: a single instrument dict
{symbol, description, exchange, assetType, ...}.
- Example:
{"cusip": "922908363"}(VOO)
Meta tools (2) — local, offline-safe, no Schwab API call
health_check — token + rate-limit + recent-error self-check
- When: first call after MCP startup; before deciding "do we need to
re-auth"; periodic cron / launchd probe (every 4 h is plenty).
- Input: none.
- Returns:
{server_version, token_state (VALID | MISSING | INSECURE_PERMS | MALFORMED), token_age_days, token_expires_in_days, last_request_status, rate_limit_remaining_per_min, recent_error_count_24h, platform_supported}
- Example:
{}
get_server_info — version handshake + capability discovery
- When: skill activation (validates
compatible_mcp_version); first
time registering on a new client; debugging / bug reports.
- Input: none.
- Returns:
`{serverversion, mcpsdkversion, schwabpy_ve
…
Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: kevinkda
- Source: kevinkda/schwab-marketdata-mcp
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.