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/build/index.js CHANGED
@@ -1,61 +1,26 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
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
- * ourpr-mcp-server — an agent reads your own running history.
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
- const server = new McpServer({ name: "ourpr-mcp-server", version: "0.1.0" });
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
- /** Errors reach the agent as an error result with copy it can act on. */
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
- /** A markdown table. Agents read these far more reliably than raw JSON, and
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
- // ─── The window ─────────────────────────────────────────────────────────────
96
- server.registerTool("ourpr_list_runs", {
97
- title: "List runs in a date range",
98
- description: "Training history between two dates, newest first: date, name, type, " +
99
- "miles, pace, time, elevation, average heart rate. Start here for " +
100
- "totals, streaks, trends, or finding a run. For one run's splits, use " +
101
- "ourpr_get_run with an id from here.",
102
- inputSchema: {
103
- start_date: z
104
- .string()
105
- .describe("First day to include, YYYY-MM-DD. Example: 2026-01-01"),
106
- end_date: z
107
- .string()
108
- .describe("Last day to include, YYYY-MM-DD. Example: 2026-06-30"),
109
- include_non_runs: z
110
- .boolean()
111
- .default(false)
112
- .describe("Include rides, gym and other types. Runs only by default."),
113
- limit: z
114
- .number()
115
- .int()
116
- .min(1)
117
- .max(MAX_LIMIT)
118
- .default(DEFAULT_LIMIT)
119
- .describe("Most rows to return. The answer says what it left out."),
120
- },
121
- outputSchema: {
122
- runs: z.array(z.record(z.string(), z.unknown())),
123
- returned: z.number(),
124
- total_in_window: z.number(),
125
- truncated: z.boolean(),
126
- },
127
- annotations: { readOnlyHint: true, openWorldHint: true },
128
- }, async ({ start_date, end_date, include_non_runs, limit }) => {
129
- try {
130
- const data = await get(`/users/me/activities?after=${stamp(start_date, "start")}` +
131
- `&before=${stamp(end_date, "end")}` +
132
- `&include_non_runs=${include_non_runs}`);
133
- const all = data.activities.map(summarise);
134
- const shown = all.slice(0, limit);
135
- const truncated = all.length > shown.length;
136
- const header = `${all.length} ${include_non_runs ? "activities" : "runs"} between ` +
137
- `${cell(start_date, 10)} and ${cell(end_date, 10)}.` +
138
- (truncated
139
- ? ` Showing the ${shown.length} newest — ${all.length - shown.length} ` +
140
- "older ones are not listed. Narrow the dates or raise `limit` to see them."
141
- : "");
142
- return ok(`${header}\n\n` + fenced(shown.length ? table(shown) : "No activities in that window."), {
143
- runs: shown,
144
- returned: shown.length,
145
- total_in_window: all.length,
146
- truncated,
147
- });
148
- }
149
- catch (err) {
150
- return fail(err);
151
- }
152
- });
153
- // ─── One run ────────────────────────────────────────────────────────────────
154
- server.registerTool("ourpr_get_run", {
155
- title: "Get one run in full",
156
- description: "One activity in full: mile splits, heart rate, cadence, calories, " +
157
- "device. Id comes from ourpr_list_runs. For the profile along the " +
158
- "route, use ourpr_run_stream.",
159
- inputSchema: {
160
- activity_id: z
161
- .string()
162
- .describe("Run id from ourpr_list_runs."),
163
- },
164
- outputSchema: {
165
- run: z.record(z.string(), z.unknown()),
166
- splits: z.array(z.record(z.string(), z.unknown())),
167
- },
168
- annotations: { readOnlyHint: true, openWorldHint: true },
169
- }, async ({ activity_id }) => {
170
- try {
171
- const a = await get(`/users/me/activities/${pathId(activity_id)}`);
172
- const splits = (a.splits ?? []).map((s) => ({
173
- mile: s.split_number,
174
- pace: s.pace_per_mile ?? null,
175
- elevation_change_ft: feet(s.elevation_difference_meters),
176
- }));
177
- const run = {
178
- ...summarise(a),
179
- max_hr: a.max_heartrate ?? null,
180
- avg_cadence_spm: a.avg_cadence ?? null,
181
- calories: a.calories ?? null,
182
- device: deviceLabel(a.device),
183
- has_route: Boolean(a.summary_polyline),
184
- };
185
- const lines = [
186
- `**${run.name || "Untitled"}** — ${run.date}`,
187
- `${run.miles ?? "—"} mi · ${run.pace_per_mile ?? "—"}/mi · ` +
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
- const channels = {
236
- // Stored in centimetres; feet is what the app speaks.
237
- elevation_ft: stats(s.elev_cm, 0.0328084),
238
- heart_rate_bpm: stats(s.hr_bpm),
239
- power_w: stats(s.power_w),
240
- cadence_spm: stats(s.cadence_spm),
241
- };
242
- const present = Object.entries(channels)
243
- .filter(([, v]) => v)
244
- .map(([k, v]) => `- ${k}: min ${v.min}, avg ${v.avg}, max ${v.max} (${v.coverage}% of samples)`)
245
- .join("\n");
246
- const total_miles = miles(s.total_m) ?? 0;
247
- return ok(`Profile over ${total_miles} mi, ${s.points} samples every ${s.grid_m} m` +
248
- (s.source ? ` (from ${cell(s.source, 24)})` : "") +
249
- ".\n\n" +
250
- (present || "No sensor channels were recorded for this run."), { grid_m: s.grid_m, total_miles, samples: s.points, channels });
251
- }
252
- catch (err) {
253
- return fail(err);
254
- }
255
- });
256
- // ─── Laps ───────────────────────────────────────────────────────────────────
257
- server.registerTool("ourpr_run_laps", {
258
- title: "Get a run's laps",
259
- description: "The laps the watch recorded for one run — the runner's own button " +
260
- "presses. Laps follow the workout; mile splits follow the mile.",
261
- inputSchema: {
262
- activity_id: z.string().describe("Run id from ourpr_list_runs."),
263
- },
264
- outputSchema: { laps: z.array(z.record(z.string(), z.unknown())) },
265
- annotations: { readOnlyHint: true, openWorldHint: true },
266
- }, async ({ activity_id }) => {
267
- try {
268
- const raw = await get(`/users/me/activities/${pathId(activity_id)}/laps`);
269
- const laps = raw.map((l, i) => {
270
- const mi = miles(l.distance_meters);
271
- // THE PACE IS DERIVED HERE, because the payload does not carry one.
272
- // Moving seconds, not elapsed: standing at the line between reps is
273
- // not part of the rep, and the whole app measures pace this way.
274
- const secs = l.moving_seconds ?? l.duration_seconds ?? null;
275
- const perMile = mi && secs ? secs / mi : null;
276
- return {
277
- // THE API COUNTS FROM ZERO AND A RUNNER COUNTS FROM ONE. A watch
278
- // says "Lap 1" on the first press, so a table headed lap 0 is
279
- // describing a different session from the one they remember.
280
- lap: (l.index ?? i) + 1,
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
- const text = laps.length
289
- ? `${laps.length} laps.\n\n| lap | mi | time | pace |\n|---|---|---|---|\n` +
290
- laps.map((l) => `| ${l.lap} | ${l.miles ?? "—"} | ${l.time ?? "—"} | ${l.pace ?? "—"} |`).join("\n")
291
- : "No laps recorded for this run.";
292
- return ok(text, { laps });
293
- }
294
- catch (err) {
295
- return fail(err);
296
- }
297
- });
298
- // ─── Rep sessions ───────────────────────────────────────────────────────────
299
- server.registerTool("ourpr_rep_workouts", {
300
- title: "Find rep workouts across the history",
301
- description: "Interval sessions found across the history, with reps and distances. " +
302
- "Detection is conservative, so a session it misses is still in " +
303
- "ourpr_list_runs.",
304
- inputSchema: {
305
- limit: z
306
- .number()
307
- .int()
308
- .min(10)
309
- .max(1000)
310
- .default(200)
311
- .describe("How many recent activities to read."),
312
- },
313
- outputSchema: {
314
- workouts: z.array(z.record(z.string(), z.unknown())),
315
- scanned: z.number(),
316
- },
317
- annotations: { readOnlyHint: true, openWorldHint: true },
318
- }, async ({ limit }) => {
319
- try {
320
- const data = await get(`/users/me/activities/rep-workouts?limit=${limit}`);
321
- const workouts = (data.workouts ?? []).map((w) => ({
322
- activity_id: String(w.activity_id),
323
- date: day(w.date),
324
- name: cell(w.name),
325
- sets: (w.groups ?? []).map((g) => ({
326
- reps: g.count,
327
- rep_meters: g.rep_meters,
328
- times_s: g.times_s ?? null,
329
- })),
330
- }));
331
- // AN EMPTY ANSWER SAYS WHAT WAS READ. "None found" and "none exist" are
332
- // different claims, and only the first one is true here.
333
- const text = workouts.length
334
- ? `${workouts.length} rep sessions in the ${data.scanned} activities read.\n\n` +
335
- workouts
336
- .map((w) => `- ${w.date} · ${w.name || "Untitled"} · ` +
337
- w.sets.map((s) => `${s.reps} x ${s.rep_meters}m`).join(", "))
338
- .join("\n")
339
- : `No rep sessions in the ${data.scanned} most recent activities. ` +
340
- "Raise `limit` to read further back.";
341
- return ok(fenced(text), { workouts, scanned: data.scanned });
342
- }
343
- catch (err) {
344
- return fail(err);
345
- }
346
- });
347
- // ─── Detection on one run ───────────────────────────────────────────────────
348
- server.registerTool("ourpr_detect_reps", {
349
- title: "Look for reps in one run",
350
- description: "Whether one particular run was an interval session, and its reps.",
351
- inputSchema: {
352
- activity_id: z.string().describe("Run id from ourpr_list_runs."),
353
- },
354
- outputSchema: { detection: z.record(z.string(), z.unknown()) },
355
- annotations: { readOnlyHint: true, openWorldHint: true },
356
- }, async ({ activity_id }) => {
357
- try {
358
- const detection = await get(`/users/me/activities/${pathId(activity_id)}/workout-detection`);
359
- // BOUNDED. This was an unbounded pretty-printed dump, which is the one
360
- // shape that can spend an agent's context without anyone choosing to.
361
- const json = JSON.stringify(detection);
362
- return ok(json.length > 4000
363
- ? `${json.slice(0, 4000)}… (truncated; read the structured result)`
364
- : json, { detection });
365
- }
366
- catch (err) {
367
- return fail(err);
368
- }
369
- });
370
- // ─── Terrain ────────────────────────────────────────────────────────────────
371
- server.registerTool("ourpr_similar_terrain", {
372
- title: "Find runs over comparable ground",
373
- description: "Stretches of past runs matching a distance and climb — what the " +
374
- "runner has already done that resembles a race they are training for.",
375
- inputSchema: {
376
- miles: z.number().min(0.1).max(200).describe("Target distance in miles."),
377
- gain_ft: z.number().min(0).max(30000).describe("Target climb in feet."),
378
- limit: z.number().int().min(1).max(25).default(8).describe("How many to return."),
379
- tolerance_ft: z
380
- .number()
381
- .min(1)
382
- .max(200)
383
- .default(15)
384
- .describe("Climb tolerance, feet."),
385
- },
386
- outputSchema: {
387
- matches: z.array(z.record(z.string(), z.unknown())),
388
- scanned: z.number(),
389
- },
390
- annotations: { readOnlyHint: true, openWorldHint: true },
391
- }, async ({ miles: mi, gain_ft, limit, tolerance_ft }) => {
392
- try {
393
- const data = await get(`/users/me/terrain/similar?miles=${mi}&gain_ft=${gain_ft}` +
394
- `&limit=${limit}&tolerance_ft=${tolerance_ft}`);
395
- const matches = data.matches ?? [];
396
- const text = matches.length
397
- ? `${matches.length} stretches near ${mi} mi with about ${gain_ft} ft ` +
398
- `of climb, from ${data.scanned} runs read.\n\n` +
399
- "| date | run | from mi | to mi | climb ft | grade |\n|---|---|---|---|---|---|\n" +
400
- matches
401
- .map((m) => `| ${m.date.slice(0, 10)} | ${cell(m.name, 28)} | ${m.from_mi} | ` +
402
- `${m.to_mi} | ${m.gain_ft} | ${m.grade_pct}% |`)
403
- .join("\n")
404
- : `Nothing near ${mi} mi with about ${gain_ft} ft of climb in the ` +
405
- `${data.scanned} runs read. Widen \`tolerance_ft\`.`;
406
- return ok(fenced(text), { matches, scanned: data.scanned });
407
- }
408
- catch (err) {
409
- return fail(err);
410
- }
411
- });
412
- // ─── Start ──────────────────────────────────────────────────────────────────
413
- async function main() {
414
- await server.connect(new StdioServerTransport());
415
- // STDERR, ALWAYS. On a stdio server stdout is the protocol stream and one
416
- // `console.log` corrupts it.
417
- console.error("[ourpr-mcp-server] ready");
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
- main().catch((err) => {
420
- console.error("[ourpr-mcp-server] fatal:", err);
421
- process.exit(1);
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");