@iris-eval/mcp-server 0.8.2 → 0.10.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 (131) 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 +17 -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-CeJbaq6m.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 +11 -30
  16. package/dist/dashboard/seed-demo-data.js +1 -1
  17. package/dist/dashboard/server.d.ts +2 -0
  18. package/dist/dashboard/server.js +6 -2
  19. package/dist/eval/accuracy.d.ts +41 -0
  20. package/dist/eval/accuracy.js +97 -0
  21. package/dist/eval/citation-verify/verifier.d.ts +16 -1
  22. package/dist/eval/citation-verify/verifier.js +14 -4
  23. package/dist/eval/compose.d.ts +57 -0
  24. package/dist/eval/compose.js +179 -0
  25. package/dist/eval/criticality.d.ts +15 -1
  26. package/dist/eval/criticality.js +6 -0
  27. package/dist/eval/decision-moment.js +33 -4
  28. package/dist/eval/dormant.d.ts +4 -0
  29. package/dist/eval/dormant.js +22 -0
  30. package/dist/eval/engine.d.ts +6 -2
  31. package/dist/eval/engine.js +126 -12
  32. package/dist/eval/failure-classes.d.ts +8 -0
  33. package/dist/eval/failure-classes.js +18 -0
  34. package/dist/eval/llm-judge/evaluator.d.ts +30 -0
  35. package/dist/eval/llm-judge/evaluator.js +26 -2
  36. package/dist/eval/published-accuracy.d.ts +230 -0
  37. package/dist/eval/published-accuracy.js +86 -0
  38. package/dist/eval/questions.d.ts +12 -0
  39. package/dist/eval/questions.js +14 -0
  40. package/dist/eval/response-schema.d.ts +652 -0
  41. package/dist/eval/response-schema.js +130 -0
  42. package/dist/eval/response.d.ts +12 -0
  43. package/dist/eval/response.js +30 -0
  44. package/dist/eval/risk.d.ts +60 -0
  45. package/dist/eval/risk.js +187 -0
  46. package/dist/eval/rules/completeness.js +36 -1
  47. package/dist/eval/rules/cost.d.ts +2 -2
  48. package/dist/eval/rules/cost.js +50 -6
  49. package/dist/eval/rules/custom.d.ts +0 -12
  50. package/dist/eval/rules/custom.js +22 -0
  51. package/dist/eval/rules/relevance.js +23 -2
  52. package/dist/eval/rules/safety.d.ts +6 -2
  53. package/dist/eval/rules/safety.js +224 -51
  54. package/dist/eval/seeded-random.d.ts +4 -0
  55. package/dist/eval/seeded-random.js +36 -0
  56. package/dist/eval/stamp.d.ts +14 -0
  57. package/dist/eval/stamp.js +89 -0
  58. package/dist/eval/stats.d.ts +33 -0
  59. package/dist/eval/stats.js +109 -0
  60. package/dist/eval/text/checksums.d.ts +23 -0
  61. package/dist/eval/text/checksums.js +97 -0
  62. package/dist/eval/text/normalise.d.ts +30 -0
  63. package/dist/eval/text/normalise.js +265 -0
  64. package/dist/eval/text/sentences.d.ts +15 -0
  65. package/dist/eval/text/sentences.js +149 -0
  66. package/dist/eval/verdict.d.ts +34 -0
  67. package/dist/eval/verdict.js +131 -0
  68. package/dist/index.js +5 -28
  69. package/dist/instructions.d.ts +17 -0
  70. package/dist/instructions.js +53 -0
  71. package/dist/judge-enablement.d.ts +34 -0
  72. package/dist/judge-enablement.js +78 -0
  73. package/dist/judge-enablement.json +10 -0
  74. package/dist/preferences.d.ts +1 -1
  75. package/dist/prompts.d.ts +3 -0
  76. package/dist/prompts.js +29 -0
  77. package/dist/resources/index.d.ts +5 -2
  78. package/dist/resources/index.js +65 -5
  79. package/dist/resources/uris.d.ts +12 -0
  80. package/dist/resources/uris.js +24 -0
  81. package/dist/retention.d.ts +20 -0
  82. package/dist/retention.js +44 -0
  83. package/dist/self-test.d.ts +1 -0
  84. package/dist/self-test.js +17 -3
  85. package/dist/server.d.ts +10 -1
  86. package/dist/server.js +34 -7
  87. package/dist/storage/index.js +1 -1
  88. package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
  89. package/dist/storage/migrations/007-eval-provenance.js +30 -0
  90. package/dist/storage/migrations/index.js +24 -4
  91. package/dist/storage/sqlite-adapter.d.ts +26 -1
  92. package/dist/storage/sqlite-adapter.js +149 -15
  93. package/dist/tools/delete-rule.d.ts +8 -0
  94. package/dist/tools/delete-rule.js +30 -38
  95. package/dist/tools/delete-trace.d.ts +5 -0
  96. package/dist/tools/delete-trace.js +24 -27
  97. package/dist/tools/deploy-rule.d.ts +13 -1
  98. package/dist/tools/deploy-rule.js +37 -34
  99. package/dist/tools/describe.d.ts +20 -0
  100. package/dist/tools/describe.js +36 -0
  101. package/dist/tools/errors.d.ts +36 -0
  102. package/dist/tools/errors.js +134 -0
  103. package/dist/tools/evaluate-output.d.ts +8 -1
  104. package/dist/tools/evaluate-output.js +39 -60
  105. package/dist/tools/evaluate-with-llm-judge.d.ts +34 -0
  106. package/dist/tools/evaluate-with-llm-judge.js +124 -69
  107. package/dist/tools/get-traces.d.ts +9 -0
  108. package/dist/tools/get-traces.js +29 -28
  109. package/dist/tools/index.d.ts +8 -0
  110. package/dist/tools/index.js +22 -1
  111. package/dist/tools/list-rules.d.ts +13 -0
  112. package/dist/tools/list-rules.js +43 -46
  113. package/dist/tools/log-trace.d.ts +4 -0
  114. package/dist/tools/log-trace.js +31 -29
  115. package/dist/tools/respond.d.ts +42 -0
  116. package/dist/tools/respond.js +90 -0
  117. package/dist/tools/strict-input.js +1 -1
  118. package/dist/tools/trace-link.d.ts +2 -0
  119. package/dist/tools/trace-link.js +13 -2
  120. package/dist/tools/verify-citations.d.ts +18 -2
  121. package/dist/tools/verify-citations.js +122 -96
  122. package/dist/types/config.d.ts +44 -0
  123. package/dist/types/eval.d.ts +309 -0
  124. package/dist/types/eval.js +2 -1
  125. package/dist/types/query.d.ts +2 -0
  126. package/package.json +1 -1
  127. package/server.json +2 -2
  128. package/dist/resources/dashboard-summary.d.ts +0 -3
  129. package/dist/resources/dashboard-summary.js +0 -16
  130. package/dist/resources/trace-detail.d.ts +0 -3
  131. package/dist/resources/trace-detail.js +0 -30
