ourpr-mcp-server 0.2.0 → 0.3.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 CHANGED
@@ -1,10 +1,12 @@
1
1
  # ourpr-mcp-server
2
2
 
3
3
  An MCP server that lets an AI agent read **your own** running history from
4
- [ourpr](https://ourpr.app). Ask about your training in plain language, in
5
- whatever agent you already use.
4
+ [ourpr](https://ourpr.app), and write one thing back. Ask about your
5
+ training in plain language, in whatever agent you already use.
6
6
 
7
- Read only. It cannot change a run, plan a week, or issue another credential.
7
+ Every tool reads. One tool writes, and only that one: `ourpr_plan_week` puts
8
+ a plan on a day still ahead. It cannot log a run, edit history, or issue
9
+ another credential, and it needs a token made with the write scope.
8
10
 
9
11
  ## Why it exists
10
12
 
@@ -15,9 +17,11 @@ credential you issued to yourself and can revoke.
15
17
 
16
18
  ## Setup
17
19
 
18
- **1. Make a token.** In ourpr, go to **Settings → Access tokens → New token**.
19
- Name it after the machine it will live on. Copy it — it is shown once and
20
- cannot be recovered. A token lasts 90 days.
20
+ **1. Make a token.** In ourpr, go to
21
+ **Profile → Settings → ourpr. mcp → New token**. Name it after the machine it
22
+ will live on, and choose **Write** if you want the agent to plan your week;
23
+ **Read** otherwise. Copy it — it is shown once and cannot be
24
+ recovered. A token lasts 90 days.
21
25
 
22
26
  **2. Point a client at it.** The package runs from npm; nothing to clone.
23
27
 
@@ -132,6 +136,24 @@ done that resembles a race you are training for.
132
136
  "What have I run that's like Boston — 26 miles, 800 feet of climb?"
133
137
  ```
134
138
 
139
+ ### `ourpr_plan_week`
140
+
141
+ The one write. One planned run, or a week of them, onto days still ahead.
142
+ Each lands on the runner's week as a plan they can see, edit and remove; the
143
+ day sheet says it came from ourpr create. It never logs a run.
144
+
145
+ ```
146
+ "Put a 6 mile easy run on Tuesday and 14 long on Saturday"
147
+ "Write me next week: three easy days, one workout, one long run"
148
+ ```
149
+
150
+ `plans`, one to fourteen, each with `date` (YYYY-MM-DD, after today) and any
151
+ of `miles`, `minutes`, `name`, `note`, `tag` (easy, workout, race), `is_long`.
152
+
153
+ Needs a token made with the **Write** scope, and ourpr create on the account.
154
+ A **Read** token, or an account without create, is refused before anything is
155
+ written. Thirty plans a day.
156
+
135
157
  ## How the tool set was chosen
136
158
 
137
159
  By an evaluation, not by listing the API.
@@ -166,15 +188,17 @@ numbers the agent would only reduce anyway.
166
188
 
167
189
  ## Security
168
190
 
169
- The token grants read access to your own activity data and nothing else. It
170
- cannot write, cannot issue another token, cannot revoke your existing ones, and
171
- cannot widen its own scope.
191
+ The token grants read access to your own activity data. A token made with the
192
+ write scope, with ourpr create, may also put plans on your own week through
193
+ one route, and nothing else. No token can log a run, issue another token, revoke
194
+ your existing ones, or widen its own scope.
172
195
 
173
- Each token may make 60 reads a minute. ourpr answers 429 past that, with
174
- `RateLimit` and `Retry-After` headers, and the tool says how long to wait.
196
+ Each token may make 60 reads a minute, and a runner may write 30 plans a day.
197
+ ourpr answers 429 past either, with `RateLimit` and `Retry-After` headers, and
198
+ the tool says how long to wait.
175
199
 
176
- Revoke any token at any time in **Settings → Access tokens**. Revocation is
177
- immediate and permanent — a revoked token can never be restored.
200
+ Revoke any token at any time in **Profile → Settings → ourpr. mcp**.
201
+ Revocation is immediate and permanent — a revoked token can never be restored.
178
202
 
179
203
  ### Tool results are untrusted content
180
204
 
@@ -241,6 +265,11 @@ bundle's entry point; `npx @anthropic-ai/mcpb pack` builds `ourpr.mcpb` for
241
265
  the release. `server.json` registers the package in the MCP Registry with
242
266
  `mcp-publisher publish`.
243
267
 
268
+ The tag run answers npm's `E404` on the PUT until the package lists this
269
+ repository and `publish.yml` as a trusted publisher on npmjs.com (package
270
+ settings, Trusted publisher). Until then the release is by hand: `npm login`,
271
+ then `npm publish` from this directory, which builds and tests first.
272
+
244
273
  ## License
245
274
 
246
275
  MIT
package/build/api.js CHANGED
@@ -1,42 +1,12 @@
1
- // The ourpr API, as this server talks to it.
2
- //
3
- // AUTHENTICATION IS AN ENVIRONMENT VARIABLE, NOT A TOOL PARAMETER, and that is
4
- // a deliberate difference from revenuecat-charts-mcp, which takes `api_key` on
5
- // every call. RevenueCat issues one key per project and a person may hold
6
- // several, so asking per call is right there. An ourpr token belongs to ONE
7
- // runner and never changes between calls, so a parameter would put a live
8
- // credential into every tool call the agent makes — into its context, its
9
- // transcript, and any log of either. The env var keeps it out of all three.
10
- //
11
- // NOTHING THIS SERVER PRINTS MAY CARRY A CONFIGURED VALUE. `OURPR_TOKEN` is
12
- // never printed anywhere, and `OURPR_API_URL` is reduced to scheme and host
13
- // before it reaches an error — see `safeOrigin`. An error message travels into
14
- // the agent's context and its transcript, so printing there is publishing.
15
- //
16
- // NOTHING HERE MAY WRITE TO STDOUT. On a stdio server stdout IS the protocol
17
- // stream, and one stray `console.log` corrupts it. Diagnostics go to stderr.
1
+ // The token is an environment variable, never a tool parameter, so no call carries it.
18
2
  import { cell } from "./safe.js";
19
3
  const TOKEN = process.env.OURPR_TOKEN ?? "";
20
4
  const BASE = (process.env.OURPR_API_URL ?? "https://ourpr.onrender.com/api").replace(/\/$/, "");
21
- // A bearer token over plain http travels in the clear. Warn once rather than
22
- // refuse: localhost is how this server runs against a dev backend.
5
+ // A token over plain http travels in the clear; localhost is the dev backend.
23
6
  if (BASE.startsWith("http://") && !/^http:\/\/(localhost|127\.0\.0\.1)[:/]/.test(BASE)) {
24
7
  console.error("[ourpr-mcp-server] OURPR_API_URL is http, not https - the token is not encrypted in transit");
25
8
  }
26
- /**
27
- * The configured host, with everything else removed.
28
- *
29
- * NO ENVIRONMENT VALUE IS ECHOED WHOLE. An error goes straight into the
30
- * agent's context and its transcript, so anything printed there is published.
31
- * `OURPR_API_URL` is usually just a hostname — and it is a URL, so it CAN
32
- * carry `user:password@` before the host, which a self-hosted or staging
33
- * instance behind basic auth plausibly would. Dropping userinfo, path, query
34
- * and fragment leaves the one part that helps a person debug and none of the
35
- * part that must not travel.
36
- *
37
- * An unparseable value yields a placeholder rather than the raw string,
38
- * because "unparseable" is exactly the case where the string is unexpected.
39
- */
9
+ /** The configured host only; userinfo, path and query never reach an error. */
40
10
  function safeOrigin() {
41
11
  try {
42
12
  const url = new URL(BASE);
@@ -46,19 +16,9 @@ function safeOrigin() {
46
16
  return "the configured host";
47
17
  }
48
18
  }
49
- /**
50
- * Why a request failed, WITHOUT the message.
51
- *
52
- * A Node fetch failure carries the full request URL in `cause`, and a timeout
53
- * message can too. The code — ECONNREFUSED, ENOTFOUND, TimeoutError — says
54
- * everything a person needs and carries nothing they did not choose to
55
- * publish.
56
- */
19
+ /** The error code, never the message, which can carry the request URL. */
57
20
  function reason(err) {
58
- // WALK THE CAUSE CHAIN. Node wraps a connection failure as
59
- // `TypeError: fetch failed` with the useful code — ECONNREFUSED, ENOTFOUND —
60
- // one or two levels down in `cause`. Reading only the top gave "TypeError",
61
- // which tells a person nothing they can act on.
21
+ // Node puts the useful code one or two levels down in cause.
62
22
  let node = err;
63
23
  for (let depth = 0; node && depth < 4; depth++) {
64
24
  const code = node.code;
@@ -66,10 +26,7 @@ function reason(err) {
66
26
  return code;
67
27
  node = node.cause;
68
28
  }
69
- // NO CODE — a URL that will not parse ("bad port") reaches here, and its
70
- // MESSAGE is the only useful thing. Passed on ONLY when it cannot be
71
- // carrying configured values: an "@" means userinfo, and the raw base or the
72
- // token must never appear. Fails closed to the error name.
29
+ // A message passes only when it cannot carry a configured value.
73
30
  const deepest = deepestMessage(err);
74
31
  const safe = deepest &&
75
32
  !deepest.includes("@") &&
@@ -98,15 +55,18 @@ export class ApiError extends Error {
98
55
  function guidance(status, detail, retryAfterS) {
99
56
  if (status === 429) {
100
57
  const wait = /^\d+$/.test(retryAfterS ?? "") ? `${retryAfterS} seconds` : "a minute";
101
- return `ourpr allows 60 reads a minute for each token. Wait ${wait}, then ask again.`;
58
+ return ("ourpr allows 60 reads a minute for each token and 30 plans a day. " +
59
+ `Wait ${wait}, then ask again.`);
102
60
  }
103
61
  if (status === 401) {
104
62
  return ("ourpr rejected the token. It may be revoked, expired (a token lasts 90 " +
105
63
  "days), or OURPR_TOKEN may be unset. Make a new one in ourpr under " +
106
- "Settings, Access tokens.");
64
+ "Profile, Settings, ourpr. mcp.");
65
+ }
66
+ if (status === 403) {
67
+ return ("ourpr refused the write. The token needs the write scope, chosen when " +
68
+ "it is made, and ourpr create behind it.");
107
69
  }
108
- if (status === 403)
109
- return "That token can read only. This action needs a write scope.";
110
70
  if (status === 404)
111
71
  return "ourpr has no such run. Check the id against ourpr_list_runs.";
112
72
  if (status === 422)
@@ -115,33 +75,27 @@ function guidance(status, detail, retryAfterS) {
115
75
  return "ourpr had an error. Try again in a moment.";
116
76
  return detail || `ourpr answered ${status}.`;
117
77
  }
118
- /**
119
- * One line per call, on stderr.
120
- *
121
- * The 2026 guidance asks a server to log tool invocations, downstream calls,
122
- * errors and denials, so an operator can see what an agent did on their
123
- * behalf. THE PATH IS LOGGED AND THE TOKEN NEVER IS — a log that carries the
124
- * credential is a second copy of it, in a file nobody is guarding.
125
- */
78
+ // The path is logged and the token never is.
126
79
  function audit(path, outcome, ms) {
127
80
  console.error(`[ourpr-mcp-server] ${outcome} ${path.split("?")[0]} ${ms}ms`);
128
81
  }
129
- export async function get(path) {
82
+ async function request(method, path, payload) {
130
83
  const started = Date.now();
131
84
  if (!TOKEN) {
132
- throw new ApiError("OURPR_TOKEN is not set. Create a token in ourpr under Settings, " +
133
- "Access tokens, then put it in this server's environment.");
85
+ throw new ApiError("OURPR_TOKEN is not set. Create a token in ourpr under Profile, " +
86
+ "Settings, ourpr. mcp, then put it in this server's environment.");
134
87
  }
135
88
  let response;
136
89
  try {
137
90
  response = await fetch(`${BASE}${path}`, {
138
- headers: { Authorization: `Bearer ${TOKEN}` },
91
+ method,
92
+ headers: {
93
+ Authorization: `Bearer ${TOKEN}`,
94
+ ...(payload === undefined ? {} : { "Content-Type": "application/json" }),
95
+ },
96
+ body: payload === undefined ? undefined : JSON.stringify(payload),
139
97
  signal: AbortSignal.timeout(60_000),
140
- // DO NOT FOLLOW REDIRECTS WITH A CREDENTIAL ATTACHED. Node strips the
141
- // Authorization header across origins, so this is belt rather than
142
- // braces — but a redirect from ourpr's own host is not something this
143
- // server should follow silently either, because the answer would then
144
- // come from somewhere the operator did not configure.
98
+ // A request that carries the token never follows a redirect.
145
99
  redirect: "manual",
146
100
  });
147
101
  }
@@ -149,10 +103,7 @@ export async function get(path) {
149
103
  throw new ApiError(`Could not reach ourpr at ${safeOrigin()} (${reason(err)}). Check ` +
150
104
  "OURPR_API_URL and the network.");
151
105
  }
152
- // OUTSIDE THE TRY, deliberately. Thrown inside it, this was caught by the
153
- // network handler two lines up and re-reported as "could not reach ourpr" —
154
- // an error that names the wrong cause is worse than the status code it
155
- // replaced.
106
+ // Outside the try, so a redirect is not reported as a network failure.
156
107
  if (response.status >= 300 && response.status < 400) {
157
108
  audit(path, `${response.status}-redirect`, Date.now() - started);
158
109
  throw new ApiError("ourpr redirected the request, and this server does not follow " +
@@ -162,13 +113,11 @@ export async function get(path) {
162
113
  if (!response.ok) {
163
114
  let detail = "";
164
115
  try {
165
- // CLEANED LIKE ANY OTHER REMOTE TEXT. Today `detail` is written by the
166
- // backend, but a future backend change could derive it from runner
167
- // text, and this is the one place it enters an error message.
116
+ // detail is remote text and is cleaned like any other.
168
117
  detail = cell((await response.json()).detail, 200);
169
118
  }
170
119
  catch {
171
- // A non-JSON error body. The status still carries the meaning.
120
+ // The status carries the meaning without a body.
172
121
  }
173
122
  audit(path, `${response.status}`, Date.now() - started);
174
123
  throw new ApiError(guidance(response.status, detail, response.headers.get("retry-after") ?? undefined));
@@ -178,9 +127,7 @@ export async function get(path) {
178
127
  body = (await response.json());
179
128
  }
180
129
  catch {
181
- // A 200 whose body is not JSON - a proxy page, a captive portal. The
182
- // parse error's message can carry a fragment of that body, so the error
183
- // is replaced rather than passed on.
130
+ // A non-JSON body can leak into the parse error, so the error is replaced.
184
131
  audit(path, "200-not-json", Date.now() - started);
185
132
  throw new ApiError(`ourpr at ${safeOrigin()} answered 200 with a body that is not JSON. ` +
186
133
  "Check OURPR_API_URL - it should be the API base, ending in /api.");
@@ -188,19 +135,11 @@ export async function get(path) {
188
135
  audit(path, "200", Date.now() - started);
189
136
  return body;
190
137
  }
191
- // ─── Units ──────────────────────────────────────────────────────────────────
192
- //
193
- // The API speaks metres and stores pace as a string, "7:43". An agent reads
194
- // miles and reasons about pace as text. Convert once, here, so no tool does it
195
- // twice and no two tools do it differently.
196
- /**
197
- * An id on its way into a URL path.
198
- *
199
- * Ids arrive from the agent, and an agent can be steered by text inside a
200
- * tool result. Unencoded, a crafted id could append query parameters or move
201
- * the request to a sibling path - fetch normalises "../" before sending.
202
- * Encoding makes the id one path segment and nothing more.
203
- */
138
+ export const get = (path) => request("GET", path);
139
+ /** The one write. The route it reaches needs the write scope and ourpr create. */
140
+ export const post = (path, body) => request("POST", path, body);
141
+ // Units: the API speaks metres and an agent reads miles. Convert once, here.
142
+ /** Encoded, so a crafted id stays one path segment. */
204
143
  export const pathId = (value) => encodeURIComponent(value);
205
144
  export const METERS_PER_MILE = 1609.344;
206
145
  export const miles = (meters) => meters == null ? null : Math.round((meters / METERS_PER_MILE) * 100) / 100;
@@ -215,14 +154,7 @@ export function duration(seconds) {
215
154
  }
216
155
  /** A day key, YYYY-MM-DD, out of whatever the API stored. */
217
156
  export const day = (iso) => (iso ?? "").slice(0, 10);
218
- /**
219
- * A calendar date to the Unix second the API wants.
220
- *
221
- * TOOLS TAKE DATES AND THE API TAKES TIMESTAMPS. An agent is asked "what did I
222
- * run in July", not "what did I run between 1751328000 and 1754006400", and
223
- * making it do that arithmetic is a step where it can quietly be wrong.
224
- * `end` pushes to the end of the day so a single-day window holds that day.
225
- */
157
+ /** A calendar date to a Unix second; "end" reaches the end of the day. */
226
158
  export function stamp(date, edge) {
227
159
  const time = edge === "start" ? "T00:00:00Z" : "T23:59:59Z";
228
160
  const ms = Date.parse(`${date}${time}`);
package/build/index.js CHANGED
@@ -2,60 +2,26 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { ApiError, day, duration, feet, get, miles, pathId, stamp } from "./api.js";
5
+ import { ApiError, METERS_PER_MILE, day, duration, feet, get, miles, pathId, post, stamp, } from "./api.js";
6
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. */
7
+ // Seven 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
+ const server = new McpServer({ name: "ourpr-mcp-server", version: "0.3.0" });
17
+ // Bounds one answer; when it bites, the answer says so.
52
18
  const DEFAULT_LIMIT = 100;
53
19
  const MAX_LIMIT = 500;
54
20
  const ok = (text, structured) => ({
55
21
  content: [{ type: "text", text }],
56
22
  structuredContent: structured,
57
23
  });
58
- /** Errors reach the agent as an error result with copy it can act on. */
24
+ // An error reaches the agent as an error result with copy it can act on.
59
25
  const fail = (err) => ({
60
26
  content: [
61
27
  {
@@ -65,12 +31,10 @@ const fail = (err) => ({
65
31
  ],
66
32
  isError: true,
67
33
  });
68
- /** One activity, flattened to what an agent reasons about. */
69
34
  function summarise(a) {
70
35
  return {
71
36
  id: String(a.id),
72
37
  date: day(a.date),
73
- // RUNNER-AUTHORED TEXT. Cleaned at the one boundary it crosses.
74
38
  name: cell(a.name),
75
39
  type: cell(a.activity_type, 24),
76
40
  miles: miles(a.distance_meters),
@@ -80,8 +44,7 @@ function summarise(a) {
80
44
  avg_hr: a.avg_heartrate ?? null,
81
45
  };
82
46
  }
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. */
47
+ // A markdown table costs fewer tokens than the same rows as objects.
85
48
  function table(rows) {
86
49
  const head = "| date | name | type | mi | pace | time | ft | hr |\n" +
87
50
  "|---|---|---|---|---|---|---|---|";
@@ -217,9 +180,7 @@ server.registerTool("ourpr_run_stream", {
217
180
  }, async ({ activity_id }) => {
218
181
  try {
219
182
  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.
183
+ // A summary, not the samples: a 10 mile run is about 8,000 numbers.
223
184
  const stats = (values, scale = 1) => {
224
185
  const nums = (values ?? []).filter((v) => v != null);
225
186
  if (!nums.length)
@@ -233,7 +194,7 @@ server.registerTool("ourpr_run_stream", {
233
194
  };
234
195
  };
235
196
  const channels = {
236
- // Stored in centimetres; feet is what the app speaks.
197
+ // Stored in centimetres; the app speaks feet.
237
198
  elevation_ft: stats(s.elev_cm, 0.0328084),
238
199
  heart_rate_bpm: stats(s.hr_bpm),
239
200
  power_w: stats(s.power_w),
@@ -268,15 +229,11 @@ server.registerTool("ourpr_run_laps", {
268
229
  const raw = await get(`/users/me/activities/${pathId(activity_id)}/laps`);
269
230
  const laps = raw.map((l, i) => {
270
231
  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.
232
+ // The payload carries no pace; moving seconds, as the app measures it.
274
233
  const secs = l.moving_seconds ?? l.duration_seconds ?? null;
275
234
  const perMile = mi && secs ? secs / mi : null;
276
235
  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.
236
+ // The API counts from zero; a watch counts from one.
280
237
  lap: (l.index ?? i) + 1,
281
238
  miles: mi,
282
239
  time: duration(secs),
@@ -328,8 +285,7 @@ server.registerTool("ourpr_rep_workouts", {
328
285
  times_s: g.times_s ?? null,
329
286
  })),
330
287
  }));
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.
288
+ // "None found" and "none exist" differ; only the first is true here.
333
289
  const text = workouts.length
334
290
  ? `${workouts.length} rep sessions in the ${data.scanned} activities read.\n\n` +
335
291
  workouts
@@ -356,8 +312,7 @@ server.registerTool("ourpr_detect_reps", {
356
312
  }, async ({ activity_id }) => {
357
313
  try {
358
314
  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.
315
+ // Bounded, so a dump cannot spend the agent's context.
361
316
  const json = JSON.stringify(detection);
362
317
  return ok(json.length > 4000
363
318
  ? `${json.slice(0, 4000)}… (truncated; read the structured result)`
@@ -409,11 +364,72 @@ server.registerTool("ourpr_similar_terrain", {
409
364
  return fail(err);
410
365
  }
411
366
  });
367
+ // ─── The week ───────────────────────────────────────────────────────────────
368
+ const PLAN_TAGS = ["easy", "workout", "race"];
369
+ server.registerTool("ourpr_plan_week", {
370
+ title: "Put runs on the runner's week",
371
+ description: "Write one planned run, or a week of them, onto days still ahead. Each " +
372
+ "lands on the runner's week as a plan they can see, edit and remove. " +
373
+ "Needs a token made with the write scope and ourpr create. " +
374
+ "Never logs a run.",
375
+ inputSchema: {
376
+ plans: z
377
+ .array(z.object({
378
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("YYYY-MM-DD, after today."),
379
+ miles: z.number().min(0.1).max(200).optional().describe("Planned distance."),
380
+ minutes: z.number().min(1).max(1440).optional().describe("Planned time."),
381
+ name: z.string().max(120).optional().describe("A short name for the run."),
382
+ note: z.string().max(500).optional().describe("A line the runner will read."),
383
+ tag: z.enum(PLAN_TAGS).optional().describe("How hard: easy, workout or race."),
384
+ is_long: z.boolean().optional().describe("The week's long run."),
385
+ }))
386
+ .min(1)
387
+ .max(14)
388
+ .describe("One run, or up to fourteen."),
389
+ },
390
+ outputSchema: {
391
+ written: z.array(z.record(z.string(), z.unknown())),
392
+ },
393
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
394
+ }, async ({ plans }) => {
395
+ try {
396
+ const body = {
397
+ plans: plans.map((p) => ({
398
+ planned_date: p.date,
399
+ distance_meters: p.miles == null ? null : Math.round(p.miles * METERS_PER_MILE),
400
+ duration_seconds: p.minutes == null ? null : Math.round(p.minutes * 60),
401
+ name: p.name ?? null,
402
+ note: p.note ?? null,
403
+ tag: p.tag ?? null,
404
+ is_long: p.is_long ?? false,
405
+ })),
406
+ };
407
+ const rows = await post("/users/me/planned-activities/week", body);
408
+ const written = rows.map((r) => ({
409
+ id: String(r.id),
410
+ date: day(r.planned_date),
411
+ name: cell(r.name),
412
+ miles: miles(r.distance_meters),
413
+ time: duration(r.duration_seconds),
414
+ tag: r.tag ?? null,
415
+ is_long: r.is_long,
416
+ }));
417
+ const text = `${written.length} plan${written.length === 1 ? "" : "s"} on the runner's week.\n\n` +
418
+ "| date | name | mi | time | tag |\n|---|---|---|---|---|\n" +
419
+ written
420
+ .map((w) => `| ${w.date} | ${w.name || "—"} | ${w.miles ?? "—"} | ${w.time ?? "—"} | ` +
421
+ `${w.tag ?? "—"}${w.is_long ? " · long" : ""} |`)
422
+ .join("\n");
423
+ return ok(text, { written });
424
+ }
425
+ catch (err) {
426
+ return fail(err);
427
+ }
428
+ });
412
429
  // ─── Start ──────────────────────────────────────────────────────────────────
