hevy-mcp 4.0.1 → 4.1.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
@@ -414,20 +414,39 @@ self-hosted Streamable HTTP.
414
414
 
415
415
  ## Advanced configuration
416
416
 
417
- | Setting | Default | Scope | Notes |
418
- | ---------------------- | ------------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------- |
419
- | `HEVY_API_KEY` | None; required | Local stdio | Hevy API key from the Hevy app. Never pass it in a URL. |
420
- | `HEVY_MCP_API_TIMEOUT` | `30000` ms | Local stdio | Positive Hevy API timeout in milliseconds. Invalid values fall back to 30 seconds. |
421
- | `HEVY_MCP_DEBUG` | Disabled | Local stdio | Set to exactly `1` for privacy-bounded diagnostics on stderr. Stdout remains reserved for MCP JSON-RPC. |
422
- | `XDG_CACHE_HOME` | `~/.cache` | Local stdio | Changes the root for the npm update-check cache at `hevy-mcp/update-check.json`. |
423
- | `SENTRY_DSN` | Packaged project DSN | Optional local Node telemetry | Overrides the Sentry destination. An empty value disables Sentry export. The Worker does not import Node telemetry. |
424
- | `SENTRY_RELEASE` | `hevy-mcp@<installed-version>` | Optional local Node telemetry | Overrides the release label attached to local Sentry events and traces. |
425
- | `-h`, `--help` | N/A | Local stdio CLI | Print supported options and exit. |
426
- | `-v`, `--version` | N/A | Local stdio CLI | Print the installed version and exit. |
427
-
428
- The local executable is stdio-only. It does not support `PORT`,
429
- `HEVY_MCP_TRANSPORT`, or `--transport`, and it does not provide local HTTP or
430
- SSE behavior.
417
+ | Setting | Default | Scope | Notes |
418
+ | ---------------------------- | ------------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------- |
419
+ | `HEVY_API_KEY` | None; required | Local stdio or HTTP | Hevy API key from the Hevy app. Never pass it in a URL. |
420
+ | `HEVY_MCP_API_TIMEOUT` | `30000` ms | Local stdio | Positive Hevy API timeout in milliseconds. Invalid values fall back to 30 seconds. |
421
+ | `HEVY_MCP_DEBUG` | Disabled | Local Node | Set to exactly `1` for privacy-bounded diagnostics on stderr. Stdout remains reserved for MCP JSON-RPC. |
422
+ | `HEVY_MCP_HTTP_BEARER_TOKEN` | None | Non-loopback HTTP | Required when `--host` is not loopback; use a separate token, never the Hevy API key. |
423
+ | `XDG_CACHE_HOME` | `~/.cache` | Local stdio | Changes the root for the npm update-check cache at `hevy-mcp/update-check.json`. |
424
+ | `SENTRY_DSN` | Packaged project DSN | Optional local Node telemetry | Overrides the Sentry destination. An empty value disables Sentry export. The Worker does not import Node telemetry. |
425
+ | `SENTRY_RELEASE` | `hevy-mcp@<installed-version>` | Optional local Node telemetry | Overrides the release label attached to local Sentry events and traces. |
426
+ | `-h`, `--help` | N/A | Local stdio CLI | Print supported options and exit. |
427
+ | `-v`, `--version` | N/A | Local stdio CLI | Print the installed version and exit. |
428
+
429
+ The local executable uses stdio by default. To opt into Streamable HTTP, run:
430
+
431
+ ```bash
432
+ HEVY_API_KEY=your-hevy-api-key npx hevy-mcp --transport http --host 127.0.0.1 --port 3000
433
+ ```
434
+
435
+ The MCP endpoint is `http://127.0.0.1:3000/mcp`. For a specific bind host,
436
+ HTTP mode validates the Host header and configured port to protect against DNS
437
+ rebinding. Loopback is the default. Wildcard binds (`0.0.0.0` or `::`) accept
438
+ any hostname so they can be used behind Docker port mappings or a reverse
439
+ proxy; they require `HEVY_MCP_HTTP_BEARER_TOKEN` and rely on that separate
440
+ authentication token. Do not expose an unprotected shared Hevy account to the
441
+ public internet. For Docker HTTP mode, publish the port explicitly:
442
+
443
+ ```bash
444
+ docker run --rm -p 3000:3000 -e HEVY_API_KEY -e HEVY_MCP_HTTP_BEARER_TOKEN \
445
+ ghcr.io/chrisdoc/hevy-mcp:latest --transport http --host 0.0.0.0 --port 3000
446
+ ```
447
+
448
+ Wildcard binds are allowed only with the separate bearer token; publish the
449
+ container port deliberately and keep that token private.
431
450
 
432
451
  ### Cache behavior
433
452
 
package/dist/cli.mjs CHANGED
@@ -4,14 +4,14 @@
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@4.0.1" };
7
+ e.SENTRY_RELEASE = { id: "hevy-mcp@4.1.0" };
8
8
  var n = new e.Error().stack;
9
- n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "6c94ca81-06a8-4c24-a812-6740b134ae63", e._sentryDebugIdIdentifier = "sentry-dbid-6c94ca81-06a8-4c24-a812-6740b134ae63");
9
+ n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "625c4c9c-e20d-40b3-96b1-4e799ef130f3", e._sentryDebugIdIdentifier = "sentry-dbid-625c4c9c-e20d-40b3-96b1-4e799ef130f3");
10
10
  } catch (e) {}
