@dynamicsninja/garminconnect-mcp 0.7.0 → 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/NOTICE +16 -16
- package/README.md +155 -36
- package/dist/cli.js +348 -47
- package/dist/cli.js.map +1 -1
- package/package.json +7 -2
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(
|
|
10
|
-
super(
|
|
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(
|
|
21
|
-
super(
|
|
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(
|
|
28
|
-
super(
|
|
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(
|
|
358
|
-
return CSRF_RE.exec(
|
|
356
|
+
function csrfFrom(html2) {
|
|
357
|
+
return CSRF_RE.exec(html2)?.[1] ?? null;
|
|
359
358
|
}
|
|
360
|
-
function titleFrom(
|
|
361
|
-
return TITLE_RE.exec(
|
|
359
|
+
function titleFrom(html2) {
|
|
360
|
+
return TITLE_RE.exec(html2)?.[1]?.trim() ?? "";
|
|
362
361
|
}
|
|
363
|
-
function ticketFrom(
|
|
364
|
-
return TICKET_RE.exec(
|
|
362
|
+
function ticketFrom(html2) {
|
|
363
|
+
return TICKET_RE.exec(html2)?.[1] ?? null;
|
|
365
364
|
}
|
|
366
|
-
function mfaVarsFrom(
|
|
365
|
+
function mfaVarsFrom(html2) {
|
|
367
366
|
const vars = {};
|
|
368
|
-
for (const [, name, value] of
|
|
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
|
|
432
|
-
const title = titleFrom(
|
|
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(
|
|
442
|
+
const vars = mfaVarsFrom(html2);
|
|
444
443
|
if (lower2.includes("mfa") || vars.mfaMethod) {
|
|
445
|
-
const mfaCsrf = csrfFrom(
|
|
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(
|
|
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
|
|
485
|
-
const title = titleFrom(
|
|
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(
|
|
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
|
|
538
|
-
throw new GarminAuthError(`SSO error: ${
|
|
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
|
|
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(
|
|
579
|
+
return err.message.startsWith("SSO error:") ? err : new GarminAuthError(message2, options);
|
|
581
580
|
}
|
|
582
|
-
if (err instanceof GarminConnectionError) return new GarminConnectionError(
|
|
581
|
+
if (err instanceof GarminConnectionError) return new GarminConnectionError(message2, options);
|
|
583
582
|
if (err instanceof GarminHttpError) {
|
|
584
|
-
return new GarminHttpError(
|
|
583
|
+
return new GarminHttpError(message2, err.status, err.url, err.body, options);
|
|
585
584
|
}
|
|
586
|
-
return err.constructor === GarminError ? new GarminError(
|
|
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,
|
|
4278
|
-
throw new SpecError(`${path3}: ${
|
|
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"]
|
|
4628
|
-
downloadDir:
|
|
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:
|
|
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 = "
|
|
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) {
|
|
@@ -4731,6 +4736,16 @@ import {
|
|
|
4731
4736
|
CallToolRequestSchema,
|
|
4732
4737
|
ListToolsRequestSchema
|
|
4733
4738
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
4739
|
+
|
|
4740
|
+
// src/icon.ts
|
|
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>';
|
|
4742
|
+
var SERVER_ICON = {
|
|
4743
|
+
src: `data:image/svg+xml;base64,${Buffer.from(SERVER_ICON_SVG, "utf8").toString("base64")}`,
|
|
4744
|
+
mimeType: "image/svg+xml",
|
|
4745
|
+
sizes: ["any"]
|
|
4746
|
+
};
|
|
4747
|
+
|
|
4748
|
+
// src/server.ts
|
|
4734
4749
|
function createServer(deps, factories) {
|
|
4735
4750
|
const tools = factories.flatMap((factory) => factory(deps));
|
|
4736
4751
|
const byName = /* @__PURE__ */ new Map();
|
|
@@ -4739,7 +4754,13 @@ function createServer(deps, factories) {
|
|
|
4739
4754
|
byName.set(def.tool.name, def);
|
|
4740
4755
|
}
|
|
4741
4756
|
const server = new Server(
|
|
4742
|
-
{
|
|
4757
|
+
{
|
|
4758
|
+
name: "garminconnect-mcp",
|
|
4759
|
+
title: "Garmin Connect (unofficial)",
|
|
4760
|
+
version: deps.version ?? "0.0.0",
|
|
4761
|
+
websiteUrl: "https://github.com/DynamicsNinja/garminconnect-js/tree/main/mcp#readme",
|
|
4762
|
+
icons: [SERVER_ICON]
|
|
4763
|
+
},
|
|
4743
4764
|
{ capabilities: { tools: {} } }
|
|
4744
4765
|
);
|
|
4745
4766
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: tools.map((t) => t.tool) }));
|
|
@@ -9555,16 +9576,296 @@ function toolFor(m, deps) {
|
|
|
9555
9576
|
}
|
|
9556
9577
|
};
|
|
9557
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
|
+
}
|
|
9558
9602
|
var methodTools = (deps) => {
|
|
9559
|
-
const
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
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);
|
|
9564
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));
|
|
9774
|
+
}
|
|
9775
|
+
return finish(res, attempt);
|
|
9565
9776
|
}
|
|
9566
|
-
|
|
9567
|
-
|
|
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
|
+
}
|
|
9568
9869
|
|
|
9569
9870
|
// ../src/exercises.ts
|
|
9570
9871
|
var EXERCISES = {
|
|
@@ -11759,10 +12060,10 @@ ${summarizeWorkout(workout)}`);
|
|
|
11759
12060
|
];
|
|
11760
12061
|
|
|
11761
12062
|
// src/tools.ts
|
|
11762
|
-
var TOOL_FACTORIES = [workoutTools, methodTools];
|
|
12063
|
+
var TOOL_FACTORIES = [signInTools(), workoutTools, methodTools];
|
|
11763
12064
|
|
|
11764
12065
|
// src/cli.ts
|
|
11765
|
-
var
|
|
12066
|
+
var version = "0.8.0";
|
|
11766
12067
|
var USAGE = `garminconnect-mcp ${version}
|
|
11767
12068
|
|
|
11768
12069
|
garminconnect-mcp start the MCP server (stdio); Claude Desktop runs this
|