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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ COPY .env.example .env.example
RUN pip install --no-cache-dir .

# Default command to run the MCP server using stdio transport
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says this is the default command to run the server using stdio transport, but the entrypoint now runs server.py which selects transport via MCP_TRANSPORT. Update the comment to avoid misleading future changes/debugging.

Suggested change
# Default command to run the MCP server using stdio transport
# Default command to run the MCP server; transport is selected by server.py via MCP_TRANSPORT

Copilot uses AI. Check for mistakes.
CMD ["mcp", "run", "src/intervals_mcp_server/server.py"]
CMD ["python", "src/intervals_mcp_server/server.py"]
218 changes: 218 additions & 0 deletions docs/superpowers/plans/2026-03-24-render-deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# Render Deployment Implementation Plan
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With so many AI tools out there I'm cautious about letting these spec and plan files pile up. Happy to leave them here for now but something to keep in mind in the future. Perhaps well revist if there's too many.


> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Deploy intervals-mcp-server to Render as a Docker Web Service with SSE transport so Claude can connect from any device.

**Architecture:** The server runs as a Render Web Service using the existing Dockerfile. Two code changes are needed: `server.py` must pass `FASTMCP_HOST` from the environment to the `FastMCP` constructor (otherwise the server binds to `127.0.0.1` inside the container and is unreachable), and the Dockerfile `CMD` must invoke Python directly so the `MCP_TRANSPORT` env var is respected. After deployment, the Render HTTPS URL is added as a remote MCP server in Claude's integrations settings.

**Tech Stack:** Python 3.12, FastMCP (mcp[cli]), Docker, Render Web Service, pytest

---

## Chunk 1: Code changes

### Task 1: Fix FASTMCP_HOST binding in server.py

**Files:**
- Modify: `src/intervals_mcp_server/server.py:64`
- Create: `tests/test_server_config.py`

**Background:** `FastMCP.__init__` passes `host="127.0.0.1"` as an explicit keyword argument to its internal pydantic `Settings()`. Explicit kwargs take priority over environment variables in pydantic-settings, so `FASTMCP_HOST=0.0.0.0` in the environment is silently ignored. The fix is to pass the env var value explicitly at construction time.

- [ ] **Step 1: Write the failing test**

Create `tests/test_server_config.py`:

```python
"""
Tests for server configuration — specifically that FASTMCP_HOST env var
is respected when creating the FastMCP instance.
"""
import os
import sys
import pathlib

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
os.environ.setdefault("API_KEY", "test")
os.environ.setdefault("ATHLETE_ID", "i1")


def _fresh_server(monkeypatch, host):
"""Import server.py in a fresh module context with FASTMCP_HOST set."""
monkeypatch.setenv("FASTMCP_HOST", host)
# Remove cached intervals_mcp_server modules so the import picks up the new env var
for key in list(sys.modules.keys()):
if "intervals_mcp_server" in key:
del sys.modules[key]
import intervals_mcp_server.server as srv # pylint: disable=import-outside-toplevel
return srv


def test_fastmcp_host_default_is_localhost(monkeypatch):
"""When FASTMCP_HOST is not set, the mcp instance should bind to 127.0.0.1."""
monkeypatch.delenv("FASTMCP_HOST", raising=False)
for key in list(sys.modules.keys()):
if "intervals_mcp_server" in key:
del sys.modules[key]
import intervals_mcp_server.server as srv # pylint: disable=import-outside-toplevel
assert srv.mcp.settings.host == "127.0.0.1"


def test_fastmcp_host_reads_from_env(monkeypatch):
"""When FASTMCP_HOST=0.0.0.0 is set, the mcp instance should bind to 0.0.0.0."""
srv = _fresh_server(monkeypatch, "0.0.0.0")
assert srv.mcp.settings.host == "0.0.0.0"
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
cd /Users/stevenboere/intervals-mcp-server/.claude/worktrees/modest-wilson
uv run pytest tests/test_server_config.py -v
```
Comment on lines +70 to +73
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This plan embeds an absolute local filesystem path in the test command. That’s not portable and may leak personal machine details into the repo; use a repo-relative command (e.g., run from the project root) instead.

Copilot uses AI. Check for mistakes.

Expected: `test_fastmcp_host_reads_from_env` FAILS (host is still `127.0.0.1`). `test_fastmcp_host_default_is_localhost` may pass or fail depending on module cache state.

