@iris-eval/mcp-server 0.3.1 → 0.4.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 (100) hide show
  1. package/README.md +11 -3
  2. package/dist/audit-log-reader.d.ts +24 -0
  3. package/dist/audit-log-reader.js +87 -0
  4. package/dist/config/defaults.js +7 -1
  5. package/dist/custom-rule-store.d.ts +27 -0
  6. package/dist/custom-rule-store.js +188 -0
  7. package/dist/dashboard/assets/index-BEG5FYWH.css +1 -0
  8. package/dist/dashboard/assets/index-D9JHfSB2.js +12 -0
  9. package/dist/dashboard/index.html +2 -2
  10. package/dist/dashboard/routes/audit.d.ts +3 -0
  11. package/dist/dashboard/routes/audit.js +36 -0
  12. package/dist/dashboard/routes/eval-stats.js +9 -4
  13. package/dist/dashboard/routes/evaluations.js +3 -1
  14. package/dist/dashboard/routes/filters.js +5 -3
  15. package/dist/dashboard/routes/health.js +8 -1
  16. package/dist/dashboard/routes/index.d.ts +4 -0
  17. package/dist/dashboard/routes/index.js +4 -0
  18. package/dist/dashboard/routes/moments.d.ts +3 -0
  19. package/dist/dashboard/routes/moments.js +115 -0
  20. package/dist/dashboard/routes/preferences.d.ts +3 -0
  21. package/dist/dashboard/routes/preferences.js +52 -0
  22. package/dist/dashboard/routes/rules.d.ts +10 -0
  23. package/dist/dashboard/routes/rules.js +169 -0
  24. package/dist/dashboard/routes/summary.js +3 -1
  25. package/dist/dashboard/routes/traces.js +7 -4
  26. package/dist/dashboard/server.d.ts +9 -1
  27. package/dist/dashboard/server.js +52 -3
  28. package/dist/eval/citation-verify/extract.d.ts +11 -0
  29. package/dist/eval/citation-verify/extract.js +102 -0
  30. package/dist/eval/citation-verify/resolve.d.ts +26 -0
  31. package/dist/eval/citation-verify/resolve.js +237 -0
  32. package/dist/eval/citation-verify/verifier.d.ts +43 -0
  33. package/dist/eval/citation-verify/verifier.js +203 -0
  34. package/dist/eval/decision-moment.d.ts +12 -0
  35. package/dist/eval/decision-moment.js +181 -0
  36. package/dist/eval/llm-judge/client.d.ts +28 -0
  37. package/dist/eval/llm-judge/client.js +183 -0
  38. package/dist/eval/llm-judge/evaluator.d.ts +32 -0
  39. package/dist/eval/llm-judge/evaluator.js +138 -0
  40. package/dist/eval/llm-judge/pricing.d.ts +9 -0
  41. package/dist/eval/llm-judge/pricing.js +31 -0
  42. package/dist/eval/llm-judge/templates/index.d.ts +20 -0
  43. package/dist/eval/llm-judge/templates/index.js +170 -0
  44. package/dist/eval/rules/custom.js +13 -2
  45. package/dist/index.js +77 -14
  46. package/dist/middleware/index.d.ts +1 -0
  47. package/dist/middleware/index.js +1 -0
  48. package/dist/middleware/tenant.d.ts +17 -0
  49. package/dist/middleware/tenant.js +26 -0
  50. package/dist/otel/exporter.d.ts +24 -0
  51. package/dist/otel/exporter.js +116 -0
  52. package/dist/otel/lazy.d.ts +5 -0
  53. package/dist/otel/lazy.js +31 -0
  54. package/dist/otel/mapper.d.ts +24 -0
  55. package/dist/otel/mapper.js +208 -0
  56. package/dist/preferences.d.ts +129 -0
  57. package/dist/preferences.js +152 -0
  58. package/dist/resources/dashboard-summary.js +3 -1
  59. package/dist/resources/trace-detail.js +5 -3
  60. package/dist/server.d.ts +3 -1
  61. package/dist/server.js +9 -3
  62. package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
  63. package/dist/storage/migrations/004-tenant-id.js +40 -0
  64. package/dist/storage/migrations/index.js +2 -1
  65. package/dist/storage/sqlite-adapter.d.ts +17 -15
  66. package/dist/storage/sqlite-adapter.js +130 -79
  67. package/dist/tools/delete-rule.d.ts +3 -0
  68. package/dist/tools/delete-rule.js +53 -0
  69. package/dist/tools/delete-trace.d.ts +3 -0
  70. package/dist/tools/delete-trace.js +54 -0
  71. package/dist/tools/deploy-rule.d.ts +3 -0
  72. package/dist/tools/deploy-rule.js +91 -0
  73. package/dist/tools/evaluate-output.js +23 -2
  74. package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
  75. package/dist/tools/evaluate-with-llm-judge.js +147 -0
  76. package/dist/tools/get-traces.js +23 -3
  77. package/dist/tools/index.d.ts +2 -1
  78. package/dist/tools/index.js +13 -1
  79. package/dist/tools/list-rules.d.ts +3 -0
  80. package/dist/tools/list-rules.js +66 -0
  81. package/dist/tools/log-trace.js +30 -2
  82. package/dist/tools/verify-citations.d.ts +3 -0
  83. package/dist/tools/verify-citations.js +157 -0
  84. package/dist/types/custom-rule.d.ts +70 -0
  85. package/dist/types/custom-rule.js +1 -0
  86. package/dist/types/decision-moment.d.ts +122 -0
  87. package/dist/types/decision-moment.js +17 -0
  88. package/dist/types/index.d.ts +2 -0
  89. package/dist/types/index.js +1 -1
  90. package/dist/types/query.d.ts +25 -15
  91. package/dist/types/tenant.d.ts +26 -0
  92. package/dist/types/tenant.js +58 -0
  93. package/dist/utils/open-browser.d.ts +1 -0
  94. package/dist/utils/open-browser.js +45 -0
  95. package/dist/utils/validate-port-config.d.ts +2 -0
  96. package/dist/utils/validate-port-config.js +9 -0
  97. package/package.json +4 -1
  98. package/server.json +2 -2
  99. package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
  100. package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
