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.
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/runtime/console-accounts.d.ts +52 -0
- package/dist/core/runtime/console-accounts.js +103 -0
- package/dist/core/runtime/mcp-keys.d.ts +65 -0
- package/dist/core/runtime/mcp-keys.js +171 -0
- package/dist/core/runtime/oauth/model.d.ts +44 -0
- package/dist/core/runtime/oauth/model.js +341 -0
- package/dist/core/runtime/oauth/settings.d.ts +33 -0
- package/dist/core/runtime/oauth/settings.js +96 -0
- package/dist/elt/ingestion/importer.js +4 -0
- package/dist/interfaces/cli/commands.js +49 -5
- package/dist/interfaces/http/cli-options.d.ts +35 -2
- package/dist/interfaces/http/cli-options.js +119 -3
- package/dist/interfaces/http/oauth.d.ts +36 -0
- package/dist/interfaces/http/oauth.js +175 -0
- package/dist/interfaces/http/server.d.ts +45 -1
- package/dist/interfaces/http/server.js +400 -32
- package/dist/interfaces/mcp/main.js +21 -1
- package/dist/interfaces/mcp/server.d.ts +12 -1
- package/dist/interfaces/mcp/server.js +18 -4
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.js +4 -0
- package/docs/deployment/docker.md +48 -10
- package/docs/deployment/local-mcp.md +116 -8
- package/package.json +7 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/runtime/index.js
CHANGED
|
@@ -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
|
|
169
|
-
|
|
170
|
-
loopback or network-layer restriction
|
|
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:**
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
-
|
|
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
|
|
94
|
-
`athleteId` they pass —
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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.
|
|
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",
|