Skip to content

Stage 3A: compact tool catalogue (42k -> 22.6k tokens), tool sets, guides, prompts, follow-ups - #28

Merged
futureweb merged 10 commits into
mainfrom
feat/stage3-catalogue
Oct 10, 2026
Merged

futureweb merged 10 commits into
mainfrom
feat/stage3-catalogue

Conversation

@futureweb

@futureweb futureweb commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Stage 3A: make the tool catalogue much cheaper for the AI client and easier to use correctly, without losing capability.

Token size of tools/list

Measured with tiktoken cl100k_base on the compact JSON of the tools/list result ({"tools": [...]} as FastMCP serialises it: name, description, input schema, output schema, annotations), built with catalogue(permissions, toolset) for every configuration. chars/4 in brackets. "Before" is main d951844 (dumped from mcp.list_tools() with the same counter).

MCP_PERMISSIONS Tool set Tools before → after Tokens before Tokens after Change
read full 51 → 52 33,586 (35,396) 19,247 (20,993) −43 %
read,write (live ChatGPT connector) full 58 → 59 42,030 (43,709) 22,711 (24,445) −46 %
all full 66 → 67 46,241 (48,022) 24,919 (26,845) −46 %
read core 20 – 7,371 (8,074) –
read,write core 23 – 9,356 (10,010) target ≤ 12k ✓

Breakdown at read,write, before → after: descriptions 23,598 → 7,997; input schemas 14,001 → 12,963 (every parameter now has a description, constrained ones an enum; titles and null wrappers removed); output schemas 1,729 → 0. Server instructions: 1,255 characters (264 tokens) in the initialize answer. Longest description before: 6,608 characters (add_or_update_event; 48 tools over 900, 24 over 1,800); after: 895, all ≤ 900.

