catence 0.1.0 → 0.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,37 +197,37 @@ 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
  }))));
@@ -199,7 +237,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
199
237
  inputSchema: { date: z.string().date().optional() },
200
238
  }, tool('review_daily_recovery_load', async (input) => {
201
239
  const date = input.date ?? utcDate();
202
- return useRepository(paths, async (repository) => ({
240
+ return useRepository(activePaths(), async (repository) => ({
203
241
  data: { date, ...(await repository.summary(date, date)) },
204
242
  provenance: { relations: ['daily_health', 'canonical_activity_training', 'nutrition_days'], database: 'read-only DuckDB snapshot' },
205
243
  query: { date },
@@ -216,7 +254,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
216
254
  }, tool('review_weekly_training', async (input) => {
217
255
  const endDate = input.endDate ?? utcDate();
218
256
  const startDate = subtractDays(endDate, 6);
219
- return useRepository(paths, async (repository) => ({
257
+ return useRepository(activePaths(), async (repository) => ({
220
258
  data: { startDate, endDate, ...(await repository.summary(startDate, endDate)) },
221
259
  provenance: { relations: ['daily_health', 'canonical_activity_training', 'nutrition_days'], database: 'read-only DuckDB snapshot' },
222
260
  query: { startDate, endDate },
@@ -230,7 +268,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
230
268
  title: 'Review one activity in depth',
231
269
  description: 'Return canonical activity identity, source summaries, and structured intervals. Call read_series separately only when a specific sampled metric is required.',
232
270
  inputSchema: { activityId: z.string().min(1) },
233
- }, tool('review_activity_deep_dive', async (input) => useRepository(paths, async (repository) => {
271
+ }, tool('review_activity_deep_dive', async (input) => useRepository(activePaths(), async (repository) => {
234
272
  const activity = await repository.activity(input.activityId);
235
273
  return {
236
274
  data: activity,
@@ -244,7 +282,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
244
282
  server.registerTool('describe_data', {
245
283
  title: 'Describe available Catence datasets',
246
284
  description: 'List cataloged datasets, fields, units, permitted filters/groupings, providers, and time coverage. Read-only.',
247
- }, tool('describe_data', async () => useRepository(paths, async (repository) => ({
285
+ }, tool('describe_data', async () => useRepository(activePaths(), async (repository) => ({
248
286
  data: { datasets: repository.catalog(), ...(await repository.coverage()) },
249
287
  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
288
  }))));
@@ -252,7 +290,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
252
290
  title: 'Describe one Catence dataset',
253
291
  description: 'Read a compact schema, permitted filters/groupings, provenance fields, and coverage for one cataloged dataset. Read-only.',
254
292
  inputSchema: { dataset: z.string().min(1) },
255
- }, tool('describe_dataset', async (input) => useRepository(paths, async (repository) => {
293
+ }, tool('describe_dataset', async (input) => useRepository(activePaths(), async (repository) => {
256
294
  const dataset = getDataset(input.dataset);
257
295
  const coverage = (await repository.coverage()).coverage;
258
296
  const observedValues = dataset.name === 'training_metric_observations'
@@ -273,7 +311,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
273
311
  })));
274
312
  server.registerTool('read_series', {
275
313
  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 }))));
314
+ }, tool('read_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).readSeries({ ...input, filters: input.filters }))));
277
315
  server.registerTool('aggregate_data', {
278
316
  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
317
  inputSchema: {
@@ -281,15 +319,15 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
281
319
  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
320
  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
321
  },
284
- }, tool('aggregate_data', async (input) => useRepository(paths, (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
322
+ }, tool('aggregate_data', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).aggregate({ ...input, filters: input.filters }))));
285
323
  server.registerTool('analyze_series', {
286
324
  title: 'Analyze a descriptive series', description: 'Run deterministic rolling statistics, baselines, correlations, seasonal comparisons, or trends on a cataloged series.',
287
325
  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 }))));
326
+ }, tool('analyze_series', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).analyzeSeries({ ...input, filters: input.filters }))));
289
327
  server.registerTool('fit_series_model', {
290
328
  title: 'Fit a descriptive series model', description: 'Fit a bounded OLS, Theil–Sen, quadratic, or cubic descriptive model. Not a sport-performance model.',
291
329
  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 }))));
330
+ }, tool('fit_series_model', async (input) => useRepository(activePaths(), (repository) => new AnalyticsService(repository).fitSeriesModel({ ...input, filters: input.filters }))));
293
331
  server.registerTool('wellness_correlate', {
294
332
  title: 'Correlate recovery and training metrics',
295
333
  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 +335,32 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
297
335
  metricA: z.enum(WELLNESS_METRICS), metricB: z.enum(WELLNESS_METRICS), startDate: z.string().date().optional(), endDate: z.string().date().optional(),
298
336
  method: z.enum(['pearson', 'spearman']).optional(), lagDays: z.number().int().min(-30).max(30).optional(), scanLags: z.boolean().optional(),
299
337
  },
300
- }, tool('wellness_correlate', async (input) => useRepository(paths, (repository) => new WellnessService(repository).correlate(input))));
338
+ }, tool('wellness_correlate', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).correlate(input))));
301
339
  server.registerTool('wellness_baselines', {
302
340
  title: 'Read personal wellness baselines',
303
341
  description: 'Return trailing means, standard-deviation bands, latest values, and latest z-scores for common recovery and training metrics. Missing values remain missing.',
304
342
  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))));
343
+ }, tool('wellness_baselines', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).baselines(input))));
306
344
  server.registerTool('wellness_anomalies', {
307
345
  title: 'Find statistical wellness anomalies',
308
346
  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
347
  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))));
