Skip to content

Commit 528ff30

Browse files
authored
Merge of #572
2 parents 5db2ae3 + 52120de commit 528ff30

11 files changed

Lines changed: 588 additions & 1 deletion

.changeset/quiet-lions-debug.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hevy-mcp": patch
3+
---
4+
5+
Add opt-in, privacy-bounded stderr diagnostics for tool invocations and Hevy API responses.

.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
HEVY_API_KEY=HEVY_KEY
22
# Optional timeout for Hevy API requests in milliseconds (default: 30000)
33
# HEVY_MCP_API_TIMEOUT=30000
4+
# Enable privacy-bounded debug diagnostics on stderr (only 1 enables it)
5+
# HEVY_MCP_DEBUG=1
46

57
# Sentry Configuration (optional - for source map uploads during build)
68
# SENTRY_AUTH_TOKEN=your_sentry_auth_token_here

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,18 @@ Supply your Hevy API key via the `HEVY_API_KEY` environment variable (in
179179
Set `HEVY_MCP_API_TIMEOUT` to override the default 30-second Hevy API request
180180
timeout. Its value is in milliseconds.
181181

182+
Set `HEVY_MCP_DEBUG=1` to emit verbose, privacy-bounded diagnostics to stderr.
183+
Debug records include tool invocations and sanitized Hevy API response timing
184+
and status details. Other values leave diagnostics disabled, and stdout remains
185+
reserved for the MCP JSON-RPC stream.
186+
182187
```env
183188
# Example .env
184189
HEVY_API_KEY=your_hevy_api_key_here
185190
# Optional: customize Hevy API request timeout (milliseconds)
186191
HEVY_MCP_API_TIMEOUT=30000
192+
# Optional: enable verbose stderr diagnostics (only the value 1 enables it)
193+
HEVY_MCP_DEBUG=1
187194
```
188195

189196
### 🧠 Exercise Template Cache Behavior

src/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ describe("Server entry", () => {
319319
const [helpText] = logSpy.mock.calls[0] ?? [];
320320
expect(helpText).toContain("Usage:");
321321
expect(helpText).toContain("HEVY_API_KEY");
322+
expect(helpText).toContain("HEVY_MCP_DEBUG=1");
322323
expect(helpText).toContain("Examples:");
323324
expect(createClient).not.toHaveBeenCalled();
324325
expect(testDoubles.startActiveSpan).not.toHaveBeenCalled();

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const HELP_TEXT = [
7373
"",
7474
"Environment:",
7575
" HEVY_API_KEY=<api-key> Hevy API key from Hevy app settings",
76+
" HEVY_MCP_DEBUG=1 Enable verbose diagnostics on stderr",
7677
"",
7778
"Examples:",
7879
" HEVY_API_KEY=your-key npx hevy-mcp",

src/utils/debug.test.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { debugLog, isDebugEnabled, redactToolArgs } from "./debug.js";
3+
4+
describe("debug diagnostics", () => {
5+
let stderrSpy: ReturnType<typeof vi.spyOn>;
6+
let stdoutSpy: ReturnType<typeof vi.spyOn>;
7+
8+
beforeEach(() => {
9+
delete process.env.HEVY_MCP_DEBUG;
10+
stderrSpy = vi
11+
.spyOn(process.stderr, "write")
12+
.mockImplementation(() => true);
13+
stdoutSpy = vi
14+
.spyOn(process.stdout, "write")
15+
.mockImplementation(() => true);
16+
});
17+
18+
afterEach(() => {
19+
delete process.env.HEVY_MCP_DEBUG;
20+
vi.restoreAllMocks();
21+
});
22+
23+
it.each([undefined, "", "0", "true", "yes", "01", " 1"])(
24+
"keeps diagnostics disabled for %s",
25+
(value) => {
26+
if (value === undefined) {
27+
delete process.env.HEVY_MCP_DEBUG;
28+
} else {
29+
process.env.HEVY_MCP_DEBUG = value;
30+
}
31+
32+
expect(isDebugEnabled()).toBe(false);
33+
debugLog("disabled", { value: 1 });
34+
expect(stderrSpy).not.toHaveBeenCalled();
35+
expect(stdoutSpy).not.toHaveBeenCalled();
36+
},
37+
);
38+
39+
it("enables only the exact value 1 and writes one structured stderr line", () => {
40+
process.env.HEVY_MCP_DEBUG = "1";
41+
42+
expect(isDebugEnabled()).toBe(true);
43+
debugLog("test_event", { enabled: true });
44+
45+
expect(stderrSpy).toHaveBeenCalledExactlyOnceWith(
46+
'[hevy-mcp:debug] {"event":"test_event","enabled":true}\n',
47+
);
48+
expect(stdoutSpy).not.toHaveBeenCalled();
49+
});
50+
51+
it("redacts all input scalars while preserving bounded structure", () => {
52+
const args: Record<string, unknown> = {
53+
page: 2,
54+
includeCustom: true,
55+
weightKg: 81.5,
56+
fatPercent: 18.2,
57+
waist: 84,
58+
date: "2026-07-10",
59+
unknownString: "private scalar",
60+
bigintValue: 123n,
61+
notANumber: Number.NaN,
62+
positiveInfinity: Number.POSITIVE_INFINITY,
63+
symbolValue: Symbol("private symbol"),
64+
callback: () => "private result",
65+
missing: undefined,
66+
empty: null,
67+
apiKey: "sk-secret-api-key",
68+
title: "Private leg workout",
69+
query: "Find Chris's rehab routine",
70+
workout: {
71+
notes: "Knee pain and personal details",
72+
exercises: [{ name: "Secret exercise" }],
73+
sets: 4,
74+
},
75+
};
76+
args.circular = args;
77+
78+
const redacted = redactToolArgs(args);
79+
const serialized = JSON.stringify(redacted);
80+
81+
expect(redacted).toMatchObject({
82+
type: "object",
83+
fieldCount: 19,
84+
fields: {
85+
"field-1": "[number]",
86+
"field-2": "[boolean]",
87+
"field-3": "[number]",
88+
"field-4": "[number]",
89+
"field-5": "[number]",
90+
"field-6": "[string]",
91+
"field-7": "[string]",
92+
"field-8": "[bigint]",
93+
"field-9": "[number]",
94+
"field-10": "[number]",
95+
"field-11": "[symbol]",
96+
"field-12": "[function]",
97+
"field-13": "[undefined]",
98+
"field-14": "[null]",
99+
"field-15": "[string]",
100+
"field-16": "[string]",
101+
"field-17": "[string]",
102+
"field-18": {
103+
type: "object",
104+
fieldCount: 3,
105+
fields: {
106+
"field-1": "[string]",
107+
"field-2": {
108+
type: "array",
109+
length: 1,
110+
items: {
111+
"item-1": {
112+
type: "object",
113+
fieldCount: 1,
114+
fields: { "field-1": "[string]" },
115+
},
116+
},
117+
},
118+
"field-3": "[number]",
119+
},
120+
},
121+
"field-19": "[circular]",
122+
},
123+
});
124+
expect(serialized).not.toContain("81.5");
125+
expect(serialized).not.toContain("18.2");
126+
expect(serialized).not.toContain("84");
127+
expect(serialized).not.toContain("2026-07-10");
128+
expect(serialized).not.toContain("private scalar");
129+
expect(serialized).not.toContain("123");
130+
expect(serialized).not.toContain("private symbol");
131+
expect(serialized).not.toContain("sk-secret-api-key");
132+
expect(serialized).not.toContain("Private leg workout");
133+
expect(serialized).not.toContain("Chris");
134+
expect(serialized).not.toContain("Knee pain");
135+
expect(serialized).not.toContain("Secret exercise");
136+
137+
process.env.HEVY_MCP_DEBUG = "1";
138+
debugLog("redacted_args", { params: redacted });
139+
const output = String(stderrSpy.mock.calls[0]?.[0]);
140+
expect(output).not.toContain("81.5");
141+
expect(output).not.toContain("18.2");
142+
expect(output).not.toContain("84");
143+
});
144+
145+
it("removes adversarial keys and bounds nested structural diagnostics", () => {
146+
const manyKeys = Object.fromEntries(
147+
Array.from({ length: 30 }, (_, index) => [`key${index}`, index]),
148+
);
149+
let getterCalls = 0;
150+
const accessorObject = Object.defineProperty({}, "AliceDiagnosis", {
151+
enumerable: true,
152+
get: () => {
153+
getterCalls += 1;
154+
return "private getter value";
155+
},
156+
});
157+
const accessorArray = Array.from({ length: 1 });
158+
Object.defineProperty(accessorArray, "0", {
159+
enumerable: true,
160+
get: () => {
161+
getterCalls += 1;
162+
return "private array getter value";
163+
},
164+
});
165+
const redacted = redactToolArgs({
166+
kneePain_notes: "private knee value",
167+
johnToken: "private token value",
168+
AliceDiagnosis: "private diagnosis value",
169+
"私密な鍵🔒": "private unicode value",
170+
items: Array.from({ length: 1_000 }, () => "private array value"),
171+
level1: { level2: { level3: { level4: { level5: "secret" } } } },
172+
manyKeys,
173+
accessorObject,
174+
accessorArray,
175+
});
176+
const serialized = JSON.stringify(redacted);
177+
178+
expect(redacted).toMatchObject({
179+
type: "object",
180+
fieldCount: 9,
181+
fields: {
182+
"field-1": "[string]",
183+
"field-2": "[string]",
184+
"field-3": "[string]",
185+
"field-4": "[string]",
186+
"field-5": {
187+
type: "array",
188+
length: 1_000,
189+
truncatedItems: 980,
190+
},
191+
"field-7": {
192+
type: "object",
193+
fieldCount: 30,
194+
truncatedFields: 10,
195+
},
196+
"field-8": {
197+
type: "object",
198+
fieldCount: 1,
199+
fields: { "field-1": "[accessor]" },
200+
},
201+
"field-9": {
202+
type: "array",
203+
length: 1,
204+
items: { "item-1": "[empty-or-accessor]" },
205+
},
206+
},
207+
});
208+
expect(serialized).toContain("[max-depth]");
209+
expect(serialized).not.toContain("kneePain_notes");
210+
expect(serialized).not.toContain("johnToken");
211+
expect(serialized).not.toContain("AliceDiagnosis");
212+
expect(serialized).not.toContain("私密な鍵🔒");
213+
expect(serialized).not.toContain("private knee value");
214+
expect(serialized).not.toContain("private token value");
215+
expect(serialized).not.toContain("private diagnosis value");
216+
expect(serialized).not.toContain("private unicode value");
217+
expect(serialized).not.toContain("private array value");
218+
expect(getterCalls).toBe(0);
219+
220+
process.env.HEVY_MCP_DEBUG = "1";
221+
debugLog("bounded", { payload: "x".repeat(20_000) });
222+
const output = String(stderrSpy.mock.calls[0]?.[0]);
223+
expect(output.length).toBeLessThan(200);
224+
expect(output).toContain('"truncated":true');
225+
expect(stdoutSpy).not.toHaveBeenCalled();
226+
});
227+
228+
it("swallows serialization and stderr write failures", () => {
229+
process.env.HEVY_MCP_DEBUG = "1";
230+
stderrSpy.mockImplementation(() => {
231+
throw new Error("stderr unavailable");
232+
});
233+
234+
expect(() => debugLog("write_failure", { ok: true })).not.toThrow();
235+
expect(() =>
236+
debugLog("serialization_failure", { value: 1n }),
237+
).not.toThrow();
238+
const hostileProxy = new Proxy(
239+
{},
240+
{
241+
ownKeys: () => {
242+
throw new Error("reflection unavailable");
243+
},
244+
},
245+
);
246+
expect(redactToolArgs(hostileProxy)).toBe("[unavailable]");
247+
expect(stdoutSpy).not.toHaveBeenCalled();
248+
});
249+
});

0 commit comments

Comments
 (0)