Per tool (all classes, full tool set): description chars, description / input / output schema tokens, total tokens before → after
Tool R/W Description chars Description tokens Input schema tokens Output schema tokens Total tokens
add_or_update_event (core) W 6608 -> 708 1639 -> 148 1906 -> 902 30 -> 0 3784 -> 1083
create_library_workout W 1209 -> 376 295 -> 77 1798 -> 772 30 -> 0 2176 -> 882
compare_workouts R 3821 -> 837 858 -> 205 479 -> 472 29 -> 0 1423 -> 708
find_similar_intervals R 3759 -> 867 826 -> 200 463 -> 434 30 -> 0 1374 -> 663
analyze_workout_execution R 4487 -> 813 1004 -> 185 246 -> 288 30 -> 0 1327 -> 504
analyze_climbs R 3649 -> 714 787 -> 166 306 -> 348 30 -> 0 1173 -> 543
get_durability R 3281 -> 875 760 -> 196 307 -> 391 29 -> 0 1146 -> 617
get_power_hr_efficiency R 2959 -> 781 674 -> 185 328 -> 362 31 -> 0 1084 -> 579
get_load_projection (core) R 2628 -> 897 708 -> 212 274 -> 468 29 -> 0 1064 -> 711
get_training_summary (core) R 3298 -> 874 781 -> 208 194 -> 256 29 -> 0 1055 -> 494
get_activity_report (core) R 3051 -> 800 667 -> 172 249 -> 300 29 -> 0 993 -> 502
get_intensity_distribution R 2438 -> 760 663 -> 179 201 -> 262 29 -> 0 940 -> 469
get_long_ride_fatigue_profile R 2004 -> 828 505 -> 211 338 -> 425 34 -> 0 933 -> 670
update_wellness (core) W 1948 -> 681 548 -> 152 262 -> 360 30 -> 0 898 -> 546
compare_power_streams R 2311 -> 779 526 -> 181 285 -> 320 29 -> 0 890 -> 529
get_activity_intervals (core) R 2960 -> 751 622 -> 174 183 -> 248 29 -> 0 880 -> 451
get_activity_streams R 2617 -> 725 568 -> 165 225 -> 279 29 -> 0 872 -> 475
get_coach_context (core) R 2667 -> 880 640 -> 208 138 -> 186 30 -> 0 852 -> 426
get_wellness_trends (core) R 2297 -> 822 557 -> 198 211 -> 244 32 -> 0 849 -> 475
get_training_load R 2357 -> 800 585 -> 200 170 -> 206 29 -> 0 828 -> 434
add_events_bulk W 2611 -> 483 614 -> 106 89 -> 90 29 -> 0 812 -> 228
delete_events_by_date_range W 2218 -> 652 523 -> 143 172 -> 214 31 -> 0 782 -> 394
get_best_efforts (core) R 2048 -> 668 474 -> 164 227 -> 241 30 -> 0 777 -> 436
update_sport_settings W 1537 -> 640 426 -> 144 259 -> 286 30 -> 0 767 -> 463
get_recovery_snapshot (core) R 2148 -> 868 501 -> 197 174 -> 186 29 -> 0 752 -> 414
get_fatigue_resistance R 2016 -> 724 483 -> 176 181 -> 217 32 -> 0 744 -> 424
compare_best_efforts R 1733 -> 765 414 -> 185 252 -> 258 30 -> 0 741 -> 474
get_activities (core) R 1654 -> 677 388 -> 160 278 -> 308 28 -> 0 739 -> 496
get_submax_test_trends R 1707 -> 732 393 -> 183 245 -> 282 32 -> 0 717 -> 499
get_nutrition_summary R 1584 -> 699 352 -> 157 226 -> 240 31 -> 0 656 -> 429
get_pace_curves R 1217 -> 485 332 -> 119 204 -> 261 31 -> 0 622 -> 412
get_athlete_power_curves R 1093 -> 494 267 -> 120 239 -> 246 32 -> 0 587 -> 399
validate_workout (core) R 1390 -> 555 305 -> 114 209 -> 207 29 -> 0 584 -> 347
get_activity_details (core) R 1719 -> 658 367 -> 153 124 -> 182 29 -> 0 559 -> 363
update_custom_item W 1184 -> 412 279 -> 89 198 -> 238 29 -> 0 556 -> 363
get_hr_curves R 981 -> 435 269 -> 98 189 -> 226 30 -> 0 538 -> 353
add_or_update_note W 1128 -> 398 254 -> 89 195 -> 152 30 -> 0 526 -> 274
get_fueling_analysis (core) R 884 -> 750 224 -> 179 209 -> 239 30 -> 0 503 -> 448
get_wellness_data (core) R 1289 -> 709 286 -> 169 132 -> 91 31 -> 0 493 -> 294
update_activity (core) W 1082 -> 585 258 -> 124 161 -> 194 28 -> 0 492 -> 351
create_custom_item W 989 -> 376 247 -> 77 164 -> 215 29 -> 0 485 -> 325
get_activity_data_audit R 870 -> 677 205 -> 155 193 -> 196 30 -> 0 467 -> 379
get_plan_compliance R 1364 -> 754 287 -> 153 97 -> 61 30 -> 0 451 -> 245
get_sport_settings (core) R 1037 -> 594 252 -> 140 119 -> 101 30 -> 0 440 -> 270
get_events (core) R 925 -> 377 223 -> 89 149 -> 128 28 -> 0 439 -> 242
get_training_zones R 921 -> 378 221 -> 85 134 -> 138 29 -> 0 422 -> 249
get_training_plan (core) R 937 -> 473 226 -> 108 128 -> 92 29 -> 0 419 -> 226
get_activity_histogram R 958 -> 479 241 -> 112 110 -> 143 29 -> 0 415 -> 282
get_gear_details R 990 -> 572 231 -> 117 96 -> 91 30 -> 0 394 -> 239
get_weekly_summary R 964 -> 598 228 -> 144 97 -> 61 30 -> 0 391 -> 235
add_event_from_library W 846 -> 352 199 -> 86 97 -> 72 30 -> 0 369 -> 191
get_event_by_id (core) R 795 -> 228 184 -> 47 114 -> 111 30 -> 0 364 -> 186
get_athlete_profile R 805 -> 474 188 -> 116 95 -> 73 30 -> 0 352 -> 217
preview_workout (core) R 699 -> 369 160 -> 75 99 -> 130 29 -> 0 324 -> 231
list_activity_streams R 855 -> 560 177 -> 112 72 -> 63 29 -> 0 314 -> 203
get_library_workout R 617 -> 158 142 -> 40 101 -> 87 30 -> 0 309 -> 152
delete_library_workout W 667 -> 234 150 -> 48 85 -> 57 30 -> 0 307 -> 138
delete_custom_item W 548 -> 308 130 -> 68 81 -> 47 29 -> 0 281 -> 147
get_gear_list R 529 -> 318 130 -> 77 79 -> 46 30 -> 0 276 -> 151
get_server_status (core) R 700 -> 254 149 -> 51 55 -> 35 29 -> 0 266 -> 110
get_workout_library R 410 -> 145 97 -> 31 87 -> 46 30 -> 0 247 -> 102
get_custom_item_by_id R 327 -> 214 78 -> 47 83 -> 45 31 -> 0 225 -> 120
delete_event W 311 -> 331 78 -> 73 80 -> 46 28 -> 0 221 -> 150
get_custom_items R 308 -> 298 77 -> 63 64 -> 23 29 -> 0 201 -> 113
add_activity_message W 266 -> 238 66 -> 51 68 -> 53 29 -> 0 199 -> 137
get_activity_messages R 237 -> 108 57 -> 25 56 -> 33 29 -> 0 172 -> 83
get_guide (core) R - -> 317 - -> 64 - -> 82 - -> 0 - -> 171

