From bf499df44ba82c466628193c824e39fd02990154 Mon Sep 17 00:00:00 2001 From: Walker Hughes <74113220+walkerhughes@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:19:54 -0700 Subject: [PATCH] feat(tastytrade): ship as a plugin, with credentials in ~/.tastytrade-mcp Credentials now resolve from ~/.tastytrade-mcp/credentials.json, mirroring how the harbor CLI stores its key, so the server no longer depends on a .env in the client's working directory. Credentials (src/credentials.py): - Precedence is whole-source, not per-field: the environment when all three of TT_CLIENT_ID/TT_SECRET/TT_REFRESH are set, otherwise the file. Mixing a file client_id with an environment refresh_token would fail auth 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, because that fallthrough is invisible and the resulting 401 is misleading. This session already lost time to exactly that shape of bug. - Refuses a file readable by group or others, with a chmod remediation. The secret and refresh token together grant full account access, including order placement when TT_ENABLE_TRADING is true. - client.py previously did os.environ["TT_CLIENT_ID"], so a missing credential raised a bare KeyError. Tests that pass all three explicitly still skip resolution entirely, so the mock-API suite needs no credentials. Plugin: - .claude-plugin/plugin.json, marketplace entry, and scripts/start-server.sh reusing the launcher proven in #5: uv discovery across common install locations, VIRTUAL_ENV dropped, plugin root resolved from the script's own location, and a bare ${CLAUDE_PLUGIN_ROOT} with no :- default. - The launcher cds into the project and runs `python -m src.server` rather than the tastytrade-mcp console script, which is broken: pyproject flattens src/ via sources = { "src" = "" } while every internal import is relative, so an installed wheel cannot import itself (attempted relative import beyond top-level package) and the entry point still names src.server. Pre-existing and tracked separately; the launcher works around it. Error handling: - guarded_tool had no catch-all, so its docstring promise that a tool never hands back a traceback was false for anything outside four exception types. Added CredentialsError handling and a last-resort Exception clause. - The 401 guidance named only the environment variables. It now explains that resolved-but-rejected means stale rather than missing, names both sources, and says not to ask the user to paste a secret into the chat. CI: - plugin-version now discovers every */.claude-plugin/plugin.json instead of hardcoding harbor-mcp, so a new plugin is gated without editing it, and a brand-new manifest is skipped rather than failing. - tests/unit/test_plugin_config.py mirrors harbor's guard: no :- default, the launcher exists and is executable, no `source .env`, no credential named in the plugin config, and a semver version. Verified: ruff and mypy clean; 90 unit and 102 total tests pass at 92.91% coverage; the 12 eval verifiers still pass. The launcher completes an MCP handshake and lists all 12 tools from an unrelated cwd, and under `env -i PATH=/usr/bin:/bin`, the case that produced -32000. With no credentials anywhere, a tool call returns the structured error with suggestions rather than a traceback. --- .claude-plugin/marketplace.json | 5 + .github/workflows/plugin-version.yml | 52 ++++-- tastytrade-mcp/.claude-plugin/plugin.json | 12 ++ tastytrade-mcp/.env.example | 11 +- tastytrade-mcp/.mcp.json | 11 +- tastytrade-mcp/README.md | 65 +++++-- tastytrade-mcp/scripts/start-server.sh | 58 ++++++ tastytrade-mcp/src/client.py | 19 +- tastytrade-mcp/src/credentials.py | 124 +++++++++++++ tastytrade-mcp/src/infra/errors.py | 19 +- tastytrade-mcp/tests/unit/test_credentials.py | 169 ++++++++++++++++++ .../tests/unit/test_plugin_config.py | 65 +++++++ 12 files changed, 561 insertions(+), 49 deletions(-) create mode 100644 tastytrade-mcp/.claude-plugin/plugin.json create mode 100755 tastytrade-mcp/scripts/start-server.sh create mode 100644 tastytrade-mcp/src/credentials.py create mode 100644 tastytrade-mcp/tests/unit/test_credentials.py create mode 100644 tastytrade-mcp/tests/unit/test_plugin_config.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2587036..dbf18e4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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." } ] } diff --git a/.github/workflows/plugin-version.yml b/.github/workflows/plugin-version.yml index ede00a8..f7eda3b 100644 --- a/.github/workflows/plugin-version.yml +++ b/.github/workflows/plugin-version.yml @@ -10,6 +10,7 @@ on: pull_request: paths: - "harbor-mcp/**" + - "tastytrade-mcp/**" - ".github/workflows/plugin-version.yml" jobs: @@ -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" diff --git a/tastytrade-mcp/.claude-plugin/plugin.json b/tastytrade-mcp/.claude-plugin/plugin.json new file mode 100644 index 0000000..e6d9008 --- /dev/null +++ b/tastytrade-mcp/.claude-plugin/plugin.json @@ -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"] +} diff --git a/tastytrade-mcp/.env.example b/tastytrade-mcp/.env.example index 379ada1..9627af5 100644 --- a/tastytrade-mcp/.env.example +++ b/tastytrade-mcp/.env.example @@ -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="" TT_SECRET="" TT_REFRESH="" + +# Optional; defaults to api.tastyworks.com. API_BASE_URL="api.tastyworks.com" # Trading safety gate. Leave false (or unset) for a preview-only server. diff --git a/tastytrade-mcp/.mcp.json b/tastytrade-mcp/.mcp.json index eb25668..34721a8 100644 --- a/tastytrade-mcp/.mcp.json +++ b/tastytrade-mcp/.mcp.json @@ -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:-}" + } } } -} \ No newline at end of file +} diff --git a/tastytrade-mcp/README.md b/tastytrade-mcp/README.md index b9783fb..cec7e70 100644 --- a/tastytrade-mcp/README.md +++ b/tastytrade-mcp/README.md @@ -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= -TT_SECRET= -TT_REFRESH= -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 diff --git a/tastytrade-mcp/scripts/start-server.sh b/tastytrade-mcp/scripts/start-server.sh new file mode 100755 index 0000000..9632870 --- /dev/null +++ b/tastytrade-mcp/scripts/start-server.sh @@ -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 diff --git a/tastytrade-mcp/src/client.py b/tastytrade-mcp/src/client.py index 656c08f..656e112 100644 --- a/tastytrade-mcp/src/client.py +++ b/tastytrade-mcp/src/client.py @@ -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 @@ -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 diff --git a/tastytrade-mcp/src/credentials.py b/tastytrade-mcp/src/credentials.py new file mode 100644 index 0000000..7e36afc --- /dev/null +++ b/tastytrade-mcp/src/credentials.py @@ -0,0 +1,124 @@ +"""OAuth credential resolution for the Tastytrade MCP server. + +Credentials come from exactly one of two sources, in precedence order: + +1. ``TT_CLIENT_ID`` / ``TT_SECRET`` / ``TT_REFRESH`` in the environment, for CI + and scripting. +2. ``~/.tastytrade-mcp/credentials.json``, for interactive use. + +Whole-source rather than 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 configuration mistake. A *partially* set +environment is treated as an error instead of silently falling through to the +file, because that fallthrough is invisible and the resulting 401 is misleading. + +Unlike harbor, Tastytrade has no `auth login` command to write this file. The +values come from a registered OAuth application, so the file is created by hand; +see the README. +""" + +import json +import os +import stat +from dataclasses import dataclass +from pathlib import Path + +CREDENTIALS_PATH = Path.home() / ".tastytrade-mcp" / "credentials.json" + +DEFAULT_BASE_URL = "api.tastyworks.com" + +# JSON key -> environment variable holding the same value. +ENV_VARS = { + "client_id": "TT_CLIENT_ID", + "client_secret": "TT_SECRET", + "refresh_token": "TT_REFRESH", +} + + +class CredentialsError(RuntimeError): + """Raised when no usable credential set can be resolved.""" + + +@dataclass(frozen=True) +class Credentials: + """A resolved credential set, plus where it came from (never the secret).""" + + client_id: str + client_secret: str + refresh_token: str + base_url: str + source: str # "env" or the credentials file path + + +def _read_file(path: Path) -> dict[str, str]: + """Return the parsed credentials file, or {} when it does not exist. + + Refuses a file that is readable by group or others. It carries an OAuth + client secret and a refresh token, which together grant full account access + including order placement when trading is enabled. + """ + try: + mode = path.stat().st_mode + except FileNotFoundError: + return {} + except OSError as exc: + raise CredentialsError(f"Could not read {path}: {exc}") from exc + + if mode & (stat.S_IRWXG | stat.S_IRWXO): + raise CredentialsError( + f"{path} is accessible to other users (mode {stat.filemode(mode)}). " + f"It holds an OAuth secret and refresh token. Run: chmod 600 {path}" + ) + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError) as exc: + raise CredentialsError(f"Could not read {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise CredentialsError(f"{path} is not valid JSON: {exc}") from exc + + if not isinstance(data, dict): + raise CredentialsError(f"{path} must contain a JSON object, got {type(data).__name__}.") + return {k: v for k, v in data.items() if isinstance(v, str) and v.strip()} + + +def resolve_credentials(path: Path | None = None) -> Credentials: + """Resolve credentials from the environment, else the credentials file.""" + path = path or CREDENTIALS_PATH + + present = {field: os.environ.get(var, "").strip() for field, var in ENV_VARS.items()} + set_fields = {f for f, v in present.items() if v} + + if set_fields == set(ENV_VARS): + return Credentials( + **present, + base_url=os.environ.get("API_BASE_URL", "").strip() or DEFAULT_BASE_URL, + source="env", + ) + + if set_fields: + missing = sorted(ENV_VARS[f] for f in ENV_VARS if f not in set_fields) + raise CredentialsError( + f"Incomplete credentials in the environment: {', '.join(missing)} " + f"not set. Set all three, or unset them all to use {path} instead." + ) + + data = _read_file(path) + if not data: + raise CredentialsError( + f"No Tastytrade credentials found. Create {path} containing " + '{"client_id": "...", "client_secret": "...", "refresh_token": "..."} ' + "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)}.") + + 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, + source=str(path), + ) diff --git a/tastytrade-mcp/src/infra/errors.py b/tastytrade-mcp/src/infra/errors.py index 098ad41..b9e35aa 100644 --- a/tastytrade-mcp/src/infra/errors.py +++ b/tastytrade-mcp/src/infra/errors.py @@ -13,6 +13,7 @@ import httpx from pydantic import ValidationError +from ..credentials import CredentialsError from .logging import get_logger @@ -30,8 +31,12 @@ def error_response(message: str, suggestions: list[str] | None = None, **extra: 401: ( "Authentication failed.", [ - "Verify TT_CLIENT_ID, TT_SECRET, and TT_REFRESH are set and current.", - "The refresh token may have been revoked, so re-authorize the OAuth client.", + "Credentials resolved but were rejected, so they are stale rather than " + "missing: the refresh token has most likely been revoked or expired.", + "They come from ~/.tastytrade-mcp/credentials.json, or from TT_CLIENT_ID, " + "TT_SECRET and TT_REFRESH when all three are set in the environment.", + "Ask the user to re-authorize the OAuth client and replace refresh_token " + "themselves. Never ask them to paste a secret into the chat.", ], ), 403: ( @@ -118,8 +123,18 @@ async def wrapper(*args: object, **kwargs: object) -> str: f"Network error contacting Tastytrade: {type(exc).__name__}.", ["Check connectivity to the API host.", "Retry in a moment."], ) + except CredentialsError as exc: + # The message already names the file, the JSON shape, and the + # environment alternative, so it is passed through verbatim. + get_logger().warning("credentials_error tool=%s", func.__name__) + return error_response(str(exc), ["See the tastytrade-mcp README for how to obtain each value."]) except (KeyError, ValueError, TypeError) as exc: get_logger().warning("tool_value_error tool=%s err=%s", func.__name__, exc) return error_response(f"Could not process the request: {exc}", ["Re-check the arguments and retry."]) + except Exception as exc: # noqa: BLE001 + # Last resort. Without this the docstring's promise is false: anything + # outside the cases above reaches the client as a raw traceback. + get_logger().exception("unexpected_error tool=%s", func.__name__) + return error_response(f"{type(exc).__name__}: {exc}") return wrapper diff --git a/tastytrade-mcp/tests/unit/test_credentials.py b/tastytrade-mcp/tests/unit/test_credentials.py new file mode 100644 index 0000000..42bad1c --- /dev/null +++ b/tastytrade-mcp/tests/unit/test_credentials.py @@ -0,0 +1,169 @@ +"""Tests for credential resolution. + +This is a trust boundary: the file under test grants full brokerage access, +including order placement when trading is enabled. +""" + +import json +import os + +import pytest + +from src.credentials import ( + DEFAULT_BASE_URL, + ENV_VARS, + CredentialsError, + resolve_credentials, +) + +pytestmark = pytest.mark.unit + +COMPLETE = {"client_id": "cid", "client_secret": "sec", "refresh_token": "ref"} + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + for var in ENV_VARS.values(): + monkeypatch.delenv(var, raising=False) + monkeypatch.delenv("API_BASE_URL", raising=False) + + +def write_creds(tmp_path, data, mode=0o600): + path = tmp_path / "credentials.json" + path.write_text(json.dumps(data)) + path.chmod(mode) + return path + + +def test_env_takes_precedence_over_file(tmp_path, monkeypatch): + path = write_creds(tmp_path, {**COMPLETE, "client_id": "from-file"}) + for field, var in ENV_VARS.items(): + monkeypatch.setenv(var, f"env-{field}") + + creds = resolve_credentials(path) + + assert creds.client_id == "env-client_id" + assert creds.source == "env" + + +def test_reads_file_when_env_is_empty(tmp_path): + path = write_creds(tmp_path, COMPLETE) + + creds = resolve_credentials(path) + + assert (creds.client_id, creds.client_secret, creds.refresh_token) == ("cid", "sec", "ref") + assert creds.source == str(path) + assert creds.base_url == DEFAULT_BASE_URL + + +def test_partial_env_is_an_error_not_a_silent_fallthrough(tmp_path, monkeypatch): + """A half-set environment must fail loudly. + + Falling through to the file would pair a file secret with an env id and + surface as a 401 that looks like a revoked token. + """ + write_creds(tmp_path, COMPLETE) + monkeypatch.setenv("TT_CLIENT_ID", "cid") + + with pytest.raises(CredentialsError) as exc: + resolve_credentials(tmp_path / "credentials.json") + + assert "TT_SECRET" in str(exc.value) + assert "TT_REFRESH" in str(exc.value) + + +def test_refuses_a_group_or_world_readable_file(tmp_path): + path = write_creds(tmp_path, COMPLETE, mode=0o644) + + with pytest.raises(CredentialsError) as exc: + resolve_credentials(path) + + assert "chmod 600" in str(exc.value) + + +def test_missing_file_names_both_options(tmp_path): + with pytest.raises(CredentialsError) as exc: + resolve_credentials(tmp_path / "credentials.json") + + message = str(exc.value) + assert "TT_CLIENT_ID" in message + assert "credentials.json" in message + + +def test_file_missing_a_key_says_which(tmp_path): + path = write_creds(tmp_path, {"client_id": "cid", "client_secret": "sec"}) + + with pytest.raises(CredentialsError) as exc: + resolve_credentials(path) + + assert "refresh_token" in str(exc.value) + + +def test_malformed_json_is_reported_as_such(tmp_path): + path = tmp_path / "credentials.json" + path.write_text("{not json") + path.chmod(0o600) + + with pytest.raises(CredentialsError) as exc: + resolve_credentials(path) + + assert "not valid JSON" in str(exc.value) + + +def test_base_url_precedence(tmp_path, monkeypatch): + """File value wins over API_BASE_URL, which wins over the default.""" + path = write_creds(tmp_path, {**COMPLETE, "base_url": "file.example.com"}) + monkeypatch.setenv("API_BASE_URL", "env.example.com") + assert resolve_credentials(path).base_url == "file.example.com" + + path = write_creds(tmp_path, COMPLETE) + assert resolve_credentials(path).base_url == "env.example.com" + + monkeypatch.delenv("API_BASE_URL") + assert resolve_credentials(path).base_url == DEFAULT_BASE_URL + + +def test_never_echoes_the_secret_in_errors(tmp_path): + """A raised error must not leak the secret it just read.""" + path = write_creds(tmp_path, {"client_id": "cid", "client_secret": "SUPERSECRET"}) + + with pytest.raises(CredentialsError) as exc: + resolve_credentials(path) + + assert "SUPERSECRET" not in str(exc.value) + + +def test_source_is_reported_without_the_secret(tmp_path): + path = write_creds(tmp_path, COMPLETE) + creds = resolve_credentials(path) + assert creds.source == str(path) + assert "sec" not in creds.source + assert os.environ.get("TT_SECRET") is None + + +@pytest.mark.asyncio +async def test_credentials_error_becomes_a_guided_tool_error(): + """CredentialsError must not escape guarded_tool as a raw exception.""" + from src.infra.errors import guarded_tool + + @guarded_tool + async def tool() -> str: + raise CredentialsError("no credentials found at /x/credentials.json") + + payload = json.loads(await tool()) + assert "credentials.json" in payload["error"] + assert payload["suggestions"] + + +@pytest.mark.asyncio +async def test_unexpected_exception_never_leaks_a_traceback(): + """The guarded_tool docstring promises this; before the catch-all it was false.""" + from src.infra.errors import guarded_tool + + @guarded_tool + async def tool() -> str: + raise ZeroDivisionError("boom") + + payload = json.loads(await tool()) + assert payload["error"] == "ZeroDivisionError: boom" + assert "Traceback" not in json.dumps(payload) diff --git a/tastytrade-mcp/tests/unit/test_plugin_config.py b/tastytrade-mcp/tests/unit/test_plugin_config.py new file mode 100644 index 0000000..9e01113 --- /dev/null +++ b/tastytrade-mcp/tests/unit/test_plugin_config.py @@ -0,0 +1,65 @@ +"""Guards on the shipped plugin config. + +A bad .mcp.json fails at MCP connect time with an opaque -32000 that no other +test can see. The harbor-mcp plugin shipped broken three times before these +checks existed, so they are duplicated here rather than shared. +""" + +import json +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + +REPO = Path(__file__).resolve().parents[2] +MCP_CONFIG = REPO / ".mcp.json" +PLUGIN_MANIFEST = REPO / ".claude-plugin" / "plugin.json" + + +def server_config() -> dict: + servers = json.loads(MCP_CONFIG.read_text())["mcpServers"] + assert len(servers) == 1, f"expected one server, got {list(servers)}" + return next(iter(servers.values())) + + +def test_plugin_root_has_no_default_fallback(): + """`${CLAUDE_PLUGIN_ROOT:-.}` silently expands to `.`, not the plugin root. + + The `:-default` form is handled by env-var expansion, which does not know + CLAUDE_PLUGIN_ROOT, so the default always won and the server launched against + the user's own project directory instead. Only the bare form is substituted by + the plugin loader. + """ + for arg in server_config()["args"]: + assert ":-" not in arg, ( + f"{arg!r} uses a :-default; CLAUDE_PLUGIN_ROOT must be bare or it " + "expands to the default and the server launches from the wrong directory" + ) + + +def test_launcher_path_exists_and_is_executable(): + args = server_config()["args"] + assert len(args) == 1, f"expected a single script argument, got {args}" + script = REPO / args[0].replace("${CLAUDE_PLUGIN_ROOT}/", "") + assert script.is_file(), f"{script} is referenced by .mcp.json but does not exist" + assert script.stat().st_mode & 0o111, f"{script} is not executable" + + +def test_config_does_not_source_dotenv(): + """The old config did `source .env`, which silently depended on the client's cwd. + + Credentials now resolve from ~/.tastytrade-mcp/credentials.json or the + environment, both independent of where the server is launched from. + """ + raw = MCP_CONFIG.read_text() + assert "source .env" not in raw + assert "TT_SECRET" not in raw, "a credential must never be named in the plugin config" + assert "TT_REFRESH" not in raw + + +def test_manifest_version_is_semver(): + """Version is the plugin cache key, so a malformed one lands users in a stray directory.""" + version = json.loads(PLUGIN_MANIFEST.read_text())["version"] + parts = version.split(".") + assert len(parts) == 3 and all(p.isdigit() for p in parts), f"version {version!r} must be MAJOR.MINOR.PATCH"