413
430
  async function main() {
414
431
  await server.connect(new StdioServerTransport());
415
- // STDERR, ALWAYS. On a stdio server stdout is the protocol stream and one
416
- // `console.log` corrupts it.
432
+ // Stdout is the protocol stream; diagnostics go to stderr.
417
433
  console.error("[ourpr-mcp-server] ready");
418
434
  }
419
435
  main().catch((err) => {
package/build/safe.js CHANGED
@@ -1,84 +1,32 @@
1
- // Runner-authored text, on its way into an agent's context.
2
- //
3
- // EVERY TOOL RESULT HERE IS UNTRUSTED CONTENT. A run's name and description
4
- // are free text a person typed, or that arrived from a Strava or Garmin
5
- // import. They travel verbatim into the same token stream as the agent's
6
- // instructions, and a model has no hardware boundary between the two. That is
7
- // the whole of the prompt-injection problem and no library fixes it.
8
- //
9
- // Two defences, and they do different jobs.
10
- //
11
- // 1. NEUTRALISE THE STRUCTURE. A `|` or a newline in a name corrupts the
12
- // markdown table it lands in — an agent then reads columns that shifted,
13
- // which is a wrong answer rather than a refused one. Measured against
14
- // 1,000 of Joey's activities: zero names carry one today, the longest name
15
- // is 86 characters, and 400 runs carry a description up to 362. Nothing
16
- // prevents the next one. This is a correctness fix that happens to close an
17
- // injection surface.
18
- //
19
- // 2. NAME THE BOUNDARY. Wrapping the data in explicit delimiters and saying
20
- // once that what is inside is data does not solve injection. It measurably
21
- // reduces how often it lands, which is the honest claim for it, and it
22
- // costs one line per response rather than a preamble on every field.
23
- //
24
- // What this deliberately does NOT do is pattern-match for "ignore previous
25
- // instructions" and friends. That is whack-a-mole against an attacker who
26
- // writes one sentence differently, and it would give a false sense that the
27
- // content had been made safe.
28
- /** The longest a name may be before it is cut. Long enough for every real one
29
- * measured (86), short enough that a pasted paragraph cannot flood a row. */
1
+ // Runner-authored text is untrusted; it is neutralised, never pattern-matched.
2
+ // Longer than any measured name, shorter than a pasted paragraph.
30
3
  const MAX_NAME = 90;
31
- /** A description is prose and belongs on a single run, never in a table. */
4
+ // A description belongs on one run, never in a table.
32
5
  const MAX_NOTE = 300;
33
- /**
34
- * One field of runner-authored text, made safe to put in a table cell.
35
- *
36
- * Control characters and newlines become spaces, a pipe is escaped so the
37
- * column it sits in survives, and the whole thing is capped.
38
- */
6
+ /** One field of runner text, made safe for a table cell. */
39
7
  export function cell(value, max = MAX_NAME) {
40
8
  if (value === null || value === undefined || value === "")
41
9
  return "";
42
- // TAKES `unknown`, NOT `string` (2026-08-30). It was typed `string`, and
43
- // `activity_data.device` turned out to be an OBJECT, so `.replace` threw and
44
- // `ourpr_get_run` failed outright for every run — a whole tool lost to one
45
- // wrong field type. A tool that returns nothing for one field is a small
46
- // fault; a tool that throws is a total one, and this layer sits between the
47
- // agent and data whose shape the server does not control.
48
- //
49
- // A primitive is printed. Anything else returns "" rather than the string
50
- // "[object Object]": rendering junk into an agent's context is worse than
51
- // rendering nothing, and nothing is what the caller already handles.
10
+ // A field can be any JSON value; only a primitive is printed.
52
11
  if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
53
12
  return "";
54
13
  }
55
14
  const flat = String(value)
56
- // Every control character, newline and tab included. A newline in a cell
57
- // ends the row early and every column after it shifts.
15
+ // A newline in a cell ends the row early.
58
16
  .replace(/[\u0000-\u001F\u007F]/g, " ")
59
- // Zero-width and bidirectional marks: invisible in a transcript, and they
60
- // are how text can read one way to a person and another to a parser.
17
+ // Zero-width and bidirectional marks hide text from a reader.
61
18
  .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF\u2060\u00AD]/g, "")
