@iris-eval/mcp-server 0.8.2 → 0.10.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 (131) hide show
  1. package/README.md +9 -2
  2. package/dist/capabilities.d.ts +64 -0
  3. package/dist/capabilities.js +65 -0
  4. package/dist/config/defaults.js +17 -0
  5. package/dist/custom-rule-store.d.ts +4 -0
  6. package/dist/custom-rule-store.js +8 -3
  7. package/dist/dashboard/assets/{index-CyzO6OC7.js → index-CeJbaq6m.js} +1 -1
  8. package/dist/dashboard/index.html +1 -1
  9. package/dist/dashboard/routes/capabilities.d.ts +3 -0
  10. package/dist/dashboard/routes/capabilities.js +11 -0
  11. package/dist/dashboard/routes/health.d.ts +5 -1
  12. package/dist/dashboard/routes/health.js +15 -3
  13. package/dist/dashboard/routes/rules.js +4 -1
  14. package/dist/dashboard/routes/traces.d.ts +3 -0
  15. package/dist/dashboard/routes/traces.js +11 -30
  16. package/dist/dashboard/seed-demo-data.js +1 -1
  17. package/dist/dashboard/server.d.ts +2 -0
  18. package/dist/dashboard/server.js +6 -2
  19. package/dist/eval/accuracy.d.ts +41 -0
  20. package/dist/eval/accuracy.js +97 -0
  21. package/dist/eval/citation-verify/verifier.d.ts +16 -1
  22. package/dist/eval/citation-verify/verifier.js +14 -4
  23. package/dist/eval/compose.d.ts +57 -0
  24. package/dist/eval/compose.js +179 -0
  25. package/dist/eval/criticality.d.ts +15 -1
  26. package/dist/eval/criticality.js +6 -0
  27. package/dist/eval/decision-moment.js +33 -4
  28. package/dist/eval/dormant.d.ts +4 -0
  29. package/dist/eval/dormant.js +22 -0
  30. package/dist/eval/engine.d.ts +6 -2
  31. package/dist/eval/engine.js +126 -12
  32. package/dist/eval/failure-classes.d.ts +8 -0
  33. package/dist/eval/failure-classes.js +18 -0
  34. package/dist/eval/llm-judge/evaluator.d.ts +30 -0
  35. package/dist/eval/llm-judge/evaluator.js +26 -2
  36. package/dist/eval/published-accuracy.d.ts +230 -0
  37. package/dist/eval/published-accuracy.js +86 -0
  38. package/dist/eval/questions.d.ts +12 -0
  39. package/dist/eval/questions.js +14 -0
  40. package/dist/eval/response-schema.d.ts +652 -0
  41. package/dist/eval/response-schema.js +130 -0
  42. package/dist/eval/response.d.ts +12 -0
  43. package/dist/eval/response.js +30 -0
  44. package/dist/eval/risk.d.ts +60 -0
  45. package/dist/eval/risk.js +187 -0
  46. package/dist/eval/rules/completeness.js +36 -1
  47. package/dist/eval/rules/cost.d.ts +2 -2
  48. package/dist/eval/rules/cost.js +50 -6
  49. package/dist/eval/rules/custom.d.ts +0 -12
  50. package/dist/eval/rules/custom.js +22 -0
  51. package/dist/eval/rules/relevance.js +23 -2
  52. package/dist/eval/rules/safety.d.ts +6 -2
  53. package/dist/eval/rules/safety.js +224 -51
  54. package/dist/eval/seeded-random.d.ts +4 -0
  55. package/dist/eval/seeded-random.js +36 -0
  56. package/dist/eval/stamp.d.ts +14 -0
  57. package/dist/eval/stamp.js +89 -0
  58. package/dist/eval/stats.d.ts +33 -0
  59. package/dist/eval/stats.js +109 -0
  60. package/dist/eval/text/checksums.d.ts +23 -0
  61. package/dist/eval/text/checksums.js +97 -0
  62. package/dist/eval/text/normalise.d.ts +30 -0
  63. package/dist/eval/text/normalise.js +265 -0
  64. package/dist/eval/text/sentences.d.ts +15 -0
  65. package/dist/eval/text/sentences.js +149 -0
  66. package/dist/eval/verdict.d.ts +34 -0
  67. package/dist/eval/verdict.js +131 -0
  68. package/dist/index.js +5 -28
  69. package/dist/instructions.d.ts +17 -0
  70. package/dist/instructions.js +53 -0
  71. package/dist/judge-enablement.d.ts +34 -0
  72. package/dist/judge-enablement.js +78 -0
  73. package/dist/judge-enablement.json +10 -0
  74. package/dist/preferences.d.ts +1 -1
  75. package/dist/prompts.d.ts +3 -0
  76. package/dist/prompts.js +29 -0
  77. package/dist/resources/index.d.ts +5 -2
  78. package/dist/resources/index.js +65 -5
  79. package/dist/resources/uris.d.ts +12 -0
  80. package/dist/resources/uris.js +24 -0
  81. package/dist/retention.d.ts +20 -0
  82. package/dist/retention.js +44 -0
  83. package/dist/self-test.d.ts +1 -0
  84. package/dist/self-test.js +17 -3
  85. package/dist/server.d.ts +10 -1
  86. package/dist/server.js +34 -7
  87. package/dist/storage/index.js +1 -1
  88. package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
  89. package/dist/storage/migrations/007-eval-provenance.js +30 -0
  90. package/dist/storage/migrations/index.js +24 -4
  91. package/dist/storage/sqlite-adapter.d.ts +26 -1
  92. package/dist/storage/sqlite-adapter.js +149 -15
  93. package/dist/tools/delete-rule.d.ts +8 -0
  94. package/dist/tools/delete-rule.js +30 -38
  95. package/dist/tools/delete-trace.d.ts +5 -0
  96. package/dist/tools/delete-trace.js +24 -27
  97. package/dist/tools/deploy-rule.d.ts +13 -1
  98. package/dist/tools/deploy-rule.js +37 -34
  99. package/dist/tools/describe.d.ts +20 -0
  100. package/dist/tools/describe.js +36 -0
  101. package/dist/tools/errors.d.ts +36 -0
  102. package/dist/tools/errors.js +134 -0
  103. package/dist/tools/evaluate-output.d.ts +8 -1
  104. package/dist/tools/evaluate-output.js +39 -60
  105. package/dist/tools/evaluate-with-llm-judge.d.ts +34 -0
  106. package/dist/tools/evaluate-with-llm-judge.js +124 -69
  107. package/dist/tools/get-traces.d.ts +9 -0
  108. package/dist/tools/get-traces.js +29 -28
  109. package/dist/tools/index.d.ts +8 -0
  110. package/dist/tools/index.js +22 -1
  111. package/dist/tools/list-rules.d.ts +13 -0
  112. package/dist/tools/list-rules.js +43 -46
  113. package/dist/tools/log-trace.d.ts +4 -0
  114. package/dist/tools/log-trace.js +31 -29
  115. package/dist/tools/respond.d.ts +42 -0
  116. package/dist/tools/respond.js +90 -0
  117. package/dist/tools/strict-input.js +1 -1
  118. package/dist/tools/trace-link.d.ts +2 -0
  119. package/dist/tools/trace-link.js +13 -2
  120. package/dist/tools/verify-citations.d.ts +18 -2
  121. package/dist/tools/verify-citations.js +122 -96
  122. package/dist/types/config.d.ts +44 -0
  123. package/dist/types/eval.d.ts +309 -0
  124. package/dist/types/eval.js +2 -1
  125. package/dist/types/query.d.ts +2 -0
  126. package/package.json +1 -1
  127. package/server.json +2 -2
  128. package/dist/resources/dashboard-summary.d.ts +0 -3
  129. package/dist/resources/dashboard-summary.js +0 -16
  130. package/dist/resources/trace-detail.d.ts +0 -3
  131. package/dist/resources/trace-detail.js +0 -30
@@ -4,6 +4,8 @@ import * as migration003 from './003-eval-passed-index.js';
4
4
  import * as migration004 from './004-tenant-id.js';
5
5
  import * as migration005 from './005-normalize-created-at.js';
6
6
  import * as migration006 from './006-eval-critical-failures.js';
7
+ import * as migration007 from './007-eval-provenance.js';
8
+ import { PKG_VERSION } from '../../config/defaults.js';
7
9
  const migrations = [
8
10
  migration001,
9
11
  migration002,
@@ -11,6 +13,7 @@ const migrations = [
11
13
  migration004,
12
14
  migration005,
13
15
  migration006,
16
+ migration007,
14
17
  ];
15
18
  export function runMigrations(db) {
16
19
  db.exec(`
@@ -19,10 +22,24 @@ export function runMigrations(db) {
19
22
  applied_at TEXT NOT NULL DEFAULT (datetime('now'))
20
23
  )
21
24
  `);
22
- const applied = new Set(db
23
- .prepare('SELECT id FROM _iris_migrations')
24
- .all()
25
- .map((row) => row.id));
25
+ const known = new Set(migrations.map((m) => m.id));
26
+ const hasWriterVersion = db.prepare("PRAGMA table_info('_iris_migrations')").all().some((c) => c.name === 'writer_version');
27
+ const appliedRows = db
28
+ .prepare(hasWriterVersion ? 'SELECT id, writer_version FROM _iris_migrations' : 'SELECT id, NULL AS writer_version FROM _iris_migrations')
29
+ .all();
30
+ /*
31
+ * A downgrade guard (0.9.0). Before it, a binary that did not know a
32
+ * migration silently ignored it and read a schema newer than itself —
33
+ * half the columns, none of the meaning. Now an applied id this build has
34
+ * never heard of refuses to start, naming the version that wrote it, so
35
+ * the operator upgrades instead of corrupting.
36
+ */
37
+ const unknown = appliedRows.filter((r) => !known.has(r.id));
38
+ if (unknown.length > 0) {
39
+ const writers = [...new Set(unknown.map((r) => r.writer_version ?? 'an unknown version'))].join(', ');
40
+ throw new Error(`This database was migrated by a newer Iris (${writers}) — migration(s) ${unknown.map((r) => r.id).join(', ')} are unknown to v${PKG_VERSION}. Upgrade Iris, or point IRIS_DB_PATH at a database this version wrote.`);
41
+ }
42
+ const applied = new Set(appliedRows.map((r) => r.id));
26
43
  for (const migration of migrations) {
27
44
  if (!applied.has(migration.id)) {
28
45
  db.transaction(() => {
@@ -31,4 +48,7 @@ export function runMigrations(db) {
31
48
  })();
32
49
  }
33
50
  }
51
+ // Every applied migration names the binary that applied it (this one, for
52
+ // rows written before the column existed — the closest true statement).
53
+ db.prepare('UPDATE _iris_migrations SET writer_version = ? WHERE writer_version IS NULL').run(PKG_VERSION);
34
54
  }
@@ -2,10 +2,19 @@ import type { IStorageAdapter, DashboardSummary, TraceQueryOptions, TraceQueryRe
2
2
  import type { Trace, Span } from '../types/trace.js';
3
3
  import type { EvalResult } from '../types/eval.js';
4
4
  import type { TenantId } from '../types/tenant.js';
5
+ export type RedactMode = 'none' | 'critical_spans';
6
+ export interface SqliteAdapterOptions {
7
+ /** storage.redact — replace the spans a critical detector flagged in the stored text. */
8
+ redact?: RedactMode;
9
+ }
10
+ /** What every text field of an erased evaluation reads afterwards. */
11
+ export declare const ERASED_MESSAGE = "erased with the trace";
5
12
  export declare class SqliteAdapter implements IStorageAdapter {
6
13
  private db;
7
14
  private readonly dbPath;
8
- constructor(dbPath: string);
15
+ /** storage.redact — see SqliteAdapterOptions. */
16
+ private readonly redact;
17
+ constructor(dbPath: string, options?: SqliteAdapterOptions);
9
18
  initialize(): Promise<void>;
10
19
  close(): Promise<void>;
11
20
  insertTrace(tenantId: TenantId, trace: Trace): Promise<void>;
@@ -15,6 +24,7 @@ export declare class SqliteAdapter implements IStorageAdapter {
15
24
  getSpansByTraceId(tenantId: TenantId, traceId: string): Promise<Span[]>;
16
25
  insertEvalResult(tenantId: TenantId, result: EvalResult): Promise<void>;
17
26
  getEvalsByTraceId(tenantId: TenantId, traceId: string): Promise<EvalResult[]>;
27
+ getEvalById(tenantId: TenantId, id: string): Promise<EvalResult | null>;
18
28
  queryEvalResults(tenantId: TenantId, options: {
19
29
  eval_type?: string;
20
30
  passed?: boolean;
@@ -41,6 +51,21 @@ export declare class SqliteAdapter implements IStorageAdapter {
41
51
  }>;
42
52
  checkpoint(): Promise<void>;
43
53
  deleteTrace(tenantId: TenantId, traceId: string): Promise<boolean>;
54
+ /**
55
+ * Blank every text field of the evaluations linked to these traces and
56
+ * stamp erased_at (migration 007). Verdict, scores, criticality and the
57
+ * evidence OFFSETS stay — they carry no text — so history and drift
58
+ * analytics keep working over an erased row.
59
+ */
60
+ private eraseEvaluationsOfTraces;
61
+ /**
62
+ * storage.redact = 'critical_spans': the spans a critical detector fired
63
+ * on are replaced in the STORED text by [REDACTED:<pattern>]. The
64
+ * evidence offsets are left as computed — they index the text the caller
65
+ * saw, which is what a reader of the evidence needs — and the option's
66
+ * documentation says so.
67
+ */
68
+ private storedOutputText;
44
69
  getDistinctValues(tenantId: TenantId, column: string): Promise<string[]>;
45
70
  private rowToTrace;
46
71
  private rowToSpan;
@@ -21,6 +21,8 @@
21
21
  */
22
22
  import Database from 'better-sqlite3';
23
23
  import { ensureOwnerOnly } from '../utils/write-atomic.js';
24
+ import { deriveCoverage, deriveCriticalSkipped, deriveVerdict } from '../eval/verdict.js';
25
+ import { compose, DEFAULT_COMPOSE } from '../eval/compose.js';
24
26
  import { TenantContextRequiredError } from '../types/tenant.js';
25
27
  import { runMigrations } from './migrations/index.js';
26
28
  const ALLOWED_SORT_COLUMNS = new Set(['timestamp', 'latency_ms', 'cost_usd']);
@@ -34,11 +36,16 @@ function assertTenant(tenantId) {
34
36
  throw new TenantContextRequiredError('SqliteAdapter method invoked without a valid TenantId; refusing to query');
35
37
  }
36
38
  }
39
+ /** What every text field of an erased evaluation reads afterwards. */
40
+ export const ERASED_MESSAGE = 'erased with the trace';
37
41
  export class SqliteAdapter {
38
42
  db;
39
43
  dbPath;
40
- constructor(dbPath) {
44
+ /** storage.redact — see SqliteAdapterOptions. */
45
+ redact;
46
+ constructor(dbPath, options) {
41
47
  this.dbPath = dbPath;
48
+ this.redact = options?.redact ?? 'none';
42
49
  this.db = new Database(dbPath);
43
50
  }
44
51
  async initialize() {
@@ -55,7 +62,14 @@ export class SqliteAdapter {
55
62
  * alternative is the whole point of #372.
56
63
  */
57
64
  this.db.pragma('secure_delete = ON');
58
- runMigrations(this.db);
65
+ try {
66
+ runMigrations(this.db);
67
+ }
68
+ catch (err) {
69
+ // A refused boot (a newer writer, a failed migration) must not leak the handle.
70
+ this.db.close();
71
+ throw err;
72
+ }
59
73
  /*
60
74
  * iris.db holds agent inputs and outputs verbatim, and a tool that
61
75
  * detects PII necessarily stores the PII it found. better-sqlite3
@@ -203,10 +217,16 @@ export class SqliteAdapter {
203
217
  * no surface could filter, count, or explain the release's flagship
204
218
  * behaviour. NULL when nothing vetoed.
205
219
  */
220
+ /*
221
+ * Provenance (migration 007) is the part of the receipt a row cannot
222
+ * reconstruct: the release, the ruleset and config hashes, the threshold.
223
+ * verdict / coverage / critical_skipped are derived on every read from
224
+ * rule_results plus that threshold, so they are not columns.
225
+ */
206
226
  this.db.prepare(`
207
- INSERT INTO eval_results (tenant_id, id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data, critical_failures, created_at)
208
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
209
- `).run(tenantId, result.id, result.trace_id ?? null, result.eval_type, result.output_text, result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions), result.rules_evaluated ?? null, result.rules_skipped ?? null, result.insufficient_data ? 1 : 0, result.critical_failures?.length ? JSON.stringify(result.critical_failures) : null, new Date().toISOString());
227
+ INSERT INTO eval_results (tenant_id, id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data, critical_failures, created_at, provenance, engine_version, ruleset_hash, config_hash, threshold, eval_cost_usd, eval_tokens)
228
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
229
+ `).run(tenantId, result.id, result.trace_id ?? null, result.eval_type, this.storedOutputText(result), result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions), result.rules_evaluated ?? null, result.rules_skipped ?? null, result.insufficient_data ? 1 : 0, result.critical_failures?.length ? JSON.stringify(result.critical_failures) : null, new Date().toISOString(), result.provenance ? JSON.stringify(result.provenance) : null, result.provenance?.irisVersion ?? null, result.provenance?.rulesetHash ?? null, result.provenance?.configHash ?? null, result.provenance?.thresholds.default ?? null, result.eval_cost_usd ?? null, result.eval_tokens ?? null);
210
230
  }
211
231
  async getEvalsByTraceId(tenantId, traceId) {
212
232
  assertTenant(tenantId);
@@ -215,6 +235,13 @@ export class SqliteAdapter {
215
235
  .all(tenantId, traceId);
216
236
  return rows.map((row) => this.rowToEvalResult(row));
217
237
  }
238
+ async getEvalById(tenantId, id) {
239
+ assertTenant(tenantId);
240
+ const row = this.db
241
+ .prepare('SELECT * FROM eval_results WHERE tenant_id = ? AND id = ?')
242
+ .get(tenantId, id);
243
+ return row ? this.rowToEvalResult(row) : null;
244
+ }
218
245
  async queryEvalResults(tenantId, options) {
219
246
  assertTenant(tenantId);
220
247
  const conditions = ['tenant_id = ?'];
@@ -504,10 +531,14 @@ export class SqliteAdapter {
504
531
  async deleteTracesOlderThan(tenantId, days) {
505
532
  assertTenant(tenantId);
506
533
  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
507
- const result = this.db
508
- .prepare('DELETE FROM traces WHERE tenant_id = ? AND timestamp < ?')
509
- .run(tenantId, cutoff);
510
- return result.changes;
534
+ // Same erasure as deleteTrace: an evaluation younger than the window
535
+ // whose trace is swept keeps its verdict and loses its text.
536
+ const run = this.db.transaction((tid, cut) => {
537
+ const ids = this.db.prepare('SELECT trace_id FROM traces WHERE tenant_id = ? AND timestamp < ?').all(tid, cut).map((r) => r.trace_id);
538
+ this.eraseEvaluationsOfTraces(tid, ids);
539
+ return this.db.prepare('DELETE FROM traces WHERE tenant_id = ? AND timestamp < ?').run(tid, cut).changes;
540
+ });
541
+ return run(tenantId, cutoff);
511
542
  }
512
543
  async deleteEvalResultsOlderThan(tenantId, days) {
513
544
  assertTenant(tenantId);
@@ -563,10 +594,80 @@ export class SqliteAdapter {
563
594
  // Tenant-scoped: a trace id owned by a different tenant is
564
595
  // untouchable from this call. Cross-tenant deletions are not just
565
596
  // denied — they're invisible (no indication the id even exists).
566
- const result = this.db
567
- .prepare('DELETE FROM traces WHERE tenant_id = ? AND trace_id = ?')
568
- .run(tenantId, traceId);
569
- return result.changes > 0;
597
+ //
598
+ // The right-to-erasure fix: eval_results.trace_id is ON DELETE SET
599
+ // NULL, so the delete alone left every linked evaluation behind with
600
+ // output_text verbatim — including what no_pii had flagged — orphaned
601
+ // and readable. The text is erased in the same transaction, BEFORE the
602
+ // FK can orphan the rows.
603
+ const run = this.db.transaction((tid, id) => {
604
+ const exists = this.db.prepare('SELECT 1 FROM traces WHERE tenant_id = ? AND trace_id = ?').get(tid, id);
605
+ if (!exists)
606
+ return 0;
607
+ this.eraseEvaluationsOfTraces(tid, [id]);
608
+ return this.db.prepare('DELETE FROM traces WHERE tenant_id = ? AND trace_id = ?').run(tid, id).changes;
609
+ });
610
+ return run(tenantId, traceId) > 0;
611
+ }
612
+ /**
613
+ * Blank every text field of the evaluations linked to these traces and
614
+ * stamp erased_at (migration 007). Verdict, scores, criticality and the
615
+ * evidence OFFSETS stay — they carry no text — so history and drift
616
+ * analytics keep working over an erased row.
617
+ */
618
+ eraseEvaluationsOfTraces(tenantId, traceIds) {
619
+ if (traceIds.length === 0)
620
+ return 0;
621
+ const now = new Date().toISOString();
622
+ const select = this.db.prepare('SELECT id, rule_results FROM eval_results WHERE tenant_id = ? AND trace_id = ?');
623
+ const update = this.db.prepare('UPDATE eval_results SET output_text = ?, expected_text = NULL, suggestions = ?, rule_results = ?, erased_at = ? WHERE tenant_id = ? AND id = ?');
624
+ let erased = 0;
625
+ for (const traceId of traceIds) {
626
+ for (const row of select.all(tenantId, traceId)) {
627
+ let rules = [];
628
+ try {
629
+ rules = JSON.parse(row.rule_results);
630
+ }
631
+ catch {
632
+ rules = [];
633
+ }
634
+ const erasedRules = rules.map((r) => ({
635
+ ...r,
636
+ message: ERASED_MESSAGE,
637
+ ...(r.skipReason ? { skipReason: ERASED_MESSAGE } : {}),
638
+ }));
639
+ update.run('', '[]', JSON.stringify(erasedRules), now, tenantId, row.id);
640
+ erased += 1;
641
+ }
642
+ }
643
+ return erased;
644
+ }
645
+ /**
646
+ * storage.redact = 'critical_spans': the spans a critical detector fired
647
+ * on are replaced in the STORED text by [REDACTED:<pattern>]. The
648
+ * evidence offsets are left as computed — they index the text the caller
649
+ * saw, which is what a reader of the evidence needs — and the option's
650
+ * documentation says so.
651
+ */
652
+ storedOutputText(result) {
653
+ const text = result.output_text;
654
+ if (this.redact !== 'critical_spans' || !text)
655
+ return text ?? null;
656
+ const spans = result.rule_results
657
+ .filter((r) => r.critical === true && !r.passed && !r.skipped)
658
+ .flatMap((r) => (r.evidence ?? []).filter((e) => e.type === 'span' && e.source === 'output'));
659
+ if (spans.length === 0)
660
+ return text;
661
+ const seen = new Set();
662
+ let out = text;
663
+ for (const s of [...spans].sort((a, b) => b.start - a.start)) {
664
+ const key = `${s.start}:${s.end}`;
665
+ if (seen.has(key) || s.start >= s.end || s.end > out.length)
666
+ continue;
667
+ seen.add(key);
668
+ out = `${out.slice(0, s.start)}[REDACTED:${s.label}]${out.slice(s.end)}`;
669
+ }
670
+ return out;
570
671
  }
571
672
  async getDistinctValues(tenantId, column) {
572
673
  assertTenant(tenantId);
@@ -613,7 +714,7 @@ export class SqliteAdapter {
613
714
  };
614
715
  }
615
716
  rowToEvalResult(row) {
616
- return {
717
+ const result = {
617
718
  id: row.id,
618
719
  trace_id: row.trace_id,
619
720
  eval_type: row.eval_type,
@@ -623,7 +724,8 @@ export class SqliteAdapter {
623
724
  * regroup the per-bundle breakdown from what IS stored.
624
725
  */
625
726
  output_text: row.output_text,
626
- expected_text: row.expected_text,
727
+ expected_text: row.expected_text ?? undefined,
728
+ ...(row.erased_at ? { erased_at: row.erased_at } : {}),
627
729
  score: row.score,
628
730
  passed: row.passed === 1,
629
731
  rule_results: JSON.parse(row.rule_results),
@@ -640,6 +742,38 @@ export class SqliteAdapter {
640
742
  ...(row.critical_failures != null
641
743
  ? { critical_failures: JSON.parse(row.critical_failures) }
642
744
  : {}),
745
+ ...(row.eval_cost_usd != null ? { eval_cost_usd: row.eval_cost_usd } : {}),
746
+ ...(row.eval_tokens != null ? { eval_tokens: row.eval_tokens } : {}),
747
+ ...(row.provenance != null ? { provenance: JSON.parse(row.provenance) } : {}),
643
748
  };
749
+ /*
750
+ * Derived on every read, never stored (0.9.0): the critical rules that
751
+ * skipped (from the stamped flags — absent for rows older than those
752
+ * flags, never []), the coverage by question, and the verdict with its
753
+ * basis — the last only when the row carries the threshold it was judged
754
+ * against, because a basis guessed against today's threshold would be a
755
+ * fabrication about that day.
756
+ */
757
+ const criticalSkipped = deriveCriticalSkipped(result.rule_results);
758
+ if (criticalSkipped)
759
+ result.critical_skipped = criticalSkipped;
760
+ if (result.rule_results.some((r) => r.question !== undefined))
761
+ result.coverage = deriveCoverage(result.rule_results);
762
+ /*
763
+ * Read back with the SAME composer that wrote it, or a stored row would
764
+ * report a different verdict than the one the caller was given. The
765
+ * config is not stored (only its hash), so this composes under the
766
+ * shipped defaults — which is what a default-configured server used, and
767
+ * what `eval.composer: "legacy"` selects for a deployment that has not
768
+ * moved yet. A row written before the verdict existed still reads back
769
+ * with none: absent, never fabricated.
770
+ */
771
+ if (result.provenance) {
772
+ result.verdict =
773
+ DEFAULT_COMPOSE.composer === 'legacy'
774
+ ? deriveVerdict(result, result.provenance.thresholds.default)
775
+ : compose(result, DEFAULT_COMPOSE);
776
+ }
777
+ return result;
644
778
  }
645
779
  }
@@ -1,4 +1,12 @@
1
+ import { z } from 'zod';
1
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import type { CustomRuleStore } from '../custom-rule-store.js';
3
4
  import type { EvalEngine } from '../eval/engine.js';
5
+ export declare const deleteRuleOutputSchema: z.ZodObject<{
6
+ deleted: z.ZodBoolean;
7
+ rule_id: z.ZodString;
8
+ toggled: z.ZodOptional<z.ZodBoolean>;
9
+ enabled: z.ZodOptional<z.ZodBoolean>;
10
+ rule: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
11
+ }, z.core.$loose>;
4
12
  export declare function registerDeleteRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;
@@ -22,6 +22,8 @@ import { z } from 'zod';
22
22
  import { createCustomRule } from '../eval/rules/custom.js';
23
23
  import { LOCAL_TENANT } from '../types/tenant.js';
24
24
  import { strictInput } from './strict-input.js';
25
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
26
+ import { guarded, respond } from './respond.js';
25
27
  const inputSchema = {
26
28
  rule_id: z
27
29
  .string()
@@ -32,41 +34,45 @@ const inputSchema = {
32
34
  .optional()
33
35
  .describe('When present the rule is NOT deleted: false DISABLES it (kept in the store, stops firing immediately, history and provenance preserved); true RE-ENABLES a disabled rule. Omit to delete'),
34
36
  };
37
+ export const deleteRuleOutputSchema = z.looseObject({
38
+ deleted: z.boolean().describe('true when a rule was removed; always false on a toggle'),
39
+ rule_id: z.string().describe('the id that was asked for'),
40
+ toggled: z.boolean().optional().describe('toggle only: true when the rule exists (also when it was already in the requested state)'),
41
+ enabled: z.boolean().optional().describe('toggle only: the rule\'s state after the call'),
42
+ rule: z.looseObject({}).optional().describe('toggle only: the rule as stored'),
43
+ });
35
44
  export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
36
45
  server.registerTool('delete_rule', {
37
46
  title: 'Delete or Disable Custom Rule',
38
- description: [
39
- 'Remove a deployed custom evaluation rule — or, with `enabled`, disable / re-enable it without removing it. Either way the change takes effect on the next evaluate_output call; past eval_results that referenced the rule are preserved.',
40
- '',
41
- 'Sibling tools — deploy_rule adds custom rules, list_rules enumerates them (including disabled ones, with `enabled: false`), evaluate_output runs them. delete_trace handles trace deletion (separate concern); log_trace / get_traces handle trace I/O. delete_rule is the DESTRUCTIVE remove path for the custom-rule store and the only MCP path that toggles a rule; it does NOT touch traces, eval_results, or built-in (non-custom) rules.',
42
- '',
43
- 'Behavior. Without `enabled`: DESTRUCTIVE — rewrites ~/.iris/custom-rules.json without the deleted row and appends a `rule.delete` entry to the audit log (~/.iris/audit.log). Not idempotent: deleting an already-deleted rule returns `deleted: false` rather than re-emitting the audit row. The rule stops firing immediately on the live process. With `enabled`: NOT destructive — the rule row stays, its `enabled` flag and `updatedAt` change, a `rule.toggle` audit entry is appended (none if the flag was already in that state), and the live engine unregisters (false) or re-registers (true) the rule so the change is immediate; a disabled rule is not loaded at the next boot either. Historical eval_results that reference this rule_id stay in the database — drift analytics + audit trail remain valid. Operates on the local tenant. Rate-limited to 20 req/min on HTTP MCP.',
44
- '',
45
- 'Output shape. Delete: `{ "deleted": boolean, "rule_id": string }` `deleted=true` if a row was removed; `deleted=false` if no rule with that id existed. Toggle (enabled given): `{ "deleted": false, "toggled": boolean, "rule_id": string, "enabled"?: boolean, "rule"?: { ...the rule } }` — `toggled=true` with the rule\'s current state when the id exists (also when it was already in the requested state), `toggled=false` and no `rule` when it does not.',
46
- '',
47
- "Use when a custom rule is obsolete (behavior changed, false positives unacceptable, replaced by a better rule). Typical flow: list_rules → identify the stale one → delete_rule(id). Combine with deploy_rule to replace: delete_rule(oldId) + deploy_rule(newDefinition), or deploy_rule with the same name and replace:true. To temporarily PAUSE a rule — false positives to investigate, a rollout to stage — pass `enabled: false` instead of deleting; it keeps the id, the provenance and the history, and `enabled: true` brings it back with the same id.",
48
- '',
49
- "Don't use on built-in (non-custom) rules — the rule_id format checks for `rule-<hex>` custom ids; built-ins aren't in the store. Don't use to delete a trace or eval result (use delete_trace for traces; eval_results deletion is not exposed per row — they fall under data retention and `--purge`).",
50
- '',
51
- 'Parameters. rule_id must match `rule-<lowercase-hex>` format (Zod regex). Format mismatch fails Zod with 400 BEFORE the store is touched. Cross-tenant rule_ids return `deleted: false` / `toggled: false` silently — they\'re invisible to the caller\'s tenant rather than producing a not-found error (prevents enumeration attacks). The rule_id you pass is exactly what list_rules returned in `id` or what deploy_rule returned in `rule.id`. enabled is optional: omit to delete, false to disable, true to re-enable.',
52
- '',
53
- "Error modes. Throws 400 on malformed rule_id (wrong prefix) or an unknown argument. Returns `{deleted: false}` (or `{toggled: false}`) if rule_id doesn't match any deployed rule (not an error — idempotent-ish). Returns 429 on HTTP rate limit. File-write failures propagate as 500.",
54
- ].join('\n'),
47
+ description: describeTool({
48
+ summary: 'Remove a deployed custom rule — or, with enabled, disable or re-enable it without removing it effective on the next evaluate_output call.',
49
+ does: 'Without enabled: deletes the rule from ~/.iris/custom-rules.json, appends a rule.delete audit entry and unregisters it from the running engine; deleted is false when no rule has that id (already gone, or not this tenant\'s), and no audit row is written twice. ' +
50
+ 'With enabled: the rule stays with its history and provenance; false stops it firing at once and keeps it off across restarts, true brings it back under the same id; a rule.toggle audit entry is written unless the flag was already in that state. ' +
51
+ 'Past evaluations that referenced the rule are untouched either way.',
52
+ whenNot: 'On built-in rules: they are not in the store and cannot be deleted or disabled. To delete a trace (delete_trace). To replace a rule: deploy_rule with the same name and replace: true.',
53
+ returns: deleteRuleOutputSchema,
54
+ errors: 'IRIS_STORAGE_ERROR when the store cannot be written. A malformed rule_id (not rule-<hex>) or an unknown argument is refused before the handler runs. ' +
55
+ ERROR_ENVELOPE_SENTENCE,
56
+ siblings: {
57
+ deploy_rule: 'add or replace a rule',
58
+ list_rules: 'find the id',
59
+ evaluate_output: 'where the rule fires',
60
+ },
61
+ }),
55
62
  inputSchema: strictInput(inputSchema),
63
+ outputSchema: deleteRuleOutputSchema,
56
64
  annotations: {
57
65
  readOnlyHint: false,
58
66
  destructiveHint: true,
59
67
  idempotentHint: false,
60
68
  openWorldHint: false,
61
69
  },
62
- }, async (args) => {
70
+ }, guarded(async (args) => {
63
71
  // OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
64
72
  if (args.enabled !== undefined) {
65
73
  const rule = customRuleStore.setEnabled(LOCAL_TENANT, args.rule_id, args.enabled, 'mcp');
66
74
  if (!rule) {
67
- return {
68
- content: [{ type: 'text', text: JSON.stringify({ deleted: false, toggled: false, rule_id: args.rule_id }) }],
69
- };
75
+ return respond(deleteRuleOutputSchema, { deleted: false, toggled: false, rule_id: args.rule_id });
70
76
  }
71
77
  // Mirror the store in the live engine, so the toggle is immediate
72
78
  // (registerRule is idempotent by id — re-enabling an already-live
@@ -77,14 +83,7 @@ export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
77
83
  else {
78
84
  evalEngine.unregisterRule(rule.id);
79
85
  }
80
- return {
81
- content: [
82
- {
83
- type: 'text',
84
- text: JSON.stringify({ deleted: false, toggled: true, rule_id: args.rule_id, enabled: rule.enabled, rule }),
85
- },
86
- ],
87
- };
86
+ return respond(deleteRuleOutputSchema, { deleted: false, toggled: true, rule_id: args.rule_id, enabled: rule.enabled, rule });
88
87
  }
89
88
  const deleted = customRuleStore.delete(LOCAL_TENANT, args.rule_id, 'mcp');
90
89
  if (deleted) {
@@ -94,13 +93,6 @@ export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
94
93
  // rule was never registered in this process.
95
94
  evalEngine.unregisterRule(args.rule_id);
96
95
  }
97
- return {
98
- content: [
99
- {
100
- type: 'text',
101
- text: JSON.stringify({ deleted, rule_id: args.rule_id }),
102
- },
103
- ],
104
- };
105
- });
96
+ return respond(deleteRuleOutputSchema, { deleted, rule_id: args.rule_id });
97
+ }));
106
98
  }
@@ -1,3 +1,8 @@
1
+ import { z } from 'zod';
1
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import type { IStorageAdapter } from '../types/query.js';
4
+ export declare const deleteTraceOutputSchema: z.ZodObject<{
5
+ deleted: z.ZodBoolean;
6
+ trace_id: z.ZodString;
7
+ }, z.core.$loose>;
3
8
  export declare function registerDeleteTraceTool(server: McpServer, storage: IStorageAdapter): void;
@@ -12,48 +12,45 @@
12
12
  import { z } from 'zod';
13
13
  import { LOCAL_TENANT } from '../types/tenant.js';
14
14
  import { strictInput } from './strict-input.js';
15
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
16
+ import { guarded, respond } from './respond.js';
15
17
  const inputSchema = {
16
18
  trace_id: z
17
19
  .string()
18
20
  .regex(/^[a-f0-9]{32}$/)
19
21
  .describe('Trace id to delete (32-hex lowercase; obtained from log_trace response or get_traces)'),
20
22
  };
23
+ export const deleteTraceOutputSchema = z.looseObject({
24
+ deleted: z.boolean().describe('true when a trace row was removed; false when no trace with that id existed for this tenant'),
25
+ trace_id: z.string().describe('the id that was asked for'),
26
+ });
21
27
  export function registerDeleteTraceTool(server, storage) {
22
28
  server.registerTool('delete_trace', {
23
29
  title: 'Delete Trace',
24
- description: [
25
- 'Remove a single trace by id. Cascades to spans; eval_results keep the score history with trace_id NULLed.',
26
- '',
27
- 'Sibling tools log_trace creates traces, get_traces queries them, evaluate_output / evaluate_with_llm_judge / verify_citations score them. delete_rule handles custom-rule deletion (separate concern); list_rules / deploy_rule manage the custom-rule lifecycle. delete_trace is the DESTRUCTIVE single-row remove for traces; it does NOT touch eval_results (preserved for audit + drift analytics), spans cascade automatically.',
28
- '',
29
- 'Behavior. DESTRUCTIVE — SQL DELETE scoped to the caller\'s tenant_id. Cascades: spans belonging to this trace are deleted (FK ON DELETE CASCADE); eval_results that referenced this trace have their trace_id set to NULL (FK ON DELETE SET NULL) so aggregate dashboards + historical scores remain valid even after the trace is gone. Not idempotent: deleting an already-deleted trace returns `deleted: false`. Does not emit an audit log entry — traces are user-scope data, not policy changes. Rate-limited to 20 req/min on HTTP MCP.',
30
- '',
31
- 'Output shape. Returns JSON: `{ "deleted": boolean, "trace_id": string }`. `deleted=true` if a row was removed; `deleted=false` if no trace with that id existed (or it belonged to a different tenant — cross-tenant deletes silently fail).',
32
- '',
33
- "Use when a trace was captured in error, contains sensitive data that must be removed for compliance (e.g., a customer exercises GDPR right-to-erasure), or when cleaning up test data. Combine with get_traces to find candidates: query with filters → review → delete_trace(id) per target. For bulk time-window deletion, set `retention.days` in config.json (default 30; the sweep runs when the server starts) — delete_trace is the single-row surgical path.",
34
- '',
35
- "Don't use to clean up OLD data in bulk (use the `retention.days` setting in config.json; there is no command-line flag for it). Don't use to PAUSE a trace — traces are immutable once stored; there's nothing to pause. Don't use to delete eval_results — eval_results survive their trace's deletion intentionally (for audit + drift analysis); they're pruned only by retention.",
36
- '',
37
- 'Parameters. trace_id is the only parameter; must match 32-char lowercase hex (Zod regex). The trace_id you pass is exactly what log_trace returned in its response, or what get_traces returned per row. Format mismatch fails Zod with 400 BEFORE the storage layer is touched. Cross-tenant trace_ids return `deleted: false` silently — they\'re invisible to the caller\'s tenant (prevents enumeration attacks; matches delete_rule\'s tenant-isolation contract).',
38
- '',
39
- "Error modes. Throws 400 on malformed trace_id (wrong format: not 32-char lowercase hex). Returns `{deleted: false}` when the id doesn't exist in the caller's tenant (not an error — the trace may simply have been deleted already). Returns 429 on HTTP rate limit. Storage failures propagate as 500.",
40
- ].join('\n'),
30
+ description: describeTool({
31
+ summary: 'Remove one stored trace by id; its spans go with it, and every evaluation linked to it keeps its verdict and loses its text.',
32
+ does: "Deletes the trace row for the caller's tenant. Spans cascade. Evaluations linked to it keep their verdict, scores, criticality and evidence offsets; their output text, expected text, suggestions and rule messages are erased in the same transaction and erased_at is stamped, so no text from the trace survives in any evaluation. " +
33
+ "deleted is false when no trace has that id already removed, or not this tenant's and that is not an error. No audit entry is written: traces are user data, not policy.",
34
+ whenNot: 'To expire old data in bulk (retention.days; the sweep runs at boot and every retention.sweepIntervalHours). To delete evaluations: they are not deleted per row; retention and --purge cover them. To pause anything: traces are immutable, there is nothing to pause.',
35
+ returns: deleteTraceOutputSchema,
36
+ errors: 'IRIS_STORAGE_ERROR when the delete cannot run. A malformed trace_id (not 32 lowercase hex) is refused before the handler runs. ' +
37
+ ERROR_ENVELOPE_SENTENCE,
38
+ siblings: {
39
+ log_trace: 'store a trace',
40
+ get_traces: 'find the trace to delete',
41
+ delete_rule: 'the equivalent for custom rules',
42
+ },
43
+ }),
41
44
  inputSchema: strictInput(inputSchema),
45
+ outputSchema: deleteTraceOutputSchema,
42
46
  annotations: {
43
47
  readOnlyHint: false,
44
48
  destructiveHint: true,
45
49
  idempotentHint: false,
46
50
  openWorldHint: false,
47
51
  },
48
- }, async (args) => {
52
+ }, guarded(async (args) => {
49
53
  const deleted = await storage.deleteTrace(LOCAL_TENANT, args.trace_id);
50
- return {
51
- content: [
52
- {
53
- type: 'text',
54
- text: JSON.stringify({ deleted, trace_id: args.trace_id }),
55
- },
56
- ],
57
- };
58
- });
54
+ return respond(deleteTraceOutputSchema, { deleted, trace_id: args.trace_id });
55
+ }));
59
56
  }
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import type { CustomRuleStore } from '../custom-rule-store.js';
3
4
  import type { EvalEngine } from '../eval/engine.js';
@@ -6,7 +7,8 @@ import { type TenantId } from '../types/tenant.js';
6
7
  /**
7
8
  * A rule with this name is already deployed and the caller did not ask to
8
9
  * replace it. Carries the existing rule(s) so an HTTP surface can answer
9
- * 409 with them beside the same message the MCP tool throws.
10
+ * 409 with them beside the same message the MCP tool returns as
11
+ * IRIS_DUPLICATE_RULE (src/tools/errors.ts maps it by name).
10
12
  */
11
13
  export declare class DuplicateRuleNameError extends Error {
12
14
  readonly existing: DeployedCustomRule[];
@@ -34,4 +36,14 @@ export interface ReplacedRule {
34
36
  export declare function retireSameNamedRules(store: CustomRuleStore, engine: EvalEngine, tenantId: TenantId, name: string, replace: boolean, user: string): ReplacedRule[];
35
37
  /** The `warning` both deploy surfaces attach when a replace retired rules. */
36
38
  export declare function replacedRulesWarning(name: string, replaced: ReplacedRule[]): string;
39
+ export declare const deployRuleOutputSchema: z.ZodObject<{
40
+ rule: z.ZodObject<{
41
+ id: z.ZodString;
42
+ name: z.ZodString;
43
+ }, z.core.$loose>;
44
+ replaced: z.ZodOptional<z.ZodArray<z.ZodObject<{
45
+ id: z.ZodString;
46
+ }, z.core.$loose>>>;
47
+ warning: z.ZodOptional<z.ZodString>;
48
+ }, z.core.$loose>;
37
49
  export declare function registerDeployRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;