sunderos-mcp 0.2.0

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 ADDED
@@ -0,0 +1,56 @@
1
+ # sunderos-mcp
2
+
3
+ MCP server for Sunderos— a fitness tracking app with AI integration.
4
+
5
+ Connect your AI assistant to your Sunderos instance and manage workouts, programs, and exercises through natural language.
6
+
7
+ ## Setup
8
+
9
+ Add this to your `.mcp.json` (Claude Code, Gemini CLI, or any MCP client):
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "sunderos": {
15
+ "command": "npx",
16
+ "args": ["-y", "sunderos-mcp"],
17
+ "env": {
18
+ "SUNDEROS_API_URL": "https://your-instance.example.com",
19
+ "SUNDEROS_API_KEY": "so_xxx"
20
+ }
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ ### Getting an API Key
27
+
28
+ 1. Log in to your Sunderos instance
29
+ 2. Go to **Settings > API Keys**
30
+ 3. Create a new key and copy the `so_xxx` value
31
+
32
+ ### Environment Variables
33
+
34
+ | Variable | Required | Default | Description |
35
+ |----------|----------|---------|-------------|
36
+ | `SUNDEROS_API_KEY` | Yes | — | Your API key (`so_xxx`) |
37
+ | `SUNDEROS_API_URL` | Yes | — | Base URL of your Sunderos instance |
38
+
39
+ ## What You Can Do
40
+
41
+ Talk to your AI naturally — the MCP tools handle the rest:
42
+
43
+ - "Show me my workout history for this week"
44
+ - "Create a 4-week upper/lower program"
45
+ - "What's my bench press PR?"
46
+ - "Log my body weight at 185 lbs"
47
+ - "Suggest weight adjustments based on my recent RPE"
48
+ - "Build me a push day template with 4 exercises"
49
+ - "How has my squat progressed over the last month?"
50
+
51
+ 23 tools are available covering exercises, templates, programs, workout history, stats, body weight, and more.
52
+
53
+ ## Requirements
54
+
55
+ - Node.js 22+
56
+ - A running Sunderos instance with an API key
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Simple HTTP client for the Sunderos REST API.
3
+ * Authenticates via API key (Bearer token).
4
+ */
5
+ export declare class ApiError extends Error {
6
+ status: number;
7
+ constructor(status: number, message: string);
8
+ }
9
+ export declare const api: {
10
+ get: <T = unknown>(path: string) => Promise<T>;
11
+ post: <T = unknown>(path: string, body?: unknown) => Promise<T>;
12
+ put: <T = unknown>(path: string, body?: unknown) => Promise<T>;
13
+ delete: <T = unknown>(path: string) => Promise<T>;
14
+ };
15
+ //# sourceMappingURL=api-client.d.ts.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Simple HTTP client for the Sunderos REST API.
3
+ * Authenticates via API key (Bearer token).
4
+ */
5
+ const API_URL = process.env.SUNDEROS_API_URL || '';
6
+ if (!API_URL) {
7
+ console.error('Warning: SUNDEROS_API_URL not set. API requests will fail.');
8
+ }
9
+ const API_KEY = process.env.SUNDEROS_API_KEY || '';
10
+ if (!API_KEY) {
11
+ console.error('Warning: SUNDEROS_API_KEY not set. API requests will fail authentication.');
12
+ }
13
+ export class ApiError extends Error {
14
+ status;
15
+ constructor(status, message) {
16
+ super(message);
17
+ this.status = status;
18
+ this.name = 'ApiError';
19
+ }
20
+ }
21
+ async function request(method, path, body) {
22
+ const url = `${API_URL}${path}`;
23
+ const headers = {
24
+ Authorization: `Bearer ${API_KEY}`,
25
+ };
26
+ if (body) {
27
+ headers['Content-Type'] = 'application/json';
28
+ }
29
+ const res = await fetch(url, {
30
+ method,
31
+ headers,
32
+ body: body ? JSON.stringify(body) : undefined,
33
+ });
34
+ if (!res.ok) {
35
+ const text = await res.text().catch(() => '');
36
+ let message = `API ${method} ${path} failed with status ${res.status}`;
37
+ try {
38
+ const json = JSON.parse(text);
39
+ if (json.error)
40
+ message = json.error;
41
+ }
42
+ catch {
43
+ if (text)
44
+ message = text;
45
+ }
46
+ throw new ApiError(res.status, message);
47
+ }
48
+ const contentType = res.headers.get('content-type') || '';
49
+ if (contentType.includes('application/json')) {
50
+ return res.json();
51
+ }
52
+ return res.text();
53
+ }
54
+ export const api = {
55
+ get: (path) => request('GET', path),
56
+ post: (path, body) => request('POST', path, body),
57
+ put: (path, body) => request('PUT', path, body),
58
+ delete: (path) => request('DELETE', path),
59
+ };
60
+ //# sourceMappingURL=api-client.js.map
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,720 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
6
+ import { z } from 'zod';
7
+ import { api } from './api-client.js';
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+ function formatDate(d) {
12
+ if (!d)
13
+ return '—';
14
+ return new Date(d).toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
15
+ }
16
+ function getErrorMessage(err) {
17
+ return err instanceof Error ? err.message : String(err);
18
+ }
19
+ // ---------------------------------------------------------------------------
20
+ // MCP Server
21
+ // ---------------------------------------------------------------------------
22
+ const server = new McpServer({
23
+ name: 'sunderos',
24
+ version: '0.2.0',
25
+ });
26
+ // ===========================
27
+ // EXERCISES
28
+ // ===========================
29
+ server.tool('list_exercises', 'List available exercises, optionally filtered by muscle group, equipment, or name search', {
30
+ search: z.string().optional().describe('Search exercises by name (partial match)'),
31
+ muscle_group: z.enum(['chest', 'back', 'shoulders', 'biceps', 'triceps', 'forearms', 'legs', 'glutes', 'core', 'full_body']).optional(),
32
+ equipment: z.enum(['barbell', 'dumbbell', 'machine', 'bodyweight', 'cable', 'kettlebell', 'band', 'other']).optional(),
33
+ limit: z.number().min(1).max(100).default(50).optional(),
34
+ }, async ({ search, muscle_group, equipment }) => {
35
+ const params = new URLSearchParams();
36
+ if (search)
37
+ params.set('search', search);
38
+ if (muscle_group)
39
+ params.set('muscleGroup', muscle_group);
40
+ if (equipment)
41
+ params.set('equipment', equipment);
42
+ const rows = await api.get(`/api/exercises?${params}`);
43
+ const text = rows.length === 0
44
+ ? 'No exercises found.'
45
+ : rows.map((e) => `• **${e.name}** — ${e.muscleGroup || '?'} / ${e.equipment || '?'} (ID: ${e.id})`).join('\n');
46
+ return { content: [{ type: 'text', text: `Found ${rows.length} exercise(s):\n\n${text}` }] };
47
+ });
48
+ server.tool('create_exercise', 'Create a new exercise in the database', {
49
+ name: z.string().describe('Exercise name'),
50
+ muscle_group: z.enum(['chest', 'back', 'shoulders', 'biceps', 'triceps', 'forearms', 'legs', 'glutes', 'core', 'full_body']),
51
+ equipment: z.enum(['barbell', 'dumbbell', 'machine', 'bodyweight', 'cable', 'kettlebell', 'band', 'other']),
52
+ notes: z.string().optional().describe('Notes or cues for the exercise'),
53
+ }, async ({ name, muscle_group, equipment, notes }) => {
54
+ const ex = await api.post('/api/exercises', {
55
+ name,
56
+ muscleGroup: muscle_group,
57
+ equipment,
58
+ notes: notes || undefined,
59
+ });
60
+ return { content: [{ type: 'text', text: `Created exercise "${ex.name}" (ID: ${ex.id})` }] };
61
+ });
62
+ server.tool('delete_exercise', 'Delete a custom exercise by ID', {
63
+ exercise_id: z.string().uuid().describe('Exercise ID to delete'),
64
+ }, async ({ exercise_id }) => {
65
+ try {
66
+ await api.delete(`/api/exercises/${exercise_id}`);
67
+ return { content: [{ type: 'text', text: `Deleted exercise.` }] };
68
+ }
69
+ catch (err) {
70
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
71
+ }
72
+ });
73
+ // ===========================
74
+ // TEMPLATES
75
+ // ===========================
76
+ server.tool('list_templates', 'List workout templates for the user', {
77
+ search: z.string().optional().describe('Search templates by name'),
78
+ }, async ({ search }) => {
79
+ const params = new URLSearchParams();
80
+ if (search)
81
+ params.set('search', search);
82
+ const rows = await api.get(`/api/templates?${params}`);
83
+ if (rows.length === 0)
84
+ return { content: [{ type: 'text', text: 'No templates found.' }] };
85
+ const text = rows
86
+ .map((t) => {
87
+ const prefix = t.userId === null ? '[Built-in] ' : '';
88
+ const cat = t.category ? ` [${t.category}]` : '';
89
+ const tags = t.tags && t.tags.length > 0 ? ` (${t.tags.join(', ')})` : '';
90
+ return `• ${prefix}**${t.name}**${cat}${tags} — ${t.description || 'No description'} (ID: ${t.id})`;
91
+ })
92
+ .join('\n');
93
+ return { content: [{ type: 'text', text: `${rows.length} template(s):\n\n${text}` }] };
94
+ });
95
+ server.tool('get_template', 'Get full details of a workout template including its exercises and set schemes', {
96
+ template_id: z.string().uuid().describe('Template ID'),
97
+ }, async ({ template_id }) => {
98
+ try {
99
+ const tmpl = await api.get(`/api/templates/${template_id}`);
100
+ const exText = (tmpl.exercises || []).map((e, i) => {
101
+ const scheme = (e.setScheme || [])
102
+ .map((s) => `${s.sets}×${s.reps_min}${s.reps_max !== s.reps_min ? '-' + s.reps_max : ''} ${s.type}`)
103
+ .join(', ');
104
+ return ` ${i + 1}. **${e.exerciseName || e.name}** (${e.muscleGroup} / ${e.equipment}) — ${scheme} | Rest: ${e.restSeconds ?? 90}s${e.notes ? ' | Notes: ' + e.notes : ''}`;
105
+ }).join('\n');
106
+ const cat = tmpl.category ? `\nCategory: ${tmpl.category}` : '';
107
+ return {
108
+ content: [{
109
+ type: 'text',
110
+ text: `**${tmpl.name}**\n${tmpl.description || ''}${cat}\nTags: ${tmpl.tags?.join(', ') || 'none'}\n\nExercises:\n${exText || ' (none)'}`,
111
+ }],
112
+ };
113
+ }
114
+ catch (err) {
115
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
116
+ }
117
+ });
118
+ server.tool('create_template', 'Create a new workout template with exercises and set schemes', {
119
+ name: z.string().describe('Template name'),
120
+ description: z.string().optional(),
121
+ category: z.string().optional().describe('Category/folder (e.g., "Push", "Pull", "Legs", "Full Body")'),
122
+ exercises: z.array(z.object({
123
+ exercise_id: z.string().uuid().describe('Exercise ID'),
124
+ set_scheme: z.array(z.object({
125
+ sets: z.number().min(1).describe('Number of sets of this type'),
126
+ reps_min: z.number().min(1),
127
+ reps_max: z.number().min(1),
128
+ rpe_target: z.number().min(1).max(10).optional().describe('Target RPE (1-10)'),
129
+ type: z.enum(['warmup', 'working', 'backoff', 'drop', 'amrap']).default('working'),
130
+ })).describe('Set groups for this exercise'),
131
+ rest_seconds: z.number().optional().describe('Rest between sets (seconds)'),
132
+ superset_group: z.number().optional().describe('Group number to superset exercises together'),
133
+ notes: z.string().optional(),
134
+ })).describe('Exercises with set schemes'),
135
+ }, async ({ name, description, category, exercises: exList }) => {
136
+ const tmpl = await api.post('/api/templates', {
137
+ name,
138
+ description: description || undefined,
139
+ category: category || undefined,
140
+ exercises: exList.map((ex, i) => ({
141
+ exerciseId: ex.exercise_id,
142
+ orderIndex: i,
143
+ setScheme: ex.set_scheme.map(s => ({
144
+ sets: s.sets,
145
+ reps_min: s.reps_min,
146
+ reps_max: s.reps_max,
147
+ rpe_target: s.rpe_target,
148
+ type: s.type,
149
+ })),
150
+ restSeconds: ex.rest_seconds ?? 90,
151
+ supersetGroup: ex.superset_group ?? null,
152
+ notes: ex.notes || null,
153
+ })),
154
+ });
155
+ const totalSets = exList.reduce((sum, ex) => sum + ex.set_scheme.reduce((s, g) => s + g.sets, 0), 0);
156
+ return { content: [{ type: 'text', text: `Created template "${tmpl.name}" with ${exList.length} exercise(s), ${totalSets} total sets (ID: ${tmpl.id})` }] };
157
+ });
158
+ server.tool('update_template', 'Update an existing workout template (name, description, category, and/or exercises). Only provided fields are changed. If exercises are provided, they fully replace the existing list.', {
159
+ template_id: z.string().uuid().describe('Template ID to update'),
160
+ name: z.string().optional().describe('New template name'),
161
+ description: z.string().optional().describe('New description'),
162
+ category: z.string().optional().describe('New category'),
163
+ exercises: z.array(z.object({
164
+ exercise_id: z.string().uuid().describe('Exercise ID'),
165
+ set_scheme: z.array(z.object({
166
+ sets: z.number().min(1).describe('Number of sets of this type'),
167
+ reps_min: z.number().min(1),
168
+ reps_max: z.number().min(1),
169
+ rpe_target: z.number().min(1).max(10).optional().describe('Target RPE (1-10)'),
170
+ type: z.enum(['warmup', 'working', 'backoff', 'drop', 'amrap']).default('working'),
171
+ })).describe('Set groups for this exercise'),
172
+ rest_seconds: z.number().optional().describe('Rest between sets (seconds)'),
173
+ superset_group: z.number().optional().describe('Group number to superset exercises together'),
174
+ notes: z.string().optional(),
175
+ })).optional().describe('Full replacement exercise list (omit to keep existing exercises)'),
176
+ }, async ({ template_id, name, description, category, exercises: exList }) => {
177
+ try {
178
+ const body = {};
179
+ if (name !== undefined)
180
+ body.name = name;
181
+ if (description !== undefined)
182
+ body.description = description;
183
+ if (category !== undefined)
184
+ body.category = category;
185
+ if (exList !== undefined) {
186
+ body.exercises = exList.map((ex, i) => ({
187
+ exerciseId: ex.exercise_id,
188
+ orderIndex: i,
189
+ setScheme: ex.set_scheme.map(s => ({
190
+ sets: s.sets,
191
+ reps_min: s.reps_min,
192
+ reps_max: s.reps_max,
193
+ rpe_target: s.rpe_target,
194
+ type: s.type,
195
+ })),
196
+ restSeconds: ex.rest_seconds ?? 90,
197
+ supersetGroup: ex.superset_group ?? null,
198
+ notes: ex.notes || null,
199
+ }));
200
+ }
201
+ const tmpl = await api.put(`/api/templates/${template_id}`, body);
202
+ const parts = [`Updated template "${tmpl.name}" (ID: ${tmpl.id})`];
203
+ if (exList !== undefined) {
204
+ const totalSets = exList.reduce((sum, ex) => sum + ex.set_scheme.reduce((s, g) => s + g.sets, 0), 0);
205
+ parts.push(`${exList.length} exercise(s), ${totalSets} total sets`);
206
+ }
207
+ return { content: [{ type: 'text', text: parts.join(' — ') }] };
208
+ }
209
+ catch (err) {
210
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
211
+ }
212
+ });
213
+ server.tool('delete_template', 'Delete a workout template by ID', {
214
+ template_id: z.string().uuid(),
215
+ }, async ({ template_id }) => {
216
+ try {
217
+ await api.delete(`/api/templates/${template_id}`);
218
+ return { content: [{ type: 'text', text: `Deleted template.` }] };
219
+ }
220
+ catch (err) {
221
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
222
+ }
223
+ });
224
+ // ===========================
225
+ // PROGRAMS
226
+ // ===========================
227
+ server.tool('list_programs', 'List training programs. Shows which program is active.', {}, async () => {
228
+ const rows = await api.get('/api/programs');
229
+ if (rows.length === 0)
230
+ return { content: [{ type: 'text', text: 'No programs found.' }] };
231
+ const text = rows
232
+ .map((p) => {
233
+ const prefix = p.userId === null ? '[Built-in] ' : '';
234
+ return `• ${p.isActive ? '✅ ' : ''}${prefix}**${p.name}** — ${p.weeks} week(s), ${p.mode} mode (ID: ${p.id})${p.description ? '\n ' + p.description : ''}`;
235
+ })
236
+ .join('\n');
237
+ return { content: [{ type: 'text', text: `${rows.length} program(s):\n\n${text}` }] };
238
+ });
239
+ server.tool('get_program', 'Get full details of a program including its schedule (days/weeks/templates)', {
240
+ program_id: z.string().uuid(),
241
+ }, async ({ program_id }) => {
242
+ try {
243
+ const prog = await api.get(`/api/programs/${program_id}`);
244
+ const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
245
+ const dayText = (prog.days || []).map((d) => {
246
+ const dayLabel = prog.mode === 'sequential' ? `Day ${d.dayOfWeek + 1}` : DAY_NAMES[d.dayOfWeek];
247
+ if (d.isRestDay)
248
+ return ` W${d.weekNumber} ${dayLabel}: Rest`;
249
+ return ` W${d.weekNumber} ${dayLabel}: ${d.templateName || d.label || '(no template)'}`;
250
+ }).join('\n');
251
+ return {
252
+ content: [{
253
+ type: 'text',
254
+ text: `**${prog.name}**${prog.isActive ? ' ✅ Active' : ''}\n${prog.description || ''}\nMode: ${prog.mode} | Weeks: ${prog.weeks} | Progress: Day ${(prog.currentDayIndex || 0) + 1}\n\nSchedule:\n${dayText || ' (empty)'}`,
255
+ }],
256
+ };
257
+ }
258
+ catch (err) {
259
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
260
+ }
261
+ });
262
+ server.tool('create_program', 'Create a training program with a weekly or sequential schedule', {
263
+ name: z.string(),
264
+ description: z.string().optional(),
265
+ mode: z.enum(['weekly', 'sequential']).default('weekly'),
266
+ weeks: z.number().min(1).max(52).default(4),
267
+ days: z.array(z.object({
268
+ week_number: z.number().min(1),
269
+ day_of_week: z.number().min(0).max(6).describe('0=Sun .. 6=Sat (weekly), or 0-based index (sequential)'),
270
+ template_id: z.string().uuid().optional().describe('Template to assign (omit for rest day)'),
271
+ label: z.string().optional(),
272
+ is_rest_day: z.boolean().default(false),
273
+ })).describe('Schedule entries'),
274
+ activate: z.boolean().default(false).describe('Set this program as the active program'),
275
+ }, async ({ name, description, mode, weeks, days: dayList, activate }) => {
276
+ const prog = await api.post('/api/programs', {
277
+ name,
278
+ description: description || undefined,
279
+ mode,
280
+ weeks,
281
+ days: dayList.map(d => ({
282
+ weekNumber: d.week_number,
283
+ dayOfWeek: d.day_of_week,
284
+ templateId: d.template_id || null,
285
+ label: d.label || null,
286
+ isRestDay: d.is_rest_day,
287
+ })),
288
+ });
289
+ if (activate && prog.id) {
290
+ await api.post(`/api/programs/${prog.id}/activate`);
291
+ }
292
+ return { content: [{ type: 'text', text: `Created program "${prog.name}" with ${dayList.length} scheduled day(s) (ID: ${prog.id})${activate ? ' — set as active' : ''}` }] };
293
+ });
294
+ server.tool('activate_program', 'Set a program as the active training program (deactivates others)', {
295
+ program_id: z.string().uuid(),
296
+ }, async ({ program_id }) => {
297
+ try {
298
+ const result = await api.post(`/api/programs/${program_id}/activate`);
299
+ return { content: [{ type: 'text', text: `Activated program "${result.name || program_id}".` }] };
300
+ }
301
+ catch (err) {
302
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
303
+ }
304
+ });
305
+ server.tool('update_program', 'Update an existing training program (name, description, weeks, mode, and/or schedule). If days are provided, they fully replace the existing schedule.', {
306
+ program_id: z.string().uuid().describe('Program ID to update'),
307
+ name: z.string().describe('Program name'),
308
+ description: z.string().optional(),
309
+ mode: z.enum(['weekly', 'sequential']).default('weekly'),
310
+ weeks: z.number().min(1).max(52).default(4),
311
+ days: z.array(z.object({
312
+ week_number: z.number().min(1),
313
+ day_of_week: z.number().min(0).max(6).describe('0=Sun .. 6=Sat (weekly), or 0-based index (sequential)'),
314
+ template_id: z.string().uuid().optional().describe('Template to assign (omit for rest day)'),
315
+ label: z.string().optional(),
316
+ is_rest_day: z.boolean().default(false),
317
+ })).describe('Schedule entries (replaces existing schedule)'),
318
+ }, async ({ program_id, name, description, mode, weeks, days: dayList }) => {
319
+ try {
320
+ const prog = await api.put(`/api/programs/${program_id}`, {
321
+ name,
322
+ description: description || undefined,
323
+ mode,
324
+ weeks,
325
+ days: dayList.map(d => ({
326
+ weekNumber: d.week_number,
327
+ dayOfWeek: d.day_of_week,
328
+ templateId: d.template_id || null,
329
+ label: d.label || null,
330
+ isRestDay: d.is_rest_day,
331
+ })),
332
+ });
333
+ return { content: [{ type: 'text', text: `Updated program "${prog.name}" with ${dayList.length} scheduled day(s) (ID: ${prog.id})` }] };
334
+ }
335
+ catch (err) {
336
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
337
+ }
338
+ });
339
+ server.tool('delete_program', 'Delete a training program and all its scheduled days', {
340
+ program_id: z.string().uuid().describe('Program ID to delete'),
341
+ }, async ({ program_id }) => {
342
+ try {
343
+ await api.delete(`/api/programs/${program_id}`);
344
+ return { content: [{ type: 'text', text: `Deleted program.` }] };
345
+ }
346
+ catch (err) {
347
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
348
+ }
349
+ });
350
+ // ===========================
351
+ // WORKOUT HISTORY & STATS
352
+ // ===========================
353
+ server.tool('get_workout_history', 'Get recent workout sessions with stats (duration, volume, exercise count)', {
354
+ limit: z.number().min(1).max(50).default(10).optional(),
355
+ }, async ({ limit }) => {
356
+ const sessions = await api.get(`/api/sessions?limit=${limit ?? 10}`);
357
+ if (sessions.length === 0)
358
+ return { content: [{ type: 'text', text: 'No completed workouts yet.' }] };
359
+ const text = sessions.map((s) => {
360
+ const dur = s.finishedAt && s.startedAt
361
+ ? Math.round((new Date(s.finishedAt).getTime() - new Date(s.startedAt).getTime()) / 60000)
362
+ : '?';
363
+ const tags = s.tags && s.tags.length > 0 ? ` [${s.tags.join(', ')}]` : '';
364
+ const vol = s.totalVolume || s.volume || 0;
365
+ const exCount = s.exerciseCount || s.exercises?.length || 0;
366
+ const setCount = s.totalSets || s.setCount || 0;
367
+ return `• **${formatDate(s.startedAt)}** — ${s.templateName || s.name || 'Quick Workout'}${tags}\n ${exCount} exercises, ${setCount} sets, ${Math.round(vol).toLocaleString()} lb volume, ${dur} min${s.notes ? '\n Notes: ' + s.notes : ''}`;
368
+ }).join('\n');
369
+ return { content: [{ type: 'text', text: `Last ${sessions.length} workout(s):\n\n${text}` }] };
370
+ });
371
+ server.tool('get_workout_details', 'Get detailed breakdown of a specific workout session (every set logged)', {
372
+ session_id: z.string().uuid(),
373
+ }, async ({ session_id }) => {
374
+ try {
375
+ const session = await api.get(`/api/sessions/${session_id}`);
376
+ const sets = session.sets || [];
377
+ const grouped = {};
378
+ sets.forEach((s) => {
379
+ const name = s.exerciseName || s.exercise?.name || 'Unknown';
380
+ if (!grouped[name])
381
+ grouped[name] = [];
382
+ grouped[name].push(s);
383
+ });
384
+ const text = Object.entries(grouped).map(([name, exSets]) => {
385
+ const setLines = exSets.map((s) => ` Set ${(s.setIndex || 0) + 1} (${s.setType || 'working'}): ${s.weight || 0}lb × ${s.reps || 0}${s.rpe ? ` @RPE ${s.rpe}` : ''}`).join('\n');
386
+ return ` **${name}**\n${setLines}`;
387
+ }).join('\n\n');
388
+ return {
389
+ content: [{
390
+ type: 'text',
391
+ text: `Workout on ${formatDate(session.startedAt)}:\n\n${text || ' (no sets logged)'}`,
392
+ }],
393
+ };
394
+ }
395
+ catch (err) {
396
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
397
+ }
398
+ });
399
+ server.tool('get_exercise_stats', 'Get stats and PR history for a specific exercise (best weight, est. 1RM, volume trends)', {
400
+ exercise_id: z.string().uuid().optional().describe('Exercise ID (provide this or exercise_name)'),
401
+ exercise_name: z.string().optional().describe('Exercise name (partial match, used if exercise_id not provided)'),
402
+ }, async ({ exercise_id, exercise_name }) => {
403
+ let exId = exercise_id;
404
+ let exName = '';
405
+ if (!exId && exercise_name) {
406
+ const results = await api.get(`/api/exercises?search=${encodeURIComponent(exercise_name)}`);
407
+ if (results.length === 0)
408
+ return { content: [{ type: 'text', text: `No exercise found matching "${exercise_name}".` }] };
409
+ exId = results[0].id;
410
+ exName = results[0].name;
411
+ }
412
+ else if (exId) {
413
+ try {
414
+ const ex = await api.get(`/api/exercises/${exId}`);
415
+ exName = ex.name || 'Unknown';
416
+ }
417
+ catch {
418
+ exName = 'Unknown';
419
+ }
420
+ }
421
+ if (!exId)
422
+ return { content: [{ type: 'text', text: 'Provide exercise_id or exercise_name.' }] };
423
+ try {
424
+ const stats = await api.get(`/api/prs/exercise/${exId}`);
425
+ const lines = [
426
+ `**${exName}** — Stats`,
427
+ '',
428
+ `Best Weight: ${stats.bestWeight || 0}lb × ${stats.bestWeightReps || 0}`,
429
+ `Est. 1RM: ${stats.estimated1RM || 0}lb`,
430
+ `Total Volume: ${Math.round(stats.totalVolume || 0).toLocaleString()}lb`,
431
+ ];
432
+ if (stats.recentSessions && stats.recentSessions.length > 0) {
433
+ lines.push('', 'Recent sessions:');
434
+ stats.recentSessions.forEach((s) => {
435
+ lines.push(` ${formatDate(s.date ?? s.startedAt ?? null)}: ${s.sets || 0} sets, max ${s.maxWeight || 0}lb, ${Math.round(s.totalVolume || 0).toLocaleString()}lb vol`);
436
+ });
437
+ }
438
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
439
+ }
440
+ catch (err) {
441
+ return { content: [{ type: 'text', text: `No stats found for "${exName}": ${getErrorMessage(err)}` }] };
442
+ }
443
+ });
444
+ server.tool('get_prs', 'Get personal records across all exercises (highest weight for each exercise)', {}, async () => {
445
+ try {
446
+ const rows = await api.get('/api/prs/recent');
447
+ if (rows.length === 0)
448
+ return { content: [{ type: 'text', text: 'No PRs recorded yet.' }] };
449
+ const text = rows.map((r) => `• **${r.exerciseName || r.name}**: ${r.weight || r.maxWeight}lb × ${r.reps || '?'}`).join('\n');
450
+ return { content: [{ type: 'text', text: `Personal Records:\n\n${text}` }] };
451
+ }
452
+ catch (err) {
453
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
454
+ }
455
+ });
456
+ // ===========================
457
+ // BODY WEIGHT
458
+ // ===========================
459
+ server.tool('log_body_weight', 'Log a body weight measurement for a specific date', {
460
+ weight: z.number().positive().describe('Body weight value'),
461
+ date: z.string().optional().describe('Date in YYYY-MM-DD format (defaults to today)'),
462
+ unit: z.enum(['lb', 'kg']).default('lb').optional(),
463
+ notes: z.string().optional(),
464
+ }, async ({ weight, date, unit, notes }) => {
465
+ const dateStr = date || new Date().toISOString().split('T')[0];
466
+ await api.post('/api/bodyweight', {
467
+ weight,
468
+ date: dateStr,
469
+ unit: unit || 'lb',
470
+ notes: notes || undefined,
471
+ });
472
+ return { content: [{ type: 'text', text: `Logged body weight: ${weight} ${unit || 'lb'} on ${dateStr}` }] };
473
+ });
474
+ server.tool('get_body_weight_history', 'Get body weight history (recent entries and trend)', {
475
+ limit: z.number().min(1).max(100).default(30).optional(),
476
+ }, async ({ limit }) => {
477
+ const res = await api.get(`/api/bodyweight?limit=${limit ?? 30}`);
478
+ const rows = res.entries;
479
+ if (rows.length === 0)
480
+ return { content: [{ type: 'text', text: 'No body weight entries yet.' }] };
481
+ const weights = rows.map((r) => Number(r.weight));
482
+ const latest = weights[0];
483
+ const oldest = weights[weights.length - 1];
484
+ const change = latest - oldest;
485
+ const avg = (weights.reduce((a, b) => a + b, 0) / weights.length).toFixed(1);
486
+ const text = rows.slice(0, 15).map((r) => ` ${r.date}: **${Number(r.weight)}** ${r.unit}${r.notes ? ' — ' + r.notes : ''}`).join('\n');
487
+ return {
488
+ content: [{
489
+ type: 'text',
490
+ text: [
491
+ `Body Weight (last ${rows.length} entries):`,
492
+ '',
493
+ `Current: ${latest} lb | Average: ${avg} lb | Change: ${change >= 0 ? '+' : ''}${change.toFixed(1)} lb`,
494
+ '',
495
+ text,
496
+ rows.length > 15 ? ` ... and ${rows.length - 15} more` : '',
497
+ ].join('\n'),
498
+ }],
499
+ };
500
+ });
501
+ // ===========================
502
+ // RPE AUTOREGULATION
503
+ // ===========================
504
+ server.tool('suggest_weight_adjustments', 'Analyze recent RPE data for an exercise and suggest weight adjustments. Uses RPE-based autoregulation.', {
505
+ exercise_id: z.string().uuid().optional(),
506
+ exercise_name: z.string().optional().describe('Exercise name (partial match)'),
507
+ target_rpe: z.number().min(1).max(10).default(8).optional().describe('Target RPE for working sets (default: 8)'),
508
+ }, async ({ exercise_id, exercise_name, target_rpe }) => {
509
+ let exId = exercise_id;
510
+ let exName = '';
511
+ if (!exId && exercise_name) {
512
+ const results = await api.get(`/api/exercises?search=${encodeURIComponent(exercise_name)}`);
513
+ if (results.length === 0)
514
+ return { content: [{ type: 'text', text: `No exercise found matching "${exercise_name}".` }] };
515
+ exId = results[0].id;
516
+ exName = results[0].name;
517
+ }
518
+ else if (exId) {
519
+ try {
520
+ const ex = await api.get(`/api/exercises/${exId}`);
521
+ exName = ex.name || 'Unknown';
522
+ }
523
+ catch {
524
+ exName = 'Unknown';
525
+ }
526
+ }
527
+ if (!exId)
528
+ return { content: [{ type: 'text', text: 'Provide exercise_id or exercise_name.' }] };
529
+ const tRpe = target_rpe ?? 8;
530
+ try {
531
+ const stats = await api.get(`/api/prs/exercise/${exId}`);
532
+ if (!stats.recentSessions || stats.recentSessions.length === 0) {
533
+ return { content: [{ type: 'text', text: `No session data found for "${exName}". Log sets with RPE to get weight suggestions.` }] };
534
+ }
535
+ const lastSession = stats.recentSessions[0];
536
+ const lastWeight = lastSession.maxWeight || 0;
537
+ const avgRpe = lastSession.avgRpe || 0;
538
+ if (avgRpe === 0) {
539
+ return { content: [{ type: 'text', text: `No RPE data found for "${exName}". Log sets with RPE to get weight suggestions.` }] };
540
+ }
541
+ const rpeDiff = avgRpe - tRpe;
542
+ let suggestion = '';
543
+ let newWeight = lastWeight;
544
+ if (rpeDiff <= -2) {
545
+ const bump = lastWeight >= 100 ? 10 : 5;
546
+ newWeight = lastWeight + bump;
547
+ suggestion = `RPE was well below target (avg ${avgRpe.toFixed(1)} vs target ${tRpe}). **Increase weight by ${bump}lb to ${newWeight}lb.**`;
548
+ }
549
+ else if (rpeDiff <= -0.5) {
550
+ const bump = lastWeight >= 100 ? 5 : 2.5;
551
+ newWeight = lastWeight + bump;
552
+ suggestion = `RPE slightly below target (avg ${avgRpe.toFixed(1)} vs target ${tRpe}). **Increase weight by ${bump}lb to ${newWeight}lb.**`;
553
+ }
554
+ else if (rpeDiff <= 0.5) {
555
+ suggestion = `RPE is on target (avg ${avgRpe.toFixed(1)} vs target ${tRpe}). **Keep weight at ${lastWeight}lb.** Good work.`;
556
+ }
557
+ else if (rpeDiff <= 1.5) {
558
+ suggestion = `RPE slightly above target (avg ${avgRpe.toFixed(1)} vs target ${tRpe}). **Keep weight at ${lastWeight}lb** but monitor fatigue.`;
559
+ }
560
+ else {
561
+ const drop = lastWeight >= 100 ? 10 : 5;
562
+ newWeight = lastWeight - drop;
563
+ suggestion = `RPE well above target (avg ${avgRpe.toFixed(1)} vs target ${tRpe}). **Decrease weight by ${drop}lb to ${newWeight}lb.**`;
564
+ }
565
+ return {
566
+ content: [{
567
+ type: 'text',
568
+ text: [
569
+ `**${exName}** — Weight Suggestion`,
570
+ '',
571
+ suggestion,
572
+ '',
573
+ `Last session (${formatDate(lastSession.date ?? lastSession.startedAt ?? null)}):`,
574
+ ` ${lastSession.sets || 0} sets, max ${lastWeight}lb, avg RPE ${avgRpe.toFixed(1)}`,
575
+ ].join('\n'),
576
+ }],
577
+ };
578
+ }
579
+ catch (err) {
580
+ return { content: [{ type: 'text', text: `Error getting stats for "${exName}": ${getErrorMessage(err)}` }] };
581
+ }
582
+ });
583
+ // ===========================
584
+ // DATA EXPORT
585
+ // ===========================
586
+ server.tool('export_data', 'Export all user data as JSON (exercises, templates, programs, sessions, body weight)', {}, async () => {
587
+ try {
588
+ const data = await api.get('/api/settings/export');
589
+ const summary = {
590
+ exercises: data.exercises?.length || 0,
591
+ templates: data.templates?.length || 0,
592
+ programs: data.programs?.length || 0,
593
+ sessions: data.sessions?.length || 0,
594
+ bodyWeightEntries: data.bodyWeights?.length || 0,
595
+ };
596
+ return {
597
+ content: [{
598
+ type: 'text',
599
+ text: `Data export summary:\n• ${summary.exercises} exercises\n• ${summary.templates} templates\n• ${summary.programs} programs\n• ${summary.sessions} completed sessions\n• ${summary.bodyWeightEntries} body weight entries\n\nUse the app's Settings > Export feature to download a full JSON backup.`,
600
+ }],
601
+ };
602
+ }
603
+ catch (err) {
604
+ return { content: [{ type: 'text', text: `Export error: ${getErrorMessage(err)}` }] };
605
+ }
606
+ });
607
+ // ===========================
608
+ // QUICK STATUS
609
+ // ===========================
610
+ server.tool('get_training_summary', 'Get a high-level summary of current training status: active program, recent workouts, body weight trend', {}, async () => {
611
+ try {
612
+ const [stats, programs, bodyWeights] = await Promise.all([
613
+ api.get('/api/dashboard/stats').catch(() => null),
614
+ api.get('/api/programs').catch(() => []),
615
+ api.get('/api/bodyweight?limit=1').then((r) => r.entries).catch(() => []),
616
+ ]);
617
+ const activeProg = programs.find((p) => p.isActive);
618
+ const latestBw = bodyWeights[0];
619
+ const lines = [
620
+ '**Training Summary**',
621
+ '',
622
+ `Active Program: ${activeProg ? activeProg.name + ' (' + activeProg.mode + ')' : 'None'}`,
623
+ `This Week: ${stats?.weeklyWorkouts || 0} workout(s), ${Math.round(stats?.weeklyVolume || 0).toLocaleString()}lb total volume`,
624
+ `All Time: ${stats?.totalSessions || 0} completed sessions`,
625
+ latestBw ? `Body Weight: ${Number(latestBw.weight)} ${latestBw.unit} (${latestBw.date})` : 'Body Weight: Not tracked yet',
626
+ ];
627
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
628
+ }
629
+ catch (err) {
630
+ return { content: [{ type: 'text', text: `Error: ${getErrorMessage(err)}` }] };
631
+ }
632
+ });
633
+ // ---------------------------------------------------------------------------
634
+ // Transport: stdio (Claude Code, Cursor) or HTTP (ChatGPT, remote clients)
635
+ // ---------------------------------------------------------------------------
636
+ async function main() {
637
+ const transport = process.env.MCP_TRANSPORT || 'stdio';
638
+ if (transport === 'http' || transport === 'sse') {
639
+ // HTTP mode for remote clients (ChatGPT, etc.)
640
+ const port = parseInt(process.env.MCP_PORT || '3001', 10);
641
+ const host = process.env.MCP_HOST || '0.0.0.0';
642
+ const app = createMcpExpressApp({ host });
643
+ // Require API key for HTTP transport — reject unauthenticated MCP sessions
644
+ const MCP_API_KEY = process.env.MCP_API_KEY || process.env.SUNDEROS_API_KEY || '';
645
+ if (!MCP_API_KEY) {
646
+ console.error('Warning: No MCP_API_KEY or SUNDEROS_API_KEY set. HTTP transport will reject all requests.');
647
+ }
648
+ function requireApiKey(req, res, next) {
649
+ // Skip auth for health check
650
+ if (req.path === '/health')
651
+ return next();
652
+ const authHeader = req.headers.authorization;
653
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
654
+ res.status(401).json({ error: 'Missing Authorization header. Use: Bearer <api-key>' });
655
+ return;
656
+ }
657
+ const providedKey = authHeader.slice(7);
658
+ if (providedKey !== MCP_API_KEY) {
659
+ res.status(403).json({ error: 'Invalid API key' });
660
+ return;
661
+ }
662
+ next();
663
+ }
664
+ app.use(requireApiKey);
665
+ const transports = {};
666
+ app.post('/mcp', async (req, res) => {
667
+ const sessionId = req.headers['mcp-session-id'];
668
+ if (sessionId && transports[sessionId]) {
669
+ await transports[sessionId].handleRequest(req, res);
670
+ }
671
+ else {
672
+ const newTransport = new StreamableHTTPServerTransport({
673
+ sessionIdGenerator: () => crypto.randomUUID(),
674
+ });
675
+ await server.connect(newTransport);
676
+ const id = await newTransport.sessionId;
677
+ if (id)
678
+ transports[id] = newTransport;
679
+ await newTransport.handleRequest(req, res);
680
+ }
681
+ });
682
+ app.get('/mcp', async (req, res) => {
683
+ const sessionId = req.headers['mcp-session-id'];
684
+ if (sessionId && transports[sessionId]) {
685
+ await transports[sessionId].handleRequest(req, res);
686
+ }
687
+ else {
688
+ res.status(400).json({ error: 'Missing or invalid session ID' });
689
+ }
690
+ });
691
+ app.delete('/mcp', async (req, res) => {
692
+ const sessionId = req.headers['mcp-session-id'];
693
+ if (sessionId && transports[sessionId]) {
694
+ await transports[sessionId].handleRequest(req, res);
695
+ delete transports[sessionId];
696
+ }
697
+ else {
698
+ res.status(400).json({ error: 'Missing or invalid session ID' });
699
+ }
700
+ });
701
+ app.get('/health', (_req, res) => {
702
+ res.json({ status: 'ok', transport: 'http', tools: 23 });
703
+ });
704
+ app.listen(port, host, () => {
705
+ console.error(`Sunderos MCP Server running on http://${host}:${port}/mcp`);
706
+ console.error(`Health check: http://${host}:${port}/health`);
707
+ });
708
+ }
709
+ else {
710
+ // stdio mode for local clients (Claude Code, Cursor)
711
+ const stdioTransport = new StdioServerTransport();
712
+ await server.connect(stdioTransport);
713
+ console.error('Sunderos MCP Server running on stdio');
714
+ }
715
+ }
716
+ main().catch((err) => {
717
+ console.error('Fatal error:', err);
718
+ process.exit(1);
719
+ });
720
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "sunderos-mcp",
3
+ "version": "0.2.0",
4
+ "description": "MCP server for Sunderos fitness tracking — use with Claude Code, Gemini CLI, or any MCP client",
5
+ "type": "module",
6
+ "bin": {
7
+ "sunderos-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist/index.js",
11
+ "dist/api-client.js",
12
+ "dist/*.d.ts"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "scripts": {
18
+ "dev": "tsx src/index.ts",
19
+ "build": "tsc",
20
+ "lint": "eslint src/",
21
+ "test": "vitest run",
22
+ "start": "node dist/index.js",
23
+ "prepublishOnly": "pnpm build"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.12.1",
27
+ "express": "^4.21.0",
28
+ "zod": "^3.24.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/express": "^5.0.0",
32
+ "@types/node": "^22.0.0",
33
+ "tsx": "^4.19.0",
34
+ "typescript": "^5.7.0",
35
+ "vitest": "^3.0.0"
36
+ }
37
+ }