catence 0.2.0-beta.21 → 0.2.0-beta.24

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,11 +1,42 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { existsSync } from 'node:fs';
4
- import { addAthlete, athleteStorePaths, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
4
+ import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
5
5
  import { parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
6
6
  import { createCatenceHttpServer } from '../http/server.js';
7
7
  import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
8
8
  import { createCatenceMcpServer } from './server.js';
9
+ async function migrateCatalogStores(home) {
10
+ // On Docker image bumps the DuckDB schema version increases. The
11
+ // read-only MCP health probe would otherwise report "version X required"
12
+ // until the next manual sync. Proactively opening each athlete store in
13
+ // writable mode runs the idempotent migrations (database.ts:migrate)
14
+ // before the HTTP server starts listening, so a plain `docker compose up`
15
+ // after `deploy-console.sh beta` heals existing installs without an extra
16
+ // manual `catence-data migrate --all` step. Failures are best-effort:
17
+ // a missing catalog or a store still locked by another process is retried
18
+ // on the next sync or health check.
19
+ try {
20
+ const catalogPaths = resolveCatalogPaths(home);
21
+ const catalog = await loadCatalog(catalogPaths).catch(() => null);
22
+ if (!catalog || catalog.athletes.length === 0)
23
+ return;
24
+ for (const athlete of catalog.athletes) {
25
+ const paths = athleteStorePaths(catalogPaths, athlete.id);
26
+ try {
27
+ const database = await CatenceDatabase.open(paths);
28
+ await database.close();
29
+ }
30
+ catch {
31
+ // Best-effort: a lock or a corrupted store must not prevent the
32
+ // server from starting; the next sync will retry the migration.
33
+ }
34
+ }
35
+ }
36
+ catch {
37
+ // Catalog read failures are non-fatal for startup.
38
+ }
39
+ }
9
40
  async function run() {
10
41
  const arguments_ = process.argv.slice(2);
11
42
  if (arguments_[0] === 'serve') {
@@ -14,6 +45,7 @@ async function run() {
14
45
  process.stderr.write(`${SERVE_USAGE}\n`);
15
46
  return;
16
47
  }
48
+ await migrateCatalogStores(options.home);
17
49
  const httpServer = createCatenceHttpServer({
18
50
  catalogPaths: resolveCatalogPaths(options.home),
19
51
  allowedOrigins: options.allowedOrigins,
@@ -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,12 @@ 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.',
35
+ 'For swim analysis: use find_activities (sports: ["lap_swimming"] or sport: "lap_swimming") to list pool sessions. Use get_swim_laps for per-length data (pace from duration_s/pool_length_m, SPL from stroke_count, SWOLF from duration_s+stroke_count). Use swim_progress_report for session-level SWOLF, fastest-100, and stroke cadence. Per-length SWOLF is derived when the provider does not supply it. Per-length distance is typically null in Garmin FIT; use pool_length_m for pace calculations.',
30
36
  ].join(' ');
31
37
  function textResult(value) {
32
38
  return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
@@ -133,9 +139,16 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
133
139
  const activeAthlete = () => scope.getStore()?.athlete;
134
140
  const limiter = new SlidingWindowLimiter();
135
141
  const athleteIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/);
142
+ // Discoverability: record every registered tool so the catence://tools resource can
143
+ // enumerate all tools even when the host harness truncates the visible tool list.
144
+ const TOOL_INDEX = [];
145
+ function registerTool(name, config, callback) {
146
+ TOOL_INDEX.push({ name, title: config.title ?? name, description: config.description ?? '' });
147
+ server.registerTool(name, config, callback);
148
+ }
136
149
  const promptAthleteSchema = catalogPaths ? { athleteId: athleteIdSchema } : {};
137
150
  if (catalogPaths) {
138
- server.registerTool('list_athletes', {
151
+ registerTool('list_athletes', {
139
152
  title: 'List configured athletes',
140
153
  description: 'List athlete IDs and labels available to this shared Catence agent. No personal metrics are returned.',
141
154
  }, async () => {
@@ -229,24 +242,24 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
229
242
  }, ({ activityId, ...input }) => ({
230
243
  messages: [{
231
244
  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.` },
245
+ 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
246
  }],
234
247
  }));
235
- server.registerTool('catence_status', {
248
+ registerTool('catence_status', {
236
249
  title: 'Catence data status',
237
250
  description: 'Read sync state, data coverage, entity counts, stream availability, unresolved errors, and retrieval-index freshness. Read-only.',
238
251
  }, tool('catence_status', async () => useRepository(activePaths(), async (repository) => ({
239
252
  data: { ...(await repository.status()), ...(await repository.coverage()) },
240
253
  provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
241
254
  }))));
242
- server.registerTool('catence_sync_progress', {
255
+ registerTool('catence_sync_progress', {
243
256
  title: 'Catence sync progress',
244
257
  description: 'Read live progress heartbeats for active sync runs, plus the most recent completed or interrupted runs. Read-only.',
245
258
  }, tool('catence_sync_progress', async () => useRepository(activePaths(), async (repository) => ({
246
259
  data: await repository.progress(),
247
260
  provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
248
261
  }))));
249
- server.registerTool('start_detached_sync', {
262
+ registerTool('start_detached_sync', {
250
263
  title: 'Start a detached data sync',
251
264
  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
265
  inputSchema: {
@@ -268,7 +281,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
268
281
  ],
269
282
  };
270
283
  }));
271
- server.registerTool('review_daily_recovery_load', {
284
+ registerTool('review_daily_recovery_load', {
272
285
  title: 'Review daily recovery and training load',
273
286
  description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
274
287
  inputSchema: { date: z.string().date().optional() },
@@ -284,7 +297,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
284
297
  ],
285
298
  }));
286
299
  }));
287
- server.registerTool('review_weekly_training', {
300
+ registerTool('review_weekly_training', {
288
301
  title: 'Review seven days of training',
289
302
  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
303
  inputSchema: { endDate: z.string().date().optional() },
@@ -301,7 +314,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
301
314
  ],
302
315
  }));
303
316
  }));
304
- server.registerTool('review_activity_deep_dive', {
317
+ registerTool('review_activity_deep_dive', {
305
318
  title: 'Review one activity in depth',
306
319
  description: 'Return canonical activity identity, source summaries, and structured intervals. Call read_series separately only when a specific sampled metric is required.',
307
320
  inputSchema: { activityId: z.string().min(1) },
@@ -316,14 +329,14 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
316
329
  : ['No canonical activity matched this activityId. Use find_activities before drawing conclusions.'],
317
330
  };
318
331
  })));
319
- server.registerTool('describe_data', {
332
+ registerTool('describe_data', {
320
333
  title: 'Describe available Catence datasets',
321
334
  description: 'List cataloged datasets, fields, units, permitted filters/groupings, providers, and time coverage. Read-only.',
322
335
  }, tool('describe_data', async () => useRepository(activePaths(), async (repository) => ({
323
336
  data: { datasets: repository.catalog(), ...(await repository.coverage()) },
324
337
  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
338
  }))));
326
- server.registerTool('describe_dataset', {
339
+ registerTool('describe_dataset', {
327
340
  title: 'Describe one Catence dataset',
328
341
  description: 'Read a compact schema, permitted filters/groupings, provenance fields, and coverage for one cataloged dataset. Read-only.',
329
342
  inputSchema: { dataset: z.string().min(1) },
@@ -346,10 +359,10 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
346
359
  provenance: { catalog: 'Catence catalog', database: 'read-only DuckDB snapshot' }, query: input, caveats: [],
347
360
  };
348
361
  })));
349
- server.registerTool('read_series', {
362
+ registerTool('read_series', {
350
363
  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
364
  }, tool('read_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).readSeries({ ...input, filters: input.filters }))));
352
- server.registerTool('aggregate_data', {
365
+ registerTool('aggregate_data', {
353
366
  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
367
  inputSchema: {
355
368
  dataset: z.string().min(1),
@@ -357,15 +370,42 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
357
370
  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
371
  },
359
372
  }, tool('aggregate_data', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
360
- server.registerTool('analyze_series', {
373
+ registerTool('analyze_series', {
361
374
  title: 'Analyze a descriptive series', description: 'Run deterministic rolling statistics, baselines, correlations, seasonal comparisons, or trends on a cataloged series.',
362
375
  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
376
  }, tool('analyze_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).analyzeSeries({ ...input, filters: input.filters }))));
364
- server.registerTool('fit_series_model', {
377
+ registerTool('fit_series_model', {
365
378
  title: 'Fit a descriptive series model', description: 'Fit a bounded OLS, Theil–Sen, quadratic, or cubic descriptive model. Not a sport-performance model.',
366
379
  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
380
  }, tool('fit_series_model', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).fitSeriesModel({ ...input, filters: input.filters }))));
368
- server.registerTool('wellness_correlate', {
381
+ registerTool('activity_decoupling', {
382
+ title: 'Derive aerobic decoupling and GAP for one activity',
383
+ 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.',
384
+ inputSchema: { activityId: z.string().min(1), includeGap: z.boolean().optional() },
385
+ }, tool('activity_decoupling', async (input) => useRepository(activePaths(), async (repository) => {
386
+ const analytics = new AnalyticsService(repository);
387
+ const decoupling = await analytics.deriveDecoupling({ activityId: input.activityId });
388
+ const gap = input.includeGap === false ? null : await analytics.deriveGap({ activityId: input.activityId });
389
+ const decouplingData = decoupling;
390
+ const gapData = gap;
391
+ const combinedCaveats = [
392
+ ...(decouplingData.caveats ?? []),
393
+ ...(gapData?.caveats ?? []),
394
+ ];
395
+ return jsonSafe({
396
+ data: { decoupling: decouplingData.data ?? null, gap: gapData?.data ?? null, decouplingRaw: decouplingData, gapRaw: gapData },
397
+ provenance: {
398
+ decoupling: decouplingData.provenance,
399
+ gap: gapData?.provenance ?? null,
400
+ derivation: 'derived',
401
+ note: 'Both metrics are derived from activity_samples (Parquet lake) when provider summaries do not supply them; provider values remain authoritative.',
402
+ },
403
+ query: { activityId: input.activityId, includeGap: input.includeGap ?? true, decouplingQuery: decouplingData.query, gapQuery: gapData?.query ?? null },
404
+ caveats: combinedCaveats.length ? combinedCaveats : ['Derived metrics are descriptive only; not a physiological performance model.'],
405
+ inputs: { decouplingInput: decouplingData.input, gapInput: gapData?.input ?? null },
406
+ });
407
+ })));
408
+ registerTool('wellness_correlate', {
369
409
  title: 'Correlate recovery and training metrics',
370
410
  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
411
  inputSchema: {
@@ -373,35 +413,55 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
373
413
  method: z.enum(['pearson', 'spearman']).optional(), lagDays: z.number().int().min(-30).max(30).optional(), scanLags: z.boolean().optional(),
374
414
  },
375
415
  }, tool('wellness_correlate', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).correlate(input))));
376
- server.registerTool('wellness_baselines', {
416
+ registerTool('wellness_baselines', {
377
417
  title: 'Read personal wellness baselines',
378
418
  description: 'Return trailing means, standard-deviation bands, latest values, and latest z-scores for common recovery and training metrics. Missing values remain missing.',
379
419
  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
420
  }, tool('wellness_baselines', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).baselines(input))));
381
- server.registerTool('wellness_anomalies', {
421
+ registerTool('wellness_anomalies', {
382
422
  title: 'Find statistical wellness anomalies',
383
423
  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
424
  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
425
  }, tool('wellness_anomalies', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).anomalies(input))));
386
- server.registerTool('wellness_coverage', {
426
+ registerTool('wellness_coverage', {
387
427
  title: 'Inspect wellness data coverage',
388
428
  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
429
  inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
390
430
  }, tool('wellness_coverage', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).coverage(input))));
391
- server.registerTool('get_ftp_history', {
431
+ registerTool('get_ftp_history', {
392
432
  title: 'Get dated FTP history',
393
433
  description: 'Return normalized cycling FTP settings and activity-summary observations with source-aware preferred daily values. Read-only.',
394
434
  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
435
  }, tool('get_ftp_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).ftpHistory(input))));
396
- server.registerTool('get_vo2max_history', {
436
+ registerTool('readiness_baseline', {
437
+ title: 'Read a running/race readiness baseline',
438
+ 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.',
439
+ inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
440
+ }, tool('readiness_baseline', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).readinessBaseline(input))));
441
+ registerTool('resolve_event_course', {
442
+ title: 'Resolve a race event course and its synced geometry',
443
+ 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.',
444
+ inputSchema: { eventId: z.string().min(1), pastActivityId: z.string().min(1).optional() },
445
+ }, tool('resolve_event_course', async (input) => useRepository(activePaths(), async (repository) => {
446
+ const events = new EventsService(repository);
447
+ const resolved = await events.resolveEventCourse(input.eventId);
448
+ const diff = input.pastActivityId ? await events.diffCourseProfiles(resolved.courseId, input.pastActivityId) : null;
449
+ return {
450
+ data: { event: resolved, courseDiff: diff },
451
+ provenance: { relation: 'source_entities' },
452
+ caveats: [...resolved.caveats, ...(diff?.caveats ?? [])],
453
+ };
454
+ })));
455
+ registerTool('get_vo2max_history', {
397
456
  title: 'Get sport-specific VO₂max history',
398
457
  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
458
  inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
400
459
  }, tool('get_vo2max_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).vo2MaxHistory(input))));
401
- server.registerTool('find_activities', {
460
+ registerTool('find_activities', {
402
461
  title: 'Find activities and likely races',
403
462
  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
463
  inputSchema: {
464
+ sport: z.string().min(1).optional(),
405
465
  sports: z.array(z.string().min(1)).min(1).max(12).optional(),
406
466
  distanceKm: z.tuple([z.number().min(0), z.number().min(0)]).optional(),
407
467
  nameContains: z.array(z.string().min(1)).min(1).max(12).optional(),
@@ -409,17 +469,17 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
409
469
  sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
410
470
  },
411
471
  }, tool('find_activities', async (input) => useRepository(activePaths(), (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
412
- server.registerTool('get_swim_laps', {
472
+ registerTool('get_swim_laps', {
413
473
  title: 'Read explicit swim lengths and grouped sets',
414
474
  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
475
  inputSchema: { activityId: z.string().min(1), provider: z.enum(['garmin', 'intervals']).optional() },
416
476
  }, tool('get_swim_laps', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimLaps(input))));
417
- server.registerTool('swim_progress_report', {
477
+ registerTool('swim_progress_report', {
418
478
  title: 'Build a source-aware swim progress report',
419
479
  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
480
  inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), poolLengthM: z.number().positive().max(200).optional() },
421
481
  }, tool('swim_progress_report', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimProgressReport(input))));
422
- server.registerTool('get_activity_segments', {
482
+ registerTool('get_activity_segments', {
423
483
  title: 'Hydrate and read an activity’s Strava segments',
424
484
  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
485
  inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
@@ -437,35 +497,35 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
437
497
  ],
438
498
  };
439
499
  }));
440
- server.registerTool('power_curve_trend', {
500
+ registerTool('power_curve_trend', {
441
501
  title: 'Read labelled power-curve trends',
442
502
  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
503
  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
504
  }, tool('power_curve_trend', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCurveTrend(input))));
445
- server.registerTool('power_coverage_report', {
505
+ registerTool('power_coverage_report', {
446
506
  title: 'Inspect FIT-derived power coverage',
447
507
  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
508
  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
509
  }, tool('power_coverage_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCoverageReport(input))));
450
- server.registerTool('latest_cycling_activities', {
510
+ registerTool('latest_cycling_activities', {
451
511
  title: 'List latest cycling activities',
452
512
  description: 'List Garmin cycling source records without duplicate Intervals summaries, optionally flagging multisport parent records. Read-only.',
453
513
  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
514
  }, tool('latest_cycling_activities', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).latestCyclingActivities(input))));
455
- server.registerTool('cycling_progress_report', {
515
+ registerTool('cycling_progress_report', {
456
516
  title: 'Build a cycling progress report',
457
517
  description: 'Combine source-aware FTP and VO₂max histories with monthly canonical volume/load and labelled power-curve trends. Read-only and descriptive.',
458
518
  inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional() },
459
519
  }, tool('cycling_progress_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).cyclingProgressReport(input))));
460
- server.registerTool('query_read_only_data', {
520
+ registerTool('query_read_only_data', {
461
521
  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
522
  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
523
  }, tool('query_read_only_data', async (input) => useRepository(activePaths(), (repository) => queryReadOnlyData(repository, input))));
464
- server.registerTool('search_context', {
524
+ registerTool('search_context', {
465
525
  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
526
  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
527
  }, tool('search_context', async (input) => useRepository(activePaths(), (repository) => searchContext(repository, input))));
468
- server.registerTool('hydrate_strava_activity', {
528
+ registerTool('hydrate_strava_activity', {
469
529
  title: 'Hydrate one activity from Strava',
470
530
  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
531
  inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional() },
@@ -473,7 +533,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
473
533
  const data = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
474
534
  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
535
  }));
476
- server.registerTool('hydrate_recent_strava_activities', {
536
+ registerTool('hydrate_recent_strava_activities', {
477
537
  title: 'Hydrate several recent Strava activities',
478
538
  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
539
  inputSchema: z.object({
@@ -541,7 +601,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
541
601
  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
602
  };
543
603
  }));
544
- server.registerTool('hydrate_strava_segment_history', {
604
+ registerTool('hydrate_strava_segment_history', {
545
605
  title: 'Hydrate one Strava segment history',
546
606
  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
607
  inputSchema: { segmentId: z.string().min(1), refresh: z.boolean().optional() },
@@ -597,6 +657,13 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
597
657
  }
598
658
  });
599
659
  }
660
+ server.registerResource('tools', 'catence://tools', { title: 'Catence tool index', mimeType: 'application/json' }, async (uri) => {
661
+ return resource(uri, {
662
+ count: TOOL_INDEX.length,
663
+ 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.',
664
+ tools: TOOL_INDEX.map((tool) => ({ name: tool.name, purpose: tool.title, description: tool.description })),
665
+ });
666
+ });
600
667
  return server;
601
668
  }
602
669
  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.24",
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"))