Skip to content

Commit ff0bed5

Browse files
Taxusptpn
andauthored
fix(dxt): resolve token path ${HOME} and handle unset optional user_config (closes #204) (#220)
* fix(dxt): resolve default Garmin token path * fix(auth): share token path normalization * fix(auth): ignore unresolved credential placeholders --------- Co-authored-by: Paweł Nadolski <pn@users.noreply.github.com>
1 parent f80702d commit ff0bed5

10 files changed

Lines changed: 235 additions & 27 deletions

File tree

dxt/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"title": "Token storage directory",
5050
"description": "Directory where your Garmin OAuth tokens are stored. You must run garmin-mcp-auth once in a terminal to generate these tokens before using the server.",
5151
"required": false,
52-
"default": "${HOME}/.garminconnect"
52+
"default": "~/.garminconnect"
5353
},
5454
"garmin_email": {
5555
"type": "string",

garmin-mcp.dxt

-6 Bytes
Binary file not shown.

scripts/build_dxt.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
55
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
66
OUTPUT="$REPO_ROOT/garmin-mcp.dxt"
77

8+
rm -f "$OUTPUT"
89
cd "$REPO_ROOT/dxt"
910
zip "$OUTPUT" manifest.json
1011
echo "Built: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"

src/garmin_mcp/__init__.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ def get_mfa() -> str:
6262
return input("Enter MFA code: ")
6363

6464

65+
def _normalize_optional_user_config(value: str | None, key: str) -> str | None:
66+
"""Treat an unresolved optional Desktop Extension value as unset."""
67+
unresolved_placeholder = f"${{user_config.{key}}}"
68+
return None if value == unresolved_placeholder else value
69+
70+
6571
# Get credentials from environment
6672
email = os.environ.get("GARMIN_EMAIL")
6773
email_file = os.environ.get("GARMIN_EMAIL_FILE")
@@ -83,8 +89,8 @@ def get_mfa() -> str:
8389
with open(password_file, "r") as password_file:
8490
password = password_file.read().rstrip()
8591

86-
tokenstore = os.getenv("GARMINTOKENS") or "~/.garminconnect"
87-
tokenstore_base64 = os.getenv("GARMINTOKENS_BASE64") or "~/.garminconnect_base64"
92+
tokenstore = token_utils.get_token_path()
93+
tokenstore_base64 = token_utils.get_token_base64_path()
8894
is_cn = os.getenv("GARMIN_IS_CN", "false").lower() in ("true", "1", "yes")
8995

9096

@@ -218,6 +224,12 @@ def init_api(email, password):
218224
"""Initialize Garmin API with your credentials."""
219225
import io
220226

227+
# Claude Desktop may leave blank optional user_config values as literal
228+
# placeholders. Do not mistake those strings for credentials and trigger a
229+
# rate-limited Garmin login from a non-interactive MCP process.
230+
email = _normalize_optional_user_config(email, "garmin_email")
231+
password = _normalize_optional_user_config(password, "garmin_password")
232+
221233
try:
222234
# Using Oauth1 and OAuth2 token files from directory
223235
print(
@@ -294,17 +306,15 @@ def init_api(email, password):
294306
file=sys.stderr,
295307
)
296308
# Encode Oauth1 and Oauth2 tokens to base64 string and save to file for next login (alternative way)
297-
expanded_tokenstore = os.path.expanduser(tokenstore)
298-
token_json_path = os.path.join(expanded_tokenstore, "garmin_tokens.json")
309+
token_json_path = os.path.join(tokenstore, "garmin_tokens.json")
299310
with open(token_json_path, "r") as f:
300311
token_data = f.read()
301312
token_base64 = base64.b64encode(token_data.encode()).decode()
302-
dir_path = os.path.expanduser(tokenstore_base64)
303-
with open(dir_path, "w") as token_file:
313+
with open(tokenstore_base64, "w") as token_file:
304314
token_file.write(token_base64)
305-
os.chmod(dir_path, 0o600)
315+
os.chmod(tokenstore_base64, 0o600)
306316
print(
307-
f"Oauth tokens encoded as base64 string and saved to '{dir_path}' file for future use. (second method)\n",
317+
f"Oauth tokens encoded as base64 string and saved to '{tokenstore_base64}' file for future use. (second method)\n",
308318
file=sys.stderr,
309319
)
310320
except (

src/garmin_mcp/auth_cli.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
token_exists,
2020
validate_tokens,
2121
get_token_info,
22+
resolve_token_path,
2223
secure_token_dir as _secure_token_dir,
2324
)
2425

@@ -121,6 +122,9 @@ def authenticate(token_path: str, token_base64_path: str, force_reauth: bool = F
121122
"""
122123
import io
123124

125+
token_path = resolve_token_path(token_path)
126+
token_base64_path = resolve_token_path(token_base64_path)
127+
124128
# Check if tokens already exist and are valid
125129
if not force_reauth and token_exists(token_path):
126130
print(f"\nChecking existing tokens in '{token_path}'...")
@@ -164,20 +168,18 @@ def authenticate(token_path: str, token_base64_path: str, force_reauth: bool = F
164168

165169
# Save tokens to directory
166170
garmin.client.dump(token_path)
167-
expanded_token_path = os.path.expanduser(token_path)
168-
_secure_token_dir(expanded_token_path)
169-
print(f"\n✓ OAuth tokens saved to: {expanded_token_path}")
171+
_secure_token_dir(token_path)
172+
print(f"\n✓ OAuth tokens saved to: {token_path}")
170173

171174
# Save tokens as base64
172-
token_json_path = os.path.join(expanded_token_path, "garmin_tokens.json")
173-
expanded_base64_path = os.path.expanduser(token_base64_path)
175+
token_json_path = os.path.join(token_path, "garmin_tokens.json")
174176
with open(token_json_path, "r") as f:
175177
token_data = f.read()
176178
token_base64 = base64.b64encode(token_data.encode()).decode()
177-
with open(expanded_base64_path, "w") as token_file:
179+
with open(token_base64_path, "w") as token_file:
178180
token_file.write(token_base64)
179-
os.chmod(expanded_base64_path, 0o600)
180-
print(f"✓ OAuth tokens (base64) saved to: {expanded_base64_path}")
181+
os.chmod(token_base64_path, 0o600)
182+
print(f"✓ OAuth tokens (base64) saved to: {token_base64_path}")
181183

182184
# Verify tokens work with an independent token-based login. The login
183185
# above runs with return_on_mfa=True, which skips profile loading, so a

src/garmin_mcp/token_utils.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,26 @@
77
from garminconnect import Garmin, GarminConnectConnectionError
88

99

10+
def resolve_token_path(path: str) -> str:
11+
"""Resolve environment variables and the user-home marker in a token path.
12+
13+
Some MCP clients leave ``${HOME}`` unresolved when it comes from a nested
14+
user-config default. The explicit replacement also covers Windows, where
15+
``HOME`` may be unset but Python can still resolve ``~`` via ``USERPROFILE``.
16+
"""
17+
expanded = os.path.expandvars(path)
18+
expanded = expanded.replace("${HOME}", os.path.expanduser("~"))
19+
return os.path.expanduser(expanded)
20+
21+
1022
def secure_token_dir(path: str) -> None:
1123
"""Set owner-only permissions on a token directory and the files inside it.
1224
1325
OAuth tokens are ~6-month bearer credentials to the full Garmin account, so
1426
they must not be left world-readable on multi-user hosts. Safe to call on a
1527
path that is a single file rather than a directory.
1628
"""
17-
expanded = os.path.expanduser(path)
29+
expanded = resolve_token_path(path)
1830
if not os.path.exists(expanded):
1931
return
2032
if os.path.isdir(expanded):
@@ -32,7 +44,7 @@ def get_token_path() -> str:
3244
Returns:
3345
str: Path to token storage directory
3446
"""
35-
return os.getenv("GARMINTOKENS") or "~/.garminconnect"
47+
return resolve_token_path(os.getenv("GARMINTOKENS") or "~/.garminconnect")
3648

3749

3850
def get_token_base64_path() -> str:
@@ -41,7 +53,9 @@ def get_token_base64_path() -> str:
4153
Returns:
4254
str: Path to base64 token file
4355
"""
44-
return os.getenv("GARMINTOKENS_BASE64") or "~/.garminconnect_base64"
56+
return resolve_token_path(
57+
os.getenv("GARMINTOKENS_BASE64") or "~/.garminconnect_base64"
58+
)
4559

4660

4761
def token_exists(token_path: str = None) -> bool:
@@ -56,7 +70,7 @@ def token_exists(token_path: str = None) -> bool:
5670
if token_path is None:
5771
token_path = get_token_path()
5872

59-
expanded_path = Path(os.path.expanduser(token_path))
73+
expanded_path = Path(resolve_token_path(token_path))
6074
return expanded_path.exists()
6175

6276

@@ -75,6 +89,7 @@ def validate_tokens(token_path: str = None, is_cn: bool = False) -> Tuple[bool,
7589

7690
if token_path is None:
7791
token_path = get_token_path()
92+
token_path = resolve_token_path(token_path)
7893

7994
# Check if tokens exist
8095
if not token_exists(token_path):
@@ -138,17 +153,19 @@ def remove_tokens(token_path: str = None, base64_path: str = None) -> None:
138153
token_path = get_token_path()
139154
if base64_path is None:
140155
base64_path = get_token_base64_path()
156+
token_path = resolve_token_path(token_path)
157+
base64_path = resolve_token_path(base64_path)
141158

142159
# Remove token directory
143-
expanded_token_path = Path(os.path.expanduser(token_path))
160+
expanded_token_path = Path(token_path)
144161
if expanded_token_path.exists():
145162
if expanded_token_path.is_dir():
146163
shutil.rmtree(expanded_token_path)
147164
else:
148165
expanded_token_path.unlink()
149166

150167
# Remove base64 token file
151-
expanded_base64_path = Path(os.path.expanduser(base64_path))
168+
expanded_base64_path = Path(base64_path)
152169
if expanded_base64_path.exists():
153170
expanded_base64_path.unlink()
154171

@@ -165,6 +182,7 @@ def get_token_info(token_path: str = None, is_cn: bool = False) -> dict:
165182
"""
166183
if token_path is None:
167184
token_path = get_token_path()
185+
token_path = resolve_token_path(token_path)
168186

169187
exists = token_exists(token_path)
170188
is_valid = False
@@ -175,7 +193,7 @@ def get_token_info(token_path: str = None, is_cn: bool = False) -> dict:
175193

176194
return {
177195
"path": token_path,
178-
"expanded_path": os.path.expanduser(token_path),
196+
"expanded_path": token_path,
179197
"exists": exists,
180198
"valid": is_valid,
181199
"error": error_msg

tests/unit/test_auth_cli.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,48 @@ def test_successful_authentication(self, mock_chmod, mock_verify, mock_garmin, m
184184
# Verify restrictive permissions were applied to the base64 file
185185
mock_chmod.assert_any_call(os.path.expanduser(base64_path), 0o600)
186186

187+
@patch("garmin_mcp.auth_cli.token_exists", return_value=False)
188+
@patch(
189+
"garmin_mcp.auth_cli.get_credentials",
190+
return_value=("test@example.com", "secret"),
191+
)
192+
@patch("garmin_mcp.auth_cli.Garmin")
193+
@patch(
194+
"garmin_mcp.auth_cli._verify_saved_tokens",
195+
return_value=(True, "Test User"),
196+
)
197+
@patch("garmin_mcp.auth_cli.os.chmod")
198+
def test_authentication_resolves_home_in_token_paths(
199+
self,
200+
mock_chmod,
201+
mock_verify,
202+
mock_garmin,
203+
_mock_get_creds,
204+
mock_exists,
205+
monkeypatch,
206+
tmp_path,
207+
):
208+
"""The CLI writes and verifies tokens at the same resolved paths."""
209+
monkeypatch.setenv("HOME", str(tmp_path))
210+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
211+
mock_garmin.return_value.login.return_value = (None, None)
212+
213+
with patch("builtins.open", mock_open(read_data="{}")):
214+
result = authenticate(
215+
"${HOME}/.garminconnect",
216+
"${HOME}/.garminconnect_base64",
217+
)
218+
219+
expected_token_path = str(tmp_path / ".garminconnect")
220+
expected_base64_path = str(tmp_path / ".garminconnect_base64")
221+
assert result is True
222+
mock_exists.assert_called_once_with(expected_token_path)
223+
mock_garmin.return_value.client.dump.assert_called_once_with(
224+
expected_token_path
225+
)
226+
mock_verify.assert_called_once_with(expected_token_path, False)
227+
mock_chmod.assert_any_call(expected_base64_path, 0o600)
228+
187229
@patch("garmin_mcp.auth_cli.token_exists")
188230
@patch("garmin_mcp.auth_cli.get_credentials")
189231
@patch("garmin_mcp.auth_cli.Garmin")

tests/unit/test_dxt_manifest.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Regression tests for the Desktop Extension manifest."""
2+
3+
import json
4+
from pathlib import Path
5+
from zipfile import ZipFile
6+
7+
8+
REPO_ROOT = Path(__file__).parents[2]
9+
MANIFEST_PATH = REPO_ROOT / "dxt" / "manifest.json"
10+
BUNDLE_PATH = REPO_ROOT / "garmin-mcp.dxt"
11+
12+
13+
def _read_manifest():
14+
return json.loads(MANIFEST_PATH.read_text())
15+
16+
17+
def test_token_path_default_does_not_require_nested_interpolation():
18+
manifest = _read_manifest()
19+
assert manifest["user_config"]["token_path"]["default"] == "~/.garminconnect"
20+
21+
22+
def test_user_config_defaults_do_not_contain_template_variables():
23+
manifest = _read_manifest()
24+
25+
for config in manifest.get("user_config", {}).values():
26+
default = config.get("default")
27+
values = default if isinstance(default, list) else [default]
28+
assert all("${" not in value for value in values if isinstance(value, str))
29+
30+
31+
def test_bundled_manifest_matches_source_manifest():
32+
manifest = _read_manifest()
33+
with ZipFile(BUNDLE_PATH) as bundle:
34+
assert bundle.namelist() == ["manifest.json"]
35+
bundled_manifest = json.loads(bundle.read("manifest.json"))
36+
37+
assert bundled_manifest == manifest

0 commit comments

Comments
 (0)