# Schwab Marketdata Mcp

> Read-only MCP server for Charles Schwab Market Data Production API (14 tools, OAuth, DuckDB cache, OWASP-tested).

- **Type:** MCP server
- **Install:** `agentstack add mcp-kevinkda-schwab-marketdata-mcp`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [kevinkda](https://agentstack.voostack.com/s/kevinkda)
- **Installs:** 0
- **Category:** [Databases](https://agentstack.voostack.com/c/databases)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [kevinkda](https://github.com/kevinkda)
- **Source:** https://github.com/kevinkda/schwab-marketdata-mcp

## Install

```sh
agentstack add mcp-kevinkda-schwab-marketdata-mcp
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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/WINDOWS_VERIFICATION.md) — 8-criterion
>   acceptance checklist for a volunteer with a real Windows box.
> - [`docs/THREAT_MODEL.md` §6.6](docs/THREAT_MODEL.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`](https://github.com/alexgolec/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 (`0600` enforced on every read).
- Per-process rate limiter (default 120 req/min, configurable per call).
- Adaptive 429 / 5xx retry with exponential backoff and `Retry-After` parsing.
- 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
> ```

```bash
# 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.replace`s 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 `429` and `5xx` (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 any `print()` could
  fire, so `.env` loading never corrupts the JSON-RPC stream.
- **Path-injection prevention** — `SCHWAB_TOKEN_PATH` env 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 of `EQUITY` / `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_hours` but 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**:
  `{server_version, mcp_sdk_version, schwab_py_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](https://github.com/kevinkda)
- **Source:** [kevinkda/schwab-marketdata-mcp](https://github.com/kevinkda/schwab-marketdata-mcp)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** yes
- **Shell / process execution:** no
- **Environment & secrets:** yes
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-kevinkda-schwab-marketdata-mcp
- Seller: https://agentstack.voostack.com/s/kevinkda
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
