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
10 changes: 8 additions & 2 deletions tastytrade-mcp/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
{
"name": "tastytrade-mcp",
"version": "0.1.0",
"version": "0.2.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"]
"keywords": [
"tastytrade",
"brokerage",
"trading",
"mcp",
"market-data"
]
}
11 changes: 11 additions & 0 deletions tastytrade-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,19 @@ touch ~/.tastytrade-mcp/credentials.json && chmod 600 ~/.tastytrade-mcp/credenti
}
```

The environment-variable spellings work too, so a file written from your `.env` needs no renaming:

| Canonical | Also accepted |
|-----------|---------------|
| `client_id` | `TT_CLIENT_ID` |
| `client_secret` | `TT_SECRET` |
| `refresh_token` | `TT_REFRESH` |
| `base_url` | `API_BASE_URL` |

`base_url` is optional and defaults to `api.tastyworks.com`.

There is no username or password: this server authenticates with the OAuth refresh-token grant only. `TT_USERNAME` and `TT_PASSWORD` keys are ignored, so delete them rather than leaving secrets at rest for nothing to read.

**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
Expand Down
51 changes: 44 additions & 7 deletions tastytrade-mcp/src/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@
"refresh_token": "TT_REFRESH",
}

# Accepted JSON key spellings per field, in preference order. The TT_* forms are
# accepted because that is how these values are named everywhere else (the
# environment, .env.example, the Tastytrade docs), so a file written from any of
# those is the obvious thing to produce and must not be rejected.
FILE_KEYS: dict[str, tuple[str, ...]] = {
"client_id": ("client_id", "TT_CLIENT_ID"),
"client_secret": ("client_secret", "TT_SECRET"),
"refresh_token": ("refresh_token", "TT_REFRESH"),
}

BASE_URL_KEYS = ("base_url", "API_BASE_URL")

# Read by nothing: the OAuth refresh-token grant needs no username or password.
# Named here only so the error path can point out that storing them is pointless.
UNUSED_KEYS = ("TT_USERNAME", "TT_PASSWORD", "username", "password")


class CredentialsError(RuntimeError):
"""Raised when no usable credential set can be resolved."""
Expand Down Expand Up @@ -108,17 +124,38 @@ def resolve_credentials(path: Path | None = None) -> Credentials:
raise CredentialsError(
f"No Tastytrade credentials found. Create {path} containing "
'{"client_id": "...", "client_secret": "...", "refresh_token": "..."} '
"(the TT_CLIENT_ID / TT_SECRET / TT_REFRESH spellings work too) "
"with mode 600, or set TT_CLIENT_ID, TT_SECRET and TT_REFRESH."
)

missing = sorted(set(ENV_VARS) - set(data))
if missing:
raise CredentialsError(f"{path} is missing required key(s): {', '.join(missing)}.")
values: dict[str, str] = {}
missing_keys: list[str] = []
for field, keys in FILE_KEYS.items():
found = next((data[k] for k in keys if k in data), None)
if found is None:
missing_keys.append(" or ".join(keys))
else:
values[field] = found

if missing_keys:
note = ""
if any(k in data for k in UNUSED_KEYS):
# Worth saying: the OAuth refresh-token grant never reads these, so
# they are secrets sitting at rest for no reason.
note = (
" Note that any username/password keys in the file are unused: "
"this server authenticates with the OAuth refresh-token grant only, "
"so you can safely delete them."
)
raise CredentialsError(
f"{path} is missing required key(s): {'; '.join(missing_keys)}. Found: {', '.join(sorted(data))}.{note}"
)

file_base_url = next((data[k].strip() for k in BASE_URL_KEYS if data.get(k, "").strip()), "")
return Credentials(
client_id=data["client_id"],
client_secret=data["client_secret"],
refresh_token=data["refresh_token"],
base_url=data.get("base_url", "").strip() or os.environ.get("API_BASE_URL", "").strip() or DEFAULT_BASE_URL,
client_id=values["client_id"],
client_secret=values["client_secret"],
refresh_token=values["refresh_token"],
base_url=file_base_url or os.environ.get("API_BASE_URL", "").strip() or DEFAULT_BASE_URL,
source=str(path),
)
45 changes: 45 additions & 0 deletions tastytrade-mcp/tests/unit/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
pytestmark = pytest.mark.unit

COMPLETE = {"client_id": "cid", "client_secret": "sec", "refresh_token": "ref"}
# The same values under the environment-variable spellings, which is how these
# are named everywhere else and therefore what people actually write down.
COMPLETE_TT = {"TT_CLIENT_ID": "cid", "TT_SECRET": "sec", "TT_REFRESH": "ref"}


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -167,3 +170,45 @@ async def tool() -> str:
payload = json.loads(await tool())
assert payload["error"] == "ZeroDivisionError: boom"
assert "Traceback" not in json.dumps(payload)


def test_accepts_tt_prefixed_key_spellings(tmp_path):
"""A file written with the env-var names must work, not be rejected.

This is how the file is most naturally produced: those are the names used in
.env.example, the environment, and the Tastytrade docs.
"""
path = write_creds(tmp_path, {**COMPLETE_TT, "API_BASE_URL": "tt.example.com"})

creds = resolve_credentials(path)

assert (creds.client_id, creds.client_secret, creds.refresh_token) == ("cid", "sec", "ref")
assert creds.base_url == "tt.example.com"


def test_snake_case_wins_when_both_spellings_are_present(tmp_path):
path = write_creds(tmp_path, {**COMPLETE, **{k: "tt-" + v for k, v in COMPLETE_TT.items()}})
assert resolve_credentials(path).client_id == "cid"


def test_unused_username_password_keys_are_ignored(tmp_path):
"""The refresh-token grant needs no username or password; extras must not break it."""
path = write_creds(tmp_path, {**COMPLETE_TT, "TT_USERNAME": "u", "TT_PASSWORD": "p"})
assert resolve_credentials(path).client_id == "cid"


def test_missing_key_error_names_both_spellings_and_what_was_found(tmp_path):
path = write_creds(tmp_path, {"TT_USERNAME": "SECRET_USER", "TT_PASSWORD": "SECRET_PASS"})

with pytest.raises(CredentialsError) as exc:
resolve_credentials(path)

message = str(exc.value)
# Both spellings named, so the fix is obvious either way.
assert "TT_CLIENT_ID" in message and "client_id" in message
# What was actually in the file, so a key-name mismatch is diagnosable.
assert "Found: TT_PASSWORD, TT_USERNAME" in message
assert "safely delete them" in message
# Key names appear; values must not.
assert "SECRET_USER" not in message
assert "SECRET_PASS" not in message
Loading