@iris-eval/mcp-server 0.4.3-rc.0 → 0.4.5

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.
@@ -1,7 +1,30 @@
1
1
  import isSafeRegex from 'safe-regex2';
2
+ import { readNumericConfig, describeKeys } from './config-keys.js';
2
3
  const MAX_PATTERN_LENGTH = 1000;
4
+ // A rule whose CONFIG is invalid has not evaluated the output — it could not
5
+ // run at all. Returning `passed:false, score:0` for that case conflates "your
6
+ // agent produced bad output" with "this rule is broken", and because the
7
+ // engine weights every non-skipped result, one misconfigured rule silently
8
+ // deflates every eval score for as long as it stays deployed (and gives no
9
+ // hint which of the two happened). Mark it skipped: the engine already
10
+ // excludes skipped rules from the weighted average and surfaces skipReason,
11
+ // the same contract `expected_coverage` uses when no expected output exists.
12
+ //
13
+ // Deploy-time validation in custom-rule-store.ts now rejects these configs
14
+ // outright; this path remains the safety net for rules already persisted in
15
+ // a user's ~/.iris/custom-rules.json from before that validation existed.
16
+ function configError(definition, message) {
17
+ return {
18
+ ruleName: definition.name,
19
+ passed: false,
20
+ score: 0,
21
+ message,
22
+ skipped: true,
23
+ skipReason: message,
24
+ };
25
+ }
3
26
  function safeRegexResult(definition, message) {
4
- return { ruleName: definition.name, passed: false, score: 0, message };
27
+ return configError(definition, message);
5
28
  }
