catence 0.1.0 → 0.2.0-beta.10
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 +239 -269
- package/config.example.json +32 -20
- 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 +48 -0
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/logging.d.ts +16 -0
- package/dist/core/logging.js +68 -0
- package/dist/core/query/repository.d.ts +2 -0
- package/dist/core/query/repository.js +42 -0
- package/dist/core/runtime/catalog.d.ts +41 -0
- package/dist/core/runtime/catalog.js +121 -0
- package/dist/core/runtime/configuration.d.ts +1 -1
- package/dist/core/runtime/configuration.js +3 -3
- package/dist/core/runtime/secrets.d.ts +24 -0
- package/dist/core/runtime/secrets.js +68 -0
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/elt/application/management.d.ts +2 -0
- package/dist/elt/application/management.js +197 -18
- package/dist/elt/ingestion/importer.d.ts +3 -2
- package/dist/elt/ingestion/importer.js +4 -3
- package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
- package/dist/elt/ingestion/providers/intervals.js +4 -1
- package/dist/elt/ingestion/providers/strava/service.js +9 -2
- package/dist/elt/storage/database.d.ts +7 -2
- package/dist/elt/storage/database.js +81 -4
- package/dist/elt/storage/migrations.js +25 -1
- package/dist/interfaces/cli/commands.js +201 -46
- package/dist/interfaces/http/cli-options.d.ts +2 -2
- package/dist/interfaces/http/cli-options.js +10 -10
- package/dist/interfaces/http/server.d.ts +4 -1
- package/dist/interfaces/http/server.js +31 -4
- package/dist/interfaces/mcp/cli-options.d.ts +2 -2
- package/dist/interfaces/mcp/cli-options.js +14 -14
- package/dist/interfaces/mcp/main.js +12 -5
- package/dist/interfaces/mcp/server.d.ts +2 -2
- package/dist/interfaces/mcp/server.js +156 -98
- package/dist/runtime/index.d.ts +8 -1
- package/dist/runtime/index.js +4 -1
- package/docs/deployment.md +338 -0
- package/docs/docker-deployment.md +232 -0
- package/docs/llm-providers.md +467 -0
- package/package.json +6 -2
- package/python/catence/providers/garmin/cli.py +30 -9
- package/python/catence/providers/garmin/progress.py +85 -0
- package/python/catence/providers/garmin/worker.py +132 -5
- package/scripts/discover-opencode-go.mjs +210 -0
- package/scripts/discover-opencode-zen.mjs +218 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type McpCliOptions = {
|
|
2
|
-
|
|
2
|
+
home?: string;
|
|
3
3
|
help: boolean;
|
|
4
4
|
};
|
|
5
|
-
export declare const MCP_USAGE = "Usage: catence [--
|
|
5
|
+
export declare const MCP_USAGE = "Usage: catence [--home <directory>]\n catence demo [--home <directory>]\n\nRun Catence as a local, stdio-only MCP server. Ordinary data access is read-only; only declared Strava hydration tools write through the locked local store.\n\nThe demo command creates safe generated data in ~/.catence-demo by default, then starts the same shared-athlete MCP server. It refuses to overwrite a non-demo athlete store.\n\nOptions:\n --home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.\n -h, --help Show this help text.";
|
|
6
6
|
export declare function parseMcpCliOptions(arguments_: readonly string[]): McpCliOptions;
|
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
export const MCP_USAGE = `Usage: catence [--
|
|
2
|
-
catence demo [--
|
|
1
|
+
export const MCP_USAGE = `Usage: catence [--home <directory>]
|
|
2
|
+
catence demo [--home <directory>]
|
|
3
3
|
|
|
4
4
|
Run Catence as a local, stdio-only MCP server. Ordinary data access is read-only; only declared Strava hydration tools write through the locked local store.
|
|
5
5
|
|
|
6
|
-
The demo command creates safe generated data in
|
|
6
|
+
The demo command creates safe generated data in ~/.catence-demo by default, then starts the same shared-athlete MCP server. It refuses to overwrite a non-demo athlete store.
|
|
7
7
|
|
|
8
8
|
Options:
|
|
9
|
-
--
|
|
9
|
+
--home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.
|
|
10
10
|
-h, --help Show this help text.`;
|
|
11
11
|
export function parseMcpCliOptions(arguments_) {
|
|
12
|
-
let
|
|
12
|
+
let home;
|
|
13
13
|
for (let index = 0; index < arguments_.length; index++) {
|
|
14
14
|
const argument = arguments_[index];
|
|
15
15
|
if (argument === '-h' || argument === '--help')
|
|
16
|
-
return {
|
|
17
|
-
if (argument === '--
|
|
16
|
+
return { home, help: true };
|
|
17
|
+
if (argument === '--home') {
|
|
18
18
|
const value = arguments_[index + 1];
|
|
19
19
|
if (!value || value.startsWith('-'))
|
|
20
|
-
throw new Error('--
|
|
21
|
-
|
|
20
|
+
throw new Error('--home requires a directory.');
|
|
21
|
+
home = value;
|
|
22
22
|
index++;
|
|
23
23
|
continue;
|
|
24
24
|
}
|
|
25
|
-
if (argument.startsWith('--
|
|
26
|
-
const value = argument.slice('--
|
|
25
|
+
if (argument.startsWith('--home=')) {
|
|
26
|
+
const value = argument.slice('--home='.length);
|
|
27
27
|
if (!value)
|
|
28
|
-
throw new Error('--
|
|
29
|
-
|
|
28
|
+
throw new Error('--home requires a directory.');
|
|
29
|
+
home = value;
|
|
30
30
|
continue;
|
|
31
31
|
}
|
|
32
32
|
throw new Error(`Unknown option: ${argument}`);
|
|
33
33
|
}
|
|
34
|
-
return {
|
|
34
|
+
return { home, help: false };
|
|
35
35
|
}
|
|
36
36
|
//# sourceMappingURL=cli-options.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { addAthlete, athleteStorePaths, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths } from '../../runtime/index.js';
|
|
4
5
|
import { parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
|
|
5
6
|
import { createCatenceHttpServer } from '../http/server.js';
|
|
6
7
|
import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
|
|
@@ -14,7 +15,7 @@ async function run() {
|
|
|
14
15
|
return;
|
|
15
16
|
}
|
|
16
17
|
const httpServer = createCatenceHttpServer({
|
|
17
|
-
|
|
18
|
+
catalogPaths: resolveCatalogPaths(options.home),
|
|
18
19
|
allowedOrigins: options.allowedOrigins,
|
|
19
20
|
});
|
|
20
21
|
await new Promise((resolve, reject) => {
|
|
@@ -38,13 +39,19 @@ async function run() {
|
|
|
38
39
|
process.stderr.write(`${MCP_USAGE}\n`);
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
41
|
-
const
|
|
42
|
-
const paths = resolvePaths(dataDir);
|
|
42
|
+
const catalogPaths = resolveCatalogPaths(demo && !options.home ? `${defaultCatalogHome()}-demo` : options.home);
|
|
43
43
|
if (demo) {
|
|
44
|
+
if (!existsSync(catalogPaths.catalog)) {
|
|
45
|
+
await initializeCatalog(catalogPaths, { id: 'demo', label: 'Generated demo athlete' });
|
|
46
|
+
}
|
|
47
|
+
else if (!(await loadCatalog(catalogPaths)).athletes.some((athlete) => athlete.id === 'demo')) {
|
|
48
|
+
await addAthlete(catalogPaths, { id: 'demo', label: 'Generated demo athlete' });
|
|
49
|
+
}
|
|
50
|
+
const paths = athleteStorePaths(catalogPaths, 'demo');
|
|
44
51
|
const result = await createDemoStore(paths);
|
|
45
52
|
process.stderr.write(`${String(result.message)}\n`);
|
|
46
53
|
}
|
|
47
|
-
const server = createCatenceMcpServer(
|
|
54
|
+
const server = createCatenceMcpServer(catalogPaths);
|
|
48
55
|
await server.connect(new StdioServerTransport());
|
|
49
56
|
console.error('Catence MCP server is running on stdio; ordinary tools are read-only and Strava hydration is lock-guarded.');
|
|
50
57
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import { hydrateStravaActivity, type CatencePaths } from '../../runtime/index.js';
|
|
2
|
+
import { type CatalogPaths, hydrateStravaActivity, type CatencePaths } from '../../runtime/index.js';
|
|
3
3
|
type StravaActivityHydrator = typeof hydrateStravaActivity;
|
|
4
4
|
type McpDependencies = {
|
|
5
5
|
hydrateStravaActivity?: StravaActivityHydrator;
|
|
6
6
|
};
|
|
7
|
-
export declare function createCatenceMcpServer(paths?: CatencePaths, dependencies?: McpDependencies): McpServer;
|
|
7
|
+
export declare function createCatenceMcpServer(paths?: CatencePaths | CatalogPaths, dependencies?: McpDependencies): McpServer;
|
|
8
8
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
3
|
import { z } from 'zod';
|
|
3
|
-
import { ActivityDiscoveryService, AnalyticsService, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, ReadOnlyDatabaseError,
|
|
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
5
|
const filterSchema = z.object({
|
|
5
6
|
column: z.string().min(1),
|
|
6
7
|
op: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'between', 'contains']),
|
|
@@ -18,6 +19,9 @@ const seriesInput = {
|
|
|
18
19
|
cursor: z.string().min(1).optional(),
|
|
19
20
|
pageSize: z.number().int().min(1).max(1_000).optional(),
|
|
20
21
|
};
|
|
22
|
+
function isCatalogPaths(value) {
|
|
23
|
+
return 'catalog' in value;
|
|
24
|
+
}
|
|
21
25
|
const MCP_INSTRUCTIONS = [
|
|
22
26
|
'For a selected activity\'s Strava segments, climb segments, grade by segment, KOM/PR, or per-segment analysis, call get_activity_segments after identifying the activity.',
|
|
23
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.',
|
|
@@ -116,29 +120,63 @@ async function activitySegments(repository, activityId, hydratedStravaActivityId
|
|
|
116
120
|
LIMIT $limit
|
|
117
121
|
`, values);
|
|
118
122
|
}
|
|
119
|
-
export function createCatenceMcpServer(paths =
|
|
123
|
+
export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependencies = {}) {
|
|
120
124
|
const server = new McpServer({ name: 'catence', version: '0.1.0' }, { instructions: MCP_INSTRUCTIONS });
|
|
121
125
|
const hydrateActivity = dependencies.hydrateStravaActivity ?? hydrateStravaActivity;
|
|
122
|
-
const
|
|
123
|
-
const
|
|
126
|
+
const catalogPaths = isCatalogPaths(paths) ? paths : null;
|
|
127
|
+
const staticPaths = isCatalogPaths(paths) ? null : paths;
|
|
128
|
+
const scope = new AsyncLocalStorage();
|
|
129
|
+
const activePaths = () => scope.getStore()?.paths ?? staticPaths ?? (() => { throw new Error('athleteId is required. Call list_athletes to inspect the configured catalog.'); })();
|
|
130
|
+
const activeAthlete = () => scope.getStore()?.athlete;
|
|
124
131
|
const limiter = new SlidingWindowLimiter();
|
|
125
|
-
const
|
|
132
|
+
const athleteIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/);
|
|
133
|
+
const promptAthleteSchema = catalogPaths ? { athleteId: athleteIdSchema } : {};
|
|
134
|
+
if (catalogPaths) {
|
|
135
|
+
server.registerTool('list_athletes', {
|
|
136
|
+
title: 'List configured athletes',
|
|
137
|
+
description: 'List athlete IDs and labels available to this shared Catence agent. No personal metrics are returned.',
|
|
138
|
+
}, async () => {
|
|
139
|
+
try {
|
|
140
|
+
const catalog = await loadCatalog(catalogPaths);
|
|
141
|
+
return textResult({ data: { defaultAthleteId: catalog.defaultAthleteId, athletes: catalog.athletes } });
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return errorResult(error);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
const originalRegister = server.registerTool.bind(server);
|
|
148
|
+
server.registerTool = (name, configuration, handler) => {
|
|
149
|
+
const originalInput = configuration.inputSchema;
|
|
150
|
+
return originalRegister(name, {
|
|
151
|
+
...configuration,
|
|
152
|
+
description: `${String(configuration.description ?? '')} Requires athleteId from list_athletes.`,
|
|
153
|
+
inputSchema: { athleteId: athleteIdSchema, ...(originalInput ?? {}) },
|
|
154
|
+
}, handler);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const tool = (name, fn) => async (rawInput) => {
|
|
126
158
|
try {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
159
|
+
const selected = catalogPaths
|
|
160
|
+
? await resolveAthlete(catalogPaths, rawInput.athleteId ?? '')
|
|
161
|
+
: null;
|
|
162
|
+
const run = async () => {
|
|
163
|
+
const currentPaths = activePaths();
|
|
164
|
+
const config = await loadCatenceConfig(currentPaths);
|
|
165
|
+
const decision = limiter.check(`${activeAthlete()?.id ?? 'local'}:tool:${name}`, configuredMcpRateLimit(config, 'tools', name));
|
|
166
|
+
if (!decision.allowed)
|
|
167
|
+
return { ...textResult({ data: null, ...(activeAthlete() ? { athlete: activeAthlete() } : {}), error: { code: 'rate_limited', message: `MCP tool ${name} is locally rate limited.`, retryAfterSeconds: decision.retryAfterSeconds } }), isError: true };
|
|
168
|
+
const { athleteId: _athleteId, ...input } = rawInput;
|
|
169
|
+
const value = await fn(input);
|
|
170
|
+
const result = value && typeof value === 'object' && !Array.isArray(value) ? value : { data: value };
|
|
171
|
+
const demoStore = await demoStoreMetadata(currentPaths);
|
|
172
|
+
const caveats = Array.isArray(result.caveats) ? result.caveats : [];
|
|
173
|
+
return textResult({
|
|
174
|
+
...result,
|
|
175
|
+
...(activeAthlete() ? { athlete: activeAthlete() } : {}),
|
|
176
|
+
...(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
|
+
});
|
|
178
|
+
};
|
|
179
|
+
return selected ? scope.run(selected, run) : run();
|
|
142
180
|
}
|
|
143
181
|
catch (error) {
|
|
144
182
|
return errorResult(error);
|
|
@@ -146,7 +184,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
146
184
|
};
|
|
147
185
|
const resourceLimit = async (name, uri) => {
|
|
148
186
|
try {
|
|
149
|
-
const config = await
|
|
187
|
+
const config = await loadCatenceConfig(activePaths());
|
|
150
188
|
const decision = limiter.check(`resource:${name}`, configuredMcpRateLimit(config, 'resources', name));
|
|
151
189
|
if (!decision.allowed)
|
|
152
190
|
return resource(uri, { error: { code: 'rate_limited', message: `MCP resource ${name} is locally rate limited.`, retryAfterSeconds: decision.retryAfterSeconds } });
|
|
@@ -159,47 +197,54 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
159
197
|
server.registerPrompt('daily_recovery_load_review', {
|
|
160
198
|
title: 'Daily recovery and load review',
|
|
161
199
|
description: 'Ask for a source-cited recovery and training-load review for one date.',
|
|
162
|
-
argsSchema: { date: z.string().date().optional() },
|
|
163
|
-
}, ({ date }) => ({
|
|
200
|
+
argsSchema: { ...promptAthleteSchema, date: z.string().date().optional() },
|
|
201
|
+
}, ({ date, ...input }) => ({
|
|
164
202
|
messages: [{
|
|
165
203
|
role: 'user',
|
|
166
|
-
content: { type: 'text', text: `Run review_daily_recovery_load for ${date ?? utcDate()}. Summarize only returned evidence, name data gaps, and do not present a training prescription as fact.` },
|
|
204
|
+
content: { type: 'text', text: `Run review_daily_recovery_load for ${date ?? utcDate()}${typeof input.athleteId === 'string' ? ` with athleteId ${input.athleteId}` : ''}. Summarize only returned evidence, name data gaps, and do not present a training prescription as fact.` },
|
|
167
205
|
}],
|
|
168
206
|
}));
|
|
169
207
|
server.registerPrompt('weekly_training_review', {
|
|
170
208
|
title: 'Weekly training review',
|
|
171
209
|
description: 'Ask for a source-cited seven-day training review ending on an optional date.',
|
|
172
|
-
argsSchema: { endDate: z.string().date().optional() },
|
|
173
|
-
}, ({ endDate }) => ({
|
|
210
|
+
argsSchema: { ...promptAthleteSchema, endDate: z.string().date().optional() },
|
|
211
|
+
}, ({ endDate, ...input }) => ({
|
|
174
212
|
messages: [{
|
|
175
213
|
role: 'user',
|
|
176
|
-
content: { type: 'text', text: `Run review_weekly_training ending on ${endDate ?? utcDate()}. Explain volume, load, and recovery evidence with dates and coverage caveats.` },
|
|
214
|
+
content: { type: 'text', text: `Run review_weekly_training ending on ${endDate ?? utcDate()}${typeof input.athleteId === 'string' ? ` with athleteId ${input.athleteId}` : ''}. Explain volume, load, and recovery evidence with dates and coverage caveats.` },
|
|
177
215
|
}],
|
|
178
216
|
}));
|
|
179
217
|
server.registerPrompt('activity_deep_dive', {
|
|
180
218
|
title: 'Activity deep dive',
|
|
181
219
|
description: 'Ask for an evidence-first deep dive into one canonical Catence activity.',
|
|
182
|
-
argsSchema: { activityId: z.string().min(1) },
|
|
183
|
-
}, ({ activityId }) => ({
|
|
220
|
+
argsSchema: { ...promptAthleteSchema, activityId: z.string().min(1) },
|
|
221
|
+
}, ({ activityId, ...input }) => ({
|
|
184
222
|
messages: [{
|
|
185
223
|
role: 'user',
|
|
186
|
-
content: { type: 'text', text: `Run review_activity_deep_dive for ${activityId}. Use its source records and intervals first; load read_series only if a specific sampled metric is needed.` },
|
|
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.` },
|
|
187
225
|
}],
|
|
188
226
|
}));
|
|
189
227
|
server.registerTool('catence_status', {
|
|
190
228
|
title: 'Catence data status',
|
|
191
229
|
description: 'Read sync state, data coverage, entity counts, stream availability, unresolved errors, and retrieval-index freshness. Read-only.',
|
|
192
|
-
}, tool('catence_status', async () => useRepository(
|
|
230
|
+
}, tool('catence_status', async () => useRepository(activePaths(), async (repository) => ({
|
|
193
231
|
data: { ...(await repository.status()), ...(await repository.coverage()) },
|
|
194
232
|
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
195
233
|
}))));
|
|
234
|
+
server.registerTool('catence_sync_progress', {
|
|
235
|
+
title: 'Catence sync progress',
|
|
236
|
+
description: 'Read live progress heartbeats for active sync runs, plus the most recent completed or interrupted runs. Read-only.',
|
|
237
|
+
}, tool('catence_sync_progress', async () => useRepository(activePaths(), async (repository) => ({
|
|
238
|
+
data: await repository.progress(),
|
|
239
|
+
provenance: { database: 'read-only DuckDB snapshot' }, query: {}, caveats: [],
|
|
240
|
+
}))));
|
|
196
241
|
server.registerTool('review_daily_recovery_load', {
|
|
197
242
|
title: 'Review daily recovery and training load',
|
|
198
243
|
description: 'Return source-cited daily health, training, and nutrition facts for a recovery/load review. It does not prescribe a training plan.',
|
|
199
244
|
inputSchema: { date: z.string().date().optional() },
|
|
200
245
|
}, tool('review_daily_recovery_load', async (input) => {
|
|
201
246
|
const date = input.date ?? utcDate();
|
|
202
|
-
return useRepository(
|
|
247
|
+
return useRepository(activePaths(), async (repository) => ({
|
|
203
248
|
data: { date, ...(await repository.summary(date, date)) },
|
|
204
249
|
provenance: { relations: ['daily_health', 'canonical_activity_training', 'nutrition_days'], database: 'read-only DuckDB snapshot' },
|
|
205
250
|
query: { date },
|
|
@@ -216,7 +261,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
216
261
|
}, tool('review_weekly_training', async (input) => {
|
|
217
262
|
const endDate = input.endDate ?? utcDate();
|
|
218
263
|
const startDate = subtractDays(endDate, 6);
|
|
219
|
-
return useRepository(
|
|
264
|
+
return useRepository(activePaths(), async (repository) => ({
|
|
220
265
|
data: { startDate, endDate, ...(await repository.summary(startDate, endDate)) },
|
|
221
266
|
provenance: { relations: ['daily_health', 'canonical_activity_training', 'nutrition_days'], database: 'read-only DuckDB snapshot' },
|
|
222
267
|
query: { startDate, endDate },
|
|
@@ -230,7 +275,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
230
275
|
title: 'Review one activity in depth',
|
|
231
276
|
description: 'Return canonical activity identity, source summaries, and structured intervals. Call read_series separately only when a specific sampled metric is required.',
|
|
232
277
|
inputSchema: { activityId: z.string().min(1) },
|
|
233
|
-
}, tool('review_activity_deep_dive', async (input) => useRepository(
|
|
278
|
+
}, tool('review_activity_deep_dive', async (input) => useRepository(activePaths(), async (repository) => {
|
|
234
279
|
const activity = await repository.activity(input.activityId);
|
|
235
280
|
return {
|
|
236
281
|
data: activity,
|
|
@@ -244,7 +289,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
244
289
|
server.registerTool('describe_data', {
|
|
245
290
|
title: 'Describe available Catence datasets',
|
|
246
291
|
description: 'List cataloged datasets, fields, units, permitted filters/groupings, providers, and time coverage. Read-only.',
|
|
247
|
-
}, tool('describe_data', async () => useRepository(
|
|
292
|
+
}, tool('describe_data', async () => useRepository(activePaths(), async (repository) => ({
|
|
248
293
|
data: { datasets: repository.catalog(), ...(await repository.coverage()) },
|
|
249
294
|
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.'],
|
|
250
295
|
}))));
|
|
@@ -252,7 +297,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
252
297
|
title: 'Describe one Catence dataset',
|
|
253
298
|
description: 'Read a compact schema, permitted filters/groupings, provenance fields, and coverage for one cataloged dataset. Read-only.',
|
|
254
299
|
inputSchema: { dataset: z.string().min(1) },
|
|
255
|
-
}, tool('describe_dataset', async (input) => useRepository(
|
|
300
|
+
}, tool('describe_dataset', async (input) => useRepository(activePaths(), async (repository) => {
|
|
256
301
|
const dataset = getDataset(input.dataset);
|
|
257
302
|
const coverage = (await repository.coverage()).coverage;
|
|
258
303
|
const observedValues = dataset.name === 'training_metric_observations'
|
|
@@ -273,7 +318,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
273
318
|
})));
|
|
274
319
|
server.registerTool('read_series', {
|
|
275
320
|
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,
|
|
276
|
-
}, tool('read_series', async (input) => useRepository(
|
|
321
|
+
}, tool('read_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).readSeries({ ...input, filters: input.filters }))));
|
|
277
322
|
server.registerTool('aggregate_data', {
|
|
278
323
|
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.',
|
|
279
324
|
inputSchema: {
|
|
@@ -281,15 +326,15 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
281
326
|
metrics: z.array(z.object({ column: z.string().min(1), operation: z.enum(['count', 'sum', 'mean', 'min', 'max', 'percentile']), percentile: z.number().gt(0).lt(1).optional(), as: z.string().min(1).max(64).optional() })).min(1).max(12),
|
|
282
327
|
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(),
|
|
283
328
|
},
|
|
284
|
-
}, tool('aggregate_data', async (input) => useRepository(
|
|
329
|
+
}, tool('aggregate_data', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
|
|
285
330
|
server.registerTool('analyze_series', {
|
|
286
331
|
title: 'Analyze a descriptive series', description: 'Run deterministic rolling statistics, baselines, correlations, seasonal comparisons, or trends on a cataloged series.',
|
|
287
332
|
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() },
|
|
288
|
-
}, tool('analyze_series', async (input) => useRepository(
|
|
333
|
+
}, tool('analyze_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).analyzeSeries({ ...input, filters: input.filters }))));
|
|
289
334
|
server.registerTool('fit_series_model', {
|
|
290
335
|
title: 'Fit a descriptive series model', description: 'Fit a bounded OLS, Theil–Sen, quadratic, or cubic descriptive model. Not a sport-performance model.',
|
|
291
336
|
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() },
|
|
292
|
-
}, tool('fit_series_model', async (input) => useRepository(
|
|
337
|
+
}, tool('fit_series_model', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).fitSeriesModel({ ...input, filters: input.filters }))));
|
|
293
338
|
server.registerTool('wellness_correlate', {
|
|
294
339
|
title: 'Correlate recovery and training metrics',
|
|
295
340
|
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.',
|
|
@@ -297,32 +342,32 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
297
342
|
metricA: z.enum(WELLNESS_METRICS), metricB: z.enum(WELLNESS_METRICS), startDate: z.string().date().optional(), endDate: z.string().date().optional(),
|
|
298
343
|
method: z.enum(['pearson', 'spearman']).optional(), lagDays: z.number().int().min(-30).max(30).optional(), scanLags: z.boolean().optional(),
|
|
299
344
|
},
|
|
300
|
-
}, tool('wellness_correlate', async (input) => useRepository(
|
|
345
|
+
}, tool('wellness_correlate', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).correlate(input))));
|
|
301
346
|
server.registerTool('wellness_baselines', {
|
|
302
347
|
title: 'Read personal wellness baselines',
|
|
303
348
|
description: 'Return trailing means, standard-deviation bands, latest values, and latest z-scores for common recovery and training metrics. Missing values remain missing.',
|
|
304
349
|
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() },
|
|
305
|
-
}, tool('wellness_baselines', async (input) => useRepository(
|
|
350
|
+
}, tool('wellness_baselines', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).baselines(input))));
|
|
306
351
|
server.registerTool('wellness_anomalies', {
|
|
307
352
|
title: 'Find statistical wellness anomalies',
|
|
308
353
|
description: 'Find daily recovery and wellness outliers by z-score and group dates with anomalies across multiple signals. This does not diagnose or prescribe.',
|
|
309
354
|
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() },
|
|
310
|
-
}, tool('wellness_anomalies', async (input) => useRepository(
|
|
355
|
+
}, tool('wellness_anomalies', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).anomalies(input))));
|
|
311
356
|
server.registerTool('wellness_coverage', {
|
|
312
357
|
title: 'Inspect wellness data coverage',
|
|
313
358
|
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.',
|
|
314
359
|
inputSchema: { metrics: z.array(z.enum(WELLNESS_METRICS)).min(1).max(12).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
315
|
-
}, tool('wellness_coverage', async (input) => useRepository(
|
|
360
|
+
}, tool('wellness_coverage', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).coverage(input))));
|
|
316
361
|
server.registerTool('get_ftp_history', {
|
|
317
362
|
title: 'Get dated FTP history',
|
|
318
363
|
description: 'Return normalized cycling FTP settings and activity-summary observations with source-aware preferred daily values. Read-only.',
|
|
319
364
|
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() },
|
|
320
|
-
}, tool('get_ftp_history', async (input) => useRepository(
|
|
365
|
+
}, tool('get_ftp_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).ftpHistory(input))));
|
|
321
366
|
server.registerTool('get_vo2max_history', {
|
|
322
367
|
title: 'Get sport-specific VO₂max history',
|
|
323
368
|
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.',
|
|
324
369
|
inputSchema: { sport: z.string().min(1).optional(), startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
325
|
-
}, tool('get_vo2max_history', async (input) => useRepository(
|
|
370
|
+
}, tool('get_vo2max_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).vo2MaxHistory(input))));
|
|
326
371
|
server.registerTool('find_activities', {
|
|
327
372
|
title: 'Find activities and likely races',
|
|
328
373
|
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.',
|
|
@@ -333,25 +378,25 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
333
378
|
startDate: z.string().date().optional(), endDate: z.string().date().optional(),
|
|
334
379
|
sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
|
|
335
380
|
},
|
|
336
|
-
}, tool('find_activities', async (input) => useRepository(
|
|
381
|
+
}, tool('find_activities', async (input) => useRepository(activePaths(), (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
|
|
337
382
|
server.registerTool('get_swim_laps', {
|
|
338
383
|
title: 'Read explicit swim lengths and grouped sets',
|
|
339
384
|
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.',
|
|
340
385
|
inputSchema: { activityId: z.string().min(1), provider: z.enum(['garmin', 'intervals']).optional() },
|
|
341
|
-
}, tool('get_swim_laps', async (input) => useRepository(
|
|
386
|
+
}, tool('get_swim_laps', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimLaps(input))));
|
|
342
387
|
server.registerTool('swim_progress_report', {
|
|
343
388
|
title: 'Build a source-aware swim progress report',
|
|
344
389
|
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.',
|
|
345
390
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), poolLengthM: z.number().positive().max(200).optional() },
|
|
346
|
-
}, tool('swim_progress_report', async (input) => useRepository(
|
|
391
|
+
}, tool('swim_progress_report', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimProgressReport(input))));
|
|
347
392
|
server.registerTool('get_activity_segments', {
|
|
348
393
|
title: 'Hydrate and read an activity’s Strava segments',
|
|
349
394
|
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.',
|
|
350
395
|
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
|
|
351
396
|
}, tool('get_activity_segments', async (input) => {
|
|
352
|
-
const hydration = await hydrateActivity(
|
|
397
|
+
const hydration = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
|
|
353
398
|
const hydratedStravaActivityIds = stravaActivityIds(hydration);
|
|
354
|
-
const segments = await useRepository(
|
|
399
|
+
const segments = await useRepository(activePaths(), (repository) => activitySegments(repository, input.activityId, hydratedStravaActivityIds, input.limit ?? 200));
|
|
355
400
|
return {
|
|
356
401
|
data: { hydration, hydratedStravaActivityIds, segments },
|
|
357
402
|
provenance: { provider: 'strava', relations: ['activity_segments', 'strava_segments'], operation: 'targeted_activity_hydration_then_read' },
|
|
@@ -366,37 +411,37 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
366
411
|
title: 'Read labelled power-curve trends',
|
|
367
412
|
description: 'Return monthly bests for selected power durations from an explicit sport or sport family, including the supporting activity and source type. Read-only.',
|
|
368
413
|
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() },
|
|
369
|
-
}, tool('power_curve_trend', async (input) => useRepository(
|
|
414
|
+
}, tool('power_curve_trend', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCurveTrend(input))));
|
|
370
415
|
server.registerTool('power_coverage_report', {
|
|
371
416
|
title: 'Inspect FIT-derived power coverage',
|
|
372
417
|
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.',
|
|
373
418
|
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() },
|
|
374
|
-
}, tool('power_coverage_report', async (input) => useRepository(
|
|
419
|
+
}, tool('power_coverage_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCoverageReport(input))));
|
|
375
420
|
server.registerTool('latest_cycling_activities', {
|
|
376
421
|
title: 'List latest cycling activities',
|
|
377
422
|
description: 'List Garmin cycling source records without duplicate Intervals summaries, optionally flagging multisport parent records. Read-only.',
|
|
378
423
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), includeMultisport: z.boolean().optional(), limit: z.number().int().min(1).max(100).optional() },
|
|
379
|
-
}, tool('latest_cycling_activities', async (input) => useRepository(
|
|
424
|
+
}, tool('latest_cycling_activities', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).latestCyclingActivities(input))));
|
|
380
425
|
server.registerTool('cycling_progress_report', {
|
|
381
426
|
title: 'Build a cycling progress report',
|
|
382
427
|
description: 'Combine source-aware FTP and VO₂max histories with monthly canonical volume/load and labelled power-curve trends. Read-only and descriptive.',
|
|
383
428
|
inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional() },
|
|
384
|
-
}, tool('cycling_progress_report', async (input) => useRepository(
|
|
429
|
+
}, tool('cycling_progress_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).cyclingProgressReport(input))));
|
|
385
430
|
server.registerTool('query_read_only_data', {
|
|
386
431
|
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.',
|
|
387
432
|
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() },
|
|
388
|
-
}, tool('query_read_only_data', async (input) => useRepository(
|
|
433
|
+
}, tool('query_read_only_data', async (input) => useRepository(activePaths(), (repository) => queryReadOnlyData(repository, input))));
|
|
389
434
|
server.registerTool('search_context', {
|
|
390
435
|
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.',
|
|
391
436
|
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() },
|
|
392
|
-
}, tool('search_context', async (input) => useRepository(
|
|
437
|
+
}, tool('search_context', async (input) => useRepository(activePaths(), (repository) => searchContext(repository, input))));
|
|
393
438
|
server.registerTool('hydrate_strava_activity', {
|
|
394
439
|
title: 'Hydrate one activity from Strava',
|
|
395
440
|
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.',
|
|
396
441
|
inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional() },
|
|
397
442
|
}, tool('hydrate_strava_activity', async (input) => {
|
|
398
|
-
const data = await hydrateActivity(
|
|
399
|
-
return { data, coverage: await useRepository(
|
|
443
|
+
const data = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
|
|
444
|
+
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.'] };
|
|
400
445
|
}));
|
|
401
446
|
server.registerTool('hydrate_recent_strava_activities', {
|
|
402
447
|
title: 'Hydrate several recent Strava activities',
|
|
@@ -409,7 +454,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
409
454
|
}, tool('hydrate_recent_strava_activities', async (input) => {
|
|
410
455
|
const activityIds = input.activityIds
|
|
411
456
|
? [...new Set(input.activityIds)]
|
|
412
|
-
: await useRepository(
|
|
457
|
+
: await useRepository(activePaths(), async (repository) => {
|
|
413
458
|
const values = {
|
|
414
459
|
startDate: input.startDate, endDate: `${input.endDate}T23:59:59.999Z`, limit: input.limit ?? 20,
|
|
415
460
|
};
|
|
@@ -443,7 +488,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
443
488
|
continue;
|
|
444
489
|
}
|
|
445
490
|
try {
|
|
446
|
-
const result = await hydrateStravaActivity(
|
|
491
|
+
const result = await hydrateStravaActivity(activePaths(), activityId, input.refresh ?? false);
|
|
447
492
|
const status = typeof result.status === 'string' ? result.status : 'completed';
|
|
448
493
|
outcomes.push({ activityId, status, result });
|
|
449
494
|
}
|
|
@@ -471,44 +516,57 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
|
|
|
471
516
|
description: 'Write-only targeted enrichment. Fetches a persisted Strava segment and the authenticated athlete’s paged historic efforts; interrupted or throttled work is resumable.',
|
|
472
517
|
inputSchema: { segmentId: z.string().min(1), refresh: z.boolean().optional() },
|
|
473
518
|
}, tool('hydrate_strava_segment_history', async (input) => {
|
|
474
|
-
const data = await hydrateStravaSegmentHistory(
|
|
475
|
-
return { data, coverage: await useRepository(
|
|
519
|
+
const data = await hydrateStravaSegmentHistory(activePaths(), input.segmentId, input.refresh ?? false);
|
|
520
|
+
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.'] };
|
|
476
521
|
}));
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
522
|
+
if (catalogPaths) {
|
|
523
|
+
server.registerResource('athletes', 'catence://athletes', { title: 'Configured athlete roster', mimeType: 'application/json' }, async (uri) => {
|
|
524
|
+
try {
|
|
525
|
+
const catalog = await loadCatalog(catalogPaths);
|
|
526
|
+
return resource(uri, { defaultAthleteId: catalog.defaultAthleteId, athletes: catalog.athletes });
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
return resource(uri, { error: errorResult(error).content[0].text });
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
server.registerResource('status', 'catence://status', { title: 'Catence status', mimeType: 'application/json' }, async (uri) => {
|
|
535
|
+
const limited = await resourceLimit('status', uri);
|
|
536
|
+
if (limited)
|
|
537
|
+
return limited;
|
|
538
|
+
try {
|
|
539
|
+
return resource(uri, await useRepository(activePaths(), (repository) => repository.status()));
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
return resource(uri, { error: errorResult(error).content[0].text });
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
server.registerResource('catalog', 'catence://catalog', { title: 'Catence catalog', mimeType: 'application/json' }, async (uri) => { const limited = await resourceLimit('catalog', uri); if (limited)
|
|
546
|
+
return limited; return resource(uri, { datasets: Object.values(DATASET_CATALOG) }); });
|
|
547
|
+
server.registerResource('activity', new ResourceTemplate('catence://activity/{activityId}', { list: undefined }), { title: 'Activity detail', mimeType: 'application/json' }, async (uri, variables) => {
|
|
548
|
+
const limited = await resourceLimit('activity', uri);
|
|
549
|
+
if (limited)
|
|
550
|
+
return limited;
|
|
551
|
+
try {
|
|
552
|
+
return resource(uri, await useRepository(activePaths(), (repository) => repository.activity(variable(variables.activityId))));
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
return resource(uri, { error: errorResult(error).content[0].text });
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
server.registerResource('summary', new ResourceTemplate('catence://summary/{startDate}/{endDate}', { list: undefined }), { title: 'Catence date-range summary', mimeType: 'application/json' }, async (uri, variables) => {
|
|
559
|
+
const limited = await resourceLimit('summary', uri);
|
|
560
|
+
if (limited)
|
|
561
|
+
return limited;
|
|
562
|
+
try {
|
|
563
|
+
return resource(uri, await useRepository(activePaths(), (repository) => repository.summary(variable(variables.startDate), variable(variables.endDate))));
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
return resource(uri, { error: errorResult(error).content[0].text });
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
}
|
|
512
570
|
return server;
|
|
513
571
|
}
|
|
514
572
|
function variable(value) {
|