trading-mcp
README.md
# Trading MCP Server (JWT-authenticated)
A small but production-shaped [Model Context Protocol](https://modelcontextprotocol.io)
server that Claude can connect to and drive in natural language. It exposes a
five-tool **paper-trading** domain, and **every tool call is gated behind JWT
authentication** that is verified on each invocation — not once at connection time.
The trading domain was chosen deliberately: because holdings, cash, and orders
are strictly per-user, the auth layer is doing real work on every call, which
makes it a more meaningful demonstration of authentication than a shared or
read-only dataset would be.
---
## Tools
| Tool | Auth required | Purpose |
|------|:---:|---------|
| `login(username, password)` | no | Validate credentials, return a signed JWT. |
| `get_quote(token, symbol)` | yes | Current simulated price + day change for a symbol. |
| `place_order(token, symbol, side, quantity, order_type)` | yes | Execute a simulated market buy/sell against the user's own portfolio. |
| `get_portfolio(token)` | yes | Cash, positions, and live unrealized P&L. |
| `get_order_history(token)` | yes | The user's past executed orders. |
Market prices are **simulated** from a seeded local table — no external API key,
fully reproducible. See [Assumptions & trade-offs](#assumptions--trade-offs).
### Demo accounts
| Username | Password |
|----------|----------|
| `demo` | `demo123` |
| `alice` | `alice123` |
Each user has their own cash, holdings, and history — useful for confirming that
one user's token can never see another's data.
---
## Setup
Requires Python 3.10+. A recent-but-not-bleeding-edge version (3.11–3.13) is
recommended; see the note under [Connecting to Claude Desktop](#connecting-to-claude-desktop)
if you are on 3.14.
```bash
git clone <your-repo-url>
cd trading-mcp
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install -r requirements.txt
```
### Set the signing secret
The JWT signing secret is read from the environment and is **never** committed.
The server refuses to start without it.
```bash
cp .env.example .env
# then edit .env, or generate a strong secret in one line:
echo "JWT_SECRET=$(python -c 'import secrets;print(secrets.token_hex(32))')" > .env
```
---
## Verify it works (before touching Claude)
Two independent checks:
```bash
# 1. Unit tests: auth core + tool behavior
python -m pytest -q
# 2. End-to-end: launches the server over the real stdio MCP protocol,
# lists tools, and exercises login + a protected call.
export JWT_SECRET="$(python -c 'import secrets;print(secrets.token_hex(32))')"
python smoke_test.py
```
`smoke_test.py` printing `ALL SMOKE CHECKS PASSED` means the server is wired
correctly and will connect to any MCP client.
---
## Connecting to Claude Desktop
1. Find the absolute path to your virtualenv's Python and to `server.py`:
```bash
echo "$(pwd)/.venv/bin/python"
echo "$(pwd)/server.py"
```
2. In Claude Desktop: **Settings → Developer → Edit Config**, and add:
```json
{
"mcpServers": {
"trading-mcp": {
"command": "/ABSOLUTE/PATH/TO/trading-mcp/.venv/bin/python",
"args": ["/ABSOLUTE/PATH/TO/trading-mcp/server.py"],
"env": {
"JWT_SECRET": "paste-a-long-random-secret-here"
}
}
}
}
```
3. **Fully quit** Claude Desktop (Cmd+Q / quit, not just close the window) and reopen it.
The server should show as running under Settings → Developer.
### Important: use the venv's Python by absolute path
`command` **must** point at the virtualenv's Python (`.venv/bin/python`), not a
bare `python` / `python3`. The client resolves a bare command against your system
`PATH` and pins the result — on a machine whose default is a Python without the
`mcp` package installed, the server starts and immediately exits with
`ModuleNotFoundError: No module named 'mcp'`. An absolute venv path has nothing
to resolve, so it is used as-is.
> **Python 3.14 note:** some dependencies may not yet ship prebuilt wheels for
> 3.14, so `pip install` can fail while building them. If so, create the venv
> with `python3.12 -m venv .venv` (or 3.13) and reinstall — everything else is
> identical.
---
## Using it
Because the token is passed as a normal tool argument, you drive everything in
plain English and Claude handles the JSON:
- *"Log into my trading account, username demo, password demo123."* → `login`
- *"What's my portfolio?"* → `get_portfolio`
- *"What's Apple trading at?"* → `get_quote`
- *"Buy 15 shares of Nvidia."* → `place_order`
- *"Show my order history."* → `get_order_history`
To see authentication reject an unauthenticated call, start a **fresh chat** and
ask for your portfolio *before* logging in — the tool returns a `missing_token`
error and never runs its logic.
---
## Authentication design
### How the token flows
`login` verifies credentials and returns a signed JWT. **Every other tool takes
that `token` as a required parameter and verifies it as its first action.** If
verification fails, the tool returns a structured error and never touches the
data layer.
Why a per-call token parameter rather than a session or a transport auth header:
- **It makes "verify on every call" literal and auditable.** The security
boundary is one line at the top of each tool (`_authenticate(token)`); there is
no connection-level trust to reason about.
- **stdio has no per-request headers.** Unlike HTTP, the stdio transport can't
carry an `Authorization` header per call, so the token rides in the tool
arguments.
- **It's stateless and portable.** No server-side session store to manage or
leak, and the exact same server works unchanged from any MCP client and would
work over an HTTP transport too.
### What "verify" means here
Verification uses `jwt.decode(token, SECRET, algorithms=["HS256"])`, which checks
the **signature** and the **`exp` (expiry)** claim — it does not merely decode the
payload. Each failure mode returns a distinct, structured error:
| Situation | Error code |
|-----------|-----------|
| No token supplied | `missing_token` |
| Not a readable JWT | `malformed_token` |
| Signature doesn't match our secret (incl. forged tokens) | `invalid_signature` |
| Past its expiry | `token_expired` |
| Valid but missing `sub` | `invalid_token` |
### Other security properties
- **Secret management.** `JWT_SECRET` comes from the environment (`.env` locally,
which is git-ignored). The server raises on startup if it is unset.
- **Per-user isolation.** The username is read from the verified token's `sub`
claim, never from a caller-supplied argument. Every data-layer query is scoped
by that username, so a valid token for `demo` can only ever act on `demo`'s data.
- **Password storage.** Passwords are bcrypt-hashed; plaintext is never stored.
- **No user enumeration.** `login` returns the same error for a wrong password and
a non-existent user.
### Layout
```
auth.py JWT sign/verify + bcrypt password hashing (the entire security surface)
db.py SQLite schema, seed data, and queries (knows nothing about auth or MCP)
server.py MCP tool definitions; the only place auth + data meet
```
`auth.py` is intentionally domain-agnostic — it contains no trading logic at all.
---
## Testing
```bash
python -m pytest -q
```
- `tests/test_auth.py` — the JWT core: password hashing, valid tokens, and every
rejection path (missing, malformed, tampered, forged-with-wrong-secret, expired).
- `tests/test_tools.py` — login, auth-gating on protected tools, successful
authenticated trades, and graceful handling of bad input / domain violations.
---
## Assumptions & trade-offs
Reasonable scope decisions for a take-home; called out for transparency.
- **Simulated market data.** Prices come from a seeded SQLite table (with a small
per-symbol day change), not a live feed. This keeps the project reproducible and
key-free. A real feed could be added behind an env flag without changing any tool
or auth code.
- **stdio transport.** Chosen per the brief (HTTP/SSE is a bonus). With stdio,
each user runs their own local instance, so the SQLite store is per-machine. A
multi-user, internet-facing deployment would use the HTTP transport plus a shared
database.
- **No token revocation / refresh.** Tokens are valid until they expire (default
60 min). Revocation lists and refresh tokens were out of scope.
- **Market orders only, filled at the current price.** No limit orders, slippage,
or realized-P&L tracking; unrealized P&L is computed live in `get_portfolio`.
### With more time
Remote HTTP transport + OAuth for use as a shared connector (incl. claude.ai
Custom Connectors), token refresh/revocation, rate limiting on `login`, and
limit-order support.
---
## Demo
<img width="1144" height="896" alt="Screenshot 2026-09-07 at 10 11 36" src="https://github.com/user-attachments/assets/bc649b28-64f5-4136-b7a9-a17e3bc54e20" />
<img width="1144" height="896" alt="Screenshot 2026-09-07 at 10 07 58" src="https://github.com/user-attachments/assets/6040183d-146b-4502-ba1e-63db1bfab2c3" />
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues