@iris-eval/mcp-server 0.8.1 → 0.9.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 (114) hide show
  1. package/README.md +9 -2
  2. package/dist/capabilities.d.ts +64 -0
  3. package/dist/capabilities.js +65 -0
  4. package/dist/config/defaults.js +2 -0
  5. package/dist/custom-rule-store.d.ts +4 -0
  6. package/dist/custom-rule-store.js +8 -3
  7. package/dist/dashboard/assets/{index-BfMShR3p.js → index-Cz8_oOqG.js} +1 -1
  8. package/dist/dashboard/index.html +1 -1
  9. package/dist/dashboard/routes/capabilities.d.ts +3 -0
  10. package/dist/dashboard/routes/capabilities.js +11 -0
  11. package/dist/dashboard/routes/health.d.ts +5 -1
  12. package/dist/dashboard/routes/health.js +15 -3
  13. package/dist/dashboard/routes/rules.js +4 -1
  14. package/dist/dashboard/routes/traces.d.ts +3 -0
  15. package/dist/dashboard/routes/traces.js +9 -28
  16. package/dist/dashboard/server.d.ts +2 -0
  17. package/dist/dashboard/server.js +6 -2
  18. package/dist/eval/accuracy.d.ts +41 -0
  19. package/dist/eval/accuracy.js +97 -0
  20. package/dist/eval/criticality.d.ts +8 -1
  21. package/dist/eval/criticality.js +6 -0
  22. package/dist/eval/dormant.d.ts +4 -0
  23. package/dist/eval/dormant.js +22 -0
  24. package/dist/eval/engine.d.ts +1 -0
  25. package/dist/eval/engine.js +49 -3
  26. package/dist/eval/failure-classes.d.ts +8 -0
  27. package/dist/eval/failure-classes.js +18 -0
  28. package/dist/eval/llm-judge/evaluator.d.ts +10 -0
  29. package/dist/eval/llm-judge/evaluator.js +16 -1
  30. package/dist/eval/published-accuracy.d.ts +230 -0
  31. package/dist/eval/published-accuracy.js +86 -0
  32. package/dist/eval/questions.d.ts +12 -0
  33. package/dist/eval/questions.js +14 -0
  34. package/dist/eval/response-schema.d.ts +652 -0
  35. package/dist/eval/response-schema.js +130 -0
  36. package/dist/eval/response.d.ts +12 -0
  37. package/dist/eval/response.js +30 -0
  38. package/dist/eval/rules/completeness.js +31 -0
  39. package/dist/eval/rules/cost.d.ts +1 -1
  40. package/dist/eval/rules/cost.js +44 -0
  41. package/dist/eval/rules/custom.d.ts +0 -12
  42. package/dist/eval/rules/custom.js +21 -0
  43. package/dist/eval/rules/relevance.js +16 -0
  44. package/dist/eval/rules/safety.js +178 -1
  45. package/dist/eval/stamp.d.ts +14 -0
  46. package/dist/eval/stamp.js +88 -0
  47. package/dist/eval/stats.d.ts +33 -0
  48. package/dist/eval/stats.js +109 -0
  49. package/dist/eval/verdict.d.ts +34 -0
  50. package/dist/eval/verdict.js +131 -0
  51. package/dist/index.js +5 -28
  52. package/dist/instructions.d.ts +17 -0
  53. package/dist/instructions.js +53 -0
  54. package/dist/judge-enablement.d.ts +34 -0
  55. package/dist/judge-enablement.js +78 -0
  56. package/dist/judge-enablement.json +10 -0
  57. package/dist/preferences.d.ts +1 -1
  58. package/dist/prompts.d.ts +3 -0
  59. package/dist/prompts.js +29 -0
  60. package/dist/resources/index.d.ts +5 -2
  61. package/dist/resources/index.js +65 -5
  62. package/dist/resources/uris.d.ts +12 -0
  63. package/dist/resources/uris.js +24 -0
  64. package/dist/retention.d.ts +20 -0
  65. package/dist/retention.js +44 -0
  66. package/dist/self-test.d.ts +1 -0
  67. package/dist/self-test.js +14 -0
  68. package/dist/server.d.ts +10 -1
  69. package/dist/server.js +34 -7
  70. package/dist/storage/index.js +1 -1
  71. package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
  72. package/dist/storage/migrations/007-eval-provenance.js +30 -0
  73. package/dist/storage/migrations/index.js +24 -4
  74. package/dist/storage/sqlite-adapter.d.ts +26 -1
  75. package/dist/storage/sqlite-adapter.js +135 -15
  76. package/dist/tools/delete-rule.d.ts +8 -0
  77. package/dist/tools/delete-rule.js +30 -38
  78. package/dist/tools/delete-trace.d.ts +5 -0
  79. package/dist/tools/delete-trace.js +24 -27
  80. package/dist/tools/deploy-rule.d.ts +13 -1
  81. package/dist/tools/deploy-rule.js +37 -34
  82. package/dist/tools/describe.d.ts +20 -0
  83. package/dist/tools/describe.js +36 -0
  84. package/dist/tools/errors.d.ts +36 -0
  85. package/dist/tools/errors.js +134 -0
  86. package/dist/tools/evaluate-output.d.ts +8 -1
  87. package/dist/tools/evaluate-output.js +37 -58
  88. package/dist/tools/evaluate-with-llm-judge.d.ts +31 -0
  89. package/dist/tools/evaluate-with-llm-judge.js +96 -69
  90. package/dist/tools/get-traces.d.ts +9 -0
  91. package/dist/tools/get-traces.js +30 -29
  92. package/dist/tools/index.d.ts +8 -0
  93. package/dist/tools/index.js +22 -1
  94. package/dist/tools/list-rules.d.ts +13 -0
  95. package/dist/tools/list-rules.js +43 -46
  96. package/dist/tools/log-trace.d.ts +4 -0
  97. package/dist/tools/log-trace.js +31 -29
  98. package/dist/tools/respond.d.ts +42 -0
  99. package/dist/tools/respond.js +90 -0
  100. package/dist/tools/strict-input.js +1 -1
  101. package/dist/tools/trace-link.d.ts +2 -0
  102. package/dist/tools/trace-link.js +13 -2
  103. package/dist/tools/verify-citations.d.ts +17 -2
  104. package/dist/tools/verify-citations.js +98 -93
  105. package/dist/types/config.d.ts +9 -0
  106. package/dist/types/eval.d.ts +258 -0
  107. package/dist/types/eval.js +2 -1
  108. package/dist/types/query.d.ts +2 -0
  109. package/package.json +1 -1
  110. package/server.json +72 -2
  111. package/dist/resources/dashboard-summary.d.ts +0 -3
  112. package/dist/resources/dashboard-summary.js +0 -16
  113. package/dist/resources/trace-detail.d.ts +0 -3
  114. package/dist/resources/trace-detail.js +0 -30