@@ -0,0 +1,54 @@
1
+ /*
2
+ * delete_trace MCP tool — remove a single trace by id.
3
+ *
4
+ * Destructive. Scoped to tenant — an agent cannot delete a trace
5
+ * belonging to another tenant (cross-tenant deletes look like
6
+ * "id not found").
7
+ *
8
+ * Cascades to spans via FK ON DELETE CASCADE. eval_results for the
9
+ * trace have their trace_id set to NULL (score history survives
10
+ * even after the underlying trace is gone).
11
+ */
12
+ import { z } from 'zod';
13
+ import { LOCAL_TENANT } from '../types/tenant.js';
14
+ const inputSchema = {
15
+ trace_id: z
16
+ .string()
17
+ .regex(/^[a-f0-9]{32}$/)
18
+ .describe('Trace id to delete (32-hex lowercase; obtained from log_trace response or get_traces)'),
19
+ };
20
+ export function registerDeleteTraceTool(server, storage) {
21
+ server.registerTool('delete_trace', {
22
+ title: 'Delete Trace',
23
+ description: [
24
+ 'Remove a single trace by id. Cascades to spans; eval_results keep the score history with trace_id NULLed.',
25
+ '',
26
+ '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 in v0.4 — traces are user-scope data, not policy changes. Rate-limited to 20 req/min on HTTP MCP.',
27
+ '',
28
+ '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).',
29
+ '',
30
+ "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, use `deleteTracesOlderThan` via the CLI / retention config — delete_trace is the single-row surgical path.",
31
+ '',
32
+ "Don't use to clean up OLD data in bulk (use retention config with --retention-days). 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.",
33
+ '',
34
+ "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.",
35
+ ].join('\n'),
36
+ inputSchema,
37
+ annotations: {
38
+ readOnlyHint: false,
39
+ destructiveHint: true,
40
+ idempotentHint: false,
41
+ openWorldHint: false,
42
+ },
43
+ }, async (args) => {
44
+ const deleted = await storage.deleteTrace(LOCAL_TENANT, args.trace_id);
45
+ return {
46
+ content: [
47
+ {
48
+ type: 'text',
49
+ text: JSON.stringify({ deleted, trace_id: args.trace_id }),
50
+ },
51
+ ],
52
+ };
53
+ });
54
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { CustomRuleStore } from '../custom-rule-store.js';
3
+ export declare function registerDeployRuleTool(server: McpServer, customRuleStore: CustomRuleStore): void;
@@ -0,0 +1,91 @@
1
+ /*
2
+ * deploy_rule MCP tool — programmatically add a custom eval rule.
3
+ *
4
+ * Mirror of the dashboard's Make-This-A-Rule composer, but callable
5
+ * from an agent. An agent that observes a failure pattern can deploy
6
+ * a rule without a human in the loop.
7
+ *
8
+ * Writes to ~/.iris/custom-rules.json (single source of truth) and
9
+ * appends to the audit log. Persisted rules auto-load on server boot
10
+ * and fire on every future evaluate_output call of the matching
11
+ * eval_type.
12
+ */
13
+ import { z } from 'zod';
14
+ const CustomRuleDefinitionSchema = z.object({
15
+ name: z.string(),
16
+ type: z.enum([
17
+ 'regex_match',
18
+ 'regex_no_match',
19
+ 'min_length',
20
+ 'max_length',
21
+ 'contains_keywords',
22
+ 'excludes_keywords',
23
+ 'json_schema',
24
+ 'cost_threshold',
25
+ ]),
26
+ config: z.record(z.unknown()),
27
+ weight: z.number().optional(),
28
+ });
29
+ const inputSchema = {
30
+ name: z.string().min(1).max(120).describe('Human-readable rule name (used in eval results)'),
31
+ description: z
32
+ .string()
33
+ .max(500)
34
+ .optional()
35
+ .describe('What this rule checks for and why it matters'),
36
+ evalType: z
37
+ .enum(['completeness', 'relevance', 'safety', 'cost', 'custom'])
38
+ .describe('Eval category this rule belongs to; determines when it fires'),
39
+ severity: z
40
+ .enum(['low', 'medium', 'high', 'critical'])
41
+ .default('medium')
42
+ .describe('Severity used for dashboard sort + audit alerts'),
43
+ definition: CustomRuleDefinitionSchema.describe('Check definition (regex, length, keyword, cost, or schema)'),
44
+ sourceMomentId: z
45
+ .string()
46
+ .optional()
47
+ .describe('Optional Decision Moment ID the rule was derived from (preserves workflow-inversion provenance)'),
48
+ };
49
+ export function registerDeployRuleTool(server, customRuleStore) {
50
+ server.registerTool('deploy_rule', {
51
+ title: 'Deploy Custom Rule',
52
+ description: [
53
+ 'Deploy a new custom evaluation rule that will fire on every future evaluate_output call of its eval category.',
54
+ '',
55
+ '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; not idempotent (deploying twice creates two rules). Tenant-scoped in Cloud tier; OSS rules are owned by LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
56
+ '',
57
+ 'Output shape. Returns JSON: `{ "rule": { "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition", "enabled": true, "createdAt", "updatedAt", "version": 1, "sourceMomentId?" } }`. The returned rule is the canonical persisted form; save the `id` if you plan to update or delete later.',
58
+ '',
59
+ "Use when an agent observes a recurring failure pattern and decides to enforce it as a standing rule. The `sourceMomentId` 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.",
60
+ '',
61
+ "Don't use to VALIDATE a rule before committing — deploy writes immediately. Use the dashboard's preview endpoint (POST /api/v1/rules/custom/preview) for dry-run validation against sample output. Don't use to EDIT an existing rule — this call only creates; edits require a dedicated flow (coming in v0.5). To update a rule today: delete_rule then deploy_rule with the new definition.",
62
+ '',
63
+ "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars). Throws 400 on empty `name`. Throws 400 if the eval category mismatches the definition type. 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.",
64
+ ].join('\n'),
65
+ inputSchema,
66
+ annotations: {
67
+ readOnlyHint: false,
68
+ destructiveHint: false,
69
+ idempotentHint: false,
70
+ openWorldHint: false,
71
+ },
72
+ }, async (args) => {
73
+ const rule = customRuleStore.deploy({
74
+ name: args.name,
75
+ description: args.description,
76
+ evalType: args.evalType,
77
+ severity: args.severity,
78
+ definition: args.definition,
79
+ sourceMomentId: args.sourceMomentId,
80
+ user: 'mcp',
81
+ });
82
+ return {
83
+ content: [
84
+ {
85
+ type: 'text',
86
+ text: JSON.stringify({ rule }),
87
+ },
88
+ ],
89
+ };
90
+ });
91
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { LOCAL_TENANT } from '../types/tenant.js';
2
3
  const CustomRuleSchema = z.object({
3
4
  name: z.string(),
4
5
  type: z.enum([
@@ -25,8 +26,26 @@ const inputSchema = {
25
26
  export function registerEvaluateOutputTool(server, storage, evalEngine) {
26
27
  server.registerTool('evaluate_output', {
27
28
  title: 'Evaluate Output',
28
- description: 'Evaluate agent output quality using configurable rules',
29
+ description: [
30
+ 'Score agent output against configurable eval rules and return a 0..1 score + per-rule breakdown.',
31
+ '',
32
+ 'Behavior. Deterministic, in-process scoring — same inputs always produce the same result. Writes one eval_result row to Iris storage (linked to trace_id if provided; unlinked otherwise). No external network calls in heuristic mode (v0.4 adds an llm_as_judge eval_type that DOES call LLM APIs; see the separate evaluate_with_llm_judge tool for that). Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Runs in ~5-50ms for rule-based evaluation.',
33
+ '',
34
+ 'Output shape. Returns JSON: `{ "id": "<uuid>", "score": 0..1, "passed": boolean, "rule_results": [{ "ruleName", "passed", "score", "message", "skipped?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean }`. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data).',
35
+ '',
36
+ '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, sentence count, relevance to input), `relevance` (keyword overlap, topic consistency), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection), `cost` (budget threshold), or `custom` (bring your own rules via `custom_rules`).',
37
+ '',
38
+ '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).',
39
+ '',
40
+ 'Error modes. Throws on malformed custom_rules (Zod rejects). Returns 400 on regex patterns that fail safe-regex2 ReDoS check or exceed 1000-char limit. 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.',
41
+ ].join('\n'),
29
42
  inputSchema,
43
+ annotations: {
44
+ readOnlyHint: false, // Writes an eval_result row
45
+ destructiveHint: false, // Creates new data; doesn't overwrite or delete
46
+ idempotentHint: true, // Deterministic: same inputs → same score (each call writes a distinct result row, but the SCORE is stable)
47
+ openWorldHint: false, // No external network in heuristic mode; LLM-as-judge has its own tool with openWorldHint:true
48
+ },
30
49
  }, async (args) => {
31
50
  const evalType = args.eval_type;
32
51
  const result = evalEngine.evaluate(evalType, {
@@ -39,7 +58,9 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
39
58
  if (args.trace_id) {
40
59
  result.trace_id = args.trace_id;
41
60
  }
42
- await storage.insertEvalResult(result);
61
+ // OSS single-tenant: MCP tool callers are the local user. Cloud
62
+ // will derive tenant from the authenticated MCP session.
63
+ await storage.insertEvalResult(LOCAL_TENANT, result);
43
64
  return {
44
65
  content: [
45
66
  {
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { IStorageAdapter } from '../types/query.js';
3
+ export declare function registerEvaluateWithLLMJudgeTool(server: McpServer, storage: IStorageAdapter): void;
@@ -0,0 +1,147 @@
1
+ import { z } from 'zod';
2
+ import { LOCAL_TENANT } from '../types/tenant.js';
3
+ import { evaluateWithLLMJudge } from '../eval/llm-judge/evaluator.js';
4
+ import { findPricing } from '../eval/llm-judge/pricing.js';
5
+ import { generateEvalId } from '../utils/ids.js';
6
+ const inputSchema = {
7
+ output: z.string().min(1).describe('The agent output text to evaluate'),
8
+ template: z
9
+ .enum(['accuracy', 'helpfulness', 'safety', 'correctness', 'faithfulness'])
10
+ .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`).'),
11
+ model: z
12
+ .string()
13
+ .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.'),
14
+ provider: z.enum(['anthropic', 'openai']).optional().describe('Auto-detected from model when omitted'),
15
+ input: z.string().optional().describe('User question / prompt that produced the output (improves accuracy for helpfulness/safety)'),
16
+ expected: z.string().optional().describe('Reference answer (required for correctness template)'),
17
+ source_material: z.string().optional().describe('Provided RAG sources (required for faithfulness template)'),
18
+ trace_id: z.string().optional().describe('Link this evaluation to a trace'),
19
+ 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'),
20
+ max_output_tokens: z.number().int().positive().max(4096).optional().describe('Judge output token cap; default 512'),
21
+ temperature: z.number().min(0).max(2).optional().describe('Sampling temperature; default 0 (deterministic)'),
22
+ timeout_ms: z.number().int().positive().optional().describe('Per-request timeout; default 60_000'),
23
+ };
24
+ function inferProvider(model) {
25
+ const pricing = findPricing(model);
26
+ if (!pricing) {
27
+ throw new Error(`Unknown model "${model}". Provider cannot be inferred. Supported models are listed in src/eval/llm-judge/pricing.ts.`);
28
+ }
29
+ return pricing.provider;
30
+ }
31
+ function resolveApiKey(provider) {
32
+ if (provider === 'anthropic') {
33
+ const key = process.env.IRIS_ANTHROPIC_API_KEY;
34
+ if (!key) {
35
+ throw new Error('Anthropic judge requires IRIS_ANTHROPIC_API_KEY. Set it in the environment or use a different provider.');
36
+ }
37
+ return key;
38
+ }
39
+ const key = process.env.IRIS_OPENAI_API_KEY;
40
+ if (!key) {
41
+ throw new Error('OpenAI judge requires IRIS_OPENAI_API_KEY. Set it in the environment or use a different provider.');
42
+ }
43
+ return key;
44
+ }
45
+ function resolveMaxCost(paramValue) {
46
+ if (paramValue !== undefined)
47
+ return paramValue;
48
+ const envRaw = process.env.IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL;
49
+ if (envRaw) {
50
+ const parsed = Number(envRaw);
51
+ if (Number.isFinite(parsed) && parsed > 0)
52
+ return parsed;
53
+ }
54
+ return 0.25;
55
+ }
56
+ export function registerEvaluateWithLLMJudgeTool(server, storage) {
57
+ server.registerTool('evaluate_with_llm_judge', {
58
+ title: 'Evaluate With LLM Judge',
59
+ description: [
60
+ 'Score agent output using an LLM as the judge (Anthropic or OpenAI). Returns a calibrated 0..1 score with rationale, per-dimension breakdown, and exact cost.',
61
+ '',
62
+ 'Behavior. Calls an external LLM API (Anthropic or OpenAI) — costs money per call, takes 1-10 seconds, 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).',
63
+ '',
64
+ '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}`).',
65
+ '',
66
+ '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`).',
67
+ '',
68
+ "Don't use for simple regex/length/keyword checks (use evaluate_output with heuristic rules — they're free, deterministic, 1000x faster). 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.",
69
+ '',
70
+ 'Error modes. Throws when the required API key env var is missing. Throws when the estimated worst-case cost exceeds max_cost_usd (raise the cap or trim prompts). Throws LLMJudgeError on provider errors — kind=`auth` on 401/403, `rate_limit` on 429 (auto-retried once), `server_error` on 5xx, `timeout` on abort, `malformed_response` when the judge fails to emit valid JSON on both attempts. Throws "Unknown model" for unsupported model IDs — update src/eval/llm-judge/pricing.ts first.',
71
+ ].join('\n'),
72
+ inputSchema,
73
+ annotations: {
74
+ readOnlyHint: false, // Writes eval_result; also spends money (external API cost)
75
+ destructiveHint: false, // Creates data; doesn't overwrite or delete
76
+ idempotentHint: false, // Temperature > 0 may vary; even at T=0 provider non-determinism is possible; cost also varies per call
77
+ openWorldHint: true, // Calls external APIs (Anthropic / OpenAI) — touches the world beyond local process
78
+ },
79
+ }, async (args) => {
80
+ const provider = args.provider ?? inferProvider(args.model);
81
+ const apiKey = resolveApiKey(provider);
82
+ const maxCostUsd = resolveMaxCost(args.max_cost_usd);
83
+ const result = await evaluateWithLLMJudge({
84
+ output: args.output,
85
+ template: args.template,
86
+ provider,
87
+ model: args.model,
88
+ apiKey,
89
+ input: args.input,
90
+ expected: args.expected,
91
+ sourceMaterial: args.source_material,
92
+ maxCostUsdPerEval: maxCostUsd,
93
+ maxOutputTokens: args.max_output_tokens,
94
+ temperature: args.temperature,
95
+ timeoutMs: args.timeout_ms,
96
+ });
97
+ const evalId = generateEvalId();
98
+ // Persist as a normal eval_result so the dashboard picks it up
99
+ // alongside heuristic scores. eval_type is 'custom' because LLM
100
+ // judge doesn't fit completeness/relevance/safety/cost taxonomy
101
+ // cleanly — it spans all four. The rule_results payload carries
102
+ // the full judge provenance.
103
+ await storage.insertEvalResult(LOCAL_TENANT, {
104
+ id: evalId,
105
+ trace_id: args.trace_id,
106
+ eval_type: 'custom',
107
+ output_text: args.output,
108
+ expected_text: args.expected,
109
+ score: result.score,
110
+ passed: result.passed,
111
+ rule_results: [
112
+ {
113
+ ruleName: `llm_judge:${result.template}:${result.provider}/${result.model}`,
114
+ passed: result.passed,
115
+ score: result.score,
116
+ message: result.rationale || 'LLM judge evaluation',
117
+ },
118
+ ],
119
+ suggestions: result.passed ? [] : [result.rationale],
120
+ rules_evaluated: 1,
121
+ rules_skipped: 0,
122
+ insufficient_data: false,
123
+ });
124
+ return {
125
+ content: [
126
+ {
127
+ type: 'text',
128
+ text: JSON.stringify({
129
+ id: evalId,
130
+ score: result.score,
131
+ passed: result.passed,
132
+ rationale: result.rationale,
133
+ dimensions: result.dimensions,
134
+ model: result.model,
135
+ provider: result.provider,
136
+ template: result.template,
137
+ input_tokens: result.inputTokens,
138
+ output_tokens: result.outputTokens,
139
+ cost_usd: result.costUsd,
140
+ latency_ms: result.latencyMs,
141
+ raw_response_id: result.rawResponseId,
142
+ }),
143
+ },
144
+ ],
145
+ };
146
+ });
147
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { LOCAL_TENANT } from '../types/tenant.js';
2
3
  const inputSchema = {
3
4
  agent_name: z.string().optional().describe('Filter by agent name'),
4
5
  framework: z.string().optional().describe('Filter by framework'),
@@ -15,10 +16,29 @@ const inputSchema = {
15
16
  export function registerGetTracesTool(server, storage) {
16
17
  server.registerTool('get_traces', {
17
18
  title: 'Get Traces',
18
- description: 'Query stored traces with filters, pagination, and optional summary stats',
19
+ description: [
20
+ 'Query stored agent-execution traces with filters, pagination, and optional dashboard summary.',
21
+ '',
22
+ '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.',
23
+ '',
24
+ '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`.',
25
+ '',
26
+ '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.',
27
+ '',
28
+ 'Don\'t use to score a trace (use evaluate_output). Don\'t use to create a trace (use log_trace). Don\'t use as a live event stream — it\'s a query, not a subscription; poll with exponential backoff or use the dashboard\'s SSE endpoint for real-time.',
29
+ '',
30
+ 'Error modes. Returns 400 on invalid sort_by / sort_order (Zod enum). Returns 400 if limit > 1000. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. Empty result with `total: 0` on no matches (not an error).',
31
+ ].join('\n'),
19
32
  inputSchema,
33
+ annotations: {
34
+ readOnlyHint: true, // Pure query: never writes, never deletes
35
+ destructiveHint: false, // Inverse of readOnly — trivially false
36
+ idempotentHint: true, // Same args → same result (modulo new traces that may have landed since)
37
+ openWorldHint: false, // Queries local storage only; no external network
38
+ },
20
39
  }, async (args) => {
21
- const result = await storage.queryTraces({
40
+ // OSS single-tenant: MCP caller is the local user.
41
+ const result = await storage.queryTraces(LOCAL_TENANT, {
22
42
  filter: {
23
43
  agent_name: args.agent_name,
24
44
  framework: args.framework,
@@ -39,7 +59,7 @@ export function registerGetTracesTool(server, storage) {
39
59
  offset: result.offset,
40
60
  };
41
61
  if (args.include_summary) {
42
- response.summary = await storage.getDashboardSummary();
62
+ response.summary = await storage.getDashboardSummary(LOCAL_TENANT);
43
63
  }
44
64
  return {
45
65
  content: [
@@ -1,4 +1,5 @@
1
1
  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
- export declare function registerAllTools(server: McpServer, storage: IStorageAdapter, evalEngine: EvalEngine): void;
4
+ import type { CustomRuleStore } from '../custom-rule-store.js';
5
+ export declare function registerAllTools(server: McpServer, storage: IStorageAdapter, evalEngine: EvalEngine, customRuleStore: CustomRuleStore): void;
@@ -1,8 +1,20 @@
1
1
  import { registerLogTraceTool } from './log-trace.js';
2
2
  import { registerEvaluateOutputTool } from './evaluate-output.js';
3
3
  import { registerGetTracesTool } from './get-traces.js';
4
- export function registerAllTools(server, storage, evalEngine) {
4
+ import { registerListRulesTool } from './list-rules.js';
5
+ import { registerDeployRuleTool } from './deploy-rule.js';
6
+ import { registerDeleteRuleTool } from './delete-rule.js';
7
+ import { registerDeleteTraceTool } from './delete-trace.js';
8
+ import { registerEvaluateWithLLMJudgeTool } from './evaluate-with-llm-judge.js';
9
+ import { registerVerifyCitationsTool } from './verify-citations.js';
10
+ export function registerAllTools(server, storage, evalEngine, customRuleStore) {
5
11
  registerLogTraceTool(server, storage);
6
12
  registerEvaluateOutputTool(server, storage, evalEngine);
7
13
  registerGetTracesTool(server, storage);
14
+ registerListRulesTool(server, customRuleStore);
15
+ registerDeployRuleTool(server, customRuleStore);
16
+ registerDeleteRuleTool(server, customRuleStore);
17
+ registerDeleteTraceTool(server, storage);
18
+ registerEvaluateWithLLMJudgeTool(server, storage);
19
+ registerVerifyCitationsTool(server, storage);
8
20
  }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { CustomRuleStore } from '../custom-rule-store.js';
3
+ export declare function registerListRulesTool(server: McpServer, customRuleStore: CustomRuleStore): void;
@@ -0,0 +1,66 @@
1
+ /*
2
+ * list_rules MCP tool — enumerate deployed custom rules.
3
+ *
4
+ * Read-only view into the custom-rule store (~/.iris/custom-rules.json).
5
+ * Lets agents discover what rules are deployed, what each one evaluates,
6
+ * and which are enabled — so an agent can decide whether to call
7
+ * evaluate_output at all, and which eval_type to route through.
8
+ *
9
+ * Companion to deploy_rule / delete_rule. Together these replace the
10
+ * dashboard-only Make-This-A-Rule composer when an agent (not a human)
11
+ * needs to manage the rule set programmatically.
12
+ */
13
+ import { z } from 'zod';
14
+ const inputSchema = {
15
+ eval_type: z
16
+ .enum(['completeness', 'relevance', 'safety', 'cost', 'custom'])
17
+ .optional()
18
+ .describe('Filter to rules of a specific eval category'),
19
+ enabled_only: z
20
+ .boolean()
21
+ .default(false)
22
+ .describe('Return only enabled rules (excludes disabled ones)'),
23
+ };
24
+ export function registerListRulesTool(server, customRuleStore) {
25
+ server.registerTool('list_rules', {
26
+ title: 'List Custom Rules',
27
+ description: [
28
+ 'Enumerate deployed custom evaluation rules from the local rule store.',
29
+ '',
30
+ 'Behavior. Pure read of ~/.iris/custom-rules.json (in-memory cached; no disk read per call after server boot). No mutation, no external network. Tenant-scoped in Cloud tier; OSS returns all rules for the single local tenant. Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Returns in <5ms.',
31
+ '',
32
+ 'Output shape. Returns JSON: `{ "rules": [{ "id": "rule-XXXX", "name", "description?", "evalType", "severity", "definition": { type, config, weight? }, "enabled": boolean, "deployedAt": ISO timestamp, "sourceMomentId?": string }], "total": number, "enabled_count": number }`. Empty array + total=0 when no rules deployed.',
33
+ '',
34
+ 'Use when you need to know what custom rules are currently live (before calling evaluate_output, before deploying a similar rule to avoid duplicates, or when building a dashboard view). Filter with `eval_type` to scope to a specific category, or `enabled_only: true` to exclude disabled rules. Use get_traces to see trace data; use evaluate_output to run scoring; use list_rules only when you need the RULE INVENTORY.',
35
+ '',
36
+ "Don't use to count traces or evals (that's get_traces). Don't use to inspect built-in (non-custom) rules — those ship with the iris binary and are listed in docs/api-reference.md, not in the rule store. Don't use to deploy a rule (use deploy_rule); don't use to remove one (use delete_rule).",
37
+ '',
38
+ "Error modes. Returns empty list if the rule store file doesn't exist (first run). Returns 429 if HTTP rate limit exceeded. Never throws on valid input.",
39
+ ].join('\n'),
40
+ inputSchema,
41
+ annotations: {
42
+ readOnlyHint: true,
43
+ destructiveHint: false,
44
+ idempotentHint: true,
45
+ openWorldHint: false,
46
+ },
47
+ }, async (args) => {
48
+ let rules = customRuleStore.list();
49
+ if (args.eval_type) {
50
+ rules = rules.filter((r) => r.evalType === args.eval_type);
51
+ }
52
+ if (args.enabled_only) {
53
+ rules = rules.filter((r) => r.enabled);
54
+ }
55
+ const total = rules.length;
56
+ const enabled_count = rules.filter((r) => r.enabled).length;
57
+ return {
58
+ content: [
59
+ {
60
+ type: 'text',
61
+ text: JSON.stringify({ rules, total, enabled_count }),
62
+ },
63
+ ],
64
+ };
65
+ });
66
+ }
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { generateTraceId, generateSpanId } from '../utils/ids.js';
3
+ import { LOCAL_TENANT } from '../types/tenant.js';
4
+ import { bestEffortExport } from '../otel/lazy.js';
3
5
  const ToolCallSchema = z.object({
4
6
  tool_name: z.string(),
5
7
  input: z.unknown().optional(),
@@ -44,8 +46,26 @@ const inputSchema = {
44
46
  export function registerLogTraceTool(server, storage) {
45
47
  server.registerTool('log_trace', {
46
48
  title: 'Log Trace',
47
- description: 'Log an agent execution trace with spans, tool calls, and metrics',
49
+ description: [
50
+ 'Persist a single agent execution trace (input, output, spans, tool calls, cost, latency, token usage).',
51
+ '',
52
+ 'Behavior. Writes one row to Iris storage (SQLite by default; Postgres in Cloud tier). When IRIS_OTEL_ENDPOINT is set, ALSO fires a best-effort async export to the configured OTLP/HTTP collector (Jaeger, Tempo, Datadog OTLP, OTEL Collector). The OTel export is fire-and-forget — its success does not affect the tool response; failures are logged but the trace is still stored locally. No authentication in stdio mode; HTTP mode requires Bearer token. Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Not idempotent: each call mints a fresh trace_id, so resubmitting the same payload creates a duplicate trace.',
53
+ '',
54
+ 'Output shape. Returns a JSON string: `{ "trace_id": "<32-hex>", "status": "stored" }`. The trace_id is the key you pass to evaluate_output or get_traces afterwards.',
55
+ '',
56
+ 'Use when you want to record an agent execution for later evaluation, analysis, or audit. Call it AFTER the agent has produced output; call evaluate_output afterwards to score it; call get_traces to query historical traces. Store rich context: spans (span tree), tool_calls (which tools were invoked with latency/errors), token_usage, cost_usd, metadata (arbitrary key-value). All optional except agent_name.',
57
+ '',
58
+ 'Don\'t use when you only need a transient log (use console logging). Don\'t use to update an existing trace — there is no update path in v0.4 (traces are immutable once stored).',
59
+ '',
60
+ 'Error modes. Throws on missing agent_name. Throws on malformed span or tool_call objects (Zod rejects). Returns 500 on storage failure (disk full, DB locked). Never blocks on the agent — returns within ~50ms for typical payloads.',
61
+ ].join('\n'),
48
62
  inputSchema,
63
+ annotations: {
64
+ readOnlyHint: false, // Writes a row to storage
65
+ destructiveHint: false, // Creates new data; doesn't overwrite or delete
66
+ idempotentHint: false, // Each call mints a fresh trace_id; duplicate payloads produce distinct traces
67
+ openWorldHint: false, // Local storage first. When IRIS_OTEL_ENDPOINT is set a best-effort async OTel export runs but is non-blocking (tool succeeds even if export fails).
68
+ },
49
69
  }, async (args) => {
50
70
  const traceId = generateTraceId();
51
71
  const timestamp = args.timestamp ?? new Date().toISOString();
@@ -67,7 +87,15 @@ export function registerLogTraceTool(server, storage) {
67
87
  trace_id: traceId,
68
88
  })),
69
89
  };
70
- await storage.insertTrace(trace);
90
+ await storage.insertTrace(LOCAL_TENANT, trace);
91
+ // Best-effort async OTel export (fire-and-forget). No-op when
92
+ // IRIS_OTEL_ENDPOINT isn't configured. Errors are logged via the
93
+ // server logger but never affect the tool response — if the OTel
94
+ // collector is down we still want to store traces locally.
95
+ bestEffortExport(trace, (err) => {
96
+ // eslint-disable-next-line no-console
97
+ console.warn(`[iris.otel] ${err.message}`);
98
+ });
71
99
  return {
72
100
  content: [
73
101
  {
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { IStorageAdapter } from '../types/query.js';
3
+ export declare function registerVerifyCitationsTool(server: McpServer, storage: IStorageAdapter): void;