@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
@@ -12,48 +12,45 @@
12
12
  import { z } from 'zod';
13
13
  import { LOCAL_TENANT } from '../types/tenant.js';
14
14
  import { strictInput } from './strict-input.js';
15
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
16
+ import { guarded, respond } from './respond.js';
15
17
  const inputSchema = {
16
18
  trace_id: z
17
19
  .string()
18
20
  .regex(/^[a-f0-9]{32}$/)
19
21
  .describe('Trace id to delete (32-hex lowercase; obtained from log_trace response or get_traces)'),
20
22
  };
23
+ export const deleteTraceOutputSchema = z.looseObject({
24
+ deleted: z.boolean().describe('true when a trace row was removed; false when no trace with that id existed for this tenant'),
25
+ trace_id: z.string().describe('the id that was asked for'),
26
+ });
21
27
  export function registerDeleteTraceTool(server, storage) {
22
28
  server.registerTool('delete_trace', {
23
29
  title: 'Delete Trace',
24
- description: [
25
- 'Remove a single trace by id. Cascades to spans; eval_results keep the score history with trace_id NULLed.',
26
- '',
27
- 'Sibling tools log_trace creates traces, get_traces queries them, evaluate_output / evaluate_with_llm_judge / verify_citations score them. delete_rule handles custom-rule deletion (separate concern); list_rules / deploy_rule manage the custom-rule lifecycle. delete_trace is the DESTRUCTIVE single-row remove for traces; it does NOT touch eval_results (preserved for audit + drift analytics), spans cascade automatically.',
28
- '',
29
- 'Behavior. DESTRUCTIVE — SQL DELETE scoped to the caller\'s tenant_id. Cascades: spans belonging to this trace are deleted (FK ON DELETE CASCADE); eval_results that referenced this trace have their trace_id set to NULL (FK ON DELETE SET NULL) so aggregate dashboards + historical scores remain valid even after the trace is gone. Not idempotent: deleting an already-deleted trace returns `deleted: false`. Does not emit an audit log entry — traces are user-scope data, not policy changes. Rate-limited to 20 req/min on HTTP MCP.',
30
- '',
31
- 'Output shape. Returns JSON: `{ "deleted": boolean, "trace_id": string }`. `deleted=true` if a row was removed; `deleted=false` if no trace with that id existed (or it belonged to a different tenant — cross-tenant deletes silently fail).',
32
- '',
33
- "Use when a trace was captured in error, contains sensitive data that must be removed for compliance (e.g., a customer exercises GDPR right-to-erasure), or when cleaning up test data. Combine with get_traces to find candidates: query with filters → review → delete_trace(id) per target. For bulk time-window deletion, set `retention.days` in config.json (default 30; the sweep runs when the server starts) — delete_trace is the single-row surgical path.",
34
- '',
35
- "Don't use to clean up OLD data in bulk (use the `retention.days` setting in config.json; there is no command-line flag for it). Don't use to PAUSE a trace — traces are immutable once stored; there's nothing to pause. Don't use to delete eval_results — eval_results survive their trace's deletion intentionally (for audit + drift analysis); they're pruned only by retention.",
36
- '',
37
- 'Parameters. trace_id is the only parameter; must match 32-char lowercase hex (Zod regex). The trace_id you pass is exactly what log_trace returned in its response, or what get_traces returned per row. Format mismatch fails Zod with 400 BEFORE the storage layer is touched. Cross-tenant trace_ids return `deleted: false` silently — they\'re invisible to the caller\'s tenant (prevents enumeration attacks; matches delete_rule\'s tenant-isolation contract).',
38
- '',
39
- "Error modes. Throws 400 on malformed trace_id (wrong format: not 32-char lowercase hex). Returns `{deleted: false}` when the id doesn't exist in the caller's tenant (not an error — the trace may simply have been deleted already). Returns 429 on HTTP rate limit. Storage failures propagate as 500.",
40
- ].join('\n'),
30
+ description: describeTool({
31
+ summary: 'Remove one stored trace by id; its spans go with it, and every evaluation linked to it keeps its verdict and loses its text.',
32
+ does: "Deletes the trace row for the caller's tenant. Spans cascade. Evaluations linked to it keep their verdict, scores, criticality and evidence offsets; their output text, expected text, suggestions and rule messages are erased in the same transaction and erased_at is stamped, so no text from the trace survives in any evaluation. " +
33
+ "deleted is false when no trace has that id already removed, or not this tenant's and that is not an error. No audit entry is written: traces are user data, not policy.",
34
+ whenNot: 'To expire old data in bulk (retention.days; the sweep runs at boot and every retention.sweepIntervalHours). To delete evaluations: they are not deleted per row; retention and --purge cover them. To pause anything: traces are immutable, there is nothing to pause.',
35
+ returns: deleteTraceOutputSchema,
36
+ errors: 'IRIS_STORAGE_ERROR when the delete cannot run. A malformed trace_id (not 32 lowercase hex) is refused before the handler runs. ' +
37
+ ERROR_ENVELOPE_SENTENCE,
38
+ siblings: {
39
+ log_trace: 'store a trace',
40
+ get_traces: 'find the trace to delete',
41
+ delete_rule: 'the equivalent for custom rules',
42
+ },
43
+ }),
41
44
  inputSchema: strictInput(inputSchema),
45
+ outputSchema: deleteTraceOutputSchema,
42
46
  annotations: {
43
47
  readOnlyHint: false,
44
48
  destructiveHint: true,
45
49
  idempotentHint: false,
46
50
  openWorldHint: false,
47
51
  },
48
- }, async (args) => {
52
+ }, guarded(async (args) => {
49
53
  const deleted = await storage.deleteTrace(LOCAL_TENANT, args.trace_id);
50
- return {
51
- content: [
52
- {
53
- type: 'text',
54
- text: JSON.stringify({ deleted, trace_id: args.trace_id }),
55
- },
56
- ],
57
- };
58
- });
54
+ return respond(deleteTraceOutputSchema, { deleted, trace_id: args.trace_id });
55
+ }));
59
56
  }
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import type { CustomRuleStore } from '../custom-rule-store.js';
3
4
  import type { EvalEngine } from '../eval/engine.js';
@@ -6,7 +7,8 @@ import { type TenantId } from '../types/tenant.js';
6
7
  /**
7
8
  * A rule with this name is already deployed and the caller did not ask to
8
9
  * replace it. Carries the existing rule(s) so an HTTP surface can answer
9
- * 409 with them beside the same message the MCP tool throws.
10
+ * 409 with them beside the same message the MCP tool returns as
11
+ * IRIS_DUPLICATE_RULE (src/tools/errors.ts maps it by name).
10
12
  */
11
13
  export declare class DuplicateRuleNameError extends Error {
12
14
  readonly existing: DeployedCustomRule[];
@@ -34,4 +36,14 @@ export interface ReplacedRule {
34
36
  export declare function retireSameNamedRules(store: CustomRuleStore, engine: EvalEngine, tenantId: TenantId, name: string, replace: boolean, user: string): ReplacedRule[];
35
37
  /** The `warning` both deploy surfaces attach when a replace retired rules. */
36
38
  export declare function replacedRulesWarning(name: string, replaced: ReplacedRule[]): string;
39
+ export declare const deployRuleOutputSchema: z.ZodObject<{
40
+ rule: z.ZodObject<{
41
+ id: z.ZodString;
42
+ name: z.ZodString;
43
+ }, z.core.$loose>;
44
+ replaced: z.ZodOptional<z.ZodArray<z.ZodObject<{
45
+ id: z.ZodString;
46
+ }, z.core.$loose>>>;
47
+ warning: z.ZodOptional<z.ZodString>;
48
+ }, z.core.$loose>;
37
49
  export declare function registerDeployRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;
@@ -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;