@iris-eval/mcp-server 0.5.1 → 0.7.0

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.
Files changed (56) hide show
  1. package/README.md +100 -34
  2. package/dist/config/defaults.js +3 -1
  3. package/dist/config/index.d.ts +10 -0
  4. package/dist/config/index.js +33 -7
  5. package/dist/dashboard/assets/index-CKs2Wbd_.js +10 -0
  6. package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
  7. package/dist/dashboard/index.html +4 -3
  8. package/dist/dashboard/routes/health.js +10 -3
  9. package/dist/dashboard/routes/moments.js +1 -1
  10. package/dist/dashboard/routes/preferences.d.ts +1 -0
  11. package/dist/dashboard/routes/preferences.js +31 -3
  12. package/dist/dashboard/routes/rules.d.ts +18 -0
  13. package/dist/dashboard/routes/rules.js +160 -6
  14. package/dist/dashboard/routes/traces.js +27 -3
  15. package/dist/dashboard/seed-demo-data.js +11 -0
  16. package/dist/dashboard/server.js +13 -3
  17. package/dist/dashboard/session-auth.d.ts +8 -0
  18. package/dist/dashboard/session-auth.js +237 -0
  19. package/dist/dashboard/validation.d.ts +10 -4
  20. package/dist/dashboard/validation.js +73 -11
  21. package/dist/eval/engine.d.ts +79 -1
  22. package/dist/eval/engine.js +216 -82
  23. package/dist/eval/rules/relevance.d.ts +13 -0
  24. package/dist/eval/rules/relevance.js +185 -21
  25. package/dist/eval/rules/safety.d.ts +19 -0
  26. package/dist/eval/rules/safety.js +236 -24
  27. package/dist/index.js +102 -16
  28. package/dist/middleware/rate-limit.d.ts +25 -0
  29. package/dist/middleware/rate-limit.js +54 -2
  30. package/dist/self-test.d.ts +14 -0
  31. package/dist/self-test.js +97 -13
  32. package/dist/storage/demo-guard.d.ts +8 -0
  33. package/dist/storage/demo-guard.js +53 -0
  34. package/dist/storage/sqlite-adapter.d.ts +6 -0
  35. package/dist/storage/sqlite-adapter.js +72 -1
  36. package/dist/tools/delete-rule.js +49 -11
  37. package/dist/tools/deploy-rule.d.ts +33 -0
  38. package/dist/tools/deploy-rule.js +130 -27
  39. package/dist/tools/evaluate-output.js +54 -33
  40. package/dist/tools/evaluate-with-llm-judge.js +10 -3
  41. package/dist/tools/get-traces.d.ts +27 -0
  42. package/dist/tools/get-traces.js +60 -8
  43. package/dist/tools/list-rules.js +2 -2
  44. package/dist/tools/log-trace.js +4 -3
  45. package/dist/tools/strict-input.d.ts +1 -0
  46. package/dist/tools/strict-input.js +27 -2
  47. package/dist/tools/trace-link.d.ts +7 -0
  48. package/dist/tools/trace-link.js +39 -0
  49. package/dist/tools/verify-citations.d.ts +19 -0
  50. package/dist/tools/verify-citations.js +41 -4
  51. package/dist/types/eval.d.ts +52 -1
  52. package/dist/types/index.d.ts +1 -1
  53. package/dist/types/query.d.ts +25 -0
  54. package/package.json +8 -1
  55. package/server.json +2 -2
  56. package/dist/dashboard/assets/index-VI_nbMfN.js +0 -10
@@ -1,5 +1,28 @@
1
1
  import { getRulesForType, createCustomRule } from './rules/index.js';
2
2
  import { generateEvalId } from '../utils/ids.js';