@@ -1,41 +1,31 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
3
  import { verifyCitations } from '../eval/citation-verify/verifier.js';
4
- import { findPricing } from '../eval/llm-judge/pricing.js';
5
4
  import { generateEvalId } from '../utils/ids.js';
5
+ import { JUDGE_KEY_VARS } from '../judge-enablement.js';
6
6
  import { strictInput } from './strict-input.js';
7
7
  import { assertTraceExists, insertLinkedEvalResult } from './trace-link.js';
8
+ import { inferProvider, resolveApiKey } from './evaluate-with-llm-judge.js';
9
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
10
+ import { irisError } from './errors.js';
11
+ import { evaluationLinks, guarded, respond } from './respond.js';
8
12
  const inputSchema = {
9
13
  output: z.string().min(1).describe('The agent output containing citations to verify'),
10
14
  model: z
11
15
  .string()
12
- .describe('Judge model for per-citation verification. Supported: anthropic = claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5-20251001; openai = gpt-4o | gpt-4o-mini | o1-mini.'),
16
+ .describe('Judge model for per-citation verification. Supported: anthropic = claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5 | claude-haiku-4-5-20251001; openai = gpt-4o | gpt-4o-mini | o1-mini.'),
13
17
  provider: z.enum(['anthropic', 'openai']).optional().describe('Auto-detected from model when omitted'),
14
18
  allow_fetch: z.boolean().optional().describe('Permit outbound HTTP to resolve URLs/DOIs. Defaults to IRIS_CITATION_ALLOW_FETCH=1; false otherwise. SSRF-guarded regardless.'),
15
19
  domain_allowlist: z
16
20
  .array(z.string())
17
21
  .optional()
18
22
  .describe('Restrict fetches to hostnames in this list (suffix match allowed). Merged with IRIS_CITATION_DOMAINS env.'),
19
- max_cost_usd_total: z.number().positive().optional().describe('Cap TOTAL judge cost across all citations in this call; default $1.00'),
20
- max_citations: z.number().int().positive().max(50).optional().describe('Max citations to verify (extras skipped); default 20'),
23
+ max_cost_usd_total: z.number().positive().optional().describe('Cap TOTAL judge cost across all citations in this call; default 1.00 USD — the pipeline stops when the next call would exceed it'),
24
+ max_citations: z.number().int().positive().max(50).optional().describe('Max citations to verify (extras skipped, not errored); default 20, at most 50'),
21
25
  per_source_timeout_ms: z.number().int().positive().optional().describe('Per-URL fetch timeout; default 10_000'),
22
26
  per_source_max_bytes: z.number().int().positive().optional().describe('Per-URL body cap; default 5MB'),
23
27
  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'),
24
28
  };
