@dynamicsninja/garminconnect-mcp 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,19 @@ Unofficial: not made by, or affiliated with, Garmin.
21
21
 
22
22
  ## Set up
23
23
 
24
- ### 1. Install
24
+ ### Easiest: the Claude Desktop Extension
25
+
26
+ 1. Download `garminconnect-mcp-<version>.mcpb` from the
27
+ [latest release](https://github.com/DynamicsNinja/garminconnect-js/releases/latest).
28
+ 2. Double-click it (or drag it onto Claude Desktop's Settings → Extensions) and choose **Install**.
29
+ 3. Ask Claude something about your Garmin data. The first time, Claude opens a Garmin sign-in page
30
+ in your browser; sign in there (with your MFA code if you use one) and tell Claude you're done.
31
+
32
+ Optional settings (tool groups, download folder) are under Settings → Extensions → Garmin Connect.
33
+
34
+ ### Other ways to install: npm
35
+
36
+ #### 1. Install
25
37
 
26
38
  ```sh
27
39
  npm install -g @dynamicsninja/garminconnect-mcp
@@ -31,7 +43,7 @@ Install it once rather than running it through `npx`: Claude Desktop starts seve
31
43
  server at the same moment, and parallel `npx` installs into one cache folder can break each other
32
44
  (`ERR_MODULE_NOT_FOUND … ajv/dist/2020.js`).
33
45
 
34
- ### 2. Sign in to Garmin
46
+ #### 2. Sign in to Garmin
35
47
 
36
48
  In a terminal (your password never passes through Claude):
37
49
 
@@ -42,7 +54,7 @@ garminconnect-mcp login
42
54
  It asks for your Garmin email, password and, if you use it, your MFA code. The session is saved to
43
55
  `~/.garminconnect-mcp/tokens` and renews itself as long as it is used at least once every 30 days.
44
56
 
45
- ### 3. Add it to Claude Desktop
57
+ #### 3. Add it to Claude Desktop
46
58
 
47
59
  Settings → Developer → **Edit Config**, and add a `garmin` entry to `claude_desktop_config.json`.
48
60
  Point `node` at the installed server. To find the path, run `npm root -g` and append
@@ -76,7 +88,7 @@ Point `node` at the installed server. To find the path, run `npm root -g` and ap
76
88
 
77
89
  If `node` itself is not found (common with nvm), use its full path from `which node` as `command`.
78
90
 
79
- ### 4. Restart Claude Desktop
91
+ #### 4. Restart Claude Desktop
80
92
 
81
93
  **Quit it completely**: tray / menu-bar icon → Quit. Closing the window leaves it running, and it
82
94
  will not read the new config. Start it again, open a new chat, and the Garmin tools appear under
@@ -105,6 +117,8 @@ destructive, and Claude is told to confirm with you before using them.
105
117
 
106
118
  ## What it can do
107
119
 
120
+ - **`sign_in_to_garmin`**: signs you in through a page that opens in your browser, MFA included —
121
+ no terminal needed. See [Easiest: the Claude Desktop Extension](#easiest-the-claude-desktop-extension) above.
108
122
  - **Workouts**: `preview_workout`, `create_workout`, `update_workout`, `search_exercises`, for
109
123
  running, cycling, swimming, strength, HIIT, yoga, pilates, mobility, cardio, rucking and
110
124
  multi-sport; plus listing, scheduling, sending to your watch and deleting.
@@ -121,7 +135,7 @@ checked `create_workout` replaces them), and the raw GraphQL passthrough unless
121
135
  |---|---|---|
122
136
  | `GARMIN_MCP_TOKEN_DIR` | `~/.garminconnect-mcp/tokens` | Where the session is saved |
123
137
  | `GARMIN_MCP_DOWNLOAD_DIR` | `~/Downloads/garmin` | Where downloaded files (FIT, GPX, …) go |
124
- | `GARMIN_MCP_GROUPS` | all | Comma-separated tool groups: `wellness`, `activities`, `metrics`, `workouts`, `gear`, `courses`, `devices`, `badges-challenges`, `body-composition-weight`, `womens-health`, `golf`, `profile-and-misc`. The workout-builder tools are always on. Fewer groups means less of Claude's context used. |
138
+ | `GARMIN_MCP_GROUPS` | all | Comma-separated tool groups: `wellness`, `activities`, `metrics`, `workouts`, `gear`, `courses`, `devices`, `badges-challenges`, `body-composition-weight`, `womens-health`, `golf`, `profile-and-misc`. Matched case-insensitively; an unknown name is ignored (and if none you list are valid, everything loads). The workout-builder tools are always on. Fewer groups means less of Claude's context used. |
125
139
  | `GARMIN_MCP_ENABLE_GRAPHQL` | off | Set to `1` to expose `query_garmin_graphql` |
126
140
 
127
141
  Set them under `"env"` in the config entry, for example:
@@ -142,8 +156,11 @@ session.
142
156
 
143
157
  - **The tools don't appear.** Make sure Claude Desktop was fully quit and restarted (step 4). Then
144
158
  Settings → Developer → `garmin` shows the server's status and a link to its log.
145
- - **"Not logged in to Garmin"** or **"session expired"**: run `garminconnect-mcp login` again. The
146
- running server picks up the new session on the next request; no restart needed.
159
+ - **"Not logged in to Garmin"** or **"session expired"**: ask Claude to sign in again (it calls
160
+ `sign_in_to_garmin`), or run `garminconnect-mcp login` again. The running server picks up the new
161
+ session on the next request; no restart needed.
162
+ - **Sign-in page didn't open**: Claude's reply includes the link; open it in any browser on this
163
+ computer. It expires after 15 minutes — ask Claude to sign in again.
147
164
  - **`ERR_MODULE_NOT_FOUND`** after trying `npx`: delete the `_npx` folder in your npm cache
148
165
  (`npm config get cache` shows where) and use the global install above.
149
166
  - **"Garmin is rate limiting requests"**: Garmin throttles bursts of calls. Wait a minute and ask
@@ -175,6 +192,6 @@ claude mcp add garmin -- garminconnect-mcp
175
192
 
176
193
  - Dates are calendar dates in UTC (`YYYY-MM-DD`).
177
194
  - Everything runs on your machine. Your Garmin session stays in your token folder and is sent only
178
- to Garmin.
195
+ to Garmin. See [PRIVACY.md](PRIVACY.md) for the full picture.
179
196
  - Garmin Connect's API is unofficial and can change. This server is released in lockstep with
180
197
  garminconnect-js, and each release is tested against the library version it contains.
package/dist/cli.js CHANGED
@@ -1,13 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { createRequire } from "module";
5
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
5
 
7
6
  // ../src/errors.ts
8
7
  var GarminError = class extends Error {
9
- constructor(message, options) {
10
- super(message, options);
8
+ constructor(message2, options) {
9
+ super(message2, options);
11
10
  this.name = new.target.name;
12
11
  Error.captureStackTrace?.(this, new.target);
13
12
  }
@@ -17,15 +16,15 @@ var GarminAuthError = class extends GarminError {
17
16
  var GarminConnectionError = class extends GarminError {
18
17
  };
19
18
  var GarminRateLimitError = class extends GarminError {
20
- constructor(message, retryAfter, options) {
21
- super(message, options);
19
+ constructor(message2, retryAfter, options) {
20
+ super(message2, options);
22
21
  this.retryAfter = retryAfter;
23
22
  }
24
23
  retryAfter;
25
24
  };
26
25
  var GarminHttpError = class extends GarminError {
27
- constructor(message, status, url, body, options) {
28
- super(message, options);
26
+ constructor(message2, status, url, body, options) {
27
+ super(message2, options);
29
28
  this.status = status;
30
29
  this.url = url;
31
30
  this.body = body;
@@ -354,18 +353,18 @@ var CSRF_RE = /name="_csrf"\s+value="(.+?)"/;
354
353
  var TITLE_RE = /<title>([\s\S]*?)<\/title>/i;
355
354
  var TICKET_RE = /\?ticket=(ST-[^"&\s]+)/;
356
355
  var MFA_VAR_RE = /var\s+(customerGuid|mfaMethod|locale|clientId|codeSentTo)\s*=\s*"([^"]*)"\s*;/g;
357
- function csrfFrom(html) {
358
- return CSRF_RE.exec(html)?.[1] ?? null;
356
+ function csrfFrom(html2) {
357
+ return CSRF_RE.exec(html2)?.[1] ?? null;
359
358
  }
360
- function titleFrom(html) {
361
- return TITLE_RE.exec(html)?.[1]?.trim() ?? "";
359
+ function titleFrom(html2) {
360
+ return TITLE_RE.exec(html2)?.[1]?.trim() ?? "";
362
361
  }
363
- function ticketFrom(html) {
364
- return TICKET_RE.exec(html)?.[1] ?? null;
362
+ function ticketFrom(html2) {
363
+ return TICKET_RE.exec(html2)?.[1] ?? null;
365
364
  }
366
- function mfaVarsFrom(html) {
365
+ function mfaVarsFrom(html2) {
367
366
  const vars = {};
368
- for (const [, name, value] of html.matchAll(MFA_VAR_RE)) {
367
+ for (const [, name, value] of html2.matchAll(MFA_VAR_RE)) {
369
368
  if (name !== void 0 && value !== void 0) vars[name] = value;
370
369
  }
371
370
  return vars;
@@ -428,8 +427,8 @@ async function widgetLogin(email, password, fetcher, domain, opts) {
428
427
  body: new URLSearchParams({ username: email, password, embed: "true", _csrf: csrf }).toString(),
429
428
  timeoutMs: 3e4
430
429
  });
431
- const html = await res.text();
432
- const title = titleFrom(html);
430
+ const html2 = await res.text();
431
+ const title = titleFrom(html2);
433
432
  const lower2 = title.toLowerCase();
434
433
  if (isServerErrorTitle(title)) {
435
434
  throw new GarminConnectionError(`Widget login: server error '${title}'`);
@@ -440,9 +439,9 @@ async function widgetLogin(email, password, fetcher, domain, opts) {
440
439
  if (RESTRICTED_HINTS.some((h) => lower2.includes(h))) {
441
440
  throw new GarminAuthError(`SSO error: ACCOUNT_RESTRICTED: ${title}`);
442
441
  }
443
- const vars = mfaVarsFrom(html);
442
+ const vars = mfaVarsFrom(html2);
444
443
  if (lower2.includes("mfa") || vars.mfaMethod) {
445
- const mfaCsrf = csrfFrom(html);
444
+ const mfaCsrf = csrfFrom(html2);
446
445
  if (!mfaCsrf) throw new GarminConnectionError("Widget login: MFA page has no CSRF token");
447
446
  const mfaReferer = fetcher.lastUrl ?? urls.signin;
448
447
  await requestMfaCode(fetcher, urls.base, vars, mfaReferer);
@@ -462,7 +461,7 @@ async function widgetLogin(email, password, fetcher, domain, opts) {
462
461
  if (title !== "Success") {
463
462
  throw new GarminConnectionError(`Widget login: unexpected page '${title}'`);
464
463
  }
465
- const ticket = ticketFrom(html);
464
+ const ticket = ticketFrom(html2);
466
465
  if (!ticket) throw new GarminAuthError("Widget login: missing service ticket");
467
466
  return opts.complete(ticket, urls.embed);
468
467
  }
@@ -481,13 +480,13 @@ async function widgetResume(state, code, fetcher, complete) {
481
480
  }).toString(),
482
481
  timeoutMs: 3e4
483
482
  });
484
- const html = await res.text();
485
- const title = titleFrom(html);
483
+ const html2 = await res.text();
484
+ const title = titleFrom(html2);
486
485
  if (isServerErrorTitle(title)) {
487
486
  throw new GarminConnectionError(`Widget MFA: server error '${title}'`);
488
487
  }
489
488
  if (title !== "Success") throw new GarminAuthError(`SSO error: INVALID_MFA_CODE: ${title}`);
490
- const ticket = ticketFrom(html);
489
+ const ticket = ticketFrom(html2);
491
490
  if (!ticket) throw new GarminAuthError("Widget login: missing service ticket");
492
491
  return complete(ticket, urls.embed);
493
492
  }
@@ -534,8 +533,8 @@ async function parseJson(res) {
534
533
  function requireSuccess(body) {
535
534
  const type = body.responseStatus?.type ?? "UNKNOWN";
536
535
  if (type !== SSO_SUCCESSFUL) {
537
- const message = body.responseStatus?.message;
538
- throw new GarminAuthError(`SSO error: ${message ? `${type}: ${message}` : type}`);
536
+ const message2 = body.responseStatus?.message;
537
+ throw new GarminAuthError(`SSO error: ${message2 ? `${type}: ${message2}` : type}`);
539
538
  }
540
539
  return body;
541
540
  }
@@ -574,16 +573,16 @@ async function mobileCredentials(email, password, ctx, params) {
574
573
  var WIDGET_CONTEXT = "Mobile login rate limited; ";
575
574
  function explainWidgetFailure(err) {
576
575
  if (!(err instanceof GarminError) || err instanceof GarminRateLimitError) return err;
577
- const message = WIDGET_CONTEXT + err.message;
576
+ const message2 = WIDGET_CONTEXT + err.message;
578
577
  const options = { cause: err };
579
578
  if (err instanceof GarminAuthError) {
580
- return err.message.startsWith("SSO error:") ? err : new GarminAuthError(message, options);
579
+ return err.message.startsWith("SSO error:") ? err : new GarminAuthError(message2, options);
581
580
  }
582
- if (err instanceof GarminConnectionError) return new GarminConnectionError(message, options);
581
+ if (err instanceof GarminConnectionError) return new GarminConnectionError(message2, options);
583
582
  if (err instanceof GarminHttpError) {
584
- return new GarminHttpError(message, err.status, err.url, err.body, options);
583
+ return new GarminHttpError(message2, err.status, err.url, err.body, options);
585
584
  }
586
- return err.constructor === GarminError ? new GarminError(message, options) : err;
585
+ return err.constructor === GarminError ? new GarminError(message2, options) : err;
587
586
  }
588
587
  async function login(email, password, ctx) {
589
588
  const params = loginParamsFor(ctx.domain);
@@ -4274,8 +4273,8 @@ var REPEAT_HINTS = {
4274
4273
  };
4275
4274
  var SpecError = class extends GarminError {
4276
4275
  };
4277
- function fail(path3, message) {
4278
- throw new SpecError(`${path3}: ${message}`);
4276
+ function fail(path3, message2) {
4277
+ throw new SpecError(`${path3}: ${message2}`);
4279
4278
  }
4280
4279
  function show(v) {
4281
4280
  if (v === void 0) return "nothing";
@@ -4620,14 +4619,20 @@ var WORKOUT_SPEC_JSON_SCHEMA = {
4620
4619
  // src/session.ts
4621
4620
  import os from "os";
4622
4621
  import path from "path";
4622
+ var setting = (value) => {
4623
+ const v = value?.trim();
4624
+ return v && !v.includes("${") ? v : void 0;
4625
+ };
4623
4626
  function loadConfig(env = process.env) {
4624
4627
  const home = os.homedir();
4625
- const groups = (env["GARMIN_MCP_GROUPS"] ?? "").split(",").map((g) => g.trim()).filter(Boolean);
4628
+ const groups = (setting(env["GARMIN_MCP_GROUPS"]) ?? "").split(",").map((g) => g.trim()).filter(Boolean);
4629
+ const graphql = (setting(env["GARMIN_MCP_ENABLE_GRAPHQL"]) ?? "").toLowerCase();
4630
+ const downloadDir = setting(env["GARMIN_MCP_DOWNLOAD_DIR"]);
4626
4631
  return {
4627
- tokenDir: env["GARMIN_MCP_TOKEN_DIR"] || path.join(home, ".garminconnect-mcp", "tokens"),
4628
- downloadDir: env["GARMIN_MCP_DOWNLOAD_DIR"] || path.join(home, "Downloads", "garmin"),
4632
+ tokenDir: setting(env["GARMIN_MCP_TOKEN_DIR"]) ?? path.join(home, ".garminconnect-mcp", "tokens"),
4633
+ downloadDir: downloadDir && path.isAbsolute(downloadDir) ? downloadDir : path.join(home, "Downloads", "garmin"),
4629
4634
  groups: groups.length > 0 ? new Set(groups) : null,
4630
- enableGraphql: env["GARMIN_MCP_ENABLE_GRAPHQL"] === "1"
4635
+ enableGraphql: graphql === "1" || graphql === "true"
4631
4636
  };
4632
4637
  }
4633
4638
  var NotLoggedInError = class extends GarminAuthError {
@@ -4653,7 +4658,7 @@ function fileSession(tokenDir, options = {}) {
4653
4658
  }
4654
4659
 
4655
4660
  // src/errors.ts
4656
- var LOGIN_HINT = "run `garminconnect-mcp login` in a terminal, then try again";
4661
+ var LOGIN_HINT = "call the `sign_in_to_garmin` tool to sign in, then try again";
4657
4662
  function describeError(error) {
4658
4663
  if (error instanceof NotLoggedInError) return `Not logged in to Garmin: ${LOGIN_HINT}.`;
4659
4664
  if (error instanceof GarminAuthError) {
@@ -4733,9 +4738,9 @@ import {
4733
4738
  } from "@modelcontextprotocol/sdk/types.js";
4734
4739
 
4735
4740
  // src/icon.ts
4736
- var SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="22" y="2" width="20" height="60" rx="6" fill="#1f2937"/><circle cx="32" cy="32" r="20" fill="#0f766e"/><circle cx="32" cy="32" r="15" fill="#f8fafc"/><path d="M32 21v11l7 5" fill="none" stroke="#0f766e" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/><rect x="51" y="27" width="5" height="10" rx="2" fill="#1f2937"/></svg>';
4741
+ var SERVER_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="22" y="2" width="20" height="60" rx="6" fill="#1f2937"/><circle cx="32" cy="32" r="20" fill="#0f766e"/><circle cx="32" cy="32" r="15" fill="#f8fafc"/><path d="M32 21v11l7 5" fill="none" stroke="#0f766e" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/><rect x="51" y="27" width="5" height="10" rx="2" fill="#1f2937"/></svg>';
4737
4742
  var SERVER_ICON = {
4738
- src: `data:image/svg+xml;base64,${Buffer.from(SVG, "utf8").toString("base64")}`,
4743
+ src: `data:image/svg+xml;base64,${Buffer.from(SERVER_ICON_SVG, "utf8").toString("base64")}`,
4739
4744
  mimeType: "image/svg+xml",
4740
4745
  sizes: ["any"]
4741
4746
  };
@@ -9571,16 +9576,296 @@ function toolFor(m, deps) {
9571
9576
  }
9572
9577
  };
9573
9578
  }
9579
+ var KNOWN_GROUPS = new Set(GARMIN_METHODS.map((m) => m.category));
9580
+ function resolveGroups(requested) {
9581
+ if (requested === null) return null;
9582
+ const byLower = new Map([...KNOWN_GROUPS].map((g) => [g.toLowerCase(), g]));
9583
+ const resolved = /* @__PURE__ */ new Set();
9584
+ const unknown = [];
9585
+ for (const raw of requested) {
9586
+ const match = byLower.get(raw.trim().toLowerCase());
9587
+ if (match) resolved.add(match);
9588
+ else unknown.push(raw);
9589
+ }
9590
+ if (unknown.length > 0) {
9591
+ process.stderr.write(
9592
+ `garminconnect-mcp: ignoring unknown GARMIN_MCP_GROUPS value(s): ${unknown.join(", ")}. Valid: ${[...KNOWN_GROUPS].sort().join(", ")}
9593
+ `
9594
+ );
9595
+ }
9596
+ if (resolved.size === 0) {
9597
+ process.stderr.write("garminconnect-mcp: no valid GARMIN_MCP_GROUPS value remained; loading all tool groups.\n");
9598
+ return null;
9599
+ }
9600
+ return resolved;
9601
+ }
9574
9602
  var methodTools = (deps) => {
9575
- const known = new Set(GARMIN_METHODS.map((m) => m.category));
9576
- if (deps.config.groups) {
9577
- const unknown = [...deps.config.groups].filter((g) => !known.has(g));
9578
- if (unknown.length > 0) {
9579
- throw new Error(`Unknown GARMIN_MCP_GROUPS value(s): ${unknown.join(", ")}. Valid: ${[...known].sort().join(", ")}`);
9603
+ const groups = resolveGroups(deps.config.groups);
9604
+ return GARMIN_METHODS.filter((m) => !Object.hasOwn(EXCLUDED, m.name)).filter((m) => !Object.hasOwn(OPT_IN, m.name) || deps.config[OPT_IN[m.name]]).filter((m) => groups === null || groups.has(m.category)).map((m) => toolFor(m, deps));
9605
+ };
9606
+
9607
+ // src/signin-tool.ts
9608
+ import { spawn } from "child_process";
9609
+
9610
+ // src/signin-page.ts
9611
+ import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
9612
+ import http from "http";
9613
+ var HEADERS = {
9614
+ "content-type": "text/html; charset=utf-8",
9615
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
9616
+ "cache-control": "no-store",
9617
+ "x-frame-options": "DENY",
9618
+ "referrer-policy": "no-referrer"
9619
+ };
9620
+ var MAX_BODY = 16 * 1024;
9621
+ var ENTITIES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
9622
+ var escape = (s) => s.replace(/[&<>"']/g, (c) => ENTITIES[c] ?? c);
9623
+ function html(title, body) {
9624
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)}</title><style>
9625
+ body{font-family:system-ui,sans-serif;background:#f8fafc;color:#0f172a;display:grid;place-items:center;min-height:100vh;margin:0}
9626
+ main{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:28px;width:min(360px,90vw)}
9627
+ h1{font-size:1.25rem;margin:0 0 4px}p{color:#475569;margin:0 0 16px}
9628
+ label{display:block;font-size:.9rem;margin:12px 0 4px}input{width:100%;box-sizing:border-box;padding:10px;border:1px solid #cbd5e1;border-radius:8px;font-size:1rem}
9629
+ button{margin-top:18px;width:100%;padding:10px;border:0;border-radius:8px;background:#0f766e;color:#fff;font-size:1rem;cursor:pointer}
9630
+ .error{background:#fef2f2;color:#991b1b;border-radius:8px;padding:10px;margin-bottom:8px}
9631
+ </style></head><body><main>${body}</main></body></html>`;
9632
+ }
9633
+ function credentialsForm(key2, error, email = "") {
9634
+ return html(
9635
+ "Sign in to Garmin",
9636
+ `<h1>Sign in to Garmin Connect</h1><p>For the Garmin tools in Claude. Your password goes only to Garmin.</p>
9637
+ ${error ? `<div class="error">${escape(error)}</div>` : ""}
9638
+ <form method="post"><input type="hidden" name="key" value="${escape(key2)}"><input type="hidden" name="step" value="credentials">
9639
+ <label for="email">Email</label><input id="email" name="email" type="email" autocomplete="username" required value="${escape(email)}">
9640
+ <label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
9641
+ <button type="submit">Sign in</button></form>`
9642
+ );
9643
+ }
9644
+ function mfaForm(key2, error) {
9645
+ return html(
9646
+ "Garmin verification code",
9647
+ `<h1>Enter your verification code</h1><p>Garmin sent a code to your email or phone.</p>
9648
+ ${error ? `<div class="error">${escape(error)}</div>` : ""}
9649
+ <form method="post"><input type="hidden" name="key" value="${escape(key2)}"><input type="hidden" name="step" value="mfa">
9650
+ <label for="code">Code</label><input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
9651
+ <button type="submit">Verify</button></form>`
9652
+ );
9653
+ }
9654
+ var donePage = (name) => html("Signed in", `<h1>Signed in as ${escape(name)}</h1><p>You can close this tab and go back to Claude.</p>`);
9655
+ function readBody(req) {
9656
+ return new Promise((resolve, reject) => {
9657
+ let size = 0;
9658
+ const chunks = [];
9659
+ req.on("data", (c) => {
9660
+ size += c.length;
9661
+ if (size > MAX_BODY) {
9662
+ reject(new Error("Request too large"));
9663
+ req.destroy();
9664
+ } else chunks.push(c);
9665
+ });
9666
+ req.on("end", () => resolve(new URLSearchParams(Buffer.concat(chunks).toString("utf8"))));
9667
+ req.on("error", reject);
9668
+ req.on("close", () => reject(new Error("Request aborted")));
9669
+ });
9670
+ }
9671
+ var message = (e) => e instanceof Error ? e.message : String(e);
9672
+ async function startSignInPage(options) {
9673
+ const key2 = randomBytes2(32).toString("base64url");
9674
+ const keyBytes = Buffer.from(key2);
9675
+ const isKey = (candidate) => {
9676
+ if (candidate == null) return false;
9677
+ const bytes = Buffer.from(candidate);
9678
+ return bytes.length === keyBytes.length && timingSafeEqual(bytes, keyBytes);
9679
+ };
9680
+ const idleMs = options.idleMs ?? 15 * 6e4;
9681
+ let client = null;
9682
+ let mfaState = null;
9683
+ let finished = false;
9684
+ let signedInName = "your Garmin account";
9685
+ let isClosed = false;
9686
+ let idle;
9687
+ let port = 0;
9688
+ let queue = Promise.resolve();
9689
+ const serialized = (step2) => {
9690
+ const run = queue.then(step2);
9691
+ queue = run.catch(() => {
9692
+ });
9693
+ return run;
9694
+ };
9695
+ let resolveClosed;
9696
+ const closed = new Promise((r) => resolveClosed = r);
9697
+ const server = http.createServer((req, res) => {
9698
+ void handle(req, res).catch(() => {
9699
+ if (!res.headersSent) res.writeHead(500, HEADERS);
9700
+ res.end(html("Error", "<h1>Something went wrong</h1><p>Close this tab and ask Claude to sign in again.</p>"));
9701
+ });
9702
+ });
9703
+ let closing;
9704
+ const close = () => closing ??= new Promise((resolve) => {
9705
+ isClosed = true;
9706
+ clearTimeout(idle);
9707
+ server.close(() => resolve());
9708
+ server.closeAllConnections?.();
9709
+ }).then(resolveClosed);
9710
+ const touch = () => {
9711
+ if (isClosed) return;
9712
+ clearTimeout(idle);
9713
+ idle = setTimeout(() => void close(), idleMs);
9714
+ idle.unref();
9715
+ };
9716
+ const closeAfter = (res) => {
9717
+ if (res.destroyed || res.writableFinished) void close();
9718
+ else res.once("close", () => void close());
9719
+ };
9720
+ const sendDone = (res, name = "your Garmin account") => {
9721
+ closeAfter(res);
9722
+ if (res.destroyed) return;
9723
+ res.writeHead(200, HEADERS);
9724
+ res.end(donePage(name));
9725
+ };
9726
+ const notFound = (res) => {
9727
+ res.writeHead(404, { "cache-control": "no-store" });
9728
+ res.end();
9729
+ };
9730
+ async function handle(req, res) {
9731
+ const path3 = req.url ?? "";
9732
+ if (req.headers.host !== `127.0.0.1:${port}` || !path3.startsWith("/") || !isKey(path3.slice(1))) return notFound(res);
9733
+ touch();
9734
+ const send = (body) => {
9735
+ res.writeHead(200, HEADERS);
9736
+ res.end(body);
9737
+ };
9738
+ if (finished) return sendDone(res, signedInName);
9739
+ if (req.method === "GET") return send(mfaState ? mfaForm(key2) : credentialsForm(key2));
9740
+ if (req.method !== "POST") return notFound(res);
9741
+ const form = await readBody(req);
9742
+ if (!isKey(form.get("key"))) return notFound(res);
9743
+ return serialized(() => step(form, res, send));
9744
+ }
9745
+ async function step(form, res, send) {
9746
+ if (finished) return sendDone(res, signedInName);
9747
+ if (res.destroyed) return;
9748
+ if (isClosed) return notFound(res);
9749
+ if (form.get("step") === "mfa") {
9750
+ if (!client || !mfaState) return send(credentialsForm(key2, "Please sign in again."));
9751
+ const code = (form.get("code") ?? "").replace(/\s+/g, "");
9752
+ try {
9753
+ await client.resumeLogin(mfaState, code);
9754
+ } catch (e) {
9755
+ return send(mfaForm(key2, message(e)));
9756
+ }
9757
+ return finish(res, client);
9758
+ }
9759
+ const email = (form.get("email") ?? "").trim();
9760
+ const password = form.get("password") ?? "";
9761
+ client = null;
9762
+ mfaState = null;
9763
+ const attempt = options.createClient();
9764
+ let result;
9765
+ try {
9766
+ result = await attempt.login(email, password);
9767
+ } catch (e) {
9768
+ return send(credentialsForm(key2, message(e), email));
9769
+ }
9770
+ client = attempt;
9771
+ if (result.state === "mfa_required") {
9772
+ mfaState = result.mfaState;
9773
+ return send(mfaForm(key2));
9580
9774
  }
9775
+ return finish(res, attempt);
9581
9776
  }
9582
- return GARMIN_METHODS.filter((m) => !Object.hasOwn(EXCLUDED, m.name)).filter((m) => !Object.hasOwn(OPT_IN, m.name) || deps.config[OPT_IN[m.name]]).filter((m) => deps.config.groups === null || deps.config.groups.has(m.category)).map((m) => toolFor(m, deps));
9583
- };
9777
+ async function finish(res, signedIn) {
9778
+ finished = true;
9779
+ mfaState = null;
9780
+ options.onSignedIn();
9781
+ const name = await signedIn.displayName().catch(() => "your Garmin account");
9782
+ signedInName = name;
9783
+ sendDone(res, name);
9784
+ }
9785
+ let onListenError = () => {
9786
+ };
9787
+ await new Promise((resolve, reject) => {
9788
+ onListenError = reject;
9789
+ server.once("error", onListenError);
9790
+ server.listen(0, "127.0.0.1", () => resolve());
9791
+ });
9792
+ server.off("error", onListenError);
9793
+ server.on("error", () => void close());
9794
+ server.unref();
9795
+ server.on("connection", (socket) => socket.unref());
9796
+ port = server.address().port;
9797
+ touch();
9798
+ return { url: `http://127.0.0.1:${port}/${key2}`, closed, close };
9799
+ }
9800
+
9801
+ // src/signin-tool.ts
9802
+ var SAFE_SIGNIN_URL = /^http:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9_-]+$/;
9803
+ function openInBrowser(url) {
9804
+ if (!SAFE_SIGNIN_URL.test(url)) return;
9805
+ const [command2, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
9806
+ try {
9807
+ const child = spawn(command2, args, { detached: true, stdio: "ignore", windowsHide: true });
9808
+ child.on("error", () => {
9809
+ });
9810
+ child.unref();
9811
+ } catch {
9812
+ }
9813
+ }
9814
+ function tokenStoreClient(tokenDir) {
9815
+ const client = new GarminClient({ tokenStore: new FileTokenStore(tokenDir) });
9816
+ return {
9817
+ login: (email, password) => client.login(email, password),
9818
+ resumeLogin: (state, code) => client.resumeLogin(state, code),
9819
+ displayName: async () => (await new Garmin(client).getUserProfile()).displayName
9820
+ };
9821
+ }
9822
+ function signInTools(options = {}) {
9823
+ const openUrl = options.openUrl ?? openInBrowser;
9824
+ const createClient = options.createClient ?? tokenStoreClient;
9825
+ const startPage = options.startPage ?? startSignInPage;
9826
+ let page = null;
9827
+ let starting = null;
9828
+ return ({ config: config2, session }) => [
9829
+ {
9830
+ tool: {
9831
+ name: "sign_in_to_garmin",
9832
+ description: "Sign in to Garmin Connect. Call this when another Garmin tool says you are not logged in or the session expired. It opens a sign-in page in the user's browser; the password and MFA code are entered there, never in the chat. Afterwards, ask the user to tell you when they have finished, then retry.",
9833
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
9834
+ annotations: { title: "Sign in to Garmin", readOnlyHint: false, destructiveHint: false, openWorldHint: true }
9835
+ },
9836
+ async run() {
9837
+ try {
9838
+ const profile = await (await session.get()).getUserProfile();
9839
+ return textResult(`Already signed in to Garmin as ${profile.displayName}.`);
9840
+ } catch (error) {
9841
+ if (!(error instanceof GarminAuthError)) throw error;
9842
+ }
9843
+ if (!page) {
9844
+ starting ??= startPage({
9845
+ createClient: () => createClient(config2.tokenDir),
9846
+ onSignedIn: () => session.reset(),
9847
+ idleMs: options.idleMs
9848
+ });
9849
+ let started;
9850
+ try {
9851
+ started = await starting;
9852
+ } finally {
9853
+ starting = null;
9854
+ }
9855
+ page = started;
9856
+ void started.closed.then(() => {
9857
+ if (page === started) page = null;
9858
+ });
9859
+ }
9860
+ openUrl(page.url);
9861
+ return textResult(
9862
+ `A Garmin sign-in page opened in your browser: ${page.url}
9863
+ Sign in there (including any MFA code), then tell me when you're done.`
9864
+ );
9865
+ }
9866
+ }
9867
+ ];
9868
+ }
9584
9869
 
9585
9870
  // ../src/exercises.ts
9586
9871
  var EXERCISES = {
@@ -11775,10 +12060,10 @@ ${summarizeWorkout(workout)}`);
11775
12060
  ];
11776
12061
 
11777
12062
  // src/tools.ts
11778
- var TOOL_FACTORIES = [workoutTools, methodTools];
12063
+ var TOOL_FACTORIES = [signInTools(), workoutTools, methodTools];
11779
12064
 
11780
12065
  // src/cli.ts
11781
- var { version } = createRequire(import.meta.url)("../package.json");
12066
+ var version = "0.8.0";
11782
12067
  var USAGE = `garminconnect-mcp ${version}
11783
12068
 
11784
12069
  garminconnect-mcp start the MCP server (stdio); Claude Desktop runs this