Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/container-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Container build

on:
pull_request:

permissions:
contents: read

jobs:
build:
name: Build multi-architecture image
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

- name: Build image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
68 changes: 68 additions & 0 deletions .github/workflows/container-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Container publish

on:
push:
branches:
- main
- feat/ghcr-publishing

permissions:
contents: read

jobs:
publish:
name: Publish multi-architecture image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

- name: Generate image metadata
id: metadata
shell: bash
run: |
image="ghcr.io/${GITHUB_REPOSITORY,,}"
short_sha="${GITHUB_SHA::7}"
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
release_tag="latest"
else
release_tag="${GITHUB_REF_NAME//\//-}"
fi

{
echo "image=${image}"
echo "tags<<EOF"
echo "${image}:${release_tag}"
echo "${image}:sha-${short_sha}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"

- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and publish image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_SYSTEM_PYTHON=1
PATH="/app/.venv/bin:$PATH"

# Copy dependency files and README first for better layer caching
COPY pyproject.toml README.md ./
COPY pyproject.toml uv.lock README.md ./

# Copy the application source code (needed for editable install)
COPY src/ ./src/

# Install dependencies using uv
RUN uv pip install -e .
# Install the exact locked production dependencies into the project environment.
RUN uv sync --frozen --no-dev --no-editable

# Copy test files (optional, for testing in container)
COPY tests/ ./tests/
Expand Down
88 changes: 85 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Garmin's API is accessed via the awesome [python-garminconnect](https://github.c

### Tool Coverage

This MCP server implements **110+ tools** covering ~90% of the [python-garminconnect](https://github.com/cyberjunky/python-garminconnect) library (v0.3.2):
This MCP server implements **113+ tools** covering ~90% of the [python-garminconnect](https://github.com/cyberjunky/python-garminconnect) library (v0.3.2), plus Garmin's public exercise catalog:

- ✅ Activity Management (20 tools) - includes write tools for type, description, event type, perceived effort, and feel
- ✅ Health & Wellness (31 tools) - includes custom lightweight summary tools
Expand All @@ -42,6 +42,7 @@ This MCP server implements **110+ tools** covering ~90% of the [python-garmincon
- ✅ Courses (3 tools) - list / upload GPX as course / delete course
- ✅ Activity Analysis (2 tools) - FIT file parsing, Power Duration Curve; requires power meter and/or Di2
- ✅ Activity File Downloads (2 tools) - download activity files in FIT, GPX, TCX, or CSV format
- ✅ Exercise Catalog (3 tools) - browse, match, and batch-resolve public Garmin strength-exercise identifiers without account authentication

> **Note:** Activity Analysis tools require a compatible power meter (e.g., Garmin Rally, Favero Assioma, PowerTap P1) and/or Shimano Di2 / SRAM eTap electronic shifting. The `fitparse` dependency is installed automatically.

Expand Down Expand Up @@ -78,7 +79,7 @@ If you need any of these endpoints, please [open an issue](https://github.com/Ta

## Tool Filtering

This server registers 110+ tools by default, which can be a lot of context for
This server registers 113+ tools by default, which can be a lot of context for
an LLM to carry in every session. You can expose only the tools you need with
two optional environment variables:

Expand All @@ -99,6 +100,78 @@ Example — expose only sleep, stress, and recent activities:
}
```

## Exercise catalog tools

The read-only `list_strength_exercises`, `match_strength_exercise`, and
`resolve_strength_exercises` tools use
Garmin's public [exercise catalog](https://connect.garmin.com/web-data/exercises/Exercises.json)
as the canonical source of valid `category` + `exercise_name` pairs. Human-readable
labels come from Garmin's public
[`exercise_types.properties`](https://connect.garmin.com/web-translations/exercise_types/exercise_types.properties)
translation file. These tools do not access an authenticated Garmin account and
never create temporary workouts.

Browse a category, search labels or identifiers, and paginate the results:

```json
{
"category": "CRUNCH",
"search": "reverse",
"limit": 20
}
```

Match a human-friendly description and receive exact identifiers plus alternatives
when the result is uncertain:

```json
{
"query": "Reverse Crunch"
}
```

The fields to use when creating a workout are the Garmin identifiers, not the
display label:

```json
{
"category": "CRUNCH",
"exercise_name": "REVERSE_CRUNCH"
}
```

Resolve a complete create-ready batch while preserving sets, reps, rest, and any
other caller metadata:

```json
{
"exercises": [
{"name": "Reverse Crunch", "sets": 3, "reps": 12, "rest_seconds": 60},
{"category": "CARRY", "exercise_name": "FARMERS_CARRY", "sets": 4, "reps": 1}
],
"limit": 5
}
```

The batch status is `ready` only when every exercise is exact or confidently
matched. Ambiguous, unknown, conflicting, or invalid inputs return `needs_review`
with per-item alternatives. This resolver never requires Garmin authentication.

Catalog files are cached in memory and under
`~/.cache/garmin_mcp/exercise_catalog/` for seven days by default. A structurally
valid expired cache remains available as a stale fallback during network outages.
The cache directory, TTL, and both source URLs can be overridden with
`GARMIN_EXERCISE_CACHE_DIR`, `GARMIN_EXERCISE_CACHE_TTL_SECONDS`,
`GARMIN_EXERCISES_URL`, and `GARMIN_EXERCISE_LABELS_URL`.

A display label such as `Reverse Crunch` is intended for people and search;
`CRUNCH` / `REVERSE_CRUNCH` is the exact Garmin pair. A small conservative alias
table improves searches such as `abdominal invertido`, but aliases are not part
of Garmin's official catalog. Approximate semantic matching cannot guarantee that
two differently named movements are equivalent. When Garmin has no exact entry,
use the scored alternatives to make that decision rather than treating a weak
match as exact.

## High-level workout tools

These builder tools let an LLM create and schedule workouts without writing raw Garmin JSON.
Expand Down Expand Up @@ -143,7 +216,7 @@ name kept in the step description. The name is also sent as `exerciseName`, but
retains that when it matches one of its own exercise keys (e.g. `FARMERS_CARRY`) — any other
value is accepted and then stored empty.

`category` is optional and passed straight through. Omit it and the key is left out of the
By default, `category` is optional and passed straight through. Omit it and the key is left out of the
payload entirely, which Garmin accepts. Supply it and it must be one of Garmin's exercise
categories — anything else, including `OTHER` and `UNASSIGNED`, is rejected with
`400 - Invalid category`. The full list is published at
Expand All @@ -152,6 +225,7 @@ categories — anything else, including `OTHER` and `UNASSIGNED`, is rejected wi
```json
{
"name": "Full Body A",
"resolve_exercises": true,
"exercises": [
{"name": "Sentadillas", "sets": 3, "reps": 12, "rest_seconds": 90},
{"name": "Flexiones", "sets": 3, "reps": 15, "rest_seconds": 60},
Expand All @@ -161,6 +235,14 @@ categories — anything else, including `OTHER` and `UNASSIGNED`, is rejected wi
}
```

With `resolve_exercises: true`, the whole batch is checked before JSON construction.
Every item must resolve before anything is uploaded; failures return all unresolved
items and alternatives. A valid supplied `category` + `exercise_name` pair is used
as-is while `name` remains the readable description. If the public catalog and its
cache are both unavailable, creation uses the legacy inputs and the success response
contains an explicit warning. Leaving the flag false keeps the previous behavior and
does not load the catalog.

Returns: `{"status": "success", "workout_id": 1234567890, ...}`

### `schedule_week`
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ dependencies = [
"python-dotenv==1.2.2",
"garminconnect==0.3.2",
"requests==2.33.0",
"mcp>=1.28.1",
"mcp>=1.28.1,<2",
"fitparse>=1.2.0",
]

Expand All @@ -27,4 +27,3 @@ dev-dependencies = [
"pytest-mock>=3.14.0",
"pytest-timeout>=2.3.1",
]

88 changes: 47 additions & 41 deletions src/garmin_mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from garmin_mcp import workout_builders
from garmin_mcp import courses
from garmin_mcp import activity_analysis
from garmin_mcp import exercise_catalog


def is_interactive_terminal() -> bool:
Expand Down Expand Up @@ -389,31 +390,33 @@ def main():

# Initialize Garmin client
garmin_client = init_api(email, password)
if not garmin_client:
print("Failed to initialize Garmin Connect client. Exiting.", file=sys.stderr)
return

print("Garmin Connect client initialized successfully.", file=sys.stderr)

# Wrap client so runtime auth/rate-limit errors surface as clear messages
garmin_client = _GarminProxy(garmin_client)

# Configure all modules with the Garmin client
activity_management.configure(garmin_client)
health_wellness.configure(garmin_client)
user_profile.configure(garmin_client)
devices.configure(garmin_client)
gear_management.configure(garmin_client)
weight_management.configure(garmin_client)
challenges.configure(garmin_client)
training.configure(garmin_client)
workouts.configure(garmin_client)
data_management.configure(garmin_client)
womens_health.configure(garmin_client)
nutrition.configure(garmin_client)
workout_builders.configure(garmin_client)
courses.configure(garmin_client)
activity_analysis.configure(garmin_client)
if garmin_client:
print("Garmin Connect client initialized successfully.", file=sys.stderr)

# Wrap client so runtime auth/rate-limit errors surface as clear messages
garmin_client = _GarminProxy(garmin_client)

# Configure modules that use the authenticated Garmin client.
activity_management.configure(garmin_client)
health_wellness.configure(garmin_client)
user_profile.configure(garmin_client)
devices.configure(garmin_client)
gear_management.configure(garmin_client)
weight_management.configure(garmin_client)
challenges.configure(garmin_client)
training.configure(garmin_client)
workouts.configure(garmin_client)
data_management.configure(garmin_client)
womens_health.configure(garmin_client)
nutrition.configure(garmin_client)
workout_builders.configure(garmin_client)
courses.configure(garmin_client)
activity_analysis.configure(garmin_client)
else:
print(
"Garmin authentication unavailable; exposing public catalog tools only.",
file=sys.stderr,
)

# Create the MCP app, wrapped so the env-var filter can drop tools.
# host/port only matter for the HTTP transports; stdio ignores them.
Expand All @@ -425,24 +428,27 @@ def main():
print(f"Tool filter: denylist of {len(disabled_tools)} tool(s).", file=sys.stderr)

# Register tools from all modules
app = activity_management.register_tools(app)
app = health_wellness.register_tools(app)
app = user_profile.register_tools(app)
app = devices.register_tools(app)
app = gear_management.register_tools(app)
app = weight_management.register_tools(app)
app = challenges.register_tools(app)
app = training.register_tools(app)
app = workouts.register_tools(app)
app = data_management.register_tools(app)
app = womens_health.register_tools(app)
app = nutrition.register_tools(app)
app = workout_builders.register_tools(app)
app = courses.register_tools(app)
app = activity_analysis.register_tools(app)
app = exercise_catalog.register_tools(app)
if garmin_client:
app = activity_management.register_tools(app)
app = health_wellness.register_tools(app)
app = user_profile.register_tools(app)
app = devices.register_tools(app)
app = gear_management.register_tools(app)
app = weight_management.register_tools(app)
app = challenges.register_tools(app)
app = training.register_tools(app)
app = workouts.register_tools(app)
app = data_management.register_tools(app)
app = womens_health.register_tools(app)
app = nutrition.register_tools(app)
app = workout_builders.register_tools(app)
app = courses.register_tools(app)
app = activity_analysis.register_tools(app)

# Register resources (workout templates)
app = workout_templates.register_resources(app)
if garmin_client:
app = workout_templates.register_resources(app)

# Warn about filter entries that matched no tool (most likely typos)
unknown = app.unknown_filter_names()
Expand Down
Loading