Schwab MCP

Промышленного уровня, с приоритетом безопасности MCP-сервер Charles Schwab, самостоятельно размещённый на Cloudflare Workers. Котировки в реальном времени, греки опционов, OAuth 2.1 с нулевым доверием и защитные механизмы против галлюцинаций.

Документация

Schwab MCP on Cloudflare Workers

English | 简体中文

TypeScript Cloudflare Workers Self-Hosted Glama Score MCP Vitest License

[!CAUTION] Disclaimer
This project is for educational, research, and experimental automation purposes only. It does NOT constitute financial advice or investment recommendations. Trading securities and options carries substantial risk of loss. Authors and contributors assume no liability for any trading actions, losses, API errors, network latency, or LLM hallucinations.

A production-grade, single-user remote Model Context Protocol (MCP) server self-hosted on your personal Cloudflare Workers (100% Free-Tier friendly). It exposes Charles Schwab market data and account services to LLM clients (Cursor, Claude Desktop, VS Code) via Streamable HTTP (/mcp) with strict zero-trust security and anti-hallucination guardrails.


⚡ One-Line Quickstart with AI Agents

Got an AI coding assistant (Claude Code, Cursor Composer, Windsurf, or Codex)? Clone this repository and give your agent this single prompt:

💬 "Read setup.md and guide me step-by-step through configuring and deploying this Schwab MCP server to my Cloudflare account."

Your agent will automatically inspect prerequisites, provision Cloudflare KV & Secrets, validate code, and deploy to the edge!


Key Highlights

  • 100% Self-Hosted & Zero Maintenance: Deployed directly into your own Cloudflare account. Zero VPS, paid servers, or homelab hardware required—runs comfortably within Cloudflare's generous free tier with absolute data and credential sovereignty (no third-party proxy or SaaS middleman).
  • Zero-Trust Access: OAuth 2.1 dynamic client registration gated by an immutable GitHub Numeric User ID whitelist.
  • Granular Client Scopes: Each MCP client independently chooses read_only (default, trading tools hidden) or full_access.
  • Physical Credential Isolation: Schwab tokens, state machines, and audit logs reside strictly within an SQLite-backed Durable Object (SchwabVault). Secrets never leak into KV or logs.
  • Anti-Hallucination Guardrails:
    • Global killswitch (ENABLE_TRADING) defaults to false.
    • Mandatory two-phase confirmation with verbatim phrase repetition for immediate and modified orders.
    • Optional ephemeral session direct trading (30-min idle timeout, 8-hour cap, single-reveal token).
    • Deterministic delayed conditional orders executed via Durable Object Alarms.
    • Zero blind write retries on network failures (EXECUTION_UNCERTAIN).

Why Cloudflare Workers? (Comparison)

FeatureTypical Local MCP (Python/stdio)schwab-mcp (This Project)
Hosting & CostLocal PC must stay awake, or paid VPS ($5–$20/mo)Self-hosted on personal Cloudflare account ($0/mo, 100% Free Tier friendly)
Data SovereigntyPlaintext files on local diskPrivate & self-hosted: you own your credentials in DO SQLite, zero 3rd-party SaaS
Runtime & DeploymentLocal machine process (dies on sleep/shutdown)Cloudflare Workers Edge (24/7 Global Serverless, zero maintenance)
Multi-Client AccessSingle desktop onlyRemote Streamable HTTP (/mcp) across Cursor, Claude, VS Code & mobile
Credential StoragePlaintext files on local diskIsolated SQLite inside Durable Object (SchwabVault), zero KV/log leaks
Client PermissionsAll tools exposed unconditionallyDynamic OAuth Scope (read_only vs full_access) per client
Trading SafetyNone or weak prompt-based hintsDeployment killswitch (ENABLE_TRADING), two-phase phrase repetition, session capability
Off-hours / Conditional OrdersImpossible if computer sleepsDurable Object Alarms trigger deterministic conditional orders in the cloud
Network Failure SafetyRisk of double-execution on blind retryEXECUTION_UNCERTAIN state blocks blind re-submissions

Architecture

