catence 0.2.0-beta.9 → 0.2.1-beta.1
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.
- package/README.md +38 -180
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/progress.d.ts +23 -0
- package/dist/contracts/progress.js +49 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/activity-discovery.d.ts +2 -0
- package/dist/core/query/activity-discovery.js +1 -1
- package/dist/core/query/analytics.d.ts +10 -0
- package/dist/core/query/analytics.js +494 -0
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +12 -4
- package/dist/core/query/events.d.ts +34 -0
- package/dist/core/query/events.js +121 -0
- package/dist/core/query/fitness.d.ts +4 -0
- package/dist/core/query/fitness.js +60 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +48 -0
- package/dist/core/query/sql-guard.js +37 -5
- package/dist/core/query/swimming.js +2 -2
- package/dist/core/runtime/configuration.d.ts +3 -1
- package/dist/core/runtime/configuration.js +5 -1
- package/dist/core/runtime/opencode-go.d.ts +58 -0
- package/dist/core/runtime/opencode-go.js +246 -0
- package/dist/core/runtime/secrets.js +51 -13
- package/dist/elt/application/detached-sync.d.ts +50 -0
- package/dist/elt/application/detached-sync.js +99 -0
- package/dist/elt/application/management.d.ts +15 -0
- package/dist/elt/application/management.js +420 -18
- package/dist/elt/ingestion/importer.js +1 -1
- package/dist/elt/ingestion/providers/intervals.d.ts +9 -0
- package/dist/elt/ingestion/providers/intervals.js +33 -1
- package/dist/elt/ingestion/providers/registry.d.ts +13 -0
- package/dist/elt/ingestion/providers/registry.js +19 -1
- package/dist/elt/normalization/normalizers.js +207 -15
- package/dist/elt/storage/database.d.ts +21 -2
- package/dist/elt/storage/database.js +182 -6
- package/dist/elt/storage/migrations.js +145 -1
- package/dist/elt/storage/progress-sidecar.d.ts +61 -0
- package/dist/elt/storage/progress-sidecar.js +200 -0
- package/dist/interfaces/cli/commands.js +94 -1
- package/dist/interfaces/http/generation-status.d.ts +19 -0
- package/dist/interfaces/http/generation-status.js +55 -0
- package/dist/interfaces/http/server.d.ts +7 -1
- package/dist/interfaces/http/server.js +184 -1
- package/dist/interfaces/mcp/main.js +33 -1
- package/dist/interfaces/mcp/server.d.ts +2 -1
- package/dist/interfaces/mcp/server.js +203 -45
- package/dist/runtime/index.d.ts +9 -2
- package/dist/runtime/index.js +6 -2
- package/docs/architecture.md +13 -2
- package/docs/assets/image.png +0 -0
- package/docs/configuration.md +243 -0
- package/docs/deployment/docker.md +528 -0
- package/docs/deployment/local-console.md +263 -0
- package/docs/deployment/local-mcp.md +329 -0
- package/docs/development.md +337 -0
- package/docs/distribution.md +45 -0
- package/docs/llm-providers.md +24 -2
- package/docs/mcp-activity-retrieval.md +90 -210
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/python/catence/providers/garmin/cli.py +43 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/registry.py +2 -0
- package/python/catence/providers/garmin/streams.py +38 -0
- package/python/catence/providers/garmin/swim_backfill.py +83 -0
- package/python/catence/providers/garmin/worker.py +415 -12
- package/scripts/discover-opencode-go.mjs +36 -144
- package/uv.lock +3 -3
- package/docs/deployment.md +0 -338
|
@@ -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, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, 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,22 +27,33 @@ 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 or call discover_tools to inventory every available tool before concluding a capability is absent.',
|
|
35
|
+
'To find a next or upcoming race: list dates with aggregate_data on the events dataset (group or filter by occurred_on), identify the event name via search_context or its payload, then call resolve_event_course(eventId) to resolve the courseId and whether that course geometry is synced.',
|
|
36
|
+
'For a course elevation/height profile or GPX track: call resolve_event_course(eventId) first (it reports the courseId, synced geometry, and geometry sample count), then read the course_geometry dataset with read_series / aggregate_data / query_read_only_data for per-point altitude_m, lat, lon, and distance_m. Never claim a profile is absent before resolve_event_course returns.',
|
|
37
|
+
'Never treat a tool error or empty result as a dead end: switch strategy or re-read the routing guidance before re-running the same query.',
|
|
38
|
+
'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
39
|
].join(' ');
|
|
31
40
|
function textResult(value) {
|
|
32
41
|
return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
|
|
33
42
|
}
|
|
34
43
|
function errorResult(error) {
|
|
35
|
-
const classified = error instanceof
|
|
36
|
-
? { code: '
|
|
37
|
-
: error instanceof
|
|
38
|
-
? { code: '
|
|
39
|
-
: error instanceof
|
|
40
|
-
? { code: '
|
|
41
|
-
: error instanceof
|
|
42
|
-
? { code:
|
|
43
|
-
: error instanceof
|
|
44
|
-
? { code:
|
|
45
|
-
:
|
|
44
|
+
const classified = error instanceof DetachedSyncBusyError
|
|
45
|
+
? { code: 'sync_in_progress', message: error.message, retryable: true, runId: error.runId }
|
|
46
|
+
: error instanceof DataWriteBusyError
|
|
47
|
+
? { code: 'data_sync_in_progress', message: error.message, retryable: true }
|
|
48
|
+
: error instanceof StravaRateLimitError
|
|
49
|
+
? { code: 'rate_limited', message: error.message, retryable: true, retryAfterSeconds: error.retryAfterSeconds ?? null }
|
|
50
|
+
: error instanceof StravaEnrichmentError
|
|
51
|
+
? { code: 'strava_connection_error', message: error.message }
|
|
52
|
+
: error instanceof ReadOnlyDatabaseError
|
|
53
|
+
? { code: error.code, message: error.message }
|
|
54
|
+
: error instanceof QueryValidationError
|
|
55
|
+
? { code: 'invalid_request', message: error.message }
|
|
56
|
+
: { code: 'data_unavailable', message: error instanceof Error ? error.message : String(error) };
|
|
46
57
|
return { ...textResult({ data: null, provenance: {}, query: {}, caveats: [classified.message], error: classified }), isError: true };
|
|
47
58
|
}
|
|
48
59
|
function hydrationFailure(error) {
|
|
@@ -123,6 +134,7 @@ async function activitySegments(repository, activityId, hydratedStravaActivityId
|
|
|
123
134
|
export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependencies = {}) {
|
|
124
135
|
const server = new McpServer({ name: 'catence', version: '0.1.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
125
136
|
const hydrateActivity = dependencies.hydrateStravaActivity ?? hydrateStravaActivity;
|
|
137
|
+
const startSync = dependencies.startDetachedSync ?? startDetachedSync;
|
|
126
138
|
const catalogPaths = isCatalogPaths(paths) ? paths : null;
|
|
127
139
|
const staticPaths = isCatalogPaths(paths) ? null : paths;
|
|
128
140
|
const scope = new AsyncLocalStorage();
|
|
@@ -130,9 +142,28 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
130
142
|
const activeAthlete = () => scope.getStore()?.athlete;
|
|
131
143
|
const limiter = new SlidingWindowLimiter();
|
|
132
144
|
const athleteIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/);
|
|
145
|
+
// Discoverability: record every registered tool so the catence://tools resource can
|
|
146
|
+
// enumerate all tools even when the host harness truncates the visible tool list.
|
|
147
|
+
const TOOL_INDEX = [];
|
|
148
|
+
function registerTool(name, config, callback) {
|
|
149
|
+
TOOL_INDEX.push({ name, title: config.title ?? name, description: config.description ?? '', keywords: config.keywords ?? [] });
|
|
150
|
+
server.registerTool(name, config, callback);
|
|
151
|
+
}
|
|
152
|
+
// Keyword-rich routing map surfacing the recommended tool chain per question
|
|
153
|
+
// domain. Surfaced in catence://tools so a host can navigate without knowing
|
|
154
|
+
// every tool name up front.
|
|
155
|
+
const TOOL_ROUTING = [
|
|
156
|
+
{ question: 'next race / upcoming event', steps: ['aggregate_data (events, by occurred_on)', 'search_context', 'resolve_event_course'] },
|
|
157
|
+
{ question: 'course elevation / height profile / GPX track', steps: ['resolve_event_course', 'read_series / aggregate_data (course_geometry)'] },
|
|
158
|
+
{ question: 'swim per-length analysis (pace / SPL / SWOLF)', steps: ['find_activities', 'get_swim_laps', 'swim_progress_report'] },
|
|
159
|
+
{ question: 'segments / climbs / KOM / PR', steps: ['get_activity_segments', 'hydrate_strava_segment_history'] },
|
|
160
|
+
{ question: 'race readiness / fitness targets', steps: ['readiness_baseline', 'analyze_series', 'aggregate_data'] },
|
|
161
|
+
{ question: 'recovery / daily or weekly load', steps: ['review_daily_recovery_load', 'review_weekly_training', 'wellness_baselines'] },
|
|
162
|
+
{ question: 'cycling FTP / VO₂max / power curve', steps: ['get_ftp_history', 'get_vo2max_history', 'power_coverage_report', 'power_curve_trend'] },
|
|
163
|
+
];
|
|
133
164
|
const promptAthleteSchema = catalogPaths ? { athleteId: athleteIdSchema } : {};
|
|
134
165
|
if (catalogPaths) {
|
|
135
|
-
|
|
166
|
+
registerTool('list_athletes', {
|
|
136
167
|
title: 'List configured athletes',
|
|
137
168
|
description: 'List athlete IDs and labels available to this shared Catence agent. No personal metrics are returned.',
|
|
138
169
|
}, async () => {
|
|
@@ -147,10 +178,29 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
147
178
|
const originalRegister = server.registerTool.bind(server);
|
|
148
179
|
server.registerTool = (name, configuration, handler) => {
|
|
149
180
|
const originalInput = configuration.inputSchema;
|
|
181
|
+
let mergedInputSchema;
|
|
182
|
+
if (originalInput) {
|
|
183
|
+
const hasV4 = '_zod' in originalInput;
|
|
184
|
+
const hasV3 = '_def' in originalInput;
|
|
185
|
+
if (hasV4 || hasV3) {
|
|
186
|
+
// originalInput is already a Zod object schema; extract its shape and
|
|
187
|
+
// merge athleteId into a new object of the same Zod version.
|
|
188
|
+
const rawShape = originalInput.shape;
|
|
189
|
+
const shape = typeof rawShape === 'object' && rawShape !== null ? rawShape : {};
|
|
190
|
+
mergedInputSchema = z.object({ ...shape, athleteId: athleteIdSchema });
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
// originalInput is a raw shape (plain object of Zod schemas).
|
|
194
|
+
mergedInputSchema = { athleteId: athleteIdSchema, ...originalInput };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
mergedInputSchema = { athleteId: athleteIdSchema };
|
|
199
|
+
}
|
|
150
200
|
return originalRegister(name, {
|
|
151
201
|
...configuration,
|
|
152
202
|
description: `${String(configuration.description ?? '')} Requires athleteId from list_athletes.`,
|
|
153
|
-
inputSchema:
|
|
203
|
+
inputSchema: mergedInputSchema,
|
|
154
204
|
}, handler);
|
|
155
205
|
};
|
|
156
206
|
}
|
|
@@ -176,7 +226,12 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
176
226
|
...(demoStore ? { demoStore: { generated: true, seed: demoStore.seed, days: demoStore.days, startDate: demoStore.startDate, endDate: demoStore.endDate }, caveats: [...caveats, 'This response uses Catence generated demo data, not personal measurements.'] } : {}),
|
|
177
227
|
});
|
|
178
228
|
};
|
|
179
|
-
return
|
|
229
|
+
// `return await` matters here: a bare `return run()` would let a
|
|
230
|
+
// rejected promise escape the try block, bypassing the classified
|
|
231
|
+
// errorResult payload entirely.
|
|
232
|
+
if (selected)
|
|
233
|
+
return await scope.run(selected, run);
|
|
234
|
+
return await run();
|
|
180
235
|
}
|
|
181
236
|
catch (error) {
|
|
182
237
|
return errorResult(error);
|
|
@@ -221,17 +276,46 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
221
276
|
}, ({ activityId, ...input }) => ({
|
|
222
277
|
messages: [{
|
|
223
278
|
role: 'user',
|
|
224
|
-
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.` },
|
|
279
|
+
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.` },
|
|
225
280
|
}],
|
|
226
281
|
}));
|
|
227
|
-
|
|
282
|
+
registerTool('catence_status', {
|
|
228
283
|
title: 'Catence data status',
|
|
229
284
|
description: 'Read sync state, data coverage, entity counts, stream availability, unresolved errors, and retrieval-index freshness. Read-only.',
|
|
230
285
|
}, tool('catence_status', async () => useRepository(activePaths(), async (repository) => ({
|
|
231
286
|
data: { ...(await repository.status()), ...(await repository.coverage()) },
|
|
232
287
|
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
233
288
|
}))));
|
|
234
|
-
|
|
289
|
+
registerTool('catence_sync_progress', {
|
|
290
|
+
title: 'Catence sync progress',
|
|
291
|
+
description: 'Read live progress heartbeats for active sync runs, plus the most recent completed or interrupted runs. Read-only.',
|
|
292
|
+
}, tool('catence_sync_progress', async () => useRepository(activePaths(), async (repository) => ({
|
|
293
|
+
data: await repository.progress(),
|
|
294
|
+
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
295
|
+
}))));
|
|
296
|
+
registerTool('start_detached_sync', {
|
|
297
|
+
title: 'Start a detached data sync',
|
|
298
|
+
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.',
|
|
299
|
+
inputSchema: {
|
|
300
|
+
provider: z.enum(['intervals', 'garmin', 'strava', 'all']).optional(),
|
|
301
|
+
from: z.string().date().optional(),
|
|
302
|
+
refresh: z.boolean().optional(),
|
|
303
|
+
},
|
|
304
|
+
}, tool('start_detached_sync', async (input) => {
|
|
305
|
+
const paths = activePaths();
|
|
306
|
+
const athleteId = activeAthlete()?.id ?? 'local';
|
|
307
|
+
const handle = await startSync({ paths, athleteId, provider: input.provider, from: input.from, refresh: input.refresh });
|
|
308
|
+
return {
|
|
309
|
+
data: { ...handle, progressTool: 'catence_sync_progress' },
|
|
310
|
+
provenance: { operation: 'detached_sync_spawn' },
|
|
311
|
+
query: { ...input },
|
|
312
|
+
caveats: [
|
|
313
|
+
'The sync runs in its own process and keeps running if this session ends; its log file records the full worker output.',
|
|
314
|
+
'Only one sync run may be active per athlete store.',
|
|
315
|
+
],
|
|
316
|
+
};
|
|
317
|
+
}));
|
|
318
|
+
registerTool('review_daily_recovery_load', {
|
|
235
319
|
title: 'Review daily recovery and training load',
|
|
236
320
|
description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
|
|
237
321
|
inputSchema: { date: z.string().date().optional() },
|
|
@@ -247,7 +331,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
247
331
|
],
|
|
248
332
|
}));
|
|
249
333
|
}));
|
|
250
|
-
|
|
334
|
+
registerTool('review_weekly_training', {
|
|
251
335
|
title: 'Review seven days of training',
|
|
252
336
|
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.',
|
|
253
337
|
inputSchema: { endDate: z.string().date().optional() },
|
|
@@ -264,7 +348,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
264
348
|
],
|
|
265
349
|
}));
|
|
266
350
|
}));
|
|
267
|
-
|
|
351
|
+
registerTool('review_activity_deep_dive', {
|
|
268
352
|
title: 'Review one activity in depth',
|
|
269
353
|
description: 'Return canonical activity identity, source summaries, and structured intervals. Call read_series separately only when a specific sampled metric is required.',
|
|
270
354
|
inputSchema: { activityId: z.string().min(1) },
|
|
@@ -279,14 +363,14 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
279
363
|
: ['No canonical activity matched this activityId. Use find_activities before drawing conclusions.'],
|
|
280
364
|
};
|
|
281
365
|
})));
|
|
282
|
-
|
|
366
|
+
registerTool('describe_data', {
|
|
283
367
|
title: 'Describe available Catence datasets',
|
|
284
368
|
description: 'List cataloged datasets, fields, units, permitted filters/groupings, providers, and time coverage. Read-only.',
|
|
285
369
|
}, tool('describe_data', async () => useRepository(activePaths(), async (repository) => ({
|
|
286
370
|
data: { datasets: repository.catalog(), ...(await repository.coverage()) },
|
|
287
371
|
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.'],
|
|
288
372
|
}))));
|
|
289
|
-
|
|
373
|
+
registerTool('describe_dataset', {
|
|
290
374
|
title: 'Describe one Catence dataset',
|
|
291
375
|
description: 'Read a compact schema, permitted filters/groupings, provenance fields, and coverage for one cataloged dataset. Read-only.',
|
|
292
376
|
inputSchema: { dataset: z.string().min(1) },
|
|
@@ -309,10 +393,10 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
309
393
|
provenance: { catalog: 'Catence catalog', database: 'read-only DuckDB snapshot' }, query: input, caveats: [],
|
|
310
394
|
};
|
|
311
395
|
})));
|
|
312
|
-
|
|
396
|
+
registerTool('read_series', {
|
|
313
397
|
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,
|
|
314
398
|
}, tool('read_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).readSeries({ ...input, filters: input.filters }))));
|
|
315
|
-
|
|
399
|
+
registerTool('aggregate_data', {
|
|
316
400
|
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.',
|
|
317
401
|
inputSchema: {
|
|
318
402
|
dataset: z.string().min(1),
|
|
@@ -320,15 +404,42 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
320
404
|
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(),
|
|
321
405
|
},
|
|
322
406
|
}, tool('aggregate_data', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
|
|
323
|
-
|
|
407
|
+
registerTool('analyze_series', {
|
|
324
408
|
title: 'Analyze a descriptive series', description: 'Run deterministic rolling statistics, baselines, correlations, seasonal comparisons, or trends on a cataloged series.',
|
|
325
409
|
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() },
|
|
326
410
|
}, tool('analyze_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).analyzeSeries({ ...input, filters: input.filters }))));
|
|
327
|
-
|
|
411
|
+
registerTool('fit_series_model', {
|
|
328
412
|
title: 'Fit a descriptive series model', description: 'Fit a bounded OLS, Theil–Sen, quadratic, or cubic descriptive model. Not a sport-performance model.',
|
|
329
413
|
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() },
|
|
330
414
|
}, tool('fit_series_model', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).fitSeriesModel({ ...input, filters: input.filters }))));
|
|
331
|
-
|
|
415
|
+
registerTool('activity_decoupling', {
|
|
416
|
+
title: 'Derive aerobic decoupling and GAP for one activity',
|
|
417
|
+
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.',
|
|
418
|
+
inputSchema: { activityId: z.string().min(1), includeGap: z.boolean().optional() },
|
|
419
|
+
}, tool('activity_decoupling', async (input) => useRepository(activePaths(), async (repository) => {
|
|
420
|
+
const analytics = new AnalyticsService(repository);
|
|
421
|
+
const decoupling = await analytics.deriveDecoupling({ activityId: input.activityId });
|
|
422
|
+
const gap = input.includeGap === false ? null : await analytics.deriveGap({ activityId: input.activityId });
|
|
423
|
+
const decouplingData = decoupling;
|
|
424
|
+
const gapData = gap;
|
|
425
|
+
const combinedCaveats = [
|
|
426
|
+
...(decouplingData.caveats ?? []),
|
|
427
|
+
...(gapData?.caveats ?? []),
|
|
428
|
+
];
|
|
429
|
+
return jsonSafe({
|
|
430
|
+
data: { decoupling: decouplingData.data ?? null, gap: gapData?.data ?? null, decouplingRaw: decouplingData, gapRaw: gapData },
|
|
431
|
+
provenance: {
|
|
432
|
+
decoupling: decouplingData.provenance,
|
|
433
|
+
gap: gapData?.provenance ?? null,
|
|
434
|
+
derivation: 'derived',
|
|
435
|
+
note: 'Both metrics are derived from activity_samples (Parquet lake) when provider summaries do not supply them; provider values remain authoritative.',
|
|
436
|
+
},
|
|
437
|
+
query: { activityId: input.activityId, includeGap: input.includeGap ?? true, decouplingQuery: decouplingData.query, gapQuery: gapData?.query ?? null },
|
|
438
|
+
caveats: combinedCaveats.length ? combinedCaveats : ['Derived metrics are descriptive only; not a physiological performance model.'],
|
|
439
|
+
inputs: { decouplingInput: decouplingData.input, gapInput: gapData?.input ?? null },
|
|
440
|
+
});
|
|
441
|
+
})));
|
|
442
|
+
registerTool('wellness_correlate', {
|
|
332
443
|
title: 'Correlate recovery and training metrics',
|
|
333
444
|
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.',
|
|
334
445
|
inputSchema: {
|
|
@@ -336,35 +447,57 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
336
447
|
method: z.enum(['pearson', 'spearman']).optional(), lagDays: z.number().int().min(-30).max(30).optional(), scanLags: z.boolean().optional(),
|
|
337
448
|
},
|
|
338
449
|
}, tool('wellness_correlate', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).correlate(input))));
|
|
339
|
-
|
|
450
|
+
registerTool('wellness_baselines', {
|
|
340
451
|
title: 'Read personal wellness baselines',
|
|
341
452
|
description: 'Return trailing means, standard-deviation bands, latest values, and latest z-scores for common recovery and training metrics. Missing values remain missing.',
|
|
342
453
|
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() },
|
|
343
454
|
}, tool('wellness_baselines', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).baselines(input))));
|
|
344
|
-
|
|
455
|
+
registerTool('wellness_anomalies', {
|
|
345
456
|
title: 'Find statistical wellness anomalies',
|
|
346
457
|
description: 'Find daily recovery and wellness outliers by z-score and group dates with anomalies across multiple signals. This does not diagnose or prescribe.',
|
|
347
458
|
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() },
|
|
348
459
|
}, tool('wellness_anomalies', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).anomalies(input))));
|
|
349
|
-
|
|
460
|
+
registerTool('wellness_coverage', {
|
|
350
461
|
title: 'Inspect wellness data coverage',
|
|
351
462
|
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.',
|
|
352
463
|
inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
353
464
|
}, tool('wellness_coverage', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).coverage(input))));
|
|
354
|
-
|
|
465
|
+
registerTool('get_ftp_history', {
|
|
355
466
|
title: 'Get dated FTP history',
|
|
356
467
|
description: 'Return normalized cycling FTP settings and activity-summary observations with source-aware preferred daily values. Read-only.',
|
|
357
468
|
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() },
|
|
358
469
|
}, tool('get_ftp_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).ftpHistory(input))));
|
|
359
|
-
|
|
470
|
+
registerTool('readiness_baseline', {
|
|
471
|
+
title: 'Read a running/race readiness baseline',
|
|
472
|
+
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.',
|
|
473
|
+
inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
474
|
+
}, tool('readiness_baseline', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).readinessBaseline(input))));
|
|
475
|
+
registerTool('resolve_event_course', {
|
|
476
|
+
title: 'Resolve a race event course and its synced geometry',
|
|
477
|
+
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.',
|
|
478
|
+
keywords: ['course', 'race course', 'event course', 'elevation profile', 'height profile', 'GPX track', 'course geometry', 'gradient', 'climb', 'GPS track'],
|
|
479
|
+
inputSchema: { eventId: z.string().min(1), pastActivityId: z.string().min(1).optional() },
|
|
480
|
+
}, tool('resolve_event_course', async (input) => useRepository(activePaths(), async (repository) => {
|
|
481
|
+
const events = new EventsService(repository);
|
|
482
|
+
const resolved = await events.resolveEventCourse(input.eventId);
|
|
483
|
+
const diff = input.pastActivityId ? await events.diffCourseProfiles(resolved.courseId, input.pastActivityId) : null;
|
|
484
|
+
return {
|
|
485
|
+
data: { event: resolved, courseDiff: diff },
|
|
486
|
+
provenance: { relation: 'source_entities' },
|
|
487
|
+
caveats: [...resolved.caveats, ...(diff?.caveats ?? [])],
|
|
488
|
+
};
|
|
489
|
+
})));
|
|
490
|
+
registerTool('get_vo2max_history', {
|
|
360
491
|
title: 'Get sport-specific VO₂max history',
|
|
361
492
|
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.',
|
|
362
493
|
inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
363
494
|
}, tool('get_vo2max_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).vo2MaxHistory(input))));
|
|
364
|
-
|
|
495
|
+
registerTool('find_activities', {
|
|
365
496
|
title: 'Find activities and likely races',
|
|
366
497
|
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.',
|
|
498
|
+
keywords: ['race', 'races', 'next race', 'events', 'upcoming event', 'activity list', 'swim sessions', 'pool sessions', 'laps', 'recent runs'],
|
|
367
499
|
inputSchema: {
|
|
500
|
+
sport: z.string().min(1).optional(),
|
|
368
501
|
sports: z.array(z.string().min(1)).min(1).max(12).optional(),
|
|
369
502
|
distanceKm: z.tuple([z.number().min(0), z.number().min(0)]).optional(),
|
|
370
503
|
nameContains: z.array(z.string().min(1)).min(1).max(12).optional(),
|
|
@@ -372,19 +505,22 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
372
505
|
sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
|
|
373
506
|
},
|
|
374
507
|
}, tool('find_activities', async (input) => useRepository(activePaths(), (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
|
|
375
|
-
|
|
508
|
+
registerTool('get_swim_laps', {
|
|
376
509
|
title: 'Read explicit swim lengths and grouped sets',
|
|
377
510
|
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.',
|
|
511
|
+
keywords: ['swim', 'swimming', 'per-length', 'pace', 'SPL', 'SWOLF', 'pool', 'lengths', 'laps', 'cadence'],
|
|
378
512
|
inputSchema: { activityId: z.string().min(1), provider: z.enum(['garmin', 'intervals']).optional() },
|
|
379
513
|
}, tool('get_swim_laps', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimLaps(input))));
|
|
380
|
-
|
|
514
|
+
registerTool('swim_progress_report', {
|
|
381
515
|
title: 'Build a source-aware swim progress report',
|
|
382
516
|
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.',
|
|
517
|
+
keywords: ['swim', 'swimming', 'progress', 'SWOLF', 'fastest 100', 'cadence', 'pool length', 'swim trends'],
|
|
383
518
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), poolLengthM: z.number().positive().max(200).optional() },
|
|
384
519
|
}, tool('swim_progress_report', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimProgressReport(input))));
|
|
385
|
-
|
|
520
|
+
registerTool('get_activity_segments', {
|
|
386
521
|
title: 'Hydrate and read an activity’s Strava segments',
|
|
387
522
|
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.',
|
|
523
|
+
keywords: ['segments', 'climbs', 'KOM', 'PR', 'grade by segment', 'Strava segments', 'segment efforts', 'per-segment analysis'],
|
|
388
524
|
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
|
|
389
525
|
}, tool('get_activity_segments', async (input) => {
|
|
390
526
|
const hydration = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
|
|
@@ -400,35 +536,36 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
400
536
|
],
|
|
401
537
|
};
|
|
402
538
|
}));
|
|
403
|
-
|
|
539
|
+
registerTool('power_curve_trend', {
|
|
404
540
|
title: 'Read labelled power-curve trends',
|
|
405
541
|
description: 'Return monthly bests for selected power durations from an explicit sport or sport family, including the supporting activity and source type. Read-only.',
|
|
406
542
|
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() },
|
|
407
543
|
}, tool('power_curve_trend', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCurveTrend(input))));
|
|
408
|
-
|
|
544
|
+
registerTool('power_coverage_report', {
|
|
409
545
|
title: 'Inspect FIT-derived power coverage',
|
|
410
546
|
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.',
|
|
411
547
|
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() },
|
|
412
548
|
}, tool('power_coverage_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCoverageReport(input))));
|
|
413
|
-
|
|
549
|
+
registerTool('latest_cycling_activities', {
|
|
414
550
|
title: 'List latest cycling activities',
|
|
415
551
|
description: 'List Garmin cycling source records without duplicate Intervals summaries, optionally flagging multisport parent records. Read-only.',
|
|
416
552
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), includeMultisport: z.boolean().optional(), limit: z.number().int().min(1).max(100).optional() },
|
|
417
553
|
}, tool('latest_cycling_activities', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).latestCyclingActivities(input))));
|
|
418
|
-
|
|
554
|
+
registerTool('cycling_progress_report', {
|
|
419
555
|
title: 'Build a cycling progress report',
|
|
420
556
|
description: 'Combine source-aware FTP and VO₂max histories with monthly canonical volume/load and labelled power-curve trends. Read-only and descriptive.',
|
|
421
557
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
422
558
|
}, tool('cycling_progress_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).cyclingProgressReport(input))));
|
|
423
|
-
|
|
559
|
+
registerTool('query_read_only_data', {
|
|
424
560
|
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.',
|
|
425
561
|
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() },
|
|
426
562
|
}, tool('query_read_only_data', async (input) => useRepository(activePaths(), (repository) => queryReadOnlyData(repository, input))));
|
|
427
|
-
|
|
563
|
+
registerTool('search_context', {
|
|
428
564
|
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.',
|
|
565
|
+
keywords: ['event name', 'race name', 'course', 'elevation', 'GPX', 'activity summary', 'plan', 'nutrition', 'find the event'],
|
|
429
566
|
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() },
|
|
430
567
|
}, tool('search_context', async (input) => useRepository(activePaths(), (repository) => searchContext(repository, input))));
|
|
431
|
-
|
|
568
|
+
registerTool('hydrate_strava_activity', {
|
|
432
569
|
title: 'Hydrate one activity from Strava',
|
|
433
570
|
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.',
|
|
434
571
|
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional() },
|
|
@@ -436,7 +573,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
436
573
|
const data = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
|
|
437
574
|
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.'] };
|
|
438
575
|
}));
|
|
439
|
-
|
|
576
|
+
registerTool('hydrate_recent_strava_activities', {
|
|
440
577
|
title: 'Hydrate several recent Strava activities',
|
|
441
578
|
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.',
|
|
442
579
|
inputSchema: z.object({
|
|
@@ -504,7 +641,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
504
641
|
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.'],
|
|
505
642
|
};
|
|
506
643
|
}));
|
|
507
|
-
|
|
644
|
+
registerTool('hydrate_strava_segment_history', {
|
|
508
645
|
title: 'Hydrate one Strava segment history',
|
|
509
646
|
description: 'Write-only targeted enrichment. Fetches a persisted Strava segment and the authenticated athlete’s paged historic efforts; interrupted or throttled work is resumable.',
|
|
510
647
|
inputSchema: { segmentId: z.string().min(1), refresh: z.boolean().optional() },
|
|
@@ -512,6 +649,19 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
512
649
|
const data = await hydrateStravaSegmentHistory(activePaths(), input.segmentId, input.refresh ?? false);
|
|
513
650
|
return { data, coverage: await useRepository(activePaths(), (repository) => repository.coverage()), provenance: { provider: 'strava', operation: 'segment_effort_history', archivedBeforeNormalization: true }, caveats: ['Only the authenticated athlete’s historic segment efforts are requested.'] };
|
|
514
651
|
}));
|
|
652
|
+
// Registered last so every tool above is present in the tool index before its
|
|
653
|
+
// one-line catalog is baked into this tool's description. Survives host tool
|
|
654
|
+
// truncation because tool descriptions are always visible to the model.
|
|
655
|
+
registerTool('discover_tools', {
|
|
656
|
+
title: 'Discover all Catence tools and routing',
|
|
657
|
+
description: [
|
|
658
|
+
'Inventory the full Catence tool surface even when the host truncates its visible tool list. Query by question: next race/upcoming event (aggregate_data on events, search_context, resolve_event_course), course elevation/height profile/GPX (resolve_event_course then read_series or aggregate_data on course_geometry), swim per-length pace/SPL/SWOLF (find_activities, get_swim_laps, swim_progress_report), segments/climbs/KOM/PR (get_activity_segments), race readiness (readiness_baseline), recovery (review_daily_recovery_load, review_weekly_training), cycling FTP/VO₂max/power (get_ftp_history, get_vo2max_history, power_coverage_report, power_curve_trend).',
|
|
659
|
+
...TOOL_INDEX.map((entry) => `${entry.name} — ${entry.title}`),
|
|
660
|
+
].join('\n'),
|
|
661
|
+
}, tool('discover_tools', async () => ({
|
|
662
|
+
data: { count: TOOL_INDEX.length, routing: TOOL_ROUTING, tools: TOOL_INDEX.map((entry) => ({ name: entry.name, purpose: entry.title, keywords: entry.keywords })) },
|
|
663
|
+
provenance: { catalog: 'Catence tool index' }, query: {}, caveats: ['Use this to map a question to its named tool before falling back to SQL.'],
|
|
664
|
+
})));
|
|
515
665
|
if (catalogPaths) {
|
|
516
666
|
server.registerResource('athletes', 'catence://athletes', { title: 'Configured athlete roster', mimeType: 'application/json' }, async (uri) => {
|
|
517
667
|
try {
|
|
@@ -560,6 +710,14 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
560
710
|
}
|
|
561
711
|
});
|
|
562
712
|
}
|
|
713
|
+
server.registerResource('tools', 'catence://tools', { title: 'Catence tool index', mimeType: 'application/json' }, async (uri) => {
|
|
714
|
+
return resource(uri, {
|
|
715
|
+
count: TOOL_INDEX.length,
|
|
716
|
+
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.',
|
|
717
|
+
routing: TOOL_ROUTING,
|
|
718
|
+
tools: TOOL_INDEX.map((tool) => ({ name: tool.name, purpose: tool.title, description: tool.description, keywords: tool.keywords })),
|
|
719
|
+
});
|
|
720
|
+
});
|
|
563
721
|
return server;
|
|
564
722
|
}
|
|
565
723
|
function variable(value) {
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -23,13 +23,20 @@ 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';
|
|
29
30
|
export { queryReadOnlyData } from '../core/query/sql-guard.js';
|
|
30
31
|
export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
|
|
31
|
-
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, unlinkActivity, } from '../elt/application/management.js';
|
|
32
|
+
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
|
|
32
33
|
export type { ProviderChoice } from '../elt/application/management.js';
|
|
33
|
-
export {
|
|
34
|
+
export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
|
|
35
|
+
export type { DetachedSyncHandle, DetachedSyncProvider, DetachedSyncRequest, DetachedSyncSpawner } from '../elt/application/detached-sync.js';
|
|
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';
|
|
37
|
+
export type { OpenCodeGoConsoleProfiles, OpenCodeGoMergeResult, OpenCodeGoRoute } from '../core/runtime/opencode-go.js';
|
|
38
|
+
export type { InterruptedRuns, SyncProgressSnapshot, SyncProgressState } from '../contracts/progress.js';
|
|
39
|
+
export { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
34
40
|
export { DataWriteBusyError } from '../elt/storage/write-lock.js';
|
|
41
|
+
export { migrations } from '../elt/storage/migrations.js';
|
|
35
42
|
export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -10,13 +10,17 @@ 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';
|
|
16
17
|
export { queryReadOnlyData } from '../core/query/sql-guard.js';
|
|
17
18
|
export { createDemoStore, demoStoreMetadata } from '../elt/application/demo.js';
|
|
18
|
-
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, linkActivity, rebuildRetrievalIndex, retryDataSync, syncData, unlinkActivity, } from '../elt/application/management.js';
|
|
19
|
-
export {
|
|
19
|
+
export { connectStrava, connectStravaWithCallback, dataStatus, disconnectStravaAccount, initializeDataStore, importStagedFile, linkActivity, rebuildRetrievalIndex, reimportNutrition, resolveExtractionErrors, retryDataSync, syncData, syncProgress, unlinkActivity, } from '../elt/application/management.js';
|
|
20
|
+
export { DETACHED_SYNC_PROVIDERS, DetachedSyncBusyError, startDetachedSync, } from '../elt/application/detached-sync.js';
|
|
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';
|
|
22
|
+
export { CatenceDatabase, openReadOnlyRepository, ReadOnlyDatabaseError } from '../elt/storage/database.js';
|
|
20
23
|
export { DataWriteBusyError } from '../elt/storage/write-lock.js';
|
|
24
|
+
export { migrations } from '../elt/storage/migrations.js';
|
|
21
25
|
export { hydrateStravaActivity, hydrateStravaSegmentHistory, StravaEnrichmentError, StravaRateLimitError, } from '../elt/ingestion/providers/strava/service.js';
|
|
22
26
|
//# sourceMappingURL=index.js.map
|
package/docs/architecture.md
CHANGED
|
@@ -74,7 +74,7 @@ Normalized tables preserve provenance rather than force an early merge:
|
|
|
74
74
|
|
|
75
75
|
### 3. Queries, tools and methods
|
|
76
76
|
|
|
77
|
-
The MCP process is a separate stdio consumer. Ordinary tools and resources open DuckDB `READ_ONLY` snapshots. The only
|
|
77
|
+
The MCP process is a separate stdio consumer. Ordinary tools and resources open DuckDB `READ_ONLY` snapshots. The only direct database writers are the explicit `hydrate_strava_activity`, serial `hydrate_recent_strava_activities`, and `hydrate_strava_segment_history` tools: each acquires the shared data-directory lock, calls only the Strava GET allowlist, archives responses before normalization, commits, and releases the lock. If another writer owns it, the result is retryable `data_sync_in_progress`. `start_detached_sync` is also a write tool: it spawns a detached `catence-data sync` child process (which acquires the lock itself) and returns immediately with the run handle.
|
|
78
78
|
|
|
79
79
|
The query layer has four shared pieces:
|
|
80
80
|
|
|
@@ -87,7 +87,18 @@ The query layer has four shared pieces:
|
|
|
87
87
|
|
|
88
88
|
### 4. Serving and retrieval
|
|
89
89
|
|
|
90
|
-
The server exposes
|
|
90
|
+
The server exposes these tools, grouped by purpose:
|
|
91
|
+
|
|
92
|
+
- Catalog and status: `list_athletes`, `catence_status`, `catence_sync_progress`.
|
|
93
|
+
- Review workflows: `review_daily_recovery_load`, `review_weekly_training`, `review_activity_deep_dive`, plus the matching `daily_recovery_load_review`, `weekly_training_review`, and `activity_deep_dive` prompts.
|
|
94
|
+
- General data and analytics: `describe_data`, `describe_dataset`, `read_series`, `aggregate_data`, `analyze_series`, `fit_series_model`, `query_read_only_data`, `search_context`.
|
|
95
|
+
- Wellness: `wellness_correlate`, `wellness_baselines`, `wellness_anomalies`, `wellness_coverage`.
|
|
96
|
+
- Fitness, readiness, and progress: `get_ftp_history`, `get_vo2max_history`, `readiness_baseline`, `power_curve_trend`, `power_coverage_report`, `activity_decoupling`, `latest_cycling_activities`, `cycling_progress_report`, `get_swim_laps`, `swim_progress_report`.
|
|
97
|
+
- Activity detail and segments: `find_activities`, `get_activity_segments`, `resolve_event_course`.
|
|
98
|
+
- Background sync (write): `start_detached_sync` — spawns a detached `catence-data sync` child process and returns immediately; track progress with `catence_sync_progress`.
|
|
99
|
+
- Strava hydration (the only direct database writers): `hydrate_strava_activity`, `hydrate_recent_strava_activities`, `hydrate_strava_segment_history`.
|
|
100
|
+
|
|
101
|
+
Resources are mode-dependent. In catalog mode the server registers only `athletes` (`catence://athletes`), the machine-readable roster of configured athlete IDs and labels; in a single-store (non-catalog) deployment it registers resources for status, catalog, a single activity template (`catence://activity/{activityId}`), and a date-range summary template (`catence://summary/{startDate}/{endDate}`). Both modes also register `tools` (`catence://tools`), a machine-readable index of all registered tools — useful when a host truncates the tool list.
|
|
91
102
|
|
|
92
103
|
`catence serve` is a loopback-only-by-default Streamable HTTP adapter. It creates a stateless MCP server and transport for each `/mcp` request, so the HTTP and stdio surfaces use the same tool implementation. `GET /health` is a versioned client handshake with runtime version, protocol version, and capabilities; distributed Console builds reject an incompatible protocol before starting a chat. The same listener serves a small read-only `/api/v1/dashboard` snapshot for the local Console: canonical daily-health rows, weekly canonical activity aggregates, recent canonical activities, status, coverage, and explicit missing-data caveats.
|
|
93
104
|
|
package/docs/assets/image.png
CHANGED
|
Binary file
|