6
29
  function compileRegex(definition) {
7
30
  let patternStr = definition.config.pattern;
@@ -19,15 +42,21 @@ function compileRegex(definition) {
19
42
  if (patternStr.length > MAX_PATTERN_LENGTH) {
20
43
  return safeRegexResult(definition, `Regex pattern too long (${patternStr.length} > ${MAX_PATTERN_LENGTH})`);
21
44
  }
22
- if (!isSafeRegex(patternStr)) {
23
- return safeRegexResult(definition, 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)');
24
- }
45
+ // Syntax BEFORE safety: safe-regex2 returns false for anything it cannot
46
+ // parse, so checking it first reports a plainly broken pattern like `(` as
47
+ // "catastrophic backtracking" — sending the author hunting a performance
48
+ // problem they do not have instead of the typo they do.
49
+ let compiled;
25
50
  try {
26
- return new RegExp(patternStr, flags);
51
+ compiled = new RegExp(patternStr, flags);
27
52
  }
28
53
  catch (e) {
29
54
  return safeRegexResult(definition, `Invalid regex syntax: ${e instanceof Error ? e.message : 'unknown error'}`);
30
55
  }
56
+ if (!isSafeRegex(patternStr)) {
57
+ return safeRegexResult(definition, 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)');
58
+ }
59
+ return compiled;
31
60
  }
32
61
  export function createCustomRule(definition) {
33
62
  return {
@@ -52,17 +81,17 @@ export function createCustomRule(definition) {
52
81
  return { ruleName: definition.name, passed, score: passed ? 1 : 0, message: passed ? 'Forbidden pattern not found' : 'Forbidden pattern found in output' };
53
82
  }
54
83
  case 'min_length': {
55
- const min = (definition.config.min_length ?? definition.config.length);
84
+ const min = readNumericConfig(definition.config, 'min_length');
56
85
  if (min == null || min <= 0) {
57
- return { ruleName: definition.name, passed: false, score: 0, message: 'min_length rule requires config.min_length (positive number)' };
86
+ return configError(definition, `min_length rule requires ${describeKeys('min_length')} (positive number)`);
58
87
  }
59
88
  const passed = context.output.length >= min;
60
89
  return { ruleName: definition.name, passed, score: passed ? 1 : context.output.length / min, message: passed ? `Length (${context.output.length}) meets minimum (${min})` : `Length (${context.output.length}) below minimum (${min})` };
61
90
  }
62
91
  case 'max_length': {
63
- const max = (definition.config.max_length ?? definition.config.length);
92
+ const max = readNumericConfig(definition.config, 'max_length');
64
93
  if (max == null || max <= 0) {
65
- return { ruleName: definition.name, passed: false, score: 0, message: 'max_length rule requires config.max_length (positive number)' };
94
+ return configError(definition, `max_length rule requires ${describeKeys('max_length')} (positive number)`);
66
95
  }
67
96
  const passed = context.output.length <= max;
68
97
  return { ruleName: definition.name, passed, score: passed ? 1 : max / context.output.length, message: passed ? `Length (${context.output.length}) within maximum (${max})` : `Length (${context.output.length}) exceeds maximum (${max})` };
@@ -70,7 +99,7 @@ export function createCustomRule(definition) {
70
99
  case 'contains_keywords': {
71
100
  const keywords = definition.config.keywords;
72
101
  if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
73
- return { ruleName: definition.name, passed: false, score: 0, message: 'contains_keywords rule requires config.keywords (non-empty string array)' };
102
+ return configError(definition, 'contains_keywords rule requires config.keywords (non-empty string array)');
74
103
  }
75
104
  const lower = context.output.toLowerCase();
76
105
  const found = keywords.filter((k) => lower.includes(k.toLowerCase()));
@@ -81,7 +110,7 @@ export function createCustomRule(definition) {
81
110
  case 'excludes_keywords': {
82
111
  const keywords = definition.config.keywords;
83
112
  if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
84
- return { ruleName: definition.name, passed: false, score: 0, message: 'excludes_keywords rule requires config.keywords (non-empty string array)' };
113
+ return configError(definition, 'excludes_keywords rule requires config.keywords (non-empty string array)');
85
114
  }
86
115
  const lower = context.output.toLowerCase();
87
116
  const found = keywords.filter((k) => lower.includes(k.toLowerCase()));
@@ -98,16 +127,16 @@ export function createCustomRule(definition) {
98
127
  }
99
128
  }
100
129
  case 'cost_threshold': {
101
- const max = definition.config.max_cost;
130
+ const max = readNumericConfig(definition.config, 'cost_threshold');
102
131
  if (max == null || max < 0) {
103
- return { ruleName: definition.name, passed: false, score: 0, message: 'cost_threshold rule requires config.max_cost (non-negative number)' };
132
+ return configError(definition, `cost_threshold rule requires ${describeKeys('cost_threshold')} (non-negative number)`);
104
133
  }
105
134
  const cost = context.costUsd ?? 0;
106
135
  const passed = cost <= max;
107
136
  return { ruleName: definition.name, passed, score: passed ? 1 : 0, message: passed ? `Cost ($${cost}) within threshold ($${max})` : `Cost ($${cost}) exceeds threshold ($${max})` };
108
137
  }
109
138
  default:
110
- return { ruleName: definition.name, passed: false, score: 0, message: `Unknown rule type: ${definition.type}` };
139
+ return configError(definition, `Unknown rule type: ${definition.type}`);
111
140
  }
112
141
  },
113
142
  };
@@ -1,5 +1,6 @@
1
1
  import type { EvalRule } from '../../types/eval.js';
2
2
  export declare const keywordOverlap: EvalRule;
3
+ export declare const HALLUCINATION_MARKERS: string[];
3
4
  export declare const noHallucinationMarkers: EvalRule;
4
5
  export declare const topicConsistency: EvalRule;
5
6
  export declare const relevanceRules: EvalRule[];
@@ -28,7 +28,9 @@ export const keywordOverlap = {
28
28
  };
29
29
  },
30
30
  };
