@iris-eval/mcp-server 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +8 -1
  2. package/dist/capabilities.d.ts +64 -0
  3. package/dist/capabilities.js +65 -0
  4. package/dist/config/defaults.js +2 -0
  5. package/dist/custom-rule-store.d.ts +4 -0
  6. package/dist/custom-rule-store.js +8 -3
  7. package/dist/dashboard/assets/{index-CyzO6OC7.js → index-Cz8_oOqG.js} +1 -1
  8. package/dist/dashboard/index.html +1 -1
  9. package/dist/dashboard/routes/capabilities.d.ts +3 -0
  10. package/dist/dashboard/routes/capabilities.js +11 -0
  11. package/dist/dashboard/routes/health.d.ts +5 -1
  12. package/dist/dashboard/routes/health.js +15 -3
  13. package/dist/dashboard/routes/rules.js +4 -1
  14. package/dist/dashboard/routes/traces.d.ts +3 -0
  15. package/dist/dashboard/routes/traces.js +9 -28
  16. package/dist/dashboard/server.d.ts +2 -0
  17. package/dist/dashboard/server.js +6 -2
  18. package/dist/eval/accuracy.d.ts +41 -0
  19. package/dist/eval/accuracy.js +97 -0
  20. package/dist/eval/criticality.d.ts +8 -1
  21. package/dist/eval/criticality.js +6 -0
  22. package/dist/eval/dormant.d.ts +4 -0
  23. package/dist/eval/dormant.js +22 -0
  24. package/dist/eval/engine.d.ts +1 -0
  25. package/dist/eval/engine.js +49 -3
  26. package/dist/eval/failure-classes.d.ts +8 -0
  27. package/dist/eval/failure-classes.js +18 -0
  28. package/dist/eval/llm-judge/evaluator.d.ts +10 -0
  29. package/dist/eval/llm-judge/evaluator.js +16 -1
  30. package/dist/eval/published-accuracy.d.ts +230 -0
  31. package/dist/eval/published-accuracy.js +86 -0
  32. package/dist/eval/questions.d.ts +12 -0
  33. package/dist/eval/questions.js +14 -0
  34. package/dist/eval/response-schema.d.ts +652 -0
  35. package/dist/eval/response-schema.js +130 -0
  36. package/dist/eval/response.d.ts +12 -0
  37. package/dist/eval/response.js +30 -0
  38. package/dist/eval/rules/completeness.js +31 -0
  39. package/dist/eval/rules/cost.d.ts +1 -1
  40. package/dist/eval/rules/cost.js +44 -0
  41. package/dist/eval/rules/custom.d.ts +0 -12
  42. package/dist/eval/rules/custom.js +21 -0
  43. package/dist/eval/rules/relevance.js +16 -0
  44. package/dist/eval/rules/safety.js +178 -1
  45. package/dist/eval/stamp.d.ts +14 -0
  46. package/dist/eval/stamp.js +88 -0
  47. package/dist/eval/stats.d.ts +33 -0
  48. package/dist/eval/stats.js +109 -0
  49. package/dist/eval/verdict.d.ts +34 -0
  50. package/dist/eval/verdict.js +131 -0
  51. package/dist/index.js +5 -28
  52. package/dist/instructions.d.ts +17 -0
  53. package/dist/instructions.js +53 -0
  54. package/dist/judge-enablement.d.ts +34 -0
  55. package/dist/judge-enablement.js +78 -0
  56. package/dist/judge-enablement.json +10 -0
  57. package/dist/preferences.d.ts +1 -1
  58. package/dist/prompts.d.ts +3 -0
  59. package/dist/prompts.js +29 -0
  60. package/dist/resources/index.d.ts +5 -2
  61. package/dist/resources/index.js +65 -5
  62. package/dist/resources/uris.d.ts +12 -0
  63. package/dist/resources/uris.js +24 -0
  64. package/dist/retention.d.ts +20 -0
  65. package/dist/retention.js +44 -0
  66. package/dist/self-test.d.ts +1 -0
  67. package/dist/self-test.js +14 -0
  68. package/dist/server.d.ts +10 -1
  69. package/dist/server.js +34 -7
  70. package/dist/storage/index.js +1 -1
  71. package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
  72. package/dist/storage/migrations/007-eval-provenance.js +30 -0
  73. package/dist/storage/migrations/index.js +24 -4
  74. package/dist/storage/sqlite-adapter.d.ts +26 -1
  75. package/dist/storage/sqlite-adapter.js +135 -15
  76. package/dist/tools/delete-rule.d.ts +8 -0
  77. package/dist/tools/delete-rule.js +30 -38
  78. package/dist/tools/delete-trace.d.ts +5 -0
  79. package/dist/tools/delete-trace.js +24 -27
  80. package/dist/tools/deploy-rule.d.ts +13 -1
  81. package/dist/tools/deploy-rule.js +37 -34
  82. package/dist/tools/describe.d.ts +20 -0
  83. package/dist/tools/describe.js +36 -0
  84. package/dist/tools/errors.d.ts +36 -0
  85. package/dist/tools/errors.js +134 -0
  86. package/dist/tools/evaluate-output.d.ts +8 -1
  87. package/dist/tools/evaluate-output.js +37 -58
  88. package/dist/tools/evaluate-with-llm-judge.d.ts +31 -0
  89. package/dist/tools/evaluate-with-llm-judge.js +96 -69
  90. package/dist/tools/get-traces.d.ts +9 -0
  91. package/dist/tools/get-traces.js +29 -28
  92. package/dist/tools/index.d.ts +8 -0
  93. package/dist/tools/index.js +22 -1
  94. package/dist/tools/list-rules.d.ts +13 -0
  95. package/dist/tools/list-rules.js +43 -46
  96. package/dist/tools/log-trace.d.ts +4 -0
  97. package/dist/tools/log-trace.js +31 -29
  98. package/dist/tools/respond.d.ts +42 -0
  99. package/dist/tools/respond.js +90 -0
  100. package/dist/tools/strict-input.js +1 -1
  101. package/dist/tools/trace-link.d.ts +2 -0
  102. package/dist/tools/trace-link.js +13 -2
  103. package/dist/tools/verify-citations.d.ts +17 -2
  104. package/dist/tools/verify-citations.js +98 -93
  105. package/dist/types/config.d.ts +9 -0
  106. package/dist/types/eval.d.ts +258 -0
  107. package/dist/types/eval.js +2 -1
  108. package/dist/types/query.d.ts +2 -0
  109. package/package.json +1 -1
  110. package/server.json +2 -2
  111. package/dist/resources/dashboard-summary.d.ts +0 -3
  112. package/dist/resources/dashboard-summary.js +0 -16
  113. package/dist/resources/trace-detail.d.ts +0 -3
  114. package/dist/resources/trace-detail.js +0 -30