What changed

  1. Measure first: numbers above; tests/test_catalogue.py keeps budgets per permission set and tool set (chars/4 with ~10 % headroom).
  2. No duplicate results: @tool registers every tool with structured_output=False (centrally in mcp_instance.tool); no tool returns a JSON object on purpose (JSON output is JSON text). On the wire (streamable HTTP via build_http_app, auth off, live read-only call of get_coach_context):
    • before: outputSchema on 58 of 58 tools, the result carried the text and structuredContent: {"result": <same text>};
    • after: no outputSchema, content: [{type: text}] only, structuredContent absent, isError: false. ChatGPT and Claude read the text content as before.
  3. No api_key: removed from every tool function and its schema (and from the private helpers of the tools package, the tool guard and the time zone resolver); make_intervals_request uses API_KEY from the environment as before. A client that still sends api_key has it ignored. athlete_id stays, described as "Default: the configured athlete".
  4. Shorter, sharper descriptions: ≤ 900 characters, first sentence says when to use the tool ("Use ..."; write tools "Use only when the athlete asks to ..."). Every parameter has a schema description (Annotated[..., Field(description=...)], shared aliases in utils/params.py); constrained parameters are Literal enums (output_format, detail_level, sort_by, group_by, zone_type, zone_basis, threshold_as, metric, target, environment/indoor_outdoor, temperature_source, threshold_unit, category, item_type, visibility, topic), normalised case-insensitively, an empty value means the default; integer scales carry minimum/maximum (wellness 1-4, feel 1-5, RPE 1-10) while the tools keep their own range messages. The long explanations moved, complete, into guides/methods/<topic>.md, served as intervals://methods/<topic> (activity-data, execution, climbs, power-meters, load, intensity, durability, summary, comparisons, fatigue, wellness, fueling); the workout syntax into intervals://workout-syntax, referenced from the write and validate tools. Workout fields Intervals.icu fills itself are hidden from the workout_doc schema (still accepted).
  5. Server instructions (≤ 1.5k): get_coach_context for weekly analysis, get_activity_report for one activity, compact first, missing values (today's wellness) are not normal, writes only on explicit request, preview/dry-run first, read the workout syntax before writing workouts.
  6. Tool sets: MCP_TOOLSET=full (default; the live connector keeps every tool) or core (23 tools): get_server_status, get_guide, get_coach_context, get_training_summary, get_load_projection, get_events, get_event_by_id, get_training_plan, get_activities, get_activity_report, get_activity_details, get_activity_intervals, get_best_efforts, get_fueling_analysis, get_recovery_snapshot, get_wellness_data, get_wellness_trends, get_sport_settings, validate_workout, preview_workout, add_or_update_event ✎, update_wellness ✎, update_activity ✎ (MCP_PERMISSIONS still applies; descriptions, prompts and guides mark tools outside the set "(full tool set)"). Documented in the README; get_server_status and --doctor show the active tool set.
  7. Tests (tests/test_catalogue.py): description ≤ 900 and starts with "Use", token budget for core and full per permission set, no api_key in any schema or signature, no output schema, every parameter described, every constrained parameter has an enum, schema compaction keeps meaningful nulls, core set registration in a fresh process, instructions, guides complete and every intervals:// reference resolvable, every tool named in prompts/guides exists, the new prompts, and an MCP round trip (initialize, tools/list, tools/call, prompts/list, resources/list, resources/read) through a real client session.
  8. Prompts and resources: new prompts race_week, fueling_review, plan_health_check, coach_handoff; intervals://guide rewritten (phase-6 tools, include_route_history, scenario/target_date, today's wellness completeness); new read tool get_guide(topic) returns the guides for clients that cannot read resources (ChatGPT connectors only call tools), so no capability is lost by moving text out of the descriptions.
  9. Follow-ups: (a) get_activity_data_audit reports a failed streams request as the API error ("stream coverage not checked", JSON streams.error) instead of "no streams"; (b) every id in a phase-6 API path goes through seg() (a test scans every API path f-string of the package); (c) --doctor validates MCP_TOOL_MAX_REQUESTS, MCP_TOOL_TIMEOUT_S, MCP_MAX_OUTPUT_CHARS, ATHLETE_TIMEZONE, MCP_TOOLSET, OAUTH_REFRESH_REUSE_GRACE/_REVOKE, OAUTH_LOGIN_GLOBAL_RATE_LIMIT, OAUTH_REQUIRE_PRIVATE_KEY_JWT (each problem listed; OAuth ones are warnings without MCP_AUTH=oauth); (d) daily metrics (Endurance Score, Hill Score/Strength/Endurance, Fitness Age, race predictions, acute training load, VO2max, daily goals) are a group of their own in today's completeness (daily_metric, own line) via utils/wellness_completeness.py; (e) CHANGELOG 1.0.0b1 regrouped into Security / Added / Changed / Fixed with the phase labels as subsections (a script checked that no line was lost), plus the stage 3 entries.

Changed tool signatures

  • api_key removed from the 65 tools that had it (all but preview_workout). For Python callers this is breaking: in 45 tool functions api_key was not the last parameter, so positional arguments after it move one place to the left (get_activities("i1", None, "2026-01-01") would now bind the date to end_date); call tool functions with keyword arguments. No in-repo caller relies on that, and MCP clients are not affected (a stray api_key argument is ignored). get_server_status(api_key, output_format) → get_server_status(output_format).
  • New tool get_guide(topic="usage") (read); topics also accept intervals://methods/load, methods/load, workout_syntax ...
  • Enum parameters normalise case and blanks, empty = default. output_format keeps the old leniency (anything but json = text); other enums reject unknown values on MCP calls (main rejected most of them with a tool error already, see the review table). item_type includes FITNESS_TABLE.
  • get_load_projection(target_date=...) now also accepts today (start of race day).
  • No tool was renamed, no permission class changed; all other parameter names, order and defaults and the tool logic are unchanged (signatures compared with main).

Review fixes (second round, commits 2ced45e and bcc305a)

  • R28-1 lenient output_format (non-json = text; streams: text → summary, csv → full); R28-2 get_guide topic forms; R28-3 MINS_KM/MINS_MILE value format in the workout_doc Value schema; R28-4 race_week asks for/confirms the race date, target_date = today allowed; R28-5 core set + 4 tools and "(full tool set)" marks; R28-6 FITNESS_TABLE; R28-7 wording above; R28-8 one parser for --doctor and the server (inf/nan/non-positive limits fall back to the default; the doctor names the value really used).
  • Tests: tests/conftest.py blocks every non-loopback network access (a leak fails the test); test_get_activities no longer calls intervals.icu for the gear catalogue.
  • Project icon: serverInfo.websiteUrl and icons (served URLs with MCP_PUBLIC_URL, else the SVG as data: URI); unauthenticated /favicon.ico, /favicon.png, /icon.png, /icon.svg, /apple-touch-icon.png; logo on the sign-in page (CSP img-src 'self') and in the README; assets in intervals_mcp_server/assets (wheel) and docs/assets; CI checks guides and icons from the installed wheel.

For connected clients (ChatGPT / Claude)

After the deploy the clients must refresh the tool list (ChatGPT: refresh the connector in the app settings; Claude: reconnect the connector) to get the new descriptions, schemas, get_guide and the new prompts. The OAuth grant stays valid; no re-consent is needed. The live connector keeps every tool (MCP_TOOLSET defaults to full).

Checks

  • ruff check ., mypy src tests (116 files): clean.
  • pytest: 902 passed (no network access: guarded in conftest).
  • Pylint gate pylint --disable=C0301 --fail-under=0 --fail-on=E,F $(git ls-files '*.py'): exit 0 (no new messages in the changed modules).
  • Wire round trip against build_http_app(mcp, "streamable-http"), auth off, live Intervals.icu read-only (non-GET requests blocked, key never printed): initialize (instructions 1,255 chars), tools/list (59 tools at read,write, 97,444 chars, no output schema, no api_key), tools/call get_coach_context(detail_level="Compact") (one text block, no structuredContent), prompts/list (13), resources/list (15).
  • Live read-only check of today's completeness: the Garmin endurance/hill scores, fitness age, race predictions and acute load now appear as "Daily metrics not yet available".

Notes found while rewriting (not changed here)

  • get_durability text labels the efficiency-factor recent mean "last 7 d" even with another recent_days.
  • compare_workouts: a reference activity older than the newest limit activities is cut and the pattern silently falls back to the newest activity; with query + end_date + reference and no start_date only 90 days are fetched although the note says 365.
  • compare_best_efforts with activity_ids does not de-duplicate ids, and fetches all ids before limit applies.
  • The JSON output of get_coach_context and get_training_summary ignores detail_level.
  • get_activities / get_wellness_data: a past end_date without start_date gives an inverted range (start defaults to 30 days before today).

🤖 Generated with Claude Code

futureweb and others added 10 commits October 10, 2026 10:41
…ronment

Tools and the private helpers of the tools package no longer take or pass an
API key; make_intervals_request falls back to API_KEY as before. The tool guard
and the athlete time zone resolver no longer forward a key. Breaking for
Python callers that passed api_key to a tool function; all other positional
and keyword arguments are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in phase-6 URLs

get_activity_data_audit showed a failed streams request as "no streams" (the
endpoint returned none of the listed streams); it now reports the API error
and leaves the coverage unchecked (JSON streams.error). The phase-6 code
(data audit, fueling, long-ride fatigue, submax tests, route history,
multi-ride power comparison) formats every id into an API path with seg();
a test checks every API path f-string of the package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Garmin Endurance Score, Hill Score / Strength / Endurance, (achievable) Fitness
Age, predicted race times, the acute training load, VO2max and daily goals are
device values computed once a day, not overnight values. today_completeness
groups them as daily_metric (between night/morning values and day totals) and
the completeness line lists them on a line of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, instructions, guides

- @tool registers every tool with structured_output=False: FastMCP no longer adds
  an output schema and a {"result": ...} structured copy of the same text.
  The cleaned docstring is the description.
- IntervalsFastMCP.add_tool compacts the input schema: no generated titles,
  optional parameters with a null default without the anyOf/null wrapper.
  Workout fields that Intervals.icu fills itself are hidden from the schema
  (still accepted).
- MCP_TOOLSET=full (default) or core (19 curated tools; toolsets.py);
  get_server_status shows the tool set and the tools outside it.
- Server instructions (initialize) with the usage rules.
- Guides as resources intervals://guide, intervals://workout-syntax and
  intervals://methods/<topic>, and get_guide(topic) for clients that only call
  tools; the usage guide covers the phase-6 tools and today's completeness.
- utils/params.py: shared Annotated parameter types with descriptions and
  case-insensitive Literal enums (an empty value means the default).
- catalogue(permissions, toolset) builds the tools/list of any configuration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- race_week: race and remaining sessions (get_events), form on race day with
  get_load_projection(target_date=...), recovery, a fueling plan per hour from
  the athlete's get_fueling_analysis history, weather (no forecast in this
  server; past conditions on the same route) and a logistics checklist.