@@ -14,11 +14,14 @@ import { z } from 'zod';
14
14
  import { createCustomRule } from '../eval/rules/custom.js';
15
15
  import { LOCAL_TENANT } from '../types/tenant.js';
16
16
  import { strictInput, strictNested } from './strict-input.js';
17
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
18
+ import { guarded, respond } from './respond.js';
17
19
  const EvalTypeSchema = z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']);
18
20
  /**
19
21
  * A rule with this name is already deployed and the caller did not ask to
20
22
  * replace it. Carries the existing rule(s) so an HTTP surface can answer
21
- * 409 with them beside the same message the MCP tool throws.
23
+ * 409 with them beside the same message the MCP tool returns as
24
+ * IRIS_DUPLICATE_RULE (src/tools/errors.ts maps it by name).
22
25
  */
23
26
  export class DuplicateRuleNameError extends Error {
24
27
  existing;
@@ -145,34 +148,41 @@ const inputSchemaWithAliases = strictInput(inputSchema).superRefine((args, ctx)
145
148
  ctx.addIssue({ code: 'custom', path: ['sourceMomentId'], message: 'pass either source_moment_id or sourceMomentId, not both' });
146
149
  }
147
150
  });
151
+ export const deployRuleOutputSchema = z.looseObject({
152
+ rule: z.looseObject({ id: z.string(), name: z.string() }).describe('the rule as persisted: id (rule-<hex>, keep it for delete_rule), name, description, evalType, severity, definition, enabled, createdAt, updatedAt, version, sourceMomentId'),
153
+ replaced: z.array(z.looseObject({ id: z.string() })).optional().describe('with replace: true, the earlier rule(s) of the same name that were retired'),
154
+ warning: z.string().optional().describe('with replace: true, one sentence naming what was retired'),
155
+ });
148
156
  export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
