Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ __pycache__/
*.log
.python-version
.boostcamp/
docs/superpowers/
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Follow the prompts:
Once authenticated, use these tools directly in Claude:
- `get_my_profile` - View your profile and general stats.
- `list_enrolled_programs` - See your current active programs.
- `get_training_history` - Review your past workouts.
- `get_training_history` - Review your past workouts (filter by date range, page through history, or request full set-by-set detail).
- `get_home_summary` - Get your dashboard streak and totals.

## ✨ Features
Expand All @@ -96,7 +96,7 @@ Once authenticated, use these tools directly in Claude:
|------|-------------|------------|
| `get_my_profile` | Get user profile and settings | None |
| `list_enrolled_programs` | List your active programs | None |
| `get_training_history` | Get detailed workout history | `timezone_offset` |
| `get_training_history` | Workout history, compact by default. Returns JSON with pagination metadata and a `has_more` flag so large histories can be walked in chunks. | `start_date`, `end_date`, `detail`, `page`, `page_size`, `timezone_offset` |
| `get_payment_history` | View your subscription/orders | None |
| `list_custom_exercises` | List your unique exercises | None |
| `list_all_programs` | Search the program catalog | `page`, `page_size`, `keyword` |
Expand All @@ -106,6 +106,35 @@ Once authenticated, use these tools directly in Claude:
| `get_home_chart` | Training volume chart data | `timezone_offset` |
| `get_home_muscle` | Muscle group distribution | `timezone_offset` |

### Working with training history

`get_training_history` is built to keep responses small enough for any MCP
client. By default it returns a **summary** of your **50 most recent** workouts
(date, program, exercises, and total volume) plus pagination metadata:

```json
{
"workouts": [ { "date": "2026-06-10", "program_name": "...",
"exercises": ["Squat (Barbell)", "..."],
"total_volume": 11570, "volume_unit": "lbs" } ],
"pagination": {"page": 1, "page_size": 50, "total": 231,
"returned": 50, "has_more": true},
"filters": {"start_date": null, "end_date": null, "detail": "summary"},
"hint": "231 workouts match. Showing 50 (page 1, summary). Use page=2 ..."
}
```

- **Filter by date:** `start_date` / `end_date` as `YYYY-MM-DD` (inclusive),
e.g. *"my workouts since 2026-04-07"*.
- **Page through history:** bump `page` while `pagination.has_more` is `true`
(`page_size` caps at 100 for summary, 25 for full).
- **Get every set and rep:** `detail="full"` adds per-exercise records with each
set's weight, reps, target, and RPE. Use a small `page_size` here.

Supersets are flattened: each exercise inside a superset is listed individually
(with its sets counted toward `total_volume`), and in `full` detail those
exercises carry a `superset` id so the grouping is still visible.

## 🔧 Troubleshooting

