@iris-eval/mcp-server 0.5.1 → 0.6.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 (53) hide show
  1. package/README.md +95 -33
  2. package/dist/config/index.d.ts +10 -0
  3. package/dist/config/index.js +33 -7
  4. package/dist/dashboard/assets/index-CshLgDRB.js +10 -0
  5. package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
  6. package/dist/dashboard/index.html +4 -3
  7. package/dist/dashboard/routes/health.js +10 -3
  8. package/dist/dashboard/routes/moments.js +1 -1
  9. package/dist/dashboard/routes/preferences.d.ts +1 -0
  10. package/dist/dashboard/routes/preferences.js +31 -3
  11. package/dist/dashboard/routes/rules.d.ts +18 -0
  12. package/dist/dashboard/routes/rules.js +160 -6
  13. package/dist/dashboard/routes/traces.js +21 -3
  14. package/dist/dashboard/seed-demo-data.js +11 -0
  15. package/dist/dashboard/server.js +13 -3
  16. package/dist/dashboard/session-auth.d.ts +8 -0
  17. package/dist/dashboard/session-auth.js +237 -0
  18. package/dist/dashboard/validation.d.ts +9 -3
  19. package/dist/dashboard/validation.js +69 -11
  20. package/dist/eval/engine.d.ts +62 -0
  21. package/dist/eval/engine.js +188 -82
  22. package/dist/eval/rules/safety.d.ts +8 -0
  23. package/dist/eval/rules/safety.js +43 -11
  24. package/dist/index.js +102 -16
  25. package/dist/middleware/rate-limit.d.ts +25 -0
  26. package/dist/middleware/rate-limit.js +54 -2
  27. package/dist/self-test.d.ts +14 -0
  28. package/dist/self-test.js +97 -13
  29. package/dist/storage/demo-guard.d.ts +8 -0
  30. package/dist/storage/demo-guard.js +53 -0
  31. package/dist/storage/sqlite-adapter.d.ts +6 -0
  32. package/dist/storage/sqlite-adapter.js +72 -1
  33. package/dist/tools/delete-rule.js +49 -11
  34. package/dist/tools/deploy-rule.d.ts +33 -0
  35. package/dist/tools/deploy-rule.js +130 -27
  36. package/dist/tools/evaluate-output.js +41 -22
  37. package/dist/tools/evaluate-with-llm-judge.js +10 -3
  38. package/dist/tools/get-traces.d.ts +27 -0
  39. package/dist/tools/get-traces.js +60 -8
  40. package/dist/tools/list-rules.js +2 -2
  41. package/dist/tools/log-trace.js +4 -3
  42. package/dist/tools/strict-input.d.ts +1 -0
  43. package/dist/tools/strict-input.js +25 -0
  44. package/dist/tools/trace-link.d.ts +7 -0
  45. package/dist/tools/trace-link.js +39 -0
  46. package/dist/tools/verify-citations.d.ts +19 -0
  47. package/dist/tools/verify-citations.js +41 -4
  48. package/dist/types/eval.d.ts +45 -1
  49. package/dist/types/index.d.ts +1 -1
  50. package/dist/types/query.d.ts +25 -0
  51. package/package.json +1 -1
  52. package/server.json +2 -2
  53. package/dist/dashboard/assets/index-VI_nbMfN.js +0 -10
@@ -1,37 +1,45 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
- import { strictInput } from './strict-input.js';
4
- const CustomRuleSchema = z.object({
5
- name: z.string(),
3
+ import { strictInput, strictNested } from './strict-input.js';
4
+ import { assertTraceExists, insertLinkedEvalResult } from './trace-link.js';
5
+ /*
6
+ * Strict one level down (#376): `{ name, type, config, wieght: 5 }` used to
7
+ * parse with `wieght` silently discarded, so the rule ran at weight 1 and
8
+ * the score moved for a reason the response could not show. `config` stays
9
+ * a free-form record — its keys depend on `type` and are validated by the
10
+ * rule itself (a broken config reports skipped + configInvalid).
11
+ */
12
+ const CustomRuleSchema = strictNested({
13
+ name: z.string().min(1).describe('Rule name as it will appear in rule_results'),
6
14
  type: z.enum([
7
15
  'regex_match', 'regex_no_match', 'min_length', 'max_length',
8
16
  'contains_keywords', 'excludes_keywords', 'json_schema', 'cost_threshold',
9
- ]),
10
- config: z.record(z.string(), z.unknown()),
11
- weight: z.number().optional(),
12
- });
17
+ ]).describe('Check type — decides which config keys the rule reads'),
18
+ config: z.record(z.string(), z.unknown()).describe('Check configuration; keys depend on type (pattern, min_length, keywords, max_cost, …)'),
19
+ weight: z.number().positive().optional().describe('Weight in the weighted score (default 1; must be > 0)'),
20
+ }, 'a custom_rules entry');
13
21
  const inputSchema = {
14
22
  output: z.string().describe('The output text to evaluate (the agent\'s response that gets scored against rules)'),
15
23
  // .optional() rather than .default('completeness') so the handler can tell
16
24
  // "caller chose completeness" apart from "caller never chose" — the second
17
25
  // case gets a note in the response saying safety rules did not run. The
18
26
  // effective default is still completeness.
19
- eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).optional().describe('Rule bundle to apply: completeness | relevance | safety | cost | custom — picks which built-in rules fire. Defaults to "completeness" when omitted (the response then carries a note that safety rules did not run)'),
27
+ eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom', 'all']).optional().describe('Rule bundle to apply: completeness | relevance | safety | cost | custom | all — picks which built-in rules fire. "all" runs every bundle in one call and adds a per-category breakdown. Defaults to "completeness" when omitted (the response then carries a note that safety rules did not run)'),
20
28
  expected: z.string().optional().describe('Expected output for comparison — consulted only by the completeness bundle\'s expected_coverage rule; NOT used by relevance (the relevance rules compare the output against `input`)'),
21
29
  input: z.string().optional().describe('Original input for context (the ask + any source material the agent was given) — REQUIRED when eval_type="relevance" (keyword_overlap and topic_consistency compare the output against it and skip without it); also grounds the safety bundle\'s hallucination signals'),
22
- trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through'),
30
+ trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through. Must be the id of a stored trace (from log_trace / get_traces); an unknown id is rejected before anything is evaluated'),
23
31
  // .max(10): inline rules skip the deploy-time probe, and the engine runs
24
32
  // rules synchronously — without a cap, one request carrying N sandbox-
25
33
  // defeating regex rules stalls the server linearly in N (measured 9.3s at
