@iris-eval/mcp-server 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/dist/audit-log-reader.d.ts +24 -0
- package/dist/audit-log-reader.js +87 -0
- package/dist/config/defaults.js +7 -1
- package/dist/custom-rule-store.d.ts +27 -0
- package/dist/custom-rule-store.js +188 -0
- package/dist/dashboard/assets/index-BEG5FYWH.css +1 -0
- package/dist/dashboard/assets/index-D9JHfSB2.js +12 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/audit.d.ts +3 -0
- package/dist/dashboard/routes/audit.js +36 -0
- package/dist/dashboard/routes/eval-stats.js +9 -4
- package/dist/dashboard/routes/evaluations.js +3 -1
- package/dist/dashboard/routes/filters.js +5 -3
- package/dist/dashboard/routes/health.js +8 -1
- package/dist/dashboard/routes/index.d.ts +4 -0
- package/dist/dashboard/routes/index.js +4 -0
- package/dist/dashboard/routes/moments.d.ts +3 -0
- package/dist/dashboard/routes/moments.js +115 -0
- package/dist/dashboard/routes/preferences.d.ts +3 -0
- package/dist/dashboard/routes/preferences.js +52 -0
- package/dist/dashboard/routes/rules.d.ts +10 -0
- package/dist/dashboard/routes/rules.js +169 -0
- package/dist/dashboard/routes/summary.js +3 -1
- package/dist/dashboard/routes/traces.js +7 -4
- package/dist/dashboard/server.d.ts +9 -1
- package/dist/dashboard/server.js +52 -3
- package/dist/eval/citation-verify/extract.d.ts +11 -0
- package/dist/eval/citation-verify/extract.js +102 -0
- package/dist/eval/citation-verify/resolve.d.ts +26 -0
- package/dist/eval/citation-verify/resolve.js +237 -0
- package/dist/eval/citation-verify/verifier.d.ts +43 -0
- package/dist/eval/citation-verify/verifier.js +203 -0
- package/dist/eval/decision-moment.d.ts +12 -0
- package/dist/eval/decision-moment.js +181 -0
- package/dist/eval/llm-judge/client.d.ts +28 -0
- package/dist/eval/llm-judge/client.js +183 -0
- package/dist/eval/llm-judge/evaluator.d.ts +32 -0
- package/dist/eval/llm-judge/evaluator.js +138 -0
- package/dist/eval/llm-judge/pricing.d.ts +9 -0
- package/dist/eval/llm-judge/pricing.js +31 -0
- package/dist/eval/llm-judge/templates/index.d.ts +20 -0
- package/dist/eval/llm-judge/templates/index.js +170 -0
- package/dist/eval/rules/custom.js +13 -2
- package/dist/index.js +77 -14
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/tenant.d.ts +17 -0
- package/dist/middleware/tenant.js +26 -0
- package/dist/otel/exporter.d.ts +24 -0
- package/dist/otel/exporter.js +116 -0
- package/dist/otel/lazy.d.ts +5 -0
- package/dist/otel/lazy.js +31 -0
- package/dist/otel/mapper.d.ts +24 -0
- package/dist/otel/mapper.js +208 -0
- package/dist/preferences.d.ts +129 -0
- package/dist/preferences.js +152 -0
- package/dist/resources/dashboard-summary.js +3 -1
- package/dist/resources/trace-detail.js +5 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.js +9 -3
- package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
- package/dist/storage/migrations/004-tenant-id.js +40 -0
- package/dist/storage/migrations/index.js +2 -1
- package/dist/storage/sqlite-adapter.d.ts +17 -15
- package/dist/storage/sqlite-adapter.js +130 -79
- package/dist/tools/delete-rule.d.ts +3 -0
- package/dist/tools/delete-rule.js +53 -0
- package/dist/tools/delete-trace.d.ts +3 -0
- package/dist/tools/delete-trace.js +54 -0
- package/dist/tools/deploy-rule.d.ts +3 -0
- package/dist/tools/deploy-rule.js +91 -0
- package/dist/tools/evaluate-output.js +23 -2
- package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
- package/dist/tools/evaluate-with-llm-judge.js +147 -0
- package/dist/tools/get-traces.js +23 -3
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +13 -1
- package/dist/tools/list-rules.d.ts +3 -0
- package/dist/tools/list-rules.js +66 -0
- package/dist/tools/log-trace.js +30 -2
- package/dist/tools/verify-citations.d.ts +3 -0
- package/dist/tools/verify-citations.js +157 -0
- package/dist/types/custom-rule.d.ts +70 -0
- package/dist/types/custom-rule.js +1 -0
- package/dist/types/decision-moment.d.ts +122 -0
- package/dist/types/decision-moment.js +17 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +1 -1
- package/dist/types/query.d.ts +25 -15
- package/dist/types/tenant.d.ts +26 -0
- package/dist/types/tenant.js +58 -0
- package/dist/utils/open-browser.d.ts +1 -0
- package/dist/utils/open-browser.js +45 -0
- package/dist/utils/validate-port-config.d.ts +2 -0
- package/dist/utils/validate-port-config.js +9 -0
- package/package.json +4 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
- package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
3
|
+
import { verifyCitations } from '../eval/citation-verify/verifier.js';
|
|
4
|
+
import { findPricing } from '../eval/llm-judge/pricing.js';
|
|
5
|
+
import { generateEvalId } from '../utils/ids.js';
|
|
6
|
+
const inputSchema = {
|
|
7
|
+
output: z.string().min(1).describe('The agent output containing citations to verify'),
|
|
8
|
+
model: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe('Judge model for per-citation verification. Supported: anthropic = claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5-20251001; openai = gpt-4o | gpt-4o-mini | o1-mini.'),
|
|
11
|
+
provider: z.enum(['anthropic', 'openai']).optional().describe('Auto-detected from model when omitted'),
|
|
12
|
+
allow_fetch: z.boolean().optional().describe('Permit outbound HTTP to resolve URLs/DOIs. Defaults to IRIS_CITATION_ALLOW_FETCH=1; false otherwise. SSRF-guarded regardless.'),
|
|
13
|
+
domain_allowlist: z
|
|
14
|
+
.array(z.string())
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('Restrict fetches to hostnames in this list (suffix match allowed). Merged with IRIS_CITATION_DOMAINS env.'),
|
|
17
|
+
max_cost_usd_total: z.number().positive().optional().describe('Cap TOTAL judge cost across all citations in this call; default $1.00'),
|
|
18
|
+
max_citations: z.number().int().positive().max(50).optional().describe('Max citations to verify (extras skipped); default 20'),
|
|
19
|
+
per_source_timeout_ms: z.number().int().positive().optional().describe('Per-URL fetch timeout; default 10_000'),
|
|
20
|
+
per_source_max_bytes: z.number().int().positive().optional().describe('Per-URL body cap; default 5MB'),
|
|
21
|
+
trace_id: z.string().optional().describe('Link verification result to a trace'),
|
|
22
|
+
};
|
|
23
|
+
function inferProvider(model) {
|
|
24
|
+
const pricing = findPricing(model);
|
|
25
|
+
if (!pricing) {
|
|
26
|
+
throw new Error(`Unknown model "${model}". Provider cannot be inferred. Supported models: src/eval/llm-judge/pricing.ts.`);
|
|
27
|
+
}
|
|
28
|
+
return pricing.provider;
|
|
29
|
+
}
|
|
30
|
+
function resolveApiKey(provider) {
|
|
31
|
+
const key = provider === 'anthropic' ? process.env.IRIS_ANTHROPIC_API_KEY : process.env.IRIS_OPENAI_API_KEY;
|
|
32
|
+
if (!key) {
|
|
33
|
+
throw new Error(`${provider === 'anthropic' ? 'Anthropic' : 'OpenAI'} judge requires IRIS_${provider === 'anthropic' ? 'ANTHROPIC' : 'OPENAI'}_API_KEY for verify_citations.`);
|
|
34
|
+
}
|
|
35
|
+
return key;
|
|
36
|
+
}
|
|
37
|
+
function resolveAllowFetch(paramValue) {
|
|
38
|
+
if (paramValue !== undefined)
|
|
39
|
+
return paramValue;
|
|
40
|
+
return process.env.IRIS_CITATION_ALLOW_FETCH === '1';
|
|
41
|
+
}
|
|
42
|
+
function resolveDomainAllowlist(paramValue) {
|
|
43
|
+
const envRaw = process.env.IRIS_CITATION_DOMAINS;
|
|
44
|
+
const fromEnv = envRaw ? envRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
45
|
+
if (paramValue && paramValue.length > 0) {
|
|
46
|
+
return [...new Set([...fromEnv, ...paramValue])];
|
|
47
|
+
}
|
|
48
|
+
return fromEnv.length > 0 ? fromEnv : undefined;
|
|
49
|
+
}
|
|
50
|
+
export function registerVerifyCitationsTool(server, storage) {
|
|
51
|
+
server.registerTool('verify_citations', {
|
|
52
|
+
title: 'Verify Citations',
|
|
53
|
+
description: [
|
|
54
|
+
'Extract citations from agent output, fetch the cited sources, and use an LLM judge to check whether each source supports the claim in context. Returns per-citation verdicts + an overall support ratio.',
|
|
55
|
+
'',
|
|
56
|
+
'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.',
|
|
57
|
+
'',
|
|
58
|
+
'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 / resolved`; `null` when nothing resolvable was found.',
|
|
59
|
+
'',
|
|
60
|
+
'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.',
|
|
61
|
+
"",
|
|
62
|
+
"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.",
|
|
63
|
+
'',
|
|
64
|
+
'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).',
|
|
65
|
+
].join('\n'),
|
|
66
|
+
inputSchema,
|
|
67
|
+
annotations: {
|
|
68
|
+
readOnlyHint: false, // Writes eval_result + spends money
|
|
69
|
+
destructiveHint: false, // Creates data; doesn't overwrite/delete
|
|
70
|
+
idempotentHint: false, // External fetches + provider non-determinism
|
|
71
|
+
openWorldHint: true, // Outbound HTTP to citation URLs + LLM provider API
|
|
72
|
+
},
|
|
73
|
+
}, async (args) => {
|
|
74
|
+
const provider = args.provider ?? inferProvider(args.model);
|
|
75
|
+
const apiKey = resolveApiKey(provider);
|
|
76
|
+
const allowFetch = resolveAllowFetch(args.allow_fetch);
|
|
77
|
+
const domainAllowlist = resolveDomainAllowlist(args.domain_allowlist);
|
|
78
|
+
const result = await verifyCitations({
|
|
79
|
+
output: args.output,
|
|
80
|
+
provider,
|
|
81
|
+
model: args.model,
|
|
82
|
+
apiKey,
|
|
83
|
+
allowFetch,
|
|
84
|
+
domainAllowlist,
|
|
85
|
+
maxCostUsdTotal: args.max_cost_usd_total,
|
|
86
|
+
maxCitations: args.max_citations,
|
|
87
|
+
perSourceTimeoutMs: args.per_source_timeout_ms,
|
|
88
|
+
perSourceMaxBytes: args.per_source_max_bytes,
|
|
89
|
+
});
|
|
90
|
+
const evalId = generateEvalId();
|
|
91
|
+
const score = result.overallScore ?? 0;
|
|
92
|
+
// Persist so dashboard can surface. eval_type='custom' — same
|
|
93
|
+
// rationale as evaluate_with_llm_judge (spans all 4 heuristic
|
|
94
|
+
// categories). rule_results[0] carries per-citation summary.
|
|
95
|
+
await storage.insertEvalResult(LOCAL_TENANT, {
|
|
96
|
+
id: evalId,
|
|
97
|
+
trace_id: args.trace_id,
|
|
98
|
+
eval_type: 'custom',
|
|
99
|
+
output_text: args.output,
|
|
100
|
+
score,
|
|
101
|
+
passed: result.passed,
|
|
102
|
+
rule_results: [
|
|
103
|
+
{
|
|
104
|
+
ruleName: `semantic_citation_verify:${provider}/${args.model}`,
|
|
105
|
+
passed: result.passed,
|
|
106
|
+
score,
|
|
107
|
+
message: result.overallScore === null
|
|
108
|
+
? `No resolvable citations (found ${result.totalCitationsFound}, resolved ${result.totalResolved})`
|
|
109
|
+
: `${result.totalSupported}/${result.totalResolved} cited sources supported the output`,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
suggestions: result.passed ? [] : [`Only ${result.totalSupported}/${result.totalResolved} cited sources actually supported the claim.`],
|
|
113
|
+
rules_evaluated: 1,
|
|
114
|
+
rules_skipped: 0,
|
|
115
|
+
insufficient_data: result.overallScore === null,
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: 'text',
|
|
121
|
+
text: JSON.stringify({
|
|
122
|
+
id: evalId,
|
|
123
|
+
overall_score: result.overallScore,
|
|
124
|
+
passed: result.passed,
|
|
125
|
+
total_citations_found: result.totalCitationsFound,
|
|
126
|
+
total_resolved: result.totalResolved,
|
|
127
|
+
total_supported: result.totalSupported,
|
|
128
|
+
total_cost_usd: result.totalCostUsd,
|
|
129
|
+
citations: result.citations.map((c) => ({
|
|
130
|
+
citation: {
|
|
131
|
+
raw: c.citation.raw,
|
|
132
|
+
kind: c.citation.kind,
|
|
133
|
+
identifier: c.citation.identifier,
|
|
134
|
+
offset_start: c.citation.offsetStart,
|
|
135
|
+
offset_end: c.citation.offsetEnd,
|
|
136
|
+
},
|
|
137
|
+
resolve_status: c.resolveStatus,
|
|
138
|
+
resolve_error: c.resolveError,
|
|
139
|
+
source: c.source,
|
|
140
|
+
judge: c.judge
|
|
141
|
+
? {
|
|
142
|
+
supported: c.judge.supported,
|
|
143
|
+
confidence: c.judge.confidence,
|
|
144
|
+
rationale: c.judge.rationale,
|
|
145
|
+
cost_usd: c.judge.costUsd,
|
|
146
|
+
latency_ms: c.judge.latencyMs,
|
|
147
|
+
input_tokens: c.judge.inputTokens,
|
|
148
|
+
output_tokens: c.judge.outputTokens,
|
|
149
|
+
}
|
|
150
|
+
: undefined,
|
|
151
|
+
})),
|
|
152
|
+
}),
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CustomRuleDefinition, EvalType } from './eval.js';
|
|
2
|
+
export type RuleSeverity = 'low' | 'medium' | 'high' | 'critical';
|
|
3
|
+
export interface DeployedCustomRule {
|
|
4
|
+
/** Stable id (e.g., "rule-<8-char-hex>"). Generated server-side on deploy. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** User-readable name. Becomes the rule name in eval results. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Human-readable description (what the rule checks for, why it matters). */
|
|
9
|
+
description: string;
|
|
10
|
+
/** Eval category this rule belongs to. Determines when it fires. */
|
|
11
|
+
evalType: EvalType;
|
|
12
|
+
/** Severity used to sort rules in the dashboard + audit alerts. */
|
|
13
|
+
severity: RuleSeverity;
|
|
14
|
+
/** The check definition (regex pattern, length threshold, etc.). */
|
|
15
|
+
definition: CustomRuleDefinition;
|
|
16
|
+
/** Whether this rule is currently active. Disabled rules don't fire but are kept for audit. */
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
/** ISO timestamp of deploy. */
|
|
19
|
+
createdAt: string;
|
|
20
|
+
/** ISO timestamp of most recent edit. */
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
/** Optional moment ID the rule was extracted from (workflow inversion provenance). */
|
|
23
|
+
sourceMomentId?: string;
|
|
24
|
+
/** Version counter — incremented on edit. Starts at 1. */
|
|
25
|
+
version: number;
|
|
26
|
+
}
|
|
27
|
+
export interface CustomRulesFile {
|
|
28
|
+
/** File schema version — bump when shape changes. */
|
|
29
|
+
version: 1;
|
|
30
|
+
rules: DeployedCustomRule[];
|
|
31
|
+
}
|
|
32
|
+
export interface AuditLogEntry {
|
|
33
|
+
ts: string;
|
|
34
|
+
/**
|
|
35
|
+
* Which tenant the action belongs to. OSS installs always emit 'local'.
|
|
36
|
+
* Cloud installs emit the tenant resolved from the authenticated session.
|
|
37
|
+
*
|
|
38
|
+
* Optional for backward compatibility: entries written before v0.4.0
|
|
39
|
+
* don't have this field. Readers MUST treat missing `tenantId` as
|
|
40
|
+
* 'local' so old audit logs remain queryable on upgrade.
|
|
41
|
+
*/
|
|
42
|
+
tenantId?: string;
|
|
43
|
+
/** Action taken — currently rule.deploy / rule.delete / rule.toggle. */
|
|
44
|
+
action: 'rule.deploy' | 'rule.delete' | 'rule.toggle' | 'rule.update';
|
|
45
|
+
/** Who initiated. v0.4 is single-user local — always "local". v0.5+ adds users. */
|
|
46
|
+
user: string;
|
|
47
|
+
ruleId: string;
|
|
48
|
+
ruleName?: string;
|
|
49
|
+
/** Optional detail: source moment id, prior version, etc. */
|
|
50
|
+
details?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
export interface RulePreviewResult {
|
|
53
|
+
/** Number of historical traces evaluated. */
|
|
54
|
+
tracesEvaluated: number;
|
|
55
|
+
/** How many traces would have FAILED this proposed rule. */
|
|
56
|
+
wouldFail: number;
|
|
57
|
+
/** How many would have PASSED. */
|
|
58
|
+
wouldPass: number;
|
|
59
|
+
/** How many would have skipped (rule not applicable to context). */
|
|
60
|
+
wouldSkip: number;
|
|
61
|
+
/** First 5 example traces that would fail (with brief output preview). */
|
|
62
|
+
examples: Array<{
|
|
63
|
+
traceId: string;
|
|
64
|
+
agentName: string;
|
|
65
|
+
timestamp: string;
|
|
66
|
+
outputPreview: string;
|
|
67
|
+
}>;
|
|
68
|
+
/** Time window covered. */
|
|
69
|
+
windowSinceIso: string;
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export type MomentVerdict = 'pass' | 'fail' | 'partial' | 'unevaluated';
|
|
2
|
+
export type MomentSignificanceKind = 'safety-violation' | 'cost-spike' | 'first-failure' | 'novel-pattern' | 'rule-collision' | 'normal-pass' | 'normal-fail';
|
|
3
|
+
export interface MomentSignificance {
|
|
4
|
+
/** Classifier kind. */
|
|
5
|
+
kind: MomentSignificanceKind;
|
|
6
|
+
/** 0-1; how moment-worthy this trace is. Used for visual emphasis on the timeline. */
|
|
7
|
+
score: number;
|
|
8
|
+
/** Short human-readable label (≤30 chars) — shown on timeline dot tooltip. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Longer explanation — shown in the detail surface. */
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
export interface MomentRuleSnapshot {
|
|
14
|
+
/** Names of rules that failed. */
|
|
15
|
+
failed: string[];
|
|
16
|
+
/** Names of rules that were skipped (insufficient context). */
|
|
17
|
+
skipped: string[];
|
|
18
|
+
/** Count of rules that passed. */
|
|
19
|
+
passedCount: number;
|
|
20
|
+
/** Count of rules that fired total (across all eval_types). */
|
|
21
|
+
totalCount: number;
|
|
22
|
+
}
|
|
23
|
+
export interface DecisionMoment {
|
|
24
|
+
/** Stable id; equals the source trace_id. */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Source trace_id (same as id; included for query convenience). */
|
|
27
|
+
traceId: string;
|
|
28
|
+
/** Agent that produced the output. */
|
|
29
|
+
agentName: string;
|
|
30
|
+
/** ISO timestamp of the trace. */
|
|
31
|
+
timestamp: string;
|
|
32
|
+
/** Input the agent received (may be omitted for storage-size reasons). */
|
|
33
|
+
input?: string;
|
|
34
|
+
/** Output the agent produced. */
|
|
35
|
+
output?: string;
|
|
36
|
+
/** Trace-level cost in USD. */
|
|
37
|
+
costUsd?: number;
|
|
38
|
+
/** Trace-level end-to-end latency. */
|
|
39
|
+
latencyMs?: number;
|
|
40
|
+
/** Aggregated eval verdict across all eval_types that ran on this trace. */
|
|
41
|
+
verdict: MomentVerdict;
|
|
42
|
+
/** Weighted average score across all eval rules that fired (excludes skipped). */
|
|
43
|
+
overallScore: number;
|
|
44
|
+
/** Count of distinct evaluations (one per eval_type) recorded for this trace. */
|
|
45
|
+
evalCount: number;
|
|
46
|
+
/** Per-rule pass/fail/skip breakdown. */
|
|
47
|
+
ruleSnapshot: MomentRuleSnapshot;
|
|
48
|
+
/** Why this trace is (or isn't) moment-worthy. */
|
|
49
|
+
significance: MomentSignificance;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Detailed view returned by the moment-detail endpoint. Includes the full
|
|
53
|
+
* eval result list (with messages + scores per rule) and the source trace
|
|
54
|
+
* for context.
|
|
55
|
+
*/
|
|
56
|
+
export interface DecisionMomentDetail extends DecisionMoment {
|
|
57
|
+
/** Every eval recorded for this trace, with full rule result detail. */
|
|
58
|
+
evals: Array<{
|
|
59
|
+
id: string;
|
|
60
|
+
evalType: string;
|
|
61
|
+
score: number;
|
|
62
|
+
passed: boolean;
|
|
63
|
+
ruleResults: Array<{
|
|
64
|
+
ruleName: string;
|
|
65
|
+
passed: boolean;
|
|
66
|
+
score: number;
|
|
67
|
+
message: string;
|
|
68
|
+
skipped?: boolean;
|
|
69
|
+
skipReason?: string;
|
|
70
|
+
}>;
|
|
71
|
+
suggestions: string[];
|
|
72
|
+
createdAt?: string;
|
|
73
|
+
}>;
|
|
74
|
+
/** Full input (uncompressed). */
|
|
75
|
+
input?: string;
|
|
76
|
+
/** Full output (uncompressed). */
|
|
77
|
+
output?: string;
|
|
78
|
+
/** Tool-call sequence from the trace, if any. */
|
|
79
|
+
toolCalls?: Array<{
|
|
80
|
+
tool_name: string;
|
|
81
|
+
input?: unknown;
|
|
82
|
+
output?: unknown;
|
|
83
|
+
latency_ms?: number;
|
|
84
|
+
error?: string;
|
|
85
|
+
}>;
|
|
86
|
+
/** Span tree from the trace, if any. */
|
|
87
|
+
spans?: Array<{
|
|
88
|
+
span_id: string;
|
|
89
|
+
parent_span_id?: string;
|
|
90
|
+
name: string;
|
|
91
|
+
kind: string;
|
|
92
|
+
start_time: string;
|
|
93
|
+
end_time?: string;
|
|
94
|
+
}>;
|
|
95
|
+
}
|
|
96
|
+
export interface MomentQueryFilter {
|
|
97
|
+
/** Filter by agent name. */
|
|
98
|
+
agentName?: string;
|
|
99
|
+
/** Filter by verdict. */
|
|
100
|
+
verdict?: MomentVerdict;
|
|
101
|
+
/** Filter to only show moments above a significance threshold. */
|
|
102
|
+
minSignificance?: number;
|
|
103
|
+
/** Filter by significance kind (e.g., only show safety-violations). */
|
|
104
|
+
significanceKind?: MomentSignificanceKind;
|
|
105
|
+
/** ISO timestamp; only moments at or after. */
|
|
106
|
+
since?: string;
|
|
107
|
+
/** ISO timestamp; only moments at or before. */
|
|
108
|
+
until?: string;
|
|
109
|
+
}
|
|
110
|
+
export interface MomentQueryOptions {
|
|
111
|
+
filter?: MomentQueryFilter;
|
|
112
|
+
limit?: number;
|
|
113
|
+
offset?: number;
|
|
114
|
+
/** Default 'desc' (most recent first). */
|
|
115
|
+
sortOrder?: 'asc' | 'desc';
|
|
116
|
+
}
|
|
117
|
+
export interface MomentQueryResult {
|
|
118
|
+
moments: DecisionMoment[];
|
|
119
|
+
total: number;
|
|
120
|
+
limit: number;
|
|
121
|
+
offset: number;
|
|
122
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Decision Moment — the new primary unit of the Iris dashboard.
|
|
3
|
+
*
|
|
4
|
+
* A Decision Moment is the point at which an agent's output was scored.
|
|
5
|
+
* It is derived from existing data: one trace + its aggregated eval results.
|
|
6
|
+
* No schema migration — the moment is computed on read.
|
|
7
|
+
*
|
|
8
|
+
* The system-design reframe: not every eval is a moment. The significance
|
|
9
|
+
* classifier separates moments worth a human's attention (safety violations,
|
|
10
|
+
* cost spikes, first-failures, novel patterns) from operational data
|
|
11
|
+
* (passing evals on the happy path).
|
|
12
|
+
*
|
|
13
|
+
* This is the workflow primitive that supports Make-This-A-Rule: a user
|
|
14
|
+
* looking at a moment can promote the observed behavior into a new rule
|
|
15
|
+
* without leaving the moment context.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -2,3 +2,5 @@ export type { SpanKind, SpanStatus, SpanEvent, ToolCallRecord, TokenUsage, Span,
|
|
|
2
2
|
export type { EvalType, EvalRule, EvalContext, EvalRuleResult, EvalResult, 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
|
+
export type { TenantId } from './tenant.js';
|
|
6
|
+
export { LOCAL_TENANT, asTenantId, TenantContextRequiredError } from './tenant.js';
|
package/dist/types/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { LOCAL_TENANT, asTenantId, TenantContextRequiredError } from './tenant.js';
|
package/dist/types/query.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Trace, Span } from './trace.js';
|
|
2
2
|
import type { EvalResult } from './eval.js';
|
|
3
|
+
import type { TenantId } from './tenant.js';
|
|
3
4
|
export interface TraceFilter {
|
|
4
5
|
agent_name?: string;
|
|
5
6
|
framework?: string;
|
|
@@ -74,14 +75,14 @@ export interface DashboardSummary {
|
|
|
74
75
|
export interface IStorageAdapter {
|
|
75
76
|
initialize(): Promise<void>;
|
|
76
77
|
close(): Promise<void>;
|
|
77
|
-
insertTrace(trace: Trace): Promise<void>;
|
|
78
|
-
getTrace(traceId: string): Promise<Trace | null>;
|
|
79
|
-
queryTraces(options: TraceQueryOptions): Promise<TraceQueryResult>;
|
|
80
|
-
insertSpan(span: Span): Promise<void>;
|
|
81
|
-
getSpansByTraceId(traceId: string): Promise<Span[]>;
|
|
82
|
-
insertEvalResult(result: EvalResult): Promise<void>;
|
|
83
|
-
getEvalsByTraceId(traceId: string): Promise<EvalResult[]>;
|
|
84
|
-
queryEvalResults(options: {
|
|
78
|
+
insertTrace(tenantId: TenantId, trace: Trace): Promise<void>;
|
|
79
|
+
getTrace(tenantId: TenantId, traceId: string): Promise<Trace | null>;
|
|
80
|
+
queryTraces(tenantId: TenantId, options: TraceQueryOptions): Promise<TraceQueryResult>;
|
|
81
|
+
insertSpan(tenantId: TenantId, span: Span): Promise<void>;
|
|
82
|
+
getSpansByTraceId(tenantId: TenantId, traceId: string): Promise<Span[]>;
|
|
83
|
+
insertEvalResult(tenantId: TenantId, result: EvalResult): Promise<void>;
|
|
84
|
+
getEvalsByTraceId(tenantId: TenantId, traceId: string): Promise<EvalResult[]>;
|
|
85
|
+
queryEvalResults(tenantId: TenantId, options: {
|
|
85
86
|
eval_type?: string;
|
|
86
87
|
passed?: boolean;
|
|
87
88
|
since?: string;
|
|
@@ -92,11 +93,20 @@ export interface IStorageAdapter {
|
|
|
92
93
|
results: EvalResult[];
|
|
93
94
|
total: number;
|
|
94
95
|
}>;
|
|
95
|
-
getDashboardSummary(sinceHours?: number): Promise<DashboardSummary>;
|
|
96
|
-
deleteTracesOlderThan(days: number): Promise<number>;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
getDashboardSummary(tenantId: TenantId, sinceHours?: number): Promise<DashboardSummary>;
|
|
97
|
+
deleteTracesOlderThan(tenantId: TenantId, days: number): Promise<number>;
|
|
98
|
+
/**
|
|
99
|
+
* Delete a single trace by id. Cascades to spans via FK ON DELETE
|
|
100
|
+
* CASCADE; eval_results get their trace_id set to NULL (so score
|
|
101
|
+
* history survives even after the trace is deleted).
|
|
102
|
+
*
|
|
103
|
+
* Returns true if a row was deleted, false if the id didn't exist
|
|
104
|
+
* (or belonged to a different tenant).
|
|
105
|
+
*/
|
|
106
|
+
deleteTrace(tenantId: TenantId, traceId: string): Promise<boolean>;
|
|
107
|
+
getDistinctValues(tenantId: TenantId, column: string): Promise<string[]>;
|
|
108
|
+
getEvalStats(tenantId: TenantId, period: EvalStatsPeriod): Promise<EvalStats>;
|
|
109
|
+
getEvalStatsTrend(tenantId: TenantId, period: EvalStatsPeriod): Promise<EvalStatsTrendBucket[]>;
|
|
110
|
+
getEvalStatsRules(tenantId: TenantId, period: EvalStatsPeriod): Promise<EvalStatsRuleBreakdown[]>;
|
|
111
|
+
getEvalStatsFailures(tenantId: TenantId, period: EvalStatsPeriod, limit: number): Promise<EvalStatsFailure[]>;
|
|
102
112
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Nominal brand on string. A plain string cannot be used where TenantId is required. */
|
|
2
|
+
export type TenantId = string & {
|
|
3
|
+
readonly __brand: 'TenantId';
|
|
4
|
+
};
|
|
5
|
+
/** The sentinel tenant used by OSS / single-user installs. */
|
|
6
|
+
export declare const LOCAL_TENANT: TenantId;
|
|
7
|
+
/**
|
|
8
|
+
* Coerce a raw string into a TenantId after validating non-empty.
|
|
9
|
+
* Throws TenantContextRequiredError when the input is empty/null/undefined.
|
|
10
|
+
*
|
|
11
|
+
* This is the only officially-sanctioned way to mint a TenantId from user
|
|
12
|
+
* input. It centralizes the non-empty invariant so every call site is
|
|
13
|
+
* safe by construction.
|
|
14
|
+
*/
|
|
15
|
+
export declare function asTenantId(value: string | null | undefined): TenantId;
|
|
16
|
+
/**
|
|
17
|
+
* Thrown when a storage method is invoked without a valid tenant
|
|
18
|
+
* context. Fail-safe: we'd rather crash than return data that might
|
|
19
|
+
* cross a tenant boundary.
|
|
20
|
+
*
|
|
21
|
+
* Catch at the route-handler layer and translate to 500 + correlation
|
|
22
|
+
* ID; never surface the raw message to end users.
|
|
23
|
+
*/
|
|
24
|
+
export declare class TenantContextRequiredError extends Error {
|
|
25
|
+
constructor(message?: string);
|
|
26
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* tenant — tenant identity primitives.
|
|
3
|
+
*
|
|
4
|
+
* Every storage read/write in Iris takes a TenantId. For OSS / single-
|
|
5
|
+
* user installs the value is always `LOCAL_TENANT` (the literal string
|
|
6
|
+
* `'local'`). For the future Cloud SKU, the value is resolved by the
|
|
7
|
+
* authentication middleware from the session and carried on the
|
|
8
|
+
* request context.
|
|
9
|
+
*
|
|
10
|
+
* Design constraints (from the 2026-04-23 threat model §5.1):
|
|
11
|
+
* 1. Tenant context is a REQUIRED parameter on every storage method;
|
|
12
|
+
* no Optional<TenantId>. The type system enforces it.
|
|
13
|
+
* 2. The default for OSS is the literal string 'local' — never null,
|
|
14
|
+
* never undefined, never auto-generated. No "no tenant" code path
|
|
15
|
+
* exists that could become a "show me everything" path.
|
|
16
|
+
* 3. Tenant resolution happens in middleware, not storage. Storage
|
|
17
|
+
* receives the resolved TenantId and uses it verbatim.
|
|
18
|
+
* 4. Default-deny: a storage method receiving an empty tenantId MUST
|
|
19
|
+
* throw TenantContextRequiredError, not return all rows.
|
|
20
|
+
* 5. TenantId is an opaque non-empty string. No assumption beyond
|
|
21
|
+
* "valid non-empty UTF-8"; Cloud will use UUID/KSUID, OSS uses
|
|
22
|
+
* 'local'.
|
|
23
|
+
*
|
|
24
|
+
* Branded string pattern: we wrap `string` in a nominal brand so passing
|
|
25
|
+
* a raw `string` where `TenantId` is expected is a compile error. The
|
|
26
|
+
* only path to a TenantId is through `asTenantId()` or the LOCAL_TENANT
|
|
27
|
+
* constant — both of which validate non-empty.
|
|
28
|
+
*/
|
|
29
|
+
/** The sentinel tenant used by OSS / single-user installs. */
|
|
30
|
+
export const LOCAL_TENANT = 'local';
|
|
31
|
+
/**
|
|
32
|
+
* Coerce a raw string into a TenantId after validating non-empty.
|
|
33
|
+
* Throws TenantContextRequiredError when the input is empty/null/undefined.
|
|
34
|
+
*
|
|
35
|
+
* This is the only officially-sanctioned way to mint a TenantId from user
|
|
36
|
+
* input. It centralizes the non-empty invariant so every call site is
|
|
37
|
+
* safe by construction.
|
|
38
|
+
*/
|
|
39
|
+
export function asTenantId(value) {
|
|
40
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
41
|
+
throw new TenantContextRequiredError(`TenantId must be a non-empty string; got ${value === null ? 'null' : typeof value}`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Thrown when a storage method is invoked without a valid tenant
|
|
47
|
+
* context. Fail-safe: we'd rather crash than return data that might
|
|
48
|
+
* cross a tenant boundary.
|
|
49
|
+
*
|
|
50
|
+
* Catch at the route-handler layer and translate to 500 + correlation
|
|
51
|
+
* ID; never surface the raw message to end users.
|
|
52
|
+
*/
|
|
53
|
+
export class TenantContextRequiredError extends Error {
|
|
54
|
+
constructor(message = 'Tenant context required; storage cannot be called without a resolved TenantId') {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'TenantContextRequiredError';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function openBrowser(url: string): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* open-browser — minimal cross-platform browser launcher.
|
|
3
|
+
*
|
|
4
|
+
* Avoids the `open` npm dep so iris-mcp's install size stays small.
|
|
5
|
+
* Uses the platform's default URL handler:
|
|
6
|
+
* Windows: `cmd /c start "" "<url>"`
|
|
7
|
+
* macOS: `open "<url>"`
|
|
8
|
+
* Linux: `xdg-open "<url>"`
|
|
9
|
+
*
|
|
10
|
+
* Spawns detached + ignores stdio so the iris-mcp process doesn't depend
|
|
11
|
+
* on the launched browser staying alive.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
export function openBrowser(url) {
|
|
15
|
+
const platform = process.platform;
|
|
16
|
+
let command;
|
|
17
|
+
let args;
|
|
18
|
+
if (platform === 'win32') {
|
|
19
|
+
// The empty title argument is required so cmd treats the URL as the
|
|
20
|
+
// target rather than as the window title.
|
|
21
|
+
command = 'cmd';
|
|
22
|
+
args = ['/c', 'start', '', url];
|
|
23
|
+
}
|
|
24
|
+
else if (platform === 'darwin') {
|
|
25
|
+
command = 'open';
|
|
26
|
+
args = [url];
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
// Linux + BSDs.
|
|
30
|
+
command = 'xdg-open';
|
|
31
|
+
args = [url];
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const child = spawn(command, args, {
|
|
35
|
+
detached: true,
|
|
36
|
+
stdio: 'ignore',
|
|
37
|
+
});
|
|
38
|
+
child.unref();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Best-effort: if the launch fails (no browser, missing utility,
|
|
42
|
+
// sandboxed environment), the dashboard is still reachable manually
|
|
43
|
+
// at the URL we logged.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function validatePortConfig(config) {
|
|
2
|
+
if (config.transport.type === 'http' &&
|
|
3
|
+
config.dashboard.enabled &&
|
|
4
|
+
config.transport.port === config.dashboard.port) {
|
|
5
|
+
throw new Error(`Port collision: HTTP transport and dashboard are both configured for port ${config.transport.port}. ` +
|
|
6
|
+
`Pass --dashboard-port <other> or set IRIS_DASHBOARD_PORT to a different port. ` +
|
|
7
|
+
`Default dashboard port is 6920; a common pair is --port 6919 --dashboard-port 6920.`);
|
|
8
|
+
}
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iris-eval/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The agent eval standard for MCP. Score every agent output for quality, safety, and cost.",
|
|
5
5
|
"mcpName": "io.github.iris-eval/mcp-server",
|
|
6
6
|
"type": "module",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"test:watch": "vitest",
|
|
22
22
|
"test:coverage": "vitest run --coverage",
|
|
23
23
|
"test:integration": "vitest run tests/integration/",
|
|
24
|
+
"test:e2e": "playwright test",
|
|
25
|
+
"test:e2e:ui": "playwright test --ui",
|
|
24
26
|
"version:check": "bash scripts/check-version.sh",
|
|
25
27
|
"version:sync": "node scripts/sync-versions.mjs",
|
|
26
28
|
"clean": "rm -rf dist coverage",
|
|
@@ -81,6 +83,7 @@
|
|
|
81
83
|
"zod": "^3.25.0"
|
|
82
84
|
},
|
|
83
85
|
"devDependencies": {
|
|
86
|
+
"@playwright/test": "^1.59.1",
|
|
84
87
|
"@types/better-sqlite3": "^7.6.0",
|
|
85
88
|
"@types/express": "^5.0.0",
|
|
86
89
|
"@types/node": "^25.5.2",
|
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.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@iris-eval/mcp-server",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|