62
- // The tag block, U+E0000-E007F: invisible characters that mirror ASCII,
63
- // which is the known channel for smuggling hidden instructions.
19
+ // The tag block mirrors ASCII invisibly.
64
20
  .replace(/[\u{E0000}-\u{E007F}]/gu, "")
65
21
  .replace(/\s+/g, " ")
66
22
  .trim();
67
23
  const clipped = flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
68
- // Escape rather than delete: a runner who named a run "5k | tempo" should
69
- // still read it back as they wrote it.
24
+ // Escaped, not deleted, so "5k | tempo" reads back as written.
70
25
  return clipped.replace(/\|/g, "\\|");
71
26
  }
72
- /** Prose from one run — a description or a note. Same cleaning, longer cap. */
27
+ /** Prose from one run, with the longer cap. */
73
28
  export const note = (value) => cell(value, MAX_NOTE);
74
- /**
75
- * Mark a block as the runner's own data rather than instructions.
76
- *
77
- * ONE LINE, NOT A PREAMBLE. It rides on every response that carries
78
- * runner-authored text, so its cost is paid on every call and its length is
79
- * part of its design. It is a reduction in how often injection lands, never a
80
- * guarantee, and the code must not be written as though it were one.
81
- */
29
+ /** Marks a block as data; it reduces injection and does not prevent it. */
82
30
  export function fenced(body) {
83
31
  return ("<ourpr-data>\n" +
84
32
  body +
package/build/types.js CHANGED
@@ -1,8 +1,2 @@
1
- // What the ourpr API returns, narrowed to the fields these tools read.
2
- //
3
- // DELIBERATELY PARTIAL. `DetailedActivity` on the backend carries far more
4
- // than this, and copying it whole would put every field into an agent's
5
- // context whether or not a tool uses it. These interfaces name what is read
6
- // and nothing else, which is the same whitelist discipline the backend's own
7
- // response model keeps.
1
+ // Deliberately partial: only the fields a tool reads.
8
2
  export {};
package/icon.png ADDED
Binary file
package/manifest.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ourpr-mcp-server",
4
- "display_name": "ourpr",
5
- "version": "0.2.0",
6
- "description": "Read your own ourpr running history in Claude.",
7
- "long_description": "Runs, mile splits, watch laps, streams, interval workouts and similar terrain, from your own ourpr account. Read only. The token comes from ourpr Settings and you can revoke it there at any time.",
4
+ "display_name": "ourpr.",
5
+ "version": "0.3.0",
6
+ "description": "Read your own ourpr running history in Claude, and plan the week ahead.",
7
+ "long_description": "Runs, mile splits, watch laps, streams, interval workouts and similar terrain, from your own ourpr account, plus one write: a plan on the week ahead, for a token made with the write scope. The token comes from ourpr Settings and you can revoke it there at any time.",
8
8
  "author": { "name": "Joey Flores", "url": "https://ourpr.app" },
9
9
  "homepage": "https://ourpr.app/your-runs",
10
+ "icon": "icon.png",
10
11
  "documentation": "https://github.com/joeyaflores/ourpr-mcp-server#readme",
11
12
  "support": "https://ourpr.app/support",
12
13
  "license": "MIT",
@@ -25,7 +26,7 @@
25
26
  "token": {
26
27
  "type": "string",
27
28
  "title": "Access token",
28
- "description": "From ourpr: Settings, Access tokens, New token.",
29
+ "description": "In ourpr: Profile, Settings, ourpr. mcp, New token.",
29
30
  "sensitive": true,
30
31
  "required": true
31
32
  }
@@ -37,7 +38,8 @@
37
38
  { "name": "ourpr_run_laps", "description": "The laps the watch recorded." },
38
39
  { "name": "ourpr_rep_workouts", "description": "Every interval workout in your history." },
39
40
  { "name": "ourpr_detect_reps", "description": "Whether one run was an interval workout." },
40
- { "name": "ourpr_similar_terrain", "description": "Runs that match a distance and a climb." }
41
+ { "name": "ourpr_similar_terrain", "description": "Runs that match a distance and a climb." },
42
+ { "name": "ourpr_plan_week", "description": "Put a plan on a day still ahead. The one write." }
41
43
  ],
42
44
  "compatibility": { "claude_desktop": ">=0.10.0", "platforms": ["darwin", "win32", "linux"], "runtimes": { "node": ">=22.14.0" } }
43
45
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ourpr-mcp-server",
3
- "version": "0.2.0",
4
- "description": "MCP server that gives an AI agent read access to your own ourpr running history",
3
+ "version": "0.3.0",
4
+ "description": "MCP server for your own ourpr running history: read it, and put a plan on the week ahead",
5
5
  "mcpName": "io.github.joeyaflores/ourpr-mcp-server",
6
6
  "type": "module",
7
7
  "bin": {
@@ -10,7 +10,8 @@
10
10
  "files": [
11
11
  "build",
12
12
  "manifest.json",
13
- "server.json"
13
+ "server.json",
14
+ "icon.png"
14
15
  ],
15
16
  "scripts": {
16
17
  "build": "tsc && node -e \"const fs=require('fs');const f='build/index.js';const c=fs.readFileSync(f,'utf8');if(!c.startsWith('#!')){fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c)};fs.chmodSync(f,0o755)\"",
package/server.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.joeyaflores/ourpr-mcp-server",
4
- "description": "Read your own ourpr running history in an AI agent. Runs, splits, laps, streams, interval workouts.",
4
+ "description": "Read your own ourpr running history in an AI agent, and put a plan on the week ahead. Runs, splits, laps, streams, interval workouts.",
5
5
  "repository": {
6
6
  "url": "https://github.com/joeyaflores/ourpr-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.2.0",
9
+ "version": "0.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ourpr-mcp-server",
14
- "version": "0.2.0",
14
+ "version": "0.3.0",
15
15
  "transport": { "type": "stdio" },
16
16
  "environmentVariables": [
17
17
  {