Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
"name": "harbor-mcp",
"source": "./harbor-mcp",
"description": "Inspect Harbor hub evaluation jobs, trials, and uploads from Claude Code, and publish tasks, datasets, and job archives when writes are enabled."
},
{
"name": "tastytrade-mcp",
"source": "./tastytrade-mcp",
"description": "Inspect TastyTrade brokerage accounts, positions, market data, option chains, and transactions from Claude Code. Order placement is gated off by default."
}
]
}
52 changes: 33 additions & 19 deletions .github/workflows/plugin-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
pull_request:
paths:
- "harbor-mcp/**"
- "tastytrade-mcp/**"
- ".github/workflows/plugin-version.yml"

jobs:
Expand All @@ -23,26 +24,39 @@ jobs:
- name: Require a version bump when plugin files change
env:
BASE: ${{ github.event.pull_request.base.sha }}
MANIFEST: harbor-mcp/.claude-plugin/plugin.json
run: |
set -euo pipefail

# Only the shipped plugin payload matters. Tests and evals are not
# copied into the plugin cache, so changing them cannot strand a user.
if ! git diff --name-only "$BASE"...HEAD -- harbor-mcp \
':(exclude)harbor-mcp/tests/**' \
':(exclude)harbor-mcp/evals/**' | grep -q .; then
echo "No shipped plugin files changed; version bump not required."
exit 0
fi

read_version() { python3 -c "import json,sys;print(json.load(sys.stdin)['version'])"; }
base_version="$(git show "$BASE:$MANIFEST" | read_version)"
head_version="$(read_version < "$MANIFEST")"

echo "base=$base_version head=$head_version"
if [ "$base_version" = "$head_version" ]; then
echo "::error file=$MANIFEST::Shipped plugin files changed but version is still $head_version. Bump it, or installed copies will never pick this up."
exit 1
fi
echo "Version bumped $base_version -> $head_version."
status=0

# Discovered rather than listed, so a newly added plugin is gated
# without editing this workflow.
for manifest in */.claude-plugin/plugin.json; do
plugin="${manifest%%/*}"

# Only the shipped payload matters. Tests and evals are not copied
# into the plugin cache, so changing them cannot strand a user.
if ! git diff --name-only "$BASE"...HEAD -- "$plugin" \
":(exclude)$plugin/tests/**" \
":(exclude)$plugin/evals/**" | grep -q .; then
echo "$plugin: no shipped files changed, version bump not required."
continue
fi

head_version="$(read_version < "$manifest")"
if ! git cat-file -e "$BASE:$manifest" 2>/dev/null; then
echo "$plugin: new plugin at $head_version, nothing to compare against."
continue
fi
base_version="$(git show "$BASE:$manifest" | read_version)"

if [ "$base_version" = "$head_version" ]; then
echo "::error file=$manifest::Shipped files in $plugin changed but version is still $head_version. Bump it, or installed copies will never pick this up."
status=1
else
echo "$plugin: version bumped $base_version -> $head_version."
fi
done

exit "$status"
12 changes: 12 additions & 0 deletions tastytrade-mcp/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "tastytrade-mcp",
"version": "0.1.0",
"description": "MCP server for the TastyTrade Open API: brokerage accounts, positions, market data, option chains, transactions, and order preview.",
"author": {
"name": "Walker Hughes"
},
"license": "MIT",
"homepage": "https://github.com/walkerhughes/claude/tree/main/tastytrade-mcp",
"repository": "https://github.com/walkerhughes/claude",
"keywords": ["tastytrade", "brokerage", "trading", "mcp", "market-data"]
}
11 changes: 10 additions & 1 deletion tastytrade-mcp/.env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
# OAuth credentials for the Tastytrade Open API
# Optional. Credentials normally live in ~/.tastytrade-mcp/credentials.json;
# this file is for CI and scripting, where an environment is easier to inject.
#
# Nothing sources this automatically. Export what you need, e.g.
# set -a && source .env && set +a
#
# All three must be set together. A partially set environment is a hard error,
# not a silent fallthrough to the credentials file.
TT_CLIENT_ID="<your TT_CLIENT_ID here>"
TT_SECRET="<your TT_SECRET here>"
TT_REFRESH="<your TT_REFRESH here>"

