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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
700
|
-
|
|
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
|
-
|
|
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