@iris-eval/mcp-server 0.4.6 → 0.5.1
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 +98 -47
- package/dist/audit-log-reader.d.ts +0 -2
- package/dist/audit-log-reader.js +3 -3
- package/dist/config/index.js +18 -1
- package/dist/custom-rule-store.js +22 -8
- package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
- package/dist/dashboard/assets/index-VI_nbMfN.js +10 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/failures.d.ts +3 -0
- package/dist/dashboard/routes/failures.js +76 -0
- package/dist/dashboard/routes/index.d.ts +1 -0
- package/dist/dashboard/routes/index.js +1 -0
- package/dist/dashboard/routes/preferences.js +7 -2
- package/dist/dashboard/routes/rules.js +32 -14
- package/dist/dashboard/routes/traces.d.ts +12 -1
- package/dist/dashboard/routes/traces.js +99 -2
- package/dist/dashboard/seed-demo-data.d.ts +49 -0
- package/dist/dashboard/seed-demo-data.js +1080 -0
- package/dist/dashboard/server.js +81 -15
- package/dist/dashboard/validation.d.ts +74 -0
- package/dist/dashboard/validation.js +31 -2
- package/dist/eval/citation-verify/resolve.js +29 -0
- package/dist/eval/citation-verify/verifier.d.ts +18 -0
- package/dist/eval/citation-verify/verifier.js +80 -19
- package/dist/eval/decision-moment.js +17 -9
- package/dist/eval/engine.d.ts +15 -1
- package/dist/eval/engine.js +106 -5
- package/dist/eval/failure-rank.d.ts +14 -0
- package/dist/eval/failure-rank.js +44 -0
- 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.d.ts +29 -1
- package/dist/eval/rules/custom.js +213 -24
- package/dist/eval/rules/regex-budget.js +0 -0
- package/dist/eval/rules/regex-sandbox.d.ts +26 -0
- package/dist/eval/rules/regex-sandbox.js +131 -0
- package/dist/eval/rules/relevance.d.ts +0 -2
- package/dist/eval/rules/relevance.js +6 -68
- package/dist/eval/rules/safety.d.ts +10 -0
- package/dist/eval/rules/safety.js +1352 -28
- package/dist/index.js +196 -18
- package/dist/self-test.d.ts +18 -0
- package/dist/self-test.js +329 -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 +2 -0
- package/dist/storage/sqlite-adapter.js +84 -12
- package/dist/tools/delete-rule.d.ts +2 -1
- package/dist/tools/delete-rule.js +13 -4
- package/dist/tools/delete-trace.js +2 -1
- package/dist/tools/deploy-rule.d.ts +2 -1
- package/dist/tools/deploy-rule.js +29 -7
- package/dist/tools/evaluate-output.js +45 -11
- package/dist/tools/evaluate-with-llm-judge.js +3 -2
- package/dist/tools/get-traces.js +6 -2
- package/dist/tools/index.js +2 -2
- package/dist/tools/list-rules.js +2 -1
- package/dist/tools/log-trace.d.ts +51 -0
- package/dist/tools/log-trace.js +15 -3
- package/dist/tools/strict-input.d.ts +2 -0
- package/dist/tools/strict-input.js +35 -0
- package/dist/tools/verify-citations.js +8 -6
- package/dist/transport/http.js +24 -2
- package/dist/types/decision-moment.d.ts +20 -0
- package/dist/types/eval.d.ts +47 -0
- package/dist/types/query.d.ts +1 -1
- package/dist/utils/write-atomic.d.ts +2 -0
- package/dist/utils/write-atomic.js +34 -2
- package/package.json +3 -2
- package/server.json +3 -3
- package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
- package/dist/dashboard/assets/index-ChcHJDDJ.js +0 -10
|
@@ -1,3 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import type { IStorageAdapter } from '../types/query.js';
|
|
4
|
+
export declare const logTraceInputShape: {
|
|
5
|
+
agent_name: z.ZodString;
|
|
6
|
+
framework: z.ZodOptional<z.ZodString>;
|
|
7
|
+
input: z.ZodOptional<z.ZodString>;
|
|
8
|
+
output: z.ZodOptional<z.ZodString>;
|
|
9
|
+
tool_calls: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
10
|
+
tool_name: z.ZodString;
|
|
11
|
+
input: z.ZodOptional<z.ZodUnknown>;
|
|
12
|
+
output: z.ZodOptional<z.ZodUnknown>;
|
|
13
|
+
latency_ms: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
error: z.ZodOptional<z.ZodString>;
|
|
15
|
+
}, z.core.$strip>>>;
|
|
16
|
+
latency_ms: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
token_usage: z.ZodOptional<z.ZodObject<{
|
|
18
|
+
prompt_tokens: z.ZodOptional<z.ZodNumber>;
|
|
19
|
+
completion_tokens: z.ZodOptional<z.ZodNumber>;
|
|
20
|
+
total_tokens: z.ZodOptional<z.ZodNumber>;
|
|
21
|
+
}, z.core.$strip>>;
|
|
22
|
+
cost_usd: z.ZodOptional<z.ZodNumber>;
|
|
23
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
24
|
+
spans: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
25
|
+
span_id: z.ZodOptional<z.ZodString>;
|
|
26
|
+
parent_span_id: z.ZodOptional<z.ZodString>;
|
|
27
|
+
name: z.ZodString;
|
|
28
|
+
kind: z.ZodDefault<z.ZodEnum<{
|
|
29
|
+
INTERNAL: "INTERNAL";
|
|
30
|
+
SERVER: "SERVER";
|
|
31
|
+
CLIENT: "CLIENT";
|
|
32
|
+
PRODUCER: "PRODUCER";
|
|
33
|
+
CONSUMER: "CONSUMER";
|
|
34
|
+
LLM: "LLM";
|
|
35
|
+
TOOL: "TOOL";
|
|
36
|
+
}>>;
|
|
37
|
+
status_code: z.ZodDefault<z.ZodEnum<{
|
|
38
|
+
UNSET: "UNSET";
|
|
39
|
+
OK: "OK";
|
|
40
|
+
ERROR: "ERROR";
|
|
41
|
+
}>>;
|
|
42
|
+
status_message: z.ZodOptional<z.ZodString>;
|
|
43
|
+
start_time: z.ZodString;
|
|
44
|
+
end_time: z.ZodOptional<z.ZodString>;
|
|
45
|
+
attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
46
|
+
events: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
47
|
+
name: z.ZodString;
|
|
48
|
+
timestamp: z.ZodString;
|
|
49
|
+
attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
50
|
+
}, z.core.$strip>>>;
|
|
51
|
+
}, z.core.$strip>>>;
|
|
52
|
+
timestamp: z.ZodOptional<z.ZodString>;
|
|
53
|
+
};
|
|
3
54
|
export declare function registerLogTraceTool(server: McpServer, storage: IStorageAdapter): void;
|
package/dist/tools/log-trace.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
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
|
+
import { strictInput } from './strict-input.js';
|
|
5
6
|
const ToolCallSchema = z.object({
|
|
6
7
|
tool_name: z.string(),
|
|
7
8
|
input: z.unknown().optional(),
|
|
@@ -30,7 +31,14 @@ const TokenUsageSchema = z.object({
|
|
|
30
31
|
completion_tokens: z.number().optional(),
|
|
31
32
|
total_tokens: z.number().optional(),
|
|
32
33
|
});
|
|
33
|
-
|
|
34
|
+
/*
|
|
35
|
+
* The log_trace input contract. Exported because POST /api/v1/traces
|
|
36
|
+
* (src/dashboard/routes/traces.ts) accepts the SAME body — one schema,
|
|
37
|
+
* two capture paths. Duplicating it there would let the tool and the
|
|
38
|
+
* HTTP endpoint drift apart silently; importing it means a field added
|
|
39
|
+
* here is accepted (and validated identically) on both.
|
|
40
|
+
*/
|
|
41
|
+
export const logTraceInputShape = {
|
|
34
42
|
agent_name: z.string().describe('Agent name — used for filtering in get_traces (e.g., "customer-support-bot")'),
|
|
35
43
|
framework: z.string().optional().describe('Agent framework identifier (e.g., langchain, autogen, custom)'),
|
|
36
44
|
input: z.string().optional().describe('Agent input text — the user prompt or upstream input that produced this output'),
|
|
@@ -51,7 +59,7 @@ export function registerLogTraceTool(server, storage) {
|
|
|
51
59
|
'',
|
|
52
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.',
|
|
53
61
|
'',
|
|
54
|
-
'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.',
|
|
55
63
|
'',
|
|
56
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.',
|
|
57
65
|
'',
|
|
@@ -63,7 +71,11 @@ export function registerLogTraceTool(server, storage) {
|
|
|
63
71
|
'',
|
|
64
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.',
|
|
65
73
|
].join('\n'),
|
|
66
|
-
|
|
74
|
+
// Strict at the MCP boundary (unknown args rejected, not stripped).
|
|
75
|
+
// The dashboard's HTTP ingest builds its own schema FROM this shape
|
|
76
|
+
// (dashboard/validation.ts) and keeps default stripping there on
|
|
77
|
+
// purpose — it relies on it to discard a client-supplied trace_id.
|
|
78
|
+
inputSchema: strictInput(logTraceInputShape),
|
|
67
79
|
annotations: {
|
|
68
80
|
readOnlyHint: false, // Writes a row to storage
|
|
69
81
|
destructiveHint: false, // Creates new data; doesn't overwrite or delete
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/*
|
|
3
|
+
* Wraps a tool's input shape in a STRICT object schema so unknown argument
|
|
4
|
+
* names are REJECTED with an error that names the offending key(s) and
|
|
5
|
+
* lists the valid ones.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: a bare shape (or z.object()) silently STRIPS unknown
|
|
8
|
+
* keys. At the MCP tool boundary that is dangerous, not lenient — an LLM
|
|
9
|
+
* guessing an argument name is the normal case, not an edge case. Before
|
|
10
|
+
* this wrapper, `evaluate_output({ criteria: ["safety"], ... })` (a
|
|
11
|
+
* plausible guess) and `eval_typ: "safety"` (a one-character typo) both
|
|
12
|
+
* "succeeded": the arguments were dropped, the DEFAULT completeness bundle
|
|
13
|
+
* ran instead of the safety rules, and the response said passed:true on
|
|
14
|
+
* text containing real PII — with nothing indicating the arguments were
|
|
15
|
+
* ignored. Meanwhile a missing REQUIRED field produced a precise Zod
|
|
16
|
+
* error, so the failure mode was inconsistent as well as unsafe.
|
|
17
|
+
*
|
|
18
|
+
* The MCP SDK accepts a schema object (not just a raw shape) for
|
|
19
|
+
* inputSchema and validates tool calls through it, so the custom
|
|
20
|
+
* unrecognized-keys message below is exactly what the caller sees.
|
|
21
|
+
* Strictness also reaches tools/list: the generated JSON Schema carries
|
|
22
|
+
* additionalProperties:false, telling well-behaved clients up front.
|
|
23
|
+
*/
|
|
24
|
+
export function strictInput(shape) {
|
|
25
|
+
const validKeys = Object.keys(shape).join(', ');
|
|
26
|
+
return z.strictObject(shape, {
|
|
27
|
+
error: (issue) => issue.code === 'unrecognized_keys'
|
|
28
|
+
? `Unknown argument(s): ${issue.keys.map((k) => `"${k}"`).join(', ')}. ` +
|
|
29
|
+
`Valid arguments: ${validKeys}. ` +
|
|
30
|
+
'Unknown arguments are rejected rather than silently ignored, so a misspelled ' +
|
|
31
|
+
'argument name cannot change what gets evaluated — check the spelling against ' +
|
|
32
|
+
"the tool's input schema and retry."
|
|
33
|
+
: undefined,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -3,6 +3,7 @@ import { LOCAL_TENANT } from '../types/tenant.js';
|
|
|
3
3
|
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
|
+
import { strictInput } from './strict-input.js';
|
|
6
7
|
const inputSchema = {
|
|
7
8
|
output: z.string().min(1).describe('The agent output containing citations to verify'),
|
|
8
9
|
model: z
|
|
@@ -57,17 +58,17 @@ export function registerVerifyCitationsTool(server, storage) {
|
|
|
57
58
|
'',
|
|
58
59
|
'Behavior. Three-phase pipeline: (1) regex extraction of [N] numbered refs, (Author, Year) parentheticals, bare URLs, and DOIs (in-process, no network); (2) SSRF-guarded fetch of URL + DOI citations, with scheme allowlist, private/link-local/cloud-metadata IP blocking, optional domain allowlist (IRIS_CITATION_DOMAINS), 10s timeout, 5MB body cap, manual redirect chase (max 3, re-checked), in-process LRU cache; (3) per-citation LLM judge call asking "does this source support this claim?" with a 256-token verdict. Opt-in via allow_fetch=true or IRIS_CITATION_ALLOW_FETCH=1 — Iris refuses outbound HTTP by default. Cost-capped across the entire call by max_cost_usd_total (default $1.00) — the pipeline stops when the cap would be exceeded. Rate-limited to 20 req/min on HTTP MCP. Writes one eval_result row tagged with per-citation provenance.',
|
|
59
60
|
'',
|
|
60
|
-
'Output shape. Returns JSON: `{ "id": "<uuid>", "overall_score": 0..1|null, "passed": boolean, "total_citations_found": number, "total_resolved": number, "total_supported": number, "total_cost_usd": number, "citations": [{ "citation": { "raw", "kind", "identifier", "offset_start", "offset_end" }, "resolve_status": "ok"|"skipped"|"error", "resolve_error"?, "source"?: { "url", "status", "content_type", "bytes_fetched", "truncated" }, "judge"?: { "supported", "confidence", "rationale", "cost_usd", "latency_ms", "input_tokens", "output_tokens" } }] }`. `overall_score = supported /
|
|
61
|
+
'Output shape. Returns JSON: `{ "id": "<uuid>", "overall_score": 0..1|null, "passed": boolean, "total_citations_found": number, "total_resolved": number, "total_judged": number, "total_supported": number, "total_cost_usd": number, "citations": [{ "citation": { "raw", "kind", "identifier", "offset_start", "offset_end" }, "resolve_status": "ok"|"skipped"|"error", "resolve_error"?, "source"?: { "url", "status", "content_type", "bytes_fetched", "truncated" }, "judge"?: { "supported", "confidence", "rationale", "cost_usd", "latency_ms", "input_tokens", "output_tokens" } }] }`. `overall_score = supported / judged`; `null` when nothing was judged (no resolvable citations, or every judge call failed). Infrastructure failures (cost cap, judge timeout/error, malformed verdict) leave a citation resolved-but-unjudged — reported per-citation via resolve_error, never scored as unsupported.',
|
|
61
62
|
'',
|
|
62
63
|
'Use when the output makes factual claims backed by [1]-style references, DOIs, or URLs and you want to separate "cited correctly" from "cited and wrong" from "cited but unresolvable". Particularly useful for research/legal/medical agents where fabricated citations are the dominant failure mode.',
|
|
63
64
|
"",
|
|
64
65
|
"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.",
|
|
65
66
|
'',
|
|
66
|
-
'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.',
|
|
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); 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.',
|
|
67
68
|
'',
|
|
68
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).',
|
|
69
70
|
].join('\n'),
|
|
70
|
-
inputSchema,
|
|
71
|
+
inputSchema: strictInput(inputSchema),
|
|
71
72
|
annotations: {
|
|
72
73
|
readOnlyHint: false, // Writes eval_result + spends money
|
|
73
74
|
destructiveHint: false, // Creates data; doesn't overwrite/delete
|
|
@@ -109,11 +110,11 @@ export function registerVerifyCitationsTool(server, storage) {
|
|
|
109
110
|
passed: result.passed,
|
|
110
111
|
score,
|
|
111
112
|
message: result.overallScore === null
|
|
112
|
-
? `No
|
|
113
|
-
: `${result.totalSupported}/${result.
|
|
113
|
+
? `No citations judged (found ${result.totalCitationsFound}, resolved ${result.totalResolved}, judged 0)`
|
|
114
|
+
: `${result.totalSupported}/${result.totalJudged} judged sources supported the output`,
|
|
114
115
|
},
|
|
115
116
|
],
|
|
116
|
-
suggestions: result.passed ? [] : [`Only ${result.totalSupported}/${result.
|
|
117
|
+
suggestions: result.passed ? [] : [`Only ${result.totalSupported}/${result.totalJudged} judged sources actually supported the claim.`],
|
|
117
118
|
rules_evaluated: 1,
|
|
118
119
|
rules_skipped: 0,
|
|
119
120
|
insufficient_data: result.overallScore === null,
|
|
@@ -128,6 +129,7 @@ export function registerVerifyCitationsTool(server, storage) {
|
|
|
128
129
|
passed: result.passed,
|
|
129
130
|
total_citations_found: result.totalCitationsFound,
|
|
130
131
|
total_resolved: result.totalResolved,
|
|
132
|
+
total_judged: result.totalJudged,
|
|
131
133
|
total_supported: result.totalSupported,
|
|
132
134
|
total_cost_usd: result.totalCostUsd,
|
|
133
135
|
citations: result.citations.map((c) => ({
|
package/dist/transport/http.js
CHANGED
|
@@ -56,9 +56,31 @@ export async function createHttpTransport(mcpServer, config, logger) {
|
|
|
56
56
|
* `127.0.0.1:0` and reject every real request with a 403 that looks
|
|
57
57
|
* exactly like an attack. Routes are registered immediately after, and
|
|
58
58
|
* the port is not discoverable by any client until this function returns.
|
|
59
|
+
*
|
|
60
|
+
* The callback MUST inspect its error argument. Express 5 wires the
|
|
61
|
+
* listen callback as `server.once('error', done)` as well as the
|
|
62
|
+
* listening callback — so on EADDRINUSE it is invoked WITH the error.
|
|
63
|
+
* Ignoring that argument resolved this promise on a server that never
|
|
64
|
+
* bound: the caller then logged "HTTP transport listening on <port>"
|
|
65
|
+
* while another process owned the port, and the process idled forever.
|
|
66
|
+
* A CI health poll got 200 from the OTHER instance and shipped
|
|
67
|
+
* evaluations to a stranger's database. A bind failure must reject,
|
|
68
|
+
* name the port, and take the process down nonzero.
|
|
59
69
|
*/
|
|
60
|
-
const httpServer = await new Promise((resolve) => {
|
|
61
|
-
const server = app.listen(config.transport.port, config.transport.host, () =>
|
|
70
|
+
const httpServer = await new Promise((resolve, reject) => {
|
|
71
|
+
const server = app.listen(config.transport.port, config.transport.host, (err) => {
|
|
72
|
+
if (err) {
|
|
73
|
+
const bind = `${config.transport.host}:${config.transport.port}`;
|
|
74
|
+
const code = err.code;
|
|
75
|
+
reject(code === 'EADDRINUSE'
|
|
76
|
+
? new Error(`HTTP transport failed to start: port ${config.transport.port} is already in use ` +
|
|
77
|
+
`(EADDRINUSE on ${bind}). Another process — possibly another iris instance — owns it. ` +
|
|
78
|
+
`Pass --port <other> (or set IRIS_PORT) or stop the other process.`)
|
|
79
|
+
: new Error(`HTTP transport failed to bind ${bind}: ${err.message}`));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
resolve(server);
|
|
83
|
+
});
|
|
62
84
|
});
|
|
63
85
|
const address = httpServer.address();
|
|
64
86
|
const port = typeof address === 'object' && address ? address.port : config.transport.port;
|
|
@@ -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). */
|
|
@@ -120,3 +128,15 @@ export interface MomentQueryResult {
|
|
|
120
128
|
limit: number;
|
|
121
129
|
offset: number;
|
|
122
130
|
}
|
|
131
|
+
export interface RankedFailure extends DecisionMoment {
|
|
132
|
+
/** Severity × recency-decay blend, 0-1. Higher = shown first. */
|
|
133
|
+
rankScore: number;
|
|
134
|
+
}
|
|
135
|
+
export interface FailureQueryResult {
|
|
136
|
+
failures: RankedFailure[];
|
|
137
|
+
/** How many recent traces were scanned to build the list. */
|
|
138
|
+
scanned: number;
|
|
139
|
+
/** Total traces matching the filter (pre-scan-cap). */
|
|
140
|
+
total: number;
|
|
141
|
+
limit: number;
|
|
142
|
+
}
|
package/dist/types/eval.d.ts
CHANGED
|
@@ -4,6 +4,18 @@ export interface EvalRule {
|
|
|
4
4
|
description: string;
|
|
5
5
|
evalType: EvalType;
|
|
6
6
|
weight: number;
|
|
7
|
+
/**
|
|
8
|
+
* Hard-fail marker. When a critical rule FAILS (and was not skipped), the
|
|
9
|
+
* overall eval reports passed=false regardless of the weighted score.
|
|
10
|
+
*
|
|
11
|
+
* Exists because the weighted average routinely outvotes a genuine
|
|
12
|
+
* violation: an output leaking a real SSN failed no_pii while the other
|
|
13
|
+
* safety rules passed, scoring ~0.765 — above the 0.7 threshold — so the
|
|
14
|
+
* one field every CI gate reads said passed:true about the product's
|
|
15
|
+
* flagship failure scenario. The score stays a quality gradient; `passed`
|
|
16
|
+
* is the verdict, and a critical violation must never be averaged away.
|
|
17
|
+
*/
|
|
18
|
+
critical?: boolean;
|
|
7
19
|
evaluate(context: EvalContext): EvalRuleResult;
|
|
8
20
|
}
|
|
9
21
|
export interface EvalContext {
|
|
@@ -23,6 +35,17 @@ export interface EvalContext {
|
|
|
23
35
|
costUsd?: number;
|
|
24
36
|
metadata?: Record<string, unknown>;
|
|
25
37
|
customConfig?: Record<string, unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* Per-evaluation regex circuit breaker, initialized by the engine (never
|
|
40
|
+
* by callers). Each sandbox budget breach increments `breaches`; once it
|
|
41
|
+
* reaches the cap, remaining regex rules in the SAME evaluation skip
|
|
42
|
+
* without running. Bounds how long a single hostile output can stall a
|
|
43
|
+
* request: without it, N regex rules × (budget + worker respawn) of
|
|
44
|
+
* main-thread stall scale linearly with N.
|
|
45
|
+
*/
|
|
46
|
+
regexBudget?: {
|
|
47
|
+
breaches: number;
|
|
48
|
+
};
|
|
26
49
|
}
|
|
27
50
|
export interface EvalRuleResult {
|
|
28
51
|
ruleName: string;
|
|
@@ -31,6 +54,8 @@ export interface EvalRuleResult {
|
|
|
31
54
|
message: string;
|
|
32
55
|
skipped?: boolean;
|
|
33
56
|
skipReason?: string;
|
|
57
|
+
configInvalid?: boolean;
|
|
58
|
+
budgetExceeded?: boolean;
|
|
34
59
|
}
|
|
35
60
|
export interface EvalResult {
|
|
36
61
|
id: string;
|
|
@@ -46,6 +71,28 @@ export interface EvalResult {
|
|
|
46
71
|
rules_evaluated?: number;
|
|
47
72
|
rules_skipped?: number;
|
|
48
73
|
insufficient_data?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Names of critical rules that failed (present only when non-empty).
|
|
76
|
+
* Any entry here forces passed=false regardless of the weighted score —
|
|
77
|
+
* this field is how a caller tells "failed the quality bar" apart from
|
|
78
|
+
* "committed a hard violation".
|
|
79
|
+
*/
|
|
80
|
+
critical_failures?: string[];
|
|
81
|
+
/**
|
|
82
|
+
* Names of critical rules that were SKIPPED and therefore did not judge
|
|
83
|
+
* this output (present only when non-empty). Almost always a sandbox
|
|
84
|
+
* budget breach — a regex killed mid-backtrack, which an adversary can
|
|
85
|
+
* provoke deliberately by crafting output that stalls a known pattern.
|
|
86
|
+
*
|
|
87
|
+
* This is the fail-open seam between the release's two headline features:
|
|
88
|
+
* a budget-killed critical rule does NOT veto, so the evaluation can
|
|
89
|
+
* return passed=true with no `critical_failures` at all. That is
|
|
90
|
+
* deliberate (failing closed would let the same adversary force false
|
|
91
|
+
* violations on benign output), but a consumer that must fail closed
|
|
92
|
+
* needs to see it WITHOUT walking rule_results[].budgetExceeded. Treat a
|
|
93
|
+
* non-empty `critical_skipped` as "unknown", not as "clean".
|
|
94
|
+
*/
|
|
95
|
+
critical_skipped?: string[];
|
|
49
96
|
}
|
|
50
97
|
export type CustomRuleType = 'regex_match' | 'regex_no_match' | 'min_length' | 'max_length' | 'contains_keywords' | 'excludes_keywords' | 'json_schema' | 'cost_threshold';
|
|
51
98
|
export interface CustomRuleDefinition {
|
package/dist/types/query.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface TraceQueryResult {
|
|
|
23
23
|
limit: number;
|
|
24
24
|
offset: number;
|
|
25
25
|
}
|
|
26
|
-
export type EvalStatsPeriod = '24h' | '7d' | '30d' | 'all';
|
|
26
|
+
export type EvalStatsPeriod = '24h' | '2d' | '7d' | '14d' | '30d' | '60d' | '90d' | '180d' | 'all';
|
|
27
27
|
export interface EvalStats {
|
|
28
28
|
passRate: number;
|
|
29
29
|
avgScore: number;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, writeFileSync, renameSync, unlinkSync, chmodSync, existsSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
4
|
/*
|
|
@@ -28,6 +28,38 @@ import { randomBytes } from 'node:crypto';
|
|
|
28
28
|
*/
|
|
29
29
|
const TRANSIENT_RENAME_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
30
30
|
const MAX_ATTEMPTS = 5;
|
|
31
|
+
/*
|
|
32
|
+
* Owner-only (0600). These files hold agent inputs and outputs verbatim —
|
|
33
|
+
* and a tool whose job is detecting PII necessarily stores the PII it found.
|
|
34
|
+
* Node's default is 0666 before umask, so a typical umask leaves them 0644:
|
|
35
|
+
* world-readable to every local account on a shared POSIX host. The mode is
|
|
36
|
+
* a no-op on Windows (ACL inheritance governs there), which is exactly why
|
|
37
|
+
* local testing never surfaces it.
|
|
38
|
+
*
|
|
39
|
+
* Set at creation on the temp file, so the bytes are never briefly readable
|
|
40
|
+
* between write and chmod; rename preserves the mode.
|
|
41
|
+
*/
|
|
42
|
+
export const OWNER_ONLY_FILE_MODE = 0o600;
|
|
43
|
+
/*
|
|
44
|
+
* Narrow an EXISTING file to owner-only. Two cases need this, because a mode
|
|
45
|
+
* passed at write time only applies when the write creates the file:
|
|
46
|
+
* - files written before this change (every install that predates it),
|
|
47
|
+
* - files a library creates for us (better-sqlite3 opens iris.db, and WAL
|
|
48
|
+
* mode adds iris.db-wal / iris.db-shm on first write).
|
|
49
|
+
* Best-effort by design: a missing file, a read-only mount, or a
|
|
50
|
+
* non-POSIX filesystem must never take the server down over permissions.
|
|
51
|
+
*/
|
|
52
|
+
export function ensureOwnerOnly(...paths) {
|
|
53
|
+
for (const p of paths) {
|
|
54
|
+
try {
|
|
55
|
+
if (existsSync(p))
|
|
56
|
+
chmodSync(p, OWNER_ONLY_FILE_MODE);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Best effort — see above.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
31
63
|
function sleepSync(ms) {
|
|
32
64
|
// Synchronous by necessity — writeAtomic is sync, and making it async
|
|
33
65
|
// would ripple through every caller for a Windows-only edge case.
|
|
@@ -36,7 +68,7 @@ function sleepSync(ms) {
|
|
|
36
68
|
export function writeAtomic(targetPath, contents) {
|
|
37
69
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
38
70
|
const tmp = `${targetPath}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
39
|
-
writeFileSync(tmp, contents, 'utf-8');
|
|
71
|
+
writeFileSync(tmp, contents, { encoding: 'utf-8', mode: OWNER_ONLY_FILE_MODE });
|
|
40
72
|
let lastError;
|
|
41
73
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
42
74
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iris-eval/mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Stop shipping agents on vibes. Score every agent output for quality, safety, and cost.",
|
|
5
5
|
"mcpName": "io.github.iris-eval/mcp-server",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"test:coverage": "vitest run --coverage",
|
|
23
23
|
"test:integration": "vitest run tests/integration/",
|
|
24
24
|
"test:e2e": "playwright test",
|
|
25
|
+
"test:uat": "node tests/uat/run-uat.mjs",
|
|
25
26
|
"test:e2e:ui": "playwright test --ui",
|
|
26
27
|
"version:check": "bash scripts/check-version.sh",
|
|
27
28
|
"version:sync": "node scripts/sync-versions.mjs",
|
package/server.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.iris-eval/mcp-server",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Stop shipping agents on vibes. Score every agent output for quality, safety, and cost.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/iris-eval/mcp-server",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.5.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@iris-eval/mcp-server",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.5.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
@import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500..700&family=Manrope:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap";:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--iris-50:#f0fdfa;--iris-100:#ccfbf1;--iris-200:#99f6e4;--iris-300:#5eead4;--iris-400:#2dd4bf;--iris-500:#14b8a6;--iris-600:#0d9488;--iris-700:#0f766e;--iris-800:#115e59;--iris-900:#134e4a;--iris-950:#042f2e;--eval-pass:#22c55e;--eval-warn:#eab308;--eval-fail:#ef4444;--eval-tool:#3b82f6;--eval-llm:#a855f7;--eval-skipped:#71717a}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root,[data-theme=dark]{--bg-base:#050508;--bg-raised:#08080e;--bg-surface:#0d0d15;--bg-card:#101018;--bg-card-hover:#16161f;--border-subtle:#ffffff0d;--border-default:#ffffff14;--border-strong:#ffffff24;--border-glow:#14b8a680;--text-primary:#f0f0f5;--text-secondary:#9494a8;--text-muted:#5e5e72;--text-accent:var(--iris-400);--glow-primary:#14b8a61f;--glow-strong:#14b8a640;--shadow-sm:0 1px 2px #0000004d;--shadow-md:0 4px 6px #0006;--shadow-lg:0 10px 15px #00000080}[data-theme=light]{--bg-base:#fafcfc;--bg-raised:#f1f5f5;--bg-surface:#e8eded;--bg-card:#fff;--bg-card-hover:#f4f8f8;--border-subtle:#0000000a;--border-default:#00000014;--border-strong:#00000024;--border-glow:#0d948859;--text-primary:#0a0f0e;--text-secondary:#3d5250;--text-muted:#7a908e;--text-accent:var(--iris-700);--glow-primary:#0d94880f;--glow-strong:#0d94881f;--shadow-sm:0 1px 2px #0000000f;--shadow-md:0 4px 6px #00000014;--shadow-lg:0 10px 15px #0000001a}:root{--font-display:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--font-body:"Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, monospace;--font-sans:var(--font-body);--text-caption-xs:11px;--text-caption:12px;--text-body-sm:13px;--text-body:14px;--text-body-lg:15px;--text-heading-sm:16px;--text-heading:20px;--text-display-sm:28px;--text-display:40px;--font-size-xs:var(--text-caption);--font-size-sm:var(--text-body-sm);--font-size-base:var(--text-body);--font-size-lg:var(--text-body-lg);--font-size-xl:var(--text-heading-sm);--font-size-2xl:var(--text-heading);--font-size-3xl:var(--text-display-sm);--leading-body:1.5;--leading-heading:1.2;--leading-display:1.1;--leading-mono:1.4;--space-0_5:2px;--space-1:4px;--space-1_5:6px;--space-2:8px;--space-2_5:10px;--space-3:12px;--space-4:16px;--space-5:20px;--space-6:24px;--space-8:32px;--space-10:40px;--space-12:48px;--space-16:64px;--space-20:80px;--space-24:96px}:root,[data-density=compact]{--density-row:32px;--density-padding:var(--space-3);--density-body:var(--text-body-sm)}[data-density=comfortable]{--density-row:44px;--density-padding:var(--space-4);--density-body:var(--text-body)}:root{--sidebar-width-expanded:256px;--sidebar-width-collapsed:64px;--header-height:56px;--page-toolbar-height:40px;--radius-xs:4px;--radius-sm:6px;--radius:8px;--radius-lg:12px;--radius-xl:16px;--radius-pill:999px;--border-radius:var(--radius);--border-radius-sm:var(--radius-xs);--border-radius-lg:var(--radius-lg);--transition-instant:.1s ease;--transition-fast:.15s ease;--transition-base:.2s ease;--transition-slow:.3s ease;--ease-iris:cubic-bezier(.25, .4, .25, 1);--bg-primary:var(--bg-base);--bg-secondary:var(--bg-raised);--bg-tertiary:var(--bg-surface);--bg-hover:var(--bg-card-hover);--border-color:var(--border-default);--accent-primary:var(--iris-500);--accent-primary-hover:var(--iris-400);--accent-success:var(--eval-pass);--accent-error:var(--eval-fail);--accent-warning:var(--eval-warn);--accent-tool:var(--eval-tool);--accent-llm:var(--eval-llm)}html{transition:background-color var(--transition-base), color var(--transition-base)}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{width:100%;height:100%}body{font-family:var(--font-body);font-size:var(--text-body);color:var(--text-primary);background-color:var(--bg-base);line-height:var(--leading-body);-webkit-font-smoothing:antialiased;font-feature-settings:"cv11", "ss01"}h1,h2,h3,h4,.display{font-family:var(--font-display);letter-spacing:-.01em;font-weight:600}a{color:var(--text-accent);text-decoration:none}a:hover{color:var(--iris-300)}button{cursor:pointer;font-family:inherit}input,select{font-family:inherit;font-size:inherit}code,pre{font-family:var(--font-mono)}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,[role=button]:focus-visible{outline:2px solid var(--iris-500);outline-offset:2px;border-radius:var(--radius-xs)}.iris-sr-reveal{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.iris-sr-reveal:focus-within{width:auto;height:auto;margin:var(--space-3) 0 0 0;padding:var(--space-3) var(--space-4);clip:auto;white-space:normal;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--iris-500);border-radius:var(--radius-sm);position:static;overflow:visible}.iris-sr-reveal:focus-within>li{padding:var(--space-1) 0;list-style:none}::selection{background:var(--iris-600);color:#fff}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-base)}::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:var(--radius-xs)}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}html{scrollbar-color:var(--border-strong) transparent;scrollbar-width:thin}@keyframes pulse-ring{0%{opacity:.5;transform:scale(1)}to{opacity:0;transform:scale(2.5)}}.pulse-dot{position:relative}.pulse-dot:after{content:"";background:var(--iris-500);border-radius:50%;animation:2s ease-out infinite pulse-ring;position:absolute;inset:-2px}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media (width<=767px){aside[aria-label=Main\ navigation]{width:160px}main{overflow-x:auto}}@media print{body{color:#000!important;background:#fff!important}aside[aria-label=Main\ navigation],header,[role=region][aria-label=Welcome],[role=region][aria-label=Bulk\ actions],[role=status],[role=dialog]{display:none!important}body,#root,main{height:auto!important;overflow:visible!important}main{padding:0!important}tr,pre,code{page-break-inside:avoid}h1,h2,h3{page-break-after:avoid}[aria-label*=violation],[aria-label*=spike],[aria-label*=collision],[aria-label*=Pass],[aria-label*=Fail]{border:1px solid #000!important}a{color:#000!important;text-decoration:underline!important}a[href^=http]:after{content:" (" attr(href) ")";color:#555;font-size:80%}}
|