catence 0.2.1-beta.25 → 0.2.1-beta.27
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.27";
|
|
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.27';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -27,12 +27,15 @@ function bearerTokenFrom(request) {
|
|
|
27
27
|
* /mcp or /oauth exchange are otherwise invisible in container logs. Routine
|
|
28
28
|
* dashboard polling stays quiet; set CATENCE_HTTP_ACCESS_LOG=0 to disable.
|
|
29
29
|
* Logs method, path, status, latency, user agent, and the MCP protocol hints —
|
|
30
|
-
* never headers carrying credentials.
|
|
30
|
+
* never headers carrying credentials. When a body tap is supplied, a second
|
|
31
|
+
* `[mcp-body]` line shows the exchange bodies (set CATENCE_HTTP_BODY_LOG=0 to
|
|
32
|
+
* keep the access lines but drop bodies).
|
|
31
33
|
*/
|
|
32
|
-
function logRequestCompletion(request, response, pathname) {
|
|
34
|
+
function logRequestCompletion(request, response, pathname, bodyTap) {
|
|
33
35
|
if (process.env.CATENCE_HTTP_ACCESS_LOG === '0')
|
|
34
36
|
return;
|
|
35
37
|
const startedAt = Date.now();
|
|
38
|
+
const requestId = Math.random().toString(36).slice(2, 8);
|
|
36
39
|
// Captured before the compatibility bridge rewrites the header so the log
|
|
37
40
|
// always shows the value the client actually sent.
|
|
38
41
|
const rawProtocolVersion = request.headers['mcp-protocol-version'];
|
|
@@ -45,7 +48,7 @@ function logRequestCompletion(request, response, pathname) {
|
|
|
45
48
|
status >= 400;
|
|
46
49
|
if (!interesting)
|
|
47
50
|
return;
|
|
48
|
-
const parts = [`[http] ${new Date().toISOString()}`, `${request.method} ${pathname}`, String(status), `${Date.now() - startedAt}ms`];
|
|
51
|
+
const parts = [`[http] ${new Date().toISOString()}`, `rid=${requestId}`, `${request.method} ${pathname}`, String(status), `${Date.now() - startedAt}ms`];
|
|
49
52
|
const userAgent = request.headers['user-agent'];
|
|
50
53
|
if (typeof userAgent === 'string' && userAgent)
|
|
51
54
|
parts.push(`ua="${userAgent.slice(0, 120)}"`);
|
|
@@ -55,10 +58,90 @@ function logRequestCompletion(request, response, pathname) {
|
|
|
55
58
|
const mcpMethod = request.headers['mcp-method'];
|
|
56
59
|
if (typeof mcpMethod === 'string' && mcpMethod)
|
|
57
60
|
parts.push(`mcp-method=${mcpMethod}`);
|
|
61
|
+
const mcpName = request.headers['mcp-name'];
|
|
62
|
+
if (typeof mcpName === 'string' && mcpName)
|
|
63
|
+
parts.push(`mcp-name=${mcpName}`);
|
|
64
|
+
const logged = request;
|
|
65
|
+
if (logged.mcpFraming !== undefined)
|
|
66
|
+
parts.push(`framing=${logged.mcpFraming}`);
|
|
67
|
+
if (logged.mcpPrincipal !== undefined)
|
|
68
|
+
parts.push(`principal=${logged.mcpPrincipal}`);
|
|
58
69
|
if (pathname === '/mcp' || pathname === '/')
|
|
59
70
|
parts.push(`auth=${bearerTokenFrom(request) !== null ? 'bearer' : 'none'}`);
|
|
60
71
|
console.log(parts.join(' '));
|
|
72
|
+
if (bodyTap === undefined || process.env.CATENCE_HTTP_BODY_LOG === '0')
|
|
73
|
+
return;
|
|
74
|
+
// MCP exchanges always, OAuth only when something failed: error bodies
|
|
75
|
+
// carry no credentials, but token-pair responses do and stay unlogged.
|
|
76
|
+
const logBodies = pathname === '/mcp' || pathname === '/' || (status >= 400 && pathname.startsWith('/oauth/'));
|
|
77
|
+
if (!logBodies)
|
|
78
|
+
return;
|
|
79
|
+
const inbound = logged.mcpRequestBody;
|
|
80
|
+
const outbound = bodyTap();
|
|
81
|
+
if (inbound === undefined && outbound === '')
|
|
82
|
+
return;
|
|
83
|
+
const fields = [`[mcp-body] ${new Date().toISOString()}`, `rid=${requestId}`, `${request.method} ${pathname}`, String(status)];
|
|
84
|
+
if (inbound !== undefined)
|
|
85
|
+
fields.push(`in=${summarizeBody(inbound)}`);
|
|
86
|
+
if (outbound !== '')
|
|
87
|
+
fields.push(`out=${summarizeBody(outbound)}`);
|
|
88
|
+
console.log(fields.join(' '));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Cap for the request body retained for logging. */
|
|
92
|
+
const MCP_REQUEST_BODY_STASH_CHARS = 8_000;
|
|
93
|
+
/** Cap for the response bytes buffered by the body tap before summarization. */
|
|
94
|
+
const MCP_BODY_CAPTURE_LIMIT = 262_144;
|
|
95
|
+
/** Head and tail kept when a captured body is logged. */
|
|
96
|
+
const MCP_BODY_EDGE_CHARS = 2_000;
|
|
97
|
+
/**
|
|
98
|
+
* Bodies are logged on one line: whitespace is flattened, and an oversized
|
|
99
|
+
* body keeps its head and tail (the trailing fields — resultType, cacheScope,
|
|
100
|
+
* nextCursor — matter for protocol forensics and never fit a head-only cut).
|
|
101
|
+
*/
|
|
102
|
+
function summarizeBody(body) {
|
|
103
|
+
const flattened = body.replace(/[\r\n\t]+/g, ' ');
|
|
104
|
+
const budget = MCP_BODY_EDGE_CHARS * 2;
|
|
105
|
+
if (flattened.length <= budget)
|
|
106
|
+
return flattened;
|
|
107
|
+
const omitted = flattened.length - budget;
|
|
108
|
+
return `${flattened.slice(0, MCP_BODY_EDGE_CHARS)}…(truncated ${omitted} chars)…${flattened.slice(-MCP_BODY_EDGE_CHARS)}`;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Buffers the bytes a response writes (bounded) so the access log can show the
|
|
112
|
+
* exact body an MCP or failed-OAuth exchange returned; never alters the
|
|
113
|
+
* payload. Installed before the request is handled, so the modern framing
|
|
114
|
+
* layer wraps this tap and the capture always reflects the final bytes.
|
|
115
|
+
*/
|
|
116
|
+
function tapResponseBody(response) {
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let size = 0;
|
|
119
|
+
const append = (chunk, encoding) => {
|
|
120
|
+
if (size >= MCP_BODY_CAPTURE_LIMIT)
|
|
121
|
+
return;
|
|
122
|
+
let buffer;
|
|
123
|
+
if (typeof chunk === 'string')
|
|
124
|
+
buffer = Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8');
|
|
125
|
+
else if (Buffer.isBuffer(chunk))
|
|
126
|
+
buffer = chunk;
|
|
127
|
+
else if (chunk instanceof Uint8Array)
|
|
128
|
+
buffer = Buffer.from(chunk);
|
|
129
|
+
else
|
|
130
|
+
return;
|
|
131
|
+
chunks.push(buffer);
|
|
132
|
+
size += buffer.length;
|
|
133
|
+
};
|
|
134
|
+
const originalWrite = response.write.bind(response);
|
|
135
|
+
const originalEnd = response.end.bind(response);
|
|
136
|
+
response.write = ((chunk, encodingOrCallback, callback) => {
|
|
137
|
+
append(chunk, encodingOrCallback);
|
|
138
|
+
return originalWrite(chunk, encodingOrCallback, callback);
|
|
61
139
|
});
|
|
140
|
+
response.end = ((chunk, encodingOrCallback, callback) => {
|
|
141
|
+
append(chunk, encodingOrCallback);
|
|
142
|
+
return originalEnd(chunk, encodingOrCallback, callback);
|
|
143
|
+
});
|
|
144
|
+
return () => Buffer.concat(chunks).toString('utf8');
|
|
62
145
|
}
|
|
63
146
|
function matchesLegacyToken(rawToken, expected) {
|
|
64
147
|
const received = Buffer.from(rawToken);
|
|
@@ -517,6 +600,9 @@ async function handleUnauthenticatedDiscovery(request, response, paths) {
|
|
|
517
600
|
return false;
|
|
518
601
|
}
|
|
519
602
|
const jsonRpc = singleJsonRpcRequest(body);
|
|
603
|
+
const serializedBody = JSON.stringify(body);
|
|
604
|
+
if (serializedBody !== undefined)
|
|
605
|
+
request.mcpRequestBody = serializedBody.slice(0, MCP_REQUEST_BODY_STASH_CHARS);
|
|
520
606
|
if (jsonRpc?.method !== 'server/discover')
|
|
521
607
|
return false;
|
|
522
608
|
await writeDiscoveryResult(response, paths, { athleteIds: 'all' }, jsonRpc.id);
|
|
@@ -715,6 +801,10 @@ async function handleMcpRequest(request, response, paths, principal) {
|
|
|
715
801
|
return;
|
|
716
802
|
}
|
|
717
803
|
const jsonRpc = singleJsonRpcRequest(body);
|
|
804
|
+
const serializedBody = JSON.stringify(body);
|
|
805
|
+
if (serializedBody !== undefined)
|
|
806
|
+
request.mcpRequestBody = serializedBody.slice(0, MCP_REQUEST_BODY_STASH_CHARS);
|
|
807
|
+
request.mcpFraming = modernRequest ? (jsonRpc?.method ?? 'modern') : 'legacy';
|
|
718
808
|
if (jsonRpc?.method === 'server/discover') {
|
|
719
809
|
// Modern clients probe discovery before anything else; answer from the
|
|
720
810
|
// same descriptor the initialize response reports so the two never drift.
|
|
@@ -728,7 +818,11 @@ async function handleMcpRequest(request, response, paths, principal) {
|
|
|
728
818
|
// The HTTP gate has already authenticated this request; the MCP server
|
|
729
819
|
// re-enforces the principal's athlete scope on every tool and roster.
|
|
730
820
|
const server = createCatenceMcpServer(paths, {}, { scope: { athleteIds: principal.athleteIds } });
|
|
731
|
-
|
|
821
|
+
// JSON responses mirror OpenAI's own reference implementation: a single
|
|
822
|
+
// request/response has no need for a stream, and a JSON body survives
|
|
823
|
+
// validators (and intermediaries) that would mishandle an SSE frame. The
|
|
824
|
+
// spec requires clients to accept both, so this stays compatible.
|
|
825
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
732
826
|
let cleanedUp = false;
|
|
733
827
|
const cleanup = async () => {
|
|
734
828
|
if (cleanedUp)
|
|
@@ -832,7 +926,10 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
832
926
|
return createServer((request, response) => {
|
|
833
927
|
void (async () => {
|
|
834
928
|
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
835
|
-
|
|
929
|
+
// Body capture covers the MCP surface and OAuth failures; every other
|
|
930
|
+
// route keeps the plain access line.
|
|
931
|
+
const tapped = pathname === '/mcp' || pathname === '/' || pathname.startsWith('/oauth/');
|
|
932
|
+
logRequestCompletion(request, response, pathname, tapped ? tapResponseBody(response) : undefined);
|
|
836
933
|
// The OAuth endpoints are the auth surface itself: they bypass the gate
|
|
837
934
|
// and CORS entirely and are delegated to the Express sub-app. When OAuth
|
|
838
935
|
// is effectively disabled they answer 404 — no metadata is advertised.
|
|
@@ -859,6 +956,9 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
859
956
|
const principal = UNAUTHENTICATED_PATHS.has(pathname)
|
|
860
957
|
? FULL_ACCESS_PRINCIPAL
|
|
861
958
|
: await resolvePrincipal(request, effectiveOptions, accountPaths);
|
|
959
|
+
if (principal !== null && (pathname === '/mcp' || pathname === '/')) {
|
|
960
|
+
request.mcpPrincipal = `${principal.kind}${principal.username === undefined ? '' : `:${principal.username}`}`;
|
|
961
|
+
}
|
|
862
962
|
if (principal === null) {
|
|
863
963
|
// A hosted connector may probe server/discover before attaching the
|
|
864
964
|
// bearer token. The discovery result exposes nothing athlete-scoped,
|
package/package.json
CHANGED