Skip to content

Commit caf294d

Browse files
feat: send structured logging messages to MCP clients (#510)
Co-authored-by: CharlieHelps <charlie@charlielabs.ai>
1 parent 7b90699 commit caf294d

12 files changed

Lines changed: 629 additions & 15 deletions

.changeset/calm-coins-log.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hevy-mcp": minor
3+
---
4+
5+
Send structured MCP client logs for Hevy API retries, rate limits, errors, and exercise template catalog refreshes.

src/index.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const testDoubles = vi.hoisted(() => ({
2323
end: vi.fn(),
2424
},
2525
connect: vi.fn().mockResolvedValue(undefined),
26+
mcpServerConstructor: vi.fn(),
27+
sendLoggingMessage: vi.fn().mockResolvedValue(undefined),
2628
registerPrompt: vi.fn(),
2729
tool: vi.fn(),
2830
registerTool: vi.fn(),
@@ -93,7 +95,13 @@ vi.mock("@opentelemetry/api", () => ({
9395

9496
vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => {
9597
class MockMcpServer {
98+
constructor(serverInfo: unknown, options: unknown) {
99+
testDoubles.mcpServerConstructor(serverInfo, options);
100+
}
101+
96102
connect = testDoubles.connect;
103+
isConnected = vi.fn(() => true);
104+
sendLoggingMessage = testDoubles.sendLoggingMessage;
97105
registerPrompt = testDoubles.registerPrompt;
98106
tool = testDoubles.tool;
99107
registerTool = testDoubles.registerTool;
@@ -165,6 +173,20 @@ describe("Server entry", () => {
165173
);
166174
});
167175

176+
it("advertises logging capability and injects one client logger", () => {
177+
createServer({ config: { apiKey: "test-key" } });
178+
179+
expect(testDoubles.mcpServerConstructor).toHaveBeenCalledWith(
180+
{ name: "hevy-mcp", version: "dev" },
181+
{ capabilities: { logging: {} } },
182+
);
183+
expect(createClient).toHaveBeenCalledWith(
184+
"test-key",
185+
"https://api.hevyapp.com",
186+
{ logger: expect.any(Function) },
187+
);
188+
});
189+
168190
it("reports the number of tool registration calls on the registration span", () => {
169191
createServer({ config: { apiKey: "test-key" } });
170192

@@ -270,6 +292,7 @@ describe("Server entry", () => {
270292
expect(createClient).toHaveBeenCalledWith(
271293
"test-api-key",
272294
"https://api.hevyapp.com",
295+
{ logger: expect.any(Function) },
273296
);
274297
expect(Sentry.setUser).toHaveBeenCalledWith({
275298
id: TEST_API_KEY_HMAC_SHA256,
@@ -297,6 +320,7 @@ describe("Server entry", () => {
297320
expect(createClient).toHaveBeenCalledWith(
298321
"cli-key",
299322
"https://api.hevyapp.com",
323+
{ logger: expect.any(Function) },
300324
);
301325
expect(Sentry.setUser).toHaveBeenCalledWith({
302326
id: CLI_KEY_HMAC_SHA256,

src/index.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { registerUserTools } from "./tools/user.js";
2424
import { registerWorkoutTools } from "./tools/workouts.js";
2525
import { assertApiKey, parseConfig } from "./utils/config.js";
2626
import { createClient } from "./utils/hevyClient.js";
27+
import { createMcpClientLogger } from "./utils/mcp-client-logger.js";
2728
import { createInstrumentedStdioTransport } from "./utils/stdio-observability.js";
2829

2930
const name = serviceName;
@@ -138,17 +139,23 @@ function buildServer(apiKey: string) {
138139
Sentry.setUser({ id: userId });
139140
setCurrentUserId(userId);
140141

141-
const baseServer = new McpServer({
142-
name,
143-
version,
144-
});
142+
const baseServer = new McpServer(
143+
{
144+
name,
145+
version,
146+
},
147+
{ capabilities: { logging: {} } },
148+
);
145149
const server = Sentry.wrapMcpServerWithSentry(baseServer);
150+
const clientLogger = createMcpClientLogger(server);
146151

147152
const hevyClient = tracer.startActiveSpan(
148153
"mcp.hevy-client.initialize",
149154
(childSpan) => {
150155
try {
151-
return createClient(apiKey, HEVY_API_BASEURL);
156+
return createClient(apiKey, HEVY_API_BASEURL, {
157+
logger: clientLogger,
158+
});
152159
} finally {
153160
childSpan.end();
154161
}
@@ -161,7 +168,9 @@ function buildServer(apiKey: string) {
161168
const counting = createToolCountingServer(server);
162169
registerWorkoutTools(counting.server, hevyClient);
163170
registerRoutineTools(counting.server, hevyClient);
164-
registerTemplateTools(counting.server, hevyClient);
171+
registerTemplateTools(counting.server, hevyClient, {
172+
logger: clientLogger,
173+
});
165174
registerFolderTools(counting.server, hevyClient);
166175
registerBodyMeasurementTools(counting.server, hevyClient);
167176
registerUserTools(counting.server, hevyClient);

src/tools/templates.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,78 @@ describe("registerTemplateTools", () => {
440440
expect(hevyClient.getExerciseTemplates).toHaveBeenCalledTimes(2);
441441
});
442442

443+
it("logs successful initial and explicit refreshes but stays silent on cache hits", async () => {
444+
const { server, tool } = createMockServer();
445+
const logger = vi.fn();
446+
const template: ExerciseTemplate = {
447+
id: "t1",
448+
title: "Bench Press",
449+
type: "barbell",
450+
primary_muscle_group: "chest",
451+
secondary_muscle_groups: [],
452+
is_custom: false,
453+
};
454+
const hevyClient: HevyClient = {
455+
getExerciseTemplates: vi.fn().mockResolvedValue({
456+
page: 1,
457+
page_count: 1,
458+
exercise_templates: [template],
459+
}),
460+
} as unknown as HevyClient;
461+
462+
registerTemplateTools(server, hevyClient, { logger });
463+
const { handler } = getToolRegistration(
464+
tool,
465+
"search-exercise-templates",
466+
);
467+
468+
await handler({ query: "bench", refresh: false });
469+
expect(logger).toHaveBeenCalledExactlyOnceWith({
470+
level: "info",
471+
logger: "hevy-cache",
472+
data: {
473+
message: "Exercise template catalog refreshed",
474+
count: 1,
475+
reason: "initial-load",
476+
},
477+
});
478+
479+
await handler({ query: "bench", refresh: false });
480+
expect(logger).toHaveBeenCalledTimes(1);
481+
482+
await handler({ query: "bench", refresh: true });
483+
expect(logger).toHaveBeenNthCalledWith(2, {
484+
level: "info",
485+
logger: "hevy-cache",
486+
data: {
487+
message: "Exercise template catalog refreshed",
488+
count: 1,
489+
reason: "explicit-refresh",
490+
},
491+
});
492+
});
493+
494+
it("does not log a cache refresh when the catalog fetch fails", async () => {
495+
const { server, tool } = createMockServer();
496+
const logger = vi.fn();
497+
const hevyClient: HevyClient = {
498+
getExerciseTemplates: vi
499+
.fn()
500+
.mockRejectedValue(new Error("catalog fetch failed")),
501+
} as unknown as HevyClient;
502+
503+
registerTemplateTools(server, hevyClient, { logger });
504+
const { handler } = getToolRegistration(
505+
tool,
506+
"search-exercise-templates",
507+
);
508+
509+
const response = await handler({ query: "bench", refresh: false });
510+
511+
expect(response).toMatchObject({ isError: true });
512+
expect(logger).not.toHaveBeenCalled();
513+
});
514+
443515
it("filters by primaryMuscleGroup when provided", async () => {
444516
const { server, tool } = createMockServer();
445517
const chestTemplate: ExerciseTemplate = {

src/tools/templates.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
formatExerciseHistoryEntry,
1818
formatExerciseTemplate,
1919
} from "../utils/formatters.js";
20+
import type { McpClientLogger } from "../utils/mcp-client-logger.js";
2021
import type { HevyClient } from "../utils/hevyClient.js";
2122
import {
2223
exerciseHistoryOutputSchema,
@@ -39,6 +40,9 @@ import {
3940
muscleGroupEnum,
4041
} from "../utils/schemas.js";
4142

43+
export interface TemplateToolOptions {
44+
logger?: McpClientLogger;
45+
}
4246
/** Reset the exercise template cache (exposed for testing). */
4347
export function resetExerciseTemplateCache(): void {
4448
resetExerciseTemplateCatalogCache();
@@ -50,7 +54,9 @@ export function resetExerciseTemplateCache(): void {
5054
export function registerTemplateTools(
5155
server: McpServer,
5256
hevyClient: HevyClient | null,
57+
options: TemplateToolOptions = {},
5358
) {
59+
const { logger } = options;
5460
// Get exercise templates
5561
const getExerciseTemplatesSchema = {
5662
page: z.coerce.number().int().gte(1).default(1),
@@ -271,7 +277,27 @@ export function registerTemplateTools(
271277
withObservability(async (args: SearchExerciseTemplatesParams) => {
272278
const client = requireClient(hevyClient);
273279
const { query, primaryMuscleGroup, refresh } = args;
274-
const catalog = await getExerciseTemplateCatalog(client, { refresh });
280+
const catalog = await getExerciseTemplateCatalog(client, {
281+
refresh,
282+
onRefreshed: (refreshedCatalog, reason) => {
283+
try {
284+
logger?.({
285+
level: "info",
286+
logger: "hevy-cache",
287+
data: {
288+
message: "Exercise template catalog refreshed",
289+
count: refreshedCatalog.length,
290+
reason,
291+
},
292+
});
293+
} catch (error) {
294+
console.error(
295+
"Failed to emit structured exercise template cache log",
296+
error,
297+
);
298+
}
299+
},
300+
});
275301

276302
// Filter by query (case-insensitive title substring match)
277303
const queryLower = query.toLowerCase();

src/utils/exercise-template-catalog.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ const exerciseTemplateCatalogCache = new AsyncTtlCache<
1818
maxSize: EXERCISE_TEMPLATE_CATALOG_CACHE_MAX_SIZE,
1919
});
2020

21+
export type ExerciseTemplateCatalogRefreshReason =
22+
| "explicit-refresh"
23+
| "initial-load"
24+
| "ttl-expired";
25+
26+
interface ExerciseTemplateCatalogOptions {
27+
refresh?: boolean;
28+
onRefreshed?: (
29+
catalog: ExerciseTemplate[],
30+
reason: ExerciseTemplateCatalogRefreshReason,
31+
) => void;
32+
}
33+
2134
function getSafePageCount(
2235
data: GetV1ExerciseTemplates200,
2336
currentPage: number,
@@ -58,11 +71,21 @@ async function fetchExerciseTemplateCatalog(
5871

5972
export function getExerciseTemplateCatalog(
6073
hevyClient: HevyClient,
61-
options: { refresh?: boolean } = {},
74+
options: ExerciseTemplateCatalogOptions = {},
6275
): Promise<ExerciseTemplate[]> {
76+
const reason = options.refresh
77+
? "explicit-refresh"
78+
: exerciseTemplateCatalogCache.size === 0
79+
? "initial-load"
80+
: "ttl-expired";
81+
6382
return exerciseTemplateCatalogCache.getOrFetch(
6483
EXERCISE_TEMPLATE_CATALOG_CACHE_KEY,
65-
() => fetchExerciseTemplateCatalog(hevyClient),
84+
async () => {
85+
const catalog = await fetchExerciseTemplateCatalog(hevyClient);
86+
options.onRefreshed?.(catalog, reason);
87+
return catalog;
88+
},
6689
options,
6790
);
6891
}

src/utils/hevyClient.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it, vi } from "vitest";
22
import { createClient } from "./hevyClient";
3+
import { createClient as createKubbClient } from "./hevyClientKubb.js";
34

45
// Mock the Kubb client
56
vi.mock("./hevyClientKubb.js", () => ({
@@ -21,6 +22,19 @@ describe("hevyClient", () => {
2122

2223
// Assert
2324
expect(client).toEqual({ mockedClient: true });
25+
expect(createKubbClient).toHaveBeenCalledWith(apiKey, baseUrl, {});
26+
});
27+
28+
it("passes optional logging configuration to the Kubb client", () => {
29+
const logger = vi.fn();
30+
31+
createClient("test-api-key", "https://api.hevy.com", { logger });
32+
33+
expect(createKubbClient).toHaveBeenCalledWith(
34+
"test-api-key",
35+
"https://api.hevy.com",
36+
{ logger },
37+
);
2438
});
2539
});
2640
});

src/utils/hevyClient.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
// Import the Kubb-based client
22
import { createClient as createKubbClient } from "./hevyClientKubb.js";
3+
import type { HevyClientOptions } from "./hevyClientKubb.js";
34

4-
export function createClient(apiKey: string, baseUrl: string) {
5-
return createKubbClient(apiKey, baseUrl);
5+
export function createClient(
6+
apiKey: string,
7+
baseUrl: string,
8+
options: HevyClientOptions = {},
9+
) {
10+
return createKubbClient(apiKey, baseUrl, options);
611
}
712

813
// Export the HevyClient type for use in other modules

0 commit comments

Comments
 (0)