@striderlabs/mcp-orangetheory 1.0.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/src/index.ts ADDED
@@ -0,0 +1,603 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+ import {
8
+ checkLoginStatus,
9
+ initiateLogin,
10
+ performLogin,
11
+ logout,
12
+ findStudios,
13
+ getClassSchedule,
14
+ bookClass,
15
+ cancelBooking,
16
+ getWorkoutHistory,
17
+ getMembershipStatus,
18
+ getPerformanceSummary,
19
+ getUpcomingClasses,
20
+ closeBrowser,
21
+ } from "./browser.js";
22
+ import { getConfigDir } from "./auth.js";
23
+
24
+ const server = new Server(
25
+ {
26
+ name: "striderlabs-mcp-orangetheory",
27
+ version: "1.0.0",
28
+ },
29
+ {
30
+ capabilities: {
31
+ tools: {},
32
+ },
33
+ }
34
+ );
35
+
36
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
37
+ return {
38
+ tools: [
39
+ {
40
+ name: "orangetheory_status",
41
+ description:
42
+ "Check Orangetheory login status and current session information including member name, email, and home studio.",
43
+ inputSchema: {
44
+ type: "object",
45
+ properties: {},
46
+ required: [],
47
+ },
48
+ },
49
+ {
50
+ name: "orangetheory_login",
51
+ description:
52
+ "Log in to Orangetheory with email and password. Returns login instructions or performs automated login. Session is saved for future use.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ email: {
57
+ type: "string",
58
+ description: "Orangetheory account email address",
59
+ },
60
+ password: {
61
+ type: "string",
62
+ description: "Orangetheory account password",
63
+ },
64
+ },
65
+ required: [],
66
+ },
67
+ },
68
+ {
69
+ name: "orangetheory_logout",
70
+ description:
71
+ "Log out of Orangetheory and clear all saved session data and cookies.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {},
75
+ required: [],
76
+ },
77
+ },
78
+ {
79
+ name: "orangetheory_find_studios",
80
+ description:
81
+ "Find Orangetheory Fitness studios near a given location. Returns studio names, addresses, phone numbers, and distances.",
82
+ inputSchema: {
83
+ type: "object",
84
+ properties: {
85
+ location: {
86
+ type: "string",
87
+ description:
88
+ "Location to search near — city name, zip code, or full address (e.g., 'Austin, TX', '78701', '123 Main St, Boston MA')",
89
+ },
90
+ radius: {
91
+ type: "number",
92
+ description:
93
+ "Search radius in miles (default: 25). Use smaller values for dense urban areas.",
94
+ },
95
+ },
96
+ required: ["location"],
97
+ },
98
+ },
99
+ {
100
+ name: "orangetheory_class_schedule",
101
+ description:
102
+ "View class schedule for an Orangetheory studio. Shows available classes with times, coaches, class types, and available spots.",
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: {
106
+ studio_id: {
107
+ type: "string",
108
+ description:
109
+ "Studio ID from orangetheory_find_studios, or the studio location name",
110
+ },
111
+ date: {
112
+ type: "string",
113
+ description:
114
+ "Start date in YYYY-MM-DD format (default: today). Classes from this date will be shown.",
115
+ },
116
+ days: {
117
+ type: "number",
118
+ description:
119
+ "Number of days to show schedule for (default: 7, max: 14)",
120
+ },
121
+ },
122
+ required: ["studio_id"],
123
+ },
124
+ },
125
+ {
126
+ name: "orangetheory_book_class",
127
+ description:
128
+ "Book an Orangetheory class. Requires confirmation to prevent accidental bookings. Must be logged in.",
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ class_id: {
133
+ type: "string",
134
+ description:
135
+ "Class ID from orangetheory_class_schedule to book",
136
+ },
137
+ confirm: {
138
+ type: "boolean",
139
+ description:
140
+ "Must be set to true to confirm the booking. This reserves your spot and may count against your monthly class allowance.",
141
+ },
142
+ },
143
+ required: ["class_id", "confirm"],
144
+ },
145
+ },
146
+ {
147
+ name: "orangetheory_cancel_booking",
148
+ description:
149
+ "Cancel an Orangetheory class booking. Requires confirmation. Note: cancellations within 8 hours of class start may incur a late cancel fee.",
150
+ inputSchema: {
151
+ type: "object",
152
+ properties: {
153
+ booking_id: {
154
+ type: "string",
155
+ description:
156
+ "Booking ID from orangetheory_upcoming_classes or orangetheory_class_schedule",
157
+ },
158
+ confirm: {
159
+ type: "boolean",
160
+ description:
161
+ "Must be set to true to confirm cancellation. Late cancellations (within 8 hours) may incur a fee.",
162
+ },
163
+ },
164
+ required: ["booking_id", "confirm"],
165
+ },
166
+ },
167
+ {
168
+ name: "orangetheory_workout_history",
169
+ description:
170
+ "View past workout history with detailed stats including splat points, calories burned, heart rate zones, and coach information.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ limit: {
175
+ type: "number",
176
+ description:
177
+ "Maximum number of workout records to return (default: 20, max: 100)",
178
+ },
179
+ start_date: {
180
+ type: "string",
181
+ description:
182
+ "Filter workouts from this date (YYYY-MM-DD format)",
183
+ },
184
+ end_date: {
185
+ type: "string",
186
+ description:
187
+ "Filter workouts up to this date (YYYY-MM-DD format)",
188
+ },
189
+ },
190
+ required: [],
191
+ },
192
+ },
193
+ {
194
+ name: "orangetheory_membership_status",
195
+ description:
196
+ "Check Orangetheory membership status including membership type, home studio, classes remaining, billing info, and member ID.",
197
+ inputSchema: {
198
+ type: "object",
199
+ properties: {},
200
+ required: [],
201
+ },
202
+ },
203
+ {
204
+ name: "orangetheory_performance_summary",
205
+ description:
206
+ "Get aggregated performance statistics over a time period including total workouts, average splat points, calories, heart rate trends, and workout streaks.",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {
210
+ period: {
211
+ type: "string",
212
+ description:
213
+ "Time period for summary: 'week', 'month', '3months', '6months', 'year', or 'all' (default: 'month')",
214
+ enum: ["week", "month", "3months", "6months", "year", "all"],
215
+ },
216
+ },
217
+ required: [],
218
+ },
219
+ },
220
+ {
221
+ name: "orangetheory_upcoming_classes",
222
+ description:
223
+ "View upcoming booked Orangetheory classes with dates, times, coaches, and studio locations. Use this to check your schedule and get booking IDs for cancellation.",
224
+ inputSchema: {
225
+ type: "object",
226
+ properties: {},
227
+ required: [],
228
+ },
229
+ },
230
+ ],
231
+ };
232
+ });
233
+
234
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
235
+ const { name, arguments: args } = request.params;
236
+
237
+ try {
238
+ switch (name) {
239
+ case "orangetheory_status": {
240
+ const session = await checkLoginStatus();
241
+ if (session.isLoggedIn) {
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text",
246
+ text: `Logged in to Orangetheory Fitness
247
+ Member: ${session.userName || "Unknown"}
248
+ Email: ${session.userEmail || "Unknown"}
249
+ Member ID: ${session.memberId || "Unknown"}
250
+ Home Studio: ${session.homeStudio || "Unknown"}
251
+ Last updated: ${session.lastUpdated}
252
+ Config directory: ${getConfigDir()}`,
253
+ },
254
+ ],
255
+ };
256
+ } else {
257
+ return {
258
+ content: [
259
+ {
260
+ type: "text",
261
+ text: `Not logged in to Orangetheory Fitness. Use orangetheory_login to authenticate.
262
+ Config directory: ${getConfigDir()}`,
263
+ },
264
+ ],
265
+ };
266
+ }
267
+ }
268
+
269
+ case "orangetheory_login": {
270
+ const email = args?.email as string | undefined;
271
+ const password = args?.password as string | undefined;
272
+
273
+ if (email && password) {
274
+ const result = await performLogin(email, password);
275
+ return {
276
+ content: [{ type: "text", text: result.message }],
277
+ };
278
+ } else {
279
+ const instructions = await initiateLogin();
280
+ return {
281
+ content: [{ type: "text", text: instructions }],
282
+ };
283
+ }
284
+ }
285
+
286
+ case "orangetheory_logout": {
287
+ await logout();
288
+ return {
289
+ content: [
290
+ {
291
+ type: "text",
292
+ text: "Successfully logged out. All saved session data and cookies have been cleared.",
293
+ },
294
+ ],
295
+ };
296
+ }
297
+
298
+ case "orangetheory_find_studios": {
299
+ const location = args?.location as string;
300
+ const radius = args?.radius as number | undefined;
301
+
302
+ if (!location) {
303
+ throw new Error("location is required");
304
+ }
305
+
306
+ const studios = await findStudios(location, radius);
307
+
308
+ if (studios.length === 0) {
309
+ return {
310
+ content: [
311
+ {
312
+ type: "text",
313
+ text: `No Orangetheory studios found near "${location}" within ${radius || 25} miles. Try a different location or increase the search radius.`,
314
+ },
315
+ ],
316
+ };
317
+ }
318
+
319
+ const studioList = studios
320
+ .map((s, i) => {
321
+ const parts = [
322
+ `${i + 1}. ${s.name}`,
323
+ ` Address: ${s.address}${s.city ? `, ${s.city}` : ""}${s.state ? `, ${s.state}` : ""}${s.zip ? ` ${s.zip}` : ""}`,
324
+ ];
325
+ if (s.phone) parts.push(` Phone: ${s.phone}`);
326
+ if (s.distance) parts.push(` Distance: ${s.distance}`);
327
+ if (s.hours) parts.push(` Hours: ${s.hours}`);
328
+ if (s.id) parts.push(` Studio ID: ${s.id}`);
329
+ return parts.join("\n");
330
+ })
331
+ .join("\n\n");
332
+
333
+ return {
334
+ content: [
335
+ {
336
+ type: "text",
337
+ text: `Found ${studios.length} Orangetheory studio(s) near "${location}":\n\n${studioList}`,
338
+ },
339
+ ],
340
+ };
341
+ }
342
+
343
+ case "orangetheory_class_schedule": {
344
+ const studioId = args?.studio_id as string;
345
+ const date = args?.date as string | undefined;
346
+ const days = args?.days as number | undefined;
347
+
348
+ if (!studioId) {
349
+ throw new Error("studio_id is required");
350
+ }
351
+
352
+ const classes = await getClassSchedule(studioId, date, days);
353
+
354
+ if (classes.length === 0) {
355
+ return {
356
+ content: [
357
+ {
358
+ type: "text",
359
+ text: `No classes found for studio "${studioId}"${date ? ` starting ${date}` : ""}. The studio may be closed or classes may be fully booked.`,
360
+ },
361
+ ],
362
+ };
363
+ }
364
+
365
+ const classList = classes
366
+ .map((c, i) => {
367
+ const parts = [
368
+ `${i + 1}. ${c.classType} — ${c.date} at ${c.time}`,
369
+ ` Studio: ${c.studioName}`,
370
+ ];
371
+ if (c.coach) parts.push(` Coach: ${c.coach}`);
372
+ if (c.spotsAvailable !== undefined)
373
+ parts.push(` Spots Available: ${c.spotsAvailable}${c.totalSpots ? `/${c.totalSpots}` : ""}`);
374
+ if (c.isBooked) parts.push(` Status: BOOKED`);
375
+ if (c.id) parts.push(` Class ID: ${c.id}`);
376
+ if (c.bookingId) parts.push(` Booking ID: ${c.bookingId}`);
377
+ return parts.join("\n");
378
+ })
379
+ .join("\n\n");
380
+
381
+ return {
382
+ content: [
383
+ {
384
+ type: "text",
385
+ text: `Class schedule for ${studioId}${date ? ` starting ${date}` : ""}:\n\n${classList}`,
386
+ },
387
+ ],
388
+ };
389
+ }
390
+
391
+ case "orangetheory_book_class": {
392
+ const classId = args?.class_id as string;
393
+ const confirm = args?.confirm as boolean;
394
+
395
+ if (!classId) {
396
+ throw new Error("class_id is required");
397
+ }
398
+
399
+ const result = await bookClass(classId, confirm);
400
+ return {
401
+ content: [{ type: "text", text: result.message }],
402
+ };
403
+ }
404
+
405
+ case "orangetheory_cancel_booking": {
406
+ const bookingId = args?.booking_id as string;
407
+ const confirm = args?.confirm as boolean;
408
+
409
+ if (!bookingId) {
410
+ throw new Error("booking_id is required");
411
+ }
412
+
413
+ const result = await cancelBooking(bookingId, confirm);
414
+ return {
415
+ content: [{ type: "text", text: result.message }],
416
+ };
417
+ }
418
+
419
+ case "orangetheory_workout_history": {
420
+ const limit = args?.limit as number | undefined;
421
+ const startDate = args?.start_date as string | undefined;
422
+ const endDate = args?.end_date as string | undefined;
423
+
424
+ const records = await getWorkoutHistory(limit, startDate, endDate);
425
+
426
+ if (records.length === 0) {
427
+ return {
428
+ content: [
429
+ {
430
+ type: "text",
431
+ text: "No workout history found. Make sure you are logged in and have completed classes.",
432
+ },
433
+ ],
434
+ };
435
+ }
436
+
437
+ const historyList = records
438
+ .map((r, i) => {
439
+ const parts = [`${i + 1}. ${r.date} — ${r.classType || "OTF Class"}`];
440
+ if (r.studioName) parts.push(` Studio: ${r.studioName}`);
441
+ if (r.coach) parts.push(` Coach: ${r.coach}`);
442
+ if (r.splatPoints !== undefined)
443
+ parts.push(` Splat Points: ${r.splatPoints}`);
444
+ if (r.calories !== undefined)
445
+ parts.push(` Calories: ${r.calories} kcal`);
446
+ if (r.avgHeartRate !== undefined)
447
+ parts.push(` Avg Heart Rate: ${r.avgHeartRate} bpm`);
448
+ if (r.maxHeartRate !== undefined)
449
+ parts.push(` Max Heart Rate: ${r.maxHeartRate} bpm`);
450
+ if (r.activeTime) parts.push(` Active Time: ${r.activeTime}`);
451
+ if (r.totalTime) parts.push(` Total Time: ${r.totalTime}`);
452
+ if (r.zones) {
453
+ const zones = r.zones;
454
+ const zoneStr = Object.entries(zones)
455
+ .filter(([, v]) => v !== undefined)
456
+ .map(([k, v]) => `${k}: ${v}min`)
457
+ .join(", ");
458
+ if (zoneStr) parts.push(` Heart Rate Zones: ${zoneStr}`);
459
+ }
460
+ return parts.join("\n");
461
+ })
462
+ .join("\n\n");
463
+
464
+ return {
465
+ content: [
466
+ {
467
+ type: "text",
468
+ text: `Workout history (${records.length} records):\n\n${historyList}`,
469
+ },
470
+ ],
471
+ };
472
+ }
473
+
474
+ case "orangetheory_membership_status": {
475
+ const info = await getMembershipStatus();
476
+
477
+ const parts = [
478
+ `Orangetheory Membership Status`,
479
+ `Status: ${info.status}`,
480
+ ];
481
+ if (info.memberName) parts.push(`Member: ${info.memberName}`);
482
+ if (info.memberId) parts.push(`Member ID: ${info.memberId}`);
483
+ if (info.membershipType)
484
+ parts.push(`Membership Type: ${info.membershipType}`);
485
+ if (info.homeStudio) parts.push(`Home Studio: ${info.homeStudio}`);
486
+ if (info.startDate) parts.push(`Member Since: ${info.startDate}`);
487
+ if (info.nextBillingDate)
488
+ parts.push(`Next Billing Date: ${info.nextBillingDate}`);
489
+ if (info.monthlyRate) parts.push(`Monthly Rate: ${info.monthlyRate}`);
490
+ if (info.classesRemaining !== undefined)
491
+ parts.push(`Classes Remaining: ${info.classesRemaining}`);
492
+ if (info.classesUsed !== undefined)
493
+ parts.push(`Classes Used This Period: ${info.classesUsed}`);
494
+
495
+ return {
496
+ content: [{ type: "text", text: parts.join("\n") }],
497
+ };
498
+ }
499
+
500
+ case "orangetheory_performance_summary": {
501
+ const period = (args?.period as string) || "month";
502
+
503
+ const summary = await getPerformanceSummary(period);
504
+
505
+ const parts = [
506
+ `Performance Summary (${period})`,
507
+ `Total Workouts: ${summary.totalWorkouts}`,
508
+ ];
509
+ if (summary.totalCalories !== undefined)
510
+ parts.push(`Total Calories Burned: ${summary.totalCalories.toLocaleString()} kcal`);
511
+ if (summary.totalSplatPoints !== undefined)
512
+ parts.push(`Total Splat Points: ${summary.totalSplatPoints.toLocaleString()}`);
513
+ if (summary.avgSplatPoints !== undefined)
514
+ parts.push(`Avg Splat Points/Workout: ${summary.avgSplatPoints}`);
515
+ if (summary.avgCalories !== undefined)
516
+ parts.push(`Avg Calories/Workout: ${summary.avgCalories} kcal`);
517
+ if (summary.avgHeartRate !== undefined)
518
+ parts.push(`Avg Heart Rate: ${summary.avgHeartRate} bpm`);
519
+ if (summary.currentStreak !== undefined)
520
+ parts.push(`Current Streak: ${summary.currentStreak} workouts`);
521
+ if (summary.longestStreak !== undefined)
522
+ parts.push(`Longest Streak: ${summary.longestStreak} workouts`);
523
+ if (summary.recentTrend) parts.push(`Trend: ${summary.recentTrend}`);
524
+
525
+ return {
526
+ content: [{ type: "text", text: parts.join("\n") }],
527
+ };
528
+ }
529
+
530
+ case "orangetheory_upcoming_classes": {
531
+ const classes = await getUpcomingClasses();
532
+
533
+ if (classes.length === 0) {
534
+ return {
535
+ content: [
536
+ {
537
+ type: "text",
538
+ text: "No upcoming classes booked. Use orangetheory_class_schedule to find and book classes.",
539
+ },
540
+ ],
541
+ };
542
+ }
543
+
544
+ const classList = classes
545
+ .map((c, i) => {
546
+ const parts = [
547
+ `${i + 1}. ${c.classType} — ${c.date} at ${c.time}`,
548
+ ` Studio: ${c.studioName}`,
549
+ ];
550
+ if (c.coach) parts.push(` Coach: ${c.coach}`);
551
+ if (c.bookingId) parts.push(` Booking ID: ${c.bookingId}`);
552
+ return parts.join("\n");
553
+ })
554
+ .join("\n\n");
555
+
556
+ return {
557
+ content: [
558
+ {
559
+ type: "text",
560
+ text: `Upcoming booked classes (${classes.length}):\n\n${classList}`,
561
+ },
562
+ ],
563
+ };
564
+ }
565
+
566
+ default:
567
+ throw new Error(`Unknown tool: ${name}`);
568
+ }
569
+ } catch (error) {
570
+ const message = error instanceof Error ? error.message : String(error);
571
+ return {
572
+ content: [
573
+ {
574
+ type: "text",
575
+ text: `Error: ${message}`,
576
+ },
577
+ ],
578
+ isError: true,
579
+ };
580
+ }
581
+ });
582
+
583
+ async function main() {
584
+ const transport = new StdioServerTransport();
585
+ await server.connect(transport);
586
+ console.error("Strider Orangetheory MCP server running");
587
+ console.error(`Config directory: ${getConfigDir()}`);
588
+ }
589
+
590
+ main().catch((error) => {
591
+ console.error("Failed to start server:", error);
592
+ process.exit(1);
593
+ });
594
+
595
+ process.on("SIGINT", async () => {
596
+ await closeBrowser();
597
+ process.exit(0);
598
+ });
599
+
600
+ process.on("SIGTERM", async () => {
601
+ await closeBrowser();
602
+ process.exit(0);
603
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src/**/*"]
14
+ }