flowchart TD
  subgraph ClientSide ["Client"]
    Client["MCP Client (Cursor / Claude Desktop / Codex)"]
  end

  subgraph CloudflareEdge ["Cloudflare Workers Edge"]
    Router["Worker Router (/mcp, /authorize, /auth/*)"]
    OAuthProvider["@cloudflare/workers-oauth-provider"]
    KV[("OAUTH_KV (Client & Grant Meta)")]
    
    subgraph StorageDO ["SchwabVault Durable Object"]
      DO["Durable Object Runtime"]
      SQLite[("DO SQLite Storage\n- Schwab Tokens & Expiry\n- Proposals & Direct Session\n- Delayed Tasks\n- Redacted Audit Logs")]
      Alarm["DO Alarm Scheduler\n(Auto-refresh / Delayed Tasks)"]
    end
  end

  subgraph Upstream ["External Services"]
    GitHub["GitHub OAuth (User ID Whitelist)"]
    SchwabAPI["Charles Schwab REST APIs\n(Market Data & Trader)"]
  end

  Client -->|Streamable HTTP POST /mcp| Router
  Router -->|Verify Access Token| OAuthProvider
  OAuthProvider <-->|Persist Client / Grant| KV
  Router -->|Authorized Tool Calls| DO
  DO -->|Read GET / Controlled Write| SchwabAPI
  Alarm -->|Trigger on Conditions| DO

  User["Owner (Browser)"] -->|Authorize Client /authorize| Router
  Router -->|Verify Identity| GitHub
  User -->|Connect Schwab /auth/schwab/connect| Router
  Router -->|Exchange Schwab Code| SchwabAPI
  Router -->|Persist Tokens| DO

Example Agent Workflows

1. Portfolio Analysis & Option Chain (Read-Only)

User: "What's my current position in NVDA, and what does the next monthly 140 CALL option look like?"
Agent: (Calls get_account and get_option_chain)
"You hold 100 shares of NVDA at an average price of $118.40 (unrealized P&L: +18.2%). The 30-day $140 CALL is currently trading at $4.20 bid / $4.35 ask, with Delta 0.38 and IV of 42.1%."

2. Guarded Order Execution (Two-Phase Confirmation)

User: "Buy 10 shares of AAPL with a limit order at $220.00."
Agent: (Calls prepare_trade with action PLACE to freeze order snapshot)
"I have prepared and frozen the order snapshot:
- Action: BUY 10 AAPL @ Limit $220.00 (Day)
- Account: ...8a9f
- Proposal ID: prop_01JABC... (Valid for 5 minutes)
⚠️ To confirm and execute, please reply verbatim with:
CONFIRM BUY 10 AAPL LIMIT 220.00 HASH 8a9f"

User: CONFIRM BUY 10 AAPL LIMIT 220.00 HASH 8a9f
Agent: (Calls confirm_trade consuming one-time token with verbatim phrase)
"Order successfully placed with Charles Schwab. Order ID: #10098234."



MCP Tools Catalog

The server registers 24 MCP tools. Schema validation strictly follows Zod definitions in src/schemas.ts.

Read-Only & Diagnostics (15 Tools)

Available to all authenticated clients. Operations use HTTP GET:

ToolDescriptionKey Inputs
schwab_connection_statusCheck Schwab OAuth connection and expirationNone
get_trading_diagnosticsCheck tool registration, killswitch, and active trading statusNone (always visible)
get_quotesFetch real-time market quotessymbols (1–50 array), fields, indicative
get_price_historyHistorical OHLCV candle barssymbol, periodType, frequencyType, frequency, startDate, endDate
get_option_chainOption chains, Greeks, and quotessymbol, contractType (CALL/PUT/ALL), strikeCount, strategy
get_option_expiration_chainExpiration dates for option underlyingsymbol
get_market_hoursMarket trading schedulesmarkets (equity, option, bond, etc.), date (YYYY-MM-DD)
get_moversMarket gainers/losers/activesindex ($DJI, $COMPX, $SPX), sort, frequency
search_instrumentsSearch tickers or fundamental definitionssymbol, projection
list_accountsList account hash identifiers (real numbers stripped)None
get_accountBalances and positions for an account hashaccountHash, includePositions
get_ordersExecution history across accountsfromEnteredTime, toEnteredTime (ISO-8601), status
get_orderExecution details for a specific orderaccountHash, orderId
get_transactionsAccount cash flow and trade transactionsaccountHash, startDate, endDate (YYYY-MM-DD), types, symbol
get_user_preferencesUser profile context and trade permissionsNone

