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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues