mcp
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
CLIENT_PORT=8080
|
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
|
CORS_ORIGIN=http://localhost:8080
|
||||||
CLIENT_URL=http://localhost:8080
|
CLIENT_URL=http://localhost:8080
|
||||||
|
|
||||||
|
|||||||
73
README.md
Normal file
73
README.md
Normal 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
189
api/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
"mysql2": "^3.22.5",
|
"mysql2": "^3.22.5",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^1.0.0"
|
"typeorm": "^1.0.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@@ -943,6 +945,18 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -2062,6 +2076,68 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
@@ -3857,7 +3933,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.0.0"
|
"ajv": "^8.0.0"
|
||||||
@@ -3875,7 +3950,6 @@
|
|||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -3892,7 +3966,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ajv-keywords": {
|
"node_modules/ajv-keywords": {
|
||||||
@@ -4838,7 +4911,6 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
@@ -5414,6 +5486,27 @@
|
|||||||
"node": ">=0.8.x"
|
"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": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
@@ -5516,11 +5609,28 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-diff": {
|
"node_modules/fast-diff": {
|
||||||
@@ -5554,7 +5664,6 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -6178,6 +6287,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
@@ -6339,6 +6457,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -6460,7 +6587,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/istanbul-lib-coverage": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
@@ -7304,6 +7430,15 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -7368,6 +7503,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"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",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -8281,6 +8421,15 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/pkg-dir": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
||||||
@@ -8580,7 +8729,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"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==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -8778,7 +8926,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
@@ -8791,7 +8938,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -10423,7 +10569,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
@@ -10586,6 +10731,24 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"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",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
@@ -30,7 +33,8 @@
|
|||||||
"mysql2": "^3.22.5",
|
"mysql2": "^3.22.5",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^1.0.0"
|
"typeorm": "^1.0.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ import { AnalyticsService } from './analytics.service';
|
|||||||
],
|
],
|
||||||
controllers: [AnalyticsController],
|
controllers: [AnalyticsController],
|
||||||
providers: [AnalyticsService],
|
providers: [AnalyticsService],
|
||||||
|
exports: [AnalyticsService],
|
||||||
})
|
})
|
||||||
export class AnalyticsModule {}
|
export class AnalyticsModule {}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AppController } from './app.controller';
|
|||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { AnalyticsModule } from './analytics/analytics.module';
|
import { AnalyticsModule } from './analytics/analytics.module';
|
||||||
import { createTypeOrmOptions } from './database/typeorm.options';
|
import { createTypeOrmOptions } from './database/typeorm.options';
|
||||||
|
import { McpModule } from './mcp/mcp.module';
|
||||||
import { StravaModule } from './strava/strava.module';
|
import { StravaModule } from './strava/strava.module';
|
||||||
|
|
||||||
const featureImports =
|
const featureImports =
|
||||||
@@ -16,10 +17,14 @@ const featureImports =
|
|||||||
}),
|
}),
|
||||||
StravaModule,
|
StravaModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
|
McpModule,
|
||||||
];
|
];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule.forRoot({ isGlobal: true }), ...featureImports],
|
imports: [
|
||||||
|
ConfigModule.forRoot({ envFilePath: ['.env', '../.env'], isGlobal: true }),
|
||||||
|
...featureImports,
|
||||||
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
})
|
})
|
||||||
|
|||||||
26
api/src/mcp-http-main.ts
Normal file
26
api/src/mcp-http-main.ts
Normal 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
25
api/src/mcp-main.ts
Normal 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);
|
||||||
|
});
|
||||||
285
api/src/mcp/mcp-data.service.ts
Normal file
285
api/src/mcp/mcp-data.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
316
api/src/mcp/mcp-server.service.ts
Normal file
316
api/src/mcp/mcp-server.service.ts
Normal 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
26
api/src/mcp/mcp.module.ts
Normal 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 {}
|
||||||
@@ -41,6 +41,10 @@ import { TokenCryptoService } from './token-crypto.service';
|
|||||||
StravaStreamImportService,
|
StravaStreamImportService,
|
||||||
StravaSyncService,
|
StravaSyncService,
|
||||||
],
|
],
|
||||||
exports: [StravaStreamNormalizerService, StravaStreamImportService],
|
exports: [
|
||||||
|
StravaStreamNormalizerService,
|
||||||
|
StravaStreamImportService,
|
||||||
|
StravaSyncService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class StravaModule {}
|
export class StravaModule {}
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ server {
|
|||||||
proxy_pass http://127.0.0.1:3000;
|
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 / {
|
location / {
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/fd/2
|
stderr_logfile=/dev/fd/2
|
||||||
stderr_logfile_maxbytes=0
|
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]
|
[program:nginx]
|
||||||
command=nginx -g "daemon off;"
|
command=nginx -g "daemon off;"
|
||||||
autorestart=true
|
autorestart=true
|
||||||
|
|||||||
Reference in New Issue
Block a user