@@ -1,4 +1,17 @@
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';
5
+ export declare const listRulesOutputSchema: z.ZodObject<{
6
+ rules: z.ZodArray<z.ZodObject<{
7
+ id: z.ZodString;
8
+ name: z.ZodString;
9
+ }, z.core.$loose>>;
10
+ total: z.ZodNumber;
11
+ enabled_count: z.ZodNumber;
12
+ built_in: z.ZodArray<z.ZodObject<{
13
+ name: z.ZodString;
14
+ }, z.core.$loose>>;
15
+ quarantined: z.ZodArray<z.ZodUnknown>;
16
+ }, z.core.$loose>;
4
17
  export declare function registerListRulesTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;
@@ -1,59 +1,66 @@
1
1
  /*
2
- * list_rules MCP tool — enumerate deployed custom rules.
2
+ * list_rules MCP tool — the rule inventory.
3
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.
4
+ * Two halves. `built_in` is the shipped roster with everything a caller
5
+ * needs to trust a verdict: what each rule is (kind, mechanism), what it
6
+ * reads (needs absent means it skips), the question it answers, the
7
+ * criticality THIS server applies and who decided it, and its published
8
+ * accuracy. `rules` is the custom-rule store (~/.iris/custom-rules.json),
9
+ * the read path deploy_rule and delete_rule write to.
12
10
  */
13
11
  import { z } from 'zod';
14
12
  import { builtInRuleRoster } from '../eval/criticality.js';
13
+ import { ruleProof } from '../capabilities.js';
15
14
  import { LOCAL_TENANT } from '../types/tenant.js';
16
15
  import { strictInput } from './strict-input.js';
16
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
17
+ import { guarded, respond } from './respond.js';
18
+ import { PROOF_RESOURCE_URI } from '../resources/uris.js';
17
19
  const inputSchema = {
18
20
  eval_type: z
19
21
  .enum(['completeness', 'relevance', 'safety', 'cost', 'custom'])
20
22
  .optional()
21
- .describe('Filter to rules of a specific eval category'),
23
+ .describe('Filter the custom rules to one eval category (exact match); built_in is never filtered'),
22
24
  enabled_only: z
23
25
  .boolean()
24
26
  .default(false)
25
- .describe('Return only enabled rules (excludes disabled ones)'),
27
+ .describe('Return only enabled custom rules (a rule disabled with delete_rule stays in the store and does not fire)'),
26
28
  };
29
+ export const listRulesOutputSchema = z.looseObject({
30
+ rules: z.array(z.looseObject({ id: z.string(), name: z.string() })).describe('the deployed custom rules after the filters: id, name, description, evalType, severity, definition, enabled, createdAt, updatedAt, version, sourceMomentId'),
31
+ total: z.number().int().describe('custom rules after the filters'),
32
+ enabled_count: z.number().int().describe('of those, how many are enabled'),
33
+ built_in: z.array(z.looseObject({ name: z.string() })).describe('the shipped roster, never filtered: name, category, description, weight, kind, mechanism, needs, question, classes, version, the EFFECTIVE critical flag with criticalSource, and proof (published precision, recall, intervals and ppvAt from https://iris-eval.com/proof; null where the proof is a conformance check)'),
34
+ quarantined: z.array(z.unknown()).describe('entries in the store this version could not validate; they do not fire and are never deleted by a deploy'),
35
+ });
27
36
  export function registerListRulesTool(server, customRuleStore, evalEngine) {
28
37
  server.registerTool('list_rules', {
29
- title: 'List Custom Rules',
30
- description: [
31
- 'Enumerate deployed custom evaluation rules from the local rule store.',
32
- '',
33
- 'Sibling tools deploy_rule adds custom rules, delete_rule removes them, evaluate_output runs them against agent output. log_trace / get_traces / delete_trace handle the trace lifecycle separately. list_rules is the READ path for the custom-rule store; nothing else exposes the inventory.',
34
- '',
35
- 'Behavior. Pure read of ~/.iris/custom-rules.json (in-memory cached; no disk read per call after server boot). No mutation, no external network. Returns the rules of the local tenant. Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio.',
36
- '',
37
- 'Output shape. Returns JSON: `{ "rules": [{ "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition": { name, type, config, weight? }, "enabled": boolean, "createdAt": ISO timestamp, "updatedAt": ISO timestamp, "version": number, "sourceMomentId?": string }], "total": number, "enabled_count": number, "built_in": [{ "name", "category", "weight", "critical": boolean, "criticalSource": "default" | "config" }] }`. Empty `rules` array + total=0 when no custom rules are deployed. A deployed rule fires only on evaluate_output calls whose eval_type equals its evalType (or eval_type="all", which runs every bundle). `built_in` is the shipped rule set, always present and NOT narrowed by the filters; `total` and `enabled_count` count custom rules only.',
38
- '',
39
- 'Why `built_in` carries criticality. A critical rule vetoes `passed` regardless of the weighted score, and which built-in rules are critical is configurable (`eval.criticalRules` / `eval.nonCriticalRules`). `critical` is the EFFECTIVE value this server applies and `criticalSource` says who decided it: `default` is the declaration on the rule itself, `config` means one of those lists named it. Read it before trusting a `passed: true` — it is how you tell "nothing was violated" from "the rule that would have vetoed is demoted on this server".',
40
- '',
41
- '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.',
42
- '',
43
- "Don't use to count traces or evals (that's get_traces). Don't use to deploy a rule (use deploy_rule); don't use to remove one (use delete_rule). Built-in rules are not in the store and cannot be deployed, deleted or disabled — they appear under `built_in` for reference, carrying the criticality this server applies.",
44
- '',
45
- 'Parameters. eval_type filter is exact-match against each rule\'s evalType field (no wildcards). enabled_only excludes rules that are deployed-but-disabled — a rule is disabled without deleting it via delete_rule with `enabled: false` (and re-enabled with `enabled: true`), or from the dashboard; disabled rules stay in the store with their history but do not fire. Both filters are AND-combined when both are set. Both are optional; with no filter, all rules return. Defaults: eval_type=undefined (no filter), enabled_only=false (returns all rules including disabled).',
46
- '',
47
- "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.",
48
- ].join('\n'),
38
+ title: 'List Rules',
39
+ description: describeTool({
40
+ summary: 'The rule inventory: the built-in roster with what each rule needs, the criticality this server applies and its published accuracy, plus every deployed custom rule.',
41
+ does: 'Read-only, no network. built_in is the shipped roster and is never narrowed by the filters. For each rule: kind (measurement, detection, inference, judgment, policy, verification), mechanism, needs (the inputs it reads — absent means the rule skips, never passes), question, classes, version, weight, ' +
42
+ 'the EFFECTIVE critical flag with criticalSource (default, or config when eval.criticalRules / eval.nonCriticalRules changed it on this server read it before trusting a passed: true), ' +
43
+ 'and proof: precision and recall with 95% intervals and the positive predictive value at four prevalences, the numbers published at https://iris-eval.com/proof. ' +
44
+ 'rules is the custom-rule store, filterable by eval_type and enabled_only; total and enabled_count count custom rules. quarantined lists store entries this version could not validate; they do not fire.',
45
+ whenNot: 'To count traces (get_traces). To add, remove or pause a rule (deploy_rule, delete_rule). Built-in rules are not in the store and cannot be deployed, deleted or disabled.',
46
+ returns: listRulesOutputSchema,
47
+ errors: 'IRIS_INTERNAL_ERROR if the store file cannot be read. A missing store file is an empty list, not an error. ' +
48
+ ERROR_ENVELOPE_SENTENCE,
49
+ siblings: {
50
+ deploy_rule: 'add a custom rule',
51
+ delete_rule: 'remove, disable or re-enable one',
52
+ evaluate_output: 'run the rules',
53
+ },
54
+ }),
49
55
  inputSchema: strictInput(inputSchema),
56
+ outputSchema: listRulesOutputSchema,
50
57
  annotations: {
51
58
  readOnlyHint: true,
52
59
  destructiveHint: false,
53
60
  idempotentHint: true,
54
61
  openWorldHint: false,
55
62
  },
56
- }, async (args) => {
63
+ }, guarded(async (args) => {
57
64
  // OSS: MCP tools operate under LOCAL_TENANT. Cloud multi-tenant
58
65
  // exposure is a v0.5 architectural item (MCP SDK doesn't pass
59
66
  // session/tenant context to tool handlers).
@@ -75,19 +82,9 @@ export function registerListRulesTool(server, customRuleStore, evalEngine) {
75
82
  * purpose — the filters describe the custom-rule store.
76
83
  */
77
84
  const built_in = builtInRuleRoster((rule) => evalEngine.effectiveCriticality(rule)).map((r) => ({
78
- name: r.name,
79
- category: r.category,
80
- weight: r.weight,
81
- critical: r.critical,
82
- criticalSource: r.criticalSource,
85
+ ...r,
86
+ proof: ruleProof(r.name),
83
87
  }));
84
- return {
85
- content: [
86
- {
87
- type: 'text',
88
- text: JSON.stringify({ rules, total, enabled_count, built_in }),
89
- },
90
- ],
91
- };
92
- });
88
+ return respond(listRulesOutputSchema, { rules, total, enabled_count, built_in, quarantined: customRuleStore.quarantined(LOCAL_TENANT) }, [{ uri: PROOF_RESOURCE_URI, name: 'proof', description: 'The published accuracy of every measured rule, with the corpus it was measured on' }]);
89
+ }));
93
90
  }