11
11
  })();
12
- import { a as flushTelemetry, i as createSafeErrorDiagnostic, n as runStdioServer, r as MissingHevyApiKeyError } from "./src-D0fOBh7M.mjs";
12
+ import { a as createSafeErrorDiagnostic, i as MissingHevyApiKeyError, n as runServer, o as flushTelemetry } from "./src-wSWxe4LJ.mjs";
13
13
  //#region src/cli.ts
14
- runStdioServer().catch(async (error) => {
14
+ runServer().catch(async (error) => {
15
15
  if (error instanceof MissingHevyApiKeyError) console.error(error.message);
16
16
  else console.error("Fatal error in main()", createSafeErrorDiagnostic(error));
17
17
  try {
package/dist/index.d.mts CHANGED
@@ -1,8 +1,12 @@
1
1
 
2
+ //#region src/utils/arguments.d.ts
3
+ type NodeTransport = "stdio" | "http";
4
+ //#endregion
2
5
  //#region src/index.d.ts
3
6
  declare function createNodeMcpServer({ apiKey }: {
4
7
  apiKey: string;
5
- }): Promise<import("@modelcontextprotocol/sdk/server/mcp").McpServer>;
8
+ }, transport?: NodeTransport): Promise<import("@modelcontextprotocol/sdk/server/mcp").McpServer>;
6
9
  declare function runStdioServer(): Promise<void>;
10
+ declare function runServer(): Promise<void>;
7
11
  //#endregion
8
- export { createNodeMcpServer, runStdioServer };
12
+ export { createNodeMcpServer, runServer, runStdioServer };
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@4.0.1" };
7
+ e.SENTRY_RELEASE = { id: "hevy-mcp@4.1.0" };
8
8
  var n = new e.Error().stack;
9
- n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "84f6be7e-0b11-45ac-9e79-1efe427bc1ab", e._sentryDebugIdIdentifier = "sentry-dbid-84f6be7e-0b11-45ac-9e79-1efe427bc1ab");
9
+ n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "e9496b31-626b-4ad1-9416-47ba4ffd2995", e._sentryDebugIdIdentifier = "sentry-dbid-e9496b31-626b-4ad1-9416-47ba4ffd2995");
10
10
  } catch (e) {}
11
11
  })();
12
- import { n as runStdioServer, t as createNodeMcpServer } from "./src-D0fOBh7M.mjs";
13
- export { createNodeMcpServer, runStdioServer };
12
+ import { n as runServer, r as runStdioServer, t as createNodeMcpServer } from "./src-wSWxe4LJ.mjs";
13
+ export { createNodeMcpServer, runServer, runStdioServer };
@@ -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@4.0.1" };
7
+ e.SENTRY_RELEASE = { id: "hevy-mcp@4.1.0" };
8
8
  var n = new e.Error().stack;
9
- n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "bc3b943c-f25f-42fc-a541-63e23c6ca05a", e._sentryDebugIdIdentifier = "sentry-dbid-bc3b943c-f25f-42fc-a541-63e23c6ca05a");
9
+ n && (e._sentryDebugIds = e._sentryDebugIds || {}, e._sentryDebugIds[n] = "4b988c15-eb76-40ac-8399-033bebec17f8", e._sentryDebugIdIdentifier = "sentry-dbid-4b988c15-eb76-40ac-8399-033bebec17f8");
10
10
  } catch (e) {}
11
11
  })();
12
12
  import * as Sentry from "@sentry/node";
@@ -18,11 +18,15 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
18
18
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
19
19
  import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
20
20
  import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
21
- import { createHmac, randomUUID } from "node:crypto";
21
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
22
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
23
  import { z } from "zod";
24
24
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
25
25
  import { z as z$1 } from "zod/v4";