25
- function inferProvider(model) {
26
- const pricing = findPricing(model);
27
- if (!pricing) {
28
- throw new Error(`Unknown model "${model}". Provider cannot be inferred. Supported models: src/eval/llm-judge/pricing.ts.`);
29
- }
30
- return pricing.provider;
31
- }
32
- function resolveApiKey(provider) {
33
- const key = provider === 'anthropic' ? process.env.IRIS_ANTHROPIC_API_KEY : process.env.IRIS_OPENAI_API_KEY;
34
- if (!key) {
35
- throw new Error(`${provider === 'anthropic' ? 'Anthropic' : 'OpenAI'} judge requires IRIS_${provider === 'anthropic' ? 'ANTHROPIC' : 'OPENAI'}_API_KEY for verify_citations.`);
36
- }
37
- return key;
38
- }
39
29
  function resolveAllowFetch(paramValue) {
40
30
  if (paramValue !== undefined)
41
31
  return paramValue;
@@ -54,8 +44,8 @@ function resolveDomainAllowlist(paramValue) {
54
44
  * was nothing to judge (no citations, none resolved). It is NOT the honest
55
45
  * answer when citations resolved and the judge then failed on every one —
56
46
  * 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.)
47
+ * the caller reads "passed" and ships. That case is IRIS_JUDGE_FAILED
48
+ * naming the cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
59
49
  */
60
50
  export function assertJudgeRan(result) {
61
51
  if (result.totalResolved === 0 || result.totalJudged > 0)
@@ -65,39 +55,59 @@ export function assertJudgeRan(result) {
65
55
  return;
66
56
  const kinds = [...new Set(judgeFailures.map((c) => c.resolveError.kind))].join(', ');
67
57
  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}`);
58
+ throw irisError('IRIS_JUDGE_FAILED', `verify_citations could not judge any of the ${result.totalResolved} resolved citation(s): the judge failed on every one (${kinds}). ` +
59
+ `Nothing was verified and nothing was stored, so there is no verdict. First error: ${first}`, {
60
+ retryable: /timeout|rate_limit|server_error/.test(kinds),
61
+ recovery: [
62
+ 'Check the key and the model: a refused key or an unknown model fails every citation the same way.',
63
+ 'Retry when the kind is a timeout, a rate limit or a provider server error.',
64
+ 'Raise max_cost_usd_total when the kind is cost_cap_reached.',
65
+ ],
66
+ });
70
67
  }
68
+ export const verifyCitationsOutputSchema = z.looseObject({
69
+ id: z.string().describe('the evaluation id; read it back at iris://evaluations/{id}'),
70
+ trace_id: z.string().optional().describe('the linked trace, when one was named'),
71
+ overall_score: z.number().nullable().describe('supported / judged; null when nothing was judged'),
72
+ passed: z.boolean().describe('true when every judged citation was supported, or nothing was judged and nothing failed'),
73
+ total_citations_found: z.number().int().describe('citations extracted from the output'),
74
+ total_resolved: z.number().int().describe('citations whose source was fetched'),
75
+ total_judged: z.number().int().describe('citations the judge ruled on'),
76
+ total_supported: z.number().int().describe('citations the judge found supported'),
77
+ total_cost_usd: z.number().describe('the spend across every judge call'),
78
+ citations: z.array(z.looseObject({ resolve_status: z.string() })).describe('per citation: the citation (raw, kind, identifier, offsets), resolve_status ok | skipped | error, resolve_error, source (url, status, content_type, bytes_fetched, truncated), judge (supported, confidence, rationale, cost_usd, latency_ms, tokens)'),
79
+ });
71
80
  export function registerVerifyCitationsTool(server, storage) {
72
81
  server.registerTool('verify_citations', {
73
82
  title: 'Verify Citations',
74
- description: [
75
- 'Extract citations from agent output, fetch the cited sources, and use an LLM judge to check whether each source supports the claim in context. Returns per-citation verdicts + an overall support ratio.',
76
- '',
77
- 'Sibling tools evaluate_with_llm_judge runs general semantic scoring (accuracy, helpfulness, correctness, faithfulness); this tool is specifically for citation grounding (does the cited source actually support the claim). evaluate_output\'s no_hallucination_markers heuristic detects FABRICATED-looking citations cheaply (free, no fetch); this tool resolves and verifies them (paid, opt-in fetch, SSRF-guarded). log_trace / get_traces handle trace I/O. verify_citations is the GROUNDING-CHECK path narrowest in scope, deepest in rigor.',
78
- '',
79
- 'Behavior. Three-phase pipeline: (1) regex extraction of [N] numbered refs, (Author, Year) parentheticals, bare URLs, and DOIs (in-process, no network); (2) SSRF-guarded fetch of URL + DOI citations, with scheme allowlist, private/link-local/cloud-metadata IP blocking, optional domain allowlist (IRIS_CITATION_DOMAINS), 10s timeout, 5MB body cap, manual redirect chase (max 3, re-checked), in-process LRU cache; (3) per-citation LLM judge call asking "does this source support this claim?" with a 256-token verdict. Opt-in via allow_fetch=true or IRIS_CITATION_ALLOW_FETCH=1 — Iris refuses outbound HTTP by default. Cost-capped across the entire call by max_cost_usd_total (default $1.00) the pipeline stops when the cap would be exceeded. Rate-limited to 20 req/min on HTTP MCP. Writes one eval_result row tagged with per-citation provenance.',
80
- '',
81
- 'Output shape. Returns JSON: `{ "id": "<uuid>", "overall_score": 0..1|null, "passed": boolean, "total_citations_found": number, "total_resolved": number, "total_judged": number, "total_supported": number, "total_cost_usd": number, "citations": [{ "citation": { "raw", "kind", "identifier", "offset_start", "offset_end" }, "resolve_status": "ok"|"skipped"|"error", "resolve_error"?, "source"?: { "url", "status", "content_type", "bytes_fetched", "truncated" }, "judge"?: { "supported", "confidence", "rationale", "cost_usd", "latency_ms", "input_tokens", "output_tokens" } }] }`. `overall_score = supported / judged`; `null` when nothing was judged (no resolvable citations, or every judge call failed). Infrastructure failures (cost cap, judge timeout/error, malformed verdict) leave a citation resolved-but-unjudgedreported per-citation via resolve_error, never scored as unsupported.',
82
- '',
83
- 'Use when the output makes factual claims backed by [1]-style references, DOIs, or URLs and you want to separate "cited correctly" from "cited and wrong" from "cited but unresolvable". Particularly useful for research/legal/medical agents where fabricated citations are the dominant failure mode.',
84
- "",
85
- "Don't use when the agent output has no citations at all (overall_score will be null; the tool degrades gracefully but a heuristic rule is cheaper). Don't use without allow_fetch=true or IRIS_CITATION_ALLOW_FETCH=1 — the tool refuses outbound HTTP unless explicitly enabled. Don't use with an open allowlist + untrusted output on the public internet; you are effectively running a user-directed fetcher. For stricter safety set IRIS_CITATION_DOMAINS to a curated list.",
86
- '',
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.',
88
- '',
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).',
90
- ].join('\n'),
83
+ description: describeTool({
84
+ summary: 'Extract the citations in an output, fetch the sources (opt-in, SSRF-guarded) and ask an LLM judge on your key whether each source supports its claim.',
85
+ does: 'Three phases. Extraction, no network: [N] references, (Author, Year), bare URLs and DOIs. Fetch of URL and DOI citations only when allow_fetch is true or IRIS_CITATION_ALLOW_FETCH=1, through a scheme allowlist, private and cloud-metadata address blocking, an optional hostname allowlist (domain_allowlist, merged with IRIS_CITATION_DOMAINS), a per-source timeout and byte cap, and at most three re-checked redirects. ' +
86
+ 'Then one judge call per resolved citation on your own key, reading the first part of each source, capped in total by max_cost_usd_total. Up to max_citations are verified; extras are skipped, not errored. ' +
87
+ 'overall_score is supported / judged and null when nothing was judged. Per-citation failures (bad scheme, blocked address, timeout, too large, cost cap, fetch disabled) are reported on the citation, never scored as unsupported. One evaluation row is stored.',
88
+ whenNot: "When the output has no citations: the score is null, and evaluate_output's hallucination signals are the cheap check. " +
89
+ `Without a key (${JUDGE_KEY_VARS.anthropic} or ${JUDGE_KEY_VARS.openai}): the call returns IRIS_JUDGE_NOT_ENABLED with the enable steps. ` +
90
+ 'With fetch enabled and an open allowlist on untrusted output: you are running a user-directed fetcher set IRIS_CITATION_DOMAINS.',
91
+ returns: verifyCitationsOutputSchema,
92
+ errors: 'IRIS_JUDGE_NOT_ENABLED, IRIS_JUDGE_UNKNOWN_MODEL and IRIS_UNKNOWN_TRACE before any fetch or spend. IRIS_JUDGE_FAILED when citations resolved but the judge failed on every one an error, not a passing verdict; nothing is stored. ' +
93
+ ERROR_ENVELOPE_SENTENCE,
94
+ siblings: {
95
+ evaluate_with_llm_judge: 'general semantic scoring',
96
+ evaluate_output: 'the free deterministic path, including the hallucination signals',
97
+ log_trace: 'record the execution first',
98
+ },
99
+ }),
91
100
  inputSchema: strictInput(inputSchema),
101
+ outputSchema: verifyCitationsOutputSchema,
92
102
  annotations: {
93
103
  readOnlyHint: false, // Writes eval_result + spends money
94
104
  destructiveHint: false, // Creates data; doesn't overwrite/delete
95
105
  idempotentHint: false, // External fetches + provider non-determinism
96
106
  openWorldHint: true, // Outbound HTTP to citation URLs + LLM provider API
97
107
  },
98
- }, async (args) => {
108
+ }, guarded(async (args) => {
99
109
  const provider = args.provider ?? inferProvider(args.model);
100
- const apiKey = resolveApiKey(provider);
110
+ const apiKey = resolveApiKey(provider, 'verify_citations');
101
111
  const allowFetch = resolveAllowFetch(args.allow_fetch);
102
112
  const domainAllowlist = resolveDomainAllowlist(args.domain_allowlist);
103
113
  // Refused before any fetch or judge call spends anything (#376).
@@ -143,58 +153,53 @@ export function registerVerifyCitationsTool(server, storage) {
143
153
  rules_evaluated: 1,
144
154
  rules_skipped: 0,
145
155
  insufficient_data: result.overallScore === null,
156
+ eval_cost_usd: result.totalCostUsd,
146
157
  });
147
- return {
148
- content: [
149
- {
150
- type: 'text',
151
- text: JSON.stringify({
152
- id: evalId,
153
- overall_score: result.overallScore,
154
- passed: result.passed,
155
- total_citations_found: result.totalCitationsFound,
156
- total_resolved: result.totalResolved,
157
- total_judged: result.totalJudged,
158
- total_supported: result.totalSupported,
159
- total_cost_usd: result.totalCostUsd,
160
- citations: result.citations.map((c) => ({
161
- citation: {
162
- raw: c.citation.raw,
163
- kind: c.citation.kind,
164
- identifier: c.citation.identifier,
165
- offset_start: c.citation.offsetStart,
166
- offset_end: c.citation.offsetEnd,
167
- },
168
- resolve_status: c.resolveStatus,
169
- resolve_error: c.resolveError,
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,
183
- judge: c.judge
184
- ? {
185
- supported: c.judge.supported,
186
- confidence: c.judge.confidence,
187
- rationale: c.judge.rationale,
188
- cost_usd: c.judge.costUsd,
189
- latency_ms: c.judge.latencyMs,
190
- input_tokens: c.judge.inputTokens,
191
- output_tokens: c.judge.outputTokens,
192
- }
193
- : undefined,
194
- })),
195
- }),
158
+ return respond(verifyCitationsOutputSchema, {
159
+ id: evalId,
160
+ ...(args.trace_id ? { trace_id: args.trace_id } : {}),
161
+ overall_score: result.overallScore,
162
+ passed: result.passed,
163
+ total_citations_found: result.totalCitationsFound,
164
+ total_resolved: result.totalResolved,
165
+ total_judged: result.totalJudged,
166
+ total_supported: result.totalSupported,
167
+ total_cost_usd: result.totalCostUsd,
168
+ citations: result.citations.map((c) => ({
169
+ citation: {
170
+ raw: c.citation.raw,
171
+ kind: c.citation.kind,
172
+ identifier: c.citation.identifier,
173
+ offset_start: c.citation.offsetStart,
174
+ offset_end: c.citation.offsetEnd,
196
175
  },
197
- ],
198
- };
199
- });
176
+ resolve_status: c.resolveStatus,
177
+ resolve_error: c.resolveError,
178
+ // Mapped to the documented snake_case keys. The verifier's
179
+ // internal shape is camelCase (contentType, bytesFetched) and
180
+ // used to be passed through verbatim, so a client parsing
181
+ // `source.content_type` per the description read undefined.
182
+ source: c.source
183
+ ? {
184
+ url: c.source.url,
185
+ status: c.source.status,
186
+ content_type: c.source.contentType,
187
+ bytes_fetched: c.source.bytesFetched,
188
+ truncated: c.source.truncated,
189
+ }
190
+ : undefined,
191
+ judge: c.judge
192
+ ? {
193
+ supported: c.judge.supported,
194
+ confidence: c.judge.confidence,
195
+ rationale: c.judge.rationale,
196
+ cost_usd: c.judge.costUsd,
197
+ latency_ms: c.judge.latencyMs,
198
+ input_tokens: c.judge.inputTokens,
199
+ output_tokens: c.judge.outputTokens,
200
+ }
201
+ : undefined,
202
+ })),
203
+ }, evaluationLinks(evalId, args.trace_id));
204
+ }));
200
205
  }
@@ -2,6 +2,13 @@ export interface IrisConfig {
2
2
  storage: {
3
3
  type: 'sqlite';
4
4
  path: string;
5
+ /**
6
+ * `critical_spans` stores each evaluation's output text with the spans a
7
+ * critical detector flagged replaced by `[REDACTED:<pattern>]`, so a tool
8
+ * that detects leaks need not keep the leak it found. The evidence
9
+ * offsets still index the original text the caller saw. Default `none`.
10
+ */
11
+ redact?: 'none' | 'critical_spans';
5
12
  };
6
13
  server: {
7
14
  name: string;
@@ -53,6 +60,8 @@ export interface IrisConfig {
53
60
  };
54
61
  retention: {
55
62
  days: number;
63
+ /** How often the sweep re-runs after boot, in hours; 0 disables the timer (the boot sweep still runs). Default 24. */
64
+ sweepIntervalHours: number;
56
65
  };
57
66
  security: {
58
67
  apiKey?: string;
@@ -8,6 +8,23 @@ export type EvalType = 'completeness' | 'relevance' | 'safety' | 'cost' | 'custo
8
8
  * under a real bundle, never under 'all'.
9
9
  */
10
10
  export type EvalResultType = EvalType | 'all';
11
+ /**
12
+ * What KIND of claim a rule makes — the mandate's distinction between a
13
+ * measurement (a statistic against a threshold), a detection (a pattern is
14
+ * present, with a measured error rate), an inference (a signal standing in
15
+ * for an unobservable property), a judgment (a model's reasoning), a policy
16
+ * (the deployment's own constraint) and an external verification. Kind is
17
+ * the claim; `mechanism` is how the claim is measured. The composer decides
18
+ * by kind and never averages kinds together.
19
+ */
20
+ export type ClaimKind = 'measurement' | 'detection' | 'inference' | 'judgment' | 'policy' | 'verification';
21
+ export type Mechanism = 'formula' | 'pattern' | 'heuristic' | 'model' | 'external';
22
+ /** An input a rule reads. A rule skips — never passes — when a declared need is absent. */
23
+ export type Need = 'output' | 'input' | 'expected' | 'tool_calls' | 'tool_outputs' | 'tools_catalogue' | 'cost' | 'tokens' | 'citations';
24
+ /** The evaluation question a rule answers; the registry is src/eval/questions.ts. */
25
+ export type QuestionId = 'safe_output' | 'grounded' | 'complete' | 'relevant' | 'task_completed' | 'tool_use_correct' | 'within_budget';
26
+ /** What went wrong, in the reader's words, independent of which rule caught it; the registry is src/eval/failure-classes.ts. */
27
+ export type FailureClass = 'pii_leak' | 'credential_leak' | 'injection' | 'injection_compliance' | 'silent_tool_failure' | 'tool_loop' | 'stub' | 'fabrication' | 'ungrounded' | 'incomplete_ask' | 'off_task' | 'over_budget' | 'format' | 'invalid_tool_call';
11
28
  export interface EvalRule {
12
29
  name: string;
13
30
  description: string;
@@ -25,6 +42,23 @@ export interface EvalRule {
25
42
  * is the verdict, and a critical violation must never be averaged away.
26
43
  */
27
44
  critical?: boolean;
45
+ /**
46
+ * The rule's metadata — what kind of claim it makes, how it measures it,
47
+ * what it reads, which question it answers, which failure classes a
48
+ * failing result belongs to, and the version of its definition. Every
49
+ * built-in declares all six (tests/unit/eval/rule-metadata.test.ts);
50
+ * custom types declare kind, mechanism, needs and version and leave the
51
+ * question to their author. Optional on the interface so a rule built
52
+ * elsewhere still compiles; a result from a rule without them carries no
53
+ * `kind`, which reads as unknown — never as a measurement.
54
+ */
55
+ kind?: ClaimKind;
56
+ mechanism?: Mechanism;
57
+ needs?: readonly Need[];
58
+ question?: QuestionId;
59
+ classes?: readonly FailureClass[];
60
+ /** Bumped when the rule's meaning changes, so a stored result names the definition that produced it. */
61
+ version?: number;
28
62
  evaluate(context: EvalContext): EvalRuleResult;
29
63
  }
30
64
  export interface EvalContext {
@@ -64,8 +98,221 @@ export interface EvalContext {
64
98
  breaches: number;
65
99
  };
66
100
  }
101
+ /**
102
+ * What the composer DID with a result under this deployment's configuration
103
+ * — distinct from `kind`, which is what the rule claims. Today's composer
104
+ * (a weighted mean plus the critical veto) knows two roles: `veto` for an
105
+ * effectively critical rule and `term` for one that feeds the score. The
106
+ * compose-by-kind release adds `gate` (a configured policy that decides),
107
+ * `risk` (a detection or inference feeding the risk estimate) and
108
+ * `advisory` (reported, deciding nothing).
109
+ */
110
+ export type Role = 'gate' | 'veto' | 'risk' | 'advisory' | 'term';
111
+ /**
112
+ * Why a rule skipped. `not_applicable`: the evidence it needs was not
113
+ * supplied (never asked — coverage). `defeated`: asked and could not answer,
114
+ * because this output stalled its pattern past the sandbox budget.
115
+ * `config_invalid`: asked and could not answer, because its definition is
116
+ * broken. A gate that fails closed treats the last two as unknown; the first
117
+ * is a coverage fact, not a verdict.
118
+ */
119
+ export type SkipClass = 'not_applicable' | 'defeated' | 'config_invalid';
120
+ export interface Interval {
121
+ point: number;
122
+ lo: number;
123
+ hi: number;
124
+ }
125
+ /**
126
+ * What a rule saw — typed, locatable, never an excerpt. A detection reports
127
+ * the OFFSETS of what it matched (into the raw text, so a leak detector can
128
+ * redact the span it found without ever repeating it); a trajectory rule the
129
+ * index of the call it judged; a measurement its statistic with a unit and
130
+ * the threshold it was held to; a signal that yields no offset yet reports
131
+ * its name and count. The reader can locate every claim; the stored row
132
+ * can be redacted; nothing here restates the offending text.
133
+ */
134
+ export type Evidence = {
135
+ type: 'span';
136
+ source: 'output' | 'input' | `tool_outputs[${number}]`;
137
+ start: number;
138
+ end: number;
139
+ label: string;
140
+ } | {
141
+ type: 'pattern';
142
+ name: string;
143
+ count: number;
144
+ } | {
145
+ type: 'toolCall';
146
+ index: number;
147
+ toolName: string;
148
+ label: string;
149
+ } | {
150
+ type: 'citation';
151
+ url: string;
152
+ status: 'resolved' | 'dead' | 'unverifiable' | 'supported' | 'unsupported';
153
+ } | {
154
+ type: 'count';
155
+ stat: string;
156
+ unit: string;
157
+ value: number;
158
+ threshold?: number;
159
+ thresholdSource?: 'default' | 'config' | 'call' | 'rule';
160
+ };
161
+ /** A measurement's statistic — the number the rule computed, with its unit, before any score transform. */
162
+ export interface MeasuredValue {
163
+ stat: string;
164
+ unit: string;
165
+ value: number;
166
+ }
167
+ /** Evidence lists are capped so a pathological output cannot balloon a stored row. */
168
+ export declare const MAX_EVIDENCE_ITEMS = 25;
169
+ /**
170
+ * Which evaluation questions this evaluation judged, which it did not and
171
+ * why — coverage by question, not by rule count. `inputs` says what the
172
+ * call carried; a question is `judged` when at least one rule that answers
173
+ * it ran, `unjudged` when every such rule skipped (the reason names the
174
+ * missing input, or that the rule was defeated or broken), and
175
+ * `not_applicable` when no rule for it was in the selected bundles.
176
+ */
177
+ export interface Coverage {
178
+ inputs: Record<Need, boolean>;
179
+ questions: Array<{
180
+ id: QuestionId;
181
+ status: 'judged' | 'unjudged' | 'not_applicable';
182
+ why?: string;
183
+ }>;
184
+ /** Quarantined critical rules that did not run (surfaced by the rule-store release). */
185
+ dormant?: Array<{
186
+ ruleId: string;
187
+ name: string;
188
+ reason: string;
189
+ }>;
190
+ }
191
+ /**
192
+ * The verdict with its basis. `passed` is `state === 'pass'` and equals the
193
+ * top-level `passed`; `basis` says which layer decided — a configured policy,
194
+ * a detector's veto, nothing judged, or the score against the threshold.
195
+ * `risk` is null until the compose-by-kind release computes it.
196
+ */
197
+ export interface Verdict {
198
+ state: 'pass' | 'fail' | 'unknown';
199
+ passed: boolean;
200
+ basis: 'policy_gate' | 'detector_veto' | 'critical_unknown' | 'required_evidence_missing' | 'risk_over_loss' | 'score_below_threshold' | 'clean' | 'no_rules';
201
+ by: string[];
202
+ risk: {
203
+ pBad: number;
204
+ lo: number;
205
+ hi: number;
206
+ perClass: Partial<Record<FailureClass, number | null>>;
207
+ assumptions: string[];
208
+ } | null;
209
+ confidence?: 'decisive' | 'marginal';
210
+ }
211
+ /** What produced this verdict, so it can be replayed or compared: the release, the ruleset, the configuration, the thresholds, the proof corpus, the time. */
212
+ export interface Provenance {
213
+ irisVersion: string;
214
+ rulesetHash: string;
215
+ configHash: string;
216
+ thresholds: {
217
+ default: number;
218
+ perRule?: Record<string, unknown>;
219
+ };
220
+ corpusVersion: string;
221
+ judgedAt: string;
222
+ }
223
+ /**
224
+ * How wrong this result tends to be, and on what basis. `published_accuracy`
225
+ * carries the rule's measured numbers from the shipped proof (src/eval/
226
+ * published-accuracy.ts): for a fired detection or inference the positive
227
+ * predictive value at the stated prior, for one that did not fire the
228
+ * residual miss rate, each with a 95% credible interval. `definition` is a
229
+ * measurement's conformance to its formula (n cases, matched). `policy` is
230
+ * the deployment's own constraint — no error rate applies. `self_consistency`
231
+ * and `local_labels` arrive with the judge-through-the-engine and the
232
+ * own-traffic labels releases. `unmeasured` says why nothing can be stated.
233
+ */
234
+ export type Uncertainty = {
235
+ basis: 'published_accuracy';
236
+ fired: true;
237
+ ppv: Interval;
238
+ prior: {
239
+ pi: number;
240
+ source: 'default' | 'config' | 'estimated';
241
+ };
242
+ corpus: {
243
+ n: number;
244
+ tp: number;
245
+ fp: number;
246
+ fn: number;
247
+ tn: number;
248
+ version: string;
249
+ release: string;
250
+ labelling: 'same-model' | 'human-verified';
251
+ };
252
+ } | {
253
+ basis: 'published_accuracy';
254
+ fired: false;
255
+ missRate: Interval;
256
+ prior: {
257
+ pi: number;
258
+ source: 'default' | 'config' | 'estimated';
259
+ };
260
+ corpus: {
261
+ n: number;
262
+ tp: number;
263
+ fp: number;
264
+ fn: number;
265
+ tn: number;
266
+ version: string;
267
+ release: string;
268
+ labelling: 'same-model' | 'human-verified';
269
+ };
270
+ } | {
271
+ basis: 'definition';
272
+ conformance: {
273
+ n: number;
274
+ matched: number;
275
+ };
276
+ } | {
277
+ basis: 'self_consistency';
278
+ samples: number;
279
+ voteFraction: number;
280
+ scoreSd: number;
281
+ } | {
282
+ basis: 'local_labels';
283
+ precision: Interval;
284
+ n: number;
285
+ } | {
286
+ basis: 'policy';
287
+ } | {
288
+ basis: 'unmeasured';
289
+ why: string;
290
+ };
67
291
  export interface EvalRuleResult {
68
292
  ruleName: string;
293
+ /**
294
+ * What kind of claim this result makes, what the composer did with it,
295
+ * which question it answers and which failure classes a failure belongs
296
+ * to — stamped by the engine from the rule's declaration (0.9.0). Absent
297
+ * on results written before that release and on rules that declare no
298
+ * metadata; never fabricated on read.
299
+ */
300
+ kind?: ClaimKind;
301
+ role?: Role;
302
+ question?: QuestionId;
303
+ classes?: FailureClass[];
304
+ /** The version of the rule definition that produced this result. */
305
+ ruleVersion?: number;
306
+ /** Which of the rule's declared needs the call actually carried — what the rule SAW. */
307
+ saw?: Need[];
308
+ /** Present only when `skipped`; says whether the rule was never asked or was asked and could not answer. */
309
+ skipClass?: SkipClass;
310
+ /** How wrong this result tends to be, and on what basis. Present on every result that made a claim (not on skips). */
311
+ uncertainty?: Uncertainty;
312
+ /** What the rule saw: spans (offsets, never text), tool-call indices, pattern names, counts. Present on every fired detection or inference, and on measurements. */
313
+ evidence?: Evidence[];
314
+ /** A measurement's statistic and unit — the number before the score transform. */
315
+ value?: MeasuredValue;
69
316
  /**
70
317
  * Deployed rule id (rule-<hex>) when the rule came from the custom-rule
71
318
  * store. Absent for built-in rules and for inline custom_rules. Names are
@@ -169,6 +416,17 @@ export interface EvalResult {
169
416
  * rule_results carry a `category` per rule so a reader can regroup.
170
417
  */
171
418
  categories?: Partial<Record<EvalType, EvalCategoryResult>>;
419
+ /** The verdict with its basis (0.9.0) — computed by the engine, derived on read for stored rows that carry provenance. */
420
+ verdict?: Verdict;
421
+ /** Coverage by evaluation question (0.9.0) — computed by the engine, derived on read from the stamped rule results. */
422
+ coverage?: Coverage;
423
+ /** What produced this verdict (0.9.0) — persisted; absent on rows written before it, never fabricated. */
424
+ provenance?: Provenance;
425
+ /** What the evaluation itself cost (the judge's spend); undefined for the free rules. */
426
+ eval_cost_usd?: number;
427
+ eval_tokens?: number;
428
+ /** Set when the linked trace was deleted (delete_trace or the retention sweep) and this row's text was erased. */
429
+ erased_at?: string;
172
430
  }
173
431
  export type CustomRuleType = 'regex_match' | 'regex_no_match' | 'min_length' | 'max_length' | 'contains_keywords' | 'excludes_keywords' | 'json_schema' | 'cost_threshold';
174
432
  export interface CustomRuleDefinition {
@@ -1 +1,2 @@
1
- export {};
1
+ /** Evidence lists are capped so a pathological output cannot balloon a stored row. */
2
+ export const MAX_EVIDENCE_ITEMS = 25;
@@ -82,6 +82,8 @@ export interface IStorageAdapter {
82
82
  getSpansByTraceId(tenantId: TenantId, traceId: string): Promise<Span[]>;
83
83
  insertEvalResult(tenantId: TenantId, result: EvalResult): Promise<void>;
84
84
  getEvalsByTraceId(tenantId: TenantId, traceId: string): Promise<EvalResult[]>;
85
+ /** One stored evaluation by id, in the same derived-on-read shape as every other reader; null when absent. */
86
+ getEvalById(tenantId: TenantId, id: string): Promise<EvalResult | null>;
85
87
  queryEvalResults(tenantId: TenantId, options: {
86
88
  eval_type?: string;
87
89
  passed?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iris-eval/mcp-server",
3
- "version": "0.8.1",
3
+ "version": "0.9.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",