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.25";
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.25';
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
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
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
- logRequestCompletion(request, response, pathname);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.25",
3
+ "version": "0.2.1-beta.27",
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": {