catence 0.2.0-beta.21 → 0.2.0-beta.23

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.
@@ -1,7 +1,7 @@
1
1
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  import { z } from 'zod';
4
- import { ActivityDiscoveryService, AnalyticsService, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, DetachedSyncBusyError, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, startDetachedSync, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
4
+ import { ActivityDiscoveryService, AnalyticsService, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, DetachedSyncBusyError, EventsService, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, startDetachedSync, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
5
5
  const filterSchema = z.object({
6
6
  column: z.string().min(1),
7
7
  op: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'between', 'contains']),
@@ -27,6 +27,11 @@ const MCP_INSTRUCTIONS = [
27
27
  'That tool performs the targeted Strava hydration itself. Do not say segment data is unavailable before it returns; if it reports not_found, ambiguous, authorization, throttling, or an error, report that exact outcome instead.',
28
28
  'For Garmin running VO₂max, call get_vo2max_history with sport: running. Garmin stores the source value as generic; returned rows preserve that source label.',
29
29
  'Aggregate elevation alone cannot support an individual-climb conclusion. Use source-specific facts and their stated coverage.',
30
+ 'For race-readiness / fitness-target questions, establish a trend-first baseline (readiness_baseline, analyze_series, aggregate_data time buckets) before comparing any single activity.',
31
+ 'Never conclude from VO2max alone; pull full indicator set (LT/FTP/power/race-prediction).',
32
+ 'When summary metric is NULL (e.g. avg_power), fall through to FIT-derived/samples layer before declaring absence.',
33
+ 'Before reusing any prior course/elevation profile, resolve event\'s courseId and diff it against past activity\'s course; different courseId => different profile.',
34
+ 'If a needed tool is not visible in your current tool list (the host may truncate it to a subset), read the catence://tools resource to discover all available tools before concluding a capability is absent.',
30
35
  ].join(' ');
31
36
  function textResult(value) {
32
37
  return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
@@ -133,9 +138,16 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
133
138
  const activeAthlete = () => scope.getStore()?.athlete;
134
139
  const limiter = new SlidingWindowLimiter();
135
140
  const athleteIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/);
141
+ // Discoverability: record every registered tool so the catence://tools resource can
142
+ // enumerate all tools even when the host harness truncates the visible tool list.
143
+ const TOOL_INDEX = [];
144
+ function registerTool(name, config, callback) {
145
+ TOOL_INDEX.push({ name, title: config.title ?? name, description: config.description ?? '' });
146
+ server.registerTool(name, config, callback);
147
+ }
136
148
  const promptAthleteSchema = catalogPaths ? { athleteId: athleteIdSchema } : {};
137
149
  if (catalogPaths) {
138
- server.registerTool('list_athletes', {
150
+ registerTool('list_athletes', {
139
151
  title: 'List configured athletes',
140
152
  description: 'List athlete IDs and labels available to this shared Catence agent. No personal metrics are returned.',
141
153
  }, async () => {
@@ -229,24 +241,24 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
229
241
  }, ({ activityId, ...input }) => ({
230
242
  messages: [{
231
243
  role: 'user',
232
- content: { type: 'text', text: `Run review_activity_deep_dive for ${activityId}${typeof input.athleteId === 'string' ? ` with athleteId ${input.athleteId}` : ''}. Use its source records and intervals first; load read_series only if a specific sampled metric is needed.` },
244
+ content: { type: 'text', text: `Run review_activity_deep_dive for ${activityId}${typeof input.athleteId === 'string' ? ` with athleteId ${input.athleteId}` : ''}. Use its source records and intervals first; load read_series only if a specific sampled metric is needed. Use as anchor for current form only after trend baseline exists.` },
233
245
  }],
234
246
  }));
235
- server.registerTool('catence_status', {
247
+ registerTool('catence_status', {
236
248
  title: 'Catence data status',
237
249
  description: 'Read sync state, data coverage, entity counts, stream availability, unresolved errors, and retrieval-index freshness. Read-only.',
238
250
  }, tool('catence_status', async () => useRepository(activePaths(), async (repository) => ({
239
251
  data: { ...(await repository.status()), ...(await repository.coverage()) },
240
252
  provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
241
253
  }))));
242
- server.registerTool('catence_sync_progress', {
254
+ registerTool('catence_sync_progress', {
243
255
  title: 'Catence sync progress',
244
256
  description: 'Read live progress heartbeats for active sync runs, plus the most recent completed or interrupted runs. Read-only.',
245
257
  }, tool('catence_sync_progress', async () => useRepository(activePaths(), async (repository) => ({
246
258
  data: await repository.progress(),
247
259
  provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
248
260
  }))));
249
- server.registerTool('start_detached_sync', {
261
+ registerTool('start_detached_sync', {
250
262
  title: 'Start a detached data sync',
251
263
  description: 'Write-only sync trigger, named like the other explicit lock-guarded write tools. Spawns one detached catence-data sync process for this athlete and returns immediately with the run handle and log file; it refuses while another sync run is active. Track the run with catence_sync_progress; this tool never waits for completion.',
252
264
  inputSchema: {
@@ -268,7 +280,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
268
280
  ],
269
281
  };
270
282
  }));
271
- server.registerTool('review_daily_recovery_load', {
283
+ registerTool('review_daily_recovery_load', {
272
284
  title: 'Review daily recovery and training load',
273
285
  description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
274
286
  inputSchema: { date: z.string().date().optional() },
@@ -284,7 +296,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
284
296
  ],
285
297
  }));
286
298
  }));
287
- server.registerTool('review_weekly_training', {
299
+ registerTool('review_weekly_training', {
288
300
  title: 'Review seven days of training',
289
301
  description: 'Return source-cited health, training, and nutrition facts for the seven-day period ending on an optional date. It does not prescribe a training plan.',
290
302
  inputSchema: { endDate: z.string().date().optional() },
@@ -301,7 +313,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
301
313
  ],
302
314
  }));
303
315
  }));
304
- server.registerTool('review_activity_deep_dive', {
316
+ registerTool('review_activity_deep_dive', {
305
317
  title: 'Review one activity in depth',
306
318
  description: 'Return canonical activity identity, source summaries, and structured intervals. Call read_series separately only when a specific sampled metric is required.',
307
319
  inputSchema: { activityId: z.string().min(1) },
@@ -316,14 +328,14 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
316
328
  : ['No canonical activity matched this activityId. Use find_activities before drawing conclusions.'],
317
329
  };
318
330
  })));
319
- server.registerTool('describe_data', {
331
+ registerTool('describe_data', {
320
332
  title: 'Describe available Catence datasets',
321
333
  description: 'List cataloged datasets, fields, units, permitted filters/groupings, providers, and time coverage. Read-only.',
322
334
  }, tool('describe_data', async () => useRepository(activePaths(), async (repository) => ({
323
335
  data: { datasets: repository.catalog(), ...(await repository.coverage()) },
324
336
  provenance: { catalog: 'Catence catalog, restricted to analytical views' }, query: {}, caveats: ['activity_samples is available only through read_series or aggregate_data and only reads registered Parquet stream manifests.'],
325
337
  }))));
326
- server.registerTool('describe_dataset', {
338
+ registerTool('describe_dataset', {
327
339
  title: 'Describe one Catence dataset',
328
340
  description: 'Read a compact schema, permitted filters/groupings, provenance fields, and coverage for one cataloged dataset. Read-only.',
329
341
  inputSchema: { dataset: z.string().min(1) },
@@ -346,10 +358,10 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
346
358
  provenance: { catalog: 'Catence catalog', database: 'read-only DuckDB snapshot' }, query: input, caveats: [],
347
359
  };
348
360
  })));
349
- server.registerTool('read_series', {
361
+ registerTool('read_series', {
350
362
  title: 'Read a bounded time series', description: 'Read cataloged numeric series with deterministic cursor pagination and automatic stream downsampling. metrics must be numeric catalog columns; place identifiers and other strings in filters. Call describe_dataset first if the fields are uncertain.', inputSchema: seriesInput,
351
363
  }, tool('read_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).readSeries({ ...input, filters: input.filters }))));
352
- server.registerTool('aggregate_data', {
364
+ registerTool('aggregate_data', {
353
365
  title: 'Aggregate cataloged data', description: 'Declarative aggregation over one cataloged dataset. A timeBucket adds a time_bucket field, which can be used in orderBy. No joins, arbitrary expressions, or file paths.',
354
366
  inputSchema: {
355
367
  dataset: z.string().min(1),
@@ -357,15 +369,42 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
357
369
  dimensions: z.array(z.string().min(1)).max(8).optional(), filters: z.array(filterSchema).max(50).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), timeBucket: z.enum(['day', 'week', 'month']).optional(), orderBy: z.object({ column: z.string().min(1), direction: z.enum(['asc', 'desc']).optional() }).optional(), limit: z.number().int().min(1).max(500).optional(),
358
370
  },
359
371
  }, tool('aggregate_data', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
360
- server.registerTool('analyze_series', {
372
+ registerTool('analyze_series', {
361
373
  title: 'Analyze a descriptive series', description: 'Run deterministic rolling statistics, baselines, correlations, seasonal comparisons, or trends on a cataloged series.',
362
374
  inputSchema: { ...seriesInput, analysis: z.enum(['rolling_mean', 'rolling_median', 'baseline_change', 'z_score', 'pearson', 'spearman', 'seasonal_comparison', 'linear_trend', 'theil_sen_trend']), compareMetric: z.string().min(1).optional(), window: z.number().int().min(2).max(100).optional() },
363
375
  }, tool('analyze_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).analyzeSeries({ ...input, filters: input.filters }))));
364
- server.registerTool('fit_series_model', {
376
+ registerTool('fit_series_model', {
365
377
  title: 'Fit a descriptive series model', description: 'Fit a bounded OLS, Theil–Sen, quadratic, or cubic descriptive model. Not a sport-performance model.',
366
378
  inputSchema: { ...seriesInput, model: z.enum(['ols_linear', 'theil_sen_linear', 'polynomial_2', 'polynomial_3']), xMetric: z.string().min(1).optional(), yMetric: z.string().min(1).optional() },
367
379
  }, tool('fit_series_model', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).fitSeriesModel({ ...input, filters: input.filters }))));
368
- server.registerTool('wellness_correlate', {
380
+ registerTool('activity_decoupling', {
381
+ title: 'Derive aerobic decoupling and GAP for one activity',
382
+ description: 'Derive aerobic decoupling (Pa:Hr for running using speed/HR, Pw:Hr for cycling using power/HR — drift across steady first/second halves) and grade-adjusted pace (GAP, Minetti 2002 cost model) from stored activity_samples for one activity. Returns null with explanatory caveats when samples are insufficient, walk-heavy, or stop-heavy. Derived values are descriptive only and never overwrite provider-supplied decoupling/GAP. Read-only.',
383
+ inputSchema: { activityId: z.string().min(1), includeGap: z.boolean().optional() },
384
+ }, tool('activity_decoupling', async (input) => useRepository(activePaths(), async (repository) => {
385
+ const analytics = new AnalyticsService(repository);
386
+ const decoupling = await analytics.deriveDecoupling({ activityId: input.activityId });
387
+ const gap = input.includeGap === false ? null : await analytics.deriveGap({ activityId: input.activityId });
388
+ const decouplingData = decoupling;
389
+ const gapData = gap;
390
+ const combinedCaveats = [
391
+ ...(decouplingData.caveats ?? []),
392
+ ...(gapData?.caveats ?? []),
393
+ ];
394
+ return jsonSafe({
395
+ data: { decoupling: decouplingData.data ?? null, gap: gapData?.data ?? null, decouplingRaw: decouplingData, gapRaw: gapData },
396
+ provenance: {
397
+ decoupling: decouplingData.provenance,
398
+ gap: gapData?.provenance ?? null,
399
+ derivation: 'derived',
400
+ note: 'Both metrics are derived from activity_samples (Parquet lake) when provider summaries do not supply them; provider values remain authoritative.',
401
+ },
402
+ query: { activityId: input.activityId, includeGap: input.includeGap ?? true, decouplingQuery: decouplingData.query, gapQuery: gapData?.query ?? null },
403
+ caveats: combinedCaveats.length ? combinedCaveats : ['Derived metrics are descriptive only; not a physiological performance model.'],
404
+ inputs: { decouplingInput: decouplingData.input, gapInput: gapData?.input ?? null },
405
+ });
406
+ })));
407
+ registerTool('wellness_correlate', {
369
408
  title: 'Correlate recovery and training metrics',
370
409
  description: 'Calculate a compact daily Pearson or Spearman correlation between curated wellness and training metrics, with an optional -7 through +7 day lag scan. Descriptive only.',
371
410
  inputSchema: {
@@ -373,32 +412,51 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
373
412
  method: z.enum(['pearson', 'spearman']).optional(), lagDays: z.number().int().min(-30).max(30).optional(), scanLags: z.boolean().optional(),
374
413
  },
375
414
  }, tool('wellness_correlate', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).correlate(input))));
376
- server.registerTool('wellness_baselines', {
415
+ registerTool('wellness_baselines', {
377
416
  title: 'Read personal wellness baselines',
378
417
  description: 'Return trailing means, standard-deviation bands, latest values, and latest z-scores for common recovery and training metrics. Missing values remain missing.',
379
418
  inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), endDate: z.string().date().optional(), windowDays: z.number().int().min(7).max(365).optional() },
380
419
  }, tool('wellness_baselines', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).baselines(input))));
381
- server.registerTool('wellness_anomalies', {
420
+ registerTool('wellness_anomalies', {
382
421
  title: 'Find statistical wellness anomalies',
383
422
  description: 'Find daily recovery and wellness outliers by z-score and group dates with anomalies across multiple signals. This does not diagnose or prescribe.',
384
423
  inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), zThreshold: z.number().min(1).max(5).optional() },
385
424
  }, tool('wellness_anomalies', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).anomalies(input))));
386
- server.registerTool('wellness_coverage', {
425
+ registerTool('wellness_coverage', {
387
426
  title: 'Inspect wellness data coverage',
388
427
  description: 'Report present and missing dates for curated wellness/training metrics, plus unresolved extraction errors. Missing data is never interpreted as rest or a health outcome.',
389
428
  inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
390
429
  }, tool('wellness_coverage', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).coverage(input))));
391
- server.registerTool('get_ftp_history', {
430
+ registerTool('get_ftp_history', {
392
431
  title: 'Get dated FTP history',
393
432
  description: 'Return normalized cycling FTP settings and activity-summary observations with source-aware preferred daily values. Read-only.',
394
433
  inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), sourcePreference: z.enum(['settings', 'settings_then_activity', 'all']).optional() },
395
434
  }, tool('get_ftp_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).ftpHistory(input))));
396
- server.registerTool('get_vo2max_history', {
435
+ registerTool('readiness_baseline', {
436
+ title: 'Read a running/race readiness baseline',
437
+ description: 'Combine the full performance-indicator set — running lactate threshold (pace/power/HR), VO₂max, race predictions, fitness trend, endurance score, and FIT-derived power bests/coverage — as trends in one call. Use this before any single-activity comparison for race-readiness or fitness-target questions.',
438
+ inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
439
+ }, tool('readiness_baseline', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).readinessBaseline(input))));
440
+ registerTool('resolve_event_course', {
441
+ title: 'Resolve a race event course and its synced geometry',
442
+ description: 'Given a Garmin/Intervals eventId, return the courseId it references and whether that course geometry has been synced. When geometry is absent, the result carries an explicit caveat so a prior course profile is never reused. Optionally pass pastActivityId to diff the event course against that activity\'s course.',
443
+ inputSchema: { eventId: z.string().min(1), pastActivityId: z.string().min(1).optional() },
444
+ }, tool('resolve_event_course', async (input) => useRepository(activePaths(), async (repository) => {
445
+ const events = new EventsService(repository);
446
+ const resolved = await events.resolveEventCourse(input.eventId);
447
+ const diff = input.pastActivityId ? await events.diffCourseProfiles(resolved.courseId, input.pastActivityId) : null;
448
+ return {
449
+ data: { event: resolved, courseDiff: diff },
450
+ provenance: { relation: 'source_entities' },
451
+ caveats: [...resolved.caveats, ...(diff?.caveats ?? [])],
452
+ };
453
+ })));
454
+ registerTool('get_vo2max_history', {
397
455
  title: 'Get sport-specific VO₂max history',
398
456
  description: 'Return normalized Garmin VO₂max observations for exactly one sport. For running, pass sport: running (or run): Garmin supplies that series with raw sport generic, which is preserved in each row. Cycling remains separate. Omit sport only to inspect available source labels. Read-only.',
399
457
  inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
400
458
  }, tool('get_vo2max_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).vo2MaxHistory(input))));
401
- server.registerTool('find_activities', {
459
+ registerTool('find_activities', {
402
460
  title: 'Find activities and likely races',
403
461
  description: 'Find canonical activities by sport, distance, name, and date. Results are paginated and transparently flag likely-race signals without claiming provider-confirmed race metadata. Read-only.',
404
462
  inputSchema: {
@@ -409,17 +467,17 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
409
467
  sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
410
468
  },
411
469
  }, tool('find_activities', async (input) => useRepository(activePaths(), (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
412
- server.registerTool('get_swim_laps', {
470
+ registerTool('get_swim_laps', {
413
471
  title: 'Read explicit swim lengths and grouped sets',
414
472
  description: 'Return source-aware swim lengths when a provider actually supplied them, plus Garmin detected and Intervals.icu auto-detected sets. A missing length list is reported as unavailable; laps are never reconstructed from samples.',
415
473
  inputSchema: { activityId: z.string().min(1), provider: z.enum(['garmin', 'intervals']).optional() },
416
474
  }, tool('get_swim_laps', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimLaps(input))));
417
- server.registerTool('swim_progress_report', {
475
+ registerTool('swim_progress_report', {
418
476
  title: 'Build a source-aware swim progress report',
419
477
  description: 'Compare Garmin swimming summaries and data completeness over a date range, optionally within one pool length. It does not derive pace trends from moving time divided by distance.',
420
478
  inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), poolLengthM: z.number().positive().max(200).optional() },
421
479
  }, tool('swim_progress_report', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimProgressReport(input))));
422
- server.registerTool('get_activity_segments', {
480
+ registerTool('get_activity_segments', {
423
481
  title: 'Hydrate and read an activity’s Strava segments',
424
482
  description: 'For a selected activity’s segments, climbs, KOM/PRs, or per-segment analysis: automatically hydrate the matching Strava activity before returning its persisted segment efforts. Use this before saying segment data is unavailable.',
425
483
  inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
@@ -437,35 +495,35 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
437
495
  ],
438
496
  };
439
497
  }));
440
- server.registerTool('power_curve_trend', {
498
+ registerTool('power_curve_trend', {
441
499
  title: 'Read labelled power-curve trends',
442
500
  description: 'Return monthly bests for selected power durations from an explicit sport or sport family, including the supporting activity and source type. Read-only.',
443
501
  inputSchema: { sport: z.string().min(1).optional(), sportFamily: z.enum(['cycling', 'running']).optional(), durations: z.array(z.number().int().min(1).max(86_400)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), sourceQuality: z.enum(['all', 'garmin_fit_derived']).optional() },
444
502
  }, tool('power_curve_trend', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCurveTrend(input))));
445
- server.registerTool('power_coverage_report', {
503
+ registerTool('power_coverage_report', {
446
504
  title: 'Inspect FIT-derived power coverage',
447
505
  description: 'Report powered activities and the complete duration-best inventory for an explicit sport or sport family. Uses Garmin FIT-derived power, not sparse activity summaries. Read-only.',
448
506
  inputSchema: { sport: z.string().min(1).optional(), sportFamily: z.enum(['cycling', 'running']).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional(), sourceQuality: z.enum(['all', 'garmin_fit_derived']).optional() },
449
507
  }, tool('power_coverage_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCoverageReport(input))));
450
- server.registerTool('latest_cycling_activities', {
508
+ registerTool('latest_cycling_activities', {
451
509
  title: 'List latest cycling activities',
452
510
  description: 'List Garmin cycling source records without duplicate Intervals summaries, optionally flagging multisport parent records. Read-only.',
453
511
  inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), includeMultisport: z.boolean().optional(), limit: z.number().int().min(1).max(100).optional() },
454
512
  }, tool('latest_cycling_activities', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).latestCyclingActivities(input))));
455
- server.registerTool('cycling_progress_report', {
513
+ registerTool('cycling_progress_report', {
456
514
  title: 'Build a cycling progress report',
457
515
  description: 'Combine source-aware FTP and VO₂max histories with monthly canonical volume/load and labelled power-curve trends. Read-only and descriptive.',
458
516
  inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional() },
459
517
  }, tool('cycling_progress_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).cyclingProgressReport(input))));
460
- server.registerTool('query_read_only_data', {
518
+ registerTool('query_read_only_data', {
461
519
  title: 'Query cataloged read-only data', description: 'Advanced fallback after describe_data/describe_dataset: one parameterized SELECT or WITH … SELECT over cataloged views only. Do not query information_schema, DuckDB system tables, or uncataloged physical relations. Results use deterministic cursor pagination and always report whether they are incomplete. Filesystem access, extensions, DDL, and mutation are rejected.',
462
520
  inputSchema: { sql: z.string().min(1).max(20_000), values: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).refine((value) => Object.keys(value).length <= 100, 'At most 100 bind values are allowed.').optional(), cursor: z.string().min(1).optional(), pageSize: z.number().int().min(1).max(500).optional() },
463
521
  }, tool('query_read_only_data', async (input) => useRepository(activePaths(), (repository) => queryReadOnlyData(repository, input))));
464
- server.registerTool('search_context', {
522
+ registerTool('search_context', {
465
523
  title: 'Search generated context', description: 'Search compact generated activity, plan, nutrition, and message context. Results identify authoritative follow-up tools rather than making numerical claims.',
466
524
  inputSchema: { query: z.string().min(2).max(500), filters: z.array(z.object({ column: z.enum(['provider', 'entity_type', 'occurred_on']), op: z.enum(['eq', 'in', 'between']), value: z.unknown() })).max(20).optional(), limit: z.number().int().min(1).max(50).optional() },
467
525
  }, tool('search_context', async (input) => useRepository(activePaths(), (repository) => searchContext(repository, input))));
468
- server.registerTool('hydrate_strava_activity', {
526
+ registerTool('hydrate_strava_activity', {
469
527
  title: 'Hydrate one activity from Strava',
470
528
  description: 'Write-only targeted enrichment. Safely matches exactly one Catence activity/source to Strava, then archives and persists its activity detail, gear assignment, and segment efforts. For a segment/climb request, prefer get_activity_segments, which invokes this prerequisite automatically.',
471
529
  inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional() },
@@ -473,7 +531,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
473
531
  const data = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
474
532
  return { data, coverage: await useRepository(activePaths(), (repository) => repository.coverage()), provenance: { provider: 'strava', operation: 'activity_detail', archivedBeforeNormalization: true }, caveats: ['Segment verification is unavailable from Strava public segment data and is never inferred.'] };
475
533
  }));
476
- server.registerTool('hydrate_recent_strava_activities', {
534
+ registerTool('hydrate_recent_strava_activities', {
477
535
  title: 'Hydrate several recent Strava activities',
478
536
  description: 'Write-only targeted batch enrichment. Select an explicit list or a bounded date/sport window; Catence hydrates one activity at a time and reports every outcome.',
479
537
  inputSchema: z.object({
@@ -541,7 +599,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
541
599
  caveats: ['Each write is intentionally awaited before the next one, preventing data-directory lock contention.', 'not_found and ambiguous results include match diagnostics when Strava supplied them.'],
542
600
  };
543
601
  }));
544
- server.registerTool('hydrate_strava_segment_history', {
602
+ registerTool('hydrate_strava_segment_history', {
545
603
  title: 'Hydrate one Strava segment history',
546
604
  description: 'Write-only targeted enrichment. Fetches a persisted Strava segment and the authenticated athlete’s paged historic efforts; interrupted or throttled work is resumable.',
547
605
  inputSchema: { segmentId: z.string().min(1), refresh: z.boolean().optional() },
@@ -597,6 +655,13 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
597
655
  }
598
656
  });
599
657
  }
658
+ server.registerResource('tools', 'catence://tools', { title: 'Catence tool index', mimeType: 'application/json' }, async (uri) => {
659
+ return resource(uri, {
660
+ count: TOOL_INDEX.length,
661
+ note: 'If a needed tool is not visible in your current tool list (the host may truncate it), use this index to discover all available tools before concluding a capability is absent. SQL-only tables training_metric_observations and source_entities are reachable via query_read_only_data / describe_data.',
662
+ tools: TOOL_INDEX.map((tool) => ({ name: tool.name, purpose: tool.title, description: tool.description })),
663
+ });
664
+ });
600
665
  return server;
601
666
  }
602
667
  function variable(value) {
@@ -23,6 +23,7 @@ export type { DataFilter } from '../core/query/analytics.js';
23
23
  export { ActivityDiscoveryService } from '../core/query/activity-discovery.js';
24
24
  export { DATASET_CATALOG, getDataset, QueryValidationError } from '../core/query/catalog.js';
25
25
  export { FitnessService } from '../core/query/fitness.js';
26
+ export { EventsService, extractCourseId } from '../core/query/events.js';
26
27
  export { SwimmingService } from '../core/query/swimming.js';
27
28
  export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
28
29
  export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
@@ -35,6 +36,7 @@ export type { DetachedSyncHandle, DetachedSyncProvider, DetachedSyncRequest, Det
35
36
  export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
36
37
  export type { OpenCodeGoConsoleProfiles, OpenCodeGoMergeResult, OpenCodeGoRoute } from '../core/runtime/opencode-go.js';
37
38
  export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
38
- export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
39
+ export { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
39
40
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
41
+ export { migrations } from '../elt/storage/migrations.js';
40
42
  export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
@@ -10,6 +10,7 @@ export { AnalyticsService } from '../core/query/analytics.js';
10
10
  export { ActivityDiscoveryService } from '../core/query/activity-discovery.js';
11
11
  export { DATASET_CATALOG, getDataset, QueryValidationError } from '../core/query/catalog.js';
12
12
  export { FitnessService } from '../core/query/fitness.js';
13
+ export { EventsService, extractCourseId } from '../core/query/events.js';
13
14
  export { SwimmingService } from '../core/query/swimming.js';
14
15
  export { WELLNESS_METRICS, WellnessService } from '../core/query/wellness.js';
15
16
  export { jsonSafe, ReadOnlyRepository } from '../core/query/repository.js';
@@ -18,7 +19,8 @@ export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
18
19
  export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
19
20
  export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
20
21
  export { buildOpenCodeGoConsoleProfiles, classifyOpenCodeGoModel, fetchOpenCodeGoModelIds, mergeOpenCodeGoConsoleProfiles, OPENCODE_GO_API_BASE_ENV, OPENCODE_GO_API_KEY_ENV, OPENCODE_GO_DEFAULT_BASE_URL, OPENCODE_GO_MESSAGES_API_BASE_ENV, } from '../core/runtime/opencode-go.js';
21
- export { openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
22
+ export { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
22
23
  export { DataWriteBusyError } from '../elt/storage/write-lock.js';
24
+ export { migrations } from '../elt/storage/migrations.js';
23
25
  export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
24
26
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0-beta.21",
3
+ "version": "0.2.0-beta.23",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -33,6 +33,7 @@ GARMIN_READ_METHODS = {
33
33
  ),
34
34
  "workouts": ("get_workouts", "get_scheduled_workouts"),
35
35
  "events": ("get_events",),
36
+ "course": ("connectapi",),
36
37
  "goals": ("get_goals",),
37
38
  "gear": ("get_gear",),
38
39
  "golf": ("get_golf_summary",),
@@ -8,7 +8,7 @@ from typing import Any, Callable, Mapping
8
8
 
9
9
  from .progress import ProgressReporter
10
10
  from .registry import GARMIN_READ_METHODS, assert_read_only_registry
11
- from .staging import StagingWriter
11
+ from .staging import StagingWriter, safe_segment
12
12
  from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, fit_archive_to_swim_lengths, write_parquet
13
13
 
14
14
 
@@ -794,9 +794,37 @@ class GarminStagingWorker:
794
794
  if self.progress is not None:
795
795
  self.progress.advance(completed=len(calls) + len(months) + 1)
796
796
 
797
+ def stage_course(self, course_id: str) -> None:
798
+ """Fetch one Garmin course by id and stage it as a source entity.
799
+
800
+ garminconnect 0.3.x exposes no first-class course method, so this uses
801
+ the authenticated Connect API directly:
802
+ ``connectapi("course-service/course/{course_id}")``. Validated against a
803
+ live account (returns course metadata plus a ``geoPoints`` geometry array).
804
+ Failures are captured per call and reported as extraction errors without
805
+ aborting the rest of the sync.
806
+
807
+ The course is staged with remote_id == course_id (not an auto-derived
808
+ id) so event resolution can match the courseId an event references.
809
+ """
810
+ payload, raw_hash = self._capture(
811
+ "course",
812
+ lambda course_id=course_id: self.api.connectapi(f"course-service/course/{course_id}"),
813
+ course_id,
814
+ )
815
+ if payload is not None:
816
+ self.writer.source_entity("course", course_id, payload, raw_hash, course_id)
817
+
797
818
  def _fetch_garmin_events(self, start_date: str) -> None:
798
- """Fetch calendar events from start_date, following pagination."""
819
+ """Fetch calendar events from start_date, following pagination.
820
+
821
+ Any event that references a Garmin course (``courseId``/``course_id``)
822
+ triggers an automatic course fetch via stage_course, so a race's
823
+ geometry is synced without a manual recon run. Already-archived courses
824
+ are skipped to avoid re-fetching unchanged geometry every sync.
825
+ """
799
826
  page = 1
827
+ discovered_course_ids: set[str] = set()
800
828
  while True:
801
829
  payload, raw_hash = self._capture(
802
830
  "garmin_events",
@@ -806,6 +834,20 @@ class GarminStagingWorker:
806
834
  items = payload if isinstance(payload, list) else []
807
835
  if items:
808
836
  self._entity("garmin_events", "event", items, raw_hash)
837
+ for item in items:
838
+ if isinstance(item, Mapping):
839
+ course_id = item.get("courseId") or item.get("course_id")
840
+ if course_id:
841
+ discovered_course_ids.add(str(course_id))
809
842
  if len(items) < 100:
810
- return
843
+ break
811
844
  page += 1
845
+ for course_id in sorted(discovered_course_ids):
846
+ self._check_interrupted()
847
+ if not self._course_archived(course_id):
848
+ self.stage_course(course_id)
849
+
850
+ def _course_archived(self, course_id: str) -> bool:
851
+ """True when a course archive already exists under raw/garmin/course/{id}."""
852
+ directory = self.data_dir / "raw" / "garmin" / "course" / safe_segment(course_id)
853
+ return directory.is_dir() and any(directory.glob("*.json"))