hevy-mcp 4.0.1 → 4.1.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 CHANGED
@@ -414,20 +414,40 @@ 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
+ | `HEVY_MCP_TELEMETRY` | Enabled | Local Node | Set to exactly `0` before startup/import to disable Sentry errors/traces and OTLP traces/metrics. Takes precedence over `SENTRY_DSN` and packaged/runtime collector credentials. |
424
+ | `XDG_CACHE_HOME` | `~/.cache` | Local stdio | Changes the root for the npm update-check cache at `hevy-mcp/update-check.json`. |
425
+ | `SENTRY_DSN` | Packaged project DSN | Optional local Node telemetry | Sentry-only override for the destination. An empty value disables Sentry export. The Worker does not import Node telemetry. |
426
+ | `SENTRY_RELEASE` | `hevy-mcp@<installed-version>` | Optional local Node telemetry | Overrides the release label attached to local Sentry events and traces. |
427
+ | `-h`, `--help` | N/A | Local stdio CLI | Print supported options and exit. |
428
+ | `-v`, `--version` | N/A | Local stdio CLI | Print the installed version and exit. |
429
+
430
+ The local executable uses stdio by default. To opt into Streamable HTTP, run:
431
+
432
+ ```bash
433
+ HEVY_API_KEY=your-hevy-api-key npx hevy-mcp --transport http --host 127.0.0.1 --port 3000
434
+ ```
435
+
436
+ The MCP endpoint is `http://127.0.0.1:3000/mcp`. For a specific bind host,
437
+ HTTP mode validates the Host header and configured port to protect against DNS
438
+ rebinding. Loopback is the default. Wildcard binds (`0.0.0.0` or `::`) accept
439
+ any hostname so they can be used behind Docker port mappings or a reverse
440
+ proxy; they require `HEVY_MCP_HTTP_BEARER_TOKEN` and rely on that separate
441
+ authentication token. Do not expose an unprotected shared Hevy account to the
442
+ public internet. For Docker HTTP mode, publish the port explicitly:
443
+
444
+ ```bash
445
+ docker run --rm -p 3000:3000 -e HEVY_API_KEY -e HEVY_MCP_HTTP_BEARER_TOKEN \
446
+ ghcr.io/chrisdoc/hevy-mcp:latest --transport http --host 0.0.0.0 --port 3000
447
+ ```
448
+
449
+ Wildcard binds are allowed only with the separate bearer token; publish the
450
+ container port deliberately and keep that token private.
431
451
 
432
452
  ### Cache behavior
433
453
 
@@ -440,6 +460,42 @@ server-scoped in-memory catalog cache:
440
460
  - Paginated `get-exercise-templates` calls always fetch their requested page.
441
461
  - Each hosted Worker request gets a fresh cache, preventing cross-key sharing.
442
462
 
463
+ ### Local Node telemetry and privacy
464
+
465
+ The local Node package enables project telemetry by default. It is local Node
466
+ behavior only; the Cloudflare Worker does not import Node telemetry. Set
467
+ `HEVY_MCP_TELEMETRY=0` before startup or import to disable all project
468
+ telemetry. Only the literal value `0` opts out: an unset value, an empty value,
469
+ `1`, `false`, and every other value remain enabled. The master setting takes
470
+ precedence over `SENTRY_DSN` and packaged or runtime `OTEL_COLLECTOR_TOKEN`
471
+ credentials, so the disabled path creates no telemetry exporters or periodic
472
+ metric readers and makes no telemetry network requests. `SENTRY_DSN` remains a
473
+ Sentry-only setting; when telemetry is enabled, an empty value disables only
474
+ Sentry export.
475
+
476
+ When enabled, errors and traces are sent to the packaged Sentry project at
477
+ <https://o4508975499575296.ingest.de.sentry.io/4509049671647312>. Traces and
478
+ metrics are sent to the collector at
479
+ <https://otel.chrisdoc.dev/v1/traces> and
480
+ <https://otel.chrisdoc.dev/v1/metrics>, which forward to Honeycomb. Metrics
481
+ export every 10 seconds.
482
+
483
+ The API key is never exported. Enabled telemetry derives a deterministic
484
+ ten-character HMAC-SHA-256 pseudonym from it solely for cross-span
485
+ correlation. The pseudonym is attached to spans only, never used as a metric
486
+ dimension, and is not intended for per-user behavior histories.
487
+
488
+ The privacy allowlist contains service/version/transport; fixed tool feature,
489
+ read/write kind, operation, and short-lived tool name; bounded
490
+ outcome/error/count/retry/duration/session/cache/workflow values; normalized API
491
+ method/endpoint/status; shape-only key names, presence, count, and boolean
492
+ fields; sanitized client/protocol tokens; and the span-only pseudonym. It
493
+ explicitly prohibits raw prompts, tool argument values, tool result content,
494
+ request bodies, API keys, raw identifiers/queries/exact dates,
495
+ workout/routine/folder/template/body-measurement content, names/titles/
496
+ descriptions/notes, measurement values, arbitrary client metadata, and
497
+ unnormalized endpoint paths.
498
+
443
499
  ## Security and mutations
444
500
 
