@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
@@ -1,6 +1,8 @@
1
+ import { LOCAL_TENANT } from '../types/tenant.js';
1
2
  export function registerDashboardSummaryResource(server, storage) {
2
3
  server.resource('dashboard-summary', 'iris://dashboard/summary', { description: 'Dashboard summary with key metrics and trends' }, async () => {
3
- const summary = await storage.getDashboardSummary();
4
+ // OSS single-tenant: summary scopes to the local user.
5
+ const summary = await storage.getDashboardSummary(LOCAL_TENANT);
4
6
  return {
5
7
  contents: [
6
8
  {
@@ -1,7 +1,9 @@
1
+ import { LOCAL_TENANT } from '../types/tenant.js';
1
2
  export function registerTraceDetailResource(server, storage) {
2
3
  server.resource('trace-detail', 'iris://traces/{trace_id}', { description: 'Full trace detail with spans and evaluation results' }, async (uri) => {
3
4
  const traceId = uri.pathname.split('/').pop();
4
- const trace = await storage.getTrace(traceId);
5
+ // OSS single-tenant: MCP caller is the local user.
6
+ const trace = await storage.getTrace(LOCAL_TENANT, traceId);
5
7
  if (!trace) {
6
8
  return {
7
9
  contents: [
@@ -13,8 +15,8 @@ export function registerTraceDetailResource(server, storage) {
13
15
  ],
14
16
  };
15
17
  }
16
- const spans = await storage.getSpansByTraceId(traceId);
17
- const evals = await storage.getEvalsByTraceId(traceId);
18
+ const spans = await storage.getSpansByTraceId(LOCAL_TENANT, traceId);
19
+ const evals = await storage.getEvalsByTraceId(LOCAL_TENANT, traceId);
18
20
  return {
19
21
  contents: [
20
22
  {
package/dist/server.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { IrisConfig } from './types/index.js';
3
3
  import type { IStorageAdapter } from './types/query.js';
4
+ import type { CustomRuleStore } from './custom-rule-store.js';
4
5
  import { EvalEngine } from './eval/engine.js';
5
6
  export interface IrisServer {
6
7
  mcpServer: McpServer;
7
8
  evalEngine: EvalEngine;
9
+ customRuleStore: CustomRuleStore;
8
10
  }
9
- export declare function createIrisServer(config: IrisConfig, storage: IStorageAdapter): IrisServer;
11
+ export declare function createIrisServer(config: IrisConfig, storage: IStorageAdapter, customRuleStore?: CustomRuleStore): IrisServer;
package/dist/server.js CHANGED
@@ -2,13 +2,19 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { EvalEngine } from './eval/engine.js';
3
3
  import { registerAllTools } from './tools/index.js';
4
4
  import { registerAllResources } from './resources/index.js';
5
- export function createIrisServer(config, storage) {
5
+ import { createCustomRuleStore } from './custom-rule-store.js';
6
+ export function createIrisServer(config, storage, customRuleStore) {
6
7
  const mcpServer = new McpServer({
7
8
  name: config.server.name,
8
9
  version: config.server.version,
9
10
  });
10
11
  const evalEngine = new EvalEngine(config.eval.defaultThreshold, config.eval.ruleThresholds);
11
- registerAllTools(mcpServer, storage, evalEngine);
12
+ // Caller can inject a shared rule store (e.g. index.ts passes the
13
+ // same instance the HTTP dashboard uses, so a rule deployed via MCP
14
+ // is immediately visible in the dashboard without a restart). If
15
+ // none provided, create a fresh one loading from the default path.
16
+ const ruleStore = customRuleStore ?? createCustomRuleStore();
17
+ registerAllTools(mcpServer, storage, evalEngine, ruleStore);
12
18
  registerAllResources(mcpServer, storage);
13
- return { mcpServer, evalEngine };
19
+ return { mcpServer, evalEngine, customRuleStore: ruleStore };
14
20
  }
@@ -0,0 +1,3 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare const id = "004-tenant-id";
3
+ export declare function up(db: Database.Database): void;
@@ -0,0 +1,40 @@
1
+ export const id = '004-tenant-id';
2
+ /*
3
+ * Migration 004 — tenant scaffolding.
4
+ *
5
+ * Adds `tenant_id TEXT NOT NULL DEFAULT 'local'` to every data table.
6
+ * Existing rows are backfilled to 'local' by the default. New rows
7
+ * (v0.4+) MUST supply a tenant_id via the storage adapter — the default
8
+ * only exists so the existing-row backfill is automatic.
9
+ *
10
+ * Design notes (threat model §5.1 + §5.2):
11
+ * - Not NULL. Never allow a row without a tenant.
12
+ * - DEFAULT 'local'. Backfill is automatic; existing OSS installs
13
+ * continue working with zero user action.
14
+ * - Composite indexes put tenant_id first so per-tenant queries use
15
+ * the leading index column. Existing single-column indexes stay
16
+ * for non-tenant lookups (e.g., trace_id by primary key is still
17
+ * fast).
18
+ *
19
+ * Forward compatibility: when Cloud goes live, the storage backend
20
+ * swaps from SQLite to Postgres. The tenant_id column + index
21
+ * conventions translate 1:1.
22
+ */
23
+ export function up(db) {
24
+ db.exec(`
25
+ -- traces
26
+ ALTER TABLE traces ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'local';
27
+ CREATE INDEX IF NOT EXISTS idx_traces_tenant_timestamp ON traces(tenant_id, timestamp);
28
+ CREATE INDEX IF NOT EXISTS idx_traces_tenant_agent ON traces(tenant_id, agent_name);
29
+
30
+ -- spans
31
+ ALTER TABLE spans ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'local';
32
+ CREATE INDEX IF NOT EXISTS idx_spans_tenant_trace ON spans(tenant_id, trace_id);
33
+
34
+ -- eval_results
35
+ ALTER TABLE eval_results ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'local';
36
+ CREATE INDEX IF NOT EXISTS idx_eval_results_tenant_created ON eval_results(tenant_id, created_at);
37
+ CREATE INDEX IF NOT EXISTS idx_eval_results_tenant_trace ON eval_results(tenant_id, trace_id);
38
+ CREATE INDEX IF NOT EXISTS idx_eval_results_tenant_type ON eval_results(tenant_id, eval_type);
39
+ `);
40
+ }
@@ -1,7 +1,8 @@
1
1
  import * as migration001 from './001-initial-schema.js';
2
2
  import * as migration002 from './002-eval-skip-fields.js';
3
3
  import * as migration003 from './003-eval-passed-index.js';
4
- const migrations = [migration001, migration002, migration003];
4
+ import * as migration004 from './004-tenant-id.js';
5
+ const migrations = [migration001, migration002, migration003, migration004];
5
6
  export function runMigrations(db) {
6
7
  db.exec(`
7
8
  CREATE TABLE IF NOT EXISTS _iris_migrations (
@@ -1,19 +1,20 @@
1
1
  import type { IStorageAdapter, DashboardSummary, TraceQueryOptions, TraceQueryResult, EvalStatsPeriod, EvalStats, EvalStatsTrendBucket, EvalStatsRuleBreakdown, EvalStatsFailure } from '../types/query.js';
2
2
  import type { Trace, Span } from '../types/trace.js';
3
3
  import type { EvalResult } from '../types/eval.js';
4
+ import type { TenantId } from '../types/tenant.js';
4
5
  export declare class SqliteAdapter implements IStorageAdapter {
5
6
  private db;
6
7
  constructor(dbPath: string);
7
8
  initialize(): Promise<void>;
8
9
  close(): Promise<void>;
9
- insertTrace(trace: Trace): Promise<void>;
10
- getTrace(traceId: string): Promise<Trace | null>;
11
- queryTraces(options: TraceQueryOptions): Promise<TraceQueryResult>;
12
- insertSpan(span: Span): Promise<void>;
13
- getSpansByTraceId(traceId: string): Promise<Span[]>;
14
- insertEvalResult(result: EvalResult): Promise<void>;
15
- getEvalsByTraceId(traceId: string): Promise<EvalResult[]>;
16
- queryEvalResults(options: {
10
+ insertTrace(tenantId: TenantId, trace: Trace): Promise<void>;
11
+ getTrace(tenantId: TenantId, traceId: string): Promise<Trace | null>;
12
+ queryTraces(tenantId: TenantId, options: TraceQueryOptions): Promise<TraceQueryResult>;
13
+ insertSpan(tenantId: TenantId, span: Span): Promise<void>;
14
+ getSpansByTraceId(tenantId: TenantId, traceId: string): Promise<Span[]>;
15
+ insertEvalResult(tenantId: TenantId, result: EvalResult): Promise<void>;
16
+ getEvalsByTraceId(tenantId: TenantId, traceId: string): Promise<EvalResult[]>;
17
+ queryEvalResults(tenantId: TenantId, options: {
17
18
  eval_type?: string;
18
19
  passed?: boolean;
19
20
  since?: string;
@@ -24,14 +25,15 @@ export declare class SqliteAdapter implements IStorageAdapter {
24
25
  results: EvalResult[];
25
26
  total: number;
26
27
  }>;
27
- getDashboardSummary(sinceHours?: number): Promise<DashboardSummary>;
28
+ getDashboardSummary(tenantId: TenantId, sinceHours?: number): Promise<DashboardSummary>;
28
29
  private periodToSince;
29
- getEvalStats(period: EvalStatsPeriod): Promise<EvalStats>;
30
- getEvalStatsTrend(period: EvalStatsPeriod): Promise<EvalStatsTrendBucket[]>;
31
- getEvalStatsRules(period: EvalStatsPeriod): Promise<EvalStatsRuleBreakdown[]>;
32
- getEvalStatsFailures(period: EvalStatsPeriod, limit: number): Promise<EvalStatsFailure[]>;
33
- deleteTracesOlderThan(days: number): Promise<number>;
34
- getDistinctValues(column: string): Promise<string[]>;
30
+ getEvalStats(tenantId: TenantId, period: EvalStatsPeriod): Promise<EvalStats>;
31
+ getEvalStatsTrend(tenantId: TenantId, period: EvalStatsPeriod): Promise<EvalStatsTrendBucket[]>;
32
+ getEvalStatsRules(tenantId: TenantId, period: EvalStatsPeriod): Promise<EvalStatsRuleBreakdown[]>;
33
+ getEvalStatsFailures(tenantId: TenantId, period: EvalStatsPeriod, limit: number): Promise<EvalStatsFailure[]>;
34
+ deleteTracesOlderThan(tenantId: TenantId, days: number): Promise<number>;
35
+ deleteTrace(tenantId: TenantId, traceId: string): Promise<boolean>;
36
+ getDistinctValues(tenantId: TenantId, column: string): Promise<string[]>;
35
37
  private rowToTrace;
36
38
  private rowToSpan;
37
39
  private rowToEvalResult;
@@ -1,7 +1,38 @@
1
+ /*
2
+ * SqliteAdapter — tenant-enforcing SQLite implementation of IStorageAdapter.
3
+ *
4
+ * Every public method takes a TenantId as its first parameter and uses
5
+ * it in the SQL layer to prevent cross-tenant data leaks. See the
6
+ * 2026-04-23 threat model §5 for the design principles.
7
+ *
8
+ * Discipline:
9
+ * - Every method first validates tenantId is a non-empty string.
10
+ * If validation fails, throws TenantContextRequiredError. This is
11
+ * defense-in-depth — the TenantId type system already prevents
12
+ * empty strings at compile time, but we verify at runtime too so
13
+ * any dynamic bypass (e.g. a buggy cast) still fails safe.
14
+ * - Every INSERT binds tenant_id from the parameter, never from the
15
+ * payload data.
16
+ * - Every SELECT includes `WHERE tenant_id = ?` as the first
17
+ * condition; composite indexes put tenant_id first.
18
+ * - Aggregate queries (stats, trends) scope to the tenant.
19
+ * - DELETE operations scope to the tenant — a tenant can only delete
20
+ * its own data.
21
+ */
1
22
  import Database from 'better-sqlite3';
23
+ import { TenantContextRequiredError } from '../types/tenant.js';
2
24
  import { runMigrations } from './migrations/index.js';
3
25
  const ALLOWED_SORT_COLUMNS = new Set(['timestamp', 'latency_ms', 'cost_usd']);
4
26
  const ALLOWED_SORT_ORDERS = new Set(['asc', 'desc']);
27
+ /**
28
+ * Defense-in-depth runtime check. The TypeScript brand prevents most
29
+ * misuse at compile time; this catches any dynamic cast bypass.
30
+ */
31
+ function assertTenant(tenantId) {
32
+ if (typeof tenantId !== 'string' || tenantId.length === 0) {
33
+ throw new TenantContextRequiredError('SqliteAdapter method invoked without a valid TenantId; refusing to query');
34
+ }
35
+ }
5
36
  export class SqliteAdapter {
6
37
  db;
7
38
  constructor(dbPath) {
@@ -16,34 +47,39 @@ export class SqliteAdapter {
16
47
  async close() {
17
48
  this.db.close();
18
49
  }
19
- async insertTrace(trace) {
50
+ async insertTrace(tenantId, trace) {
51
+ assertTenant(tenantId);
20
52
  const insertTraceStmt = this.db.prepare(`
21
- INSERT INTO traces (trace_id, agent_name, framework, input, output, tool_calls, latency_ms, token_usage, cost_usd, metadata, timestamp)
22
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
53
+ INSERT INTO traces (tenant_id, trace_id, agent_name, framework, input, output, tool_calls, latency_ms, token_usage, cost_usd, metadata, timestamp)
54
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
23
55
  `);
24
56
  const insertSpanStmt = this.db.prepare(`
25
- INSERT INTO spans (span_id, trace_id, parent_span_id, name, kind, status_code, status_message, start_time, end_time, attributes, events)
26
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
57
+ INSERT INTO spans (tenant_id, span_id, trace_id, parent_span_id, name, kind, status_code, status_message, start_time, end_time, attributes, events)
58
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
27
59
  `);
28
60
  const insertAll = this.db.transaction((t) => {
29
- insertTraceStmt.run(t.trace_id, t.agent_name, t.framework ?? null, t.input ?? null, t.output ?? null, t.tool_calls ? JSON.stringify(t.tool_calls) : null, t.latency_ms ?? null, t.token_usage ? JSON.stringify(t.token_usage) : null, t.cost_usd ?? null, t.metadata ? JSON.stringify(t.metadata) : null, t.timestamp);
61
+ insertTraceStmt.run(tenantId, t.trace_id, t.agent_name, t.framework ?? null, t.input ?? null, t.output ?? null, t.tool_calls ? JSON.stringify(t.tool_calls) : null, t.latency_ms ?? null, t.token_usage ? JSON.stringify(t.token_usage) : null, t.cost_usd ?? null, t.metadata ? JSON.stringify(t.metadata) : null, t.timestamp);
30
62
  if (t.spans) {
31
63
  for (const span of t.spans) {
32
- insertSpanStmt.run(span.span_id, t.trace_id, span.parent_span_id ?? null, span.name, span.kind, span.status_code, span.status_message ?? null, span.start_time, span.end_time ?? null, span.attributes ? JSON.stringify(span.attributes) : null, span.events ? JSON.stringify(span.events) : null);
64
+ insertSpanStmt.run(tenantId, span.span_id, t.trace_id, span.parent_span_id ?? null, span.name, span.kind, span.status_code, span.status_message ?? null, span.start_time, span.end_time ?? null, span.attributes ? JSON.stringify(span.attributes) : null, span.events ? JSON.stringify(span.events) : null);
33
65
  }
34
66
  }
35
67
  });
36
68
  insertAll(trace);
37
69
  }
38
- async getTrace(traceId) {
39
- const row = this.db.prepare('SELECT * FROM traces WHERE trace_id = ?').get(traceId);
70
+ async getTrace(tenantId, traceId) {
71
+ assertTenant(tenantId);
72
+ const row = this.db
73
+ .prepare('SELECT * FROM traces WHERE tenant_id = ? AND trace_id = ?')
74
+ .get(tenantId, traceId);
40
75
  if (!row)
41
76
  return null;
42
77
  return this.rowToTrace(row);
43
78
  }
44
- async queryTraces(options) {
45
- const conditions = [];
46
- const params = [];
79
+ async queryTraces(tenantId, options) {
80
+ assertTenant(tenantId);
81
+ const conditions = ['tenant_id = ?'];
82
+ const params = [tenantId];
47
83
  const filter = options.filter;
48
84
  if (filter?.agent_name) {
49
85
  conditions.push('agent_name = ?');
@@ -62,14 +98,14 @@ export class SqliteAdapter {
62
98
  params.push(filter.until);
63
99
  }
64
100
  if (filter?.min_score !== undefined) {
65
- conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.trace_id = traces.trace_id AND e.score >= ?)');
101
+ conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.tenant_id = traces.tenant_id AND e.trace_id = traces.trace_id AND e.score >= ?)');
66
102
  params.push(filter.min_score);
67
103
  }
68
104
  if (filter?.max_score !== undefined) {
69
- conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.trace_id = traces.trace_id AND e.score <= ?)');
105
+ conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.tenant_id = traces.tenant_id AND e.trace_id = traces.trace_id AND e.score <= ?)');
70
106
  params.push(filter.max_score);
71
107
  }
72
- const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
108
+ const whereClause = `WHERE ${conditions.join(' AND ')}`;
73
109
  const sortBy = options.sort_by ?? 'timestamp';
74
110
  const sortOrder = options.sort_order ?? 'desc';
75
111
  if (!ALLOWED_SORT_COLUMNS.has(sortBy)) {
@@ -93,33 +129,38 @@ export class SqliteAdapter {
93
129
  offset,
94
130
  };
95
131
  }
96
- async insertSpan(span) {
132
+ async insertSpan(tenantId, span) {
133
+ assertTenant(tenantId);
97
134
  this.db.prepare(`
98
- INSERT INTO spans (span_id, trace_id, parent_span_id, name, kind, status_code, status_message, start_time, end_time, attributes, events)
99
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
100
- `).run(span.span_id, span.trace_id, span.parent_span_id ?? null, span.name, span.kind, span.status_code, span.status_message ?? null, span.start_time, span.end_time ?? null, span.attributes ? JSON.stringify(span.attributes) : null, span.events ? JSON.stringify(span.events) : null);
135
+ INSERT INTO spans (tenant_id, span_id, trace_id, parent_span_id, name, kind, status_code, status_message, start_time, end_time, attributes, events)
136
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
137
+ `).run(tenantId, span.span_id, span.trace_id, span.parent_span_id ?? null, span.name, span.kind, span.status_code, span.status_message ?? null, span.start_time, span.end_time ?? null, span.attributes ? JSON.stringify(span.attributes) : null, span.events ? JSON.stringify(span.events) : null);
101
138
  }
102
- async getSpansByTraceId(traceId) {
139
+ async getSpansByTraceId(tenantId, traceId) {
140
+ assertTenant(tenantId);
103
141
  const rows = this.db
104
- .prepare('SELECT * FROM spans WHERE trace_id = ? ORDER BY start_time')
105
- .all(traceId);
142
+ .prepare('SELECT * FROM spans WHERE tenant_id = ? AND trace_id = ? ORDER BY start_time')
143
+ .all(tenantId, traceId);
106
144
  return rows.map((row) => this.rowToSpan(row));
107
145
  }
108
- async insertEvalResult(result) {
146
+ async insertEvalResult(tenantId, result) {
147
+ assertTenant(tenantId);
109
148
  this.db.prepare(`
110
- INSERT INTO eval_results (id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data)
111
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
112
- `).run(result.id, result.trace_id ?? null, result.eval_type, result.output_text, result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions), result.rules_evaluated ?? null, result.rules_skipped ?? null, result.insufficient_data ? 1 : 0);
149
+ INSERT INTO eval_results (tenant_id, id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data)
150
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
151
+ `).run(tenantId, result.id, result.trace_id ?? null, result.eval_type, result.output_text, result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions), result.rules_evaluated ?? null, result.rules_skipped ?? null, result.insufficient_data ? 1 : 0);
113
152
  }
114
- async getEvalsByTraceId(traceId) {
153
+ async getEvalsByTraceId(tenantId, traceId) {
154
+ assertTenant(tenantId);
115
155
  const rows = this.db
116
- .prepare('SELECT * FROM eval_results WHERE trace_id = ? ORDER BY created_at DESC')
117
- .all(traceId);
156
+ .prepare('SELECT * FROM eval_results WHERE tenant_id = ? AND trace_id = ? ORDER BY created_at DESC')
157
+ .all(tenantId, traceId);
118
158
  return rows.map((row) => this.rowToEvalResult(row));
119
159
  }
120
- async queryEvalResults(options) {
121
- const conditions = [];
122
- const params = [];
160
+ async queryEvalResults(tenantId, options) {
161
+ assertTenant(tenantId);
162
+ const conditions = ['tenant_id = ?'];
163
+ const params = [tenantId];
123
164
  if (options.eval_type) {
124
165
  conditions.push('eval_type = ?');
125
166
  params.push(options.eval_type);
@@ -136,7 +177,7 @@ export class SqliteAdapter {
136
177
  conditions.push('created_at <= ?');
137
178
  params.push(options.until);
138
179
  }
139
- const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
180
+ const whereClause = `WHERE ${conditions.join(' AND ')}`;
140
181
  const limit = options.limit ?? 50;
141
182
  const offset = options.offset ?? 0;
142
183
  const countRow = this.db
@@ -150,37 +191,38 @@ export class SqliteAdapter {
150
191
  total: countRow.count,
151
192
  };
152
193
  }
153
- async getDashboardSummary(sinceHours = 24) {
194
+ async getDashboardSummary(tenantId, sinceHours = 24) {
195
+ assertTenant(tenantId);
154
196
  const since = new Date(Date.now() - sinceHours * 60 * 60 * 1000).toISOString();
155
197
  const stats = this.db.prepare(`
156
198
  SELECT
157
199
  COUNT(*) as total_traces,
158
200
  COALESCE(AVG(latency_ms), 0) as avg_latency_ms,
159
201
  COALESCE(SUM(cost_usd), 0) as total_cost_usd
160
- FROM traces WHERE timestamp >= ?
161
- `).get(since);
202
+ FROM traces WHERE tenant_id = ? AND timestamp >= ?
203
+ `).get(tenantId, since);
162
204
  const errorCount = this.db.prepare(`
163
205
  SELECT COUNT(DISTINCT t.trace_id) as count
164
206
  FROM traces t
165
- JOIN spans s ON s.trace_id = t.trace_id
166
- WHERE t.timestamp >= ? AND s.status_code = 'ERROR'
167
- `).get(since);
207
+ JOIN spans s ON s.tenant_id = t.tenant_id AND s.trace_id = t.trace_id
208
+ WHERE t.tenant_id = ? AND t.timestamp >= ? AND s.status_code = 'ERROR'
209
+ `).get(tenantId, since);
168
210
  const evalStats = this.db.prepare(`
169
211
  SELECT
170
212
  COUNT(*) as total,
171
213
  SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) as passed_count
172
- FROM eval_results WHERE created_at >= ?
173
- `).get(since);
214
+ FROM eval_results WHERE tenant_id = ? AND created_at >= ?
215
+ `).get(tenantId, since);
174
216
  const tracesPerHour = this.db.prepare(`
175
217
  SELECT strftime('%Y-%m-%dT%H:00:00', timestamp) as hour, COUNT(*) as count
176
- FROM traces WHERE timestamp >= ?
218
+ FROM traces WHERE tenant_id = ? AND timestamp >= ?
177
219
  GROUP BY hour ORDER BY hour
178
- `).all(since);
220
+ `).all(tenantId, since);
179
221
  const topAgents = this.db.prepare(`
180
222
  SELECT agent_name, COUNT(*) as count
181
- FROM traces WHERE timestamp >= ?
223
+ FROM traces WHERE tenant_id = ? AND timestamp >= ?
182
224
  GROUP BY agent_name ORDER BY count DESC LIMIT 10
183
- `).all(since);
225
+ `).all(tenantId, since);
184
226
  return {
185
227
  total_traces: stats.total_traces,
186
228
  avg_latency_ms: Math.round(stats.avg_latency_ms * 100) / 100,
@@ -200,7 +242,8 @@ export class SqliteAdapter {
200
242
  const hours = period === '24h' ? 24 : period === '7d' ? 168 : 720;
201
243
  return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
202
244
  }
203
- async getEvalStats(period) {
245
+ async getEvalStats(tenantId, period) {
246
+ assertTenant(tenantId);
204
247
  const since = this.periodToSince(period);
205
248
  const agg = this.db.prepare(`
206
249
  SELECT
@@ -208,27 +251,25 @@ export class SqliteAdapter {
208
251
  COALESCE(AVG(score), 0) AS avg_score,
209
252
  SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) AS passed_count
210
253
  FROM eval_results
211
- WHERE created_at >= ? AND trace_id IS NOT NULL
212
- `).get(since);
254
+ WHERE tenant_id = ? AND created_at >= ? AND trace_id IS NOT NULL
255
+ `).get(tenantId, since);
213
256
  const cost = this.db.prepare(`
214
257
  SELECT COALESCE(SUM(cost_usd), 0) AS total_cost
215
258
  FROM traces
216
- WHERE timestamp >= ?
217
- `).get(since);
259
+ WHERE tenant_id = ? AND timestamp >= ?
260
+ `).get(tenantId, since);
218
261
  const agents = this.db.prepare(`
219
262
  SELECT COUNT(DISTINCT agent_name) AS agent_count
220
263
  FROM traces
221
- WHERE timestamp >= ?
222
- `).get(since);
223
- // Safety violations — scan rule_results JSON for failing safety rules.
224
- // rule_results is stored as a JSON array of EvalRuleResult objects.
264
+ WHERE tenant_id = ? AND timestamp >= ?
265
+ `).get(tenantId, since);
225
266
  const safetyRows = this.db.prepare(`
226
267
  SELECT rule_results
227
268
  FROM eval_results
228
- WHERE created_at >= ?
269
+ WHERE tenant_id = ? AND created_at >= ?
229
270
  AND eval_type = 'safety'
230
271
  AND passed = 0
231
- `).all(since);
272
+ `).all(tenantId, since);
232
273
  const violations = { pii: 0, injection: 0, hallucination: 0 };
233
274
  for (const row of safetyRows) {
234
275
  const rules = JSON.parse(row.rule_results);
@@ -255,21 +296,18 @@ export class SqliteAdapter {
255
296
  period,
256
297
  };
257
298
  }
258
- async getEvalStatsTrend(period) {
299
+ async getEvalStatsTrend(tenantId, period) {
300
+ assertTenant(tenantId);
259
301
  const since = this.periodToSince(period);
260
- // Determine bucket format for strftime
261
302
  let bucketExpr;
262
303
  if (period === '24h') {
263
- // hourly buckets
264
304
  bucketExpr = "strftime('%Y-%m-%dT%H:00:00Z', created_at)";
265
305
  }
266
306
  else if (period === '7d') {
267
- // 6-hour buckets: floor hour to nearest 6
268
307
  bucketExpr =
269
308
  "strftime('%Y-%m-%dT', created_at) || printf('%02d', (CAST(strftime('%H', created_at) AS INTEGER) / 6) * 6) || ':00:00Z'";
270
309
  }
271
310
  else {
272
- // daily buckets
273
311
  bucketExpr = "strftime('%Y-%m-%dT00:00:00Z', created_at)";
274
312
  }
275
313
  const rows = this.db.prepare(`
@@ -281,10 +319,10 @@ export class SqliteAdapter {
281
319
  ELSE 0 END AS pass_rate,
282
320
  COUNT(*) AS eval_count
283
321
  FROM eval_results
284
- WHERE created_at >= ?
322
+ WHERE tenant_id = ? AND created_at >= ?
285
323
  GROUP BY bucket
286
324
  ORDER BY bucket
287
- `).all(since);
325
+ `).all(tenantId, since);
288
326
  return rows.map((r) => ({
289
327
  timestamp: r.bucket,
290
328
  avgScore: Math.round(r.avg_score * 1000) / 1000,
@@ -292,14 +330,14 @@ export class SqliteAdapter {
292
330
  evalCount: r.eval_count,
293
331
  }));
294
332
  }
295
- async getEvalStatsRules(period) {
333
+ async getEvalStatsRules(tenantId, period) {
334
+ assertTenant(tenantId);
296
335
  const since = this.periodToSince(period);
297
336
  const rows = this.db.prepare(`
298
337
  SELECT rule_results
299
338
  FROM eval_results
300
- WHERE created_at >= ?
301
- `).all(since);
302
- // Aggregate per-rule stats from the JSON arrays
339
+ WHERE tenant_id = ? AND created_at >= ?
340
+ `).all(tenantId, since);
303
341
  const ruleMap = new Map();
304
342
  for (const row of rows) {
305
343
  const rules = JSON.parse(row.rule_results);
@@ -324,11 +362,11 @@ export class SqliteAdapter {
324
362
  failCount: stats.failCount,
325
363
  });
326
364
  }
327
- // Sort by passRate ASC (worst rules first)
328
365
  result.sort((a, b) => a.passRate - b.passRate);
329
366
  return result;
330
367
  }
331
- async getEvalStatsFailures(period, limit) {
368
+ async getEvalStatsFailures(tenantId, period, limit) {
369
+ assertTenant(tenantId);
332
370
  const since = this.periodToSince(period);
333
371
  const rows = this.db.prepare(`
334
372
  SELECT
@@ -339,14 +377,13 @@ export class SqliteAdapter {
339
377
  e.output_text,
340
378
  e.created_at
341
379
  FROM eval_results e
342
- LEFT JOIN traces t ON t.trace_id = e.trace_id
343
- WHERE e.created_at >= ?
380
+ LEFT JOIN traces t ON t.tenant_id = e.tenant_id AND t.trace_id = e.trace_id
381
+ WHERE e.tenant_id = ? AND e.created_at >= ?
344
382
  AND e.passed = 0
345
383
  ORDER BY e.created_at DESC
346
384
  LIMIT ?
347
- `).all(since, limit);
385
+ `).all(tenantId, since, limit);
348
386
  return rows.map((r) => {
349
- // Find the first failing rule to surface as the primary rule
350
387
  const rules = JSON.parse(r.rule_results);
351
388
  const failingRule = rules.find((rule) => !rule.passed);
352
389
  return {
@@ -359,21 +396,35 @@ export class SqliteAdapter {
359
396
  };
360
397
  });
361
398
  }
362
- async deleteTracesOlderThan(days) {
399
+ async deleteTracesOlderThan(tenantId, days) {
400
+ assertTenant(tenantId);
363
401
  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
364
- const result = this.db.prepare('DELETE FROM traces WHERE timestamp < ?').run(cutoff);
402
+ const result = this.db
403
+ .prepare('DELETE FROM traces WHERE tenant_id = ? AND timestamp < ?')
404
+ .run(tenantId, cutoff);
365
405
  return result.changes;
366
406
  }
367
- async getDistinctValues(column) {
407
+ async deleteTrace(tenantId, traceId) {
408
+ assertTenant(tenantId);
409
+ // Tenant-scoped: a trace id owned by a different tenant is
410
+ // untouchable from this call. Cross-tenant deletions are not just
411
+ // denied — they're invisible (no indication the id even exists).
412
+ const result = this.db
413
+ .prepare('DELETE FROM traces WHERE tenant_id = ? AND trace_id = ?')
414
+ .run(tenantId, traceId);
415
+ return result.changes > 0;
416
+ }
417
+ async getDistinctValues(tenantId, column) {
418
+ assertTenant(tenantId);
368
419
  const queries = {
369
- agent_name: 'SELECT DISTINCT agent_name FROM traces WHERE agent_name IS NOT NULL ORDER BY agent_name',
370
- framework: 'SELECT DISTINCT framework FROM traces WHERE framework IS NOT NULL ORDER BY framework',
420
+ agent_name: 'SELECT DISTINCT agent_name FROM traces WHERE tenant_id = ? AND agent_name IS NOT NULL ORDER BY agent_name',
421
+ framework: 'SELECT DISTINCT framework FROM traces WHERE tenant_id = ? AND framework IS NOT NULL ORDER BY framework',
371
422
  };
372
423
  const query = queries[column];
373
424
  if (!query) {
374
425
  throw new Error(`Column '${column}' is not queryable (allowed: ${Object.keys(queries).join(', ')})`);
375
426
  }
376
- const rows = this.db.prepare(query).all();
427
+ const rows = this.db.prepare(query).all(tenantId);
377
428
  return rows.map((row) => row[column]);
378
429
  }
379
430
  rowToTrace(row) {
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { CustomRuleStore } from '../custom-rule-store.js';
3
+ export declare function registerDeleteRuleTool(server: McpServer, customRuleStore: CustomRuleStore): void;
@@ -0,0 +1,53 @@
1
+ /*
2
+ * delete_rule MCP tool — remove a deployed custom rule.
3
+ *
4
+ * Destructive counterpart to deploy_rule. Removes the rule from
5
+ * ~/.iris/custom-rules.json (stops firing on future evaluate_output
6
+ * calls) and appends a `rule.delete` entry to the audit log.
7
+ *
8
+ * Past eval_results that referenced this rule stay intact — the
9
+ * history is preserved even after the rule is removed. The audit
10
+ * log row is the permanent record that the rule ever existed.
11
+ */
12
+ import { z } from 'zod';
13
+ const inputSchema = {
14
+ rule_id: z
15
+ .string()
16
+ .regex(/^rule-[a-z0-9]+$/)
17
+ .describe('Rule id to delete (format: rule-<hex>); obtained from list_rules or deploy_rule response'),
18
+ };
19
+ export function registerDeleteRuleTool(server, customRuleStore) {
20
+ server.registerTool('delete_rule', {
21
+ title: 'Delete Custom Rule',
22
+ description: [
23
+ 'Remove a deployed custom evaluation rule. The rule stops firing on future evaluate_output calls; past eval_results that referenced it are preserved.',
24
+ '',
25
+ '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.',
26
+ '',
27
+ '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.',
28
+ '',
29
+ "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).",
30
+ '',
31
+ "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).",
32
+ '',
33
+ "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.",
34
+ ].join('\n'),
35
+ inputSchema,
36
+ annotations: {
37
+ readOnlyHint: false,
38
+ destructiveHint: true,
39
+ idempotentHint: false,
40
+ openWorldHint: false,
41
+ },
42
+ }, async (args) => {
43
+ const deleted = customRuleStore.delete(args.rule_id, 'mcp');
44
+ return {
45
+ content: [
46
+ {
47
+ type: 'text',
48
+ text: JSON.stringify({ deleted, rule_id: args.rule_id }),
49
+ },
50
+ ],
51
+ };
52
+ });
53
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { IStorageAdapter } from '../types/query.js';
3
+ export declare function registerDeleteTraceTool(server: McpServer, storage: IStorageAdapter): void;