Skip to main content
Glama
Tejas-gaikwad

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" />

```