ourpr-mcp-server 0.2.0 → 0.4.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 +169 -19
- package/build/api.js +66 -102
- package/build/index.js +627 -372
- package/build/safe.js +11 -63
- package/build/types.js +1 -7
- package/icon.png +0 -0
- package/manifest.json +11 -6
- package/npm-shrinkwrap.json +1903 -0
- package/package.json +8 -4
- package/server.json +3 -3
package/build/index.js
CHANGED
|
@@ -1,61 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/
|
|
3
|
-
import {
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
3
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { ApiError, day, duration, feet, get, miles, pathId, stamp } from "./api.js";
|
|
6
|
-
import { cell, fenced } from "./safe.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* WHY THIS EXISTS. Every authenticated read in ourpr is gated by a session
|
|
11
|
-
* that lives about an hour, so nothing outside a browser could hold one.
|
|
12
|
-
* Personal access tokens changed that, and this is what they were for: your
|
|
13
|
-
* training data, in whatever agent you already use, with a credential you
|
|
14
|
-
* issued to yourself and can revoke.
|
|
15
|
-
*
|
|
16
|
-
* READ ONLY, and not by omission. A token's default scope is read, and every
|
|
17
|
-
* route behind these tools is a GET. Nothing here can change a run, plan a
|
|
18
|
-
* week, or mint another token.
|
|
19
|
-
*
|
|
20
|
-
* THE TOOL SET WAS CHOSEN BY AN EVALUATION, not by listing the API. Ten
|
|
21
|
-
* verified questions were written first (`backend/scripts/mcp_eval_oracle.py`)
|
|
22
|
-
* and they said what matters: nine of ten need the activity window, three need
|
|
23
|
-
* one run in full, two need a second source. So the window is the tool that
|
|
24
|
-
* has to be excellent, and a `training_summary` tool that seemed obvious when
|
|
25
|
-
* guessing turned out to answer nothing and was not built.
|
|
26
|
-
*
|
|
27
|
-
* CONTEXT IS THE REAL CONSTRAINT. A window can hold 800 runs, and handing an
|
|
28
|
-
* agent 800 full activity objects destroys the context it needs to think. So
|
|
29
|
-
* `ourpr_list_runs` returns a COMPACT table and one run in full is a separate
|
|
30
|
-
* tool. That is the same lesson the backend learned when `laps` turned out to
|
|
31
|
-
* be 36% of a payload nothing rendered — an agent's context is more expensive
|
|
32
|
-
* than egress, not less.
|
|
33
|
-
*/
|
|
34
|
-
/** The watch or app that recorded a run, as one readable name.
|
|
35
|
-
*
|
|
36
|
-
* `device` is an OBJECT and its keys vary — measured across all 7,911
|
|
37
|
-
* production rows: {make,model,raw} 2,064, {app,raw} 718, {app,make,model}
|
|
38
|
-
* 684, {make,raw} 373, {model,raw} 81, and 3,986 null. So prefer the human
|
|
39
|
-
* pair, fall back to whichever single name exists, and only then to `raw`,
|
|
40
|
-
* which is a slug like "garmin/fr255" and is the last resort rather than the
|
|
41
|
-
* answer. */
|
|
5
|
+
import { ApiError, METERS_PER_MILE, addDays, clock, day, duration, feet, get, localDay, miles, pathId, post, stamp, weekOf, } from "./api.js";
|
|
6
|
+
import { cell, fenced, note } from "./safe.js";
|
|
7
|
+
// Ten reads and one write. The write needs a token made with the write
|
|
8
|
+
// scope and ourpr create behind it; every other tool is a GET.
|
|
9
|
+
// device keys vary: the human pair first, then a single name, then the raw slug.
|
|
42
10
|
function deviceLabel(d) {
|
|
43
11
|
if (!d)
|
|
44
12
|
return null;
|
|
45
13
|
const named = [d.make, d.model].filter(Boolean).join(" ").trim();
|
|
46
14
|
return cell(named || d.app || d.raw, 40) || null;
|
|
47
15
|
}
|
|
48
|
-
|
|
49
|
-
// A window can hold years. This bounds ONE answer, and when it bites the
|
|
50
|
-
// answer says so — a silent truncation reads as "that is all there was",
|
|
51
|
-
// which is the difference between a short answer and a wrong one.
|
|
16
|
+
// Bounds one answer; when it bites, the answer says so.
|
|
52
17
|
const DEFAULT_LIMIT = 100;
|
|
53
18
|
const MAX_LIMIT = 500;
|
|
54
19
|
const ok = (text, structured) => ({
|
|
55
20
|
content: [{ type: "text", text }],
|
|
56
21
|
structuredContent: structured,
|
|
57
22
|
});
|
|
58
|
-
|
|
23
|
+
// An error reaches the agent as an error result with copy it can act on.
|
|
59
24
|
const fail = (err) => ({
|
|
60
25
|
content: [
|
|
61
26
|
{
|
|
@@ -65,12 +30,10 @@ const fail = (err) => ({
|
|
|
65
30
|
],
|
|
66
31
|
isError: true,
|
|
67
32
|
});
|
|
68
|
-
/** One activity, flattened to what an agent reasons about. */
|
|
69
33
|
function summarise(a) {
|
|
70
34
|
return {
|
|
71
35
|
id: String(a.id),
|
|
72
36
|
date: day(a.date),
|
|
73
|
-
// RUNNER-AUTHORED TEXT. Cleaned at the one boundary it crosses.
|
|
74
37
|
name: cell(a.name),
|
|
75
38
|
type: cell(a.activity_type, 24),
|
|
76
39
|
miles: miles(a.distance_meters),
|
|
@@ -80,8 +43,7 @@ function summarise(a) {
|
|
|
80
43
|
avg_hr: a.avg_heartrate ?? null,
|
|
81
44
|
};
|
|
82
45
|
}
|
|
83
|
-
|
|
84
|
-
* it costs a fraction of the tokens the same rows cost as objects. */
|
|
46
|
+
// A markdown table costs fewer tokens than the same rows as objects.
|
|
85
47
|
function table(rows) {
|
|
86
48
|
const head = "| date | name | type | mi | pace | time | ft | hr |\n" +
|
|
87
49
|
"|---|---|---|---|---|---|---|---|";
|
|
@@ -92,331 +54,624 @@ function table(rows) {
|
|
|
92
54
|
.join("\n");
|
|
93
55
|
return `${head}\n${body}`;
|
|
94
56
|
}
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
`${run.moving_time ?? "—"} · ${run.elevation_ft ?? "—"} ft climb`,
|
|
189
|
-
run.avg_hr ? `Heart rate ${run.avg_hr} avg, ${run.max_hr ?? "—"} max` : "",
|
|
190
|
-
splits.length
|
|
191
|
-
? `\n| mile | pace | ± ft |\n|---|---|---|\n` +
|
|
192
|
-
splits.map((s) => `| ${s.mile} | ${s.pace ?? "—"} | ${s.elevation_change_ft ?? "—"} |`).join("\n")
|
|
193
|
-
: "\nNo mile splits recorded for this run.",
|
|
194
|
-
].filter(Boolean);
|
|
195
|
-
return ok(fenced(lines.join("\n")), { run, splits });
|
|
196
|
-
}
|
|
197
|
-
catch (err) {
|
|
198
|
-
return fail(err);
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
// ─── The profile ────────────────────────────────────────────────────────────
|
|
202
|
-
server.registerTool("ourpr_run_stream", {
|
|
203
|
-
title: "Get a run's elevation and sensor profile",
|
|
204
|
-
description: "A run's profile on a 10 m grid — elevation, heart rate, power, " +
|
|
205
|
-
"cadence — as min, average, max and coverage per channel, not every " +
|
|
206
|
-
"sample. No profile is a normal answer for an indoor run.",
|
|
207
|
-
inputSchema: {
|
|
208
|
-
activity_id: z.string().describe("Run id from ourpr_list_runs."),
|
|
209
|
-
},
|
|
210
|
-
outputSchema: {
|
|
211
|
-
grid_m: z.number(),
|
|
212
|
-
total_miles: z.number(),
|
|
213
|
-
samples: z.number(),
|
|
214
|
-
channels: z.record(z.string(), z.unknown()),
|
|
215
|
-
},
|
|
216
|
-
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
217
|
-
}, async ({ activity_id }) => {
|
|
218
|
-
try {
|
|
219
|
-
const s = await get(`/users/me/activities/${pathId(activity_id)}/stream`);
|
|
220
|
-
// SUMMARY AND NOT THE SAMPLES. A 10 mile run is ~1,600 points per
|
|
221
|
-
// channel and five channels. Handing an agent 8,000 numbers spends its
|
|
222
|
-
// context on data it will only reduce anyway.
|
|
223
|
-
const stats = (values, scale = 1) => {
|
|
224
|
-
const nums = (values ?? []).filter((v) => v != null);
|
|
225
|
-
if (!nums.length)
|
|
226
|
-
return null;
|
|
227
|
-
const sum = nums.reduce((a, b) => a + b, 0);
|
|
228
|
-
return {
|
|
229
|
-
min: Math.round((Math.min(...nums) * scale) * 10) / 10,
|
|
230
|
-
max: Math.round((Math.max(...nums) * scale) * 10) / 10,
|
|
231
|
-
avg: Math.round(((sum / nums.length) * scale) * 10) / 10,
|
|
232
|
-
coverage: Math.round((nums.length / (values?.length || 1)) * 100),
|
|
57
|
+
// One instance for each connection. serveStdio calls this once the opening
|
|
58
|
+
// message says which protocol era the client speaks.
|
|
59
|
+
function buildServer() {
|
|
60
|
+
const server = new McpServer({ name: "ourpr-mcp-server", version: "0.4.0" });
|
|
61
|
+
// ─── The window ─────────────────────────────────────────────────────────────
|
|
62
|
+
server.registerTool("ourpr_list_runs", {
|
|
63
|
+
title: "List runs in a date range",
|
|
64
|
+
description: "Training history between two dates, newest first: date, name, type, " +
|
|
65
|
+
"miles, pace, time, elevation, average heart rate. Start here for " +
|
|
66
|
+
"totals, streaks, trends, or finding a run. For one run's splits, use " +
|
|
67
|
+
"ourpr_get_run with an id from here.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
start_date: z
|
|
70
|
+
.string()
|
|
71
|
+
.describe("First day to include, YYYY-MM-DD. Example: 2026-01-01"),
|
|
72
|
+
end_date: z
|
|
73
|
+
.string()
|
|
74
|
+
.describe("Last day to include, YYYY-MM-DD. Example: 2026-06-30"),
|
|
75
|
+
include_non_runs: z
|
|
76
|
+
.boolean()
|
|
77
|
+
.default(false)
|
|
78
|
+
.describe("Include rides, gym and other types. Runs only by default."),
|
|
79
|
+
limit: z
|
|
80
|
+
.number()
|
|
81
|
+
.int()
|
|
82
|
+
.min(1)
|
|
83
|
+
.max(MAX_LIMIT)
|
|
84
|
+
.default(DEFAULT_LIMIT)
|
|
85
|
+
.describe("Most rows to return. The answer says what it left out."),
|
|
86
|
+
},
|
|
87
|
+
outputSchema: {
|
|
88
|
+
runs: z.array(z.record(z.string(), z.unknown())),
|
|
89
|
+
returned: z.number(),
|
|
90
|
+
total_in_window: z.number(),
|
|
91
|
+
truncated: z.boolean(),
|
|
92
|
+
},
|
|
93
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
94
|
+
}, async ({ start_date, end_date, include_non_runs, limit }) => {
|
|
95
|
+
try {
|
|
96
|
+
const data = await get(`/users/me/activities?after=${stamp(start_date, "start")}` +
|
|
97
|
+
`&before=${stamp(end_date, "end")}` +
|
|
98
|
+
`&include_non_runs=${include_non_runs}`);
|
|
99
|
+
const all = data.activities.map(summarise);
|
|
100
|
+
const shown = all.slice(0, limit);
|
|
101
|
+
const truncated = all.length > shown.length;
|
|
102
|
+
const header = `${all.length} ${include_non_runs ? "activities" : "runs"} between ` +
|
|
103
|
+
`${cell(start_date, 10)} and ${cell(end_date, 10)}.` +
|
|
104
|
+
(truncated
|
|
105
|
+
? ` Showing the ${shown.length} newest — ${all.length - shown.length} ` +
|
|
106
|
+
"older ones are not listed. Narrow the dates or raise `limit` to see them."
|
|
107
|
+
: "");
|
|
108
|
+
return ok(`${header}\n\n` + fenced(shown.length ? table(shown) : "No activities in that window."), {
|
|
109
|
+
runs: shown,
|
|
110
|
+
returned: shown.length,
|
|
111
|
+
total_in_window: all.length,
|
|
112
|
+
truncated,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return fail(err);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
// ─── One run ────────────────────────────────────────────────────────────────
|
|
120
|
+
server.registerTool("ourpr_get_run", {
|
|
121
|
+
title: "Get one run in full",
|
|
122
|
+
description: "One activity in full: mile splits, heart rate, cadence, calories, " +
|
|
123
|
+
"device. Id comes from ourpr_list_runs. For the profile along the " +
|
|
124
|
+
"route, use ourpr_run_stream.",
|
|
125
|
+
inputSchema: {
|
|
126
|
+
activity_id: z
|
|
127
|
+
.string()
|
|
128
|
+
.describe("Run id from ourpr_list_runs."),
|
|
129
|
+
},
|
|
130
|
+
outputSchema: {
|
|
131
|
+
run: z.record(z.string(), z.unknown()),
|
|
132
|
+
splits: z.array(z.record(z.string(), z.unknown())),
|
|
133
|
+
},
|
|
134
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
135
|
+
}, async ({ activity_id }) => {
|
|
136
|
+
try {
|
|
137
|
+
const a = await get(`/users/me/activities/${pathId(activity_id)}`);
|
|
138
|
+
const splits = (a.splits ?? []).map((s) => ({
|
|
139
|
+
mile: s.split_number,
|
|
140
|
+
pace: s.pace_per_mile ?? null,
|
|
141
|
+
elevation_change_ft: feet(s.elevation_difference_meters),
|
|
142
|
+
}));
|
|
143
|
+
const run = {
|
|
144
|
+
...summarise(a),
|
|
145
|
+
max_hr: a.max_heartrate ?? null,
|
|
146
|
+
avg_cadence_spm: a.avg_cadence ?? null,
|
|
147
|
+
calories: a.calories ?? null,
|
|
148
|
+
device: deviceLabel(a.device),
|
|
149
|
+
has_route: Boolean(a.summary_polyline),
|
|
233
150
|
};
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
.filter(
|
|
244
|
-
.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}, async ({ activity_id }) => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
miles: mi,
|
|
282
|
-
time: duration(secs),
|
|
283
|
-
pace: perMile
|
|
284
|
-
? `${Math.floor(perMile / 60)}:${String(Math.round(perMile % 60)).padStart(2, "0")}`
|
|
285
|
-
: null,
|
|
151
|
+
const lines = [
|
|
152
|
+
`**${run.name || "Untitled"}** — ${run.date}`,
|
|
153
|
+
`${run.miles ?? "—"} mi · ${run.pace_per_mile ?? "—"}/mi · ` +
|
|
154
|
+
`${run.moving_time ?? "—"} · ${run.elevation_ft ?? "—"} ft climb`,
|
|
155
|
+
run.avg_hr ? `Heart rate ${run.avg_hr} avg, ${run.max_hr ?? "—"} max` : "",
|
|
156
|
+
splits.length
|
|
157
|
+
? `\n| mile | pace | ± ft |\n|---|---|---|\n` +
|
|
158
|
+
splits.map((s) => `| ${s.mile} | ${s.pace ?? "—"} | ${s.elevation_change_ft ?? "—"} |`).join("\n")
|
|
159
|
+
: "\nNo mile splits recorded for this run.",
|
|
160
|
+
].filter(Boolean);
|
|
161
|
+
return ok(fenced(lines.join("\n")), { run, splits });
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
return fail(err);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
// ─── The profile ────────────────────────────────────────────────────────────
|
|
168
|
+
server.registerTool("ourpr_run_stream", {
|
|
169
|
+
title: "Get a run's elevation and sensor profile",
|
|
170
|
+
description: "A run's profile on a 10 m grid — elevation, heart rate, power, " +
|
|
171
|
+
"cadence — as min, average, max and coverage per channel, not every " +
|
|
172
|
+
"sample. No profile is a normal answer for an indoor run.",
|
|
173
|
+
inputSchema: {
|
|
174
|
+
activity_id: z.string().describe("Run id from ourpr_list_runs."),
|
|
175
|
+
},
|
|
176
|
+
outputSchema: {
|
|
177
|
+
grid_m: z.number(),
|
|
178
|
+
total_miles: z.number(),
|
|
179
|
+
samples: z.number(),
|
|
180
|
+
channels: z.record(z.string(), z.unknown()),
|
|
181
|
+
},
|
|
182
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
183
|
+
}, async ({ activity_id }) => {
|
|
184
|
+
try {
|
|
185
|
+
const s = await get(`/users/me/activities/${pathId(activity_id)}/stream`);
|
|
186
|
+
// A summary, not the samples: a 10 mile run is about 8,000 numbers.
|
|
187
|
+
const stats = (values, scale = 1) => {
|
|
188
|
+
const nums = (values ?? []).filter((v) => v != null);
|
|
189
|
+
if (!nums.length)
|
|
190
|
+
return null;
|
|
191
|
+
const sum = nums.reduce((a, b) => a + b, 0);
|
|
192
|
+
return {
|
|
193
|
+
min: Math.round((Math.min(...nums) * scale) * 10) / 10,
|
|
194
|
+
max: Math.round((Math.max(...nums) * scale) * 10) / 10,
|
|
195
|
+
avg: Math.round(((sum / nums.length) * scale) * 10) / 10,
|
|
196
|
+
coverage: Math.round((nums.length / (values?.length || 1)) * 100),
|
|
197
|
+
};
|
|
286
198
|
};
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}, async ({
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
.join("\n")
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
|
|
199
|
+
const channels = {
|
|
200
|
+
// Stored in centimetres; the app speaks feet.
|
|
201
|
+
elevation_ft: stats(s.elev_cm, 0.0328084),
|
|
202
|
+
heart_rate_bpm: stats(s.hr_bpm),
|
|
203
|
+
power_w: stats(s.power_w),
|
|
204
|
+
cadence_spm: stats(s.cadence_spm),
|
|
205
|
+
};
|
|
206
|
+
const present = Object.entries(channels)
|
|
207
|
+
.filter(([, v]) => v)
|
|
208
|
+
.map(([k, v]) => `- ${k}: min ${v.min}, avg ${v.avg}, max ${v.max} (${v.coverage}% of samples)`)
|
|
209
|
+
.join("\n");
|
|
210
|
+
const total_miles = miles(s.total_m) ?? 0;
|
|
211
|
+
return ok(`Profile over ${total_miles} mi, ${s.points} samples every ${s.grid_m} m` +
|
|
212
|
+
(s.source ? ` (from ${cell(s.source, 24)})` : "") +
|
|
213
|
+
".\n\n" +
|
|
214
|
+
(present || "No sensor channels were recorded for this run."), { grid_m: s.grid_m, total_miles, samples: s.points, channels });
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
return fail(err);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
// ─── Laps ───────────────────────────────────────────────────────────────────
|
|
221
|
+
server.registerTool("ourpr_run_laps", {
|
|
222
|
+
title: "Get a run's laps",
|
|
223
|
+
description: "The laps the watch recorded for one run — the runner's own button " +
|
|
224
|
+
"presses. Laps follow the workout; mile splits follow the mile.",
|
|
225
|
+
inputSchema: {
|
|
226
|
+
activity_id: z.string().describe("Run id from ourpr_list_runs."),
|
|
227
|
+
},
|
|
228
|
+
outputSchema: { laps: z.array(z.record(z.string(), z.unknown())) },
|
|
229
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
230
|
+
}, async ({ activity_id }) => {
|
|
231
|
+
try {
|
|
232
|
+
const raw = await get(`/users/me/activities/${pathId(activity_id)}/laps`);
|
|
233
|
+
const laps = raw.map((l, i) => {
|
|
234
|
+
const mi = miles(l.distance_meters);
|
|
235
|
+
// The payload carries no pace; moving seconds, as the app measures it.
|
|
236
|
+
const secs = l.moving_seconds ?? l.duration_seconds ?? null;
|
|
237
|
+
const perMile = mi && secs ? secs / mi : null;
|
|
238
|
+
return {
|
|
239
|
+
// The API counts from zero; a watch counts from one.
|
|
240
|
+
lap: (l.index ?? i) + 1,
|
|
241
|
+
miles: mi,
|
|
242
|
+
time: duration(secs),
|
|
243
|
+
pace: perMile
|
|
244
|
+
? `${Math.floor(perMile / 60)}:${String(Math.round(perMile % 60)).padStart(2, "0")}`
|
|
245
|
+
: null,
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
const text = laps.length
|
|
249
|
+
? `${laps.length} laps.\n\n| lap | mi | time | pace |\n|---|---|---|---|\n` +
|
|
250
|
+
laps.map((l) => `| ${l.lap} | ${l.miles ?? "—"} | ${l.time ?? "—"} | ${l.pace ?? "—"} |`).join("\n")
|
|
251
|
+
: "No laps recorded for this run.";
|
|
252
|
+
return ok(text, { laps });
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
return fail(err);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
// ─── Rep sessions ───────────────────────────────────────────────────────────
|
|
259
|
+
server.registerTool("ourpr_rep_workouts", {
|
|
260
|
+
title: "Find rep workouts across the history",
|
|
261
|
+
description: "Interval sessions found across the history, with reps and distances. " +
|
|
262
|
+
"Detection is conservative, so a session it misses is still in " +
|
|
263
|
+
"ourpr_list_runs.",
|
|
264
|
+
inputSchema: {
|
|
265
|
+
limit: z
|
|
266
|
+
.number()
|
|
267
|
+
.int()
|
|
268
|
+
.min(10)
|
|
269
|
+
.max(1000)
|
|
270
|
+
.default(200)
|
|
271
|
+
.describe("How many recent activities to read."),
|
|
272
|
+
},
|
|
273
|
+
outputSchema: {
|
|
274
|
+
workouts: z.array(z.record(z.string(), z.unknown())),
|
|
275
|
+
scanned: z.number(),
|
|
276
|
+
},
|
|
277
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
278
|
+
}, async ({ limit }) => {
|
|
279
|
+
try {
|
|
280
|
+
const data = await get(`/users/me/activities/rep-workouts?limit=${limit}`);
|
|
281
|
+
const workouts = (data.workouts ?? []).map((w) => ({
|
|
282
|
+
activity_id: String(w.activity_id),
|
|
283
|
+
date: day(w.date),
|
|
284
|
+
name: cell(w.name),
|
|
285
|
+
sets: (w.groups ?? []).map((g) => ({
|
|
286
|
+
reps: g.count,
|
|
287
|
+
rep_meters: g.rep_meters,
|
|
288
|
+
times_s: g.times_s ?? null,
|
|
289
|
+
})),
|
|
290
|
+
}));
|
|
291
|
+
// "None found" and "none exist" differ; only the first is true here.
|
|
292
|
+
const text = workouts.length
|
|
293
|
+
? `${workouts.length} rep sessions in the ${data.scanned} activities read.\n\n` +
|
|
294
|
+
workouts
|
|
295
|
+
.map((w) => `- ${w.date} · ${w.name || "Untitled"} · ` +
|
|
296
|
+
w.sets.map((s) => `${s.reps} x ${s.rep_meters}m`).join(", "))
|
|
297
|
+
.join("\n")
|
|
298
|
+
: `No rep sessions in the ${data.scanned} most recent activities. ` +
|
|
299
|
+
"Raise `limit` to read further back.";
|
|
300
|
+
return ok(fenced(text), { workouts, scanned: data.scanned });
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
return fail(err);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
// ─── Detection on one run ───────────────────────────────────────────────────
|
|
307
|
+
server.registerTool("ourpr_detect_reps", {
|
|
308
|
+
title: "Look for reps in one run",
|
|
309
|
+
description: "Whether one particular run was an interval session, and its reps.",
|
|
310
|
+
inputSchema: {
|
|
311
|
+
activity_id: z.string().describe("Run id from ourpr_list_runs."),
|
|
312
|
+
},
|
|
313
|
+
outputSchema: { detection: z.record(z.string(), z.unknown()) },
|
|
314
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
315
|
+
}, async ({ activity_id }) => {
|
|
316
|
+
try {
|
|
317
|
+
const detection = await get(`/users/me/activities/${pathId(activity_id)}/workout-detection`);
|
|
318
|
+
// Bounded, so a dump cannot spend the agent's context.
|
|
319
|
+
const json = JSON.stringify(detection);
|
|
320
|
+
return ok(json.length > 4000
|
|
321
|
+
? `${json.slice(0, 4000)}… (truncated; read the structured result)`
|
|
322
|
+
: json, { detection });
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
return fail(err);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
// ─── Terrain ────────────────────────────────────────────────────────────────
|
|
329
|
+
server.registerTool("ourpr_similar_terrain", {
|
|
330
|
+
title: "Find runs over comparable ground",
|
|
331
|
+
description: "Stretches of past runs matching a distance and climb — what the " +
|
|
332
|
+
"runner has already done that resembles a race they are training for.",
|
|
333
|
+
inputSchema: {
|
|
334
|
+
miles: z.number().min(0.1).max(200).describe("Target distance in miles."),
|
|
335
|
+
gain_ft: z.number().min(0).max(30000).describe("Target climb in feet."),
|
|
336
|
+
limit: z.number().int().min(1).max(25).default(8).describe("How many to return."),
|
|
337
|
+
tolerance_ft: z
|
|
338
|
+
.number()
|
|
339
|
+
.min(1)
|
|
340
|
+
.max(200)
|
|
341
|
+
.default(15)
|
|
342
|
+
.describe("Climb tolerance, feet."),
|
|
343
|
+
},
|
|
344
|
+
outputSchema: {
|
|
345
|
+
matches: z.array(z.record(z.string(), z.unknown())),
|
|
346
|
+
scanned: z.number(),
|
|
347
|
+
},
|
|
348
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
349
|
+
}, async ({ miles: mi, gain_ft, limit, tolerance_ft }) => {
|
|
350
|
+
try {
|
|
351
|
+
const data = await get(`/users/me/terrain/similar?miles=${mi}&gain_ft=${gain_ft}` +
|
|
352
|
+
`&limit=${limit}&tolerance_ft=${tolerance_ft}`);
|
|
353
|
+
const matches = data.matches ?? [];
|
|
354
|
+
const text = matches.length
|
|
355
|
+
? `${matches.length} stretches near ${mi} mi with about ${gain_ft} ft ` +
|
|
356
|
+
`of climb, from ${data.scanned} runs read.\n\n` +
|
|
357
|
+
"| date | run | from mi | to mi | climb ft | grade |\n|---|---|---|---|---|---|\n" +
|
|
358
|
+
matches
|
|
359
|
+
.map((m) => `| ${m.date.slice(0, 10)} | ${cell(m.name, 28)} | ${m.from_mi} | ` +
|
|
360
|
+
`${m.to_mi} | ${m.gain_ft} | ${m.grade_pct}% |`)
|
|
361
|
+
.join("\n")
|
|
362
|
+
: `Nothing near ${mi} mi with about ${gain_ft} ft of climb in the ` +
|
|
363
|
+
`${data.scanned} runs read. Widen \`tolerance_ft\`.`;
|
|
364
|
+
return ok(fenced(text), { matches, scanned: data.scanned });
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
return fail(err);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
// ─── Blocks and races ───────────────────────────────────────────────────────
|
|
371
|
+
server.registerTool("ourpr_training_blocks", {
|
|
372
|
+
title: "Get the goal race and training blocks",
|
|
373
|
+
description: "The goal race and its Block: race day, distance, goal time, which " +
|
|
374
|
+
"week of the Block today falls in, and miles for each week. Also the " +
|
|
375
|
+
"Blocks before past races, each with its result, weeks and peak week. " +
|
|
376
|
+
"A Block is the race-anchored Monday to Sunday weeks before a race.",
|
|
377
|
+
inputSchema: {
|
|
378
|
+
past: z
|
|
379
|
+
.number()
|
|
380
|
+
.int()
|
|
381
|
+
.min(0)
|
|
382
|
+
.max(30)
|
|
383
|
+
.default(3)
|
|
384
|
+
.describe("How many past Blocks to include, newest first."),
|
|
385
|
+
},
|
|
386
|
+
outputSchema: {
|
|
387
|
+
goal: z.record(z.string(), z.unknown()).nullable(),
|
|
388
|
+
past: z.array(z.record(z.string(), z.unknown())),
|
|
389
|
+
total_races: z.number(),
|
|
390
|
+
},
|
|
391
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
392
|
+
}, async ({ past }) => {
|
|
393
|
+
try {
|
|
394
|
+
const data = await get("/users/me/race-arcs");
|
|
395
|
+
const arcs = data.arcs ?? [];
|
|
396
|
+
const today = localDay(new Date());
|
|
397
|
+
const goalArc = arcs.find((a) => a.is_goal) ?? null;
|
|
398
|
+
let goal = null;
|
|
399
|
+
let goalText = "No goal race is set in ourpr.";
|
|
400
|
+
if (goalArc) {
|
|
401
|
+
const now = weekOf(goalArc.arc_start, goalArc.arc_weeks, today);
|
|
402
|
+
const weeks = goalArc.weekly_mileage.map((w, i) => {
|
|
403
|
+
const begins = addDays(goalArc.arc_start, i * 7);
|
|
404
|
+
// A week not yet begun has no miles to report, not zero miles.
|
|
405
|
+
const ahead = begins > today;
|
|
406
|
+
return {
|
|
407
|
+
week: i + 1,
|
|
408
|
+
begins,
|
|
409
|
+
miles: ahead ? null : w.miles,
|
|
410
|
+
runs: ahead ? null : w.runs,
|
|
411
|
+
is_now: i + 1 === now,
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
goal = {
|
|
415
|
+
race: cell(goalArc.race_name),
|
|
416
|
+
race_date: day(goalArc.race_date),
|
|
417
|
+
distance: goalArc.distance_category,
|
|
418
|
+
goal_time: goalArc.finish_time || null,
|
|
419
|
+
block_weeks: goalArc.arc_weeks,
|
|
420
|
+
week_now: now,
|
|
421
|
+
block_start: goalArc.arc_start,
|
|
422
|
+
weeks,
|
|
423
|
+
};
|
|
424
|
+
const where = now === 0
|
|
425
|
+
? `The Block opens on ${goalArc.arc_start}.`
|
|
426
|
+
: `Today is in week ${now} of ${goalArc.arc_weeks}.`;
|
|
427
|
+
goalText =
|
|
428
|
+
`Goal: ${goal.race || "Untitled"}, ${goalArc.distance_category}, ` +
|
|
429
|
+
`race day ${goal.race_date}, goal time ${goal.goal_time ?? "not set"}. ${where}\n\n` +
|
|
430
|
+
"| week | begins | mi | runs |\n|---|---|---|---|\n" +
|
|
431
|
+
weeks
|
|
432
|
+
.map((w) => `| ${w.week}${w.is_now ? " (now)" : ""} | ${w.begins} | ` +
|
|
433
|
+
`${w.miles ?? "—"} | ${w.runs ?? "—"} |`)
|
|
434
|
+
.join("\n");
|
|
435
|
+
}
|
|
436
|
+
const done = arcs
|
|
437
|
+
.filter((a) => !a.is_goal)
|
|
438
|
+
.sort((a, b) => b.race_date.localeCompare(a.race_date))
|
|
439
|
+
.slice(0, past)
|
|
440
|
+
.map((a) => ({
|
|
441
|
+
race: cell(a.race_name),
|
|
442
|
+
race_date: day(a.race_date),
|
|
443
|
+
distance: a.distance_category,
|
|
444
|
+
result: a.finish_time || null,
|
|
445
|
+
block_weeks: a.arc_weeks,
|
|
446
|
+
total_miles: a.total_miles,
|
|
447
|
+
total_runs: a.total_runs,
|
|
448
|
+
peak_week_miles: Math.max(0, ...a.weekly_mileage.map((w) => w.miles)),
|
|
449
|
+
tune_ups: a.tune_ups.map((t) => ({
|
|
450
|
+
race: cell(t.race_name),
|
|
451
|
+
race_date: day(t.race_date),
|
|
452
|
+
distance: t.distance_category,
|
|
453
|
+
result: t.finish_time || null,
|
|
454
|
+
})),
|
|
455
|
+
}));
|
|
456
|
+
const pastText = done.length
|
|
457
|
+
? `\n\nPast Blocks, newest first:\n\n` +
|
|
458
|
+
"| race | date | distance | result | weeks | mi | peak week mi | runs |\n" +
|
|
459
|
+
"|---|---|---|---|---|---|---|---|\n" +
|
|
460
|
+
done
|
|
461
|
+
.map((b) => `| ${b.race || "Untitled"} | ${b.race_date} | ${b.distance} | ` +
|
|
462
|
+
`${b.result ?? "—"} | ${b.block_weeks} | ${b.total_miles} | ` +
|
|
463
|
+
`${b.peak_week_miles} | ${b.total_runs} |`)
|
|
464
|
+
.join("\n")
|
|
465
|
+
: "";
|
|
466
|
+
return ok(fenced(goalText + pastText), {
|
|
467
|
+
goal,
|
|
468
|
+
past: done,
|
|
469
|
+
total_races: data.total_races,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
return fail(err);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
// The distances a race result compares across; an ultra varies too much.
|
|
477
|
+
const FIXED_DISTANCES = ["5K", "10K", "Half Marathon", "Marathon"];
|
|
478
|
+
server.registerTool("ourpr_list_races", {
|
|
479
|
+
title: "List the races in the history",
|
|
480
|
+
description: "Every race ourpr finds in the history, newest first, tune-ups " +
|
|
481
|
+
"included: date, name, distance, time, pace and run id. Also the " +
|
|
482
|
+
"fastest result at 5K, 10K, half marathon and marathon among them.",
|
|
483
|
+
inputSchema: {
|
|
484
|
+
distance: z
|
|
485
|
+
.enum(["5K", "10K", "Half Marathon", "Marathon", "Ultra"])
|
|
486
|
+
.optional()
|
|
487
|
+
.describe("Only races at this distance."),
|
|
488
|
+
limit: z.number().int().min(1).max(500).default(50).describe("Most rows to return."),
|
|
489
|
+
},
|
|
490
|
+
outputSchema: {
|
|
491
|
+
races: z.array(z.record(z.string(), z.unknown())),
|
|
492
|
+
fastest: z.array(z.record(z.string(), z.unknown())),
|
|
493
|
+
returned: z.number(),
|
|
494
|
+
total: z.number(),
|
|
495
|
+
},
|
|
496
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
497
|
+
}, async ({ distance, limit }) => {
|
|
498
|
+
try {
|
|
499
|
+
const data = await get("/users/me/races");
|
|
500
|
+
const all = (data.races ?? [])
|
|
501
|
+
.filter((r) => !distance || r.distance_category === distance)
|
|
502
|
+
.map((r) => ({
|
|
503
|
+
activity_id: String(r.activity_id),
|
|
504
|
+
date: day(r.date),
|
|
505
|
+
name: cell(r.name),
|
|
506
|
+
distance: r.distance_category,
|
|
507
|
+
miles: r.distance_miles,
|
|
508
|
+
time: clock(r.duration_seconds),
|
|
509
|
+
seconds: r.duration_seconds || null,
|
|
510
|
+
pace_per_mile: r.pace_per_mile ?? null,
|
|
511
|
+
}));
|
|
512
|
+
const fastest = FIXED_DISTANCES.flatMap((d) => {
|
|
513
|
+
const timed = all.filter((r) => r.distance === d && r.seconds);
|
|
514
|
+
if (!timed.length)
|
|
515
|
+
return [];
|
|
516
|
+
const best = timed.reduce((a, b) => (b.seconds < a.seconds ? b : a));
|
|
517
|
+
return [{ distance: d, time: best.time, date: best.date, activity_id: best.activity_id }];
|
|
518
|
+
});
|
|
519
|
+
const shown = all.slice(0, limit);
|
|
520
|
+
const head = `${all.length} races${distance ? ` at ${distance}` : ""}.` +
|
|
521
|
+
(all.length > shown.length
|
|
522
|
+
? ` Showing the ${shown.length} newest; raise \`limit\` to see the rest.`
|
|
523
|
+
: "") +
|
|
524
|
+
(fastest.length
|
|
525
|
+
? "\nFastest: " + fastest.map((f) => `${f.distance} ${f.time} (${f.date})`).join(", ") + "."
|
|
526
|
+
: "");
|
|
527
|
+
const body = shown.length
|
|
528
|
+
? "| date | race | distance | mi | time | pace | id |\n|---|---|---|---|---|---|---|\n" +
|
|
529
|
+
shown
|
|
530
|
+
.map((r) => `| ${r.date} | ${r.name || "Race"} | ${r.distance} | ${r.miles} | ` +
|
|
531
|
+
`${r.time ?? "—"} | ${r.pace_per_mile ?? "—"} | ${r.activity_id} |`)
|
|
532
|
+
.join("\n")
|
|
533
|
+
: "No races found.";
|
|
534
|
+
return ok(fenced(`${head}\n\n${body}`), {
|
|
535
|
+
races: shown,
|
|
536
|
+
fastest,
|
|
537
|
+
returned: shown.length,
|
|
538
|
+
total: all.length,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
return fail(err);
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
// ─── The week ───────────────────────────────────────────────────────────────
|
|
546
|
+
// The list route answers at most this many plans.
|
|
547
|
+
const PLAN_PAGE = 200;
|
|
548
|
+
server.registerTool("ourpr_list_plans", {
|
|
549
|
+
title: "List planned runs in a date range",
|
|
550
|
+
description: "The runner's planned runs between two dates: day, name, miles, time, " +
|
|
551
|
+
"tag, the runner's note, whether a logged run fulfilled it, and " +
|
|
552
|
+
"whether ourpr create wrote it. Read it before ourpr_plan_week so a " +
|
|
553
|
+
"new plan does not land on a day that already holds one.",
|
|
554
|
+
inputSchema: {
|
|
555
|
+
start_date: z
|
|
556
|
+
.string()
|
|
557
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
558
|
+
.optional()
|
|
559
|
+
.describe("First day, YYYY-MM-DD. Default today."),
|
|
560
|
+
end_date: z
|
|
561
|
+
.string()
|
|
562
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
563
|
+
.optional()
|
|
564
|
+
.describe("Last day, YYYY-MM-DD. Default 13 days after the first."),
|
|
565
|
+
},
|
|
566
|
+
outputSchema: {
|
|
567
|
+
plans: z.array(z.record(z.string(), z.unknown())),
|
|
568
|
+
start_date: z.string(),
|
|
569
|
+
end_date: z.string(),
|
|
570
|
+
truncated: z.boolean(),
|
|
571
|
+
},
|
|
572
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
573
|
+
}, async ({ start_date, end_date }) => {
|
|
574
|
+
try {
|
|
575
|
+
const start = start_date ?? localDay(new Date());
|
|
576
|
+
const end = end_date ?? addDays(start, 13);
|
|
577
|
+
const rows = await get(`/users/me/planned-activities?start=${start}&end=${end}`);
|
|
578
|
+
const plans = rows.map((p) => ({
|
|
579
|
+
id: String(p.id),
|
|
580
|
+
date: day(p.planned_date),
|
|
581
|
+
type: cell(p.activity_type, 24) || "Run",
|
|
582
|
+
name: cell(p.name),
|
|
583
|
+
miles: miles(p.distance_meters),
|
|
584
|
+
time: duration(p.duration_seconds),
|
|
585
|
+
tag: p.tag ?? null,
|
|
586
|
+
is_long: p.is_long,
|
|
587
|
+
note: note(p.note),
|
|
588
|
+
done_by_run: p.completed_activity_id ?? null,
|
|
589
|
+
from_ourpr_create: p.source === "token",
|
|
590
|
+
}));
|
|
591
|
+
const truncated = rows.length >= PLAN_PAGE;
|
|
592
|
+
const head = `${plans.length} plans between ${start} and ${end}.` +
|
|
593
|
+
(truncated ? " ourpr returns at most 200; narrow the dates to see the rest." : "");
|
|
594
|
+
const body = plans.length
|
|
595
|
+
? "| date | name | type | mi | time | tag | done | note |\n|---|---|---|---|---|---|---|---|\n" +
|
|
596
|
+
plans
|
|
597
|
+
.map((p) => `| ${p.date} | ${p.name || "—"} | ${p.type} | ${p.miles ?? "—"} | ` +
|
|
598
|
+
`${p.time ?? "—"} | ${p.tag ?? "—"}${p.is_long ? " · long" : ""} | ` +
|
|
599
|
+
`${p.done_by_run ? "yes" : "no"} | ${p.note || "—"} |`)
|
|
600
|
+
.join("\n")
|
|
601
|
+
: "No plans in that window.";
|
|
602
|
+
return ok(fenced(`${head}\n\n${body}`), { plans, start_date: start, end_date: end, truncated });
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
return fail(err);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
const PLAN_TAGS = ["easy", "workout", "race"];
|
|
609
|
+
server.registerTool("ourpr_plan_week", {
|
|
610
|
+
title: "Put runs on the runner's week",
|
|
611
|
+
description: "Write one planned run, or a week of them, onto days still ahead. Each " +
|
|
612
|
+
"lands on the runner's week as a plan they can see, edit and remove. " +
|
|
613
|
+
"Needs a token made with the write scope and ourpr create. " +
|
|
614
|
+
"Never logs a run.",
|
|
615
|
+
inputSchema: {
|
|
616
|
+
plans: z
|
|
617
|
+
.array(z.object({
|
|
618
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("YYYY-MM-DD, after today."),
|
|
619
|
+
miles: z.number().min(0.1).max(200).optional().describe("Planned distance."),
|
|
620
|
+
minutes: z.number().min(1).max(1440).optional().describe("Planned time."),
|
|
621
|
+
name: z.string().max(120).optional().describe("A short name for the run."),
|
|
622
|
+
note: z.string().max(500).optional().describe("A line the runner will read."),
|
|
623
|
+
tag: z.enum(PLAN_TAGS).optional().describe("How hard: easy, workout or race."),
|
|
624
|
+
is_long: z.boolean().optional().describe("The week's long run."),
|
|
625
|
+
}))
|
|
626
|
+
.min(1)
|
|
627
|
+
.max(14)
|
|
628
|
+
.describe("One run, or up to fourteen."),
|
|
629
|
+
},
|
|
630
|
+
outputSchema: {
|
|
631
|
+
written: z.array(z.record(z.string(), z.unknown())),
|
|
632
|
+
},
|
|
633
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
634
|
+
}, async ({ plans }) => {
|
|
635
|
+
try {
|
|
636
|
+
const body = {
|
|
637
|
+
plans: plans.map((p) => ({
|
|
638
|
+
planned_date: p.date,
|
|
639
|
+
distance_meters: p.miles == null ? null : Math.round(p.miles * METERS_PER_MILE),
|
|
640
|
+
duration_seconds: p.minutes == null ? null : Math.round(p.minutes * 60),
|
|
641
|
+
name: p.name ?? null,
|
|
642
|
+
note: p.note ?? null,
|
|
643
|
+
tag: p.tag ?? null,
|
|
644
|
+
is_long: p.is_long ?? false,
|
|
645
|
+
})),
|
|
646
|
+
};
|
|
647
|
+
const rows = await post("/users/me/planned-activities/week", body);
|
|
648
|
+
const written = rows.map((r) => ({
|
|
649
|
+
id: String(r.id),
|
|
650
|
+
date: day(r.planned_date),
|
|
651
|
+
name: cell(r.name),
|
|
652
|
+
miles: miles(r.distance_meters),
|
|
653
|
+
time: duration(r.duration_seconds),
|
|
654
|
+
tag: r.tag ?? null,
|
|
655
|
+
is_long: r.is_long,
|
|
656
|
+
}));
|
|
657
|
+
const text = `${written.length} plan${written.length === 1 ? "" : "s"} on the runner's week.\n\n` +
|
|
658
|
+
"| date | name | mi | time | tag |\n|---|---|---|---|---|\n" +
|
|
659
|
+
written
|
|
660
|
+
.map((w) => `| ${w.date} | ${w.name || "—"} | ${w.miles ?? "—"} | ${w.time ?? "—"} | ` +
|
|
661
|
+
`${w.tag ?? "—"}${w.is_long ? " · long" : ""} |`)
|
|
662
|
+
.join("\n");
|
|
663
|
+
return ok(text, { written });
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
return fail(err);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
return server;
|
|
418
670
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
671
|
+
// ─── Start ──────────────────────────────────────────────────────────────────
|
|
672
|
+
// Both eras: 2026-07-28 per request, and the 2025 handshake for older clients.
|
|
673
|
+
serveStdio(buildServer, {
|
|
674
|
+
onerror: (err) => console.error("[ourpr-mcp-server] error:", err.message),
|
|
422
675
|
});
|
|
676
|
+
// Stdout is the protocol stream; diagnostics go to stderr.
|
|
677
|
+
console.error("[ourpr-mcp-server] ready");
|