@iris-eval/mcp-server 0.5.0 → 0.6.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.
- package/README.md +99 -36
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +33 -7
- package/dist/dashboard/assets/index-CshLgDRB.js +10 -0
- package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
- package/dist/dashboard/index.html +4 -3
- package/dist/dashboard/routes/health.js +10 -3
- package/dist/dashboard/routes/moments.js +1 -1
- package/dist/dashboard/routes/preferences.d.ts +1 -0
- package/dist/dashboard/routes/preferences.js +31 -3
- package/dist/dashboard/routes/rules.d.ts +18 -0
- package/dist/dashboard/routes/rules.js +160 -6
- package/dist/dashboard/routes/traces.js +30 -3
- package/dist/dashboard/seed-demo-data.js +14 -3
- package/dist/dashboard/server.js +13 -3
- package/dist/dashboard/session-auth.d.ts +8 -0
- package/dist/dashboard/session-auth.js +237 -0
- package/dist/dashboard/validation.d.ts +9 -3
- package/dist/dashboard/validation.js +69 -11
- package/dist/eval/citation-verify/verifier.d.ts +17 -0
- package/dist/eval/citation-verify/verifier.js +68 -15
- package/dist/eval/decision-moment.js +17 -9
- package/dist/eval/engine.d.ts +62 -0
- package/dist/eval/engine.js +196 -58
- package/dist/eval/llm-judge/evaluator.js +50 -33
- package/dist/eval/llm-judge/templates/index.d.ts +4 -0
- package/dist/eval/llm-judge/templates/index.js +10 -4
- package/dist/eval/rules/custom.js +59 -6
- package/dist/eval/rules/relevance.js +1 -1
- package/dist/eval/rules/safety.d.ts +8 -0
- package/dist/eval/rules/safety.js +63 -18
- package/dist/index.js +102 -16
- package/dist/middleware/rate-limit.d.ts +25 -0
- package/dist/middleware/rate-limit.js +54 -2
- package/dist/self-test.d.ts +14 -0
- package/dist/self-test.js +97 -13
- package/dist/storage/demo-guard.d.ts +8 -0
- package/dist/storage/demo-guard.js +53 -0
- package/dist/storage/migrations/006-eval-critical-failures.d.ts +3 -0
- package/dist/storage/migrations/006-eval-critical-failures.js +23 -0
- package/dist/storage/migrations/index.js +2 -0
- package/dist/storage/sqlite-adapter.d.ts +6 -0
- package/dist/storage/sqlite-adapter.js +91 -4
- package/dist/tools/delete-rule.js +49 -11
- package/dist/tools/deploy-rule.d.ts +33 -0
- package/dist/tools/deploy-rule.js +130 -27
- package/dist/tools/evaluate-output.js +50 -24
- package/dist/tools/evaluate-with-llm-judge.js +11 -4
- package/dist/tools/get-traces.d.ts +27 -0
- package/dist/tools/get-traces.js +60 -8
- package/dist/tools/list-rules.js +2 -2
- package/dist/tools/log-trace.js +5 -4
- package/dist/tools/strict-input.d.ts +1 -0
- package/dist/tools/strict-input.js +25 -0
- package/dist/tools/trace-link.d.ts +7 -0
- package/dist/tools/trace-link.js +39 -0
- package/dist/tools/verify-citations.d.ts +19 -0
- package/dist/tools/verify-citations.js +42 -5
- package/dist/types/decision-moment.d.ts +8 -0
- package/dist/types/eval.d.ts +60 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/query.d.ts +25 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-BZZt8bVh.js +0 -10
package/dist/tools/list-rules.js
CHANGED
|
@@ -33,13 +33,13 @@ export function registerListRulesTool(server, customRuleStore) {
|
|
|
33
33
|
'',
|
|
34
34
|
'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.',
|
|
35
35
|
'',
|
|
36
|
-
'Output shape. Returns JSON: `{ "rules": [{ "id": "rule-XXXX", "name", "description
|
|
36
|
+
'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 }`. Empty array + total=0 when no rules deployed. A deployed rule fires only on evaluate_output calls whose eval_type equals its evalType (or eval_type="all", which runs every bundle).',
|
|
37
37
|
'',
|
|
38
38
|
'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.',
|
|
39
39
|
'',
|
|
40
40
|
"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).",
|
|
41
41
|
'',
|
|
42
|
-
'Parameters. eval_type filter is exact-match against each rule\'s evalType field (no wildcards). enabled_only excludes rules that are deployed-but-disabled
|
|
42
|
+
'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).',
|
|
43
43
|
'',
|
|
44
44
|
"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.",
|
|
45
45
|
].join('\n'),
|
package/dist/tools/log-trace.js
CHANGED
|
@@ -59,7 +59,7 @@ export function registerLogTraceTool(server, storage) {
|
|
|
59
59
|
'',
|
|
60
60
|
'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.',
|
|
61
61
|
'',
|
|
62
|
-
'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
|
|
62
|
+
'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 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.',
|
|
63
63
|
'',
|
|
64
64
|
'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.',
|
|
65
65
|
'',
|
|
@@ -72,9 +72,10 @@ export function registerLogTraceTool(server, storage) {
|
|
|
72
72
|
'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.',
|
|
73
73
|
].join('\n'),
|
|
74
74
|
// Strict at the MCP boundary (unknown args rejected, not stripped).
|
|
75
|
-
// The dashboard's HTTP ingest builds its own
|
|
76
|
-
// (dashboard/validation.ts)
|
|
77
|
-
//
|
|
75
|
+
// The dashboard's HTTP ingest builds its own — equally strict —
|
|
76
|
+
// schema FROM this shape (dashboard/validation.ts): a client-supplied
|
|
77
|
+
// trace_id is rejected there with a 400 whose message says the server
|
|
78
|
+
// mints it, exactly as this tool mints its own in the handler below.
|
|
78
79
|
inputSchema: strictInput(logTraceInputShape),
|
|
79
80
|
annotations: {
|
|
80
81
|
readOnlyHint: false, // Writes a row to storage
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export declare function strictInput<T extends z.ZodRawShape>(shape: T): z.ZodObject<{ -readonly [P in keyof T]: T[P]; }, z.core.$strict>;
|
|
3
|
+
export declare function strictNested<T extends z.ZodRawShape>(shape: T, container: string): z.ZodObject<{ -readonly [P in keyof T]: T[P]; }, z.core.$strict>;
|
|
@@ -33,3 +33,28 @@ export function strictInput(shape) {
|
|
|
33
33
|
: undefined,
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
|
+
/*
|
|
37
|
+
* The same contract ONE LEVEL DOWN, for structured nested objects — a
|
|
38
|
+
* custom_rules[] entry, deploy_rule's `definition`. Top-level strictness
|
|
39
|
+
* shipped in 0.5.0 and stopped there, so `custom_rules: [{ name, type,
|
|
40
|
+
* config, wieght: 5 }]` still parsed cleanly with `wieght` discarded: the
|
|
41
|
+
* rule ran at the default weight and the score moved for a reason nothing
|
|
42
|
+
* in the response could show (#376). Free-form record fields (a rule's
|
|
43
|
+
* `config`, trace `metadata`, span `attributes`) are deliberately NOT
|
|
44
|
+
* strict — arbitrary keys there are the documented contract.
|
|
45
|
+
*
|
|
46
|
+
* `container` names the object in the message ("a custom_rules entry");
|
|
47
|
+
* the SDK appends the path (`at custom_rules.0`) so the caller sees
|
|
48
|
+
* exactly which entry to fix.
|
|
49
|
+
*/
|
|
50
|
+
export function strictNested(shape, container) {
|
|
51
|
+
const validKeys = Object.keys(shape).join(', ');
|
|
52
|
+
return z.strictObject(shape, {
|
|
53
|
+
error: (issue) => issue.code === 'unrecognized_keys'
|
|
54
|
+
? `Unknown key(s) in ${container}: ${issue.keys.map((k) => `"${k}"`).join(', ')}. ` +
|
|
55
|
+
`Valid keys: ${validKeys}. ` +
|
|
56
|
+
'Unknown keys are rejected rather than silently dropped, so a misspelled key ' +
|
|
57
|
+
'cannot change how the rule scores — check the spelling and retry.'
|
|
58
|
+
: undefined,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { IStorageAdapter } from '../types/query.js';
|
|
2
|
+
import type { EvalResult } from '../types/eval.js';
|
|
3
|
+
import type { TenantId } from '../types/tenant.js';
|
|
4
|
+
export declare function unknownTraceMessage(traceId: string): string;
|
|
5
|
+
export declare function assertTraceExists(storage: IStorageAdapter, tenantId: TenantId, traceId: string): Promise<void>;
|
|
6
|
+
/** insertEvalResult with the foreign-key race translated into the same clear message. */
|
|
7
|
+
export declare function insertLinkedEvalResult(storage: IStorageAdapter, tenantId: TenantId, result: EvalResult): Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Linking an evaluation to a trace that does not exist.
|
|
3
|
+
*
|
|
4
|
+
* eval_results.trace_id is a foreign key. Passing an unknown trace_id to
|
|
5
|
+
* evaluate_output used to run the whole evaluation and then fail at the
|
|
6
|
+
* INSERT with SQLite's own words — "FOREIGN KEY constraint failed" — which
|
|
7
|
+
* names no field, no value and no fix (#376). Worse for the paid tools:
|
|
8
|
+
* evaluate_with_llm_judge had already spent the provider call by the time
|
|
9
|
+
* the insert refused it.
|
|
10
|
+
*
|
|
11
|
+
* Two layers, because a check-then-insert has a gap: the pre-check refuses
|
|
12
|
+
* BEFORE any work (and before any money) with the trace_id named; the
|
|
13
|
+
* insert wrapper translates the constraint error for the race where the
|
|
14
|
+
* trace is deleted between the check and the write.
|
|
15
|
+
*/
|
|
16
|
+
export function unknownTraceMessage(traceId) {
|
|
17
|
+
return (`trace_id "${traceId}" does not match any stored trace, so the evaluation cannot be linked to it. ` +
|
|
18
|
+
'Nothing was evaluated or written. Pass the trace_id returned by log_trace (or listed by get_traces), ' +
|
|
19
|
+
'or omit trace_id to store an unlinked evaluation.');
|
|
20
|
+
}
|
|
21
|
+
export async function assertTraceExists(storage, tenantId, traceId) {
|
|
22
|
+
const trace = await storage.getTrace(tenantId, traceId);
|
|
23
|
+
if (!trace)
|
|
24
|
+
throw new Error(unknownTraceMessage(traceId));
|
|
25
|
+
}
|
|
26
|
+
/** insertEvalResult with the foreign-key race translated into the same clear message. */
|
|
27
|
+
export async function insertLinkedEvalResult(storage, tenantId, result) {
|
|
28
|
+
try {
|
|
29
|
+
await storage.insertEvalResult(tenantId, result);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const code = err.code;
|
|
33
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
34
|
+
if (result.trace_id && (code === 'SQLITE_CONSTRAINT_FOREIGNKEY' || /FOREIGN KEY constraint failed/i.test(message))) {
|
|
35
|
+
throw new Error(unknownTraceMessage(result.trace_id));
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -1,3 +1,22 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { IStorageAdapter } from '../types/query.js';
|
|
3
|
+
/**
|
|
4
|
+
* `passed: true` with `overall_score: null` is the honest answer when there
|
|
5
|
+
* was nothing to judge (no citations, none resolved). It is NOT the honest
|
|
6
|
+
* answer when citations resolved and the judge then failed on every one —
|
|
7
|
+
* 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.)
|
|
10
|
+
*/
|
|
11
|
+
export declare function assertJudgeRan(result: {
|
|
12
|
+
totalResolved: number;
|
|
13
|
+
totalJudged: number;
|
|
14
|
+
citations: ReadonlyArray<{
|
|
15
|
+
resolveStatus: string;
|
|
16
|
+
resolveError?: {
|
|
17
|
+
kind: string;
|
|
18
|
+
message: string;
|
|
19
|
+
};
|
|
20
|
+
}>;
|
|
21
|
+
}): void;
|
|
3
22
|
export declare function registerVerifyCitationsTool(server: McpServer, storage: IStorageAdapter): void;
|
|
@@ -4,6 +4,7 @@ import { verifyCitations } from '../eval/citation-verify/verifier.js';
|
|
|
4
4
|
import { findPricing } from '../eval/llm-judge/pricing.js';
|
|
5
5
|
import { generateEvalId } from '../utils/ids.js';
|
|
6
6
|
import { strictInput } from './strict-input.js';
|
|
7
|
+
import { assertTraceExists, insertLinkedEvalResult } from './trace-link.js';
|
|
7
8
|
const inputSchema = {
|
|
8
9
|
output: z.string().min(1).describe('The agent output containing citations to verify'),
|
|
9
10
|
model: z
|
|
@@ -19,7 +20,7 @@ const inputSchema = {
|
|
|
19
20
|
max_citations: z.number().int().positive().max(50).optional().describe('Max citations to verify (extras skipped); default 20'),
|
|
20
21
|
per_source_timeout_ms: z.number().int().positive().optional().describe('Per-URL fetch timeout; default 10_000'),
|
|
21
22
|
per_source_max_bytes: z.number().int().positive().optional().describe('Per-URL body cap; default 5MB'),
|
|
22
|
-
trace_id: z.string().optional().describe('Link verification result to a trace'),
|
|
23
|
+
trace_id: z.string().optional().describe('Link verification result to a stored trace (id from log_trace / get_traces); an unknown id is rejected before any fetch or judge call'),
|
|
23
24
|
};
|
|
24
25
|
function inferProvider(model) {
|
|
25
26
|
const pricing = findPricing(model);
|
|
@@ -48,6 +49,25 @@ function resolveDomainAllowlist(paramValue) {
|
|
|
48
49
|
}
|
|
49
50
|
return fromEnv.length > 0 ? fromEnv : undefined;
|
|
50
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* `passed: true` with `overall_score: null` is the honest answer when there
|
|
54
|
+
* was nothing to judge (no citations, none resolved). It is NOT the honest
|
|
55
|
+
* answer when citations resolved and the judge then failed on every one —
|
|
56
|
+
* a wrong API key, a model the provider refused, a parse failure — because
|
|
57
|
+
* the caller reads "passed" and ships. That case is an error naming the
|
|
58
|
+
* cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
|
|
59
|
+
*/
|
|
60
|
+
export function assertJudgeRan(result) {
|
|
61
|
+
if (result.totalResolved === 0 || result.totalJudged > 0)
|
|
62
|
+
return;
|
|
63
|
+
const judgeFailures = result.citations.filter((c) => c.resolveStatus === 'ok' && c.resolveError);
|
|
64
|
+
if (judgeFailures.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
const kinds = [...new Set(judgeFailures.map((c) => c.resolveError.kind))].join(', ');
|
|
67
|
+
const first = judgeFailures[0].resolveError.message;
|
|
68
|
+
throw new Error(`verify_citations could not judge any of the ${result.totalResolved} resolved citation(s): the judge failed on every one (${kinds}). ` +
|
|
69
|
+
`Nothing was verified and nothing was stored, so there is no verdict. First error: ${first}`);
|
|
70
|
+
}
|
|
51
71
|
export function registerVerifyCitationsTool(server, storage) {
|
|
52
72
|
server.registerTool('verify_citations', {
|
|
53
73
|
title: 'Verify Citations',
|
|
@@ -64,9 +84,9 @@ export function registerVerifyCitationsTool(server, storage) {
|
|
|
64
84
|
"",
|
|
65
85
|
"Don't use when the agent output has no citations at all (overall_score will be null; the tool degrades gracefully but a heuristic rule is cheaper). Don't use without allow_fetch=true or IRIS_CITATION_ALLOW_FETCH=1 — the tool refuses outbound HTTP unless explicitly enabled. Don't use with an open allowlist + untrusted output on the public internet; you are effectively running a user-directed fetcher. For stricter safety set IRIS_CITATION_DOMAINS to a curated list.",
|
|
66
86
|
'',
|
|
67
|
-
'Parameters. model is required; provider auto-detected from model name (override only for ambiguous IDs). allow_fetch=false by default — outbound HTTP is REFUSED unless explicitly true OR IRIS_CITATION_ALLOW_FETCH=1 env. domain_allowlist suffix-matches hostnames (e.g., "wikipedia.org" allows en.wikipedia.org); merged with IRIS_CITATION_DOMAINS env (UNION — either source permits). max_citations defaults 20, hard cap 50 (extras are skipped silently, NOT errored — check total_citations_found in the response if precise). max_cost_usd_total defaults $1.00 — the pipeline stops mid-citation when the next judge call would exceed the cap (returns partial verdicts). per_source_timeout_ms defaults 10000 (10s); per_source_max_bytes defaults 5MB (truncates at boundary, judges still run on truncated content). trace_id optional but recommended. Defaults: max_citations=20, max_cost_usd_total=$1.00, per_source_timeout_ms=10000, per_source_max_bytes=5242880, allow_fetch=false.',
|
|
87
|
+
'Parameters. model is required; provider auto-detected from model name (override only for ambiguous IDs). allow_fetch=false by default — outbound HTTP is REFUSED unless explicitly true OR IRIS_CITATION_ALLOW_FETCH=1 env. domain_allowlist suffix-matches hostnames (e.g., "wikipedia.org" allows en.wikipedia.org); merged with IRIS_CITATION_DOMAINS env (UNION — either source permits). max_citations defaults 20, hard cap 50 (extras are skipped silently, NOT errored — check total_citations_found in the response if precise). max_cost_usd_total defaults $1.00 — the pipeline stops mid-citation when the next judge call would exceed the cap (returns partial verdicts). per_source_timeout_ms defaults 10000 (10s); per_source_max_bytes defaults 5MB (truncates at boundary, judges still run on truncated content); independently of that, the judge reads at most the first 12,000 characters of each fetched source, and the per-citation cost estimate is taken on that truncated prompt, not on the full body. trace_id optional but recommended. Defaults: max_citations=20, max_cost_usd_total=$1.00, per_source_timeout_ms=10000, per_source_max_bytes=5242880, allow_fetch=false.',
|
|
68
88
|
'',
|
|
69
|
-
'Error modes. Throws when the API key env var is missing. Throws "Unknown model" on unsupported model IDs. Per-citation errors are collected (resolve_error.kind = bad_scheme / ssrf / not_allowed_domain / timeout / too_large / bad_status / redirect_loop / not_text / fetch_disabled / malformed_judge_response / cost_cap_reached / unresolvable_kind) and returned in the response rather than thrown. An empty output or output with zero extractable citations returns overall_score=null + passed=true (nothing to fail).',
|
|
89
|
+
'Error modes. Throws when the API key env var is missing. Throws "Unknown model" on unsupported model IDs. Throws when trace_id does not match a stored trace (checked before any fetch or judge call; nothing is written). Per-citation errors are collected (resolve_error.kind = bad_scheme / ssrf / not_allowed_domain / timeout / too_large / bad_status / redirect_loop / not_text / fetch_disabled / malformed_judge_response / cost_cap_reached / unresolvable_kind) and returned in the response rather than thrown. An empty output or output with zero extractable citations returns overall_score=null + passed=true (nothing to fail).',
|
|
70
90
|
].join('\n'),
|
|
71
91
|
inputSchema: strictInput(inputSchema),
|
|
72
92
|
annotations: {
|
|
@@ -80,6 +100,10 @@ export function registerVerifyCitationsTool(server, storage) {
|
|
|
80
100
|
const apiKey = resolveApiKey(provider);
|
|
81
101
|
const allowFetch = resolveAllowFetch(args.allow_fetch);
|
|
82
102
|
const domainAllowlist = resolveDomainAllowlist(args.domain_allowlist);
|
|
103
|
+
// Refused before any fetch or judge call spends anything (#376).
|
|
104
|
+
if (args.trace_id) {
|
|
105
|
+
await assertTraceExists(storage, LOCAL_TENANT, args.trace_id);
|
|
106
|
+
}
|
|
83
107
|
const result = await verifyCitations({
|
|
84
108
|
output: args.output,
|
|
85
109
|
provider,
|
|
@@ -92,12 +116,13 @@ export function registerVerifyCitationsTool(server, storage) {
|
|
|
92
116
|
perSourceTimeoutMs: args.per_source_timeout_ms,
|
|
93
117
|
perSourceMaxBytes: args.per_source_max_bytes,
|
|
94
118
|
});
|
|
119
|
+
assertJudgeRan(result);
|
|
95
120
|
const evalId = generateEvalId();
|
|
96
121
|
const score = result.overallScore ?? 0;
|
|
97
122
|
// Persist so dashboard can surface. eval_type='custom' — same
|
|
98
123
|
// rationale as evaluate_with_llm_judge (spans all 4 heuristic
|
|
99
124
|
// categories). rule_results[0] carries per-citation summary.
|
|
100
|
-
await storage
|
|
125
|
+
await insertLinkedEvalResult(storage, LOCAL_TENANT, {
|
|
101
126
|
id: evalId,
|
|
102
127
|
trace_id: args.trace_id,
|
|
103
128
|
eval_type: 'custom',
|
|
@@ -142,7 +167,19 @@ export function registerVerifyCitationsTool(server, storage) {
|
|
|
142
167
|
},
|
|
143
168
|
resolve_status: c.resolveStatus,
|
|
144
169
|
resolve_error: c.resolveError,
|
|
145
|
-
|
|
170
|
+
// Mapped to the documented snake_case keys. The verifier's
|
|
171
|
+
// internal shape is camelCase (contentType, bytesFetched) and
|
|
172
|
+
// used to be passed through verbatim, so a client parsing
|
|
173
|
+
// `source.content_type` per the description read undefined.
|
|
174
|
+
source: c.source
|
|
175
|
+
? {
|
|
176
|
+
url: c.source.url,
|
|
177
|
+
status: c.source.status,
|
|
178
|
+
content_type: c.source.contentType,
|
|
179
|
+
bytes_fetched: c.source.bytesFetched,
|
|
180
|
+
truncated: c.source.truncated,
|
|
181
|
+
}
|
|
182
|
+
: undefined,
|
|
146
183
|
judge: c.judge
|
|
147
184
|
? {
|
|
148
185
|
supported: c.judge.supported,
|
|
@@ -69,6 +69,14 @@ export interface DecisionMomentDetail extends DecisionMoment {
|
|
|
69
69
|
skipReason?: string;
|
|
70
70
|
}>;
|
|
71
71
|
suggestions: string[];
|
|
72
|
+
/**
|
|
73
|
+
* Rules that HARD-FAILED this evaluation — a critical safety rule
|
|
74
|
+
* (no_pii / no_injection_patterns / no_blocklist_words) or a deployed
|
|
75
|
+
* rule with severity high/critical. Present only when the veto fired;
|
|
76
|
+
* absent means nothing vetoed, or the row predates migration 006. This
|
|
77
|
+
* is what lets a surface distinguish "vetoed" from "scored low".
|
|
78
|
+
*/
|
|
79
|
+
criticalFailures?: string[];
|
|
72
80
|
createdAt?: string;
|
|
73
81
|
}>;
|
|
74
82
|
/** Full input (uncompressed). */
|
package/dist/types/eval.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export type EvalType = 'completeness' | 'relevance' | 'safety' | 'cost' | 'custom';
|
|
2
|
+
/**
|
|
3
|
+
* What an EvalResult can be tagged as: a single bundle (EvalType), or
|
|
4
|
+
* 'all' — evaluate_output's eval_type="all", which runs every bundle in one
|
|
5
|
+
* pass and reports a per-category breakdown beside the overall verdict.
|
|
6
|
+
* Kept apart from EvalType on purpose: rules are deployed and registered
|
|
7
|
+
* under a real bundle, never under 'all'.
|
|
8
|
+
*/
|
|
9
|
+
export type EvalResultType = EvalType | 'all';
|
|
2
10
|
export interface EvalRule {
|
|
3
11
|
name: string;
|
|
4
12
|
description: string;
|
|
@@ -49,6 +57,20 @@ export interface EvalContext {
|
|
|
49
57
|
}
|
|
50
58
|
export interface EvalRuleResult {
|
|
51
59
|
ruleName: string;
|
|
60
|
+
/**
|
|
61
|
+
* Deployed rule id (rule-<hex>) when the rule came from the custom-rule
|
|
62
|
+
* store. Absent for built-in rules and for inline custom_rules. Names are
|
|
63
|
+
* not unique — a same-name redeploy with replace:true mints a new id, and
|
|
64
|
+
* stores written before the same-name guard may hold duplicates — so this
|
|
65
|
+
* is the field that tells two same-named results apart (#373).
|
|
66
|
+
*/
|
|
67
|
+
ruleId?: string;
|
|
68
|
+
/**
|
|
69
|
+
* The bundle this rule belongs to. Present only on eval_type="all"
|
|
70
|
+
* results, where rule_results spans every bundle and a reader needs to
|
|
71
|
+
* regroup them.
|
|
72
|
+
*/
|
|
73
|
+
category?: EvalType;
|
|
52
74
|
passed: boolean;
|
|
53
75
|
score: number;
|
|
54
76
|
message: string;
|
|
@@ -57,10 +79,24 @@ export interface EvalRuleResult {
|
|
|
57
79
|
configInvalid?: boolean;
|
|
58
80
|
budgetExceeded?: boolean;
|
|
59
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Per-bundle verdict inside an eval_type="all" result. Same semantics as a
|
|
84
|
+
* single-bundle EvalResult (threshold + critical veto), computed over that
|
|
85
|
+
* bundle's rules only.
|
|
86
|
+
*/
|
|
87
|
+
export interface EvalCategoryResult {
|
|
88
|
+
score: number;
|
|
89
|
+
passed: boolean;
|
|
90
|
+
rules_evaluated: number;
|
|
91
|
+
rules_skipped: number;
|
|
92
|
+
insufficient_data: boolean;
|
|
93
|
+
critical_failures?: string[];
|
|
94
|
+
critical_skipped?: string[];
|
|
95
|
+
}
|
|
60
96
|
export interface EvalResult {
|
|
61
97
|
id: string;
|
|
62
98
|
trace_id?: string;
|
|
63
|
-
eval_type:
|
|
99
|
+
eval_type: EvalResultType;
|
|
64
100
|
output_text: string;
|
|
65
101
|
expected_text?: string;
|
|
66
102
|
score: number;
|
|
@@ -78,6 +114,29 @@ export interface EvalResult {
|
|
|
78
114
|
* "committed a hard violation".
|
|
79
115
|
*/
|
|
80
116
|
critical_failures?: string[];
|
|
117
|
+
/**
|
|
118
|
+
* Names of critical rules that were SKIPPED and therefore did not judge
|
|
119
|
+
* this output (present only when non-empty). Almost always a sandbox
|
|
120
|
+
* budget breach — a regex killed mid-backtrack, which an adversary can
|
|
121
|
+
* provoke deliberately by crafting output that stalls a known pattern.
|
|
122
|
+
*
|
|
123
|
+
* This is the fail-open seam between the release's two headline features:
|
|
124
|
+
* a budget-killed critical rule does NOT veto, so the evaluation can
|
|
125
|
+
* return passed=true with no `critical_failures` at all. That is
|
|
126
|
+
* deliberate (failing closed would let the same adversary force false
|
|
127
|
+
* violations on benign output), but a consumer that must fail closed
|
|
128
|
+
* needs to see it WITHOUT walking rule_results[].budgetExceeded. Treat a
|
|
129
|
+
* non-empty `critical_skipped` as "unknown", not as "clean".
|
|
130
|
+
*/
|
|
131
|
+
critical_skipped?: string[];
|
|
132
|
+
/**
|
|
133
|
+
* Per-bundle breakdown, present only when eval_type is 'all'. Keyed by
|
|
134
|
+
* bundle; a bundle with no rules at all (nothing deployed under "custom"
|
|
135
|
+
* and no inline custom_rules) is absent rather than reported as
|
|
136
|
+
* insufficient. Response-only — not persisted as a column; the stored
|
|
137
|
+
* rule_results carry a `category` per rule so a reader can regroup.
|
|
138
|
+
*/
|
|
139
|
+
categories?: Partial<Record<EvalType, EvalCategoryResult>>;
|
|
81
140
|
}
|
|
82
141
|
export type CustomRuleType = 'regex_match' | 'regex_no_match' | 'min_length' | 'max_length' | 'contains_keywords' | 'excludes_keywords' | 'json_schema' | 'cost_threshold';
|
|
83
142
|
export interface CustomRuleDefinition {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { SpanKind, SpanStatus, SpanEvent, ToolCallRecord, TokenUsage, Span, Trace, } from './trace.js';
|
|
2
|
-
export type { EvalType, EvalRule, EvalContext, EvalRuleResult, EvalResult, CustomRuleType, CustomRuleDefinition, } from './eval.js';
|
|
2
|
+
export type { EvalType, EvalResultType, EvalRule, EvalContext, EvalRuleResult, EvalResult, EvalCategoryResult, CustomRuleType, CustomRuleDefinition, } from './eval.js';
|
|
3
3
|
export type { TraceFilter, TraceQueryOptions, TraceQueryResult, DashboardSummary, EvalStatsPeriod, EvalStats, EvalStatsTrendBucket, EvalStatsRuleBreakdown, EvalStatsFailure, IStorageAdapter, } from './query.js';
|
|
4
4
|
export type { IrisConfig } from './config.js';
|
|
5
5
|
export type { TenantId } from './tenant.js';
|
package/dist/types/query.d.ts
CHANGED
|
@@ -95,6 +95,31 @@ export interface IStorageAdapter {
|
|
|
95
95
|
}>;
|
|
96
96
|
getDashboardSummary(tenantId: TenantId, sinceHours?: number): Promise<DashboardSummary>;
|
|
97
97
|
deleteTracesOlderThan(tenantId: TenantId, days: number): Promise<number>;
|
|
98
|
+
/**
|
|
99
|
+
* Retention twin of deleteTracesOlderThan for eval_results (#372).
|
|
100
|
+
* Deleting a trace only NULLs the trace_id on its evaluations (FK ON
|
|
101
|
+
* DELETE SET NULL), so every eval row — output_text verbatim, including
|
|
102
|
+
* whatever no_pii flagged — outlived the retention window until this
|
|
103
|
+
* existed. Cutoff is on created_at.
|
|
104
|
+
*/
|
|
105
|
+
deleteEvalResultsOlderThan(tenantId: TenantId, days: number): Promise<number>;
|
|
106
|
+
/**
|
|
107
|
+
* Delete EVERY trace, span and eval result for the tenant, then compact
|
|
108
|
+
* the database so the deleted text does not linger in free pages or in
|
|
109
|
+
* the write-ahead log. Returns what was removed. Deployed rules, the
|
|
110
|
+
* audit log and preferences are not storage rows and are untouched.
|
|
111
|
+
*/
|
|
112
|
+
purge(tenantId: TenantId): Promise<{
|
|
113
|
+
traces: number;
|
|
114
|
+
evalResults: number;
|
|
115
|
+
}>;
|
|
116
|
+
/**
|
|
117
|
+
* Fold the write-ahead log into the main file and truncate it
|
|
118
|
+
* (wal_checkpoint TRUNCATE). Best-effort; called after a retention sweep
|
|
119
|
+
* so rows deleted at startup do not survive as readable text in
|
|
120
|
+
* iris.db-wal.
|
|
121
|
+
*/
|
|
122
|
+
checkpoint(): Promise<void>;
|
|
98
123
|
/**
|
|
99
124
|
* Delete a single trace by id. Cascades to spans via FK ON DELETE
|
|
100
125
|
* CASCADE; eval_results get their trace_id set to NULL (so score
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/iris-eval/mcp-server",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.6.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@iris-eval/mcp-server",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.6.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|