- [ ] **Step 3: Implement the fix in server.py**

In `src/intervals_mcp_server/server.py`, add `os` to the standard library imports at the top of the file (after the existing `import logging`):

```python
import logging
import os
```

Then change line 64 from:

```python
mcp = FastMCP("intervals-icu", lifespan=setup_api_client)
```

to:

```python
mcp = FastMCP("intervals-icu", lifespan=setup_api_client, host=os.getenv("FASTMCP_HOST", "127.0.0.1"))
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
uv run pytest tests/test_server_config.py -v
```

Expected: both tests PASS.

- [ ] **Step 5: Run the full test suite to confirm no regressions**

```bash
uv run pytest -v tests
```

Expected: all tests PASS.

- [ ] **Step 6: Commit**

```bash
git add src/intervals_mcp_server/server.py tests/test_server_config.py
git commit -m "fix: read FASTMCP_HOST from env when creating FastMCP instance"
```

---

### Task 2: Fix Dockerfile CMD

**Files:**
- Modify: `Dockerfile:26`

**Background:** The current `CMD ["mcp", "run", "src/intervals_mcp_server/server.py"]` uses the `mcp` CLI, which does not execute the `if __name__ == "__main__":` block in `server.py`. This means `setup_transport()` and `start_server()` are never called, so `MCP_TRANSPORT=sse` is ignored and the server starts on stdio instead of SSE.

- [ ] **Step 1: Update the Dockerfile CMD**

Change the last line of `Dockerfile` from:

```dockerfile
CMD ["mcp", "run", "src/intervals_mcp_server/server.py"]
```

to:

```dockerfile
CMD ["python", "src/intervals_mcp_server/server.py"]
```

- [ ] **Step 2: Commit**

```bash
git add Dockerfile
git commit -m "fix: use python entrypoint in Dockerfile so MCP_TRANSPORT is respected"
```

---

## Chunk 2: Render deployment

These are manual steps, not code changes. Complete them after the code changes are merged to `main`.

### Task 3: Create Render Web Service

- [ ] **Step 1: Push the branch and merge to main**

Ensure both commits from Chunk 1 are on `main` (or open a PR and merge).

- [ ] **Step 2: Create a new Web Service on Render**

