From fac73091163e139a91b7d1e30ec8d0513a4e6c78 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 17 Jun 2026 14:08:15 +0200 Subject: [PATCH] mcp --- .env.example | 4 + README.md | 73 ++++++ api/package-lock.json | 189 +++++++++++++-- api/package.json | 6 +- api/src/analytics/analytics.module.ts | 1 + api/src/app.module.ts | 7 +- api/src/mcp-http-main.ts | 26 +++ api/src/mcp-main.ts | 25 ++ api/src/mcp/mcp-data.service.ts | 285 +++++++++++++++++++++++ api/src/mcp/mcp-server.service.ts | 316 ++++++++++++++++++++++++++ api/src/mcp/mcp.module.ts | 26 +++ api/src/strava/strava.module.ts | 6 +- docker/nginx.conf | 13 ++ docker/supervisord.conf | 12 + 14 files changed, 973 insertions(+), 16 deletions(-) create mode 100644 README.md create mode 100644 api/src/mcp-http-main.ts create mode 100644 api/src/mcp-main.ts create mode 100644 api/src/mcp/mcp-data.service.ts create mode 100644 api/src/mcp/mcp-server.service.ts create mode 100644 api/src/mcp/mcp.module.ts diff --git a/.env.example b/.env.example index efd3194..a4f4cde 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ PORT=3000 API_PORT=3000 CLIENT_PORT=8080 +MCP_PORT=3001 +MCP_CORS_ORIGIN=* +# Optional: require Authorization: Bearer for /mcp +# MCP_AUTH_TOKEN=change-me CORS_ORIGIN=http://localhost:8080 CLIENT_URL=http://localhost:8080 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac7b5b3 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Strava MCP + +Diese Anwendung besteht aus einer NestJS API, einem Angular Client und einem MCP-Server fuer KI-Zugriff auf die synchronisierten Strava-Daten. + +## MCP starten + +Im Docker-Container startet der MCP-Server automatisch als HTTP-Endpunkt. nginx veroeffentlicht ihn unter `/mcp` auf demselben Port wie den Client. + +```bash +docker run -d --env-file .env -p 8080:80 --name strava-mcp 192.168.30.40:3000/bastian/strava-mcp:latest +``` + +Der MCP-Endpunkt ist dann erreichbar unter: + +```text +http://:8080/mcp +``` + +Optional kann der Endpunkt mit `MCP_AUTH_TOKEN` geschuetzt werden. MCP-Clients muessen dann den Header `Authorization: Bearer ` senden. + +Fuer lokale stdio-Nutzung gibt es weiterhin einen separaten Einstieg. Die `.env` kann im Repository-Root oder im `api`-Ordner liegen. + +```bash +cd api +npm run build +npm run start:mcp +``` + +Beispiel fuer einen MCP-Client: + +```json +{ + "mcpServers": { + "strava": { + "command": "node", + "args": ["C:/repositories/strava-mcp/api/dist/mcp-main.js"], + "env": { + "DATABASE_HOST": "mysql.example.internal", + "DATABASE_PORT": "3306", + "DATABASE_USER": "strava_app", + "DATABASE_PASSWORD": "change-me", + "DATABASE_NAME": "strava_app", + "APP_ENCRYPTION_KEY": "change-me-to-a-long-random-secret", + "STRAVA_CLIENT_ID": "change-me", + "STRAVA_CLIENT_SECRET": "change-me", + "STRAVA_REDIRECT_URI": "http://localhost:3000/auth/strava/callback" + } + } + } +} +``` + +Der HTTP-MCP kann lokal auch ohne Docker gestartet werden: + +```bash +cd api +npm run build +npm run start:mcp:http +``` + +## MCP Tools + +- `get_athlete_profile`: verbundenes Strava-Profil, Aktivitaetsanzahl, Sportarten und neueste Aktivitaet. +- `get_training_dashboard`: Summen, Durchschnitte, Wochenverlauf, Sportarten und letzte Aktivitaeten. +- `get_running_summary`: Laufumfang, Pace, laengster Lauf, Hoehenmeter und Wochenverlauf. +- `get_running_kpis`: Load, Acute/Chronic Ratio, Monotony, Recovery, Intensitaetsverteilung, Progression und PR-Schaetzungen. +- `list_activities`: Aktivitaeten nach Zeitraum, Sportart und Name suchen. +- `get_activity_detail`: Details zu einer Aktivitaet, optional mit begrenzten Stream-Punkten und Raw Payload. +- `get_running_activity_detail`: Laufdetails mit Splits und Serien. +- `get_sync_status`: letzter Strava-Sync-Job. +- `start_strava_sync`: startet oder reaktiviert den Strava-Sync. + +Zusätzlich gibt es den Prompt `training_review`, der eine KI zur strukturierten Analyse der Trainingsdaten anleitet. diff --git a/api/package-lock.json b/api/package-lock.json index cae017d..27ed266 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", @@ -19,7 +20,8 @@ "mysql2": "^3.22.5", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^1.0.0" + "typeorm": "^1.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -943,6 +945,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2062,6 +2076,68 @@ "node": ">=8" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -3857,7 +3933,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -3875,7 +3950,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3892,7 +3966,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/ajv-keywords": { @@ -4838,7 +4911,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5414,6 +5486,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5516,11 +5609,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -5554,7 +5664,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, "funding": [ { "type": "github", @@ -6178,6 +6287,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6339,6 +6457,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6460,7 +6587,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -7304,6 +7430,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7368,6 +7503,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -8199,7 +8340,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8281,6 +8421,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -8580,7 +8729,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8778,7 +8926,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8791,7 +8938,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10423,7 +10569,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10586,6 +10731,24 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/api/package.json b/api/package.json index cbb64e3..f0154a9 100644 --- a/api/package.json +++ b/api/package.json @@ -12,6 +12,8 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", + "start:mcp": "node dist/mcp-main", + "start:mcp:http": "node dist/mcp-http-main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", @@ -20,6 +22,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", @@ -30,7 +33,8 @@ "mysql2": "^3.22.5", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^1.0.0" + "typeorm": "^1.0.0", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/api/src/analytics/analytics.module.ts b/api/src/analytics/analytics.module.ts index 2bfd3e4..9b8eca5 100644 --- a/api/src/analytics/analytics.module.ts +++ b/api/src/analytics/analytics.module.ts @@ -18,5 +18,6 @@ import { AnalyticsService } from './analytics.service'; ], controllers: [AnalyticsController], providers: [AnalyticsService], + exports: [AnalyticsService], }) export class AnalyticsModule {} diff --git a/api/src/app.module.ts b/api/src/app.module.ts index a95a661..320dda7 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -5,6 +5,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AnalyticsModule } from './analytics/analytics.module'; import { createTypeOrmOptions } from './database/typeorm.options'; +import { McpModule } from './mcp/mcp.module'; import { StravaModule } from './strava/strava.module'; const featureImports = @@ -16,10 +17,14 @@ const featureImports = }), StravaModule, AnalyticsModule, + McpModule, ]; @Module({ - imports: [ConfigModule.forRoot({ isGlobal: true }), ...featureImports], + imports: [ + ConfigModule.forRoot({ envFilePath: ['.env', '../.env'], isGlobal: true }), + ...featureImports, + ], controllers: [AppController], providers: [AppService], }) diff --git a/api/src/mcp-http-main.ts b/api/src/mcp-http-main.ts new file mode 100644 index 0000000..9c5ea38 --- /dev/null +++ b/api/src/mcp-http-main.ts @@ -0,0 +1,26 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { McpServerService } from './mcp/mcp-server.service'; + +async function bootstrap() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: false, + }); + const mcpServer = app.get(McpServerService); + const port = Number(process.env.MCP_PORT ?? 3001); + + process.on('SIGINT', () => { + void app.close().finally(() => process.exit(0)); + }); + process.on('SIGTERM', () => { + void app.close().finally(() => process.exit(0)); + }); + + await mcpServer.startHttp(port); +} + +bootstrap().catch((error) => { + const message = error instanceof Error ? error.stack : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/api/src/mcp-main.ts b/api/src/mcp-main.ts new file mode 100644 index 0000000..001f9c0 --- /dev/null +++ b/api/src/mcp-main.ts @@ -0,0 +1,25 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { McpServerService } from './mcp/mcp-server.service'; + +async function bootstrap() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: false, + }); + const mcpServer = app.get(McpServerService); + + process.on('SIGINT', () => { + void app.close().finally(() => process.exit(0)); + }); + process.on('SIGTERM', () => { + void app.close().finally(() => process.exit(0)); + }); + + await mcpServer.startStdio(); +} + +bootstrap().catch((error) => { + const message = error instanceof Error ? error.stack : String(error); + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/api/src/mcp/mcp-data.service.ts b/api/src/mcp/mcp-data.service.ts new file mode 100644 index 0000000..45cde79 --- /dev/null +++ b/api/src/mcp/mcp-data.service.ts @@ -0,0 +1,285 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + StravaActivityEntity, + StravaActivityStreamPointEntity, + StravaAthleteEntity, +} from '../database/entities'; + +export interface ListActivitiesInput { + weeks?: number; + fromDate?: string; + toDate?: string; + sportType?: string; + query?: string; + limit?: number; +} + +export interface ActivityDetailInput { + id: string; + includeStreams?: boolean; + includeRawPayload?: boolean; + maxStreamPoints?: number; +} + +@Injectable() +export class McpDataService { + constructor( + @InjectRepository(StravaAthleteEntity) + private readonly athleteRepository: Repository, + @InjectRepository(StravaActivityEntity) + private readonly activityRepository: Repository, + @InjectRepository(StravaActivityStreamPointEntity) + private readonly streamPointRepository: Repository, + ) {} + + async getAthleteProfile(): Promise> { + const athlete = await this.athleteRepository.findOne({ + where: { accountKey: 'primary' }, + }); + + if (!athlete) { + throw new NotFoundException('No Strava athlete connected'); + } + + const [activityCount, latestActivity, sportRows] = await Promise.all([ + this.activityRepository.count({ + where: { stravaAthleteId: athlete.id }, + }), + this.activityRepository.findOne({ + where: { stravaAthleteId: athlete.id }, + order: { startDate: 'DESC' }, + }), + this.activityRepository + .createQueryBuilder('activity') + .select('activity.sportType', 'sportType') + .addSelect('COUNT(*)', 'count') + .where('activity.stravaAthleteId = :athleteId', { + athleteId: athlete.id, + }) + .groupBy('activity.sportType') + .orderBy('count', 'DESC') + .getRawMany<{ sportType: string | null; count: string }>(), + ]); + + return { + id: athlete.id, + stravaAthleteId: athlete.stravaAthleteId, + username: athlete.username, + firstName: athlete.firstName, + lastName: athlete.lastName, + city: athlete.city, + state: athlete.state, + country: athlete.country, + sex: athlete.sex, + activityCount, + latestActivity: latestActivity + ? this.toActivitySummary(latestActivity) + : null, + sports: sportRows.map((row) => ({ + sportType: row.sportType ?? 'Unbekannt', + count: Number(row.count), + })), + createdAt: this.toIso(athlete.createdAt), + updatedAt: this.toIso(athlete.updatedAt), + }; + } + + async listActivities(input: ListActivitiesInput): Promise> { + const limit = this.clampInteger(input.limit ?? 25, 1, 100); + const query = this.activityRepository + .createQueryBuilder('activity') + .orderBy('activity.startDate', 'DESC'); + + const rangeStart = input.fromDate + ? this.parseDate(input.fromDate, 'fromDate') + : input.weeks + ? this.addDays(new Date(), -this.clampInteger(input.weeks, 1, 520) * 7) + : null; + const rangeEnd = input.toDate ? this.parseDate(input.toDate, 'toDate') : null; + + if (rangeStart) { + query.andWhere('activity.startDate >= :rangeStart', { rangeStart }); + } + if (rangeEnd) { + query.andWhere('activity.startDate <= :rangeEnd', { + rangeEnd: this.endOfDay(rangeEnd), + }); + } + if (input.sportType && input.sportType !== 'all') { + query.andWhere('activity.sportType = :sportType', { + sportType: input.sportType, + }); + } + if (input.query?.trim()) { + query.andWhere('activity.name LIKE :nameQuery', { + nameQuery: `%${input.query.trim()}%`, + }); + } + + const totalMatches = await query.getCount(); + const activities = await query.take(limit).getMany(); + + return { + totalMatches, + returned: activities.length, + filters: { + weeks: input.weeks ?? null, + fromDate: rangeStart ? this.toDateKey(rangeStart) : null, + toDate: rangeEnd ? this.toDateKey(rangeEnd) : null, + sportType: input.sportType ?? null, + query: input.query ?? null, + limit, + }, + activities: activities.map((activity) => this.toActivitySummary(activity)), + }; + } + + async getActivityDetail( + input: ActivityDetailInput, + ): Promise> { + const activity = await this.activityRepository.findOne({ + where: [{ id: input.id }, { stravaActivityId: input.id }], + }); + + if (!activity) { + throw new NotFoundException('Activity not found'); + } + + const streamPointCount = await this.streamPointRepository.count({ + where: { activityId: activity.id }, + }); + const detail: Record = { + ...this.toActivitySummary(activity), + elapsedTimeSeconds: activity.elapsedTime, + maxSpeedMetersPerSecond: activity.maxSpeed, + maxHeartrate: activity.maxHeartrate, + maxWatts: activity.maxWatts, + weightedAverageWatts: activity.weightedAverageWatts, + averageCadence: activity.averageCadence, + calories: activity.calories, + gearId: activity.gearId, + trainer: activity.trainer, + commute: activity.commute, + manual: activity.manual, + private: activity.private, + visibility: activity.visibility, + mapId: activity.mapId, + summaryPolyline: activity.summaryPolyline, + streamPointCount, + createdAt: this.toIso(activity.createdAt), + updatedAt: this.toIso(activity.updatedAt), + }; + + if (input.includeRawPayload) { + detail.rawPayload = activity.rawPayload; + } + + if (input.includeStreams) { + const maxStreamPoints = this.clampInteger( + input.maxStreamPoints ?? 1000, + 1, + 5000, + ); + const points = await this.streamPointRepository.find({ + where: { activityId: activity.id }, + order: { pointIndex: 'ASC' }, + }); + const sampledPoints = this.sample(points, maxStreamPoints); + + detail.streams = { + totalPoints: points.length, + returnedPoints: sampledPoints.length, + sampled: sampledPoints.length < points.length, + points: sampledPoints.map((point) => ({ + index: point.pointIndex, + timeSeconds: point.timeSeconds, + distanceMeters: point.distance, + latitude: point.latitude, + longitude: point.longitude, + altitudeMeters: point.altitude, + velocitySmoothMetersPerSecond: point.velocitySmooth, + heartRate: point.heartRate, + cadence: point.cadence, + watts: point.watts, + temperature: point.temperature, + moving: point.moving, + gradeSmooth: point.gradeSmooth, + })), + }; + } + + return detail; + } + + private toActivitySummary( + activity: StravaActivityEntity, + ): Record { + return { + id: activity.id, + stravaActivityId: activity.stravaActivityId, + name: activity.name, + sportType: activity.sportType, + startDate: this.toIso(activity.startDate), + startDateLocal: this.toIso(activity.startDateLocal), + timezone: activity.timezone, + distanceMeters: activity.distance, + movingTimeSeconds: activity.movingTime, + elevationGainMeters: activity.totalElevationGain, + averageSpeedMetersPerSecond: activity.averageSpeed, + averageHeartrate: activity.averageHeartrate, + averageWatts: activity.averageWatts, + paceSecondsPerKm: + activity.distance && activity.distance > 0 && activity.movingTime + ? Math.round(activity.movingTime / (activity.distance / 1000)) + : null, + }; + } + + private sample(values: T[], maxItems: number): T[] { + if (values.length <= maxItems) { + return values; + } + + const step = (values.length - 1) / (maxItems - 1); + return Array.from({ length: maxItems }, (_, index) => { + return values[Math.round(index * step)]; + }); + } + + private parseDate(value: string, field: string): Date { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid ${field}: ${value}`); + } + return date; + } + + private endOfDay(value: Date): Date { + const date = new Date(value); + date.setHours(23, 59, 59, 999); + return date; + } + + private addDays(value: Date, days: number): Date { + const date = new Date(value); + date.setDate(date.getDate() + days); + return date; + } + + private clampInteger(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(Math.max(Math.trunc(value), min), max); + } + + private toIso(value: Date | null): string | null { + return value ? value.toISOString() : null; + } + + private toDateKey(value: Date): string { + return value.toISOString().slice(0, 10); + } +} diff --git a/api/src/mcp/mcp-server.service.ts b/api/src/mcp/mcp-server.service.ts new file mode 100644 index 0000000..4a529ff --- /dev/null +++ b/api/src/mcp/mcp-server.service.ts @@ -0,0 +1,316 @@ +import { Injectable } from '@nestjs/common'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createServer, IncomingMessage, ServerResponse } from 'node:http'; +import { z } from 'zod'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { StravaSyncService } from '../strava/strava-sync.service'; +import { McpDataService } from './mcp-data.service'; + +@Injectable() +export class McpServerService { + constructor( + private readonly analyticsService: AnalyticsService, + private readonly stravaSyncService: StravaSyncService, + private readonly mcpDataService: McpDataService, + ) {} + + async startStdio(): Promise { + const server = this.createServer(); + await server.connect(new StdioServerTransport()); + } + + async startHttp(port = 3001): Promise { + const mcpServer = this.createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + await mcpServer.connect(transport); + + const httpServer = createServer((request, response) => { + void this.handleHttpRequest(request, response, transport); + }); + + await new Promise((resolve) => { + httpServer.listen(port, '0.0.0.0', resolve); + }); + + process.stderr.write(`Strava MCP HTTP server listening on port ${port}\n`); + } + + private createServer(): McpServer { + const server = new McpServer({ + name: 'strava-mcp', + version: '1.0.0', + }); + + this.registerTools(server); + this.registerPrompts(server); + + return server; + } + + private async handleHttpRequest( + request: IncomingMessage, + response: ServerResponse, + transport: StreamableHTTPServerTransport, + ): Promise { + this.setCorsHeaders(response); + + if (request.method === 'OPTIONS') { + response.writeHead(204); + response.end(); + return; + } + + const pathname = new URL(request.url ?? '/', 'http://localhost').pathname; + if (pathname !== '/mcp') { + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'Not found' })); + return; + } + + if (!this.isAuthorized(request)) { + response.writeHead(401, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + try { + await transport.handleRequest(request, response); + } catch (error) { + if (!response.headersSent) { + response.writeHead(500, { 'content-type': 'application/json' }); + } + response.end( + JSON.stringify({ + error: error instanceof Error ? error.message : 'MCP request failed', + }), + ); + } + } + + private isAuthorized(request: IncomingMessage): boolean { + const token = process.env.MCP_AUTH_TOKEN; + if (!token) { + return true; + } + + return request.headers.authorization === `Bearer ${token}`; + } + + private setCorsHeaders(response: ServerResponse): void { + response.setHeader( + 'Access-Control-Allow-Origin', + process.env.MCP_CORS_ORIGIN ?? '*', + ); + response.setHeader( + 'Access-Control-Allow-Headers', + 'authorization, content-type, mcp-session-id', + ); + response.setHeader( + 'Access-Control-Allow-Methods', + 'GET, POST, DELETE, OPTIONS', + ); + response.setHeader('Access-Control-Expose-Headers', 'mcp-session-id'); + } + + private registerTools(server: McpServer): void { + server.registerTool( + 'get_athlete_profile', + { + title: 'Athlete Profile', + description: + 'Liefert das verbundene Strava-Profil, Aktivitaetsanzahl, neueste Aktivitaet und Sportarten.', + }, + async () => this.jsonResult(await this.mcpDataService.getAthleteProfile()), + ); + + server.registerTool( + 'get_training_dashboard', + { + title: 'Training Dashboard', + description: + 'Verdichtete Trainingsdaten fuer einen Zeitraum: Summen, Durchschnitte, Wochen, Sportarten und letzte Aktivitaeten.', + inputSchema: { + weeks: z + .number() + .int() + .min(1) + .max(104) + .default(12) + .describe('Anzahl Wochen rueckwirkend.'), + sportType: z + .string() + .optional() + .describe('Optionaler Strava-Sporttyp, z.B. Run oder Ride.'), + }, + }, + async ({ weeks, sportType }) => + this.jsonResult( + await this.analyticsService.getDashboard(weeks, sportType), + ), + ); + + server.registerTool( + 'get_running_summary', + { + title: 'Running Summary', + description: + 'Lauf-Fokus mit Wochenverlauf, Gesamtumfang, Pace, laengstem Lauf, Hoehenmetern und letzten Laeufen.', + inputSchema: { + weeks: z.number().int().min(1).max(104).default(12), + }, + }, + async ({ weeks }) => + this.jsonResult(await this.analyticsService.getRunningSummary(weeks)), + ); + + server.registerTool( + 'get_running_kpis', + { + title: 'Running KPIs', + description: + 'Lauf-KPIs fuer Trainingssteuerung: Load, Acute/Chronic Ratio, Monotony, Recovery, Intensitaetsverteilung, Progression und PR-Schaetzungen.', + inputSchema: { + weeks: z.number().int().min(1).max(104).default(12), + }, + }, + async ({ weeks }) => + this.jsonResult(await this.analyticsService.getRunningKpis(weeks)), + ); + + server.registerTool( + 'list_activities', + { + title: 'List Activities', + description: + 'Sucht Aktivitaeten nach Zeitraum, Sportart und Name. Nutze dieses Tool fuer Rohdatenlisten und gezielte Rueckfragen.', + inputSchema: { + weeks: z + .number() + .int() + .min(1) + .max(520) + .optional() + .describe('Optionaler Zeitraum rueckwirkend in Wochen.'), + fromDate: z + .string() + .optional() + .describe('Optionales Startdatum, z.B. 2026-01-01.'), + toDate: z + .string() + .optional() + .describe('Optionales Enddatum, z.B. 2026-06-17.'), + sportType: z.string().optional(), + query: z.string().optional().describe('Suche im Aktivitaetsnamen.'), + limit: z.number().int().min(1).max(100).default(25), + }, + }, + async (input) => + this.jsonResult(await this.mcpDataService.listActivities(input)), + ); + + server.registerTool( + 'get_activity_detail', + { + title: 'Activity Detail', + description: + 'Liefert Details zu einer Aktivitaet anhand interner ID oder Strava-ID. Streams koennen optional begrenzt mitgeliefert werden.', + inputSchema: { + id: z + .string() + .min(1) + .describe('Interne Aktivitaets-ID oder Strava-Aktivitaets-ID.'), + includeStreams: z.boolean().default(false), + includeRawPayload: z.boolean().default(false), + maxStreamPoints: z.number().int().min(1).max(5000).default(1000), + }, + }, + async (input) => + this.jsonResult(await this.mcpDataService.getActivityDetail(input)), + ); + + server.registerTool( + 'get_running_activity_detail', + { + title: 'Running Activity Detail', + description: + 'Analysiert einen Lauf mit Splits und Serien. Falls Streams fehlen, kann die bestehende Strava-Importlogik diese nachladen.', + inputSchema: { + id: z.string().min(1).describe('Interne Aktivitaets-ID eines Laufs.'), + }, + }, + async ({ id }) => + this.jsonResult( + await this.analyticsService.getRunningActivityDetail(id), + ), + ); + + server.registerTool( + 'get_sync_status', + { + title: 'Sync Status', + description: 'Liefert den letzten Strava-Sync-Job mit Status und Zaehlern.', + }, + async () => this.jsonResult(await this.stravaSyncService.getLatestJob()), + ); + + server.registerTool( + 'start_strava_sync', + { + title: 'Start Strava Sync', + description: + 'Startet oder reaktiviert einen Strava-Sync. Nutze das nur, wenn aktuelle Daten benoetigt werden.', + }, + async () => this.jsonResult(await this.stravaSyncService.startSync()), + ); + } + + private registerPrompts(server: McpServer): void { + server.registerPrompt( + 'training_review', + { + title: 'Training Review', + description: + 'Prompt fuer eine KI-Analyse der Strava-Daten mit konkreten Erkenntnissen und Rueckfragen.', + argsSchema: { + focus: z + .string() + .optional() + .describe('Optionaler Fokus, z.B. Marathon, Grundlagenausdauer oder Verletzungsrisiko.'), + weeks: z.string().optional().describe('Zeitraum in Wochen, Standard 12.'), + }, + }, + ({ focus, weeks }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: + `Analysiere meine Strava-Daten fuer die letzten ${weeks ?? '12'} Wochen` + + `${focus ? ` mit Fokus auf ${focus}` : ''}. ` + + 'Nutze zuerst get_athlete_profile, get_training_dashboard, get_running_summary und get_running_kpis. ' + + 'Wenn Details unklar sind, suche passende Aktivitaeten mit list_activities und hole Detaildaten mit get_activity_detail. ' + + 'Gib konkrete Erkenntnisse, moegliche Risiken, belastbare Trends und sinnvolle naechste Fragen aus.', + }, + }, + ], + }), + ); + } + + private jsonResult(data: unknown) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; + } +} diff --git a/api/src/mcp/mcp.module.ts b/api/src/mcp/mcp.module.ts new file mode 100644 index 0000000..f8bb06d --- /dev/null +++ b/api/src/mcp/mcp.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AnalyticsModule } from '../analytics/analytics.module'; +import { + StravaActivityEntity, + StravaActivityStreamPointEntity, + StravaAthleteEntity, +} from '../database/entities'; +import { StravaModule } from '../strava/strava.module'; +import { McpDataService } from './mcp-data.service'; +import { McpServerService } from './mcp-server.service'; + +@Module({ + imports: [ + AnalyticsModule, + StravaModule, + TypeOrmModule.forFeature([ + StravaAthleteEntity, + StravaActivityEntity, + StravaActivityStreamPointEntity, + ]), + ], + providers: [McpDataService, McpServerService], + exports: [McpServerService], +}) +export class McpModule {} diff --git a/api/src/strava/strava.module.ts b/api/src/strava/strava.module.ts index ebb5889..410c133 100644 --- a/api/src/strava/strava.module.ts +++ b/api/src/strava/strava.module.ts @@ -41,6 +41,10 @@ import { TokenCryptoService } from './token-crypto.service'; StravaStreamImportService, StravaSyncService, ], - exports: [StravaStreamNormalizerService, StravaStreamImportService], + exports: [ + StravaStreamNormalizerService, + StravaStreamImportService, + StravaSyncService, + ], }) export class StravaModule {} diff --git a/docker/nginx.conf b/docker/nginx.conf index 4fb1bb8..886b994 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -33,6 +33,19 @@ server { proxy_pass http://127.0.0.1:3000; } + location = /mcp { + proxy_http_version 1.1; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://127.0.0.1:3001; + } + location / { add_header Cache-Control "no-store"; try_files $uri $uri/ /index.html; diff --git a/docker/supervisord.conf b/docker/supervisord.conf index e422b37..e8dec66 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -16,6 +16,18 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/fd/2 stderr_logfile_maxbytes=0 +[program:mcp] +directory=/app/api +command=node dist/mcp-http-main.js +user=node +autorestart=true +stopasgroup=true +killasgroup=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 + [program:nginx] command=nginx -g "daemon off;" autorestart=true