445
501
  - Keep `HEVY_API_KEY` out of source control, URLs, logs, and screenshots.
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.1" };
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-BPbLELQf.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.1" };
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-BPbLELQf.mjs";
13
+ export { createNodeMcpServer, runServer, runStdioServer };
@@ -4,11 +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@4.0.1" };
7
+ e.SENTRY_RELEASE = { id: "hevy-mcp@4.1.1" };
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] = "3ad9084e-6d6c-4681-ad27-9168114359ab", e._sentryDebugIdIdentifier = "sentry-dbid-3ad9084e-6d6c-4681-ad27-9168114359ab");
10
10
  } catch (e) {}
11
11
  })();
12
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
12
13
  import * as Sentry from "@sentry/node";
13
14
  import { SentryPropagator, SentrySampler, SentrySpanProcessor } from "@sentry/opentelemetry";
14
15
  import { SpanStatusCode, metrics, trace } from "@opentelemetry/api";
@@ -18,11 +19,14 @@ import { resourceFromAttributes } from "@opentelemetry/resources";
18
19
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
19
20
  import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
20
21
  import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
21
- import { createHmac, randomUUID } 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,26 +81,15 @@ 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.1";
85
+ const telemetryEnabled = process.env.HEVY_MCP_TELEMETRY !== "0";
81
86
  const collectorToken = "NH9vOela-HYreQxAbJa68cjEmORoEKM57EvneUDVcOo";
82
87
  const COLLECTOR_ENDPOINT = "https://otel.chrisdoc.dev/v1";
