@iris-eval/mcp-server 0.4.4 → 0.4.6

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 (46) hide show
  1. package/README.md +55 -1
  2. package/dist/audit-log-reader.js +3 -3
  3. package/dist/config/defaults.d.ts +1 -0
  4. package/dist/config/defaults.js +6 -3
  5. package/dist/config/index.d.ts +1 -0
  6. package/dist/config/index.js +14 -5
  7. package/dist/custom-rule-store.js +190 -35
  8. package/dist/dashboard/assets/index-ChcHJDDJ.js +10 -0
  9. package/dist/dashboard/index.html +1 -1
  10. package/dist/dashboard/routes/rules.js +1 -1
  11. package/dist/dashboard/server.js +60 -5
  12. package/dist/dashboard/validation.d.ts +36 -62
  13. package/dist/eval/citation-verify/resolve.js +101 -15
  14. package/dist/eval/engine.js +25 -10
  15. package/dist/eval/rules/config-keys.d.ts +14 -0
  16. package/dist/eval/rules/config-keys.js +43 -0
  17. package/dist/eval/rules/custom.js +43 -14
  18. package/dist/eval/rules/regex-budget.d.ts +5 -0
  19. package/dist/eval/rules/regex-budget.js +0 -0
  20. package/dist/eval/rules/relevance.d.ts +1 -0
  21. package/dist/eval/rules/relevance.js +3 -1
  22. package/dist/eval/rules/safety.d.ts +5 -0
  23. package/dist/eval/rules/safety.js +43 -5
  24. package/dist/index.js +13 -2
  25. package/dist/middleware/error-handler.js +19 -1
  26. package/dist/middleware/rebinding-guard.d.ts +21 -0
  27. package/dist/middleware/rebinding-guard.js +77 -0
  28. package/dist/otel/mapper.js +2 -1
  29. package/dist/preferences.d.ts +43 -93
  30. package/dist/preferences.js +5 -10
  31. package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
  32. package/dist/storage/migrations/005-normalize-created-at.js +34 -0
  33. package/dist/storage/migrations/index.js +8 -1
  34. package/dist/storage/sqlite-adapter.js +28 -4
  35. package/dist/tools/deploy-rule.js +2 -2
  36. package/dist/tools/evaluate-output.js +1 -1
  37. package/dist/tools/log-trace.js +3 -3
  38. package/dist/transport/http.js +68 -4
  39. package/dist/types/config.d.ts +7 -0
  40. package/dist/utils/iris-home.d.ts +1 -0
  41. package/dist/utils/iris-home.js +21 -0
  42. package/dist/utils/write-atomic.d.ts +1 -0
  43. package/dist/utils/write-atomic.js +64 -0
  44. package/package.json +10 -5
  45. package/server.json +2 -2
  46. package/dist/dashboard/assets/index-DNflCqmJ.js +0 -12
package/README.md CHANGED
@@ -41,7 +41,7 @@ Iris evaluates all of it.
41
41
 
42
42
  ## Quickstart
43
43
 
44
- Add Iris to your MCP config. Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent.
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.
45
45
 
