From ec0002b9f93a65340a7151c073386998eaa9939b Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Tue, 21 Jul 2026 18:36:43 +0200 Subject: [PATCH] latest updates --- server.py | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index 36f5a5a..6884728 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,5 @@ import json +import logging import os import queue import threading @@ -6,9 +7,82 @@ from datetime import date from garminconnect import Garmin, GarminConnectAuthenticationError from mcp.server.fastmcp import FastMCP +from rich._log_render import LogRender +from rich.console import Console +from rich.containers import Renderables +from rich.logging import RichHandler +from rich.table import Table +from rich.text import Text mcp = FastMCP("garmin") + +class _PathFirstLogRender(LogRender): + """Rich's default log layout is time/level/message/path, with path + stealing fixed width from the message column that's cramped further + still by Console falling back to an 80-column default (stderr here is + a pipe to the parent process, not a real terminal, so Rich can't + detect an actual width). Reordering to time/level/path/message and + widening the console both help: message gets the whole remaining line + instead of wrapping every ~30 characters.""" + + def __call__(self, console, renderables, log_time=None, time_format=None, + level="", path=None, line_no=None, link_path=None): + output = Table.grid(padding=(0, 1)) + output.expand = True + if self.show_time: + output.add_column(style="log.time") + if self.show_level: + output.add_column(style="log.level", width=self.level_width) + if self.show_path and path: + output.add_column(style="log.path") + output.add_column(ratio=1, style="log.message", overflow="fold") + + row = [] + if self.show_time: + log_time = log_time or console.get_datetime() + time_format = time_format or self.time_format + log_time_display = ( + time_format(log_time) if callable(time_format) else Text(log_time.strftime(time_format)) + ) + if log_time_display == self._last_time and self.omit_repeated_times: + row.append(Text(" " * len(log_time_display))) + else: + row.append(log_time_display) + self._last_time = log_time_display + if self.show_level: + row.append(level) + if self.show_path and path: + path_text = Text() + path_text.append(path, style=f"link file://{link_path}" if link_path else "") + if line_no: + path_text.append(":") + path_text.append(str(line_no), style=f"link file://{link_path}#{line_no}" if link_path else "") + row.append(path_text) + row.append(Renderables(renderables)) + + output.add_row(*row) + return output + + +def _configure_wide_logging() -> None: + """Replace the RichHandler FastMCP installed (via configure_logging() + in its __init__, already run by the time FastMCP("garmin") above + returns) with one that isn't squeezed into 80 columns.""" + handler = RichHandler(console=Console(stderr=True, width=200), rich_tracebacks=True) + handler._log_render = _PathFirstLogRender(show_time=True, show_level=True, show_path=True) + logging.root.handlers = [handler] + + +_configure_wide_logging() + +# Where garth caches the Garmin session (SSO/OAuth tokens), so a restart +# resumes an existing session instead of doing a full login (and MFA +# challenge) every time. GARMIN_TOKENSTORE lets the caller (smartrund) +# override this; garminconnect's own GARMINTOKENS env fallback is not used +# since nothing here sets it. +_TOKENSTORE_PATH = os.environ.get("GARMIN_TOKENSTORE") or os.path.expanduser("~/.garth") + _client: Garmin | None = None _auth_state: str = "unauthenticated" _mfa_input_queue: queue.Queue = queue.Queue() @@ -39,7 +113,7 @@ def _startup_login() -> None: ) try: _client = Garmin(email, password) - _client.login() + _client.login(tokenstore=_TOKENSTORE_PATH) _auth_state = "authenticated" except Exception: _auth_state = "unauthenticated" @@ -57,7 +131,7 @@ def authenticate() -> str: def _do_login() -> None: try: - _client.login() + _client.login(tokenstore=_TOKENSTORE_PATH) _login_result_queue.put(("success", None)) except Exception as exc: _login_result_queue.put(("error", str(exc))) @@ -131,6 +205,34 @@ def get_activity_details(activity_id: str) -> str: return f"Error fetching activity details: {exc}" +@mcp.tool() +def get_activity_splits(activity_id: str) -> str: + """Get lap/split summaries for a single activity by its ID.""" + if err := _check_auth(): + return err + try: + result = _client.get_activity_splits(activity_id) + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching activity splits: {exc}" + + +@mcp.tool() +def get_workout_by_id(workout_id: str) -> str: + """Get a structured workout's step-by-step plan by its ID.""" + if err := _check_auth(): + return err + try: + result = _client.get_workout_by_id(workout_id) + return json.dumps(result, indent=2) + except GarminConnectAuthenticationError: + return "Authentication error. Call authenticate() again." + except Exception as exc: + return f"Error fetching workout: {exc}" + + @mcp.tool() def get_sleep(date: str = "") -> str: """Get sleep data for a date (YYYY-MM-DD): score, stages, duration. Defaults to today."""