This commit is contained in:
Bastian Wagner
2026-06-17 14:08:15 +02:00
parent 8151a0f7cd
commit fac7309116
14 changed files with 973 additions and 16 deletions

View File

@@ -1,6 +1,10 @@
PORT=3000
API_PORT=3000
CLIENT_PORT=8080
MCP_PORT=3001
MCP_CORS_ORIGIN=*
# Optional: require Authorization: Bearer <token> for /mcp
# MCP_AUTH_TOKEN=change-me
CORS_ORIGIN=http://localhost:8080
CLIENT_URL=http://localhost:8080

73
README.md Normal file
View File

@@ -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://<server>:8080/mcp
```
Optional kann der Endpunkt mit `MCP_AUTH_TOKEN` geschuetzt werden. MCP-Clients muessen dann den Header `Authorization: Bearer <token>` 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.

189
api/package-lock.json generated
View File

@@ -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"
}
}
}
}

View File

@@ -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",

View File

@@ -18,5 +18,6 @@ import { AnalyticsService } from './analytics.service';
],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -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],
})

26
api/src/mcp-http-main.ts Normal file
View File

@@ -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);
});

25
api/src/mcp-main.ts Normal file
View File

@@ -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);
});

View File

@@ -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<StravaAthleteEntity>,
@InjectRepository(StravaActivityEntity)
private readonly activityRepository: Repository<StravaActivityEntity>,
@InjectRepository(StravaActivityStreamPointEntity)
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
) {}
async getAthleteProfile(): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown> = {
...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<string, unknown> {
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<T>(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);
}
}

View File

@@ -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<void> {
const server = this.createServer();
await server.connect(new StdioServerTransport());
}
async startHttp(port = 3001): Promise<void> {
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<void>((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<void> {
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),
},
],
};
}
}

26
api/src/mcp/mcp.module.ts Normal file
View File

@@ -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 {}

View File

@@ -41,6 +41,10 @@ import { TokenCryptoService } from './token-crypto.service';
StravaStreamImportService,
StravaSyncService,
],
exports: [StravaStreamNormalizerService, StravaStreamImportService],
exports: [
StravaStreamNormalizerService,
StravaStreamImportService,
StravaSyncService,
],
})
export class StravaModule {}

View File

@@ -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;

View File

@@ -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