46
46
  ```json
47
47
  {
@@ -102,6 +102,60 @@ Then restart the session (`/clear` or relaunch) for tools to load.
102
102
 
103
103
  Add to your workspace `.cursor/mcp.json` or global MCP settings using the JSON config above.
104
104
 
105
+ #### VS Code (native MCP)
106
+
107
+ Add to `.vscode/mcp.json` in your workspace (note: VS Code uses `servers`, not `mcpServers`):
108
+
109
+ ```json
110
+ {
111
+ "servers": {
112
+ "iris-eval": {
113
+ "command": "npx",
114
+ "args": ["@iris-eval/mcp-server"]
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ #### Cline
121
+
122
+ Open Cline's MCP Servers panel → Configure MCP Servers, and add the `mcpServers` JSON config above to `cline_mcp_settings.json`.
123
+
124
+ #### Zed
125
+
126
+ Add to Zed `settings.json`:
127
+
128
+ ```json
129
+ {
130
+ "context_servers": {
131
+ "iris-eval": {
132
+ "command": {
133
+ "path": "npx",
134
+ "args": ["@iris-eval/mcp-server"]
135
+ }
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ #### OpenAI Codex CLI
142
+
143
+ Add to `~/.codex/config.toml`:
144
+
145
+ ```toml
146
+ [mcp_servers.iris-eval]
147
+ command = "npx"
148
+ args = ["@iris-eval/mcp-server"]
149
+ ```
150
+
151
+ #### Gemini CLI
152
+
153
+ Add the `mcpServers` JSON config above to `~/.gemini/settings.json`.
154
+
155
+ #### Anything else that speaks MCP
156
+
157
+ Iris is a standard stdio MCP server — one `npx @iris-eval/mcp-server` command, no SDK, no code changes. If your client supports MCP, it supports Iris. Client config formats change; when in doubt, check your client's MCP docs and point it at that command.
158
+
105
159
  </details>
106
160
 
107
161
  ### Other Install Methods
@@ -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({
@@ -28,10 +28,10 @@ const EntrySchema = z.object({
28
28
  user: z.string(),
29
29
  ruleId: z.string(),
30
30
  ruleName: z.string().optional(),
31
- details: z.record(z.unknown()).optional(),
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();
@@ -1,2 +1,3 @@
1
1
  import type { IrisConfig } from '../types/index.js';
2
+ export declare const PKG_VERSION: string;
2
3
  export declare const defaultConfig: IrisConfig;
@@ -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 {
@@ -11,10 +10,13 @@ try {
11
10
  catch {
12
11
  // Fallback if package.json isn't resolvable at runtime
13
12
  }
13
+ // The single runtime source for the server's own version — import this
14
+ // instead of hardcoding a literal (OTel resource attrs, health, banners).
15
+ export const PKG_VERSION = pkgVersion;
14
16
  export const defaultConfig = {
15
17
  storage: {
16
18
  type: 'sqlite',
17
- path: join(irisHome, 'iris.db'),
19
+ path: join(irisHome(), 'iris.db'),
18
20
  },
19
21
  server: {
20
22
  name: 'iris-eval',
@@ -28,6 +30,7 @@ export const defaultConfig = {
28
30
  dashboard: {
29
31
  enabled: false,
30
32
  port: 6920,
33
+ host: '127.0.0.1',
31
34
  },
32
35
  eval: {
33
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)) {
@@ -71,6 +71,12 @@ function loadEnvVars() {
71
71
  port: parsePortEnv(process.env.IRIS_DASHBOARD_PORT, 'IRIS_DASHBOARD_PORT'),
72
72
  };
73
73
  }
74
+ if (process.env.IRIS_DASHBOARD_HOST) {
75
+ config.dashboard = {
76
+ ...config.dashboard,
77
+ host: process.env.IRIS_DASHBOARD_HOST,
78
+ };
79
+ }
74
80
  if (process.env.IRIS_API_KEY) {
75
81
  config.security = { ...config.security, apiKey: process.env.IRIS_API_KEY };
76
82
  }
@@ -99,17 +105,20 @@ function cliArgsToConfig(args) {
99
105
  if (args.dashboardPort) {
100
106
  config.dashboard = { ...config.dashboard, port: args.dashboardPort };
101
107
  }
108
+ if (args.dashboardHost) {
109
+ config.dashboard = { ...config.dashboard, host: args.dashboardHost };
110
+ }
102
111
  if (args.apiKey) {
103
112
  config.security = { ...config.security, apiKey: args.apiKey };
104
113
  }
105
114
  return config;
106
115
  }
107
116
  export function loadConfig(cliArgs) {
108
- const irisHome = join(homedir(), '.iris');
109
- if (!existsSync(irisHome)) {
110
- mkdirSync(irisHome, { recursive: true });
117
+ const home = irisHome();
118
+ if (!existsSync(home)) {
119
+ mkdirSync(home, { recursive: true });
111
120
  }
112
- const configPath = cliArgs?.config ?? join(irisHome, 'config.json');
121
+ const configPath = cliArgs?.config ?? join(home, 'config.json');
113
122
  const fileConfig = loadConfigFile(configPath);
114
123
  const envConfig = loadEnvVars();
115
124
  const argsConfig = cliArgs ? cliArgsToConfig(cliArgs) : {};
@@ -23,11 +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 } 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';
32
+ import isSafeRegex from 'safe-regex2';
33
+ import { regexBacktrackingBudgetExceeded } from './eval/rules/regex-budget.js';
34
+ import { CUSTOM_RULE_CONFIG_KEYS, readNumericConfig, describeKeys } from './eval/rules/config-keys.js';
31
35
  import { LOCAL_TENANT } from './types/tenant.js';
32
36
  const SEVERITY_VALUES = ['low', 'medium', 'high', 'critical'];
33
37
  const EVAL_TYPE_VALUES = ['completeness', 'relevance', 'safety', 'cost', 'custom'];
@@ -41,11 +45,131 @@ const RULE_TYPE_VALUES = [
41
45
  'json_schema',
42
46
  'cost_threshold',
43
47
  ];
44
- const DefinitionSchema = z.object({
48
+ // Per-type config requirements, enforced at DEPLOY time.
49
+ //
50
+ // `config` was previously `z.record(z.unknown())` — any object passed. That
51
+ // let a rule like {type:'min_length', config:{}} deploy successfully and then
52
+ // fail on every single evaluation forever, silently dragging down aggregate
53
+ // scores with no indication the RULE (not the agent) was broken. Validating
54
+ // here means the failure surfaces once, at deploy, with an actionable message
55
+ // — instead of quietly corrupting every eval that follows.
56
+ const MAX_RULE_PATTERN_LENGTH = 1000;
57
+ function requirePositiveNumber(config, type, ctx) {
58
+ const value = readNumericConfig(config, type);
59
+ if (value == null || value <= 0) {
60
+ ctx.addIssue({
61
+ code: z.ZodIssueCode.custom,
62
+ path: ['config', CUSTOM_RULE_CONFIG_KEYS[type][0]],
63
+ message: `${type} rule requires ${describeKeys(type)} (positive number)`,
64
+ });
65
+ }
66
+ }
67
+ function requireNonEmptyStringArray(config, key, ctx, hint) {
68
+ const value = config[key];
69
+ if (!Array.isArray(value) || value.length === 0 || !value.every((v) => typeof v === 'string')) {
70
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['config', key], message: hint });
71
+ }
72
+ }
73
+ const DefinitionSchema = z
74
+ .object({
45
75
  name: z.string().min(1).max(80),
46
76
  type: z.enum(RULE_TYPE_VALUES),
47
- config: z.record(z.unknown()),
77
+ config: z.record(z.string(), z.unknown()),
48
78
  weight: z.number().positive().optional(),
79
+ })
80
+ .superRefine((def, ctx) => {
81
+ const config = def.config ?? {};
82
+ switch (def.type) {
83
+ case 'regex_match':
84
+ case 'regex_no_match': {
85
+ const pattern = config.pattern;
86
+ if (typeof pattern !== 'string' || pattern.length === 0) {
87
+ ctx.addIssue({
88
+ code: z.ZodIssueCode.custom,
89
+ path: ['config', 'pattern'],
90
+ message: `${def.type} rule requires config.pattern (non-empty string)`,
91
+ });
92
+ break;
93
+ }
94
+ if (pattern.length > MAX_RULE_PATTERN_LENGTH) {
95
+ ctx.addIssue({
96
+ code: z.ZodIssueCode.custom,
97
+ path: ['config', 'pattern'],
98
+ message: `Regex pattern too long (${pattern.length} > ${MAX_RULE_PATTERN_LENGTH})`,
99
+ });
100
+ break;
101
+ }
102
+ // Strip a leading inline flag group the way the evaluator does, so a
103
+ // pattern that WILL run is not rejected here for syntax it tolerates.
104
+ const stripped = pattern.replace(/^\(\?[imsugy]+\)/, '');
105
+ // Syntax BEFORE safety: safe-regex2 returns false for anything it
106
+ // cannot parse, so checking it first reports a plainly broken pattern
107
+ // like `(` as "catastrophic backtracking" — an error that sends the
108
+ // author looking for a performance problem they do not have.
109
+ try {
110
+ new RegExp(stripped, typeof config.flags === 'string' ? config.flags : '');
111
+ }
112
+ catch (e) {
113
+ ctx.addIssue({
114
+ code: z.ZodIssueCode.custom,
115
+ path: ['config', 'pattern'],
116
+ message: `Invalid regex syntax: ${e instanceof Error ? e.message : 'unknown error'}`,
117
+ });
118
+ break;
119
+ }
120
+ if (!isSafeRegex(stripped)) {
121
+ ctx.addIssue({
122
+ code: z.ZodIssueCode.custom,
123
+ path: ['config', 'pattern'],
124
+ message: 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)',
125
+ });
126
+ break;
127
+ }
128
+ // safe-regex2 is a star-height heuristic — it catches EXPONENTIAL
129
+ // blowup only. Polynomial patterns pass it: a*a*a*a*a*b is judged
130
+ // safe and takes 156ms on 40 characters. Measure what the static
131
+ // check cannot see.
132
+ {
133
+ const budgetIssue = regexBacktrackingBudgetExceeded(stripped, typeof config.flags === 'string' ? config.flags : '');
134
+ if (budgetIssue) {
135
+ ctx.addIssue({
136
+ code: z.ZodIssueCode.custom,
137
+ path: ['config', 'pattern'],
138
+ message: budgetIssue,
139
+ });
140
+ }
141
+ }
142
+ break;
143
+ }
144
+ case 'min_length':
145
+ requirePositiveNumber(config, 'min_length', ctx);
146
+ break;
147
+ case 'max_length':
148
+ requirePositiveNumber(config, 'max_length', ctx);
149
+ break;
150
+ case 'contains_keywords':
151
+ requireNonEmptyStringArray(config, 'keywords', ctx, 'contains_keywords rule requires config.keywords (non-empty string array)');
152
+ break;
153
+ case 'excludes_keywords':
154
+ requireNonEmptyStringArray(config, 'keywords', ctx, 'excludes_keywords rule requires config.keywords (non-empty string array)');
155
+ break;
156
+ case 'cost_threshold': {
157
+ // 0 is a legitimate threshold ("must be free"), so only reject
158
+ // missing / non-numeric / negative.
159
+ const max = readNumericConfig(config, 'cost_threshold');
160
+ if (max == null || max < 0) {
161
+ ctx.addIssue({
162
+ code: z.ZodIssueCode.custom,
163
+ path: ['config', CUSTOM_RULE_CONFIG_KEYS.cost_threshold[0]],
164
+ message: `cost_threshold rule requires ${describeKeys('cost_threshold')} (non-negative number)`,
165
+ });
166
+ }
167
+ break;
168
+ }
169
+ case 'json_schema':
170
+ // No required config — validity is judged against the output itself.
171
+ break;
172
+ }
49
173
  });
50
174
  const DeployedRuleSchema = z.object({
51
175
  id: z.string().min(1),
@@ -60,26 +184,22 @@ const DeployedRuleSchema = z.object({
60
184
  sourceMomentId: z.string().optional(),
61
185
  version: z.number().int().positive(),
62
186
  });
63
- const FileSchema = z.object({
64
- version: z.literal(1),
65
- rules: z.array(DeployedRuleSchema),
66
- });
67
187
  /**
68
188
  * Default file path for a tenant. LOCAL_TENANT keeps the v0.4 path
69
189
  * (zero migration); others get a per-tenant suffix.
70
190
  */
71
191
  function defaultPathFor(tenantId) {
72
192
  if (tenantId === LOCAL_TENANT) {
73
- return join(homedir(), '.iris', 'custom-rules.json');
193
+ return join(irisHome(), 'custom-rules.json');
74
194
  }
75
195
  // Sanitize tenant id for filesystem safety. TenantId is branded but
76
196
  // could in principle contain odd chars on Cloud — limit to a known-safe
77
197
  // alphabet so we never write outside the .iris directory.
78
198
  const safe = String(tenantId).replace(/[^a-zA-Z0-9._-]/g, '_');
79
- return join(homedir(), '.iris', `custom-rules-${safe}.json`);
199
+ return join(irisHome(), `custom-rules-${safe}.json`);
80
200
  }
81
201
  function defaultAuditPath() {
82
- return join(homedir(), '.iris', 'audit.log');
202
+ return join(irisHome(), 'audit.log');
83
203
  }
84
204
  function generateRuleId() {
85
205
  return `rule-${randomBytes(4).toString('hex')}`;
@@ -94,45 +214,80 @@ function appendAudit(auditPath, entry) {
94
214
  // still succeeds; the operator just loses the audit trail.
95
215
  }
96
216
  }
97
- function writeAtomic(targetPath, contents) {
98
- mkdirSync(dirname(targetPath), { recursive: true });
99
- const tmp = `${targetPath}.tmp.${process.pid}`;
100
- writeFileSync(tmp, contents, 'utf-8');
101
- renameSync(tmp, targetPath);
102
- }
217
+ /*
218
+ * Read leniently, one rule at a time.
219
+ *
220
+ * This used to validate the whole array with a single safeParse and return
221
+ * [] if ANY element failed. The empty result was then cached, and the next
222
+ * deploy/delete/toggle called persist(), which wrote {version:1, rules:[]}
223
+ * over the file — permanently destroying every valid rule alongside the
224
+ * bad one. The old comment ("do NOT overwrite the file") described an
225
+ * intent the write path did not honour.
226
+ *
227
+ * It was reachable, not theoretical: DefinitionSchema's superRefine now
228
+ * runs on READ as well as WRITE, and eval/rules/custom.ts notes that rules
229
+ * predating that validation — e.g. {type:'min_length', config:{}} — are
230
+ * already sitting in users' files.
231
+ */
103
232
  function loadRulesFromDisk(rulesPath) {
104
233
  if (!existsSync(rulesPath))
105
- return [];
234
+ return { rules: [], quarantined: [], readable: true };
235
+ let parsedJson;
106
236
  try {
107
- const raw = readFileSync(rulesPath, 'utf-8');
108
- const parsed = FileSchema.safeParse(JSON.parse(raw));
109
- if (parsed.success)
110
- return parsed.data.rules;
111
- // Malformed: leave rules empty; do NOT overwrite the file.
112
- return [];
237
+ parsedJson = JSON.parse(readFileSync(rulesPath, 'utf-8'));
113
238
  }
114
239
  catch {
115
- // Unreadable: leave rules empty.
116
- return [];
240
+ return { rules: [], quarantined: [], readable: false };
241
+ }
242
+ const envelope = z.object({ rules: z.array(z.unknown()).optional() }).safeParse(parsedJson);
243
+ if (!envelope.success)
244
+ return { rules: [], quarantined: [], readable: false };
245
+ const rules = [];
246
+ const quarantined = [];
247
+ for (const entry of envelope.data.rules ?? []) {
248
+ const rule = DeployedRuleSchema.safeParse(entry);
249
+ if (rule.success)
250
+ rules.push(rule.data);
251
+ else
252
+ quarantined.push(entry);
117
253
  }
254
+ return { rules, quarantined, readable: true };
118
255
  }
119
256
  export function createCustomRuleStore(opts) {
120
257
  const pathFor = opts?.pathFor ?? defaultPathFor;
121
258
  const auditPath = opts?.auditPath ?? defaultAuditPath();
122
259
  // In-memory cache keyed by tenant. Lazy-loaded on first access per
123
260
  // tenant; subsequent calls hit the cache.
124
- const tenantRules = new Map();
125
- function load(tenantId) {
126
- let rules = tenantRules.get(tenantId);
127
- if (rules === undefined) {
128
- rules = loadRulesFromDisk(pathFor(tenantId));
129
- tenantRules.set(tenantId, rules);
261
+ const tenantState = new Map();
262
+ function state(tenantId) {
263
+ let loaded = tenantState.get(tenantId);
264
+ if (loaded === undefined) {
265
+ loaded = loadRulesFromDisk(pathFor(tenantId));
266
+ tenantState.set(tenantId, loaded);
130
267
  }
131
- return rules;
268
+ return loaded;
269
+ }
270
+ function load(tenantId) {
271
+ return state(tenantId).rules;
132
272
  }
133
273
  function persist(tenantId) {
134
- const rules = tenantRules.get(tenantId) ?? [];
135
- const file = { version: 1, rules };
274
+ const loaded = state(tenantId);
275
+ if (!loaded.readable) {
276
+ /*
277
+ * The file exists but never parsed. Overwriting it would replace
278
+ * content we could not read — exactly the data loss this store used
279
+ * to cause silently. Fail loudly so the caller surfaces a 500 and
280
+ * the operator can fix or move the file.
281
+ */
282
+ throw new Error(`Refusing to write ${pathFor(tenantId)}: the existing file could not be parsed. ` +
283
+ `Fix or move it, then retry — writing now would destroy its contents.`);
284
+ }
285
+ // Quarantined entries ride along untouched so a deploy never deletes
286
+ // rules this version could not validate.
287
+ const file = {
288
+ version: 1,
289
+ rules: [...loaded.rules, ...loaded.quarantined],
290
+ };
136
291
  writeAtomic(pathFor(tenantId), JSON.stringify(file, null, 2));
137
292
  }
138
293
  return {