@iris-eval/mcp-server 0.4.6 → 0.5.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 (82) hide show
  1. package/README.md +94 -44
  2. package/dist/audit-log-reader.d.ts +0 -2
  3. package/dist/audit-log-reader.js +3 -3
  4. package/dist/config/index.js +18 -1
  5. package/dist/custom-rule-store.js +22 -8
  6. package/dist/dashboard/assets/index-BZZt8bVh.js +10 -0
  7. package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
  8. package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  9. package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  10. package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
  11. package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  12. package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
  13. package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
  14. package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
  15. package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
  16. package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
  17. package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
  18. package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
  19. package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
  20. package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
  21. package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
  22. package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
  23. package/dist/dashboard/index.html +2 -2
  24. package/dist/dashboard/routes/failures.d.ts +3 -0
  25. package/dist/dashboard/routes/failures.js +76 -0
  26. package/dist/dashboard/routes/index.d.ts +1 -0
  27. package/dist/dashboard/routes/index.js +1 -0
  28. package/dist/dashboard/routes/preferences.js +7 -2
  29. package/dist/dashboard/routes/rules.js +32 -14
  30. package/dist/dashboard/routes/traces.d.ts +12 -1
  31. package/dist/dashboard/routes/traces.js +90 -2
  32. package/dist/dashboard/seed-demo-data.d.ts +49 -0
  33. package/dist/dashboard/seed-demo-data.js +1080 -0
  34. package/dist/dashboard/server.js +81 -15
  35. package/dist/dashboard/validation.d.ts +74 -0
  36. package/dist/dashboard/validation.js +31 -2
  37. package/dist/eval/citation-verify/resolve.js +29 -0
  38. package/dist/eval/citation-verify/verifier.d.ts +1 -0
  39. package/dist/eval/citation-verify/verifier.js +12 -4
  40. package/dist/eval/engine.d.ts +15 -1
  41. package/dist/eval/engine.js +74 -5
  42. package/dist/eval/failure-rank.d.ts +14 -0
  43. package/dist/eval/failure-rank.js +44 -0
  44. package/dist/eval/rules/custom.d.ts +29 -1
  45. package/dist/eval/rules/custom.js +155 -19
  46. package/dist/eval/rules/regex-budget.js +0 -0
  47. package/dist/eval/rules/regex-sandbox.d.ts +26 -0
  48. package/dist/eval/rules/regex-sandbox.js +131 -0
  49. package/dist/eval/rules/relevance.d.ts +0 -2
  50. package/dist/eval/rules/relevance.js +6 -68
  51. package/dist/eval/rules/safety.d.ts +10 -0
  52. package/dist/eval/rules/safety.js +1337 -26
  53. package/dist/index.js +196 -18
  54. package/dist/self-test.d.ts +18 -0
  55. package/dist/self-test.js +329 -0
  56. package/dist/storage/sqlite-adapter.d.ts +2 -0
  57. package/dist/storage/sqlite-adapter.js +65 -9
  58. package/dist/tools/delete-rule.d.ts +2 -1
  59. package/dist/tools/delete-rule.js +13 -4
  60. package/dist/tools/delete-trace.js +2 -1
  61. package/dist/tools/deploy-rule.d.ts +2 -1
  62. package/dist/tools/deploy-rule.js +29 -7
  63. package/dist/tools/evaluate-output.js +36 -9
  64. package/dist/tools/evaluate-with-llm-judge.js +2 -1
  65. package/dist/tools/get-traces.js +6 -2
  66. package/dist/tools/index.js +2 -2
  67. package/dist/tools/list-rules.js +2 -1
  68. package/dist/tools/log-trace.d.ts +51 -0
  69. package/dist/tools/log-trace.js +14 -2
  70. package/dist/tools/strict-input.d.ts +2 -0
  71. package/dist/tools/strict-input.js +35 -0
  72. package/dist/tools/verify-citations.js +7 -5
  73. package/dist/transport/http.js +24 -2
  74. package/dist/types/decision-moment.d.ts +12 -0
  75. package/dist/types/eval.d.ts +32 -0
  76. package/dist/types/query.d.ts +1 -1
  77. package/dist/utils/write-atomic.d.ts +2 -0
  78. package/dist/utils/write-atomic.js +34 -2
  79. package/package.json +3 -2
  80. package/server.json +3 -3
  81. package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
  82. package/dist/dashboard/assets/index-ChcHJDDJ.js +0 -10
@@ -20,6 +20,7 @@
20
20
  * its own data.
21
21
  */
22
22
  import Database from 'better-sqlite3';
23
+ import { ensureOwnerOnly } from '../utils/write-atomic.js';
23
24
  import { TenantContextRequiredError } from '../types/tenant.js';
24
25
  import { runMigrations } from './migrations/index.js';
25
26
  const ALLOWED_SORT_COLUMNS = new Set(['timestamp', 'latency_ms', 'cost_usd']);
@@ -35,7 +36,9 @@ function assertTenant(tenantId) {
35
36
  }
