latest updates
This commit is contained in:
106
server.py
106
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."""
|
||||
|
||||
Reference in New Issue
Block a user