3
+ /**
4
+ * Every bundle eval_type="all" walks, in the order their categories are
5
+ * reported. 'custom' is last: it holds only deployed rules registered under
6
+ * evalType "custom" plus the call's inline custom_rules, so it is absent
7
+ * from the breakdown when neither exists.
8
+ */
9
+ export const ALL_EVAL_TYPES = ['completeness', 'relevance', 'safety', 'cost', 'custom'];
10
+ /**
11
+ * What runs when a caller never chose a bundle. It used to be
12
+ * 'completeness', so a CI gate keyed on `passed` skipped PII and injection
13
+ * unless the caller knew to set eval_type — six of seven UAT personas read
14
+ * passed:true on PII-laden text with nothing in the payload saying the
15
+ * safety bundle had not run. Every bundle is the only default under which
16
+ * an omitted argument cannot silently narrow the verdict. The MCP tool and
17
+ * the HTTP ingest route both read this constant, so the two surfaces
18
+ * cannot default differently.
19
+ */
20
+ export const DEFAULT_EVAL_TYPE = 'all';
21
+ /**
22
+ * The one-line note both surfaces attach when the default ran, so a reader
23
+ * of the response knows the bundle was chosen for them and how to narrow it.
24
+ */
25
+ export const DEFAULT_EVAL_TYPE_NOTE = 'eval_type was omitted, so the default ran every bundle — completeness, relevance, safety, cost and any custom rules — the same as eval_type="all"; pass a single bundle name to narrow the run.';
3
26
  export class EvalEngine {
4
27
  additionalRules = new Map();
5
28
  /**
@@ -9,18 +32,34 @@ export class EvalEngine {
9
32
  * share a name with different definitions.
10
33
  */
11
34
  rulesById = new Map();
35
+ /**
36
+ * Reverse index, so each rule's result can carry its deployed id
37
+ * (EvalRuleResult.ruleId) without mutating the rule object itself.
38
+ */
39
+ idByRule = new Map();
12
40
  threshold;
13
41
  ruleThresholds;
14
42
  constructor(threshold = 0.7, ruleThresholds) {
15
43
  this.threshold = threshold;
16
44
  this.ruleThresholds = ruleThresholds;
17
45
  }
46
+ /**
47
+ * Register a rule under a bundle. When `ruleId` is given the registration
48
+ * is IDEMPOTENT by id: registering an id that is already live replaces the
49
+ * earlier instance instead of adding a second one. That is what re-enable
50
+ * (delete_rule enabled:true) and a reload after edit need — without it,
51
+ * every toggle stacked another copy that fired alongside the first.
52
+ */
18
53
  registerRule(evalType, rule, ruleId) {
54
+ if (ruleId !== undefined && this.rulesById.has(ruleId)) {
55
+ this.unregisterRule(ruleId);
56
+ }
19
57
  const existing = this.additionalRules.get(evalType) ?? [];
20
58
  existing.push(rule);
21
59
  this.additionalRules.set(evalType, existing);
22
60
  if (ruleId !== undefined) {
23
61
  this.rulesById.set(ruleId, { evalType, rule });
62
+ this.idByRule.set(rule, ruleId);
24
63
  }
25
64
  }
26
65
  /**
@@ -34,6 +73,7 @@ export class EvalEngine {
34
73
  if (!entry)
35
74
  return false;
36
75
  this.rulesById.delete(ruleId);
76
+ this.idByRule.delete(entry.rule);
37
77
  const rules = this.additionalRules.get(entry.evalType);
38
78
  if (rules) {
39
79
  const idx = rules.indexOf(entry.rule);
@@ -42,14 +82,11 @@ export class EvalEngine {
42
82
  }
43
83
  return true;
44
84
  }
85
+ /** Whether a deployed rule id is currently registered (and therefore firing). */
86
+ hasRule(ruleId) {
87
+ return this.rulesById.has(ruleId);
88
+ }
45
89
  evaluate(evalType, context, customRules) {
46
- // Merge system-level thresholds into customConfig (user-provided values take precedence)
47
- if (this.ruleThresholds) {
48
- context = {
49
- ...context,
50
- customConfig: { ...this.ruleThresholds, ...context.customConfig },
51
- };
52
- }
53
90
  /*
54
91
  * Inline custom_rules are ADDITIVE, which is what evaluate_output's
55
92
  * description promises in two places: "fires REGARDLESS of eval_type"
@@ -75,6 +112,40 @@ export class EvalEngine {
75
112
  ...(this.additionalRules.get(evalType) ?? []),
76
113
  ...(customRules ?? []).map((def) => createCustomRule(def)),
77
114
  ];
115
+ return this.run(evalType, rules, undefined, context);
116
+ }
117
+ /**
118
+ * eval_type="all" (#370): every built-in bundle, each with the deployed
119
+ * rules registered under it, plus the rules deployed under "custom" and
120
+ * the call's inline custom_rules — in ONE pass, sharing one regex budget,
121
+ * so the whole call is bounded exactly like a single bundle. The overall
122
+ * verdict is the same arithmetic as a single bundle applied to every rule
123
+ * that ran (weighted score against the threshold, critical veto across
124
+ * all bundles); `categories` carries the same arithmetic per bundle.
125
+ */
126
+ evaluateAll(context, customRules) {
127
+ const rules = [];
128
+ const categories = [];
129
+ for (const type of ALL_EVAL_TYPES) {
130
+ for (const rule of [...getRulesForType(type), ...(this.additionalRules.get(type) ?? [])]) {
131
+ rules.push(rule);
132
+ categories.push(type);
133
+ }
134
+ }
135
+ for (const def of customRules ?? []) {
136
+ rules.push(createCustomRule(def));
137
+ categories.push('custom');
138
+ }
139
+ return this.run('all', rules, categories, context);
140
+ }
141
+ run(evalType, rules, categories, context) {
142
+ // Merge system-level thresholds into customConfig (user-provided values take precedence)
143
+ if (this.ruleThresholds) {
144
+ context = {
145
+ ...context,
146
+ customConfig: { ...this.ruleThresholds, ...context.customConfig },
147
+ };
148
+ }
78
149
  if (rules.length === 0) {
79
150
  return {
80
151
  id: generateEvalId(),
@@ -99,22 +170,26 @@ export class EvalEngine {
99
170
  * rule it carries.
100
171
  */
101
172
  const evalContext = { ...context, regexBudget: { breaches: 0 } };
102
- const ruleResults = rules.map((rule) => rule.evaluate(evalContext));
103
- // Partition into evaluated vs skipped
104
- const evaluatedIndices = [];
105
- const skippedIndices = [];
106
- for (let i = 0; i < ruleResults.length; i++) {
107
- if (ruleResults[i].skipped) {
108
- skippedIndices.push(i);
109
- }
110
- else {
111
- evaluatedIndices.push(i);
112
- }
113
- }
114
- const rulesEvaluated = evaluatedIndices.length;
115
- const rulesSkipped = skippedIndices.length;
173
+ const ruleResults = rules.map((rule, i) => {
174
+ const raw = rule.evaluate(evalContext);
175
+ const ruleId = this.idByRule.get(rule);
176
+ const category = categories?.[i];
177
+ if (ruleId === undefined && category === undefined)
178
+ return raw;
179
+ // ruleId / category sit right after the name so a reader scanning
180
+ // rule_results sees WHICH deployed rule (and which bundle) spoke.
181
+ const { ruleName, ...rest } = raw;
182
+ return {
183
+ ruleName,
184
+ ...(ruleId !== undefined ? { ruleId } : {}),
185
+ ...(category !== undefined ? { category } : {}),
186
+ ...rest,
187
+ };
188
+ });
189
+ const overall = this.summarize(rules, ruleResults);
190
+ const perCategory = categories ? this.categorize(rules, ruleResults, categories) : undefined;
116
191
  // Handle "all rules skipped" — insufficient data
117
- if (rulesEvaluated === 0) {
192
+ if (overall.rulesEvaluated === 0) {
118
193
  const skipMessages = ruleResults
119
194
  .filter((r) => r.skipped)
120
195
  .map((r) => `[${r.ruleName}] ${r.skipReason ?? r.message}`);
@@ -122,9 +197,6 @@ export class EvalEngine {
122
197
  // that EVERY critical rule that skipped is named here, and a caller
123
198
  // whose only rules were critical ones should not have to infer that
124
199
  // from insufficient_data alone.
125
- const criticalSkippedAll = skippedIndices
126
- .filter((i) => rules[i].critical === true)
127
- .map((i) => ruleResults[i].ruleName);
128
200
  return {
129
201
  id: generateEvalId(),
130
202
  eval_type: evalType,
@@ -138,64 +210,22 @@ export class EvalEngine {
138
210
  ...skipMessages,
139
211
  ],
140
212
  rules_evaluated: 0,
141
- rules_skipped: rulesSkipped,
213
+ rules_skipped: overall.rulesSkipped,
142
214
  insufficient_data: true,
143
- ...(criticalSkippedAll.length > 0 ? { critical_skipped: criticalSkippedAll } : {}),
215
+ ...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
216
+ ...(perCategory ? { categories: perCategory } : {}),
144
217
  };
145
218
  }
146
- // Weighted average across evaluated rules only (exclude skipped)
147
- const totalWeight = evaluatedIndices.reduce((sum, i) => sum + rules[i].weight, 0);
148
- const weightedScore = evaluatedIndices.reduce((sum, i) => {
149
- const ruleScore = Number.isFinite(ruleResults[i].score) ? ruleResults[i].score : 0;
150
- return sum + ruleScore * rules[i].weight;
151
- }, 0);
152
- const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
153
- const score = Number.isFinite(rawScore) ? rawScore : 0;
154
- /*
155
- * Critical rules hard-fail. Before this existed, the weighted average
156
- * routinely outvoted a genuine violation: an output containing a real
157
- * SSN failed no_pii while the other safety rules passed, landing at
158
- * ~0.765 — over the 0.7 threshold — so `passed`, the one field every
159
- * automated gate keys on, said true about the product's flagship
160
- * failure scenario. A detection that reports an all-clear is worse
161
- * than no detection.
162
- *
163
- * Only EVALUATED failures count: a critical rule that skipped (missing
164
- * context, broken config) has not judged the output and must not veto
165
- * it. The score is left as-is — it stays a quality gradient; `passed`
166
- * is the verdict, and the two answer different questions.
167
- */
168
- const criticalFailures = evaluatedIndices
169
- .filter((i) => rules[i].critical === true && !ruleResults[i].passed)
170
- .map((i) => ruleResults[i].ruleName);
171
- /*
172
- * The other half of that sentence, surfaced as a field.
173
- *
174
- * A critical rule that SKIPPED is the fail-open seam between this
175
- * release's two headline features: an adversary who knows a deployed
176
- * critical regex can craft output that stalls it past the sandbox
177
- * budget, and the rule then neither judges nor vetoes — so the eval
178
- * returns passed=true with an EMPTY critical_failures on output that
179
- * nobody actually cleared. The trade-off is deliberate (failing closed
180
- * would let the same adversary force false violations on benign
181
- * output), but before this field the only trace of it was a suggestions
182
- * line — prose. A gate that must fail closed should not have to walk
183
- * rule_results[].budgetExceeded to discover it was defeated.
184
- */
185
- const criticalSkipped = skippedIndices
186
- .filter((i) => rules[i].critical === true)
187
- .map((i) => ruleResults[i].ruleName);
188
- const passed = score >= this.threshold && criticalFailures.length === 0;
189
219
  const suggestions = [];
190
220
  for (const result of ruleResults) {
191
221
  if (!result.passed && !result.skipped) {
192
222
  suggestions.push(`[${result.ruleName}] ${result.message}`);
193
223
  }
194
224
  }
195
- if (criticalFailures.length > 0 && score >= this.threshold) {
196
- suggestions.push(`Critical rule(s) failed (${criticalFailures.join(', ')}) — passed=false regardless of the weighted score`);
225
+ if (overall.criticalFailures.length > 0 && overall.score >= this.threshold) {
226
+ suggestions.push(`Critical rule(s) failed (${overall.criticalFailures.join(', ')}) — passed=false regardless of the weighted score`);
197
227
  }
198
- if (rulesSkipped > 0) {
228
+ if (overall.rulesSkipped > 0) {
199
229
  /*
200
230
  * Say WHY each rule skipped. The old line hardcoded "(missing
201
231
  * context)" — but a rule whose regex was killed at the sandbox budget
@@ -207,10 +237,10 @@ export class EvalEngine {
207
237
  const skippedParts = ruleResults
208
238
  .filter((r) => r.skipped)
209
239
  .map((r) => `${r.ruleName} (${r.skipReason ?? 'missing context'})`);
210
- suggestions.push(`${rulesSkipped} rule(s) skipped — excluded from the weighted score: ${skippedParts.join('; ')}`);
240
+ suggestions.push(`${overall.rulesSkipped} rule(s) skipped — excluded from the weighted score: ${skippedParts.join('; ')}`);
211
241
  }
212
- if (criticalSkipped.length > 0) {
213
- suggestions.push(`Critical rule(s) did NOT judge this output (${criticalSkipped.join(', ')}) — ` +
242
+ if (overall.criticalSkipped.length > 0) {
243
+ suggestions.push(`Critical rule(s) did NOT judge this output (${overall.criticalSkipped.join(', ')}) — ` +
214
244
  'they skipped, so they could not veto. This evaluation is "unknown" on those ' +
215
245
  'checks, not "clean"; a gate that must fail closed should treat critical_skipped ' +
216
246
  'as a failure.');
@@ -220,15 +250,119 @@ export class EvalEngine {
220
250
  eval_type: evalType,
221
251
  output_text: context.output,
222
252
  expected_text: context.expected,
223
- score: Math.round(score * 1000) / 1000,
224
- passed,
253
+ score: Math.round(overall.score * 1000) / 1000,
254
+ passed: overall.passed,
225
255
  rule_results: ruleResults,
226
256
  suggestions,
227
- rules_evaluated: rulesEvaluated,
228
- rules_skipped: rulesSkipped,
257
+ rules_evaluated: overall.rulesEvaluated,
258
+ rules_skipped: overall.rulesSkipped,
229
259
  insufficient_data: false,
230
- ...(criticalFailures.length > 0 ? { critical_failures: criticalFailures } : {}),
231
- ...(criticalSkipped.length > 0 ? { critical_skipped: criticalSkipped } : {}),
260
+ ...(overall.criticalFailures.length > 0 ? { critical_failures: overall.criticalFailures } : {}),
261
+ ...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
262
+ ...(perCategory ? { categories: perCategory } : {}),
232
263
  };
233
264
  }
265
+ /**
266
+ * Weighted average over the rules that ran, plus the critical veto.
267
+ *
268
+ * Critical rules hard-fail. Before this existed, the weighted average
269
+ * routinely outvoted a genuine violation: an output containing a real
270
+ * SSN failed no_pii while the other safety rules passed, landing at
271
+ * ~0.765 — over the 0.7 threshold — so `passed`, the one field every
272
+ * automated gate keys on, said true about the product's flagship
273
+ * failure scenario. A detection that reports an all-clear is worse
274
+ * than no detection.
275
+ *
276
+ * Only EVALUATED failures count: a critical rule that skipped (missing
277
+ * context, broken config) has not judged the output and must not veto
278
+ * it. The score is left as-is — it stays a quality gradient; `passed`
279
+ * is the verdict, and the two answer different questions.
280
+ *
281
+ * A critical rule that SKIPPED is the fail-open seam between the
282
+ * release's two headline features: an adversary who knows a deployed
283
+ * critical regex can craft output that stalls it past the sandbox
284
+ * budget, and the rule then neither judges nor vetoes — so the eval
285
+ * returns passed=true with an EMPTY critical_failures on output that
286
+ * nobody actually cleared. The trade-off is deliberate (failing closed
287
+ * would let the same adversary force false violations on benign
288
+ * output), but before `criticalSkipped` the only trace of it was a
289
+ * suggestions line — prose. A gate that must fail closed should not
290
+ * have to walk rule_results[].budgetExceeded to discover it was defeated.
291
+ */
292
+ summarize(rules, ruleResults) {
293
+ const evaluatedIndices = [];
294
+ const skippedIndices = [];
295
+ for (let i = 0; i < ruleResults.length; i++) {
296
+ if (ruleResults[i].skipped) {
297
+ skippedIndices.push(i);
298
+ }
299
+ else {
300
+ evaluatedIndices.push(i);
301
+ }
302
+ }
303
+ const criticalSkipped = skippedIndices
304
+ .filter((i) => rules[i].critical === true)
305
+ .map((i) => ruleResults[i].ruleName);
306
+ if (evaluatedIndices.length === 0) {
307
+ return {
308
+ score: 0,
309
+ passed: false,
310
+ rulesEvaluated: 0,
311
+ rulesSkipped: skippedIndices.length,
312
+ criticalFailures: [],
313
+ criticalSkipped,
314
+ };
315
+ }
316
+ // Weighted average across evaluated rules only (exclude skipped)
317
+ const totalWeight = evaluatedIndices.reduce((sum, i) => sum + rules[i].weight, 0);
318
+ const weightedScore = evaluatedIndices.reduce((sum, i) => {
319
+ const ruleScore = Number.isFinite(ruleResults[i].score) ? ruleResults[i].score : 0;
320
+ return sum + ruleScore * rules[i].weight;
321
+ }, 0);
322
+ const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
323
+ const score = Number.isFinite(rawScore) ? rawScore : 0;
324
+ const criticalFailures = evaluatedIndices
325
+ .filter((i) => rules[i].critical === true && !ruleResults[i].passed)
326
+ .map((i) => ruleResults[i].ruleName);
327
+ return {
328
+ score,
329
+ passed: score >= this.threshold && criticalFailures.length === 0,
330
+ rulesEvaluated: evaluatedIndices.length,
331
+ rulesSkipped: skippedIndices.length,
332
+ criticalFailures,
333
+ criticalSkipped,
334
+ };
335
+ }
336
+ /** The per-bundle breakdown for eval_type="all": summarize() over each bundle's slice. */
337
+ categorize(rules, ruleResults, categories) {
338
+ const breakdown = {};
339
+ for (const type of ALL_EVAL_TYPES) {
340
+ const indices = categories.flatMap((c, i) => (c === type ? [i] : []));
341
+ if (indices.length === 0)
342
+ continue;
343
+ const verdict = this.summarize(indices.map((i) => rules[i]), indices.map((i) => ruleResults[i]));
344
+ /*
345
+ * A bundle whose every rule skipped was not judged (#406). Reporting
346
+ * it as passed:false / score:0 read as "failing" to anyone regrouping
347
+ * by category — cost "failed" on a call that carried no cost data.
348
+ * Inside the breakdown, null is the honest value: neither passing
349
+ * nor failing, and it never counted toward the overall verdict
350
+ * (summarize() already excludes skipped rules). The TOP-LEVEL
351
+ * `passed` is deliberately not made nullable — it is the verdict a
352
+ * gate keys on, and a gate must fail closed when nothing was judged;
353
+ * `insufficient_data: true` is the "unknown" marker at that level.
354
+ */
355
+ const judged = verdict.rulesEvaluated > 0;
356
+ breakdown[type] = {
357
+ score: judged ? Math.round(verdict.score * 1000) / 1000 : null,
358
+ passed: judged ? verdict.passed : null,
359
+ rules_evaluated: verdict.rulesEvaluated,
360
+ rules_skipped: verdict.rulesSkipped,
361
+ insufficient_data: !judged,
362
+ ...(verdict.criticalFailures.length > 0 ? { critical_failures: verdict.criticalFailures } : {}),
363
+ ...(verdict.criticalSkipped.length > 0 ? { critical_skipped: verdict.criticalSkipped } : {}),
364
+ };
365
+ }
366
+ return breakdown;
367
+ }
234
368
  }
@@ -1,4 +1,17 @@
1
1
  import type { EvalRule } from '../../types/eval.js';
2
+ /**
3
+ * Light stemmer: plurals, -ing/-ed/-ly, -ation/-ator/-ate/-ion, a trailing
4
+ * e, and a doubled final consonant. Crude on purpose (see the header):
5
+ * both sides are stemmed identically.
6
+ */
7
+ export declare function stemTerm(word: string): string;
8
+ /**
9
+ * Content terms of a text: fenced code removed, camelCase split, everything
10
+ * that is not a run of three or more letters treated as a separator (so
11
+ * paths, flags, snake_case and dotted identifiers fall apart into their
12
+ * words and numbers vanish), stopwords dropped, the rest stemmed.
13
+ */
14
+ export declare function contentTerms(text: string): string[];
2
15
  export declare const keywordOverlap: EvalRule;
3
16
  export declare const topicConsistency: EvalRule;
4
17
  export declare const relevanceRules: EvalRule[];