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