83
- const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@4.0.1`;
88
+ const sentryRelease = process.env.SENTRY_RELEASE ?? `hevy-mcp@4.1.1`;
84
89
  const resource = resourceFromAttributes({
85
90
  "service.name": name$1,
86
91
  "service.version": version$1
87
92
  });
88
- const rawDsn = process.env.SENTRY_DSN ?? "https://ce696d8333b507acbf5203eb877bce0f@o4508975499575296.ingest.de.sentry.io/4509049671647312";
89
- const isValidDsn = typeof rawDsn === "string" && rawDsn.length > 0 && !rawDsn.startsWith("*");
90
- const sentryClient = Sentry.init({
91
- dsn: isValidDsn ? rawDsn : void 0,
92
- beforeSendSpan: sanitizeSentryMcpSpan,
93
- release: sentryRelease,
94
- tracesSampleRate: 1,
95
- sendDefaultPii: false,
96
- skipOpenTelemetrySetup: true,
97
- registerEsmLoaderHooks: false,
98
- ignoreErrors: ["EPIPE", "broken pipe"]
99
- });
100
93
  let currentUserHash;
101
94
  var UserHashSpanProcessor = class {
102
95
  onStart(span) {
@@ -106,37 +99,53 @@ var UserHashSpanProcessor = class {
106
99
  async forceFlush() {}
107
100
  async shutdown() {}
108
101
  };
109
- const spanProcessors = [new UserHashSpanProcessor(), new SentrySpanProcessor()];
110
- spanProcessors.push(new BatchSpanProcessor(new OTLPTraceExporter({
111
- url: `${COLLECTOR_ENDPOINT}/traces`,
112
- headers: { Authorization: `Bearer ${collectorToken}` }
113
- })));
114
- const tracerProvider = new NodeTracerProvider({
115
- resource,
116
- sampler: sentryClient ? new SentrySampler(sentryClient) : void 0,
117
- spanProcessors
118
- });
119
- tracerProvider.register({
120
- propagator: new SentryPropagator(),
121
- contextManager: new Sentry.SentryContextManager()
122
- });
102
+ let tracerProvider;
123
103
  let meterProvider;
124
- meterProvider = new MeterProvider({
125
- resource,
126
- readers: [new PeriodicExportingMetricReader({
127
- exporter: new OTLPMetricExporter({
128
- url: `${COLLECTOR_ENDPOINT}/metrics`,
129
- headers: { Authorization: `Bearer ${collectorToken}` }
130
- }),
131
- exportIntervalMillis: 1e4
132
- })]
133
- });
134
- metrics.setGlobalMeterProvider(meterProvider);
135
- trace.setGlobalTracerProvider(tracerProvider);
136
- Sentry.validateOpenTelemetrySetup();
104
+ if (telemetryEnabled) {
105
+ const rawDsn = process.env.SENTRY_DSN ?? "https://ce696d8333b507acbf5203eb877bce0f@o4508975499575296.ingest.de.sentry.io/4509049671647312";
106
+ const isValidDsn = typeof rawDsn === "string" && rawDsn.length > 0 && !rawDsn.startsWith("*");
107
+ const sentryClient = Sentry.init({
108
+ dsn: isValidDsn ? rawDsn : void 0,
109
+ beforeSendSpan: sanitizeSentryMcpSpan,
110
+ release: sentryRelease,
111
+ tracesSampleRate: 1,
112
+ sendDefaultPii: false,
113
+ skipOpenTelemetrySetup: true,
114
+ registerEsmLoaderHooks: false,
115
+ ignoreErrors: ["EPIPE", "broken pipe"]
116
+ });
117
+ const spanProcessors = [new UserHashSpanProcessor(), new SentrySpanProcessor()];
118
+ spanProcessors.push(new BatchSpanProcessor(new OTLPTraceExporter({
119
+ url: `${COLLECTOR_ENDPOINT}/traces`,
120
+ headers: { Authorization: `Bearer ${collectorToken}` }
121
+ })));
122
+ tracerProvider = new NodeTracerProvider({
123
+ resource,
124
+ sampler: sentryClient ? new SentrySampler(sentryClient) : void 0,
125
+ spanProcessors
126
+ });
127
+ tracerProvider.register({
128
+ propagator: new SentryPropagator(),
129
+ contextManager: new Sentry.SentryContextManager()
130
+ });
131
+ meterProvider = new MeterProvider({
132
+ resource,
133
+ readers: [new PeriodicExportingMetricReader({
134
+ exporter: new OTLPMetricExporter({
135
+ url: `${COLLECTOR_ENDPOINT}/metrics`,
136
+ headers: { Authorization: `Bearer ${collectorToken}` }
137
+ }),
138
+ exportIntervalMillis: 1e4
139
+ })]
140
+ });
141
+ metrics.setGlobalMeterProvider(meterProvider);
142
+ trace.setGlobalTracerProvider(tracerProvider);
143
+ Sentry.validateOpenTelemetrySetup();
144
+ }
137
145
  async function flushTelemetry(timeoutMs = 1e3) {
146
+ if (!telemetryEnabled) return;
138
147
  const flushPromise = Promise.allSettled([
139
- tracerProvider.forceFlush(),
148
+ ...tracerProvider ? [tracerProvider.forceFlush()] : [],
140
149
  ...meterProvider ? [meterProvider.forceFlush()] : [],
141
150
  Sentry.flush(timeoutMs)
142
151
  ]);
@@ -152,8 +161,12 @@ async function flushTelemetry(timeoutMs = 1e3) {
152
161
  }
153
162
  const tracer = trace.getTracer(name$1);
154
163
  const meter = metrics.getMeter(name$1);
155
- function setCurrentUserHash(hash) {
156
- currentUserHash = hash;
164
+ const SENTRY_USER_ID_CONTEXT = "hevy-mcp:sentry-user-id:v1";
165
+ function setTelemetryUser(apiKey) {
166
+ if (!telemetryEnabled) return;
167
+ const userHash = createHmac("sha256", apiKey).update(SENTRY_USER_ID_CONTEXT).digest("hex").slice(0, 10);
168
+ currentUserHash = userHash;
169
+ Sentry.setUser({ id: userHash });
157
170
  }
158
171
  //#endregion
159
172
  //#region src/utils/metrics.ts
@@ -2582,7 +2595,7 @@ function registerHevyResources(server, runtime) {
2582
2595
  //#endregion
2583
2596
  //#region ../core/src/server-metadata.ts
2584
2597
  const SERVER_NAME = "hevy-mcp";
2585
- const SERVER_VERSION = "4.0.1";
2598
+ const SERVER_VERSION = "4.1.1";
2586
2599
  const SERVER_INSTRUCTIONS = [
2587
2600
  "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
2601
  "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 +4445,444 @@ function assertApiKey(apiKey) {
4432
4445
  if (!apiKey) throw new MissingHevyApiKeyError();
4433
4446
  }
4434
4447
  //#endregion
4448
+ //#region src/utils/arguments.ts
4449
+ const DEFAULT_HOST = "127.0.0.1";
4450
+ const DEFAULT_PORT = 3e3;
4451
+ function valueAfter(args, index, option) {
4452
+ const value = args[index + 1];
4453
+ if (!value || value.startsWith("-")) throw new Error(`${option} requires a value.`);
4454
+ return value;
4455
+ }
4456
+ function parseHost(value) {
4457
+ const host = value.trim();
4458
+ const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
4459
+ const isBracketed = host.startsWith("[") || host.endsWith("]");
4460
+ 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.`);
4461
+ return unbracketed;
4462
+ }
4463
+ function parsePort(value) {
4464
+ if (!/^[0-9]+$/u.test(value)) throw new Error(`Invalid port: ${value}. Use an integer from 1 to 65535.`);
4465
+ const port = Number(value);
4466
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid port: ${value}. Use an integer from 1 to 65535.`);
4467
+ return port;
4468
+ }
4469
+ function parseNodeCliOptions(args) {
4470
+ let transport = "stdio";
4471
+ let host = DEFAULT_HOST;
4472
+ let port = DEFAULT_PORT;
4473
+ let transportExplicit = false;
4474
+ let hostExplicit = false;
4475
+ let portExplicit = false;
4476
+ for (let index = 0; index < args.length; index += 1) {
4477
+ const arg = args[index];
4478
+ if (!arg || [
4479
+ "--help",
4480
+ "-h",
4481
+ "--version",
4482
+ "-v"
4483
+ ].includes(arg)) continue;
4484
+ switch (arg) {
4485
+ case "--transport": {
4486
+ const value = valueAfter(args, index, arg);
4487
+ index += 1;
4488
+ if (value !== "stdio" && value !== "http") throw new Error(`Invalid transport: ${value}. Use either stdio or http.`);
4489
+ transport = value;
4490
+ transportExplicit = true;
4491
+ break;
4492
+ }
4493
+ case "--host":
4494
+ host = parseHost(valueAfter(args, index, arg));
4495
+ hostExplicit = true;
4496
+ index += 1;
4497
+ break;
4498
+ case "--port":
4499
+ port = parsePort(valueAfter(args, index, arg));
4500
+ portExplicit = true;
4501
+ index += 1;
4502
+ break;
4503
+ default: throw new Error(`Unknown option: ${arg}`);
4504
+ }
4505
+ }
4506
+ if (transport === "stdio" && (hostExplicit || portExplicit)) throw new Error("--host and --port can only be used with --transport http.");
4507
+ if (transport === "stdio" && transportExplicit) return {
4508
+ transport,
4509
+ host: DEFAULT_HOST,
4510
+ port: DEFAULT_PORT
4511
+ };
4512
+ return {
4513
+ transport,
4514
+ host,
4515
+ port
4516
+ };
4517
+ }
4518
+ //#endregion
4519
+ //#region src/utils/mcp-session-observability.ts
4520
+ const UNKNOWN_METADATA = "unknown";
4521
+ const MAX_METADATA_LENGTH = 64;
4522
+ const SAFE_METADATA_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+:/@-]{0,63}$/u;
4523
+ const contextStorage = new AsyncLocalStorage();
4524
+ let activeStdioSession;
4525
+ function isRecord(value) {
4526
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4527
+ }
4528
+ function normalizeMetadata(value) {
4529
+ if (typeof value !== "string") return UNKNOWN_METADATA;
4530
+ const normalized = value.trim();
4531
+ if (normalized.length === 0 || normalized.length > MAX_METADATA_LENGTH || !SAFE_METADATA_PATTERN.test(normalized)) return UNKNOWN_METADATA;
4532
+ return normalized;
4533
+ }
4534
+ function getInitializeParams(message) {
4535
+ if (!isRecord(message) || message.method !== "initialize") return {};
4536
+ return isRecord(message.params) ? message.params : {};
4537
+ }
4538
+ function extractMcpClientMetadata(message) {
4539
+ const params = getInitializeParams(message);
4540
+ const clientInfo = isRecord(params.clientInfo) ? params.clientInfo : {};
4541
+ return {
4542
+ name: normalizeMetadata(clientInfo.name),
4543
+ version: normalizeMetadata(clientInfo.version),
4544
+ protocolVersion: normalizeMetadata(params.protocolVersion)
4545
+ };
4546
+ }
4547
+ function createMcpSessionContext(message, transport = "stdio") {
4548
+ return {
4549
+ metadata: extractMcpClientMetadata(message),
4550
+ startedAt: Date.now(),
4551
+ toolCalls: 0,
4552
+ hadToolFailure: false,
4553
+ transport
4554
+ };
4555
+ }
4556
+ function runWithMcpSessionContext(context, callback) {
4557
+ return contextStorage.run(context, callback);
4558
+ }
4559
+ function getSession() {
4560
+ return contextStorage.getStore() ?? activeStdioSession;
4561
+ }
4562
+ function metadataAttributes(metadata, transport) {
4563
+ return {
4564
+ client_name: metadata.name,
4565
+ client_version: metadata.version,
4566
+ protocol_version: metadata.protocolVersion,
4567
+ transport
4568
+ };
4569
+ }
4570
+ function durationBucket(durationMs) {
4571
+ if (durationMs < 1e3) return "<1s";
4572
+ if (durationMs < 1e4) return "1-10s";
4573
+ if (durationMs < 6e4) return "10-60s";
4574
+ if (durationMs < 3e5) return "1-5m";
4575
+ return "5m+";
4576
+ }
4577
+ function recordMcpSessionStart(message, transport = "stdio", context) {
4578
+ const session = context ?? createMcpSessionContext(message, transport);
4579
+ if (session.transport === "stdio") activeStdioSession = session;
4580
+ const attributes = metadataAttributes(session.metadata, session.transport);
4581
+ sessionStarted.add(1, attributes);
4582
+ return session.metadata;
4583
+ }
4584
+ function recordMcpToolInvocation() {
4585
+ const session = getSession();
4586
+ if (session) session.toolCalls += 1;
4587
+ return metadataAttributes(session?.metadata ?? {
4588
+ name: UNKNOWN_METADATA,
4589
+ version: UNKNOWN_METADATA,
4590
+ protocolVersion: UNKNOWN_METADATA
4591
+ }, session?.transport ?? "stdio");
4592
+ }
4593
+ function recordMcpToolFailure() {
4594
+ const session = getSession();
4595
+ if (session) session.hadToolFailure = true;
4596
+ }
4597
+ function getCurrentMcpClientMetadata() {
4598
+ return getSession()?.metadata ?? {
4599
+ name: UNKNOWN_METADATA,
4600
+ version: UNKNOWN_METADATA,
4601
+ protocolVersion: UNKNOWN_METADATA
4602
+ };
4603
+ }
4604
+ function getCurrentMcpTransport() {
4605
+ return getSession()?.transport ?? "stdio";
4606
+ }
4607
+ function recordMcpSessionTermination(category, context) {
4608
+ const session = context ?? getSession();
4609
+ const durationMs = session ? Math.max(0, Date.now() - session.startedAt) : 0;
4610
+ const attributes = {
4611
+ ...metadataAttributes(session?.metadata ?? {
4612
+ name: UNKNOWN_METADATA,
4613
+ version: UNKNOWN_METADATA,
4614
+ protocolVersion: UNKNOWN_METADATA
4615
+ }, session?.transport ?? "stdio"),
4616
+ termination_category: category,
4617
+ session_duration_bucket: durationBucket(durationMs),
4618
+ tool_calls_bucket: bucketCount(session?.toolCalls ?? 0)
4619
+ };
4620
+ sessionEnded.add(1, attributes);
4621
+ if (!context || context === activeStdioSession) activeStdioSession = void 0;
4622
+ }
4623
+ function resolveSessionTerminationCategory(shutdownSucceeded, context) {
4624
+ if (!shutdownSucceeded) return "unknown";
4625
+ return (context ?? getSession())?.hadToolFailure ? "tool_failure" : "clean";
4626
+ }
4627
+ //#endregion
4628
+ //#region src/utils/streamable-http.ts
4629
+ const MCP_PATH = "/mcp";
4630
+ const MAX_BODY_BYTES = 1048576;
4631
+ const HTTP_BEARER_TOKEN = "HEVY_MCP_HTTP_BEARER_TOKEN";
4632
+ var HttpRequestError = class extends Error {
4633
+ statusCode;
4634
+ constructor(statusCode, message) {
4635
+ super(message);
4636
+ this.name = "HttpRequestError";
4637
+ this.statusCode = statusCode;
4638
+ }
4639
+ };
4640
+ function normalizeHost(host) {
4641
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
4642
+ }
4643
+ function isLoopbackHost(host) {
4644
+ const normalized = normalizeHost(host).toLowerCase();
4645
+ return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
4646
+ }
4647
+ function hostNamesFor(options) {
4648
+ const configured = normalizeHost(options.host).toLowerCase();
4649
+ const names = /* @__PURE__ */ new Set([configured]);
4650
+ if (isLoopbackHost(options.host)) {
4651
+ names.add("localhost");
4652
+ names.add("127.0.0.1");
4653
+ names.add("::1");
4654
+ }
4655
+ return names;
4656
+ }
4657
+ function expectedPort(options, server) {
4658
+ if (options.port !== 0) return options.port;
4659
+ const address = server.address();
4660
+ return address && typeof address !== "string" ? address.port : 0;
4661
+ }
4662
+ function validateHostHeader(request, allowedHosts, port, allowAnyHostname = false) {
4663
+ const header = request.headers.host;
4664
+ if (!header || Array.isArray(header)) return false;
4665
+ try {
4666
+ const parsed = new URL(`http://${header}`);
4667
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || !allowAnyHostname && (parsed.port ? Number(parsed.port) : 80) !== port) return false;
4668
+ return allowAnyHostname || allowedHosts.has(normalizeHost(parsed.hostname).toLowerCase());
4669
+ } catch {
4670
+ return false;
4671
+ }
4672
+ }
4673
+ function safeDiagnostic(error) {
4674
+ return JSON.stringify(createSafeErrorDiagnostic(error));
4675
+ }
4676
+ function writeJson(res, status, message) {
4677
+ if (res.headersSent) return;
4678
+ res.statusCode = status;
4679
+ res.setHeader("Content-Type", "application/json");
4680
+ res.end(JSON.stringify({ error: message }));
4681
+ }
4682
+ function readBody(request) {
4683
+ return new Promise((resolve, reject) => {
4684
+ let size = 0;
4685
+ let rejected = false;
4686
+ const chunks = [];
4687
+ const onData = (chunk) => {
4688
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4689
+ size += buffer.byteLength;
4690
+ if (size > MAX_BODY_BYTES) {
4691
+ if (!rejected) {
4692
+ rejected = true;
4693
+ request.removeListener("data", onData);
4694
+ request.resume();
4695
+ reject(new HttpRequestError(413, "Request body is too large."));
4696
+ }
4697
+ return;
4698
+ }
4699
+ if (!rejected) chunks.push(buffer);
4700
+ };
4701
+ request.on("data", onData);
4702
+ request.once("error", (error) => {
4703
+ if (!rejected) reject(error);
4704
+ });
4705
+ request.once("end", () => {
4706
+ if (rejected) return;
4707
+ try {
4708
+ const raw = Buffer.concat(chunks).toString("utf8");
4709
+ resolve(raw.length === 0 ? void 0 : JSON.parse(raw));
4710
+ } catch {
4711
+ reject(new HttpRequestError(400, "Request body must be valid JSON."));
4712
+ }
4713
+ });
4714
+ });
4715
+ }
4716
+ function isInitializeRequest(body) {
4717
+ return Boolean(body && typeof body === "object" && !Array.isArray(body) && "method" in body && body.method === "initialize");
4718
+ }
4719
+ function closeServer(server) {
4720
+ return new Promise((resolve, reject) => {
4721
+ if (!server.listening) {
4722
+ resolve();
4723
+ return;
4724
+ }
4725
+ server.close((error) => error ? reject(error) : resolve());
4726
+ server.closeIdleConnections();
4727
+ server.closeAllConnections();
4728
+ });
4729
+ }
4730
+ function isBearerAuthorized(request, token) {
4731
+ if (!token) return false;
4732
+ const header = request.headers.authorization;
4733
+ if (typeof header !== "string") return false;
4734
+ const expected = Buffer.from(`Bearer ${token}`);
4735
+ const actual = Buffer.from(header);
4736
+ return expected.byteLength === actual.byteLength && timingSafeEqual(expected, actual);
4737
+ }
4738
+ function aggregateErrors(errors, message) {
4739
+ if (errors.length === 1) return errors[0];
4740
+ return new AggregateError(errors, message);
4741
+ }
4742
+ async function startStreamableHttpServer(options, apiKey, createMcpServer) {
4743
+ const wildcard = options.host === "0.0.0.0" || options.host === "::" || options.host === "[::]";
4744
+ const loopback = isLoopbackHost(options.host);
4745
+ const bearerToken = process.env[HTTP_BEARER_TOKEN];
4746
+ if (!loopback && !bearerToken) throw new Error(`Non-loopback HTTP mode requires ${HTTP_BEARER_TOKEN} to protect the shared Hevy account.`);
4747
+ const sessions = /* @__PURE__ */ new Map();
4748
+ const cleanupErrors = [];
4749
+ const server = createServer((request, response) => {
4750
+ handleRequest(request, response).catch((error) => {
4751
+ if (response.headersSent) {
4752
+ if (!response.writableEnded) response.destroy();
4753
+ return;
4754
+ }
4755
+ if (error instanceof HttpRequestError) {
4756
+ writeJson(response, error.statusCode, error.message);
4757
+ return;
4758
+ }
4759
+ console.error(`HTTP request failed: ${safeDiagnostic(error)}`);
4760
+ writeJson(response, 500, "Internal server error.");
4761
+ });
4762
+ });
4763
+ async function closeSession(session, failureCategory) {
4764
+ if (session.closed) return;
4765
+ session.closed = true;
4766
+ for (const [id, current] of sessions) if (current === session) sessions.delete(id);
4767
+ for (const response of session.responses) if (!response.writableEnded) response.destroy();
4768
+ const errors = (await Promise.allSettled([Promise.resolve().then(() => session.transport.close()), Promise.resolve().then(() => session.server.close())])).flatMap((result) => result.status === "rejected" ? [result.reason] : []);
4769
+ const succeeded = errors.length === 0;
4770
+ recordMcpSessionTermination(failureCategory && succeeded ? failureCategory : resolveSessionTerminationCategory(succeeded, session.context), session.context);
4771
+ if (errors.length > 0) throw aggregateErrors(errors, "MCP session cleanup failed.");
4772
+ }
4773
+ async function closeUnregistered(transport, mcpServer) {
4774
+ const errors = (await Promise.allSettled([Promise.resolve().then(() => transport.close()), ...mcpServer ? [Promise.resolve().then(() => mcpServer.close())] : []])).flatMap((result) => result.status === "rejected" ? [result.reason] : []);
4775
+ if (errors.length > 0) throw aggregateErrors(errors, "MCP cleanup failed.");
4776
+ }
4777
+ async function handleRequest(request, response) {
4778
+ if (request.url?.split("?", 1)[0] !== MCP_PATH) {
4779
+ writeJson(response, 404, "Not found");
4780
+ return;
4781
+ }
4782
+ if (!validateHostHeader(request, hostNamesFor(options), expectedPort(options, server), wildcard)) {
4783
+ writeJson(response, 403, "Invalid Host header");
4784
+ return;
4785
+ }
4786
+ if (!loopback && !isBearerAuthorized(request, bearerToken)) {
4787
+ writeJson(response, 401, "Authorization required");
4788
+ return;
4789
+ }
4790
+ const body = request.method === "POST" ? await readBody(request) : void 0;
4791
+ const initializing = request.method === "POST" && isInitializeRequest(body);
4792
+ const sessionHeader = request.headers["mcp-session-id"];
4793
+ const sessionId = typeof sessionHeader === "string" ? sessionHeader : void 0;
4794
+ if (initializing) {
4795
+ if (sessionId) {
4796
+ writeJson(response, 400, "Initialization must not include Mcp-Session-Id.");
4797
+ return;
4798
+ }
4799
+ const context = createMcpSessionContext(body, "http");
4800
+ recordMcpSessionStart(body, "http", context);
4801
+ let session;
4802
+ let mcpServer;
4803
+ let connected = false;
4804
+ const transport = new StreamableHTTPServerTransport({
4805
+ sessionIdGenerator: randomUUID,
4806
+ onsessioninitialized: (id) => {
4807
+ if (session) sessions.set(id, session);
4808
+ }
4809
+ });
4810
+ transport.onclose = () => {
4811
+ if (session) closeSession(session).catch((error) => {
4812
+ cleanupErrors.push(error);
4813
+ });
4814
+ };
4815
+ try {
4816
+ mcpServer = await createMcpServer({ apiKey });
4817
+ session = {
4818
+ transport,
4819
+ server: mcpServer,
4820
+ context,
4821
+ responses: /* @__PURE__ */ new Set(),
4822
+ closed: false
4823
+ };
4824
+ await mcpServer.connect(transport);
4825
+ connected = true;
4826
+ session.responses.add(response);
4827
+ response.once("close", () => session?.responses.delete(response));
4828
+ await runWithMcpSessionContext(context, () => transport.handleRequest(request, response, body));
4829
+ } catch (error) {
4830
+ console.error(`HTTP session request failed: ${safeDiagnostic(error)}`);
4831
+ let cleanupError;
4832
+ try {
4833
+ if (session) await closeSession(session, connected ? "unknown" : "connect_failure");
4834
+ else {
4835
+ await closeUnregistered(transport, mcpServer);
4836
+ recordMcpSessionTermination("startup_failure", context);
4837
+ }
4838
+ } catch (cleanupFailure) {
4839
+ cleanupError = cleanupFailure;
4840
+ if (!session) recordMcpSessionTermination("unknown", context);
4841
+ }
4842
+ if (cleanupError) console.error(`HTTP cleanup failed: ${safeDiagnostic(cleanupError)}`);
4843
+ if (!response.writableEnded) writeJson(response, 500, "Internal server error.");
4844
+ }
4845
+ return;
4846
+ }
4847
+ if (!sessionId) {
4848
+ writeJson(response, 400, "Mcp-Session-Id header is required.");
4849
+ return;
4850
+ }
4851
+ const session = sessions.get(sessionId);
4852
+ if (!session) {
4853
+ writeJson(response, 404, "Unknown Mcp-Session-Id.");
4854
+ return;
4855
+ }
4856
+ if (request.method !== "DELETE") {
4857
+ session.responses.add(response);
4858
+ response.once("close", () => session.responses.delete(response));
4859
+ }
4860
+ await runWithMcpSessionContext(session.context, () => session.transport.handleRequest(request, response, body));
4861
+ }
4862
+ await new Promise((resolve, reject) => {
4863
+ server.once("error", reject);
4864
+ server.listen(options.port, options.host, () => {
4865
+ server.removeListener("error", reject);
4866
+ resolve();
4867
+ });
4868
+ });
4869
+ let closePromise;
4870
+ return {
4871
+ server,
4872
+ close: () => {
4873
+ if (closePromise) return closePromise;
4874
+ closePromise = (async () => {
4875
+ const sessionsToClose = [...sessions.values()];
4876
+ const results = await Promise.allSettled([...sessionsToClose.map((session) => closeSession(session)), closeServer(server)]);
4877
+ sessions.clear();
4878
+ const errors = [...cleanupErrors, ...results.flatMap((result) => result.status === "rejected" ? [result.reason] : [])];
4879
+ if (errors.length > 0) throw aggregateErrors(errors, "HTTP server cleanup failed.");
4880
+ })();
4881
+ return closePromise;
4882
+ }
4883
+ };
4884
+ }
4885
+ //#endregion
4435
4886
  //#region src/utils/graceful-shutdown.ts
