diff --git a/mcp/src/tools.js b/mcp/src/tools.js index dd4600aa8..3b4d042d8 100644 --- a/mcp/src/tools.js +++ b/mcp/src/tools.js @@ -12,7 +12,7 @@ import { import { exOr } from '../../frontend/src/lib/exercises.js' import { isWarmupRow } from '../../frontend/src/lib/workout-model.js' import { - estimate1RM, best1RM, e1rmSeries, DEFAULT_FORMULA, REP_CAP + bestSetOf, best1RM, e1rmSeries, DEFAULT_FORMULA, REP_CAP } from '../../frontend/src/lib/onerm.js' import { loadOfWorkouts, rankOf, levelsOf } from '../../frontend/src/lib/muscles.js' import { policyFor } from '../../frontend/src/lib/progression.js' @@ -20,6 +20,18 @@ import { buildSessionEntries, startsFromLast } from '../../frontend/src/lib/sess /* ---------- helpers ---------- */ +// A 'YYYY-MM-DD' the calendar actually has. The regex alone let 2026-02-30 through, and +// new Date('2026-02-30T12:00:00') rolls over to March 2 — so preview_session answered for +// March 2 while echoing February 30 back as the date it had answered for. Round-trip the string +// through the same local-noon construction the handlers use and insist it comes back unchanged; +// as a zod refine that is a -32602 at the SDK boundary instead of a confident wrong answer. +const localIso = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') +const todayIso = () => localIso(new Date()) +const isoDate = () => z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'must be YYYY-MM-DD').refine( + v => { const d = new Date(v + 'T12:00:00'); return !Number.isNaN(d.getTime()) && localIso(d) === v }, + { message: 'not a date the calendar has (YYYY-MM-DD)' } +) + // A custom exercise lives in S.customEx and is merged into EXIDX by registerCustom() at // store load (useStore.js:54). The MCP server deliberately never calls it: http.js serves // several profiles from one process behind withRemoteState, so mutating the module-global @@ -55,23 +67,26 @@ function entryView(e, S) { } // Best estimate per exercise, mirroring the UI's PR table: every eligible set across history, biggest wins. +// It asks bestSetOf() rather than scanning the rows here, so this table and the per-exercise answer of +// estimate_1rm are the same reading of the same history. They were not while this scanned: a unilateral +// set is one row carrying both sides, so the row's `r` is L+R and went over the rep cap while bestSetOf +// read each side's own reps, and a row with one side still unchecked is not `done` here but is completed +// work for the limb that finished it. The exercise then had a 1RM and no line in the PR table at once. +// An assistance machine is left out the same way (issue #232): the load is the help you were given. // Warm-ups are skipped for the same reason bestSetOf() skips them (onerm.js): a heavy ramp row is not a // record. Without this the coach's PR and the athlete's PR silently disagree for the same exercise. function prTable(S, formula) { const byId = new Map() for (const w of (S.workouts || [])) { for (const e of (w.entries || [])) { - const ex = exerciseOf(e.id, S) - for (const s of (e.sets || [])) { - if (!s.done || isWarmupRow(s)) continue - const est = estimate1RM(s.w, s.r, formula) - if (est == null) continue - const prev = byId.get(e.id) - if (!prev || est > prev.est) { - // exId as well as exName: the consumer needs an id, not a name — exOr() treats any - // string as both, so passing exName where exId belongs would silently "work" wrong. - byId.set(e.id, { exId: e.id, exName: ex.n, bp: ex.bp || null, est, w: Number(s.w), r: Math.round(Number(s.r)), date: w.d }) - } + const best = bestSetOf(e, formula) + if (!best) continue + const prev = byId.get(e.id) + if (!prev || best.est > prev.est) { + const ex = exerciseOf(e.id, S) + // exId as well as exName: the consumer needs an id, not a name — exOr() treats any + // string as both, so passing exName where exId belongs would silently "work" wrong. + byId.set(e.id, { exId: e.id, exName: ex.n, bp: ex.bp || null, est: best.est, w: best.w, r: best.r, date: w.d }) } } } @@ -192,24 +207,34 @@ export const listWorkouts = { name: 'list_workouts', description: 'List recent finished workouts, newest first. Each item summarises the date, exercise count, sets done / planned, total volume (in the user\'s unit), duration and whether PRs were set. Use this before drilling into a specific date with get_workout.', schema: { - from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive start date YYYY-MM-DD. Defaults to no lower bound (list most recent).'), - to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive end date YYYY-MM-DD. Defaults to today.'), + from: isoDate().optional().describe('Inclusive start date YYYY-MM-DD. Defaults to no lower bound (list most recent).'), + to: isoDate().optional().describe('Inclusive end date YYYY-MM-DD. Defaults to today.'), limit: z.number().int().min(1).max(200).optional().describe('Max items to return. Defaults to 25.') }, handler: ({ from, to, limit }) => { const S = getState() if (!S) return noState() const lim = Math.min(Math.max(limit || 25, 1), 200) + // `to` is documented as defaulting to today, and had no default at all. A row dated in the + // future — another device with a wrong clock — was listed first as the most recent session. + const hi = to || todayIso() const all = (S.workouts || []).slice().sort((a, b) => (b.d || '').localeCompare(a.d || '')) - const filtered = all.filter(w => { + const matching = all.filter(w => { if (from && w.d < from) return false - if (to && w.d > to) return false + if (w.d > hi) return false return true - }).slice(0, lim) + }) + const filtered = matching.slice(0, lim) return { unit: S.unit || 'kg', + // total_count is all-time and predates the filter — kept as it is, since callers read it + // that way. matching_count is how many are in the from/to range, and truncated says the + // limit cut the list: without them "how many sessions did I do in March" was unanswerable, + // because 25 rows and a total of 340 say nothing about the 40 in March. total_count: all.length, + matching_count: matching.length, returned_count: filtered.length, + truncated: filtered.length < matching.length, workouts: filtered.map(w => ({ // The only thing that identifies a session uniquely. Two workouts on one day is // ordinary — a lifting session and an evening run — and without an id here the second @@ -243,7 +268,7 @@ export const getWorkout = { name: 'get_workout', description: 'Get the full breakdown of one workout: every exercise, its mode (reps/time/cardio), the target, and per-set labels (e.g. "5 @ 60 kg", "1:30 · 20 kg"). Identify it by workout_id (from list_workouts) or by date. Use list_workouts first if you don\'t know either.', schema: { - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('The workout date as YYYY-MM-DD. If two sessions share that date, the answer lists them instead and asks for a workout_id.'), + date: isoDate().optional().describe('The workout date as YYYY-MM-DD. If two sessions share that date, the answer lists them instead and asks for a workout_id.'), workout_id: z.string().min(1).optional().describe('The id from list_workouts. Preferred: it names one session even on a day with two.') }, handler: ({ date, workout_id }) => { @@ -302,16 +327,18 @@ export const getBodyweight = { name: 'get_bodyweight', description: 'Get the body-weight log: chronological weigh-ins with weights, current goal, deltas vs goal (signed positive = above goal), and a latest summary. Useful for "am I trending toward my weight goal?" questions.', schema: { - from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive start date YYYY-MM-DD.'), - to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive end date YYYY-MM-DD. Defaults to today.') + from: isoDate().optional().describe('Inclusive start date YYYY-MM-DD.'), + to: isoDate().optional().describe('Inclusive end date YYYY-MM-DD. Defaults to today.') }, handler: ({ from, to }) => { const S = getState() if (!S) return noState() const goal = S.targetW || null + // Same documented default as list_workouts: a future-dated weigh-in is not "latest". + const hi = to || todayIso() const bw = (S.bodyweight || []).filter(b => { if (from && b.d < from) return false - if (to && b.d > to) return false + if (b.d > hi) return false return true }).sort((a, b) => (a.d || '').localeCompare(b.d || '')) const latest = bw.length ? bw[bw.length - 1] : null @@ -334,7 +361,9 @@ export const estimate1rm = { name: 'estimate_1rm', description: `Estimate one-rep max using Epley, Brzycki or Lombardi formulas. If an exercise_id is given, returns the all-time best estimate for that exercise with the source set (weight × reps + date) and the trend across history. If no exercise_id is given, returns a PR table across all reps-mode exercises (sorted highest first). Refuses to guess above ${REP_CAP} reps — above that, formulas diverge past 10% and "work capacity" is read instead of "maximal strength".`, schema: { - exercise_id: z.string().optional().describe('An exercise id from list_routines or get_workout entries. If omitted, returns a full PR table.'), + // .min(1): an empty string is falsy, so it used to fall through to "no exercise_id given" and + // answer a question about one exercise with the whole PR table. + exercise_id: z.string().min(1).optional().describe('An exercise id from list_routines or get_workout entries. If omitted, returns a full PR table.'), formula: z.enum(['epley', 'brzycki', 'lombardi']).optional().describe(`Formula to use. Defaults to ${DEFAULT_FORMULA}.`) }, handler: ({ exercise_id, formula }) => { @@ -350,16 +379,23 @@ export const estimate1rm = { // records for calf raise" — a confident statement about the opposite of the truth. const trainedAtAll = (S.workouts || []).some(w => (w.entries || []).some(e => e.id === exercise_id && (e.sets || []).some(s => s.done))) + // exOr's miss is a placeholder named "Unknown exercise", not null. A typo'd or made-up id + // therefore came back as "No completed sets logged for this exercise" — a statement about + // the athlete's training, when the truth is that no such exercise exists. A deleted custom + // is unknown to the catalogue too, but it has logged sets, so it keeps the real answer. + const unknown = !!ex.missing && !trainedAtAll // w/r (not weight/reps) matches pr_table and entry-view — every set in the API surface uses the same couple. return { - exercise: { id: exercise_id, name: ex.n, body_part: ex.bp || null }, + exercise: { id: exercise_id, name: ex.n, body_part: ex.bp || null, ...(unknown ? { unknown: true } : {}) }, formula: f, formula_note: `Estimates use the ${f} formula. Cap at ${REP_CAP} reps applies; r=1 is treated as the measurement, not an estimate.`, best: best ? { est: best.est, w: best.w, r: best.r, date: best.d } : null, no_estimate_reason: best ? null : trainedAtAll ? `This exercise has logged sets, but none of them qualify: every set was above the ${REP_CAP}-rep cap, or carried no weight. That is not the same as never having trained it.` - : 'No completed sets logged for this exercise.', + : unknown + ? `No exercise with id ${JSON.stringify(exercise_id)} exists — not in the catalogue, not among this profile's custom exercises, and nothing is logged against it. Check the id against list_routines or a get_workout entry.` + : 'No completed sets logged for this exercise.', trend: series.map(p => ({ date: p.d, est: p.y, w: p.w, r: p.r })) } } @@ -381,9 +417,14 @@ export const muscleBalance = { handler: ({ period }) => { const S = getState() if (!S) return noState() - const now = Date.now() - const cutoff = period === 'week' ? now - 7 * 86400000 - : period === 'month' ? now - 30 * 86400000 + // Whole local days, counting today: a week is the 7 dates ending today, a month the 30. The + // cutoff used to be an instant 7 x 24h back, which for the workouts that carry no clock — an + // import, a hand-added session; they fall back to their date at local noon — pulled in an + // EIGHTH calendar date, the one 7 days ago. "Last 7 days" listing 8 of them. + const now = new Date() + const midnightDaysBack = n => new Date(now.getFullYear(), now.getMonth(), now.getDate() - n).getTime() + const cutoff = period === 'week' ? midnightDaysBack(6) + : period === 'month' ? midnightDaysBack(29) : Number.NEGATIVE_INFINITY const workouts = (S.workouts || []).filter(w => (w.start || new Date(w.d + 'T12:00:00').getTime()) >= cutoff) // loadOf() resolves each entry through EXIDX, which holds the catalogue only, so a @@ -403,7 +444,10 @@ export const muscleBalance = { const levels = levelsOf(load) return { period, - cutoff_iso: period === 'all' ? null : new Date(cutoff).toISOString().slice(0, 10), + // Local, like every other date this API reports (get_week_plan.today, every workout date). + // toISOString() reads UTC, so late in the evening west of Greenwich the reported cutoff was + // the day AFTER the one the filter used, and east of Greenwich the day before. + cutoff_iso: period === 'all' ? null : localIso(new Date(cutoff)), workouts_in_period: workouts.length, worked: worked.map(slug => ({ slug, name: muscleName(slug), level: levels[slug], effective_sets: Math.round((load[slug] || 0) * 10) / 10 })), neglected: missed.map(slug => ({ slug, name: muscleName(slug) })), @@ -450,7 +494,7 @@ export const previewSession = { 'Preview the session a routine will actually open with — the numbers the user will see after the progression policy and their training history have overridden the routine\'s own targets. This is NOT the same as get_routine: a routine storing "squat 3x8 @ 60kg" can open at 75kg because the policy progressed or deloaded from that routine\'s last logged session. The reps are the routine\'s own unless a policy that moves reps moved them, or the profile starts planned sessions from the last session (starts_from). Always call this (not get_routine) before telling someone what weight they are about to lift, or before judging whether an edit to a routine had any effect. Returns, per exercise, the planned target, the policy\'s decision and its stated reason, the opening set rows, and where each number came from. Defaults to today\'s scheduled routine.', schema: { routine_id: z.string().min(1).optional().describe('Routine to preview. Defaults to the routine scheduled for `date`.'), - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Date the session would be started on, YYYY-MM-DD. Affects which routine is scheduled and any one-off day override. Defaults to today.') + date: isoDate().optional().describe('Date the session would be started on, YYYY-MM-DD. Affects which routine is scheduled and any one-off day override. Defaults to today.') }, handler: ({ routine_id, date }) => { const S = getState() diff --git a/mcp/test/tools.test.js b/mcp/test/tools.test.js index 0eb77a959..0bdfaa8b5 100644 --- a/mcp/test/tools.test.js +++ b/mcp/test/tools.test.js @@ -322,6 +322,26 @@ describe('list_workouts', () => { expect(r.returned_count).toBeLessThanOrEqual(3) expect(r.total_count).toBeGreaterThanOrEqual(r.returned_count) }) + + test('"how many did I do in March" is answerable: matching_count and truncated', () => { + const wk = (id, d) => ({ + id, d, start: Date.parse(d + 'T10:00:00'), end: Date.parse(d + 'T11:00:00'), name: 'S', routineId: S.routines[0].id, + entries: [{ id: '0025', target: { sets: 1, reps: 5, weight: 100 }, sets: [{ done: true, w: 100, r: 5 }] }] + }) + S.workouts = [] + for (let i = 1; i <= 40; i++) S.workouts.push(wk('mar' + i, '2026-03-' + String((i % 28) + 1).padStart(2, '0'))) + for (let i = 1; i <= 300; i++) S.workouts.push(wk('jan' + i, '2026-01-' + String((i % 28) + 1).padStart(2, '0'))) + _seedStateForTests(S) + const r = call('list_workouts', { from: '2026-03-01', to: '2026-03-31' }) + expect(r.total_count).toBe(340) // unchanged meaning: all-time, before the filter + expect(r.matching_count).toBe(40) // in the range asked for + expect(r.returned_count).toBe(25) // and how many came back + expect(r.truncated).toBe(true) + expect(r.workouts.every(w => w.date.startsWith('2026-03'))).toBe(true) + const whole = call('list_workouts', { from: '2026-03-01', to: '2026-03-31', limit: 200 }) + expect(whole.returned_count).toBe(40) + expect(whole.truncated).toBe(false) + }) }) /* ---------- get_workout ---------- */ @@ -492,6 +512,45 @@ describe('estimate_1rm', () => { expect(epley.best.est).not.toBeCloseTo(brz.best.est, 1) }) + // Both halves of this tool are one reading of one history. A unilateral set is a single row + // carrying both sides, so the row's own `r` is L+R and goes over the rep cap while each side's + // reps do not; before the PR table asked bestSetOf() the same way the per-exercise answer does, + // the exercise had a 1RM and no line in the table at the same time. + test('the PR table and the per-exercise best agree on a per-side exercise', () => { + const CURL = '0294' + const side = (w, r, rDone, lDone = true) => ({ + w, r: r * 2, done: lDone && rDone, + sides: { L: { w, r, done: lDone }, R: { w, r, done: rDone } } + }) + S.workouts = [{ + id: 'w1', d: '2026-07-06', start: Date.parse('2026-07-06T10:00:00'), end: Date.parse('2026-07-06T11:00:00'), name: 'S', + entries: [{ id: CURL, target: { sets: 3, reps: 8, weight: 20, perSide: true }, sets: [side(20, 8, true), side(20, 8, true), side(22, 8, false)] }] + }] + _seedStateForTests(S) + const one = call('estimate_1rm', { exercise_id: CURL }) + const row = call('estimate_1rm', {}).pr_table.find(p => p.exId === CURL) + expect(one.best).toBeTruthy() + expect(row).toBeTruthy() + expect(row.est).toBeCloseTo(one.best.est, 3) + expect(row.w).toBe(one.best.w) + expect(row.r).toBe(one.best.r) + }) + + // The app keeps an assistance machine out of the estimate, the curve and the strength list + // (bestSetOf, issue #232): the load is the help you were given, so Epley on it would rise as + // you got weaker and call that a record. The PR table scanned the rows itself and listed it. + test('an assistance machine is not in the PR table, the same as in the app', () => { + const ASSISTED_PULL_UP = '0017' + S.workouts = [{ + id: 'w1', d: '2026-07-06', start: Date.parse('2026-07-06T10:00:00'), end: Date.parse('2026-07-06T11:00:00'), name: 'S', + entries: [{ id: ASSISTED_PULL_UP, target: { sets: 2, reps: 8, weight: 30 }, sets: [{ done: true, w: 30, r: 8 }, { done: true, w: 25, r: 8 }] }] + }] + _seedStateForTests(S) + expect(bestSetOf(S.workouts[0].entries[0])).toBeNull() + expect(call('estimate_1rm', {}).pr_table.find(p => p.exId === ASSISTED_PULL_UP)).toBeUndefined() + expect(call('estimate_1rm', { exercise_id: ASSISTED_PULL_UP }).best).toBeNull() + }) + test('exercises with no reps-mode history get null best + empty trend (not a 0 estimate)', () => { S.workouts = [] _seedStateForTests(S) @@ -502,6 +561,39 @@ describe('estimate_1rm', () => { expect(table).toEqual([]) }) + test('an id that does not exist says so, and is not confused with "never trained"', () => { + const r = call('estimate_1rm', { exercise_id: 'not-an-exercise-at-all' }) + expect(r.best).toBeNull() + expect(r.exercise.unknown).toBe(true) + expect(r.no_estimate_reason).toMatch(/No exercise with id "not-an-exercise-at-all" exists/) + expect(r.no_estimate_reason).not.toMatch(/No completed sets logged/) + // a real exercise with no history keeps the answer about the training, not about the id + S.workouts = [] + _seedStateForTests(S) + const real = call('estimate_1rm', { exercise_id: LEG_PRESS_ID }) + expect(real.exercise.unknown).toBeUndefined() + expect(real.no_estimate_reason).toBe('No completed sets logged for this exercise.') + }) + + test('an empty exercise_id is a validation error, not the whole PR table', async () => { + const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js') + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js') + const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js') + const t = TOOLS.find(x => x.name === 'estimate_1rm') + const server = new McpServer({ name: 'opengym', version: '0.1.0' }) + server.tool(t.name, t.description, t.schema, async p => ({ content: [{ type: 'text', text: JSON.stringify(t.handler(p || {})) }] })) + const client = new Client({ name: 'tools-test', version: '1' }) + const [ct, st] = InMemoryTransport.createLinkedPair() + await Promise.all([server.connect(st), client.connect(ct)]) + const r = await client.callTool({ name: 'estimate_1rm', arguments: { exercise_id: '' } }).then( + x => ({ ok: !x.isError, text: x.content?.[0]?.text || '' }), + e => ({ code: e.code, text: String(e.message) }) + ) + expect(r.ok).not.toBe(true) + expect(String(r.code ?? r.text)).toContain('-32602') + expect(r.text).not.toContain('pr_table') + }) + test('a heavy warm-up is never a PR — the table matches the app, which excludes warm-ups', () => { // The app scans with bestSetOf(), which skips warm-ups (onerm.js). prTable used to gate on // s.done alone, so a ramp row heavier than the work set became a record the coach reported @@ -643,6 +735,51 @@ describe('muscle_balance', () => { expect(month).toBeLessThanOrEqual(all) }) + test('"last 7 days" is 7 calendar dates, including for workouts that carry no clock', () => { + // Imported or hand-added sessions have no w.start and fall back to their date at local noon. + // Against an instant 7x24h back, noon on the 7th day ago is still inside the window, so the + // week spanned 8 dates. + const iso = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + S.workouts = Array.from({ length: 40 }, (_, i) => ({ + id: 'd' + i, d: iso(new Date(Date.now() - i * 86400000)), + entries: [{ id: '0025', sets: [{ done: true, w: 100, r: 5 }] }] + })) + _seedStateForTests(S) + expect(call('muscle_balance', { period: 'week' }).workouts_in_period).toBe(7) + expect(call('muscle_balance', { period: 'month' }).workouts_in_period).toBe(30) + // and the reported cutoff is the oldest date in the window + expect(call('muscle_balance', { period: 'week' }).cutoff_iso).toBe(iso(new Date(Date.now() - 6 * 86400000))) + }) + + // TZ east of Greenwich: there the UTC date of the cutoff instant is a day behind the local one, + // which is the bug. Node re-reads process.env.TZ, so this block owns its own zone and clock. + describe('the reported cutoff is a local date, like every other date in this API', () => { + const TZ = process.env.TZ + beforeAll(() => { process.env.TZ = 'Asia/Tokyo'; vi.setSystemTime(new Date('2026-07-27T08:00:00')) }) + afterAll(() => { process.env.TZ = TZ; vi.setSystemTime(new Date(FAKE_TODAY_ISO + 'T12:00:00Z')) }) + + // Workouts imported without a clock (no w.start) fall back to their date at local noon, so + // they are the probe for which calendar dates the window really accepts. + const dated = (id, iso) => ({ id, d: iso, entries: [{ id: '0025', sets: [{ done: true, w: 100, r: 5 }] }] }) + const dayBefore = iso => { + const [y, m, d] = iso.split('-').map(Number) + const p = new Date(y, m - 1, d - 1) + return p.getFullYear() + '-' + String(p.getMonth() + 1).padStart(2, '0') + '-' + String(p.getDate()).padStart(2, '0') + } + + for (const period of ['week', 'month']) { + test(`${period}: cutoff_iso is the first date the filter accepts`, () => { + const iso = call('muscle_balance', { period }).cutoff_iso + expect(iso).toMatch(/^\d{4}-\d{2}-\d{2}$/) + S.workouts = [dated('on-the-cutoff', iso), dated('the-day-before', dayBefore(iso))] + _seedStateForTests(S) + const r = call('muscle_balance', { period }) + expect(r.cutoff_iso).toBe(iso) + expect(r.workouts_in_period).toBe(1) // the reported day counts; the one before it does not + }) + } + }) + test('zero-workout state still returns all 18 muscles as neglected (not empty)', () => { S.workouts = [] const r = call('muscle_balance', { period: 'all' }) @@ -926,3 +1063,85 @@ describe('workout photos and videos never leave through MCP', () => { } }) }) + +/* ---------- the documented `to` default ---------- */ + +describe('`to` defaults to today, as documented', () => { + test('a workout and a weigh-in dated in the future are not "the most recent"', () => { + // Another device with a wrong clock writes a 2099 row. Both tools document `to` as + // "Defaults to today" and had no default at all, so that row was listed first and read as + // the latest weight. + S.workouts.push({ id: 'w-future', d: '2099-01-01', name: 'Time Machine', routineId: S.routines[0].id, start: 1, end: 2, entries: [] }) + S.bodyweight.push({ d: '2099-01-01', w: 1 }) + _seedStateForTests(S) + const lw = call('list_workouts') + expect(lw.workouts.map(w => w.date)).not.toContain('2099-01-01') + expect(lw.workouts[0].date).toBe(NEWEST_WORKOUT.date) + const bw = call('get_bodyweight') + expect(bw.entries.map(e => e.date)).not.toContain('2099-01-01') + expect(bw.latest.date).toBe(LATEST_BW.date) + expect(bw.latest.weight).toBe(LATEST_BW.weight) + // …and asking for them explicitly still works + expect(call('list_workouts', { to: '2099-12-31' }).workouts[0].date).toBe('2099-01-01') + expect(call('get_bodyweight', { to: '2099-12-31' }).latest.date).toBe('2099-01-01') + }) +}) + +/* ---------- argument validation at the real SDK boundary ---------- */ + +// Driven through McpServer + an in-memory transport with the registration loop from src/index.js, +// because the thing under test is what zod does to the arguments BEFORE a handler ever runs. +describe('date arguments must be dates the calendar has', () => { + let client + + beforeAll(async () => { + const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js') + const { Client } = await import('@modelcontextprotocol/sdk/client/index.js') + const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js') + const server = new McpServer({ name: 'opengym', version: '0.1.0' }) + for (const t of TOOLS) { + server.tool(t.name, t.description, t.schema, async (params) => { + try { + return { content: [{ type: 'text', text: JSON.stringify(t.handler(params || {}), null, 2) }] } + } catch (err) { + return { isError: true, content: [{ type: 'text', text: `${err.code || 'ERROR'}: ${err.message}` }] } + } + }) + } + client = new Client({ name: 'tools-test', version: '1' }) + const [ct, st] = InMemoryTransport.createLinkedPair() + await Promise.all([server.connect(st), client.connect(ct)]) + }) + + const shot = (name, args) => client.callTool({ name, arguments: args }).then( + r => ({ ok: !r.isError, text: r.content?.[0]?.text || '' }), + e => ({ rejected: true, code: e.code, message: String(e.message) }) + ) + + test('February 30th is refused, not answered for March 2nd', async () => { + for (const [tool, args] of [ + ['preview_session', { date: '2026-02-30' }], + ['get_workout', { date: '2026-02-30' }], + ['list_workouts', { from: '2026-13-45' }], + ['list_workouts', { to: '0000-00-00' }], + ['get_bodyweight', { to: '2026-02-30' }], + ['get_bodyweight', { from: '2025-02-29' }] // 2024 had one, 2025 did not + ]) { + const r = await shot(tool, args) + const where = `${tool} ${JSON.stringify(args)}` + // The SDK answers an invalid-params rejection either as a throw or as an error result + // carrying the same code; both are the -32602 the client sees, neither runs the handler. + expect(r.ok, `${where} must be refused`).not.toBe(true) + expect(String(r.code ?? r.text), where).toContain('-32602') + expect(String(r.message ?? r.text), where).toMatch(/not a date the calendar has|must be YYYY-MM-DD/) + } + }) + + test('real dates, including a leap day, still get through', async () => { + for (const args of [{ from: '2026-02-28', to: '2026-07-27' }, { from: '2024-02-29' }]) { + const r = await shot('list_workouts', args) + expect(r.rejected).toBeUndefined() + expect(r.ok).toBe(true) + } + }) +})