Trading Control Tools (9 Tools)

[!WARNING] Only registered for clients granted full_access during OAuth authorization. Executing trades additionally requires ENABLE_TRADING=true. Cancellation remains available even when trading is disabled to de-risk positions.

ToolDescriptionKey Inputs
enable_tradingRead-only check for deployment killswitch statusNone (switch cannot be toggled via MCP)
prepare_tradeFreeze order snapshot and issue 5-minute single-use tokenaccountHash, action (PLACE/REPLACE), executionMode, orderPayload, userInstruction
confirm_tradeConsume confirmation token with verbatim text playbackproposalId, confirmationToken, confirmationText
enable_session_direct_tradingEnable direct order placement for the current sessionuserInstruction (returns single-reveal sessionCapability)
place_order_directDirect order submission bypassing per-order confirmationsessionCapability, executionKey (UUID), accountHash, orderPayload, userInstruction
disable_session_direct_tradingInvalidate active direct trading capability immediatelysessionCapability
cancel_orderCancel working market order (no confirmation needed)accountHash, orderId, userInstruction
list_delayed_tradesList active and historical conditional ordersstatus (ACTIVE, EXECUTING, EXECUTED, etc.)
cancel_delayed_tradeCancel an active conditional task immediatelytaskId, userInstruction

Safety & Security Guardrails

  1. Deployment Killswitch (ENABLE_TRADING):
    • Configured in wrangler.jsonc (default: "false").
    • When false, order preparations, confirmations, and session capability grants fail closed. Working orders can still be cancelled.
  2. Anti-Hallucination Two-Phase Confirmation:
    • prepare_trade generates an immutable snapshot and a random confirmation sentence.
    • The user must explicitly repeat the confirmation sentence before confirm_trade consumes the token and executes the order.
  3. Session Direct Trading Mode:
    • Limited strictly to IMMEDIATE + PLACE (order modifications still require confirmation).
    • 30-minute idle expiry, refreshed on use; 8-hour hard lifetime.
    • Atomic idempotent claim using client-provided executionKey.
  4. Deterministic Delayed Orders:
    • AST-based rules combining TIME and QUOTE thresholds. Monitored by Durable Object Alarms.
  5. No Blind Retries on Write (EXECUTION_UNCERTAIN):
    • Network errors or upstream 5xx on write requests mark the order as EXECUTION_UNCERTAIN. Blind retries are blocked; inspect status manually with get_orders.
  6. Payload Sanitization:
    • Max 64 KiB UTF-8 JSON, max depth of 16, max 2048 nodes. Real account numbers and sensitive tokens are strictly scrubbed.

Quickstart

Prerequisites

  • Node.js: >= 20.0.0 (LTS recommended)
  • Cloudflare Account: Workers & Durable Objects enabled
  • GitHub Account: OAuth App created for owner authentication
  • Charles Schwab Developer Account: Approved developer app with Trader & Market Data APIs

