garmin-personal-api-mcp 1.1.0 → 1.2.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 +9 -1
- package/dist/keystore.js +9 -4
- package/dist/keystore.js.map +1 -1
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -114,7 +114,15 @@ This is an independent project, not made or endorsed by Garmin. "Garmin" and "Ga
|
|
|
114
114
|
|
|
115
115
|
## Claude Code, Cursor and other apps
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
**Claude Code and Cowork:** install the plugin, which adds the server plus a skill that tells Claude how to answer training and recovery questions:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
/plugin install garmin-personal-api-mcp --marketplace aaronhaddad/garmin-connect-mcp-claude-connector
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Claude Code asks for your key when you install it. Cowork doesn't ask, so run the setup below once and the plugin uses the saved key. See the [plugin README](plugin/README.md) for details.
|
|
124
|
+
|
|
125
|
+
**Any other MCP app**, or to save the key on your computer: run the setup wizard. You need Node.js 20.3 or later.
|
|
118
126
|
|
|
119
127
|
```bash
|
|
120
128
|
npx garmin-personal-api-mcp setup
|
package/dist/keystore.js
CHANGED
|
@@ -10,11 +10,15 @@ import { dirname } from "node:path";
|
|
|
10
10
|
import { API_KEY_ENV_VAR, CONFIG_DIR, DEFAULT_KEY_FILE, EXTENSION_DISPLAY_NAME, KEY_PAGE_URL, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, RUNTIME_ENV_VAR, SETTINGS_FILE, } from "./constants.js";
|
|
11
11
|
/** True when Claude Desktop runs us as a desktop extension, where the key comes from its settings, not `setup`. */
|
|
12
12
|
export const isExtension = () => process.env[RUNTIME_ENV_VAR] === "mcpb";
|
|
13
|
+
/** True when a Claude plugin (Claude Code, Cowork) runs us; the key comes from the plugin's userConfig or, failing that, from `setup`. */
|
|
14
|
+
export const isPlugin = () => process.env[RUNTIME_ENV_VAR] === "plugin";
|
|
13
15
|
/** How the user replaces a missing or rejected key, phrased for where we're running. Completes "Ask the user to …". */
|
|
14
16
|
export function keyFixHint() {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
if (isExtension())
|
|
18
|
+
return `open Claude Desktop → Settings → Extensions → ${EXTENSION_DISPLAY_NAME}, and paste a new key created at ${KEY_PAGE_URL}`;
|
|
19
|
+
if (isPlugin())
|
|
20
|
+
return `create a new key at ${KEY_PAGE_URL}, then either paste it into the plugin's settings (in Claude Code: /plugin → Installed → ${EXTENSION_DISPLAY_NAME} → Configure) or run \`npx garmin-personal-api-mcp setup\` in a terminal to save it on this computer`;
|
|
21
|
+
return "run `npx garmin-personal-api-mcp setup` in a terminal to add a new key";
|
|
18
22
|
}
|
|
19
23
|
/** Appended to key errors: without it the model goes looking for the data in a browser instead of telling the user. */
|
|
20
24
|
export const KEY_STOP_NOTE = "Only the user can fix this: tell them, and don't try to get the data another way (such as a browser).";
|
|
@@ -68,7 +72,8 @@ export function deleteKeyFile(path = DEFAULT_KEY_FILE) {
|
|
|
68
72
|
}
|
|
69
73
|
export function resolveKey() {
|
|
70
74
|
const env = process.env[API_KEY_ENV_VAR]?.trim();
|
|
71
|
-
|
|
75
|
+
// An app that doesn't fill in plugin settings can pass the template through untouched; that's not a key.
|
|
76
|
+
if (env && !env.startsWith("${"))
|
|
72
77
|
return { key: env, source: "env", location: `$${API_KEY_ENV_VAR}` };
|
|
73
78
|
const kc = readKeychain();
|
|
74
79
|
if (kc)
|
package/dist/keystore.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"keystore.js","sourceRoot":"","sources":["../src/keystore.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Where the Personal API key lives. Lookup order:\n * 1. GARMIN_PERSONAL_API_KEY environment variable\n * 2. macOS Keychain (written by `garmin-personal-api-mcp setup`)\n * 3. ~/.config/garmin/connect-personal-api-key (shared with Garmin's official Python client)\n */\nimport { spawnSync } from \"node:child_process\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport {\n API_KEY_ENV_VAR,\n CONFIG_DIR,\n DEFAULT_KEY_FILE,\n EXTENSION_DISPLAY_NAME,\n KEY_PAGE_URL,\n KEYCHAIN_ACCOUNT,\n KEYCHAIN_SERVICE,\n RUNTIME_ENV_VAR,\n SETTINGS_FILE,\n} from \"./constants.js\";\n\nexport type KeySource = \"env\" | \"keychain\" | \"file\";\n\nexport interface ResolvedKey {\n key: string;\n source: KeySource;\n location: string;\n}\n\nexport interface Settings {\n storage?: Exclude<KeySource, \"env\">;\n /** ISO date the user said the key expires, for reminders. */\n keyExpiresOn?: string;\n setupAt?: string;\n}\n\n/** True when Claude Desktop runs us as a desktop extension, where the key comes from its settings, not `setup`. */\nexport const isExtension = (): boolean => process.env[RUNTIME_ENV_VAR] === \"mcpb\";\n\n/** How the user replaces a missing or rejected key, phrased for where we're running. Completes \"Ask the user to …\". */\nexport function keyFixHint(): string {\n return isExtension()\n ? `open Claude Desktop → Settings → Extensions → ${EXTENSION_DISPLAY_NAME}, and paste a new key created at ${KEY_PAGE_URL}`\n : \"run `npx garmin-personal-api-mcp setup` in a terminal to add a new key\";\n}\n\n/** Appended to key errors: without it the model goes looking for the data in a browser instead of telling the user. */\nexport const KEY_STOP_NOTE = \"Only the user can fix this: tell them, and don't try to get the data another way (such as a browser).\";\n\nexport const keychainAvailable = (): boolean =>\n process.platform === \"darwin\" && existsSync(\"/usr/bin/security\");\n\nfunction runSecurity(args: string[], input?: string) {\n return spawnSync(\"/usr/bin/security\", args, { input, encoding: \"utf8\" });\n}\n\nexport function readKeychain(): string | undefined {\n if (!keychainAvailable()) return undefined;\n const res = runSecurity([\"find-generic-password\", \"-a\", KEYCHAIN_ACCOUNT, \"-s\", KEYCHAIN_SERVICE, \"-w\"]);\n const value = res.status === 0 ? res.stdout.trim() : \"\";\n return value || undefined;\n}\n\n/**\n * Writes via `security -i` (commands on stdin) so the key never appears in the\n * process list the way a `-w <key>` argument would.\n */\nexport function writeKeychain(key: string): void {\n const escaped = key.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n const cmd = `add-generic-password -U -a \"${KEYCHAIN_ACCOUNT}\" -s \"${KEYCHAIN_SERVICE}\" -l \"Garmin Connect Personal API key\" -w \"${escaped}\"\\n`;\n const res = runSecurity([\"-i\"], cmd);\n if (res.status !== 0 || /error/i.test(res.stderr)) {\n throw new Error(`Could not save the key to the macOS Keychain: ${res.stderr.trim() || `exit ${res.status}`}`);\n }\n}\n\nexport function deleteKeychain(): boolean {\n if (!keychainAvailable()) return false;\n return runSecurity([\"delete-generic-password\", \"-a\", KEYCHAIN_ACCOUNT, \"-s\", KEYCHAIN_SERVICE]).status === 0;\n}\n\nexport function readKeyFile(path = DEFAULT_KEY_FILE): string | undefined {\n try {\n const value = readFileSync(path, \"utf8\").trim();\n return value || undefined;\n } catch {\n return undefined;\n }\n}\n\nexport function writeKeyFile(key: string, path = DEFAULT_KEY_FILE): void {\n mkdirSync(dirname(path), { recursive: true, mode: 0o700 });\n writeFileSync(path, `${key}\\n`, { mode: 0o600 });\n chmodSync(path, 0o600);\n}\n\nexport function deleteKeyFile(path = DEFAULT_KEY_FILE): boolean {\n if (!existsSync(path)) return false;\n rmSync(path);\n return true;\n}\n\nexport function resolveKey(): ResolvedKey | undefined {\n const env = process.env[API_KEY_ENV_VAR]?.trim();\n if (env) return { key: env, source: \"env\", location: `$${API_KEY_ENV_VAR}` };\n const kc = readKeychain();\n if (kc) return { key: kc, source: \"keychain\", location: `macOS Keychain (${KEYCHAIN_SERVICE})` };\n const file = readKeyFile();\n if (file) return { key: file, source: \"file\", location: DEFAULT_KEY_FILE };\n return undefined;\n}\n\nexport function readSettings(): Settings {\n try {\n return JSON.parse(readFileSync(SETTINGS_FILE, \"utf8\")) as Settings;\n } catch {\n return {};\n }\n}\n\nexport function writeSettings(settings: Settings): void {\n mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });\n writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });\n}\n\n/** Days until the key expires, or undefined if unknown. Negative once expired. */\nexport function daysUntilKeyExpiry(settings = readSettings(), now = new Date()): number | undefined {\n if (!settings.keyExpiresOn) return undefined;\n const ms = new Date(`${settings.keyExpiresOn}T23:59:59`).getTime() - now.getTime();\n return Math.floor(ms / 86_400_000);\n}\n\n/** Masked preview such as `gk_a1…9f2c`, safe to print. */\nexport function maskKey(key: string): string {\n if (key.length <= 10) return \"•\".repeat(key.length);\n return `${key.slice(0, 4)}…${key.slice(-4)}`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"keystore.js","sourceRoot":"","sources":["../src/keystore.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAChG,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EACL,eAAe,EACf,UAAU,EACV,gBAAgB,EAChB,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,aAAa,GACd,MAAM,gBAAgB,CAAC;AAiBxB,mHAAmH;AACnH,MAAM,CAAC,MAAM,WAAW,GAAG,GAAY,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,KAAK,MAAM,CAAC;AAElF,0IAA0I;AAC1I,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAY,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,KAAK,QAAQ,CAAC;AAEjF,uHAAuH;AACvH,MAAM,UAAU,UAAU;IACxB,IAAI,WAAW,EAAE;QAAE,OAAO,iDAAiD,sBAAsB,oCAAoC,YAAY,EAAE,CAAC;IACpJ,IAAI,QAAQ,EAAE;QACZ,OAAO,uBAAuB,YAAY,4FAA4F,sBAAsB,sGAAsG,CAAC;IACrQ,OAAO,wEAAwE,CAAC;AAClF,CAAC;AAED,uHAAuH;AACvH,MAAM,CAAC,MAAM,aAAa,GAAG,uGAAuG,CAAC;AAErI,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAY,EAAE,CAC7C,OAAO,CAAC,QAAQ,KAAK,QAAQ,IAAI,UAAU,CAAC,mBAAmB,CAAC,CAAC;AAEnE,SAAS,WAAW,CAAC,IAAc,EAAE,KAAc;IACjD,OAAO,SAAS,CAAC,mBAAmB,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,YAAY;IAC1B,IAAI,CAAC,iBAAiB,EAAE;QAAE,OAAO,SAAS,CAAC;IAC3C,MAAM,GAAG,GAAG,WAAW,CAAC,CAAC,uBAAuB,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,CAAC,CAAC,CAAC;IACzG,MAAM,KAAK,GAAG,GAAG,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACxD,OAAO,KAAK,IAAI,SAAS,CAAC;AAC5B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAChE,MAAM,GAAG,GAAG,+BAA+B,gBAAgB,SAAS,gBAAgB,8CAA8C,OAAO,KAAK,CAAC;IAC/I,MAAM,GAAG,GAAG,WAAW,CAAC,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;IACrC,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CAAC,iDAAiD,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,QAAQ,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAChH,CAAC;AACH,CAAC;AAED,MAAM,UAAU,cAAc;IAC5B,IAAI,CAAC,iBAAiB,EAAE;QAAE,OAAO,KAAK,CAAC;IACvC,OAAO,WAAW,CAAC,CAAC,yBAAyB,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,gBAAgB,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC;AAC/G,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,IAAI,GAAG,gBAAgB;IACjD,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC;QAChD,OAAO,KAAK,IAAI,SAAS,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,GAAW,EAAE,IAAI,GAAG,gBAAgB;IAC/D,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC3D,aAAa,CAAC,IAAI,EAAE,GAAG,GAAG,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACjD,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,IAAI,GAAG,gBAAgB;IACnD,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IACpC,MAAM,CAAC,IAAI,CAAC,CAAC;IACb,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,UAAU;IACxB,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,EAAE,IAAI,EAAE,CAAC;IACjD,yGAAyG;IACzG,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,eAAe,EAAE,EAAE,CAAC;IACtG,MAAM,EAAE,GAAG,YAAY,EAAE,CAAC;IAC1B,IAAI,EAAE;QAAE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,mBAAmB,gBAAgB,GAAG,EAAE,CAAC;IACjG,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;IAC3B,IAAI,IAAI;QAAE,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,gBAAgB,EAAE,CAAC;IAC3E,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,YAAY;IAC1B,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,aAAa,EAAE,MAAM,CAAC,CAAa,CAAC;IACrE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,QAAkB;IAC9C,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACxD,aAAa,CAAC,aAAa,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AACnF,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,kBAAkB,CAAC,QAAQ,GAAG,YAAY,EAAE,EAAE,GAAG,GAAG,IAAI,IAAI,EAAE;IAC5E,IAAI,CAAC,QAAQ,CAAC,YAAY;QAAE,OAAO,SAAS,CAAC;IAC7C,MAAM,EAAE,GAAG,IAAI,IAAI,CAAC,GAAG,QAAQ,CAAC,YAAY,WAAW,CAAC,CAAC,OAAO,EAAE,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC;IACnF,OAAO,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,UAAU,CAAC,CAAC;AACrC,CAAC;AAED,0DAA0D;AAC1D,MAAM,UAAU,OAAO,CAAC,GAAW;IACjC,IAAI,GAAG,CAAC,MAAM,IAAI,EAAE;QAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACpD,OAAO,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AAC/C,CAAC","sourcesContent":["/**\n * Where the Personal API key lives. Lookup order:\n * 1. GARMIN_PERSONAL_API_KEY environment variable\n * 2. macOS Keychain (written by `garmin-personal-api-mcp setup`)\n * 3. ~/.config/garmin/connect-personal-api-key (shared with Garmin's official Python client)\n */\nimport { spawnSync } from \"node:child_process\";\nimport { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport {\n API_KEY_ENV_VAR,\n CONFIG_DIR,\n DEFAULT_KEY_FILE,\n EXTENSION_DISPLAY_NAME,\n KEY_PAGE_URL,\n KEYCHAIN_ACCOUNT,\n KEYCHAIN_SERVICE,\n RUNTIME_ENV_VAR,\n SETTINGS_FILE,\n} from \"./constants.js\";\n\nexport type KeySource = \"env\" | \"keychain\" | \"file\";\n\nexport interface ResolvedKey {\n key: string;\n source: KeySource;\n location: string;\n}\n\nexport interface Settings {\n storage?: Exclude<KeySource, \"env\">;\n /** ISO date the user said the key expires, for reminders. */\n keyExpiresOn?: string;\n setupAt?: string;\n}\n\n/** True when Claude Desktop runs us as a desktop extension, where the key comes from its settings, not `setup`. */\nexport const isExtension = (): boolean => process.env[RUNTIME_ENV_VAR] === \"mcpb\";\n\n/** True when a Claude plugin (Claude Code, Cowork) runs us; the key comes from the plugin's userConfig or, failing that, from `setup`. */\nexport const isPlugin = (): boolean => process.env[RUNTIME_ENV_VAR] === \"plugin\";\n\n/** How the user replaces a missing or rejected key, phrased for where we're running. Completes \"Ask the user to …\". */\nexport function keyFixHint(): string {\n if (isExtension()) return `open Claude Desktop → Settings → Extensions → ${EXTENSION_DISPLAY_NAME}, and paste a new key created at ${KEY_PAGE_URL}`;\n if (isPlugin())\n return `create a new key at ${KEY_PAGE_URL}, then either paste it into the plugin's settings (in Claude Code: /plugin → Installed → ${EXTENSION_DISPLAY_NAME} → Configure) or run \\`npx garmin-personal-api-mcp setup\\` in a terminal to save it on this computer`;\n return \"run `npx garmin-personal-api-mcp setup` in a terminal to add a new key\";\n}\n\n/** Appended to key errors: without it the model goes looking for the data in a browser instead of telling the user. */\nexport const KEY_STOP_NOTE = \"Only the user can fix this: tell them, and don't try to get the data another way (such as a browser).\";\n\nexport const keychainAvailable = (): boolean =>\n process.platform === \"darwin\" && existsSync(\"/usr/bin/security\");\n\nfunction runSecurity(args: string[], input?: string) {\n return spawnSync(\"/usr/bin/security\", args, { input, encoding: \"utf8\" });\n}\n\nexport function readKeychain(): string | undefined {\n if (!keychainAvailable()) return undefined;\n const res = runSecurity([\"find-generic-password\", \"-a\", KEYCHAIN_ACCOUNT, \"-s\", KEYCHAIN_SERVICE, \"-w\"]);\n const value = res.status === 0 ? res.stdout.trim() : \"\";\n return value || undefined;\n}\n\n/**\n * Writes via `security -i` (commands on stdin) so the key never appears in the\n * process list the way a `-w <key>` argument would.\n */\nexport function writeKeychain(key: string): void {\n const escaped = key.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n const cmd = `add-generic-password -U -a \"${KEYCHAIN_ACCOUNT}\" -s \"${KEYCHAIN_SERVICE}\" -l \"Garmin Connect Personal API key\" -w \"${escaped}\"\\n`;\n const res = runSecurity([\"-i\"], cmd);\n if (res.status !== 0 || /error/i.test(res.stderr)) {\n throw new Error(`Could not save the key to the macOS Keychain: ${res.stderr.trim() || `exit ${res.status}`}`);\n }\n}\n\nexport function deleteKeychain(): boolean {\n if (!keychainAvailable()) return false;\n return runSecurity([\"delete-generic-password\", \"-a\", KEYCHAIN_ACCOUNT, \"-s\", KEYCHAIN_SERVICE]).status === 0;\n}\n\nexport function readKeyFile(path = DEFAULT_KEY_FILE): string | undefined {\n try {\n const value = readFileSync(path, \"utf8\").trim();\n return value || undefined;\n } catch {\n return undefined;\n }\n}\n\nexport function writeKeyFile(key: string, path = DEFAULT_KEY_FILE): void {\n mkdirSync(dirname(path), { recursive: true, mode: 0o700 });\n writeFileSync(path, `${key}\\n`, { mode: 0o600 });\n chmodSync(path, 0o600);\n}\n\nexport function deleteKeyFile(path = DEFAULT_KEY_FILE): boolean {\n if (!existsSync(path)) return false;\n rmSync(path);\n return true;\n}\n\nexport function resolveKey(): ResolvedKey | undefined {\n const env = process.env[API_KEY_ENV_VAR]?.trim();\n // An app that doesn't fill in plugin settings can pass the template through untouched; that's not a key.\n if (env && !env.startsWith(\"${\")) return { key: env, source: \"env\", location: `$${API_KEY_ENV_VAR}` };\n const kc = readKeychain();\n if (kc) return { key: kc, source: \"keychain\", location: `macOS Keychain (${KEYCHAIN_SERVICE})` };\n const file = readKeyFile();\n if (file) return { key: file, source: \"file\", location: DEFAULT_KEY_FILE };\n return undefined;\n}\n\nexport function readSettings(): Settings {\n try {\n return JSON.parse(readFileSync(SETTINGS_FILE, \"utf8\")) as Settings;\n } catch {\n return {};\n }\n}\n\nexport function writeSettings(settings: Settings): void {\n mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });\n writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), { mode: 0o600 });\n}\n\n/** Days until the key expires, or undefined if unknown. Negative once expired. */\nexport function daysUntilKeyExpiry(settings = readSettings(), now = new Date()): number | undefined {\n if (!settings.keyExpiresOn) return undefined;\n const ms = new Date(`${settings.keyExpiresOn}T23:59:59`).getTime() - now.getTime();\n return Math.floor(ms / 86_400_000);\n}\n\n/** Masked preview such as `gk_a1…9f2c`, safe to print. */\nexport function maskKey(key: string): string {\n if (key.length <= 10) return \"•\".repeat(key.length);\n return `${key.slice(0, 4)}…${key.slice(-4)}`;\n}\n"]}
|
package/dist/server.js
CHANGED
|
@@ -3,9 +3,11 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
|
3
3
|
import { GarminClient } from "./api.js";
|
|
4
4
|
import { TokenManager } from "./auth.js";
|
|
5
5
|
import { SERVER_NAME, VERSION } from "./constants.js";
|
|
6
|
-
import { daysUntilKeyExpiry, isExtension, KEY_STOP_NOTE, keyFixHint, resolveKey } from "./keystore.js";
|
|
6
|
+
import { daysUntilKeyExpiry, isExtension, isPlugin, KEY_STOP_NOTE, keyFixHint, resolveKey } from "./keystore.js";
|
|
7
7
|
import { registerTools } from "./tools.js";
|
|
8
8
|
function noKeyMessage() {
|
|
9
|
+
if (isPlugin())
|
|
10
|
+
return `No Garmin Personal API key is set for this plugin or on this computer. Ask the user to ${keyFixHint()}. ${KEY_STOP_NOTE}`;
|
|
9
11
|
return isExtension()
|
|
10
12
|
? `No Garmin Personal API key is set in this extension. Ask the user to ${keyFixHint()}. ${KEY_STOP_NOTE}`
|
|
11
13
|
: "No Garmin Personal API key is set up on this computer. Ask the user to run `npx garmin-personal-api-mcp setup` in a terminal (it walks them through creating and saving the key), then restart this app. " + KEY_STOP_NOTE;
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,aAAa,EAAE,UAAU,EAAkB,UAAU,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,QAAQ,EAAE,aAAa,EAAE,UAAU,EAAkB,UAAU,EAAE,MAAM,eAAe,CAAC;AACjI,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE3C,SAAS,YAAY;IACnB,IAAI,QAAQ,EAAE;QAAE,OAAO,0FAA0F,UAAU,EAAE,KAAK,aAAa,EAAE,CAAC;IAClJ,OAAO,WAAW,EAAE;QAClB,CAAC,CAAC,wEAAwE,UAAU,EAAE,KAAK,aAAa,EAAE;QAC1G,CAAC,CAAC,2MAA2M,GAAG,aAAa,CAAC;AAClO,CAAC;AAED,6FAA6F;AAC7F,SAAS,MAAM;IACb,MAAM,KAAK,GAAG,UAAU,EAAE,CAAC;IAC3B,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC,CAAC;IAC5C,OAAO,KAAK,CAAC,GAAG,CAAC;AACnB,CAAC;AAED,iIAAiI;AACjI,MAAM,UAAU,aAAa,CAAC,MAA6B,EAAE,IAAwB;IACnF,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,KAAK,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IAC9E,IAAI,IAAI,GAAG,CAAC;QAAE,OAAO,gDAAgD,UAAU,EAAE,GAAG,CAAC;IACrF,IAAI,IAAI,IAAI,CAAC;QAAE,OAAO,uBAAuB,IAAI,yCAAyC,UAAU,EAAE,GAAG,CAAC;IAC1G,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,MAAM,GAAG,IAAI,YAAY,CAAC,IAAI,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;AAE1D,MAAM,UAAU,YAAY;IAC1B,MAAM,MAAM,GAAG,IAAI,SAAS,CAC1B,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,EAAE,EACvC;QACE,YAAY,EACV,+pBAA+pB;YAC/pB,aAAa,CAAC,UAAU,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE,CAAC;KAC5D,CACF,CAAC;IACF,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,UAAU;IACxB,UAAU,CAAC,YAAY,CAAC,CAAC;IACzB,MAAM,GAAG,GAAG,UAAU,EAAE,CAAC;IACzB,OAAO,CAAC,KAAK,CAAC,2BAA2B,OAAO,oBAAoB,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,YAAY,UAAU,EAAE,GAAG,EAAE,CAAC,CAAC;AAC1H,CAAC","sourcesContent":["import { McpServer } from \"@modelcontextprotocol/server\";\nimport { serveStdio } from \"@modelcontextprotocol/server/stdio\";\nimport { GarminClient } from \"./api.js\";\nimport { TokenManager } from \"./auth.js\";\nimport { SERVER_NAME, VERSION } from \"./constants.js\";\nimport { daysUntilKeyExpiry, isExtension, isPlugin, KEY_STOP_NOTE, keyFixHint, type KeySource, resolveKey } from \"./keystore.js\";\nimport { registerTools } from \"./tools.js\";\n\nfunction noKeyMessage(): string {\n if (isPlugin()) return `No Garmin Personal API key is set for this plugin or on this computer. Ask the user to ${keyFixHint()}. ${KEY_STOP_NOTE}`;\n return isExtension()\n ? `No Garmin Personal API key is set in this extension. Ask the user to ${keyFixHint()}. ${KEY_STOP_NOTE}`\n : \"No Garmin Personal API key is set up on this computer. Ask the user to run `npx garmin-personal-api-mcp setup` in a terminal (it walks them through creating and saving the key), then restart this app. \" + KEY_STOP_NOTE;\n}\n\n/** Key is resolved per token refresh, so a key added by `setup` works without restarting. */\nfunction getKey(): string {\n const found = resolveKey();\n if (!found) throw new Error(noKeyMessage());\n return found.key;\n}\n\n/** The expiry date in settings belongs to the key `setup` saved; an env key (e.g. from the extension) may be a different one. */\nexport function keyExpiryNote(source: KeySource | undefined, days: number | undefined): string {\n if (source === undefined || source === \"env\" || days === undefined) return \"\";\n if (days < 0) return ` The saved key has expired; tell the user to ${keyFixHint()}.`;\n if (days <= 7) return ` The key expires in ${days} day(s); mention that the user should ${keyFixHint()}.`;\n return \"\";\n}\n\nconst client = new GarminClient(new TokenManager(getKey));\n\nexport function createServer(): McpServer {\n const server = new McpServer(\n { name: SERVER_NAME, version: VERSION },\n {\n instructions:\n \"Read-only access to the user's own Garmin Connect data through Garmin's official Personal API: activities (with exact splits from the .FIT file), sleep, HRV, Body Battery, stress, steps, weight and nutrition. Start with garmin_get_day for recovery questions and garmin_list_activities → garmin_get_activity_splits for training questions. Dates accept YYYY-MM-DD, 'today', 'yesterday' or 'N days ago'. For VO2 max (as Garmin Connect shows it), training effect and recovery time use garmin_get_performance_metrics, which decodes them from the activity files. Training readiness, training status and race predictions are computed on Garmin's servers and are not available.\" +\n keyExpiryNote(resolveKey()?.source, daysUntilKeyExpiry()),\n },\n );\n registerTools(server, client);\n return server;\n}\n\nexport function startStdio(): void {\n serveStdio(createServer);\n const key = resolveKey();\n console.error(`garmin-personal-api-mcp ${VERSION} on stdio · key: ${key ? key.location : `not set (${keyFixHint()})`}`);\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "garmin-personal-api-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "MCP server for your own Garmin Connect data (activities with FIT splits, sleep, HRV, Body Battery, weight) via Garmin's official Personal API, with an interactive key setup wizard.",
|
|
5
5
|
"main": "dist/server.js",
|
|
6
6
|
"scripts": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"prepare": "npm run build",
|
|
15
15
|
"check": "tsc --noEmit && vitest run && npm run smoke",
|
|
16
16
|
"pack:mcpb": "node scripts/pack-mcpb.mjs",
|
|
17
|
-
"version": "node scripts/sync-manifest-version.mjs && git add manifest.json"
|
|
17
|
+
"version": "node scripts/sync-manifest-version.mjs && git add manifest.json plugin/.claude-plugin/plugin.json plugin/.mcp.json"
|
|
18
18
|
},
|
|
19
19
|
"keywords": [
|
|
20
20
|
"mcp",
|