149
157
  server.registerTool('deploy_rule', {
150
158
  title: 'Deploy Custom Rule',
151
- description: [
152
- 'Deploy a new custom evaluation rule that will fire on every future evaluate_output call of its eval category.',
153
- '',
154
- 'Sibling tools list_rules enumerates deployed rules, delete_rule removes them (or disables/re-enables them with its `enabled` argument), evaluate_output runs them. log_trace / get_traces / delete_trace handle the trace lifecycle separately; evaluate_with_llm_judge / verify_citations run semantic scoring (not heuristic-rule-driven). deploy_rule is the WRITE path that grows the custom-rule library.',
155
- '',
156
- 'Behavior. Writes a row to ~/.iris/custom-rules.json (atomic write via temp file + rename) and appends a `rule.deploy` entry to the audit log (~/.iris/audit.log). The rule activates immediately for the running process and persists across restarts. Each call mints a fresh rule_id. Rule names are unique among deployed rules: deploying a name that is already deployed is REJECTED unless `replace: true`, in which case the existing same-named rule(s) are deleted (audit `rule.delete` rows written, unregistered from the live engine) and the new rule takes their place under a new id the response lists what was replaced. Rules are owned by the local tenant. Rate-limited to 20 req/min on HTTP MCP.',
157
- '',
158
- 'Output shape. Returns JSON: `{ "rule": { "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition", "enabled": true, "createdAt", "updatedAt", "version": 1, "sourceMomentId?" }, "replaced?": [{ "id", "evalType", "severity" }], "warning?": string }`. The returned rule is the canonical persisted form; save the `id` if you plan to disable or delete later. `replaced` and `warning` appear only when `replace: true` removed an earlier rule of the same name.',
159
- '',
160
- "Use when an agent observes a recurring failure pattern and decides to enforce it as a standing rule. The `source_moment_id` field preserves provenance downstream audit can trace the rule back to the moment that inspired it. Combine with evaluate_output + get_traces: 1) evaluate_output surfaces failures; 2) get_traces filters to the failure set; 3) analyze the pattern; 4) deploy_rule bakes it into the default eval path.",
161
- '',
162
- "Don't use to VALIDATE a rule before committing — deploy writes immediately. Use the dashboard's preview endpoint (POST /api/v1/rules/custom/preview) to replay a definition against recent stored traces first. To UPDATE a rule: call deploy_rule with the same name and `replace: true` (the old rule is deleted, the new one gets a fresh id), or delete_rule then deploy_rule. To pause a rule without losing it: delete_rule with `enabled: false`.",
163
- '',
164
- 'Parameters. Argument names are snake_case (eval_type, source_moment_id) — the camelCase spellings evalType / sourceMomentId are accepted as aliases for compatibility, but pass only one spelling of each. name is 1-80 chars (Zod-enforced min/max — the same cap the persisted store applies); appears in eval_result rule_results (alongside the rule id as `ruleId`) so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). eval_type determines WHEN the rule fires: a deployed rule runs ONLY on evaluate_output calls whose eval_type equals the rule\'s eval_type, plus eval_type="all" (which runs every bundle) — a "completeness" rule does NOT fire on eval_type="safety" or on eval_type="custom"; eval_type="custom" runs rules deployed under "custom" (and the call\'s inline custom_rules) and nothing else. severity decides what a FAILURE of the rule does: low/medium failures only lower the weighted score (and drive dashboard sort + audit alerts); high/critical failures HARD-FAIL the evaluation — the overall `passed` is forced to false regardless of the weighted score, and the rule is listed in the response\'s `critical_failures`. Severity never changes the numeric score itself (that uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_cost; min_length needs config.min_length; max_length needs config.max_length; contains_keywords/excludes_keywords need config.keywords). definition.name is optional and, if given, overwritten by the top-level name. Invalid configs are REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. source_moment_id is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). replace defaults to false. Defaults: severity="medium", replace=false.',
165
- '',
166
- "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars), on an unknown key inside `definition` (the valid keys are listed; config keys are free-form), on empty `name` or `name` over 80 chars, on a non-positive weight, and when both spellings of an aliased argument are passed. Throws when a rule with the same name is already deployed and `replace` is not true — the message names the existing rule id so you can delete it, replace it, or pick another name. Any eval_type/definition.type combination is valid (a regex_match rule can enforce a safety policy; a max_length rule can express completeness) — there is no category/type mismatch error. Returns 429 when HTTP rate limit exceeded. File-write failures (disk full, read-only fs) propagate as 500; the audit log is best-effort and does not block deploy.",
167
- ].join('\n'),
159
+ description: describeTool({
160
+ summary: 'Deploy a custom rule that fires on every future evaluate_output call of its bundle — persisted, active immediately, audited.',
161
+ does: 'Writes the rule to ~/.iris/custom-rules.json, appends a rule.deploy audit entry and registers it with the running engine, so it fires on the very next call and survives restarts. ' +
162
+ 'eval_type says WHEN it fires (that bundle, and eval_type="all"); severity says what a failure DOES: low and medium only lower the weighted score, high and critical force passed to false and list the rule in critical_failures. ' +
163
+ 'definition.type picks the check (regex_match, regex_no_match, min_length, max_length, contains_keywords, excludes_keywords, json_schema, cost_threshold) and definition.config carries its keys (pattern; min_length; max_length; keywords; max_cost). ' +
164
+ 'Any bundle and type combine. Names are unique: a taken name is refused unless replace is true, which retires the earlier rule(s) first and reports them. Argument names are snake_case; the camelCase aliases evalType and sourceMomentId are acceptedpass one spelling of each.',
165
+ whenNot: 'To try a rule first: POST /api/v1/rules/custom/preview on the dashboard replays a definition against stored traces without deploying. For a one-off check on one call: the custom_rules argument of evaluate_output. To pause a rule: delete_rule with enabled: false.',
166
+ returns: deployRuleOutputSchema,
167
+ errors: 'IRIS_DUPLICATE_RULE when the name is deployed and replace is false (the message names the existing id). ' +
168
+ 'IRIS_INVALID_RULE_CONFIG when the definition is rejected a regex that fails the ReDoS check or exceeds 1000 characters, a missing config keynaming the field; nothing is deployed. ' +
169
+ 'IRIS_STORAGE_ERROR when the store cannot be written. An unknown key in definition, a name over 80 characters, a non-positive weight or both spellings of an alias are refused before the handler runs. ' +
170
+ ERROR_ENVELOPE_SENTENCE,
171
+ siblings: {
172
+ list_rules: 'see what is deployed and the built-in roster',
173
+ delete_rule: 'remove, disable or re-enable',
174
+ evaluate_output: 'where the rule fires',
175
+ },
176
+ }),
168
177
  inputSchema: inputSchemaWithAliases,
178
+ outputSchema: deployRuleOutputSchema,
169
179
  annotations: {
170
180
  readOnlyHint: false,
171
181
  destructiveHint: false,
172
182
  idempotentHint: false,
173
183
  openWorldHint: false,
174
184
  },
175
- }, async (args) => {
185
+ }, guarded(async (args) => {
176
186
  const evalType = (args.eval_type ?? args.evalType);
177
187
  const sourceMomentId = args.source_moment_id ?? args.sourceMomentId;
178
188
  // Server overrides the inner definition's `name` so it always matches
@@ -186,10 +196,12 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
186
196
  };
187
197
  // Same-name redeploy (#373) — shared with the dashboard's deploy
188
198
  // route; see retireSameNamedRules above. Throws (nothing deployed)
189
- // when the name is taken and replace is false.
199
+ // when the name is taken and replace is false; `guarded` turns that
200
+ // into IRIS_DUPLICATE_RULE.
190
201
  const replaced = retireSameNamedRules(customRuleStore, evalEngine, LOCAL_TENANT, args.name, args.replace, 'mcp');
191
202
  // OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
192
203
  const rule = customRuleStore.deploy(LOCAL_TENANT, {
204
+ replaces: replaced.map((r) => r.id),
193
205
  name: args.name,
194
206
  description: args.description,
195
207
  evalType,
@@ -205,18 +217,9 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
205
217
  // Registered under its rule id so delete_rule can hot-remove it.
206
218
  // Severity rides along: high/critical makes the rule hard-failing.
207
219
  evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
208
- return {
209
- content: [
210
- {
211
- type: 'text',
212
- text: JSON.stringify({
213
- rule,
214
- ...(replaced.length > 0
215
- ? { replaced, warning: replacedRulesWarning(args.name, replaced) }
216
- : {}),
217
- }),
218
- },
219
- ],
220
- };
221
- });
220
+ return respond(deployRuleOutputSchema, {
221
+ rule,
222
+ ...(replaced.length > 0 ? { replaced, warning: replacedRulesWarning(args.name, replaced) } : {}),
223
+ });
224
+ }));
222
225
  }