@@ -58,4 +58,8 @@ export declare const logTraceInputShape: {
58
58
  }, z.core.$strip>>>;
59
59
  timestamp: z.ZodOptional<z.ZodString>;
60
60
  };
61
+ export declare const logTraceOutputSchema: z.ZodObject<{
62
+ trace_id: z.ZodString;
63
+ status: z.ZodLiteral<"stored">;
64
+ }, z.core.$loose>;
61
65
  export declare function registerLogTraceTool(server: McpServer, storage: IStorageAdapter): void;
@@ -3,6 +3,9 @@ import { generateTraceId, generateSpanId } from '../utils/ids.js';
3
3
  import { LOCAL_TENANT } from '../types/tenant.js';
4
4
  import { bestEffortExport } from '../otel/lazy.js';
5
5
  import { strictInput, strictNested } from './strict-input.js';
6
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
7
+ import { guarded, respond } from './respond.js';
8
+ import { traceUri } from '../resources/uris.js';
6
9
  /*
7
10
  * The tool-call record — one entry of `tool_calls[]`.
8
11
  *
@@ -58,47 +61,51 @@ export const logTraceInputShape = {
58
61
  framework: z.string().optional().describe('Agent framework identifier (e.g., langchain, autogen, custom)'),
59
62
  input: z.string().optional().describe('Agent input text — the user prompt or upstream input that produced this output'),
60
63
  output: z.string().optional().describe('Agent output text — what the agent produced (pass to evaluate_output for scoring)'),
61
- tool_calls: z.array(toolCallSchema).optional().describe('Tool calls made during execution (per-call latency, errors, input/output)'),
64
+ tool_calls: z.array(toolCallSchema).optional().describe('Tool calls made during execution, in order, each { tool_name, input?, output?, latency_ms?, error? } — what the trajectory rules judge; evaluate_output reuses them when given this trace_id'),
62
65
  latency_ms: z.number().optional().describe('Total execution time in milliseconds (end-to-end agent latency)'),
63
66
  token_usage: TokenUsageSchema.optional().describe('Token usage breakdown (prompt/completion/total — used for cost analysis)'),
64
67
  cost_usd: z.number().optional().describe('Total cost in USD — overrides per-span aggregation when provided (treated as authoritative)'),
65
68
  metadata: z.record(z.string(), z.unknown()).optional().describe('Opaque key-value tags (e.g. {requestId, userId, env}) — queryable in dashboard, not via get_traces filters'),
66
- spans: z.array(SpanSchema).optional().describe('Detailed execution spans (hierarchical span tree with timings, attributes, events)'),
69
+ spans: z.array(SpanSchema).optional().describe('Detailed execution spans (hierarchical span tree with timings, attributes, events); a span without start_time takes the trace timestamp'),
67
70
  timestamp: z.string().optional().describe('Trace timestamp (ISO 8601); defaults to now() when omitted'),
68
71
  };
72
+ export const logTraceOutputSchema = z.looseObject({
73
+ trace_id: z.string().describe('the stored trace id, 32 hex — pass it to evaluate_output, get_traces or delete_trace'),
74
+ status: z.literal('stored').describe('always "stored" on success'),
75
+ });
69
76
  export function registerLogTraceTool(server, storage) {
70
77
  server.registerTool('log_trace', {
71
78
  title: 'Log Trace',
72
- description: [
73
- 'Persist a single agent execution trace (input, output, spans, tool calls, cost, latency, token usage).',
74
- '',
75
- 'Sibling tools evaluate_output runs heuristic scoring on the trace; evaluate_with_llm_judge runs semantic LLM-based scoring; verify_citations checks citation grounding; get_traces queries stored traces; delete_trace removes a single trace; list_rules / deploy_rule / delete_rule manage custom evaluation rules. log_trace is the WRITE path that records executions; everything else reads, scores, or manages around it.',
76
- '',
77
- 'Behavior. Writes one row to Iris storage (SQLite). 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 a Bearer token ONLY when --api-key / IRIS_API_KEY is set (recommended); with no key configured the auth middleware is a pass-through and writes are unauthenticated — a default HTTP server is protected by its loopback bind (127.0.0.1) and Origin validation, not by a credential. 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.',
78
- '',
79
- '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.',
80
- '',
81
- '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.',
82
- '',
83
- '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 (traces are immutable once stored).',
84
- '',
85
- 'Parameters. agent_name is required; everything else is optional. token_usage and cost_usd are summary fields — if you ALSO pass spans with per-tool-call costs, the summary fields are treated as authoritative (no auto-aggregation). spans without an explicit start_time fall back to the trace timestamp; spans with an end_time get a duration_ms derived. metadata is opaque key-value (queryable in the dashboard, not via get_traces filters). tool_calls record per-tool latency + errors; missing latency_ms means "not reported," not "zero." Defaults: span.kind="INTERNAL", span.status_code="UNSET", timestamp=now() if omitted.',
86
- '',
87
- '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 — the local write is synchronous and the OTel export is asynchronous.',
88
- ].join('\n'),
79
+ description: describeTool({
80
+ summary: 'Store one agent execution input, output, tool calls, spans, cost, latency, token usage — and get the trace_id every later call keys on.',
81
+ does: 'Writes one trace row to local SQLite and mints a fresh trace_id; nothing is deduplicated, so resubmitting the same payload stores a second trace. ' +
82
+ 'Only agent_name is required. Store what you have: tool_calls so the trajectory rules can later judge what the agent did, cost_usd and token_usage so the cost rules can, input and output so everything else can. ' +
83
+ 'When IRIS_OTEL_ENDPOINT is set the trace is also exported to that collector, best-effort and asynchronous; the local write never waits on it. ' +
84
+ 'Traces are immutable: there is no update path. In stdio mode nothing authenticates the caller; over HTTP a Bearer token is required only when an API key is configured.',
85
+ whenNot: 'For a transient log line (use your logger). To score an output: log first, then call evaluate_output with the trace_id, which also lets it reuse the stored tool_calls. To change a stored trace: delete_trace and log again.',
86
+ returns: logTraceOutputSchema,
87
+ errors: 'IRIS_STORAGE_ERROR when the database cannot be written. An unknown argument or a malformed span or tool_calls entry is refused before the handler runs, naming the valid keys. ' +
88
+ ERROR_ENVELOPE_SENTENCE,
89
+ siblings: {
90
+ evaluate_output: 'score the stored output',
91
+ get_traces: 'query what was logged',
92
+ delete_trace: 'remove one trace',
93
+ },
94
+ }),
89
95
  // Strict at the MCP boundary (unknown args rejected, not stripped).
90
96
  // The dashboard's HTTP ingest builds its own — equally strict —
91
97
  // schema FROM this shape (dashboard/validation.ts): a client-supplied
92
98
  // trace_id is rejected there with a 400 whose message says the server
93
99
  // mints it, exactly as this tool mints its own in the handler below.
94
100
  inputSchema: strictInput(logTraceInputShape),
101
+ outputSchema: logTraceOutputSchema,
95
102
  annotations: {
96
103
  readOnlyHint: false, // Writes a row to storage
97
104
  destructiveHint: false, // Creates new data; doesn't overwrite or delete
98
105
  idempotentHint: false, // Each call mints a fresh trace_id; duplicate payloads produce distinct traces
99
106
  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).
100
107
  },
101
- }, async (args) => {
108
+ }, guarded(async (args) => {
102
109
  const traceId = generateTraceId();
103
110
  const timestamp = args.timestamp ?? new Date().toISOString();
104
111
  const trace = {
@@ -128,13 +135,8 @@ export function registerLogTraceTool(server, storage) {
128
135
  // eslint-disable-next-line no-console
129
136
  console.warn(`[iris.otel] ${err.message}`);
130
137
  });
131
- return {
132
- content: [
133
- {
134
- type: 'text',
135
- text: JSON.stringify({ trace_id: traceId, status: 'stored' }),
136
- },
137
- ],
138
- };
139
- });
138
+ return respond(logTraceOutputSchema, { trace_id: traceId, status: 'stored' }, [
139
+ { uri: traceUri(traceId), name: `trace ${traceId}`, description: 'The stored trace with its spans and, later, its evaluations' },
140
+ ]);
141
+ }));
140
142
  }
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
+ import { IrisError } from './errors.js';
4
+ export declare const CAPABILITIES_URI = "iris://capabilities";
5
+ export interface ResourceLink {
6
+ uri: string;
7
+ name: string;
8
+ description?: string;
9
+ }
10
+ export declare const CAPABILITIES_LINK: ResourceLink;
11
+ export declare function respond<S extends z.ZodType>(schema: S, payload: object, links?: ResourceLink[]): CallToolResult;
12
+ export declare const errorEnvelopeSchema: z.ZodObject<{
13
+ error: z.ZodObject<{
14
+ code: z.ZodEnum<{
15
+ IRIS_INVALID_ARGUMENT: "IRIS_INVALID_ARGUMENT";
16
+ IRIS_UNKNOWN_TRACE: "IRIS_UNKNOWN_TRACE";
17
+ IRIS_DUPLICATE_RULE: "IRIS_DUPLICATE_RULE";
18
+ IRIS_INVALID_RULE_CONFIG: "IRIS_INVALID_RULE_CONFIG";
19
+ IRIS_JUDGE_NOT_ENABLED: "IRIS_JUDGE_NOT_ENABLED";
20
+ IRIS_JUDGE_UNKNOWN_MODEL: "IRIS_JUDGE_UNKNOWN_MODEL";
21
+ IRIS_BUDGET_EXCEEDED: "IRIS_BUDGET_EXCEEDED";
22
+ IRIS_PROVIDER_ERROR: "IRIS_PROVIDER_ERROR";
23
+ IRIS_JUDGE_FAILED: "IRIS_JUDGE_FAILED";
24
+ IRIS_STORAGE_ERROR: "IRIS_STORAGE_ERROR";
25
+ IRIS_INTERNAL_ERROR: "IRIS_INTERNAL_ERROR";
26
+ }>;
27
+ message: z.ZodString;
28
+ recovery: z.ZodArray<z.ZodString>;
29
+ retryable: z.ZodBoolean;
30
+ field: z.ZodOptional<z.ZodString>;
31
+ valid: z.ZodOptional<z.ZodArray<z.ZodString>>;
32
+ see: z.ZodOptional<z.ZodString>;
33
+ kind: z.ZodOptional<z.ZodString>;
34
+ retryAfterMs: z.ZodOptional<z.ZodNumber>;
35
+ }, z.core.$loose>;
36
+ }, z.core.$loose>;
37
+ export type ErrorEnvelope = z.infer<typeof errorEnvelopeSchema>;
38
+ export declare function errorResult(err: IrisError): CallToolResult;
39
+ /** Wrap a handler so every failure returns an envelope instead of a flattened line. */
40
+ export declare function guarded<A extends unknown[]>(fn: (...args: A) => Promise<CallToolResult> | CallToolResult): (...args: A) => Promise<CallToolResult>;
41
+ /** Links for what an evaluation created: the evaluation, and the trace when linked. */
42
+ export declare function evaluationLinks(evalId: string, traceId?: string): ResourceLink[];
@@ -0,0 +1,90 @@
1
+ /*
2
+ * One way to answer a tool call.
3
+ *
4
+ * `respond` parses the payload through the tool's own output schema
5
+ * BEFORE serialising it — a field the schema does not describe fails a
6
+ * test, not a user — and emits the same object twice: as the text a
7
+ * client without structured-content support reads, and as
8
+ * `structuredContent` for one that has it. Beside the payload go
9
+ * `resource_link` items for what the call created and what explains its
10
+ * limits, so a client can follow them with resources/read instead of
11
+ * guessing a URI.
12
+ *
13
+ * `errorResult` is the failure shape: the IrisError envelope as the text
14
+ * and as structuredContent, `isError: true`, and a link to
15
+ * iris://capabilities. `guarded` wraps a handler so nothing thrown inside
16
+ * it reaches the SDK's flattener.
17
+ */
18
+ import { z } from 'zod';
19
+ import { ERROR_CODE_CATALOGUE, toIrisError } from './errors.js';
20
+ import { CAPABILITIES_RESOURCE_URI, evaluationUri, traceUri } from '../resources/uris.js';
21
+ export const CAPABILITIES_URI = CAPABILITIES_RESOURCE_URI;
22
+ export const CAPABILITIES_LINK = {
23
+ uri: CAPABILITIES_URI,
24
+ name: 'capabilities',
25
+ description: 'What this server can judge, what each rule needs, judge state, limits, tools and resources',
26
+ };
27
+ const linkItem = (l) => ({
28
+ type: 'resource_link',
29
+ uri: l.uri,
30
+ name: l.name,
31
+ ...(l.description ? { description: l.description } : {}),
32
+ mimeType: 'application/json',
33
+ });
34
+ /** JSON round-trip: what the text carries is exactly what structuredContent carries. */
35
+ function normalise(payload) {
36
+ return JSON.parse(JSON.stringify(payload));
37
+ }
38
+ export function respond(schema, payload, links = []) {
39
+ const body = normalise(payload);
40
+ const parsed = schema.safeParse(body);
41
+ if (!parsed.success) {
42
+ // A programming error: the tool built a response its own schema does
43
+ // not describe. Loud on purpose — the drift-lock tests catch it.
44
+ const issues = parsed.error.issues.slice(0, 3).map((i) => `${i.path.map(String).join('.') || '(root)'}: ${i.message}`);
45
+ throw new Error(`response does not match the tool's output schema: ${issues.join('; ')}`);
46
+ }
47
+ return {
48
+ content: [{ type: 'text', text: JSON.stringify(body) }, ...links.map(linkItem)],
49
+ structuredContent: body,
50
+ };
51
+ }
52
+ export const errorEnvelopeSchema = z.looseObject({
53
+ error: z.looseObject({
54
+ code: z.enum(ERROR_CODE_CATALOGUE),
55
+ message: z.string(),
56
+ recovery: z.array(z.string()),
57
+ retryable: z.boolean(),
58
+ field: z.string().optional(),
59
+ valid: z.array(z.string()).optional(),
60
+ see: z.string().optional(),
61
+ kind: z.string().optional(),
62
+ retryAfterMs: z.number().optional(),
63
+ }),
64
+ });
65
+ export function errorResult(err) {
66
+ const body = normalise({ error: err.envelope });
67
+ return {
68
+ content: [{ type: 'text', text: JSON.stringify(body) }, linkItem(CAPABILITIES_LINK)],
69
+ structuredContent: body,
70
+ isError: true,
71
+ };
72
+ }
73
+ /** Wrap a handler so every failure returns an envelope instead of a flattened line. */
74
+ export function guarded(fn) {
75
+ return async (...args) => {
76
+ try {
77
+ return await fn(...args);
78
+ }
79
+ catch (err) {
80
+ return errorResult(toIrisError(err));
81
+ }
82
+ };
83
+ }
84
+ /** Links for what an evaluation created: the evaluation, and the trace when linked. */
85
+ export function evaluationLinks(evalId, traceId) {
86
+ const links = [{ uri: evaluationUri(evalId), name: `evaluation ${evalId}`, description: 'The stored evaluation, as every reader sees it' }];
87
+ if (traceId)
88
+ links.push({ uri: traceUri(traceId), name: `trace ${traceId}`, description: 'The trace this evaluation is linked to, with its spans and every evaluation' });
89
+ return links;
90
+ }
@@ -29,7 +29,7 @@ export function strictInput(shape) {
29
29
  `Valid arguments: ${validKeys}. ` +
30
30
  'Unknown arguments are rejected rather than silently ignored, so a misspelled ' +
31
31
  'argument name cannot change what gets evaluated — check the spelling against ' +
32
- "the tool's input schema and retry."
32
+ "the tool's input schema and retry. Code IRIS_INVALID_ARGUMENT."
33
33
  : undefined,
34
34
  });
35
35
  }
@@ -11,6 +11,8 @@ export declare function unknownTraceMessage(traceId: string): string;
11
11
  * existence check had to load the row anyway. Fetching it twice would be
12
12
  * two reads for one fact — and two chances for them to disagree.
13
13
  */
14
+ /** The IRIS_UNKNOWN_TRACE error, built once for both the pre-check and the insert race. */
15
+ export declare function unknownTraceError(traceId: string): import("./errors.js").IrisError;
14
16
  export declare function getTraceOrThrow(storage: IStorageAdapter, tenantId: TenantId, traceId: string): Promise<Trace>;
15
17
  export declare function assertTraceExists(storage: IStorageAdapter, tenantId: TenantId, traceId: string): Promise<void>;
16
18
  /** insertEvalResult with the foreign-key race translated into the same clear message. */
@@ -1,3 +1,4 @@
1
+ import { irisError } from './errors.js';
1
2
  /*
2
3
  * Linking an evaluation to a trace that does not exist.
3
4
  *
@@ -26,10 +27,20 @@ export function unknownTraceMessage(traceId) {
26
27
  * existence check had to load the row anyway. Fetching it twice would be
27
28
  * two reads for one fact — and two chances for them to disagree.
28
29
  */
30
+ /** The IRIS_UNKNOWN_TRACE error, built once for both the pre-check and the insert race. */
31
+ export function unknownTraceError(traceId) {
32
+ return irisError('IRIS_UNKNOWN_TRACE', unknownTraceMessage(traceId), {
33
+ field: 'trace_id',
34
+ recovery: [
35
+ 'Pass the trace_id that log_trace returned, or one listed by get_traces.',
36
+ 'Or omit trace_id to store an unlinked evaluation.',
37
+ ],
38
+ });
39
+ }
29
40
  export async function getTraceOrThrow(storage, tenantId, traceId) {
30
41
  const trace = await storage.getTrace(tenantId, traceId);
31
42
  if (!trace)
32
- throw new Error(unknownTraceMessage(traceId));
43
+ throw unknownTraceError(traceId);
33
44
  return trace;
34
45
  }
35
46
  export async function assertTraceExists(storage, tenantId, traceId) {
@@ -44,7 +55,7 @@ export async function insertLinkedEvalResult(storage, tenantId, result) {
44
55
  const code = err.code;
45
56
  const message = err instanceof Error ? err.message : String(err);
46
57
  if (result.trace_id && (code === 'SQLITE_CONSTRAINT_FOREIGNKEY' || /FOREIGN KEY constraint failed/i.test(message))) {
47
- throw new Error(unknownTraceMessage(result.trace_id));
58
+ throw unknownTraceError(result.trace_id);
48
59
  }
49
60
  throw err;
50
61
  }
@@ -1,3 +1,4 @@
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';
3
4
  /**
@@ -5,8 +6,8 @@ import type { IStorageAdapter } from '../types/query.js';
5
6
  * was nothing to judge (no citations, none resolved). It is NOT the honest
6
7
  * answer when citations resolved and the judge then failed on every one —
7
8
  * a wrong API key, a model the provider refused, a parse failure — because
8
- * the caller reads "passed" and ships. That case is an error naming the
9
- * cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
9
+ * the caller reads "passed" and ships. That case is IRIS_JUDGE_FAILED
10
+ * naming the cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
10
11
  */
11
12
  export declare function assertJudgeRan(result: {
12
13
  totalResolved: number;
@@ -19,4 +20,18 @@ export declare function assertJudgeRan(result: {
19
20
  };
20
21
  }>;
21
22
  }): void;
23
+ export declare const verifyCitationsOutputSchema: z.ZodObject<{
24
+ id: z.ZodString;
25
+ trace_id: z.ZodOptional<z.ZodString>;
26
+ overall_score: z.ZodNullable<z.ZodNumber>;
27
+ passed: z.ZodBoolean;
28
+ total_citations_found: z.ZodNumber;
29
+ total_resolved: z.ZodNumber;
30
+ total_judged: z.ZodNumber;
31
+ total_supported: z.ZodNumber;
32
+ total_cost_usd: z.ZodNumber;
33
+ citations: z.ZodArray<z.ZodObject<{
34
+ resolve_status: z.ZodString;
35
+ }, z.core.$loose>>;
36
+ }, z.core.$loose>;
22
37
  export declare function registerVerifyCitationsTool(server: McpServer, storage: IStorageAdapter): void;