@kernlang/mcp 3.1.8 → 3.1.9
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.
- package/dist/__tests__/transpiler-mcp-e2e.test.js +101 -3
- package/dist/__tests__/transpiler-mcp-e2e.test.js.map +1 -1
- package/dist/__tests__/transpiler-mcp-python.test.js +1 -1
- package/dist/__tests__/transpiler-mcp-python.test.js.map +1 -1
- package/dist/__tests__/transpiler-mcp.test.js +8 -7
- package/dist/__tests__/transpiler-mcp.test.js.map +1 -1
- package/dist/effect-patterns.d.ts +7 -0
- package/dist/effect-patterns.js +10 -0
- package/dist/effect-patterns.js.map +1 -0
- package/dist/transpiler-mcp-python.js +143 -57
- package/dist/transpiler-mcp-python.js.map +1 -1
- package/dist/transpiler-mcp.js +190 -58
- package/dist/transpiler-mcp.js.map +1 -1
- package/package.json +2 -2
package/dist/transpiler-mcp.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { accountNode, buildDiagnostics, camelKey, countTokens, getChildren, getFirstChild, getProps, serializeIR, } from '@kernlang/core';
|
|
2
|
+
import { FILE_IO_PATTERN, NETWORK_PATTERN, SHELL_EXEC_PATTERN } from './effect-patterns.js';
|
|
2
3
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
3
4
|
function json(value) {
|
|
4
5
|
return JSON.stringify(value);
|
|
@@ -51,6 +52,7 @@ function collectGuard(node, fallbackAllowlist) {
|
|
|
51
52
|
'rateLimit',
|
|
52
53
|
'sizeLimit',
|
|
53
54
|
'sanitizeOutput',
|
|
55
|
+
'urlValidation',
|
|
54
56
|
];
|
|
55
57
|
if (!validKinds.includes(kind))
|
|
56
58
|
return null;
|
|
@@ -70,15 +72,23 @@ function collectGuard(node, fallbackAllowlist) {
|
|
|
70
72
|
windowMs: str(props.windowMs) || str(props.window),
|
|
71
73
|
maxRequests: str(props.maxRequests) || str(props.requests),
|
|
72
74
|
maxBytes: str(props.maxBytes) || (kind === 'sizeLimit' ? str(props.max) : undefined),
|
|
75
|
+
allowSchemes: splitCsv(str(props.allowSchemes) || str(props.schemes)),
|
|
76
|
+
allowHosts: splitCsv(str(props.allowHosts) || str(props.hosts)),
|
|
73
77
|
};
|
|
74
78
|
}
|
|
79
|
+
// ── Parameter categorization helpers (for auto-injection/guard scoping) ──
|
|
80
|
+
const CONTENT_PARAMS = /^(content|code|body|data|payload|text|source|script|html|markdown|template)$/i;
|
|
81
|
+
const PATH_PARAMS = /(?:^|[_A-Z])(?:path|file|dir(?:ectory)?|root|workspace)(?:$|[_A-Z])/i;
|
|
82
|
+
function isContentParam(name) {
|
|
83
|
+
return CONTENT_PARAMS.test(name);
|
|
84
|
+
}
|
|
75
85
|
function isPathLikeParam(name) {
|
|
76
|
-
return
|
|
86
|
+
return PATH_PARAMS.test(name);
|
|
87
|
+
}
|
|
88
|
+
const URL_PARAMS = /(?:^|[_A-Z])(?:url|uri|endpoint|href|link|origin|host)(?:$|[_A-Z])/i;
|
|
89
|
+
function isUrlLikeParam(name) {
|
|
90
|
+
return URL_PARAMS.test(name);
|
|
77
91
|
}
|
|
78
|
-
// ── Handler effect detection — auto-inject guards for effects found in handler code ──
|
|
79
|
-
const FILE_IO_PATTERN = /\b(readFile|readFileSync|writeFile|writeFileSync|readdir|readdirSync|unlink|unlinkSync|copyFile|rename|mkdir|rmdir|openSync|createReadStream|createWriteStream)\b/;
|
|
80
|
-
const SHELL_EXEC_PATTERN = /\b(exec|execSync|execFile|execFileSync|spawn|spawnSync|child_process)\b/;
|
|
81
|
-
const NETWORK_PATTERN = /\b(fetch|http\.request|https\.request|axios|got\.get|got\.post)\b/;
|
|
82
92
|
function detectHandlerEffects(handlerCode) {
|
|
83
93
|
return {
|
|
84
94
|
fileIO: FILE_IO_PATTERN.test(handlerCode),
|
|
@@ -96,30 +106,40 @@ function autoInjectEffectGuards(params, parentGuards, effects, fallbackAllowlist
|
|
|
96
106
|
const stringParams = params.filter((p) => p.type === 'string');
|
|
97
107
|
if (stringParams.length === 0)
|
|
98
108
|
return;
|
|
99
|
-
// File I/O without pathContainment → inject on all string params
|
|
109
|
+
// File I/O without pathContainment → inject on all non-content string params
|
|
100
110
|
if (effects.fileIO && !allGuards.some((g) => g.kind === 'pathContainment')) {
|
|
101
111
|
for (const p of stringParams) {
|
|
102
|
-
if (!p.guards.some((g) => g.kind === 'pathContainment')) {
|
|
112
|
+
if (!p.guards.some((g) => g.kind === 'pathContainment') && !isContentParam(p.name)) {
|
|
103
113
|
p.guards.push({ kind: 'pathContainment', target: p.name, allowlist: fallbackAllowlist });
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
|
-
// Shell exec without sanitize on
|
|
117
|
+
// Shell exec without sanitize → inject on non-content string params
|
|
108
118
|
if (effects.shellExec) {
|
|
109
119
|
for (const p of stringParams) {
|
|
110
|
-
if (!p.guards.some((g) => g.kind === 'sanitize')) {
|
|
120
|
+
if (!p.guards.some((g) => g.kind === 'sanitize') && !isContentParam(p.name)) {
|
|
111
121
|
p.guards.push({ kind: 'sanitize', target: p.name, allowlist: [] });
|
|
112
122
|
}
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
|
-
// Network calls
|
|
125
|
+
// Network calls → inject urlValidation on URL-like params, sanitize on others
|
|
116
126
|
if (effects.network) {
|
|
117
127
|
for (const p of stringParams) {
|
|
118
|
-
if (!p.guards.some((g) => g.kind === '
|
|
128
|
+
if (isUrlLikeParam(p.name) && !p.guards.some((g) => g.kind === 'urlValidation')) {
|
|
129
|
+
p.guards.push({ kind: 'urlValidation', target: p.name, allowlist: [], allowSchemes: ['https', 'http'] });
|
|
130
|
+
}
|
|
131
|
+
else if (!p.guards.some((g) => g.kind === 'sanitize') && !isContentParam(p.name) && !isUrlLikeParam(p.name)) {
|
|
119
132
|
p.guards.push({ kind: 'sanitize', target: p.name, allowlist: [] });
|
|
120
133
|
}
|
|
121
134
|
}
|
|
122
135
|
}
|
|
136
|
+
// JSON/object params → inject sizeLimit to prevent oversized payloads (defense-in-depth)
|
|
137
|
+
const jsonParams = params.filter((p) => p.type === 'object' || p.type === 'json');
|
|
138
|
+
for (const p of jsonParams) {
|
|
139
|
+
if (!p.guards.some((g) => g.kind === 'sizeLimit')) {
|
|
140
|
+
p.guards.push({ kind: 'sizeLimit', target: p.name, allowlist: [], maxBytes: '1048576' });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
123
143
|
}
|
|
124
144
|
function collectParams(node, fallbackAllowlist) {
|
|
125
145
|
const paramNodes = getChildren(node, 'param');
|
|
@@ -134,7 +154,18 @@ function collectParams(node, fallbackAllowlist) {
|
|
|
134
154
|
...getChildren(paramNode, 'guard')
|
|
135
155
|
.map((g) => collectGuard(g, fallbackAllowlist))
|
|
136
156
|
.filter((g) => !!g),
|
|
137
|
-
...parentGuards.filter((g) =>
|
|
157
|
+
...parentGuards.filter((g) => {
|
|
158
|
+
if (g.target)
|
|
159
|
+
return g.target === name;
|
|
160
|
+
// Untargeted guard: apply to compatible types instead of silently dropping
|
|
161
|
+
if (g.kind === 'pathContainment')
|
|
162
|
+
return type === 'string' && !isContentParam(name);
|
|
163
|
+
if (g.kind === 'sanitize')
|
|
164
|
+
return type === 'string';
|
|
165
|
+
if (g.kind === 'sizeLimit')
|
|
166
|
+
return true; // works on string (byteLength) and non-string (JSON.stringify)
|
|
167
|
+
return true; // validate applies to all types
|
|
168
|
+
}),
|
|
138
169
|
];
|
|
139
170
|
// Auto-inject pathContainment for path-like params (from Codex)
|
|
140
171
|
if (!guards.some((g) => g.kind === 'pathContainment') && isPathLikeParam(name)) {
|
|
@@ -181,31 +212,40 @@ function zodForParam(param) {
|
|
|
181
212
|
expr = 'z.string()';
|
|
182
213
|
break;
|
|
183
214
|
}
|
|
184
|
-
// Apply validate guards —
|
|
215
|
+
// Apply validate guards — type-gated to prevent invalid Zod chains
|
|
216
|
+
const isStringType = param.type === 'string' || !param.type || param.type === '';
|
|
217
|
+
const isNumericType = ['number', 'float', 'int', 'integer'].includes(param.type);
|
|
218
|
+
const isArrayType = param.type.endsWith('[]');
|
|
219
|
+
const supportsMinMax = isStringType || isNumericType || isArrayType;
|
|
185
220
|
for (const guard of param.guards.filter((g) => g.kind === 'validate')) {
|
|
186
|
-
if (guard.min && !Number.isNaN(Number(guard.min)))
|
|
221
|
+
if (guard.min && !Number.isNaN(Number(guard.min)) && supportsMinMax)
|
|
187
222
|
expr += `.min(${Number(guard.min)})`;
|
|
188
|
-
if (guard.max && !Number.isNaN(Number(guard.max)))
|
|
223
|
+
if (guard.max && !Number.isNaN(Number(guard.max)) && supportsMinMax)
|
|
189
224
|
expr += `.max(${Number(guard.max)})`;
|
|
190
|
-
if (guard.regex) {
|
|
225
|
+
if (guard.regex && isStringType) {
|
|
191
226
|
try {
|
|
192
227
|
new RegExp(guard.regex);
|
|
228
|
+
// Reject nested quantifiers to prevent ReDoS
|
|
229
|
+
if (/([+*}])\s*\)\s*[+*{]/.test(guard.regex))
|
|
230
|
+
continue;
|
|
193
231
|
expr += `.regex(new RegExp(${json(guard.regex)}))`;
|
|
194
232
|
}
|
|
195
233
|
catch {
|
|
196
|
-
/* skip invalid regex
|
|
234
|
+
/* skip invalid regex */
|
|
197
235
|
}
|
|
198
236
|
}
|
|
199
237
|
}
|
|
200
|
-
// Apply inline min/max from param props —
|
|
238
|
+
// Apply inline min/max from param props — type-gated
|
|
201
239
|
const pp = getProps(param.node);
|
|
202
240
|
if (pp.min !== undefined &&
|
|
203
241
|
!Number.isNaN(Number(pp.min)) &&
|
|
242
|
+
supportsMinMax &&
|
|
204
243
|
!param.guards.some((g) => g.kind === 'validate' && g.min)) {
|
|
205
244
|
expr += `.min(${Number(pp.min)})`;
|
|
206
245
|
}
|
|
207
246
|
if (pp.max !== undefined &&
|
|
208
247
|
!Number.isNaN(Number(pp.max)) &&
|
|
248
|
+
supportsMinMax &&
|
|
209
249
|
!param.guards.some((g) => g.kind === 'validate' && g.max)) {
|
|
210
250
|
expr += `.max(${Number(pp.max)})`;
|
|
211
251
|
}
|
|
@@ -237,10 +277,12 @@ function emitGuardLines(params) {
|
|
|
237
277
|
for (const param of params) {
|
|
238
278
|
const accessor = `params[${json(param.name)}]`;
|
|
239
279
|
for (const guard of param.guards.filter((g) => g.kind === 'sanitize')) {
|
|
240
|
-
const pattern = guard.pattern || '[
|
|
241
|
-
// Validate regex at transpile time
|
|
280
|
+
const pattern = guard.pattern || '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]';
|
|
281
|
+
// Validate regex at transpile time and reject catastrophic patterns
|
|
242
282
|
try {
|
|
243
283
|
new RegExp(pattern);
|
|
284
|
+
if (/([+*}])\s*\)\s*[+*{]/.test(pattern))
|
|
285
|
+
continue; // ReDoS prevention
|
|
244
286
|
}
|
|
245
287
|
catch {
|
|
246
288
|
continue;
|
|
@@ -265,10 +307,24 @@ function emitGuardLines(params) {
|
|
|
265
307
|
lines.push(`${accessor} = ensurePathContainment(${base}, ALLOWED_PATHS);`);
|
|
266
308
|
}
|
|
267
309
|
}
|
|
268
|
-
//
|
|
310
|
+
// urlValidation guard — validate URL scheme and optionally host
|
|
311
|
+
for (const guard of param.guards.filter((g) => g.kind === 'urlValidation')) {
|
|
312
|
+
const schemes = guard.allowSchemes?.length ? guard.allowSchemes : ['https', 'http'];
|
|
313
|
+
const schemeLiteral = `[${schemes.map((s) => json(s)).join(', ')}]`;
|
|
314
|
+
lines.push(`if (typeof ${accessor} === "string") {`);
|
|
315
|
+
lines.push(` let _url: URL; try { _url = new URL(${accessor}); } catch { throw new Error("Invalid URL: " + ${accessor}); }`);
|
|
316
|
+
lines.push(` if (!${schemeLiteral}.includes(_url.protocol.replace(":", ""))) throw new Error("URL scheme must be one of ${schemes.join(', ')}: " + ${accessor});`);
|
|
317
|
+
if (guard.allowHosts?.length) {
|
|
318
|
+
const hostLiteral = `[${guard.allowHosts.map((h) => json(h)).join(', ')}]`;
|
|
319
|
+
lines.push(` if (!${hostLiteral}.includes(_url.hostname)) throw new Error("URL host not in allowlist: " + _url.hostname);`);
|
|
320
|
+
}
|
|
321
|
+
lines.push(`}`);
|
|
322
|
+
}
|
|
323
|
+
// sizeLimit guard — check byte length for strings and serialized size for other types
|
|
269
324
|
for (const guard of param.guards.filter((g) => g.kind === 'sizeLimit')) {
|
|
270
325
|
const maxBytes = guard.maxBytes || guard.max || '1048576';
|
|
271
326
|
lines.push(`if (typeof ${accessor} === "string" && Buffer.byteLength(${accessor}) > ${maxBytes}) throw new Error("Input ${param.name} exceeds size limit of ${maxBytes} bytes");`);
|
|
327
|
+
lines.push(`else if (${accessor} != null && typeof ${accessor} !== "string") { const _sz = JSON.stringify(${accessor}); if (_sz && Buffer.byteLength(_sz) > ${maxBytes}) throw new Error("Input ${param.name} exceeds size limit of ${maxBytes} bytes"); }`);
|
|
272
328
|
}
|
|
273
329
|
}
|
|
274
330
|
return lines;
|
|
@@ -286,13 +342,13 @@ function emitToolGuardLines(node) {
|
|
|
286
342
|
const envVar = str(props.envVar) || str(props.env) || 'MCP_AUTH_TOKEN';
|
|
287
343
|
const header = str(props.header) || 'authorization';
|
|
288
344
|
helpers.add('auth');
|
|
289
|
-
pre.push(`checkAuth(${json(envVar)}, ${json(header)});`);
|
|
345
|
+
pre.push(`checkAuth(${json(envVar)}, ${json(header)}, extra);`);
|
|
290
346
|
}
|
|
291
347
|
if (kind === 'rateLimit') {
|
|
292
348
|
const windowMs = str(props.windowMs) || str(props.window) || '60000';
|
|
293
349
|
const maxReqs = str(props.maxRequests) || str(props.requests) || '100';
|
|
294
350
|
helpers.add('rateLimit');
|
|
295
|
-
pre.push(`checkRateLimit(${json(str(getProps(node).name) || 'tool')}, ${windowMs}, ${maxReqs});`);
|
|
351
|
+
pre.push(`checkRateLimit(${json(str(getProps(node).name) || 'tool')}, ${windowMs}, ${maxReqs}, extra);`);
|
|
296
352
|
}
|
|
297
353
|
if (kind === 'sanitizeOutput') {
|
|
298
354
|
sanitizeOutput = true;
|
|
@@ -302,33 +358,43 @@ function emitToolGuardLines(node) {
|
|
|
302
358
|
return { pre, helpers, sanitizeOutput };
|
|
303
359
|
}
|
|
304
360
|
// ── Tool / Resource / Prompt emission ───────────────────────────────────
|
|
305
|
-
function emitTool(node, fallbackAllowlist, requiredHelpers) {
|
|
361
|
+
function emitTool(node, fallbackAllowlist, requiredHelpers, customDiagnostics) {
|
|
306
362
|
const name = str(getProps(node).name) || 'tool';
|
|
307
363
|
const description = extractDescription(node) || `Run ${name}`;
|
|
308
364
|
const params = collectParams(node, fallbackAllowlist);
|
|
309
365
|
const handlerNode = getFirstChild(node, 'handler');
|
|
310
366
|
const handlerCode = handlerNode ? str(getProps(handlerNode).code) || '' : '';
|
|
367
|
+
// Diagnostic: missing handler
|
|
368
|
+
if (!handlerCode) {
|
|
369
|
+
customDiagnostics.push({
|
|
370
|
+
nodeType: 'tool',
|
|
371
|
+
outcome: 'suppressed',
|
|
372
|
+
target: 'mcp',
|
|
373
|
+
loc: node.loc ? { line: node.loc.line, col: node.loc.col } : undefined,
|
|
374
|
+
severity: 'error',
|
|
375
|
+
message: `Tool "${name}" has no handler — add handler <<<...>>>`,
|
|
376
|
+
reason: 'no-handler',
|
|
377
|
+
});
|
|
378
|
+
}
|
|
311
379
|
// Auto-inject guards based on handler effects (secure by construction)
|
|
312
380
|
const effects = detectHandlerEffects(handlerCode);
|
|
313
381
|
const parentGuards = getChildren(node, 'guard')
|
|
314
382
|
.map((g) => collectGuard(g, fallbackAllowlist))
|
|
315
383
|
.filter((g) => !!g);
|
|
316
384
|
autoInjectEffectGuards(params, parentGuards, effects, fallbackAllowlist);
|
|
317
|
-
// Auto-inject sanitizeOutput
|
|
318
|
-
|
|
319
|
-
// Add sanitizeOutput as a tool-level guard node so emitToolGuardLines picks it up
|
|
320
|
-
const syntheticGuard = { type: 'guard', props: { type: 'sanitizeOutput' } };
|
|
321
|
-
if (!node.children)
|
|
322
|
-
node.children = [];
|
|
323
|
-
node.children.push(syntheticGuard);
|
|
324
|
-
}
|
|
385
|
+
// Auto-inject sanitizeOutput without mutating the IR tree
|
|
386
|
+
const autoSanitizeOutput = effects.network && !getChildren(node, 'guard').some((g) => str(getProps(g).type) === 'sanitizeOutput');
|
|
325
387
|
const toolGuards = emitToolGuardLines(node);
|
|
388
|
+
if (autoSanitizeOutput) {
|
|
389
|
+
toolGuards.sanitizeOutput = true;
|
|
390
|
+
toolGuards.helpers.add('sanitizeOutput');
|
|
391
|
+
}
|
|
326
392
|
for (const h of toolGuards.helpers)
|
|
327
393
|
requiredHelpers.add(h);
|
|
328
394
|
// Detect sampling/elicitation children — if present, handler gets extra context
|
|
329
395
|
const hasSampling = getFirstChild(node, 'sampling') !== undefined;
|
|
330
396
|
const hasElicitation = getFirstChild(node, 'elicitation') !== undefined;
|
|
331
|
-
const needsContext = hasSampling || hasElicitation;
|
|
397
|
+
const needsContext = hasSampling || hasElicitation || toolGuards.helpers.has('auth') || toolGuards.helpers.has('rateLimit');
|
|
332
398
|
const lines = [];
|
|
333
399
|
// Zod schema object
|
|
334
400
|
if (params.length > 0) {
|
|
@@ -339,7 +405,7 @@ function emitTool(node, fallbackAllowlist, requiredHelpers) {
|
|
|
339
405
|
lines.push(`};`);
|
|
340
406
|
lines.push('');
|
|
341
407
|
}
|
|
342
|
-
lines.push(`server.tool(${json(name)}, ${json(description)}, ${params.length > 0 ? `${camelKey(name)}Schema` : '{}'}, async (
|
|
408
|
+
lines.push(`server.tool(${json(name)}, ${json(description)}, ${params.length > 0 ? `${camelKey(name)}Schema` : '{}'}, async (_raw_input${needsContext ? ', extra' : ''}) => {`);
|
|
343
409
|
lines.push(` const requestId = nextRequestId();`);
|
|
344
410
|
lines.push(` logger.info("tool:call", { requestId, tool: ${json(name)} });`);
|
|
345
411
|
lines.push(` try {`);
|
|
@@ -347,23 +413,20 @@ function emitTool(node, fallbackAllowlist, requiredHelpers) {
|
|
|
347
413
|
for (const line of toolGuards.pre) {
|
|
348
414
|
lines.push(` ${line}`);
|
|
349
415
|
}
|
|
416
|
+
// Always declare params and args — handler code can reference either consistently
|
|
350
417
|
if (params.length > 0) {
|
|
351
|
-
const
|
|
418
|
+
lines.push(` const params = { ..._raw_input } as Record<string, unknown>;`);
|
|
419
|
+
const hasRuntimeGuards = params.some((p) => p.guards.some((g) => g.kind === 'sanitize' || g.kind === 'pathContainment' || g.kind === 'sizeLimit' || g.kind === 'urlValidation'));
|
|
352
420
|
if (hasRuntimeGuards) {
|
|
353
|
-
// Guards may mutate values — use Record<string, unknown> for mutation, then expose as args
|
|
354
|
-
lines.push(` const params = { ...input } as Record<string, unknown>;`);
|
|
355
421
|
for (const line of emitGuardLines(params)) {
|
|
356
422
|
lines.push(` ${line}`);
|
|
357
423
|
}
|
|
358
|
-
lines.push(` const args = params as typeof input;`);
|
|
359
|
-
}
|
|
360
|
-
else {
|
|
361
|
-
// No runtime param mutations — preserve original types
|
|
362
|
-
lines.push(` const args = input;`);
|
|
363
424
|
}
|
|
425
|
+
lines.push(` const args = params as typeof _raw_input;`);
|
|
364
426
|
}
|
|
365
427
|
else {
|
|
366
|
-
lines.push(` const
|
|
428
|
+
lines.push(` const params = { ...(_raw_input ?? {}) } as Record<string, unknown>;`);
|
|
429
|
+
lines.push(` const args = params as Record<string, unknown>;`);
|
|
367
430
|
}
|
|
368
431
|
// Inject sampling/elicitation context helpers
|
|
369
432
|
if (hasSampling) {
|
|
@@ -371,6 +434,7 @@ function emitTool(node, fallbackAllowlist, requiredHelpers) {
|
|
|
371
434
|
const sp = getProps(samplingNode);
|
|
372
435
|
const maxTokens = str(sp.maxTokens) || '500';
|
|
373
436
|
lines.push(` // Sampling — request LLM completion from the client`);
|
|
437
|
+
lines.push(` // SDK v1: server.server.createMessage() | SDK v2: ctx.mcpReq.requestSampling()`);
|
|
374
438
|
lines.push(` async function requestSampling(prompt: string): Promise<string> {`);
|
|
375
439
|
lines.push(` const response = await server.server.createMessage({`);
|
|
376
440
|
lines.push(` messages: [{ role: "user", content: { type: "text", text: prompt } }],`);
|
|
@@ -384,13 +448,17 @@ function emitTool(node, fallbackAllowlist, requiredHelpers) {
|
|
|
384
448
|
const ep = getProps(elicitNode);
|
|
385
449
|
const elicitMessage = str(ep.message) || str(ep.text) || 'Please provide input';
|
|
386
450
|
lines.push(` // Elicitation — request structured user input`);
|
|
451
|
+
lines.push(` // SDK v1: server.server.elicitInput() | SDK v2: ctx.mcpReq.elicitInput()`);
|
|
387
452
|
lines.push(` async function requestInput(message = ${json(elicitMessage)}): Promise<Record<string, unknown> | null> {`);
|
|
388
|
-
lines.push(` const result = await server.server.elicitInput({ message, requestedSchema: { type: "object", properties: {} } });`);
|
|
453
|
+
lines.push(` const result = await server.server.elicitInput({ mode: "form", message, requestedSchema: { type: "object", properties: {} } });`);
|
|
389
454
|
lines.push(` return result.action === "accept" ? (result.content || {}) : null;`);
|
|
390
455
|
lines.push(` }`);
|
|
391
456
|
}
|
|
457
|
+
// Always wrap handler code in async IIFE when it contains return statements —
|
|
458
|
+
// prevents bare return in the server.tool callback from bypassing error handling.
|
|
459
|
+
// sanitizeOutput wraps the result additionally.
|
|
460
|
+
const handlerHasReturn = handlerCode && /\breturn\b/.test(handlerCode);
|
|
392
461
|
if (toolGuards.sanitizeOutput) {
|
|
393
|
-
// Wrap handler in output sanitization — strips prompt injection markers from responses
|
|
394
462
|
lines.push(` const _rawResult = await (async () => {`);
|
|
395
463
|
if (handlerCode) {
|
|
396
464
|
lines.push(...indent(handlerCode, 6));
|
|
@@ -401,6 +469,12 @@ function emitTool(node, fallbackAllowlist, requiredHelpers) {
|
|
|
401
469
|
lines.push(` })();`);
|
|
402
470
|
lines.push(` return sanitizeToolOutput(_rawResult);`);
|
|
403
471
|
}
|
|
472
|
+
else if (handlerHasReturn) {
|
|
473
|
+
// Wrap in IIFE so return works correctly inside try/catch
|
|
474
|
+
lines.push(` return await (async () => {`);
|
|
475
|
+
lines.push(...indent(handlerCode, 6));
|
|
476
|
+
lines.push(` })();`);
|
|
477
|
+
}
|
|
404
478
|
else {
|
|
405
479
|
if (handlerCode) {
|
|
406
480
|
lines.push(...indent(handlerCode, 4));
|
|
@@ -511,6 +585,7 @@ function buildCode(root, _config) {
|
|
|
511
585
|
// Allowlist from mcp node props
|
|
512
586
|
const rawAllow = splitCsv(str(props.allowlist) || str(props.allowedPaths) || str(props.baseDir));
|
|
513
587
|
const allowlist = rawAllow.length > 0 ? rawAllow : ['process.cwd()'];
|
|
588
|
+
const usingDefaultAllowlist = rawAllow.length === 0;
|
|
514
589
|
const toolNodes = getChildren(container, 'tool');
|
|
515
590
|
const resourceNodes = getChildren(container, 'resource');
|
|
516
591
|
const promptNodes = getChildren(container, 'prompt');
|
|
@@ -522,8 +597,28 @@ function buildCode(root, _config) {
|
|
|
522
597
|
const allNodes = [...toolNodes, ...resourceNodes, ...promptNodes];
|
|
523
598
|
const allGuards = allNodes.flatMap((n) => getChildren(n, 'guard'));
|
|
524
599
|
const allParams = allNodes.flatMap((n) => collectParams(n, allowlist));
|
|
600
|
+
const customDiagnostics = [];
|
|
601
|
+
// Pre-scan: detect which helpers auto-injection will need (S5-4 fix)
|
|
602
|
+
const autoInjectedGuardKinds = new Set();
|
|
603
|
+
for (const toolNode of toolNodes) {
|
|
604
|
+
const preParams = collectParams(toolNode, allowlist);
|
|
605
|
+
const hNode = getFirstChild(toolNode, 'handler');
|
|
606
|
+
const hCode = hNode ? str(getProps(hNode).code) || '' : '';
|
|
607
|
+
if (hCode) {
|
|
608
|
+
const eff = detectHandlerEffects(hCode);
|
|
609
|
+
const pGuards = getChildren(toolNode, 'guard')
|
|
610
|
+
.map((g) => collectGuard(g, allowlist))
|
|
611
|
+
.filter((g) => !!g);
|
|
612
|
+
autoInjectEffectGuards(preParams, pGuards, eff, allowlist);
|
|
613
|
+
for (const p of preParams) {
|
|
614
|
+
for (const g of p.guards)
|
|
615
|
+
autoInjectedGuardKinds.add(g.kind);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
525
619
|
const needsPath = allGuards.some((g) => str(getProps(g).type) === 'pathContainment' || str(getProps(g).kind) === 'pathContainment') ||
|
|
526
|
-
allParams.some((p) => p.guards.some((g) => g.kind === 'pathContainment'))
|
|
620
|
+
allParams.some((p) => p.guards.some((g) => g.kind === 'pathContainment')) ||
|
|
621
|
+
autoInjectedGuardKinds.has('pathContainment');
|
|
527
622
|
const needsResourceTemplate = resourceNodes.some((r) => (str(getProps(r).uri) || '').includes('{'));
|
|
528
623
|
const transportType = str(props.transport) || 'stdio';
|
|
529
624
|
const _needsSizeLimit = allParams.some((p) => p.guards.some((g) => g.kind === 'sizeLimit'));
|
|
@@ -574,7 +669,7 @@ function buildCode(root, _config) {
|
|
|
574
669
|
lines.push(` return error instanceof Error ? error.message : String(error);`);
|
|
575
670
|
lines.push(`}`);
|
|
576
671
|
lines.push('');
|
|
577
|
-
if (allParams.some((p) => p.guards.some((g) => g.kind === 'sanitize'))) {
|
|
672
|
+
if (allParams.some((p) => p.guards.some((g) => g.kind === 'sanitize')) || autoInjectedGuardKinds.has('sanitize')) {
|
|
578
673
|
lines.push(`function sanitizeValue(value: unknown, pattern: string, replacement: string): unknown {`);
|
|
579
674
|
lines.push(` if (typeof value !== "string") return value;`);
|
|
580
675
|
lines.push(` try { return value.replace(new RegExp(pattern, "g"), replacement); }`);
|
|
@@ -601,7 +696,7 @@ function buildCode(root, _config) {
|
|
|
601
696
|
outLine: lines.length + 1,
|
|
602
697
|
outCol: 1,
|
|
603
698
|
});
|
|
604
|
-
lines.push(...emitTool(toolNode, allowlist, requiredHelpers), '');
|
|
699
|
+
lines.push(...emitTool(toolNode, allowlist, requiredHelpers, customDiagnostics), '');
|
|
605
700
|
}
|
|
606
701
|
for (const resourceNode of resourceNodes) {
|
|
607
702
|
sourceMap.push({
|
|
@@ -621,24 +716,61 @@ function buildCode(root, _config) {
|
|
|
621
716
|
});
|
|
622
717
|
lines.push(...emitPrompt(promptNode, allowlist), '');
|
|
623
718
|
}
|
|
719
|
+
// ── Info diagnostics for guard quality (S3-12/13/14)
|
|
720
|
+
if (usingDefaultAllowlist && needsPath) {
|
|
721
|
+
customDiagnostics.push({
|
|
722
|
+
nodeType: 'mcp',
|
|
723
|
+
outcome: 'expressed',
|
|
724
|
+
target: 'mcp',
|
|
725
|
+
severity: 'info',
|
|
726
|
+
message: 'No explicit allowlist — using process.cwd() which may not match workspace root. Set allowlist in .kern file for production.',
|
|
727
|
+
});
|
|
728
|
+
}
|
|
624
729
|
// ── Inject auth/rateLimit helpers if any tool uses them (after registrations so we know what's needed)
|
|
625
730
|
const helperBlock = [];
|
|
626
731
|
if (requiredHelpers.has('auth')) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
732
|
+
if (transportType === 'stdio') {
|
|
733
|
+
// stdio is trusted-local — no caller identity available
|
|
734
|
+
customDiagnostics.push({
|
|
735
|
+
nodeType: 'guard',
|
|
736
|
+
outcome: 'expressed',
|
|
737
|
+
target: 'mcp',
|
|
738
|
+
severity: 'warning',
|
|
739
|
+
message: 'Auth guard on stdio transport is config-only — verifies env var exists, cannot authenticate callers. Use HTTP/SSE transport for real auth.',
|
|
740
|
+
});
|
|
741
|
+
helperBlock.push(`// WARNING: stdio transport — auth is config-only, not caller verification.`);
|
|
742
|
+
helperBlock.push(`// Switch to transport=http or transport=sse for real authentication via MCP session.`);
|
|
743
|
+
helperBlock.push(`function checkAuth(envVar: string, _header: string, _extra?: Record<string, unknown>): void {`);
|
|
744
|
+
helperBlock.push(` const token = process.env[envVar];`);
|
|
745
|
+
helperBlock.push(` if (!token) throw new Error("Server not configured: set " + envVar + " environment variable");`);
|
|
746
|
+
helperBlock.push(`}`);
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
// HTTP/SSE — real caller authentication via MCP session
|
|
750
|
+
helperBlock.push(`function checkAuth(envVar: string, _header: string, extra?: Record<string, unknown>): void {`);
|
|
751
|
+
helperBlock.push(` // Verify caller via MCP session authInfo (HTTP/SSE transport)`);
|
|
752
|
+
helperBlock.push(` const authInfo = (extra as any)?.authInfo;`);
|
|
753
|
+
helperBlock.push(` if (authInfo) {`);
|
|
754
|
+
helperBlock.push(` if (!authInfo.token) throw new Error("Authentication required: no token in session");`);
|
|
755
|
+
helperBlock.push(` if (authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) throw new Error("Authentication token expired");`);
|
|
756
|
+
helperBlock.push(` return;`);
|
|
757
|
+
helperBlock.push(` }`);
|
|
758
|
+
helperBlock.push(` // Fallback: verify server configuration`);
|
|
759
|
+
helperBlock.push(` const token = process.env[envVar];`);
|
|
760
|
+
helperBlock.push(` if (!token) throw new Error("Authentication required: set " + envVar + " or configure MCP auth");`);
|
|
761
|
+
helperBlock.push(`}`);
|
|
762
|
+
}
|
|
633
763
|
helperBlock.push('');
|
|
634
764
|
}
|
|
635
765
|
if (requiredHelpers.has('rateLimit')) {
|
|
636
766
|
helperBlock.push(`const _rateLimitStore = new Map<string, { count: number; resetAt: number }>();`);
|
|
637
|
-
helperBlock.push(`function checkRateLimit(toolName: string, windowMs: number, maxRequests: number): void {`);
|
|
767
|
+
helperBlock.push(`function checkRateLimit(toolName: string, windowMs: number, maxRequests: number, extra?: Record<string, unknown>): void {`);
|
|
768
|
+
helperBlock.push(` const sessionId = (extra as any)?.sessionId || (extra as any)?.authInfo?.clientId || "global";`);
|
|
769
|
+
helperBlock.push(' const key = `${sessionId}:${toolName}`;');
|
|
638
770
|
helperBlock.push(` const now = Date.now();`);
|
|
639
|
-
helperBlock.push(` const entry = _rateLimitStore.get(
|
|
771
|
+
helperBlock.push(` const entry = _rateLimitStore.get(key);`);
|
|
640
772
|
helperBlock.push(` if (!entry || now > entry.resetAt) {`);
|
|
641
|
-
helperBlock.push(` _rateLimitStore.set(
|
|
773
|
+
helperBlock.push(` _rateLimitStore.set(key, { count: 1, resetAt: now + windowMs });`);
|
|
642
774
|
helperBlock.push(` return;`);
|
|
643
775
|
helperBlock.push(` }`);
|
|
644
776
|
helperBlock.push(` entry.count++;`);
|
|
@@ -709,7 +841,7 @@ function buildCode(root, _config) {
|
|
|
709
841
|
return {
|
|
710
842
|
code: lines.join('\n'),
|
|
711
843
|
sourceMap,
|
|
712
|
-
diagnostics: buildDiagnostics(root, accounted, 'mcp'),
|
|
844
|
+
diagnostics: [...buildDiagnostics(root, accounted, 'mcp'), ...customDiagnostics],
|
|
713
845
|
};
|
|
714
846
|
}
|
|
715
847
|
/** Transpile a KERN IR tree to MCP server TypeScript code string. */
|