Skip to main content
Glama
benniblau

zwift-mcp

by benniblau

zwift-mcp

Tu historial de entrenamiento de Zwift en una base de datos SQLite local, expuesto a clientes MCP (Claude y cualquier otra cosa que hable MCP) y a través de una API REST.

Dos componentes:

  • zwift_downloader.py — un trabajo cron que obtiene datos de la API del juego Zwift y de ZwiftPower, analiza los archivos FIT originales y lo guarda todo en SQLite.

  • mcp_server.py — un servidor MCP sin estado con HTTP transmisible más una API REST, ambos leyendo la misma base de datos detrás de un token de portador.

Por qué dos fuentes

Zwift y ZwiftPower saben cosas distintas, y ninguna de las dos es completa:

Zwift API

ZwiftPower

Cada salida, incluidas las salidas en solitario y los entrenamientos

✅

❌ solo carreras

Frecuencia cardíaca, cadencia, velocidad, potencia máxima

✅ endpoint de detalle

✅ por carrera

Vueltas y flujos por segundo

solo dentro del archivo FIT

❌

Posición en la carrera, categoría, grupo

❌

✅

Curva de potencia crítica

❌

✅ (solo carreras)

Carga de entrenamiento, CTL/ATL/TSB

❌

❌

Así que el descargador enumera las actividades de Zwift, llama al endpoint de detalle para los campos de resumen que la lista omite, descarga y analiza cada FIT para obtener vueltas y flujos, obtiene los resultados de las carreras de ZwiftPower y calcula la carga de entrenamiento localmente. Los resultados de los dos sitios se vinculan por la hora de inicio, ya que no comparten ningún identificador — con datos reales la coincidencia es de unos tres minutos, el tiempo que pasas en el cajón de salida antes de que caiga la bandera.

Related MCP server: catence

Configuración

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp .env.example .env       # fill in ZWIFT_USER / ZWIFT_PASS

Genera un token de portador para el servidor:

python3 -c "import secrets; print(secrets.token_urlsafe(32))"

Ponlo en .env como ZWIFT_MCP_AUTH_TOKEN.

Antes de la primera sincronización, confirma que los endpoints siguen teniendo el aspecto que este código espera — ninguno de ellos está documentado ni es estable:

.venv/bin/python probe_zwift_api.py

Escribe probe_zwift.json (ignorado por git) con las cargas útiles sin procesar e imprime un resumen. Si una sección informa de un error, corrige el mapeo antes de sincronizar en lugar de llenar la base de datos con NULL.

Sincronización

.venv/bin/python zwift_downloader.py                 # incremental
.venv/bin/python zwift_downloader.py --days 30
.venv/bin/python zwift_downloader.py --since 2024-01-01
.venv/bin/python zwift_downloader.py --full          # re-fetch everything
.venv/bin/python zwift_downloader.py --with-samples  # + per-second streams
.venv/bin/python zwift_downloader.py --backfill-detail
.venv/bin/python zwift_downloader.py --redo-detail   # re-parse cached FIT files
.venv/bin/python zwift_downloader.py --skip-fit      # no FIT pass (fast)
.venv/bin/python zwift_downloader.py --zp-only       # ZwiftPower only
.venv/bin/python zwift_downloader.py --skip-zp       # game API only
.venv/bin/python zwift_downloader.py --with-zp-fields  # + full race fields
.venv/bin/python zwift_downloader.py --summary       # print stats, sync nothing

Una entrada cron nocturna:

30 4 * * * cd /opt/zwift-mcp && .venv/bin/python zwift_downloader.py >> sync.log 2>&1

La primera ejecución es la lenta: descarga un archivo FIT por actividad. Las ejecuciones posteriores solo obtienen lo nuevo, y los FIT en caché nunca se vuelven a descargar.

Ejecutar el servidor

.venv/bin/python mcp_server.py                    # HTTP (default), port 8081
.venv/bin/python mcp_server.py --transport stdio  # local Claude Desktop

En modo HTTP:

