catence 0.2.0 → 0.2.1-beta.2
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 +1 -1
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/query/catalog.d.ts +1 -1
- package/dist/core/query/catalog.js +4 -0
- package/dist/core/query/sql-guard.js +37 -5
- package/dist/interfaces/mcp/server.js +38 -3
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Install Catence and create the first athlete. The default catalog home is `~/.ca
|
|
|
31
31
|
Every `catence-data` command requires `--athlete <id>`; there is no default athlete. Pair it with `--home <dir>` to select the catalog when not using the default home.
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
npm install --global catence@
|
|
34
|
+
npm install --global catence@latest # or catence@beta
|
|
35
35
|
catence-data setup --athlete alex --label "Alex"
|
|
36
36
|
```
|
|
37
37
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.2";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.2.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.2';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear';
|
|
1
|
+
export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear' | 'course_geometry';
|
|
2
2
|
export type CatalogColumn = {
|
|
3
3
|
name: string;
|
|
4
4
|
type: string;
|
|
@@ -113,6 +113,10 @@ export const DATASET_CATALOG = {
|
|
|
113
113
|
name: 'activity_samples', description: 'Registered normalized activity samples from immutable Parquet; callers cannot supply file paths.', dateColumn: 'timestamp_utc', provenanceColumns: ['provider', 'activity_source_id', 'content_hash'], permittedGroupings: ['provider', 'activity_source_id'], samplesOnly: true,
|
|
114
114
|
columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('content_hash', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('timestamp_utc', 'TIMESTAMPTZ', { filterable: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('latitude', 'DOUBLE', { unit: 'degrees', metric: true }), column('longitude', 'DOUBLE', { unit: 'degrees', metric: true }), column('altitude_m', 'DOUBLE', { unit: 'm', metric: true }), column('heart_rate_bpm', 'DOUBLE', { unit: 'bpm', metric: true }), column('power_w', 'DOUBLE', { unit: 'W', metric: true }), column('cadence_rpm', 'DOUBLE', { unit: 'rpm', metric: true }), column('speed_mps', 'DOUBLE', { unit: 'm/s', metric: true }), column('temperature_c', 'DOUBLE', { unit: 'C', metric: true }), column('grade_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_right_balance_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_torque_effectiveness_pct', 'DOUBLE', { unit: '%', metric: true }), column('right_torque_effectiveness_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_pedal_smoothness_pct', 'DOUBLE', { unit: '%', metric: true }), column('right_pedal_smoothness_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_power_phase_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_peak_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_peak_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_peak_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_peak_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_platform_center_offset_mm', 'DOUBLE', { unit: 'mm', metric: true }), column('right_platform_center_offset_mm', 'DOUBLE', { unit: 'mm', metric: true }), column('extras_json', 'JSON')],
|
|
115
115
|
},
|
|
116
|
+
course_geometry: {
|
|
117
|
+
name: 'course_geometry', relation: 'course_geometry', description: 'Per-point Garmin course/route geometry (lat/lon/altitude_m/distance_m) normalized from a race event course. Resolve a courseId first with resolve_event_course, then query these rows for an elevation/height profile, gradient, or GPX-style track.', provenanceColumns: ['course_id', 'raw_object_hash'], permittedGroupings: ['course_id'],
|
|
118
|
+
columns: [column('course_id', 'VARCHAR', { filterable: true, groupable: true }), column('seq', 'INTEGER', { metric: true }), column('lat', 'DOUBLE', { unit: 'degrees', metric: true }), column('lon', 'DOUBLE', { unit: 'degrees', metric: true }), column('altitude_m', 'DOUBLE', { unit: 'm', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('raw_object_hash', 'VARCHAR')],
|
|
119
|
+
},
|
|
116
120
|
};
|
|
117
121
|
function domain(name, id, description, dateColumn) {
|
|
118
122
|
return {
|
|
@@ -44,6 +44,26 @@ function normalizeRelation(name) {
|
|
|
44
44
|
const relation = name.trim().replace(/\s+(?:as\s+)?(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s*$/i, '');
|
|
45
45
|
return relation.replaceAll('"', '').split('.').at(-1).toLowerCase();
|
|
46
46
|
}
|
|
47
|
+
// Physical base tables that are reachable only through cataloged views
|
|
48
|
+
// (e.g. the events/workouts/routes/… domain projections over domain_entities).
|
|
49
|
+
// They must be accepted when DuckDB resolves a cataloged view down to its base
|
|
50
|
+
// table after tableNames(), but must never be writable directly by name.
|
|
51
|
+
const INDIRECT_BASE_TABLES = new Set(['domain_entities']);
|
|
52
|
+
// dataset->named tool hints for the common dead-end relations, so a rejected
|
|
53
|
+
// query tells the agent where to go instead of just that it was denied.
|
|
54
|
+
const RELATION_HINTS = {
|
|
55
|
+
events: 'events is a domain projection; use aggregate_data on events or resolve_event_course(eventId) / search_context to read events and races.',
|
|
56
|
+
workouts: 'workouts is a domain projection; use aggregate_data on workouts or search_context to read workouts.',
|
|
57
|
+
workout_documents: 'workout_documents is a domain projection; use aggregate_data on workout_documents or search_context to read workout documents.',
|
|
58
|
+
training_plans: 'training_plans is a domain projection; use aggregate_data on training_plans or search_context to read training plans.',
|
|
59
|
+
routes: 'routes is a domain projection; use aggregate_data on routes or search_context to read routes.',
|
|
60
|
+
gear: 'gear is a domain projection; use aggregate_data on gear or search_context to read saved gear.',
|
|
61
|
+
devices: 'devices is a domain projection; use aggregate_data on devices or search_context to read devices.',
|
|
62
|
+
goals: 'goals is a domain projection; use aggregate_data on goals or search_context to read goals and challenges.',
|
|
63
|
+
achievements: 'achievements is a domain projection; use aggregate_data on achievements or search_context to read badges and personal records.',
|
|
64
|
+
messages: 'messages is a domain projection; use aggregate_data on messages or search_context to read provider messages.',
|
|
65
|
+
course_geometry: 'course_geometry is the per-point course track (lat/lon/altitude_m/distance_m); resolve a race course first with resolve_event_course, then query it for elevation/height profiles.',
|
|
66
|
+
};
|
|
47
67
|
export async function queryReadOnlyData(repository, request) {
|
|
48
68
|
const sql = safeSql(request.sql);
|
|
49
69
|
let relations;
|
|
@@ -53,16 +73,28 @@ export async function queryReadOnlyData(repository, request) {
|
|
|
53
73
|
catch (error) {
|
|
54
74
|
throw new QueryValidationError(`SQL could not be inspected: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
75
|
}
|
|
56
|
-
|
|
76
|
+
// The catalog relation names form the strict allow-list for what a query may
|
|
77
|
+
// name directly (Check B below). Base tables reached only through cataloged
|
|
78
|
+
// views are additionally permitted after DuckDB resolves views for Check A,
|
|
79
|
+
// but they are never valid relation names in the SQL text.
|
|
80
|
+
const allowedNamed = new Set(Object.values(DATASET_CATALOG).filter((dataset) => !dataset.samplesOnly && dataset.relation).map((dataset) => dataset.relation.toLowerCase()));
|
|
81
|
+
const allowedForExpansion = new Set([...allowedNamed, ...INDIRECT_BASE_TABLES]);
|
|
82
|
+
const rejected = [];
|
|
57
83
|
for (const relation of relations) {
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
const normalized = normalizeRelation(relation);
|
|
85
|
+
if (!allowedForExpansion.has(normalized))
|
|
86
|
+
rejected.push(relation);
|
|
87
|
+
}
|
|
88
|
+
if (rejected.length > 0) {
|
|
89
|
+
const name = normalizeRelation(rejected[0]);
|
|
90
|
+
throw new QueryValidationError(`Relation ${rejected[0]} is not in the read-only catalog.${RELATION_HINTS[name] ? ` ${RELATION_HINTS[name]}` : ''}`);
|
|
60
91
|
}
|
|
61
92
|
const ctes = new Set([...sql.matchAll(/(?:\bwith|,)\s*([A-Za-z_][A-Za-z0-9_]*)\s+as\s*\(/gi)].map((match) => match[1].toLowerCase()));
|
|
62
93
|
for (const match of sql.matchAll(/\b(?:from|join)\s+([A-Za-z_][A-Za-z0-9_.]*)/gi)) {
|
|
63
94
|
const relation = normalizeRelation(match[1]);
|
|
64
|
-
if (!
|
|
65
|
-
throw new QueryValidationError(`Relation ${match[1]} is not in the read-only catalog
|
|
95
|
+
if (!allowedNamed.has(relation) && !ctes.has(relation)) {
|
|
96
|
+
throw new QueryValidationError(`Relation ${match[1]} is not in the read-only catalog.${RELATION_HINTS[relation] ? ` ${RELATION_HINTS[relation]}` : ''}`);
|
|
97
|
+
}
|
|
66
98
|
}
|
|
67
99
|
const values = request.values ?? {};
|
|
68
100
|
if ('__catence_page_size' in values || '__catence_offset' in values)
|
|
@@ -31,7 +31,10 @@ const MCP_INSTRUCTIONS = [
|
|
|
31
31
|
'Never conclude from VO2max alone; pull full indicator set (LT/FTP/power/race-prediction).',
|
|
32
32
|
'When summary metric is NULL (e.g. avg_power), fall through to FIT-derived/samples layer before declaring absence.',
|
|
33
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
|
|
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.',
|
|
35
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.',
|
|
36
39
|
].join(' ');
|
|
37
40
|
function textResult(value) {
|
|
@@ -143,9 +146,21 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
143
146
|
// enumerate all tools even when the host harness truncates the visible tool list.
|
|
144
147
|
const TOOL_INDEX = [];
|
|
145
148
|
function registerTool(name, config, callback) {
|
|
146
|
-
TOOL_INDEX.push({ name, title: config.title ?? name, description: config.description ?? '' });
|
|
149
|
+
TOOL_INDEX.push({ name, title: config.title ?? name, description: config.description ?? '', keywords: config.keywords ?? [] });
|
|
147
150
|
server.registerTool(name, config, callback);
|
|
148
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
|
+
];
|
|
149
164
|
const promptAthleteSchema = catalogPaths ? { athleteId: athleteIdSchema } : {};
|
|
150
165
|
if (catalogPaths) {
|
|
151
166
|
registerTool('list_athletes', {
|
|
@@ -460,6 +475,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
460
475
|
registerTool('resolve_event_course', {
|
|
461
476
|
title: 'Resolve a race event course and its synced geometry',
|
|
462
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'],
|
|
463
479
|
inputSchema: { eventId: z.string().min(1), pastActivityId: z.string().min(1).optional() },
|
|
464
480
|
}, tool('resolve_event_course', async (input) => useRepository(activePaths(), async (repository) => {
|
|
465
481
|
const events = new EventsService(repository);
|
|
@@ -479,6 +495,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
479
495
|
registerTool('find_activities', {
|
|
480
496
|
title: 'Find activities and likely races',
|
|
481
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'],
|
|
482
499
|
inputSchema: {
|
|
483
500
|
sport: z.string().min(1).optional(),
|
|
484
501
|
sports: z.array(z.string().min(1)).min(1).max(12).optional(),
|
|
@@ -491,16 +508,19 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
491
508
|
registerTool('get_swim_laps', {
|
|
492
509
|
title: 'Read explicit swim lengths and grouped sets',
|
|
493
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'],
|
|
494
512
|
inputSchema: { activityId: z.string().min(1), provider: z.enum(['garmin', 'intervals']).optional() },
|
|
495
513
|
}, tool('get_swim_laps', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimLaps(input))));
|
|
496
514
|
registerTool('swim_progress_report', {
|
|
497
515
|
title: 'Build a source-aware swim progress report',
|
|
498
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'],
|
|
499
518
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), poolLengthM: z.number().positive().max(200).optional() },
|
|
500
519
|
}, tool('swim_progress_report', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimProgressReport(input))));
|
|
501
520
|
registerTool('get_activity_segments', {
|
|
502
521
|
title: 'Hydrate and read an activity’s Strava segments',
|
|
503
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'],
|
|
504
524
|
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
|
|
505
525
|
}, tool('get_activity_segments', async (input) => {
|
|
506
526
|
const hydration = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
|
|
@@ -542,6 +562,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
542
562
|
}, tool('query_read_only_data', async (input) => useRepository(activePaths(), (repository) => queryReadOnlyData(repository, input))));
|
|
543
563
|
registerTool('search_context', {
|
|
544
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'],
|
|
545
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() },
|
|
546
567
|
}, tool('search_context', async (input) => useRepository(activePaths(), (repository) => searchContext(repository, input))));
|
|
547
568
|
registerTool('hydrate_strava_activity', {
|
|
@@ -628,6 +649,19 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
628
649
|
const data = await hydrateStravaSegmentHistory(activePaths(), input.segmentId, input.refresh ?? false);
|
|
629
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.'] };
|
|
630
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
|
+
})));
|
|
631
665
|
if (catalogPaths) {
|
|
632
666
|
server.registerResource('athletes', 'catence://athletes', { title: 'Configured athlete roster', mimeType: 'application/json' }, async (uri) => {
|
|
633
667
|
try {
|
|
@@ -680,7 +714,8 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
|
|
|
680
714
|
return resource(uri, {
|
|
681
715
|
count: TOOL_INDEX.length,
|
|
682
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.',
|
|
683
|
-
|
|
717
|
+
routing: TOOL_ROUTING,
|
|
718
|
+
tools: TOOL_INDEX.map((tool) => ({ name: tool.name, purpose: tool.title, description: tool.description, keywords: tool.keywords })),
|
|
684
719
|
});
|
|
685
720
|
});
|
|
686
721
|
return server;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "catence",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1-beta.2",
|
|
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": {
|
|
@@ -82,8 +82,9 @@
|
|
|
82
82
|
"vitest": "^3.2.4"
|
|
83
83
|
},
|
|
84
84
|
"overrides": {
|
|
85
|
-
"fast-uri": "3.1.
|
|
85
|
+
"fast-uri": "3.1.7",
|
|
86
86
|
"hono": "4.12.34",
|
|
87
|
-
"nanoid": "3.3.18"
|
|
87
|
+
"nanoid": "3.3.18",
|
|
88
|
+
"qs": "6.16.0"
|
|
88
89
|
}
|
|
89
90
|
}
|