catence 0.2.1-beta.13 → 0.2.1-beta.14
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/http/cli-options.d.ts +4 -1
- package/dist/interfaces/http/cli-options.js +30 -2
- package/dist/interfaces/http/server.d.ts +7 -0
- package/dist/interfaces/http/server.js +18 -0
- package/dist/interfaces/mcp/main.js +5 -1
- package/docs/configuration.md +1 -0
- package/docs/deployment/docker.md +28 -0
- package/docs/deployment/local-mcp.md +25 -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 unless `--auth-token-file` (or `CATENCE_MCP_TOKEN_FILE`) requires a bearer token on every endpoint except the health probes.
|
|
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.14";
|
|
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.14';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -3,7 +3,10 @@ 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> Require 'Authorization: Bearer <token>' on every request except\n the health endpoints. Defaults to CATENCE_MCP_TOKEN_FILE.\n -h, --help Show this help text.";
|
|
10
|
+
/** Read and validate the bearer token from a file: trimmed, non-empty, or fail loudly. */
|
|
11
|
+
export declare function loadBearerToken(filePath: string): string;
|
|
9
12
|
export declare function parseServeCliOptions(arguments_: readonly string[]): ServeCliOptions;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
export const SERVE_USAGE = `Usage: catence serve [options]
|
|
2
3
|
|
|
3
4
|
Run Catence as a local Streamable HTTP MCP server.
|
|
@@ -7,7 +8,22 @@ Options:
|
|
|
7
8
|
--host <host> Listener host. Defaults to 127.0.0.1.
|
|
8
9
|
--port <port> Listener port. Defaults to 8787.
|
|
9
10
|
--allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.
|
|
11
|
+
--auth-token-file <path> Require 'Authorization: Bearer <token>' on every request except
|
|
12
|
+
the health endpoints. Defaults to CATENCE_MCP_TOKEN_FILE.
|
|
10
13
|
-h, --help Show this help text.`;
|
|
14
|
+
/** Read and validate the bearer token from a file: trimmed, non-empty, or fail loudly. */
|
|
15
|
+
export function loadBearerToken(filePath) {
|
|
16
|
+
let token;
|
|
17
|
+
try {
|
|
18
|
+
token = readFileSync(filePath, 'utf8').trim();
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new Error(`Cannot read the auth token file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
22
|
+
}
|
|
23
|
+
if (!token)
|
|
24
|
+
throw new Error(`The auth token file ${filePath} is empty.`);
|
|
25
|
+
return token;
|
|
26
|
+
}
|
|
11
27
|
function requiredValue(arguments_, index, option) {
|
|
12
28
|
const value = arguments_[index + 1];
|
|
13
29
|
if (!value || value.startsWith('-'))
|
|
@@ -27,10 +43,11 @@ export function parseServeCliOptions(arguments_) {
|
|
|
27
43
|
let host = process.env.CATENCE_HTTP_HOST ?? '127.0.0.1';
|
|
28
44
|
let port = process.env.CATENCE_HTTP_PORT ? parsePort(process.env.CATENCE_HTTP_PORT) : 8787;
|
|
29
45
|
const allowedOrigins = [];
|
|
46
|
+
let authTokenFile = process.env.CATENCE_MCP_TOKEN_FILE || undefined;
|
|
30
47
|
for (let index = 0; index < arguments_.length; index++) {
|
|
31
48
|
const argument = arguments_[index];
|
|
32
49
|
if (argument === '-h' || argument === '--help')
|
|
33
|
-
return { home, host, port, allowedOrigins, help: true };
|
|
50
|
+
return { home, host, port, allowedOrigins, authTokenFile, help: true };
|
|
34
51
|
if (argument === '--home') {
|
|
35
52
|
home = requiredValue(arguments_, index, '--home');
|
|
36
53
|
index++;
|
|
@@ -42,6 +59,17 @@ export function parseServeCliOptions(arguments_) {
|
|
|
42
59
|
throw new Error('--home requires a value.');
|
|
43
60
|
continue;
|
|
44
61
|
}
|
|
62
|
+
if (argument === '--auth-token-file') {
|
|
63
|
+
authTokenFile = requiredValue(arguments_, index, '--auth-token-file');
|
|
64
|
+
index++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (argument.startsWith('--auth-token-file=')) {
|
|
68
|
+
authTokenFile = argument.slice('--auth-token-file='.length);
|
|
69
|
+
if (!authTokenFile)
|
|
70
|
+
throw new Error('--auth-token-file requires a value.');
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
45
73
|
if (argument === '--host') {
|
|
46
74
|
host = requiredValue(arguments_, index, '--host');
|
|
47
75
|
index++;
|
|
@@ -76,6 +104,6 @@ export function parseServeCliOptions(arguments_) {
|
|
|
76
104
|
}
|
|
77
105
|
throw new Error(`Unknown option: ${argument}`);
|
|
78
106
|
}
|
|
79
|
-
return { home, host, port, allowedOrigins, help: false };
|
|
107
|
+
return { home, host, port, allowedOrigins, authTokenFile, help: false };
|
|
80
108
|
}
|
|
81
109
|
//# sourceMappingURL=cli-options.js.map
|
|
@@ -6,6 +6,13 @@ 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
|
+
* When set, every request except the health endpoints and CORS preflights
|
|
11
|
+
* must present `Authorization: Bearer <token>`. The MCP server has no
|
|
12
|
+
* authentication of its own; this gates it (and the JSON APIs) for
|
|
13
|
+
* non-loopback exposure such as a Cloudflare Tunnel to an MCP client.
|
|
14
|
+
*/
|
|
15
|
+
bearerToken?: string;
|
|
9
16
|
/** Internal compatibility hook for tests: override the detached sync spawner. */
|
|
10
17
|
startSync?: typeof startDetachedSync;
|
|
11
18
|
/** Internal compatibility hook for tests: passed into the default startDetachedSync. */
|
|
@@ -1,3 +1,4 @@
|
|
|
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';
|
|
@@ -5,6 +6,17 @@ import { addAthlete, AthleteFileConflictError, AthleteFileTooLargeError, Athlete
|
|
|
5
6
|
import { createCatenceMcpServer } from '../mcp/server.js';
|
|
6
7
|
import { generationStatus } from './generation-status.js';
|
|
7
8
|
const MAX_REQUEST_BYTES = 1_000_000;
|
|
9
|
+
/** Endpoints that stay open when bearer authentication is enabled: they carry
|
|
10
|
+
* no personal data and must answer unauthenticated readiness probes. */
|
|
11
|
+
const UNAUTHENTICATED_PATHS = new Set(['/health', '/api/v1/health']);
|
|
12
|
+
function isAuthorized(request, bearerToken) {
|
|
13
|
+
if (!bearerToken)
|
|
14
|
+
return true;
|
|
15
|
+
const received = Buffer.from(request.headers.authorization ?? '');
|
|
16
|
+
const expected = Buffer.from(`Bearer ${bearerToken}`);
|
|
17
|
+
// timingSafeEqual throws on length mismatch, so compare sizes first.
|
|
18
|
+
return received.length === expected.length && timingSafeEqual(received, expected);
|
|
19
|
+
}
|
|
8
20
|
function json(response, statusCode, value) {
|
|
9
21
|
const body = JSON.stringify(jsonSafe(value));
|
|
10
22
|
response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
|
@@ -313,6 +325,12 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
313
325
|
response.writeHead(204).end();
|
|
314
326
|
return;
|
|
315
327
|
}
|
|
328
|
+
if (!UNAUTHENTICATED_PATHS.has(pathname) && !isAuthorized(request, options.bearerToken)) {
|
|
329
|
+
// No WWW-Authenticate on purpose: MCP connector auto-detection treats a
|
|
330
|
+
// plain 401 as a bearer gate rather than an OAuth server.
|
|
331
|
+
json(response, 401, { error: { code: 'unauthorized', message: 'A valid Authorization: Bearer token is required.' } });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
316
334
|
if (pathname === '/health' && request.method === 'GET') {
|
|
317
335
|
json(response, 200, catenceRuntimeHealth());
|
|
318
336
|
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 { loadBearerToken, 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,11 @@ async function run() {
|
|
|
46
46
|
return;
|
|
47
47
|
}
|
|
48
48
|
await migrateCatalogStores(options.home);
|
|
49
|
+
const bearerToken = options.authTokenFile ? loadBearerToken(options.authTokenFile) : undefined;
|
|
49
50
|
const httpServer = createCatenceHttpServer({
|
|
50
51
|
catalogPaths: resolveCatalogPaths(options.home),
|
|
51
52
|
allowedOrigins: options.allowedOrigins,
|
|
53
|
+
bearerToken,
|
|
52
54
|
});
|
|
53
55
|
await new Promise((resolve, reject) => {
|
|
54
56
|
httpServer.once('error', reject);
|
|
@@ -58,6 +60,8 @@ async function run() {
|
|
|
58
60
|
});
|
|
59
61
|
});
|
|
60
62
|
console.error(`Catence Streamable HTTP server is listening at http://${options.host}:${options.port}/mcp`);
|
|
63
|
+
if (bearerToken)
|
|
64
|
+
console.error('Bearer-token authentication is enabled; health endpoints stay open for readiness probes.');
|
|
61
65
|
const close = () => {
|
|
62
66
|
httpServer.close(() => process.exit(0));
|
|
63
67
|
};
|
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` | Default for `catence serve --auth-token-file`; when set, every endpoint except `/health` and `/api/v1/health` requires `Authorization: Bearer <token>` |
|
|
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,34 @@ 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, enable bearer-token
|
|
513
|
+
authentication instead of leaving the runtime unauthenticated. Set
|
|
514
|
+
`CATENCE_MCP_TOKEN_FILE` in `catence-deploy/.env` to a host file holding one
|
|
515
|
+
token (`openssl rand -hex 32`); the deploy script bind-mounts that file
|
|
516
|
+
read-only into the container at the same path:
|
|
517
|
+
|
|
518
|
+
```sh
|
|
519
|
+
# once, when the stack is already deployed:
|
|
520
|
+
printf %s "$(openssl rand -hex 32)" > /srv/catence-mcp-token # mode 0600, outside the data volume
|
|
521
|
+
echo "CATENCE_MCP_TOKEN_FILE=/srv/catence-mcp-token" >> catence-deploy/.env
|
|
522
|
+
./scripts/deploy-console.sh beta # re-renders compose with the token mount
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
With the token configured, every runtime endpoint except `/health` and
|
|
526
|
+
`/api/v1/health` answers `401` without an exact `Authorization: Bearer
|
|
527
|
+
<token>` header — including `/mcp` and the JSON APIs. The Console attaches
|
|
528
|
+
the header automatically to its own runtime calls, and refuses to start when
|
|
529
|
+
the configured file is unreadable or empty.
|
|
530
|
+
|
|
531
|
+
Keep `CATENCE_MCP_BIND=127.0.0.1` (the default): the tunnel client on the
|
|
532
|
+
host forwards to the host-loopback mapping, so the port still never binds a
|
|
533
|
+
LAN interface. Point the tunnel ingress for the MCP hostname at
|
|
534
|
+
`http://127.0.0.1:8787` and complete the client's credential flow with the
|
|
535
|
+
same token. Rotate by rewriting the file and restarting the stack.
|
|
536
|
+
|
|
509
537
|
## Exposing the Console safely
|
|
510
538
|
|
|
511
539
|
Keep the Console port bound to `127.0.0.1` (the default) and front it with a
|
|
@@ -302,6 +302,31 @@ 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), require a bearer token with `--auth-token-file <path>`
|
|
310
|
+
(environment equivalent: `CATENCE_MCP_TOKEN_FILE`):
|
|
311
|
+
|
|
312
|
+
```sh
|
|
313
|
+
catence serve --host 127.0.0.1 --port 8787 --auth-token-file /path/to/token
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
- The file holds one token (whitespace-trimmed); generate one with
|
|
317
|
+
`openssl rand -hex 32`. An unreadable or empty file aborts startup.
|
|
318
|
+
- Every endpoint except `/health` and `/api/v1/health` (kept open for readiness
|
|
319
|
+
probes) then answers `401` without an exact
|
|
320
|
+
`Authorization: Bearer <token>` header. The `401` deliberately carries no
|
|
321
|
+
`WWW-Authenticate`, so MCP clients with auth auto-detection (Mistral Work)
|
|
322
|
+
classify the server as bearer-auth rather than OAuth.
|
|
323
|
+
- CORS preflights (`OPTIONS`) pass through without a token.
|
|
324
|
+
- Rotation: rewrite the file and restart the server. Keep the token out of
|
|
325
|
+
`.env`, `config.json`, shell history, and repositories.
|
|
326
|
+
- The Console attaches the same header automatically to its runtime calls when
|
|
327
|
+
`CATENCE_MCP_TOKEN_FILE` is set, and refuses to start if the file is
|
|
328
|
+
unreadable.
|
|
329
|
+
|
|
305
330
|
- `GET /api/v1/athletes` returns IDs and labels only.
|
|
306
331
|
- `POST /api/v1/athletes` creates a catalog athlete from
|
|
307
332
|
`{"id": "sam", "label": "Sam", "setDefault": false}` and answers with the
|
package/package.json
CHANGED