@@ -0,0 +1,20 @@
1
+ import type { z } from 'zod';
2
+ export declare const DESCRIPTION_HEADINGS: readonly ["What it does.", "When not to use it.", "Returns.", "Errors.", "Siblings."];
3
+ export declare const DESCRIPTION_WORD_CAP = 450;
4
+ export interface ToolDescriptionSpec {
5
+ /** One sentence: what calling this does. */
6
+ summary: string;
7
+ does: string;
8
+ whenNot: string;
9
+ /** The tool's output schema; every top-level field must carry a description. */
10
+ returns: z.ZodObject<z.ZodRawShape>;
11
+ errors: string;
12
+ /** Sibling tool → one clause on when it is the better call. */
13
+ siblings: Record<string, string>;
14
+ }
15
+ /** `field` (its description); … — from the schema, never retyped. */
16
+ export declare function returnsFrom(schema: z.ZodObject<z.ZodRawShape>): string;
17
+ export declare function wordCount(text: string): number;
18
+ export declare function describeTool(spec: ToolDescriptionSpec): string;
19
+ /** The sentence every "Errors" heading ends with, so the envelope is stated once. */
20
+ export declare const ERROR_ENVELOPE_SENTENCE = "Every failure returns {\"error\":{\"code\",\"message\",\"recovery\":[]}} with isError true; follow recovery before retrying.";
@@ -0,0 +1,36 @@
1
+ export const DESCRIPTION_HEADINGS = ['What it does.', 'When not to use it.', 'Returns.', 'Errors.', 'Siblings.'];
2
+ export const DESCRIPTION_WORD_CAP = 450;
3
+ /** `field` (its description); … — from the schema, never retyped. */
4
+ export function returnsFrom(schema) {
5
+ const parts = [];
6
+ for (const [key, field] of Object.entries(schema.shape)) {
7
+ const description = field.description;
8
+ if (!description)
9
+ throw new Error(`output field "${key}" has no description; add .describe() on the schema`);
10
+ parts.push(`\`${key}\` (${description})`);
11
+ }
12
+ return `JSON with ${parts.join('; ')}.`;
13
+ }
14
+ export function wordCount(text) {
15
+ return text.split(/\s+/).filter(Boolean).length;
16
+ }
17
+ export function describeTool(spec) {
18
+ const siblings = Object.entries(spec.siblings)
19
+ .map(([name, when]) => `${name} — ${when}`)
20
+ .join('; ');
21
+ const text = [
22
+ spec.summary,
23
+ `${DESCRIPTION_HEADINGS[0]} ${spec.does}`,
24
+ `${DESCRIPTION_HEADINGS[1]} ${spec.whenNot}`,
25
+ `${DESCRIPTION_HEADINGS[2]} ${returnsFrom(spec.returns)}`,
26
+ `${DESCRIPTION_HEADINGS[3]} ${spec.errors}`,
27
+ `${DESCRIPTION_HEADINGS[4]} ${siblings}.`,
28
+ ].join('\n\n');
29
+ const words = wordCount(text);
30
+ if (words > DESCRIPTION_WORD_CAP) {
31
+ throw new Error(`tool description is ${words} words; the cap is ${DESCRIPTION_WORD_CAP}`);
32
+ }
33
+ return text;
34
+ }
35
+ /** The sentence every "Errors" heading ends with, so the envelope is stated once. */
36
+ export const ERROR_ENVELOPE_SENTENCE = 'Every failure returns {"error":{"code","message","recovery":[]}} with isError true; follow recovery before retrying.';
@@ -0,0 +1,36 @@
1
+ import { LLMJudgeError } from '../eval/llm-judge/client.js';
2
+ export declare const ERROR_CODE_CATALOGUE: readonly ["IRIS_INVALID_ARGUMENT", "IRIS_UNKNOWN_TRACE", "IRIS_DUPLICATE_RULE", "IRIS_INVALID_RULE_CONFIG", "IRIS_JUDGE_NOT_ENABLED", "IRIS_JUDGE_UNKNOWN_MODEL", "IRIS_BUDGET_EXCEEDED", "IRIS_PROVIDER_ERROR", "IRIS_JUDGE_FAILED", "IRIS_STORAGE_ERROR", "IRIS_INTERNAL_ERROR"];
3
+ export type IrisErrorCode = (typeof ERROR_CODE_CATALOGUE)[number];
4
+ export type ProviderErrorKind = LLMJudgeError['kind'];
5
+ export interface IrisErrorEnvelope {
6
+ code: IrisErrorCode;
7
+ /** One sentence a person can act on. */
8
+ message: string;
9
+ /** What to do, in order, before retrying. */
10
+ recovery: string[];
11
+ /** True when the same call can succeed later without a change from the caller. */
12
+ retryable: boolean;
13
+ /** The argument or environment variable at fault, when the failure is about one. */
14
+ field?: string;
15
+ /** The values the field accepts, when there is a closed list. */
16
+ valid?: string[];
17
+ /** A resource that explains the limit or the state. */
18
+ see?: string;
19
+ /** IRIS_PROVIDER_ERROR only: the provider failure class. */
20
+ kind?: ProviderErrorKind;
21
+ /** When the provider said how long to wait. */
22
+ retryAfterMs?: number;
23
+ }
24
+ export declare class IrisError extends Error {
25
+ readonly envelope: IrisErrorEnvelope;
26
+ constructor(envelope: Omit<IrisErrorEnvelope, 'recovery' | 'retryable'> & Partial<Pick<IrisErrorEnvelope, 'recovery' | 'retryable'>>);
27
+ }
28
+ export declare function irisError(code: IrisErrorCode, message: string, extra?: Partial<Omit<IrisErrorEnvelope, 'code' | 'message'>>): IrisError;
29
+ /**
30
+ * Every error a handler can throw, mapped to its code. Typed errors are
31
+ * matched by class or by name (the name check avoids importing the tool
32
+ * that defines the class, which would be a cycle); anything unrecognised
33
+ * is an internal error — reported as such, never dressed up as a caller
34
+ * mistake.
35
+ */
36
+ export declare function toIrisError(err: unknown): IrisError;
@@ -0,0 +1,134 @@
1
+ /*
2
+ * Structured tool errors.
3
+ *
4
+ * A thrown Error reaches an MCP client as one line of text with isError
5
+ * set (the SDK flattens it), so the only thing an agent could do with
6
+ * "Anthropic judge requires IRIS_ANTHROPIC_API_KEY" was guess. Every
7
+ * failure a tool can produce is now a code from ONE catalogue with a
8
+ * message, the steps that clear it, whether a retry can help, and — when
9
+ * the failure is about one argument — the field and the valid values.
10
+ * Tools return these (src/tools/respond.ts wraps the handler); they never
11
+ * throw past the handler.
12
+ *
13
+ * The catalogue is exhaustive on purpose: tests/unit/tools/error-codes.test.ts
14
+ * provokes every code over a real transport and asserts the provoked set
15
+ * EQUALS this list, so a code nothing can raise cannot linger here and a
16
+ * new throw site cannot ship without a code.
17
+ *
18
+ * Two errors are not this module's: an argument the input schema rejects
19
+ * is refused by the SDK before the handler runs (the text names
20
+ * IRIS_INVALID_ARGUMENT and the valid arguments — see strict-input.ts), and
21
+ * an HTTP rate limit answers 429 at the transport before any tool runs.
22
+ */
23
+ import { ZodError } from 'zod';
24
+ import { LLMJudgeError } from '../eval/llm-judge/client.js';
25
+ import { CAPABILITIES_RESOURCE_URI } from '../resources/uris.js';
26
+ export const ERROR_CODE_CATALOGUE = [
27
+ 'IRIS_INVALID_ARGUMENT',
28
+ 'IRIS_UNKNOWN_TRACE',
29
+ 'IRIS_DUPLICATE_RULE',
30
+ 'IRIS_INVALID_RULE_CONFIG',
31
+ 'IRIS_JUDGE_NOT_ENABLED',
32
+ 'IRIS_JUDGE_UNKNOWN_MODEL',
33
+ 'IRIS_BUDGET_EXCEEDED',
34
+ 'IRIS_PROVIDER_ERROR',
35
+ 'IRIS_JUDGE_FAILED',
36
+ 'IRIS_STORAGE_ERROR',
37
+ 'IRIS_INTERNAL_ERROR',
38
+ ];
39
+ export class IrisError extends Error {
40
+ envelope;
41
+ constructor(envelope) {
42
+ super(envelope.message);
43
+ this.name = 'IrisError';
44
+ this.envelope = { retryable: false, recovery: [], ...envelope };
45
+ }
46
+ }
47
+ export function irisError(code, message, extra = {}) {
48
+ return new IrisError({ code, message, ...extra });
49
+ }
50
+ const CAPABILITIES = CAPABILITIES_RESOURCE_URI;
51
+ function isSqliteError(err) {
52
+ const code = err.code;
53
+ if (typeof code === 'string' && code.startsWith('SQLITE_'))
54
+ return true;
55
+ const message = err instanceof Error ? err.message : '';
56
+ return /SQLITE|database is locked|disk I\/O error|readonly database/i.test(message);
57
+ }
58
+ /**
59
+ * Every error a handler can throw, mapped to its code. Typed errors are
60
+ * matched by class or by name (the name check avoids importing the tool
61
+ * that defines the class, which would be a cycle); anything unrecognised
62
+ * is an internal error — reported as such, never dressed up as a caller
63
+ * mistake.
64
+ */
65
+ export function toIrisError(err) {
66
+ if (err instanceof IrisError)
67
+ return err;
68
+ const name = err?.name;
69
+ const message = err instanceof Error ? err.message : String(err);
70
+ if (name === 'DuplicateRuleNameError') {
71
+ return irisError('IRIS_DUPLICATE_RULE', message, {
72
+ field: 'name',
73
+ recovery: [
74
+ 'Pass replace: true to retire the rule(s) with this name and deploy this one in their place.',
75
+ 'Or call delete_rule with the existing rule id first.',
76
+ 'Or choose a different name.',
77
+ ],
78
+ });
79
+ }
80
+ if (name === 'CostCapError') {
81
+ const e = err;
82
+ return irisError('IRIS_BUDGET_EXCEEDED', message, {
83
+ field: 'max_cost_usd',
84
+ recovery: [
85
+ `Raise max_cost_usd on the call (the worst case was ${e.estimatedUsd?.toFixed(4) ?? '?'} USD against a cap of ${e.capUsd?.toFixed(4) ?? '?'} USD).`,
86
+ 'Or trim the output, the input or max_output_tokens so the worst case fits.',
87
+ 'Nothing was spent.',
88
+ ],
89
+ see: CAPABILITIES,
90
+ });
91
+ }
92
+ if (err instanceof LLMJudgeError) {
93
+ const retryable = err.kind === 'rate_limit' || err.kind === 'timeout' || err.kind === 'server_error';
94
+ const recovery = {
95
+ auth: ['The provider refused the key. Check that the key in the env block of your MCP config is valid and live, then restart the session.'],
96
+ rate_limit: ['The provider rate-limited the call. Wait and retry; Iris already retried once.'],
97
+ bad_request: ['The provider rejected the request. Check the model name and the template inputs.'],
98
+ server_error: ['The provider failed on its side. Retry later.'],
99
+ timeout: ['The provider did not answer within timeout_ms. Retry, or raise timeout_ms.'],
100
+ malformed_response: ['The judge did not return valid JSON on two attempts. Retry; if it recurs, pick another model.'],
101
+ unknown: ['Retry once; if it recurs, report the message with the provider and model.'],
102
+ };
103
+ return irisError('IRIS_PROVIDER_ERROR', message, {
104
+ kind: err.kind,
105
+ retryable,
106
+ recovery: recovery[err.kind],
107
+ ...(err.retryAfterSeconds !== undefined ? { retryAfterMs: err.retryAfterSeconds * 1000 } : {}),
108
+ });
109
+ }
110
+ if (err instanceof ZodError) {
111
+ // The SDK validates tool input before the handler runs, so a ZodError
112
+ // inside a handler is the rule store rejecting a definition.
113
+ const first = err.issues[0];
114
+ const field = first?.path?.length ? first.path.map(String).join('.') : undefined;
115
+ return irisError('IRIS_INVALID_RULE_CONFIG', `The rule definition was rejected: ${err.issues.map((i) => `${i.path.map(String).join('.') || 'definition'}: ${i.message}`).join('; ')}`, {
116
+ ...(field ? { field } : {}),
117
+ recovery: ['Fix the named field and deploy again. Nothing was deployed.'],
118
+ });
119
+ }
120
+ if (isSqliteError(err)) {
121
+ const code = err.code ?? '';
122
+ return irisError('IRIS_STORAGE_ERROR', `Iris storage failed: ${message}`, {
123
+ retryable: code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED',
124
+ recovery: [
125
+ 'Retry once. If it recurs, check that the database path is writable and the disk is not full.',
126
+ 'Run `npx @iris-eval/mcp-server --self-test` to probe the configured home and database.',
127
+ ],
128
+ });
129
+ }
130
+ return irisError('IRIS_INTERNAL_ERROR', message || 'Iris hit an unexpected error.', {
131
+ recovery: ['Retry once. If it recurs, report the message with the Iris version from iris://capabilities.'],
132
+ see: CAPABILITIES,
133
+ });
134
+ }
@@ -1,4 +1,11 @@
1
+ import type { DormantRule } from '../eval/dormant.js';
1
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import type { IStorageAdapter } from '../types/query.js';
3
4
  import type { EvalEngine } from '../eval/engine.js';