# Optional; defaults to api.tastyworks.com.
API_BASE_URL="api.tastyworks.com"

# Trading safety gate. Leave false (or unset) for a preview-only server.
Expand Down
11 changes: 5 additions & 6 deletions tastytrade-mcp/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
"tastytrade": {
"type": "stdio",
"command": "bash",
"args": [
"-c",
"set -a && source .env && set +a && exec uv run python -m src.server"
],
"env": {}
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/start-server.sh"],
"env": {
"TT_ENABLE_TRADING": "${TT_ENABLE_TRADING:-}"
}
}
}
}
}
65 changes: 49 additions & 16 deletions tastytrade-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,34 +50,67 @@ for the full reasoning.
- **Deterministic mock API** for tests and evals: [`tests/fixtures/mock_api/`](tests/fixtures/mock_api)
- **Agent-loop evals** through Harbor: [`evals/`](evals)

## Getting Started
## Install as a Claude Code plugin

### 1. Install dependencies
Two separate commands. The first opens a prompt that expects only the `owner/repo`, so do not paste both lines at once:

```bash
uv sync
```
/plugin marketplace add walkerhughes/claude
```

### 2. Configure credentials
```
/plugin install tastytrade-mcp
```

Copy `.env.example` to `.env` and fill in your TastyTrade API credentials:
Requires [`uv`](https://docs.astral.sh/uv/) on your PATH. The first launch builds the server's environment, which takes a few seconds before the tools appear.

```bash
cp .env.example .env
```
## Credentials

Unlike Harbor, TastyTrade has no `auth login` command, so this file is created by hand once. Getting the values is the involved part:

1. Register an OAuth application in the TastyTrade developer portal. That yields a **client id** and a **client secret**.
2. Complete the authorization flow for your account to obtain a **refresh token**. This server uses the refresh-token grant, so the refresh token is the long-lived credential; there is no username or password anywhere.

You'll need a registered OAuth client from TastyTrade. Set these values in `.env`:
Then write them to `~/.tastytrade-mcp/credentials.json`:

```bash
mkdir -p ~/.tastytrade-mcp && chmod 700 ~/.tastytrade-mcp
touch ~/.tastytrade-mcp/credentials.json && chmod 600 ~/.tastytrade-mcp/credentials.json
```
TT_CLIENT_ID=<your client id>
TT_SECRET=<your client secret>
TT_REFRESH=<your refresh token>
API_BASE_URL=api.tastyworks.com

```json
{
"client_id": "...",
"client_secret": "...",
"refresh_token": "...",
"base_url": "api.tastyworks.com"
}
```

### 3. Run with Claude Code
`base_url` is optional and defaults to `api.tastyworks.com`.

The `.mcp.json` is already configured. Start Claude Code from the project directory and the TastyTrade MCP server will be available automatically.
**The server refuses to load this file if it is readable by group or others**, and tells you to `chmod 600`. Together the secret and refresh token grant full account access, including order placement when trading is enabled.

### Precedence

| | Source |
|---|--------|
| 1 | `TT_CLIENT_ID`, `TT_SECRET`, `TT_REFRESH` in the environment, when **all three** are set |
| 2 | `~/.tastytrade-mcp/credentials.json` |

Whole-source, not per-field: a file `client_id` paired with an environment `refresh_token` would fail authentication in a way that looks like a revoked token rather than a misconfiguration. A *partially* set environment is a hard error rather than a silent fallthrough to the file, for the same reason.

The environment path exists for CI and scripting. Interactively, use the file.

## Trading gate

Read tools work with any valid credentials. `place_order` refuses unless `TT_ENABLE_TRADING=true` is set in the server environment, and then still requires `confirm=true` on each call. Preview-only is the default; leave it that way unless you intend to place live orders.

## Local development

```bash
uv sync
```

## Example

Expand Down
58 changes: 58 additions & 0 deletions tastytrade-mcp/scripts/start-server.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Launch the tastytrade MCP server.
#
# Exists because "command": "uv" in .mcp.json assumes uv is on whatever PATH the
# MCP client spawns with. It often is not: a Homebrew uv lives in
# /opt/homebrew/bin, which is missing from the minimal PATH some launch contexts
# provide, and the failure surfaces only as an opaque JSON-RPC -32000.
#
# Diagnostics go to stderr. stdout is the JSON-RPC channel and must stay clean.
set -euo pipefail

# Resolve the plugin root from this script's own location rather than
# CLAUDE_PLUGIN_ROOT, so it works however the server is launched.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

find_uv() {
if command -v uv >/dev/null 2>&1; then
command -v uv
return
fi
# ponytail: fixed candidate list beats parsing shell profiles. Covers Homebrew
# on Apple Silicon and Intel, the standalone installer, and cargo install.
local candidate
for candidate in \
/opt/homebrew/bin/uv \
/usr/local/bin/uv \
"$HOME/.local/bin/uv" \
"$HOME/.cargo/bin/uv"
do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return
fi
done
return 1
}

if ! UV="$(find_uv)"; then
echo "tastytrade-mcp: cannot find the 'uv' executable." >&2
echo " Searched PATH plus /opt/homebrew/bin, /usr/local/bin, ~/.local/bin, ~/.cargo/bin." >&2
echo " Install it (https://docs.astral.sh/uv/) or symlink it into /usr/local/bin." >&2
exit 127
fi

# Drop any inherited VIRTUAL_ENV. uv already ignores a mismatched one, but it
# warns loudly on stderr about "does not match the project environment path",
# which reads like the cause of a failure.
unset VIRTUAL_ENV

# cd into the project rather than using the `tastytrade-mcp` console script.
# That script is currently broken: pyproject flattens src/ via
# sources = { "src" = "" }, but every internal import is relative, so the
# installed package cannot import itself and the entry point still names
# `src.server`. Running as a module from the project root is the only working
# invocation. Credentials resolve from ~/.tastytrade-mcp/credentials.json or the
# environment, both absolute, so this cd does not affect them.
cd "$ROOT"
exec "$UV" run --project "$ROOT" python -m src.server
19 changes: 14 additions & 5 deletions tastytrade-mcp/src/client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""TastyTrade API client with OAuth2 authentication."""

import asyncio
import os
import time

import httpx

from .credentials import DEFAULT_BASE_URL, resolve_credentials
from .infra.logging import get_logger


Expand All @@ -24,13 +24,22 @@ def __init__(
refresh_token: str | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
base = base_url or os.environ.get("API_BASE_URL", "api.tastyworks.com")
# Tests pass all three explicitly, so credentials are only resolved when
# something is missing. That keeps the mock-API suite from needing a
# credentials file or environment at all.
if client_id and client_secret and refresh_token:
resolved = None
else:
resolved = resolve_credentials()

base = base_url or (resolved.base_url if resolved else None) or DEFAULT_BASE_URL
if not base.startswith("http"):
base = f"https://{base}"
self.base_url = base
self._client_id = client_id or os.environ["TT_CLIENT_ID"]
self._client_secret = client_secret or os.environ["TT_SECRET"]
self._refresh_token = refresh_token or os.environ["TT_REFRESH"]
self._client_id = client_id or (resolved.client_id if resolved else "")
self._client_secret = client_secret or (resolved.client_secret if resolved else "")
self._refresh_token = refresh_token or (resolved.refresh_token if resolved else "")
self.credential_source = resolved.source if resolved else "explicit"
self._access_token: str | None = None
self._token_expires_at: float = 0
self.customer_id: str | None = None
Expand Down
Loading
Loading