@iris-eval/mcp-server 0.8.2 → 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 +8 -1
  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-CyzO6OC7.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 +29 -28
  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 +2 -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,10 +1,16 @@
1
1
  import { z } from 'zod';
2
+ import { toEvaluationResponse } from '../eval/response.js';
3
+ import { evaluateOutputResponseSchema } from '../eval/response-schema.js';
2
4
  import { DEFAULT_EVAL_TYPE, DEFAULT_EVAL_TYPE_NOTE } from '../eval/engine.js';
3
5
  import { INJECTION_SCOPE_SENTENCE } from '../eval/rules/safety.js';
4
6
  import { LOCAL_TENANT } from '../types/tenant.js';
5
7
  import { strictInput, strictNested } from './strict-input.js';
6
8
  import { toolCallSchema } from './log-trace.js';
7
9
  import { getTraceOrThrow, insertLinkedEvalResult } from './trace-link.js';
10
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
11
+ import { evaluationLinks, guarded, respond } from './respond.js';
12
+ /** The most inline custom rules one call may carry (see the argument description). */
13
+ export const MAX_INLINE_CUSTOM_RULES = 10;
8
14
  /*
9
15
  * Strict one level down (#376): `{ name, type, config, wieght: 5 }` used to
10
16
  * parse with `wieght` silently discarded, so the rule ran at weight 1 and
@@ -31,13 +37,13 @@ const inputSchema = {
31
37
  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 "all" when omitted — every bundle runs, safety included, and the response carries a note saying the default ran'),
32
38
  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`)'),
33
39
  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'),
34
- 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'),
40
+ trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through and lets the tool reuse the trace\'s stored tool_calls. Must be the id of a stored trace (from log_trace / get_traces); an unknown id is rejected before anything is evaluated'),
35
41
  // .max(10): inline rules skip the deploy-time probe, and the engine runs
36
42
  // rules synchronously — without a cap, one request carrying N sandbox-
37
43
  // defeating regex rules stalls the server linearly in N (measured 9.3s at
38
44
  // N=50). Ten is ample for per-call rules; persistent sets belong in
39
45
  // deploy_rule, where deploy-time validation probes each pattern.
40
- 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'),
46
+ custom_rules: z.array(CustomRuleSchema).max(MAX_INLINE_CUSTOM_RULES).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'),
41
47
  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)'),
42
48
  token_usage: z.object({
43
49
  prompt_tokens: z.number().optional(),
@@ -49,38 +55,38 @@ const inputSchema = {
49
55
  // is how that field goes missing on one path and not the other.
50
56
  tool_calls: z.array(toolCallSchema).optional().describe('What the agent DID — the tool calls it made, in order, each { tool_name, input?, output?, latency_ms?, error? } exactly as log_trace records them. Read by the trajectory rules — the rules that judge what the agent DID rather than what it wrote. Omit it and those rules SKIP rather than pass — an evaluation with no trajectory data reports "not judged", never "clean". When trace_id names a stored trace and this argument is omitted, the tool_calls stored on that trace are loaded and used, so a caller who already logged them need not resend them'),
51
57
  };
52
- export function registerEvaluateOutputTool(server, storage, evalEngine) {
58
+ export function registerEvaluateOutputTool(server, storage, evalEngine, options) {
53
59
  server.registerTool('evaluate_output', {
54
60
  title: 'Evaluate Output',
55
- description: [
56
- 'Score agent output against configurable eval rules and return a 0..1 score + per-rule breakdown.',
57
- '',
58
- 'Sibling tools evaluate_with_llm_judge runs semantic LLM-based scoring (slower, costs money; this tool is heuristic, free, deterministic), verify_citations checks citation grounding specifically, log_trace records executions, get_traces queries them, list_rules / deploy_rule / delete_rule manage the custom-rule lifecycle. evaluate_output is the FAST PATH for length / keyword / PII / injection / cost-threshold checks where rules are sufficient.',
59
- '',
60
- '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 — semantic scoring is a separate tool (evaluate_with_llm_judge) that needs an API key you supply. Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Runs in-process; no provider is called.',
61
- '',
62
- '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?", "critical", "criticalSource", "passed", "score", "message", "skipped?", "skipReason?", "budgetExceeded?", "configInvalid?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean, "categories?": { "<bundle>": { "score": number|null, "passed": boolean|null, "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). Inside `categories`, a bundle that evaluated no rule (every rule skipped for missing context — cost without `cost_usd`, relevance without `input`) reports `passed: null` and `score: null` with `insufficient_data: true`: it was not judged, so it is neither passing nor failing, and it does not count toward the overall verdict. The top-level `passed` stays a boolean and is false when NOTHING was evaluated — a gate keyed on it fails closed; read `insufficient_data` to tell "failed" from "not judged". `note` appears only when eval_type was omitted, saying that the default ran every bundle.',
63
- '',
64
- '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. Which built-in rules are critical is CONFIGURABLE per deployment (`eval.criticalRules` / `eval.nonCriticalRules`), so do not infer it from this list: every rule result carries `critical` (the effective value) and `criticalSource` (`default` when the declaration on the rule itself decided it, `config` when this server promoted or demoted it), and `list_rules` reports the same for the whole built-in roster. 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.',
65
- '',
66
- '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).',
67
- '',
68
- 'Injection scope. ' + INJECTION_SCOPE_SENTENCE + ' Screening what reaches the agent is a different control, outside this tool.',
69
- '',
70
- '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).',
71
- '',
72
- '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. tool_calls is what the agent DID (the trajectory) and is read only by the trajectory rules; omit it and those rules SKIP rather than pass, so an evaluation with no trajectory data reports "not judged" instead of "clean", and when trace_id names a stored trace the tool_calls stored on it are used unless this argument overrides them. 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="all" — every bundle runs, safety included, and when you rely on that default the response carries a `note` saying so; pass a single bundle name to narrow the run.',
73
- '',
74
- '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`) or inside a tool_calls entry (e.g. `err` for `error`) — 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).',
75
- ].join('\n'),
61
+ description: describeTool({
62
+ summary: 'Score an agent output against the deterministic rule bundles: the ship verdict with its basis, every rule result with evidence and uncertainty, and what was not judged.',
63
+ does: 'In-process, no network, no key. eval_type picks one bundle (completeness, relevance, safety, cost, custom) or all (the default): every bundle plus deployed and inline custom rules, with a per-bundle breakdown in categories. ' +
64
+ 'Inputs decide what can be judged: input is REQUIRED when eval_type="relevance" (keyword_overlap and topic_consistency compare the output against it and skip without it) and grounds the hallucination signals; ' +
65
+ 'tool_calls, or a trace_id whose stored tool_calls are reused, feed the trajectory rules; cost_usd and token_usage feed the cost rules; expected feeds only expected_coverage. ' +
66
+ 'A rule without its input SKIPS, is named, and never counts as a pass. custom_rules always fire. One row is stored, linked to trace_id when given.',
67
+ whenNot: 'To validate arbitrary JSON Schema (the json_schema custom type asserts an output\'s shape only). ' +
68
+ `To screen inputs before they reach an agent: ${INJECTION_SCOPE_SENTENCE} ` +
69
+ 'For semantic judgment, evaluate_with_llm_judge and verify_citations need a key you supply.',
70
+ returns: evaluateOutputResponseSchema,
71
+ errors: 'IRIS_UNKNOWN_TRACE when trace_id names no stored trace — checked first, nothing scored or written. IRIS_STORAGE_ERROR when the row cannot be written. ' +
72
+ 'Unknown arguments or keys are refused before the handler runs, naming the valid ones; a regex rule over its budget or with a broken config reports skipped, not an error. ' +
73
+ ERROR_ENVELOPE_SENTENCE,
74
+ siblings: {
75
+ log_trace: 'record the execution first',
76
+ evaluate_with_llm_judge: 'semantic scoring on your key',
77
+ verify_citations: 'citation grounding on your key',
78
+ list_rules: 'the roster, needs and published accuracy',
79
+ },
80
+ }),
76
81
  inputSchema: strictInput(inputSchema),
82
+ outputSchema: evaluateOutputResponseSchema,
77
83
  annotations: {
78
84
  readOnlyHint: false, // Writes an eval_result row
79
85
  destructiveHint: false, // Creates new data; doesn't overwrite or delete
80
86
  idempotentHint: true, // Deterministic: same inputs → same score (each call writes a distinct result row, but the SCORE is stable)
81
87
  openWorldHint: false, // No external network in heuristic mode; LLM-as-judge has its own tool with openWorldHint:true
82
88
  },
83
- }, async (args) => {
89
+ }, guarded(async (args) => {
84
90
  // Refuse an unknown trace_id up front (#376): the old path ran the
85
91
  // evaluation and then surfaced SQLite's "FOREIGN KEY constraint
86
92
  // failed", which names neither the field nor the fix.
@@ -118,37 +124,10 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
118
124
  // OSS single-tenant: MCP tool callers are the local user. Cloud
119
125
  // will derive tenant from the authenticated MCP session.
120
126
  await insertLinkedEvalResult(storage, LOCAL_TENANT, result);
121
- return {
122
- content: [
123
- {
124
- type: 'text',
125
- text: JSON.stringify({
126
- id: result.id,
127
- // Echo which bundle actually ran. Without this, a caller who
128
- // omitted eval_type could not tell a "safety pass" from a
129
- // completeness eval that never ran a single safety rule.
130
- eval_type: result.eval_type,
131
- score: result.score,
132
- passed: result.passed,
133
- ...(result.critical_failures ? { critical_failures: result.critical_failures } : {}),
134
- // The other half of the veto contract. The engine names every
135
- // critical rule that SKIPPED (budget-killed regex, missing cost
136
- // data) so a fail-closed gate can treat the eval as unknown;
137
- // this response used to drop the field, so the gate the
138
- // description tells users to write keyed on something that
139
- // never arrived and read passed:true as clean.
140
- ...(result.critical_skipped ? { critical_skipped: result.critical_skipped } : {}),
141
- rule_results: result.rule_results,
142
- suggestions: result.suggestions,
143
- rules_evaluated: result.rules_evaluated,
144
- rules_skipped: result.rules_skipped,
145
- insufficient_data: result.insufficient_data,
146
- // Per-bundle breakdown — eval_type="all" only.
147
- ...(result.categories ? { categories: result.categories } : {}),
148
- ...(evalTypeOmitted ? { note: DEFAULT_EVAL_TYPE_NOTE } : {}),
149
- }),
150
- },
151
- ],
152
- };
153
- });
127
+ // One serializer for every evaluation surface (src/eval/response.ts):
128
+ // the tool, the HTTP ingest route, the resources and the drift-lock
129
+ // all read the same object, so a field added there reaches every
130
+ // reader at once.
131
+ return respond(evaluateOutputResponseSchema, toEvaluationResponse(result, { traceId: args.trace_id, dormant: options?.dormant?.(), ...(evalTypeOmitted ? { note: DEFAULT_EVAL_TYPE_NOTE } : {}) }), evaluationLinks(result.id, args.trace_id));
132
+ }));
154
133
  }
@@ -1,3 +1,34 @@
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
+ import type { LLMProvider } from '../eval/llm-judge/client.js';
5
+ /** The models the pricing table knows, so an unknown one can be refused with the valid list. */
6
+ export declare function supportedModels(): string[];
7
+ export declare function inferProvider(model: string): LLMProvider;
8
+ /**
9
+ * The key for the provider, from this process's environment. Missing is
10
+ * IRIS_JUDGE_NOT_ENABLED with the enable steps as recovery — the fact
11
+ * users get wrong is that a shell export does not reach the process an
12
+ * MCP client spawns, and the steps say so.
13
+ */
14
+ export declare function resolveApiKey(provider: LLMProvider, toolName?: string): string;
15
+ export declare const judgeOutputSchema: z.ZodObject<{
16
+ id: z.ZodString;
17
+ trace_id: z.ZodOptional<z.ZodString>;
18
+ score: z.ZodNumber;
19
+ passed: z.ZodBoolean;
20
+ rationale: z.ZodString;
21
+ dimensions: z.ZodRecord<z.ZodString, z.ZodUnknown>;
22
+ model: z.ZodString;
23
+ provider: z.ZodEnum<{
24
+ anthropic: "anthropic";
25
+ openai: "openai";
26
+ }>;
27
+ template: z.ZodString;
28
+ input_tokens: z.ZodNumber;
29
+ output_tokens: z.ZodNumber;
30
+ cost_usd: z.ZodNullable<z.ZodNumber>;
31
+ latency_ms: z.ZodNumber;
32
+ raw_response_id: z.ZodOptional<z.ZodString>;
33
+ }, z.core.$loose>;
3
34
  export declare function registerEvaluateWithLLMJudgeTool(server: McpServer, storage: IStorageAdapter): void;
@@ -1,10 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
3
  import { evaluateWithLLMJudge } from '../eval/llm-judge/evaluator.js';
4
- import { findPricing } from '../eval/llm-judge/pricing.js';
4
+ import { findPricing, MODEL_PRICING } from '../eval/llm-judge/pricing.js';
5
5
  import { generateEvalId } from '../utils/ids.js';
6
+ import { JUDGE_COST_CAP_VAR, JUDGE_DEFAULT_COST_CAP_USD, JUDGE_KEY_VARS, judgeCostCapUsd, judgeRecovery } from '../judge-enablement.js';
6
7
  import { strictInput } from './strict-input.js';
7
8
  import { assertTraceExists, insertLinkedEvalResult } from './trace-link.js';
9
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
10
+ import { irisError } from './errors.js';
11
+ import { evaluationLinks, guarded, respond } from './respond.js';
12
+ import { CAPABILITIES_RESOURCE_URI } from '../resources/uris.js';
8
13
  const inputSchema = {
9
14
  output: z.string().min(1).describe('The agent output text to evaluate'),
10
15
  template: z
@@ -12,77 +17,101 @@ const inputSchema = {
12
17
  .describe('Judge dimension: accuracy (factual correctness), helpfulness (does it address the ask), safety (harm potential), correctness (vs reference answer — requires `expected`), faithfulness (RAG grounding — requires `source_material`).'),
13
18
  model: z
14
19
  .string()
15
- .describe('Model ID. 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.'),
20
+ .describe('Model ID. 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. Required — cost varies a hundredfold across models'),
16
21
  provider: z.enum(['anthropic', 'openai']).optional().describe('Auto-detected from model when omitted'),
17
22
  input: z.string().optional().describe('User question / prompt that produced the output (improves accuracy for helpfulness/safety)'),
18
23
  expected: z.string().optional().describe('Reference answer (required for correctness template)'),
19
24
  source_material: z.string().optional().describe('Provided RAG sources (required for faithfulness template)'),
20
25
  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'),
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'),
26
+ max_cost_usd: z.number().positive().optional().describe(`Cost cap in USD for this call; defaults to ${JUDGE_COST_CAP_VAR} or ${JUDGE_DEFAULT_COST_CAP_USD}. The worst case (two attempts, full max_output_tokens) is computed before the call and refused if it exceeds the cap`),
22
27
  max_output_tokens: z.number().int().positive().max(4096).optional().describe('Judge output token cap; default 512'),
23
28
  temperature: z.number().min(0).max(2).optional().describe('Sampling temperature; default 0 (deterministic)'),
24
29
  timeout_ms: z.number().int().positive().optional().describe('Per-request timeout; default 60_000'),
25
30
  };
26
- function inferProvider(model) {
31
+ /** The models the pricing table knows, so an unknown one can be refused with the valid list. */
32
+ export function supportedModels() {
33
+ return MODEL_PRICING.map((m) => m.model);
34
+ }
35
+ export function inferProvider(model) {
27
36
  const pricing = findPricing(model);
28
37
  if (!pricing) {
29
- throw new Error(`Unknown model "${model}". Provider cannot be inferred. Supported models are listed in src/eval/llm-judge/pricing.ts.`);
38
+ throw irisError('IRIS_JUDGE_UNKNOWN_MODEL', `Unknown model "${model}": its provider and price are not known, so the cost cap cannot be enforced.`, {
39
+ field: 'model',
40
+ valid: supportedModels(),
41
+ recovery: ['Pass one of the supported models (see valid).', 'Pass provider explicitly only for a model in the list whose id is ambiguous.'],
42
+ });
30
43
  }
31
44
  return pricing.provider;
32
45
  }
33
- function resolveApiKey(provider) {
34
- if (provider === 'anthropic') {
35
- const key = process.env.IRIS_ANTHROPIC_API_KEY;
36
- if (!key) {
37
- throw new Error('Anthropic judge requires IRIS_ANTHROPIC_API_KEY. Set it in the environment or use a different provider.');
38
- }
39
- return key;
40
- }
41
- const key = process.env.IRIS_OPENAI_API_KEY;
46
+ /**
47
+ * The key for the provider, from this process's environment. Missing is
48
+ * IRIS_JUDGE_NOT_ENABLED with the enable steps as recovery — the fact
49
+ * users get wrong is that a shell export does not reach the process an
50
+ * MCP client spawns, and the steps say so.
51
+ */
52
+ export function resolveApiKey(provider, toolName = 'evaluate_with_llm_judge') {
53
+ const variable = JUDGE_KEY_VARS[provider];
54
+ const key = process.env[variable];
42
55
  if (!key) {
43
- throw new Error('OpenAI judge requires IRIS_OPENAI_API_KEY. Set it in the environment or use a different provider.');
56
+ throw irisError('IRIS_JUDGE_NOT_ENABLED', `${toolName} needs ${variable} in the environment of the process that runs Iris; no key for ${provider} reached this process. Nothing was spent.`, {
57
+ field: variable,
58
+ recovery: judgeRecovery(provider),
59
+ see: CAPABILITIES_RESOURCE_URI,
60
+ });
44
61
  }
45
62
  return key;
46
63
  }
47
64
  function resolveMaxCost(paramValue) {
48
65
  if (paramValue !== undefined)
49
66
  return paramValue;
50
- const envRaw = process.env.IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL;
51
- if (envRaw) {
52
- const parsed = Number(envRaw);
53
- if (Number.isFinite(parsed) && parsed > 0)
54
- return parsed;
55
- }
56
- return 0.25;
67
+ return judgeCostCapUsd();
57
68
  }
69
+ export const judgeOutputSchema = z.looseObject({
70
+ id: z.string().describe('the evaluation id; read it back at iris://evaluations/{id}'),
71
+ trace_id: z.string().optional().describe('the linked trace, when one was named'),
72
+ score: z.number().describe('0..1 from the judge'),
73
+ passed: z.boolean().describe('the judge\'s own pass verdict for the template'),
74
+ rationale: z.string().describe('the judge\'s reasoning, in its words'),
75
+ dimensions: z.record(z.string(), z.unknown()).describe('per-dimension sub-scores for the template'),
76
+ model: z.string().describe('the model that judged'),
77
+ provider: z.enum(['anthropic', 'openai']).describe('the provider called'),
78
+ template: z.string().describe('the template used'),
79
+ input_tokens: z.number().describe('tokens sent, across both attempts when a retry ran'),
80
+ output_tokens: z.number().describe('tokens received, across both attempts when a retry ran'),
81
+ cost_usd: z.number().nullable().describe('the exact spend from the pricing table'),
82
+ latency_ms: z.number().describe('wall time of the provider call(s)'),
83
+ raw_response_id: z.string().optional().describe('the provider\'s response id, for your own audit'),
84
+ });
58
85
  export function registerEvaluateWithLLMJudgeTool(server, storage) {
59
86
  server.registerTool('evaluate_with_llm_judge', {
60
87
  title: 'Evaluate With LLM Judge',
61
- description: [
62
- 'Score agent output using an LLM as the judge (Anthropic or OpenAI). Returns a 0..1 score with rationale, per-dimension breakdown, and exact cost. The judge\'s own accuracy is measurable on a key you supply and is not yet published — see https://iris-eval.com/proof.',
63
- '',
64
- 'Sibling tools evaluate_output runs heuristic rules (free, deterministic, no API key needed); this tool runs LLM-based semantic scoring (paid, requires an API key). verify_citations is a SPECIALIZED form of LLM judging that focuses on citation grounding only. log_trace / get_traces handle trace I/O; list_rules / deploy_rule / delete_rule manage heuristic-rule lifecycle. evaluate_with_llm_judge is the GENERAL semantic-scoring path.',
65
- '',
66
- 'Behavior. Calls an external LLM API (Anthropic or OpenAI) — costs money per call, takes as long as the provider takes, respects an IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL cap. Non-deterministic at temperature > 0; default temperature=0 gives near-deterministic scores. Writes one eval_result row to Iris storage (linked to trace_id if provided) plus captures provider response id + latency + token counts + cost in the rule_results payload. Rate-limited to 20 req/min on HTTP MCP; your LLM provider also enforces its own rate limits (we transparently retry once on 429).',
67
- '',
68
- 'Output shape. Returns JSON: `{ "id": "<uuid>", "score": 0..1, "passed": boolean, "rationale": string, "dimensions": {...}, "model": string, "provider": "anthropic"|"openai", "template": string, "input_tokens": number, "output_tokens": number, "cost_usd": number, "latency_ms": number }`. `dimensions` has per-dimension sub-scores (e.g., accuracy template returns `{factual_claims, citations, internal_consistency}`).',
69
- '',
70
- 'Use when heuristic rules (via evaluate_output) are too coarse for the quality signal you need semantic correctness, factual accuracy vs a reference, RAG faithfulness to sources, nuanced safety/helpfulness. Pick the template that matches: `accuracy` (hallucination detection), `helpfulness` (does it address the ask), `safety` (harm potential beyond regex PII), `correctness` (vs reference answer pass `expected`), `faithfulness` (RAG grounding — pass `source_material`).',
71
- '',
72
- "Don't use for simple regex/length/keyword checks (use evaluate_output with heuristic rules — they're free, deterministic and in-process). Don't use without an API key set (IRIS_ANTHROPIC_API_KEY or IRIS_OPENAI_API_KEY). Don't use on very large outputs (>8K tokens) without raising max_cost_usd — the pre-check will refuse the call.",
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.',
75
- '',
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.',
77
- ].join('\n'),
88
+ description: describeTool({
89
+ summary: 'Score an output with an LLM judge on your own provider key: a 0..1 score, a rationale, per-dimension sub-scores and the exact spend.',
90
+ does: `Calls Anthropic or OpenAI directly with the key in this process's environment (${JUDGE_KEY_VARS.anthropic} or ${JUDGE_KEY_VARS.openai}); Iris never proxies. ` +
91
+ 'template picks the question: accuracy, helpfulness, safety, correctness (needs expected) or faithfulness (needs source_material); input improves helpfulness and safety. model is required; provider is inferred from it. ' +
92
+ `The worst-case spend — both attempts, full max_output_tokens — is computed BEFORE the call and refused if it exceeds max_cost_usd (default ${JUDGE_COST_CAP_VAR} or ${JUDGE_DEFAULT_COST_CAP_USD}). ` +
93
+ 'temperature defaults to 0; a rate-limited call is retried once. One evaluation row is stored with the provider response id, tokens, cost and latency, linked to trace_id when given. ' +
94
+ "The judge's own accuracy is measurable on a key you supply and is not yet published (see iris://proof).",
95
+ whenNot: 'For length, keyword, PII, injection or cost checks: evaluate_output is free and deterministic. Without a key: the call returns IRIS_JUDGE_NOT_ENABLED with the enable steps do not search for them. On very large outputs without raising max_cost_usd: the pre-check refuses.',
96
+ returns: judgeOutputSchema,
97
+ errors: 'IRIS_JUDGE_NOT_ENABLED (no key for the provider reached this process; recovery carries the steps). IRIS_JUDGE_UNKNOWN_MODEL (valid lists the models). IRIS_UNKNOWN_TRACE, checked before any spend. ' +
98
+ 'IRIS_BUDGET_EXCEEDED (nothing spent; the message carries both numbers). IRIS_PROVIDER_ERROR with kind auth, rate_limit, bad_request, server_error, timeout or malformed_response, and retryable set. ' +
99
+ ERROR_ENVELOPE_SENTENCE,
100
+ siblings: {
101
+ evaluate_output: 'the free deterministic path',
102
+ verify_citations: 'citation grounding, the narrower judge',
103
+ log_trace: 'record the execution first',
104
+ },
105
+ }),
78
106
  inputSchema: strictInput(inputSchema),
107
+ outputSchema: judgeOutputSchema,
79
108
  annotations: {
80
109
  readOnlyHint: false, // Writes eval_result; also spends money (external API cost)
81
110
  destructiveHint: false, // Creates data; doesn't overwrite or delete
82
111
  idempotentHint: false, // Temperature > 0 may vary; even at T=0 provider non-determinism is possible; cost also varies per call
83
112
  openWorldHint: true, // Calls external APIs (Anthropic / OpenAI) — touches the world beyond local process
84
113
  },
85
- }, async (args) => {
114
+ }, guarded(async (args) => {
86
115
  const provider = args.provider ?? inferProvider(args.model);
87
116
  const apiKey = resolveApiKey(provider);
88
117
  const maxCostUsd = resolveMaxCost(args.max_cost_usd);
@@ -95,8 +124,8 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
95
124
  const result = await evaluateWithLLMJudge({
96
125
  output: args.output,
97
126
  template: args.template,
98
- provider,
99
127
  model: args.model,
128
+ provider,
100
129
  apiKey,
101
130
  input: args.input,
102
131
  expected: args.expected,
@@ -106,12 +135,12 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
106
135
  temperature: args.temperature,
107
136
  timeoutMs: args.timeout_ms,
108
137
  });
138
+ // Persist to eval_results so the dashboard can surface it.
139
+ // eval_type='custom' — LLM judge scores span all 4 heuristic
140
+ // categories (accuracy, helpfulness, safety, faithfulness); 'custom'
141
+ // is the honest bucket. rule_results[0] captures per-dimension
142
+ // breakdown + provider metadata for audit.
109
143
  const evalId = generateEvalId();
110
- // Persist as a normal eval_result so the dashboard picks it up
111
- // alongside heuristic scores. eval_type is 'custom' because LLM
112
- // judge doesn't fit completeness/relevance/safety/cost taxonomy
113
- // cleanly — it spans all four. The rule_results payload carries
114
- // the full judge provenance.
115
144
  await insertLinkedEvalResult(storage, LOCAL_TENANT, {
116
145
  id: evalId,
117
146
  trace_id: args.trace_id,
@@ -132,28 +161,26 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
132
161
  rules_evaluated: 1,
133
162
  rules_skipped: 0,
134
163
  insufficient_data: false,
164
+ // What the evaluation itself cost — the description promised it was
165
+ // kept and the write path stored none of it (arc zero, G15).
166
+ eval_cost_usd: result.costUsd ?? undefined,
167
+ eval_tokens: result.inputTokens + result.outputTokens,
135
168
  });
136
- return {
137
- content: [
138
- {
139
- type: 'text',
140
- text: JSON.stringify({
141
- id: evalId,
142
- score: result.score,
143
- passed: result.passed,
144
- rationale: result.rationale,
145
- dimensions: result.dimensions,
146
- model: result.model,
147
- provider: result.provider,
148
- template: result.template,
149
- input_tokens: result.inputTokens,
150
- output_tokens: result.outputTokens,
151
- cost_usd: result.costUsd,
152
- latency_ms: result.latencyMs,
153
- raw_response_id: result.rawResponseId,
154
- }),
155
- },
156
- ],
157
- };
158
- });
169
+ return respond(judgeOutputSchema, {
170
+ id: evalId,
171
+ ...(args.trace_id ? { trace_id: args.trace_id } : {}),
172
+ score: result.score,
173
+ passed: result.passed,
174
+ rationale: result.rationale,
175
+ dimensions: result.dimensions,
176
+ model: result.model,
177
+ provider: result.provider,
178
+ template: result.template,
179
+ input_tokens: result.inputTokens,
180
+ output_tokens: result.outputTokens,
181
+ cost_usd: result.costUsd,
182
+ latency_ms: result.latencyMs,
183
+ raw_response_id: result.rawResponseId,
184
+ }, evaluationLinks(evalId, args.trace_id));
185
+ }));
159
186
  }
@@ -27,4 +27,13 @@ export interface TraceRangeArgs {
27
27
  * bound the tool rejects is never one the HTTP query quietly accepts.
28
28
  */
29
29
  export declare function addTraceRangeIssues(args: TraceRangeArgs, ctx: z.RefinementCtx): void;
30
+ export declare const getTracesOutputSchema: z.ZodObject<{
31
+ traces: z.ZodArray<z.ZodObject<{
32
+ trace_id: z.ZodString;
33
+ }, z.core.$loose>>;
34
+ total: z.ZodNumber;
35
+ limit: z.ZodNumber;
36
+ offset: z.ZodNumber;
37
+ summary: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
38
+ }, z.core.$loose>;
30
39
  export declare function registerGetTracesTool(server: McpServer, storage: IStorageAdapter): void;
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
3
  import { strictInput } from './strict-input.js';
4
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
5
+ import { guarded, respond } from './respond.js';
4
6
  /*
5
7
  * An ISO-8601 instant (2026-08-01T00:00:00Z, offsets allowed) or calendar
6
8
  * date (2026-08-01). Stored timestamps are ISO strings and the adapter
@@ -61,7 +63,7 @@ const inputSchema = {
61
63
  // Mirrors traceQuerySchema in dashboard/validation.ts — both capture paths
62
64
  // (MCP tool, HTTP query) enforce the same 1..1000 bound. Unclamped, limit:-1
63
65
  // meant "LIMIT -1" in SQLite, i.e. every row (#332).
64
- limit: z.number().int().min(1).max(1000).default(50).describe('Results per page (default 50, max 1000 — values >1000 return 400)'),
66
+ limit: z.number().int().min(1).max(1000).default(50).describe('Results per page (default 50, max 1000 — values above are rejected)'),
65
67
  offset: z.number().int().min(0).default(0).describe('Zero-based pagination offset — skip first N results (non-negative integer)'),
66
68
  sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp').describe('Sort by timestamp | latency_ms | cost_usd (default timestamp)'),
67
69
  sort_order: z.enum(['asc', 'desc']).default('desc').describe('Sort order: asc | desc (default desc — most recent / highest first)'),
@@ -69,34 +71,40 @@ const inputSchema = {
69
71
  };
70
72
  // Cross-field range checks — see addTraceRangeIssues above.
71
73
  const inputSchemaWithRanges = strictInput(inputSchema).superRefine(addTraceRangeIssues);
74
+ export const getTracesOutputSchema = z.looseObject({
75
+ traces: z.array(z.looseObject({ trace_id: z.string() })).describe('the page of traces: trace_id, agent_name, framework, input, output, tool_calls, latency_ms, token_usage, cost_usd, metadata, timestamp'),
76
+ total: z.number().int().describe('how many traces match the filters, across every page'),
77
+ limit: z.number().int().describe('the page size applied'),
78
+ offset: z.number().int().describe('the offset applied'),
79
+ summary: z.looseObject({}).optional().describe('the dashboard aggregates for the last hour, when include_summary was true'),
80
+ });
72
81
  export function registerGetTracesTool(server, storage) {
73
82
  server.registerTool('get_traces', {
74
83
  title: 'Get Traces',
75
- description: [
76
- 'Query stored agent-execution traces with filters, pagination, and optional dashboard summary.',
77
- '',
78
- 'Sibling tools log_trace creates traces, delete_trace removes a single trace, evaluate_output / evaluate_with_llm_judge / verify_citations score them, list_rules / deploy_rule / delete_rule manage the custom-rule lifecycle. get_traces is the READ path for historical agent executions — never mutates anything.',
79
- '',
80
- 'Behavior. Read-only: never mutates storage, never calls external services. Idempotent: repeated calls with the same args return consistent results (new traces logged after the call obviously show up on subsequent calls). Tenant-scoped: queries only the caller\'s tenant rows (LOCAL_TENANT in OSS). Paginates results (default limit 50, max 1000). Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio.',
81
- '',
82
- 'Output shape. Returns JSON: `{ "traces": [{...traceRow}], "total": number, "limit": number, "offset": number, "summary"?: { total_traces, avg_latency_ms, total_cost_usd, error_rate, eval_pass_rate, traces_per_hour, top_agents } }`. Each trace row includes trace_id, agent_name, framework, input, output, tool_calls, latency_ms, token_usage, cost_usd, metadata, timestamp. `summary` only included when `include_summary: true`.',
83
- '',
84
- 'Use when you need historical data: investigating a past failure, computing quality trends, comparing agents, or feeding an analytics job. Set `agent_name` / `framework` / `since` / `until` to narrow the query. Set `min_score` / `max_score` to surface outliers. Set `sort_by: "cost_usd"` + `sort_order: "desc"` to find the most expensive traces. Set `include_summary: true` when you want dashboard-style aggregates in one round-trip.',
85
- '',
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, and Iris has no event-stream endpoint; poll with exponential backoff.',
87
- '',
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). Defaults: limit=50, offset=0, sort_by="timestamp", sort_order="desc", include_summary=false.',
89
- '',
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).',
91
- ].join('\n'),
84
+ description: describeTool({
85
+ summary: 'Query stored traces with filters, pagination and sorting; optionally include the dashboard summary in the same response.',
86
+ does: 'Read-only, local storage only. Filters are exact-match (agent_name, framework), inclusive time bounds (since, until — an ISO 8601 timestamp or date) and a score range applied to the LATEST evaluation of each trace (min_score, max_score, 0..1). ' +
87
+ 'limit is 1..1000 (default 50), offset counts from 0, sort_by is timestamp, latency_ms or cost_usd, sort_order asc or desc (default: newest first). include_summary adds the one-hour dashboard aggregates. ' +
88
+ 'A crossed range (min above max, since after until) is refused naming both values rather than returning an empty page that reads as "no such traces".',
89
+ whenNot: 'To score a trace (evaluate_output). To create one (log_trace). As a live stream: this is a query, and Iris has no event stream poll with backoff.',
90
+ returns: getTracesOutputSchema,
91
+ errors: 'IRIS_STORAGE_ERROR when the database cannot be read. An out-of-range or crossed bound is refused before the handler runs, naming the values. An empty result is total 0, not an error. ' +
92
+ ERROR_ENVELOPE_SENTENCE,
93
+ siblings: {
94
+ log_trace: 'record an execution',
95
+ evaluate_output: 'score one output',
96
+ delete_trace: 'remove one trace',
97
+ },
98
+ }),
92
99
  inputSchema: inputSchemaWithRanges,
100
+ outputSchema: getTracesOutputSchema,
93
101
  annotations: {
94
102
  readOnlyHint: true, // Pure query: never writes, never deletes
95
103
  destructiveHint: false, // Inverse of readOnly — trivially false
96
104
  idempotentHint: true, // Same args → same result (modulo new traces that may have landed since)
97
105
  openWorldHint: false, // Queries local storage only; no external network
98
106
  },
99
- }, async (args) => {
107
+ }, guarded(async (args) => {
100
108
  // OSS single-tenant: MCP caller is the local user.
101
109
  const result = await storage.queryTraces(LOCAL_TENANT, {
102
110
  filter: {
@@ -121,13 +129,6 @@ export function registerGetTracesTool(server, storage) {
121
129
  if (args.include_summary) {
122
130
  response.summary = await storage.getDashboardSummary(LOCAL_TENANT);
123
131
  }
124
- return {
125
- content: [
126
- {
127
- type: 'text',
128
- text: JSON.stringify(response),
129
- },
130
- ],
131
- };
132
- });
132
+ return respond(getTracesOutputSchema, response);
133
+ }));
133
134
  }
@@ -2,4 +2,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { IStorageAdapter } from '../types/query.js';
3
3
  import type { EvalEngine } from '../eval/engine.js';
4
4
  import type { CustomRuleStore } from '../custom-rule-store.js';
5
+ /**
6
+ * Every tool this server registers, by name. The capabilities object
7
+ * lists it, the docs contract checks prose against it, and a test asserts
8
+ * it equals what tools/list returns — so a tool added below without a
9
+ * name here (or the reverse) fails before it ships.
10
+ */
11
+ export declare const TOOL_NAMES: readonly ["log_trace", "evaluate_output", "get_traces", "list_rules", "deploy_rule", "delete_rule", "delete_trace", "evaluate_with_llm_judge", "verify_citations"];
12
+ export type ToolName = (typeof TOOL_NAMES)[number];
5
13
  export declare function registerAllTools(server: McpServer, storage: IStorageAdapter, evalEngine: EvalEngine, customRuleStore: CustomRuleStore): void;
@@ -7,9 +7,30 @@ import { registerDeleteRuleTool } from './delete-rule.js';
7
7
  import { registerDeleteTraceTool } from './delete-trace.js';
8
8
  import { registerEvaluateWithLLMJudgeTool } from './evaluate-with-llm-judge.js';
9
9
  import { registerVerifyCitationsTool } from './verify-citations.js';
10
+ import { dormantRulesFrom } from '../eval/dormant.js';
11
+ import { LOCAL_TENANT } from '../types/tenant.js';
12
+ /**
13
+ * Every tool this server registers, by name. The capabilities object
14
+ * lists it, the docs contract checks prose against it, and a test asserts
15
+ * it equals what tools/list returns — so a tool added below without a
16
+ * name here (or the reverse) fails before it ships.
17
+ */
18
+ export const TOOL_NAMES = [
19
+ 'log_trace',
20
+ 'evaluate_output',
21
+ 'get_traces',
22
+ 'list_rules',
23
+ 'deploy_rule',
24
+ 'delete_rule',
25
+ 'delete_trace',
26
+ 'evaluate_with_llm_judge',
27
+ 'verify_citations',
28
+ ];
10
29
  export function registerAllTools(server, storage, evalEngine, customRuleStore) {
11
30
  registerLogTraceTool(server, storage);
12
- registerEvaluateOutputTool(server, storage, evalEngine);
31
+ registerEvaluateOutputTool(server, storage, evalEngine, {
32
+ dormant: () => dormantRulesFrom(customRuleStore.quarantined(LOCAL_TENANT)),
33
+ });
13
34
  registerGetTracesTool(server, storage);
14
35
  registerListRulesTool(server, customRuleStore, evalEngine);
15
36
  registerDeployRuleTool(server, customRuleStore, evalEngine);