catence 0.2.1-beta.13 → 0.2.1-beta.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/interfaces/cli/commands.js +36 -0
- package/dist/interfaces/http/cli-options.d.ts +31 -1
- package/dist/interfaces/http/cli-options.js +98 -2
- package/dist/interfaces/http/server.d.ts +15 -0
- package/dist/interfaces/http/server.js +51 -0
- package/dist/interfaces/mcp/main.js +7 -2
- package/docs/configuration.md +1 -0
- package/docs/deployment/docker.md +41 -0
- package/docs/deployment/local-mcp.md +39 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ Optional local Streamable HTTP MCP and HTTP APIs:
|
|
|
120
120
|
catence serve --host 127.0.0.1 --port 8787
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
`GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route. `POST /api/v1/athletes` creates a catalog athlete (`{"id": "sam", "label": "Sam", "setDefault": false}`); the packaged Console exposes the same call to admins from **Settings → Athletes**. The MCP server has no authentication of its own.
|
|
123
|
+
`GET /api/v1/athletes` returns IDs and labels only. `GET /api/v1/dashboard` requires `athleteId`, for example `http://127.0.0.1:8787/api/v1/dashboard?athleteId=alex&days=28`. Browser origins must be listed with `--allow-origin`; the packaged Console instead proxies the dashboard through its authenticated same-origin route. `POST /api/v1/athletes` creates a catalog athlete (`{"id": "sam", "label": "Sam", "setDefault": false}`); the packaged Console exposes the same call to admins from **Settings → Athletes**. The MCP server has no authentication of its own beyond the optional bearer-token gate: `catence-data mcp enable` generates a token at `<home>/mcp-token` and every endpoint except the health probes then requires `Authorization: Bearer <token>`; `mcp status`/`mcp token`/`mcp disable` manage it, and the Console Settings expose the same controls to admins.
|
|
124
124
|
|
|
125
125
|
## Configuration
|
|
126
126
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.15";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.15';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { createInterface } from 'node:readline/promises';
|
|
3
3
|
import { stdin as input, stdout as output } from 'node:process';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
+
import { defaultBearerTokenPath, disableBearerToken, ensureBearerToken, mcpExposureStatus, readBearerToken, } from '../http/cli-options.js';
|
|
5
6
|
import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, CatenceDatabase, channelForVersion, createDemoStore, dataStatus, deleteAthleteSecret, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, importStagedFile, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, reimportNutrition, reimportWeight, resolveExtractionErrors, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, syncProgress, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
|
|
6
7
|
const program = new Command()
|
|
7
8
|
.name('catence-data')
|
|
@@ -37,6 +38,41 @@ async function stdinSecret() {
|
|
|
37
38
|
throw new Error('--value-stdin received an empty secret.');
|
|
38
39
|
return value;
|
|
39
40
|
}
|
|
41
|
+
const mcpCommand = program.command('mcp').description('Manage Streamable HTTP MCP exposure: the bearer-token gate, its token, and endpoint URLs.');
|
|
42
|
+
const mcpLocalEndpoint = () => `http://127.0.0.1:${process.env.CATENCE_HTTP_PORT ?? '8787'}`;
|
|
43
|
+
mcpCommand.command('status')
|
|
44
|
+
.description('Report whether the bearer-token gate is enabled, the token, and the endpoint URLs.')
|
|
45
|
+
.option('--file <path>', 'non-default token file path')
|
|
46
|
+
.action(async (options) => {
|
|
47
|
+
const tokenFile = options.file ?? defaultBearerTokenPath(currentCatalog().root);
|
|
48
|
+
process.stdout.write(`${JSON.stringify(mcpExposureStatus(tokenFile, mcpLocalEndpoint()), null, 2)}\n`);
|
|
49
|
+
});
|
|
50
|
+
mcpCommand.command('enable')
|
|
51
|
+
.description('Enable the bearer-token gate: generate the token file (mode 0600) if it does not exist.')
|
|
52
|
+
.option('--file <path>', 'non-default token file path')
|
|
53
|
+
.action(async (options) => {
|
|
54
|
+
const tokenFile = options.file ?? defaultBearerTokenPath(currentCatalog().root);
|
|
55
|
+
ensureBearerToken(tokenFile);
|
|
56
|
+
process.stdout.write(`${JSON.stringify(mcpExposureStatus(tokenFile, mcpLocalEndpoint()), null, 2)}\n`);
|
|
57
|
+
});
|
|
58
|
+
mcpCommand.command('disable')
|
|
59
|
+
.description('Disable the bearer-token gate by removing the token file. Remote access is refused again.')
|
|
60
|
+
.option('--file <path>', 'non-default token file path')
|
|
61
|
+
.action(async (options) => {
|
|
62
|
+
const tokenFile = options.file ?? defaultBearerTokenPath(currentCatalog().root);
|
|
63
|
+
disableBearerToken(tokenFile);
|
|
64
|
+
process.stdout.write(`${JSON.stringify(mcpExposureStatus(tokenFile, mcpLocalEndpoint()), null, 2)}\n`);
|
|
65
|
+
});
|
|
66
|
+
mcpCommand.command('token')
|
|
67
|
+
.description('Print only the bearer token for pasting into an MCP client credential flow.')
|
|
68
|
+
.option('--file <path>', 'non-default token file path')
|
|
69
|
+
.action(async (options) => {
|
|
70
|
+
const tokenFile = options.file ?? defaultBearerTokenPath(currentCatalog().root);
|
|
71
|
+
const token = readBearerToken(tokenFile);
|
|
72
|
+
if (token === null)
|
|
73
|
+
throw new Error(`MCP exposure is not enabled; run 'catence-data mcp enable' first.`);
|
|
74
|
+
process.stdout.write(`${token}\n`);
|
|
75
|
+
});
|
|
40
76
|
program.command('setup')
|
|
41
77
|
.description('Create a home catalog and its first athlete store. Existing 0.1 stores must be re-synced.')
|
|
42
78
|
.option('--athlete <id>', 'first athlete ID')
|
|
@@ -3,7 +3,37 @@ export type ServeCliOptions = {
|
|
|
3
3
|
host: string;
|
|
4
4
|
port: number;
|
|
5
5
|
allowedOrigins: string[];
|
|
6
|
+
authTokenFile?: string;
|
|
6
7
|
help: boolean;
|
|
7
8
|
};
|
|
8
|
-
export declare const SERVE_USAGE = "Usage: catence serve [options]\n\nRun Catence as a local Streamable HTTP MCP server.\n\nOptions:\n --home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.\n --host <host> Listener host. Defaults to 127.0.0.1.\n --port <port> Listener port. Defaults to 8787.\n --allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.\n -h, --help Show this help text.";
|
|
9
|
+
export declare const SERVE_USAGE = "Usage: catence serve [options]\n\nRun Catence as a local Streamable HTTP MCP server.\n\nOptions:\n --home <directory> Catence catalog home. Defaults to CATENCE_HOME or ~/.catence.\n --host <host> Listener host. Defaults to 127.0.0.1.\n --port <port> Listener port. Defaults to 8787.\n --allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.\n --auth-token-file <path> Bearer-token file for the HTTP gate. Its presence switches authentication on;\n generate/inspect it with 'catence-data mcp enable|token'.\n Defaults to CATENCE_MCP_TOKEN_FILE or <catalog-home>/mcp-token.\n -h, --help Show this help text.";
|
|
10
|
+
/** The canonical token location inside a catalog home. */
|
|
11
|
+
export declare function defaultBearerTokenPath(catalogRoot: string): string;
|
|
12
|
+
/** Read and validate a stored bearer token: trimmed, non-empty, or fail loudly. */
|
|
13
|
+
export declare function loadBearerToken(filePath: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* The bearer token for the server: the stored one, or a freshly generated
|
|
16
|
+
* token written to the file (mode 0600) when it does not exist yet. Users
|
|
17
|
+
* never hand-craft tokens; they enable auth and read the result with
|
|
18
|
+
* `catence-data mcp-token`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function ensureBearerToken(filePath: string): string;
|
|
21
|
+
export declare function disableBearerToken(filePath: string): void;
|
|
22
|
+
/** The bearer token when exposure is enabled, or null when no token file exists. */
|
|
23
|
+
export declare function readBearerToken(filePath: string): string | null;
|
|
24
|
+
/**
|
|
25
|
+
* The currently active bearer token for the HTTP gate, cached by the token
|
|
26
|
+
* file's mtime so enabling or disabling is picked up without a restart. A
|
|
27
|
+
* missing file disables the gate; an existing but unreadable or empty file
|
|
28
|
+
* fails closed (the gate rejects everything except health probes).
|
|
29
|
+
*/
|
|
30
|
+
export declare function activeBearerToken(filePath: string): string | null | 'invalid';
|
|
31
|
+
export type McpExposureStatus = {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
tokenFile: string;
|
|
34
|
+
endpoint: string;
|
|
35
|
+
healthEndpoint: string;
|
|
36
|
+
token?: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function mcpExposureStatus(filePath: string, endpointBase: string): McpExposureStatus;
|
|
9
39
|
export declare function parseServeCliOptions(arguments_: readonly string[]): ServeCliOptions;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
1
3
|
export const SERVE_USAGE = `Usage: catence serve [options]
|
|
2
4
|
|
|
3
5
|
Run Catence as a local Streamable HTTP MCP server.
|
|
@@ -7,7 +9,89 @@ Options:
|
|
|
7
9
|
--host <host> Listener host. Defaults to 127.0.0.1.
|
|
8
10
|
--port <port> Listener port. Defaults to 8787.
|
|
9
11
|
--allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.
|
|
12
|
+
--auth-token-file <path> Bearer-token file for the HTTP gate. Its presence switches authentication on;
|
|
13
|
+
generate/inspect it with 'catence-data mcp enable|token'.
|
|
14
|
+
Defaults to CATENCE_MCP_TOKEN_FILE or <catalog-home>/mcp-token.
|
|
10
15
|
-h, --help Show this help text.`;
|
|
16
|
+
/** The canonical token location inside a catalog home. */
|
|
17
|
+
export function defaultBearerTokenPath(catalogRoot) {
|
|
18
|
+
return `${catalogRoot.replace(/[/\\]+$/, '')}/mcp-token`;
|
|
19
|
+
}
|
|
20
|
+
/** Read and validate a stored bearer token: trimmed, non-empty, or fail loudly. */
|
|
21
|
+
export function loadBearerToken(filePath) {
|
|
22
|
+
let token;
|
|
23
|
+
try {
|
|
24
|
+
token = readFileSync(filePath, 'utf8').trim();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new Error(`Cannot read the auth token file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
28
|
+
}
|
|
29
|
+
if (!token)
|
|
30
|
+
throw new Error(`The auth token file ${filePath} is empty.`);
|
|
31
|
+
return token;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The bearer token for the server: the stored one, or a freshly generated
|
|
35
|
+
* token written to the file (mode 0600) when it does not exist yet. Users
|
|
36
|
+
* never hand-craft tokens; they enable auth and read the result with
|
|
37
|
+
* `catence-data mcp-token`.
|
|
38
|
+
*/
|
|
39
|
+
export function ensureBearerToken(filePath) {
|
|
40
|
+
if (existsSync(filePath))
|
|
41
|
+
return loadBearerToken(filePath);
|
|
42
|
+
const token = randomBytes(32).toString('hex');
|
|
43
|
+
writeFileSync(filePath, token, { mode: 0o600 });
|
|
44
|
+
return token;
|
|
45
|
+
}
|
|
46
|
+
export function disableBearerToken(filePath) {
|
|
47
|
+
if (existsSync(filePath))
|
|
48
|
+
rmSync(filePath);
|
|
49
|
+
}
|
|
50
|
+
/** The bearer token when exposure is enabled, or null when no token file exists. */
|
|
51
|
+
export function readBearerToken(filePath) {
|
|
52
|
+
return existsSync(filePath) ? loadBearerToken(filePath) : null;
|
|
53
|
+
}
|
|
54
|
+
const tokenCache = new Map();
|
|
55
|
+
/**
|
|
56
|
+
* The currently active bearer token for the HTTP gate, cached by the token
|
|
57
|
+
* file's mtime so enabling or disabling is picked up without a restart. A
|
|
58
|
+
* missing file disables the gate; an existing but unreadable or empty file
|
|
59
|
+
* fails closed (the gate rejects everything except health probes).
|
|
60
|
+
*/
|
|
61
|
+
export function activeBearerToken(filePath) {
|
|
62
|
+
let mtimeMs = 0;
|
|
63
|
+
try {
|
|
64
|
+
mtimeMs = statSync(filePath).mtimeMs;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
tokenCache.set(filePath, { mtimeMs: 0, token: null });
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const cached = tokenCache.get(filePath);
|
|
71
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
72
|
+
return cached.token === null ? 'invalid' : cached.token;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const token = loadBearerToken(filePath);
|
|
76
|
+
tokenCache.set(filePath, { mtimeMs, token });
|
|
77
|
+
return token;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
tokenCache.set(filePath, { mtimeMs, token: null });
|
|
81
|
+
return 'invalid';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function mcpExposureStatus(filePath, endpointBase) {
|
|
85
|
+
const token = readBearerToken(filePath);
|
|
86
|
+
const base = endpointBase.replace(/\/+$/, '');
|
|
87
|
+
return {
|
|
88
|
+
enabled: token !== null,
|
|
89
|
+
tokenFile: filePath,
|
|
90
|
+
endpoint: `${base}/mcp`,
|
|
91
|
+
healthEndpoint: `${base}/health`,
|
|
92
|
+
...(token !== null ? { token } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
11
95
|
function requiredValue(arguments_, index, option) {
|
|
12
96
|
const value = arguments_[index + 1];
|
|
13
97
|
if (!value || value.startsWith('-'))
|
|
@@ -27,10 +111,11 @@ export function parseServeCliOptions(arguments_) {
|
|
|
27
111
|
let host = process.env.CATENCE_HTTP_HOST ?? '127.0.0.1';
|
|
28
112
|
let port = process.env.CATENCE_HTTP_PORT ? parsePort(process.env.CATENCE_HTTP_PORT) : 8787;
|
|
29
113
|
const allowedOrigins = [];
|
|
114
|
+
let authTokenFile = process.env.CATENCE_MCP_TOKEN_FILE || undefined;
|
|
30
115
|
for (let index = 0; index < arguments_.length; index++) {
|
|
31
116
|
const argument = arguments_[index];
|
|
32
117
|
if (argument === '-h' || argument === '--help')
|
|
33
|
-
return { home, host, port, allowedOrigins, help: true };
|
|
118
|
+
return { home, host, port, allowedOrigins, authTokenFile, help: true };
|
|
34
119
|
if (argument === '--home') {
|
|
35
120
|
home = requiredValue(arguments_, index, '--home');
|
|
36
121
|
index++;
|
|
@@ -42,6 +127,17 @@ export function parseServeCliOptions(arguments_) {
|
|
|
42
127
|
throw new Error('--home requires a value.');
|
|
43
128
|
continue;
|
|
44
129
|
}
|
|
130
|
+
if (argument === '--auth-token-file') {
|
|
131
|
+
authTokenFile = requiredValue(arguments_, index, '--auth-token-file');
|
|
132
|
+
index++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (argument.startsWith('--auth-token-file=')) {
|
|
136
|
+
authTokenFile = argument.slice('--auth-token-file='.length);
|
|
137
|
+
if (!authTokenFile)
|
|
138
|
+
throw new Error('--auth-token-file requires a value.');
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
45
141
|
if (argument === '--host') {
|
|
46
142
|
host = requiredValue(arguments_, index, '--host');
|
|
47
143
|
index++;
|
|
@@ -76,6 +172,6 @@ export function parseServeCliOptions(arguments_) {
|
|
|
76
172
|
}
|
|
77
173
|
throw new Error(`Unknown option: ${argument}`);
|
|
78
174
|
}
|
|
79
|
-
return { home, host, port, allowedOrigins, help: false };
|
|
175
|
+
return { home, host, port, allowedOrigins, authTokenFile, help: false };
|
|
80
176
|
}
|
|
81
177
|
//# sourceMappingURL=cli-options.js.map
|
|
@@ -6,6 +6,21 @@ export type CatenceHttpServerOptions = {
|
|
|
6
6
|
/** Internal compatibility hook for tests and embedded single-store callers. */
|
|
7
7
|
paths?: CatencePaths;
|
|
8
8
|
allowedOrigins?: readonly string[];
|
|
9
|
+
/**
|
|
10
|
+
* Static bearer token for the gate (tests and embedded single-store callers).
|
|
11
|
+
* Mutually exclusive with bearerTokenFile.
|
|
12
|
+
*/
|
|
13
|
+
bearerToken?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Token file whose presence switches the gate on: every request except the
|
|
16
|
+
* health endpoints and CORS preflights must present
|
|
17
|
+
* `Authorization: Bearer <token>`. The file is re-read on mtime change, so
|
|
18
|
+
* `catence-data mcp enable|disable` (and the /api/v1/mcp-exposure route)
|
|
19
|
+
* take effect without a restart. The MCP server has no other
|
|
20
|
+
* authentication; this gates it (and the JSON APIs) for non-loopback
|
|
21
|
+
* exposure such as a Cloudflare Tunnel to an MCP client.
|
|
22
|
+
*/
|
|
23
|
+
bearerTokenFile?: string;
|
|
9
24
|
/** Internal compatibility hook for tests: override the detached sync spawner. */
|
|
10
25
|
startSync?: typeof startDetachedSync;
|
|
11
26
|
/** Internal compatibility hook for tests: passed into the default startDetachedSync. */
|
|
@@ -1,10 +1,26 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { createServer } from 'node:http';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
5
|
import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, DashboardSnapshotService, deleteAthleteSecret, describeAthleteSecrets, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, jsonSafe, listAthleteFileRevisions, loadCatalog, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, readAthleteFile, readAthleteFileRevision, resolveAthlete, resolveCatalogPaths, setAthleteSecret, startDetachedSync, syncProgress, updateAthleteFile, } from '../../runtime/index.js';
|
|
6
|
+
import { activeBearerToken, ensureBearerToken, disableBearerToken, mcpExposureStatus } from './cli-options.js';
|
|
5
7
|
import { createCatenceMcpServer } from '../mcp/server.js';
|
|
6
8
|
import { generationStatus } from './generation-status.js';
|
|
7
9
|
const MAX_REQUEST_BYTES = 1_000_000;
|
|
10
|
+
/** Endpoints that stay open when bearer authentication is enabled: they carry
|
|
11
|
+
* no personal data and must answer unauthenticated readiness probes. */
|
|
12
|
+
const UNAUTHENTICATED_PATHS = new Set(['/health', '/api/v1/health']);
|
|
13
|
+
function isAuthorized(request, options) {
|
|
14
|
+
const bearerToken = options.bearerToken !== undefined ? options.bearerToken : options.bearerTokenFile !== undefined ? activeBearerToken(options.bearerTokenFile) : undefined;
|
|
15
|
+
if (bearerToken === undefined || bearerToken === null)
|
|
16
|
+
return true;
|
|
17
|
+
if (bearerToken === 'invalid')
|
|
18
|
+
return false;
|
|
19
|
+
const received = Buffer.from(request.headers.authorization ?? '');
|
|
20
|
+
const expected = Buffer.from(`Bearer ${bearerToken}`);
|
|
21
|
+
// timingSafeEqual throws on length mismatch, so compare sizes first.
|
|
22
|
+
return received.length === expected.length && timingSafeEqual(received, expected);
|
|
23
|
+
}
|
|
8
24
|
function json(response, statusCode, value) {
|
|
9
25
|
const body = JSON.stringify(jsonSafe(value));
|
|
10
26
|
response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
|
@@ -313,6 +329,12 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
313
329
|
response.writeHead(204).end();
|
|
314
330
|
return;
|
|
315
331
|
}
|
|
332
|
+
if (!UNAUTHENTICATED_PATHS.has(pathname) && !isAuthorized(request, options)) {
|
|
333
|
+
// No WWW-Authenticate on purpose: MCP connector auto-detection treats a
|
|
334
|
+
// plain 401 as a bearer gate rather than an OAuth server.
|
|
335
|
+
json(response, 401, { error: { code: 'unauthorized', message: 'A valid Authorization: Bearer token is required.' } });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
316
338
|
if (pathname === '/health' && request.method === 'GET') {
|
|
317
339
|
json(response, 200, catenceRuntimeHealth());
|
|
318
340
|
return;
|
|
@@ -581,6 +603,35 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
581
603
|
}
|
|
582
604
|
return;
|
|
583
605
|
}
|
|
606
|
+
if (pathname === '/api/v1/mcp-exposure' && request.method === 'GET') {
|
|
607
|
+
if (!options.bearerTokenFile) {
|
|
608
|
+
json(response, 400, { error: { code: 'exposure_not_configured', message: 'This server was started without a bearer-token file; restart it with --auth-token-file to manage MCP exposure.' } });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`));
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (pathname === '/api/v1/mcp-exposure' && request.method === 'POST') {
|
|
615
|
+
if (!options.bearerTokenFile) {
|
|
616
|
+
json(response, 400, { error: { code: 'exposure_not_configured', message: 'This server was started without a bearer-token file; restart it with --auth-token-file to manage MCP exposure.' } });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
const body = await readJsonBody(request);
|
|
621
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body) || !('enabled' in body) || typeof body.enabled !== 'boolean') {
|
|
622
|
+
throw new Error('Request body must be a JSON object with a boolean "enabled" field.');
|
|
623
|
+
}
|
|
624
|
+
if (body.enabled)
|
|
625
|
+
ensureBearerToken(options.bearerTokenFile);
|
|
626
|
+
else
|
|
627
|
+
disableBearerToken(options.bearerTokenFile);
|
|
628
|
+
json(response, 200, mcpExposureStatus(options.bearerTokenFile, `http://${request.headers.host ?? '127.0.0.1:8787'}`));
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
json(response, 400, { error: { code: 'exposure_update_failed', message: error instanceof Error ? error.message : String(error) } });
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
584
635
|
if (pathname === '/mcp') {
|
|
585
636
|
await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths());
|
|
586
637
|
return;
|
|
@@ -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 } from '../../runtime/index.js';
|
|
5
|
-
import { parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
|
|
5
|
+
import { defaultBearerTokenPath, 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';
|
|
@@ -46,9 +46,13 @@ async function run() {
|
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
await migrateCatalogStores(options.home);
|
|
49
|
+
const catalogPaths = resolveCatalogPaths(options.home);
|
|
49
50
|
const httpServer = createCatenceHttpServer({
|
|
50
|
-
catalogPaths
|
|
51
|
+
catalogPaths,
|
|
51
52
|
allowedOrigins: options.allowedOrigins,
|
|
53
|
+
// The token file's presence switches authentication on; enable it with
|
|
54
|
+
// `catence-data mcp enable` (or the Console Settings / exposure API).
|
|
55
|
+
bearerTokenFile: options.authTokenFile ?? defaultBearerTokenPath(catalogPaths.root),
|
|
52
56
|
});
|
|
53
57
|
await new Promise((resolve, reject) => {
|
|
54
58
|
httpServer.once('error', reject);
|
|
@@ -58,6 +62,7 @@ async function run() {
|
|
|
58
62
|
});
|
|
59
63
|
});
|
|
60
64
|
console.error(`Catence Streamable HTTP server is listening at http://${options.host}:${options.port}/mcp`);
|
|
65
|
+
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.`);
|
|
61
66
|
const close = () => {
|
|
62
67
|
httpServer.close(() => process.exit(0));
|
|
63
68
|
};
|
package/docs/configuration.md
CHANGED
|
@@ -247,6 +247,7 @@ profiles and preserve existing ones, limits, and `defaultProfile` unless
|
|
|
247
247
|
| --- | --- |
|
|
248
248
|
| `CATENCE_HOME` | Catalog home (default `~/.catence`); equivalent to `--home` |
|
|
249
249
|
| `CATENCE_HTTP_HOST` / `CATENCE_HTTP_PORT` | Defaults for `catence serve --host/--port` (host `127.0.0.1`, port `8787`) |
|
|
250
|
+
| `CATENCE_MCP_TOKEN_FILE` | Overrides the bearer-token file path for `catence serve` (default `<catalog-home>/mcp-token`); the file's presence switches the gate on, managed with `catence-data mcp enable/disable` or the Console Settings |
|
|
250
251
|
|
|
251
252
|
Provider credentials are stored per athlete, either with `catence-data secret
|
|
252
253
|
set` (see [`local-mcp.md`](deployment/local-mcp.md#3-store-provider-credentials-stdin-only-never-shell-history))
|
|
@@ -506,6 +506,47 @@ claude mcp add --transport http catence -- http://<tailscale-ip>:8787/mcp
|
|
|
506
506
|
same-origin route.
|
|
507
507
|
- SSH port forwarding keeps the trust boundary at your SSH credentials.
|
|
508
508
|
|
|
509
|
+
### Bearer-token authentication for remote MCP clients
|
|
510
|
+
|
|
511
|
+
To serve the MCP endpoint to a remote MCP client (for example a Mistral Work
|
|
512
|
+
custom connector) over a Cloudflare Tunnel, switch on the native
|
|
513
|
+
bearer-token gate. The deploy scaffold arms it at `/data/mcp-token` (inside
|
|
514
|
+
the data volume, so it survives rebuilds); the file's presence is the switch
|
|
515
|
+
and Catence generates the token itself:
|
|
516
|
+
|
|
517
|
+
```sh
|
|
518
|
+
# enable from the host (or use the Console Settings once logged in):
|
|
519
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env \
|
|
520
|
+
exec console catence-data --home /data mcp enable
|
|
521
|
+
|
|
522
|
+
# print the token for the client's credential flow:
|
|
523
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env \
|
|
524
|
+
exec console catence-data --home /data mcp token
|
|
525
|
+
|
|
526
|
+
# disable again:
|
|
527
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env \
|
|
528
|
+
exec console catence-data --home /data mcp disable
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
With the gate enabled, every runtime endpoint except `/health` and
|
|
532
|
+
`/api/v1/health` answers `401` without an exact `Authorization: Bearer
|
|
533
|
+
<token>` header — including `/mcp` and the JSON APIs. Toggling takes effect
|
|
534
|
+
without a restart; the Console attaches the header automatically to its own
|
|
535
|
+
runtime calls.
|
|
536
|
+
|
|
537
|
+
Keep `CATENCE_MCP_BIND=127.0.0.1` (the default): the tunnel client on the
|
|
538
|
+
host forwards to the host-loopback mapping, so the port still never binds a
|
|
539
|
+
LAN interface. Point the tunnel ingress for the MCP hostname at
|
|
540
|
+
`http://127.0.0.1:8787` and complete the client's credential flow with the
|
|
541
|
+
token from `mcp token`. Rotate with `mcp disable && mcp enable` and
|
|
542
|
+
re-authenticate the client. The only additional setup the tunnel needs is
|
|
543
|
+
the ingress rule for the MCP hostname.
|
|
544
|
+
|
|
545
|
+
A custom host-side token file is still possible: set
|
|
546
|
+
`CATENCE_MCP_TOKEN_FILE=<host path>` in `.env`. The file is then bind-mounted
|
|
547
|
+
read-only at the same path, so it must already exist on the host — enabling
|
|
548
|
+
from the Console cannot create it.
|
|
549
|
+
|
|
509
550
|
## Exposing the Console safely
|
|
510
551
|
|
|
511
552
|
Keep the Console port bound to `127.0.0.1` (the default) and front it with a
|
|
@@ -302,6 +302,45 @@ catence serve --host 127.0.0.1 --port 8787
|
|
|
302
302
|
By default it is loopback-only. `CATENCE_HTTP_HOST` and `CATENCE_HTTP_PORT`
|
|
303
303
|
override the host and port when the flags are omitted.
|
|
304
304
|
|
|
305
|
+
#### Bearer-token authentication
|
|
306
|
+
|
|
307
|
+
The server has no authentication of its own; when it must be reachable beyond
|
|
308
|
+
loopback (for example through a Cloudflare Tunnel to an MCP client such as
|
|
309
|
+
Mistral Work), switch on the bearer-token gate. The gate is armed at a token
|
|
310
|
+
file — `<catalog-home>/mcp-token` by default, or the path given by
|
|
311
|
+
`--auth-token-file` (environment equivalent: `CATENCE_MCP_TOKEN_FILE`) — and
|
|
312
|
+
the **file's presence is the switch**: create it to enable, remove it to
|
|
313
|
+
disable, no restart needed.
|
|
314
|
+
|
|
315
|
+
Catence generates the token itself; never hand-craft one:
|
|
316
|
+
|
|
317
|
+
```sh
|
|
318
|
+
catence-data mcp status # enabled, token file, endpoint URLs
|
|
319
|
+
catence-data mcp enable # generate the token file (mode 0600) if missing
|
|
320
|
+
catence-data mcp token # print the token for the client credential flow
|
|
321
|
+
catence-data mcp disable # remove the file; remote access is refused again
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- Every endpoint except `/health` and `/api/v1/health` (kept open for readiness
|
|
325
|
+
probes) answers `401` without an exact `Authorization: Bearer <token>`
|
|
326
|
+
header while the gate is on. The `401` deliberately carries no
|
|
327
|
+
`WWW-Authenticate`, so MCP clients with auth auto-detection (Mistral Work)
|
|
328
|
+
classify the server as bearer-auth rather than OAuth.
|
|
329
|
+
- CORS preflights (`OPTIONS`) pass through without a token.
|
|
330
|
+
- An existing but unreadable or empty token file fails closed: the gate
|
|
331
|
+
rejects everything except the health probes until the file is fixed or
|
|
332
|
+
removed.
|
|
333
|
+
- `GET /api/v1/mcp-exposure` reports the same status the Console Settings page
|
|
334
|
+
shows (including the token); `POST /api/v1/mcp-exposure` with
|
|
335
|
+
`{"enabled": true|false}` toggles the gate. The Console exposes both to
|
|
336
|
+
admins only.
|
|
337
|
+
- Rotation: `catence-data mcp disable && catence-data mcp enable` (or rewrite
|
|
338
|
+
the file), then re-authenticate the MCP client. Keep the token out of
|
|
339
|
+
`.env`, `config.json`, shell history, and repositories.
|
|
340
|
+
- The Console attaches the same header automatically to its runtime calls and
|
|
341
|
+
reads the token file lazily, so enabling the gate from the Console or CLI
|
|
342
|
+
needs no Console restart either.
|
|
343
|
+
|
|
305
344
|
- `GET /api/v1/athletes` returns IDs and labels only.
|
|
306
345
|
- `POST /api/v1/athletes` creates a catalog athlete from
|
|
307
346
|
`{"id": "sam", "label": "Sam", "setDefault": false}` and answers with the
|
package/package.json
CHANGED