36
37
  export class SqliteAdapter {
37
38
  db;
39
+ dbPath;
38
40
  constructor(dbPath) {
41
+ this.dbPath = dbPath;
39
42
  this.db = new Database(dbPath);
40
43
  }
41
44
  async initialize() {
@@ -43,6 +46,17 @@ export class SqliteAdapter {
43
46
  this.db.pragma('busy_timeout = 5000');
44
47
  this.db.pragma('foreign_keys = ON');
45
48
  runMigrations(this.db);
49
+ /*
50
+ * iris.db holds agent inputs and outputs verbatim, and a tool that
51
+ * detects PII necessarily stores the PII it found. better-sqlite3
52
+ * creates the file with the process umask (typically 0644 = readable by
53
+ * every local account), and WAL mode creates two sidecars that hold the
54
+ * same data. Narrow all three after the pragmas, since -wal/-shm do not
55
+ * exist until WAL is enabled. No-op on Windows and on :memory:.
56
+ */
57
+ if (this.dbPath !== ':memory:') {
58
+ ensureOwnerOnly(this.dbPath, `${this.dbPath}-wal`, `${this.dbPath}-shm`);
59
+ }
46
60
  }
47
61
  async close() {
48
62
  this.db.close();
@@ -97,13 +111,31 @@ export class SqliteAdapter {
97
111
  conditions.push('timestamp <= ?');
98
112
  params.push(filter.until);
99
113
  }
100
- if (filter?.min_score !== undefined) {
101
- conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.tenant_id = traces.tenant_id AND e.trace_id = traces.trace_id AND e.score >= ?)');
102
- params.push(filter.min_score);
103
- }
104
- if (filter?.max_score !== undefined) {
105
- conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.tenant_id = traces.tenant_id AND e.trace_id = traces.trace_id AND e.score <= ?)');
106
- params.push(filter.max_score);
114
+ if (filter?.min_score !== undefined || filter?.max_score !== undefined) {
115
+ /*
116
+ * Both bounds apply to the LATEST eval per trace (created_at DESC,
117
+ * rowid breaking ties within the same millisecond) — the semantics
118
+ * the get_traces description promises. These used to be two
119
+ * INDEPENDENT EXISTS subqueries, so a trace with evals at 0.95 and
120
+ * 0.05 matched min_score=0.4 + max_score=0.6: each bound was
121
+ * satisfied by a different eval even though no single eval — let
122
+ * alone the latest — was in range (#332).
123
+ */
124
+ const scoreBounds = [];
125
+ if (filter.min_score !== undefined) {
126
+ scoreBounds.push('e.score >= ?');
127
+ }
128
+ if (filter.max_score !== undefined) {
129
+ scoreBounds.push('e.score <= ?');
130
+ }
131
+ conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.rowid = ' +
132
+ '(SELECT e2.rowid FROM eval_results e2 WHERE e2.tenant_id = traces.tenant_id AND e2.trace_id = traces.trace_id ' +
133
+ 'ORDER BY e2.created_at DESC, e2.rowid DESC LIMIT 1) ' +
134
+ `AND ${scoreBounds.join(' AND ')})`);
135
+ if (filter.min_score !== undefined)
136
+ params.push(filter.min_score);
137
+ if (filter.max_score !== undefined)
138
+ params.push(filter.max_score);
107
139
  }
108
140
  const whereClause = `WHERE ${conditions.join(' AND ')}`;
109
141
  const sortBy = options.sort_by ?? 'timestamp';
@@ -244,22 +276,46 @@ export class SqliteAdapter {
244
276
  // ---------------------------------------------------------------------------
245
277
  // Eval-stats endpoints (v0.2.0 dashboard)
246
278
  // ---------------------------------------------------------------------------
279
+ /*
280
+ * Table-driven rather than a nested ternary: the old form silently fell
281
+ * through to 720 hours for anything that wasn't '24h' or '7d', so a new
282
+ * period value would have quietly returned 30d data rather than failing.
283
+ */
284
+ static PERIOD_HOURS = {
285
+ '24h': 24,
286
+ '2d': 48,
287
+ '7d': 168,
288
+ '14d': 336,
289
+ '30d': 720,
290
+ '60d': 1440,
291
+ '90d': 2160,
292
+ '180d': 4320,
293
+ };
247
294
  periodToSince(period) {
248
295
  if (period === 'all')
249
296
  return '1970-01-01T00:00:00.000Z';
250
- const hours = period === '24h' ? 24 : period === '7d' ? 168 : 720;
297
+ const hours = SqliteAdapter.PERIOD_HOURS[period];
251
298
  return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
252
299
  }
253
300
  async getEvalStats(tenantId, period) {
254
301
  assertTenant(tenantId);
255
302
  const since = this.periodToSince(period);
303
+ /*
304
+ * No trace_id filter — deliberately. evaluate_output without a
305
+ * trace_id is documented and normal, and every sibling scan (trend,
306
+ * per-rule breakdown, failures) counts unlinked evals. Filtering only
307
+ * this headline made totalEvals disagree with the trend's sum, and —
308
+ * because eval_results.trace_id is ON DELETE SET NULL — deleting a
309
+ * trace retroactively shrank the headline while the trend kept the
310
+ * eval. One population everywhere: every eval in the window.
311
+ */
256
312
  const agg = this.db.prepare(`
257
313
  SELECT
258
314
  COUNT(*) AS total_evals,
259
315
  COALESCE(AVG(score), 0) AS avg_score,
260
316
  SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) AS passed_count
261
317
  FROM eval_results
262
- WHERE tenant_id = ? AND created_at >= ? AND trace_id IS NOT NULL
318
+ WHERE tenant_id = ? AND created_at >= ?
263
319
  `).get(tenantId, since);
264
320
  const cost = this.db.prepare(`
265
321
  SELECT COALESCE(SUM(cost_usd), 0) AS total_cost
@@ -1,3 +1,4 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { CustomRuleStore } from '../custom-rule-store.js';
3
- export declare function registerDeleteRuleTool(server: McpServer, customRuleStore: CustomRuleStore): void;
3
+ import type { EvalEngine } from '../eval/engine.js';
4
+ export declare function registerDeleteRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;
@@ -2,8 +2,9 @@
2
2
  * delete_rule MCP tool — remove a deployed custom rule.
3
3
  *
4
4
  * Destructive counterpart to deploy_rule. Removes the rule from
5
- * ~/.iris/custom-rules.json (stops firing on future evaluate_output
6
- * calls) and appends a `rule.delete` entry to the audit log.
5
+ * ~/.iris/custom-rules.json AND unregisters it from the live eval
6
+ * engine, so it stops firing on the very next evaluate_output call —
7
+ * no restart needed. Appends a `rule.delete` entry to the audit log.
7
8
  *
8
9
  * Past eval_results that referenced this rule stay intact — the
9
10
  * history is preserved even after the rule is removed. The audit
@@ -11,13 +12,14 @@
11
12
  */
12
13
  import { z } from 'zod';
13
14
  import { LOCAL_TENANT } from '../types/tenant.js';
15
+ import { strictInput } from './strict-input.js';
14
16
  const inputSchema = {
15
17
  rule_id: z
16
18
  .string()
17
19
  .regex(/^rule-[a-z0-9]+$/)
18
20
  .describe('Rule id to delete (format: rule-<hex>); obtained from list_rules or deploy_rule response'),
19
21
  };
20
- export function registerDeleteRuleTool(server, customRuleStore) {
22
+ export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
21
23
  server.registerTool('delete_rule', {
22
24
  title: 'Delete Custom Rule',
23
25
  description: [
@@ -37,7 +39,7 @@ export function registerDeleteRuleTool(server, customRuleStore) {
37
39
  '',
38
40
  "Error modes. Throws 400 on malformed rule_id (wrong prefix). Returns `{deleted: 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.",
39
41
  ].join('\n'),
40
- inputSchema,
42
+ inputSchema: strictInput(inputSchema),
41
43
  annotations: {
42
44
  readOnlyHint: false,
43
45
  destructiveHint: true,
@@ -47,6 +49,13 @@ export function registerDeleteRuleTool(server, customRuleStore) {
47
49
  }, async (args) => {
48
50
  // OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
49
51
  const deleted = customRuleStore.delete(LOCAL_TENANT, args.rule_id, 'mcp');
52
+ if (deleted) {
53
+ // Hot-remove from the live engine so the rule stops firing on the
54
+ // very next evaluate_output call — the "stops firing immediately on
55
+ // the live process" this description promises (#332). No-op when the
56
+ // rule was never registered in this process.
57
+ evalEngine.unregisterRule(args.rule_id);
58
+ }
50
59
  return {
51
60
  content: [
52
61
  {
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { z } from 'zod';
13
13
  import { LOCAL_TENANT } from '../types/tenant.js';
14
+ import { strictInput } from './strict-input.js';
14
15
  const inputSchema = {
15
16
  trace_id: z
16
17
  .string()
@@ -37,7 +38,7 @@ export function registerDeleteTraceTool(server, storage) {
37
38
  '',
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.",
39
40
  ].join('\n'),
40
- inputSchema,
41
+ inputSchema: strictInput(inputSchema),
41
42
  annotations: {
42
43
  readOnlyHint: false,
43
44
  destructiveHint: true,
@@ -1,3 +1,4 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { CustomRuleStore } from '../custom-rule-store.js';
3
- export declare function registerDeployRuleTool(server: McpServer, customRuleStore: CustomRuleStore): void;
3
+ import type { EvalEngine } from '../eval/engine.js';
4
+ export declare function registerDeployRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;
@@ -11,7 +11,9 @@
11
11
  * eval_type.
12
12
  */
13
13
  import { z } from 'zod';
14
+ import { createCustomRule } from '../eval/rules/custom.js';
14
15
  import { LOCAL_TENANT } from '../types/tenant.js';
16
+ import { strictInput } from './strict-input.js';
15
17
  const CustomRuleDefinitionSchema = z.object({
16
18
  name: z.string(),
17
19
  type: z.enum([
@@ -28,7 +30,11 @@ const CustomRuleDefinitionSchema = z.object({
28
30
  weight: z.number().optional(),
29
31
  });
30
32
  const inputSchema = {
31
- name: z.string().min(1).max(120).describe('Human-readable rule name (used in eval results)'),
33
+ // 80 mirrors the persisted store's cap (custom-rule-store.ts). The tool
34
+ // used to allow 120, so a 100-char name passed the tool schema and then
35
+ // surfaced the store's ZodError as a raw 500 (#332). One limit, enforced
36
+ // at the boundary, fails cleanly as a 400.
37
+ name: z.string().min(1).max(80).describe('Human-readable rule name (1-80 chars; used in eval results)'),
32
38
  description: z
33
39
  .string()
34
40
  .max(500)
@@ -40,14 +46,14 @@ const inputSchema = {
40
46
  severity: z
41
47
  .enum(['low', 'medium', 'high', 'critical'])
42
48
  .default('medium')
43
- .describe('Severity used for dashboard sort + audit alerts'),
49
+ .describe('What a FAILURE of this rule means. low/medium: informational — contributes to the weighted score only (plus dashboard sort + audit alerts). high/critical: hard-fail — a failing evaluation of this rule forces the overall passed=false regardless of the weighted score'),
44
50
  definition: CustomRuleDefinitionSchema.describe('Check definition (regex, length, keyword, cost, or schema)'),
45
51
  sourceMomentId: z
46
52
  .string()
47
53
  .optional()
48
54
  .describe('Optional Decision Moment ID the rule was derived from (preserves workflow-inversion provenance)'),
49
55
  };
50
- export function registerDeployRuleTool(server, customRuleStore) {
56
+ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
51
57
  server.registerTool('deploy_rule', {
52
58
  title: 'Deploy Custom Rule',
53
59
  description: [
@@ -63,11 +69,11 @@ export function registerDeployRuleTool(server, customRuleStore) {
63
69
  '',
64
70
  "Don't use to VALIDATE a rule before committing — deploy writes immediately. Use the dashboard's preview endpoint (POST /api/v1/rules/custom/preview) for dry-run validation against sample output. Don't use to EDIT an existing rule — this call only creates; edits require a dedicated flow (coming in v0.5). To update a rule today: delete_rule then deploy_rule with the new definition.",
65
71
  '',
66
- 'Parameters. name is 1-120 chars (Zod-enforced min/max); appears in eval_result rule_results so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). evalType determines WHEN the rule fires (must match the eval_type your evaluate_output calls use; e.g., a "completeness" rule fires on every evaluate_output where eval_type="completeness" OR eval_type="custom"). severity affects dashboard sort + audit log signal but does NOT affect scoring (scoring uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_cost; min_length needs config.min_length; max_length needs config.max_length; contains_keywords/excludes_keywords need config.keywords). Invalid configs are now REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. sourceMomentId is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). Defaults: severity="medium".',
72
+ 'Parameters. name is 1-80 chars (Zod-enforced min/max — the same cap the persisted store applies); appears in eval_result rule_results so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). evalType determines WHEN the rule fires (must match the eval_type your evaluate_output calls use; e.g., a "completeness" rule fires on every evaluate_output where eval_type="completeness" OR eval_type="custom"). severity decides what a FAILURE of the rule does: low/medium failures only lower the weighted score (and drive dashboard sort + audit alerts); high/critical failures HARD-FAIL the evaluation the overall `passed` is forced to false regardless of the weighted score, and the rule is listed in the response\'s `critical_failures`. Severity never changes the numeric score itself (that uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_cost; min_length needs config.min_length; max_length needs config.max_length; contains_keywords/excludes_keywords need config.keywords). Invalid configs are now REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. sourceMomentId is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). Defaults: severity="medium".',
67
73
  '',
68
- "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars). Throws 400 on empty `name`. Throws 400 if the eval category mismatches the definition type. Returns 429 when HTTP rate limit exceeded. File-write failures (disk full, read-only fs) propagate as 500; the audit log is best-effort and does not block deploy.",
74
+ "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars). Throws 400 on empty `name` or `name` over 80 chars. Any evalType/definition.type combination is valid (a regex_match rule can enforce a safety policy; a max_length rule can express completeness) — there is no category/type mismatch error. Returns 429 when HTTP rate limit exceeded. File-write failures (disk full, read-only fs) propagate as 500; the audit log is best-effort and does not block deploy.",
69
75
  ].join('\n'),
70
- inputSchema,
76
+ inputSchema: strictInput(inputSchema),
71
77
  annotations: {
72
78
  readOnlyHint: false,
73
79
  destructiveHint: false,
@@ -75,16 +81,32 @@ export function registerDeployRuleTool(server, customRuleStore) {
75
81
  openWorldHint: false,
76
82
  },
77
83
  }, async (args) => {
84
+ // Server overrides the inner definition's `name` so it always matches
85
+ // the user-facing rule name — same normalization the dashboard's
86
+ // deploy route applies. Also keeps the tool's 80-char cap authoritative
87
+ // (an unchecked definition.name used to reach the store and surface its
88
+ // ZodError as a raw 500).
89
+ const definition = {
90
+ ...args.definition,
91
+ name: args.name,
92
+ };
78
93
  // OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
79
94
  const rule = customRuleStore.deploy(LOCAL_TENANT, {
80
95
  name: args.name,
81
96
  description: args.description,
82
97
  evalType: args.evalType,
83
98
  severity: args.severity,
84
- definition: args.definition,
99
+ definition,
85
100
  sourceMomentId: args.sourceMomentId,
86
101
  user: 'mcp',
87
102
  });
103
+ // Register with the live engine so the rule fires on the very next
104
+ // evaluate_output call — the "activates immediately for the running
105
+ // process" this description promises. Previously only the dashboard's
106
+ // deploy route did this; MCP deploys silently waited for a restart.
107
+ // Registered under its rule id so delete_rule can hot-remove it.
108
+ // Severity rides along: high/critical makes the rule hard-failing.
109
+ evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
88
110
  return {
89
111
  content: [
90
112
  {
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
+ import { strictInput } from './strict-input.js';
3
4
  const CustomRuleSchema = z.object({
4
5
  name: z.string(),
5
6
  type: z.enum([
@@ -11,11 +12,20 @@ const CustomRuleSchema = z.object({
11
12
  });
12
13
  const inputSchema = {
13
14
  output: z.string().describe('The output text to evaluate (the agent\'s response that gets scored against rules)'),
14
- eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).default('completeness').describe('Rule bundle to apply: completeness | relevance | safety | cost | custom — picks which built-in rules fire'),
15
+ // .optional() rather than .default('completeness') so the handler can tell
16
+ // "caller chose completeness" apart from "caller never chose" — the second
17
+ // case gets a note in the response saying safety rules did not run. The
18
+ // effective default is still completeness.
19
+ eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).optional().describe('Rule bundle to apply: completeness | relevance | safety | cost | custom — picks which built-in rules fire. Defaults to "completeness" when omitted (the response then carries a note that safety rules did not run)'),
15
20
  expected: z.string().optional().describe('Expected output for comparison — REQUIRED when eval_type="relevance" (used as keyword-overlap target)'),
16
- input: z.string().optional().describe('Original input for context — improves relevance scoring (keyword overlap vs input)'),
21
+ input: z.string().optional().describe('Original input for context (the ask + any source material the agent was given) — improves relevance scoring and grounds the safety bundle\'s hallucination signals'),
17
22
  trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through'),
18
- custom_rules: z.array(CustomRuleSchema).optional().describe('Custom evaluation rules fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these'),
23
+ // .max(10): inline rules skip the deploy-time probe, and the engine runs
24
+ // rules synchronously — without a cap, one request carrying N sandbox-
25
+ // defeating regex rules stalls the server linearly in N (measured 9.3s at
26
+ // N=50). Ten is ample for per-call rules; persistent sets belong in
27
+ // deploy_rule, where deploy-time validation probes each pattern.
28
+ custom_rules: z.array(CustomRuleSchema).max(10).optional().describe('Custom evaluation rules, max 10 per call (deploy persistent rule sets via deploy_rule instead) — fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these'),
19
29
  cost_usd: z.number().optional().describe('Cost in USD — only consulted when eval_type="cost" (compared against cost_threshold rules)'),
20
30
  token_usage: z.object({
21
31
  prompt_tokens: z.number().optional(),
@@ -33,17 +43,19 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
33
43
  '',
34
44
  'Behavior. Deterministic, in-process scoring — same inputs always produce the same result. Writes one eval_result row to Iris storage (linked to trace_id if provided; unlinked otherwise). No external network calls in heuristic mode (v0.4 adds an llm_as_judge eval_type that DOES call LLM APIs; see the separate evaluate_with_llm_judge tool for that). Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Runs in ~5-50ms for rule-based evaluation.',
35
45
  '',
36
- 'Output shape. Returns JSON: `{ "id": "<uuid>", "score": 0..1, "passed": boolean, "rule_results": [{ "ruleName", "passed", "score", "message", "skipped?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean }`. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data).',
46
+ 'Output shape. Returns JSON: `{ "id": "<uuid>", "eval_type": "<bundle that ran>", "score": 0..1, "passed": boolean, "critical_failures?": string[], "rule_results": [{ "ruleName", "passed", "score", "message", "skipped?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean, "note?": string }`. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data). `note` appears only when eval_type was omitted, naming the defaulted bundle and that safety rules did not run.',
37
47
  '',
38
- 'Use when you want a quality score on a specific output typically after log_trace records the execution. Pass `eval_type` to route to the right rule bundle: `completeness` (length, sentence count, relevance to input), `relevance` (keyword overlap, topic consistency), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection), `cost` (budget threshold), or `custom` (bring your own rules via `custom_rules`).',
48
+ 'What `passed` means. `score` and `passed` answer different questions. `score` is the weighted average across the rules that ran a 0..1 quality gradient. `passed` is the ship/no-ship verdict: true only when the score clears the pass threshold (default 0.7, configurable via config `eval.defaultThreshold`) AND no critical rule failed. Critical rules HARD-FAIL: if one fails, `passed` is false regardless of the weighted score, and the culprits are listed in `critical_failures`. The critical rules are the genuine safety violations — `no_pii`, `no_injection_patterns`, `no_blocklist_words` plus any deployed custom rule with severity high/critical. A leaked SSN can never be averaged away by other rules passing.',
49
+ '',
50
+ 'Use when you want a quality score on a specific output — typically after log_trace records the execution. Pass `eval_type` to route to the right rule bundle: `completeness` (length, sentence count, relevance to input), `relevance` (keyword overlap, topic consistency), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection — pass `input` so the hallucination signals can cross-check the output against the material the agent was given), `cost` (budget threshold), or `custom` (bring your own rules via `custom_rules`).',
39
51
  '',
40
52
  'Don\'t use when the output is empty or has no applicable rules — the eval_type decides which rules apply, and invalid combinations return score=0 + insufficient_data=true (not an error, but not actionable). Don\'t use to VALIDATE JSON schemas directly (use your language\'s JSON Schema validator — Iris\'s `json_schema` custom rule type is for output-shape assertions, not arbitrary validation).',
41
53
  '',
42
- 'Parameters. expected is REQUIRED when eval_type="relevance" (used as the comparison target for keyword overlap + topic consistency); ignored for other eval_types. cost_usd + token_usage are ONLY consulted when eval_type="cost" (ignored otherwise). custom_rules ALWAYS fires regardless of eval_type — pass eval_type="custom" if you want ONLY your rules to run (otherwise both your rules AND the eval_type bundle run together). trace_id is optional but recommended (linking the eval to its trace surfaces it in the dashboard\'s drill-through). input adds context to keyword-overlap relevance checks; ignored otherwise. Defaults: eval_type="completeness".',
54
+ 'Parameters. expected is REQUIRED when eval_type="relevance" (used as the comparison target for keyword overlap + topic consistency); ignored for other eval_types. cost_usd + token_usage are ONLY consulted when eval_type="cost" (ignored otherwise). custom_rules ALWAYS fires regardless of eval_type — pass eval_type="custom" if you want ONLY your rules to run (otherwise both your rules AND the eval_type bundle run together). trace_id is optional but recommended (linking the eval to its trace surfaces it in the dashboard\'s drill-through). input adds context to keyword-overlap relevance checks AND grounds the safety bundle\'s hallucination signals (without it those signals stay silent rather than guess); ignored otherwise. Defaults: eval_type="completeness" — and when you rely on that default, the response carries a `note` reminding you that the safety bundle did not run.',
43
55
  '',
44
- 'Error modes. Throws on malformed custom_rules (Zod rejects). Returns 400 on regex patterns that fail safe-regex2 ReDoS check or exceed 1000-char limit. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. The eval itself never throws — failing rules report `passed: false` with a message, they don\'t bubble exceptions.',
56
+ 'Error modes. Throws on unknown argument names (strict schema — a misspelled argument is rejected with the valid argument list, never silently dropped). Throws on malformed custom_rules (Zod rejects) and on more than 10 custom_rules in one call (use deploy_rule for persistent rule sets). Returns 400 on regex patterns that fail safe-regex2 ReDoS check or exceed 1000-char limit. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. The eval itself never throws — failing rules report `passed: false` with a message, they don\'t bubble exceptions. A regex that exceeds the 100ms sandbox matching budget on a given output reports skipped with budgetExceeded=true instead of hanging the server (fail-open per rule — gate on that flag if you must fail closed).',
45
57
  ].join('\n'),
46
- inputSchema,
58
+ inputSchema: strictInput(inputSchema),
47
59
  annotations: {
48
60
  readOnlyHint: false, // Writes an eval_result row
49
61
  destructiveHint: false, // Creates new data; doesn't overwrite or delete
@@ -51,7 +63,12 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
51
63
  openWorldHint: false, // No external network in heuristic mode; LLM-as-judge has its own tool with openWorldHint:true
52
64
  },
53
65
  }, async (args) => {
54
- const evalType = args.eval_type;
66
+ // Track omission explicitly: a caller who never chose a bundle gets
67
+ // the completeness default AND a note saying so — six of seven UAT
68
+ // personas read passed:true on PII-laden text with no hint that the
69
+ // safety bundle never ran.
70
+ const evalTypeOmitted = args.eval_type === undefined;
71
+ const evalType = (args.eval_type ?? 'completeness');
55
72
  const result = evalEngine.evaluate(evalType, {
56
73
  output: args.output,
57
74
  expected: args.expected,
@@ -71,13 +88,23 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
71
88
  type: 'text',
72
89
  text: JSON.stringify({
73
90
  id: result.id,
91
+ // Echo which bundle actually ran. Without this, a caller who
92
+ // omitted eval_type could not tell a "safety pass" from a
93
+ // completeness eval that never ran a single safety rule.
94
+ eval_type: result.eval_type,
74
95
  score: result.score,
75
96
  passed: result.passed,
97
+ ...(result.critical_failures ? { critical_failures: result.critical_failures } : {}),
76
98
  rule_results: result.rule_results,
77
99
  suggestions: result.suggestions,
78
100
  rules_evaluated: result.rules_evaluated,
79
101
  rules_skipped: result.rules_skipped,
80
102
  insufficient_data: result.insufficient_data,
103
+ ...(evalTypeOmitted
104
+ ? {
105
+ note: 'eval_type was omitted, so the default "completeness" bundle ran. Safety rules (PII, injection, blocklist, stub, hallucination) were NOT part of this evaluation — pass eval_type="safety" to run them.',
106
+ }
107
+ : {}),
81
108
  }),
82
109
  },
83
110
  ],
@@ -3,6 +3,7 @@ import { LOCAL_TENANT } from '../types/tenant.js';
3
3
  import { evaluateWithLLMJudge } from '../eval/llm-judge/evaluator.js';
4
4
  import { findPricing } from '../eval/llm-judge/pricing.js';
5
5
  import { generateEvalId } from '../utils/ids.js';
6
+ import { strictInput } from './strict-input.js';
6
7
  const inputSchema = {
7
8
  output: z.string().min(1).describe('The agent output text to evaluate'),
8
9
  template: z
@@ -73,7 +74,7 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
73
74
  '',
74
75
  'Error modes. Throws when the required API key env var is missing. Throws when the estimated worst-case cost exceeds max_cost_usd (raise the cap or trim prompts). Throws LLMJudgeError on provider errors — kind=`auth` on 401/403, `rate_limit` on 429 (auto-retried once), `server_error` on 5xx, `timeout` on abort, `malformed_response` when the judge fails to emit valid JSON on both attempts. Throws "Unknown model" for unsupported model IDs — update src/eval/llm-judge/pricing.ts first.',
75
76
  ].join('\n'),
76
- inputSchema,
77
+ inputSchema: strictInput(inputSchema),
77
78
  annotations: {
78
79
  readOnlyHint: false, // Writes eval_result; also spends money (external API cost)
79
80
  destructiveHint: false, // Creates data; doesn't overwrite or delete
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
+ import { strictInput } from './strict-input.js';
3
4
  const inputSchema = {
4
5
  agent_name: z.string().optional().describe('Filter by agent name — exact match (no wildcards in v0.4)'),
5
6
  framework: z.string().optional().describe('Filter by agent framework — exact match (e.g., langchain, autogen)'),
@@ -7,7 +8,10 @@ const inputSchema = {
7
8
  until: z.string().optional().describe('ISO timestamp upper bound — return traces with timestamp < this'),
8
9
  min_score: z.number().optional().describe('Minimum eval score filter (0..1) — applied to LATEST eval per trace, not all evals'),
9
10
  max_score: z.number().optional().describe('Maximum eval score filter (0..1) — applied to LATEST eval per trace'),
10
- limit: z.number().default(50).describe('Results per page (default 50, max 1000 values >1000 return 400)'),
11
+ // Mirrors traceQuerySchema in dashboard/validation.tsboth capture paths
12
+ // (MCP tool, HTTP query) enforce the same 1..1000 bound. Unclamped, limit:-1
13
+ // meant "LIMIT -1" in SQLite, i.e. every row (#332).
14
+ limit: z.number().int().min(1).max(1000).default(50).describe('Results per page (default 50, max 1000 — values >1000 return 400)'),
11
15
  offset: z.number().default(0).describe('Zero-based pagination offset — skip first N results'),
12
16
  sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp').describe('Sort by timestamp | latency_ms | cost_usd (default timestamp)'),
13
17
  sort_order: z.enum(['asc', 'desc']).default('desc').describe('Sort order: asc | desc (default desc — most recent / highest first)'),
@@ -33,7 +37,7 @@ export function registerGetTracesTool(server, storage) {
33
37
  '',
34
38
  'Error modes. Returns 400 on invalid sort_by / sort_order (Zod enum). Returns 400 if limit > 1000. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. Empty result with `total: 0` on no matches (not an error).',
35
39
  ].join('\n'),
36
- inputSchema,
40
+ inputSchema: strictInput(inputSchema),
37
41
  annotations: {
38
42
  readOnlyHint: true, // Pure query: never writes, never deletes
39
43
  destructiveHint: false, // Inverse of readOnly — trivially false
@@ -12,8 +12,8 @@ export function registerAllTools(server, storage, evalEngine, customRuleStore) {
12
12
  registerEvaluateOutputTool(server, storage, evalEngine);
13
13
  registerGetTracesTool(server, storage);
14
14
  registerListRulesTool(server, customRuleStore);
15
- registerDeployRuleTool(server, customRuleStore);
16
- registerDeleteRuleTool(server, customRuleStore);
15
+ registerDeployRuleTool(server, customRuleStore, evalEngine);
16
+ registerDeleteRuleTool(server, customRuleStore, evalEngine);
17
17
  registerDeleteTraceTool(server, storage);
18
18
  registerEvaluateWithLLMJudgeTool(server, storage);
19
19
  registerVerifyCitationsTool(server, storage);
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { z } from 'zod';
14
14
  import { LOCAL_TENANT } from '../types/tenant.js';
15
+ import { strictInput } from './strict-input.js';
15
16
  const inputSchema = {
16
17
  eval_type: z
17
18
  .enum(['completeness', 'relevance', 'safety', 'cost', 'custom'])
@@ -42,7 +43,7 @@ export function registerListRulesTool(server, customRuleStore) {
42
43
  '',
43
44
  "Error modes. Returns empty list if the rule store file doesn't exist (first run). Returns 429 if HTTP rate limit exceeded. Never throws on valid input.",
44
45
  ].join('\n'),
45
- inputSchema,
46
+ inputSchema: strictInput(inputSchema),
46
47
  annotations: {
47
48
  readOnlyHint: true,
48
49
  destructiveHint: false,
@@ -1,3 +1,54 @@
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 logTraceInputShape: {
5
+ agent_name: z.ZodString;
6
+ framework: z.ZodOptional<z.ZodString>;
7
+ input: z.ZodOptional<z.ZodString>;
8
+ output: z.ZodOptional<z.ZodString>;
9
+ tool_calls: z.ZodOptional<z.ZodArray<z.ZodObject<{
10
+ tool_name: z.ZodString;
11
+ input: z.ZodOptional<z.ZodUnknown>;
12
+ output: z.ZodOptional<z.ZodUnknown>;
13
+ latency_ms: z.ZodOptional<z.ZodNumber>;
14
+ error: z.ZodOptional<z.ZodString>;
15
+ }, z.core.$strip>>>;
16
+ latency_ms: z.ZodOptional<z.ZodNumber>;
17
+ token_usage: z.ZodOptional<z.ZodObject<{
18
+ prompt_tokens: z.ZodOptional<z.ZodNumber>;
19
+ completion_tokens: z.ZodOptional<z.ZodNumber>;
20
+ total_tokens: z.ZodOptional<z.ZodNumber>;
21
+ }, z.core.$strip>>;
22
+ cost_usd: z.ZodOptional<z.ZodNumber>;
23
+ metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
24
+ spans: z.ZodOptional<z.ZodArray<z.ZodObject<{
25
+ span_id: z.ZodOptional<z.ZodString>;
26
+ parent_span_id: z.ZodOptional<z.ZodString>;
27
+ name: z.ZodString;
28
+ kind: z.ZodDefault<z.ZodEnum<{
29
+ INTERNAL: "INTERNAL";
30
+ SERVER: "SERVER";
31
+ CLIENT: "CLIENT";
32
+ PRODUCER: "PRODUCER";
33
+ CONSUMER: "CONSUMER";
34
+ LLM: "LLM";
35
+ TOOL: "TOOL";
36
+ }>>;
37
+ status_code: z.ZodDefault<z.ZodEnum<{
38
+ UNSET: "UNSET";
39
+ OK: "OK";
40
+ ERROR: "ERROR";
41
+ }>>;
42
+ status_message: z.ZodOptional<z.ZodString>;
43
+ start_time: z.ZodString;
44
+ end_time: z.ZodOptional<z.ZodString>;
45
+ attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
46
+ events: z.ZodOptional<z.ZodArray<z.ZodObject<{
47
+ name: z.ZodString;
48
+ timestamp: z.ZodString;
49
+ attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
50
+ }, z.core.$strip>>>;
51
+ }, z.core.$strip>>>;
52
+ timestamp: z.ZodOptional<z.ZodString>;
53
+ };
3
54
  export declare function registerLogTraceTool(server: McpServer, storage: IStorageAdapter): void;
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { generateTraceId, generateSpanId } from '../utils/ids.js';
3
3
  import { LOCAL_TENANT } from '../types/tenant.js';
4
4
  import { bestEffortExport } from '../otel/lazy.js';
5
+ import { strictInput } from './strict-input.js';
5
6
  const ToolCallSchema = z.object({
6
7
  tool_name: z.string(),
7
8
  input: z.unknown().optional(),
@@ -30,7 +31,14 @@ const TokenUsageSchema = z.object({
30
31
  completion_tokens: z.number().optional(),
31
32
  total_tokens: z.number().optional(),
32
33
  });
33
- const inputSchema = {
34
+ /*
35
+ * The log_trace input contract. Exported because POST /api/v1/traces
36
+ * (src/dashboard/routes/traces.ts) accepts the SAME body — one schema,
37
+ * two capture paths. Duplicating it there would let the tool and the
38
+ * HTTP endpoint drift apart silently; importing it means a field added
39
+ * here is accepted (and validated identically) on both.
40
+ */
41
+ export const logTraceInputShape = {
34
42
  agent_name: z.string().describe('Agent name — used for filtering in get_traces (e.g., "customer-support-bot")'),
35
43
  framework: z.string().optional().describe('Agent framework identifier (e.g., langchain, autogen, custom)'),
36
44
  input: z.string().optional().describe('Agent input text — the user prompt or upstream input that produced this output'),
@@ -63,7 +71,11 @@ export function registerLogTraceTool(server, storage) {
63
71
  '',
64
72
  'Error modes. Throws on missing agent_name. Throws on malformed span or tool_call objects (Zod rejects). Returns 500 on storage failure (disk full, DB locked). Never blocks on the agent — returns within ~50ms for typical payloads.',
65
73
  ].join('\n'),
66
- inputSchema,
74
+ // Strict at the MCP boundary (unknown args rejected, not stripped).
75
+ // The dashboard's HTTP ingest builds its own schema FROM this shape
76
+ // (dashboard/validation.ts) and keeps default stripping there on
77
+ // purpose — it relies on it to discard a client-supplied trace_id.
78
+ inputSchema: strictInput(logTraceInputShape),
67
79
  annotations: {
68
80
  readOnlyHint: false, // Writes a row to storage
69
81
  destructiveHint: false, // Creates new data; doesn't overwrite or delete
@@ -0,0 +1,2 @@
1
+ import { z } from 'zod';
2
+ export declare function strictInput<T extends z.ZodRawShape>(shape: T): z.ZodObject<{ -readonly [P in keyof T]: T[P]; }, z.core.$strict>;
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ /*
3
+ * Wraps a tool's input shape in a STRICT object schema so unknown argument
4
+ * names are REJECTED with an error that names the offending key(s) and
5
+ * lists the valid ones.
6
+ *
7
+ * Why this exists: a bare shape (or z.object()) silently STRIPS unknown
8
+ * keys. At the MCP tool boundary that is dangerous, not lenient — an LLM
9
+ * guessing an argument name is the normal case, not an edge case. Before
10
+ * this wrapper, `evaluate_output({ criteria: ["safety"], ... })` (a
11
+ * plausible guess) and `eval_typ: "safety"` (a one-character typo) both
12
+ * "succeeded": the arguments were dropped, the DEFAULT completeness bundle
13
+ * ran instead of the safety rules, and the response said passed:true on
14
+ * text containing real PII — with nothing indicating the arguments were
15
+ * ignored. Meanwhile a missing REQUIRED field produced a precise Zod
16
+ * error, so the failure mode was inconsistent as well as unsafe.
17
+ *
18
+ * The MCP SDK accepts a schema object (not just a raw shape) for
19
+ * inputSchema and validates tool calls through it, so the custom
20
+ * unrecognized-keys message below is exactly what the caller sees.
21
+ * Strictness also reaches tools/list: the generated JSON Schema carries
22
+ * additionalProperties:false, telling well-behaved clients up front.
23
+ */
24
+ export function strictInput(shape) {
25
+ const validKeys = Object.keys(shape).join(', ');
26
+ return z.strictObject(shape, {
27
+ error: (issue) => issue.code === 'unrecognized_keys'
28
+ ? `Unknown argument(s): ${issue.keys.map((k) => `"${k}"`).join(', ')}. ` +
29
+ `Valid arguments: ${validKeys}. ` +
30
+ 'Unknown arguments are rejected rather than silently ignored, so a misspelled ' +
31
+ 'argument name cannot change what gets evaluated — check the spelling against ' +
32
+ "the tool's input schema and retry."
33
+ : undefined,
34
+ });
35
+ }