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.
Files changed (51) hide show
  1. package/README.md +239 -269
  2. package/config.example.json +32 -20
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +48 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +42 -0
  13. package/dist/core/runtime/catalog.d.ts +41 -0
  14. package/dist/core/runtime/catalog.js +121 -0
  15. package/dist/core/runtime/configuration.d.ts +1 -1
  16. package/dist/core/runtime/configuration.js +3 -3
  17. package/dist/core/runtime/secrets.d.ts +24 -0
  18. package/dist/core/runtime/secrets.js +68 -0
  19. package/dist/core/runtime/self-update.d.ts +44 -0
  20. package/dist/core/runtime/self-update.js +138 -0
  21. package/dist/elt/application/management.d.ts +2 -0
  22. package/dist/elt/application/management.js +197 -18
  23. package/dist/elt/ingestion/importer.d.ts +3 -2
  24. package/dist/elt/ingestion/importer.js +4 -3
  25. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  26. package/dist/elt/ingestion/providers/intervals.js +4 -1
  27. package/dist/elt/ingestion/providers/strava/service.js +9 -2
  28. package/dist/elt/storage/database.d.ts +7 -2
  29. package/dist/elt/storage/database.js +81 -4
  30. package/dist/elt/storage/migrations.js +25 -1
  31. package/dist/interfaces/cli/commands.js +201 -46
  32. package/dist/interfaces/http/cli-options.d.ts +2 -2
  33. package/dist/interfaces/http/cli-options.js +10 -10
  34. package/dist/interfaces/http/server.d.ts +4 -1
  35. package/dist/interfaces/http/server.js +31 -4
  36. package/dist/interfaces/mcp/cli-options.d.ts +2 -2
  37. package/dist/interfaces/mcp/cli-options.js +14 -14
  38. package/dist/interfaces/mcp/main.js +12 -5
  39. package/dist/interfaces/mcp/server.d.ts +2 -2
  40. package/dist/interfaces/mcp/server.js +156 -98
  41. package/dist/runtime/index.d.ts +8 -1
  42. package/dist/runtime/index.js +4 -1
  43. package/docs/deployment.md +338 -0
  44. package/docs/docker-deployment.md +232 -0
  45. package/docs/llm-providers.md +467 -0
  46. package/package.json +6 -2
  47. package/python/catence/providers/garmin/cli.py +30 -9
  48. package/python/catence/providers/garmin/progress.py +85 -0
  49. package/python/catence/providers/garmin/worker.py +132 -5
  50. package/scripts/discover-opencode-go.mjs +210 -0
  51. package/scripts/discover-opencode-zen.mjs +218 -0
@@ -1,6 +1,6 @@
1
1
  export type McpCliOptions = {
2
- dataDir?: string;
2
+ home?: string;
3
3
  help: boolean;
4
4
  };
5
- export declare const MCP_USAGE = "Usage: catence [--data-dir <directory>]\n catence demo [--data-dir <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 MCP server. It refuses to overwrite a non-demo data directory.\n\nOptions:\n --data-dir <directory> Catence data directory. Overrides CATENCE_DATA_DIR.\n -h, --help Show this help text.";
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 [--data-dir <directory>]
2
- catence demo [--data-dir <directory>]
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 ./catence-demo by default, then starts the same MCP server. It refuses to overwrite a non-demo data directory.
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
- --data-dir <directory> Catence data directory. Overrides CATENCE_DATA_DIR.
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 dataDir;
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 { dataDir, help: true };
17
- if (argument === '--data-dir') {
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('--data-dir requires a directory.');
21
- dataDir = value;
20
+ throw new Error('--home requires a directory.');
21
+ home = value;
22
22
  index++;
23
23
  continue;
24
24
  }
25
- if (argument.startsWith('--data-dir=')) {
26
- const value = argument.slice('--data-dir='.length);
25
+ if (argument.startsWith('--home=')) {
26
+ const value = argument.slice('--home='.length);
27
27
  if (!value)
28
- throw new Error('--data-dir requires a directory.');
29
- dataDir = value;
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 { dataDir, help: false };
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 { createDemoStore, resolvePaths } from '../../runtime/index.js';
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
- paths: resolvePaths(options.dataDir),
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 dataDir = options.dataDir ?? (demo ? process.env.CATENCE_DATA_DIR ?? './catence-demo' : undefined);
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(paths);
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, resolvePaths, searchContext, SlidingWindowLimiter, StravaEnrichmentError, StravaRateLimitError, SwimmingService, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
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 = resolvePaths(), dependencies = {}) {
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 configPromise = loadCatenceConfig(paths);
123
- const demoStorePromise = demoStoreMetadata(paths);
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 tool = (name, fn) => async (input) => {
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 config = await configPromise;
128
- const decision = limiter.check(`tool:${name}`, configuredMcpRateLimit(config, 'tools', name));
129
- if (!decision.allowed)
130
- return { ...textResult({ data: null, error: { code: 'rate_limited', message: `MCP tool ${name} is locally rate limited.`, retryAfterSeconds: decision.retryAfterSeconds } }), isError: true };
131
- const value = await fn(input);
132
- const demoStore = await demoStorePromise;
133
- if (!demoStore || !value || typeof value !== 'object' || Array.isArray(value))
134
- return textResult(value);
135
- const result = value;
136
- const caveats = Array.isArray(result.caveats) ? result.caveats : [];
137
- return textResult({
138
- ...result,
139
- demoStore: { generated: true, seed: demoStore.seed, days: demoStore.days, startDate: demoStore.startDate, endDate: demoStore.endDate },
140
- caveats: [...caveats, 'This response uses Catence generated demo data, not personal measurements.'],
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 configPromise;
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(paths, async (repository) => ({
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(paths, async (repository) => ({
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(paths, async (repository) => ({
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(paths, async (repository) => {
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(paths, async (repository) => ({
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(paths, async (repository) => {
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(paths, (repository) => new AnalyticsService(repository).readSeries({ ...input, filters: input.filters }))));
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(paths, (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
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(paths, (repository) => new AnalyticsService(repository).analyzeSeries({ ...input, filters: input.filters }))));
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(paths, (repository) => new AnalyticsService(repository).fitSeriesModel({ ...input, filters: input.filters }))));
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(paths, (repository) => new WellnessService(repository).correlate(input))));
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(paths, (repository) => new WellnessService(repository).baselines(input))));
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(paths, (repository) => new WellnessService(repository).anomalies(input))));
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(paths, (repository) => new WellnessService(repository).coverage(input))));
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(paths, (repository) => new FitnessService(repository).ftpHistory(input))));
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(paths, (repository) => new FitnessService(repository).vo2MaxHistory(input))));
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(paths, (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
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(paths, (repository) => new SwimmingService(repository).swimLaps(input))));
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(paths, (repository) => new SwimmingService(repository).swimProgressReport(input))));
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(paths, input.activityId, input.refresh ?? false);
397
+ const hydration = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
353
398
  const hydratedStravaActivityIds = stravaActivityIds(hydration);
354
- const segments = await useRepository(paths, (repository) => activitySegments(repository, input.activityId, hydratedStravaActivityIds, input.limit ?? 200));
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(paths, (repository) => new FitnessService(repository).powerCurveTrend(input))));
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(paths, (repository) => new FitnessService(repository).powerCoverageReport(input))));
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(paths, (repository) => new FitnessService(repository).latestCyclingActivities(input))));
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(paths, (repository) => new FitnessService(repository).cyclingProgressReport(input))));
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(paths, (repository) => queryReadOnlyData(repository, input))));
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(paths, (repository) => searchContext(repository, input))));
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(paths, input.activityId, input.refresh ?? false);
399
- return { data, coverage: await useRepository(paths, (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.'] };
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(paths, async (repository) => {
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(paths, activityId, input.refresh ?? false);
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(paths, input.segmentId, input.refresh ?? false);
475
- return { data, coverage: await useRepository(paths, (repository) => repository.coverage()), provenance: { provider: 'strava', operation: 'segment_effort_history', archivedBeforeNormalization: true }, caveats: ['Only the authenticated athlete’s historic segment efforts are requested.'] };
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
- server.registerResource('status', 'catence://status', { title: 'Catence status', mimeType: 'application/json' }, async (uri) => {
478
- const limited = await resourceLimit('status', uri);
479
- if (limited)
480
- return limited;
481
- try {
482
- return resource(uri, await useRepository(paths, (repository) => repository.status()));
483
- }
484
- catch (error) {
485
- return resource(uri, { error: errorResult(error).content[0].text });
486
- }
487
- });
488
- server.registerResource('catalog', 'catence://catalog', { title: 'Catence catalog', mimeType: 'application/json' }, async (uri) => { const limited = await resourceLimit('catalog', uri); if (limited)
489
- return limited; return resource(uri, { datasets: Object.values(DATASET_CATALOG) }); });
490
- server.registerResource('activity', new ResourceTemplate('catence://activity/{activityId}', { list: undefined }), { title: 'Activity detail', mimeType: 'application/json' }, async (uri, variables) => {
491
- const limited = await resourceLimit('activity', uri);
492
- if (limited)
493
- return limited;
494
- try {
495
- return resource(uri, await useRepository(paths, (repository) => repository.activity(variable(variables.activityId))));
496
- }
497
- catch (error) {
498
- return resource(uri, { error: errorResult(error).content[0].text });
499
- }
500
- });
501
- server.registerResource('summary', new ResourceTemplate('catence://summary/{startDate}/{endDate}', { list: undefined }), { title: 'Catence date-range summary', mimeType: 'application/json' }, async (uri, variables) => {
502
- const limited = await resourceLimit('summary', uri);
503
- if (limited)
504
- return limited;
505
- try {
506
- return resource(uri, await useRepository(paths, (repository) => repository.summary(variable(variables.startDate), variable(variables.endDate))));
507
- }
508
- catch (error) {
509
- return resource(uri, { error: errorResult(error).content[0].text });
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) {