@iris-eval/mcp-server 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +94 -44
  2. package/dist/audit-log-reader.d.ts +0 -2
  3. package/dist/audit-log-reader.js +5 -5
  4. package/dist/config/defaults.js +3 -3
  5. package/dist/config/index.d.ts +1 -0
  6. package/dist/config/index.js +32 -6
  7. package/dist/custom-rule-store.js +100 -38
  8. package/dist/dashboard/assets/index-BZZt8bVh.js +10 -0
  9. package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
  10. package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  11. package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  12. package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
  13. package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  14. package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
  15. package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
  16. package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
  17. package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
  18. package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
  19. package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
  20. package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
  21. package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
  22. package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
  23. package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
  24. package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
  25. package/dist/dashboard/index.html +2 -2
  26. package/dist/dashboard/routes/failures.d.ts +3 -0
  27. package/dist/dashboard/routes/failures.js +76 -0
  28. package/dist/dashboard/routes/index.d.ts +1 -0
  29. package/dist/dashboard/routes/index.js +1 -0
  30. package/dist/dashboard/routes/preferences.js +7 -2
  31. package/dist/dashboard/routes/rules.js +32 -14
  32. package/dist/dashboard/routes/traces.d.ts +12 -1
  33. package/dist/dashboard/routes/traces.js +90 -2
  34. package/dist/dashboard/seed-demo-data.d.ts +49 -0
  35. package/dist/dashboard/seed-demo-data.js +1080 -0
  36. package/dist/dashboard/server.js +124 -16
  37. package/dist/dashboard/validation.d.ts +74 -0
  38. package/dist/dashboard/validation.js +31 -2
  39. package/dist/eval/citation-verify/resolve.js +29 -0
  40. package/dist/eval/citation-verify/verifier.d.ts +1 -0
  41. package/dist/eval/citation-verify/verifier.js +12 -4
  42. package/dist/eval/engine.d.ts +15 -1
  43. package/dist/eval/engine.js +99 -15
  44. package/dist/eval/failure-rank.d.ts +14 -0
  45. package/dist/eval/failure-rank.js +44 -0
  46. package/dist/eval/rules/custom.d.ts +29 -1
  47. package/dist/eval/rules/custom.js +155 -19
  48. package/dist/eval/rules/regex-budget.d.ts +5 -0
  49. package/dist/eval/rules/regex-budget.js +100 -0
  50. package/dist/eval/rules/regex-sandbox.d.ts +26 -0
  51. package/dist/eval/rules/regex-sandbox.js +131 -0
  52. package/dist/eval/rules/relevance.d.ts +0 -2
  53. package/dist/eval/rules/relevance.js +6 -68
  54. package/dist/eval/rules/safety.d.ts +10 -0
  55. package/dist/eval/rules/safety.js +1377 -30
  56. package/dist/index.js +206 -19
  57. package/dist/middleware/rebinding-guard.d.ts +21 -0
  58. package/dist/middleware/rebinding-guard.js +77 -0
  59. package/dist/preferences.js +5 -10
  60. package/dist/self-test.d.ts +18 -0
  61. package/dist/self-test.js +329 -0
  62. package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
  63. package/dist/storage/migrations/005-normalize-created-at.js +34 -0
  64. package/dist/storage/migrations/index.js +8 -1
  65. package/dist/storage/sqlite-adapter.d.ts +2 -0
  66. package/dist/storage/sqlite-adapter.js +93 -13
  67. package/dist/tools/delete-rule.d.ts +2 -1
  68. package/dist/tools/delete-rule.js +13 -4
  69. package/dist/tools/delete-trace.js +2 -1
  70. package/dist/tools/deploy-rule.d.ts +2 -1
  71. package/dist/tools/deploy-rule.js +29 -7
  72. package/dist/tools/evaluate-output.js +36 -9
  73. package/dist/tools/evaluate-with-llm-judge.js +2 -1
  74. package/dist/tools/get-traces.js +6 -2
  75. package/dist/tools/index.js +2 -2
  76. package/dist/tools/list-rules.js +2 -1
  77. package/dist/tools/log-trace.d.ts +51 -0
  78. package/dist/tools/log-trace.js +14 -2
  79. package/dist/tools/strict-input.d.ts +2 -0
  80. package/dist/tools/strict-input.js +35 -0
  81. package/dist/tools/verify-citations.js +7 -5
  82. package/dist/transport/http.js +24 -2
  83. package/dist/types/config.d.ts +7 -0
  84. package/dist/types/decision-moment.d.ts +12 -0
  85. package/dist/types/eval.d.ts +32 -0
  86. package/dist/types/query.d.ts +1 -1
  87. package/dist/utils/iris-home.d.ts +1 -0
  88. package/dist/utils/iris-home.js +21 -0
  89. package/dist/utils/write-atomic.d.ts +3 -0
  90. package/dist/utils/write-atomic.js +96 -0
  91. package/package.json +3 -2
  92. package/server.json +3 -3
  93. package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
  94. package/dist/dashboard/assets/index-CIKsbEhq.js +0 -10
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
- # Iris — The Agent Eval Standard for MCP
1
+ # Iris — stop shipping agents on vibes
2
2
 
