Skip to content

Commit b5ffe99

Browse files
feat: add guided workout prompts (#508)
Co-authored-by: CharlieHelps <charlie@charlielabs.ai>
1 parent 3adfd97 commit b5ffe99

7 files changed

Lines changed: 251 additions & 0 deletions

File tree

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+
Add guided MCP prompts for analyzing workout progress and creating a completed workout from a routine.

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,17 @@ The supported path is stdio via `npx hevy-mcp`.
240240
> folders, exercise templates, or body measurements, so `hevy-mcp` does not
241241
> provide delete tools for these resources.
242242
243+
## 💬 Available MCP Prompts
244+
245+
| Prompt | Arguments | Guided workflow |
246+
| :---------------------------- | :------------------------------------------------------------------------------------- | :---------------------------------------------------- |
247+
| `analyze-workout-progress` | Optional `weeks` (1-12; defaults to `4` when omitted from a supplied arguments object) | Analyze recent workout and body-measurement trends. |
248+
| `create-workout-from-routine` | `routineId`, `startTime` (UTC ISO seconds) | Record a completed workout using a routine as a plan. |
249+
250+
Compatibility note: with MCP SDK v1.29.0, clients using the default must send
251+
`arguments: {}` because the SDK rejects requests that omit the entire
252+
`arguments` object before prompt field defaults are evaluated.
253+
243254
---
244255

245256
## 📚 Available MCP Resources

node_modules

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/home/user/hevy-mcp/node_modules

src/index.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const testDoubles = vi.hoisted(() => ({
2323
end: vi.fn(),
2424
},
2525
connect: vi.fn().mockResolvedValue(undefined),
26+
registerPrompt: vi.fn(),
2627
tool: vi.fn(),
2728
registerTool: vi.fn(),
2829
directRegisterToolCalls: 0,
@@ -93,6 +94,7 @@ vi.mock("@opentelemetry/api", () => ({
9394
vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => {
9495
class MockMcpServer {
9596
connect = testDoubles.connect;
97+
registerPrompt = testDoubles.registerPrompt;
9698
tool = testDoubles.tool;
9799
registerTool = testDoubles.registerTool;
98100
registerResource = vi.fn();
@@ -148,6 +150,10 @@ describe("Server entry", () => {
148150
it("creates an MCP server instance", () => {
149151
const server = createServer({ config: { apiKey: "test-key" } });
150152
expect(server).toBeDefined();
153+
expect(testDoubles.registerPrompt).toHaveBeenCalledTimes(2);
154+
expect(
155+
testDoubles.registerPrompt.mock.calls.map(([prompt]) => prompt),
156+
).toEqual(["analyze-workout-progress", "create-workout-from-routine"]);
151157
expect(testDoubles.startActiveSpan).toHaveBeenCalledWith(
152158
"mcp.server.build",
153159
expect.objectContaining({

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createHmac } from "node:crypto";
1414
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1515
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1616
import { z } from "zod";
17+
import { registerWorkoutPrompts } from "./prompts/workouts.js";
1718
import { registerHevyResources } from "./resources/hevy.js";
1819
import { registerBodyMeasurementTools } from "./tools/body-measurements.js";
1920
import { registerFolderTools } from "./tools/folders.js";
@@ -170,6 +171,7 @@ function buildServer(apiKey: string) {
170171
}
171172
});
172173

174+
registerWorkoutPrompts(server);
173175
tracer.startActiveSpan(
174176
"mcp.resources.register",
175177
{

src/prompts/workouts.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { registerWorkoutPrompts } from "./workouts.js";
6+
7+
describe("workout prompts", () => {
8+
let client: Client;
9+
let server: McpServer;
10+
11+
beforeEach(async () => {
12+
server = new McpServer({ name: "prompt-test-server", version: "1.0.0" });
13+
registerWorkoutPrompts(server);
14+
15+
client = new Client({ name: "prompt-test-client", version: "1.0.0" });
16+
const [clientTransport, serverTransport] =
17+
InMemoryTransport.createLinkedPair();
18+
await Promise.all([
19+
server.connect(serverTransport),
20+
client.connect(clientTransport),
21+
]);
22+
});
23+
24+
afterEach(async () => {
25+
await Promise.all([client.close(), server.close()]);
26+
});
27+
28+
it("lists both prompts with discoverable metadata and argument schemas", async () => {
29+
const result = await client.listPrompts();
30+
31+
expect(result.prompts).toHaveLength(2);
32+
expect(result.prompts).toEqual(
33+
expect.arrayContaining([
34+
expect.objectContaining({
35+
name: "analyze-workout-progress",
36+
title: "Analyze Workout Progress",
37+
description: expect.stringContaining("workout"),
38+
arguments: expect.arrayContaining([
39+
expect.objectContaining({
40+
name: "weeks",
41+
required: false,
42+
}),
43+
]),
44+
}),
45+
expect.objectContaining({
46+
name: "create-workout-from-routine",
47+
title: "Create Workout From Routine",
48+
description: expect.stringContaining("routine"),
49+
arguments: expect.arrayContaining([
50+
expect.objectContaining({ name: "routineId", required: true }),
51+
expect.objectContaining({ name: "startTime", required: true }),
52+
]),
53+
}),
54+
]),
55+
);
56+
});
57+
58+
it("coerces a string week count in prompts/get", async () => {
59+
const result = await client.getPrompt({
60+
name: "analyze-workout-progress",
61+
arguments: { weeks: "6" },
62+
});
63+
64+
expect(result.messages).toEqual([
65+
expect.objectContaining({
66+
role: "user",
67+
content: expect.objectContaining({
68+
type: "text",
69+
text: expect.stringContaining("last 6 weeks"),
70+
}),
71+
}),
72+
]);
73+
expect(result.messages[0]?.content).toEqual(
74+
expect.objectContaining({
75+
text: expect.stringContaining("pageSize=10"),
76+
}),
77+
);
78+
});
79+
80+
it("uses the default week count with explicit empty arguments", async () => {
81+
const result = await client.getPrompt({
82+
name: "analyze-workout-progress",
83+
arguments: {},
84+
});
85+
86+
expect(result.messages[0]?.content).toEqual(
87+
expect.objectContaining({
88+
text: expect.stringContaining("last 4 weeks"),
89+
}),
90+
);
91+
});
92+
93+
it("rejects omitting the entire arguments object at the SDK boundary", async () => {
94+
await expect(
95+
client.getPrompt({ name: "analyze-workout-progress" }),
96+
).rejects.toThrow(/arguments/i);
97+
});
98+
99+
it.each(["0", "13", "2.5", "not-a-number"])(
100+
"rejects invalid week value %s",
101+
async (weeks) => {
102+
await expect(
103+
client.getPrompt({
104+
name: "analyze-workout-progress",
105+
arguments: { weeks },
106+
}),
107+
).rejects.toThrow();
108+
},
109+
);
110+
111+
it("returns routine-to-workout guidance without inventing completion data", async () => {
112+
const result = await client.getPrompt({
113+
name: "create-workout-from-routine",
114+
arguments: {
115+
routineId: "routine-123",
116+
startTime: "2026-07-10T08:00:00Z",
117+
},
118+
});
119+
120+
expect(result.messages).toHaveLength(1);
121+
expect(result.messages[0]).toEqual(
122+
expect.objectContaining({
123+
role: "user",
124+
content: expect.objectContaining({
125+
type: "text",
126+
text: expect.stringMatching(
127+
/get-routine[\s\S]*restSeconds[\s\S]*repRange[\s\S]*endTime[\s\S]*Never invent/,
128+
),
129+
}),
130+
}),
131+
);
132+
});
133+
134+
it("rejects an invalid workout start timestamp", async () => {
135+
await expect(
136+
client.getPrompt({
137+
name: "create-workout-from-routine",
138+
arguments: {
139+
routineId: "routine-123",
140+
startTime: "2026-07-10T08:00:00+00:00",
141+
},
142+
}),
143+
).rejects.toThrow();
144+
});
145+
});

src/prompts/workouts.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { z } from "zod";
3+
4+
const utcSecondTimestamp = z
5+
.string()
6+
.regex(
7+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/,
8+
"Must use the UTC format YYYY-MM-DDTHH:mm:ssZ",
9+
);
10+
11+
/** Register guided workout workflow prompts. */
12+
export function registerWorkoutPrompts(server: McpServer) {
13+
server.registerPrompt(
14+
"analyze-workout-progress",
15+
{
16+
title: "Analyze Workout Progress",
17+
description: "Analyze recent workout and body-measurement trends.",
18+
argsSchema: {
19+
weeks: z.coerce
20+
.number()
21+
.int()
22+
.min(1)
23+
.max(12)
24+
.default(4)
25+
.optional()
26+
.describe("Number of recent weeks to analyze (1-12)."),
27+
},
28+
},
29+
({ weeks = 4 }) => ({
30+
messages: [
31+
{
32+
role: "user",
33+
content: {
34+
type: "text",
35+
text: [
36+
`Analyze my workout progress over the last ${weeks} weeks.`,
37+
"Use get-workout-count to establish the available workout total.",
38+
"Then call get-workouts with pageSize=10 and continue through pages until the requested date window is fully covered or no more workouts remain.",
39+
"Also call get-body-measurements with pageSize=10 and paginate until the same date window is covered or no more measurements remain.",
40+
"Base the analysis on retrieved evidence and discuss workout frequency, training volume, exercise variety, consistency, and body-measurement trends.",
41+
"Distinguish observations from suggestions, note missing or limited data, and do not make unsupported claims or medical conclusions.",
42+
].join("\n"),
43+
},
44+
},
45+
],
46+
}),
47+
);
48+
49+
server.registerPrompt(
50+
"create-workout-from-routine",
51+
{
52+
title: "Create Workout From Routine",
53+
description: "Create a completed workout from an existing routine.",
54+
argsSchema: {
55+
routineId: z.string().min(1).describe("Routine ID to use as a guide."),
56+
startTime: utcSecondTimestamp.describe(
57+
"Workout start time in UTC as YYYY-MM-DDTHH:mm:ssZ.",
58+
),
59+
},
60+
},
61+
({ routineId, startTime }) => ({
62+
messages: [
63+
{
64+
role: "user",
65+
content: {
66+
type: "text",
67+
text: [
68+
`Create a workout from routine ${routineId}, starting at ${startTime}.`,
69+
"First call get-routine with the routineId and map supported plan fields: routine title to workout title, plus each exerciseTemplateId, supersetId, exercise notes, and set type.",
70+
"Do not copy routine-only restSeconds or repRange fields into create-workout.",
71+
"Before calling create-workout, confirm or collect the user's actual completed set data for every set, including applicable weight, reps, distance, duration, RPE, or custom metric values.",
72+
"Also collect the required endTime in strict UTC YYYY-MM-DDTHH:mm:ssZ format and confirm any other missing required workout fields.",
73+
"Never invent completion data. If the actual results or endTime are unavailable, ask the user for them instead of creating the workout.",
74+
"Once confirmed, call create-workout with only fields supported by that tool.",
75+
].join("\n"),
76+
},
77+
},
78+
],
79+
}),
80+
);
81+
}

0 commit comments

Comments
 (0)