/mcp  and  /mcp/     MCP streamable HTTP endpoint (both spellings work)
/api/v1/...          REST API, same bearer token
/api/v1/health       liveness probe, unauthenticated

Conecta un cliente MCP con la URL y Authorization: Bearer <token>.

Producción

deploy/zwift-mcp.service es una unidad systemd endurecida para una instalación en /opt/zwift-mcp. Ejecutarla desde un directorio personal en su lugar implica eliminar ProtectHome — ocultaría el directorio de trabajo del propio servicio — y apuntar ReadWritePaths a la ruta de instalación.

sudo cp deploy/zwift-mcp.service /etc/systemd/system/
sudo systemctl enable --now zwift-mcp
journalctl -u zwift-mcp -f

Elige un puerto que no use nada más (ss -tlnp) y configúralo en .env; un servidor MCP por puerto.

Una sincronización nocturna, escalonada con respecto a cualquier otra cosa que se ejecute en la máquina:

15 9 * * * cd /opt/zwift-mcp && .venv/bin/python zwift_downloader.py --days 10 > download.log 2>&1

Para actualizar un despliegue en ejecución:

cd /opt/zwift-mcp && ./deploy/update.sh

Eso hace pull, instala las nuevas dependencias, reinicia la unidad y comprueba el endpoint de salud — y se niega a ejecutarse si los archivos rastreados tienen ediciones locales.

.env, la base de datos y fits/ están ignorados por git, así que un pull nunca los toca. Si el pull cambia schema/schema_zwift.sql, ten en cuenta que no hay migraciones — borra la base de datos y vuelve a sincronizar (los FIT en caché lo hacen barato).

La base de datos está en modo WAL, de modo que la sincronización nocturna y el servidor en ejecución no se bloquean mutuamente. Mantenlo así: con el diario de reversión predeterminado, un recálculo largo provoca que las consultas en vivo reciban 'database is locked'.

Superficie MCP

Recursos — zwift://athlete, zwift://activities, zwift://activities/recent, zwift://stats/summary, zwift://stats/monthly, zwift://training/daily, zwift://power/curve, zwift://racing/results

Herramientas de lectura

Herramienta

Qué hace

query_activities

Filtrar por deporte, mundo, fecha, distancia, duración, potencia, carreras

get_activity_details

Resumen, vueltas, tiempo en zona, curva de esa salida, resultado de carrera

get_training_load

TSS diario con CTL / ATL / TSB

get_training_trends

Volumen semanal o mensual

get_training_zones

Umbrales y las zonas de potencia/FC derivadas de ellos

get_activity_stats

Totales generales, por deporte y por mundo

get_power_curve

Mejor potencia media por duración, local vs ZwiftPower

get_race_results

Resultados de ZwiftPower con categoría y posición

get_race_details

Una carrera, más la clasificación final si está sincronizada

get_athlete_profile

Perfil de Zwift, perfil de ZwiftPower, forma actual

execute_sql

SELECT de solo lectura contra toda la base de datos

Herramientas de escritura — rename_activity (envía a Zwift y luego actualiza localmente), set_local_annotation (etiquetas y notas locales, nunca se envían a ningún sitio) y get_activity_fit_file (dónde vive el FIT original).

API REST

GET    /api/v1/health                          unauthenticated
GET    /api/v1/athlete
GET    /api/v1/activities?sport=&world_id=&start_date=&races_only=&limit=
GET    /api/v1/activities/{id}?include_samples=
PATCH  /api/v1/activities/{id}                 {"name": …, "local_notes": …}
GET    /api/v1/activities/{id}/laps
GET    /api/v1/activities/{id}/samples?limit=&offset=
GET    /api/v1/stats/summary
GET    /api/v1/stats/monthly
GET    /api/v1/daily-metrics?start_date=&end_date=&limit=
GET    /api/v1/power-curve?source=local|zwiftpower|both
GET    /api/v1/races?start_date=&title_contains=&limit=
GET    /api/v1/races/{event_id}
GET    /api/v1/zwiftpower/profile
GET    /api/v1/sync-state
curl -H "Authorization: Bearer $ZWIFT_MCP_AUTH_TOKEN" \
     "http://localhost:8081/api/v1/activities?races_only=true&limit=5"

