catence 0.2.1-beta.20 → 0.2.1-beta.21

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.21";
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.21';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -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
@@ -414,11 +415,46 @@ async function lastSyncSummary(paths) {
414
415
  return null;
415
416
  }
416
417
  }
418
+ /**
419
+ * The MCP revision ChatGPT's hosted connector speaks during setup. The bundled
420
+ * SDK predates it, but the request shapes the revision uses — server/discover,
421
+ * then stateless tools/list and tools/call without initialize — are already
422
+ * handled. Only the protocol header value is rejected, so it is bridged down
423
+ * and server/discover is answered from the same descriptor initialize reports.
424
+ */
425
+ const MODERN_MCP_PROTOCOL_VERSION = '2026-07-28';
426
+ /** A single JSON-RPC request (batches are left to the transport). */
427
+ function singleJsonRpcRequest(body) {
428
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
429
+ return null;
430
+ const method = body.method;
431
+ return typeof method === 'string' ? body : null;
432
+ }
433
+ /**
434
+ * Server capabilities exactly as the SDK's initialize response reports them.
435
+ * getCapabilities() is the runtime accessor that builds that response; the
436
+ * typings mark it private.
437
+ */
438
+ function mcpServerCapabilities(server) {
439
+ return server.server.getCapabilities();
440
+ }
417
441
  async function handleMcpRequest(request, response, paths, principal) {
418
442
  if (request.method !== 'POST') {
419
443
  methodNotAllowed(response);
420
444
  return;
421
445
  }
446
+ // ChatGPT's discovery probe and the requests that follow it carry the
447
+ // 2026-07-28 revision; the transport understands only the older list. The
448
+ // Node adapter rebuilds the web-standard headers from rawHeaders (not the
449
+ // parsed headers object), so both views are rewritten.
450
+ if (request.headers['mcp-protocol-version'] === MODERN_MCP_PROTOCOL_VERSION) {
451
+ request.headers['mcp-protocol-version'] = LATEST_PROTOCOL_VERSION;
452
+ const rawHeaders = request.rawHeaders;
453
+ for (let index = 0; index < rawHeaders.length; index += 2) {
454
+ if (rawHeaders[index]?.toLowerCase() === 'mcp-protocol-version')
455
+ rawHeaders[index + 1] = LATEST_PROTOCOL_VERSION;
456
+ }
457
+ }
422
458
  let body;
423
459
  try {
424
460
  body = await readJsonBody(request);
@@ -431,6 +467,31 @@ async function handleMcpRequest(request, response, paths, principal) {
431
467
  });
432
468
  return;
433
469
  }
470
+ const jsonRpc = singleJsonRpcRequest(body);
471
+ if (jsonRpc?.method === 'server/discover') {
472
+ // Modern clients probe discovery before anything else; answer from the
473
+ // same descriptor the initialize response reports so the two never drift.
474
+ const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
475
+ try {
476
+ json(response, 200, {
477
+ jsonrpc: '2.0',
478
+ id: jsonRpc.id ?? null,
479
+ result: {
480
+ resultType: 'complete',
481
+ supportedVersions: [MODERN_MCP_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS],
482
+ capabilities: mcpServerCapabilities(server),
483
+ _meta: { 'io.modelcontextprotocol/serverInfo': CATENCE_MCP_SERVER_INFO },
484
+ instructions: MCP_INSTRUCTIONS,
485
+ ttlMs: 3_600_000,
486
+ cacheScope: 'public',
487
+ },
488
+ });
489
+ }
490
+ finally {
491
+ await server.close();
492
+ }
493
+ return;
494
+ }
434
495
  // The HTTP gate has already authenticated this request; the MCP server
435
496
  // re-enforces the principal's athlete scope on every tool and roster.
436
497
  const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
@@ -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.21",
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": {