3
3
  [![Glama Score](https://glama.ai/mcp/servers/iris-eval/mcp-server/badges/score.svg)](https://glama.ai/mcp/servers/iris-eval/mcp-server)
4
- [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](cursor://anysphere.cursor-deeplink/mcp/install?name=server&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBpcmlzLWV2YWwvbWNwLXNlcnZlciJdLCJlbnYiOnsiSVJJU19MT0dfTEVWRUwiOiJpbmZvIn19)
4
+ [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](cursor://anysphere.cursor-deeplink/mcp/install?name=iris-eval&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBpcmlzLWV2YWwvbWNwLXNlcnZlciJdLCJlbnYiOnsiSVJJU19MT0dfTEVWRUwiOiJpbmZvIn19)
5
5
  [![npm version](https://img.shields.io/npm/v/@iris-eval/mcp-server)](https://npmjs.com/package/@iris-eval/mcp-server)
6
6
  [![npm downloads](https://img.shields.io/npm/dt/@iris-eval/mcp-server)](https://npmjs.com/package/@iris-eval/mcp-server)
7
7
  [![GitHub stars](https://img.shields.io/github/stars/iris-eval/mcp-server?style=social)](https://github.com/iris-eval/mcp-server)
@@ -13,70 +13,77 @@
13
13
  [![PulseMCP](https://img.shields.io/badge/PulseMCP-Listed-blue?style=flat-square)](https://www.pulsemcp.com/servers/iris-eval)
14
14
  [![mcp.so](https://img.shields.io/badge/mcp.so-Listed-blue?style=flat-square)](https://mcp.so/server/iris/iris-eval)
15
15
 
16
- **Know whether your AI agents are actually good enough to ship.** Iris is an open-source MCP server that scores output quality, catches safety failures, and enforces cost budgets across all your agents. Any MCP-compatible agent discovers and uses it automatically no SDK, no code changes.
16
+ **Iris scores every agent run for quality, safety, and cost — on your machine, with no SDK and no account.** Most agent projects check quality by running a few remembered prompts and eyeballing the output. Iris replaces that with numbers you can audit: your agent's runs land in a SQLite database on your disk, 13 built-in rules score them deterministically PII, prompt injection, hallucination markers, cost thresholds — free, with no LLM calls, and an optional LLM judge with a hard per-eval cost cap handles the semantic questions. Every rule is inspectable and editable, because a judge you can't audit is just vibes with a number on it. MIT licensed, no telemetry; your traces never leave your machine.
17
+
18
+ **Requires Node.js 20 or later.** Check with `node --version`.
17
19
 
18
20
  ![Iris Dashboard](https://raw.githubusercontent.com/iris-eval/mcp-server/main/docs/assets/dashboard-overview.png)
19
21
 
20
- ## The Problem
22
+ ## A failure on screen in 60 seconds
21
23
 
22
- Your agents are running in production. Infrastructure monitoring sees `200 OK` and moves on. It has no idea the agent just:
24
+ No agent wiring, no config one command:
23
25
 
24
- - Leaked a social security number in its response
25
- - Hallucinated an answer with zero factual grounding
26
- - Burned $0.47 on a single query — 4.7x your budget threshold
27
- - Made 6 tool calls when 2 would have sufficed
26
+ ```bash
27
+ npx @iris-eval/mcp-server --demo
28
+ ```
28
29
 
29
- Iris evaluates all of it.
30
+ This seeds a demo database — a handful of small agents with a week of runs — and serves the dashboard against it at **http://localhost:6920** (your browser opens automatically on first run). The dashboard lands on **Failures**: what failed, worst and newest first. Worth clicking into — a PII leak caught by the safety rules, a flagged prompt-injection attempt, and a failed LLM-judge score with its rationale.
30
31
 
31
- ## What You Get
32
+ Demo data lives in its own database (`demo.db` in your Iris home directory — `~/.iris` on macOS/Linux, `%USERPROFILE%\.iris` on Windows) and never mixes with your real traces. Remove all of it with one command:
32
33
 
33
- | | |
34
- |---|---|
35
- | **Trace Logging** | Hierarchical span trees with per-tool-call latency, token usage, and cost in USD. Stored in SQLite, queryable instantly. |
36
- | **Output Evaluation** | 13 built-in rules across 4 categories: completeness, relevance, safety, cost. PII detection (10 patterns: SSN, credit card, phone, email, IBAN, DOB, MRN, IP, API key, passport), prompt injection (13 patterns), stub-output detection, hallucination markers (17 hedging phrases + fabricated-citation heuristic). Add custom rules with Zod schemas. |
37
- | **Cost Visibility** | Aggregate cost across all agents over any time window. Set budget thresholds. Get flagged when agents overspend. |
38
- | **Web Dashboard** | Real-time dark-mode UI with trace visualization, eval results, and cost breakdowns. |
39
-
40
- **Requires Node.js 20 or later.** Check with `node --version`.
34
+ ```bash
35
+ npx @iris-eval/mcp-server --demo-clear
36
+ ```
41
37
 
42
- ## Quickstart
38
+ ## Hook up your own agent
43
39
 
44
- Add Iris to your MCP config. Works with Claude Desktop, Claude Code, Cursor, Windsurf, Continue, VS Code, Cline, Zed, Codex CLI, Gemini CLI — and any other MCP-compatible agent.
40
+ Add Iris to your MCP config. Works with Claude Desktop, Claude Code, Cursor, Windsurf, Continue, VS Code, Cline, Zed, Codex CLI, Gemini CLI — and any other MCP-compatible agent. One block, dashboard included:
45
41
 
46
42
  ```json
47
43
  {
48
44
  "mcpServers": {
49
45
  "iris-eval": {
50
46
  "command": "npx",
51
- "args": ["@iris-eval/mcp-server"]
47
+ "args": ["@iris-eval/mcp-server", "--dashboard"]
52
48
  }
53
49
  }
54
50
  }
55
51
  ```
56
52
 
57
- That's it. Your agent discovers Iris and starts logging traces automatically.
53
+ Your agent discovers Iris's nine tools on connect, and the dashboard serves at **http://localhost:6920**. Now paste this to your agent:
58
54
 
59
- ### Turn on the dashboard
55
+ > Log that last task to Iris and evaluate the output.
60
56
 
61
- Iris ships with a real-time web dashboard showing traces, eval results, cost breakdowns, and rule pass-rates. It's off by default so the MCP server stays lightweight flip it on with a flag.
57
+ The trace lands on the dashboard with its scores. Prefer the MCP server headless? Drop `--dashboard` from the args you can open the same dashboard any time with `npx @iris-eval/mcp-server --dashboard`.
62
58
 
63
- ```json
64
- {
65
- "mcpServers": {
66
- "iris-eval": {
67
- "command": "npx",
68
- "args": ["@iris-eval/mcp-server", "--dashboard"]
69
- }
70
- }
71
- }
59
+ **One thing worth knowing up front:** MCP tools are called when the model decides to call them. Iris doesn't intercept your agent, so traces are logged when your agent asks it to log them — either because you told it to, or because your code calls the tools directly. Ask your agent to "log this to Iris and evaluate it" and it will. If you want capture that doesn't depend on the model choosing, `POST /api/v1/traces` does exactly that — your code sends the trace over plain HTTP, no model in the loop (see [docs/http-ingest.md](docs/http-ingest.md)). The CLI and SDKs on the [roadmap](docs/roadmap.md) will be thin clients over the same endpoint.
60
+
61
+ ### Capture over HTTP (no model in the loop)
62
+
63
+ With the dashboard running, anything that can send an HTTP request can log a trace — and optionally run the deterministic evals in the same request:
64
+
65
+ ```bash
66
+ curl -s -X POST "http://127.0.0.1:6920/api/v1/traces" \
67
+ -H "Content-Type: application/json" \
68
+ -d '{
69
+ "agent_name": "support-bot",
70
+ "input": "What is the refund policy?",
71
+ "output": "Refunds are available within 30 days of purchase.",
72
+ "evaluate": true,
73
+ "eval_type": "safety"
74
+ }'
72
75
  ```
73
76
 
74
- Then open **http://localhost:6920** after your agent runs a trace. The same dashboard is available via CLI:
77
+ Returns `201` with the stored `trace_id` and the evaluation result. The endpoint accepts the same body as the `log_trace` tool and sits behind the same loopback-only middleware stack as the rest of the dashboard. Full contract, field reference, and error semantics: [docs/http-ingest.md](docs/http-ingest.md).
78
+
79
+ ### Check the install
75
80
 
76
81
  ```bash
77
- npx @iris-eval/mcp-server --dashboard
82
+ npx @iris-eval/mcp-server --self-test
78
83
  ```
79
84
 
85
+ An offline install diagnostic: storage round-trip, deterministic evals, dashboard + DNS-rebinding guard — all inside an isolated temp home, so your real database is never opened. Exit code 0 = healthy, 1 = a check failed.
86
+
80
87
  <details>
81
88
  <summary><strong>Setup by tool</strong></summary>
82
89
 
@@ -171,6 +178,19 @@ docker run -p 3000:3000 -v iris-data:/data ghcr.io/iris-eval/mcp-server
171
178
 
172
179
  > **Tip:** Global install (`npm install -g`) stores traces persistently at `~/.iris/iris.db`. With `npx`, traces persist in the same location, but startup is slower due to package resolution.
173
180
 
181
+ ## What You Get
182
+
183
+ | | |
184
+ |---|---|
185
+ | **Trace Logging** | Hierarchical span trees with per-tool-call latency, token usage, and cost in USD. Stored in SQLite, queryable instantly. |
186
+ | **Output Evaluation** | 13 built-in rules across 4 categories: completeness, relevance, safety, cost. PII detection (19 patterns: SSN, credit card, phone, email, IBAN, DOB, MRN, IP, API key, passport, plus AWS/Slack/SendGrid/GitHub/Google/npm/DigitalOcean tokens, PEM private-key blocks and seed phrases), prompt injection (37 patterns, phrase + structural), stub-output detection, hallucination detection (25 context-grounded fabrication/contradiction signals — pass `input` to ground them against the agent's source material). Add custom rules with Zod schemas. |
187
+ | **LLM-as-Judge** | Optional semantic scoring via Anthropic or OpenAI — bring your own API key. Five templates. Hard per-eval cost cap (`IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL`, default $0.25), per-eval pricing disclosed in the result. |
188
+ | **Cost Visibility** | Aggregate cost across all agents over any time window. Set budget thresholds. Get flagged when agents overspend. |
189
+ | **Web Dashboard** | Real-time dark-mode UI that lands on the failures, worst and newest first — trace visualization, eval results, cost breakdowns, and a command palette (⌘K) that searches your own rules, traces, and evals. |
190
+ | **Local-first** | Everything lives in SQLite on your disk. No account, no sign-up, no telemetry. Outbound HTTP happens only where you opt in: your own LLM-judge key, citation fetching, or an OTel exporter you configure. |
191
+
192
+ Where this is going next: [the roadmap](docs/roadmap.md).
193
+
174
194
  ## MCP Tools
175
195
 
176
196
  Iris registers nine tools that any MCP-compatible agent can invoke — full rule + trace lifecycle + LLM-as-judge + semantic citation verification:
@@ -187,13 +207,26 @@ Iris registers nine tools that any MCP-compatible agent can invoke — full rule
187
207
 
188
208
  When `IRIS_OTEL_ENDPOINT` is configured, `log_trace` calls also emit a best-effort OTLP/HTTP JSON export to any OpenTelemetry collector (Jaeger, Grafana Tempo, Datadog OTLP, Honeycomb, etc). See [docs/otel-integration.md](docs/otel-integration.md).
189
209
 
210
+ ### How `passed` is decided
211
+
212
+ `evaluate_output` returns both a `score` and a `passed` flag — they answer different questions:
213
+
214
+ - **`score`** (0..1) is the weighted average across the rules that ran — a quality gradient.
215
+ - **`passed`** is the ship/no-ship verdict: `true` only when the score clears the pass threshold (default **0.7**) **and no critical rule failed**.
216
+
217
+ Genuine safety violations hard-fail. `no_pii`, `no_injection_patterns`, and `no_blocklist_words` are **critical rules**: if one fails, the eval reports `passed: false` no matter how well the other rules scored, and the response names the culprits in `critical_failures`. A leaked SSN can't be averaged away. Custom rules deployed with `severity: "high"` or `"critical"` hard-fail the same way; `low`/`medium` severities only affect the score. One boundary to know: a critical rule that **skipped** (missing context, or any other cause of a skip) has not judged the output and does not veto — `rule_results` shows every skip and its reason, so a gate that must fail closed on non-verdicts can.
218
+
219
+ One gotcha for CI gates: if you omit `eval_type`, the default `completeness` bundle runs — **safety rules don't**. The response echoes `eval_type` (plus a `note` when it was defaulted) so your gate can verify which bundle actually ran. Key on `passed` for the verdict and `eval_type: "safety"` for coverage.
220
+
190
221
  Full tool schemas and configuration: [iris-eval.com](https://iris-eval.com)
191
222
 
192
- ## Cloud Tier (Coming Soon)
223
+ ## Hosted features
224
+
225
+ Iris runs entirely on your machine today, and everything it does is free and MIT licensed with no limits and no account.
193
226
 
194
- Self-hosted Iris runs on your machine with SQLite. As your team's eval needs grow, the cloud tier adds PostgreSQL, team dashboards, alerting on quality regressions, and managed infrastructure.
227
+ Hosted storage, shared team history and alerting are **under consideration, not under construction**. There is no pricing, and nothing to buy. If shared history would be useful to you, [the waitlist](https://iris-eval.com#waitlist) is how we find out whether it's worth building it commits you to nothing.
195
228
 
196
- [Join the waitlist](https://iris-eval.com#waitlist) to get early access.
229
+ Two commitments hold regardless: **nothing that is free today will move behind a paywall**, and **no compliance certification will be claimed before it is held**.
197
230
 
198
231
  ## Examples
199
232
 
@@ -208,6 +241,7 @@ Self-hosted Iris runs on your machine with SQLite. As your team's eval needs gro
208
241
  - [GitHub Issues](https://github.com/iris-eval/mcp-server/issues) — Bug reports and feature requests
209
242
  - [GitHub Discussions](https://github.com/iris-eval/mcp-server/discussions) — Questions and ideas
210
243
  - [Contributing Guide](CONTRIBUTING.md) — How to contribute
244
+ - [HTTP Ingest](docs/http-ingest.md) — Deterministic trace capture via `POST /api/v1/traces`
211
245
  - [Roadmap](docs/roadmap.md) — What's coming next
212
246
 
213
247
  <details>
@@ -224,6 +258,10 @@ Self-hosted Iris runs on your machine with SQLite. As your team's eval needs gro
224
258
  | `--api-key` | — | API key for HTTP authentication |
225
259
  | `--dashboard` | `false` | Enable web dashboard |
226
260
  | `--dashboard-port` | `6920` | Dashboard port |
261
+ | `--dashboard-host` | `127.0.0.1` | Dashboard bind address. Loopback by default — the dashboard is unauthenticated unless `--api-key` is set, so binding beyond loopback exposes your full trace history |
262
+ | `--demo` | `false` | Seed a demo database (separate from your real traces) and serve the dashboard against it |
263
+ | `--demo-clear` | `false` | Delete the demo database and exit |
264
+ | `--self-test` | `false` | Run the offline install diagnostic in an isolated temp home, then exit (0 = healthy, 1 = a check failed) |
227
265
 
228
266
  ### Environment Variables
229
267
 
@@ -232,10 +270,12 @@ Self-hosted Iris runs on your machine with SQLite. As your team's eval needs gro
232
270
  | `IRIS_TRANSPORT` | Transport type (`stdio` or `http`) |
233
271
  | `IRIS_PORT` | HTTP transport port |
234
272
  | `IRIS_HOST` | HTTP transport host (default `127.0.0.1`) |
235
- | `IRIS_DB_PATH` | SQLite database path |
273
+ | `IRIS_HOME` | Directory for all per-user files: `config.json`, `iris.db`, `custom-rules.json`, `audit.log`, `preferences.json` (default `~/.iris`) |
274
+ | `IRIS_DB_PATH` | SQLite database path (overrides `IRIS_HOME` for the DB only) |
236
275
  | `IRIS_LOG_LEVEL` | Log level: `debug`, `info`, `warn`, `error` |
237
- | `IRIS_DASHBOARD` | Enable web dashboard (`true`/`false`) |
276
+ | `IRIS_DASHBOARD` | Enable web dashboard (`true`/`false`; `false` also overrides `dashboard.enabled` in config.json) |
238
277
  | `IRIS_DASHBOARD_PORT` | Dashboard port (default `6920`) |
278
+ | `IRIS_DASHBOARD_HOST` | Dashboard bind address (default `127.0.0.1`) |
239
279
  | `IRIS_API_KEY` | API key for HTTP authentication |
240
280
  | `IRIS_ALLOWED_ORIGINS` | Comma-separated allowed CORS origins |
241
281
 
@@ -263,6 +303,14 @@ iris-mcp --transport http --port 3000 --api-key "$(openssl rand -hex 32)" --dash
263
303
  <details>
264
304
  <summary><strong>Troubleshooting</strong></summary>
265
305
 
306
+ ### First move: run the self-test
307
+
308
+ ```bash
309
+ npx @iris-eval/mcp-server --self-test
310
+ ```
311
+
312
+ It checks storage, the deterministic evals, and the dashboard in an isolated temp home and prints a per-step verdict — the failure output names the broken step. Exit code 0 means the install is healthy.
313
+
266
314
  ### Iris won't start / `ERR_MODULE_NOT_FOUND`
267
315
 
268
316
  You may have a cached older version. Clear the npx cache and retry:
@@ -283,13 +331,15 @@ MCP tools only load at session start. After adding iris-eval, restart the sessio
283
331
 
284
332
  ### Version check
285
333
 
286
- Verify which version is running:
334
+ Iris logs its version on the first startup line:
287
335
 
288
336
  ```bash
289
- npx @iris-eval/mcp-server --help
290
- # Shows "Iris MCP-Native Agent Eval Server vX.Y.Z"
337
+ npx @iris-eval/mcp-server --dashboard
338
+ # First log line: "Starting Iris MCP server vX.Y.Z"
291
339
  ```
292
340
 
341
+ For a global install, `npm ls -g @iris-eval/mcp-server` shows the installed version.
342
+
293
343
  ### Updating
294
344
 
295
345
  ```bash
@@ -12,8 +12,6 @@ export interface AuditQueryResult {
12
12
  total: number;
13
13
  limit: number;
14
14
  offset: number;
15
- /** Absolute path to the audit log file (for diagnostics). */
16
- path: string;
17
15
  }
18
16
  export declare function readAuditLog(opts?: {
19
17
  filePath?: string;
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { readFileSync, existsSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
- import { homedir } from 'node:os';
15
+ import { irisHome } from './utils/iris-home.js';
16
16
  import { z } from 'zod';
17
17
  const AUDIT_ACTIONS = ['rule.deploy', 'rule.delete', 'rule.toggle', 'rule.update'];
18
18
  const EntrySchema = z.object({
@@ -31,21 +31,21 @@ const EntrySchema = z.object({
31
31
  details: z.record(z.string(), z.unknown()).optional(),
32
32
  });
33
33
  function defaultAuditPath() {
34
- return join(homedir(), '.iris', 'audit.log');
34
+ return join(irisHome(), 'audit.log');
35
35
  }
36
36
  export function readAuditLog(opts) {
37
37
  const filePath = opts?.filePath ?? defaultAuditPath();
38
38
  const limit = Math.min(Math.max(opts?.limit ?? 100, 1), 1000);
39
39
  const offset = Math.max(opts?.offset ?? 0, 0);
40
40
  if (!existsSync(filePath)) {
41
- return { entries: [], total: 0, limit, offset, path: filePath };
41
+ return { entries: [], total: 0, limit, offset };
42
42
  }
43
43
  let raw;
44
44
  try {
45
45
  raw = readFileSync(filePath, 'utf-8');
46
46
  }
47
47
  catch {
48
- return { entries: [], total: 0, limit, offset, path: filePath };
48
+ return { entries: [], total: 0, limit, offset };
49
49
  }
50
50
  // Parse line-by-line, drop malformed rows silently. The audit log is
51
51
  // append-only so a partially-flushed last line is the only realistic
@@ -83,5 +83,5 @@ export function readAuditLog(opts) {
83
83
  filtered.sort((a, b) => b.ts.localeCompare(a.ts));
84
84
  const total = filtered.length;
85
85
  const entries = filtered.slice(offset, offset + limit);
86
- return { entries, total, limit, offset, path: filePath };
86
+ return { entries, total, limit, offset };
87
87
  }
@@ -1,7 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { readFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- const irisHome = join(homedir(), '.iris');
3
+ import { irisHome } from '../utils/iris-home.js';
5
4
  // Read version from package.json to avoid hardcoded drift
6
5
  let pkgVersion = '0.1.8';
7
6
  try {
@@ -17,7 +16,7 @@ export const PKG_VERSION = pkgVersion;
17
16
  export const defaultConfig = {
18
17
  storage: {
19
18
  type: 'sqlite',
20
- path: join(irisHome, 'iris.db'),
19
+ path: join(irisHome(), 'iris.db'),
21
20
  },
22
21
  server: {
23
22
  name: 'iris-eval',
@@ -31,6 +30,7 @@ export const defaultConfig = {
31
30
  dashboard: {
32
31
  enabled: false,
33
32
  port: 6920,
33
+ host: '127.0.0.1',
34
34
  },
35
35
  eval: {
36
36
  defaultThreshold: 0.7,
@@ -6,6 +6,7 @@ export interface CliArgs {
6
6
  dbPath?: string;
7
7
  dashboard?: boolean;
8
8
  dashboardPort?: number;
9
+ dashboardHost?: string;
9
10
  apiKey?: string;
10
11
  }
11
12
  export declare function loadConfig(cliArgs?: CliArgs): IrisConfig;
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, mkdirSync, existsSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
- import { homedir } from 'node:os';
4
3
  import { defaultConfig } from './defaults.js';
4
+ import { irisHome } from '../utils/iris-home.js';
5
5
  function deepMerge(target, source) {
6
6
  const result = { ...target };
7
7
  for (const key of Object.keys(source)) {
@@ -45,6 +45,23 @@ function parsePortEnv(value, name) {
45
45
  }
46
46
  return n;
47
47
  }
48
+ /*
49
+ * IRIS_DASHBOARD used to be `value === 'true'`, which silently read every
50
+ * other spelling — 1, yes, on, TRUE — as an explicit DISABLE that then
51
+ * overrode config.json's dashboard.enabled in the layer merge. The user who
52
+ * exported IRIS_DASHBOARD=1 got no dashboard plus a pointer log telling
53
+ * them to set the very variable they believed they had set. Unrecognized
54
+ * values now throw, same contract as parsePortEnv: loud beats silently
55
+ * wrong for a startup switch.
56
+ */
57
+ function parseBooleanEnv(value, name) {
58
+ const normalized = value.trim().toLowerCase();
59
+ if (['true', '1', 'yes', 'on'].includes(normalized))
60
+ return true;
61
+ if (['false', '0', 'no', 'off'].includes(normalized))
62
+ return false;
63
+ throw new Error(`${name}=${JSON.stringify(value)} is not a valid boolean (use true/1/yes/on or false/0/no/off)`);
64
+ }
48
65
  function loadEnvVars() {
49
66
  const config = {};
50
67
  if (process.env.IRIS_TRANSPORT) {
@@ -63,7 +80,7 @@ function loadEnvVars() {
63
80
  config.logging = { level: process.env.IRIS_LOG_LEVEL };
64
81
  }
65
82
  if (process.env.IRIS_DASHBOARD) {
66
- config.dashboard = { enabled: process.env.IRIS_DASHBOARD === 'true' };
83
+ config.dashboard = { enabled: parseBooleanEnv(process.env.IRIS_DASHBOARD, 'IRIS_DASHBOARD') };
67
84
  }
68
85
  if (process.env.IRIS_DASHBOARD_PORT) {
69
86
  config.dashboard = {
@@ -71,6 +88,12 @@ function loadEnvVars() {
71
88
  port: parsePortEnv(process.env.IRIS_DASHBOARD_PORT, 'IRIS_DASHBOARD_PORT'),
72
89
  };
73
90
  }
91
+ if (process.env.IRIS_DASHBOARD_HOST) {
92
+ config.dashboard = {
93
+ ...config.dashboard,
94
+ host: process.env.IRIS_DASHBOARD_HOST,
95
+ };
96
+ }
74
97
  if (process.env.IRIS_API_KEY) {
75
98
  config.security = { ...config.security, apiKey: process.env.IRIS_API_KEY };
76
99
  }
@@ -99,17 +122,20 @@ function cliArgsToConfig(args) {
99
122
  if (args.dashboardPort) {
100
123
  config.dashboard = { ...config.dashboard, port: args.dashboardPort };
101
124
  }
125
+ if (args.dashboardHost) {
126
+ config.dashboard = { ...config.dashboard, host: args.dashboardHost };
127
+ }
102
128
  if (args.apiKey) {
103
129
  config.security = { ...config.security, apiKey: args.apiKey };
104
130
  }
105
131
  return config;
106
132
  }
107
133
  export function loadConfig(cliArgs) {
108
- const irisHome = join(homedir(), '.iris');
109
- if (!existsSync(irisHome)) {
110
- mkdirSync(irisHome, { recursive: true });
134
+ const home = irisHome();
135
+ if (!existsSync(home)) {
136
+ mkdirSync(home, { recursive: true });
111
137
  }
112
- const configPath = cliArgs?.config ?? join(irisHome, 'config.json');
138
+ const configPath = cliArgs?.config ?? join(home, 'config.json');
113
139
  const fileConfig = loadConfigFile(configPath);
114
140
  const envConfig = loadEnvVars();
115
141
  const argsConfig = cliArgs ? cliArgsToConfig(cliArgs) : {};
@@ -23,12 +23,15 @@
23
23
  * For now we use atomic write-via-rename so a crashed write doesn't
24
24
  * leave a half-file.
25
25
  */
26
- import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync, appendFileSync } from 'node:fs';
26
+ import { mkdirSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
27
+ import { writeAtomic, ensureOwnerOnly, OWNER_ONLY_FILE_MODE } from './utils/write-atomic.js';
28
+ import { irisHome } from './utils/iris-home.js';
27
29
  import { join, dirname } from 'node:path';
28
- import { homedir } from 'node:os';
29
30
  import { randomBytes } from 'node:crypto';
30
31
  import { z } from 'zod';
31
32
  import isSafeRegex from 'safe-regex2';
33
+ import { regexBacktrackingBudgetExceeded } from './eval/rules/regex-budget.js';
34
+ import { normalizeRegexSource } from './eval/rules/custom.js';
32
35
  import { CUSTOM_RULE_CONFIG_KEYS, readNumericConfig, describeKeys } from './eval/rules/config-keys.js';
33
36
  import { LOCAL_TENANT } from './types/tenant.js';
34
37
  const SEVERITY_VALUES = ['low', 'medium', 'high', 'critical'];
@@ -97,15 +100,18 @@ const DefinitionSchema = z
97
100
  });
98
101
  break;
99
102
  }
100
- // Strip a leading inline flag group the way the evaluator does, so a
101
- // pattern that WILL run is not rejected here for syntax it tolerates.
102
- const stripped = pattern.replace(/^\(\?[imsugy]+\)/, '');
103
+ // Normalize EXACTLY the way the evaluator does — same helper — so
104
+ // this layer validates and probes the identical pattern+flags pair
105
+ // that will actually run. (It used to strip the inline flag group
106
+ // but not merge its flags: a `(?i)` pattern was probed under
107
+ // different flags than evaluation used.)
108
+ const { pattern: stripped, flags: normalizedFlags } = normalizeRegexSource(pattern, typeof config.flags === 'string' ? config.flags : '');
103
109
  // Syntax BEFORE safety: safe-regex2 returns false for anything it
104
110
  // cannot parse, so checking it first reports a plainly broken pattern
105
111
  // like `(` as "catastrophic backtracking" — an error that sends the
106
112
  // author looking for a performance problem they do not have.
107
113
  try {
108
- new RegExp(stripped, typeof config.flags === 'string' ? config.flags : '');
114
+ new RegExp(stripped, normalizedFlags);
109
115
  }
110
116
  catch (e) {
111
117
  ctx.addIssue({
@@ -121,6 +127,21 @@ const DefinitionSchema = z
121
127
  path: ['config', 'pattern'],
122
128
  message: 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)',
123
129
  });
130
+ break;
131
+ }
132
+ // safe-regex2 is a star-height heuristic — it catches EXPONENTIAL
133
+ // blowup only. Polynomial patterns pass it: a*a*a*a*a*b is judged
134
+ // safe and takes 156ms on 40 characters. Measure what the static
135
+ // check cannot see.
136
+ {
137
+ const budgetIssue = regexBacktrackingBudgetExceeded(stripped, normalizedFlags);
138
+ if (budgetIssue) {
139
+ ctx.addIssue({
140
+ code: z.ZodIssueCode.custom,
141
+ path: ['config', 'pattern'],
142
+ message: budgetIssue,
143
+ });
144
+ }
124
145
  }
125
146
  break;
126
147
  }
@@ -167,26 +188,22 @@ const DeployedRuleSchema = z.object({
167
188
  sourceMomentId: z.string().optional(),
168
189
  version: z.number().int().positive(),
169
190
  });
170
- const FileSchema = z.object({
171
- version: z.literal(1),
172
- rules: z.array(DeployedRuleSchema),
173
- });
174
191
  /**
175
192
  * Default file path for a tenant. LOCAL_TENANT keeps the v0.4 path
176
193
  * (zero migration); others get a per-tenant suffix.
177
194
  */
178
195
  function defaultPathFor(tenantId) {
179
196
  if (tenantId === LOCAL_TENANT) {
180
- return join(homedir(), '.iris', 'custom-rules.json');
197
+ return join(irisHome(), 'custom-rules.json');
181
198
  }
182
199
  // Sanitize tenant id for filesystem safety. TenantId is branded but
183
200
  // could in principle contain odd chars on Cloud — limit to a known-safe
184
201
  // alphabet so we never write outside the .iris directory.
185
202
  const safe = String(tenantId).replace(/[^a-zA-Z0-9._-]/g, '_');
186
- return join(homedir(), '.iris', `custom-rules-${safe}.json`);
203
+ return join(irisHome(), `custom-rules-${safe}.json`);
187
204
  }
188
205
  function defaultAuditPath() {
189
- return join(homedir(), '.iris', 'audit.log');
206
+ return join(irisHome(), 'audit.log');
190
207
  }
191
208
  function generateRuleId() {
192
209
  return `rule-${randomBytes(4).toString('hex')}`;
@@ -194,52 +211,97 @@ function generateRuleId() {
194
211
  function appendAudit(auditPath, entry) {
195
212
  try {
196
213
  mkdirSync(dirname(auditPath), { recursive: true });
197
- appendFileSync(auditPath, `${JSON.stringify(entry)}\n`, 'utf-8');
214
+ // mode applies only when appendFileSync creates the file; an existing
215
+ // audit.log keeps its mode, which is why ensureOwnerOnly() also runs at
216
+ // store construction to repair files created before this change.
217
+ appendFileSync(auditPath, `${JSON.stringify(entry)}\n`, {
218
+ encoding: 'utf-8',
219
+ mode: OWNER_ONLY_FILE_MODE,
220
+ });
198
221
  }
199
222
  catch {
200
223
  // Audit best-effort. If filesystem is read-only or full, the deploy
201
224
  // still succeeds; the operator just loses the audit trail.
202
225
  }
203
226
  }
204
- function writeAtomic(targetPath, contents) {
205
- mkdirSync(dirname(targetPath), { recursive: true });
206
- const tmp = `${targetPath}.tmp.${process.pid}`;
207
- writeFileSync(tmp, contents, 'utf-8');
208
- renameSync(tmp, targetPath);
209
- }
227
+ /*
228
+ * Read leniently, one rule at a time.
229
+ *
230
+ * This used to validate the whole array with a single safeParse and return
231
+ * [] if ANY element failed. The empty result was then cached, and the next
232
+ * deploy/delete/toggle called persist(), which wrote {version:1, rules:[]}
233
+ * over the file — permanently destroying every valid rule alongside the
234
+ * bad one. The old comment ("do NOT overwrite the file") described an
235
+ * intent the write path did not honour.
236
+ *
237
+ * It was reachable, not theoretical: DefinitionSchema's superRefine now
238
+ * runs on READ as well as WRITE, and eval/rules/custom.ts notes that rules
239
+ * predating that validation — e.g. {type:'min_length', config:{}} — are
240
+ * already sitting in users' files.
241
+ */
210
242
  function loadRulesFromDisk(rulesPath) {
211
243
  if (!existsSync(rulesPath))
212
- return [];
244
+ return { rules: [], quarantined: [], readable: true };
245
+ let parsedJson;
213
246
  try {
214
- const raw = readFileSync(rulesPath, 'utf-8');
215
- const parsed = FileSchema.safeParse(JSON.parse(raw));
216
- if (parsed.success)
217
- return parsed.data.rules;
218
- // Malformed: leave rules empty; do NOT overwrite the file.
219
- return [];
247
+ parsedJson = JSON.parse(readFileSync(rulesPath, 'utf-8'));
220
248
  }
221
249
  catch {
222
- // Unreadable: leave rules empty.
223
- return [];
250
+ return { rules: [], quarantined: [], readable: false };
224
251
  }
252
+ const envelope = z.object({ rules: z.array(z.unknown()).optional() }).safeParse(parsedJson);
253
+ if (!envelope.success)
254
+ return { rules: [], quarantined: [], readable: false };
255
+ const rules = [];
256
+ const quarantined = [];
257
+ for (const entry of envelope.data.rules ?? []) {
258
+ const rule = DeployedRuleSchema.safeParse(entry);
259
+ if (rule.success)
260
+ rules.push(rule.data);
261
+ else
262
+ quarantined.push(entry);
263
+ }
264
+ return { rules, quarantined, readable: true };
225
265
  }
226
266
  export function createCustomRuleStore(opts) {
227
267
  const pathFor = opts?.pathFor ?? defaultPathFor;
228
268
  const auditPath = opts?.auditPath ?? defaultAuditPath();
229
269
  // In-memory cache keyed by tenant. Lazy-loaded on first access per
230
270
  // tenant; subsequent calls hit the cache.
231
- const tenantRules = new Map();
232
- function load(tenantId) {
233
- let rules = tenantRules.get(tenantId);
234
- if (rules === undefined) {
235
- rules = loadRulesFromDisk(pathFor(tenantId));
236
- tenantRules.set(tenantId, rules);
271
+ const tenantState = new Map();
272
+ function state(tenantId) {
273
+ let loaded = tenantState.get(tenantId);
274
+ if (loaded === undefined) {
275
+ const path = pathFor(tenantId);
276
+ loaded = loadRulesFromDisk(path);
277
+ // Repair permissions on files created before the owner-only change
278
+ // (and on the audit log, which appendFileSync only modes at creation).
279
+ ensureOwnerOnly(path, auditPath);
280
+ tenantState.set(tenantId, loaded);
237
281
  }
238
- return rules;
282
+ return loaded;
283
+ }
284
+ function load(tenantId) {
285
+ return state(tenantId).rules;
239
286
  }
240
287
  function persist(tenantId) {
241
- const rules = tenantRules.get(tenantId) ?? [];
242
- const file = { version: 1, rules };
288
+ const loaded = state(tenantId);
289
+ if (!loaded.readable) {
290
+ /*
291
+ * The file exists but never parsed. Overwriting it would replace
292
+ * content we could not read — exactly the data loss this store used
293
+ * to cause silently. Fail loudly so the caller surfaces a 500 and
294
+ * the operator can fix or move the file.
295
+ */
296
+ throw new Error(`Refusing to write ${pathFor(tenantId)}: the existing file could not be parsed. ` +
297
+ `Fix or move it, then retry — writing now would destroy its contents.`);
298
+ }
299
+ // Quarantined entries ride along untouched so a deploy never deletes
300
+ // rules this version could not validate.
301
+ const file = {
302
+ version: 1,
303
+ rules: [...loaded.rules, ...loaded.quarantined],
304
+ };
243
305
  writeAtomic(pathFor(tenantId), JSON.stringify(file, null, 2));
244
306
  }
245
307
  return {