Skip to main content
Glama
jasonquinn77

yahoo-fantasy-mcp

by jasonquinn77
README.md
# yahoo-fantasy-mcp

Local stdio MCP server for the **Example League** Yahoo league. It answers three
questions against the live league:

- who has a positional hole I can exploit
- what trade would actually get accepted
- which free agents matter this week

Built for a single private league. The design notes that drove it — why there
is no roster cache, why two valuation sources, why no ML — are summarised in
the comments at the top of each module.

## Status

Built and tested against fixtures. **Not yet connected to Yahoo** — API access
is a reviewed application (runbook §6) and has not been submitted. Every piece
that does not need credentials is working and covered by tests.

## Layout

```
server.py                  7 MCP tools, stdio transport
yahoo_fantasy_mcp/
  auth.py                  OAuth 2.0 consent + transparent refresh
  yahoo_client.py          GET -> normalized JSON, refresh-on-401, retry once
  normalize.py             collapses Yahoo's fragmented JSON
  parsing.py               payloads -> domain objects; refuses to guess scoring
  league.py                league-key discovery, settings, rosters, free agents
  scoring.py               replacement level, surplus/deficit, lineup points
  trades.py                partner matching + package construction
  waivers.py               free agents ranked by value to THIS roster
  matching.py              Yahoo <-> FantasyCalc name matching
  fantasycalc.py           market values (no auth)
scripts/authorize.py       one-time consent flow
scripts/healthcheck.py     credential-path probe -> fleet ledger
```

## Setup

```bash
python3 -m venv .venv
./.venv/bin/pip install -e ".[dev]"
./.venv/bin/python -m pytest -q
```

Credentials go outside the repo, at `~/.config/yahoo-fantasy-mcp/.env`
(mode 600):

```
YAHOO_CLIENT_ID=...
YAHOO_CLIENT_SECRET=...
YAHOO_REDIRECT_URI=https://localhost:8000/callback
YAHOO_LEAGUE_ID=123456
```

Then, once:

```bash
./.venv/bin/python scripts/authorize.py
```

The browser will fail to load the redirect — that is expected. Paste the
address-bar URL back and the token pair is written to
`~/.config/yahoo-fantasy-mcp/tokens.json` (mode 600). Refresh is automatic
from there.

## Claude Desktop registration

The server ships as a container. Desktop spawns it per conversation over
stdio and it exits when the conversation ends — nothing is left running.

```bash
docker build -t yahoo-fantasy-mcp:latest .
```

The build runs the full test suite in an intermediate stage and the runtime
stage copies its stamp file, so **a failing test produces no image**. Verified:
breaking the replacement-level calculation fails 5 tests and the build aborts.

```json
{
  "mcpServers": {
    "yahoo-fantasy": {
      "command": "/usr/local/bin/docker",
      "args": [
        "run", "-i", "--rm",
        "-v", "/Users/you/.config/yahoo-fantasy-mcp:/config",
        "yahoo-fantasy-mcp:latest"
      ]
    }
  }
}
```

Absolute path to `docker` included: Desktop does not inherit your shell
environment, so anything resolved from `PATH` will not be there.

### What the container does and does not buy you

It buys a reproducible runtime — dependencies fully pinned in
`requirements.lock`, no dependence on the host's Python — and it makes the
image movable to another machine.

It does **not** decouple the server from Desktop. Stdio is a local pipe, so the
container must run on the same machine Desktop runs on. "Move it to another
machine" means moving Desktop too. Running Desktop on one host and the server
on another needs an HTTP transport, a tunnel and an auth layer in front of a
long-lived Yahoo refresh token — that is the trade ADR-2 declines, and it is a
separate decision, not a config change.

Two operational notes:

- **Docker must be running when Desktop launches the server.** This is a new
  failure mode the venv did not have. `scripts/healthcheck.py`, run in the
  container, exercises the same path and so reports red for this too.
- **On a Linux host, add `--user $(id -u):$(id -g)`.** macOS maps bind-mount
  ownership automatically (verified: a non-root container writes `/config`
  fine); Linux does not.

### Running without the container

`pyproject.toml` and the venv still work, and are the quicker loop for tests:

```bash
./.venv/bin/python -m pytest -q
```

`uv` is supported by `pyproject.toml` but is not used for launching: `uv run`
re-resolves dependencies on every invocation, which is a network round-trip and
a failure opportunity at kickoff, to save a `pip install` needed roughly twice
a year for two dependencies. The container pins them properly instead.

## Monitoring

`scripts/healthcheck.py` exercises a real token refresh and one authenticated
call, then appends an event to `~/ops/events.jsonl` as JSON lines. Run it on
whichever machine holds the tokens:

```bash
OPS_HOST=$(hostname -s) docker compose --profile ops run --rm healthcheck
```

`OPS_HOST` is required inside a container and the probe refuses to run without
it. The ledger fingerprint hashes host + service + stage + error class; a
container ID changes every run, so letting one through would give every
occurrence a unique fingerprint and silently destroy recurrence counting.
Refusing beats poisoning the ledger with events that can never match.

The failure it exists to catch is a silently dead refresh token. The server
runs only while a question is being asked, so nothing notices between
conversations — silence reads as health right up until you need an answer.
Error classes are bucketed (`token_refresh_failed`, `scoring_unavailable`,
`http_status`, `http_timeout`, `config_missing`) so repeat failures are
countable rather than each looking novel.

## Deliberately absent

- **No `fetch_url` tool.** The community servers all ship one; it is the
  largest prompt-injection surface in the design.
- **No writes.** Read-only by design, and Yahoo currently offers read access
  only. Every action stays a human decision.
- **No database, scheduler or ML.** See ADR-1 and ADR-4.
- **No long-running container.** The image exists for reproducibility, not to
  keep a process alive. There is no `docker compose up` for the server.