8 Commits

Author SHA1 Message Date
matthias 7c8061eeea chore: rename strava-mcp-server package to strava-mcp-server-hnrx in uv.lock
CI/CD Pipeline / Lint & Check (push) Successful in 9s
CI/CD Pipeline / Publish to PyPI (push) Successful in 14s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m20s
2026-05-14 20:10:39 +02:00
matthias 7d0364e0ed chore: update landing page copy, improve code formatting, and add PyPI publish workflow
CI/CD Pipeline / Lint & Check (push) Failing after 7s
Deploy Website to S3 / deploy (push) Successful in 6s
CI/CD Pipeline / Publish to PyPI (push) Has been skipped
CI/CD Pipeline / Build & Push Docker Image (push) Has been skipped
2026-05-14 20:10:27 +02:00
matthias 2223a2aafa feat: add repository link to navigation menu with localization support
CI/CD Pipeline / Lint & Check (push) Successful in 10s
Deploy Website to S3 / deploy (push) Successful in 6s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m21s
2026-05-14 19:46:38 +02:00
matthias 63d41ed9db chore: update S3 deployment configuration to use specific endpoint and path-style addressing
CI/CD Pipeline / Lint & Check (push) Successful in 9s
Deploy Website to S3 / deploy (push) Successful in 7s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m20s
2026-05-14 19:42:04 +02:00
matthias 94e7cd6a8c feat: add project landing page and automated deployment workflow
CI/CD Pipeline / Lint & Check (push) Successful in 10s
Deploy Website to S3 / deploy (push) Failing after 48s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m19s
2026-05-14 19:33:59 +02:00
matthias b463b2eeb8 refactor: simplify athlete profile formatting and export full API response in tool output, plus add AGENTS.md documentation
CI/CD Pipeline / Lint & Check (push) Successful in 9s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m20s
2026-05-13 01:21:16 +02:00
matthias 99fd37fc12 test: implement unit testing suite with pytest and add pre-push verification hook
CI/CD Pipeline / Lint & Check (push) Successful in 10s
CI/CD Pipeline / Build & Push Docker Image (push) Successful in 1m21s
2026-05-13 00:12:32 +02:00
matthias 8e9e4c01d4 style: refactor codebase to adhere to PEP 8 formatting standards throughout all source files 2026-05-12 23:55:58 +02:00
29 changed files with 1412 additions and 192 deletions
+22
View File
@@ -34,6 +34,28 @@ jobs:
- name: Run Ruff (Lint & Syntax Check)
run: uv run ruff check src
publish-pypi:
name: Publish to PyPI
needs: lint
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v2
with:
version: "latest"
- name: Build package
run: uv build
- name: Publish to PyPI
run: uv publish --token ${{ secrets.PYPI_TOKEN }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
build-and-push:
name: Build & Push Docker Image
needs: lint
+29
View File
@@ -0,0 +1,29 @@
name: Deploy Website to S3
on:
push:
branches:
- main
paths:
- 'website/**'
- '.gitea/workflows/deploy-website.yaml'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Sync to S3
uses: https://github.com/jakejarvis/s3-sync-action@master
with:
args: --acl public-read --follow-symlinks --delete
env:
AWS_S3_BUCKET: ${{ secrets.S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_S3_ENDPOINT: "https://s3.hnrx.net"
AWS_S3_FORCE_PATH_STYLE: 'true'
AWS_REGION: 'garage'
SOURCE_DIR: 'website'
+5
View File
@@ -0,0 +1,5 @@
Das Git Repo zu dem Projekt:
"Strava MCP Server"
findest du hier: https://git.hnrx.net/hnrx/strava-mcp-server
Issues: https://git.hnrx.net/hnrx/strava-mcp-server/issues
+46
View File
@@ -32,6 +32,8 @@ Built with [FastMCP](https://github.com/jlowin/fastmcp) and the [MCP Python SDK]
- [Project Structure](#project-structure)
- [Design Decisions](#design-decisions)
- [CI/CD (Gitea Actions)](#cicd-gitea-actions)
- [Development & Testing](#-development--testing)
- [Git Hooks](#git-hooks)
- [Known Strava API Limitations](#known-strava-api-limitations)
- [Troubleshooting](#troubleshooting)
@@ -226,6 +228,50 @@ docker buildx build --platform linux/amd64,linux/arm64 -t strava-mcp-server:test
---
### 5. Git Hooks
Two hooks are provided in `scripts/hooks/` — install them both after cloning:
```bash
cp scripts/hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
cp scripts/hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
```
#### `pre-commit` — Lint & Format check
Runs on staged `.py` files before every commit.
- 🔍 `ruff check` — linting
- 🎨 `ruff format --check` — formatting
- 🔧 Fix: `uv run ruff check --fix` + `uv run ruff format`
- ⚡ Bypass: `git commit --no-verify`
#### `pre-push` — Unit tests
Runs the full unit test suite before every push.
- 🧪 `pytest tests/unit/ -q`
- ⚡ Bypass: `git push --no-verify`
### 6. Unit Tests
Fast, offline unit tests for pure functions and MCP helpers (no Strava API required).
```bash
# Run all unit tests
uv run pytest tests/unit/ -v
# Run with coverage (if pytest-cov is installed)
uv run pytest tests/unit/ --cov=strava_mcp_server
```
**Test coverage:**
| Module | Tests |
|--------|-------|
| `utils.py``parse_iso_to_unix` | `None`, empty, invalid, UTC, offset, date-only |
| `utils.py``format_date_iso` | Normalization, `None`, datetime object, invalid |
| `utils.py``format_date_human` | German format, `None`, datetime object, regex pattern |
| `tools/*``_resource()` | Type, mimeType, URI, JSON validity, audience |
| `tools/*``_user_text()` | Type, text value, audience |
---
## License
MIT
+11 -1
View File
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "strava-mcp-server"
name = "strava-mcp-server-hnrx"
dynamic = ["version"]
description = "A Model Context Protocol (MCP) server that exposes the Strava API v3 as tools, resources, and prompts for AI agents."
readme = "README.md"
@@ -46,7 +46,17 @@ auth = "strava_mcp_server.get_token:main"
[dependency-groups]
dev = [
"ruff>=0.15.12",
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-sugar>=1.1.1",
]
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.wheel]
packages = ["src/strava_mcp_server"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# pre-commit hook: runs ruff check (lint) and ruff format --check on staged Python files
# To bypass: git commit --no-verify
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')
if [ -z "$STAGED_FILES" ]; then
exit 0
fi
FAILED=0
echo "🔍 Running ruff check (lint)..."
uv run ruff check $STAGED_FILES
if [ $? -ne 0 ]; then
echo " ↳ Fix with: uv run ruff check --fix"
FAILED=1
fi
echo "🎨 Running ruff format --check..."
uv run ruff format --check $STAGED_FILES
if [ $? -ne 0 ]; then
echo " ↳ Fix with: uv run ruff format"
FAILED=1
fi
if [ $FAILED -ne 0 ]; then
echo ""
echo "❌ Pre-commit checks failed. Commit aborted."
echo " To bypass: git commit --no-verify"
exit 1
fi
echo "✅ All checks passed."
exit 0
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# pre-push hook: runs unit tests before every git push
# To bypass: git push --no-verify
echo "🧪 Running unit tests..."
uv run pytest tests/unit/ -q
exit_code=$?
if [ $exit_code -ne 0 ]; then
echo ""
echo "❌ Unit tests failed. Push aborted."
echo " To bypass: git push --no-verify"
exit 1
fi
echo "✅ All unit tests passed."
exit 0
+1
View File
@@ -1,2 +1,3 @@
"""Strava MCP Server"""
__version__ = "0.1.0"
+10 -6
View File
@@ -11,6 +11,7 @@ Required scopes:
Usage: uv run get_token.py
"""
import os
import webbrowser
import httpx
@@ -25,6 +26,7 @@ CLIENT_SECRET = os.getenv("STRAVA_CLIENT_SECRET")
REDIRECT_URI = "http://localhost:8765/callback"
SCOPES = "profile:read_all,activity:read_all,activity:read,profile:write"
class CallbackHandler(BaseHTTPRequestHandler):
client_id: str = ""
client_secret: str = ""
@@ -50,14 +52,15 @@ class CallbackHandler(BaseHTTPRequestHandler):
)
response.raise_for_status()
self.tokens = response.json()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
refresh_token = self.tokens.get("refresh_token")
self.wfile.write(f"""
self.wfile.write(
f"""
<html>
<head>
<style>
@@ -103,7 +106,8 @@ STRAVA_REFRESH_TOKEN={refresh_token}</pre>
</p>
</body>
</html>
""".encode("utf-8"))
""".encode("utf-8")
)
except Exception as e:
self.error = str(e)
self.send_response(500)
@@ -176,7 +180,7 @@ def main():
print(f"STRAVA_CLIENT_SECRET={CLIENT_SECRET}")
print(f"STRAVA_REFRESH_TOKEN={refresh_token}")
print("-" * 40)
# Optional: Automatically update .env if it exists
try:
env_path = ".env"
+5 -3
View File
@@ -24,9 +24,11 @@ def validate_credentials() -> None:
print(f" - {key}")
print("\nCopy .env.example to .env and fill in your Strava API credentials.")
sys.exit(1)
if not os.getenv("STRAVA_REFRESH_TOKEN"):
print("️ No STRAVA_REFRESH_TOKEN found. Server starting in unauthenticated mode.")
print(
"️ No STRAVA_REFRESH_TOKEN found. Server starting in unauthenticated mode."
)
print(" Run 'uv run auth' on your local machine to authenticate.")
@@ -54,7 +56,7 @@ def main() -> None:
streamable_http_path="/mcp",
stateless_http=True,
)
try:
mcp._mcp_server.version = importlib.metadata.version("strava-mcp-server")
except importlib.metadata.PackageNotFoundError:
+61 -21
View File
@@ -37,8 +37,10 @@ class StravaClient:
async def get_valid_token(self) -> str:
"""Returns a valid access token, refreshing it if necessary."""
if not self.refresh_token:
raise ValueError("No Strava refresh token found. Please run 'uv run auth' on your local machine to authenticate first.")
raise ValueError(
"No Strava refresh token found. Please run 'uv run auth' on your local machine to authenticate first."
)
if not self.access_token or time.time() > self.expires_at - 60:
await self._refresh_access_token()
return self.access_token
@@ -99,15 +101,27 @@ class StravaClient:
"""Gets the heart rate and power zones for a specific activity."""
return await self._get(f"activities/{activity_id}/zones")
async def get_activity_comments(self, activity_id: int, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
async def get_activity_comments(
self, activity_id: int, page: int = 1, per_page: int = 30
) -> List[Dict[str, Any]]:
"""Gets comments on a specific activity."""
return await self._get(f"activities/{activity_id}/comments", params={"page": page, "per_page": per_page})
return await self._get(
f"activities/{activity_id}/comments",
params={"page": page, "per_page": per_page},
)
async def get_activity_kudoers(self, activity_id: int, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
async def get_activity_kudoers(
self, activity_id: int, page: int = 1, per_page: int = 30
) -> List[Dict[str, Any]]:
"""Gets athletes who kudoed a specific activity."""
return await self._get(f"activities/{activity_id}/kudos", params={"page": page, "per_page": per_page})
return await self._get(
f"activities/{activity_id}/kudos",
params={"page": page, "per_page": per_page},
)
async def get_activity_streams(self, activity_id: int, keys: List[str]) -> Dict[str, Any]:
async def get_activity_streams(
self, activity_id: int, keys: List[str]
) -> Dict[str, Any]:
"""Gets data streams for a specific activity."""
return await self._get(
f"activities/{activity_id}/streams",
@@ -116,28 +130,45 @@ class StravaClient:
# ── Clubs ─────────────────────────────────────────────────────────────────
async def get_athlete_clubs(self, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
async def get_athlete_clubs(
self, page: int = 1, per_page: int = 30
) -> List[Dict[str, Any]]:
"""Gets clubs the current athlete belongs to."""
return await self._get("athlete/clubs", params={"page": page, "per_page": per_page})
return await self._get(
"athlete/clubs", params={"page": page, "per_page": per_page}
)
async def get_club(self, club_id: int) -> Dict[str, Any]:
"""Gets a specific club by ID."""
return await self._get(f"clubs/{club_id}")
async def get_club_activities(self, club_id: int, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
async def get_club_activities(
self, club_id: int, page: int = 1, per_page: int = 30
) -> List[Dict[str, Any]]:
"""Gets recent activities from a club."""
return await self._get(f"clubs/{club_id}/activities", params={"page": page, "per_page": per_page})
return await self._get(
f"clubs/{club_id}/activities", params={"page": page, "per_page": per_page}
)
async def get_club_members(self, club_id: int, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
async def get_club_members(
self, club_id: int, page: int = 1, per_page: int = 30
) -> List[Dict[str, Any]]:
"""Gets members of a club."""
return await self._get(f"clubs/{club_id}/members", params={"page": page, "per_page": per_page})
return await self._get(
f"clubs/{club_id}/members", params={"page": page, "per_page": per_page}
)
# ── Routes ────────────────────────────────────────────────────────────────
async def get_routes_by_athlete(self, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
async def get_routes_by_athlete(
self, page: int = 1, per_page: int = 30
) -> List[Dict[str, Any]]:
"""Gets routes created by the current athlete."""
athlete = await self.get_athlete()
return await self._get(f"athletes/{athlete['id']}/routes", params={"page": page, "per_page": per_page})
return await self._get(
f"athletes/{athlete['id']}/routes",
params={"page": page, "per_page": per_page},
)
async def get_route_by_id(self, route_id: str) -> Dict[str, Any]:
"""Gets a specific route by its ID."""
@@ -153,19 +184,26 @@ class StravaClient:
"""Gets a specific segment by ID."""
return await self._get(f"segments/{segment_id}")
async def get_starred_segments(self, page: int = 1, per_page: int = 30) -> List[Dict[str, Any]]:
async def get_starred_segments(
self, page: int = 1, per_page: int = 30
) -> List[Dict[str, Any]]:
"""Gets segments starred by the current athlete."""
return await self._get("segments/starred", params={"page": page, "per_page": per_page})
return await self._get(
"segments/starred", params={"page": page, "per_page": per_page}
)
async def explore_segments(self, bounds: str, activity_type: str = "riding") -> Dict[str, Any]:
async def explore_segments(
self, bounds: str, activity_type: str = "riding"
) -> Dict[str, Any]:
"""Explores segments within a given bounding box."""
return await self._get(
"segments/explore",
params={"bounds": bounds, "activity_type": activity_type},
)
async def get_segment_streams(self, segment_id: int, keys: List[str]) -> Dict[str, Any]:
async def get_segment_streams(
self, segment_id: int, keys: List[str]
) -> Dict[str, Any]:
"""Gets data streams for a specific segment."""
return await self._get(
f"segments/{segment_id}/streams",
@@ -193,7 +231,9 @@ class StravaClient:
params["end_date_local"] = end_date_local
return await self._get("segment_efforts", params=params)
async def get_segment_effort_streams(self, effort_id: str, keys: List[str]) -> Dict[str, Any]:
async def get_segment_effort_streams(
self, effort_id: str, keys: List[str]
) -> Dict[str, Any]:
"""Gets data streams for a specific segment effort."""
return await self._get(
f"segment_efforts/{effort_id}/streams",
+2
View File
@@ -3,6 +3,7 @@ MCP Tool definitions for the Strava MCP Server.
Register all tools by calling register_tools(mcp, strava).
"""
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
@@ -15,6 +16,7 @@ from . import segment_efforts
from . import gear
from . import prompts
def register_tools(mcp: FastMCP, strava: StravaClient) -> None:
"""Register all available tools and prompts."""
athlete.register(mcp, strava)
+95 -30
View File
@@ -2,7 +2,12 @@ import json
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent, Annotations, EmbeddedResource, TextResourceContents
from strava_mcp_server.strava_client import StravaClient
from strava_mcp_server.utils import parse_iso_to_unix, format_date_iso, format_date_human
from strava_mcp_server.utils import (
parse_iso_to_unix,
format_date_iso,
format_date_human,
)
def _resource(uri: str, data) -> EmbeddedResource:
"""Helper: return an assistant-facing EmbeddedResource with application/json."""
@@ -16,9 +21,13 @@ def _resource(uri: str, data) -> EmbeddedResource:
annotations=Annotations(audience=["assistant"]),
)
def _user_text(text: str) -> TextContent:
"""Helper: return a user-facing TextContent."""
return TextContent(type="text", text=text, annotations=Annotations(audience=["user"]))
return TextContent(
type="text", text=text, annotations=Annotations(audience=["user"])
)
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
@@ -49,27 +58,39 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
essential_data = []
for a in activities:
start_date_raw = a.get("start_date")
essential_data.append({
"id": a["id"],
"name": a["name"],
"sport_type": a.get("sport_type") or a.get("type"),
"start_date": format_date_iso(start_date_raw),
"start_date_local": format_date_human(start_date_raw),
"distance": f"{a.get('distance', 0) / 1000:.2f} km",
"moving_time": f"{a.get('moving_time', 0) / 60:.1f} min",
"total_elevation_gain": f"{a.get('total_elevation_gain', 0):.0f} m",
"average_heartrate": a.get("average_heartrate"),
"gear_id": a.get("gear_id"),
})
essential_data.append(
{
"id": a["id"],
"name": a["name"],
"sport_type": a.get("sport_type") or a.get("type"),
"start_date": format_date_iso(start_date_raw),
"start_date_local": format_date_human(start_date_raw),
"distance": f"{a.get('distance', 0) / 1000:.2f} km",
"moving_time": f"{a.get('moving_time', 0) / 60:.1f} min",
"total_elevation_gain": f"{a.get('total_elevation_gain', 0):.0f} m",
"average_heartrate": a.get("average_heartrate"),
"gear_id": a.get("gear_id"),
}
)
if not essential_data:
markdown_summary = "### 📭 Keine Aktivitäten in diesem Zeitraum gefunden."
markdown_summary = (
"### 📭 Keine Aktivitäten in diesem Zeitraum gefunden."
)
else:
markdown_summary = f"### 🚴 Aktivitäten (Seite {page})\n"
markdown_summary += "| Datum | Sport | Name | Distanz | Zeit | Höhenmeter | Ø HR |\n"
markdown_summary += "|-------|-------|------|---------|------|------------|------|\n"
markdown_summary += (
"| Datum | Sport | Name | Distanz | Zeit | Höhenmeter | Ø HR |\n"
)
markdown_summary += (
"|-------|-------|------|---------|------|------------|------|\n"
)
for a in essential_data:
hr = f"{a['average_heartrate']:.0f} bpm" if a['average_heartrate'] else "-"
hr = (
f"{a['average_heartrate']:.0f} bpm"
if a["average_heartrate"]
else "-"
)
markdown_summary += f"| {a['start_date_local']} | {a['sport_type']} | {a['name']} | {a['distance']} | {a['moving_time']} | {a['total_elevation_gain']} | {hr} |\n"
return [
@@ -106,10 +127,26 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
dist = f"{activity.get('distance', 0) / 1000:.2f} km"
time = f"{activity.get('moving_time', 0) / 60:.1f} min"
elev = f"{activity.get('total_elevation_gain', 0):.0f} m"
avg_hr = f"{activity.get('average_heartrate', 0):.0f} bpm" if activity.get("average_heartrate") else "N/A"
max_hr = f"{activity.get('max_heartrate', 0):.0f} bpm" if activity.get("max_heartrate") else "N/A"
avg_spd = f"{activity.get('average_speed', 0) * 3.6:.1f} km/h" if activity.get("average_speed") else "N/A"
avg_w = f"{activity.get('average_watts', 0):.0f} W" if activity.get("average_watts") else "N/A"
avg_hr = (
f"{activity.get('average_heartrate', 0):.0f} bpm"
if activity.get("average_heartrate")
else "N/A"
)
max_hr = (
f"{activity.get('max_heartrate', 0):.0f} bpm"
if activity.get("max_heartrate")
else "N/A"
)
avg_spd = (
f"{activity.get('average_speed', 0) * 3.6:.1f} km/h"
if activity.get("average_speed")
else "N/A"
)
avg_w = (
f"{activity.get('average_watts', 0):.0f} W"
if activity.get("average_watts")
else "N/A"
)
gear = activity.get("gear_id") or "N/A"
n_efforts = len(activity.get("segment_efforts", []))
@@ -133,7 +170,11 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
_resource(f"internal://activities/{activity_id}", activity),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching activity details: {str(e)}")]
return [
TextContent(
type="text", text=f"Error fetching activity details: {str(e)}"
)
]
@mcp.tool()
async def get_activity_comments(activity_id: int, limit: int = 30):
@@ -159,7 +200,10 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
md = f"### 💬 Kommentare ({len(data)})\n"
for c in data:
md += f"- **{c['athlete']}** ({format_date_human(c['created_at'])}): {c['text']}\n"
return [_user_text(md.strip()), _resource(f"internal://activities/{activity_id}/comments", data)]
return [
_user_text(md.strip()),
_resource(f"internal://activities/{activity_id}/comments", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching comments: {str(e)}")]
@@ -189,7 +233,10 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
for k in data:
loc = ", ".join(filter(None, [k["city"], k["country"]])) or "N/A"
md += f"| {k['name']} | {loc} |\n"
return [_user_text(md.strip()), _resource(f"internal://activities/{activity_id}/kudoers", data)]
return [
_user_text(md.strip()),
_resource(f"internal://activities/{activity_id}/kudoers", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching kudoers: {str(e)}")]
@@ -224,9 +271,16 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
md += "| # | Distanz | Zeit | Ø Speed | Ø HR |\n"
md += "|---|---------|------|---------|------|\n"
for lap in data:
hr = f"{lap['average_heartrate']:.0f} bpm" if lap['average_heartrate'] else "-"
hr = (
f"{lap['average_heartrate']:.0f} bpm"
if lap["average_heartrate"]
else "-"
)
md += f"| {lap['lap_index']} | {lap['distance']} | {lap['moving_time']} | {lap['average_speed']} | {hr} |\n"
return [_user_text(md.strip()), _resource(f"internal://activities/{activity_id}/laps", data)]
return [
_user_text(md.strip()),
_resource(f"internal://activities/{activity_id}/laps", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching laps: {str(e)}")]
@@ -260,15 +314,26 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"distribution_buckets": buckets,
}
data.append(zone_data)
label = "Herzfrequenz" if zone_data["type"] == "heartrate" else "Leistung (Power)"
label = (
"Herzfrequenz"
if zone_data["type"] == "heartrate"
else "Leistung (Power)"
)
md += f"#### {label}\n| Zone | Bereich | Zeit |\n|------|---------|------|\n"
for b in buckets:
max_val = "max" if b["max"] == -1 else str(b["max"])
md += f"| {b['zone']} | {b['min']} {max_val} | {b['time_in_zone']} |\n"
md += "\n"
return [_user_text(md.strip()), _resource(f"internal://activities/{activity_id}/zones", data)]
return [
_user_text(md.strip()),
_resource(f"internal://activities/{activity_id}/zones", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching activity zones: {str(e)}")]
return [
TextContent(
type="text", text=f"Error fetching activity zones: {str(e)}"
)
]
@mcp.tool()
async def get_activity_streams(
+45 -51
View File
@@ -2,7 +2,8 @@ import json
from mcp.server.fastmcp import FastMCP, Context
from mcp.types import TextContent, Annotations, EmbeddedResource, TextResourceContents
from strava_mcp_server.strava_client import StravaClient
from strava_mcp_server.utils import format_date_iso, format_date_human
from strava_mcp_server.utils import format_date_human
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.tool()
@@ -12,59 +13,52 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
Returns name, city, country, follower count, and other profile details.
"""
try:
await ctx.info("Fetching athlete profile...")
athlete = await strava.get_athlete()
location_parts = [p for p in (athlete.get('city'), athlete.get('state'), athlete.get('country')) if p]
location_parts = [
p
for p in (
athlete.get("city"),
athlete.get("state"),
athlete.get("country"),
)
if p
]
location = ", ".join(location_parts) if location_parts else "N/A"
essential_data = {
"id": athlete.get("id"),
"username": athlete.get("username"),
"name": f"{athlete.get('firstname')} {athlete.get('lastname')}".strip(),
"location": location,
"sex": athlete.get("sex"),
"weight": athlete.get("weight"),
"measurement_units": athlete.get("measurement_preference"),
"is_premium": athlete.get("premium", False),
"profile_medium": athlete.get("profile_medium"),
"created_at": format_date_iso(athlete.get("created_at")),
"updated_at": format_date_iso(athlete.get("updated_at")),
"bio": athlete.get("bio"),
"follower_count": athlete.get("follower_count"),
"friend_count": athlete.get("friend_count"),
}
full_name = (
f"{athlete.get('firstname', '')} {athlete.get('lastname', '')}".strip()
)
markdown_summary = f"""
👤 **Profile for {essential_data['name']}** (ID: {essential_data['id']})
- Username: {essential_data['username'] or 'N/A'}
- Location: {essential_data['location']}
- Sex: {essential_data['sex'] or 'N/A'}
- Weight: {essential_data['weight'] or 'N/A'} kg
- Measurement Units: {essential_data['measurement_units'] or 'N/A'}
- Strava Summit Member: {'Yes' if essential_data['is_premium'] else 'No'}
- Profile Image (Medium): {essential_data['profile_medium'] or 'N/A'}
- Joined Strava: {format_date_human(essential_data['created_at'])}
- Last Updated: {format_date_human(essential_data['updated_at'])}
👤 **Profile for {full_name}** (ID: {athlete.get("id")})
- Username: {athlete.get("username") or "N/A"}
- Location: {location}
- Sex: {athlete.get("sex") or "N/A"}
- Weight: {athlete.get("weight") or "N/A"} kg
- Measurement Units: {athlete.get("measurement_preference") or "N/A"}
- Strava Summit Member: {"Yes" if athlete.get("premium") else "No"}
- Profile Image (Medium): {athlete.get("profile_medium") or "N/A"}
- Joined Strava: {format_date_human(athlete.get("created_at"))}
- Last Updated: {format_date_human(athlete.get("updated_at"))}
""".strip()
return [
TextContent(
type="text",
type="text",
text=markdown_summary,
annotations=Annotations(audience=["user"])
annotations=Annotations(audience=["user"]),
),
EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="internal://athlete/profile",
mimeType="application/json",
text=json.dumps(essential_data, indent=2)
text=json.dumps(athlete, indent=2),
),
annotations=Annotations(audience=["assistant"])
)
annotations=Annotations(audience=["assistant"]),
),
]
except Exception as e:
@@ -81,9 +75,9 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
try:
await ctx.info("Fetching athlete zones...")
zones = await strava.get_athlete_zones()
markdown_summary = "### 💓 Trainingszonen\n\n"
# Heart Rate Zones
hr_zones = zones.get("heart_rate", {}).get("zones", [])
if hr_zones:
@@ -91,9 +85,9 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
markdown_summary += "| Zone | Bereich (bpm) |\n"
markdown_summary += "|------|---------------|\n"
for i, z in enumerate(hr_zones):
markdown_summary += f"| {i+1} | {z.get('min')} - {z.get('max') if z.get('max') != -1 else 'max'} |\n"
markdown_summary += f"| {i + 1} | {z.get('min')} - {z.get('max') if z.get('max') != -1 else 'max'} |\n"
markdown_summary += "\n"
# Power Zones
power_zones = zones.get("power", {}).get("zones", [])
if power_zones:
@@ -101,26 +95,26 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
markdown_summary += "| Zone | Bereich (W) |\n"
markdown_summary += "|------|-------------|\n"
for i, z in enumerate(power_zones):
markdown_summary += f"| {i+1} | {z.get('min')} - {z.get('max') if z.get('max') != -1 else 'max'} |\n"
markdown_summary += f"| {i + 1} | {z.get('min')} - {z.get('max') if z.get('max') != -1 else 'max'} |\n"
if not hr_zones and not power_zones:
markdown_summary = "⚠️ Keine Trainingszonen konfiguriert."
return [
TextContent(
type="text",
type="text",
text=markdown_summary.strip(),
annotations=Annotations(audience=["user"])
annotations=Annotations(audience=["user"]),
),
EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="internal://athlete/zones",
mimeType="application/json",
text=json.dumps(zones, indent=2)
text=json.dumps(zones, indent=2),
),
annotations=Annotations(audience=["assistant"])
)
annotations=Annotations(audience=["assistant"]),
),
]
except Exception as e:
error_msg = f"Error fetching athlete zones: {str(e)}"
@@ -163,7 +157,7 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
}
markdown_summary = "### 📈 Trainingsstatistiken\n\n"
def create_table(title: str, data: dict):
tbl = f"#### {title}\n"
tbl += "| Sport | Aktivitäten | Distanz | Zeit | Höhenmeter |\n"
@@ -179,19 +173,19 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
return [
TextContent(
type="text",
type="text",
text=markdown_summary.strip(),
annotations=Annotations(audience=["user"])
annotations=Annotations(audience=["user"]),
),
EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="internal://athlete/stats",
mimeType="application/json",
text=json.dumps(stats, indent=2)
text=json.dumps(stats, indent=2),
),
annotations=Annotations(audience=["assistant"])
)
annotations=Annotations(audience=["assistant"]),
),
]
except Exception as e:
error_msg = f"Error fetching athlete stats: {str(e)}"
+23 -6
View File
@@ -7,12 +7,17 @@ from strava_mcp_server.strava_client import StravaClient
def _resource(uri: str, data) -> EmbeddedResource:
return EmbeddedResource(
type="resource",
resource=TextResourceContents(uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)),
resource=TextResourceContents(
uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)
),
annotations=Annotations(audience=["assistant"]),
)
def _user_text(text: str) -> TextContent:
return TextContent(type="text", text=text, annotations=Annotations(audience=["user"]))
return TextContent(
type="text", text=text, annotations=Annotations(audience=["user"])
)
def register(mcp: FastMCP, strava: StravaClient) -> None:
@@ -88,9 +93,16 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
md += "|--------|-------|------|---------|------|\n"
for a in data:
md += f"| {a['athlete']} | {a['sport_type']} | {a['name']} | {a['distance']} | {a['moving_time']} |\n"
return [_user_text(md.strip()), _resource(f"internal://clubs/{club_id}/activities", data)]
return [
_user_text(md.strip()),
_resource(f"internal://clubs/{club_id}/activities", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching club activities: {str(e)}")]
return [
TextContent(
type="text", text=f"Error fetching club activities: {str(e)}"
)
]
@mcp.tool()
async def get_club_members(club_id: int, limit: int = 30):
@@ -118,6 +130,11 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
for m in data:
loc = ", ".join(filter(None, [m["city"], m["country"]])) or "N/A"
md += f"| {m['name']} | {loc} |\n"
return [_user_text(md.strip()), _resource(f"internal://clubs/{club_id}/members", data)]
return [
_user_text(md.strip()),
_resource(f"internal://clubs/{club_id}/members", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching club members: {str(e)}")]
return [
TextContent(type="text", text=f"Error fetching club members: {str(e)}")
]
+21 -10
View File
@@ -7,12 +7,17 @@ from strava_mcp_server.strava_client import StravaClient
def _resource(uri: str, data) -> EmbeddedResource:
return EmbeddedResource(
type="resource",
resource=TextResourceContents(uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)),
resource=TextResourceContents(
uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)
),
annotations=Annotations(audience=["assistant"]),
)
def _user_text(text: str) -> TextContent:
return TextContent(type="text", text=text, annotations=Annotations(audience=["user"]))
return TextContent(
type="text", text=text, annotations=Annotations(audience=["user"])
)
def register(mcp: FastMCP, strava: StravaClient) -> None:
@@ -38,18 +43,24 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"primary": g.get("primary", False),
"retired": g.get("retired", False),
}
brand_model = " ".join(filter(None, [data["brand_name"], data["model_name"]])) or "N/A"
md = f"""### 🚲 Ausrüstung: {data['name'] or gear_id}
brand_model = (
" ".join(filter(None, [data["brand_name"], data["model_name"]]))
or "N/A"
)
md = f"""### 🚲 Ausrüstung: {data["name"] or gear_id}
| Feld | Wert |
|------|------|
| Marke / Modell | {brand_model} |
| Spitzname | {data['nickname'] or 'N/A'} |
| Typ | {data['type'] or 'N/A'} |
| Gesamt-Distanz | {data['distance']} |
| Primär | {'✅ Ja' if data['primary'] else 'Nein'} |
| Im Ruhestand | {'🛑 Ja' if data['retired'] else 'Nein'} |"""
| Spitzname | {data["nickname"] or "N/A"} |
| Typ | {data["type"] or "N/A"} |
| Gesamt-Distanz | {data["distance"]} |
| Primär | {"✅ Ja" if data["primary"] else "Nein"} |
| Im Ruhestand | {"🛑 Ja" if data["retired"] else "Nein"} |"""
if data["description"]:
md += f"\n\n_{data['description']}_"
return [_user_text(md.strip()), _resource(f"internal://gear/{gear_id}", data)]
return [
_user_text(md.strip()),
_resource(f"internal://gear/{gear_id}", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching gear: {str(e)}")]
+6 -2
View File
@@ -1,6 +1,7 @@
from mcp.server.fastmcp import FastMCP
from strava_mcp_server.strava_client import StravaClient
def register(mcp: FastMCP, strava: StravaClient) -> None:
@mcp.prompt()
def analyze_activity(activity_id: str) -> str:
@@ -30,8 +31,11 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
Fetches recent activities and athlete stats to produce a summary report.
"""
from datetime import datetime, timedelta, timezone
after_dt = (datetime.now(timezone.utc) - timedelta(weeks=weeks)).replace(hour=0, minute=0, second=0, microsecond=0)
after_iso = after_dt.isoformat().replace('+00:00', 'Z')
after_dt = (datetime.now(timezone.utc) - timedelta(weeks=weeks)).replace(
hour=0, minute=0, second=0, microsecond=0
)
after_iso = after_dt.isoformat().replace("+00:00", "Z")
return (
f"Please summarize my Strava training for the last {weeks} weeks.\n\n"
f"Use list_activities with after='{after_iso}' (ISO 8601) "
+28 -12
View File
@@ -7,12 +7,17 @@ from strava_mcp_server.strava_client import StravaClient
def _resource(uri: str, data) -> EmbeddedResource:
return EmbeddedResource(
type="resource",
resource=TextResourceContents(uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)),
resource=TextResourceContents(
uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)
),
annotations=Annotations(audience=["assistant"]),
)
def _user_text(text: str) -> TextContent:
return TextContent(type="text", text=text, annotations=Annotations(audience=["user"]))
return TextContent(
type="text", text=text, annotations=Annotations(audience=["user"])
)
def register(mcp: FastMCP, strava: StravaClient) -> None:
@@ -30,7 +35,11 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"id": str(r.get("id")),
"name": r.get("name"),
"description": r.get("description") or "",
"type": "Run" if r.get("type") == 1 else "Ride" if r.get("type") == 2 else "Other",
"type": "Run"
if r.get("type") == 1
else "Ride"
if r.get("type") == 2
else "Other",
"sub_type": r.get("sub_type"),
"distance": f"{r.get('distance', 0) / 1000:.2f} km",
"elevation_gain": f"{r.get('elevation_gain', 0):.0f} m",
@@ -66,7 +75,11 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"id": str(r.get("id")),
"name": r.get("name"),
"description": r.get("description") or "",
"type": "Run" if r.get("type") == 1 else "Ride" if r.get("type") == 2 else "Other",
"type": "Run"
if r.get("type") == 1
else "Ride"
if r.get("type") == 2
else "Other",
"distance": f"{r.get('distance', 0) / 1000:.2f} km",
"elevation_gain": f"{r.get('elevation_gain', 0):.0f} m",
"estimated_moving_time": f"{r.get('estimated_moving_time', 0) / 60:.0f} min",
@@ -83,19 +96,22 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
],
}
n_seg = len(data["segments"])
md = f"""### 🗺️ Route: {data['name']}
md = f"""### 🗺️ Route: {data["name"]}
| Feld | Wert |
|------|------|
| Typ | {data['type']} |
| Distanz | {data['distance']} |
| Höhenmeter | {data['elevation_gain']} |
| Geschätzte Dauer | {data['estimated_moving_time']} |
| Typ | {data["type"]} |
| Distanz | {data["distance"]} |
| Höhenmeter | {data["elevation_gain"]} |
| Geschätzte Dauer | {data["estimated_moving_time"]} |
| Segmente | {n_seg} |
| Favorit | {'⭐ Ja' if data['starred'] else 'Nein'} |
| Privat | {'🔒 Ja' if data['private'] else 'Nein'} |"""
| Favorit | {"⭐ Ja" if data["starred"] else "Nein"} |
| Privat | {"🔒 Ja" if data["private"] else "Nein"} |"""
if data["description"]:
md += f"\n\n_{data['description']}_"
return [_user_text(md.strip()), _resource(f"internal://routes/{route_id}", data)]
return [
_user_text(md.strip()),
_resource(f"internal://routes/{route_id}", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching route: {str(e)}")]
+35 -12
View File
@@ -8,12 +8,17 @@ from strava_mcp_server.utils import format_date_iso, format_date_human
def _resource(uri: str, data) -> EmbeddedResource:
return EmbeddedResource(
type="resource",
resource=TextResourceContents(uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)),
resource=TextResourceContents(
uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)
),
annotations=Annotations(audience=["assistant"]),
)
def _user_text(text: str) -> TextContent:
return TextContent(type="text", text=text, annotations=Annotations(audience=["user"]))
return TextContent(
type="text", text=text, annotations=Annotations(audience=["user"])
)
def register(mcp: FastMCP, strava: StravaClient) -> None:
@@ -43,21 +48,32 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
pr = f"#{data['pr_rank']}" if data["pr_rank"] else "N/A"
kom = f"#{data['kom_rank']}" if data["kom_rank"] else "N/A"
w = f"{data['average_watts']:.0f} W" if data["average_watts"] else "N/A"
hr = f"{data['average_heartrate']:.0f} bpm" if data["average_heartrate"] else "N/A"
md = f"""### 🏅 Segment-Effort: {data['name']}
hr = (
f"{data['average_heartrate']:.0f} bpm"
if data["average_heartrate"]
else "N/A"
)
md = f"""### 🏅 Segment-Effort: {data["name"]}
| Feld | Wert |
|------|------|
| Datum | {format_date_human(data['start_date'])} |
| Distanz | {data['distance']} |
| Zeit (gesamt) | {data['elapsed_time']} |
| Fahrzeit | {data['moving_time']} |
| Datum | {format_date_human(data["start_date"])} |
| Distanz | {data["distance"]} |
| Zeit (gesamt) | {data["elapsed_time"]} |
| Fahrzeit | {data["moving_time"]} |
| Ø Leistung | {w} |
| Ø Herzfrequenz | {hr} |
| PR-Rang | {pr} |
| KOM-Rang | {kom} |"""
return [_user_text(md.strip()), _resource(f"internal://segment_efforts/{effort_id}", data)]
return [
_user_text(md.strip()),
_resource(f"internal://segment_efforts/{effort_id}", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching segment effort: {str(e)}")]
return [
TextContent(
type="text", text=f"Error fetching segment effort: {str(e)}"
)
]
@mcp.tool()
async def list_segment_efforts(
@@ -101,9 +117,16 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
for effort in data:
pr = f"#{effort['pr_rank']}" if effort["pr_rank"] else "-"
md += f"| {format_date_human(effort['start_date'])} | {effort['elapsed_time']} | {effort['moving_time']} | {pr} |\n"
return [_user_text(md.strip()), _resource(f"internal://segments/{segment_id}/efforts", data)]
return [
_user_text(md.strip()),
_resource(f"internal://segments/{segment_id}/efforts", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching segment efforts: {str(e)}")]
return [
TextContent(
type="text", text=f"Error fetching segment efforts: {str(e)}"
)
]
@mcp.tool()
async def get_segment_effort_streams(
+39 -19
View File
@@ -7,12 +7,17 @@ from strava_mcp_server.strava_client import StravaClient
def _resource(uri: str, data) -> EmbeddedResource:
return EmbeddedResource(
type="resource",
resource=TextResourceContents(uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)),
resource=TextResourceContents(
uri=uri, mimeType="application/json", text=json.dumps(data, indent=2)
),
annotations=Annotations(audience=["assistant"]),
)
def _user_text(text: str) -> TextContent:
return TextContent(type="text", text=text, annotations=Annotations(audience=["user"]))
return TextContent(
type="text", text=text, annotations=Annotations(audience=["user"])
)
def register(mcp: FastMCP, strava: StravaClient) -> None:
@@ -43,22 +48,25 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
"country": s.get("country"),
}
loc = ", ".join(filter(None, [data["city"], data["country"]])) or "N/A"
md = f"""### 📍 Segment: {data['name']}
md = f"""### 📍 Segment: {data["name"]}
| Feld | Wert |
|------|------|
| Sport | {data['activity_type']} |
| Distanz | {data['distance']} |
| Ø Steigung | {data['average_grade']} |
| Max Steigung | {data['maximum_grade']} |
| Höhe (hoch) | {data['elevation_high']} |
| Höhe (tief) | {data['elevation_low']} |
| Höhenmeter | {data['total_elevation_gain']} |
| Versuche | {data['effort_count']} |
| Athleten | {data['athlete_count']} |
| KOM/QOM | {data['kom'] or 'N/A'} |
| Sport | {data["activity_type"]} |
| Distanz | {data["distance"]} |
| Ø Steigung | {data["average_grade"]} |
| Max Steigung | {data["maximum_grade"]} |
| Höhe (hoch) | {data["elevation_high"]} |
| Höhe (tief) | {data["elevation_low"]} |
| Höhenmeter | {data["total_elevation_gain"]} |
| Versuche | {data["effort_count"]} |
| Athleten | {data["athlete_count"]} |
| KOM/QOM | {data["kom"] or "N/A"} |
| Ort | {loc} |
| Favorit | {'⭐ Ja' if data['starred'] else 'Nein'} |"""
return [_user_text(md.strip()), _resource(f"internal://segments/{segment_id}", data)]
| Favorit | {"⭐ Ja" if data["starred"] else "Nein"} |"""
return [
_user_text(md.strip()),
_resource(f"internal://segments/{segment_id}", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching segment: {str(e)}")]
@@ -90,9 +98,16 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
md += "|------|-------|---------|------------|----------|\n"
for s in data:
md += f"| {s['name']} | {s['activity_type']} | {s['distance']} | {s['average_grade']} | {s['effort_count']} |\n"
return [_user_text(md.strip()), _resource("internal://segments/starred", data)]
return [
_user_text(md.strip()),
_resource("internal://segments/starred", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error fetching starred segments: {str(e)}")]
return [
TextContent(
type="text", text=f"Error fetching starred segments: {str(e)}"
)
]
@mcp.tool()
async def explore_segments(
@@ -127,9 +142,14 @@ def register(mcp: FastMCP, strava: StravaClient) -> None:
md += "|------|---------|------------|-----------|----------|\n"
for s in data:
md += f"| {s['name']} | {s['distance']} | {s['average_grade']} | {s['elevation_difference']} | {s['climb_category']} |\n"
return [_user_text(md.strip()), _resource("internal://segments/explore", data)]
return [
_user_text(md.strip()),
_resource("internal://segments/explore", data),
]
except Exception as e:
return [TextContent(type="text", text=f"Error exploring segments: {str(e)}")]
return [
TextContent(type="text", text=f"Error exploring segments: {str(e)}")
]
@mcp.tool()
async def get_segment_streams(
+12 -9
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone
from typing import Optional
def parse_iso_to_unix(iso_str: Optional[str]) -> Optional[int]:
"""
Parses an ISO 8601 string into a Unix timestamp.
@@ -10,9 +11,9 @@ def parse_iso_to_unix(iso_str: Optional[str]) -> Optional[int]:
return None
try:
# Remove 'Z' and replace with +00:00 for fromisoformat compatibility if needed
# but fromisoformat in Python 3.11+ handles Z correctly.
# but fromisoformat in Python 3.11+ handles Z correctly.
# For older versions or varied formats, we use a slightly more robust approach.
clean_iso = iso_str.replace('Z', '+00:00')
clean_iso = iso_str.replace("Z", "+00:00")
dt = datetime.fromisoformat(clean_iso)
# Ensure it has a timezone; default to UTC if missing
if dt.tzinfo is None:
@@ -21,40 +22,42 @@ def parse_iso_to_unix(iso_str: Optional[str]) -> Optional[int]:
except Exception:
return None
def format_date_iso(date_input: Optional[str | datetime]) -> str:
"""
Standardizes a date string or datetime object to ISO 8601 (UTC).
"""
if not date_input:
return "N/A"
try:
if isinstance(date_input, str):
# Strava dates are often '2024-01-01T12:00:00Z'
dt = datetime.fromisoformat(date_input.replace('Z', '+00:00'))
dt = datetime.fromisoformat(date_input.replace("Z", "+00:00"))
else:
dt = date_input
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat().replace('+00:00', 'Z')
return dt.isoformat().replace("+00:00", "Z")
except Exception:
return str(date_input)
def format_date_human(date_input: Optional[str | datetime]) -> str:
"""
Returns a human-readable date/time string (DD.MM.YYYY HH:MM).
"""
if not date_input:
return "N/A"
try:
if isinstance(date_input, str):
dt = datetime.fromisoformat(date_input.replace('Z', '+00:00'))
dt = datetime.fromisoformat(date_input.replace("Z", "+00:00"))
else:
dt = date_input
return dt.strftime("%d.%m.%Y %H:%M")
except Exception:
return str(date_input)
+16 -8
View File
@@ -3,37 +3,45 @@ import os
from strava_client import StravaClient
from dotenv import load_dotenv
async def test_connection():
load_dotenv()
client_id = os.getenv("STRAVA_CLIENT_ID")
client_secret = os.getenv("STRAVA_CLIENT_SECRET")
refresh_token = os.getenv("STRAVA_REFRESH_TOKEN")
if not all([client_id, client_secret, refresh_token]):
print("❌ Error: Missing Strava credentials in .env file.")
print("Please ensure STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET, and STRAVA_REFRESH_TOKEN are set.")
print(
"Please ensure STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET, and STRAVA_REFRESH_TOKEN are set."
)
return
client = StravaClient()
print("Testing Strava connection...")
try:
athlete = await client.get_athlete()
print(f"✅ Success! Connected as {athlete.get('firstname')} {athlete.get('lastname')}")
print(
f"✅ Success! Connected as {athlete.get('firstname')} {athlete.get('lastname')}"
)
print(f"Athlete ID: {athlete.get('id')}")
print("\nFetching recent activities...")
activities = await client.list_activities(limit=3)
print(f"Found {len(activities)} recent activities:")
for a in activities:
print(f"- {a['name']} ({a['type']}) on {a['start_date']}")
except Exception as e:
print(f"❌ Connection failed: {str(e)}")
if "401" in str(e):
print("Hint: This is likely a scope issue. Your token needs 'activity:read' permission.")
print(
"Hint: This is likely a scope issue. Your token needs 'activity:read' permission."
)
print("Run: uv run get_token.py to re-authorize with the correct scopes.")
if __name__ == "__main__":
asyncio.run(test_connection())
+1
View File
@@ -0,0 +1 @@
# This file marks the tests/unit directory as a Python package.
+63
View File
@@ -0,0 +1,63 @@
"""Unit tests for MCP content block helper functions."""
import json
from mcp.types import TextContent, EmbeddedResource
# Import helpers directly from a tool module to test them
from strava_mcp_server.tools.activities import _resource, _user_text
class TestUserTextHelper:
def test_returns_text_content(self):
result = _user_text("Hello World")
assert isinstance(result, TextContent)
def test_type_is_text(self):
result = _user_text("Hello")
assert result.type == "text"
def test_text_value(self):
result = _user_text("Test message")
assert result.text == "Test message"
def test_audience_is_user(self):
result = _user_text("Hello")
assert result.annotations is not None
assert result.annotations.audience == ["user"]
class TestResourceHelper:
def test_returns_embedded_resource(self):
result = _resource("internal://test/data", {"key": "value"})
assert isinstance(result, EmbeddedResource)
def test_type_is_resource(self):
result = _resource("internal://test/data", {"key": "value"})
assert result.type == "resource"
def test_mime_type_is_json(self):
result = _resource("internal://test/data", {"key": "value"})
assert result.resource.mimeType == "application/json"
def test_uri_is_set(self):
result = _resource("internal://athlete/profile", {"id": 1})
assert str(result.resource.uri) == "internal://athlete/profile"
def test_text_is_valid_json(self):
data = {"id": 42, "name": "Test Athlete", "active": True}
result = _resource("internal://test", data)
parsed = json.loads(result.resource.text)
assert parsed == data
def test_audience_is_assistant(self):
result = _resource("internal://test", {})
assert result.annotations is not None
assert result.annotations.audience == ["assistant"]
def test_list_data(self):
data = [{"id": 1}, {"id": 2}]
result = _resource("internal://activities/list", data)
parsed = json.loads(result.resource.text)
assert len(parsed) == 2
assert parsed[0]["id"] == 1
+98
View File
@@ -0,0 +1,98 @@
"""Unit tests for strava_mcp_server.utils — pure functions, no external dependencies."""
from datetime import datetime, timezone
from strava_mcp_server.utils import (
parse_iso_to_unix,
format_date_iso,
format_date_human,
)
class TestParseIsoToUnix:
def test_full_iso_with_z(self):
result = parse_iso_to_unix("2024-01-15T12:00:00Z")
assert result == int(
datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc).timestamp()
)
def test_full_iso_with_offset(self):
result = parse_iso_to_unix("2024-01-15T13:00:00+01:00")
assert result == int(
datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc).timestamp()
)
def test_date_only(self):
result = parse_iso_to_unix("2024-06-01")
assert result == int(
datetime(2024, 6, 1, 0, 0, 0, tzinfo=timezone.utc).timestamp()
)
def test_none_returns_none(self):
assert parse_iso_to_unix(None) is None
def test_empty_string_returns_none(self):
assert parse_iso_to_unix("") is None
def test_invalid_string_returns_none(self):
assert parse_iso_to_unix("not-a-date") is None
def test_returns_int(self):
result = parse_iso_to_unix("2024-01-01T00:00:00Z")
assert isinstance(result, int)
class TestFormatDateIso:
def test_z_suffix_normalized(self):
result = format_date_iso("2024-01-15T12:00:00Z")
assert result == "2024-01-15T12:00:00Z"
def test_offset_preserved(self):
# format_date_iso preserves timezone offset info; normalization to UTC
# is the caller's responsibility if needed.
result = format_date_iso("2024-01-15T13:00:00+01:00")
assert result == "2024-01-15T13:00:00+01:00"
def test_datetime_object(self):
dt = datetime(2024, 3, 10, 8, 30, 0, tzinfo=timezone.utc)
result = format_date_iso(dt)
assert result == "2024-03-10T08:30:00Z"
def test_none_returns_na(self):
assert format_date_iso(None) == "N/A"
def test_empty_string_returns_na(self):
assert format_date_iso("") == "N/A"
def test_invalid_string_returned_as_is(self):
result = format_date_iso("not-a-date")
assert result == "not-a-date"
class TestFormatDateHuman:
def test_iso_with_z(self):
result = format_date_human("2024-01-15T08:30:00Z")
assert result == "15.01.2024 08:30"
def test_iso_with_offset(self):
# +01:00 → displayed in local time of the datetime (13:00 in +01 = 13:00 displayed)
result = format_date_human("2024-01-15T13:45:00+01:00")
assert result == "15.01.2024 13:45"
def test_datetime_object(self):
dt = datetime(2024, 12, 31, 23, 59, tzinfo=timezone.utc)
result = format_date_human(dt)
assert result == "31.12.2024 23:59"
def test_none_returns_na(self):
assert format_date_human(None) == "N/A"
def test_empty_string_returns_na(self):
assert format_date_human("") == "N/A"
def test_format_pattern(self):
result = format_date_human("2024-06-01T00:00:00Z")
# Ensure it matches DD.MM.YYYY HH:MM
import re
assert re.match(r"\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}", result)
Generated
+154 -2
View File
@@ -48,6 +48,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "certifi"
version = "2026.4.22"
@@ -303,6 +312,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -382,6 +400,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -562,6 +598,51 @@ crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-sugar"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "termcolor" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -812,7 +893,7 @@ wheels = [
]
[[package]]
name = "strava-mcp-server"
name = "strava-mcp-server-hnrx"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
@@ -824,6 +905,9 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-sugar" },
{ name = "ruff" },
]
@@ -837,7 +921,75 @@ requires-dist = [
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.12" }]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "pytest-sugar", specifier = ">=1.1.1" },
{ name = "ruff", specifier = ">=0.15.12" },
]
[[package]]
name = "termcolor"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typer"
Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

+206
View File
@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Strava MCP Server | Modern Training Data Access</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="bg-gradient"></div>
<div class="container">
<nav>
<div class="logo">STRAVA<span>MCP</span></div>
<ul class="nav-links">
<li><a href="#features" data-i18n="nav-features">Features</a></li>
<li><a href="#architecture" data-i18n="nav-arch">Architecture</a></li>
<li><a href="#installation" data-i18n="nav-install">Installation</a></li>
<li><a href="https://git.hnrx.net/hnrx/strava-mcp-server" data-i18n="nav-repo">GitHub</a></li>
</ul>
<div class="lang-switch">
<button class="lang-btn active" onclick="setLanguage('en')">EN</button>
<button class="lang-btn" onclick="setLanguage('de')">DE</button>
</div>
</nav>
<header class="hero">
<img src="assets/hero.png" alt="Futuristic Background" class="hero-img">
<h1 data-i18n="hero-title">Empower your AI with Strava Data.</h1>
<p data-i18n="hero-subtitle">A production-ready Model Context Protocol (MCP) server that exposes the Strava
API for AI agents and LLMs.</p>
<div class="btn-group">
<a href="https://git.hnrx.net/hnrx/strava-mcp-server" class="btn btn-primary" data-i18n="btn-start">Get
Started</a>
<a href="#architecture" class="btn btn-secondary" data-i18n="btn-more">Learn More</a>
</div>
</header>
<section id="features">
<div class="section-title">
<h2 data-i18n="features-title">Features</h2>
<p data-i18n="features-subtitle">Comprehensive access to your training data through standardized MCP
tools.</p>
</div>
<div class="grid">
<div class="card">
<span class="card-icon">👤</span>
<h3 data-i18n="feat-1-t">Athlete Profiles</h3>
<p data-i18n="feat-1-d">Detailed profiles, heart rate zones, and power stats for personalized
analysis.</p>
</div>
<div class="card">
<span class="card-icon">🚴</span>
<h3 data-i18n="feat-2-t">Activity Deep-Dive</h3>
<p data-i18n="feat-2-d">Access laps, streams, comments, and detailed segment efforts.</p>
</div>
<div class="card">
<span class="card-icon">📍</span>
<h3 data-i18n="feat-3-t">Segments & Routes</h3>
<p data-i18n="feat-3-d">Explore popular segments and your saved routes with all metadata.</p>
</div>
<div class="card">
<span class="card-icon">⚙️</span>
<h3 data-i18n="feat-4-t">Hardware Tracking</h3>
<p data-i18n="feat-4-d">Manage your equipment and track the mileage of your bikes and shoes.</p>
</div>
</div>
</section>
<section id="architecture" class="architecture">
<div>
<h2 data-i18n="arch-title">Dual-Output Architecture</h2>
<p data-i18n="arch-p">Optimized for both humans and machines. Every tool delivers two outputs:</p>
<br>
<ul style="list-style: none; color: var(--text-dim);">
<li style="margin-bottom: 1rem;"><strong style="color: var(--primary);" data-i18n="arch-user-t">User
Content:</strong> <span data-i18n="arch-user-d">Formatted markdown for an aesthetic display
in the chat.</span></li>
<li><strong style="color: #fff;" data-i18n="arch-llm-t">Assistant Resource:</strong> <span
data-i18n="arch-llm-d">Structured JSON for precise data processing by the LLM.</span></li>
</ul>
</div>
<div class="code-window">
<div class="code-header">
<div class="dot red"></div>
<div class="dot yellow"></div>
<div class="dot green"></div>
</div>
<div class="code-content">
<pre>
<span class="comment">// JSON Resource for Assistant</span>
{
<span class="keyword">"id"</span>: <span class="string">"12345678"</span>,
<span class="keyword">"name"</span>: <span class="string">"Morning Ride"</span>,
<span class="keyword">"distance"</span>: <span class="string">"45200"</span>,
<span class="keyword">"moving_time"</span>: <span class="string">"5400"</span>,
<span class="keyword">"audience"</span>: [<span class="string">"assistant"</span>]
}</pre>
</div>
</div>
</section>
<section id="installation" class="quick-start">
<h2 data-i18n="install-title">Quick Start</h2>
<p data-i18n="install-p">Start the server locally with a single command via UV.</p>
<div class="terminal">
<span>$</span> uv run strava-mcp-server
</div>
</section>
<footer>
<p>&copy; 2024 Strava MCP Server. Build for high-performance AI Agents.</p>
</footer>
</div>
<script>
const translations = {
en: {
"nav-features": "Features",
"nav-arch": "Architecture",
"nav-install": "Installation",
"nav-repo": "Source",
"hero-title": "Empower your AI with Strava Data.",
"hero-subtitle": "A production-ready Model Context Protocol (MCP) server that exposes the Strava API for AI agents and LLMs.",
"btn-start": "Get Started",
"btn-more": "Learn More",
"features-title": "Features",
"features-subtitle": "Comprehensive access to your training data through standardized MCP tools.",
"feat-1-t": "Athlete Profiles",
"feat-1-d": "Detailed profiles, heart rate zones, and power stats for personalized analysis.",
"feat-2-t": "Activity Deep-Dive",
"feat-2-d": "Access laps, streams, comments, and detailed segment efforts.",
"feat-3-t": "Segments & Routes",
"feat-3-d": "Explore popular segments and your saved routes with all metadata.",
"feat-4-t": "Hardware Tracking",
"feat-4-d": "Manage your equipment and track the mileage of your bikes and shoes.",
"arch-title": "Dual-Output Architecture",
"arch-p": "Optimized for both humans and machines. Every tool delivers two outputs:",
"arch-user-t": "User Content:",
"arch-user-d": "Formatted markdown for an aesthetic display in the chat.",
"arch-llm-t": "Assistant Resource:",
"arch-llm-d": "Structured JSON for precise data processing by the LLM.",
"install-title": "Quick Start",
"install-p": "Start the server locally with a single command via UV."
},
de: {
"nav-features": "Funktionen",
"nav-arch": "Architektur",
"nav-install": "Installation",
"nav-repo": "Quellcode",
"hero-title": "Analysiere deine Strava-Daten mit KI-Power.",
"hero-subtitle": "Ein produktionsreifer Model Context Protocol (MCP) Server, der die Strava API für AI Agents und LLMs nutzbar macht.",
"btn-start": "Loslegen",
"btn-more": "Mehr erfahren",
"features-title": "Funktionen",
"features-subtitle": "Umfangreicher Zugriff auf deine Trainingsdaten über standardisierte MCP Tools.",
"feat-1-t": "Athleten-Profile",
"feat-1-d": "Detaillierte Profile, Herzfrequenz-Zonen und Power-Stats für personalisierte Analysen.",
"feat-2-t": "Aktivitäts-Analyse",
"feat-2-d": "Zugriff auf Laps, Streams, Kommentare und detaillierte Segment-Efforts.",
"feat-3-t": "Segmente & Routen",
"feat-3-d": "Erkunde beliebte Segmente und deine gespeicherten Routen mit allen Metadaten.",
"feat-4-t": "Hardware-Tracking",
"feat-4-d": "Verwalte deine Ausrüstung und verfolge die Laufleistung deiner Bikes und Schuhe.",
"arch-title": "Dual-Output Architektur",
"arch-p": "Optimiert für Mensch und Maschine. Jedes Tool liefert zwei Ausgaben:",
"arch-user-t": "User Content:",
"arch-user-d": "Formatiertes Markdown für eine ästhetische Anzeige im Chat.",
"arch-llm-t": "Assistant Resource:",
"arch-llm-d": "Strukturiertes JSON für präzise Datenverarbeitung durch das LLM.",
"install-title": "Schnellstart",
"install-p": "Starte den Server lokal mit nur einem Befehl über UV."
}
};
function setLanguage(lang) {
localStorage.setItem('preferredLang', lang);
// Update all text elements
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (translations[lang][key]) {
el.textContent = translations[lang][key];
}
});
// Update toggle buttons
document.querySelectorAll('.lang-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.textContent.toLowerCase() === lang) {
btn.classList.add('active');
}
});
document.documentElement.lang = lang;
}
// Initialize from storage or browser language
const savedLang = localStorage.getItem('preferredLang') || (navigator.language.startsWith('de') ? 'de' : 'en');
setLanguage(savedLang);
</script>
</body>
</html>
+325
View File
@@ -0,0 +1,325 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap');
:root {
--primary: #FC4C02;
--primary-glow: rgba(252, 76, 2, 0.4);
--bg-dark: #0A0A0A;
--bg-card: rgba(255, 255, 255, 0.05);
--text-main: #FFFFFF;
--text-dim: #A0A0A0;
--glass-border: rgba(255, 255, 255, 0.1);
--transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Outfit', sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
line-height: 1.6;
overflow-x: hidden;
}
/* Background Effects */
.bg-gradient {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at 80% 20%, #1a100a 0%, transparent 40%),
radial-gradient(circle at 10% 80%, #0d0a14 0%, transparent 40%);
z-index: -1;
}
/* Layout */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
/* Navigation */
nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2rem 0;
backdrop-filter: blur(10px);
position: sticky;
top: 0;
z-index: 100;
}
.logo {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -1px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.logo span {
color: var(--primary);
}
.nav-links {
display: flex;
gap: 2.5rem;
list-style: none;
}
.nav-links a {
color: var(--text-dim);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: var(--transition);
}
.nav-links a:hover {
color: var(--primary);
}
.lang-switch {
display: flex;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.05);
padding: 0.25rem;
border-radius: 100px;
border: 1px solid var(--glass-border);
}
.lang-btn {
padding: 0.4rem 0.8rem;
border-radius: 100px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
border: none;
background: transparent;
color: var(--text-dim);
transition: var(--transition);
}
.lang-btn.active {
background: var(--primary);
color: white;
}
/* Hero Section */
.hero {
padding: 8rem 0;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
}
.hero-img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 140%;
opacity: 0.15;
pointer-events: none;
z-index: -1;
}
.hero h1 {
font-size: 5rem;
font-weight: 800;
line-height: 1.1;
margin-bottom: 1.5rem;
background: linear-gradient(to bottom, #fff 40%, #888);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero p {
font-size: 1.25rem;
color: var(--text-dim);
max-width: 600px;
margin-bottom: 3rem;
}
.btn-group {
display: flex;
gap: 1.5rem;
}
.btn {
padding: 1rem 2.5rem;
border-radius: 100px;
font-weight: 600;
text-decoration: none;
transition: var(--transition);
font-size: 1rem;
}
.btn-primary {
background: var(--primary);
color: white;
box-shadow: 0 10px 30px var(--primary-glow);
}
.btn-primary:hover {
transform: translateY(-5px);
box-shadow: 0 15px 40px var(--primary-glow);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.05);
color: white;
border: 1px solid var(--glass-border);
backdrop-filter: blur(10px);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Features Grid */
.section-title {
text-align: center;
margin-bottom: 4rem;
}
.section-title h2 {
font-size: 3rem;
margin-bottom: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 8rem;
}
.card {
background: var(--bg-card);
border: 1px solid var(--glass-border);
padding: 3rem;
border-radius: 2rem;
transition: var(--transition);
backdrop-filter: blur(20px);
}
.card:hover {
border-color: var(--primary);
transform: translateY(-10px);
background: rgba(252, 76, 2, 0.03);
}
.card-icon {
font-size: 2.5rem;
margin-bottom: 1.5rem;
display: block;
}
.card h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.card p {
color: var(--text-dim);
}
/* Code Preview Section */
.architecture {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
margin-bottom: 8rem;
background: rgba(255, 255, 255, 0.02);
padding: 4rem;
border-radius: 3rem;
border: 1px solid var(--glass-border);
}
.code-window {
background: #000;
border-radius: 1rem;
overflow: hidden;
border: 1px solid var(--glass-border);
box-shadow: 0 30px 60px rgba(0,0,0,0.5);
}
.code-header {
background: #1A1A1A;
padding: 0.75rem 1rem;
display: flex;
gap: 0.5rem;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.red { background: #FF5F56; }
.yellow { background: #FFBD2E; }
.green { background: #27C93F; }
.code-content {
padding: 1.5rem;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.85rem;
color: #f8f8f2;
}
.code-content pre {
white-space: pre-wrap;
}
.keyword { color: #ff79c6; }
.string { color: #f1fa8c; }
.comment { color: #6272a4; }
/* Quick Start */
.quick-start {
text-align: center;
max-width: 800px;
margin: 0 auto 8rem;
}
.terminal {
background: #111;
padding: 1.5rem 2rem;
border-radius: 1rem;
font-family: monospace;
margin-top: 2rem;
display: inline-block;
border: 1px solid var(--glass-border);
}
.terminal span {
color: var(--primary);
margin-right: 1rem;
}
footer {
padding: 4rem 0;
border-top: 1px solid var(--glass-border);
text-align: center;
color: var(--text-dim);
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.hero h1 { font-size: 3rem; }
.architecture { grid-template-columns: 1fr; padding: 2rem; }
.nav-links { display: none; }
}