26
34
  // N=50). Ten is ample for per-call rules; persistent sets belong in
27
35
  // deploy_rule, where deploy-time validation probes each pattern.
28
- custom_rules: z.array(CustomRuleSchema).max(10).optional().describe('Custom evaluation rules, max 10 per call (deploy persistent rule sets via deploy_rule instead) — fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these'),
29
- cost_usd: z.number().optional().describe('Cost in USD — consulted by the cost bundle (eval_type="cost") AND by any cost_threshold custom rule regardless of eval_type; omit it and such a rule skips rather than passes (a critical one is listed in critical_skipped)'),
36
+ custom_rules: z.array(CustomRuleSchema).max(10).optional().describe('Custom evaluation rules, max 10 per call (deploy persistent rule sets via deploy_rule instead) — fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these. Each entry accepts exactly name, type, config, weight — an unknown key (e.g. a misspelled weight) is rejected'),
37
+ cost_usd: z.number().optional().describe('Cost in USD — consulted by the cost bundle (eval_type="cost" or "all") AND by any cost_threshold custom rule regardless of eval_type; omit it and such a rule skips rather than passes (a critical one is listed in critical_skipped)'),
30
38
  token_usage: z.object({
31
39
  prompt_tokens: z.number().optional(),
32
40
  completion_tokens: z.number().optional(),
33
41
  total_tokens: z.number().optional(),
34
- }).optional().describe('Token usage breakdown — only consulted when eval_type="cost" (used for token-budget rules)'),
42
+ }).optional().describe('Token usage breakdown — only consulted by the cost bundle (eval_type="cost" or "all"; used for token-budget rules)'),
35
43
  };