4
- export declare function registerEvaluateOutputTool(server: McpServer, storage: IStorageAdapter, evalEngine: EvalEngine): void;
5
+ /** The most inline custom rules one call may carry (see the argument description). */
6
+ export declare const MAX_INLINE_CUSTOM_RULES = 10;
7
+ export interface EvaluateOutputOptions {
8
+ /** The quarantined gating rules on this server, for coverage.dormant. */
9
+ dormant?: () => DormantRule[];
10
+ }
11
+ export declare function registerEvaluateOutputTool(server: McpServer, storage: IStorageAdapter, evalEngine: EvalEngine, options?: EvaluateOutputOptions): void;
@@ -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.
@@ -110,45 +116,18 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
110
116
  };
111
117
  const customRules = args.custom_rules;
112
118
  const result = evalType === 'all'
113
- ? evalEngine.evaluateAll(context, customRules)
114
- : evalEngine.evaluate(evalType, context, customRules);
119
+ ? await evalEngine.evaluateAll(context, customRules)
120
+ : await evalEngine.evaluate(evalType, context, customRules);
115
121
  if (args.trace_id) {
116
122
  result.trace_id = args.trace_id;
117
123
  }
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,37 @@
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
+ pass_threshold: z.ZodNumber;
21
+ self_reported_pass: z.ZodOptional<z.ZodBoolean>;
22
+ disagreement: z.ZodOptional<z.ZodBoolean>;
23
+ rationale: z.ZodString;
24
+ dimensions: z.ZodRecord<z.ZodString, z.ZodUnknown>;
25
+ model: z.ZodString;
26
+ provider: z.ZodEnum<{
27
+ anthropic: "anthropic";
28
+ openai: "openai";
29
+ }>;
30
+ template: z.ZodString;
31
+ input_tokens: z.ZodNumber;
32
+ output_tokens: z.ZodNumber;
33
+ cost_usd: z.ZodNullable<z.ZodNumber>;
34
+ latency_ms: z.ZodNumber;
35
+ raw_response_id: z.ZodOptional<z.ZodString>;
36
+ }, z.core.$loose>;
3
37
  export declare function registerEvaluateWithLLMJudgeTool(server: McpServer, storage: IStorageAdapter): void;