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 +70 -14
- package/dist/cli.mjs +4 -4
- package/dist/index.d.mts +6 -2
- package/dist/index.mjs +4 -4
- package/dist/{src-D0fOBh7M.mjs → src-BPbLELQf.mjs} +561 -160
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -414,20 +414,40 @@ self-hosted Streamable HTTP.
|
|
|
414
414
|
|
|
415
415
|
## Advanced configuration
|
|
416
416
|
|
|
417
|
-
| Setting
|
|
418
|
-
|
|
|
419
|
-
| `HEVY_API_KEY`
|
|
420
|
-
| `HEVY_MCP_API_TIMEOUT`
|
|
421
|
-
| `HEVY_MCP_DEBUG`
|
|
422
|
-
| `
|
|
423
|
-
| `
|
|
424
|
-
| `
|
|
425
|
-
|
|
|
426
|
-
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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.
|
|
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] = "
|
|
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
|
|
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
|
-
|
|
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.
|
|
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] = "
|
|
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-
|
|
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.
|
|
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] = "
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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.
|
|
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":
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
5753
|
+
export { createSafeErrorDiagnostic as a, MissingHevyApiKeyError as i, runServer as n, flushTelemetry as o, runStdioServer as r, createNodeMcpServer as t };
|
package/package.json
CHANGED
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.
|
|
10
|
+
"version": "4.1.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "hevy-mcp",
|
|
15
|
-
"version": "4.
|
|
15
|
+
"version": "4.1.1",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|