36
44
  export function registerEvaluateOutputTool(server, storage, evalEngine) {
37
45
  server.registerTool('evaluate_output', {
@@ -43,17 +51,17 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
43
51
  '',
44
52
  'Behavior. Deterministic, in-process scoring — same inputs always produce the same result. Writes one eval_result row to Iris storage (linked to trace_id if provided; unlinked otherwise). No external network calls in heuristic mode (v0.4 adds an llm_as_judge eval_type that DOES call LLM APIs; see the separate evaluate_with_llm_judge tool for that). Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Runs in ~5-50ms for rule-based evaluation.',
45
53
  '',
46
- 'Output shape. Returns JSON: `{ "id": "<uuid>", "eval_type": "<bundle that ran>", "score": 0..1, "passed": boolean, "critical_failures?": string[], "critical_skipped?": string[], "rule_results": [{ "ruleName", "passed", "score", "message", "skipped?", "skipReason?", "budgetExceeded?", "configInvalid?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean, "note?": string }`. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data). `note` appears only when eval_type was omitted, naming the defaulted bundle and that safety rules did not run.',
54
+ 'Output shape. Returns JSON: `{ "id": "<uuid>", "eval_type": "<bundle that ran>", "score": 0..1, "passed": boolean, "critical_failures?": string[], "critical_skipped?": string[], "rule_results": [{ "ruleName", "ruleId?", "category?", "passed", "score", "message", "skipped?", "skipReason?", "budgetExceeded?", "configInvalid?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean, "categories?": { "<bundle>": { "score", "passed", "rules_evaluated", "rules_skipped", "insufficient_data", "critical_failures?", "critical_skipped?" } }, "note?": string }`. `ruleId` is present on results produced by a deployed rule (rule-XXXX) so two rules sharing a name stay distinguishable. `categories` appears only for eval_type="all" and carries one entry per bundle that had rules, each with the same threshold + critical-veto semantics as a single-bundle run; `category` on each rule result says which bundle it came from. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data). `note` appears only when eval_type was omitted, naming the defaulted bundle and that safety rules did not run.',
47
55
  '',
48
- 'What `passed` means. `score` and `passed` answer different questions. `score` is the weighted average across the rules that ran — a 0..1 quality gradient. `passed` is the ship/no-ship verdict: true only when the score clears the pass threshold (default 0.7, configurable via config `eval.defaultThreshold`) AND no critical rule failed. Critical rules HARD-FAIL: if one fails, `passed` is false regardless of the weighted score, and the culprits are listed in `critical_failures`. The critical rules are the genuine safety violations — `no_pii`, `no_injection_patterns`, `no_blocklist_words` — plus any deployed custom rule with severity high/critical. A leaked SSN can never be averaged away by other rules passing. One caveat, stated because it is reachable on purpose: a critical rule that SKIPPED did not judge the output and therefore cannot veto — a regex rule whose match blew the 100ms sandbox budget on crafted output skips, so `passed` can be true with no `critical_failures`. Every such rule is named in `critical_skipped`. If your gate must fail closed, treat a non-empty `critical_skipped` as UNKNOWN, not clean.',
56
+ 'What `passed` means. `score` and `passed` answer different questions. `score` is the weighted average across the rules that ran — a 0..1 quality gradient. `passed` is the ship/no-ship verdict: true only when the score clears the pass threshold (default 0.7, configurable via config `eval.defaultThreshold`) AND no critical rule failed. Critical rules HARD-FAIL: if one fails, `passed` is false regardless of the weighted score, and the culprits are listed in `critical_failures`. The critical rules are the genuine safety violations — `no_pii`, `no_injection_patterns`, `no_blocklist_words` — plus any deployed custom rule with severity high/critical. A leaked SSN can never be averaged away by other rules passing. For eval_type="all" the veto spans every bundle: one critical failure anywhere forces the overall `passed` to false. One caveat, stated because it is reachable on purpose: a critical rule that SKIPPED did not judge the output and therefore cannot veto — a regex rule whose match blew the 100ms sandbox budget on crafted output skips, so `passed` can be true with no `critical_failures`. Every such rule is named in `critical_skipped`. If your gate must fail closed, treat a non-empty `critical_skipped` as UNKNOWN, not clean.',
49
57
  '',
50
- 'Use when you want a quality score on a specific output — typically after log_trace records the execution. Pass `eval_type` to route to the right rule bundle: `completeness` (length, non-empty output, sentence count, coverage of `expected`), `relevance` (keyword overlap and topic consistency against `input`), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection — pass `input` so the hallucination signals can cross-check the output against the material the agent was given), `cost` (budget threshold), or `custom` (bring your own rules via `custom_rules`).',
58
+ 'Use when you want a quality score on a specific output — typically after log_trace records the execution. Pass `eval_type` to route to the right rule bundle: `completeness` (length, non-empty output, sentence count, coverage of `expected`), `relevance` (keyword overlap and topic consistency against `input`), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection — pass `input` so the hallucination signals can cross-check the output against the material the agent was given), `cost` (budget threshold), `custom` (bring your own rules via `custom_rules`), or `all` (every bundle above in one call — completeness, relevance, safety, cost, plus rules deployed under "custom" and any inline custom_rules — with per-category scores in `categories` and one overall verdict; rules whose context is missing, such as relevance without `input` or cost without `cost_usd`, skip and are excluded from the score exactly as in a single-bundle run).',
51
59
  '',
52
60
  'Don\'t use when the output is empty or has no applicable rules — the eval_type decides which rules apply, and invalid combinations return score=0 + insufficient_data=true (not an error, but not actionable). Don\'t use to VALIDATE JSON schemas directly (use your language\'s JSON Schema validator — Iris\'s `json_schema` custom rule type is for output-shape assertions, not arbitrary validation).',
53
61
  '',
54
- 'Parameters. input is REQUIRED when eval_type="relevance" (keyword_overlap and topic_consistency compare the output against it; without it both rules skip and the response reports insufficient_data=true) AND grounds the safety bundle\'s hallucination signals (without it those signals stay silent rather than guess); ignored otherwise. expected is consulted only by the completeness bundle\'s expected_coverage rule; ignored for other eval_types — it is NOT the relevance target. cost_usd is consulted by the cost bundle AND by any cost_threshold custom rule regardless of eval_type — omit it and such a rule skips rather than passes (a critical one is listed in critical_skipped); token_usage is ONLY consulted when eval_type="cost". custom_rules ALWAYS fires regardless of eval_type — pass eval_type="custom" if you want ONLY your rules to run (otherwise both your rules AND the eval_type bundle run together). trace_id is optional but recommended (linking the eval to its trace surfaces it in the dashboard\'s drill-through). Defaults: eval_type="completeness" — and when you rely on that default, the response carries a `note` reminding you that the safety bundle did not run.',
62
+ 'Parameters. input is REQUIRED when eval_type="relevance" (keyword_overlap and topic_consistency compare the output against it; without it both rules skip and the response reports insufficient_data=true) AND grounds the safety bundle\'s hallucination signals (without it those signals stay silent rather than guess); ignored otherwise. expected is consulted only by the completeness bundle\'s expected_coverage rule; ignored for other eval_types — it is NOT the relevance target. cost_usd is consulted by the cost bundle AND by any cost_threshold custom rule regardless of eval_type — omit it and such a rule skips rather than passes (a critical one is listed in critical_skipped); token_usage is ONLY consulted by the cost bundle. custom_rules ALWAYS fires regardless of eval_type — pass eval_type="custom" if you want ONLY your rules to run (otherwise both your rules AND the eval_type bundle run together); each entry takes exactly name, type, config and weight. trace_id is optional but recommended (linking the eval to its trace surfaces it in the dashboard\'s drill-through) and must name a stored trace. Defaults: eval_type="completeness" — and when you rely on that default, the response carries a `note` reminding you that the safety bundle did not run.',
55
63
  '',
56
- 'Error modes. Throws on unknown argument names (strict schema — a misspelled argument is rejected with the valid argument list, never silently dropped). Throws on malformed custom_rules (Zod rejects the shape: missing name/type, unknown type, non-object config) and on more than 10 custom_rules in one call (use deploy_rule for persistent rule sets). An inline rule whose CONFIG is unusable — a regex that fails the safe-regex2 ReDoS check or exceeds the 1000-char limit, a missing or non-string config.pattern, non-string keywords — does NOT error: that rule reports skipped with configInvalid=true and a skipReason naming the field, and the other rules still run (deploy_rule rejects the same configs with a 400 at deploy time). Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. The eval itself never throws — failing rules report `passed: false` with a message, they don\'t bubble exceptions. A regex that exceeds the 100ms sandbox matching budget on a given output reports skipped with budgetExceeded=true instead of hanging the server (fail-open per rule — gate on that flag if you must fail closed).',
64
+ 'Error modes. Throws on unknown argument names (strict schema — a misspelled argument is rejected with the valid argument list, never silently dropped), and likewise on an unknown key inside a custom_rules entry (e.g. `wieght`) — the valid keys are listed; a rule\'s `config` keys are free-form and are not checked here. Throws on malformed custom_rules (Zod rejects the shape: missing name/type, unknown type, non-object config, non-positive weight) and on more than 10 custom_rules in one call (use deploy_rule for persistent rule sets). Throws when trace_id does not match a stored trace — checked BEFORE evaluating, so nothing is scored or written; the message names the trace_id. An inline rule whose CONFIG is unusable — a regex that fails the safe-regex2 ReDoS check or exceeds the 1000-char limit, a missing or non-string config.pattern, non-string keywords — does NOT error: that rule reports skipped with configInvalid=true and a skipReason naming the field, and the other rules still run (deploy_rule rejects the same configs with a 400 at deploy time). Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. The eval itself never throws — failing rules report `passed: false` with a message, they don\'t bubble exceptions. A regex that exceeds the 100ms sandbox matching budget on a given output reports skipped with budgetExceeded=true instead of hanging the server (fail-open per rule — gate on that flag if you must fail closed).',
57
65
  ].join('\n'),
58
66
  inputSchema: strictInput(inputSchema),
59
67
  annotations: {
@@ -63,25 +71,34 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
63
71
  openWorldHint: false, // No external network in heuristic mode; LLM-as-judge has its own tool with openWorldHint:true
64
72
  },
65
73
  }, async (args) => {
74
+ // Refuse an unknown trace_id up front (#376): the old path ran the
75
+ // evaluation and then surfaced SQLite's "FOREIGN KEY constraint
76
+ // failed", which names neither the field nor the fix.
77
+ if (args.trace_id) {
78
+ await assertTraceExists(storage, LOCAL_TENANT, args.trace_id);
79
+ }
66
80
  // Track omission explicitly: a caller who never chose a bundle gets
67
81
  // the completeness default AND a note saying so — six of seven UAT
68
82
  // personas read passed:true on PII-laden text with no hint that the
69
83
  // safety bundle never ran.
70
84
  const evalTypeOmitted = args.eval_type === undefined;
71
- const evalType = (args.eval_type ?? 'completeness');
72
- const result = evalEngine.evaluate(evalType, {
85
+ const context = {
73
86
  output: args.output,
74
87
  expected: args.expected,
75
88
  input: args.input,
76
89
  costUsd: args.cost_usd,
77
90
  tokenUsage: args.token_usage,
78
- }, args.custom_rules);
91
+ };
92
+ const customRules = args.custom_rules;
93
+ const result = args.eval_type === 'all'
94
+ ? evalEngine.evaluateAll(context, customRules)
95
+ : evalEngine.evaluate((args.eval_type ?? 'completeness'), context, customRules);
79
96
  if (args.trace_id) {
80
97
  result.trace_id = args.trace_id;
81
98
  }
82
99
  // OSS single-tenant: MCP tool callers are the local user. Cloud
83
100
  // will derive tenant from the authenticated MCP session.
84
- await storage.insertEvalResult(LOCAL_TENANT, result);
101
+ await insertLinkedEvalResult(storage, LOCAL_TENANT, result);
85
102
  return {
86
103
  content: [
87
104
  {
@@ -107,9 +124,11 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
107
124
  rules_evaluated: result.rules_evaluated,
108
125
  rules_skipped: result.rules_skipped,
109
126
  insufficient_data: result.insufficient_data,
127
+ // Per-bundle breakdown — eval_type="all" only.
128
+ ...(result.categories ? { categories: result.categories } : {}),
110
129
  ...(evalTypeOmitted
111
130
  ? {
112
- note: 'eval_type was omitted, so the default "completeness" bundle ran. Safety rules (PII, injection, blocklist, stub, hallucination) were NOT part of this evaluation — pass eval_type="safety" to run them.',
131
+ note: 'eval_type was omitted, so the default "completeness" bundle ran. Safety rules (PII, injection, blocklist, stub, hallucination) were NOT part of this evaluation — pass eval_type="safety" to run them, or eval_type="all" to run every bundle.',
113
132
  }
114
133
  : {}),
115
134
  }),
@@ -4,6 +4,7 @@ import { evaluateWithLLMJudge } from '../eval/llm-judge/evaluator.js';
4
4
  import { findPricing } from '../eval/llm-judge/pricing.js';
5
5
  import { generateEvalId } from '../utils/ids.js';
6
6
  import { strictInput } from './strict-input.js';
7
+ import { assertTraceExists, insertLinkedEvalResult } from './trace-link.js';
7
8
  const inputSchema = {
8
9
  output: z.string().min(1).describe('The agent output text to evaluate'),
9
10
  template: z
@@ -16,7 +17,7 @@ const inputSchema = {
16
17
  input: z.string().optional().describe('User question / prompt that produced the output (improves accuracy for helpfulness/safety)'),
17
18
  expected: z.string().optional().describe('Reference answer (required for correctness template)'),
18
19
  source_material: z.string().optional().describe('Provided RAG sources (required for faithfulness template)'),
19
- trace_id: z.string().optional().describe('Link this evaluation to a trace'),
20
+ trace_id: z.string().optional().describe('Link this evaluation to a stored trace (id from log_trace / get_traces); an unknown id is rejected BEFORE the judge is called'),
20
21
  max_cost_usd: z.number().positive().optional().describe('Cost cap in USD; defaults to IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL or 0.25'),
21
22
  max_output_tokens: z.number().int().positive().max(4096).optional().describe('Judge output token cap; default 512'),
22
23
  temperature: z.number().min(0).max(2).optional().describe('Sampling temperature; default 0 (deterministic)'),
@@ -72,7 +73,7 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
72
73
  '',
73
74
  'Parameters. model is required (no default — pick consciously since cost varies 100x across models). provider is auto-detected from the model name; override only for ambiguous IDs. expected is REQUIRED when template="correctness" (the reference answer to compare against); ignored for other templates. source_material is REQUIRED when template="faithfulness" (the RAG sources to ground against); ignored otherwise. input is optional but improves scoring on helpfulness/safety templates (gives the judge the user prompt that produced the output). max_cost_usd defaults to env var IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL or $0.25 — the worst-case cost is computed BEFORE the call (input_tokens × prompt_price + max_output_tokens × completion_price, PLUS the same for the one retry that fires if the judge\'s first reply is not valid JSON); call refused upfront if that two-attempt worst case would exceed. When a retry does run, the reported input_tokens / output_tokens / cost_usd / latency_ms are totals across both attempts. max_output_tokens caps the judge response (default 512, max 4096); higher = more rationale detail + more cost. temperature default 0 (deterministic). timeout_ms default 60000. trace_id optional but recommended (links eval to trace in dashboard). Defaults: temperature=0, max_output_tokens=512, max_cost_usd=$0.25, timeout_ms=60000.',
74
75
  '',
75
- 'Error modes. Throws when the required API key env var is missing. Throws when the estimated worst-case cost exceeds max_cost_usd (raise the cap or trim prompts). Throws LLMJudgeError on provider errors — kind=`auth` on 401/403, `rate_limit` on 429 (auto-retried once), `server_error` on 5xx, `timeout` on abort, `malformed_response` when the judge fails to emit valid JSON on both attempts. Throws "Unknown model" for unsupported model IDs — update src/eval/llm-judge/pricing.ts first.',
76
+ 'Error modes. Throws when the required API key env var is missing. Throws when trace_id does not match a stored trace — checked before the provider call, so no money is spent and nothing is written. Throws when the estimated worst-case cost exceeds max_cost_usd (raise the cap or trim prompts). Throws LLMJudgeError on provider errors — kind=`auth` on 401/403, `rate_limit` on 429 (auto-retried once), `server_error` on 5xx, `timeout` on abort, `malformed_response` when the judge fails to emit valid JSON on both attempts. Throws "Unknown model" for unsupported model IDs — update src/eval/llm-judge/pricing.ts first.',
76
77
  ].join('\n'),
77
78
  inputSchema: strictInput(inputSchema),
78
79
  annotations: {
@@ -85,6 +86,12 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
85
86
  const provider = args.provider ?? inferProvider(args.model);
86
87
  const apiKey = resolveApiKey(provider);
87
88
  const maxCostUsd = resolveMaxCost(args.max_cost_usd);
89
+ // An unknown trace_id is refused BEFORE the provider call — the old
90
+ // path spent the judge's money and then failed the INSERT with a raw
91
+ // "FOREIGN KEY constraint failed" (#376).
92
+ if (args.trace_id) {
93
+ await assertTraceExists(storage, LOCAL_TENANT, args.trace_id);
94
+ }
88
95
  const result = await evaluateWithLLMJudge({
89
96
  output: args.output,
90
97
  template: args.template,
@@ -105,7 +112,7 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
105
112
  // judge doesn't fit completeness/relevance/safety/cost taxonomy
106
113
  // cleanly — it spans all four. The rule_results payload carries
107
114
  // the full judge provenance.
108
- await storage.insertEvalResult(LOCAL_TENANT, {
115
+ await insertLinkedEvalResult(storage, LOCAL_TENANT, {
109
116
  id: evalId,
110
117
  trace_id: args.trace_id,
111
118
  eval_type: 'custom',
@@ -1,3 +1,30 @@
1
+ import { z } from 'zod';
1
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import type { IStorageAdapter } from '../types/query.js';
4
+ export declare function isIsoTimestamp(value: string): boolean;
5
+ /**
6
+ * The `since` / `until` field schema. Shared with the dashboard's trace
7
+ * query (dashboard/validation.ts) so both read paths refuse the same
8
+ * unparseable bounds with the same hint.
9
+ */
10
+ export declare const isoTimestamp: z.ZodString;
11
+ /** The cross-field bounds a trace query can carry. */
12
+ export interface TraceRangeArgs {
13
+ min_score?: number;
14
+ max_score?: number;
15
+ since?: string;
16
+ until?: string;
17
+ }
18
+ /**
19
+ * Cross-field checks the per-field schema cannot express (#373). A range
20
+ * whose bounds cross — min_score 0.9 / max_score 0.1, or since after until
21
+ * — used to be accepted and return an empty page, which reads as "no such
22
+ * traces" when the truth is "no trace could ever match this". Refusing it
23
+ * with the two values named is what the argument descriptions promise.
24
+ *
25
+ * One function for both read paths: `get_traces` (MCP) and
26
+ * `GET /api/v1/traces` (dashboard) call it from their `superRefine`, so a
27
+ * bound the tool rejects is never one the HTTP query quietly accepts.
28
+ */
29
+ export declare function addTraceRangeIssues(args: TraceRangeArgs, ctx: z.RefinementCtx): void;
3
30
  export declare function registerGetTracesTool(server: McpServer, storage: IStorageAdapter): void;
@@ -1,22 +1,74 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
3
  import { strictInput } from './strict-input.js';
4
+ /*
5
+ * An ISO-8601 instant (2026-08-01T00:00:00Z, offsets allowed) or calendar
6
+ * date (2026-08-01). Stored timestamps are ISO strings and the adapter
7
+ * compares them lexically, so both forms bound the query correctly; a
8
+ * date-only value is the natural "since the 1st" spelling and is kept
9
+ * rather than forced into a full timestamp.
10
+ */
11
+ const isoInstant = z.iso.datetime({ offset: true });
12
+ const isoDate = z.iso.date();
13
+ export function isIsoTimestamp(value) {
14
+ return isoInstant.safeParse(value).success || isoDate.safeParse(value).success;
15
+ }
16
+ const TIMESTAMP_HINT = 'must be an ISO 8601 timestamp (e.g. 2026-08-01T00:00:00Z) or date (2026-08-01)';
17
+ /**
18
+ * The `since` / `until` field schema. Shared with the dashboard's trace
19
+ * query (dashboard/validation.ts) so both read paths refuse the same
20
+ * unparseable bounds with the same hint.
21
+ */
22
+ export const isoTimestamp = z.string().refine(isIsoTimestamp, {
23
+ // The rejected value is echoed so the error names what was sent, as the
24
+ // crossed-bound errors already do (v0.6.0 acceptance pass, B8/C9).
25
+ error: (issue) => `${JSON.stringify(issue.input)} ${TIMESTAMP_HINT}`,
26
+ });
27
+ /**
28
+ * Cross-field checks the per-field schema cannot express (#373). A range
29
+ * whose bounds cross — min_score 0.9 / max_score 0.1, or since after until
30
+ * — used to be accepted and return an empty page, which reads as "no such
31
+ * traces" when the truth is "no trace could ever match this". Refusing it
32
+ * with the two values named is what the argument descriptions promise.
33
+ *
34
+ * One function for both read paths: `get_traces` (MCP) and
35
+ * `GET /api/v1/traces` (dashboard) call it from their `superRefine`, so a
36
+ * bound the tool rejects is never one the HTTP query quietly accepts.
37
+ */
38
+ export function addTraceRangeIssues(args, ctx) {
39
+ if (args.min_score !== undefined && args.max_score !== undefined && args.min_score > args.max_score) {
40
+ ctx.addIssue({
41
+ code: 'custom',
42
+ path: ['min_score'],
43
+ message: `min_score (${args.min_score}) must be <= max_score (${args.max_score}) — the range is empty and no trace could match it`,
44
+ });
45
+ }
46
+ if (args.since !== undefined && args.until !== undefined && Date.parse(args.since) > Date.parse(args.until)) {
47
+ ctx.addIssue({
48
+ code: 'custom',
49
+ path: ['since'],
50
+ message: `since (${args.since}) must not be later than until (${args.until}) — the window is empty and no trace could match it`,
51
+ });
52
+ }
53
+ }
4
54
  const inputSchema = {
5
55
  agent_name: z.string().optional().describe('Filter by agent name — exact match (no wildcards in v0.4)'),
6
56
  framework: z.string().optional().describe('Filter by agent framework — exact match (e.g., langchain, autogen)'),
7
- since: z.string().optional().describe('ISO timestamp lower bound — return traces with timestamp >= this'),
8
- until: z.string().optional().describe('ISO timestamp upper bound — return traces with timestamp < this'),
9
- min_score: z.number().optional().describe('Minimum eval score filter (0..1) — applied to LATEST eval per trace, not all evals'),
10
- max_score: z.number().optional().describe('Maximum eval score filter (0..1) — applied to LATEST eval per trace'),
57
+ since: isoTimestamp.optional().describe('ISO 8601 timestamp (or date) lower bound — return traces with timestamp >= this; anything that is not an ISO timestamp is rejected, never treated as "no bound"'),
58
+ until: isoTimestamp.optional().describe('ISO 8601 timestamp (or date) upper bound — return traces with timestamp <= this; must not be earlier than `since`'),
59
+ min_score: z.number().min(0).max(1).optional().describe('Minimum eval score filter (0..1; values outside are rejected) — applied to LATEST eval per trace, not all evals; must be <= max_score when both are set'),
60
+ max_score: z.number().min(0).max(1).optional().describe('Maximum eval score filter (0..1; values outside are rejected) — applied to LATEST eval per trace'),
11
61
  // Mirrors traceQuerySchema in dashboard/validation.ts — both capture paths
12
62
  // (MCP tool, HTTP query) enforce the same 1..1000 bound. Unclamped, limit:-1
13
63
  // meant "LIMIT -1" in SQLite, i.e. every row (#332).
14
64
  limit: z.number().int().min(1).max(1000).default(50).describe('Results per page (default 50, max 1000 — values >1000 return 400)'),
15
- offset: z.number().default(0).describe('Zero-based pagination offset — skip first N results'),
65
+ offset: z.number().int().min(0).default(0).describe('Zero-based pagination offset — skip first N results (non-negative integer)'),
16
66
  sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp').describe('Sort by timestamp | latency_ms | cost_usd (default timestamp)'),
17
67
  sort_order: z.enum(['asc', 'desc']).default('desc').describe('Sort order: asc | desc (default desc — most recent / highest first)'),
18
68
  include_summary: z.boolean().default(false).describe('Include dashboard summary stats in same response — saves a round-trip when ingesting for dashboards'),
19
69
  };
70
+ // Cross-field range checks — see addTraceRangeIssues above.
71
+ const inputSchemaWithRanges = strictInput(inputSchema).superRefine(addTraceRangeIssues);
20
72
  export function registerGetTracesTool(server, storage) {
21
73
  server.registerTool('get_traces', {
22
74
  title: 'Get Traces',
@@ -33,11 +85,11 @@ export function registerGetTracesTool(server, storage) {
33
85
  '',
34
86
  'Don\'t use to score a trace (use evaluate_output). Don\'t use to create a trace (use log_trace). Don\'t use as a live event stream — it\'s a query, not a subscription; poll with exponential backoff or use the dashboard\'s SSE endpoint for real-time.',
35
87
  '',
36
- 'Parameters. limit defaults to 50, max 1000 (anything higher returns 400). offset is zero-based pagination. min_score / max_score filter on the LATEST eval per trace, not all evals (so a trace with one failing + one passing eval may or may not match depending on which landed last). Combining since + sort_by="latency_ms" + sort_order="desc" is the canonical "find slow recent traces" query. include_summary returns dashboard-style aggregates in the SAME response (saves a round-trip; use true for dashboard ingest, false for analytics queries that don\'t need them). agent_name and framework are exact-match (no wildcards in v0.4). Defaults: limit=50, offset=0, sort_by="timestamp", sort_order="desc", include_summary=false.',
88
+ 'Parameters. limit defaults to 50, max 1000 (anything higher returns 400). offset is zero-based pagination (non-negative integer). since / until must be ISO 8601 timestamps or dates — `since` is inclusive (timestamp >= since), `until` is inclusive (timestamp <= until), and `since` may not be later than `until`. min_score / max_score are 0..1 and filter on the LATEST eval per trace, not all evals (so a trace with one failing + one passing eval may or may not match depending on which landed last); min_score may not exceed max_score. Combining since + sort_by="latency_ms" + sort_order="desc" is the canonical "find slow recent traces" query. include_summary returns dashboard-style aggregates in the SAME response (saves a round-trip; use true for dashboard ingest, false for analytics queries that don\'t need them). agent_name and framework are exact-match (no wildcards in v0.4). Defaults: limit=50, offset=0, sort_by="timestamp", sort_order="desc", include_summary=false.',
37
89
  '',
38
- 'Error modes. Returns 400 on invalid sort_by / sort_order (Zod enum). Returns 400 if limit > 1000. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. Empty result with `total: 0` on no matches (not an error).',
90
+ 'Error modes. Returns 400 on invalid sort_by / sort_order (Zod enum). Returns 400 if limit > 1000 or offset < 0. Returns 400 — naming both values — on an empty range: min_score > max_score, since later than until, a score outside 0..1, or a since/until that is not an ISO 8601 timestamp or date (an unparseable bound is refused, never silently ignored). Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. Empty result with `total: 0` on no matches (not an error).',
39
91
  ].join('\n'),
40
- inputSchema: strictInput(inputSchema),
92
+ inputSchema: inputSchemaWithRanges,
41
93
  annotations: {
42
94
  readOnlyHint: true, // Pure query: never writes, never deletes
43
95
  destructiveHint: false, // Inverse of readOnly — trivially false
@@ -33,13 +33,13 @@ export function registerListRulesTool(server, customRuleStore) {
33
33
  '',
34
34
  '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
35
  '',
36
- 'Output shape. Returns JSON: `{ "rules": [{ "id": "rule-XXXX", "name", "description?", "evalType", "severity", "definition": { type, config, weight? }, "enabled": boolean, "deployedAt": ISO timestamp, "sourceMomentId?": string }], "total": number, "enabled_count": number }`. Empty array + total=0 when no rules deployed.',
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
37
  '',
38
38
  '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
39
  '',
40
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).",
41
41
  '',
42
- 'Parameters. eval_type filter is exact-match against each rule\'s evalType field (no wildcards). enabled_only excludes rules that are deployed-but-disabled (toggled via the dashboard\'s rule-list affordance there\'s no MCP toggle tool in v0.4). 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).',
42
+ '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
43
  '',
44
44
  "Error modes. Returns empty list if the rule store file doesn't exist (first run). Returns 429 if HTTP rate limit exceeded. Never throws on valid input.",
45
45
  ].join('\n'),
@@ -72,9 +72,10 @@ export function registerLogTraceTool(server, storage) {
72
72
  'Error modes. Throws on missing agent_name. Throws on malformed span or tool_call objects (Zod rejects). Returns 500 on storage failure (disk full, DB locked). Never blocks on the agent — returns within ~50ms for typical payloads.',
73
73
  ].join('\n'),
74
74
  // Strict at the MCP boundary (unknown args rejected, not stripped).
75
- // The dashboard's HTTP ingest builds its own schema FROM this shape
76
- // (dashboard/validation.ts) and keeps default stripping there on
77
- // purpose it relies on it to discard a client-supplied trace_id.
75
+ // The dashboard's HTTP ingest builds its own equally strict
76
+ // schema FROM this shape (dashboard/validation.ts): a client-supplied
77
+ // trace_id is rejected there with a 400 whose message says the server
78
+ // mints it, exactly as this tool mints its own in the handler below.
78
79
  inputSchema: strictInput(logTraceInputShape),
79
80
  annotations: {
80
81
  readOnlyHint: false, // Writes a row to storage
@@ -1,2 +1,3 @@
1
1
  import { z } from 'zod';
2
2
  export declare function strictInput<T extends z.ZodRawShape>(shape: T): z.ZodObject<{ -readonly [P in keyof T]: T[P]; }, z.core.$strict>;
3
+ export declare function strictNested<T extends z.ZodRawShape>(shape: T, container: string): z.ZodObject<{ -readonly [P in keyof T]: T[P]; }, z.core.$strict>;
@@ -33,3 +33,28 @@ export function strictInput(shape) {
33
33
  : undefined,
34
34
  });
35
35
  }
36
+ /*
37
+ * The same contract ONE LEVEL DOWN, for structured nested objects — a
38
+ * custom_rules[] entry, deploy_rule's `definition`. Top-level strictness
39
+ * shipped in 0.5.0 and stopped there, so `custom_rules: [{ name, type,
40
+ * config, wieght: 5 }]` still parsed cleanly with `wieght` discarded: the
41
+ * rule ran at the default weight and the score moved for a reason nothing
42
+ * in the response could show (#376). Free-form record fields (a rule's
43
+ * `config`, trace `metadata`, span `attributes`) are deliberately NOT
44
+ * strict — arbitrary keys there are the documented contract.
45
+ *
46
+ * `container` names the object in the message ("a custom_rules entry");
47
+ * the SDK appends the path (`at custom_rules.0`) so the caller sees
48
+ * exactly which entry to fix.
49
+ */
50
+ export function strictNested(shape, container) {
51
+ const validKeys = Object.keys(shape).join(', ');
52
+ return z.strictObject(shape, {
53
+ error: (issue) => issue.code === 'unrecognized_keys'
54
+ ? `Unknown key(s) in ${container}: ${issue.keys.map((k) => `"${k}"`).join(', ')}. ` +
55
+ `Valid keys: ${validKeys}. ` +
56
+ 'Unknown keys are rejected rather than silently dropped, so a misspelled key ' +
57
+ 'cannot change how the rule scores — check the spelling and retry.'
58
+ : undefined,
59
+ });
60
+ }
@@ -0,0 +1,7 @@
1
+ import type { IStorageAdapter } from '../types/query.js';
2
+ import type { EvalResult } from '../types/eval.js';
3
+ import type { TenantId } from '../types/tenant.js';
4
+ export declare function unknownTraceMessage(traceId: string): string;
5
+ export declare function assertTraceExists(storage: IStorageAdapter, tenantId: TenantId, traceId: string): Promise<void>;
6
+ /** insertEvalResult with the foreign-key race translated into the same clear message. */
7
+ export declare function insertLinkedEvalResult(storage: IStorageAdapter, tenantId: TenantId, result: EvalResult): Promise<void>;
@@ -0,0 +1,39 @@
1
+ /*
2
+ * Linking an evaluation to a trace that does not exist.
3
+ *
4
+ * eval_results.trace_id is a foreign key. Passing an unknown trace_id to
5
+ * evaluate_output used to run the whole evaluation and then fail at the
6
+ * INSERT with SQLite's own words — "FOREIGN KEY constraint failed" — which
7
+ * names no field, no value and no fix (#376). Worse for the paid tools:
8
+ * evaluate_with_llm_judge had already spent the provider call by the time
9
+ * the insert refused it.
10
+ *
11
+ * Two layers, because a check-then-insert has a gap: the pre-check refuses
12
+ * BEFORE any work (and before any money) with the trace_id named; the
13
+ * insert wrapper translates the constraint error for the race where the
14
+ * trace is deleted between the check and the write.
15
+ */
16
+ export function unknownTraceMessage(traceId) {
17
+ return (`trace_id "${traceId}" does not match any stored trace, so the evaluation cannot be linked to it. ` +
18
+ 'Nothing was evaluated or written. Pass the trace_id returned by log_trace (or listed by get_traces), ' +
19
+ 'or omit trace_id to store an unlinked evaluation.');
20
+ }
21
+ export async function assertTraceExists(storage, tenantId, traceId) {
22
+ const trace = await storage.getTrace(tenantId, traceId);
23
+ if (!trace)
24
+ throw new Error(unknownTraceMessage(traceId));
25
+ }
26
+ /** insertEvalResult with the foreign-key race translated into the same clear message. */
27
+ export async function insertLinkedEvalResult(storage, tenantId, result) {
28
+ try {
29
+ await storage.insertEvalResult(tenantId, result);
30
+ }
31
+ catch (err) {
32
+ const code = err.code;
33
+ const message = err instanceof Error ? err.message : String(err);
34
+ if (result.trace_id && (code === 'SQLITE_CONSTRAINT_FOREIGNKEY' || /FOREIGN KEY constraint failed/i.test(message))) {
35
+ throw new Error(unknownTraceMessage(result.trace_id));
36
+ }
37
+ throw err;
38
+ }
39
+ }
@@ -1,3 +1,22 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { IStorageAdapter } from '../types/query.js';
3
+ /**
4
+ * `passed: true` with `overall_score: null` is the honest answer when there
5
+ * was nothing to judge (no citations, none resolved). It is NOT the honest
6
+ * answer when citations resolved and the judge then failed on every one —
7
+ * a wrong API key, a model the provider refused, a parse failure — because
8
+ * the caller reads "passed" and ships. That case is an error naming the
9
+ * cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
10
+ */
11
+ export declare function assertJudgeRan(result: {
12
+ totalResolved: number;
13
+ totalJudged: number;
14
+ citations: ReadonlyArray<{
15
+ resolveStatus: string;
16
+ resolveError?: {
17
+ kind: string;
18
+ message: string;
19
+ };
20
+ }>;
21
+ }): void;
3
22
  export declare function registerVerifyCitationsTool(server: McpServer, storage: IStorageAdapter): void;
@@ -4,6 +4,7 @@ import { verifyCitations } from '../eval/citation-verify/verifier.js';
4
4
  import { findPricing } from '../eval/llm-judge/pricing.js';
5
5
  import { generateEvalId } from '../utils/ids.js';
6
6
  import { strictInput } from './strict-input.js';
7
+ import { assertTraceExists, insertLinkedEvalResult } from './trace-link.js';
7
8
  const inputSchema = {
8
9
  output: z.string().min(1).describe('The agent output containing citations to verify'),
9
10
  model: z
@@ -19,7 +20,7 @@ const inputSchema = {
19
20
  max_citations: z.number().int().positive().max(50).optional().describe('Max citations to verify (extras skipped); default 20'),
20
21
  per_source_timeout_ms: z.number().int().positive().optional().describe('Per-URL fetch timeout; default 10_000'),
21
22
  per_source_max_bytes: z.number().int().positive().optional().describe('Per-URL body cap; default 5MB'),
22
- trace_id: z.string().optional().describe('Link verification result to a trace'),
23
+ trace_id: z.string().optional().describe('Link verification result to a stored trace (id from log_trace / get_traces); an unknown id is rejected before any fetch or judge call'),
23
24
  };
24
25
  function inferProvider(model) {
25
26
  const pricing = findPricing(model);
@@ -48,6 +49,25 @@ function resolveDomainAllowlist(paramValue) {
48
49
  }
49
50
  return fromEnv.length > 0 ? fromEnv : undefined;
50
51
  }
52
+ /**
53
+ * `passed: true` with `overall_score: null` is the honest answer when there
54
+ * was nothing to judge (no citations, none resolved). It is NOT the honest
55
+ * answer when citations resolved and the judge then failed on every one —
56
+ * a wrong API key, a model the provider refused, a parse failure — because
57
+ * the caller reads "passed" and ships. That case is an error naming the
58
+ * cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
59
+ */
60
+ export function assertJudgeRan(result) {
61
+ if (result.totalResolved === 0 || result.totalJudged > 0)
62
+ return;
63
+ const judgeFailures = result.citations.filter((c) => c.resolveStatus === 'ok' && c.resolveError);
64
+ if (judgeFailures.length === 0)
65
+ return;
66
+ const kinds = [...new Set(judgeFailures.map((c) => c.resolveError.kind))].join(', ');
67
+ const first = judgeFailures[0].resolveError.message;
68
+ throw new Error(`verify_citations could not judge any of the ${result.totalResolved} resolved citation(s): the judge failed on every one (${kinds}). ` +
69
+ `Nothing was verified and nothing was stored, so there is no verdict. First error: ${first}`);
70
+ }
51
71
  export function registerVerifyCitationsTool(server, storage) {
52
72
  server.registerTool('verify_citations', {
53
73
  title: 'Verify Citations',
@@ -66,7 +86,7 @@ export function registerVerifyCitationsTool(server, storage) {
66
86
  '',
67
87
  'Parameters. model is required; provider auto-detected from model name (override only for ambiguous IDs). allow_fetch=false by default — outbound HTTP is REFUSED unless explicitly true OR IRIS_CITATION_ALLOW_FETCH=1 env. domain_allowlist suffix-matches hostnames (e.g., "wikipedia.org" allows en.wikipedia.org); merged with IRIS_CITATION_DOMAINS env (UNION — either source permits). max_citations defaults 20, hard cap 50 (extras are skipped silently, NOT errored — check total_citations_found in the response if precise). max_cost_usd_total defaults $1.00 — the pipeline stops mid-citation when the next judge call would exceed the cap (returns partial verdicts). per_source_timeout_ms defaults 10000 (10s); per_source_max_bytes defaults 5MB (truncates at boundary, judges still run on truncated content); independently of that, the judge reads at most the first 12,000 characters of each fetched source, and the per-citation cost estimate is taken on that truncated prompt, not on the full body. trace_id optional but recommended. Defaults: max_citations=20, max_cost_usd_total=$1.00, per_source_timeout_ms=10000, per_source_max_bytes=5242880, allow_fetch=false.',
68
88
  '',
69
- 'Error modes. Throws when the API key env var is missing. Throws "Unknown model" on unsupported model IDs. Per-citation errors are collected (resolve_error.kind = bad_scheme / ssrf / not_allowed_domain / timeout / too_large / bad_status / redirect_loop / not_text / fetch_disabled / malformed_judge_response / cost_cap_reached / unresolvable_kind) and returned in the response rather than thrown. An empty output or output with zero extractable citations returns overall_score=null + passed=true (nothing to fail).',
89
+ 'Error modes. Throws when the API key env var is missing. Throws "Unknown model" on unsupported model IDs. Throws when trace_id does not match a stored trace (checked before any fetch or judge call; nothing is written). Per-citation errors are collected (resolve_error.kind = bad_scheme / ssrf / not_allowed_domain / timeout / too_large / bad_status / redirect_loop / not_text / fetch_disabled / malformed_judge_response / cost_cap_reached / unresolvable_kind) and returned in the response rather than thrown. An empty output or output with zero extractable citations returns overall_score=null + passed=true (nothing to fail).',
70
90
  ].join('\n'),
71
91
  inputSchema: strictInput(inputSchema),
72
92
  annotations: {
@@ -80,6 +100,10 @@ export function registerVerifyCitationsTool(server, storage) {
80
100
  const apiKey = resolveApiKey(provider);
81
101
  const allowFetch = resolveAllowFetch(args.allow_fetch);
82
102
  const domainAllowlist = resolveDomainAllowlist(args.domain_allowlist);
103
+ // Refused before any fetch or judge call spends anything (#376).
104
+ if (args.trace_id) {
105
+ await assertTraceExists(storage, LOCAL_TENANT, args.trace_id);
106
+ }
83
107
  const result = await verifyCitations({
84
108
  output: args.output,
85
109
  provider,
@@ -92,12 +116,13 @@ export function registerVerifyCitationsTool(server, storage) {
92
116
  perSourceTimeoutMs: args.per_source_timeout_ms,
93
117
  perSourceMaxBytes: args.per_source_max_bytes,
94
118
  });
119
+ assertJudgeRan(result);
95
120
  const evalId = generateEvalId();
96
121
  const score = result.overallScore ?? 0;
97
122
  // Persist so dashboard can surface. eval_type='custom' — same
98
123
  // rationale as evaluate_with_llm_judge (spans all 4 heuristic
99
124
  // categories). rule_results[0] carries per-citation summary.
100
- await storage.insertEvalResult(LOCAL_TENANT, {
125
+ await insertLinkedEvalResult(storage, LOCAL_TENANT, {
101
126
  id: evalId,
102
127
  trace_id: args.trace_id,
103
128
  eval_type: 'custom',
@@ -142,7 +167,19 @@ export function registerVerifyCitationsTool(server, storage) {
142
167
  },
143
168
  resolve_status: c.resolveStatus,
144
169
  resolve_error: c.resolveError,
145
- source: c.source,
170
+ // Mapped to the documented snake_case keys. The verifier's
171
+ // internal shape is camelCase (contentType, bytesFetched) and
172
+ // used to be passed through verbatim, so a client parsing
173
+ // `source.content_type` per the description read undefined.
174
+ source: c.source
175
+ ? {
176
+ url: c.source.url,
177
+ status: c.source.status,
178
+ content_type: c.source.contentType,
179
+ bytes_fetched: c.source.bytesFetched,
180
+ truncated: c.source.truncated,
181
+ }
182
+ : undefined,
146
183
  judge: c.judge
147
184
  ? {
148
185
  supported: c.judge.supported,