@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
@@ -45,6 +45,16 @@ export class SqliteAdapter {
45
45
  this.db.pragma('journal_mode = WAL');
46
46
  this.db.pragma('busy_timeout = 5000');
47
47
  this.db.pragma('foreign_keys = ON');
48
+ /*
49
+ * secure_delete overwrites freed content with zeros instead of leaving
50
+ * it in place until the page is reused. Without it, a DELETE — the
51
+ * retention sweep, delete_trace, --purge — removed the row from every
52
+ * query while the text stayed byte-for-byte readable in the file with
53
+ * `strings iris.db`. Deletes are rare here (startup sweep, explicit
54
+ * deletes), so the write cost is negligible; the privacy cost of the
55
+ * alternative is the whole point of #372.
56
+ */
57
+ this.db.pragma('secure_delete = ON');
48
58
  runMigrations(this.db);
49
59
  /*
50
60
  * iris.db holds agent inputs and outputs verbatim, and a tool that
@@ -352,11 +362,18 @@ export class SqliteAdapter {
352
362
  * skips rules that passed, so scanning every safety eval in the window
353
363
  * is both correct and sufficient.
354
364
  */
365
+ /*
366
+ * eval_type IN ('safety', 'all'): an eval_type="all" run carries the
367
+ * whole safety bundle inside its rule_results, and a PII leak caught
368
+ * there is exactly as real as one caught by a safety-only run. The
369
+ * per-rule loop below keys on rule NAMES, so the wider filter cannot
370
+ * over-count.
371
+ */
355
372
  const safetyRows = this.db.prepare(`
356
373
  SELECT rule_results
357
374
  FROM eval_results
358
375
  WHERE tenant_id = ? AND created_at >= ?
359
- AND eval_type = 'safety'
376
+ AND eval_type IN ('safety', 'all')
360
377
  `).all(tenantId, since);
361
378
  const violations = { pii: 0, injection: 0, hallucination: 0 };
362
379
  for (const row of safetyRows) {
@@ -492,6 +509,55 @@ export class SqliteAdapter {
492
509
  .run(tenantId, cutoff);
493
510
  return result.changes;
494
511
  }
512
+ async deleteEvalResultsOlderThan(tenantId, days) {
513
+ assertTenant(tenantId);
514
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
515
+ /*
516
+ * created_at, not the linked trace's timestamp: an unlinked eval has
517
+ * no trace, and a linked one whose trace was already swept has a NULL
518
+ * trace_id — either way the eval's own age is the only age it has.
519
+ * Rows are ISO-8601 here (write path + migration 005), so the string
520
+ * comparison against an ISO cutoff is exact.
521
+ */
522
+ const result = this.db
523
+ .prepare('DELETE FROM eval_results WHERE tenant_id = ? AND created_at < ?')
524
+ .run(tenantId, cutoff);
525
+ return result.changes;
526
+ }
527
+ async purge(tenantId) {
528
+ assertTenant(tenantId);
529
+ const deleteAll = this.db.transaction(() => {
530
+ const evalResults = this.db.prepare('DELETE FROM eval_results WHERE tenant_id = ?').run(tenantId).changes;
531
+ // spans cascade (FK ON DELETE CASCADE).
532
+ const traces = this.db.prepare('DELETE FROM traces WHERE tenant_id = ?').run(tenantId).changes;
533
+ return { traces, evalResults };
534
+ });
535
+ const counts = deleteAll();
536
+ /*
537
+ * VACUUM rebuilds the file from the live rows only — the freed pages
538
+ * (already zeroed by secure_delete) are dropped rather than kept as
539
+ * free-list slack — and the TRUNCATE checkpoint then folds the WAL into
540
+ * the main file and cuts it to zero bytes, so neither iris.db nor
541
+ * iris.db-wal keeps a copy of what was just deleted. Skipped for
542
+ * :memory: (nothing on disk to clean).
543
+ */
544
+ if (this.dbPath !== ':memory:') {
545
+ this.db.exec('VACUUM');
546
+ }
547
+ await this.checkpoint();
548
+ return counts;
549
+ }
550
+ async checkpoint() {
551
+ if (this.dbPath === ':memory:')
552
+ return;
553
+ try {
554
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
555
+ }
556
+ catch {
557
+ // Best effort: a checkpoint can be refused while another connection
558
+ // holds a read transaction. The next one will pick the pages up.
559
+ }
560
+ }
495
561
  async deleteTrace(tenantId, traceId) {
496
562
  assertTenant(tenantId);
497
563
  // Tenant-scoped: a trace id owned by a different tenant is
@@ -551,6 +617,11 @@ export class SqliteAdapter {
551
617
  id: row.id,
552
618
  trace_id: row.trace_id,
553
619
  eval_type: row.eval_type,
620
+ /*
621
+ * `categories` is not a column: an eval_type="all" row carries a
622
+ * `category` on every rule_results entry instead, so a reader can
623
+ * regroup the per-bundle breakdown from what IS stored.
624
+ */
554
625
  output_text: row.output_text,
555
626
  expected_text: row.expected_text,
556
627
  score: row.score,
@@ -1,43 +1,56 @@
1
1
  /*
2
- * delete_rule MCP tool — remove a deployed custom rule.
2
+ * delete_rule MCP tool — remove a deployed custom rule, or disable /
3
+ * re-enable one without removing it.
3
4
  *
4
5
  * Destructive counterpart to deploy_rule. Removes the rule from
5
6
  * ~/.iris/custom-rules.json AND unregisters it from the live eval
6
7
  * engine, so it stops firing on the very next evaluate_output call —
7
8
  * no restart needed. Appends a `rule.delete` entry to the audit log.
8
9
  *
10
+ * With `enabled` present the call is a TOGGLE instead: the rule stays in
11
+ * the store with its history and provenance, is unregistered from (or
12
+ * re-registered with) the live engine, and a `rule.toggle` audit row is
13
+ * written. The descriptions used to point at "the dashboard's toggle
14
+ * affordance" for this — which did not exist on any surface; the store
15
+ * had setEnabled() and nothing called it. This is the MCP path to it.
16
+ *
9
17
  * Past eval_results that referenced this rule stay intact — the
10
18
  * history is preserved even after the rule is removed. The audit
11
19
  * log row is the permanent record that the rule ever existed.
12
20
  */
13
21
  import { z } from 'zod';
22
+ import { createCustomRule } from '../eval/rules/custom.js';
14
23
  import { LOCAL_TENANT } from '../types/tenant.js';
15
24
  import { strictInput } from './strict-input.js';
16
25
  const inputSchema = {
17
26
  rule_id: z
18
27
  .string()
19
28
  .regex(/^rule-[a-z0-9]+$/)
20
- .describe('Rule id to delete (format: rule-<hex>); obtained from list_rules or deploy_rule response'),
29
+ .describe('Rule id to delete or toggle (format: rule-<hex>); obtained from list_rules or deploy_rule response'),
30
+ enabled: z
31
+ .boolean()
32
+ .optional()
33
+ .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'),
21
34
  };
22
35
  export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
23
36
  server.registerTool('delete_rule', {
24
- title: 'Delete Custom Rule',
37
+ title: 'Delete or Disable Custom Rule',
25
38
  description: [
26
- 'Remove a deployed custom evaluation rule. The rule stops firing on future evaluate_output calls; past eval_results that referenced it are preserved.',
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.',
27
40
  '',
28
- 'Sibling tools — deploy_rule adds custom rules, list_rules enumerates them, 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; it does NOT touch traces, eval_results, or built-in (non-custom) rules.',
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.',
29
42
  '',
30
- 'Behavior. 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. Historical eval_results that reference this rule_id stay in the database — drift analytics + audit trail remain valid. Tenant-scoped in Cloud tier; OSS operates on LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
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. Tenant-scoped in Cloud tier; OSS operates on LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
31
44
  '',
32
- 'Output shape. Returns JSON: `{ "deleted": boolean, "rule_id": string }`. `deleted=true` if a row was removed; `deleted=false` if no rule with that id existed.',
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.',
33
46
  '',
34
- "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). To temporarily disable a rule WITHOUT deletion, use the dashboard's toggle affordance instead delete is permanent in intent (rule is gone; re-adding requires a new id).",
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.",
35
48
  '',
36
- "Don't use to pause a rule (toggle in the dashboard preserves history better). 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 in v0.4 — they fall under data retention).",
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`).",
37
50
  '',
38
- 'Parameters. rule_id is the only parameter; 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` 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`.',
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.',
39
52
  '',
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.",
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.",
41
54
  ].join('\n'),
42
55
  inputSchema: strictInput(inputSchema),
43
56
  annotations: {
@@ -48,6 +61,31 @@ export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
48
61
  },
49
62
  }, async (args) => {
50
63
  // OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
64
+ if (args.enabled !== undefined) {
65
+ const rule = customRuleStore.setEnabled(LOCAL_TENANT, args.rule_id, args.enabled, 'mcp');
66
+ if (!rule) {
67
+ return {
68
+ content: [{ type: 'text', text: JSON.stringify({ deleted: false, toggled: false, rule_id: args.rule_id }) }],
69
+ };
70
+ }
71
+ // Mirror the store in the live engine, so the toggle is immediate
72
+ // (registerRule is idempotent by id — re-enabling an already-live
73
+ // rule does not stack a second copy).
74
+ if (rule.enabled) {
75
+ evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
76
+ }
77
+ else {
78
+ evalEngine.unregisterRule(rule.id);
79
+ }
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
+ };
88
+ }
51
89
  const deleted = customRuleStore.delete(LOCAL_TENANT, args.rule_id, 'mcp');
52
90
  if (deleted) {
53
91
  // Hot-remove from the live engine so the rule stops firing on the
@@ -1,4 +1,37 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { CustomRuleStore } from '../custom-rule-store.js';
3
3
  import type { EvalEngine } from '../eval/engine.js';
4
+ import type { DeployedCustomRule } from '../types/custom-rule.js';
5
+ import { type TenantId } from '../types/tenant.js';
6
+ /**
7
+ * A rule with this name is already deployed and the caller did not ask to
8
+ * 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
+ */
11
+ export declare class DuplicateRuleNameError extends Error {
12
+ readonly existing: DeployedCustomRule[];
13
+ constructor(name: string, existing: DeployedCustomRule[]);
14
+ }
15
+ /** One rule retired by a `replace: true` deploy. */
16
+ export interface ReplacedRule {
17
+ id: string;
18
+ evalType: string;
19
+ severity: string;
20
+ }
21
+ /**
22
+ * Same-name redeploy (#373). Two rules with one name both fire, and their
23
+ * rule_results used to be indistinguishable — the same ruleName showing
24
+ * PASS and FAIL in one response. Refuse by default; with replace:true,
25
+ * retire the earlier rule(s) first so the name means one thing again. The
26
+ * store keeps the audit trail either way.
27
+ *
28
+ * One function for both deploy surfaces — the `deploy_rule` tool and the
29
+ * dashboard's `POST /api/v1/rules/custom` — so the semantics and the
30
+ * wording cannot drift between them. Returns the rules it retired (empty
31
+ * when the name was free); throws DuplicateRuleNameError when the name is
32
+ * taken and `replace` is false. Nothing is deployed by this function.
33
+ */
34
+ export declare function retireSameNamedRules(store: CustomRuleStore, engine: EvalEngine, tenantId: TenantId, name: string, replace: boolean, user: string): ReplacedRule[];
35
+ /** The `warning` both deploy surfaces attach when a replace retired rules. */
36
+ export declare function replacedRulesWarning(name: string, replaced: ReplacedRule[]): string;
4
37
  export declare function registerDeployRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;
@@ -7,16 +7,82 @@
7
7
  *
8
8
  * Writes to ~/.iris/custom-rules.json (single source of truth) and
9
9
  * appends to the audit log. Persisted rules auto-load on server boot
10
- * and fire on every future evaluate_output call of the matching
11
- * eval_type.
10
+ * and fire on every future evaluate_output call whose eval_type equals
11
+ * the rule's evalType (or eval_type="all").
12
12
  */
13
13
  import { z } from 'zod';
14
14
  import { createCustomRule } from '../eval/rules/custom.js';
15
15
  import { LOCAL_TENANT } from '../types/tenant.js';
16
- import { strictInput } from './strict-input.js';
17
- const CustomRuleDefinitionSchema = z.object({
18
- name: z.string(),
19
- type: z.enum([
16
+ import { strictInput, strictNested } from './strict-input.js';
17
+ const EvalTypeSchema = z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']);
18
+ /**
19
+ * A rule with this name is already deployed and the caller did not ask to
20
+ * replace it. Carries the existing rule(s) so an HTTP surface can answer
21
+ * 409 with them beside the same message the MCP tool throws.
22
+ */
23
+ export class DuplicateRuleNameError extends Error {
24
+ existing;
25
+ constructor(name, existing) {
26
+ const listed = existing
27
+ .map((r) => `${r.id} (eval_type ${r.evalType}, severity ${r.severity}, ${r.enabled ? 'enabled' : 'disabled'})`)
28
+ .join('; ');
29
+ super(`A rule named "${name}" is already deployed: ${listed}. Deploying a second rule with the same name ` +
30
+ 'would make both fire with indistinguishable rule_results. Pass replace: true to delete the existing rule ' +
31
+ 'and deploy this one in its place, call delete_rule first, or choose a different name. Nothing was deployed.');
32
+ this.name = 'DuplicateRuleNameError';
33
+ this.existing = existing;
34
+ }
35
+ }
36
+ /**
37
+ * Same-name redeploy (#373). Two rules with one name both fire, and their
38
+ * rule_results used to be indistinguishable — the same ruleName showing
39
+ * PASS and FAIL in one response. Refuse by default; with replace:true,
40
+ * retire the earlier rule(s) first so the name means one thing again. The
41
+ * store keeps the audit trail either way.
42
+ *
43
+ * One function for both deploy surfaces — the `deploy_rule` tool and the
44
+ * dashboard's `POST /api/v1/rules/custom` — so the semantics and the
45
+ * wording cannot drift between them. Returns the rules it retired (empty
46
+ * when the name was free); throws DuplicateRuleNameError when the name is
47
+ * taken and `replace` is false. Nothing is deployed by this function.
48
+ */
49
+ export function retireSameNamedRules(store, engine, tenantId, name, replace, user) {
50
+ const sameName = store.list(tenantId).filter((r) => r.name === name);
51
+ if (sameName.length === 0)
52
+ return [];
53
+ if (!replace)
54
+ throw new DuplicateRuleNameError(name, sameName);
55
+ const replaced = [];
56
+ for (const old of sameName) {
57
+ if (store.delete(tenantId, old.id, user)) {
58
+ engine.unregisterRule(old.id);
59
+ replaced.push({ id: old.id, evalType: old.evalType, severity: old.severity });
60
+ }
61
+ }
62
+ return replaced;
63
+ }
64
+ /** The `warning` both deploy surfaces attach when a replace retired rules. */
65
+ export function replacedRulesWarning(name, replaced) {
66
+ return (`Replaced ${replaced.length} previously deployed rule(s) named "${name}" ` +
67
+ `(${replaced.map((r) => r.id).join(', ')}); they no longer fire. Their audit rows are preserved.`);
68
+ }
69
+ /*
70
+ * Strict one level down, like evaluate_output's custom_rules entries
71
+ * (#376): a misspelled `wieght` used to be dropped silently. `config`
72
+ * stays free-form (its keys depend on `type` and are validated by the
73
+ * store at deploy time). `name` is optional: the server always overwrites
74
+ * it with the top-level rule name (#377), so requiring a value that is
75
+ * then discarded only invited a mismatch.
76
+ */
77
+ const CustomRuleDefinitionSchema = strictNested({
78
+ name: z
79
+ .string()
80
+ .min(1)
81
+ .max(80)
82
+ .optional()
83
+ .describe('Optional and IGNORED if given — the server overwrites it with the top-level `name` so the rule reports under one name everywhere'),
84
+ type: z
85
+ .enum([
20
86
  'regex_match',
21
87
  'regex_no_match',
22
88
  'min_length',
@@ -25,55 +91,81 @@ const CustomRuleDefinitionSchema = z.object({
25
91
  'excludes_keywords',
26
92
  'json_schema',
27
93
  'cost_threshold',
28
- ]),
29
- config: z.record(z.string(), z.unknown()),
30
- weight: z.number().optional(),
31
- });
94
+ ])
95
+ .describe('Check type — decides which config keys are required'),
96
+ config: z
97
+ .record(z.string(), z.unknown())
98
+ .describe('Check configuration; required keys depend on type (regex_match: pattern; min_length: min_length; max_length: max_length; contains_keywords/excludes_keywords: keywords; cost_threshold: max_cost; json_schema: none)'),
99
+ weight: z.number().positive().optional().describe('Weight in the weighted score (default 1; must be > 0)'),
100
+ }, 'definition');
32
101
  const inputSchema = {
33
102
  // 80 mirrors the persisted store's cap (custom-rule-store.ts). The tool
34
103
  // used to allow 120, so a 100-char name passed the tool schema and then
35
104
  // surfaced the store's ZodError as a raw 500 (#332). One limit, enforced
36
105
  // 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)'),
106
+ name: z.string().min(1).max(80).describe('Human-readable rule name (1-80 chars; used in eval results). Must be unique among deployed rules unless replace=true'),
38
107
  description: z
39
108
  .string()
40
109
  .max(500)
41
110
  .optional()
42
111
  .describe('What this rule checks for and why it matters'),
43
- evalType: z
44
- .enum(['completeness', 'relevance', 'safety', 'cost', 'custom'])
45
- .describe('Eval category this rule belongs to; determines when it fires'),
112
+ eval_type: EvalTypeSchema.optional().describe('Eval category this rule belongs to; the rule fires on evaluate_output calls whose eval_type equals it (and on eval_type="all"). Canonical snake_case spelling — pass exactly one of eval_type / evalType'),
113
+ evalType: EvalTypeSchema.optional().describe('camelCase alias of eval_type, accepted for compatibility — prefer eval_type (snake_case is canonical across the tools)'),
46
114
  severity: z
47
115
  .enum(['low', 'medium', 'high', 'critical'])
48
116
  .default('medium')
49
117
  .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'),
50
- definition: CustomRuleDefinitionSchema.describe('Check definition (regex, length, keyword, cost, or schema)'),
118
+ definition: CustomRuleDefinitionSchema.describe('Check definition (regex, length, keyword, cost, or schema). Accepts exactly type, config, weight and an optional name — an unknown key is rejected'),
119
+ source_moment_id: z
120
+ .string()
121
+ .optional()
122
+ .describe('Optional Decision Moment ID the rule was derived from (preserves workflow-inversion provenance). Canonical snake_case — pass exactly one of source_moment_id / sourceMomentId'),
51
123
  sourceMomentId: z
52
124
  .string()
53
125
  .optional()
54
- .describe('Optional Decision Moment ID the rule was derived from (preserves workflow-inversion provenance)'),
126
+ .describe('camelCase alias of source_moment_id, accepted for compatibility prefer source_moment_id'),
127
+ replace: z
128
+ .boolean()
129
+ .default(false)
130
+ .describe('When a rule with this name is already deployed: false (default) rejects the call; true deletes the existing same-named rule(s) and deploys this one in their place (fresh id; audit rows preserved)'),
55
131
  };
132
+ /*
133
+ * Exactly one spelling of each aliased argument. Both spellings present
134
+ * (even with equal values) is refused rather than reconciled — a caller
135
+ * sending both has a bug somewhere, and silently picking one hides it.
136
+ */
137
+ const inputSchemaWithAliases = strictInput(inputSchema).superRefine((args, ctx) => {
138
+ if (args.eval_type === undefined && args.evalType === undefined) {
139
+ ctx.addIssue({ code: 'custom', path: ['eval_type'], message: 'eval_type is required (evalType is the accepted camelCase alias)' });
140
+ }
141
+ else if (args.eval_type !== undefined && args.evalType !== undefined) {
142
+ ctx.addIssue({ code: 'custom', path: ['evalType'], message: 'pass either eval_type or evalType, not both' });
143
+ }
144
+ if (args.source_moment_id !== undefined && args.sourceMomentId !== undefined) {
145
+ ctx.addIssue({ code: 'custom', path: ['sourceMomentId'], message: 'pass either source_moment_id or sourceMomentId, not both' });
146
+ }
147
+ });
56
148
  export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
57
149
  server.registerTool('deploy_rule', {
58
150
  title: 'Deploy Custom Rule',
59
151
  description: [
60
152
  'Deploy a new custom evaluation rule that will fire on every future evaluate_output call of its eval category.',
61
153
  '',
62
- 'Sibling tools — list_rules enumerates deployed rules, delete_rule removes them, evaluate_output runs them. log_trace / get_traces / delete_trace handle the trace lifecycle separately; evaluate_with_llm_judge / verify_citations run semantic scoring (not heuristic-rule-driven). deploy_rule is the WRITE path that grows the custom-rule library.',
154
+ 'Sibling tools — list_rules enumerates deployed rules, delete_rule removes them (or disables/re-enables them with its `enabled` argument), evaluate_output runs them. log_trace / get_traces / delete_trace handle the trace lifecycle separately; evaluate_with_llm_judge / verify_citations run semantic scoring (not heuristic-rule-driven). deploy_rule is the WRITE path that grows the custom-rule library.',
63
155
  '',
64
- 'Behavior. Writes a row to ~/.iris/custom-rules.json (atomic write via temp file + rename) and appends a `rule.deploy` entry to the audit log (~/.iris/audit.log). The rule activates immediately for the running process and persists across restarts. Each call mints a fresh rule_id; not idempotent (deploying twice creates two rules). Tenant-scoped in Cloud tier; OSS rules are owned by LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
156
+ 'Behavior. Writes a row to ~/.iris/custom-rules.json (atomic write via temp file + rename) and appends a `rule.deploy` entry to the audit log (~/.iris/audit.log). The rule activates immediately for the running process and persists across restarts. Each call mints a fresh rule_id. Rule names are unique among deployed rules: deploying a name that is already deployed is REJECTED unless `replace: true`, in which case the existing same-named rule(s) are deleted (audit `rule.delete` rows written, unregistered from the live engine) and the new rule takes their place under a new id — the response lists what was replaced. Tenant-scoped in Cloud tier; OSS rules are owned by LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
65
157
  '',
66
- 'Output shape. Returns JSON: `{ "rule": { "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition", "enabled": true, "createdAt", "updatedAt", "version": 1, "sourceMomentId?" } }`. The returned rule is the canonical persisted form; save the `id` if you plan to update or delete later.',
158
+ 'Output shape. Returns JSON: `{ "rule": { "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition", "enabled": true, "createdAt", "updatedAt", "version": 1, "sourceMomentId?" }, "replaced?": [{ "id", "evalType", "severity" }], "warning?": string }`. The returned rule is the canonical persisted form; save the `id` if you plan to disable or delete later. `replaced` and `warning` appear only when `replace: true` removed an earlier rule of the same name.',
67
159
  '',
68
- "Use when an agent observes a recurring failure pattern and decides to enforce it as a standing rule. The `sourceMomentId` field preserves provenance — downstream audit can trace the rule back to the moment that inspired it. Combine with evaluate_output + get_traces: 1) evaluate_output surfaces failures; 2) get_traces filters to the failure set; 3) analyze the pattern; 4) deploy_rule bakes it into the default eval path.",
160
+ "Use when an agent observes a recurring failure pattern and decides to enforce it as a standing rule. The `source_moment_id` field preserves provenance — downstream audit can trace the rule back to the moment that inspired it. Combine with evaluate_output + get_traces: 1) evaluate_output surfaces failures; 2) get_traces filters to the failure set; 3) analyze the pattern; 4) deploy_rule bakes it into the default eval path.",
69
161
  '',
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.",
162
+ "Don't use to VALIDATE a rule before committing — deploy writes immediately. Use the dashboard's preview endpoint (POST /api/v1/rules/custom/preview) to replay a definition against recent stored traces first. To UPDATE a rule: call deploy_rule with the same name and `replace: true` (the old rule is deleted, the new one gets a fresh id), or delete_rule then deploy_rule. To pause a rule without losing it: delete_rule with `enabled: false`.",
71
163
  '',
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".',
164
+ 'Parameters. Argument names are snake_case (eval_type, source_moment_id) — the camelCase spellings evalType / sourceMomentId are accepted as aliases for compatibility, but pass only one spelling of each. name is 1-80 chars (Zod-enforced min/max — the same cap the persisted store applies); appears in eval_result rule_results (alongside the rule id as `ruleId`) so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). eval_type determines WHEN the rule fires: a deployed rule runs ONLY on evaluate_output calls whose eval_type equals the rule\'s eval_type, plus eval_type="all" (which runs every bundle) — a "completeness" rule does NOT fire on eval_type="safety" or on eval_type="custom"; eval_type="custom" runs rules deployed under "custom" (and the call\'s inline custom_rules) and nothing else. 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). definition.name is optional and, if given, overwritten by the top-level name. Invalid configs are REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. source_moment_id is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). replace defaults to false. Defaults: severity="medium", replace=false.',
73
165
  '',
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.",
166
+ "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars), on an unknown key inside `definition` (the valid keys are listed; config keys are free-form), on empty `name` or `name` over 80 chars, on a non-positive weight, and when both spellings of an aliased argument are passed. Throws when a rule with the same name is already deployed and `replace` is not true — the message names the existing rule id so you can delete it, replace it, or pick another name. Any eval_type/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.",
75
167
  ].join('\n'),
76
- inputSchema: strictInput(inputSchema),
168
+ inputSchema: inputSchemaWithAliases,
77
169
  annotations: {
78
170
  readOnlyHint: false,
79
171
  destructiveHint: false,
@@ -81,6 +173,8 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
81
173
  openWorldHint: false,
82
174
  },
83
175
  }, async (args) => {
176
+ const evalType = (args.eval_type ?? args.evalType);
177
+ const sourceMomentId = args.source_moment_id ?? args.sourceMomentId;
84
178
  // Server overrides the inner definition's `name` so it always matches
85
179
  // the user-facing rule name — same normalization the dashboard's
86
180
  // deploy route applies. Also keeps the tool's 80-char cap authoritative
@@ -90,14 +184,18 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
90
184
  ...args.definition,
91
185
  name: args.name,
92
186
  };
187
+ // Same-name redeploy (#373) — shared with the dashboard's deploy
188
+ // route; see retireSameNamedRules above. Throws (nothing deployed)
189
+ // when the name is taken and replace is false.
190
+ const replaced = retireSameNamedRules(customRuleStore, evalEngine, LOCAL_TENANT, args.name, args.replace, 'mcp');
93
191
  // OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
94
192
  const rule = customRuleStore.deploy(LOCAL_TENANT, {
95
193
  name: args.name,
96
194
  description: args.description,
97
- evalType: args.evalType,
195
+ evalType,
98
196
  severity: args.severity,
99
197
  definition,
100
- sourceMomentId: args.sourceMomentId,
198
+ sourceMomentId,
101
199
  user: 'mcp',
102
200
  });
103
201
  // Register with the live engine so the rule fires on the very next
@@ -111,7 +209,12 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
111
209
  content: [
112
210
  {
113
211
  type: 'text',
114
- text: JSON.stringify({ rule }),
212
+ text: JSON.stringify({
213
+ rule,
214
+ ...(replaced.length > 0
215
+ ? { replaced, warning: replacedRulesWarning(args.name, replaced) }
216
+ : {}),
217
+ }),
115
218
  },
116
219
  ],
117
220
  };