26
+ import { isIP } from "node:net";
27
+ import { createServer } from "node:http";
28
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
29
+ import { AsyncLocalStorage } from "node:async_hooks";
26
30
  import { deserializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
27
31
  import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
28
32
  import { homedir } from "node:os";
@@ -77,10 +81,10 @@ function sanitizeSentryMcpSpan(span) {
77
81
  * OTel Collector → Honeycomb: performance traces, metrics
78
82
  */
79
83
  const name$1 = "hevy-mcp";
80
- const version$1 = "4.0.1";
84
+ const version$1 = "4.1.0";
81
85
  const collectorToken = "NH9vOela-HYreQxAbJa68cjEmORoEKM57EvneUDVcOo";
82
86
  const COLLECTOR_ENDPOINT = "https://otel.chrisdoc.dev/v1";
83
- const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@4.0.1`;
87
+ const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@4.1.0`;
84
88
  const resource = resourceFromAttributes({
85
89
  "service.name": name$1,
86
90
  "service.version": version$1
@@ -2582,7 +2586,7 @@ function registerHevyResources(server, runtime) {
2582
2586
  //#endregion
2583
2587
  //#region ../core/src/server-metadata.ts
2584
2588
  const SERVER_NAME = "hevy-mcp";
2585
- const SERVER_VERSION = "4.0.1";
2589
+ const SERVER_VERSION = "4.1.0";
2586
2590
  const SERVER_INSTRUCTIONS = [
2587
2591
  "Hevy MCP connects clients to the authenticated user's Hevy workout-tracking data, including workouts, routines, exercise templates, routine folders, body measurements, and profile information. HEVY_API_KEY must contain a valid Hevy API key for local stdio use.",
2588
2592
  "Safety: all get-* and search-* tools are read-only. create-* and update-* tools mutate Hevy data. Creates are additive and non-idempotent, so repeating one can create duplicates. Updates can overwrite existing data. Delete operations are not available.",
@@ -4432,6 +4436,444 @@ function assertApiKey(apiKey) {
4432
4436
  if (!apiKey) throw new MissingHevyApiKeyError();
4433
4437
  }
4434
4438
  //#endregion
4439
+ //#region src/utils/arguments.ts
4440
+ const DEFAULT_HOST = "127.0.0.1";
4441
+ const DEFAULT_PORT = 3e3;
4442
+ function valueAfter(args, index, option) {
4443
+ const value = args[index + 1];
4444
+ if (!value || value.startsWith("-")) throw new Error(`${option} requires a value.`);
4445
+ return value;
4446
+ }
4447
+ function parseHost(value) {
4448
+ const host = value.trim();
4449
+ const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
4450
+ const isBracketed = host.startsWith("[") || host.endsWith("]");
4451
+ if (host.length === 0 || isBracketed && !(host.startsWith("[") && host.endsWith("]")) || isBracketed && isIP(unbracketed) !== 6 || !isBracketed && host.includes(":") && isIP(host) !== 6 || !isBracketed && !host.includes(":") && isIP(host) === 0 && !/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/u.test(host) || /\s/u.test(host) || host.includes("/") || host.includes("@") || host.includes("://")) throw new Error(`Invalid host: ${value}. Provide a hostname or IP address.`);
4452
+ return unbracketed;
4453
+ }
4454
+ function parsePort(value) {
4455
+ if (!/^[0-9]+$/u.test(value)) throw new Error(`Invalid port: ${value}. Use an integer from 1 to 65535.`);
4456
+ const port = Number(value);
4457
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid port: ${value}. Use an integer from 1 to 65535.`);
4458
+ return port;
4459
+ }
4460
+ function parseNodeCliOptions(args) {
4461
+ let transport = "stdio";
4462
+ let host = DEFAULT_HOST;
4463
+ let port = DEFAULT_PORT;
4464
+ let transportExplicit = false;
4465
+ let hostExplicit = false;
4466
+ let portExplicit = false;
4467
+ for (let index = 0; index < args.length; index += 1) {
4468
+ const arg = args[index];
4469
+ if (!arg || [
4470
+ "--help",
4471
+ "-h",
4472
+ "--version",
4473
+ "-v"
4474
+ ].includes(arg)) continue;
4475
+ switch (arg) {
4476
+ case "--transport": {
4477
+ const value = valueAfter(args, index, arg);
4478
+ index += 1;
4479
+ if (value !== "stdio" && value !== "http") throw new Error(`Invalid transport: ${value}. Use either stdio or http.`);
4480
+ transport = value;
4481
+ transportExplicit = true;
4482
+ break;
4483
+ }
4484
+ case "--host":
4485
+ host = parseHost(valueAfter(args, index, arg));
4486
+ hostExplicit = true;
4487
+ index += 1;
4488
+ break;
4489
+ case "--port":
4490
+ port = parsePort(valueAfter(args, index, arg));
4491
+ portExplicit = true;
4492
+ index += 1;
4493
+ break;
4494
+ default: throw new Error(`Unknown option: ${arg}`);
4495
+ }
4496
+ }
4497
+ if (transport === "stdio" && (hostExplicit || portExplicit)) throw new Error("--host and --port can only be used with --transport http.");
4498
+ if (transport === "stdio" && transportExplicit) return {
4499
+ transport,
4500
+ host: DEFAULT_HOST,
4501
+ port: DEFAULT_PORT
4502
+ };
4503
+ return {
4504
+ transport,
4505
+ host,
4506
+ port
4507
+ };
4508
+ }
4509
+ //#endregion
4510
+ //#region src/utils/mcp-session-observability.ts
4511
+ const UNKNOWN_METADATA = "unknown";
4512
+ const MAX_METADATA_LENGTH = 64;
4513
+ const SAFE_METADATA_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+:/@-]{0,63}$/u;
4514
+ const contextStorage = new AsyncLocalStorage();
4515
+ let activeStdioSession;
4516
+ function isRecord(value) {
4517
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4518
+ }
4519
+ function normalizeMetadata(value) {
4520
+ if (typeof value !== "string") return UNKNOWN_METADATA;
4521
+ const normalized = value.trim();
4522
+ if (normalized.length === 0 || normalized.length > MAX_METADATA_LENGTH || !SAFE_METADATA_PATTERN.test(normalized)) return UNKNOWN_METADATA;
4523
+ return normalized;
4524
+ }
4525
+ function getInitializeParams(message) {
4526
+ if (!isRecord(message) || message.method !== "initialize") return {};
4527
+ return isRecord(message.params) ? message.params : {};
4528
+ }
4529
+ function extractMcpClientMetadata(message) {
4530
+ const params = getInitializeParams(message);
4531
+ const clientInfo = isRecord(params.clientInfo) ? params.clientInfo : {};
4532
+ return {
4533
+ name: normalizeMetadata(clientInfo.name),
4534
+ version: normalizeMetadata(clientInfo.version),
4535
+ protocolVersion: normalizeMetadata(params.protocolVersion)
4536
+ };
4537
+ }
4538
+ function createMcpSessionContext(message, transport = "stdio") {
4539
+ return {
4540
+ metadata: extractMcpClientMetadata(message),
4541
+ startedAt: Date.now(),
4542
+ toolCalls: 0,
4543
+ hadToolFailure: false,
4544
+ transport
4545
+ };
4546
+ }
4547
+ function runWithMcpSessionContext(context, callback) {
4548
+ return contextStorage.run(context, callback);
4549
+ }
4550
+ function getSession() {
4551
+ return contextStorage.getStore() ?? activeStdioSession;
4552
+ }
4553
+ function metadataAttributes(metadata, transport) {
4554
+ return {
4555
+ client_name: metadata.name,
4556
+ client_version: metadata.version,
4557
+ protocol_version: metadata.protocolVersion,
4558
+ transport
4559
+ };
4560
+ }
4561
+ function durationBucket(durationMs) {
4562
+ if (durationMs < 1e3) return "<1s";
4563
+ if (durationMs < 1e4) return "1-10s";
4564
+ if (durationMs < 6e4) return "10-60s";
4565
+ if (durationMs < 3e5) return "1-5m";
4566
+ return "5m+";
4567
+ }
4568
+ function recordMcpSessionStart(message, transport = "stdio", context) {
4569
+ const session = context ?? createMcpSessionContext(message, transport);
4570
+ if (session.transport === "stdio") activeStdioSession = session;
4571
+ const attributes = metadataAttributes(session.metadata, session.transport);
4572
+ sessionStarted.add(1, attributes);
4573
+ return session.metadata;
4574
+ }
4575
+ function recordMcpToolInvocation() {
4576
+ const session = getSession();
4577
+ if (session) session.toolCalls += 1;
4578
+ return metadataAttributes(session?.metadata ?? {
4579
+ name: UNKNOWN_METADATA,
4580
+ version: UNKNOWN_METADATA,
4581
+ protocolVersion: UNKNOWN_METADATA
4582
+ }, session?.transport ?? "stdio");
4583
+ }
4584
+ function recordMcpToolFailure() {
4585
+ const session = getSession();
4586
+ if (session) session.hadToolFailure = true;
4587
+ }
4588
+ function getCurrentMcpClientMetadata() {
4589
+ return getSession()?.metadata ?? {
4590
+ name: UNKNOWN_METADATA,
4591
+ version: UNKNOWN_METADATA,
4592
+ protocolVersion: UNKNOWN_METADATA
4593
+ };
4594
+ }
4595
+ function getCurrentMcpTransport() {
4596
+ return getSession()?.transport ?? "stdio";
4597
+ }
4598
+ function recordMcpSessionTermination(category, context) {
4599
+ const session = context ?? getSession();
4600
+ const durationMs = session ? Math.max(0, Date.now() - session.startedAt) : 0;
4601
+ const attributes = {
4602
+ ...metadataAttributes(session?.metadata ?? {
4603
+ name: UNKNOWN_METADATA,
4604
+ version: UNKNOWN_METADATA,
4605
+ protocolVersion: UNKNOWN_METADATA
4606
+ }, session?.transport ?? "stdio"),
4607
+ termination_category: category,
4608
+ session_duration_bucket: durationBucket(durationMs),
4609
+ tool_calls_bucket: bucketCount(session?.toolCalls ?? 0)
4610
+ };
4611
+ sessionEnded.add(1, attributes);
4612
+ if (!context || context === activeStdioSession) activeStdioSession = void 0;
4613
+ }
4614
+ function resolveSessionTerminationCategory(shutdownSucceeded, context) {
4615
+ if (!shutdownSucceeded) return "unknown";
4616
+ return (context ?? getSession())?.hadToolFailure ? "tool_failure" : "clean";
4617
+ }
4618
+ //#endregion
4619
+ //#region src/utils/streamable-http.ts
4620
+ const MCP_PATH = "/mcp";
4621
+ const MAX_BODY_BYTES = 1048576;
4622
+ const HTTP_BEARER_TOKEN = "HEVY_MCP_HTTP_BEARER_TOKEN";
4623
+ var HttpRequestError = class extends Error {
4624
+ statusCode;
4625
+ constructor(statusCode, message) {
4626
+ super(message);
4627
+ this.name = "HttpRequestError";
4628
+ this.statusCode = statusCode;
4629
+ }
4630
+ };
4631
+ function normalizeHost(host) {
4632
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
4633
+ }
4634
+ function isLoopbackHost(host) {
4635
+ const normalized = normalizeHost(host).toLowerCase();
4636
+ return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
4637
+ }
4638
+ function hostNamesFor(options) {
4639
+ const configured = normalizeHost(options.host).toLowerCase();
4640
+ const names = /* @__PURE__ */ new Set([configured]);
4641
+ if (isLoopbackHost(options.host)) {
4642
+ names.add("localhost");
4643
+ names.add("127.0.0.1");
4644
+ names.add("::1");
4645
+ }
4646
+ return names;
4647
+ }
4648
+ function expectedPort(options, server) {
4649
+ if (options.port !== 0) return options.port;
4650
+ const address = server.address();
4651
+ return address && typeof address !== "string" ? address.port : 0;
4652
+ }
4653
+ function validateHostHeader(request, allowedHosts, port, allowAnyHostname = false) {
4654
+ const header = request.headers.host;
4655
+ if (!header || Array.isArray(header)) return false;
4656
+ try {
4657
+ const parsed = new URL(`http://${header}`);
4658
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || !allowAnyHostname && (parsed.port ? Number(parsed.port) : 80) !== port) return false;
4659
+ return allowAnyHostname || allowedHosts.has(normalizeHost(parsed.hostname).toLowerCase());
4660
+ } catch {
4661
+ return false;
4662
+ }
4663
+ }
4664
+ function safeDiagnostic(error) {
4665
+ return JSON.stringify(createSafeErrorDiagnostic(error));
4666
+ }
4667
+ function writeJson(res, status, message) {
4668
+ if (res.headersSent) return;
4669
+ res.statusCode = status;
4670
+ res.setHeader("Content-Type", "application/json");
4671
+ res.end(JSON.stringify({ error: message }));
4672
+ }
4673
+ function readBody(request) {
4674
+ return new Promise((resolve, reject) => {
4675
+ let size = 0;
4676
+ let rejected = false;
4677
+ const chunks = [];
4678
+ const onData = (chunk) => {
4679
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4680
+ size += buffer.byteLength;
4681
+ if (size > MAX_BODY_BYTES) {
4682
+ if (!rejected) {
4683
+ rejected = true;
4684
+ request.removeListener("data", onData);
4685
+ request.resume();
4686
+ reject(new HttpRequestError(413, "Request body is too large."));
4687
+ }
4688
+ return;
4689
+ }
4690
+ if (!rejected) chunks.push(buffer);
4691
+ };
4692
+ request.on("data", onData);
4693
+ request.once("error", (error) => {
4694
+ if (!rejected) reject(error);
4695
+ });
4696
+ request.once("end", () => {
4697
+ if (rejected) return;
4698
+ try {
4699
+ const raw = Buffer.concat(chunks).toString("utf8");
4700
+ resolve(raw.length === 0 ? void 0 : JSON.parse(raw));
4701
+ } catch {
4702
+ reject(new HttpRequestError(400, "Request body must be valid JSON."));
4703
+ }
4704
+ });
4705
+ });
4706
+ }
4707
+ function isInitializeRequest(body) {
4708
+ return Boolean(body && typeof body === "object" && !Array.isArray(body) && "method" in body && body.method === "initialize");
4709
+ }
4710
+ function closeServer(server) {
4711
+ return new Promise((resolve, reject) => {
4712
+ if (!server.listening) {
4713
+ resolve();
4714
+ return;
4715
+ }
4716
+ server.close((error) => error ? reject(error) : resolve());
4717
+ server.closeIdleConnections();
4718
+ server.closeAllConnections();
4719
+ });
4720
+ }
4721
+ function isBearerAuthorized(request, token) {
4722
+ if (!token) return false;
4723
+ const header = request.headers.authorization;
4724
+ if (typeof header !== "string") return false;
4725
+ const expected = Buffer.from(`Bearer ${token}`);
4726
+ const actual = Buffer.from(header);
4727
+ return expected.byteLength === actual.byteLength && timingSafeEqual(expected, actual);
4728
+ }
4729
+ function aggregateErrors(errors, message) {
4730
+ if (errors.length === 1) return errors[0];
4731
+ return new AggregateError(errors, message);
4732
+ }
4733
+ async function startStreamableHttpServer(options, apiKey, createMcpServer) {
4734
+ const wildcard = options.host === "0.0.0.0" || options.host === "::" || options.host === "[::]";
4735
+ const loopback = isLoopbackHost(options.host);
4736
+ const bearerToken = process.env[HTTP_BEARER_TOKEN];
4737
+ if (!loopback && !bearerToken) throw new Error(`Non-loopback HTTP mode requires ${HTTP_BEARER_TOKEN} to protect the shared Hevy account.`);
4738
+ const sessions = /* @__PURE__ */ new Map();
4739
+ const cleanupErrors = [];
4740
+ const server = createServer((request, response) => {
4741
+ handleRequest(request, response).catch((error) => {
4742
+ if (response.headersSent) {
4743
+ if (!response.writableEnded) response.destroy();
4744
+ return;
4745
+ }
4746
+ if (error instanceof HttpRequestError) {
4747
+ writeJson(response, error.statusCode, error.message);
4748
+ return;
4749
+ }
4750
+ console.error(`HTTP request failed: ${safeDiagnostic(error)}`);
4751
+ writeJson(response, 500, "Internal server error.");
4752
+ });
4753
+ });
4754
+ async function closeSession(session, failureCategory) {
4755
+ if (session.closed) return;
4756
+ session.closed = true;
4757
+ for (const [id, current] of sessions) if (current === session) sessions.delete(id);
4758
+ for (const response of session.responses) if (!response.writableEnded) response.destroy();
4759
+ const errors = (await Promise.allSettled([Promise.resolve().then(() => session.transport.close()), Promise.resolve().then(() => session.server.close())])).flatMap((result) => result.status === "rejected" ? [result.reason] : []);
4760
+ const succeeded = errors.length === 0;
4761
+ recordMcpSessionTermination(failureCategory && succeeded ? failureCategory : resolveSessionTerminationCategory(succeeded, session.context), session.context);
4762
+ if (errors.length > 0) throw aggregateErrors(errors, "MCP session cleanup failed.");
4763
+ }
4764
+ async function closeUnregistered(transport, mcpServer) {
4765
+ const errors = (await Promise.allSettled([Promise.resolve().then(() => transport.close()), ...mcpServer ? [Promise.resolve().then(() => mcpServer.close())] : []])).flatMap((result) => result.status === "rejected" ? [result.reason] : []);
4766
+ if (errors.length > 0) throw aggregateErrors(errors, "MCP cleanup failed.");
4767
+ }
4768
+ async function handleRequest(request, response) {
4769
+ if (request.url?.split("?", 1)[0] !== MCP_PATH) {
4770
+ writeJson(response, 404, "Not found");
4771
+ return;
4772
+ }
4773
+ if (!validateHostHeader(request, hostNamesFor(options), expectedPort(options, server), wildcard)) {
4774
+ writeJson(response, 403, "Invalid Host header");
4775
+ return;
4776
+ }
4777
+ if (!loopback && !isBearerAuthorized(request, bearerToken)) {
4778
+ writeJson(response, 401, "Authorization required");
4779
+ return;
4780
+ }
4781
+ const body = request.method === "POST" ? await readBody(request) : void 0;
4782
+ const initializing = request.method === "POST" && isInitializeRequest(body);
4783
+ const sessionHeader = request.headers["mcp-session-id"];
4784
+ const sessionId = typeof sessionHeader === "string" ? sessionHeader : void 0;
4785
+ if (initializing) {
4786
+ if (sessionId) {
4787
+ writeJson(response, 400, "Initialization must not include Mcp-Session-Id.");
4788
+ return;
4789
+ }
4790
+ const context = createMcpSessionContext(body, "http");
4791
+ recordMcpSessionStart(body, "http", context);
4792
+ let session;
4793
+ let mcpServer;
4794
+ let connected = false;
4795
+ const transport = new StreamableHTTPServerTransport({
4796
+ sessionIdGenerator: randomUUID,
4797
+ onsessioninitialized: (id) => {
4798
+ if (session) sessions.set(id, session);
4799
+ }
4800
+ });
4801
+ transport.onclose = () => {
4802
+ if (session) closeSession(session).catch((error) => {
4803
+ cleanupErrors.push(error);
4804
+ });
4805
+ };
4806
+ try {
4807
+ mcpServer = await createMcpServer({ apiKey });
4808
+ session = {
4809
+ transport,
4810
+ server: mcpServer,
4811
+ context,
4812
+ responses: /* @__PURE__ */ new Set(),
4813
+ closed: false
4814
+ };
4815
+ await mcpServer.connect(transport);
4816
+ connected = true;
4817
+ session.responses.add(response);
4818
+ response.once("close", () => session?.responses.delete(response));
4819
+ await runWithMcpSessionContext(context, () => transport.handleRequest(request, response, body));
4820
+ } catch (error) {
4821
+ console.error(`HTTP session request failed: ${safeDiagnostic(error)}`);
4822
+ let cleanupError;
4823
+ try {
4824
+ if (session) await closeSession(session, connected ? "unknown" : "connect_failure");
4825
+ else {
4826
+ await closeUnregistered(transport, mcpServer);
4827
+ recordMcpSessionTermination("startup_failure", context);
4828
+ }
4829
+ } catch (cleanupFailure) {
4830
+ cleanupError = cleanupFailure;
4831
+ if (!session) recordMcpSessionTermination("unknown", context);
4832
+ }
4833
+ if (cleanupError) console.error(`HTTP cleanup failed: ${safeDiagnostic(cleanupError)}`);
4834
+ if (!response.writableEnded) writeJson(response, 500, "Internal server error.");
4835
+ }
4836
+ return;
4837
+ }
4838
+ if (!sessionId) {
4839
+ writeJson(response, 400, "Mcp-Session-Id header is required.");
4840
+ return;
4841
+ }
4842
+ const session = sessions.get(sessionId);
4843
+ if (!session) {
4844
+ writeJson(response, 404, "Unknown Mcp-Session-Id.");
4845
+ return;
4846
+ }
4847
+ if (request.method !== "DELETE") {
4848
+ session.responses.add(response);
4849
+ response.once("close", () => session.responses.delete(response));
4850
+ }
4851
+ await runWithMcpSessionContext(session.context, () => session.transport.handleRequest(request, response, body));
4852
+ }
4853
+ await new Promise((resolve, reject) => {
4854
+ server.once("error", reject);
4855
+ server.listen(options.port, options.host, () => {
4856
+ server.removeListener("error", reject);
4857
+ resolve();
4858
+ });
4859
+ });
4860
+ let closePromise;
4861
+ return {
4862
+ server,
4863
+ close: () => {
4864
+ if (closePromise) return closePromise;
4865
+ closePromise = (async () => {
4866
+ const sessionsToClose = [...sessions.values()];
4867
+ const results = await Promise.allSettled([...sessionsToClose.map((session) => closeSession(session)), closeServer(server)]);
4868
+ sessions.clear();
4869
+ const errors = [...cleanupErrors, ...results.flatMap((result) => result.status === "rejected" ? [result.reason] : [])];
4870
+ if (errors.length > 0) throw aggregateErrors(errors, "HTTP server cleanup failed.");
4871
+ })();
4872
+ return closePromise;
4873
+ }
4874
+ };
4875
+ }
4876
+ //#endregion
4435
4877
  //#region src/utils/graceful-shutdown.ts
4436
4878
  const shutdownSignals = ["SIGINT", "SIGTERM"];
4437
4879
  const FORCED_EXIT_TIMEOUT_MS = 5e3;
@@ -4599,99 +5041,6 @@ function createNodeHevyClientOptions() {
4599
5041
  };
4600
5042
  }
4601
5043
  //#endregion
4602
- //#region src/utils/mcp-session-observability.ts
4603
- const UNKNOWN_METADATA = "unknown";
4604
- const MAX_METADATA_LENGTH = 64;
4605
- const SAFE_METADATA_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+:/@-]{0,63}$/u;
4606
- let currentSession;
4607
- function isRecord(value) {
4608
- return value !== null && typeof value === "object" && !Array.isArray(value);
4609
- }
4610
- function normalizeMetadata(value) {
4611
- if (typeof value !== "string") return UNKNOWN_METADATA;
4612
- const normalized = value.trim();
4613
- if (normalized.length === 0 || normalized.length > MAX_METADATA_LENGTH || !SAFE_METADATA_PATTERN.test(normalized)) return UNKNOWN_METADATA;
4614
- return normalized;
4615
- }
4616
- function getInitializeParams(message) {
4617
- if (!isRecord(message) || message.method !== "initialize") return {};
4618
- return isRecord(message.params) ? message.params : {};
4619
- }
4620
- function extractMcpClientMetadata(message) {
4621
- const params = getInitializeParams(message);
4622
- const clientInfo = isRecord(params.clientInfo) ? params.clientInfo : {};
4623
- return {
4624
- name: normalizeMetadata(clientInfo.name),
4625
- version: normalizeMetadata(clientInfo.version),
4626
- protocolVersion: normalizeMetadata(params.protocolVersion)
4627
- };
4628
- }
4629
- function metadataAttributes(metadata) {
4630
- return {
4631
- client_name: metadata.name,
4632
- client_version: metadata.version,
4633
- protocol_version: metadata.protocolVersion,
4634
- transport: "stdio"
4635
- };
4636
- }
4637
- function durationBucket(durationMs) {
4638
- if (durationMs < 1e3) return "<1s";
4639
- if (durationMs < 1e4) return "1-10s";
4640
- if (durationMs < 6e4) return "10-60s";
4641
- if (durationMs < 3e5) return "1-5m";
4642
- return "5m+";
4643
- }
4644
- function recordMcpSessionStart(message) {
4645
- const metadata = extractMcpClientMetadata(message);
4646
- currentSession = {
4647
- metadata,
4648
- startedAt: Date.now(),
4649
- toolCalls: 0,
4650
- hadToolFailure: false
4651
- };
4652
- const attributes = metadataAttributes(metadata);
4653
- sessionStarted.add(1, attributes);
4654
- return metadata;
4655
- }
4656
- function recordMcpToolInvocation() {
4657
- if (currentSession) currentSession.toolCalls += 1;
4658
- return metadataAttributes(currentSession?.metadata ?? {
4659
- name: UNKNOWN_METADATA,
4660
- version: UNKNOWN_METADATA,
4661
- protocolVersion: UNKNOWN_METADATA
4662
- });
4663
- }
4664
- function recordMcpToolFailure() {
4665
- if (currentSession) currentSession.hadToolFailure = true;
4666
- }
4667
- function getCurrentMcpClientMetadata() {
4668
- return currentSession?.metadata ?? {
4669
- name: UNKNOWN_METADATA,
4670
- version: UNKNOWN_METADATA,
4671
- protocolVersion: UNKNOWN_METADATA
4672
- };
4673
- }
4674
- function recordMcpSessionTermination(category) {
4675
- const session = currentSession;
4676
- const durationMs = session ? Math.max(0, Date.now() - session.startedAt) : 0;
4677
- const attributes = {
4678
- ...metadataAttributes(session?.metadata ?? {
4679
- name: UNKNOWN_METADATA,
4680
- version: UNKNOWN_METADATA,
4681
- protocolVersion: UNKNOWN_METADATA
4682
- }),
4683
- termination_category: category,
4684
- session_duration_bucket: durationBucket(durationMs),
4685
- tool_calls_bucket: bucketCount(session?.toolCalls ?? 0)
4686
- };
4687
- sessionEnded.add(1, attributes);
4688
- currentSession = void 0;
4689
- }
4690
- function resolveSessionTerminationCategory(shutdownSucceeded) {
4691
- if (!shutdownSucceeded) return "unknown";
4692
- return currentSession?.hadToolFailure ? "tool_failure" : "clean";
4693
- }
4694
- //#endregion
4695
5044
  //#region src/utils/tool-observer.ts
4696
5045
  const WORKFLOW_PAGINATION_RESOURCES = /* @__PURE__ */ new Set([
4697
5046
  "workouts",
@@ -4722,7 +5071,7 @@ function createAttributes(invocation) {
4722
5071
  "mcp.client.name": clientMetadata.name,
4723
5072
  "mcp.client.version": clientMetadata.version,
4724
5073
  "mcp.protocol.version": clientMetadata.protocolVersion,
4725
- "mcp.transport": "stdio",
5074
+ "mcp.transport": getCurrentMcpTransport(),
4726
5075
  "mcp.tool.args.key_count_bucket": invocation.argumentKeyCountBucket ?? "unknown",
4727
5076
  "mcp.tool.args.keys": invocation.argumentKeys?.join(",") ?? ""
4728
5077
  };
@@ -5185,13 +5534,18 @@ const HELP_TEXT = [
5185
5534
  "Options:",
5186
5535
  " -h, --help Show this help message and exit",
5187
5536
  " -v, --version Show version and exit",
5537
+ " --transport stdio|http Select the transport (default: stdio)",
5538
+ " --host <host> HTTP bind host (default: 127.0.0.1)",
5539
+ " --port <port> HTTP bind port (default: 3000)",
5188
5540
  "",
5189
5541
  "Environment:",
5190
5542
  " HEVY_API_KEY=<api-key> Hevy API key from Hevy app settings",
5191
5543
  " HEVY_MCP_DEBUG=1 Enable verbose diagnostics on stderr",
5544
+ " HEVY_MCP_HTTP_BEARER_TOKEN Protect non-loopback HTTP deployments",
5192
5545
  "",
5193
5546
  "Examples:",
5194
- " HEVY_API_KEY=your-key npx hevy-mcp"
5547
+ " HEVY_API_KEY=your-key npx hevy-mcp",
5548
+ " HEVY_API_KEY=your-key npx hevy-mcp --transport http --port 3000"
5195
5549
  ].join("\n");
5196
5550
  function getCliAction(args) {
5197
5551
  for (const arg of args) {
@@ -5251,11 +5605,11 @@ async function validateApiKey(apiKey) {
5251
5605
  console.error(diagnostic ? `${API_KEY_VALIDATION_WARNING} Diagnostic: ${diagnostic}.` : API_KEY_VALIDATION_WARNING);
5252
5606
  }
5253
5607
  }
5254
- function buildServer(apiKey) {
5608
+ function buildServer(apiKey, transport = "stdio") {
5255
5609
  return tracer.startActiveSpan("mcp.server.build", { attributes: {
5256
5610
  "mcp.server.name": name,
5257
5611
  "mcp.server.version": version,
5258
- "mcp.transport": "stdio"
5612
+ "mcp.transport": transport
5259
5613
  } }, (span) => {
5260
5614
  try {
5261
5615
  const server = createHevyMcpServer({
@@ -5282,13 +5636,13 @@ function buildServer(apiKey) {
5282
5636
  }
5283
5637
  });
5284
5638
  }
5285
- async function createNodeMcpServer({ apiKey }) {
5639
+ async function createNodeMcpServer({ apiKey }, transport = "stdio") {
5286
5640
  const { apiKey: validatedApiKey } = serverConfigSchema.parse({ apiKey });
5287
5641
  const userHash = fingerprintApiKey(validatedApiKey);
5288
5642
  setCurrentUserHash(userHash);
5289
5643
  Sentry.setUser({ id: userHash });
5290
5644
  await validateApiKey(validatedApiKey);
5291
- return buildServer(validatedApiKey);
5645
+ return buildServer(validatedApiKey, transport);
5292
5646
  }
5293
5647
  async function runStdioServer() {
5294
5648
  const cliAction = getCliAction(process.argv.slice(2));
@@ -5348,5 +5702,51 @@ async function runStdioServer() {
5348
5702
  }
5349
5703
  });
5350
5704
  }
5705
+ async function runServer() {
5706
+ const args = process.argv.slice(2);
5707
+ const cliAction = getCliAction(args);
5708
+ if (cliAction === "version") {
5709
+ console.error(`${name} v${version}`);
5710
+ return;
5711
+ }
5712
+ if (cliAction === "help") {
5713
+ console.log(HELP_TEXT);
5714
+ return;
5715
+ }
5716
+ const options = parseNodeCliOptions(args);
5717
+ if (options.transport === "stdio") {
5718
+ await runStdioServer();
5719
+ return;
5720
+ }
5721
+ await tracer.startActiveSpan("mcp.server.run", { attributes: { "mcp.transport": "http" } }, async (span) => {
5722
+ let listening = false;
5723
+ serverStartups.add(1, { version });
5724
+ try {
5725
+ const cfg = parseConfig(process.env);
5726
+ assertApiKey(cfg.apiKey);
5727
+ await validateApiKey(cfg.apiKey);
5728
+ const handle = await startStreamableHttpServer(options, cfg.apiKey, (params) => Promise.resolve(buildServer(params.apiKey, "http")));
5729
+ listening = true;
5730
+ console.error(`Starting MCP server in HTTP mode at ${options.host}:${options.port}/mcp`);
5731
+ scheduleUpdateCheck({
5732
+ packageName: name$1,
5733
+ currentVersion: version$1
5734
+ });
5735
+ installGracefulShutdown({
5736
+ target: handle,
5737
+ onComplete: async () => {
5738
+ await flushTelemetry();
5739
+ }
5740
+ });
5741
+ span.setStatus({ code: SpanStatusCode.OK });
5742
+ } catch (error) {
5743
+ recordMcpSessionTermination(listening ? "unknown" : "startup_failure");
5744
+ span.setStatus({ code: SpanStatusCode.ERROR });
5745
+ throw error;
5746
+ } finally {
5747
+ span.end();
5748
+ }
5749
+ });
5750
+ }
5351
5751
  //#endregion
5352
- export { flushTelemetry as a, createSafeErrorDiagnostic as i, runStdioServer as n, MissingHevyApiKeyError as r, createNodeMcpServer as t };
5752
+ export { createSafeErrorDiagnostic as a, MissingHevyApiKeyError as i, runServer as n, flushTelemetry as o, runStdioServer as r, createNodeMcpServer as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hevy-mcp",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "private": false,
5
5
  "description": "A Model Context Protocol (MCP) server implementation that interfaces with the Hevy fitness tracking app and its API.",
6
6
  "repository": {
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/chrisdoc/hevy-mcp",
8
8
  "source": "github"
9
9
  },
10
- "version": "4.0.1",
10
+ "version": "4.1.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "hevy-mcp",
15
- "version": "4.0.1",
15
+ "version": "4.1.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },