@iris-eval/mcp-server 0.3.1 → 0.4.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 (100) hide show
  1. package/README.md +11 -3
  2. package/dist/audit-log-reader.d.ts +24 -0
  3. package/dist/audit-log-reader.js +87 -0
  4. package/dist/config/defaults.js +7 -1
  5. package/dist/custom-rule-store.d.ts +27 -0
  6. package/dist/custom-rule-store.js +188 -0
  7. package/dist/dashboard/assets/index-BEG5FYWH.css +1 -0
  8. package/dist/dashboard/assets/index-D9JHfSB2.js +12 -0
  9. package/dist/dashboard/index.html +2 -2
  10. package/dist/dashboard/routes/audit.d.ts +3 -0
  11. package/dist/dashboard/routes/audit.js +36 -0
  12. package/dist/dashboard/routes/eval-stats.js +9 -4
  13. package/dist/dashboard/routes/evaluations.js +3 -1
  14. package/dist/dashboard/routes/filters.js +5 -3
  15. package/dist/dashboard/routes/health.js +8 -1
  16. package/dist/dashboard/routes/index.d.ts +4 -0
  17. package/dist/dashboard/routes/index.js +4 -0
  18. package/dist/dashboard/routes/moments.d.ts +3 -0
  19. package/dist/dashboard/routes/moments.js +115 -0
  20. package/dist/dashboard/routes/preferences.d.ts +3 -0
  21. package/dist/dashboard/routes/preferences.js +52 -0
  22. package/dist/dashboard/routes/rules.d.ts +10 -0
  23. package/dist/dashboard/routes/rules.js +169 -0
  24. package/dist/dashboard/routes/summary.js +3 -1
  25. package/dist/dashboard/routes/traces.js +7 -4
  26. package/dist/dashboard/server.d.ts +9 -1
  27. package/dist/dashboard/server.js +52 -3
  28. package/dist/eval/citation-verify/extract.d.ts +11 -0
  29. package/dist/eval/citation-verify/extract.js +102 -0
  30. package/dist/eval/citation-verify/resolve.d.ts +26 -0
  31. package/dist/eval/citation-verify/resolve.js +237 -0
  32. package/dist/eval/citation-verify/verifier.d.ts +43 -0
  33. package/dist/eval/citation-verify/verifier.js +203 -0
  34. package/dist/eval/decision-moment.d.ts +12 -0
  35. package/dist/eval/decision-moment.js +181 -0
  36. package/dist/eval/llm-judge/client.d.ts +28 -0
  37. package/dist/eval/llm-judge/client.js +183 -0
  38. package/dist/eval/llm-judge/evaluator.d.ts +32 -0
  39. package/dist/eval/llm-judge/evaluator.js +138 -0
  40. package/dist/eval/llm-judge/pricing.d.ts +9 -0
  41. package/dist/eval/llm-judge/pricing.js +31 -0
  42. package/dist/eval/llm-judge/templates/index.d.ts +20 -0
  43. package/dist/eval/llm-judge/templates/index.js +170 -0
  44. package/dist/eval/rules/custom.js +13 -2
  45. package/dist/index.js +77 -14
  46. package/dist/middleware/index.d.ts +1 -0
  47. package/dist/middleware/index.js +1 -0
  48. package/dist/middleware/tenant.d.ts +17 -0
  49. package/dist/middleware/tenant.js +26 -0
  50. package/dist/otel/exporter.d.ts +24 -0
  51. package/dist/otel/exporter.js +116 -0
  52. package/dist/otel/lazy.d.ts +5 -0
  53. package/dist/otel/lazy.js +31 -0
  54. package/dist/otel/mapper.d.ts +24 -0
  55. package/dist/otel/mapper.js +208 -0
  56. package/dist/preferences.d.ts +129 -0
  57. package/dist/preferences.js +152 -0
  58. package/dist/resources/dashboard-summary.js +3 -1
  59. package/dist/resources/trace-detail.js +5 -3
  60. package/dist/server.d.ts +3 -1
  61. package/dist/server.js +9 -3
  62. package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
  63. package/dist/storage/migrations/004-tenant-id.js +40 -0
  64. package/dist/storage/migrations/index.js +2 -1
  65. package/dist/storage/sqlite-adapter.d.ts +17 -15
  66. package/dist/storage/sqlite-adapter.js +130 -79
  67. package/dist/tools/delete-rule.d.ts +3 -0
  68. package/dist/tools/delete-rule.js +53 -0
  69. package/dist/tools/delete-trace.d.ts +3 -0
  70. package/dist/tools/delete-trace.js +54 -0
  71. package/dist/tools/deploy-rule.d.ts +3 -0
  72. package/dist/tools/deploy-rule.js +91 -0
  73. package/dist/tools/evaluate-output.js +23 -2
  74. package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
  75. package/dist/tools/evaluate-with-llm-judge.js +147 -0
  76. package/dist/tools/get-traces.js +23 -3
  77. package/dist/tools/index.d.ts +2 -1
  78. package/dist/tools/index.js +13 -1
  79. package/dist/tools/list-rules.d.ts +3 -0
  80. package/dist/tools/list-rules.js +66 -0
  81. package/dist/tools/log-trace.js +30 -2
  82. package/dist/tools/verify-citations.d.ts +3 -0
  83. package/dist/tools/verify-citations.js +157 -0
  84. package/dist/types/custom-rule.d.ts +70 -0
  85. package/dist/types/custom-rule.js +1 -0
  86. package/dist/types/decision-moment.d.ts +122 -0
  87. package/dist/types/decision-moment.js +17 -0
  88. package/dist/types/index.d.ts +2 -0
  89. package/dist/types/index.js +1 -1
  90. package/dist/types/query.d.ts +25 -15
  91. package/dist/types/tenant.d.ts +26 -0
  92. package/dist/types/tenant.js +58 -0
  93. package/dist/utils/open-browser.d.ts +1 -0
  94. package/dist/utils/open-browser.js +45 -0
  95. package/dist/utils/validate-port-config.d.ts +2 -0
  96. package/dist/utils/validate-port-config.js +9 -0
  97. package/package.json +4 -1
  98. package/server.json +2 -2
  99. package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
  100. package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>Iris — Agent Eval & Observability</title>
8
- <script type="module" crossorigin src="/assets/index-CnDg6bYi.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-ZsBou2-c.css">
8
+ <script type="module" crossorigin src="/assets/index-D9JHfSB2.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BEG5FYWH.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { CustomRuleStore } from '../../custom-rule-store.js';
3
+ export declare function registerAuditRoutes(router: Router, store?: CustomRuleStore): void;
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { readAuditLog } from '../../audit-log-reader.js';
3
+ const QuerySchema = z.object({
4
+ action: z.enum(['rule.deploy', 'rule.delete', 'rule.toggle', 'rule.update']).optional(),
5
+ since: z.string().datetime({ offset: true }).optional(),
6
+ search: z.string().max(200).optional(),
7
+ limit: z.coerce.number().int().min(1).max(1000).default(100),
8
+ offset: z.coerce.number().int().min(0).default(0),
9
+ });
10
+ export function registerAuditRoutes(router, store) {
11
+ router.get('/audit', (req, res) => {
12
+ try {
13
+ const query = QuerySchema.parse(req.query);
14
+ const result = readAuditLog({
15
+ // CustomRuleStore controls the audit file path; defer to it when
16
+ // available so tests + production match the same target.
17
+ filePath: store?.auditPath,
18
+ filter: {
19
+ action: query.action,
20
+ since: query.since,
21
+ search: query.search,
22
+ },
23
+ limit: query.limit,
24
+ offset: query.offset,
25
+ });
26
+ res.json(result);
27
+ }
28
+ catch (err) {
29
+ if (err instanceof z.ZodError) {
30
+ res.status(400).json({ error: 'Invalid audit query', details: err.issues });
31
+ return;
32
+ }
33
+ throw err;
34
+ }
35
+ });
36
+ }
@@ -1,3 +1,4 @@
1
+ import { requireTenant } from '../../middleware/tenant.js';
1
2
  import { evalStatsPeriodSchema, evalStatsFailuresSchema } from '../validation.js';
2
3
  export function registerEvalStatsRoutes(router, storage) {
3
4
  /**
@@ -6,8 +7,9 @@ export function registerEvalStatsRoutes(router, storage) {
6
7
  */
7
8
  router.get('/eval-stats', async (req, res) => {
8
9
  try {
10
+ const tenantId = requireTenant(req);
9
11
  const { period } = evalStatsPeriodSchema.parse(req.query);
10
- const stats = await storage.getEvalStats(period);
12
+ const stats = await storage.getEvalStats(tenantId, period);
11
13
  res.json(stats);
12
14
  }
13
15
  catch (err) {
@@ -24,8 +26,9 @@ export function registerEvalStatsRoutes(router, storage) {
24
26
  */
25
27
  router.get('/eval-stats/trend', async (req, res) => {
26
28
  try {
29
+ const tenantId = requireTenant(req);
27
30
  const { period } = evalStatsPeriodSchema.parse(req.query);
28
- const trend = await storage.getEvalStatsTrend(period);
31
+ const trend = await storage.getEvalStatsTrend(tenantId, period);
29
32
  res.json(trend);
30
33
  }
31
34
  catch (err) {
@@ -43,8 +46,9 @@ export function registerEvalStatsRoutes(router, storage) {
43
46
  */
44
47
  router.get('/eval-stats/rules', async (req, res) => {
45
48
  try {
49
+ const tenantId = requireTenant(req);
46
50
  const { period } = evalStatsPeriodSchema.parse(req.query);
47
- const rules = await storage.getEvalStatsRules(period);
51
+ const rules = await storage.getEvalStatsRules(tenantId, period);
48
52
  res.json(rules);
49
53
  }
50
54
  catch (err) {
@@ -61,8 +65,9 @@ export function registerEvalStatsRoutes(router, storage) {
61
65
  */
62
66
  router.get('/eval-stats/failures', async (req, res) => {
63
67
  try {
68
+ const tenantId = requireTenant(req);
64
69
  const query = evalStatsFailuresSchema.parse(req.query);
65
- const failures = await storage.getEvalStatsFailures(query.period, query.limit);
70
+ const failures = await storage.getEvalStatsFailures(tenantId, query.period, query.limit);
66
71
  res.json(failures);
67
72
  }
68
73
  catch (err) {
@@ -1,9 +1,11 @@
1
+ import { requireTenant } from '../../middleware/tenant.js';
1
2
  import { evalQuerySchema } from '../validation.js';
2
3
  export function registerEvaluationRoutes(router, storage) {
3
4
  router.get('/evaluations', async (req, res) => {
4
5
  try {
6
+ const tenantId = requireTenant(req);
5
7
  const query = evalQuerySchema.parse(req.query);
6
- const result = await storage.queryEvalResults({
8
+ const result = await storage.queryEvalResults(tenantId, {
7
9
  eval_type: query.eval_type,
8
10
  passed: query.passed,
9
11
  since: query.since,
@@ -1,8 +1,10 @@
1
+ import { requireTenant } from '../../middleware/tenant.js';
1
2
  export function registerFilterRoutes(router, storage) {
2
- router.get('/filters', async (_req, res) => {
3
+ router.get('/filters', async (req, res) => {
4
+ const tenantId = requireTenant(req);
3
5
  const [agentNames, frameworks] = await Promise.all([
4
- storage.getDistinctValues('agent_name'),
5
- storage.getDistinctValues('framework'),
6
+ storage.getDistinctValues(tenantId, 'agent_name'),
7
+ storage.getDistinctValues(tenantId, 'framework'),
6
8
  ]);
7
9
  res.json({ agent_names: agentNames, frameworks });
8
10
  });
@@ -1,3 +1,4 @@
1
+ import { LOCAL_TENANT } from '../../types/tenant.js';
1
2
  const startTime = Date.now();
2
3
  export function registerHealthRoutes(router, storage, version) {
3
4
  const serverVersion = version ?? 'unknown';
@@ -5,7 +6,13 @@ export function registerHealthRoutes(router, storage, version) {
5
6
  const uptime_seconds = Math.floor((Date.now() - startTime) / 1000);
6
7
  if (storage) {
7
8
  try {
8
- const summary = await storage.getDashboardSummary(1);
9
+ /* Health probes use LOCAL_TENANT directly — the health endpoint
10
+ * is pre-tenant-resolution (runs for unauthenticated callers on
11
+ * Cloud too) and reports server-level stats. This is the ONE
12
+ * place storage is called with an explicit LOCAL_TENANT rather
13
+ * than a resolved tenantId; deliberate, documented here so it
14
+ * stays the only exception. */
15
+ const summary = await storage.getDashboardSummary(LOCAL_TENANT, 1);
9
16
  res.json({
10
17
  status: 'ok',
11
18
  version: serverVersion,
@@ -4,3 +4,7 @@ export { registerEvaluationRoutes } from './evaluations.js';
4
4
  export { registerEvalStatsRoutes } from './eval-stats.js';
5
5
  export { registerFilterRoutes } from './filters.js';
6
6
  export { registerHealthRoutes } from './health.js';
7
+ export { registerMomentRoutes } from './moments.js';
8
+ export { registerRuleRoutes } from './rules.js';
9
+ export { registerPreferencesRoutes } from './preferences.js';
10
+ export { registerAuditRoutes } from './audit.js';
@@ -4,3 +4,7 @@ export { registerEvaluationRoutes } from './evaluations.js';
4
4
  export { registerEvalStatsRoutes } from './eval-stats.js';
5
5
  export { registerFilterRoutes } from './filters.js';
6
6
  export { registerHealthRoutes } from './health.js';
7
+ export { registerMomentRoutes } from './moments.js';
8
+ export { registerRuleRoutes } from './rules.js';
9
+ export { registerPreferencesRoutes } from './preferences.js';
10
+ export { registerAuditRoutes } from './audit.js';
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { IStorageAdapter } from '../../types/query.js';
3
+ export declare function registerMomentRoutes(router: Router, storage: IStorageAdapter): void;
@@ -0,0 +1,115 @@
1
+ import { z } from 'zod';
2
+ import { requireTenant } from '../../middleware/tenant.js';
3
+ import { deriveMoment, deriveMomentDetail } from '../../eval/decision-moment.js';
4
+ const VERDICT_VALUES = ['pass', 'fail', 'partial', 'unevaluated'];
5
+ const SIGNIFICANCE_KINDS = [
6
+ 'safety-violation',
7
+ 'cost-spike',
8
+ 'first-failure',
9
+ 'novel-pattern',
10
+ 'rule-collision',
11
+ 'normal-pass',
12
+ 'normal-fail',
13
+ ];
14
+ const momentQuerySchema = z.object({
15
+ agent_name: z.string().min(1).max(200).optional(),
16
+ verdict: z.enum(VERDICT_VALUES).optional(),
17
+ min_significance: z.coerce.number().min(0).max(1).optional(),
18
+ significance_kind: z
19
+ .enum(SIGNIFICANCE_KINDS)
20
+ .optional(),
21
+ since: z.string().datetime({ offset: true }).optional(),
22
+ until: z.string().datetime({ offset: true }).optional(),
23
+ limit: z.coerce.number().int().min(1).max(200).default(50),
24
+ offset: z.coerce.number().int().min(0).default(0),
25
+ sort_order: z.enum(['asc', 'desc']).default('desc'),
26
+ });
27
+ export function registerMomentRoutes(router, storage) {
28
+ router.get('/moments', async (req, res) => {
29
+ try {
30
+ const tenantId = requireTenant(req);
31
+ const query = momentQuerySchema.parse(req.query);
32
+ // Pull the underlying traces. We over-fetch when post-filtering by
33
+ // significance to give the moment classifier headroom, then trim.
34
+ const wantsSignificanceFilter = query.min_significance !== undefined || query.significance_kind !== undefined;
35
+ const fetchLimit = wantsSignificanceFilter
36
+ ? Math.min(query.limit * 4, 200)
37
+ : query.limit;
38
+ const traceResult = await storage.queryTraces(tenantId, {
39
+ filter: {
40
+ agent_name: query.agent_name,
41
+ since: query.since,
42
+ until: query.until,
43
+ },
44
+ limit: fetchLimit,
45
+ offset: query.offset,
46
+ sort_by: 'timestamp',
47
+ sort_order: query.sort_order,
48
+ });
49
+ // Hydrate moments by fetching evals per trace. Acceptable for limit ≤ 200;
50
+ // batching is a v0.4.1 optimization once we have moment-volume data.
51
+ const moments = [];
52
+ for (const trace of traceResult.traces) {
53
+ const evals = await storage.getEvalsByTraceId(tenantId, trace.trace_id);
54
+ const moment = deriveMoment(trace, evals);
55
+ if (query.verdict && moment.verdict !== query.verdict)
56
+ continue;
57
+ if (query.min_significance !== undefined &&
58
+ moment.significance.score < query.min_significance)
59
+ continue;
60
+ if (query.significance_kind &&
61
+ moment.significance.kind !== query.significance_kind)
62
+ continue;
63
+ moments.push(moment);
64
+ if (moments.length >= query.limit)
65
+ break;
66
+ }
67
+ const result = {
68
+ moments,
69
+ // total reflects the underlying trace count (pre-filter) — significance-
70
+ // filtered totals would require materializing the full set, which we
71
+ // avoid for now. Clients should treat this as "at least this many."
72
+ total: traceResult.total,
73
+ limit: query.limit,
74
+ offset: query.offset,
75
+ };
76
+ res.json(result);
77
+ }
78
+ catch (err) {
79
+ if (err instanceof Error && err.name === 'ZodError') {
80
+ res.status(400).json({
81
+ error: 'Invalid query parameters',
82
+ details: err.issues,
83
+ });
84
+ return;
85
+ }
86
+ throw err;
87
+ }
88
+ });
89
+ router.get('/moments/:id', async (req, res) => {
90
+ try {
91
+ const tenantId = requireTenant(req);
92
+ const trace = await storage.getTrace(tenantId, req.params.id);
93
+ if (!trace) {
94
+ res.status(404).json({ error: 'Decision moment not found' });
95
+ return;
96
+ }
97
+ const [evals, spans] = await Promise.all([
98
+ storage.getEvalsByTraceId(tenantId, req.params.id),
99
+ storage.getSpansByTraceId(tenantId, req.params.id),
100
+ ]);
101
+ const detail = deriveMomentDetail(trace, evals, spans);
102
+ res.json(detail);
103
+ }
104
+ catch (err) {
105
+ if (err instanceof Error && err.name === 'ZodError') {
106
+ res.status(400).json({
107
+ error: 'Invalid query parameters',
108
+ details: err.issues,
109
+ });
110
+ return;
111
+ }
112
+ throw err;
113
+ }
114
+ });
115
+ }
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { PreferenceStore } from '../../preferences.js';
3
+ export declare function registerPreferencesRoutes(router: Router, store: PreferenceStore): void;
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ /* PATCH body schema. Every field optional + strict (rejects unknown keys
3
+ * to catch typos at the API boundary). Inner schemas mirror preferences.ts. */
4
+ const MomentFiltersPatchSchema = z
5
+ .object({
6
+ agentName: z.string().max(200).optional(),
7
+ verdict: z.enum(['pass', 'fail', 'partial', 'unevaluated']).optional(),
8
+ significanceKind: z
9
+ .enum([
10
+ 'safety-violation',
11
+ 'cost-spike',
12
+ 'first-failure',
13
+ 'novel-pattern',
14
+ 'rule-collision',
15
+ 'normal-pass',
16
+ 'normal-fail',
17
+ ])
18
+ .optional(),
19
+ })
20
+ .strict();
21
+ const PatchSchema = z
22
+ .object({
23
+ autoLaunch: z.boolean().optional(),
24
+ dismissedBanners: z.array(z.string().max(80)).optional(),
25
+ theme: z.enum(['dark', 'light', 'system']).optional(),
26
+ momentFilters: MomentFiltersPatchSchema.optional(),
27
+ dismissedTours: z.array(z.string().max(80)).optional(),
28
+ archivedMoments: z.array(z.string().max(200)).optional(),
29
+ density: z.enum(['compact', 'comfortable']).optional(),
30
+ sidebarCollapsed: z.boolean().optional(),
31
+ notificationsLastSeen: z.string().datetime({ offset: true }).optional(),
32
+ })
33
+ .strict();
34
+ export function registerPreferencesRoutes(router, store) {
35
+ router.get('/preferences', (_req, res) => {
36
+ res.json({ preferences: store.read(), path: store.path });
37
+ });
38
+ router.patch('/preferences', (req, res) => {
39
+ try {
40
+ const patch = PatchSchema.parse(req.body);
41
+ const updated = store.patch(patch);
42
+ res.json({ preferences: updated, path: store.path });
43
+ }
44
+ catch (err) {
45
+ if (err instanceof z.ZodError) {
46
+ res.status(400).json({ error: 'Invalid preferences patch', details: err.issues });
47
+ return;
48
+ }
49
+ throw err;
50
+ }
51
+ });
52
+ }
@@ -0,0 +1,10 @@
1
+ import { Router } from 'express';
2
+ import type { IStorageAdapter } from '../../types/query.js';
3
+ import type { CustomRuleStore } from '../../custom-rule-store.js';
4
+ import type { EvalEngine } from '../../eval/engine.js';
5
+ interface RoutesOptions {
6
+ customRuleStore: CustomRuleStore;
7
+ evalEngine: EvalEngine;
8
+ }
9
+ export declare function registerRuleRoutes(router: Router, storage: IStorageAdapter, opts: RoutesOptions): void;
10
+ export {};
@@ -0,0 +1,169 @@
1
+ import { z } from 'zod';
2
+ import { createCustomRule } from '../../eval/rules/custom.js';
3
+ import { requireTenant } from '../../middleware/tenant.js';
4
+ const SeveritySchema = z.enum(['low', 'medium', 'high', 'critical']);
5
+ const EvalTypeSchema = z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']);
6
+ const RuleTypeSchema = z.enum([
7
+ 'regex_match',
8
+ 'regex_no_match',
9
+ 'min_length',
10
+ 'max_length',
11
+ 'contains_keywords',
12
+ 'excludes_keywords',
13
+ 'json_schema',
14
+ 'cost_threshold',
15
+ ]);
16
+ const DefinitionSchema = z.object({
17
+ name: z.string().min(1).max(80),
18
+ type: RuleTypeSchema,
19
+ config: z.record(z.unknown()),
20
+ weight: z.number().positive().optional(),
21
+ });
22
+ const DeploySchema = z.object({
23
+ name: z.string().min(1).max(80).regex(/^[a-z0-9._-]+$/i, 'Use letters, digits, dot, dash, underscore'),
24
+ description: z.string().max(500).optional(),
25
+ evalType: EvalTypeSchema,
26
+ severity: SeveritySchema.optional(),
27
+ definition: DefinitionSchema,
28
+ sourceMomentId: z.string().optional(),
29
+ });
30
+ const PreviewSchema = z.object({
31
+ definition: DefinitionSchema,
32
+ evalType: EvalTypeSchema.default('custom'),
33
+ /** Window in days; default 7. Hard cap at 30 to keep memory bounded. */
34
+ windowDays: z.coerce.number().int().min(1).max(30).default(7),
35
+ /** Trace cap; default 1000. */
36
+ maxTraces: z.coerce.number().int().min(1).max(5000).default(1000),
37
+ });
38
+ export function registerRuleRoutes(router, storage, opts) {
39
+ router.get('/rules/custom', (_req, res) => {
40
+ const rules = opts.customRuleStore.list();
41
+ res.json({ rules });
42
+ });
43
+ router.post('/rules/custom', async (req, res) => {
44
+ try {
45
+ const input = DeploySchema.parse(req.body);
46
+ // Server overrides the inner definition's `name` so it always matches the
47
+ // user-facing rule name. Avoids confusion when the rule name and the
48
+ // inner definition's check name diverge.
49
+ const definition = {
50
+ ...input.definition,
51
+ name: input.name,
52
+ };
53
+ const rule = opts.customRuleStore.deploy({
54
+ name: input.name,
55
+ description: input.description,
56
+ evalType: input.evalType,
57
+ severity: input.severity,
58
+ definition,
59
+ sourceMomentId: input.sourceMomentId,
60
+ });
61
+ // Register the new rule with the live engine so it fires on subsequent
62
+ // evaluate_output calls without requiring a server restart.
63
+ opts.evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition));
64
+ res.status(201).json({ rule });
65
+ }
66
+ catch (err) {
67
+ if (err instanceof z.ZodError) {
68
+ res.status(400).json({ error: 'Invalid rule definition', details: err.issues });
69
+ return;
70
+ }
71
+ throw err;
72
+ }
73
+ });
74
+ router.delete('/rules/custom/:id', (req, res) => {
75
+ const removed = opts.customRuleStore.delete(req.params.id);
76
+ if (!removed) {
77
+ res.status(404).json({ error: 'Rule not found' });
78
+ return;
79
+ }
80
+ // Note: removing from the live engine requires a registry reset, which
81
+ // the engine doesn't expose in v0.4. The deleted rule continues to fire
82
+ // until the next iris-mcp restart. Documented behavior; v0.4.1 adds
83
+ // engine.unregisterRule for hot-removal.
84
+ res.status(204).end();
85
+ });
86
+ router.post('/rules/custom/preview', async (req, res) => {
87
+ try {
88
+ const tenantId = requireTenant(req);
89
+ const input = PreviewSchema.parse(req.body);
90
+ const result = await previewRule(tenantId, input, storage);
91
+ res.json(result);
92
+ }
93
+ catch (err) {
94
+ if (err instanceof z.ZodError) {
95
+ res.status(400).json({ error: 'Invalid preview request', details: err.issues });
96
+ return;
97
+ }
98
+ const status = err.status;
99
+ if (status === 422) {
100
+ res.status(422).json({ error: 'Rule definition rejected', message: err.message });
101
+ return;
102
+ }
103
+ throw err;
104
+ }
105
+ });
106
+ }
107
+ async function previewRule(tenantId, input, storage) {
108
+ const since = new Date(Date.now() - input.windowDays * 24 * 60 * 60 * 1000).toISOString();
109
+ const traceResult = await storage.queryTraces(tenantId, {
110
+ filter: { since },
111
+ limit: input.maxTraces,
112
+ sort_by: 'timestamp',
113
+ sort_order: 'desc',
114
+ });
115
+ const rule = createCustomRule(input.definition);
116
+ // Sanity-probe the rule against an empty input. Compile-time errors
117
+ // (invalid regex, pattern too long, ReDoS rejection) surface here as a
118
+ // failed result with a recognizable error prefix. Surface as a 422 so
119
+ // the UI can show the error instead of a misleading "5 traces fail."
120
+ const probe = rule.evaluate({ output: '' });
121
+ if (!probe.skipped &&
122
+ !probe.passed &&
123
+ /^(?:Invalid regex|Regex pattern (?:too long|rejected))/.test(probe.message)) {
124
+ const err = new Error(probe.message);
125
+ err.status = 422;
126
+ throw err;
127
+ }
128
+ let wouldPass = 0;
129
+ let wouldFail = 0;
130
+ let wouldSkip = 0;
131
+ const examples = [];
132
+ for (const trace of traceResult.traces) {
133
+ if (trace.output === undefined) {
134
+ wouldSkip++;
135
+ continue;
136
+ }
137
+ const result = rule.evaluate({
138
+ output: trace.output,
139
+ input: trace.input,
140
+ costUsd: trace.cost_usd,
141
+ tokenUsage: trace.token_usage,
142
+ });
143
+ if (result.skipped) {
144
+ wouldSkip++;
145
+ }
146
+ else if (result.passed) {
147
+ wouldPass++;
148
+ }
149
+ else {
150
+ wouldFail++;
151
+ if (examples.length < 5) {
152
+ examples.push({
153
+ traceId: trace.trace_id,
154
+ agentName: trace.agent_name,
155
+ timestamp: trace.timestamp,
156
+ outputPreview: trace.output.slice(0, 200),
157
+ });
158
+ }
159
+ }
160
+ }
161
+ return {
162
+ tracesEvaluated: traceResult.traces.length,
163
+ wouldFail,
164
+ wouldPass,
165
+ wouldSkip,
166
+ examples,
167
+ windowSinceIso: since,
168
+ };
169
+ }
@@ -1,9 +1,11 @@
1
+ import { requireTenant } from '../../middleware/tenant.js';
1
2
  import { summaryQuerySchema } from '../validation.js';
2
3
  export function registerSummaryRoutes(router, storage) {
3
4
  router.get('/summary', async (req, res) => {
4
5
  try {
6
+ const tenantId = requireTenant(req);
5
7
  const query = summaryQuerySchema.parse(req.query);
6
- const summary = await storage.getDashboardSummary(query.hours);
8
+ const summary = await storage.getDashboardSummary(tenantId, query.hours);
7
9
  res.json(summary);
8
10
  }
9
11
  catch (err) {
@@ -1,9 +1,11 @@
1
+ import { requireTenant } from '../../middleware/tenant.js';
1
2
  import { traceQuerySchema } from '../validation.js';
2
3
  export function registerTraceRoutes(router, storage) {
3
4
  router.get('/traces', async (req, res) => {
4
5
  try {
6
+ const tenantId = requireTenant(req);
5
7
  const query = traceQuerySchema.parse(req.query);
6
- const result = await storage.queryTraces({
8
+ const result = await storage.queryTraces(tenantId, {
7
9
  filter: {
8
10
  agent_name: query.agent_name,
9
11
  framework: query.framework,
@@ -27,13 +29,14 @@ export function registerTraceRoutes(router, storage) {
27
29
  });
28
30
  router.get('/traces/:id', async (req, res) => {
29
31
  try {
30
- const trace = await storage.getTrace(req.params.id);
32
+ const tenantId = requireTenant(req);
33
+ const trace = await storage.getTrace(tenantId, req.params.id);
31
34
  if (!trace) {
32
35
  res.status(404).json({ error: 'Trace not found' });
33
36
  return;
34
37
  }
35
- const spans = await storage.getSpansByTraceId(req.params.id);
36
- const evals = await storage.getEvalsByTraceId(req.params.id);
38
+ const spans = await storage.getSpansByTraceId(tenantId, req.params.id);
39
+ const evals = await storage.getEvalsByTraceId(tenantId, req.params.id);
37
40
  res.json({ trace, spans, evals });
38
41
  }
39
42
  catch (err) {
@@ -3,8 +3,16 @@ import type { Server } from 'node:http';
3
3
  import type { IStorageAdapter } from '../types/query.js';
4
4
  import type { IrisConfig } from '../types/config.js';
5
5
  import type { Logger } from '../utils/logger.js';
6
+ import type { CustomRuleStore } from '../custom-rule-store.js';
7
+ import type { EvalEngine } from '../eval/engine.js';
8
+ import type { PreferenceStore } from '../preferences.js';
6
9
  export interface DashboardServer {
7
10
  app: express.Application;
8
11
  start(): Server;
9
12
  }
10
- export declare function createDashboardServer(storage: IStorageAdapter, config: IrisConfig, logger: Logger): DashboardServer;
13
+ export interface DashboardServerOptions {
14
+ customRuleStore?: CustomRuleStore;
15
+ evalEngine?: EvalEngine;
16
+ preferenceStore?: PreferenceStore;
17
+ }
18
+ export declare function createDashboardServer(storage: IStorageAdapter, config: IrisConfig, logger: Logger, options?: DashboardServerOptions): DashboardServer;