Base de datos

athletes                   — profile, FTP, weight, lifetime totals
worlds                     — world id lookup (seeded)
activities                 — one row per ride or run
activity_laps              — from the FIT lap messages
activity_samples           — per-second stream (only with --with-samples)
activity_zone_distribution — time in zone, computed from samples + FTP
power_curve                — best mean power per duration, per activity
segment_results            — segment efforts from the Zwift API
zp_profile                 — ZwiftPower category, zFTP, racing score
zp_results                 — one row per race
zp_event_results           — full finishing fields (--with-zp-fields)
zp_critical_power          — ZwiftPower's own CP curve
daily_metrics              — derived TSS, CTL, ATL, TSB per day
sync_state                 — per-dataset watermarks

Views:
  activity_summary  — km, km/h, w/kg, TSS
  monthly_stats     — by month and sport
  weekly_load       — weekly volume and TSS
  power_curve_best  — all-time best per duration, with the ride that set it
  race_results      — ZwiftPower results joined to the local activity

Todos los valores almacenados son del SI: metros, segundos, vatios, ppm, m/s. Las conversiones viven en las vistas.

Cosas que merece la pena saber

  • La carga de entrenamiento se calcula aquí, no se obtiene. Cada salida se escala contra el FTP que Zwift tenía en ese momento (profileFtp), con respaldo al actual. ZWIFT_FTP_OVERRIDE reemplaza un valor desactualizado.

  • tss_source te indica cómo se alcanzó un TSS — np desde un FIT analizado, o avg_power estimado a partir del resumen. La estimación subestima una salida con grandes picos de potencia, así que se etiqueta en lugar de ocultarse.

  • Las actividades de running se puntúan contra el FTP de bicicleta a menos que se establezca ZWIFT_RUN_FTP. La potencia de running de Zwift no es la misma magnitud, así que trata esos valores de TSS como indicativos.

  • El archivo FIT es el detalle. Sin él no hay vueltas, ni flujos, ni potencia normalizada, ni curva de potencia.

  • Las actividades de running registradas sin medidor de potencia no obtienen curva de potencia ni zonas de potencia. Su FIT contiene un canal de potencia todo a cero, lo cual es ausencia, no datos.

  • La curva de potencia crítica de ZwiftPower cubre solo carreras, y solo las recientes — una curva vacía es una respuesta normal, no un fallo.

  • La base de datos no tiene migraciones. Si una columna del esquema cambia, borra zwift_activities.db y vuelve a sincronizar; los FIT en caché en fits/ significan que no se vuelve a descargar nada.

  • ZwiftPower es una vinculación de cuenta separada. Si la API no devuelve nada, abre zwiftpower.com en un navegador una vez e inicia sesión con Zwift; el perfil tiene que existir allí antes de que se pueda consultar nada.

  • Un fallo de ZwiftPower nunca hace fallar la sincronización. Los datos de carrera son secundarios respecto a los datos del juego, así que una interrupción se registra en sync_state y se omite.

  • Ninguna API es pública. Los nombres de los campos y los endpoints cambian sin previo aviso. probe_zwift_api.py existe para decirte cuál se ha roto.

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    A
    maintenance
    MCP server for local fitness-data extraction and analysis from Garmin Connect, Intervals.icu, and Strava. Provides read-only analytical tools over DuckDB and targeted Strava enrichment.
    35
    338 npm
    AGPL 3.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    MCP server that mirrors your Garmin data into a personal database and exposes tools for health summaries, training load, muscle readiness, and race analysis, with optional chat-driven insights via stdio or HTTP.
    1
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    A read-only MCP server that synchronizes Garmin Connect summaries into a local SQLite cache and provides tools to query daily activity, recent activities, and recovery data.
    MIT