feat: add Render deployment for multi-device Claude access#98
feat: add Render deployment for multi-device Claude access#98boeresteven-bit wants to merge 6 commits into
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Could you please resolve the conflicts? |
There was a problem hiding this comment.
Pull request overview
This PR enables deploying intervals-mcp-server to Render for remote (multi-device) Claude access over SSE, and includes supporting docs plus a few UX tweaks around “Unnamed” activities.
Changes:
- Read
FASTMCP_HOSTfrom the environment when constructing theFastMCPinstance (with tests to verify behavior). - Update Dockerfile entrypoint to run
server.pydirectly soMCP_TRANSPORTselection in__main__is applied. - Improve handling/display of “Unnamed” activities by falling back to activity type in summaries and adjusting filtering logic.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/intervals_mcp_server/server.py |
Pass host= from FASTMCP_HOST when creating FastMCP. |
tests/test_server_config.py |
Adds tests ensuring FASTMCP_HOST is respected. |
Dockerfile |
Switches CMD to python src/intervals_mcp_server/server.py. |
src/intervals_mcp_server/utils/formatting.py |
Formats activity name using type fallback when name is empty/“Unnamed”. |
src/intervals_mcp_server/tools/activities.py |
Adjusts filtering so activities with a known type aren’t dropped due to “Unnamed”. |
docs/superpowers/specs/2026-03-24-render-deployment-design.md |
Design spec for Render SSE deployment. |
docs/superpowers/plans/2026-03-24-render-deployment.md |
Step-by-step implementation/deployment plan. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -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 | |||
There was a problem hiding this comment.
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.
| # 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 |
| 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. | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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?
| ```bash | ||
| cd /Users/stevenboere/intervals-mcp-server/.claude/worktrees/modest-wilson | ||
| uv run pytest tests/test_server_config.py -v | ||
| ``` |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
Deleting every module whose name contains "intervals_mcp_server" can create multiple live module instances (old objects still referenced by imported functions, while monkeypatch/setattr later may import a fresh module), which can make the suite order-dependent/flaky. Prefer reloading only intervals_mcp_server.server (e.g., via importlib.reload) or save/restore the affected sys.modules entries so other tests keep patching the same module objects.
| 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": |
There was a problem hiding this comment.
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()).
| 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": |
| """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. | ||
| This prevents real cycling or running activities from being dropped just because | ||
| the user left the default "Unnamed" label in Garmin/Strava. | ||
| """ |
There was a problem hiding this comment.
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").
| raw_name = activity.get("name", "") | ||
| activity_type = activity.get("type", "") | ||
| display_name = raw_name if (raw_name and raw_name != "Unnamed") else (activity_type or "Unnamed") | ||
|
|
There was a problem hiding this comment.
The new display_name behavior (falling back to activity type when name is empty/"Unnamed") isn’t covered by tests. Since tests/test_formatting.py already asserts activity name formatting, add a case for an unnamed activity to prevent regressions (e.g., name="Unnamed", type="Ride" should render as "Activity: Ride").
|
Awesome work @boeresteven-bit could you update the README to describe how to set it up? |
| """ | ||
| UNKNOWN_TYPES = {"", "unknown", None} | ||
|
|
||
| def _is_valid_activity(activity: dict[str, Any]) -> bool: |
There was a problem hiding this comment.
Not a big fan of function definitions inside function definitions. Especially for a function like this that could have wider use in the future.
| 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. | ||
|
|
There was a problem hiding this comment.
@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?
| """ | ||
| UNKNOWN_TYPES = {"", "unknown", None} | ||
|
|
||
| def _is_valid_activity(activity: dict[str, Any]) -> bool: |
There was a problem hiding this comment.
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.
| @@ -0,0 +1,218 @@ | |||
| # Render Deployment Implementation Plan | |||
There was a problem hiding this comment.
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.
| # 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": |
| """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. |
| for key in list(sys.modules.keys()): | ||
| if "intervals_mcp_server" in key: | ||
| del sys.modules[key] |
| | `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` | |
| # 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": | ||
| return True |
Summary
Test plan
🤖 Generated with Claude Code