4436
4887
  const shutdownSignals = ["SIGINT", "SIGTERM"];
4437
4888
  const FORCED_EXIT_TIMEOUT_MS = 5e3;
@@ -4599,99 +5050,6 @@ function createNodeHevyClientOptions() {
4599
5050
  };
4600
5051
  }
4601
5052
  //#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
5053
  //#region src/utils/tool-observer.ts
4696
5054
  const WORKFLOW_PAGINATION_RESOURCES = /* @__PURE__ */ new Set([
4697
5055
  "workouts",
@@ -4722,7 +5080,7 @@ function createAttributes(invocation) {
4722
5080
  "mcp.client.name": clientMetadata.name,
4723
5081
  "mcp.client.version": clientMetadata.version,
4724
5082
  "mcp.protocol.version": clientMetadata.protocolVersion,
4725
- "mcp.transport": "stdio",
5083
+ "mcp.transport": getCurrentMcpTransport(),
4726
5084
  "mcp.tool.args.key_count_bucket": invocation.argumentKeyCountBucket ?? "unknown",
4727
5085
  "mcp.tool.args.keys": invocation.argumentKeys?.join(",") ?? ""
4728
5086
  };
@@ -5185,13 +5543,19 @@ const HELP_TEXT = [
5185
5543
  "Options:",
5186
5544
  " -h, --help Show this help message and exit",
5187
5545
  " -v, --version Show version and exit",
5546
+ " --transport stdio|http Select the transport (default: stdio)",
5547
+ " --host <host> HTTP bind host (default: 127.0.0.1)",
5548
+ " --port <port> HTTP bind port (default: 3000)",
5188
5549
  "",
5189
5550
  "Environment:",
5190
5551
  " HEVY_API_KEY=<api-key> Hevy API key from Hevy app settings",
5191
5552
  " HEVY_MCP_DEBUG=1 Enable verbose diagnostics on stderr",
5553
+ " HEVY_MCP_HTTP_BEARER_TOKEN Protect non-loopback HTTP deployments",
5554
+ " HEVY_MCP_TELEMETRY=0 Disable all project telemetry",
5192
5555
  "",
5193
5556
  "Examples:",
5194
- " HEVY_API_KEY=your-key npx hevy-mcp"
5557
+ " HEVY_API_KEY=your-key npx hevy-mcp",
5558
+ " HEVY_API_KEY=your-key npx hevy-mcp --transport http --port 3000"
5195
5559
  ].join("\n");
5196
5560
  function getCliAction(args) {
5197
5561
  for (const arg of args) {
@@ -5216,10 +5580,6 @@ const SAFE_NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
5216
5580
  "ETIMEDOUT",
5217
5581
  "HEVY_RETRY_EXHAUSTED"
5218
5582
  ]);
5219
- const SENTRY_USER_ID_CONTEXT = "hevy-mcp:sentry-user-id:v1";
5220
- function fingerprintApiKey(apiKey) {
5221
- return createHmac("sha256", apiKey).update(SENTRY_USER_ID_CONTEXT).digest("hex").slice(0, 10);
5222
- }
5223
5583
  const serverConfigSchema = z.object({ apiKey: z.string().min(1, "Hevy API key is required").describe("Your Hevy API key (available in the Hevy app settings).") });
5224
5584
  function getHttpStatus(error) {
5225
5585
  if (isHevyHttpError(error)) return error.status;
@@ -5251,11 +5611,11 @@ async function validateApiKey(apiKey) {
5251
5611
  console.error(diagnostic ? `${API_KEY_VALIDATION_WARNING} Diagnostic: ${diagnostic}.` : API_KEY_VALIDATION_WARNING);
5252
5612
  }
5253
5613
  }
5254
- function buildServer(apiKey) {
5614
+ function buildServer(apiKey, transport = "stdio") {
5255
5615
  return tracer.startActiveSpan("mcp.server.build", { attributes: {
5256
5616
  "mcp.server.name": name,
5257
5617
  "mcp.server.version": version,
5258
- "mcp.transport": "stdio"
5618
+ "mcp.transport": transport
5259
5619
  } }, (span) => {
5260
5620
  try {
5261
5621
  const server = createHevyMcpServer({
@@ -5282,13 +5642,11 @@ function buildServer(apiKey) {
5282
5642
  }
5283
5643
  });
5284
5644
  }
5285
- async function createNodeMcpServer({ apiKey }) {
5645
+ async function createNodeMcpServer({ apiKey }, transport = "stdio") {
5286
5646
  const { apiKey: validatedApiKey } = serverConfigSchema.parse({ apiKey });
5287
- const userHash = fingerprintApiKey(validatedApiKey);
5288
- setCurrentUserHash(userHash);
5289
- Sentry.setUser({ id: userHash });
5647
+ setTelemetryUser(validatedApiKey);
5290
5648
  await validateApiKey(validatedApiKey);
5291
- return buildServer(validatedApiKey);
5649
+ return buildServer(validatedApiKey, transport);
5292
5650
  }
5293
5651
  async function runStdioServer() {
5294
5652
  const cliAction = getCliAction(process.argv.slice(2));
@@ -5302,11 +5660,7 @@ async function runStdioServer() {
5302
5660
  }
5303
5661
  serverStartups.add(1, { version });
5304
5662
  const configuredApiKey = process.env.HEVY_API_KEY;
5305
- const initialUserHash = configuredApiKey ? fingerprintApiKey(configuredApiKey) : void 0;
5306
- if (initialUserHash) {
5307
- setCurrentUserHash(initialUserHash);
5308
- Sentry.setUser({ id: initialUserHash });
5309
- }
5663
+ if (configuredApiKey) setTelemetryUser(configuredApiKey);
5310
5664
  let connectAttempted = false;
5311
5665
  await tracer.startActiveSpan("mcp.server.run", { attributes: { "mcp.transport": "stdio" } }, async (span) => {
5312
5666
  try {
@@ -5348,5 +5702,52 @@ 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
+ setTelemetryUser(cfg.apiKey);
5728
+ await validateApiKey(cfg.apiKey);
5729
+ const handle = await startStreamableHttpServer(options, cfg.apiKey, (params) => Promise.resolve(buildServer(params.apiKey, "http")));
5730
+ listening = true;
5731
+ console.error(`Starting MCP server in HTTP mode at ${options.host}:${options.port}/mcp`);
5732
+ scheduleUpdateCheck({
5733
+ packageName: name$1,
5734
+ currentVersion: version$1
5735
+ });
5736
+ installGracefulShutdown({
5737
+ target: handle,
5738
+ onComplete: async () => {
5739
+ await flushTelemetry();
5740
+ }
5741
+ });
5742
+ span.setStatus({ code: SpanStatusCode.OK });
5743
+ } catch (error) {
5744
+ recordMcpSessionTermination(listening ? "unknown" : "startup_failure");
5745
+ span.setStatus({ code: SpanStatusCode.ERROR });
5746
+ throw error;
5747
+ } finally {
5748
+ span.end();
5749
+ }
5750
+ });
5751
+ }
5351
5752
  //#endregion
5352
- export { flushTelemetry as a, createSafeErrorDiagnostic as i, runStdioServer as n, MissingHevyApiKeyError as r, createNodeMcpServer as t };
5753
+ 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.1",
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.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "hevy-mcp",
15
- "version": "4.0.1",
15
+ "version": "4.1.1",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },