schwab-mcp
README.md
<p align="center">
<img src="docs/assets/logo.svg" alt="Schwab MCP logo" width="160" height="160">
</p>
# Schwab MCP on Cloudflare Workers
English | [简体中文](README.zh-CN.md)
[](https://www.typescriptlang.org/)
[](https://workers.cloudflare.com/)
[](https://workers.cloudflare.com/)
[](https://glama.ai/mcp/servers/chensirui2008/schwab-mcp)
[](https://modelcontextprotocol.io/)
[](https://vitest.dev/)
[](LICENSE)
[](https://mcpservers.org/servers/chensirui2008/schwab-mcp)
> [!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)
| Feature | Typical Local MCP (Python/stdio) | **schwab-mcp (This Project)** |
| :--- | :--- | :--- |
| **Hosting & Cost** | Local PC must stay awake, or paid VPS ($5–$20/mo) | **Self-hosted on personal Cloudflare account ($0/mo, 100% Free Tier friendly)** |
| **Data Sovereignty** | Plaintext files on local disk | **Private & self-hosted: you own your credentials in DO SQLite, zero 3rd-party SaaS** |
| **Runtime & Deployment** | Local machine process (dies on sleep/shutdown) | **Cloudflare Workers Edge (24/7 Global Serverless, zero maintenance)** |
| **Multi-Client Access** | Single desktop only | **Remote Streamable HTTP (`/mcp`) across Cursor, Claude, VS Code & mobile** |
| **Credential Storage** | Plaintext files on local disk | **Isolated SQLite inside Durable Object (`SchwabVault`), zero KV/log leaks** |
| **Client Permissions** | All tools exposed unconditionally | **Dynamic OAuth Scope (`read_only` vs `full_access`) per client** |
| **Trading Safety** | None or weak prompt-based hints | **Deployment killswitch (`ENABLE_TRADING`), two-phase phrase repetition, session capability** |
| **Off-hours / Conditional Orders** | Impossible if computer sleeps | **Durable Object Alarms trigger deterministic conditional orders in the cloud** |
| **Network Failure Safety** | Risk of double-execution on blind retry | **`EXECUTION_UNCERTAIN` state blocks blind re-submissions** |
---
## Architecture
```mermaid
flowchart TD
subgraph ClientSide ["Client"]
Client["MCP Client (Cursor / Claude Desktop / Codex)"]
end
subgraph CloudflareEdge ["Cloudflare Workers Edge"]
Router["Worker Router (/mcp, /authorize, /auth/*, /security/*)"]
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- Passkeys / Policies / Browser State\n- Redacted Audit Logs")]
Alarm["DO Alarm Scheduler\n(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 **up to 26 MCP tools**. Schema validation strictly follows Zod definitions in [`src/schemas.ts`](src/schemas.ts).
### Read-Only & Diagnostics (15 Tools)
Available to all authenticated clients. Schwab queries use HTTP `GET`; deployment diagnostics read configuration:
| Tool | Description | Key Inputs |
| :--- | :--- | :--- |
| `schwab_connection_status` | Check Schwab OAuth connection and expiration | None |
| `get_trading_diagnostics` | Check tool registration, killswitch, and active trading status | None (always visible) |
| `get_quotes` | Fetch real-time market quotes | `symbols` (1–50 array), `fields`, `indicative` |
| `get_price_history` | Historical OHLCV candle bars | `symbol`, `periodType`, `frequencyType`, `frequency`, `startDate`, `endDate` |
| `get_option_chain` | Option chains, Greeks, and quotes | `symbol`, `contractType` (`CALL`/`PUT`/`ALL`), `strikeCount`, `strategy` |
| `get_option_expiration_chain` | Expiration dates for option underlying | `symbol` |
| `get_market_hours` | Market trading schedules | `markets` (`equity`, `option`, `bond`, etc.), `date` (`YYYY-MM-DD`) |
| `get_movers` | Market gainers/losers/actives | `index` (`$DJI`, `$COMPX`, `$SPX`), `sort`, `frequency` |
| `search_instruments` | Search tickers or fundamental definitions | `symbol`, `projection` |
| `list_accounts` | List authorized account hashes (real numbers stripped) | None |
| `get_account` | Balances and positions for an account hash | `accountHash`, `includePositions` |
| `get_orders` | Execution history across authorized accounts | `fromEnteredTime`, `toEnteredTime` (ISO-8601), `status` |
| `get_order` | Execution details for a specific order | `accountHash`, `orderId` |
| `get_transactions` | Account cash flow and trade transactions | `accountHash`, `startDate`, `endDate` (`YYYY-MM-DD`), `types`, `symbol` |
| `get_user_preferences` | Sanitized preferences for authorized accounts | None |
### Per-client account consent
After GitHub owner verification, select one or more linked accounts to expose to this MCP client, or leave every account unchecked for market data only. The browser shows masked account numbers and hashes; previous selections are preselected on reauthorization. Newly linked Schwab accounts are never added automatically. If Schwab is disconnected, connect it and restart MCP authorization to select accounts; an empty selection remains available.
The Durable Object enforces each client's account allowlist for balances, positions, orders, transactions and trading, including guessed hashes and frozen proposals. `list_accounts` returns only authorized hashes. `get_orders` queries authorized accounts individually, sorts results by entered time descending and applies a global `maxResults`. `get_user_preferences` returns only selected-account hashes, primary-account flags and account types; streamer and unrelated fields are excluded. Unselected account access returns `ACCOUNT_ACCESS_DENIED`. Empty scopes return empty account/order/preference lists without fetching private data; market queries still require an active Schwab connection.
Reauthorization revokes the client's old grants before committing the new account scope. Removing an account pauses that client's active delayed tasks for the account as `PAUSED`; records remain available for that client to inspect and cancel. Existing tasks without explicit account consent are also paused during migration. Adding an account back never resumes paused tasks automatically: create and approve a new task. Other account data stays inaccessible. No new bindings or secrets are required. Real browser/account selection and real-device Passkey checks remain manual acceptance before release; automated tests use simulated Schwab responses only.
### Trading Control Tools (11 Tools)
> [!WARNING]
> Only registered for clients granted **`full_access`** during OAuth authorization. Advanced Security clients omit `confirm_trade`, `enable_session_direct_trading`, and `place_order_direct` (23 tools total); read-only clients retain 15 tools. Executing trades additionally requires `ENABLE_TRADING=true`. Cancellation remains available even when trading is disabled to de-risk positions.
| Tool | Description | Key Inputs |
| :--- | :--- | :--- |
| `enable_trading` | Read-only check for deployment killswitch status | None (switch cannot be toggled via MCP) |
| `prepare_trade` | Freeze an order for five minutes; return a confirmation token or a Passkey approval URL | `accountHash`, `action` (`PLACE`/`REPLACE`), `executionMode`, `orderPayload`, `userInstruction` |
| `prepare_trade_batch` | Freeze 1–20 immediate orders for one account and one approval | `accountHash`, `orders` (`PLACE`/`REPLACE`, `orderId` for replacements, `orderPayload`), `userInstruction` |
| `get_trade_proposal` | Read this client’s proposal status and execution result without confirmation credentials | `proposalId` (UUID) |
| `confirm_trade` | Consume one confirmation token and verbatim phrase for a frozen order or entire batch | `proposalId`, `confirmationToken`, `confirmationText` |
| `enable_session_direct_trading` | Enable direct order placement for the current session | `userInstruction` (returns single-reveal `sessionCapability`) |
| `place_order_direct` | Direct order submission bypassing per-order confirmation | `sessionCapability`, `executionKey` (UUID), `accountHash`, `orderPayload`, `userInstruction` |
| `disable_session_direct_trading` | Invalidate active direct trading capability immediately | `sessionCapability` |
| `cancel_order` | Cancel working market order (no confirmation needed) | `accountHash`, `orderId`, `userInstruction` |
| `list_delayed_trades` | List active and historical conditional orders | `status` (`ACTIVE`, `EXECUTING`, `EXECUTED`, etc.) |
| `cancel_delayed_trade` | Cancel an active conditional task immediately | `taskId`, `userInstruction` |
---
## Batch orders: one approval, ordered submissions
`prepare_trade_batch` accepts **one authorized account** and an ordered list of immediate `PLACE` and/or `REPLACE` operations. Every replacement requires its target `orderId`; a new order must omit it. Delayed execution and session direct trading are not part of a batch. Each payload retains the existing 64 KiB safety limit; the serialized `orders` array is limited to 256 KiB and 20 orders.
The following shows the input shape only, not a trading recommendation:
```json
{
"accountHash": "<authorized-account-hash>",
"orders": [
{
"action": "PLACE",
"orderPayload": {
"orderStrategyType": "SINGLE",
"orderType": "LIMIT",
"session": "NORMAL",
"duration": "DAY",
"price": "100.00",
"orderLegCollection": [
{
"instruction": "BUY",
"quantity": 1,
"instrument": { "symbol": "AAPL", "assetType": "EQUITY" }
}
]
}
},
{
"action": "REPLACE",
"orderId": 123456,
"orderPayload": {
"orderStrategyType": "SINGLE",
"orderType": "LIMIT",
"session": "NORMAL",
"duration": "DAY",
"price": "101.00",
"orderLegCollection": [
{
"instruction": "BUY",
"quantity": 1,
"instrument": { "symbol": "MSFT", "assetType": "EQUITY" }
}
]
}
}
],
"userInstruction": "<the user's explicit instruction covering this entire batch>"
}
```
- Preparation validates **all** items before saving a proposal; it submits nothing. The five-minute proposal digest covers the account, complete ordered list, actions, replacement targets and payloads.
- Ordinary clients receive **one** `confirmationToken` and `confirmationText`. Show the entire frozen preview and obtain the user's verbatim confirmation once, then call the existing `confirm_trade`.
- Advanced Security clients receive **one** approval URL, no conversational credentials. The browser shows every order and the complete JSON; one Passkey assertion approves the entire batch. Rejecting cancels the pending batch, not existing Schwab orders.
- Submissions run **sequentially in input order**, not as a Schwab atomic transaction. A definite order rejection does not prevent later submissions. Network errors or 5xx produce `EXECUTION_UNCERTAIN` and stop the remaining orders. Expired approval, lost authorization, policy changes or disabled trading also stop further submissions.
- No rollback or automatic resubmission: accepted requests remain accepted. Acceptance is **not** a fill guarantee. Inspect uncertain orders manually before preparing anything again. Unsubmitted items require a new proposal and approval.
- `get_trade_proposal({ "proposalId": "..." })` returns the frozen preview and durable per-item progress/results. Items distinguish `EXECUTED`, `FAILED`, `EXECUTION_UNCERTAIN` and `NOT_SUBMITTED` (or `PENDING`/`EXECUTING` while running). Overall states include `PARTIALLY_EXECUTED` and `EXECUTION_UNCERTAIN`; do not treat an HTTP-success result as proof every order succeeded.
- A consumed batch is never resumed by another confirmation or approval replay. If execution is interrupted, inspect the persisted item states; a claimed item without a recorded outcome must be reconciled with Schwab, not blindly submitted again.
## Advanced Security: optional Passkey approval
Choose **Full access** and check **Advanced Security** on the OAuth consent page. New clients default to off; reauthorization preselects the last saved choice. This setting applies to each client independently: other full-access clients that leave it off retain ordinary confirmation and session direct trading. The account-consent upgrade below requires all older grants to reconnect; grants issued under the current policy retain their security mode.
After GitHub numeric owner verification, enabling requires registration of the first Passkey, or verification with an existing owner Passkey. Disabling also requires an existing Passkey. All old grants must be successfully revoked before the new client policy and grant are issued. Policy versions prevent stale tokens from writing under an old policy.
Client OAuth approval can be completed from another owner-authenticated device at `/security/authorizations/:id`. The approving device only approves the frozen request; the original browser alone receives the OAuth callback. Reloading an already approved request on that browser resumes completion. Same-client completion is serialized, and invalidated requests receive no code. Failed completion cleans up only its own grants; if cleanup cannot be verified, that client remains blocked rather than permitting an overlapping authorization.
For enhanced clients, `prepare_trade` keeps the same input and returns `proposalId`, `approvalMethod: "passkey"`, `approvalUrl`, the frozen preview, digest and expiry. It returns no executable confirmation token or phrase. Open the URL in an external desktop or phone browser, authenticate with GitHub if needed, review every leg and the complete JSON, then click **批准并提交** to invoke WebAuthn. Successful verification immediately submits the frozen immediate order, or activates the frozen delayed task. Query `get_trade_proposal({ proposalId })` for status and execution outcome. Approving or replaying a proposal never submits it twice. An `EXECUTION_UNCERTAIN` result still requires manual order inspection.
The approval page also provides a red **Reject order** button below Passkey approval. It cancels only the pending draft and returns to `/dashboard` after cancellation succeeds, without invoking Passkey verification or submitting a Schwab request. Already submitted orders and activated tasks cannot be cancelled through this button.
Passkey approval authorizes the frozen proposal; it does not perform balance, notional-limit or wash-sale checks. Review the complete order independently before approving.
Every immediate PLACE/REPLACE and delayed task creation/modification requires Passkey approval; protected delayed tasks cannot be downgraded by modification from an ordinary client. Enhanced clients cannot enable or use session direct trading. Cancellation, delayed task cancellation and disabling an existing direct session retain their existing rules. The deployment killswitch also blocks browser approval and task activation.
Manage owner credentials at `/security`: register, name, add, rename and delete Passkeys shared by all enhanced clients. Adding, renaming or deleting requires an existing Passkey, and the final credential cannot be deleted directly. If **all credentials are lost**, open `/security/recover`. This always starts a separate GitHub owner OAuth flow; an MCP token or existing security browser session cannot reset credentials. Type **重置全部 Passkey** and register a replacement before the server atomically replaces all keys. Recovery invalidates old keys, browser sessions, challenges and pending enhanced proposals, while preserving activated delayed tasks and the enabled client policies. Disabling afterward still requires the replacement Passkey. **The GitHub account is the credential-recovery trust entry point.**
Proposals expire in five minutes, browser sessions in fifteen minutes, and single-use challenges in at most two minutes (capped by proposal/session expiry). Cancellation, timeout and unsupported browsers leave orders unapproved, with no conversational fallback. Native WebAuthn uses required user verification and the pinned [`@simplewebauthn/server@14.0.3`](https://simplewebauthn.dev/docs/packages/server). The configured Schwab callback **origin** is the expected origin and its hostname the RP ID; approval links must use this same origin. No app, notification service, CDN, frontend build or new Worker binding is required.
Credentials, policies and browser state live only in owner DO SQLite, using repeatable incremental migrations. No existing tables, Schwab tokens or trade history are rebuilt or deleted. This protects against compromised MCP tokens or clients; it does not promise resistance to compromise of the Worker, approval device or GitHub recovery account.
Local checks include real-signature software-authenticator tests, OAuth regressions, deployment/introspection checks and Wrangler dry-run packaging. Browser UI checks use controlled local fixtures; **actual Safari, phone and device Passkey operation remain manual release acceptance**. See [validation and manual acceptance](docs/passkey-validation.md).
## Owner dashboard
Open `/dashboard` on the configured Schwab callback origin. GitHub numeric owner login creates a fifteen-minute browser session; MCP Bearer tokens, consent-pending sessions and recovery sessions cannot administer the dashboard.
The dashboard's login/renew link uses `/dashboard/login` to start fresh GitHub owner OAuth, even with a valid session. Successful owner verification issues a new fifteen-minute session; refreshing `/dashboard` alone does not extend its expiry.
Browser pages support Simplified Chinese and English. Language selection takes precedence over the saved `schwab_lang` cookie, then the highest-quality supported `Accept-Language` entry; unsupported languages and entries with `q=0` are skipped, with Chinese as the fallback. Consent-page language links preserve the OAuth request parameters, including PKCE and client state.
After storing Schwab credentials, `/auth/schwab/callback` redirects with HTTP 303 to `/auth/schwab/complete`. This no-store completion page can be refreshed or translated without replaying the one-time OAuth callback. It acknowledges completion rather than querying current connection health; use the dashboard overview for current status.
The overview reports unexpired grants with a missing or outdated authorization policy version as requiring client reauthorization. These historical grants do not count as authorized clients or initialize client policies, and do not prevent current clients from loading. Malformed current-version permission metadata still fails explicitly; historical grants remain unusable for MCP access.
- **Clients**: inspect names, redirect URIs, grant dates and last calls; change read-only/full-access permissions or revoke all grants. SQLite policy is checked on every MCP request and token exchange; policy outages fail closed rather than restoring stale token privileges. Changes invalidate pending proposals, not already activated delayed tasks.
- **Advanced Security**: enabling and disabling require native Passkey verification bound to the client, desired mode and current policy version. First activation can register the first credential. Read-only downgrade preserves the hardware policy for a later upgrade. This never changes the deployment-controlled `ENABLE_TRADING` switch.
- **Audit**: filter tool calls by client, tool and status, with pagination, success/error totals and 24-hour activity. Records stay in owner SQLite, limited to thirty days and the newest 5,000 rows. Parameter summaries contain only allowlisted counts, booleans and enums (at most 512 UTF-8 bytes), not raw arguments, orders, credentials or upstream error text. Background persistence failures do not change tool results, so this is operational history, not a guaranteed compliance ledger.
- **Passkeys**: register, add, rename and delete using the existing `/security/api/*` ceremonies; deletion cannot remove the final credential. `/security/recover` remains the independent GitHub recovery entry.
- **Inbox**: review pending client authorizations and trade proposals, and open notification details even when the event is outside the current page. Opening a notification marks that event read.
- **App and push settings**: install the Dashboard as a PWA and manage optional Apple/FCM Web Push devices. Local test/disable controls resolve the current browser's exact subscription; endpoints and keys are not returned in device lists. A server-disabled subscription can be registered again from its original browser.
Unlinking a push device asks for device-specific confirmation and stops future notifications to that device; it does not delete a Passkey or require a Passkey ceremony.
The dashboard loads operational state and audit history on page load or explicit refresh. The Inbox also refreshes while visible and when a push message arrives. It does not provide a live JSON-RPC protocol stream or continuously verified hardware-readiness telemetry. An unavailable Service Worker disables push controls without blocking the rest of the dashboard.
Dashboard writes require same-origin JSON, the browser cookie and CSRF header, with an actual 64 KiB body limit. Pages and APIs are no-store and deny framing. Core dashboard operations need no new secrets, CDN or frontend dependencies; optional owner email uses a restricted `send_email` binding. Automated signatures and local runtime smoke do not replace final-origin Safari/mobile hardware acceptance.
Only public static assets are cached for offline use. Notifications, subscriptions and VAPID keys remain in owner SQLite; push payloads contain only event identifiers, types and timestamps. The unified alarm handles delivery, token-expiry reminders and delayed trades, including reminder scheduling for existing token records after upgrade. Retention removes completed deliveries before their parent events, and resolved notifications cannot be resurrected by a late retry.
## 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**:
- In ordinary mode, `prepare_trade` generates an immutable snapshot and a random confirmation sentence; enhanced mode uses browser Passkey approval as described above.
- The user must explicitly repeat the confirmation sentence before `confirm_trade` consumes the token and executes the order.
3. **Session Direct Trading Mode**:
- Available only in ordinary 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**: >= 22.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:
```bash
npm install
```
2. Configure local development variables:
```bash
cp .dev.vars.example .dev.vars
```
Fill in `.dev.vars` (ignored by git):
```ini
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:
```bash
npm run validate
```
*(Runs typecheck, lint, and Vitest suite under simulated Workers DO environments).*
---
## Production Deployment
### Step 1: Create KV & Set Secrets
```bash
# 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
```bash
npm run deploy
```
> [!NOTE]
> MCP authorization uses `AUTHORIZATION_VERSION = "client-accounts-v2"`. Older access tokens, refresh tokens and unexchanged codes are invalid. Reconnect each client, verify the GitHub owner, then select accounts. Schwab credentials remain connected. Older tasks without explicit account consent are retained as `PAUSED`.
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`](.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!**
Browser authorization starts use Workers Rate Limiting: 30 attempts per 60 seconds per connecting IP and Cloudflare location. Shared outbound IPs share this loose, eventually consistent allowance; it is not a global Schwab API quota. Rejections return `429 RATE_LIMITED` with `Retry-After: 60`; unavailable admission returns `503 UPSTREAM_UNAVAILABLE`. MCP, token exchange, registration, callbacks and authenticated dashboard APIs do not consume this allowance. Acceptance routes use a separate binding. Optional `PROD_BROWSER_AUTH_RATE_LIMIT_NAMESPACE_ID` / `PROD_ACCEPTANCE_BROWSER_AUTH_RATE_LIMIT_NAMESPACE_ID` override the two distinct positive-integer namespace IDs; no zone/WAF setup is required.
Workers Logs retain allowlisted application events (`mcp_tool_call`, `schwab_api_request`, admission failures) with request IDs, status and duration. Automatic Invocation Logs remain disabled. [Workers tracing](https://developers.cloudflare.com/workers/observability/traces/) is enabled with the default sampling rate of `1` (100%); `observability.redact_query_string: true` removes query strings from automatically captured request URLs in logs and traces, including OAuth callback codes. Traces capture platform operation metadata without adding message or tool payload recording. SQL traces include query text and the number of bound parameters, so sensitive values must remain bound parameters rather than SQL literals. This project is an MCP tool server, with no local agent loop or model calls to instrument; agent turns and session replay require instrumentation in the external agent client. After deployment, run a sampled request and inspect its trace in Workers Observability, including callback URL redaction. Source maps are uploaded with the Worker, not served as public assets. Application logs never include raw tool arguments, order bodies, credentials, addresses or upstream exception messages; they are short-term diagnostics, not a compliance archive.
### Step 4: Authorize Charles Schwab
Visit in your browser:
```text
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.
### Optional owner email alerts
Critical email is independent of Web Push: authorization due within 24 hours, reauthorization required, and `EXECUTION_UNCERTAIN` results. No success, routine failure or approval-request emails. The same owner SQLite/Alarm stores unique event deliveries, frozen addresses and fenced leases; no Queue/KV/R2 copy is added. Each delivery has at most three attempts (30-second then 120-second backoff); `SENT` means service acceptance, not inbox delivery. A crash after acceptance can cause a duplicate. Renewed authorization suppresses obsolete reminders; Passkey recovery does not disable the deployment-controlled owner mailbox.
Before enabling:
1. Choose a sender domain you control and one owner recipient. Authorize the correct Cloudflare account if the domain is not visible. Prefer an unused mail domain: [Email Routing onboarding](https://developers.cloudflare.com/email-service/get-started/route-emails/) can change MX/SPF/DKIM; do not overwrite an existing mailbox provider's MX.
2. Add the destination in Email Routing → Destination Addresses, then **personally open Cloudflare's verification email**. Use the [verified-destination route](https://developers.cloudflare.com/email-service/) rather than enabling arbitrary-recipient paid sending. Verified-destination sends are currently free; Worker/storage/log quotas still apply.
3. Set Actions **Variables** `PROD_ALERT_EMAIL_FROM`, `PROD_ALERT_EMAIL_TO` and `PROD_EMAIL_ALERTS_ENABLED=false`, then deploy. Both addresses must be present and valid; the generator restricts `OWNER_ALERT_EMAIL` to that recipient. No new Worker Secret, application-domain change, OAuth reauthorization or Passkey registration is needed. Do not commit generated personal addresses.
4. While signed into `/dashboard`, run this once in its same-origin browser console:
```js
const csrf = document.querySelector('meta[name="csrf"]').content;
await fetch('/dashboard/api/email/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: '{}',
}).then(r => r.json());
```
The UUID response means queued. TEST mail works while business alerts are disabled, has a one-minute cooldown and expires after one hour. The API cannot accept recipient/sender fields and is not an MCP tool. Check `email_alert_delivery` by `eventId` in application logs, then **personally confirm receipt**, including spam, sender and link. Only then set `PROD_EMAIL_ALERTS_ENABLED=true` and redeploy.
Mail contains only a fixed reason, event UUID, UTC time and the canonical dashboard notification link—never account/order details, credentials or approval capabilities. Disabling the flag or changing addresses suppresses outstanding business deliveries; TEST is the explicit exception. Removing both address variables while disabled removes the binding. With no mail configuration, the feature stays off and existing trading/push paths are unchanged.
Local Wrangler smoke uses a non-remote email binding and cannot prove inbox delivery. Production email remains pending until domain/destination verification and the human receipt check are complete.
---
## Client Configuration
MCP Endpoint: `https://<your-worker-domain>/mcp`
Every new MCP OAuth authorization requires [RFC 7636 S256 PKCE](https://www.rfc-editor.org/rfc/rfc7636). 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`:
```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
| Code | Cause | Action |
| :--- | :--- | :--- |
| `SECURITY_VERIFICATION_FAILED` | Passkey verification or browser binding failed | Restart browser verification; no conversational fallback |
| `SECURITY_POLICY_CHANGED` | Client policy version is stale | Reconnect this MCP client |
| `BROWSER_AUTH_REQUIRED` | Browser session expired or invalid | Restart GitHub owner login |
| `AUTH_REQUIRED` | Schwab account not linked | Visit `reconnectUrl` from response |
| `REAUTHORIZATION_REQUIRED` | Refresh token expired (7 days inactivity) | Visit `reconnectUrl` to re-authenticate |
| `INVALID_ARGUMENT` | Payload failed Zod validation | Review input against [`src/schemas.ts`](src/schemas.ts) |
| `RATE_LIMITED` | Schwab API rate limit reached (HTTP 429) | Back off following `retryAfterMs` |
| `SCHWAB_REJECTED` | Upstream business rejection | Check ticker, order status, or market hours |
| `UPSTREAM_UNAVAILABLE` | Schwab gateway or Durable Object error | Retry request (read requests retry once automatically) |
| `CONFIRMATION_INVALID` | Confirmation token mismatch or incorrect phrase | Re-run confirmation with exact text |
| `CONFIRMATION_EXPIRED` | Token exceeded 5-minute validity | Call `prepare_trade` again |
| `TRADE_STATE_CONFLICT` | Proposal already consumed or task cancelled | Refresh state with `list_delayed_trades` |
| `DIRECT_TRADING_SESSION_INVALID` | Session expired (30m idle / 8h max) | Call `enable_session_direct_trading` |
| `EXECUTION_UNCERTAIN` | Network failure during order submission | **Do not blindly retry.** Verify via `get_orders` |
### Same-Worker OAuth acceptance
The optional, access-key-protected `/__test/` routes exercise the deployed OAuth flow with isolated KV/SQLite state, mocked GitHub and a software authenticator. They cannot access brokerage credentials, accounts or trading. Production routes retain real GitHub authentication. See [OAuth navigation validation](docs/oauth-navigation-validation.md#same-worker-cloud-acceptance-2026-10-16) for setup, cross-origin checks, results and shutdown.
### Real-Time Log Monitoring
Stream sanitized structured JSON logs via Cloudflare Workers Observability:
```bash
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](LICENSE).
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessNo recent community issues