-
-
Notifications
You must be signed in to change notification settings - Fork 98
feat: add Render deployment for multi-device Claude access #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
eb5c5f5
e610692
c516ac4
0dcacf0
dc2d147
bbff6eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| # Render Deployment Implementation Plan | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||
|
|
||
| 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. | ||
| 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
|
||
| ## 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. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| UNKNOWN_TYPES = {"", "unknown", None} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _is_valid_activity(activity: dict[str, Any]) -> bool: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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": |
There was a problem hiding this comment.
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.pywhich selects transport viaMCP_TRANSPORT. Update the comment to avoid misleading future changes/debugging.