From 90f0ad8b4f95c0f4c66845eda69d0dffa5028ab9 Mon Sep 17 00:00:00 2001 From: Walker Hughes <74113220+walkerhughes@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:44:08 -0700 Subject: [PATCH] fix(tastytrade): accept the TT_* key spellings in credentials.json The loader only accepted snake_case keys (client_id, client_secret, refresh_token), but the natural thing to write is the TT_* names, which is what .env.example, the environment, and the Tastytrade docs all use. A file holding TT_CLIENT_ID / TT_SECRET / TT_REFRESH was reported as missing all three credentials, which reads as "no credentials" rather than "wrong key names". - Both spellings are accepted per field, snake_case preferred when both appear. base_url likewise accepts API_BASE_URL. - The missing-key error now names both spellings and lists which keys the file actually contained, so a naming mismatch is diagnosable from the message. - TT_USERNAME and TT_PASSWORD are ignored: the OAuth refresh-token grant reads neither. When they are the only keys present, the error says so, since they are otherwise secrets at rest that nothing reads. Verified against a real credentials.json using the TT_* names: it resolves, and a live read-only list_accounts through the plugin launcher returns the account. Values are never echoed in any error; a test asserts that with distinctive sentinel values. Version 0.2.0 so installed copies pick this up. --- tastytrade-mcp/.claude-plugin/plugin.json | 10 +++- tastytrade-mcp/README.md | 11 ++++ tastytrade-mcp/src/credentials.py | 51 ++++++++++++++++--- tastytrade-mcp/tests/unit/test_credentials.py | 45 ++++++++++++++++ 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/tastytrade-mcp/.claude-plugin/plugin.json b/tastytrade-mcp/.claude-plugin/plugin.json index e6d9008..a7e9dd8 100644 --- a/tastytrade-mcp/.claude-plugin/plugin.json +++ b/tastytrade-mcp/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "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" @@ -8,5 +8,11 @@ "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" + ] } diff --git a/tastytrade-mcp/README.md b/tastytrade-mcp/README.md index cec7e70..869dc26 100644 --- a/tastytrade-mcp/README.md +++ b/tastytrade-mcp/README.md @@ -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 diff --git a/tastytrade-mcp/src/credentials.py b/tastytrade-mcp/src/credentials.py index 7e36afc..93b43ae 100644 --- a/tastytrade-mcp/src/credentials.py +++ b/tastytrade-mcp/src/credentials.py @@ -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.""" @@ -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), ) diff --git a/tastytrade-mcp/tests/unit/test_credentials.py b/tastytrade-mcp/tests/unit/test_credentials.py index 42bad1c..bf696b5 100644 --- a/tastytrade-mcp/tests/unit/test_credentials.py +++ b/tastytrade-mcp/tests/unit/test_credentials.py @@ -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) @@ -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