- fueling_review: one activity or the long sessions of the last weeks.
- plan_health_check: get_load_projection of the planned weeks with its plan
  checks and a what-if scenario that is never written.
- coach_handoff: compact summary with ids for another coach or session.
- workout_planning_validation points to the workout syntax first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hod guides

Every tool description is at most 900 characters and its first sentence says
when to use the tool (write tools: only on the athlete's request). Parameters
are described in the input schema (Annotated + Field) instead of an Args block;
parameters with fixed values are Literal enums (output_format, detail_level,
sort_by, group_by, zone_type, zone_basis, threshold_as, metric, target,
environment, temperature_source, threshold_unit, category, item_type,
visibility, topic), integer scales (wellness 1-4, feel 1-5, RPE 1-10) carry
their range. The long method explanations moved, complete, into
guides/methods/<topic>.md (intervals://methods/<topic>, get_guide).
Tool names, permission classes, parameter names, order and defaults and the
tool behaviour are unchanged.

tools/list at MCP_PERMISSIONS=read,write: 42.0k -> 22.6k tokens (cl100k);
MCP_TOOLSET=core: 8.1k. tests/test_catalogue.py checks the limits, the budget
per permission set and tool set, enums, descriptions, no api_key, no output
schema and an MCP round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ettings

MCP_TOOL_MAX_REQUESTS, MCP_TOOL_TIMEOUT_S, MCP_MAX_OUTPUT_CHARS, ATHLETE_TIMEZONE
and MCP_TOOLSET are checked one by one (the server itself still falls back to
the default for a wrong limit or time zone); OAUTH_REFRESH_REUSE_GRACE,
OAUTH_REFRESH_REUSE_REVOKE, OAUTH_LOGIN_GLOBAL_RATE_LIMIT and
OAUTH_REQUIRE_PRIVATE_KEY_JWT are errors with MCP_AUTH=oauth and warnings
otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…by type

README: tool sets (MCP_TOOLSET), the compact catalogue, get_guide, the new
prompts and resources, refreshing the tool list after an update. .env.example:
MCP_TOOLSET. AGENTS/CONTRIBUTING: the description and parameter rules.
CHANGELOG 1.0.0b1: regrouped into Security / Added / Changed / Fixed with the
phase labels kept as subsections (no entry lost), plus the stage 3 entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r parity, no network in tests

- R28-1: output_format accepts every value the tools always took: anything but
  json (or application/json) means text; get_activity_streams maps text to
  summary and csv/table to full. The schema still shows the enum.
- R28-2: get_guide accepts intervals://methods/load, methods/load,
  workout_syntax, "Workout Syntax", guide, syntax ...
- R28-3: the Value schema of the workout_doc states the MINS_KM/MINS_MILE
  value format (seconds or decimal minutes; 5.35 is 5:21, not 5:35).
- R28-4: race_week asks for or confirms the race date instead of assuming the
  next A race; get_load_projection accepts target_date = today (start of race
  day, no taper search).
- R28-5: core set + get_event_by_id, get_training_plan, preview_workout and
  get_wellness_trends (23 tools, 9.4k tokens at read,write); with a tool set
  other than full, tool descriptions, prompts and guides mark the tools outside
  it "(full tool set)".
- R28-6: item_type offers FITNESS_TABLE (OpenAPI CustomItem.type).
- R28-8: --doctor and the server share the parsers of MCP_TOOL_MAX_REQUESTS,
  MCP_TOOL_TIMEOUT_S and MCP_MAX_OUTPUT_CHARS and name the value really used;
  inf/nan/non-positive limits fall back to the default (inf broke every call).
- Tests: conftest blocks every non-loopback network access (a leak fails the
  test); test_get_activities mocks the gear catalogue instead of calling
  intervals.icu. Small wording fixes (monotony > 2.0, API key error text,
  pace_units no longer suggested in the workout syntax).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he sign-in page and README

- serverInfo carries websiteUrl (GitHub) and icons: with MCP_PUBLIC_URL the SVG
  and PNGs served by this server, without it (stdio) the SVG as a data: URI.
- The HTTP transports serve /favicon.ico (48 and 128 px), /favicon.png,
  /icon.png, /icon.svg and /apple-touch-icon.png without authentication, with
  cache headers, nosniff and a locked-down CSP for the SVG.
- The sign-in/consent page shows the logo (CSP img-src 'self') and links the
  favicon; the README shows the logo from docs/assets.
- The icon files live in intervals_mcp_server/assets (part of the wheel); CI
  checks that the installed wheel can read the guides and the icons.
- Docs: README and CHANGELOG for the icon and the review fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@futureweb
futureweb merged commit b6758ac into main Oct 10, 2026
8 checks passed
@futureweb
futureweb deleted the feat/stage3-catalogue branch October 10, 2026 10:03
futureweb added a commit that referenced this pull request Oct 10, 2026
…echo) and six fixes from #28 (#29)

Stage 3B: make every write predictable and verifiable for the athlete,
and fix the six bugs noted in #28. The existing safety stays as it is
(validation before writes, range delete only with `confirm_ids` from a
preview, `replace_workout=true` before text replaces a structured
workout, empty texts never wipe, `clear_*` flags). Nothing is created,
renamed, paired or deleted automatically.

## Write safety

### 1. `dry_run` on every create/update tool
New parameter `dry_run` (default `false`, so existing calls behave
exactly as before) on `add_or_update_event`, `add_or_update_note`,
`add_events_bulk`, `add_event_from_library`, `create_library_workout`,
`create_custom_item`, `update_activity`, `update_wellness`,
`update_custom_item`, `update_sport_settings`, `add_activity_message`.

- A dry run runs every check of the real call, including the reads it
needs (duplicate check, the current event before a date change, the
custom item content to merge, the sport settings), and returns compact
JSON:
`{"dry_run":true,"message":...,"request":{"method","path","params","body"},"validation":{"ok":true,"warnings":[...]},
...}`. `body` is exactly what would be sent after all defaults and
merges (e.g. the merged custom item content, the rendered workout text,
`color: green` of a new note); `update_sport_settings` also shows the
current values, `add_events_bulk` the indexes sent and the refused
entries.
- A refusal (invalid workout, duplicate, nothing to change) is returned
exactly as the real call would return it.
- Second line of defence: the tool guard runs a call with `dry_run=true`
(also one nested in another tool call) inside
`api.client.read_only_requests()`, so `make_intervals_request` refuses
every non-GET request during a dry run (also covers
`delete_events_by_date_range`, whose default is `dry_run=true`).

### 2. Duplicate check before creating events
`add_or_update_event` / `add_or_update_note` without `event_id`,
`add_events_bulk` and `add_event_from_library` read the events of the
dates first (one GET over the date range, checked against the tool
call's request budget) and refuse a create when that day already has an
event of the same category and the same sport (when both have one) with
the same name (case/whitespace-insensitive) or the same non-trivial
content (workout text with at least two step lines or a repeat: its step
and repeat lines are compared; plain text with at least two lines). A
single generic line (`- 45m Z2 HR`, "Rest day") is never compared, so a
brick day and AM/PM sessions with different names are created. The
answer names the existing event id and name and how to proceed
(`event_id=...` or `allow_duplicate=true`). Bulk decides per entry: an
entry duplicating an existing event or repeating an earlier entry
exactly is listed under `refused` and not written; entries with the same
day, sport and name but different content are planned double sessions
and are created (with a warning). If the events cannot be read, nothing
is written and the answer says to try again (no hint to skip the check).

### 3. Read-back after every event / library write
After a successful write the stored object is read (`GET /events/{id}`,
`GET /workouts/{id}`; bulk: one `GET /events` over the dates of the
created events) and the answer reports what Intervals.icu stored and
parsed: date (time only when not midnight), name, category, sport,
planned duration, load, timed steps parsed vs sent, and parse warnings:
steps dropped/merged/added, repeat counts changed (labelled headers such
as "Main set 3x" count), a step's duration or distance changed, a target
missing or in other units (absolute paces sent as `MINS_KM` … and stored
as `secs/km` … are the same unit and are compared in seconds, 2 s
tolerance), no planned duration, fields stored with another value (e.g.
a planned moving time replaced by the steps' total). Notes, holidays,
sick and injured days: date, category, name and text length only. Text
answers get one `Read-back:` paragraph (≤ 600 characters).
`add_events_bulk` answers compact JSON with status, id, date, name and
short warnings per entry; `detail_level="full"` adds what was stored. If
the read-back fails: "the write succeeded but event N could not be
verified (...)".

Example (live data, read-only: the comparison applied to a stored
event):
`Read-back: Intervals.icu stored 2026-10-06T00:00:00 WORKOUT Ride
'Ultimate – 3×12 min Sweet Spot', 1:08:00, load 79, 7 steps parsed (7
sent), paired with i194378945. Parse warnings: none.`

### 4. Deletions echo what was deleted
`delete_event` reads the event first and answers e.g. `Deleted event 5:
2026-10-13 WORKOUT Ride 'Done ride' (paired with activity i9).` A
missing id (404, verified live read-only: Intervals.icu answers 404 for
a missing event and custom item, `{}` for a missing library workout)
answers `Event 5 not found; nothing was deleted.` without a DELETE; a
DELETE answered with 404 says it was already gone.
`delete_library_workout` and `delete_custom_item` do the same (library:
id, name, sport, folder).

### Requests added per write
| Write | Added requests |
| --- | --- |
| create one event (`add_or_update_event`, `add_or_update_note`,
`add_event_from_library`) | +1 GET duplicate check, +1 GET read-back
(`allow_duplicate=true`: only the read-back) |
| update one event | +1 GET read-back |
| `add_events_bulk` | +1 GET duplicate check over the date range, +1 GET
read-back |
| `create_library_workout` | +1 GET read-back |
| `delete_event` | +1 GET before the DELETE |
| `delete_library_workout`, `delete_custom_item` | none (they read first
already) |
| other writes (`create_custom_item`, `update_activity`,
`update_wellness`, `update_custom_item`, `update_sport_settings`,
`add_activity_message`) | none |
| any dry run | only the reads of the real call, no write |

## Fixes (noted in #28)
- **a** `get_durability`: the recent efficiency-factor mean is labelled
with `recent_days` (was always "last 7 d"); JSON
`efficiency.<sport>.recent_days`.
- **b** `compare_workouts`: the reference is fetched by id, always takes
one of the `limit` places and is always the pattern (it was cut when
older than the newest `limit` activities and the newest activity
silently became the pattern). A reference without a main set says so.
- **c** `compare_workouts` with `query` + `end_date` + reference and no
`start_date`: the listed window is now the one the note names (365 days
before the reference up to `end_date`; it was 90 days before
`end_date`). A name search with a reference and no dates lists that
window too instead of the newest search results.
- **d** `compare_best_efforts` with `activity_ids` de-duplicates and
caps to `limit` before any request (`cap_ids`/`ids_note`) and says what
was dropped.
- **e** The JSON of `get_coach_context` and `get_training_summary`
follows an explicit `detail_level` (compact / standard / full, with
`detail_level` in the payload). Without `detail_level` the JSON keeps
the previous full structure (backwards compatible; listed under
Changed). Live: coach context compact 8.6k characters vs 14.5k full,
training summary 3.5k vs 30k.
- **f** `get_activities` / `get_wellness_data` with a past `end_date`
and no `start_date`: the default start is 30 days before the earlier of
`end_date` and today (it was 30 days before today, after the end).

## Catalogue (`tools/list`, tiktoken cl100k, chars/4 in brackets)
| `MCP_PERMISSIONS` | Tool set | Before (main b6758ac) | After | Budget
(chars/4) |
| --- | --- | --- | --- | --- |
| `read` | full | 19,247 (20,993) | 19,267 (21,011) | 23,000 |
| `read,write` (live connector) | full | 22,711 (24,445) | **23,085**
(24,884), +374 | 26,500 |
| `all` | full | 24,919 (26,845) | 25,527 (27,557), +608 | 29,000 |
| `read,write` | core | 9,356 (10,010) | 9,501 (10,177), +145 | 11,000 |

New parameters have short descriptions (`dry_run`: "true = check and
return the exact request, write nothing"; `allow_duplicate`: "Create
even if that day has the same event (name or workout)"); the details are
in the new **Write safety** section of `intervals://guide`
(`get_guide`), the workout syntax guide and the README. All descriptions
stay ≤ 900 characters; server instructions 1,303 characters.

## Review fixes (commit 4cb9a29)
R29-1 absolute pace units (`MINS_KM` ≡ `secs/km` …, values compared in
seconds); R29-2 labelled repeat headers ("Main set 3x"); R29-3
duplicates need the same sport and non-trivial content; R29-4 bulk
refuses only exact repeats within the list, double sessions are created;
R29-5 no `allow_duplicate` hint when the check could not run; R29-6 one
range GET for the duplicate check; R29-7 compact bulk answer
(`detail_level`); R29-8 notes read back with date, category, name, text
length; R29-9 `create_custom_item` has `dry_run`, docs complete; R29-10
nested dry runs read-only too; R29-11 JSON default unchanged without
`detail_level`.

## Tests
- `tests/test_write_safety.py` (41): every dry run end to end through
the real API client with a recording HTTP client (only GETs leave,
body/path/method checked), dry-run bodies after defaults and merges,
refusals identical to the real call, the guard refusing a write of a
buggy tool during a dry run, the range-delete preview running read-only,
duplicate detection (name case/whitespace, same workout with another
intro, other category not refused, `allow_duplicate`, failed day read,
notes, library, bulk per entry incl. repeats within the list, all
refused, request budget), read-back (summary, parse warnings for dropped
steps/repeats/units/durations/missing duration/fields, ≤ 600 characters,
failed read-back, updates, library workouts, library events without
steps), deletions (echo, not found, already gone, read error) and a
tools/call round trip of the new parameters.
- `tests/test_stage3b_fixes.py` (9): one regression test per bug; all 9
fail on `main`.
- Existing tests adapted where answers now carry the read-back or the
mocks have to answer the duplicate-check GET.

## Checks
- `ruff check .`, `mypy src tests` (121 files): clean.
- `pytest`: 952 passed (902 before); network access stays blocked by
`tests/conftest.py`.
- Pylint gate `pylint --disable=C0301 --fail-under=0 --fail-on=E,F $(git
ls-files '*.py')`: exit 0, no new messages compared with `main`.
- Live, read-only only (credentials loaded in a subshell, key never
printed, a runner that blocks every non-GET request before it leaves the
process): dry runs of all eleven tools (all requests GET, 0 blocked
attempts: the tools never tried to write), duplicate refusal of a real
event (2026-10-06, event 142029202) by name and by steps, while a Run
with the same name or text is not refused; bulk with an exact repeat
(refused) and a double session (created); the read-back comparison
applied to all 85 stored events of the calendar, both their own text and
their own workout_doc (0 false parse warnings), 404 answers for missing
ids, the JSON defaults and the six fixes.

## Not verified
- No write was made against Intervals.icu (by design): the read-back
after a real write, the bulk read-back and the parse warnings were
verified with mocks and by applying the comparison to existing stored
events, not on a freshly written event. The stored spelling of absolute
paces other than `secs/km` (`secs/mile`, `secs/100m` …) and the parse of
`power_zone`, `hr_zone`, `%hr`, `%mmp`, `maxeffort`, `freeride` targets
are not confirmed (the calendar has none).
- `add_event_from_library` dry run live only up to "No library workout
found" (the athlete's library has no workouts); the full path is covered
by tests.

## For connected clients
After the deploy the clients should refresh the tool list (ChatGPT:
refresh the connector; Claude: reconnect) to see `dry_run` /
`allow_duplicate`. Answers of the write tools now end with a
`Read-back:` paragraph; `delete_event` answers with text instead of the
raw API JSON; `add_events_bulk` answers compact JSON with
`status`/`date` per entry.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant