catence 0.2.1-beta.22 → 0.2.1-beta.24

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.22";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.24";
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.22';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.24';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -36,6 +36,7 @@ function logRequestCompletion(request, response, pathname) {
36
36
  response.once('finish', () => {
37
37
  const status = response.statusCode;
38
38
  const interesting = pathname === '/mcp' ||
39
+ pathname === '/' ||
39
40
  pathname.startsWith('/oauth/') ||
40
41
  pathname.startsWith('/.well-known/oauth-') ||
41
42
  status >= 400;
@@ -51,7 +52,7 @@ function logRequestCompletion(request, response, pathname) {
51
52
  const mcpMethod = request.headers['mcp-method'];
52
53
  if (typeof mcpMethod === 'string' && mcpMethod)
53
54
  parts.push(`mcp-method=${mcpMethod}`);
54
- if (pathname === '/mcp')
55
+ if (pathname === '/mcp' || pathname === '/')
55
56
  parts.push(`auth=${bearerTokenFrom(request) !== null ? 'bearer' : 'none'}`);
56
57
  console.log(parts.join(' '));
57
58
  });
@@ -518,6 +519,141 @@ async function handleUnauthenticatedDiscovery(request, response, paths) {
518
519
  await writeDiscoveryResult(response, paths, { athleteIds: 'all' }, jsonRpc.id);
519
520
  return true;
520
521
  }
522
+ /**
523
+ * The 2026-07-28 revision frames every completed result with `resultType` and
524
+ * requires caching hints on cacheable operations (server/discover, tools/list,
525
+ * prompts/list, resources/list, resources/templates/list, resources/read).
526
+ * The bundled SDK emits the legacy shape, so responses negotiated under a
527
+ * dated 2026 revision are rewritten on the way out. List results vary with the
528
+ * caller's scope, so cacheable responses from this server are always
529
+ * `private`. Errors and notifications carry no result and pass through.
530
+ */
531
+ const MODERN_CACHEABLE_METHODS = new Set(['server/discover', 'tools/list', 'prompts/list', 'resources/list', 'resources/templates/list', 'resources/read']);
532
+ const MODERN_RESULT_TTL_MS = 300_000;
533
+ /** Rewrite one JSON-RPC payload; null when it carries nothing to change. */
534
+ function frameModernRpcPayload(payload, method) {
535
+ let envelope;
536
+ try {
537
+ envelope = JSON.parse(payload);
538
+ }
539
+ catch {
540
+ return null;
541
+ }
542
+ if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope))
543
+ return null;
544
+ const record = envelope;
545
+ const result = record.result;
546
+ if (typeof result !== 'object' || result === null || Array.isArray(result))
547
+ return null;
548
+ const framed = { ...result };
549
+ let changed = false;
550
+ if (!('resultType' in framed)) {
551
+ framed.resultType = 'complete';
552
+ changed = true;
553
+ }
554
+ if (method !== undefined && MODERN_CACHEABLE_METHODS.has(method)) {
555
+ if (!('ttlMs' in framed)) {
556
+ framed.ttlMs = MODERN_RESULT_TTL_MS;
557
+ changed = true;
558
+ }
559
+ if (!('cacheScope' in framed)) {
560
+ framed.cacheScope = 'private';
561
+ changed = true;
562
+ }
563
+ }
564
+ return changed ? JSON.stringify({ ...record, result: framed }) : null;
565
+ }
566
+ /** Rewrite a whole response body (SSE event stream or plain JSON). */
567
+ function frameModernResponseBody(body, contentType, method) {
568
+ // The adapter hands headers straight to writeHead without caching them, so
569
+ // the content type may be unreadable here; the body shape is a reliable
570
+ // fallback (SSE payloads carry `data:` lines, JSON payloads open with `{`).
571
+ const isEventStream = (contentType !== undefined && contentType.includes('text/event-stream')) ||
572
+ body.startsWith('event:') ||
573
+ body.startsWith('data:') ||
574
+ body.includes('\ndata:');
575
+ if (isEventStream) {
576
+ let changed = false;
577
+ const lines = body.split('\n').map((line) => {
578
+ if (!line.startsWith('data:'))
579
+ return line;
580
+ const framed = frameModernRpcPayload(line.slice('data:'.length).trim(), method);
581
+ if (framed === null)
582
+ return line;
583
+ changed = true;
584
+ return `data: ${framed}`;
585
+ });
586
+ return changed ? lines.join('\n') : null;
587
+ }
588
+ if ((contentType !== undefined && contentType.includes('application/json')) || body.trimStart().startsWith('{')) {
589
+ return frameModernRpcPayload(body, method);
590
+ }
591
+ return null;
592
+ }
593
+ /**
594
+ * Buffer the outgoing body so the modern framing is in place before any byte
595
+ * reaches the socket; bodies that need no rewriting are forwarded verbatim.
596
+ * The Node adapter may flush headers early for streamed (chunked) responses,
597
+ * which stays valid because rewritten bodies only ever drop `content-length`
598
+ * (SSE responses never carry one to begin with).
599
+ */
600
+ function installModernFraming(response, method) {
601
+ const chunks = [];
602
+ const append = (chunk, encoding) => {
603
+ if (typeof chunk === 'string')
604
+ chunks.push(Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8'));
605
+ else if (Buffer.isBuffer(chunk))
606
+ chunks.push(chunk);
607
+ else if (chunk instanceof Uint8Array)
608
+ chunks.push(Buffer.from(chunk));
609
+ else if (chunk !== undefined && chunk !== null)
610
+ chunks.push(Buffer.from(String(chunk)));
611
+ };
612
+ const originalEnd = response.end.bind(response);
613
+ const originalWriteHead = response.writeHead.bind(response);
614
+ // Headers handed straight to writeHead are not cached for getHeader and may
615
+ // reach the socket immediately, so a computed content-length is dropped
616
+ // here before any byte leaves the process: the framed body differs in size
617
+ // and chunked framing is always valid for these responses. (`removeHeader`
618
+ // is not an option at end time — by then the headers are already sent.)
619
+ const withoutLength = (value) => {
620
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
621
+ return value;
622
+ const copy = {};
623
+ for (const [key, headerValue] of Object.entries(value)) {
624
+ if (key.toLowerCase() !== 'content-length')
625
+ copy[key] = headerValue;
626
+ }
627
+ return copy;
628
+ };
629
+ response.writeHead = ((statusCode, arg1, arg2) => {
630
+ if (typeof arg1 === 'string')
631
+ return originalWriteHead(statusCode, arg1, withoutLength(arg2));
632
+ return originalWriteHead(statusCode, withoutLength(arg1));
633
+ });
634
+ response.write = ((chunk, encodingOrCallback) => {
635
+ append(chunk, encodingOrCallback);
636
+ if (typeof encodingOrCallback === 'function')
637
+ queueMicrotask(encodingOrCallback);
638
+ return true;
639
+ });
640
+ response.end = ((chunk, encodingOrCallback, callback) => {
641
+ if (response.writableEnded)
642
+ return response;
643
+ append(chunk, encodingOrCallback);
644
+ const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : typeof callback === 'function' ? callback : undefined;
645
+ const raw = Buffer.concat(chunks);
646
+ const header = response.getHeader('content-type');
647
+ const contentType = Array.isArray(header) ? header.join(', ') : header === undefined ? undefined : String(header);
648
+ const framed = frameModernResponseBody(raw.toString('utf8'), contentType, method);
649
+ const final = framed === null ? raw : Buffer.from(framed, 'utf8');
650
+ if (done)
651
+ originalEnd(final, done);
652
+ else
653
+ originalEnd(final);
654
+ return response;
655
+ });
656
+ }
521
657
  async function handleMcpRequest(request, response, paths, principal) {
522
658
  if (request.method !== 'POST') {
523
659
  methodNotAllowed(response);
@@ -531,7 +667,8 @@ async function handleMcpRequest(request, response, paths, principal) {
531
667
  // the web-standard headers from rawHeaders (not the parsed headers object),
532
668
  // so both views are rewritten.
533
669
  const requestedVersion = request.headers['mcp-protocol-version'];
534
- if (typeof requestedVersion === 'string' && /^2026-\d{2}-\d{2}$/.test(requestedVersion)) {
670
+ const modernRequest = typeof requestedVersion === 'string' && /^2026-\d{2}-\d{2}$/.test(requestedVersion);
671
+ if (modernRequest) {
535
672
  request.headers['mcp-protocol-version'] = LATEST_PROTOCOL_VERSION;
536
673
  const rawHeaders = request.rawHeaders;
537
674
  for (let index = 0; index < rawHeaders.length; index += 2) {
@@ -558,6 +695,10 @@ async function handleMcpRequest(request, response, paths, principal) {
558
695
  await writeDiscoveryResult(response, paths, { athleteIds: principal.athleteIds }, jsonRpc.id);
559
696
  return;
560
697
  }
698
+ // A dated 2026 revision expects the SDK's legacy result shape reframed with
699
+ // `resultType`/caching fields before it leaves the process.
700
+ if (modernRequest)
701
+ installModernFraming(response, jsonRpc?.method);
561
702
  // The HTTP gate has already authenticated this request; the MCP server
562
703
  // re-enforces the principal's athlete scope on every tool and roster.
563
704
  const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
@@ -696,8 +837,9 @@ export function createCatenceHttpServer(options = {}) {
696
837
  // A hosted connector may probe server/discover before attaching the
697
838
  // bearer token. The discovery result exposes nothing athlete-scoped,
698
839
  // 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())) {
840
+ // unauthenticated MCP request stays behind the gate. The root alias
841
+ // mirrors /mcp here too so bare-domain connectors see the same gate.
842
+ if ((pathname === '/mcp' || pathname === '/') && request.method === 'POST' && await handleUnauthenticatedDiscovery(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths())) {
701
843
  return;
702
844
  }
703
845
  // A presented-but-rejected token is a bearer problem, not an OAuth
@@ -1233,7 +1375,11 @@ export function createCatenceHttpServer(options = {}) {
1233
1375
  }
1234
1376
  return;
1235
1377
  }
1236
- if (pathname === '/mcp') {
1378
+ // Hosted connectors probe the URL exactly as the operator typed it, and
1379
+ // a bare-domain connector has no /mcp path to append. Root requests are
1380
+ // served exactly like /mcp; the protected-resource metadata still
1381
+ // advertises /mcp, so issued tokens keep their /mcp audience.
1382
+ if (pathname === '/mcp' || pathname === '/') {
1237
1383
  await handleMcpRequest(request, response, catalogPaths ?? staticPaths ?? resolveCatalogPaths(), principal);
1238
1384
  return;
1239
1385
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.22",
3
+ "version": "0.2.1-beta.24",
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": {