Local Setup

  1. Install dependencies:

    npm install
    
  2. Configure local development variables:

    cp .dev.vars.example .dev.vars
    

    Fill in .dev.vars (ignored by git):

    GITHUB_CLIENT_SECRET="your_github_client_secret"
    SCHWAB_CLIENT_ID="your_schwab_app_key"
    SCHWAB_CLIENT_SECRET="your_schwab_app_secret"
    
  3. Update wrangler.jsonc:

    • Set GITHUB_CLIENT_ID and ALLOWED_GITHUB_USER_ID (find via curl -s https://api.github.com/users/<username> | grep '"id":').
    • Set SCHWAB_REDIRECT_URI to https://<worker-name>.<subdomain>.workers.dev/auth/schwab/callback.
    • Keep ENABLE_TRADING="false" during development.
  4. Run automated validation:

    npm run validate
    

    (Runs typecheck, lint, and Vitest suite under simulated Workers DO environments).


Production Deployment

Step 1: Create KV & Set Secrets

# 1. Create KV namespace for OAuth client/token persistence
npx wrangler kv namespace create OAUTH_KV
# Put the returned id into wrangler.jsonc under kv_namespaces

# 2. Upload secrets interactively
npx wrangler secret put GITHUB_CLIENT_SECRET
npx wrangler secret put SCHWAB_CLIENT_ID
npx wrangler secret put SCHWAB_CLIENT_SECRET

Step 2: Deploy Worker

npm run deploy

[!NOTE] MCP authorization uses AUTHORIZATION_VERSION = "client-permissions-v1". Updating this version invalidates active grants, requiring clients to re-authenticate and pick permissions. Schwab account bindings and pending delayed trades remain intact.

The S256 PKCE update keeps this authorization version unchanged: existing valid access and refresh tokens retain their permissions, including grants issued without PKCE. Authorization codes issued before this update must restart authorization; pending consent or GitHub callback flows without valid S256 parameters are also rejected. Reauthorizing the same client still revokes its previous grants before issuing a replacement.

Step 3: CI/CD Pipeline (GitHub Actions)

The repository includes .github/workflows/deploy.yml for automated builds on main:

  • Repository Secrets: CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN
  • Repository Variables: PROD_OAUTH_KV_ID, PROD_ALLOWED_GITHUB_USER_ID, PROD_GITHUB_CLIENT_ID, PROD_SCHWAB_REDIRECT_URI, PROD_ENABLE_TRADING (false by default)

Run npm run configure:deploy in CI to generate the deployment wrangler.jsonc. Never put Schwab or GitHub client secrets in GitHub Actions!

Step 4: Authorize Charles Schwab

Visit in your browser:

https://<your-worker-domain>/auth/schwab/connect
  1. Authenticate with GitHub (must match ALLOWED_GITHUB_USER_ID).
  2. Log in to Schwab and approve account access.
  3. Tokens are securely encrypted into SchwabVault Durable Object.

Client Configuration

MCP Endpoint: https://<your-worker-domain>/mcp

Every new MCP OAuth authorization requires RFC 7636 S256 PKCE. Server metadata advertises only S256; clients that lack S256 support must upgrade. Send exactly one code_challenge (43 unpadded base64url characters) and one code_challenge_method=S256 in the authorization request. Keep the independently generated random verifier on the client and send it as code_verifier only when exchanging the authorization code. Missing, duplicate, or invalid challenge/method parameters return HTTP 400 invalid_request before starting consent. Missing or incorrect verifiers fail token exchange through the OAuth provider.

Local protocol tests cover dynamic registration, browser consent, simulated GitHub owner verification, code exchange, refresh, and MCP calls for both permission levels. These tests do not establish compatibility with real client applications; reconnect and verify each client after deployment.

Claude Desktop

Add to claude_desktop_config.json:

{
  "mcpServers": {
    "schwab": {
      "url": "https://<your-worker-domain>/mcp"
    }
  }
}

Cursor / VS Code / Codex

Configure as a remote MCP server:

  • Type: Streamable HTTP
  • URL: https://<your-worker-domain>/mcp
  • On first connection, complete the browser OAuth flow, select Read only or Full access, and verify via GitHub.

Error Codes & Operations

CodeCauseAction
AUTH_REQUIREDSchwab account not linkedVisit reconnectUrl from response
REAUTHORIZATION_REQUIREDRefresh token expired (7 days inactivity)Visit reconnectUrl to re-authenticate
INVALID_ARGUMENTPayload failed Zod validationReview input against src/schemas.ts
RATE_LIMITEDSchwab API rate limit reached (HTTP 429)Back off following retryAfterMs
SCHWAB_REJECTEDUpstream business rejectionCheck ticker, order status, or market hours
UPSTREAM_UNAVAILABLESchwab gateway or Durable Object errorRetry request (read requests retry once automatically)
CONFIRMATION_INVALIDConfirmation token mismatch or incorrect phraseRe-run confirmation with exact text
CONFIRMATION_EXPIREDToken exceeded 5-minute validityCall prepare_trade again
TRADE_STATE_CONFLICTProposal already consumed or task cancelledRefresh state with list_delayed_trades
DIRECT_TRADING_SESSION_INVALIDSession expired (30m idle / 8h max)Call enable_session_direct_trading
EXECUTION_UNCERTAINNetwork failure during order submissionDo not blindly retry. Verify via get_orders

Real-Time Log Monitoring

Stream sanitized structured JSON logs via Cloudflare Workers Observability:

npx wrangler tail schwab-mcp --format json

All headers, credentials, OAuth tokens, and account numbers are automatically redacted before logging.


License

Licensed under the Apache License, Version 2.0.