catence 0.2.1-beta.26 → 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.
|
|
@@ -836,7 +926,10 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
836
926
|
return createServer((request, response) => {
|
|
837
927
|
void (async () => {
|
|
838
928
|
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
839
|
-
|
|
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);
|
|
840
933
|
// The OAuth endpoints are the auth surface itself: they bypass the gate
|
|
841
934
|
// and CORS entirely and are delegated to the Express sub-app. When OAuth
|
|
842
935
|
// is effectively disabled they answer 404 — no metadata is advertised.
|
|
@@ -863,6 +956,9 @@ export function createCatenceHttpServer(options = {}) {
|
|
|
863
956
|
const principal = UNAUTHENTICATED_PATHS.has(pathname)
|
|
864
957
|
? FULL_ACCESS_PRINCIPAL
|
|
865
958
|
: await resolvePrincipal(request, effectiveOptions, accountPaths);
|
|
959
|
+
if (principal !== null && (pathname === '/mcp' || pathname === '/')) {
|
|
960
|
+
request.mcpPrincipal = `${principal.kind}${principal.username === undefined ? '' : `:${principal.username}`}`;
|
|
961
|
+
}
|
|
866
962
|
if (principal === null) {
|
|
867
963
|
// A hosted connector may probe server/discover before attaching the
|
|
868
964
|
// bearer token. The discovery result exposes nothing athlete-scoped,
|
package/package.json
CHANGED