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.
|
|
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.
|
|
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 {
|
|
@@ -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)
|
|
684
|
+
if (typeof tool !== 'object' || tool === null || Array.isArray(tool))
|
|
569
685
|
return tool;
|
|
570
686
|
const copy = { ...tool };
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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