### Authentication Issues
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@ login = "boostcamp_mcp.login:main"
[tool.uv]
dev-dependencies = [
"mcp[cli]>=1.2.1",
"pytest>=8.0",
"pytest-asyncio>=0.24",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

[tool.uv.sources]
boostcampapi = { git = "https://github.com/Alex-Keyes/boostcamp-api.git" }

Expand Down
225 changes: 225 additions & 0 deletions src/boostcamp_mcp/history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
"""Pure transforms for shaping Boostcamp training history.

The upstream /programs/history endpoint returns the entire history grouped by
date with no server-side filtering or pagination, so all shaping happens here.
No network or I/O — every function in this module is pure and unit-tested.
"""
import re
from collections import Counter
from typing import Any, Optional

VALID_DETAIL = ("summary", "full")
SUMMARY_CAP = 100
FULL_CAP = 25
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


def _to_number(raw: Any, empty: bool) -> float:
"""Coerce a stringy numeric field to float; empty/blank -> 0.0."""
if empty:
return 0.0
try:
return float(raw)
except (TypeError, ValueError):
return 0.0


def _slim_set(s: dict) -> dict:
"""Keep only the analytically meaningful fields of a raw set."""
intensity = s.get("intensity") or None
return {
"weight": _to_number(s.get("value"), s.get("valueEmpty", False)),
"reps": _to_number(s.get("amount"), s.get("amountEmpty", False)),
"target": s.get("target"),
"target_type": s.get("target_type"),
"rpe": intensity,
"skipped": s.get("skipped", False),
"weight_unit": s.get("weight_unit"),
}


def _set_volume(s: dict) -> float:
"""weight * reps for a non-skipped set, else 0.0."""
if s.get("skipped", False):
return 0.0
weight = _to_number(s.get("value"), s.get("valueEmpty", False))
reps = _to_number(s.get("amount"), s.get("amountEmpty", False))
return weight * reps


def _normalize_records(records: list) -> list:
"""Expand Superset wrapper records into their child exercises.

A Superset record carries no name or sets of its own — its exercises live in
a `supersets` list, each shaped like a normal record. Left as-is they surface
as a `null` exercise and, worse, their sets (often real weighted work) are
skipped when computing volume. We expand the children to top-level records,
tagging each with the wrapper's id so full detail can still show the grouping.
Non-superset records pass through unchanged.
"""
out = []
for r in records:
if r.get("type") == "Superset":
group = r.get("uniq") or r.get("id")
for child in r.get("supersets", []):
tagged = dict(child)
tagged["superset"] = group
out.append(tagged)
else:
out.append(r)
return out


def _workout_volume_and_unit(records: list) -> tuple[float, Optional[str]]:
"""Total volume across non-skipped sets, and the dominant weight unit.

Volume excludes skipped sets, but the unit is read from any set that
declares a weight_unit (skipped or not) so bodyweight/skipped-only days
still report the unit their weights would be in rather than null.
"""
total = 0.0
units: Counter = Counter()
for rec in records:
for s in rec.get("sets", []):
total += _set_volume(s)
if s.get("weight_unit"):
units[s["weight_unit"]] += 1
unit = units.most_common(1)[0][0] if units else None
return total, unit


def _summarize_workout(w: dict) -> dict:
records = _normalize_records(w.get("records", []))
total, unit = _workout_volume_and_unit(records)
named = [r for r in records if r.get("name")]
return {
"date": w.get("date"),
"title": w.get("title"),
"program_name": w.get("name"),
"week": w.get("week"),
"day": w.get("day"),
"finished_at": w.get("finished_at"),
"exercise_count": len(named),
"exercises": [r["name"] for r in named],
"total_volume": total,
"volume_unit": unit,
}


def _full_workout(w: dict) -> dict:
out = _summarize_workout(w)
records = []
for r in _normalize_records(w.get("records", [])):
rec = {
"name": r.get("name"),
"type": r.get("type"),
"muscles_list": r.get("muscles_list", []),
"sets": [_slim_set(s) for s in r.get("sets", [])],
}
if r.get("superset"):
rec["superset"] = r["superset"]
records.append(rec)
out["records"] = records
return out


def _flatten(raw: dict) -> list:
"""Flatten {date: [workout,...]} into a flat list tagged with date,
sorted newest-first (tie-break: later finished_at first)."""
flat = []
for date, workouts in raw.get("data", {}).items():
for w in workouts:
tagged = dict(w)
tagged["date"] = date
flat.append(tagged)
flat.sort(key=lambda w: (w["date"], w.get("finished_at") or ""),
reverse=True)
return flat


def _filter_by_date(flat: list, start_date: Optional[str],
end_date: Optional[str]) -> list:
"""Inclusive date-range filter on the YYYY-MM-DD date tag."""
out = flat
if start_date:
out = [w for w in out if w["date"] >= start_date]
if end_date:
out = [w for w in out if w["date"] <= end_date]
return out


def _check_date(label: str, value: Optional[str]) -> None:
if value is not None and not _DATE_RE.match(value):
raise ValueError(f"{label} must be YYYY-MM-DD (got {value!r}).")


def validate_params(start_date, end_date, detail, page, page_size) -> int:
"""Validate inputs; return the effective (capped) page_size.

Raises ValueError with a user-facing message on bad input.
"""
_check_date("start_date", start_date)
_check_date("end_date", end_date)
if detail not in VALID_DETAIL:
raise ValueError(
f"detail must be one of {VALID_DETAIL} (got {detail!r}).")
if page < 1:
raise ValueError(f"page must be >= 1 (got {page}).")
if page_size < 1:
raise ValueError(f"page_size must be >= 1 (got {page_size}).")
cap = SUMMARY_CAP if detail == "summary" else FULL_CAP
return min(page_size, cap)


def _build_hint(total, page, page_size, returned, has_more, detail) -> str:
if total == 0:
return ("No workouts match. Remove or widen start_date/end_date, "
"or omit them to see your full history.")
parts = [f"{total} workouts match."]
if returned == 0:
last_page = (total + page_size - 1) // page_size
parts.append(f"Page {page} is past the end; last page is {last_page}.")
return " ".join(parts)
parts.append(f"Showing {returned} (page {page}, {detail}).")
if has_more:
parts.append(f"Use page={page + 1} for older workouts, or "
"start_date/end_date (YYYY-MM-DD) to narrow.")
if detail == "summary":
parts.append("Use detail='full' for sets and reps.")
return " ".join(parts)


def shape_history(raw, *, start_date, end_date, detail, page, page_size) -> dict:
"""Public entrypoint: validate, filter, shape, paginate, build envelope."""
effective_size = validate_params(
start_date, end_date, detail, page, page_size)

flat = _filter_by_date(_flatten(raw), start_date, end_date)
total = len(flat)

start = (page - 1) * effective_size
end = start + effective_size
page_items = flat[start:end]

shaper = _summarize_workout if detail == "summary" else _full_workout
workouts = [shaper(w) for w in page_items]

returned = len(workouts)
has_more = end < total
return {
"workouts": workouts,
"pagination": {
"page": page,
"page_size": effective_size,
"total": total,
"returned": returned,
"has_more": has_more,
},
"filters": {
"start_date": start_date,
"end_date": end_date,
"detail": detail,
},
"hint": _build_hint(
total, page, effective_size, returned, has_more, detail),
}
47 changes: 44 additions & 3 deletions src/boostcamp_mcp/server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import json
import asyncio
from fastmcp import FastMCP
from dotenv import load_dotenv
Expand All @@ -8,6 +9,8 @@
# Import the actual library and exceptions
from boostcampapi import BoostcampAPI, BoostcampAuthException, RequestFailedException

from boostcamp_mcp import history

# Load .env from current directory
env_path = Path(".env")
load_dotenv(dotenv_path=env_path)
Expand Down Expand Up @@ -44,9 +47,47 @@ async def list_enrolled_programs() -> str:
return await handle_api_call(lambda api: api.list_user_programs())

@mcp.tool()
async def get_training_history(timezone_offset: int = -300) -> str:
"""Get the user's training history. Default timezone offset is -300."""
return await handle_api_call(lambda api: api.get_training_history(timezone_offset))
async def get_training_history(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
detail: str = "summary",
page: int = 1,
page_size: int = 50,
timezone_offset: int = -300,
) -> str:
"""Get the user's workout history, filtered and paginated to stay compact.

Args:
start_date: Only workouts on/after this date, "YYYY-MM-DD" (inclusive).
end_date: Only workouts on/before this date, "YYYY-MM-DD" (inclusive).
detail: "summary" (date, program, exercises, total volume — default) or
"full" (adds every set with weight/reps/RPE).
page: 1-based page number, newest workouts first.
page_size: Workouts per page. Capped at 100 for summary, 25 for full.
timezone_offset: Timezone offset in minutes (default -300 / EST).

Returns JSON with `workouts`, `pagination` (incl. has_more), `filters`, and
a `hint` describing how to fetch more (next page, date range, or full detail).
"""
try:
# Surface validation errors as clean strings before the network call.
history.validate_params(start_date, end_date, detail, page, page_size)
except ValueError as e:
return f"Error: {e}"

api = get_api_client()
try:
raw = await api.get_training_history(timezone_offset)
except BoostcampAuthException as e:
return (f"Authentication Error: {str(e)}. "
"Please run 'uv run login' again.")
except Exception as e:
return f"Error: {str(e)}"

result = history.shape_history(
raw, start_date=start_date, end_date=end_date, detail=detail,
page=page, page_size=page_size)
return json.dumps(result, indent=2)

@mcp.tool()
async def get_payment_history() -> str:
Expand Down
Empty file added tests/__init__.py
Empty file.
Loading