catence 0.2.1-beta.20 → 0.2.1-beta.22

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.
@@ -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.20";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.22";
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.20';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.22';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { SYNC_STAGES } from '../../contracts/progress.js';
3
+ import { STALE_RUN_TIMEOUT_MS, SYNC_STAGES } from '../../contracts/progress.js';
4
4
  import { mergeProgress, readRunningProgress } from '../../elt/storage/progress-sidecar.js';
5
5
  import { sqlString } from './sql.js';
6
6
  import { DATASET_CATALOG, getDataset } from './catalog.js';
@@ -142,6 +142,11 @@ export class ReadOnlyRepository {
142
142
  async progress() {
143
143
  // Same sequential-read discipline as status(): the shared native
144
144
  // connection must not run these small reads concurrently.
145
+ // A run killed with its container keeps status='running' in the database;
146
+ // the heartbeat age filter mirrors the sidecar contract and db.syncProgress
147
+ // so such a run stops reading as active (and stops blocking new syncs)
148
+ // after the stale-run window.
149
+ const cutoff = new Date(Date.now() - STALE_RUN_TIMEOUT_MS).toISOString();
145
150
  const running = await this.rows(`
146
151
  SELECT runs.run_id, runs.provider, coalesce(progress.stage, 'starting') AS stage,
147
152
  progress.current_step,
@@ -154,8 +159,9 @@ export class ReadOnlyRepository {
154
159
  FROM sync_runs AS runs
155
160
  LEFT JOIN sync_run_progress AS progress USING (run_id)
156
161
  WHERE runs.status = 'running'
162
+ AND coalesce(progress.heartbeat_at, runs.started_at) > $cutoff
157
163
  ORDER BY runs.started_at DESC
158
- `);
164
+ `, { cutoff });
159
165
  const recent = await this.rows(`
160
166
  SELECT run_id, provider, stage, current_step, completed_units, total_units, percent,
161
167
  elapsed_seconds, estimated_remaining_seconds,
@@ -2,9 +2,10 @@ import { timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import path from 'node:path';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/sdk/types.js';
5
6
  import { addAthlete, addMcpKey, AthleteFileConflictError, AthleteFileTooLargeError, AthleteFileValidationError, catenceRuntimeHealth, countActiveMcpKeys, DashboardSnapshotService, deleteAthleteSecret, describeAthleteSecrets, DetachedSyncBusyError, DETACHED_SYNC_PROVIDERS, filterRosterToScope, jsonSafe, listAthleteFileRevisions, listMcpKeys, loadCatalog, McpKeyNotFoundError, mergeOpenCodeGoConsoleProfiles, openReadOnlyRepository, parseAutoSyncInterval, readAutoSyncSchedule, readAthleteFile, readAthleteFileRevision, readMcpOauthSettings, resolveAccountScope, resolveAthlete, resolveCatalogPaths, resolveKeyPrincipal, revokeMcpKey, rollMcpKey, scopeAllowsAthlete, setAthleteSecret, startDetachedSync, syncProgress, updateAthleteFile, updateAutoSyncSchedule, writeMcpOauthSettings, } from '../../runtime/index.js';
6
7
  import { activeBearerToken, ensureBearerToken, disableBearerToken, mcpExposureStatus } from './cli-options.js';
7
- import { createCatenceMcpServer } from '../mcp/server.js';
8
+ import { CATENCE_MCP_SERVER_INFO, createCatenceMcpServer, MCP_INSTRUCTIONS } from '../mcp/server.js';
8
9
  import { generationStatus } from './generation-status.js';
9
10
  const MAX_REQUEST_BYTES = 1_000_000;
10
11
  /** Endpoints that stay open when bearer authentication is enabled: they carry
@@ -21,6 +22,40 @@ function bearerTokenFrom(request) {
21
22
  return null;
22
23
  return header.slice('Bearer '.length);
23
24
  }
25
+ /**
26
+ * One-line diagnostics for the auth and MCP surfaces: gate outcomes and every
27
+ * /mcp or /oauth exchange are otherwise invisible in container logs. Routine
28
+ * dashboard polling stays quiet; set CATENCE_HTTP_ACCESS_LOG=0 to disable.
29
+ * Logs method, path, status, latency, user agent, and the MCP protocol hints —
30
+ * never headers carrying credentials.
31
+ */
32
+ function logRequestCompletion(request, response, pathname) {
33
+ if (process.env.CATENCE_HTTP_ACCESS_LOG === '0')
34
+ return;
35
+ const startedAt = Date.now();
36
+ response.once('finish', () => {
37
+ const status = response.statusCode;
38
+ const interesting = pathname === '/mcp' ||
39
+ pathname.startsWith('/oauth/') ||
40
+ pathname.startsWith('/.well-known/oauth-') ||
41
+ status >= 400;
42
+ if (!interesting)
43
+ return;
44
+ const parts = [`[http] ${new Date().toISOString()}`, `${request.method} ${pathname}`, String(status), `${Date.now() - startedAt}ms`];
45
+ const userAgent = request.headers['user-agent'];
46
+ if (typeof userAgent === 'string' && userAgent)
47
+ parts.push(`ua="${userAgent.slice(0, 120)}"`);
48
+ const protocolVersion = request.headers['mcp-protocol-version'];
49
+ if (typeof protocolVersion === 'string' && protocolVersion)
50
+ parts.push(`mcp-protocol-version=${protocolVersion}`);
51
+ const mcpMethod = request.headers['mcp-method'];
52
+ if (typeof mcpMethod === 'string' && mcpMethod)
53
+ parts.push(`mcp-method=${mcpMethod}`);
54
+ if (pathname === '/mcp')
55
+ parts.push(`auth=${bearerTokenFrom(request) !== null ? 'bearer' : 'none'}`);
56
+ console.log(parts.join(' '));
57
+ });
58
+ }
24
59
  function matchesLegacyToken(rawToken, expected) {
25
60
  const received = Buffer.from(rawToken);
26
61
  const expectedBuffer = Buffer.from(expected);
@@ -414,11 +449,96 @@ async function lastSyncSummary(paths) {
414
449
  return null;
415
450
  }
416
451
  }
452
+ /**
453
+ * The MCP revision ChatGPT's hosted connector speaks during setup. The bundled
454
+ * SDK predates it, but the request shapes the revision uses — server/discover,
455
+ * then stateless tools/list and tools/call without initialize — are already
456
+ * handled. Only the protocol header value is rejected, so it is bridged down
457
+ * and server/discover is answered from the same descriptor initialize reports.
458
+ */
459
+ const MODERN_MCP_PROTOCOL_VERSION = '2026-07-28';
460
+ /** A single JSON-RPC request (batches are left to the transport). */
461
+ function singleJsonRpcRequest(body) {
462
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
463
+ return null;
464
+ const method = body.method;
465
+ return typeof method === 'string' ? body : null;
466
+ }
467
+ /**
468
+ * Server capabilities exactly as the SDK's initialize response reports them.
469
+ * getCapabilities() is the runtime accessor that builds that response; the
470
+ * typings mark it private.
471
+ */
472
+ function mcpServerCapabilities(server) {
473
+ return server.server.getCapabilities();
474
+ }
475
+ /**
476
+ * Answer server/discover from a throwaway scoped server so the descriptor
477
+ * (capabilities, server identity, instructions) can never drift from what the
478
+ * initialize response reports. See MODERN_MCP_PROTOCOL_VERSION.
479
+ */
480
+ async function writeDiscoveryResult(response, paths, scope, id) {
481
+ const server = createCatenceMcpServer(paths, {}, { scope });
482
+ try {
483
+ json(response, 200, {
484
+ jsonrpc: '2.0',
485
+ id: id ?? null,
486
+ result: {
487
+ resultType: 'complete',
488
+ supportedVersions: [MODERN_MCP_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS],
489
+ capabilities: mcpServerCapabilities(server),
490
+ _meta: { 'io.modelcontextprotocol/serverInfo': CATENCE_MCP_SERVER_INFO },
491
+ instructions: MCP_INSTRUCTIONS,
492
+ ttlMs: 3_600_000,
493
+ cacheScope: 'public',
494
+ },
495
+ });
496
+ }
497
+ finally {
498
+ await server.close();
499
+ }
500
+ }
501
+ /**
502
+ * Hosted connectors may probe server/discover before attaching the bearer
503
+ * token; the result exposes nothing athlete-scoped, so it is answered ahead
504
+ * of the 401. Every other unauthenticated /mcp request stays behind the gate.
505
+ * Returns true when the response was written.
506
+ */
507
+ async function handleUnauthenticatedDiscovery(request, response, paths) {
508
+ let body;
509
+ try {
510
+ body = await readJsonBody(request);
511
+ }
512
+ catch {
513
+ return false;
514
+ }
515
+ const jsonRpc = singleJsonRpcRequest(body);
516
+ if (jsonRpc?.method !== 'server/discover')
517
+ return false;
518
+ await writeDiscoveryResult(response, paths, { athleteIds: 'all' }, jsonRpc.id);
519
+ return true;
520
+ }
417
521
  async function handleMcpRequest(request, response, paths, principal) {
418
522
  if (request.method !== 'POST') {
419
523
  methodNotAllowed(response);
420
524
  return;
421
525
  }
526
+ // ChatGPT's discovery probe and the requests that follow it carry a dated
527
+ // 2026 MCP revision (2026-07-28 as of today, later revisions as they ship);
528
+ // the SDK transport understands only its own older list, and the surface
529
+ // here is stateless either way, so any dated 2026 revision is bridged down
530
+ // while still-unknown values keep failing closed. The Node adapter rebuilds
531
+ // the web-standard headers from rawHeaders (not the parsed headers object),
532
+ // so both views are rewritten.
533
+ const requestedVersion = request.headers['mcp-protocol-version'];
534
+ if (typeof requestedVersion === 'string' && /^2026-\d{2}-\d{2}$/.test(requestedVersion)) {
535
+ request.headers['mcp-protocol-version'] = LATEST_PROTOCOL_VERSION;
536
+ const rawHeaders = request.rawHeaders;
537
+ for (let index = 0; index < rawHeaders.length; index += 2) {
538
+ if (rawHeaders[index]?.toLowerCase() === 'mcp-protocol-version')
539
+ rawHeaders[index + 1] = LATEST_PROTOCOL_VERSION;
540
+ }
541
+ }
422
542
  let body;
423
543
  try {
424
544
  body = await readJsonBody(request);
@@ -431,6 +551,13 @@ async function handleMcpRequest(request, response, paths, principal) {
431
551
  });
432
552
  return;
433
553
  }
554
+ const jsonRpc = singleJsonRpcRequest(body);
555
+ if (jsonRpc?.method === 'server/discover') {
556
+ // Modern clients probe discovery before anything else; answer from the
557
+ // same descriptor the initialize response reports so the two never drift.
558
+ await writeDiscoveryResult(response, paths, { athleteIds: principal.athleteIds }, jsonRpc.id);
559
+ return;
560
+ }
434
561
  // The HTTP gate has already authenticated this request; the MCP server
435
562
  // re-enforces the principal's athlete scope on every tool and roster.
436
563
  const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
@@ -538,6 +665,7 @@ export function createCatenceHttpServer(options = {}) {
538
665
  return createServer((request, response) => {
539
666
  void (async () => {
540
667
  const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
668
+ logRequestCompletion(request, response, pathname);
541
669
  // The OAuth endpoints are the auth surface itself: they bypass the gate
542
670
  // and CORS entirely and are delegated to the Express sub-app. When OAuth
543
671
  // is effectively disabled they answer 404 — no metadata is advertised.
@@ -565,6 +693,13 @@ export function createCatenceHttpServer(options = {}) {
565
693
  ? FULL_ACCESS_PRINCIPAL
566
694
  : await resolvePrincipal(request, effectiveOptions, accountPaths);
567
695
  if (principal === null) {
696
+ // A hosted connector may probe server/discover before attaching the
697
+ // bearer token. The discovery result exposes nothing athlete-scoped,
698
+ // so that one probe is answered ahead of the 401; every other
699
+ // unauthenticated /mcp request stays behind the gate.
700
+ if (pathname === '/mcp' && request.method === 'POST' && await handleUnauthenticatedDiscovery(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths())) {
701
+ return;
702
+ }
568
703
  // A presented-but-rejected token is a bearer problem, not an OAuth
569
704
  // discovery problem: answer RFC 6750 invalid_token without
570
705
  // resource_metadata, so static-key clients get a clear "your token is
@@ -5,6 +5,15 @@ type McpDependencies = {
5
5
  hydrateStravaActivity?: StravaActivityHydrator;
6
6
  startDetachedSync?: typeof startDetachedSync;
7
7
  };
8
+ export declare const MCP_INSTRUCTIONS: string;
9
+ /**
10
+ * Server identity shared by the MCP initialize response (stdio and HTTP) and
11
+ * the HTTP server/discover probe; clients compare the two for consistency.
12
+ */
13
+ export declare const CATENCE_MCP_SERVER_INFO: {
14
+ name: string;
15
+ version: string;
16
+ };
8
17
  export type CatenceMcpServerOptions = {
9
18
  /**
10
19
  * Athlete scope enforced on every tool that selects an athlete, and applied
@@ -22,7 +22,7 @@ const seriesInput = {
22
22
  function isCatalogPaths(value) {
23
23
  return 'catalog' in value;
24
24
  }
25
- const MCP_INSTRUCTIONS = [
25
+ export const MCP_INSTRUCTIONS = [
26
26
  'For a selected activity\'s Strava segments, climb segments, grade by segment, KOM/PR, or per-segment analysis, call get_activity_segments after identifying the activity.',
27
27
  'That tool performs the targeted Strava hydration itself. Base segment answers only on what it returns.',
28
28
  'For Garmin running VO₂max, call get_vo2max_history with sport: running. Garmin stores the source value as generic; returned rows preserve that source label.',
@@ -39,6 +39,11 @@ const MCP_INSTRUCTIONS = [
39
39
  'For running dynamics or running form, use the run_dynamics dataset (vertical_oscillation_cm, ground_contact_time_ms, flight_time_ms, stride_length_cm, vertical_ratio_pct, cadence_spm); values are provider-normalized and a null means the provider did not report that metric.',
40
40
  'For durable athlete facts shared between the athlete and the agent, use the athlete file: get_athlete_file before personalizing advice, and update_athlete_file for lasting goals, constraints, preferences, or profile facts. Always pass the hash you read as expectedHash (null when the file does not exist yet); a changed file is rejected so it is never silently overwritten. Never store credentials or provider configuration in it.',
41
41
  ].join(' ');
42
+ /**
43
+ * Server identity shared by the MCP initialize response (stdio and HTTP) and
44
+ * the HTTP server/discover probe; clients compare the two for consistency.
45
+ */
46
+ export const CATENCE_MCP_SERVER_INFO = { name: 'catence', version: '0.1.0' };
42
47
  function textResult(value) {
43
48
  return { content: [{ type: 'text', text: JSON.stringify(jsonSafe(value), null, 2) }] };
44
49
  }
@@ -138,7 +143,7 @@ async function activitySegments(repository, activityId, hydratedStravaActivityId
138
143
  `, values);
139
144
  }
140
145
  export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependencies = {}, options = {}) {
141
- const server = new McpServer({ name: 'catence', version: '0.1.0' }, { instructions: MCP_INSTRUCTIONS });
146
+ const server = new McpServer({ ...CATENCE_MCP_SERVER_INFO }, { instructions: MCP_INSTRUCTIONS });
142
147
  const hydrateActivity = dependencies.hydrateStravaActivity ?? hydrateStravaActivity;
143
148
  const startSync = dependencies.startDetachedSync ?? startDetachedSync;
144
149
  const catalogPaths = isCatalogPaths(paths) ? paths : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.20",
3
+ "version": "0.2.1-beta.22",
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": {