catence 0.2.1-beta.16 → 0.2.1-beta.18

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.
@@ -2,7 +2,7 @@
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { addAthlete, athleteStorePaths, CatenceDatabase, createDemoStore, defaultCatalogHome, initializeCatalog, loadCatalog, resolveCatalogPaths, startAutoSyncScheduler } from '../../runtime/index.js';
5
- import { defaultBearerTokenPath, parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
5
+ import { defaultBearerTokenPath, defaultMcpKeysPath, defaultOauthSettingsPath, oauthStatusFromSettings, parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
6
6
  import { createCatenceHttpServer } from '../http/server.js';
7
7
  import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
8
8
  import { createCatenceMcpServer } from './server.js';
@@ -47,12 +47,24 @@ async function run() {
47
47
  }
48
48
  await migrateCatalogStores(options.home);
49
49
  const catalogPaths = resolveCatalogPaths(options.home);
50
+ const mcpKeysFile = options.mcpKeysFile ?? defaultMcpKeysPath(catalogPaths.root);
51
+ const oauthSettingsFile = options.oauthSettingsFile ?? defaultOauthSettingsPath(catalogPaths.root);
50
52
  const httpServer = createCatenceHttpServer({
51
53
  catalogPaths,
52
54
  allowedOrigins: options.allowedOrigins,
53
55
  // The token file's presence switches authentication on; enable it with
54
56
  // `catence-data mcp enable` (or the Console Settings / exposure API).
55
57
  bearerTokenFile: options.authTokenFile ?? defaultBearerTokenPath(catalogPaths.root),
58
+ // Any active per-account key in the registry arms the gate too; manage
59
+ // keys with `catence-data mcp key`.
60
+ mcpKeysFile,
61
+ // The Console-managed settings file overrides the startup --oauth flag at
62
+ // runtime; the Express sub-app builds lazily on first effective use.
63
+ oauthSettingsFile,
64
+ // The OAuth authorization server (express is loaded lazily inside the
65
+ // server) serves /.well-known/oauth-* and /oauth/* and verifies tokens
66
+ // at the gate; requires the https --public-url validated at parse time.
67
+ ...(options.oauth && options.publicUrl ? { oauth: { publicUrl: options.publicUrl } } : {}),
56
68
  });
57
69
  await new Promise((resolve, reject) => {
58
70
  httpServer.once('error', reject);
@@ -63,6 +75,14 @@ async function run() {
63
75
  });
64
76
  console.error(`Catence Streamable HTTP server is listening at http://${options.host}:${options.port}/mcp`);
65
77
  console.error(`Bearer-token authentication is armed at ${options.authTokenFile ?? defaultBearerTokenPath(catalogPaths.root)}; enable it with 'catence-data mcp enable'. Health endpoints stay open for readiness probes.`);
78
+ console.error(`Per-account MCP keys are read from ${mcpKeysFile}; manage them with 'catence-data mcp key'.`);
79
+ const oauthStatus = oauthStatusFromSettings(oauthSettingsFile);
80
+ if (oauthStatus.enabled && oauthStatus.publicUrl) {
81
+ console.error(`OAuth 2.1 authorization server is enabled (${oauthStatus.source}); issuer ${oauthStatus.publicUrl}, consent at ${oauthStatus.publicUrl}/oauth/consent.`);
82
+ }
83
+ else {
84
+ console.error('OAuth 2.1 authorization server is disabled; enable it from the Console (Settings, MCP exposure tab) or with CATENCE_OAUTH_ENABLED=1 and CATENCE_PUBLIC_URL.');
85
+ }
66
86
  // Auto-sync runs while the server is up: each athlete's autosync.json
67
87
  // (default: enabled, every 2 hours) triggers a detached sync. The
68
88
  // detached child and the single-writer DuckDB lock stay the only
@@ -5,5 +5,16 @@ type McpDependencies = {
5
5
  hydrateStravaActivity?: StravaActivityHydrator;
6
6
  startDetachedSync?: typeof startDetachedSync;
7
7
  };
8
- export declare function createCatenceMcpServer(paths?: CatencePaths | CatalogPaths, dependencies?: McpDependencies): McpServer;
8
+ export type CatenceMcpServerOptions = {
9
+ /**
10
+ * Athlete scope enforced on every tool that selects an athlete, and applied
11
+ * to the list_athletes tool and the catence://athletes resource. "all" (or no
12
+ * scope at all, as on the stdio path) means the caller may reach every
13
+ * athlete.
14
+ */
15
+ scope?: {
16
+ athleteIds: string[] | 'all';
17
+ };
18
+ };
19
+ export declare function createCatenceMcpServer(paths?: CatencePaths | CatalogPaths, dependencies?: McpDependencies, options?: CatenceMcpServerOptions): McpServer;
9
20
  export {};
@@ -1,7 +1,7 @@
1
1
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  import { z } from 'zod';
4
- import { ActivityDiscoveryService, AnalyticsService, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, DetachedSyncBusyError, EventsService, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, listAthleteFileRevisions, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, readAthleteFile, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, searchContext, SlidingWindowLimiter, startDetachedSync, StravaEnrichmentError, StravaRateLimitError, SwimmingService, updateAthleteFile, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
4
+ import { ActivityDiscoveryService, AnalyticsService, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, loadCatalog, configuredMcpRateLimit, DataWriteBusyError, DATASET_CATALOG, demoStoreMetadata, DetachedSyncBusyError, EventsService, filterRosterToScope, FitnessService, getDataset, hydrateStravaActivity, hydrateStravaSegmentHistory, jsonSafe, listAthleteFileRevisions, loadCatenceConfig, openReadOnlyRepository, queryReadOnlyData, QueryValidationError, readAthleteFile, ReadOnlyDatabaseError, resolveAthlete, resolveCatalogPaths, scopeAllowsAthlete, searchContext, SlidingWindowLimiter, startDetachedSync, StravaEnrichmentError, StravaRateLimitError, SwimmingService, updateAthleteFile, WELLNESS_METRICS, WellnessService, } from '../../runtime/index.js';
5
5
  const filterSchema = z.object({
6
6
  column: z.string().min(1),
7
7
  op: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'between', 'contains']),
@@ -137,12 +137,13 @@ async function activitySegments(repository, activityId, hydratedStravaActivityId
137
137
  LIMIT $limit
138
138
  `, values);
139
139
  }
140
- export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependencies = {}) {
140
+ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependencies = {}, options = {}) {
141
141
  const server = new McpServer({ name: 'catence', version: '0.1.0' }, { instructions: MCP_INSTRUCTIONS });
142
142
  const hydrateActivity = dependencies.hydrateStravaActivity ?? hydrateStravaActivity;
143
143
  const startSync = dependencies.startDetachedSync ?? startDetachedSync;
144
144
  const catalogPaths = isCatalogPaths(paths) ? paths : null;
145
145
  const staticPaths = isCatalogPaths(paths) ? null : paths;
146
+ const athleteScope = options.scope?.athleteIds;
146
147
  const scope = new AsyncLocalStorage();
147
148
  const activePaths = () => scope.getStore()?.paths ?? staticPaths ?? (() => { throw new Error('athleteId is required. Call list_athletes to inspect the configured catalog.'); })();
148
149
  const activeAthlete = () => scope.getStore()?.athlete;
@@ -177,7 +178,9 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
177
178
  }, async () => {
178
179
  try {
179
180
  const catalog = await loadCatalog(catalogPaths);
180
- return textResult({ data: { defaultAthleteId: catalog.defaultAthleteId, athletes: catalog.athletes } });
181
+ // The roster is narrowed to the caller's athlete scope, if any.
182
+ const roster = filterRosterToScope(catalog.athletes, catalog.defaultAthleteId, athleteScope ?? 'all');
183
+ return textResult({ data: { defaultAthleteId: roster.defaultAthleteId, athletes: roster.athletes } });
181
184
  }
182
185
  catch (error) {
183
186
  return errorResult(error);
@@ -217,6 +220,15 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
217
220
  const selected = catalogPaths
218
221
  ? await resolveAthlete(catalogPaths, rawInput.athleteId ?? '')
219
222
  : null;
223
+ // A scoped caller may only reach athletes inside its scope; the HTTP gate
224
+ // applies the same rule to its JSON APIs.
225
+ if (selected && athleteScope !== undefined && !scopeAllowsAthlete(athleteScope, selected.athlete.id)) {
226
+ const message = `This credential is not allowed to access athlete ${selected.athlete.id}.`;
227
+ return {
228
+ ...textResult({ data: null, provenance: {}, query: { athleteId: selected.athlete.id }, caveats: [message], error: { code: 'athlete_forbidden', message } }),
229
+ isError: true,
230
+ };
231
+ }
220
232
  const run = async () => {
221
233
  const currentPaths = activePaths();
222
234
  const config = await loadCatenceConfig(currentPaths);
@@ -731,7 +743,9 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
731
743
  server.registerResource('athletes', 'catence://athletes', { title: 'Configured athlete roster', mimeType: 'application/json' }, async (uri) => {
732
744
  try {
733
745
  const catalog = await loadCatalog(catalogPaths);
734
- return resource(uri, { defaultAthleteId: catalog.defaultAthleteId, athletes: catalog.athletes });
746
+ // The roster is narrowed to the caller's athlete scope, if any.
747
+ const roster = filterRosterToScope(catalog.athletes, catalog.defaultAthleteId, athleteScope ?? 'all');
748
+ return resource(uri, { defaultAthleteId: roster.defaultAthleteId, athletes: roster.athletes });
735
749
  }
736
750
  catch (error) {
737
751
  return resource(uri, { error: errorResult(error).content[0].text });
@@ -13,6 +13,13 @@ export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth
13
13
  export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
14
14
  export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
15
15
  export type { Athlete, CatalogPaths, CatenceCatalog } from '../core/runtime/catalog.js';
16
+ export { addMcpKey, countActiveMcpKeys, listMcpKeys, McpKeyNotFoundError, resolveKeyPrincipal, revokeMcpKey, rollMcpKey, } from '../core/runtime/mcp-keys.js';
17
+ export type { McpKeyPrincipal, McpKeyRecord, McpKeySummary } from '../core/runtime/mcp-keys.js';
18
+ export { consoleAccountsPath, filterRosterToScope, loadConsoleAccounts, resolveAccountScope, scopeAllowsAthlete, verifyAccountPassword, } from '../core/runtime/console-accounts.js';
19
+ export type { ConsoleAccount, ConsoleAccountRole, ConsoleAccountScope } from '../core/runtime/console-accounts.js';
20
+ export { createFileOAuthServerModel, FileOAuthServerModel } from '../core/runtime/oauth/model.js';
21
+ export { normalizeMcpOauthSettings, readMcpOauthSettings, validateOauthPublicUrl, writeMcpOauthSettings, } from '../core/runtime/oauth/settings.js';
22
+ export type { McpOauthSettings, McpOauthSettingsInput, McpOauthSettingsRead, McpOauthSettingsState, } from '../core/runtime/oauth/settings.js';
16
23
  export { athleteProviderEnvironment, deleteAthleteSecret, describeAthleteSecrets, providerSecretPath, readAthleteSecrets, secretProviderLabels, setAthleteSecret } from '../core/runtime/secrets.js';
17
24
  export type { AthleteSecrets, SecretFieldDescription, SecretProvider, SecretProviderDescription } from '../core/runtime/secrets.js';
18
25
  export { ATHLETE_FILE_MAX_CHARACTERS, ATHLETE_FILE_TEMPLATE, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, hashAthleteFileContent, listAthleteFileRevisions, readAthleteFile, readAthleteFileRevision, updateAthleteFile, } from '../core/runtime/athlete-file.js';
@@ -2,6 +2,10 @@ export { channelForVersion, compareReleaseVersions, fetchNpmDistTags, fetchPypiR
2
2
  export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
3
3
  export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
4
4
  export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
5
+ export { addMcpKey, countActiveMcpKeys, listMcpKeys, McpKeyNotFoundError, resolveKeyPrincipal, revokeMcpKey, rollMcpKey, } from '../core/runtime/mcp-keys.js';
6
+ export { consoleAccountsPath, filterRosterToScope, loadConsoleAccounts, resolveAccountScope, scopeAllowsAthlete, verifyAccountPassword, } from '../core/runtime/console-accounts.js';
7
+ export { createFileOAuthServerModel, FileOAuthServerModel } from '../core/runtime/oauth/model.js';
8
+ export { normalizeMcpOauthSettings, readMcpOauthSettings, validateOauthPublicUrl, writeMcpOauthSettings, } from '../core/runtime/oauth/settings.js';
5
9
  export { athleteProviderEnvironment, deleteAthleteSecret, describeAthleteSecrets, providerSecretPath, readAthleteSecrets, secretProviderLabels, setAthleteSecret } from '../core/runtime/secrets.js';
6
10
  export { ATHLETE_FILE_MAX_CHARACTERS, ATHLETE_FILE_TEMPLATE, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, hashAthleteFileContent, listAthleteFileRevisions, readAthleteFile, readAthleteFileRevision, updateAthleteFile, } from '../core/runtime/athlete-file.js';
7
11
  export { SlidingWindowLimiter } from '../core/runtime/limiter.js';
@@ -27,6 +27,7 @@ explained in the [beta release notes](../../README.md#beta-releases).
27
27
  | API keys | process environment only | `.env` |
28
28
  | Chat history + preferences | data home | `console/chat-history.sqlite3` |
29
29
  | Console logins + athlete grants | data home (mode 0600, bcrypt hashes) | `console/accounts.json` |
30
+ | Per-account MCP keys + OAuth state | data home (registry 0600; `oauth/` dir 0700) | `mcp-keys.json`, `oauth/` |
30
31
 
31
32
  Credentials never land in `config.json`; profiles reference environment
32
33
  variable *names* (`apiKeyEnv`, `apiBaseEnv`, `apiVersionEnv`). See
@@ -165,9 +166,10 @@ to scope a chat to. The environment pair is a never-persisted break-glass
165
166
  admin: it keeps working when the accounts file is lost or the last admin is
166
167
  locked out, and unsetting the two variables revokes it.
167
168
 
168
- Accounts gate the Console only. The MCP server keeps its own contract — no
169
- authentication, the caller's `athleteId` is trusted — so port 8787 still needs
170
- loopback or network-layer restriction (see
169
+ Accounts gate the Console only. The MCP server has no authentication by
170
+ default — the caller's `athleteId` is trusted — so port 8787 still needs
171
+ loopback or network-layer restriction unless you switch on the bearer gate,
172
+ per-account keys, or OAuth (see
171
173
  [Connecting to the Docker-based MCP from outside](#connecting-to-the-docker-based-mcp-from-outside)).
172
174
 
173
175
  The accounts file is the only copy of your stored logins: keep it in backups
@@ -468,11 +470,12 @@ CATENCE_MCP_BIND=0.0.0.0 ./scripts/deploy-console.sh beta
468
470
  After deployment, the MCP server is available at
469
471
  `http://<tailscale-ip>:8787/mcp` from any device on your tailnet.
470
472
 
471
- **Security note:** The MCP server has **no authentication** — it trusts the
472
- caller's `athleteId` parameter. When exposing on `0.0.0.0`, you **must**
473
- restrict access at the network layer (Tailscale ACLs, firewall rules, etc.).
474
- The Console UI remains on loopback (`127.0.0.1:8000`) and should still be
475
- fronted by Cloudflare Tunnel or a reverse proxy.
473
+ **Security note:** Without the bearer gate, per-account keys, or OAuth, the MCP
474
+ server has **no authentication** — it trusts the caller's `athleteId`
475
+ parameter. When exposing on `0.0.0.0`, you **must** restrict access at the
476
+ network layer (Tailscale ACLs, firewall rules, etc.). The Console UI remains
477
+ on loopback (`127.0.0.1:8000`) and should still be fronted by Cloudflare
478
+ Tunnel or a reverse proxy.
476
479
 
477
480
  **Examples: common MCP clients**
478
481
 
@@ -498,8 +501,8 @@ claude mcp add --transport http catence -- http://<tailscale-ip>:8787/mcp
498
501
 
499
502
  ### Why not expose port 8787 directly?
500
503
 
501
- - The MCP server has **no authentication** — it trusts the caller's
502
- `athleteId` parameter.
504
+ - Without the bearer gate, per-account keys, or OAuth, the MCP server has
505
+ **no authentication** — it trusts the caller's `athleteId` parameter.
503
506
  - Exposing it would allow any network caller to read all athlete data in the
504
507
  catalog.
505
508
  - The Console proxies dashboard requests through its **authenticated**
@@ -547,6 +550,41 @@ A custom host-side token file is still possible: set
547
550
  read-only at the same path, so it must already exist on the host — enabling
548
551
  from the Console cannot create it.
549
552
 
553
+ #### Per-account MCP keys and OAuth 2.1 (Docker)
554
+
555
+ The runtime reads three more environment variables. The deploy script passes
556
+ them into the container, writes them into `catence-deploy/.env`, and validates
557
+ them before rendering the compose file:
558
+
559
+ | Variable | Default | Meaning |
560
+ | --- | --- | --- |
561
+ | `CATENCE_OAUTH_ENABLED` | empty (disabled) | `1` or `true` runs the OAuth 2.1 authorization server so ChatGPT/Codex connect with their OAuth flow. Startup default — the Console (Settings → MCP Exposure → OAuth 2.1 sign-in) overrides it at runtime. Requires `CATENCE_PUBLIC_URL` when enabled. |
562
+ | `CATENCE_PUBLIC_URL` | empty | Public `https` URL of this server (the tunnel URL; no query string or fragment). It becomes the advertised OAuth issuer. Startup default — overridable in the Console. |
563
+ | `CATENCE_MCP_KEYS_FILE` | `/data/mcp-keys.json` | Per-account MCP key registry, a container path — no host mount needed. Managed with `catence-data mcp key` or the Console (Settings → MCP Exposure → API keys). |
564
+
565
+ ```sh
566
+ # Per-account key for a Console account (the raw token is printed exactly once):
567
+ docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env \
568
+ exec console catence-data --home /data mcp key add --account coach
569
+
570
+ # OAuth for ChatGPT/Codex — preferred: enable in the Console
571
+ # (Settings → MCP Exposure → OAuth 2.1 sign-in; applies without a restart).
572
+ # Startup defaults, set in catence-deploy/.env and re-run the script:
573
+ # CATENCE_OAUTH_ENABLED=1
574
+ # CATENCE_PUBLIC_URL=https://catence-mcp.example.com
575
+ ./scripts/deploy-console.sh beta
576
+ ```
577
+
578
+ The script refuses to deploy when `CATENCE_OAUTH_ENABLED` is set without a
579
+ valid https `CATENCE_PUBLIC_URL`. Once a valid Console settings file exists
580
+ (`<catalog-home>/oauth/settings.json`, inside the data volume), it overrides
581
+ these startup values at runtime. The checked-in dev compose
582
+ (`docker-compose.beta.yml`) accepts `CATENCE_OAUTH_ENABLED` and
583
+ `CATENCE_PUBLIC_URL` the same way (empty default = disabled). See
584
+ [local-mcp.md](local-mcp.md#per-account-api-keys) and
585
+ [local-mcp.md](local-mcp.md#oauth-21-chatgptcodex) for the full semantics and
586
+ the client matrix.
587
+
550
588
  ## Exposing the Console safely
551
589
 
552
590
  Keep the Console port bound to `127.0.0.1` (the default) and front it with a
@@ -90,8 +90,10 @@ implicitly; the MCP server does not authenticate callers — it trusts the
90
90
  Catence serves several athlete stores from one server. Each store has its own
91
91
  DuckDB, raw data, and tokens; a call acts on the single store it names and
92
92
  never merges athletes implicitly. That is a tool contract, not an access
93
- boundary: the MCP server does not authenticate callers and trusts the
94
- `athleteId` they pass — see [MCP clients and HTTP](#streamable-http-server).
93
+ boundary: the stdio server does not authenticate callers and trusts the
94
+ `athleteId` they pass — over Streamable HTTP the bearer gate, per-account
95
+ keys, or OAuth authenticate and scope callers; see
96
+ [MCP clients and HTTP](#streamable-http-server).
95
97
 
96
98
  ### MCP behavior
97
99
 
@@ -310,7 +312,10 @@ Mistral Work), switch on the bearer-token gate. The gate is armed at a token
310
312
  file — `<catalog-home>/mcp-token` by default, or the path given by
311
313
  `--auth-token-file` (environment equivalent: `CATENCE_MCP_TOKEN_FILE`) — and
312
314
  the **file's presence is the switch**: create it to enable, remove it to
313
- disable, no restart needed.
315
+ disable, no restart needed. The gate is also armed when the per-account key
316
+ registry holds at least one active key or when OAuth is enabled (see the next
317
+ sections); only with no token file, no keys, and no OAuth configured is the
318
+ server open.
314
319
 
315
320
  Catence generates the token itself; never hand-craft one:
316
321
 
@@ -325,7 +330,9 @@ catence-data mcp disable # remove the file; remote access is refused again
325
330
  probes) answers `401` without an exact `Authorization: Bearer <token>`
326
331
  header while the gate is on. The `401` deliberately carries no
327
332
  `WWW-Authenticate`, so MCP clients with auth auto-detection (Mistral Work)
328
- classify the server as bearer-auth rather than OAuth.
333
+ classify the server as bearer-auth rather than OAuth. With OAuth enabled the
334
+ `401` instead gains a `WWW-Authenticate: Bearer resource_metadata="<public-url>/.well-known/oauth-protected-resource/mcp"`
335
+ challenge — see [OAuth 2.1 (ChatGPT/Codex)](#oauth-21-chatgptcodex).
329
336
  - CORS preflights (`OPTIONS`) pass through without a token.
330
337
  - An existing but unreadable or empty token file fails closed: the gate
331
338
  rejects everything except the health probes until the file is fixed or
@@ -369,7 +376,108 @@ catence-data mcp disable # remove the file; remote access is refused again
369
376
  packaged Console instead proxies the dashboard through its authenticated
370
377
  same-origin route.
371
378
 
372
- Connect HTTP-based clients to `http://127.0.0.1:8787/mcp`. There is **no
373
- authentication on the MCP server itself** — it trusts the caller's `athleteId`
374
- parameter — so keep it on loopback or restrict it at the network layer. For
375
- remote access over SSH: `ssh -L 8787:127.0.0.1:8787 server`.
379
+ #### Per-account API keys
380
+
381
+ The shared bearer token is a single admin credential. For per-account access,
382
+ create API keys bound to Console accounts (`console/accounts.json`). A key acts
383
+ as its account: an admin account's key sees every athlete, a member account's
384
+ key sees only that account's granted athletes.
385
+
386
+ The key registry lives at `<catalog-home>/mcp-keys.json` — override with
387
+ `--mcp-keys-file` or `CATENCE_MCP_KEYS_FILE`. In the Docker deployment the
388
+ default is `/data/mcp-keys.json` inside the data volume: runtime-managed, no
389
+ host mount needed. The file is written atomically with mode 0600 and stores
390
+ only SHA-256 token hashes. Any active key in the registry arms the gate,
391
+ exactly like the token file's presence does.
392
+
393
+ ```sh
394
+ catence-data mcp status # gate, token, key counts, OAuth state
395
+ catence-data mcp key add --account coach --label "vibe" # prints the raw token exactly once
396
+ catence-data mcp key list # ids, labels, accounts — never hashes
397
+ catence-data mcp key roll <id> # new raw token, printed exactly once
398
+ catence-data mcp key revoke <id> # stops working immediately
399
+ ```
400
+
401
+ All four accept `--file <path>` for a non-default registry. Admins manage the
402
+ same registry in the Console under Settings → MCP Exposure → API keys (create
403
+ with an account picker and a label — the raw token is shown once — and revoke
404
+ by id), backed by the admin-only HTTP API `GET`/`POST`/`DELETE
405
+ /api/v1/mcp-keys` and `POST /api/v1/mcp-keys/roll`.
406
+
407
+ Scope semantics:
408
+
409
+ - A key is bound to a Console account; its athlete scope is the account's
410
+ grant list, resolved live from `console/accounts.json` on every request.
411
+ - Scope is enforced in the runtime for both MCP tools and the HTTP APIs: an
412
+ out-of-scope `athleteId` is refused with `403 athlete_forbidden` over HTTP
413
+ (`athlete_forbidden` tool error over MCP), and `list_athletes`,
414
+ `catence://athletes`, and `GET /api/v1/athletes` are filtered to the scope.
415
+ - Admin-only APIs (`POST /api/v1/athletes`, `/api/v1/mcp-exposure`,
416
+ `/api/v1/mcp-keys`, `/api/v1/models/discover`) require an admin principal:
417
+ the legacy token is always admin, an account key is admin only when its
418
+ account is.
419
+ - Fail closed: a revoked key, a deleted account, or an unreadable registry or
420
+ accounts file rejects the request (`401`) — never an anonymous fallback.
421
+ - The raw token is shown exactly once (on `add` and `roll`); only its hash is
422
+ stored. Keep it in a password manager, out of `.env`, `config.json`, shell
423
+ history, and repositories.
424
+ - `mcp disable` only closes the shared-token path: while keys or OAuth are
425
+ configured, exposure stays armed through them.
426
+
427
+ #### OAuth 2.1 (ChatGPT/Codex)
428
+
429
+ `catence serve --oauth --public-url <url>` runs an OAuth 2.1 authorization
430
+ server inside the runtime so ChatGPT and Codex can connect with their native
431
+ OAuth flow. The environment equivalents are `CATENCE_OAUTH_ENABLED=1|true` and
432
+ `CATENCE_PUBLIC_URL=<url>`.
433
+
434
+ From the Console, open Settings → MCP exposure → **OAuth 2.1 sign-in**: set the
435
+ public URL and flip the switch. The Console writes
436
+ `<catalog-home>/oauth/settings.json`, which the runtime reads on every request —
437
+ the change applies immediately, without a restart. Once a valid settings file
438
+ exists it alone decides whether OAuth is enabled and overrides the
439
+ `CATENCE_OAUTH_ENABLED`/`CATENCE_PUBLIC_URL` (or `--oauth`/`--public-url`)
440
+ startup values; a malformed file fails closed to disabled until the Console
441
+ saves a valid one. `catence-data mcp status` reports the effective state and
442
+ its `source` (`console`, `environment`, or `disabled`).
443
+
444
+ - The issuer is the public URL: it must be an absolute `https` URL with no
445
+ query string or fragment (typically the tunnel URL), and it is required when
446
+ OAuth is enabled — the server refuses to start without it (startup values)
447
+ and the Console write rejects it (settings values).
448
+ - Clients discover the server through RFC 8414/RFC 9728 metadata served at
449
+ `/.well-known/oauth-authorization-server` and
450
+ `/.well-known/oauth-protected-resource[/mcp]`. Dynamic client registration
451
+ (`POST /oauth/register`, RFC 7591) and client ID metadata documents (CIMD)
452
+ are enabled; PKCE with S256 is enforced.
453
+ - The browser consent page lives at `<public-url>/oauth/consent`: it logs in
454
+ with a Console account (username + password, verified against
455
+ `console/accounts.json`) and asks to approve or deny the client. The access
456
+ token carries the account username, and the athlete scope is that account's
457
+ live grants — scoped exactly like an account key.
458
+ - While OAuth is enabled, the `401` for a missing or invalid bearer token
459
+ gains an RFC 9728 challenge —
460
+ `WWW-Authenticate: Bearer resource_metadata="<public-url>/.well-known/oauth-protected-resource/mcp"`
461
+ — so OAuth clients can discover the authorization server. Without `--oauth`
462
+ the `401` stays plain on purpose (Mistral connector auto-detection).
463
+ - Authorization-server state (registered clients, authorization codes, tokens)
464
+ lives in `<catalog-home>/oauth/` (directory mode 0700, files 0600).
465
+
466
+ Note: the added `WWW-Authenticate` challenge can flip a client with auth
467
+ auto-detection (Mistral Work) from bearer to OAuth — smoke-test the Mistral
468
+ connector after enabling OAuth.
469
+
470
+ #### Client matrix
471
+
472
+ | Client | Setup | Credential |
473
+ | --- | --- | --- |
474
+ | Mistral Vibe (Work custom connector) | Connectors → Add Connector → Custom MCP Connector, URL `https://<host>/mcp` | Bearer credential (the shared token) — unchanged |
475
+ | Claude (custom connector) | Authentication: "No sign in"; Request headers: `Authorization: Bearer <key>` | Per-account API key |
476
+ | Claude Code (CLI) | `claude mcp add --transport http --header "Authorization: Bearer <key>" <url>` | Per-account API key |
477
+ | ChatGPT / Codex | Connector OAuth connect flow | OAuth 2.1 (Console: MCP exposure → OAuth 2.1 sign-in, or `--oauth`) |
478
+
479
+ Connect HTTP-based clients to `http://127.0.0.1:8787/mcp`. Without the bearer
480
+ gate, per-account keys, or OAuth there is **no authentication on the MCP
481
+ server itself** — it trusts the caller's `athleteId` parameter — so keep it on
482
+ loopback or restrict it at the network layer. For remote access over SSH:
483
+ `ssh -L 8787:127.0.0.1:8787 server`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.16",
3
+ "version": "0.2.1-beta.18",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -71,12 +71,18 @@
71
71
  "dependencies": {
72
72
  "@duckdb/node-api": "^1.4.4-r.1",
73
73
  "@modelcontextprotocol/sdk": "^1.30.0",
74
+ "bcryptjs": "3.0.3",
74
75
  "commander": "^14.0.0",
75
76
  "dotenv": "^17.2.2",
77
+ "express": "5.3.0",
78
+ "express-rate-limit": "8.7.1",
76
79
  "intervals-icu": "^2.2.0",
80
+ "mcp-oauth-server": "1.0.1",
77
81
  "zod": "^4.1.5"
78
82
  },
79
83
  "devDependencies": {
84
+ "@types/bcryptjs": "2.4.6",
85
+ "@types/express": "5.0.6",
80
86
  "@types/node": "^24.3.0",
81
87
  "tsx": "^4.20.5",
82
88
  "typescript": "^5.9.2",