Fork intervals-mcp-server: native OAuth + streamable-HTTP, no monkeypatch
build-image / build (push) Failing after 18s
build-image / build (push) Failing after 18s
- Bump mcp[cli] 1.22 -> 1.28.1 (negotiates MCP protocol 2025-11-25, matching current Claude clients; the old 2025-06-18 server never got a tools/list on the connector surface). - Bake transport config into code: stateless_http + json_response for HTTP (single JSON body instead of a 34KB SSE stream, which the connector pipeline handles far more reliably). - Bake Authentik OAuth (AuthSettings + JWT TokenVerifier) into intervals_mcp_server.auth, configured from MCP_ISSUER/MCP_RESOURCE/MCP_JWKS_URI/MCP_CLIENT_ID — removes the runtime FastMCP.__init__ monkeypatch from the k8s deployment command. - Accept token audience with/without trailing slash (RFC 8707 clients use the slash-normalised resource metadata value). - Dockerfile CMD runs the module (transport via MCP_TRANSPORT); add .gitea CI to build+push the image to git.farh.net/farhoodlabs/intervalsicu-mcp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
API client module for Intervals.icu MCP Server.
|
||||
|
||||
This module contains the HTTP client and API request handling logic.
|
||||
"""
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
API client for Intervals.icu MCP Server.
|
||||
|
||||
This module handles all HTTP communication with the Intervals.icu API,
|
||||
including request management, error handling, and client lifecycle.
|
||||
"""
|
||||
|
||||
from json import JSONDecodeError
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
import httpx # pylint: disable=import-error
|
||||
from mcp.server.fastmcp import FastMCP # pylint: disable=import-error
|
||||
|
||||
from intervals_mcp_server.config import get_config
|
||||
|
||||
logger = logging.getLogger("intervals_icu_mcp_server")
|
||||
|
||||
# Create a single AsyncClient instance for all requests (lazily initialized)
|
||||
# This can be monkeypatched via server.httpx_client for testing
|
||||
httpx_client: httpx.AsyncClient | None = None # pylint: disable=invalid-name
|
||||
|
||||
|
||||
async def _get_httpx_client() -> httpx.AsyncClient:
|
||||
"""
|
||||
Lazily create or reuse the shared httpx AsyncClient.
|
||||
|
||||
The client may be closed by downstream transports between tool invocations,
|
||||
so we recreate it when necessary.
|
||||
|
||||
This function checks server.httpx_client first (if available) to support
|
||||
test monkeypatching via server.httpx_client.
|
||||
"""
|
||||
global httpx_client # pylint: disable=global-statement # noqa: PLW0603 - we intentionally manage the shared client here
|
||||
|
||||
# Check for monkeypatched client in server module first (for test compatibility)
|
||||
# This allows tests to monkeypatch server.httpx_client and have it work
|
||||
try:
|
||||
server_module = sys.modules.get("intervals_mcp_server.server")
|
||||
if server_module and hasattr(server_module, "httpx_client"):
|
||||
server_client = server_module.httpx_client
|
||||
if server_client is not None and not server_client.is_closed:
|
||||
return server_client
|
||||
except (AttributeError, ImportError):
|
||||
pass
|
||||
|
||||
# Use this module's httpx_client
|
||||
if httpx_client is None or httpx_client.is_closed:
|
||||
httpx_client = httpx.AsyncClient()
|
||||
return httpx_client
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def setup_api_client(_app: FastMCP):
|
||||
"""
|
||||
Context manager to ensure the shared httpx client is closed when the server stops.
|
||||
|
||||
Args:
|
||||
_app (FastMCP): The MCP server application instance.
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Close the module-level httpx_client
|
||||
if httpx_client and not httpx_client.is_closed:
|
||||
await httpx_client.aclose()
|
||||
|
||||
# Also close server.httpx_client if it exists (for test compatibility)
|
||||
# This ensures monkeypatched clients in tests are properly closed
|
||||
try:
|
||||
server_module = sys.modules.get("intervals_mcp_server.server")
|
||||
if server_module and hasattr(server_module, "httpx_client"):
|
||||
server_client = getattr(server_module, "httpx_client", None)
|
||||
if server_client is not None and not server_client.is_closed:
|
||||
await server_client.aclose()
|
||||
except (AttributeError, ImportError):
|
||||
pass
|
||||
|
||||
|
||||
def _get_error_message(error_code: int, error_text: str) -> str:
|
||||
"""Return a user-friendly error message for a given HTTP status code."""
|
||||
error_messages = {
|
||||
HTTPStatus.UNAUTHORIZED: f"{HTTPStatus.UNAUTHORIZED.value} {HTTPStatus.UNAUTHORIZED.phrase}: Please check your API key.",
|
||||
HTTPStatus.FORBIDDEN: f"{HTTPStatus.FORBIDDEN.value} {HTTPStatus.FORBIDDEN.phrase}: You may not have permission to access this resource.",
|
||||
HTTPStatus.NOT_FOUND: f"{HTTPStatus.NOT_FOUND.value} {HTTPStatus.NOT_FOUND.phrase}: The requested endpoint or ID doesn't exist.",
|
||||
HTTPStatus.UNPROCESSABLE_ENTITY: f"{HTTPStatus.UNPROCESSABLE_ENTITY.value} {HTTPStatus.UNPROCESSABLE_ENTITY.phrase}: The server couldn't process the request (invalid parameters or unsupported operation).",
|
||||
HTTPStatus.TOO_MANY_REQUESTS: f"{HTTPStatus.TOO_MANY_REQUESTS.value} {HTTPStatus.TOO_MANY_REQUESTS.phrase}: Too many requests in a short time period.",
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR: f"{HTTPStatus.INTERNAL_SERVER_ERROR.value} {HTTPStatus.INTERNAL_SERVER_ERROR.phrase}: The Intervals.icu server encountered an internal error.",
|
||||
HTTPStatus.SERVICE_UNAVAILABLE: f"{HTTPStatus.SERVICE_UNAVAILABLE.value} {HTTPStatus.SERVICE_UNAVAILABLE.phrase}: The Intervals.icu server might be down or undergoing maintenance.",
|
||||
}
|
||||
try:
|
||||
status = HTTPStatus(error_code)
|
||||
return error_messages.get(status, error_text)
|
||||
except ValueError:
|
||||
return error_text
|
||||
|
||||
|
||||
def _prepare_request_config(
|
||||
url: str,
|
||||
api_key: str | None,
|
||||
method: str,
|
||||
) -> tuple[str, httpx.BasicAuth, dict[str, str], str | None]:
|
||||
"""Prepare request configuration including headers, auth, and URL.
|
||||
|
||||
Returns:
|
||||
Tuple of (full_url, auth, headers, error_message).
|
||||
error_message is None if configuration is valid.
|
||||
"""
|
||||
config = get_config()
|
||||
headers = {"User-Agent": config.user_agent, "Accept": "application/json"}
|
||||
|
||||
if method in ["POST", "PUT"]:
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
# Use provided api_key or fall back to global API_KEY
|
||||
key_to_use = api_key if api_key is not None else config.api_key
|
||||
if not key_to_use:
|
||||
logger.error("No API key provided for request to: %s", url)
|
||||
return (
|
||||
"",
|
||||
httpx.BasicAuth("", ""),
|
||||
{},
|
||||
"API key is required. Set API_KEY env var or pass api_key",
|
||||
)
|
||||
|
||||
auth = httpx.BasicAuth("API_KEY", key_to_use)
|
||||
full_url = f"{config.intervals_api_base_url}{url}"
|
||||
return full_url, auth, headers, None
|
||||
|
||||
|
||||
def _parse_response(
|
||||
response: httpx.Response, full_url: str
|
||||
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Parse HTTP response and return JSON data or error dict.
|
||||
|
||||
Returns:
|
||||
Parsed JSON response or error dict.
|
||||
"""
|
||||
try:
|
||||
response_data = response.json() if response.content else {}
|
||||
except JSONDecodeError:
|
||||
logger.error("Invalid JSON in response from: %s", full_url)
|
||||
return {"error": True, "message": "Invalid JSON in response"}
|
||||
response.raise_for_status()
|
||||
return response_data
|
||||
|
||||
|
||||
async def make_intervals_request(
|
||||
url: str,
|
||||
api_key: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
method: str = "GET",
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""
|
||||
Make a request to the Intervals.icu API with proper error handling.
|
||||
|
||||
Args:
|
||||
url (str): The API endpoint path (e.g., '/athlete/{id}/activities').
|
||||
api_key (str | None): Optional API key to use for authentication. Defaults to the global API_KEY.
|
||||
params (dict[str, Any] | None): Optional query parameters for the request.
|
||||
method (str): HTTP method to use (GET, POST, etc.). Defaults to GET.
|
||||
data (dict[str, Any] | None): Optional data to send in the request body.
|
||||
|
||||
Returns:
|
||||
dict[str, Any] | list[dict[str, Any]]: The parsed JSON response from the API, or an error dict.
|
||||
"""
|
||||
# Prepare request configuration
|
||||
full_url, auth, headers, error_msg = _prepare_request_config(url, api_key, method)
|
||||
if error_msg:
|
||||
return {"error": True, "message": error_msg}
|
||||
|
||||
async def _send_request(client: httpx.AsyncClient) -> httpx.Response:
|
||||
if method in {"POST", "PUT"} and data is not None:
|
||||
body = json.dumps(data)
|
||||
logger.debug("Request %s %s body: %s", method, full_url, body)
|
||||
return await client.request(
|
||||
method=method,
|
||||
url=full_url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
auth=auth,
|
||||
timeout=30.0,
|
||||
content=body,
|
||||
)
|
||||
return await client.request(
|
||||
method=method,
|
||||
url=full_url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
auth=auth,
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
try:
|
||||
client = await _get_httpx_client()
|
||||
|
||||
try:
|
||||
response = await _send_request(client)
|
||||
except RuntimeError as runtime_error:
|
||||
# httpx closes the client when the underlying connection is severed;
|
||||
# recreate the shared client lazily and retry once.
|
||||
if "client has been closed" not in str(runtime_error).lower():
|
||||
raise
|
||||
logger.warning("HTTPX client was closed; creating a new instance for retries.")
|
||||
global httpx_client # pylint: disable=global-statement # noqa: PLW0603 - we intentionally manage the shared client here
|
||||
httpx_client = None
|
||||
client = await _get_httpx_client()
|
||||
response = await _send_request(client)
|
||||
|
||||
return _parse_response(response, full_url)
|
||||
except httpx.HTTPStatusError as e:
|
||||
return _handle_http_status_error(e)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("Request error: %s", str(e))
|
||||
return {"error": True, "message": f"Request error: {str(e)}"}
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("HTTP client error: %s", str(e))
|
||||
return {"error": True, "message": f"HTTP client error: {str(e)}"}
|
||||
|
||||
|
||||
def _handle_http_status_error(e: httpx.HTTPStatusError) -> dict[str, Any]:
|
||||
"""Handle HTTP status errors and return formatted error dict.
|
||||
|
||||
Args:
|
||||
e: The HTTPStatusError exception.
|
||||
|
||||
Returns:
|
||||
Error dictionary with status code and message.
|
||||
"""
|
||||
error_code = e.response.status_code
|
||||
error_text = e.response.text
|
||||
logger.error("HTTP error: %s - %s", error_code, error_text)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": error_code,
|
||||
"message": _get_error_message(error_code, error_text),
|
||||
}
|
||||
Reference in New Issue
Block a user