1. Go to [render.com](https://render.com) → **New** → **Web Service**
2. Connect your GitHub repository (`intervals-mcp-server`)
3. Set **Branch** to `main`
4. Set **Runtime** to **Docker**
5. Set **Port** to `8000`
6. Set **Health Check Path** to `/sse`

- [ ] **Step 3: Set environment variables**

In the Render dashboard, under **Environment**, add:

| Key | Value |
|---|---|
| `MCP_TRANSPORT` | `sse` |
| `FASTMCP_HOST` | `0.0.0.0` |
| `ATHLETE_ID` | your Intervals.icu athlete ID (e.g. `i12345`) |
| `API_KEY` | your Intervals.icu API key |
| `INTERVALS_API_BASE_URL` | `https://intervals.icu/api/v1` |
Comment on lines +178 to +182

- [ ] **Step 4: Deploy and verify**

1. Click **Create Web Service** — Render will build the Docker image and deploy.
2. Wait for the health check to pass (green status in Render dashboard).
3. Note your service URL, e.g. `https://intervals-mcp-server-xxxx.onrender.com`.
4. Verify the SSE endpoint responds: open `https://<your-service>.onrender.com/sse` in a browser — you should see a `text/event-stream` response (the connection stays open).

---

### Task 4: Configure Claude integrations

- [ ] **Step 1: Add MCP server in Claude**

On any device:

1. Open Claude (web or mobile) → **Settings** → **Integrations** (or **MCP Servers**)
2. Click **Add** (or **Connect apps**)
3. Fill in:
- **Name:** `Intervals.icu`
- **URL:** `https://<your-render-service>.onrender.com/sse`
4. Save.

- [ ] **Step 2: Verify tools are available**

Open a new Claude conversation and ask:
> "What MCP tools do you have available?"

Expected: tools like `get_activities`, `get_wellness_data`, `get_events` etc. appear in the list.

- [ ] **Step 3: End-to-end test**

Ask Claude:
> "Fetch my recent activities from Intervals.icu"

Expected: Claude calls `get_activities` and returns your real activity data.
106 changes: 106 additions & 0 deletions docs/superpowers/specs/2026-03-24-render-deployment-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Render Deployment Design: intervals-mcp-server

**Date:** 2026-03-24
**Status:** Approved

## Goal

Deploy the intervals-mcp-server to Render so it is accessible from Claude on any device (phone, tablet, other laptops) — not just the local machine.

## Context

The server currently runs locally via stdio transport, which means it is only available in Claude Desktop on the machine where it runs. The server already supports SSE and Streamable HTTP transports via the `MCP_TRANSPORT` environment variable. A Dockerfile already exists.

## Approach

Deploy as a Render Web Service using the existing Dockerfile, with `MCP_TRANSPORT=sse`. Claude (web and mobile) supports remote MCP servers via SSE. The Render-provided HTTPS URL is used directly as the MCP server URL in Claude's integrations settings.

Security relies on URL obscurity (the Render subdomain is not guessable). No additional authentication is added.

Comment on lines +16 to +19
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relying on an unguessable URL as the only access control means anyone who obtains the Render URL can call MCP tools and indirectly access the athlete’s data using the server-side API_KEY. Consider documenting this as a concrete risk and adding at least an optional shared-secret/bearer-token check (or Render-level auth) for safer defaults when deployed publicly.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@boeresteven-bit this seems like too big a security risk. Can you move the API key and Athlete id to the claude mcp config file and not the render web app?

## Architecture

```
Claude (any device, any platform)
│ HTTPS + SSE
Render Web Service (Docker)
└── intervals-mcp-server
│ MCP_TRANSPORT=sse
│ FASTMCP_HOST=0.0.0.0 (applied via code fix below)
└── Intervals.icu API (HTTPS)
```

## Changes Required

### 1. server.py — read host from environment

FastMCP passes `host='127.0.0.1'` as an explicit keyword argument to its internal `Settings()`, which takes precedence over environment variables. This means `FASTMCP_HOST=0.0.0.0` in Render's env vars would be silently ignored, causing the server to bind to the loopback interface and be unreachable by Render's load balancer.

Fix: pass `host` explicitly from the environment when constructing the `FastMCP` instance in `server.py`:

```python
import os

mcp = FastMCP(
"intervals-icu",
lifespan=setup_api_client,
host=os.getenv("FASTMCP_HOST", "127.0.0.1"),
)
```

This keeps the default behaviour unchanged locally (`127.0.0.1`) while allowing Render to override it via `FASTMCP_HOST=0.0.0.0`.

### 2. Dockerfile — CMD change

The current `CMD` uses `mcp run`, which bypasses the `__main__` block in `server.py` and therefore ignores the `MCP_TRANSPORT` environment variable. Change it to invoke Python directly:

```dockerfile
# Before
CMD ["mcp", "run", "src/intervals_mcp_server/server.py"]

# After
CMD ["python", "src/intervals_mcp_server/server.py"]
```

### 3. Render Web Service configuration

| Setting | Value |
|---|---|
| Type | Web Service |
| Runtime | Docker |
| Branch | `main` |
| Port | `8000` |
| Health check path | `/sse` |

Environment variables to set in Render dashboard:

| Variable | Value |
|---|---|
| `MCP_TRANSPORT` | `sse` |
| `FASTMCP_HOST` | `0.0.0.0` (requires code fix in section 1) |
| `ATHLETE_ID` | your Intervals.icu athlete ID |
| `API_KEY` | your Intervals.icu API key |
| `INTERVALS_API_BASE_URL` | `https://intervals.icu/api/v1` |

Note: `FASTMCP_LOG_LEVEL=INFO` can be added optionally for verbose Uvicorn logs.

### 4. Claude integration configuration

In Claude (web or mobile): **Settings → Integrations → Add MCP Server**

- **Name:** Intervals.icu
- **URL:** `https://<your-render-service-name>.onrender.com/sse`

This works on all devices where the user is logged in to Claude.

## Out of Scope

- Authentication / access control (URL obscurity accepted as sufficient for personal use)
- Streamable HTTP transport (SSE chosen for broader claude.ai support)
- Dedicated `/health` endpoint (Render's health checker handles the SSE 200 response adequately)

## Testing

1. After deploy, verify the Render service starts and the health check passes.
2. Add the SSE URL to Claude integrations and confirm the tools (`get_activities`, `get_wellness_data`, etc.) appear.
3. Test a tool call from Claude mobile to confirm end-to-end connectivity.
3 changes: 2 additions & 1 deletion src/intervals_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"""

import logging
import os

from mcp.server.fastmcp import FastMCP # pylint: disable=import-error

Expand Down Expand Up @@ -61,7 +62,7 @@
config = get_config()

# Initialize FastMCP server with custom lifespan
mcp = FastMCP("intervals-icu", lifespan=setup_api_client)
mcp = FastMCP("intervals-icu", lifespan=setup_api_client, host=os.getenv("FASTMCP_HOST", "127.0.0.1"))

# Set the shared mcp instance for tool modules to use (breaks cyclic imports)
from intervals_mcp_server import mcp_instance # pylint: disable=wrong-import-position # noqa: E402
Expand Down
32 changes: 26 additions & 6 deletions src/intervals_mcp_server/tools/activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,32 @@ def _parse_activities_from_result(result: Any) -> list[dict[str, Any]]:


def _filter_named_activities(activities: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Filter out unnamed activities from the list."""
return [
activity
for activity in activities
if activity.get("name") and activity.get("name") != "Unnamed"
]
"""Filter out unnamed activities from the list.

Keeps activities that either have a real name, or have a known activity type
(e.g. Ride, Run, VirtualRide) even if they were not explicitly named by the user.
Comment on lines +41 to +44
This prevents real cycling or running activities from being dropped just because
the user left the default "Unnamed" label in Garmin/Strava.
"""
Comment on lines +41 to +47
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function now keeps activities that are still effectively unnamed (e.g., name == "Unnamed") if they have a type, which changes the meaning of include_unnamed=False and makes _filter_named_activities (and related user-facing messaging like "No named activities found") inaccurate. Consider renaming this filter and/or updating the include_unnamed parameter docs/messages to reflect the new behavior (e.g., "include activities with no name and no type").

Copilot uses AI. Check for mistakes.
UNKNOWN_TYPES = {"", "unknown", None}

def _is_valid_activity(activity: dict[str, Any]) -> bool:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big fan of function definitions inside function definitions. Especially for a function like this that could have wider use in the future.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big fan of function definitions within function defintions. Harder to test. In this case the functionis_valid_activity may have use outside this function in a future iteration. Id move this out of the outer function.

name = activity.get("name", "")
activity_type = activity.get("type", "")

# Accept activities with a meaningful custom name
if name and name != "Unnamed":
return True

# Also accept activities that have a known type, even if their name is
# "Unnamed" or empty – these are real workouts that were just never
# renamed (common for auto-synced Garmin activities).
if str(activity_type).lower() not in UNKNOWN_TYPES and activity_type != "Unknown":
Comment on lines +52 to +61
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UNKNOWN_TYPES includes None, but the check uses str(activity_type).lower(). If activity_type is None, this becomes "none" and the condition incorrectly treats it as a known type, keeping unnamed activities with no real type. Handle None/non-string types explicitly (e.g., normalize to "" first or check activity_type is None before calling str(...).lower()).

Suggested change
activity_type = activity.get("type", "")
# Accept activities with a meaningful custom name
if name and name != "Unnamed":
return True
# Also accept activities that have a known type, even if their name is
# "Unnamed" or empty – these are real workouts that were just never
# renamed (common for auto-synced Garmin activities).
if str(activity_type).lower() not in UNKNOWN_TYPES and activity_type != "Unknown":
activity_type = activity.get("type")
# Accept activities with a meaningful custom name
if name and name != "Unnamed":
return True
# Normalize activity type: only lower-case strings; treat None/non-strings as unknown.
if isinstance(activity_type, str):
normalized_type = activity_type.strip().lower()
else:
normalized_type = ""
# Also accept activities that have a known type, even if their name is
# "Unnamed" or empty – these are real workouts that were just never
# renamed (common for auto-synced Garmin activities).
if normalized_type not in UNKNOWN_TYPES and activity_type != "Unknown":

Copilot uses AI. Check for mistakes.
return True
Comment on lines +58 to +62

return False

return [activity for activity in activities if _is_valid_activity(activity)]


async def _fetch_more_activities(
Expand Down
Loading
Loading