hevy-mcp 1.26.0 → 1.26.1
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 +63 -16
- package/dist/cli.mjs +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{src-Di7keTQA.mjs → src-_FHG1OUO.mjs} +261 -72
- package/dist/src-_FHG1OUO.mjs.map +1 -0
- package/package.json +2 -1
- package/dist/src-Di7keTQA.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -38,10 +38,10 @@ A Model Context Protocol (MCP) server implementation that interfaces with the [H
|
|
|
38
38
|
|
|
39
39
|
Pick the workflow that fits your setup:
|
|
40
40
|
|
|
41
|
-
| Scenario | Command
|
|
42
|
-
| :-------------------- |
|
|
43
|
-
| **One-off stdio run** | `HEVY_API_KEY=sk_live... npx -y hevy-mcp`
|
|
44
|
-
| **Local development** | `npm install && npm run build && npm start`
|
|
41
|
+
| Scenario | Command | Requirements |
|
|
42
|
+
| :-------------------- | :------------------------------------------------------------------------------------------ | :------------------------- |
|
|
43
|
+
| **One-off stdio run** | `HEVY_API_KEY=sk_live... npx -y hevy-mcp` or `HEVY_API_KEY=sk_live... bunx hevy-mcp@latest` | Node.js ≥ 26, Hevy API key |
|
|
44
|
+
| **Local development** | `npm install && npm run build && npm start` | `.env` with `HEVY_API_KEY` |
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
@@ -49,18 +49,24 @@ Pick the workflow that fits your setup:
|
|
|
49
49
|
|
|
50
50
|
- **Node.js**: v26 or higher (strongly recommended to use the exact version pinned in `.nvmrc`).
|
|
51
51
|
- **npm**: v10 or higher.
|
|
52
|
+
- **Bun** (optional): If you want to launch with `bunx`.
|
|
52
53
|
- **Hevy API key**: Required for all operations (available with Hevy PRO).
|
|
53
54
|
|
|
54
55
|
---
|
|
55
56
|
|
|
56
57
|
## 📦 Installation
|
|
57
58
|
|
|
58
|
-
### Run via npx
|
|
59
|
+
### Run via npx or bunx
|
|
59
60
|
|
|
60
|
-
You can launch the server directly without cloning
|
|
61
|
+
You can launch the server directly without cloning. Both launchers are covered
|
|
62
|
+
by nightly smoke tests:
|
|
61
63
|
|
|
62
64
|
```bash
|
|
65
|
+
# npm launcher
|
|
63
66
|
HEVY_API_KEY=your_hevy_api_key_here npx -y hevy-mcp
|
|
67
|
+
|
|
68
|
+
# bun launcher
|
|
69
|
+
HEVY_API_KEY=your_hevy_api_key_here bunx hevy-mcp@latest
|
|
64
70
|
```
|
|
65
71
|
|
|
66
72
|
### Manual Installation
|
|
@@ -103,6 +109,15 @@ To use this server with Claude Desktop, add the following to your `claude_deskto
|
|
|
103
109
|
}
|
|
104
110
|
```
|
|
105
111
|
|
|
112
|
+
If you prefer Bun, swap the launcher fields:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"command": "bunx",
|
|
117
|
+
"args": ["hevy-mcp@latest"]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
106
121
|
### Cursor Configuration
|
|
107
122
|
|
|
108
123
|
Add this server under `"mcpServers"` in `~/.cursor/mcp.json`:
|
|
@@ -121,6 +136,15 @@ Add this server under `"mcpServers"` in `~/.cursor/mcp.json`:
|
|
|
121
136
|
}
|
|
122
137
|
```
|
|
123
138
|
|
|
139
|
+
If you prefer Bun, swap the launcher fields:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"command": "bunx",
|
|
144
|
+
"args": ["hevy-mcp@latest"]
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
124
148
|
### Other MCP Clients (via add-mcp)
|
|
125
149
|
|
|
126
150
|
For a generic setup flow across MCP clients, use [`add-mcp`](https://github.com/neon-solutions/add-mcp):
|
|
@@ -144,16 +168,34 @@ This bootstraps the `hevy-mcp` entry in your client config without manual JSON e
|
|
|
144
168
|
|
|
145
169
|
## ⚙️ Configuration
|
|
146
170
|
|
|
147
|
-
Supply your Hevy API key via
|
|
171
|
+
Supply your Hevy API key via the `HEVY_API_KEY` environment variable (in
|
|
172
|
+
`.env` or system environment).
|
|
148
173
|
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
> ⚠️ CLI API key arguments (`--hevy-api-key=...`, `--hevyApiKey=...`,
|
|
175
|
+
> `hevy-api-key=...`) are still accepted for backward compatibility, but are
|
|
176
|
+
> deprecated and insecure. Use `HEVY_API_KEY` instead.
|
|
151
177
|
|
|
152
178
|
```env
|
|
153
179
|
# Example .env
|
|
154
180
|
HEVY_API_KEY=your_hevy_api_key_here
|
|
155
181
|
```
|
|
156
182
|
|
|
183
|
+
### 🧠 Exercise Template Cache Behavior
|
|
184
|
+
|
|
185
|
+
`search-exercise-templates` now uses a shared in-memory async cache for the
|
|
186
|
+
full exercise template catalog:
|
|
187
|
+
|
|
188
|
+
- **TTL**: 5 minutes per cached catalog entry.
|
|
189
|
+
- **Memory bound**: max 1 catalog entry (LRU bounded cache).
|
|
190
|
+
- **In-flight de-duplication**: concurrent requests share the same active
|
|
191
|
+
fetch when possible.
|
|
192
|
+
- **Manual refresh**: set `refresh: true` in the tool input to invalidate the
|
|
193
|
+
cached catalog and force a re-fetch from the Hevy API.
|
|
194
|
+
|
|
195
|
+
This cache currently applies to `search-exercise-templates` only. Paginated
|
|
196
|
+
`get-exercise-templates` requests still call the API directly to keep paging
|
|
197
|
+
behavior explicit and avoid cross-page invalidation complexity.
|
|
198
|
+
|
|
157
199
|
### 📡 Sentry Monitoring
|
|
158
200
|
|
|
159
201
|
`hevy-mcp` includes Sentry monitoring to observe errors and usage in production. It initializes `@sentry/node` with tracing enabled and PII collection disabled by default. Recent observability changes also add:
|
|
@@ -166,15 +208,12 @@ HEVY_API_KEY=your_hevy_api_key_here
|
|
|
166
208
|
---
|
|
167
209
|
|
|
168
210
|
<details>
|
|
169
|
-
<summary><strong>⚠️
|
|
170
|
-
|
|
171
|
-
### Stdio Only
|
|
211
|
+
<summary><strong>⚠️ Migration Note (v1.18.0)</strong></summary>
|
|
172
212
|
|
|
173
|
-
As of
|
|
213
|
+
As of **v1.18.0**, `hevy-mcp` removed both HTTP/SSE transport and Docker
|
|
214
|
+
support.
|
|
174
215
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
Docker-based workflows are retired. The provided `Dockerfile` now exits with a message pointing to the stdio-native experience. Legacy GHCR images are no longer updated.
|
|
216
|
+
The supported path is stdio via `npx hevy-mcp`.
|
|
178
217
|
|
|
179
218
|
</details>
|
|
180
219
|
|
|
@@ -191,6 +230,11 @@ Docker-based workflows are retired. The provided `Dockerfile` now exits with a m
|
|
|
191
230
|
| **Body Measurements** | `get-body-measurements`, `get-body-measurement`, `create-body-measurement`, `update-body-measurement` |
|
|
192
231
|
| **User** | `get-user-info` |
|
|
193
232
|
|
|
233
|
+
> **Delete operations are currently unsupported:** The upstream Hevy OpenAPI
|
|
234
|
+
> spec does not expose `DELETE` endpoints for workouts, routines, routine
|
|
235
|
+
> folders, exercise templates, or body measurements, so `hevy-mcp` does not
|
|
236
|
+
> provide delete tools for these resources.
|
|
237
|
+
|
|
194
238
|
---
|
|
195
239
|
|
|
196
240
|
## 👨💻 Development & Contributing
|
|
@@ -199,6 +243,7 @@ Docker-based workflows are retired. The provided `Dockerfile` now exits with a m
|
|
|
199
243
|
|
|
200
244
|
- **Build**: `npm run build`
|
|
201
245
|
- **Lint/Format**: `npm run check` (uses oxlint/oxfmt)
|
|
246
|
+
- **Type Check**: `npm run check:types`
|
|
202
247
|
- **Unit Tests**: `npx vitest run --exclude tests/integration/**`
|
|
203
248
|
- **Full Test Suite**: `npm test` (requires `HEVY_API_KEY`)
|
|
204
249
|
- **Changeset Check**: `npm run check:changeset`
|
|
@@ -210,6 +255,8 @@ For a detailed senior engineer guide, please refer to [AGENTS.md](./AGENTS.md).
|
|
|
210
255
|
- **Conventional Commits**: CI lints commit messages on pull requests, so use
|
|
211
256
|
prefixes such as `feat:`, `fix:`, `docs:`, `ci:`, `chore:`, `refactor:`,
|
|
212
257
|
`test:`, or `style:`.
|
|
258
|
+
- **Type Checking**: CI runs `npm run check:types` on pull requests and pushes
|
|
259
|
+
to `main`; run this locally before opening a PR.
|
|
213
260
|
- **Changesets**: Contributor pull requests targeting `main` must include a
|
|
214
261
|
changeset. Dependabot PRs and automated `changeset-release/main` release PRs
|
|
215
262
|
are handled by automation and skip this check.
|
package/dist/cli.mjs
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
(function() {
|
|
5
5
|
try {
|
|
6
6
|
var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {};
|
|
7
|
-
e.SENTRY_RELEASE = { id: "hevy-mcp@1.26.
|
|
7
|
+
e.SENTRY_RELEASE = { id: "hevy-mcp@1.26.1" };
|
|
8
8
|
var n = new e.Error().stack;
|
|
9
9
|
n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "03cf150f-6466-4ca3-9cf1-e423cc0e3cd2", e._sentryDebugIdIdentifier = "sentry-dbid-03cf150f-6466-4ca3-9cf1-e423cc0e3cd2");
|
|
10
10
|
} catch (e) {}
|
|
11
11
|
})();
|
|
12
|
-
import { r as runServer } from "./src-
|
|
12
|
+
import { r as runServer } from "./src-_FHG1OUO.mjs";
|
|
13
13
|
//#region src/cli.ts
|
|
14
14
|
runServer().catch((error) => {
|
|
15
15
|
console.error("Fatal error in main():", error);
|
package/dist/index.mjs
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
(function() {
|
|
5
5
|
try {
|
|
6
6
|
var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {};
|
|
7
|
-
e.SENTRY_RELEASE = { id: "hevy-mcp@1.26.
|
|
7
|
+
e.SENTRY_RELEASE = { id: "hevy-mcp@1.26.1" };
|
|
8
8
|
var n = new e.Error().stack;
|
|
9
9
|
n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "5b077b94-9ef8-4f24-a0b0-8dcfbf491be1", e._sentryDebugIdIdentifier = "sentry-dbid-5b077b94-9ef8-4f24-a0b0-8dcfbf491be1");
|
|
10
10
|
} catch (e) {}
|
|
11
11
|
})();
|
|
12
|
-
import { n as createServer, r as runServer, t as configSchema } from "./src-
|
|
12
|
+
import { n as createServer, r as runServer, t as configSchema } from "./src-_FHG1OUO.mjs";
|
|
13
13
|
export { configSchema, createServer, createServer as default, runServer };
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
(function() {
|
|
5
5
|
try {
|
|
6
6
|
var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {};
|
|
7
|
-
e.SENTRY_RELEASE = { id: "hevy-mcp@1.26.
|
|
7
|
+
e.SENTRY_RELEASE = { id: "hevy-mcp@1.26.1" };
|
|
8
8
|
var n = new e.Error().stack;
|
|
9
|
-
n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "
|
|
9
|
+
n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "d8a16a1c-7e19-4663-9699-a6c28dfa8b53", e._sentryDebugIdIdentifier = "sentry-dbid-d8a16a1c-7e19-4663-9699-a6c28dfa8b53");
|
|
10
10
|
} catch (e) {}
|
|
11
11
|
})();
|
|
12
12
|
import * as Sentry from "@sentry/node";
|
|
@@ -36,10 +36,10 @@ import { deserializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
|
|
|
36
36
|
* OTel Collector → Honeycomb: performance traces, metrics
|
|
37
37
|
*/
|
|
38
38
|
const name$1 = "hevy-mcp";
|
|
39
|
-
const version$1 = "1.26.
|
|
39
|
+
const version$1 = "1.26.1";
|
|
40
40
|
const collectorToken = process.env.OTEL_COLLECTOR_TOKEN ?? "";
|
|
41
41
|
const COLLECTOR_ENDPOINT = "https://otel.chrisdoc.dev/v1";
|
|
42
|
-
const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@1.26.
|
|
42
|
+
const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@1.26.1`;
|
|
43
43
|
const resource = resourceFromAttributes({
|
|
44
44
|
"service.name": name$1,
|
|
45
45
|
"service.version": version$1
|
|
@@ -135,18 +135,21 @@ const serverStartups = meter.createCounter("mcp.server.startups", { description:
|
|
|
135
135
|
* @returns A formatted MCP tool response with error information
|
|
136
136
|
*/
|
|
137
137
|
function createErrorResponse(error, context) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
} catch (_e) {
|
|
145
|
-
errorMessage = String(data);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
138
|
+
const originalErrorMessage = extractErrorMessage(error);
|
|
139
|
+
let errorMessage = originalErrorMessage;
|
|
140
|
+
const axiosErrorContext = extractAxiosErrorContext(error);
|
|
141
|
+
const mappedHevyErrorMessage = mapHevyErrorMessageByStatus(axiosErrorContext?.status);
|
|
142
|
+
if (mappedHevyErrorMessage) errorMessage = mappedHevyErrorMessage;
|
|
143
|
+
if (!mappedHevyErrorMessage && axiosErrorContext?.data) errorMessage = stringifyErrorData(axiosErrorContext.data);
|
|
148
144
|
const errorCode = error instanceof Error && "code" in error ? error.code : void 0;
|
|
149
145
|
const errorType = determineErrorType(error, errorMessage);
|
|
146
|
+
const errorContext = {
|
|
147
|
+
sourceContext: context,
|
|
148
|
+
originalErrorMessage,
|
|
149
|
+
errorCode,
|
|
150
|
+
errorType,
|
|
151
|
+
axios: axiosErrorContext ?? void 0
|
|
152
|
+
};
|
|
150
153
|
const formattedMessage = `${context ? `[${context}] ` : ""}Error: ${errorMessage}`;
|
|
151
154
|
const errorCodeSuffix = errorCode ? `, Code: ${errorCode}` : "";
|
|
152
155
|
console.error(`${formattedMessage} (Type: ${errorType}${errorCodeSuffix})`, error);
|
|
@@ -155,9 +158,49 @@ function createErrorResponse(error, context) {
|
|
|
155
158
|
type: "text",
|
|
156
159
|
text: formattedMessage
|
|
157
160
|
}],
|
|
158
|
-
isError: true
|
|
161
|
+
isError: true,
|
|
162
|
+
errorContext
|
|
159
163
|
};
|
|
160
164
|
}
|
|
165
|
+
function extractErrorMessage(error) {
|
|
166
|
+
if (error instanceof Error) return error.message;
|
|
167
|
+
if (typeof error === "string") return error;
|
|
168
|
+
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
|
|
169
|
+
if (error && typeof error === "object") try {
|
|
170
|
+
return JSON.stringify(error);
|
|
171
|
+
} catch (_e) {
|
|
172
|
+
return "Unknown error object";
|
|
173
|
+
}
|
|
174
|
+
return String(error);
|
|
175
|
+
}
|
|
176
|
+
function extractAxiosErrorContext(error) {
|
|
177
|
+
if (!isAxiosError(error)) return null;
|
|
178
|
+
return {
|
|
179
|
+
status: error.response?.status,
|
|
180
|
+
statusText: error.response?.statusText,
|
|
181
|
+
data: error.response?.data,
|
|
182
|
+
method: error.config?.method,
|
|
183
|
+
url: error.config?.url
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function mapHevyErrorMessageByStatus(status) {
|
|
187
|
+
if (status === 401 || status === 403) return "The Hevy API key is invalid or has expired. Check HEVY_API_KEY.";
|
|
188
|
+
if (status === 404) return "The requested resource was not found in Hevy.";
|
|
189
|
+
if (status === 409) return "A conflict occurred (e.g., a body measurement already exists for this date). Use the update tool instead.";
|
|
190
|
+
if (status === 422) return "The request failed Hevy validation. Check the field values and try again.";
|
|
191
|
+
if (status === 429) return "Rate limited by Hevy. Please wait and retry.";
|
|
192
|
+
if (status && status >= 500 && status <= 599) return "Hevy API experienced an error. Please retry later.";
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
function stringifyErrorData(data) {
|
|
196
|
+
if (typeof data === "string") return data;
|
|
197
|
+
if (data && typeof data === "object") try {
|
|
198
|
+
return JSON.stringify(data);
|
|
199
|
+
} catch (_e) {
|
|
200
|
+
return "Unable to serialize error response data";
|
|
201
|
+
}
|
|
202
|
+
return String(data);
|
|
203
|
+
}
|
|
161
204
|
/**
|
|
162
205
|
* Determine the type of error based on error characteristics
|
|
163
206
|
*/
|
|
@@ -405,7 +448,7 @@ function createJsonResponse(data, options = {
|
|
|
405
448
|
}) {
|
|
406
449
|
return { content: [{
|
|
407
450
|
type: "text",
|
|
408
|
-
text: options.pretty ? JSON.stringify(data, null, options.indent) : JSON.stringify(data)
|
|
451
|
+
text: (options.pretty ? JSON.stringify(data, null, options.indent) : JSON.stringify(data)) ?? "null"
|
|
409
452
|
}] };
|
|
410
453
|
}
|
|
411
454
|
/**
|
|
@@ -427,10 +470,7 @@ function createTextResponse(message) {
|
|
|
427
470
|
* @returns A formatted MCP tool response for empty results
|
|
428
471
|
*/
|
|
429
472
|
function createEmptyResponse(message = "No data found") {
|
|
430
|
-
return
|
|
431
|
-
type: "text",
|
|
432
|
-
text: message
|
|
433
|
-
}] };
|
|
473
|
+
return createTextResponse(message);
|
|
434
474
|
}
|
|
435
475
|
//#endregion
|
|
436
476
|
//#region src/utils/tool-annotations.ts
|
|
@@ -836,6 +876,92 @@ function registerRoutineTools(server, hevyClient) {
|
|
|
836
876
|
}, "update-routine"));
|
|
837
877
|
}
|
|
838
878
|
//#endregion
|
|
879
|
+
//#region src/utils/cache.ts
|
|
880
|
+
/**
|
|
881
|
+
* Shared in-memory cache for async fetches with TTL, LRU eviction,
|
|
882
|
+
* in-flight de-duplication, and explicit refresh/invalidation support.
|
|
883
|
+
*/
|
|
884
|
+
var AsyncTtlCache = class {
|
|
885
|
+
ttlMs;
|
|
886
|
+
maxSize;
|
|
887
|
+
now;
|
|
888
|
+
entries = /* @__PURE__ */ new Map();
|
|
889
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
890
|
+
requestCounter = 0;
|
|
891
|
+
constructor(options, now = Date.now) {
|
|
892
|
+
const { ttlMs, maxSize } = options;
|
|
893
|
+
if (ttlMs <= 0) throw new Error("Cache ttlMs must be greater than 0.");
|
|
894
|
+
if (maxSize <= 0) throw new Error("Cache maxSize must be greater than 0.");
|
|
895
|
+
this.ttlMs = ttlMs;
|
|
896
|
+
this.maxSize = maxSize;
|
|
897
|
+
this.now = now;
|
|
898
|
+
}
|
|
899
|
+
async getOrFetch(key, fetcher, options = {}) {
|
|
900
|
+
const { refresh = false } = options;
|
|
901
|
+
if (refresh) this.invalidate(key);
|
|
902
|
+
else {
|
|
903
|
+
const cachedEntry = this.entries.get(key);
|
|
904
|
+
if (cachedEntry !== void 0) {
|
|
905
|
+
if (cachedEntry.expiresAt > this.now()) {
|
|
906
|
+
this.markAsRecentlyUsed(key, cachedEntry);
|
|
907
|
+
return cachedEntry.value;
|
|
908
|
+
}
|
|
909
|
+
this.entries.delete(key);
|
|
910
|
+
}
|
|
911
|
+
const inFlightEntry = this.inFlight.get(key);
|
|
912
|
+
if (inFlightEntry !== void 0) return inFlightEntry.promise;
|
|
913
|
+
}
|
|
914
|
+
const requestId = ++this.requestCounter;
|
|
915
|
+
const request = (async () => {
|
|
916
|
+
try {
|
|
917
|
+
const value = await fetcher();
|
|
918
|
+
if (this.isCurrentRequest(key, requestId)) this.setValue(key, value);
|
|
919
|
+
return value;
|
|
920
|
+
} finally {
|
|
921
|
+
if (this.inFlight.get(key)?.requestId === requestId) this.inFlight.delete(key);
|
|
922
|
+
}
|
|
923
|
+
})();
|
|
924
|
+
this.inFlight.set(key, {
|
|
925
|
+
promise: request,
|
|
926
|
+
requestId
|
|
927
|
+
});
|
|
928
|
+
return request;
|
|
929
|
+
}
|
|
930
|
+
invalidate(key) {
|
|
931
|
+
this.entries.delete(key);
|
|
932
|
+
this.inFlight.delete(key);
|
|
933
|
+
}
|
|
934
|
+
clear() {
|
|
935
|
+
this.entries.clear();
|
|
936
|
+
this.inFlight.clear();
|
|
937
|
+
}
|
|
938
|
+
get size() {
|
|
939
|
+
return this.entries.size;
|
|
940
|
+
}
|
|
941
|
+
isCurrentRequest(key, requestId) {
|
|
942
|
+
return this.inFlight.get(key)?.requestId === requestId;
|
|
943
|
+
}
|
|
944
|
+
markAsRecentlyUsed(key, entry) {
|
|
945
|
+
this.entries.delete(key);
|
|
946
|
+
this.entries.set(key, entry);
|
|
947
|
+
}
|
|
948
|
+
setValue(key, value) {
|
|
949
|
+
this.entries.delete(key);
|
|
950
|
+
this.entries.set(key, {
|
|
951
|
+
value,
|
|
952
|
+
expiresAt: this.now() + this.ttlMs
|
|
953
|
+
});
|
|
954
|
+
this.evictLeastRecentlyUsed();
|
|
955
|
+
}
|
|
956
|
+
evictLeastRecentlyUsed() {
|
|
957
|
+
while (this.entries.size > this.maxSize) {
|
|
958
|
+
const oldestKey = this.entries.keys().next().value;
|
|
959
|
+
if (oldestKey === void 0) return;
|
|
960
|
+
this.entries.delete(oldestKey);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
//#endregion
|
|
839
965
|
//#region src/tools/templates.ts
|
|
840
966
|
const MUSCLE_GROUPS = [
|
|
841
967
|
"abdominals",
|
|
@@ -859,8 +985,27 @@ const MUSCLE_GROUPS = [
|
|
|
859
985
|
"full_body",
|
|
860
986
|
"other"
|
|
861
987
|
];
|
|
862
|
-
|
|
863
|
-
|
|
988
|
+
const EXERCISE_TEMPLATE_CATALOG_CACHE_KEY = "exercise-template-catalog";
|
|
989
|
+
const exerciseTemplateCatalogCache = new AsyncTtlCache({
|
|
990
|
+
ttlMs: 300 * 1e3,
|
|
991
|
+
maxSize: 1
|
|
992
|
+
});
|
|
993
|
+
async function fetchExerciseTemplateCatalog(hevyClient) {
|
|
994
|
+
const allTemplates = [];
|
|
995
|
+
let page = 1;
|
|
996
|
+
let pageCount = 1;
|
|
997
|
+
do {
|
|
998
|
+
const data = await hevyClient.getExerciseTemplates({
|
|
999
|
+
page,
|
|
1000
|
+
pageSize: 100
|
|
1001
|
+
});
|
|
1002
|
+
const templates = data?.exercise_templates ?? [];
|
|
1003
|
+
allTemplates.push(...templates);
|
|
1004
|
+
pageCount = data?.page_count ?? 1;
|
|
1005
|
+
page++;
|
|
1006
|
+
} while (page <= pageCount);
|
|
1007
|
+
return allTemplates;
|
|
1008
|
+
}
|
|
864
1009
|
/**
|
|
865
1010
|
* Register all exercise template-related tools with the MCP server
|
|
866
1011
|
*/
|
|
@@ -945,39 +1090,14 @@ function registerTemplateTools(server, hevyClient) {
|
|
|
945
1090
|
const searchExerciseTemplatesSchema = {
|
|
946
1091
|
query: z.string().min(1).describe("Case-insensitive substring to match against exercise template titles"),
|
|
947
1092
|
primaryMuscleGroup: z.enum(MUSCLE_GROUPS).optional().describe("Optional filter to restrict results to a specific primary muscle group"),
|
|
948
|
-
refresh: z.boolean().optional().default(false).describe("Set to true to
|
|
1093
|
+
refresh: z.boolean().optional().default(false).describe("Set to true to invalidate the catalog cache and re-fetch all templates from the API")
|
|
949
1094
|
};
|
|
950
|
-
server.tool("search-exercise-templates", "Search exercise templates by name with optional muscle group filter. Fetches all templates from the Hevy API on first call
|
|
1095
|
+
server.tool("search-exercise-templates", "Search exercise templates by name with optional muscle group filter. Fetches all templates from the Hevy API on first call, caches the catalog in memory with a bounded TTL cache, and reuses it for subsequent searches. Use refresh:true to force a re-fetch.", searchExerciseTemplatesSchema, readOnlyAnnotations("Search Exercise Templates"), withErrorHandling(async (args) => {
|
|
951
1096
|
if (!hevyClient) throw new Error("API client not initialized. Please provide HEVY_API_KEY.");
|
|
952
1097
|
const { query, primaryMuscleGroup, refresh } = args;
|
|
953
|
-
|
|
954
|
-
if (refresh) exerciseTemplateFetch = null;
|
|
955
|
-
if (exerciseTemplateFetch === null) exerciseTemplateFetch = (async () => {
|
|
956
|
-
try {
|
|
957
|
-
const allTemplates = [];
|
|
958
|
-
let page = 1;
|
|
959
|
-
let pageCount = 1;
|
|
960
|
-
do {
|
|
961
|
-
const data = await hevyClient.getExerciseTemplates({
|
|
962
|
-
page,
|
|
963
|
-
pageSize: 100
|
|
964
|
-
});
|
|
965
|
-
const templates = data?.exercise_templates ?? [];
|
|
966
|
-
allTemplates.push(...templates);
|
|
967
|
-
pageCount = data?.page_count ?? 1;
|
|
968
|
-
page++;
|
|
969
|
-
} while (page <= pageCount);
|
|
970
|
-
exerciseTemplateCache = allTemplates;
|
|
971
|
-
return allTemplates;
|
|
972
|
-
} finally {
|
|
973
|
-
exerciseTemplateFetch = null;
|
|
974
|
-
}
|
|
975
|
-
})();
|
|
976
|
-
await exerciseTemplateFetch;
|
|
977
|
-
}
|
|
1098
|
+
const catalog = await exerciseTemplateCatalogCache.getOrFetch(EXERCISE_TEMPLATE_CATALOG_CACHE_KEY, () => fetchExerciseTemplateCatalog(hevyClient), { refresh });
|
|
978
1099
|
const queryLower = query.toLowerCase();
|
|
979
|
-
|
|
980
|
-
let results = exerciseTemplateCache.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
|
|
1100
|
+
let results = catalog.filter((t) => (t.title ?? "").toLowerCase().includes(queryLower));
|
|
981
1101
|
if (primaryMuscleGroup !== void 0) results = results.filter((t) => t.primary_muscle_group === primaryMuscleGroup);
|
|
982
1102
|
if (results.length === 0) return createEmptyResponse(`No exercise templates found matching "${query}"${primaryMuscleGroup ? ` with primary muscle group "${primaryMuscleGroup}"` : ""}`);
|
|
983
1103
|
return createJsonResponse(results.map(formatExerciseTemplate));
|
|
@@ -1165,16 +1285,23 @@ function registerWorkoutTools(server, hevyClient) {
|
|
|
1165
1285
|
}
|
|
1166
1286
|
//#endregion
|
|
1167
1287
|
//#region src/utils/config.ts
|
|
1288
|
+
const DEPRECATED_CLI_ARGUMENT_WARNING = [
|
|
1289
|
+
"DEPRECATION WARNING: Passing the Hevy API key via CLI arguments",
|
|
1290
|
+
"(--hevy-api-key=..., --hevyApiKey=..., hevy-api-key=...) is",
|
|
1291
|
+
"deprecated and insecure. Use the HEVY_API_KEY environment",
|
|
1292
|
+
"variable instead."
|
|
1293
|
+
].join(" ");
|
|
1168
1294
|
/**
|
|
1169
1295
|
* Parse CLI arguments and environment to derive configuration.
|
|
1170
|
-
* Priority order for API key: CLI flag forms > environment variable.
|
|
1171
|
-
* Supported CLI arg forms:
|
|
1296
|
+
* Priority order for API key: deprecated CLI flag forms > environment variable.
|
|
1297
|
+
* Supported deprecated CLI arg forms:
|
|
1172
1298
|
* --hevy-api-key=KEY
|
|
1173
1299
|
* --hevyApiKey=KEY
|
|
1174
1300
|
* hevy-api-key=KEY (bare, e.g. when passed after npm start -- )
|
|
1175
1301
|
*/
|
|
1176
1302
|
function parseConfig(argv, env) {
|
|
1177
1303
|
let apiKey = "";
|
|
1304
|
+
let usedDeprecatedApiKeyArg = false;
|
|
1178
1305
|
const apiKeyArgPatterns = [
|
|
1179
1306
|
/^--hevy-api-key=(.+)$/i,
|
|
1180
1307
|
/^--hevyApiKey=(.+)$/i,
|
|
@@ -1185,17 +1312,19 @@ function parseConfig(argv, env) {
|
|
|
1185
1312
|
const m = raw.match(pattern);
|
|
1186
1313
|
if (m) {
|
|
1187
1314
|
apiKey = m[1];
|
|
1315
|
+
usedDeprecatedApiKeyArg = true;
|
|
1188
1316
|
break;
|
|
1189
1317
|
}
|
|
1190
1318
|
}
|
|
1191
1319
|
if (apiKey) break;
|
|
1192
1320
|
}
|
|
1321
|
+
if (usedDeprecatedApiKeyArg) console.error(DEPRECATED_CLI_ARGUMENT_WARNING);
|
|
1193
1322
|
if (!apiKey) apiKey = env.HEVY_API_KEY || "";
|
|
1194
1323
|
return { apiKey };
|
|
1195
1324
|
}
|
|
1196
1325
|
function assertApiKey(apiKey) {
|
|
1197
1326
|
if (!apiKey) {
|
|
1198
|
-
console.error("Hevy API key is required. Provide it via the HEVY_API_KEY environment variable
|
|
1327
|
+
console.error("Hevy API key is required. Provide it via the HEVY_API_KEY environment variable.");
|
|
1199
1328
|
process.exit(1);
|
|
1200
1329
|
}
|
|
1201
1330
|
}
|
|
@@ -1921,6 +2050,44 @@ function createClient(apiKey, baseUrl) {
|
|
|
1921
2050
|
//#endregion
|
|
1922
2051
|
//#region src/utils/stdio-observability.ts
|
|
1923
2052
|
const UTF8_BOM = "";
|
|
2053
|
+
/**
|
|
2054
|
+
* Adapter boundary around MCP SDK stdio internals.
|
|
2055
|
+
*
|
|
2056
|
+
* MCP SDK v1.29.0 exposes public message-level hooks but does not expose a
|
|
2057
|
+
* public raw-chunk hook on `StdioServerTransport`. To capture chunk metadata,
|
|
2058
|
+
* we currently rely on private internals (`_ondata`, `_readBuffer`, `_buffer`)
|
|
2059
|
+
* in this one place.
|
|
2060
|
+
*
|
|
2061
|
+
* If those internals change in a future SDK release, this adapter should fail
|
|
2062
|
+
* closed and preserve default transport behavior (no instrumentation patching).
|
|
2063
|
+
*/
|
|
2064
|
+
function createSdkPrivateStdioAdapter(transport) {
|
|
2065
|
+
const mutableTransport = transport;
|
|
2066
|
+
return {
|
|
2067
|
+
wrapOnData(onChunk) {
|
|
2068
|
+
const originalOnData = mutableTransport._ondata;
|
|
2069
|
+
if (typeof originalOnData !== "function") return;
|
|
2070
|
+
mutableTransport._ondata = (chunk) => {
|
|
2071
|
+
onChunk(chunk);
|
|
2072
|
+
originalOnData(chunk);
|
|
2073
|
+
};
|
|
2074
|
+
},
|
|
2075
|
+
installReadMessageHook(onReadLine) {
|
|
2076
|
+
const readBuffer = mutableTransport._readBuffer;
|
|
2077
|
+
if (!readBuffer || typeof readBuffer.readMessage !== "function") return false;
|
|
2078
|
+
readBuffer.readMessage = () => {
|
|
2079
|
+
const buffer = readBuffer._buffer;
|
|
2080
|
+
if (!buffer) return null;
|
|
2081
|
+
const index = buffer.indexOf("\n");
|
|
2082
|
+
if (index === -1) return null;
|
|
2083
|
+
const lineBuffer = buffer.subarray(0, index);
|
|
2084
|
+
readBuffer._buffer = buffer.subarray(index + 1);
|
|
2085
|
+
return onReadLine(lineBuffer.toString("utf8").replace(/\r$/, ""));
|
|
2086
|
+
};
|
|
2087
|
+
return true;
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
1924
2091
|
function hasUtf8BomPrefix(chunk) {
|
|
1925
2092
|
return chunk.length >= 3 && chunk[0] === 239 && chunk[1] === 187 && chunk[2] === 191;
|
|
1926
2093
|
}
|
|
@@ -1987,36 +2154,48 @@ function deserializeMessageWithObservability(line, chunkSnapshot) {
|
|
|
1987
2154
|
});
|
|
1988
2155
|
}
|
|
1989
2156
|
function createInstrumentedStdioTransport(transport) {
|
|
1990
|
-
const
|
|
2157
|
+
const privateAdapter = createSdkPrivateStdioAdapter(transport);
|
|
1991
2158
|
let lastChunkSnapshot = {
|
|
1992
2159
|
lastChunkByteLength: 0,
|
|
1993
2160
|
lastChunkStartsWithUtf8Bom: false
|
|
1994
2161
|
};
|
|
1995
|
-
|
|
1996
|
-
if (typeof originalOnData === "function") mutableTransport._ondata = (chunk) => {
|
|
2162
|
+
privateAdapter.wrapOnData((chunk) => {
|
|
1997
2163
|
lastChunkSnapshot = {
|
|
1998
2164
|
lastChunkByteLength: chunk.byteLength,
|
|
1999
2165
|
lastChunkStartsWithUtf8Bom: hasUtf8BomPrefix(chunk)
|
|
2000
2166
|
};
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
const readBuffer = mutableTransport._readBuffer;
|
|
2004
|
-
if (!readBuffer || typeof readBuffer.readMessage !== "function") return transport;
|
|
2005
|
-
readBuffer.readMessage = () => {
|
|
2006
|
-
const buffer = readBuffer._buffer;
|
|
2007
|
-
if (!buffer) return null;
|
|
2008
|
-
const index = buffer.indexOf("\n");
|
|
2009
|
-
if (index === -1) return null;
|
|
2010
|
-
const lineBuffer = buffer.subarray(0, index);
|
|
2011
|
-
readBuffer._buffer = buffer.subarray(index + 1);
|
|
2012
|
-
return deserializeMessageWithObservability(lineBuffer.toString("utf8").replace(/\r$/, ""), lastChunkSnapshot);
|
|
2013
|
-
};
|
|
2167
|
+
});
|
|
2168
|
+
if (!privateAdapter.installReadMessageHook((line) => deserializeMessageWithObservability(line, lastChunkSnapshot))) return transport;
|
|
2014
2169
|
return transport;
|
|
2015
2170
|
}
|
|
2016
2171
|
//#endregion
|
|
2017
2172
|
//#region src/index.ts
|
|
2018
2173
|
const name = name$1;
|
|
2019
2174
|
const version = version$1;
|
|
2175
|
+
const HELP_TEXT = [
|
|
2176
|
+
"Usage:",
|
|
2177
|
+
" hevy-mcp [options]",
|
|
2178
|
+
"",
|
|
2179
|
+
"Options:",
|
|
2180
|
+
" -h, --help Show this help message and exit",
|
|
2181
|
+
" -v, --version Show version and exit",
|
|
2182
|
+
" --hevy-api-key=<api-key> (deprecated, use HEVY_API_KEY env var)",
|
|
2183
|
+
"",
|
|
2184
|
+
"Environment:",
|
|
2185
|
+
" HEVY_API_KEY=<api-key> Hevy API key from Hevy app settings",
|
|
2186
|
+
"",
|
|
2187
|
+
"Examples:",
|
|
2188
|
+
" HEVY_API_KEY=your-key npx hevy-mcp",
|
|
2189
|
+
" npx hevy-mcp --hevy-api-key=your-key",
|
|
2190
|
+
" npm start -- --hevy-api-key=your-key"
|
|
2191
|
+
].join("\n");
|
|
2192
|
+
function getCliAction(args) {
|
|
2193
|
+
for (const arg of args) {
|
|
2194
|
+
if (arg === "--version" || arg === "-v") return "version";
|
|
2195
|
+
if (arg === "--help" || arg === "-h") return "help";
|
|
2196
|
+
}
|
|
2197
|
+
return "start";
|
|
2198
|
+
}
|
|
2020
2199
|
const HEVY_API_BASEURL = "https://api.hevyapp.com";
|
|
2021
2200
|
const SENTRY_USER_ID_CONTEXT = "hevy-mcp:sentry-user-id:v1";
|
|
2022
2201
|
function fingerprintApiKey(apiKey) {
|
|
@@ -2075,10 +2254,20 @@ function createServer({ config }) {
|
|
|
2075
2254
|
return buildServer(apiKey);
|
|
2076
2255
|
}
|
|
2077
2256
|
async function runServer() {
|
|
2257
|
+
const args = process.argv.slice(2);
|
|
2258
|
+
const cliAction = getCliAction(args);
|
|
2259
|
+
if (cliAction === "version") {
|
|
2260
|
+
console.log(version);
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
if (cliAction === "help") {
|
|
2264
|
+
console.log(HELP_TEXT);
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2078
2267
|
serverStartups.add(1, { version });
|
|
2079
2268
|
await tracer.startActiveSpan("mcp.server.run", { attributes: { "mcp.transport": "stdio" } }, async (span) => {
|
|
2080
2269
|
try {
|
|
2081
|
-
const apiKey = parseConfig(
|
|
2270
|
+
const apiKey = parseConfig(args, process.env).apiKey;
|
|
2082
2271
|
assertApiKey(apiKey);
|
|
2083
2272
|
const server = buildServer(apiKey);
|
|
2084
2273
|
console.error("Starting MCP server in stdio mode");
|
|
@@ -2106,4 +2295,4 @@ async function runServer() {
|
|
|
2106
2295
|
//#endregion
|
|
2107
2296
|
export { createServer as n, runServer as r, configSchema as t };
|
|
2108
2297
|
|
|
2109
|
-
//# sourceMappingURL=src-
|
|
2298
|
+
//# sourceMappingURL=src-_FHG1OUO.mjs.map
|