@kernlang/mcp 3.1.7 → 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.
@@ -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 /(?:^|[_A-Z])(?:path|file|dir(?:ectory)?|root|workspace)(?:$|[_A-Z])/i.test(name);
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 all string params → inject
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 without sanitize → inject sanitize on string params
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 === 'sanitize')) {
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) => (!g.target ? paramNodes.length === 1 : g.target === name)),
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 — skip NaN values from malformed .kern input
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 — ReDoS prevention */
234
+ /* skip invalid regex */
197
235
  }
198
236
  }
199
237
  }
200
- // Apply inline min/max from param props — skip NaN
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 || '[^\\w./ -]';
241
- // Validate regex at transpile time to prevent ReDoS in generated code
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
- // sizeLimit guard — check byte length of string params
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 if handler calls external APIs and no sanitizeOutput guard exists
318
- if (effects.network && !getChildren(node, 'guard').some((g) => str(getProps(g).type) === 'sanitizeOutput')) {
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 (input${needsContext ? ', extra' : ''}) => {`);
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 hasRuntimeGuards = params.some((p) => p.guards.some((g) => g.kind === 'sanitize' || g.kind === 'pathContainment' || g.kind === 'sizeLimit'));
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 args = input ?? {};`);
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));
@@ -428,7 +502,7 @@ function emitResource(node, fallbackAllowlist) {
428
502
  if (description)
429
503
  lines.push(`// ${description}`);
430
504
  const uriArg = hasTemplate ? `new ResourceTemplate(${json(uri)}, { list: undefined })` : json(uri);
431
- lines.push(`server.resource(${json(name)}, ${uriArg}, async (uri${hasTemplate ? ', variables' : ''}) => {`);
505
+ lines.push(`server.resource(${json(name)}, ${uriArg}, async (uri: URL${hasTemplate ? ', variables: Record<string, string>' : ''}) => {`);
432
506
  lines.push(` logger.info("resource:read", { resource: ${json(name)}, uri: uri.href });`);
433
507
  lines.push(` try {`);
434
508
  if (params.length > 0) {
@@ -469,10 +543,10 @@ function emitPrompt(node, fallbackAllowlist) {
469
543
  for (const param of params) {
470
544
  lines.push(` ${json(param.name)}: z.string()${param.optional ? '.optional()' : ''},`);
471
545
  }
472
- lines.push(`}, async (args) => {`);
546
+ lines.push(`}, async (args: Record<string, string>) => {`);
473
547
  }
474
548
  else {
475
- lines.push(`server.prompt(${json(name)}, ${json(description || name)}, async (args) => {`);
549
+ lines.push(`server.prompt(${json(name)}, ${json(description || name)}, async (args: Record<string, string>) => {`);
476
550
  }
477
551
  lines.push(` const requestId = nextRequestId();`);
478
552
  lines.push(` logger.info("prompt:call", { requestId, prompt: ${json(name)} });`);
@@ -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
- helperBlock.push(`// NOTE: checkAuth is a bootstrap check — it verifies the env var exists, not that`);
628
- helperBlock.push(`// the caller is authenticated. For production, add real token verification logic.`);
629
- helperBlock.push(`function checkAuth(envVar: string, _header: string): void {`);
630
- helperBlock.push(` const token = process.env[envVar];`);
631
- helperBlock.push(` if (!token) throw new Error("Authentication required: set " + envVar + " environment variable");`);
632
- helperBlock.push(`}`);
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(toolName);`);
771
+ helperBlock.push(` const entry = _rateLimitStore.get(key);`);
640
772
  helperBlock.push(` if (!entry || now > entry.resetAt) {`);
641
- helperBlock.push(` _rateLimitStore.set(toolName, { count: 1, resetAt: now + windowMs });`);
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. */