31
- const HALLUCINATION_MARKERS = [
31
+ // Exported so the claims drift test can assert .claims.json counts against
32
+ // the runtime truth (tests/claims-eval-rules-counts.test.ts).
33
+ export const HALLUCINATION_MARKERS = [
32
34
  'as an ai',
33
35
  'as a language model',
34
36
  'i cannot',
@@ -1,6 +1,11 @@
1
1
  import type { EvalRule } from '../../types/eval.js';
2
+ export declare const PII_PATTERNS: Array<{
3
+ name: string;
4
+ pattern: RegExp;
5
+ }>;
2
6
  export declare const noPii: EvalRule;
3
7
  export declare const noBlocklistWords: EvalRule;
8
+ export declare const INJECTION_PATTERNS: RegExp[];
4
9
  export declare const noInjectionPatterns: EvalRule;
5
10
  export declare const noStubOutput: EvalRule;
6
11
  export declare const safetyRules: EvalRule[];
@@ -5,7 +5,9 @@
5
5
  * patterns evaluate. Word-boundary anchors avoid matching inside larger
6
6
  * strings where appropriate.
7
7
  */
8
- const PII_PATTERNS = [
8
+ // Exported so the claims drift test can assert .claims.json counts against
9
+ // the runtime truth (tests/claims-eval-rules-counts.test.ts).
10
+ export const PII_PATTERNS = [
9
11
  // Original v0.3.0 patterns
10
12
  { name: 'SSN', pattern: /\b\d{3}-\d{2}-\d{4}\b/ },
11
13
  { name: 'Credit Card', pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b/ },
@@ -79,7 +81,7 @@ export const noBlocklistWords = {
79
81
  * leaks, or role-override acknowledgments). Input-side detection is the
80
82
  * agent host's job; output-side is Iris's.
81
83
  */
82
- const INJECTION_PATTERNS = [
84
+ export const INJECTION_PATTERNS = [
83
85
  // Original v0.3.0 patterns
84
86
  /ignore (?:all )?(?:previous|above|prior) (?:instructions|prompts)/i,
85
87
  /you are now (?:a |in )/i,
package/dist/index.js CHANGED
@@ -85,7 +85,9 @@ Environment variables (CLI flags take precedence):
85
85
  IRIS_DASHBOARD true to enable web dashboard
86
86
  IRIS_DASHBOARD_PORT Dashboard port (1-65535, default: 6920)
87
87
  IRIS_API_KEY API key for HTTP authentication
88
- IRIS_ALLOWED_ORIGINS Comma-separated CORS origin allowlist
88
+ IRIS_ALLOWED_ORIGINS Comma-separated origin allowlist. Dashboard: CORS headers (supports globs, e.g. http://localhost:*).
89
+ HTTP transport: exact-match Origin allowlist for DNS-rebinding protection (globs ignored;
90
+ this server's own loopback origins are always allowed).
89
91
  IRIS_NO_AUTO_LAUNCH Set to 1 to disable first-run dashboard auto-launch
90
92
  IRIS_ANTHROPIC_API_KEY Required by evaluate_with_llm_judge + verify_citations (provider=anthropic)
91
93
  IRIS_OPENAI_API_KEY Required by evaluate_with_llm_judge + verify_citations (provider=openai)
@@ -9,7 +9,25 @@ export function createErrorHandler(logger) {
9
9
  return;
10
10
  }
11
11
  const status = err.status ?? err.statusCode ?? 500;
12
- const message = status >= 500 ? 'Internal server error' : (err.message ?? 'Unknown error');
12
+ /*
13
+ * Never echo a Node system error to the client. Their messages embed
14
+ * absolute paths — an ENOENT from res.sendFile returned
15
+ * "ENOENT: no such file or directory, stat 'C:\...\dist\dashboard\index.html'"
16
+ * with a 404, disclosing the install path and OS user to anyone who
17
+ * could reach the dashboard (CWE-209). 5xx was already masked; the leak
18
+ * was in the 4xx branch, where echoing err.message is otherwise useful
19
+ * (body-parser's "request entity too large", Zod messages, etc.).
20
+ *
21
+ * Identify system errors by the shape Node gives them — a string `code`
22
+ * plus `syscall` — rather than by matching path-ish text, which would
23
+ * miss cases and mangle legitimate messages.
24
+ */
25
+ const isSystemError = typeof err?.code === 'string' && typeof err?.syscall === 'string';
26
+ const message = status >= 500 || isSystemError
27
+ ? status >= 500
28
+ ? 'Internal server error'
29
+ : 'Not found'
30
+ : (err.message ?? 'Unknown error');
13
31
  logger.error(`Request error: ${err.message}`, { status, stack: err.stack });
14
32
  res.status(status).json({
15
33
  error: message,
@@ -9,6 +9,7 @@
9
9
  //
10
10
  // We intentionally do not depend on @opentelemetry/* — the exporter
11
11
  // serializes these plain objects, and the payload shape is the OTLP spec.
12
+ import { PKG_VERSION } from '../config/defaults.js';
12
13
  // OTel SpanKind enum values (from opentelemetry-proto/trace/v1/trace.proto).
13
14
  const KIND_MAP = {
14
15
  INTERNAL: 1,
@@ -145,7 +146,7 @@ export function buildExportPayload(traces, serviceName) {
145
146
  { key: 'service.name', value: { stringValue: serviceName } },
146
147
  { key: 'telemetry.sdk.name', value: { stringValue: 'iris-mcp' } },
147
148
  { key: 'telemetry.sdk.language', value: { stringValue: 'nodejs' } },
148
- { key: 'telemetry.sdk.version', value: { stringValue: '0.4.0' } },
149
+ { key: 'telemetry.sdk.version', value: { stringValue: PKG_VERSION } },
149
150
  ],
150
151
  };
151
152
  const spans = [];
@@ -1,108 +1,58 @@
1
1
  import { z } from 'zod';
2
2
  declare const MomentFiltersSchema: z.ZodObject<{
3
3
  agentName: z.ZodOptional<z.ZodString>;
4
- verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
5
- significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
6
- }, "strict", z.ZodTypeAny, {
7
- verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
8
- agentName?: string | undefined;
9
- significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
10
- }, {
11
- verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
12
- agentName?: string | undefined;
13
- significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
14
- }>;
15
- export declare const PreferencesSchema: z.ZodObject<{
16
- autoLaunch: z.ZodDefault<z.ZodBoolean>;
17
- firstSeen: z.ZodOptional<z.ZodString>;
18
- dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
19
- /** Theme override; "system" defers to prefers-color-scheme. v0.4. */
20
- theme: z.ZodDefault<z.ZodEnum<["dark", "light", "system"]>>;
21
- /** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
22
- momentFilters: z.ZodDefault<z.ZodObject<{
23
- agentName: z.ZodOptional<z.ZodString>;
24
- verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
25
- significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
26
- }, "strict", z.ZodTypeAny, {
27
- verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
28
- agentName?: string | undefined;
29
- significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
30
- }, {
31
- verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
32
- agentName?: string | undefined;
33
- significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
4
+ verdict: z.ZodOptional<z.ZodEnum<{
5
+ pass: "pass";
6
+ fail: "fail";
7
+ partial: "partial";
8
+ unevaluated: "unevaluated";
34
9
  }>>;
35
- /** Tour ids the user has completed or dismissed. v0.4. */
36
- dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
37
- /** Decision Moments hidden from the timeline by user action. v0.4. */
38
- archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
39
- /** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
40
- density: z.ZodDefault<z.ZodEnum<["compact", "comfortable"]>>;
41
- /** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
42
- sidebarCollapsed: z.ZodDefault<z.ZodBoolean>;
43
- /** ISO timestamp of last notifications-popover opened — drives unread badge. */
44
- notificationsLastSeen: z.ZodOptional<z.ZodString>;
45
- }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
46
- autoLaunch: z.ZodDefault<z.ZodBoolean>;
47
- firstSeen: z.ZodOptional<z.ZodString>;
48
- dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
49
- /** Theme override; "system" defers to prefers-color-scheme. v0.4. */
50
- theme: z.ZodDefault<z.ZodEnum<["dark", "light", "system"]>>;
51
- /** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
52
- momentFilters: z.ZodDefault<z.ZodObject<{
53
- agentName: z.ZodOptional<z.ZodString>;
54
- verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
55
- significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
56
- }, "strict", z.ZodTypeAny, {
57
- verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
58
- agentName?: string | undefined;
59
- significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
60
- }, {
61
- verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
62
- agentName?: string | undefined;
63
- significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
10
+ significanceKind: z.ZodOptional<z.ZodEnum<{
11
+ "safety-violation": "safety-violation";
12
+ "cost-spike": "cost-spike";
13
+ "first-failure": "first-failure";
14
+ "novel-pattern": "novel-pattern";
15
+ "rule-collision": "rule-collision";
16
+ "normal-pass": "normal-pass";
17
+ "normal-fail": "normal-fail";
64
18
  }>>;
65
- /** Tour ids the user has completed or dismissed. v0.4. */
66
- dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
67
- /** Decision Moments hidden from the timeline by user action. v0.4. */
68
- archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
69
- /** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
70
- density: z.ZodDefault<z.ZodEnum<["compact", "comfortable"]>>;
71
- /** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
72
- sidebarCollapsed: z.ZodDefault<z.ZodBoolean>;
73
- /** ISO timestamp of last notifications-popover opened — drives unread badge. */
74
- notificationsLastSeen: z.ZodOptional<z.ZodString>;
75
- }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
19
+ }, z.core.$strict>;
20
+ export declare const PreferencesSchema: z.ZodObject<{
76
21
  autoLaunch: z.ZodDefault<z.ZodBoolean>;
77
22
  firstSeen: z.ZodOptional<z.ZodString>;
78
- dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
79
- /** Theme override; "system" defers to prefers-color-scheme. v0.4. */
80
- theme: z.ZodDefault<z.ZodEnum<["dark", "light", "system"]>>;
81
- /** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
23
+ dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString>>;
24
+ theme: z.ZodDefault<z.ZodEnum<{
25
+ light: "light";
26
+ dark: "dark";
27
+ system: "system";
28
+ }>>;
82
29
  momentFilters: z.ZodDefault<z.ZodObject<{
83
30
  agentName: z.ZodOptional<z.ZodString>;
84
- verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
85
- significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
86
- }, "strict", z.ZodTypeAny, {
87
- verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
88
- agentName?: string | undefined;
89
- significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
90
- }, {
91
- verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
92
- agentName?: string | undefined;
93
- significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
31
+ verdict: z.ZodOptional<z.ZodEnum<{
32
+ pass: "pass";
33
+ fail: "fail";
34
+ partial: "partial";
35
+ unevaluated: "unevaluated";
36
+ }>>;
37
+ significanceKind: z.ZodOptional<z.ZodEnum<{
38
+ "safety-violation": "safety-violation";
39
+ "cost-spike": "cost-spike";
40
+ "first-failure": "first-failure";
41
+ "novel-pattern": "novel-pattern";
42
+ "rule-collision": "rule-collision";
43
+ "normal-pass": "normal-pass";
44
+ "normal-fail": "normal-fail";
45
+ }>>;
46
+ }, z.core.$strict>>;
47
+ dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString>>;
48
+ archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString>>;
49
+ density: z.ZodDefault<z.ZodEnum<{
50
+ compact: "compact";
51
+ comfortable: "comfortable";
94
52
  }>>;
95
- /** Tour ids the user has completed or dismissed. v0.4. */
96
- dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
97
- /** Decision Moments hidden from the timeline by user action. v0.4. */
98
- archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
99
- /** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
100
- density: z.ZodDefault<z.ZodEnum<["compact", "comfortable"]>>;
101
- /** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
102
53
  sidebarCollapsed: z.ZodDefault<z.ZodBoolean>;
103
- /** ISO timestamp of last notifications-popover opened — drives unread badge. */
104
54
  notificationsLastSeen: z.ZodOptional<z.ZodString>;
105
- }, z.ZodTypeAny, "passthrough">>;
55
+ }, z.core.$loose>;
106
56
  export type Preferences = z.infer<typeof PreferencesSchema>;
107
57
  export type MomentFilters = z.infer<typeof MomentFiltersSchema>;
108
58
  export interface PreferenceState {
@@ -24,7 +24,7 @@ const CustomRuleDefinitionSchema = z.object({
24
24
  'json_schema',
25
25
  'cost_threshold',
26
26
  ]),
27
- config: z.record(z.unknown()),
27
+ config: z.record(z.string(), z.unknown()),
28
28
  weight: z.number().optional(),
29
29
  });
30
30
  const inputSchema = {
@@ -63,7 +63,7 @@ export function registerDeployRuleTool(server, customRuleStore) {
63
63
  '',
64
64
  "Don't use to VALIDATE a rule before committing — deploy writes immediately. Use the dashboard's preview endpoint (POST /api/v1/rules/custom/preview) for dry-run validation against sample output. Don't use to EDIT an existing rule — this call only creates; edits require a dedicated flow (coming in v0.5). To update a rule today: delete_rule then deploy_rule with the new definition.",
65
65
  '',
66
- 'Parameters. name is 1-120 chars (Zod-enforced min/max); appears in eval_result rule_results so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). evalType determines WHEN the rule fires (must match the eval_type your evaluate_output calls use; e.g., a "completeness" rule fires on every evaluate_output where eval_type="completeness" OR eval_type="custom"). severity affects dashboard sort + audit log signal but does NOT affect scoring (scoring uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_usd; min_length needs config.min). sourceMomentId is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). Defaults: severity="medium".',
66
+ 'Parameters. name is 1-120 chars (Zod-enforced min/max); appears in eval_result rule_results so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). evalType determines WHEN the rule fires (must match the eval_type your evaluate_output calls use; e.g., a "completeness" rule fires on every evaluate_output where eval_type="completeness" OR eval_type="custom"). severity affects dashboard sort + audit log signal but does NOT affect scoring (scoring uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_cost; min_length needs config.min_length; max_length needs config.max_length; contains_keywords/excludes_keywords need config.keywords). Invalid configs are now REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. sourceMomentId is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). Defaults: severity="medium".',
67
67
  '',
68
68
  "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars). Throws 400 on empty `name`. Throws 400 if the eval category mismatches the definition type. Returns 429 when HTTP rate limit exceeded. File-write failures (disk full, read-only fs) propagate as 500; the audit log is best-effort and does not block deploy.",
69
69
  ].join('\n'),
@@ -6,7 +6,7 @@ const CustomRuleSchema = z.object({
6
6
  'regex_match', 'regex_no_match', 'min_length', 'max_length',
7
7
  'contains_keywords', 'excludes_keywords', 'json_schema', 'cost_threshold',
8
8
  ]),
9
- config: z.record(z.unknown()),
9
+ config: z.record(z.string(), z.unknown()),
10
10
  weight: z.number().optional(),
11
11
  });
12
12
  const inputSchema = {
@@ -18,11 +18,11 @@ const SpanSchema = z.object({
18
18
  status_message: z.string().optional(),
19
19
  start_time: z.string(),
20
20
  end_time: z.string().optional(),
21
- attributes: z.record(z.unknown()).optional(),
21
+ attributes: z.record(z.string(), z.unknown()).optional(),
22
22
  events: z.array(z.object({
23
23
  name: z.string(),
24
24
  timestamp: z.string(),
25
- attributes: z.record(z.unknown()).optional(),
25
+ attributes: z.record(z.string(), z.unknown()).optional(),
26
26
  })).optional(),
27
27
  });
28
28
  const TokenUsageSchema = z.object({
@@ -39,7 +39,7 @@ const inputSchema = {
39
39
  latency_ms: z.number().optional().describe('Total execution time in milliseconds (end-to-end agent latency)'),
40
40
  token_usage: TokenUsageSchema.optional().describe('Token usage breakdown (prompt/completion/total — used for cost analysis)'),
41
41
  cost_usd: z.number().optional().describe('Total cost in USD — overrides per-span aggregation when provided (treated as authoritative)'),
42
- metadata: z.record(z.unknown()).optional().describe('Opaque key-value tags (e.g. {requestId, userId, env}) — queryable in dashboard, not via get_traces filters'),
42
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Opaque key-value tags (e.g. {requestId, userId, env}) — queryable in dashboard, not via get_traces filters'),
43
43
  spans: z.array(SpanSchema).optional().describe('Detailed execution spans (hierarchical span tree with timings, attributes, events)'),
44
44
  timestamp: z.string().optional().describe('Trace timestamp (ISO 8601); defaults to now() when omitted'),
45
45
  };
@@ -23,7 +23,74 @@ export async function createHttpTransport(mcpServer, config, logger) {
23
23
  });
24
24
  // Authentication
25
25
  app.use(createAuthMiddleware(config));
26
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
26
+ /*
27
+ * DNS-rebinding protection (MCP spec: servers MUST validate Origin on
28
+ * HTTP transports; when local, SHOULD bind loopback).
29
+ *
30
+ * iris bound loopback but validated nothing, and `security.apiKey` is
31
+ * undefined by default — so `createAuthMiddleware` is a pass-through. A
32
+ * default `--transport http` server was therefore reachable from any web
33
+ * page the operator visited: the page resolves an attacker-controlled
34
+ * hostname to 127.0.0.1, the browser treats it as same-origin, and the
35
+ * request carries no credentials to be missing. That exposes traces and
36
+ * eval history and allows rule deployment.
37
+ *
38
+ * Origin validation is the fix, and it is safe to switch on by default
39
+ * because the SDK only rejects when an Origin header is PRESENT (see
40
+ * validateRequestHeaders). Real MCP clients — Claude Desktop, Cursor, the
41
+ * CLI — send none, so they are unaffected; browsers always do.
42
+ *
43
+ * Host validation is applied only when bound to loopback. Binding
44
+ * elsewhere is a deliberate network deployment that usually sits behind a
45
+ * proxy rewriting Host, and an exact-match list would break it — the case
46
+ * where the operator has already taken ownership of the boundary.
47
+ */
48
+ const isLoopbackBind = config.transport.host === '127.0.0.1' ||
49
+ config.transport.host === 'localhost' ||
50
+ config.transport.host === '::1';
51
+ /*
52
+ * Bind FIRST, then build the allowlists from the port actually bound.
53
+ * `config.transport.port` is 0 when the caller wants an ephemeral port
54
+ * (tests and embedders do this), and the OS then picks something else —
55
+ * so allowlists derived from the configured value would contain
56
+ * `127.0.0.1:0` and reject every real request with a 403 that looks
57
+ * exactly like an attack. Routes are registered immediately after, and
58
+ * the port is not discoverable by any client until this function returns.
59
+ */
60
+ const httpServer = await new Promise((resolve) => {
61
+ const server = app.listen(config.transport.port, config.transport.host, () => resolve(server));
62
+ });
63
+ const address = httpServer.address();
64
+ const port = typeof address === 'object' && address ? address.port : config.transport.port;
65
+ const loopbackOrigins = [
66
+ `http://127.0.0.1:${port}`,
67
+ `http://localhost:${port}`,
68
+ `http://[::1]:${port}`,
69
+ ];
70
+ /*
71
+ * The SDK matches origins EXACTLY (`allowedOrigins.includes(origin)`),
72
+ * while iris's own CORS allowlist accepts glob patterns like the shipped
73
+ * default `http://localhost:*`. A pattern entry can never match here, so
74
+ * it is dropped rather than passed through to sit in the list looking
75
+ * effective. The concrete loopback origins added above already express
76
+ * what `http://localhost:*` means for this server's port.
77
+ *
78
+ * Note this rejection is what actually stops the attack. Emitting CORS
79
+ * headers would not: the browser only withholds the RESPONSE, after the
80
+ * server has already executed the request — so a rebound page could still
81
+ * deploy rules or delete traces and simply not read the reply.
82
+ */
83
+ const configuredOrigins = (config.security.allowedOrigins ?? []).filter((origin) => !origin.includes('*'));
84
+ const allowedOrigins = [...new Set([...loopbackOrigins, ...configuredOrigins])];
85
+ const allowedHosts = isLoopbackBind
86
+ ? [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]
87
+ : undefined;
88
+ const transport = new StreamableHTTPServerTransport({
89
+ sessionIdGenerator: () => crypto.randomUUID(),
90
+ enableDnsRebindingProtection: true,
91
+ allowedOrigins,
92
+ ...(allowedHosts ? { allowedHosts } : {}),
93
+ });
27
94
  // Rate limiter for MCP POST/DELETE (not GET — SSE streaming)
28
95
  const mcpLimiter = createMcpRateLimiter(config);
29
96
  app.post('/mcp', mcpLimiter, async (req, res) => {
@@ -37,8 +104,5 @@ export async function createHttpTransport(mcpServer, config, logger) {
37
104
  });
38
105
  // Error handler (must be last)
39
106
  app.use(createErrorHandler(logger));
40
- const httpServer = await new Promise((resolve) => {
41
- const server = app.listen(config.transport.port, config.transport.host, () => resolve(server));
42
- });
43
107
  return { transport, httpServer };
44
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iris-eval/mcp-server",
3
- "version": "0.4.3-rc.0",
3
+ "version": "0.4.5",
4
4
  "description": "The agent eval standard for MCP. Score every agent output for quality, safety, and cost.",
5
5
  "mcpName": "io.github.iris-eval/mcp-server",
6
6
  "type": "module",
@@ -76,27 +76,32 @@
76
76
  "node": ">=20.0.0"
77
77
  },
78
78
  "overrides": {
79
- "fast-uri": "^3.1.2"
79
+ "brace-expansion": "^5.0.7",
80
+ "fast-uri": "^3.1.5",
81
+ "ip-address": "^10.4.0",
82
+ "postcss": "^8.5.25",
83
+ "@hono/node-server": "^2.1.0"
80
84
  },
81
85
  "dependencies": {
82
- "@modelcontextprotocol/sdk": "^1.29.0",
86
+ "@modelcontextprotocol/sdk": "^1.30.0",
83
87
  "better-sqlite3": "^12.8.0",
84
88
  "express": "^5.1.0",
85
89
  "express-rate-limit": "^8.3.2",
86
90
  "helmet": "^8.1.0",
87
91
  "pino": "^10.3.1",
88
92
  "safe-regex2": "^5.1.0",
89
- "zod": "^3.25.0"
93
+ "zod": "^4.4.3"
90
94
  },
91
95
  "devDependencies": {
92
96
  "@playwright/test": "^1.59.1",
93
97
  "@types/better-sqlite3": "^7.6.0",
94
98
  "@types/express": "^5.0.0",
95
- "@types/node": "^25.5.2",
99
+ "@types/node": "^26.1.1",
96
100
  "@typescript-eslint/eslint-plugin": "^8.58.0",
97
101
  "@typescript-eslint/parser": "^8.58.0",
98
102
  "@vitest/coverage-v8": "^4.1.1",
99
103
  "eslint": "^10.2.0",
104
+ "fast-check": "^4.8.0",
100
105
  "prettier": "^3.0.0",
101
106
  "tsx": "^4.0.0",
102
107
  "typescript": "^5.7.0",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/iris-eval/mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.4.3-rc.0",
9
+ "version": "0.4.5",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@iris-eval/mcp-server",
14
- "version": "0.4.3-rc.0",
14
+ "version": "0.4.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },