@iris-eval/mcp-server 0.5.1 → 0.6.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.
- package/README.md +95 -33
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +33 -7
- package/dist/dashboard/assets/index-CshLgDRB.js +10 -0
- package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
- package/dist/dashboard/index.html +4 -3
- package/dist/dashboard/routes/health.js +10 -3
- package/dist/dashboard/routes/moments.js +1 -1
- package/dist/dashboard/routes/preferences.d.ts +1 -0
- package/dist/dashboard/routes/preferences.js +31 -3
- package/dist/dashboard/routes/rules.d.ts +18 -0
- package/dist/dashboard/routes/rules.js +160 -6
- package/dist/dashboard/routes/traces.js +21 -3
- package/dist/dashboard/seed-demo-data.js +11 -0
- package/dist/dashboard/server.js +13 -3
- package/dist/dashboard/session-auth.d.ts +8 -0
- package/dist/dashboard/session-auth.js +237 -0
- package/dist/dashboard/validation.d.ts +9 -3
- package/dist/dashboard/validation.js +69 -11
- package/dist/eval/engine.d.ts +62 -0
- package/dist/eval/engine.js +188 -82
- package/dist/eval/rules/safety.d.ts +8 -0
- package/dist/eval/rules/safety.js +43 -11
- package/dist/index.js +102 -16
- package/dist/middleware/rate-limit.d.ts +25 -0
- package/dist/middleware/rate-limit.js +54 -2
- package/dist/self-test.d.ts +14 -0
- package/dist/self-test.js +97 -13
- package/dist/storage/demo-guard.d.ts +8 -0
- package/dist/storage/demo-guard.js +53 -0
- package/dist/storage/sqlite-adapter.d.ts +6 -0
- package/dist/storage/sqlite-adapter.js +72 -1
- package/dist/tools/delete-rule.js +49 -11
- package/dist/tools/deploy-rule.d.ts +33 -0
- package/dist/tools/deploy-rule.js +130 -27
- package/dist/tools/evaluate-output.js +41 -22
- package/dist/tools/evaluate-with-llm-judge.js +10 -3
- package/dist/tools/get-traces.d.ts +27 -0
- package/dist/tools/get-traces.js +60 -8
- package/dist/tools/list-rules.js +2 -2
- package/dist/tools/log-trace.js +4 -3
- package/dist/tools/strict-input.d.ts +1 -0
- package/dist/tools/strict-input.js +25 -0
- package/dist/tools/trace-link.d.ts +7 -0
- package/dist/tools/trace-link.js +39 -0
- package/dist/tools/verify-citations.d.ts +19 -0
- package/dist/tools/verify-citations.js +41 -4
- package/dist/types/eval.d.ts +45 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/query.d.ts +25 -0
- package/package.json +1 -1
- package/server.json +2 -2
- 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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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('
|
|
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
|
|
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
|
|
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 `
|
|
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)
|
|
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).
|
|
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)
|
|
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:
|
|
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
|
|
195
|
+
evalType,
|
|
98
196
|
severity: args.severity,
|
|
99
197
|
definition,
|
|
100
|
-
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({
|
|
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
|
};
|