catence 0.2.1-beta.23 → 0.2.1-beta.25

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.23";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.25";
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.23';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.25';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -109,6 +109,11 @@ export async function createOAuthApp(options) {
109
109
  app.get('/.well-known/oauth-authorization-server', serveMetadata(oauthMetadata));
110
110
  app.get('/.well-known/oauth-protected-resource', serveMetadata(protectedResourceMetadata));
111
111
  app.get('/.well-known/oauth-protected-resource/mcp', serveMetadata(protectedResourceMetadata));
112
+ // The connector platform probes OIDC-style discovery on every validation
113
+ // pass (observed hitting the auth gate as a 401 right after fetching the
114
+ // RFC 8414 document); answer it publicly with the same metadata.
115
+ app.get('/.well-known/openid-configuration', serveMetadata(oauthMetadata));
116
+ app.get('/.well-known/openid-configuration/mcp', serveMetadata(oauthMetadata));
112
117
  // DCR runs through the library's router, whose own express.json() would
113
118
  // re-parse (and reject) an already-consumed stream; parsing here first
114
119
  // leaves the sanitized body in place for it.
@@ -33,6 +33,9 @@ function logRequestCompletion(request, response, pathname) {
33
33
  if (process.env.CATENCE_HTTP_ACCESS_LOG === '0')
34
34
  return;
35
35
  const startedAt = Date.now();
36
+ // Captured before the compatibility bridge rewrites the header so the log
37
+ // always shows the value the client actually sent.
38
+ const rawProtocolVersion = request.headers['mcp-protocol-version'];
36
39
  response.once('finish', () => {
37
40
  const status = response.statusCode;
38
41
  const interesting = pathname === '/mcp' ||
@@ -46,7 +49,7 @@ function logRequestCompletion(request, response, pathname) {
46
49
  const userAgent = request.headers['user-agent'];
47
50
  if (typeof userAgent === 'string' && userAgent)
48
51
  parts.push(`ua="${userAgent.slice(0, 120)}"`);
49
- const protocolVersion = request.headers['mcp-protocol-version'];
52
+ const protocolVersion = rawProtocolVersion;
50
53
  if (typeof protocolVersion === 'string' && protocolVersion)
51
54
  parts.push(`mcp-protocol-version=${protocolVersion}`);
52
55
  const mcpMethod = request.headers['mcp-method'];
@@ -519,6 +522,164 @@ async function handleUnauthenticatedDiscovery(request, response, paths) {
519
522
  await writeDiscoveryResult(response, paths, { athleteIds: 'all' }, jsonRpc.id);
520
523
  return true;
521
524
  }
525
+ /**
526
+ * The 2026-07-28 revision frames every completed result with `resultType` and
527
+ * requires caching hints on cacheable operations (server/discover, tools/list,
528
+ * prompts/list, resources/list, resources/templates/list, resources/read).
529
+ * The bundled SDK emits the legacy shape, so responses negotiated under a
530
+ * dated 2026 revision are rewritten on the way out. List results vary with the
531
+ * caller's scope, so cacheable responses from this server are always
532
+ * `private`. Every result also repeats the server identity in `_meta`, and
533
+ * tool definitions drop the SDK's `execution` field — neither the per-result
534
+ * `_meta.serverInfo` nor `execution` appeared in the revision's schemas for
535
+ * earlier SDKs, but the revision asks for the former and omits the latter.
536
+ * Errors and notifications carry no result and pass through.
537
+ */
538
+ const MODERN_CACHEABLE_METHODS = new Set(['server/discover', 'tools/list', 'prompts/list', 'resources/list', 'resources/templates/list', 'resources/read']);
539
+ const MODERN_RESULT_TTL_MS = 300_000;
540
+ /** Rewrite one JSON-RPC payload; null when it carries nothing to change. */
541
+ function frameModernRpcPayload(payload, method) {
542
+ let envelope;
543
+ try {
544
+ envelope = JSON.parse(payload);
545
+ }
546
+ catch {
547
+ return null;
548
+ }
549
+ if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope))
550
+ return null;
551
+ const record = envelope;
552
+ const result = record.result;
553
+ if (typeof result !== 'object' || result === null || Array.isArray(result))
554
+ return null;
555
+ const framed = { ...result };
556
+ let changed = false;
557
+ if (!('resultType' in framed)) {
558
+ framed.resultType = 'complete';
559
+ changed = true;
560
+ }
561
+ if (!('_meta' in framed)) {
562
+ framed._meta = { 'io.modelcontextprotocol/serverInfo': CATENCE_MCP_SERVER_INFO };
563
+ changed = true;
564
+ }
565
+ if (method === 'tools/list' && Array.isArray(framed.tools)) {
566
+ let stripped = false;
567
+ const tools = framed.tools.map((tool) => {
568
+ if (typeof tool !== 'object' || tool === null || Array.isArray(tool) || !('execution' in tool))
569
+ return tool;
570
+ const copy = { ...tool };
571
+ delete copy.execution;
572
+ stripped = true;
573
+ return copy;
574
+ });
575
+ if (stripped) {
576
+ framed.tools = tools;
577
+ changed = true;
578
+ }
579
+ }
580
+ if (method !== undefined && MODERN_CACHEABLE_METHODS.has(method)) {
581
+ if (!('ttlMs' in framed)) {
582
+ framed.ttlMs = MODERN_RESULT_TTL_MS;
583
+ changed = true;
584
+ }
585
+ if (!('cacheScope' in framed)) {
586
+ framed.cacheScope = 'private';
587
+ changed = true;
588
+ }
589
+ }
590
+ return changed ? JSON.stringify({ ...record, result: framed }) : null;
591
+ }
592
+ /** Rewrite a whole response body (SSE event stream or plain JSON). */
593
+ function frameModernResponseBody(body, contentType, method) {
594
+ // The adapter hands headers straight to writeHead without caching them, so
595
+ // the content type may be unreadable here; the body shape is a reliable
596
+ // fallback (SSE payloads carry `data:` lines, JSON payloads open with `{`).
597
+ const isEventStream = (contentType !== undefined && contentType.includes('text/event-stream')) ||
598
+ body.startsWith('event:') ||
599
+ body.startsWith('data:') ||
600
+ body.includes('\ndata:');
601
+ if (isEventStream) {
602
+ let changed = false;
603
+ const lines = body.split('\n').map((line) => {
604
+ if (!line.startsWith('data:'))
605
+ return line;
606
+ const framed = frameModernRpcPayload(line.slice('data:'.length).trim(), method);
607
+ if (framed === null)
608
+ return line;
609
+ changed = true;
610
+ return `data: ${framed}`;
611
+ });
612
+ return changed ? lines.join('\n') : null;
613
+ }
614
+ if ((contentType !== undefined && contentType.includes('application/json')) || body.trimStart().startsWith('{')) {
615
+ return frameModernRpcPayload(body, method);
616
+ }
617
+ return null;
618
+ }
619
+ /**
620
+ * Buffer the outgoing body so the modern framing is in place before any byte
621
+ * reaches the socket; bodies that need no rewriting are forwarded verbatim.
622
+ * The Node adapter may flush headers early for streamed (chunked) responses,
623
+ * which stays valid because rewritten bodies only ever drop `content-length`
624
+ * (SSE responses never carry one to begin with).
625
+ */
626
+ function installModernFraming(response, method) {
627
+ const chunks = [];
628
+ const append = (chunk, encoding) => {
629
+ if (typeof chunk === 'string')
630
+ chunks.push(Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8'));
631
+ else if (Buffer.isBuffer(chunk))
632
+ chunks.push(chunk);
633
+ else if (chunk instanceof Uint8Array)
634
+ chunks.push(Buffer.from(chunk));
635
+ else if (chunk !== undefined && chunk !== null)
636
+ chunks.push(Buffer.from(String(chunk)));
637
+ };
638
+ const originalEnd = response.end.bind(response);
639
+ const originalWriteHead = response.writeHead.bind(response);
640
+ // Headers handed straight to writeHead are not cached for getHeader and may
641
+ // reach the socket immediately, so a computed content-length is dropped
642
+ // here before any byte leaves the process: the framed body differs in size
643
+ // and chunked framing is always valid for these responses. (`removeHeader`
644
+ // is not an option at end time — by then the headers are already sent.)
645
+ const withoutLength = (value) => {
646
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
647
+ return value;
648
+ const copy = {};
649
+ for (const [key, headerValue] of Object.entries(value)) {
650
+ if (key.toLowerCase() !== 'content-length')
651
+ copy[key] = headerValue;
652
+ }
653
+ return copy;
654
+ };
655
+ response.writeHead = ((statusCode, arg1, arg2) => {
656
+ if (typeof arg1 === 'string')
657
+ return originalWriteHead(statusCode, arg1, withoutLength(arg2));
658
+ return originalWriteHead(statusCode, withoutLength(arg1));
659
+ });
660
+ response.write = ((chunk, encodingOrCallback) => {
661
+ append(chunk, encodingOrCallback);
662
+ if (typeof encodingOrCallback === 'function')
663
+ queueMicrotask(encodingOrCallback);
664
+ return true;
665
+ });
666
+ response.end = ((chunk, encodingOrCallback, callback) => {
667
+ if (response.writableEnded)
668
+ return response;
669
+ append(chunk, encodingOrCallback);
670
+ const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : typeof callback === 'function' ? callback : undefined;
671
+ const raw = Buffer.concat(chunks);
672
+ const header = response.getHeader('content-type');
673
+ const contentType = Array.isArray(header) ? header.join(', ') : header === undefined ? undefined : String(header);
674
+ const framed = frameModernResponseBody(raw.toString('utf8'), contentType, method);
675
+ const final = framed === null ? raw : Buffer.from(framed, 'utf8');
676
+ if (done)
677
+ originalEnd(final, done);
678
+ else
679
+ originalEnd(final);
680
+ return response;
681
+ });
682
+ }
522
683
  async function handleMcpRequest(request, response, paths, principal) {
523
684
  if (request.method !== 'POST') {
524
685
  methodNotAllowed(response);
@@ -532,7 +693,8 @@ async function handleMcpRequest(request, response, paths, principal) {
532
693
  // the web-standard headers from rawHeaders (not the parsed headers object),
533
694
  // so both views are rewritten.
534
695
  const requestedVersion = request.headers['mcp-protocol-version'];
535
- if (typeof requestedVersion === 'string' && /^2026-\d{2}-\d{2}$/.test(requestedVersion)) {
696
+ const modernRequest = typeof requestedVersion === 'string' && /^2026-\d{2}-\d{2}$/.test(requestedVersion);
697
+ if (modernRequest) {
536
698
  request.headers['mcp-protocol-version'] = LATEST_PROTOCOL_VERSION;
537
699
  const rawHeaders = request.rawHeaders;
538
700
  for (let index = 0; index < rawHeaders.length; index += 2) {
@@ -559,6 +721,10 @@ async function handleMcpRequest(request, response, paths, principal) {
559
721
  await writeDiscoveryResult(response, paths, { athleteIds: principal.athleteIds }, jsonRpc.id);
560
722
  return;
561
723
  }
724
+ // A dated 2026 revision expects the SDK's legacy result shape reframed with
725
+ // `resultType`/caching fields before it leaves the process.
726
+ if (modernRequest)
727
+ installModernFraming(response, jsonRpc?.method);
562
728
  // The HTTP gate has already authenticated this request; the MCP server
563
729
  // re-enforces the principal's athlete scope on every tool and roster.
564
730
  const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
@@ -670,7 +836,7 @@ export function createCatenceHttpServer(options = {}) {
670
836
  // The OAuth endpoints are the auth surface itself: they bypass the gate
671
837
  // and CORS entirely and are delegated to the Express sub-app. When OAuth
672
838
  // is effectively disabled they answer 404 — no metadata is advertised.
673
- if (pathname.startsWith('/.well-known/oauth-') || pathname.startsWith('/oauth/')) {
839
+ if (pathname.startsWith('/.well-known/oauth-') || pathname.startsWith('/.well-known/openid-configuration') || pathname.startsWith('/oauth/')) {
674
840
  const config = effectiveOauth();
675
841
  if (config.enabled && config.publicUrl !== undefined) {
676
842
  const handle = await getOauthApp(config.publicUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.23",
3
+ "version": "0.2.1-beta.25",
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": {