@iris-eval/mcp-server 0.7.0 → 0.8.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.
@@ -11,6 +11,7 @@
11
11
  * needs to manage the rule set programmatically.
12
12
  */
13
13
  import { z } from 'zod';
14
+ import { builtInRuleRoster } from '../eval/criticality.js';
14
15
  import { LOCAL_TENANT } from '../types/tenant.js';
15
16
  import { strictInput } from './strict-input.js';
16
17
  const inputSchema = {
@@ -23,7 +24,7 @@ const inputSchema = {
23
24
  .default(false)
24
25
  .describe('Return only enabled rules (excludes disabled ones)'),
25
26
  };
26
- export function registerListRulesTool(server, customRuleStore) {
27
+ export function registerListRulesTool(server, customRuleStore, evalEngine) {
27
28
  server.registerTool('list_rules', {
28
29
  title: 'List Custom Rules',
29
30
  description: [
@@ -33,11 +34,13 @@ export function registerListRulesTool(server, customRuleStore) {
33
34
  '',
34
35
  'Behavior. Pure read of ~/.iris/custom-rules.json (in-memory cached; no disk read per call after server boot). No mutation, no external network. Tenant-scoped in Cloud tier; OSS returns all rules for the single local tenant. Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Returns in <5ms.',
35
36
  '',
36
- 'Output shape. Returns JSON: `{ "rules": [{ "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition": { name, type, config, weight? }, "enabled": boolean, "createdAt": ISO timestamp, "updatedAt": ISO timestamp, "version": number, "sourceMomentId?": string }], "total": number, "enabled_count": number }`. Empty array + total=0 when no rules deployed. A deployed rule fires only on evaluate_output calls whose eval_type equals its evalType (or eval_type="all", which runs every bundle).',
37
+ 'Output shape. Returns JSON: `{ "rules": [{ "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition": { name, type, config, weight? }, "enabled": boolean, "createdAt": ISO timestamp, "updatedAt": ISO timestamp, "version": number, "sourceMomentId?": string }], "total": number, "enabled_count": number, "built_in": [{ "name", "category", "weight", "critical": boolean, "criticalSource": "default" | "config" }] }`. Empty `rules` array + total=0 when no custom rules are deployed. A deployed rule fires only on evaluate_output calls whose eval_type equals its evalType (or eval_type="all", which runs every bundle). `built_in` is the shipped rule set, always present and NOT narrowed by the filters; `total` and `enabled_count` count custom rules only.',
38
+ '',
39
+ 'Why `built_in` carries criticality. A critical rule vetoes `passed` regardless of the weighted score, and which built-in rules are critical is configurable (`eval.criticalRules` / `eval.nonCriticalRules`). `critical` is the EFFECTIVE value this server applies and `criticalSource` says who decided it: `default` is the declaration on the rule itself, `config` means one of those lists named it. Read it before trusting a `passed: true` — it is how you tell "nothing was violated" from "the rule that would have vetoed is demoted on this server".',
37
40
  '',
38
41
  'Use when you need to know what custom rules are currently live (before calling evaluate_output, before deploying a similar rule to avoid duplicates, or when building a dashboard view). Filter with `eval_type` to scope to a specific category, or `enabled_only: true` to exclude disabled rules. Use get_traces to see trace data; use evaluate_output to run scoring; use list_rules only when you need the RULE INVENTORY.',
39
42
  '',
40
- "Don't use to count traces or evals (that's get_traces). Don't use to inspect built-in (non-custom) rules those ship with the iris binary and are listed in docs/api-reference.md, not in the rule store. Don't use to deploy a rule (use deploy_rule); don't use to remove one (use delete_rule).",
43
+ "Don't use to count traces or evals (that's get_traces). Don't use to deploy a rule (use deploy_rule); don't use to remove one (use delete_rule). Built-in rules are not in the store and cannot be deployed, deleted or disabled they appear under `built_in` for reference, carrying the criticality this server applies.",
41
44
  '',
42
45
  'Parameters. eval_type filter is exact-match against each rule\'s evalType field (no wildcards). enabled_only excludes rules that are deployed-but-disabled — a rule is disabled without deleting it via delete_rule with `enabled: false` (and re-enabled with `enabled: true`), or from the dashboard; disabled rules stay in the store with their history but do not fire. Both filters are AND-combined when both are set. Both are optional; with no filter, all rules return. Defaults: eval_type=undefined (no filter), enabled_only=false (returns all rules including disabled).',
43
46
  '',
@@ -63,11 +66,26 @@ export function registerListRulesTool(server, customRuleStore) {
63
66
  }
64
67
  const total = rules.length;
65
68
  const enabled_count = rules.filter((r) => r.enabled).length;
69
+ /*
70
+ * The built-in roster, with the criticality THIS engine applies.
71
+ * Until eval.criticalRules existed, "which rules veto" was a constant
72
+ * a reader could look up in the docs; it is now per-deployment, and a
73
+ * caller deciding whether to trust `passed` has no other way to see
74
+ * that a veto was promoted or demoted underneath them. Unfiltered on
75
+ * purpose — the filters describe the custom-rule store.
76
+ */
77
+ const built_in = builtInRuleRoster((rule) => evalEngine.effectiveCriticality(rule)).map((r) => ({
78
+ name: r.name,
79
+ category: r.category,
80
+ weight: r.weight,
81
+ critical: r.critical,
82
+ criticalSource: r.criticalSource,
83
+ }));
66
84
  return {
67
85
  content: [
68
86
  {
69
87
  type: 'text',
70
- text: JSON.stringify({ rules, total, enabled_count }),
88
+ text: JSON.stringify({ rules, total, enabled_count, built_in }),
71
89
  },
72
90
  ],
73
91
  };
@@ -1,6 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import type { IStorageAdapter } from '../types/query.js';
4
+ export declare const toolCallSchema: z.ZodObject<{
5
+ tool_name: z.ZodString;
6
+ input: z.ZodOptional<z.ZodUnknown>;
7
+ output: z.ZodOptional<z.ZodUnknown>;
8
+ latency_ms: z.ZodOptional<z.ZodNumber>;
9
+ error: z.ZodOptional<z.ZodString>;
10
+ }, z.core.$strict>;
4
11
  export declare const logTraceInputShape: {
5
12
  agent_name: z.ZodString;
6
13
  framework: z.ZodOptional<z.ZodString>;
@@ -12,7 +19,7 @@ export declare const logTraceInputShape: {
12
19
  output: z.ZodOptional<z.ZodUnknown>;
13
20
  latency_ms: z.ZodOptional<z.ZodNumber>;
14
21
  error: z.ZodOptional<z.ZodString>;
15
- }, z.core.$strip>>>;
22
+ }, z.core.$strict>>>;
16
23
  latency_ms: z.ZodOptional<z.ZodNumber>;
17
24
  token_usage: z.ZodOptional<z.ZodObject<{
18
25
  prompt_tokens: z.ZodOptional<z.ZodNumber>;
@@ -2,14 +2,29 @@ import { z } from 'zod';
2
2
  import { generateTraceId, generateSpanId } from '../utils/ids.js';
3
3
  import { LOCAL_TENANT } from '../types/tenant.js';
4
4
  import { bestEffortExport } from '../otel/lazy.js';
5
- import { strictInput } from './strict-input.js';
6
- const ToolCallSchema = z.object({
5
+ import { strictInput, strictNested } from './strict-input.js';
6
+ /*
7
+ * The tool-call record — one entry of `tool_calls[]`.
8
+ *
9
+ * Exported because it is now read on THREE paths, not one: log_trace and
10
+ * the HTTP ingest capture it, and evaluate_output accepts it directly so
11
+ * the trajectory rules (no_silent_tool_failure, no_tool_loop) can judge
12
+ * what the agent DID. All three must agree on the field names, so they all
13
+ * derive from this one schema rather than restating it.
14
+ *
15
+ * Strict for the same reason custom_rules entries are (#376): a dropped
16
+ * key here is silent AND load-bearing. `{ tool_name, output, err: "..." }`
17
+ * used to parse with `err` discarded, and a trajectory rule reading
18
+ * `error` would then score a failed call as a clean one — the exact
19
+ * failure mode the rules exist to catch, reintroduced by a typo.
20
+ */
21
+ export const toolCallSchema = strictNested({
7
22
  tool_name: z.string(),
8
23
  input: z.unknown().optional(),
9
24
  output: z.unknown().optional(),
10
25
  latency_ms: z.number().optional(),
11
26
  error: z.string().optional(),
12
- });
27
+ }, 'a tool_calls entry');
13
28
  const SpanSchema = z.object({
14
29
  span_id: z.string().optional(),
15
30
  parent_span_id: z.string().optional(),
@@ -43,7 +58,7 @@ export const logTraceInputShape = {
43
58
  framework: z.string().optional().describe('Agent framework identifier (e.g., langchain, autogen, custom)'),
44
59
  input: z.string().optional().describe('Agent input text — the user prompt or upstream input that produced this output'),
45
60
  output: z.string().optional().describe('Agent output text — what the agent produced (pass to evaluate_output for scoring)'),
46
- tool_calls: z.array(ToolCallSchema).optional().describe('Tool calls made during execution (per-call latency, errors, input/output)'),
61
+ tool_calls: z.array(toolCallSchema).optional().describe('Tool calls made during execution (per-call latency, errors, input/output)'),
47
62
  latency_ms: z.number().optional().describe('Total execution time in milliseconds (end-to-end agent latency)'),
48
63
  token_usage: TokenUsageSchema.optional().describe('Token usage breakdown (prompt/completion/total — used for cost analysis)'),
49
64
  cost_usd: z.number().optional().describe('Total cost in USD — overrides per-span aggregation when provided (treated as authoritative)'),
@@ -1,7 +1,17 @@
1
1
  import type { IStorageAdapter } from '../types/query.js';
2
2
  import type { EvalResult } from '../types/eval.js';
3
+ import type { Trace } from '../types/trace.js';
3
4
  import type { TenantId } from '../types/tenant.js';
4
5
  export declare function unknownTraceMessage(traceId: string): string;
6
+ /**
7
+ * The same refuse-before-any-work check, returning the row it already read.
8
+ *
9
+ * evaluate_output needs the trace itself (its `tool_calls`, so a caller who
10
+ * has already logged the trajectory does not have to resend it), and the
11
+ * existence check had to load the row anyway. Fetching it twice would be
12
+ * two reads for one fact — and two chances for them to disagree.
13
+ */
14
+ export declare function getTraceOrThrow(storage: IStorageAdapter, tenantId: TenantId, traceId: string): Promise<Trace>;
5
15
  export declare function assertTraceExists(storage: IStorageAdapter, tenantId: TenantId, traceId: string): Promise<void>;
6
16
  /** insertEvalResult with the foreign-key race translated into the same clear message. */
7
17
  export declare function insertLinkedEvalResult(storage: IStorageAdapter, tenantId: TenantId, result: EvalResult): Promise<void>;
@@ -18,10 +18,22 @@ export function unknownTraceMessage(traceId) {
18
18
  'Nothing was evaluated or written. Pass the trace_id returned by log_trace (or listed by get_traces), ' +
19
19
  'or omit trace_id to store an unlinked evaluation.');
20
20
  }
21
- export async function assertTraceExists(storage, tenantId, traceId) {
21
+ /**
22
+ * The same refuse-before-any-work check, returning the row it already read.
23
+ *
24
+ * evaluate_output needs the trace itself (its `tool_calls`, so a caller who
25
+ * has already logged the trajectory does not have to resend it), and the
26
+ * existence check had to load the row anyway. Fetching it twice would be
27
+ * two reads for one fact — and two chances for them to disagree.
28
+ */
29
+ export async function getTraceOrThrow(storage, tenantId, traceId) {
22
30
  const trace = await storage.getTrace(tenantId, traceId);
23
31
  if (!trace)
24
32
  throw new Error(unknownTraceMessage(traceId));
33
+ return trace;
34
+ }
35
+ export async function assertTraceExists(storage, tenantId, traceId) {
36
+ await getTraceOrThrow(storage, tenantId, traceId);
25
37
  }
26
38
  /** insertEvalResult with the foreign-key race translated into the same clear message. */
27
39
  export async function insertLinkedEvalResult(storage, tenantId, result) {
@@ -32,7 +32,21 @@ export interface IrisConfig {
32
32
  topic_consistency?: number;
33
33
  cost_threshold?: number;
34
34
  max_token_ratio?: number;
35
+ max_tool_repeats?: number;
35
36
  };
37
+ /**
38
+ * Built-in rule names promoted to CRITICAL — a failure vetoes `passed`
39
+ * regardless of the weighted score. Validated against the rule registry
40
+ * when the config loads; an unknown name is a startup error naming the
41
+ * valid list, never a silent no-op.
42
+ */
43
+ criticalRules?: string[];
44
+ /**
45
+ * Built-in rule names demoted from critical — they still score and still
46
+ * report a failure, but they stop vetoing `passed`. Same validation. A
47
+ * name in both lists is a config error: it does not say what you want.
48
+ */
49
+ nonCriticalRules?: string[];
36
50
  };
37
51
  logging: {
38
52
  level: 'debug' | 'info' | 'warn' | 'error';
@@ -1,3 +1,4 @@
1
+ import type { ToolCallRecord } from './trace.js';
1
2
  export type EvalType = 'completeness' | 'relevance' | 'safety' | 'cost' | 'custom';
2
3
  /**
3
4
  * What an EvalResult can be tagged as: a single bundle (EvalType), or
@@ -30,11 +31,19 @@ export interface EvalContext {
30
31
  output: string;
31
32
  expected?: string;
32
33
  input?: string;
33
- toolCalls?: Array<{
34
- tool_name: string;
35
- input?: unknown;
36
- output?: unknown;
37
- }>;
34
+ /**
35
+ * The agent's trajectory — what it actually DID, in call order.
36
+ *
37
+ * Deliberately the SAME record the capture path stores (ToolCallRecord =
38
+ * log_trace's `tool_calls[]`), not a narrower local shape. It used to be
39
+ * a three-field inline type without `error`, so a rule could see that a
40
+ * tool was called but never that it FAILED: the acceptance pass found
41
+ * three real transcripts that answered confidently after a grep exited 1,
42
+ * an ls hit a missing directory and a node -e threw, and no rule could
43
+ * reach the fact. Re-declaring a subset here would reintroduce exactly
44
+ * that gap the next time a field is added to the capture shape.
45
+ */
46
+ toolCalls?: ToolCallRecord[];
38
47
  tokenUsage?: {
39
48
  prompt_tokens?: number;
40
49
  completion_tokens?: number;
@@ -71,6 +80,22 @@ export interface EvalRuleResult {
71
80
  * regroup them.
72
81
  */
73
82
  category?: EvalType;
83
+ /**
84
+ * Whether this rule VETOES the verdict — its EFFECTIVE criticality, after
85
+ * `eval.criticalRules` / `eval.nonCriticalRules` are applied, not the
86
+ * value on the rule's definition. A reader holding a failed evaluation
87
+ * could otherwise not tell a hard violation from a low score without
88
+ * knowing the rule library by heart.
89
+ */
90
+ critical?: boolean;
91
+ /**
92
+ * Who decided that: 'default' is the rule's own declaration (for a
93
+ * deployed custom rule, the severity it was deployed with); 'config' means
94
+ * one of the two override lists named it. The distinction is the point of
95
+ * making criticality configurable — an operator reading a verdict must be
96
+ * able to see that their own promotion caused it.
97
+ */
98
+ criticalSource?: 'default' | 'config';
74
99
  passed: boolean;
75
100
  score: number;
76
101
  message: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iris-eval/mcp-server",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Stop shipping agents on vibes. Score every agent output for quality, safety, and cost.",
5
5
  "mcpName": "io.github.iris-eval/mcp-server",
6
6
  "type": "module",
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.7.0",
9
+ "version": "0.8.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@iris-eval/mcp-server",
14
- "version": "0.7.0",
14
+ "version": "0.8.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },