latest updates
This commit is contained in:
106
server.py
106
server.py
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
@@ -6,9 +7,82 @@ from datetime import date
|
|||||||
|
|
||||||
from garminconnect import Garmin, GarminConnectAuthenticationError
|
from garminconnect import Garmin, GarminConnectAuthenticationError
|
||||||
from mcp.server.fastmcp import FastMCP
|
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")
|
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
|
_client: Garmin | None = None
|
||||||
_auth_state: str = "unauthenticated"
|
_auth_state: str = "unauthenticated"
|
||||||
_mfa_input_queue: queue.Queue = queue.Queue()
|
_mfa_input_queue: queue.Queue = queue.Queue()
|
||||||
@@ -39,7 +113,7 @@ def _startup_login() -> None:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_client = Garmin(email, password)
|
_client = Garmin(email, password)
|
||||||
_client.login()
|
_client.login(tokenstore=_TOKENSTORE_PATH)
|
||||||
_auth_state = "authenticated"
|
_auth_state = "authenticated"
|
||||||
except Exception:
|
except Exception:
|
||||||
_auth_state = "unauthenticated"
|
_auth_state = "unauthenticated"
|
||||||
@@ -57,7 +131,7 @@ def authenticate() -> str:
|
|||||||
|
|
||||||
def _do_login() -> None:
|
def _do_login() -> None:
|
||||||
try:
|
try:
|
||||||
_client.login()
|
_client.login(tokenstore=_TOKENSTORE_PATH)
|
||||||
_login_result_queue.put(("success", None))
|
_login_result_queue.put(("success", None))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_login_result_queue.put(("error", str(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}"
|
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()
|
@mcp.tool()
|
||||||
def get_sleep(date: str = "") -> str:
|
def get_sleep(date: str = "") -> str:
|
||||||
"""Get sleep data for a date (YYYY-MM-DD): score, stages, duration. Defaults to today."""
|
"""Get sleep data for a date (YYYY-MM-DD): score, stages, duration. Defaults to today."""
|
||||||
|
|||||||
Reference in New Issue
Block a user