348
+ }, tool('wellness_anomalies', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).anomalies(input))));
311
349
  server.registerTool('wellness_coverage', {
312
350
  title: 'Inspect wellness data coverage',
313
351
  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
352
  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))));
353
+ }, tool('wellness_coverage', async (input) => useRepository(activePaths(), (repository) => new WellnessService(repository).coverage(input))));
316
354
  server.registerTool('get_ftp_history', {
317
355
  title: 'Get dated FTP history',
318
356
  description: 'Return normalized cycling FTP settings and activity-summary observations with source-aware preferred daily values. Read-only.',
319
357
  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))));
358
+ }, tool('get_ftp_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).ftpHistory(input))));
321
359
  server.registerTool('get_vo2max_history', {
322
360
  title: 'Get sport-specific VO₂max history',
323
361
  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
362
  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))));
363
+ }, tool('get_vo2max_history', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).vo2MaxHistory(input))));
326
364
  server.registerTool('find_activities', {
327
365
  title: 'Find activities and likely races',
328
366
  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 +371,25 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
333
371
  startDate: z.string().date().optional(), endDate: z.string().date().optional(),
334
372
  sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
335
373
  },
336
- }, tool('find_activities', async (input) => useRepository(paths, (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
374
+ }, tool('find_activities', async (input) => useRepository(activePaths(), (repository) => new ActivityDiscoveryService(repository).findActivities(input))));
337
375
  server.registerTool('get_swim_laps', {
338
376
  title: 'Read explicit swim lengths and grouped sets',
339
377
  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
378
  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))));
379
+ }, tool('get_swim_laps', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimLaps(input))));
342
380
  server.registerTool('swim_progress_report', {
343
381
  title: 'Build a source-aware swim progress report',
344
382
  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
383
  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))));
384
+ }, tool('swim_progress_report', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimProgressReport(input))));
347
385
  server.registerTool('get_activity_segments', {
348
386
  title: 'Hydrate and read an activity’s Strava segments',
349
387
  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
388
  inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
351
389
  }, tool('get_activity_segments', async (input) => {
352
- const hydration = await hydrateActivity(paths, input.activityId, input.refresh ?? false);
390
+ const hydration = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
353
391
  const hydratedStravaActivityIds = stravaActivityIds(hydration);
354
- const segments = await useRepository(paths, (repository) => activitySegments(repository, input.activityId, hydratedStravaActivityIds, input.limit ?? 200));
392
+ const segments = await useRepository(activePaths(), (repository) => activitySegments(repository, input.activityId, hydratedStravaActivityIds, input.limit ?? 200));
355
393
  return {
356
394
  data: { hydration, hydratedStravaActivityIds, segments },
357
395
  provenance: { provider: 'strava', relations: ['activity_segments', 'strava_segments'], operation: 'targeted_activity_hydration_then_read' },
@@ -366,37 +404,37 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
366
404
  title: 'Read labelled power-curve trends',
367
405
  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
406
  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))));
407
+ }, tool('power_curve_trend', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCurveTrend(input))));
370
408
  server.registerTool('power_coverage_report', {
371
409
  title: 'Inspect FIT-derived power coverage',
372
410
  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
411
  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))));
412
+ }, tool('power_coverage_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).powerCoverageReport(input))));
375
413
  server.registerTool('latest_cycling_activities', {
376
414
  title: 'List latest cycling activities',
377
415
  description: 'List Garmin cycling source records without duplicate Intervals summaries, optionally flagging multisport parent records. Read-only.',
378
416
  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))));
417
+ }, tool('latest_cycling_activities', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).latestCyclingActivities(input))));
380
418
  server.registerTool('cycling_progress_report', {
381
419
  title: 'Build a cycling progress report',
382
420
  description: 'Combine source-aware FTP and VO₂max histories with monthly canonical volume/load and labelled power-curve trends. Read-only and descriptive.',
383
421
  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))));
422
+ }, tool('cycling_progress_report', async (input) => useRepository(activePaths(), (repository) => new FitnessService(repository).cyclingProgressReport(input))));
385
423
  server.registerTool('query_read_only_data', {
386
424
  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
425
  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))));
426
+ }, tool('query_read_only_data', async (input) => useRepository(activePaths(), (repository) => queryReadOnlyData(repository, input))));
389
427
  server.registerTool('search_context', {
390
428
  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
429
  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))));
430
+ }, tool('search_context', async (input) => useRepository(activePaths(), (repository) => searchContext(repository, input))));
393
431
  server.registerTool('hydrate_strava_activity', {
394
432
  title: 'Hydrate one activity from Strava',
395
433
  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
434
  inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional() },
397
435
  }, 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.'] };
