Install
$ agentstack add skill-kevinkda-schwab-marketdata-skill-schwab-marketdata-ops-en ✓ 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 No
- ✓ Filesystem access No
- ✓ 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-ops-en
> Responsibility disclaimer: This skill drives the Schwab Market Data > Production API. Users are required to read > and > , and bear sole responsibility for > compliance of their usage. The authors of schwab-marketdata-mcp and > schwab-marketdata-skill are not liable for any usage that violates the > Schwab Terms of Service.
Activation handshake (run first)
When activating this skill, the first step must be a call to get_server_info to confirm that the returned server_version falls within the frontmatter compatible_mcp_version range:
get_server_info()
→ { "server_version": "0.1.x", "supported_tools": [...12 names...] }
If the version does not match or the call fails: stop immediately, tell the user to upgrade either schwab-marketdata-mcp or this skill, and do not continue any business calls.
Full handshake (including token health check):
1. get_server_info() → verify server_version ∈ compatible_mcp_version
2. health_check() → verify token_state == "valid"
and token_expires_in_days >= 0.5
3. If either step fails → stop immediately and follow the references
to repair before continuing.
Quick Start — 7 steps from zero to hello-world
For a first-time onboarding or a new machine, read the following 7 step files in order:
| Step | File | Expected outcome | | ---- | ---- | ---------------- | | 1 | [references/quick-start/step-1-developer-portal-app.md](references/quick-start/step-1-developer-portal-app.md) | Schwab Developer Portal app created, App Key / Secret in hand | | 2 | [references/quick-start/step-2-credentials-env.md](references/quick-start/step-2-credentials-env.md) | .env populated (chmod 600), pre-commit hooks installed | | 3 | [references/quick-start/step-3-first-oauth.md](references/quick-start/step-3-first-oauth.md) | token.json written to disk (chmod 600) | | 4 | [references/quick-start/step-4-token-health-check.md](references/quick-start/step-4-token-health-check.md) | health module exits 0, token_state == "valid" | | 5 | [references/quick-start/step-5-first-mcp-tool-call.md](references/quick-start/step-5-first-mcp-tool-call.md) | Minimal Python MCP client successfully calls get_quote("VOO") | | 6 | [references/quick-start/step-6-cursor-integration.md](references/quick-start/step-6-cursor-integration.md) | ~/.cursor/mcp.json registered, agent can invoke all 12 tools | | 7 | [references/quick-start/step-7-cron-launchd-setup.md](references/quick-start/step-7-cron-launchd-setup.md) | cron / launchd health probes enabled, desktop notification channel verified |
Common usage scenarios
Following the helis "I want to..." routing pattern, dispatch directly to the right tool and reference doc based on user intent.
"I want a real-time quote"
| Scenario | What to use | Notes | | -------- | ----------- | ----- | | Single stock / ETF / index | get_quote("AAPL") / get_quote("$SPX") | symbol must be UPPERCASE; indexes need a $ prefix | | Batch (≤ 50) | get_quotes(symbols=[…]) | beyond 50, batch on the client side, otherwise SchwabValidationError(field="symbols") | | Option contract | get_quote("AAPL 240119C00170000") | OSI 21 chars: 6-char root (space-padded) + YYMMDD + C/P + 8-digit strike(×1000) |
→ Full schema in [references/tools/tool-reference-quotes.md](references/tools/tool-reference-quotes.md).
"I want to research an option chain"
get_option_expiration_chain(symbol)— fetch the list of available expirations firstget_option_chain(symbol, contract_type=…, strike_count=…, strike_range=…)— pull concrete contracts by ATM/OTM/ITM- Multi-step research workflow → switch to the
schwab-marketdata-workflows-enskill'soption-chain-research.mdplaybook
→ Schema in [references/tools/tool-reference-options.md](references/tools/tool-reference-options.md); Greeks freshness covered in the playbook's Cautions section and [references/concepts/osi-option-symbol.md](references/concepts/osi-option-symbol.md).
"Token expired, what now?"
1. health_check()
→ inspect token_state ∈ {"valid","missing","malformed","insecure_perms"}
→ inspect token_expires_in_days (< 0.5 strongly suggests immediate reauthorize)
2. If SchwabAuthError(reason="refresh_token_expired" | "token_not_initialized"):
uv run python -m schwab_marketdata_mcp.auth login_flow # default
# or manual_flow (headless / SSH-only / WSL2)
3. Re-run health_check() to confirm token_state == "valid"
→ Full mapping table and step-by-step remediation in [references/troubleshooting/auth-overview.md](references/troubleshooting/auth-overview.md); OAuth flow walkthrough in [references/oauth/oauth-overview.md](references/oauth/oauth-overview.md); token lifecycle in [references/oauth/oauth-token-lifecycle.md](references/oauth/oauth-token-lifecycle.md).
"Got rate-limited, what now?"
| Symptom | Remediation | | ------- | ----------- | | SchwabRateLimitError(retry_after_seconds=N) | Wait N seconds and retry; if it happens twice in a row, surface to the user | | stderr emits {"event":"rate_limit_warning","remaining":<20} | Switch bulk requests to get_quotes (50 per call); or lower SCHWAB_RATE_LIMIT_PER_MIN | | 0 slots, raises immediately | Apply retry-with-backoff on the agent side (schwab-py already retries SCHWAB_MAX_RETRIES times internally) |
→ Token-bucket behavior and triage in [references/operations/rate-limit-token-bucket.md](references/operations/rate-limit-token-bucket.md); 4-symptom rate-limit triage in [references/troubleshooting/rate-limit-overview.md](references/troubleshooting/rate-limit-overview.md).
"I want to inspect server health"
get_server_info() → server_version / mcp_sdk_version / schwab_py_version / supported_tools
health_check() → token_state / token_expires_in_days / rate_limit_remaining_per_min / recent_error_count_24h
The two together answer "Is the MCP server healthy, is the token valid, and have we exhausted the rate limit?" in under 30 seconds.
→ Schema in [references/tools/tool-reference-meta.md](references/tools/tool-reference-meta.md).
"I want candlesticks / historical data"
get_price_history(symbol, period_type, period?, frequency_type, frequency?, ...)
The (period_type, period, frequency_type, frequency) 4-tuple only accepts a restricted set of combinations — illegal combinations are silently 400'd by the server. The MCP server pre-rejects them at the Pydantic layer.
→ Schema + legal-combination table in [references/tools/tool-reference-price-history.md](references/tools/tool-reference-price-history.md); Cartesian-product error remediation in [references/troubleshooting/validation-pricehistory-cartesian.md](references/troubleshooting/validation-pricehistory-cartesian.md).
"I want instrument metadata / company fundamentals"
| Scenario | What to use | | -------- | ----------- | | Known ticker → metadata | search_instruments(symbols=["AAPL"], projection="SYMBOL_SEARCH") | | Known ticker → fundamentals | search_instruments(symbols=["AAPL"], projection="FUNDAMENTAL") | | Known 9-digit CUSIP | get_instrument_by_cusip(cusip="037833100") | | Fuzzy company-name lookup | search_instruments(symbols=["TESLA"], projection="DESCRIPTION_SEARCH") |
→ Schema in [references/tools/tool-reference-instruments.md](references/tools/tool-reference-instruments.md).
"I want top movers / unusual activity today"
get_movers(index="DJI"|"COMPX"|"SPX"|..., sort_order=..., frequency=...)
The index enum value must be passed as the enum name ("DJI"), not the wire value ("$DJI").
→ Schema + enum reference in [references/tools/tool-reference-movers.md](references/tools/tool-reference-movers.md).
"I want to call this from my own Python / TypeScript / Rust app"
| Client | File | | ------ | ---- | | Python (mcp SDK) | [references/integration/python-mcp-client.md](references/integration/python-mcp-client.md) | | TypeScript / Node | [references/integration/typescript-mcp-client.md](references/integration/typescript-mcp-client.md) | | Rust (rmcp or hand-rolled) | [references/integration/rust-mcp-client.md](references/integration/rust-mcp-client.md) | | Shell + jq pipe | [references/integration/cli-jq-pipe.md](references/integration/cli-jq-pipe.md) |
Data coverage clarifications
get_price_history is the candlestick / kline endpoint
If you're looking for OHLCV bars (candles, klines, candlesticks), get_price_history is the tool. The response carries a candles[] array, each entry exposing open / high / low / close / volume / datetime (epoch milliseconds).
Supported granularity comes from the (period_type, frequency_type, frequency) triple:
period_type=DAY:MINUTE× {1, 5, 10, 15, 30} (~48 days for 1-min,
~9 months for 5–30 min).
period_type=MONTH:DAILY/WEEKLY(up to 6 months).period_type=YEAR:DAILY/WEEKLY/MONTHLY(**up to 20
years**).
period_type=YEAR_TO_DATE:DAILY/WEEKLY(year-to-date).
Sub-minute candles (seconds, ticks) are not in the Schwab Market Data API surface.
→ Full legal-combination table + Cartesian-product error remediation in [references/tools/tool-reference-price-history.md](references/tools/tool-reference-price-history.md) and the MCP repo's README → "Data coverage clarifications".
What the Schwab Market Data API does NOT provide
The following data is architecturally unavailable through the Schwab Market Data Production API and would require a third-party provider:
- Time & sales / tape (trade-level) — not in REST or Streaming
since Schwab's 2024 API migration removed the TIMESALE_* services.
- Tick-by-tick history — not in the Schwab API.
- Level 2 historical snapshots — Streaming only, no REST history.
- Fundamental / earnings time series (EPS history, revenue
history, etc.) — quotes carry FUNDAMENTAL fields but there is no historical endpoint.
- News / SEC filings — not in the Market Data API.
Recommended third-party providers (Polygon.io, Tiingo, Alpaca, Databento, FMP, SEC EDGAR) for each data class are listed in the MCP repo's README → "Data coverage clarifications".
Trader API is out of scope
Trader API endpoints (account, orders, transactions, positions) are explicitly out of scope — this skill covers read-only Market Data only. If the user asks to place an order or modify positions, refuse immediately and point them at the MCP README's "Responsible use" section.
Decision tree — pick the right tool
| User intent | What to use | | ------------------------------------------ | -------------------------------------------------------- | | Single stock/ETF/index spot price | get_quote(symbol="AAPL") | | One-shot multi-symbol quotes (≤50) | get_quotes(symbols=[...]) | | Historical candles / OHLC | get_price_history(symbol, period_type, …) | | Option chain snapshot | get_option_chain(symbol, contract_type=…) | | Option expiration list | get_option_expiration_chain(symbol) | | Multi-market open/close status | get_market_hours(markets_list=[…]) | | Single-market open/close status | get_market_hour_single(market_id) | | Today's top movers | get_movers(index, sort_order) | | Fuzzy instrument lookup by ticker | search_instruments(symbols, projection) | | Exact lookup by 9-digit CUSIP | get_instrument_by_cusip(cusip) | | Inspect token state / error counters | health_check() | | Get server metadata (version, 12 tools) | get_server_info() |
Enum values must always use the enum names defined in models.py (e.g. "VOLUME", "NASDAQ", "DAY"); the MCP server internally translates them into schwab-py wire values.
Key Concepts
| Term | Definition | | ---- | ---------- | | accesstoken | Schwab OAuth bearer token, valid for 90 minutes; schwab-py auto-renews it before each API call, so the agent never has to think about it. | | refreshtoken | rotate-on-use: every refresh issues a new refreshtoken, with a hard 7-day lifetime. After expiry you must run login_flow again — there is no shortcut (Schwab OAuth is designed this way). | | TokenState | One of 4 enums returned by health_check(): valid (usable) / missing (no token.json) / insecure_perms (file mode is not 600/700) / malformed (JSON corrupt or missing fields). The first three are fixed by auth login_flow; the last typically requires backing up and deleting token.json first. | | SchwabAuthError reason | A 6-way enum attached to auth failures during business calls: refresh_token_expired_soon / refresh_token_expired / token_not_initialized / token_corrupted / insecure_token_perms / callback_url_mismatch. Each reason has its own 5-section remediation page in [references/troubleshooting/](references/troubleshooting/). | | ratelimit_bucket | The MCP server uses a token bucket + sliding window (capacity = SCHWAB_RATE_LIMIT_PER_MIN, default 120) instead of asyncio.Semaphore. Difference: a Semaphore would hold its slot during retry sleep, blocking other concurrent tools; the token bucket releases the slot during sleep, letting other tools proceed. | | OSI option symbol | A 21-character fixed format: {ROOT:6}{YYMMDD:6}{C\|P:1}{STRIKE×1000:8}, with the root right-padded with spaces if shorter than 6 chars. Example: "AAPL 240119C00170000" = AAPL 2024-01-19 Call $170.00; "BRK/B 240419P00350000" (contains /). Pass this directly to get_quote. | | pricehistory cartesian product | Schwab only accepts a restricted set of combinations for the (period_type, period, frequency_type, frequency) tuple; illegal combinations silently return 400. The MCP server pre-rejects them at the Pydantic layer to avoid wasting billed quota. The full legal-combination table lives in [references/tools/tool-reference-price-history.md](references/tools/tool-reference-price-history.md). | | Pydantic Literal: enum name vs wire value | The public API always takes the enum name (e.g. MoversIndex.DJI = "DJI"); the MCP server translates internally to the schwab-py wire value (e.g. "$DJI"). This translation layer means the agent never has to remember prefix characters ($ / lowercase / mixed case). Never pass a wire value directly — it triggers SchwabValidationError. | | Activation handshake | The two mandatory steps after skill activation: get_server_info to verify version compatibility, health_check to verify token state. Either failure stops the flow immediately — no silent fallback allowed. | | error normalization | All schwab-py exceptions are wrapped by the server into {"error": "Schwab*Error", ...} dicts and returned to the caller — they do not propagate as exceptions through the MCP protocol. The agent must always check the error field before reading the payload. | | non-redistributable | Schwab Market Data is non-redistributable; any markdown / report writes must stay inside private repositories (the workflows skill enforces this with gh repo view --json isPrivate). |
Architecture overview
┌────────────────────┐
│ AI agent (this
…
## Source & license
This open-source skill 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-skill](https://github.com/kevinkda/schwab-marketdata-skill)
- **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.