catence 0.2.1-beta.26 → 0.2.1-beta.28

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.26";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.28";
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.26';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.28';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -3,7 +3,7 @@ export type FindActivitiesRequest = {
3
3
  sports?: string[];
4
4
  /** Accepted as a convenience alias for `sports: [sport]`. */
5
5
  sport?: string;
6
- distanceKm?: [number, number];
6
+ distanceKm?: number[];
7
7
  nameContains?: string[];
8
8
  startDate?: string;
9
9
  endDate?: string;
@@ -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,11 +58,91 @@ 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(' '));
61
89
  });
62
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);
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');
145
+ }
63
146
  function matchesLegacyToken(rawToken, expected) {
64
147
  const received = Buffer.from(rawToken);
65
148
  const expectedBuffer = Buffer.from(expected);
@@ -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);
@@ -533,10 +619,40 @@ async function handleUnauthenticatedDiscovery(request, response, paths) {
533
619
  * tool definitions drop the SDK's `execution` field — neither the per-result
534
620
  * `_meta.serverInfo` nor `execution` appeared in the revision's schemas for
535
621
  * earlier SDKs, but the revision asks for the former and omits the latter.
622
+ * Tool schemas also drop `propertyNames`, which strict validators reject and
623
+ * whose only instance merely restates JSON's string-key rule.
536
624
  * Errors and notifications carry no result and pass through.
537
625
  */
538
626
  const MODERN_CACHEABLE_METHODS = new Set(['server/discover', 'tools/list', 'prompts/list', 'resources/list', 'resources/templates/list', 'resources/read']);
539
627
  const MODERN_RESULT_TTL_MS = 300_000;
628
+ /** Copy a JSON schema without `propertyNames` keywords; returns the original when nothing was removed. */
629
+ function stripPropertyNames(value) {
630
+ if (Array.isArray(value)) {
631
+ let changed = false;
632
+ const copy = value.map((item) => {
633
+ const next = stripPropertyNames(item);
634
+ if (next !== item)
635
+ changed = true;
636
+ return next;
637
+ });
638
+ return changed ? copy : value;
639
+ }
640
+ if (typeof value !== 'object' || value === null)
641
+ return value;
642
+ let changed = false;
643
+ const copy = {};
644
+ for (const [key, item] of Object.entries(value)) {
645
+ if (key === 'propertyNames') {
646
+ changed = true;
647
+ continue;
648
+ }
649
+ const next = stripPropertyNames(item);
650
+ if (next !== item)
651
+ changed = true;
652
+ copy[key] = next;
653
+ }
654
+ return changed ? copy : value;
655
+ }
540
656
  /** Rewrite one JSON-RPC payload; null when it carries nothing to change. */
541
657
  function frameModernRpcPayload(payload, method) {
542
658
  let envelope;
@@ -565,10 +681,23 @@ function frameModernRpcPayload(payload, method) {
565
681
  if (method === 'tools/list' && Array.isArray(framed.tools)) {
566
682
  let stripped = false;
567
683
  const tools = framed.tools.map((tool) => {
568
- if (typeof tool !== 'object' || tool === null || Array.isArray(tool) || !('execution' in tool))
684
+ if (typeof tool !== 'object' || tool === null || Array.isArray(tool))
569
685
  return tool;
570
686
  const copy = { ...tool };
571
- delete copy.execution;
687
+ let edited = false;
688
+ if ('execution' in copy) {
689
+ delete copy.execution;
690
+ edited = true;
691
+ }
692
+ if ('inputSchema' in copy) {
693
+ const schema = stripPropertyNames(copy.inputSchema);
694
+ if (schema !== copy.inputSchema) {
695
+ copy.inputSchema = schema;
696
+ edited = true;
697
+ }
698
+ }
699
+ if (!edited)
700
+ return tool;
572
701
  stripped = true;
573
702
  return copy;
574
703
  });
@@ -715,6 +844,10 @@ async function handleMcpRequest(request, response, paths, principal) {
715
844
  return;
716
845
  }
717
846
  const jsonRpc = singleJsonRpcRequest(body);
847
+ const serializedBody = JSON.stringify(body);
848
+ if (serializedBody !== undefined)
849
+ request.mcpRequestBody = serializedBody.slice(0, MCP_REQUEST_BODY_STASH_CHARS);
850
+ request.mcpFraming = modernRequest ? (jsonRpc?.method ?? 'modern') : 'legacy';
718
851
  if (jsonRpc?.method === 'server/discover') {
719
852
  // Modern clients probe discovery before anything else; answer from the
720
853
  // same descriptor the initialize response reports so the two never drift.
@@ -836,7 +969,10 @@ export function createCatenceHttpServer(options = {}) {
836
969
  return createServer((request, response) => {
837
970
  void (async () => {
838
971
  const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
839
- logRequestCompletion(request, response, pathname);
972
+ // Body capture covers the MCP surface and OAuth failures; every other
973
+ // route keeps the plain access line.
974
+ const tapped = pathname === '/mcp' || pathname === '/' || pathname.startsWith('/oauth/');
975
+ logRequestCompletion(request, response, pathname, tapped ? tapResponseBody(response) : undefined);
840
976
  // The OAuth endpoints are the auth surface itself: they bypass the gate
841
977
  // and CORS entirely and are delegated to the Express sub-app. When OAuth
842
978
  // is effectively disabled they answer 404 — no metadata is advertised.
@@ -863,6 +999,9 @@ export function createCatenceHttpServer(options = {}) {
863
999
  const principal = UNAUTHENTICATED_PATHS.has(pathname)
864
1000
  ? FULL_ACCESS_PRINCIPAL
865
1001
  : await resolvePrincipal(request, effectiveOptions, accountPaths);
1002
+ if (principal !== null && (pathname === '/mcp' || pathname === '/')) {
1003
+ request.mcpPrincipal = `${principal.kind}${principal.username === undefined ? '' : `:${principal.username}`}`;
1004
+ }
866
1005
  if (principal === null) {
867
1006
  // A hosted connector may probe server/discover before attaching the
868
1007
  // bearer token. The discovery result exposes nothing athlete-scoped,
@@ -583,7 +583,11 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
583
583
  inputSchema: {
584
584
  sport: z.string().min(1).optional(),
585
585
  sports: z.array(z.string().min(1)).min(1).max(12).optional(),
586
- distanceKm: z.tuple([z.number().min(0), z.number().min(0)]).optional(),
586
+ // Tuple-form `items` is valid draft-07 but strict clients reject it
587
+ // (ChatGPT's connector validator flagged exactly this as "Invalid MCP
588
+ // tool schema"). A bounded two-number array keeps the same contract; the
589
+ // service still enforces the ascending [minimum, maximum] order.
590
+ distanceKm: z.array(z.number().min(0)).min(2).max(2).optional(),
587
591
  nameContains: z.array(z.string().min(1)).min(1).max(12).optional(),
588
592
  startDate: z.string().date().optional(), endDate: z.string().date().optional(),
589
593
  sort: z.enum(['date_desc', 'date_asc']).optional(), limit: z.number().int().min(1).max(100).optional(), cursor: z.string().min(1).optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.1-beta.26",
3
+ "version": "0.2.1-beta.28",
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": {