436
+ const data = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
437
+ 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
438
  }));
401
439
  server.registerTool('hydrate_recent_strava_activities', {
402
440
  title: 'Hydrate several recent Strava activities',
@@ -409,7 +447,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
409
447
  }, tool('hydrate_recent_strava_activities', async (input) => {
410
448
  const activityIds = input.activityIds
411
449
  ? [...new Set(input.activityIds)]
412
- : await useRepository(paths, async (repository) => {
450
+ : await useRepository(activePaths(), async (repository) => {
413
451
  const values = {
414
452
  startDate: input.startDate, endDate: `${input.endDate}T23:59:59.999Z`, limit: input.limit ?? 20,
415
453
  };
@@ -443,7 +481,7 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
443
481
  continue;
444
482
  }
445
483
  try {
446
- const result = await hydrateStravaActivity(paths, activityId, input.refresh ?? false);
484
+ const result = await hydrateStravaActivity(activePaths(), activityId, input.refresh ?? false);
447
485
  const status = typeof result.status === 'string' ? result.status : 'completed';
448
486
  outcomes.push({ activityId, status, result });
449
487
  }
@@ -471,44 +509,57 @@ export function createCatenceMcpServer(paths = resolvePaths(), dependencies = {}
471
509
  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
510
  inputSchema: { segmentId: z.string().min(1), refresh: z.boolean().optional() },
473
511
  }, 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.'] };
512
+ const data = await hydrateStravaSegmentHistory(activePaths(), input.segmentId, input.refresh ?? false);
513
+ 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
514
  }));
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
- });
515
+ if (catalogPaths) {
516
+ server.registerResource('athletes', 'catence://athletes', { title: 'Configured athlete roster', mimeType: 'application/json' }, async (uri) => {
517
+ try {
518
+ const catalog = await loadCatalog(catalogPaths);
519
+ return resource(uri, { defaultAthleteId: catalog.defaultAthleteId, athletes: catalog.athletes });
520
+ }
521
+ catch (error) {
522
+ return resource(uri, { error: errorResult(error).content[0].text });
523
+ }
524
+ });
525
+ }
526
+ else {
527
+ server.registerResource('status', 'catence://status', { title: 'Catence status', mimeType: 'application/json' }, async (uri) => {
528
+ const limited = await resourceLimit('status', uri);
529
+ if (limited)
530
+ return limited;
531
+ try {
532
+ return resource(uri, await useRepository(activePaths(), (repository) => repository.status()));
533
+ }
534
+ catch (error) {
535
+ return resource(uri, { error: errorResult(error).content[0].text });
536
+ }
537
+ });
538
+ server.registerResource('catalog', 'catence://catalog', { title: 'Catence catalog', mimeType: 'application/json' }, async (uri) => { const limited = await resourceLimit('catalog', uri); if (limited)
539
+ return limited; return resource(uri, { datasets: Object.values(DATASET_CATALOG) }); });
540
+ server.registerResource('activity', new ResourceTemplate('catence://activity/{activityId}', { list: undefined }), { title: 'Activity detail', mimeType: 'application/json' }, async (uri, variables) => {
541
+ const limited = await resourceLimit('activity', uri);
542
+ if (limited)
543
+ return limited;
544
+ try {
545
+ return resource(uri, await useRepository(activePaths(), (repository) => repository.activity(variable(variables.activityId))));
546
+ }
547
+ catch (error) {
548
+ return resource(uri, { error: errorResult(error).content[0].text });
549
+ }
550
+ });
551
+ server.registerResource('summary', new ResourceTemplate('catence://summary/{startDate}/{endDate}', { list: undefined }), { title: 'Catence date-range summary', mimeType: 'application/json' }, async (uri, variables) => {
552
+ const limited = await resourceLimit('summary', uri);
553
+ if (limited)
554
+ return limited;
555
+ try {
556
+ return resource(uri, await useRepository(activePaths(), (repository) => repository.summary(variable(variables.startDate), variable(variables.endDate))));
557
+ }
558
+ catch (error) {
559
+ return resource(uri, { error: errorResult(error).content[0].text });
560
+ }
561
+ });
562
+ }
512
563
  return server;
513
564
  }
514
565
  function variable(value) {
@@ -9,6 +9,10 @@
9
9
  export type { CatencePaths } from '../contracts/runtime.js';
10
10
  export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
11
11
  export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
12
+ export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
13
+ export type { Athlete, CatalogPaths, CatenceCatalog } from '../core/runtime/catalog.js';
14
+ export { athleteProviderEnvironment, providerSecretPath, readAthleteSecrets, setAthleteSecret } from '../core/runtime/secrets.js';
15
+ export type { AthleteSecrets, SecretProvider } from '../core/runtime/secrets.js';
12
16
  export { SlidingWindowLimiter } from '../core/runtime/limiter.js';
13
17
  export { DashboardSnapshotService } from '../core/dashboard/snapshot.js';
14
18
  export { searchContext } from '../core/retrieval/index.js';