@rundida/mcp-server 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/LICENSE +21 -0
- package/README.md +110 -0
- package/index.js +341 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 RunDida
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# @rundida/mcp-server
|
|
2
|
+
|
|
3
|
+
MCP server for [**RunDida**](https://rundida.com) — the world's most comprehensive running tools platform.
|
|
4
|
+
|
|
5
|
+
Give your AI assistant access to 86 running calculators, 29 marathon events, pace/time/distance calculations, race time predictions, and heart rate training zones.
|
|
6
|
+
|
|
7
|
+
[](https://rundida.com)
|
|
8
|
+
[](https://www.npmjs.com/package/@rundida/mcp-server)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### Claude Desktop
|
|
14
|
+
|
|
15
|
+
Add to your `claude_desktop_config.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"rundida": {
|
|
21
|
+
"command": "npx",
|
|
22
|
+
"args": ["-y", "@rundida/mcp-server"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Claude Code
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
claude mcp add rundida -- npx -y @rundida/mcp-server
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Cursor / Windsurf
|
|
35
|
+
|
|
36
|
+
Add to your MCP configuration:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"rundida": {
|
|
41
|
+
"command": "npx",
|
|
42
|
+
"args": ["-y", "@rundida/mcp-server"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Available Tools
|
|
48
|
+
|
|
49
|
+
| Tool | Type | Description |
|
|
50
|
+
|------|------|-------------|
|
|
51
|
+
| `list_tools` | Data | Browse all [86 running calculators](https://rundida.com) with descriptions |
|
|
52
|
+
| `get_tool` | Data | Get details, FAQs, and sources for a specific tool |
|
|
53
|
+
| `list_marathons` | Data | List [29 marathon events](https://rundida.com/marathon/) with dates and locations |
|
|
54
|
+
| `get_marathon` | Data | Get marathon details including weather and course profile |
|
|
55
|
+
| `calculate_pace` | Compute | Calculate pace, time, or distance (provide any 2 of 3) |
|
|
56
|
+
| `predict_race` | Compute | Predict race times using Riegel formula + VO2max estimation |
|
|
57
|
+
| `heart_rate_zones` | Compute | Calculate 5 HR training zones (Karvonen method) |
|
|
58
|
+
| `marathon_countdown` | Compute | Get countdown to a specific marathon event |
|
|
59
|
+
|
|
60
|
+
**Data tools** fetch from the [RunDida API](https://rundida.com/api/) with 30-minute caching. **Compute tools** run locally with zero latency — no API calls needed.
|
|
61
|
+
|
|
62
|
+
## Example Usage
|
|
63
|
+
|
|
64
|
+
Ask your AI assistant:
|
|
65
|
+
|
|
66
|
+
- "What's my marathon pace if I want to finish in 3:30?"
|
|
67
|
+
- "Predict my marathon time based on my 10K of 45 minutes"
|
|
68
|
+
- "What are my heart rate zones? I'm 32 with a resting HR of 52"
|
|
69
|
+
- "How many days until the Tokyo Marathon?"
|
|
70
|
+
- "Show me all running calculators related to nutrition"
|
|
71
|
+
|
|
72
|
+
## About RunDida
|
|
73
|
+
|
|
74
|
+
[**RunDida**](https://rundida.com) (跑滴答) is a free running tools platform for runners of all levels:
|
|
75
|
+
|
|
76
|
+
- **[86 Interactive Calculators](https://rundida.com)** — Pace, heart rate zones, VO2max, race prediction, nutrition, gear sizing, weather impact, and more
|
|
77
|
+
- **[29 Marathon Countdowns](https://rundida.com/marathon/)** — Live timers with race-day weather forecasts, course profiles, and training tools
|
|
78
|
+
- **[Free JSON API](https://rundida.com/api/)** — No authentication required, CORS enabled, [OpenAPI 3.0 documented](https://rundida.com/api/openapi.json)
|
|
79
|
+
- **Multi-language** — English, Chinese (中文)
|
|
80
|
+
- **Embeddable Widgets** — One-line iframe embed for any calculator
|
|
81
|
+
|
|
82
|
+
All tools are free, no account required. Try them at [rundida.com](https://rundida.com).
|
|
83
|
+
|
|
84
|
+
## How It Works
|
|
85
|
+
|
|
86
|
+
The computation tools use established running science formulas:
|
|
87
|
+
|
|
88
|
+
| Formula | Used In | Description |
|
|
89
|
+
|---------|---------|-------------|
|
|
90
|
+
| **Riegel formula** | `predict_race` | Race time prediction across distances |
|
|
91
|
+
| **Jack Daniels method** | `predict_race` | VO2max estimation from race performance |
|
|
92
|
+
| **Karvonen method** | `heart_rate_zones` | Heart rate training zones from age and resting HR |
|
|
93
|
+
|
|
94
|
+
## Requirements
|
|
95
|
+
|
|
96
|
+
- Node.js >= 18
|
|
97
|
+
- Internet connection (data tools fetch from [rundida.com](https://rundida.com))
|
|
98
|
+
|
|
99
|
+
## Links
|
|
100
|
+
|
|
101
|
+
| Resource | URL |
|
|
102
|
+
|----------|-----|
|
|
103
|
+
| **RunDida Website** | [rundida.com](https://rundida.com) |
|
|
104
|
+
| **API Documentation** | [rundida.com/api](https://rundida.com/api/) |
|
|
105
|
+
| **OpenAPI Spec** | [rundida.com/api/openapi.json](https://rundida.com/api/openapi.json) |
|
|
106
|
+
| **NPM Package** | [@rundida/mcp-server](https://www.npmjs.com/package/@rundida/mcp-server) |
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
const BASE_URL = 'https://rundida.com';
|
|
8
|
+
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
|
9
|
+
|
|
10
|
+
// Simple in-memory cache
|
|
11
|
+
const cache = new Map();
|
|
12
|
+
|
|
13
|
+
async function fetchJSON(url) {
|
|
14
|
+
const cached = cache.get(url);
|
|
15
|
+
if (cached && Date.now() - cached.ts < CACHE_TTL) return cached.data;
|
|
16
|
+
|
|
17
|
+
const res = await fetch(url);
|
|
18
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
cache.set(url, { data, ts: Date.now() });
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---- Pace calculation helpers ----
|
|
25
|
+
|
|
26
|
+
function paceToSeconds(pace) {
|
|
27
|
+
const [m, s] = pace.split(':').map(Number);
|
|
28
|
+
return m * 60 + (s || 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function secondsToPace(secs) {
|
|
32
|
+
const m = Math.floor(secs / 60);
|
|
33
|
+
const s = Math.round(secs % 60);
|
|
34
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function timeToSeconds(time) {
|
|
38
|
+
const parts = time.split(':').map(Number);
|
|
39
|
+
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
40
|
+
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
41
|
+
return parts[0];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function secondsToTime(secs) {
|
|
45
|
+
const h = Math.floor(secs / 3600);
|
|
46
|
+
const m = Math.floor((secs % 3600) / 60);
|
|
47
|
+
const s = Math.round(secs % 60);
|
|
48
|
+
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
49
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DISTANCES = {
|
|
53
|
+
'5k': 5,
|
|
54
|
+
'10k': 10,
|
|
55
|
+
'half': 21.0975,
|
|
56
|
+
'half-marathon': 21.0975,
|
|
57
|
+
'marathon': 42.195,
|
|
58
|
+
'50k': 50,
|
|
59
|
+
'100k': 100,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function resolveDistance(input) {
|
|
63
|
+
const lower = String(input).toLowerCase().trim();
|
|
64
|
+
if (DISTANCES[lower]) return DISTANCES[lower];
|
|
65
|
+
const num = parseFloat(lower);
|
|
66
|
+
if (!isNaN(num) && num > 0) return num;
|
|
67
|
+
throw new Error(`Unknown distance: ${input}. Use 5k, 10k, half, marathon, or a number in km.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- Riegel race prediction ----
|
|
71
|
+
|
|
72
|
+
function predictTime(knownDist, knownTimeSecs, targetDist, exponent = 1.06) {
|
|
73
|
+
return knownTimeSecs * Math.pow(targetDist / knownDist, exponent);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- VO2max estimation (Jack Daniels) ----
|
|
77
|
+
|
|
78
|
+
function estimateVO2max(distKm, timeSecs) {
|
|
79
|
+
const timeMin = timeSecs / 60;
|
|
80
|
+
const velocity = distKm * 1000 / timeMin; // meters per minute
|
|
81
|
+
const pctVO2max = 0.8 + 0.1894393 * Math.exp(-0.012778 * timeMin)
|
|
82
|
+
+ 0.2989558 * Math.exp(-0.1932605 * timeMin);
|
|
83
|
+
const vo2 = -4.60 + 0.182258 * velocity + 0.000104 * velocity * velocity;
|
|
84
|
+
return vo2 / pctVO2max;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- Heart Rate Zones (Karvonen) ----
|
|
88
|
+
|
|
89
|
+
function heartRateZones(age, restingHR, maxHR) {
|
|
90
|
+
const mhr = maxHR || Math.round(207 - 0.7 * age);
|
|
91
|
+
const hrr = mhr - restingHR;
|
|
92
|
+
const zones = [
|
|
93
|
+
{ zone: 1, name: 'Recovery', low: 0.50, high: 0.60 },
|
|
94
|
+
{ zone: 2, name: 'Aerobic', low: 0.60, high: 0.70 },
|
|
95
|
+
{ zone: 3, name: 'Tempo', low: 0.70, high: 0.80 },
|
|
96
|
+
{ zone: 4, name: 'Threshold', low: 0.80, high: 0.90 },
|
|
97
|
+
{ zone: 5, name: 'VO2max', low: 0.90, high: 1.00 },
|
|
98
|
+
];
|
|
99
|
+
return {
|
|
100
|
+
maxHR: mhr,
|
|
101
|
+
restingHR,
|
|
102
|
+
zones: zones.map(z => ({
|
|
103
|
+
...z,
|
|
104
|
+
bpmLow: Math.round(restingHR + hrr * z.low),
|
|
105
|
+
bpmHigh: Math.round(restingHR + hrr * z.high),
|
|
106
|
+
})),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- Create Server ----
|
|
111
|
+
|
|
112
|
+
const server = new McpServer({
|
|
113
|
+
name: 'RunDida',
|
|
114
|
+
version: '1.0.0',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Tool: list_tools
|
|
118
|
+
server.tool(
|
|
119
|
+
'list_tools',
|
|
120
|
+
'List all available running calculators and tools on RunDida',
|
|
121
|
+
{},
|
|
122
|
+
async () => {
|
|
123
|
+
const data = await fetchJSON(`${BASE_URL}/api/tools.json`);
|
|
124
|
+
const list = data.tools.map(t => `- ${t.title} (${t.slug}): ${t.description}`).join('\n');
|
|
125
|
+
return {
|
|
126
|
+
content: [{
|
|
127
|
+
type: 'text',
|
|
128
|
+
text: `RunDida has ${data.meta.total} running tools:\n\n${list}\n\nUse get_tool with a slug to see full details.`,
|
|
129
|
+
}],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Tool: get_tool
|
|
135
|
+
server.tool(
|
|
136
|
+
'get_tool',
|
|
137
|
+
'Get detailed information about a specific running tool including FAQs and related tools',
|
|
138
|
+
{ slug: z.string().describe('Tool slug, e.g. "pace-calculator", "heart-rate-zones"') },
|
|
139
|
+
async ({ slug }) => {
|
|
140
|
+
const data = await fetchJSON(`${BASE_URL}/api/tools/${slug}.json`);
|
|
141
|
+
const t = data.tool;
|
|
142
|
+
let text = `## ${t.title}\n\n${t.description}\n\nURL: ${t.url}\nCategory: ${t.category}\n`;
|
|
143
|
+
if (t.relatedTools.length) text += `\nRelated tools: ${t.relatedTools.join(', ')}`;
|
|
144
|
+
if (t.faqs.length) {
|
|
145
|
+
text += '\n\n### FAQs\n';
|
|
146
|
+
t.faqs.forEach(f => { text += `\n**Q: ${f.question}**\nA: ${f.answer}\n`; });
|
|
147
|
+
}
|
|
148
|
+
return { content: [{ type: 'text', text }] };
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Tool: list_marathons
|
|
153
|
+
server.tool(
|
|
154
|
+
'list_marathons',
|
|
155
|
+
'List all marathon events tracked by RunDida with dates and locations',
|
|
156
|
+
{},
|
|
157
|
+
async () => {
|
|
158
|
+
const data = await fetchJSON(`${BASE_URL}/api/marathons.json`);
|
|
159
|
+
const list = data.active
|
|
160
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
161
|
+
.map(m => `- ${m.name.en} — ${m.date} — ${m.city}`)
|
|
162
|
+
.join('\n');
|
|
163
|
+
return {
|
|
164
|
+
content: [{
|
|
165
|
+
type: 'text',
|
|
166
|
+
text: `RunDida tracks ${data.meta.totalActive} upcoming marathons:\n\n${list}\n\nUse get_marathon with an ID for details.`,
|
|
167
|
+
}],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Tool: get_marathon
|
|
173
|
+
server.tool(
|
|
174
|
+
'get_marathon',
|
|
175
|
+
'Get detailed information about a specific marathon including countdown and Schema.org data',
|
|
176
|
+
{ id: z.string().describe('Marathon ID, e.g. "tokyo2026", "boston2026", "berlin2026"') },
|
|
177
|
+
async ({ id }) => {
|
|
178
|
+
const data = await fetchJSON(`${BASE_URL}/api/marathons/${id}.json`);
|
|
179
|
+
const m = data.marathon;
|
|
180
|
+
const raceDate = new Date(m.date);
|
|
181
|
+
const now = new Date();
|
|
182
|
+
const daysUntil = Math.ceil((raceDate - now) / (1000 * 60 * 60 * 24));
|
|
183
|
+
let text = `## ${m.name.en}\n\nDate: ${m.date}\nCity: ${m.city}\n`;
|
|
184
|
+
if (m.country) text += `Country/Region: ${m.country}\n`;
|
|
185
|
+
text += `Timezone: ${m.timezone}\n`;
|
|
186
|
+
text += `Days until race: ${daysUntil > 0 ? daysUntil : 'Race has passed'}\n`;
|
|
187
|
+
if (m.weather) {
|
|
188
|
+
text += `\n### Race Day Weather\nTemperature: ${m.weather.avgTempC}°C / ${m.weather.avgTempF}°F\n`;
|
|
189
|
+
text += `Humidity: ${m.weather.humidity}% | Wind: ${m.weather.windKmh} km/h | Rain: ${m.weather.precipPct}%\n`;
|
|
190
|
+
text += `Conditions: ${m.weather.conditions}\n`;
|
|
191
|
+
}
|
|
192
|
+
if (m.course) {
|
|
193
|
+
text += `\n### Course Profile\nType: ${m.course.type} | Elevation: ${m.course.elevationGain}m | Terrain: ${m.course.terrain}\n`;
|
|
194
|
+
text += `${m.course.profile}\n`;
|
|
195
|
+
}
|
|
196
|
+
text += `\nPage: ${m.links.page}\nCountdown: ${m.links.countdown}\n`;
|
|
197
|
+
return { content: [{ type: 'text', text }] };
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Tool: calculate_pace
|
|
202
|
+
server.tool(
|
|
203
|
+
'calculate_pace',
|
|
204
|
+
'Calculate running pace, finish time, or distance. Provide any two of: distance, time, pace.',
|
|
205
|
+
{
|
|
206
|
+
distance: z.string().optional().describe('Distance: "5k", "10k", "half", "marathon", or km value like "15"'),
|
|
207
|
+
time: z.string().optional().describe('Finish time in H:MM:SS or MM:SS format, e.g. "3:30:00" or "25:00"'),
|
|
208
|
+
pace: z.string().optional().describe('Pace per km in M:SS format, e.g. "5:00"'),
|
|
209
|
+
},
|
|
210
|
+
async ({ distance, time, pace }) => {
|
|
211
|
+
const given = [distance, time, pace].filter(Boolean).length;
|
|
212
|
+
if (given < 2) throw new Error('Provide at least 2 of: distance, time, pace');
|
|
213
|
+
|
|
214
|
+
let result = {};
|
|
215
|
+
|
|
216
|
+
if (distance && time) {
|
|
217
|
+
const distKm = resolveDistance(distance);
|
|
218
|
+
const timeSecs = timeToSeconds(time);
|
|
219
|
+
const paceSecs = timeSecs / distKm;
|
|
220
|
+
result = {
|
|
221
|
+
distance: `${distKm} km`,
|
|
222
|
+
time: secondsToTime(timeSecs),
|
|
223
|
+
pace: `${secondsToPace(paceSecs)}/km`,
|
|
224
|
+
speed: `${(distKm / (timeSecs / 3600)).toFixed(2)} km/h`,
|
|
225
|
+
};
|
|
226
|
+
} else if (distance && pace) {
|
|
227
|
+
const distKm = resolveDistance(distance);
|
|
228
|
+
const paceSecs = paceToSeconds(pace);
|
|
229
|
+
const timeSecs = distKm * paceSecs;
|
|
230
|
+
result = {
|
|
231
|
+
distance: `${distKm} km`,
|
|
232
|
+
time: secondsToTime(timeSecs),
|
|
233
|
+
pace: `${secondsToPace(paceSecs)}/km`,
|
|
234
|
+
speed: `${(distKm / (timeSecs / 3600)).toFixed(2)} km/h`,
|
|
235
|
+
};
|
|
236
|
+
} else if (time && pace) {
|
|
237
|
+
const timeSecs = timeToSeconds(time);
|
|
238
|
+
const paceSecs = paceToSeconds(pace);
|
|
239
|
+
const distKm = timeSecs / paceSecs;
|
|
240
|
+
result = {
|
|
241
|
+
distance: `${distKm.toFixed(2)} km`,
|
|
242
|
+
time: secondsToTime(timeSecs),
|
|
243
|
+
pace: `${secondsToPace(paceSecs)}/km`,
|
|
244
|
+
speed: `${(distKm / (timeSecs / 3600)).toFixed(2)} km/h`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
content: [{
|
|
250
|
+
type: 'text',
|
|
251
|
+
text: Object.entries(result).map(([k, v]) => `${k}: ${v}`).join('\n'),
|
|
252
|
+
}],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Tool: predict_race
|
|
258
|
+
server.tool(
|
|
259
|
+
'predict_race',
|
|
260
|
+
'Predict race finish times using the Riegel formula based on a known race result',
|
|
261
|
+
{
|
|
262
|
+
known_distance: z.string().describe('Known race distance: "5k", "10k", "half", "marathon", or km value'),
|
|
263
|
+
known_time: z.string().describe('Known race time in H:MM:SS or MM:SS format'),
|
|
264
|
+
target_distance: z.string().optional().describe('Target distance to predict (defaults to showing all standard distances)'),
|
|
265
|
+
},
|
|
266
|
+
async ({ known_distance, known_time, target_distance }) => {
|
|
267
|
+
const distKm = resolveDistance(known_distance);
|
|
268
|
+
const timeSecs = timeToSeconds(known_time);
|
|
269
|
+
const vo2max = estimateVO2max(distKm, timeSecs);
|
|
270
|
+
|
|
271
|
+
const targets = target_distance
|
|
272
|
+
? [{ name: target_distance, km: resolveDistance(target_distance) }]
|
|
273
|
+
: [
|
|
274
|
+
{ name: '5K', km: 5 },
|
|
275
|
+
{ name: '10K', km: 10 },
|
|
276
|
+
{ name: 'Half Marathon', km: 21.0975 },
|
|
277
|
+
{ name: 'Marathon', km: 42.195 },
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
let text = `Based on ${known_distance} in ${known_time}:\n\nEstimated VO2max: ${vo2max.toFixed(1)} ml/kg/min\n\nPredictions:\n`;
|
|
281
|
+
targets.forEach(t => {
|
|
282
|
+
const predicted = predictTime(distKm, timeSecs, t.km);
|
|
283
|
+
const pace = predicted / t.km;
|
|
284
|
+
text += `- ${t.name}: ${secondsToTime(predicted)} (pace: ${secondsToPace(pace)}/km)\n`;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
text += `\nNote: Predictions assume equivalent training for the target distance. Use RunDida's Race Time Predictor for more details: ${BASE_URL}/tools/race-time-predictor/`;
|
|
288
|
+
return { content: [{ type: 'text', text }] };
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Tool: heart_rate_zones
|
|
293
|
+
server.tool(
|
|
294
|
+
'heart_rate_zones',
|
|
295
|
+
'Calculate heart rate training zones using the Karvonen method',
|
|
296
|
+
{
|
|
297
|
+
age: z.number().min(10).max(99).describe('Your age in years'),
|
|
298
|
+
resting_hr: z.number().min(30).max(120).optional().describe('Resting heart rate in bpm (default: 60)'),
|
|
299
|
+
max_hr: z.number().min(100).max(220).optional().describe('Known max heart rate in bpm (auto-calculated if omitted)'),
|
|
300
|
+
},
|
|
301
|
+
async ({ age, resting_hr, max_hr }) => {
|
|
302
|
+
const result = heartRateZones(age, resting_hr || 60, max_hr);
|
|
303
|
+
let text = `Heart Rate Training Zones (Karvonen Method)\n\nAge: ${age} | Resting HR: ${result.restingHR} bpm | Max HR: ${result.maxHR} bpm\n\n`;
|
|
304
|
+
result.zones.forEach(z => {
|
|
305
|
+
text += `Zone ${z.zone} (${z.name}): ${z.bpmLow}-${z.bpmHigh} bpm\n`;
|
|
306
|
+
});
|
|
307
|
+
text += `\nFor more options: ${BASE_URL}/tools/heart-rate-zones/`;
|
|
308
|
+
return { content: [{ type: 'text', text }] };
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Tool: marathon_countdown
|
|
313
|
+
server.tool(
|
|
314
|
+
'marathon_countdown',
|
|
315
|
+
'Get a countdown to a specific marathon event',
|
|
316
|
+
{ id: z.string().describe('Marathon ID, e.g. "tokyo2026", "boston2026"') },
|
|
317
|
+
async ({ id }) => {
|
|
318
|
+
const data = await fetchJSON(`${BASE_URL}/api/marathons/${id}.json`);
|
|
319
|
+
const m = data.marathon;
|
|
320
|
+
const raceDate = new Date(m.date + 'T00:00:00');
|
|
321
|
+
const now = new Date();
|
|
322
|
+
const diff = raceDate - now;
|
|
323
|
+
|
|
324
|
+
if (diff <= 0) {
|
|
325
|
+
return { content: [{ type: 'text', text: `${m.name.en} has already taken place on ${m.date}.` }] };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
329
|
+
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
330
|
+
|
|
331
|
+
let text = `${m.name.en} Countdown\n\n`;
|
|
332
|
+
text += `${days} days, ${hours} hours until race day\n`;
|
|
333
|
+
text += `Date: ${m.date}\nCity: ${m.city}\n`;
|
|
334
|
+
text += `\nLive countdown: ${m.links.countdown}`;
|
|
335
|
+
return { content: [{ type: 'text', text }] };
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// Start server
|
|
340
|
+
const transport = new StdioServerTransport();
|
|
341
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rundida/mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for RunDida running tools — marathon data, pace calculations, heart rate zones, and race predictions for AI agents",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"rundida-mcp": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node index.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"running",
|
|
25
|
+
"marathon",
|
|
26
|
+
"pace-calculator",
|
|
27
|
+
"heart-rate-zones",
|
|
28
|
+
"race-prediction",
|
|
29
|
+
"vo2max",
|
|
30
|
+
"ai-tools",
|
|
31
|
+
"claude",
|
|
32
|
+
"llm",
|
|
33
|
+
"fitness"
|
|
34
|
+
],
|
|
35
|
+
"author": "RunDida (https://rundida.com)",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"homepage": "https://rundida.com",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/XWeaponX7/rundida-mcp"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/XWeaponX7/rundida-mcp/issues"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
47
|
+
"zod": "^3.25 || ^4.0"
|
|
48
|
+
}
|
|
49
|
+
}
|