@iris-eval/mcp-server 0.4.0 → 0.4.2
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 +2 -2
- package/dist/custom-rule-store.d.ts +18 -10
- package/dist/custom-rule-store.js +81 -45
- package/dist/dashboard/assets/{index-BEG5FYWH.css → index-B4Aw6ozt.css} +1 -1
- package/dist/dashboard/assets/index-YnFsPfd6.js +12 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/rules.js +18 -5
- package/dist/eval/citation-verify/resolve.d.ts +7 -0
- package/dist/eval/citation-verify/resolve.js +42 -7
- package/dist/index.js +5 -2
- package/dist/middleware/auth.js +12 -1
- package/dist/middleware/cors.js +7 -1
- package/dist/middleware/tenant.d.ts +5 -3
- package/dist/tools/delete-rule.js +7 -1
- package/dist/tools/delete-trace.js +4 -0
- package/dist/tools/deploy-rule.js +7 -1
- package/dist/tools/evaluate-output.js +12 -8
- package/dist/tools/evaluate-with-llm-judge.js +4 -0
- package/dist/tools/get-traces.js +15 -11
- package/dist/tools/list-rules.js +9 -1
- package/dist/tools/log-trace.js +15 -11
- package/dist/tools/verify-citations.js +4 -0
- package/package.json +5 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-D9JHfSB2.js +0 -12
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>Iris — Agent Eval & Observability</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-YnFsPfd6.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B4Aw6ozt.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
|
@@ -36,12 +36,22 @@ const PreviewSchema = z.object({
|
|
|
36
36
|
maxTraces: z.coerce.number().int().min(1).max(5000).default(1000),
|
|
37
37
|
});
|
|
38
38
|
export function registerRuleRoutes(router, storage, opts) {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/*
|
|
40
|
+
* Every /rules/custom route resolves the tenant via requireTenant(req)
|
|
41
|
+
* and threads it through to the store. In OSS the tenant middleware
|
|
42
|
+
* always sets LOCAL_TENANT, so all rules continue to live at
|
|
43
|
+
* ~/.iris/custom-rules.json (the v0.4 path — zero migration). In Cloud,
|
|
44
|
+
* each tenant's rules will live in their own file (per-tenant partition)
|
|
45
|
+
* and writes will only ever touch the resolved tenant's data.
|
|
46
|
+
*/
|
|
47
|
+
router.get('/rules/custom', (req, res) => {
|
|
48
|
+
const tenantId = requireTenant(req);
|
|
49
|
+
const rules = opts.customRuleStore.list(tenantId);
|
|
41
50
|
res.json({ rules });
|
|
42
51
|
});
|
|
43
52
|
router.post('/rules/custom', async (req, res) => {
|
|
44
53
|
try {
|
|
54
|
+
const tenantId = requireTenant(req);
|
|
45
55
|
const input = DeploySchema.parse(req.body);
|
|
46
56
|
// Server overrides the inner definition's `name` so it always matches the
|
|
47
57
|
// user-facing rule name. Avoids confusion when the rule name and the
|
|
@@ -50,7 +60,7 @@ export function registerRuleRoutes(router, storage, opts) {
|
|
|
50
60
|
...input.definition,
|
|
51
61
|
name: input.name,
|
|
52
62
|
};
|
|
53
|
-
const rule = opts.customRuleStore.deploy({
|
|
63
|
+
const rule = opts.customRuleStore.deploy(tenantId, {
|
|
54
64
|
name: input.name,
|
|
55
65
|
description: input.description,
|
|
56
66
|
evalType: input.evalType,
|
|
@@ -59,7 +69,9 @@ export function registerRuleRoutes(router, storage, opts) {
|
|
|
59
69
|
sourceMomentId: input.sourceMomentId,
|
|
60
70
|
});
|
|
61
71
|
// Register the new rule with the live engine so it fires on subsequent
|
|
62
|
-
// evaluate_output calls without requiring a server restart.
|
|
72
|
+
// evaluate_output calls without requiring a server restart. The engine
|
|
73
|
+
// is process-global in v0.4 — Cloud multi-tenant engine wiring is a
|
|
74
|
+
// v0.5 architectural item.
|
|
63
75
|
opts.evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition));
|
|
64
76
|
res.status(201).json({ rule });
|
|
65
77
|
}
|
|
@@ -72,7 +84,8 @@ export function registerRuleRoutes(router, storage, opts) {
|
|
|
72
84
|
}
|
|
73
85
|
});
|
|
74
86
|
router.delete('/rules/custom/:id', (req, res) => {
|
|
75
|
-
const
|
|
87
|
+
const tenantId = requireTenant(req);
|
|
88
|
+
const removed = opts.customRuleStore.delete(tenantId, req.params.id);
|
|
76
89
|
if (!removed) {
|
|
77
90
|
res.status(404).json({ error: 'Rule not found' });
|
|
78
91
|
return;
|
|
@@ -22,5 +22,12 @@ export declare class CitationResolveError extends Error {
|
|
|
22
22
|
constructor(message: string, kind: 'bad_scheme' | 'ssrf' | 'not_allowed_domain' | 'timeout' | 'too_large' | 'bad_status' | 'redirect_loop' | 'not_text' | 'fetch_disabled', details?: string | undefined);
|
|
23
23
|
}
|
|
24
24
|
export declare function isSafeHost(host: string): boolean;
|
|
25
|
+
type DnsLookupAll = (host: string) => Promise<Array<{
|
|
26
|
+
address: string;
|
|
27
|
+
family: 4 | 6;
|
|
28
|
+
}>>;
|
|
29
|
+
export declare function __setDnsLookupForTests(impl: DnsLookupAll | null): void;
|
|
30
|
+
export declare function resolveAndCheckHost(host: string): Promise<void>;
|
|
25
31
|
export declare function __clearCitationCacheForTests(): void;
|
|
26
32
|
export declare function resolveSource(identifier: string, opts: ResolveOptions): Promise<ResolvedSource>;
|
|
33
|
+
export {};
|
|
@@ -7,15 +7,25 @@
|
|
|
7
7
|
// 1. Scheme allowlist — http/https only; refuse file:/javascript:/etc.
|
|
8
8
|
// 2. SSRF host check — refuse localhost, link-local, private ranges,
|
|
9
9
|
// and cloud metadata (AWS/GCP/Azure/DigitalOcean) IP literals.
|
|
10
|
-
// 3.
|
|
10
|
+
// 3. DNS pre-resolve — every public hostname is resolved via
|
|
11
|
+
// dns.lookup({all:true}) and EVERY returned IP is re-checked against
|
|
12
|
+
// the IP blocklist. Defeats DNS-rebinding via public records pointing
|
|
13
|
+
// at private space (e.g. `*.localtest.me` resolving to 127.0.0.1).
|
|
14
|
+
// Residual TOCTOU window between this lookup and the socket connect
|
|
15
|
+
// is acknowledged — closing it requires a custom undici dispatcher;
|
|
16
|
+
// queued for follow-up if exploitation is observed.
|
|
17
|
+
// 4. Optional domain allowlist — IRIS_CITATION_DOMAINS=doi.org,arxiv.org
|
|
11
18
|
// restricts to a curated set; empty/unset = open web (still SSRF-guarded).
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
19
|
+
// 5. Timeout + size cap — 10s default, 5MB cap on response body.
|
|
20
|
+
// 6. Redirect chase cap — follow max 3 redirects, each re-checked.
|
|
21
|
+
// 7. Cache — in-process LRU (100 entries) so retries don't re-fetch.
|
|
15
22
|
//
|
|
16
23
|
// This is opt-in: calls require passing {allowFetch: true} so an agent
|
|
17
24
|
// can't trick Iris into fetching random URLs without operator consent
|
|
18
25
|
// (consent granted via tool param or env IRIS_CITATION_ALLOW_FETCH=1).
|
|
26
|
+
import { lookup as dnsLookupCb } from 'node:dns';
|
|
27
|
+
import { promisify } from 'node:util';
|
|
28
|
+
const dnsLookupAll = promisify(dnsLookupCb);
|
|
19
29
|
export class CitationResolveError extends Error {
|
|
20
30
|
kind;
|
|
21
31
|
details;
|
|
@@ -76,6 +86,33 @@ export function isSafeHost(host) {
|
|
|
76
86
|
}
|
|
77
87
|
return true;
|
|
78
88
|
}
|
|
89
|
+
let dnsLookupImpl = (host) => dnsLookupAll(host, { all: true });
|
|
90
|
+
export function __setDnsLookupForTests(impl) {
|
|
91
|
+
dnsLookupImpl = impl ?? ((host) => dnsLookupAll(host, { all: true }));
|
|
92
|
+
}
|
|
93
|
+
export async function resolveAndCheckHost(host) {
|
|
94
|
+
if (!isSafeHost(host)) {
|
|
95
|
+
throw new CitationResolveError(`Refusing SSRF-blocked host: ${host}`, 'ssrf', host);
|
|
96
|
+
}
|
|
97
|
+
// IP literals already passed isSafeHost — skip DNS (would just re-resolve to self).
|
|
98
|
+
if (isIpv4(host) || isIpv6(host))
|
|
99
|
+
return;
|
|
100
|
+
let addresses;
|
|
101
|
+
try {
|
|
102
|
+
addresses = await dnsLookupImpl(host);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
throw new CitationResolveError(`DNS resolution failed for ${host}: ${err instanceof Error ? err.message : String(err)}`, 'ssrf', host);
|
|
106
|
+
}
|
|
107
|
+
if (addresses.length === 0) {
|
|
108
|
+
throw new CitationResolveError(`DNS returned no addresses for ${host}`, 'ssrf', host);
|
|
109
|
+
}
|
|
110
|
+
for (const { address } of addresses) {
|
|
111
|
+
if (!isSafeHost(address)) {
|
|
112
|
+
throw new CitationResolveError(`Refusing SSRF-blocked address ${address} (resolved from ${host})`, 'ssrf', host);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
79
116
|
function matchesAllowlist(host, allowlist) {
|
|
80
117
|
if (!allowlist || allowlist.length === 0)
|
|
81
118
|
return true;
|
|
@@ -127,9 +164,7 @@ async function doFetch(url, opts, redirectsLeft) {
|
|
|
127
164
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
128
165
|
throw new CitationResolveError(`Refusing non-http(s) scheme: ${parsed.protocol}`, 'bad_scheme', parsed.protocol);
|
|
129
166
|
}
|
|
130
|
-
|
|
131
|
-
throw new CitationResolveError(`Refusing SSRF-blocked host: ${parsed.hostname}`, 'ssrf', parsed.hostname);
|
|
132
|
-
}
|
|
167
|
+
await resolveAndCheckHost(parsed.hostname);
|
|
133
168
|
if (!matchesAllowlist(parsed.hostname, opts.domainAllowlist)) {
|
|
134
169
|
throw new CitationResolveError(`Host ${parsed.hostname} not in IRIS_CITATION_DOMAINS allowlist`, 'not_allowed_domain', parsed.hostname);
|
|
135
170
|
}
|
package/dist/index.js
CHANGED
|
@@ -129,12 +129,15 @@ async function main() {
|
|
|
129
129
|
// Load deployed custom rules from ~/.iris/custom-rules.json (B3 — workflow inversion).
|
|
130
130
|
// Each enabled rule is registered with the engine under its evalType so it fires on
|
|
131
131
|
// every evaluate_output call of that category. Persistence via custom-rule-store.
|
|
132
|
-
|
|
132
|
+
// OSS single-tenant: register rules under LOCAL_TENANT only. Cloud multi-tenant
|
|
133
|
+
// engine wiring is a v0.5 architectural item (the engine is a process singleton
|
|
134
|
+
// and would need per-tenant rule registration).
|
|
135
|
+
const enabled = customRuleStore.enabledRules(LOCAL_TENANT);
|
|
133
136
|
for (const rule of enabled) {
|
|
134
137
|
evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition));
|
|
135
138
|
}
|
|
136
139
|
if (enabled.length > 0) {
|
|
137
|
-
logger.info(`Loaded ${enabled.length} deployed custom rule(s) from ${customRuleStore.
|
|
140
|
+
logger.info(`Loaded ${enabled.length} deployed custom rule(s) from ${customRuleStore.pathFor(LOCAL_TENANT)}`);
|
|
138
141
|
}
|
|
139
142
|
const httpServers = [];
|
|
140
143
|
// Run data retention cleanup on startup.
|
package/dist/middleware/auth.js
CHANGED
|
@@ -5,6 +5,7 @@ export function createAuthMiddleware(config) {
|
|
|
5
5
|
return (_req, _res, next) => next();
|
|
6
6
|
}
|
|
7
7
|
const keyBuffer = Buffer.from(apiKey);
|
|
8
|
+
const keyLen = keyBuffer.length;
|
|
8
9
|
return (req, res, next) => {
|
|
9
10
|
if (req.path === '/health' || req.path === '/api/v1/health') {
|
|
10
11
|
return next();
|
|
@@ -14,8 +15,18 @@ export function createAuthMiddleware(config) {
|
|
|
14
15
|
res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
18
|
+
// Pad the incoming token to the configured-key length and run
|
|
19
|
+
// timingSafeEqual on same-size buffers. The byte-compare and the
|
|
20
|
+
// length-equality check are computed independently before being
|
|
21
|
+
// combined, so the request takes the same compare path regardless
|
|
22
|
+
// of whether the token's length matches — eliminating the precise
|
|
23
|
+
// length-equality fast-path the original code had.
|
|
17
24
|
const tokenBuffer = Buffer.from(authHeader.slice(7));
|
|
18
|
-
|
|
25
|
+
const candidate = Buffer.alloc(keyLen);
|
|
26
|
+
tokenBuffer.copy(candidate, 0, 0, keyLen);
|
|
27
|
+
const cmpEq = timingSafeEqual(candidate, keyBuffer);
|
|
28
|
+
const lenEq = tokenBuffer.length === keyLen;
|
|
29
|
+
if (!(cmpEq && lenEq)) {
|
|
19
30
|
res.status(403).json({ error: 'Invalid API key' });
|
|
20
31
|
return;
|
|
21
32
|
}
|
package/dist/middleware/cors.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
// Wildcard `*` in an origin pattern matches a SINGLE label only — no dots,
|
|
2
|
+
// colons, or slashes. Previously substituted `.*`, which let
|
|
3
|
+
// `http://localhost:*` match `http://localhost:8080.evil.com` (the `.*`
|
|
4
|
+
// happily consumed `8080.evil.com`). Single-label match prevents the
|
|
5
|
+
// label-crossing bypass while still supporting `*.example.com` for
|
|
6
|
+
// per-subdomain allowlists and `localhost:*` for ephemeral dev ports.
|
|
1
7
|
function isOriginAllowed(origin, allowedOrigins) {
|
|
2
8
|
for (const pattern of allowedOrigins) {
|
|
3
9
|
if (pattern === '*')
|
|
4
10
|
return true;
|
|
5
11
|
if (pattern === origin)
|
|
6
12
|
return true;
|
|
7
|
-
const regex = new RegExp('^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '
|
|
13
|
+
const regex = new RegExp('^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^.:/]+') + '$');
|
|
8
14
|
if (regex.test(origin))
|
|
9
15
|
return true;
|
|
10
16
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { RequestHandler, Request } from 'express';
|
|
2
2
|
import type { TenantId } from '../types/tenant.js';
|
|
3
|
-
declare
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
declare global {
|
|
4
|
+
namespace Express {
|
|
5
|
+
interface Request {
|
|
6
|
+
tenantId?: TenantId;
|
|
7
|
+
}
|
|
6
8
|
}
|
|
7
9
|
}
|
|
8
10
|
/** Read `req.tenantId` with a fail-safe guarantee for downstream code. */
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* log row is the permanent record that the rule ever existed.
|
|
11
11
|
*/
|
|
12
12
|
import { z } from 'zod';
|
|
13
|
+
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
13
14
|
const inputSchema = {
|
|
14
15
|
rule_id: z
|
|
15
16
|
.string()
|
|
@@ -22,6 +23,8 @@ export function registerDeleteRuleTool(server, customRuleStore) {
|
|
|
22
23
|
description: [
|
|
23
24
|
'Remove a deployed custom evaluation rule. The rule stops firing on future evaluate_output calls; past eval_results that referenced it are preserved.',
|
|
24
25
|
'',
|
|
26
|
+
'Sibling tools — deploy_rule adds custom rules, list_rules enumerates them, evaluate_output runs them. delete_trace handles trace deletion (separate concern); log_trace / get_traces handle trace I/O. delete_rule is the DESTRUCTIVE remove path for the custom-rule store; it does NOT touch traces, eval_results, or built-in (non-custom) rules.',
|
|
27
|
+
'',
|
|
25
28
|
'Behavior. DESTRUCTIVE — rewrites ~/.iris/custom-rules.json without the deleted row and appends a `rule.delete` entry to the audit log (~/.iris/audit.log). Not idempotent: deleting an already-deleted rule returns `deleted: false` rather than re-emitting the audit row. The rule stops firing immediately on the live process. Historical eval_results that reference this rule_id stay in the database — drift analytics + audit trail remain valid. Tenant-scoped in Cloud tier; OSS operates on LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
|
|
26
29
|
'',
|
|
27
30
|
'Output shape. Returns JSON: `{ "deleted": boolean, "rule_id": string }`. `deleted=true` if a row was removed; `deleted=false` if no rule with that id existed.',
|
|
@@ -30,6 +33,8 @@ export function registerDeleteRuleTool(server, customRuleStore) {
|
|
|
30
33
|
'',
|
|
31
34
|
"Don't use to pause a rule (toggle in the dashboard preserves history better). Don't use on built-in (non-custom) rules — the rule_id format checks for `rule-<hex>` custom ids; built-ins aren't in the store. Don't use to delete a trace or eval result (use delete_trace for traces; eval_results deletion is not exposed in v0.4 — they fall under data retention).",
|
|
32
35
|
'',
|
|
36
|
+
'Parameters. rule_id is the only parameter; must match `rule-<lowercase-hex>` format (Zod regex). Format mismatch fails Zod with 400 BEFORE the store is touched. Cross-tenant rule_ids return `deleted: false` silently — they\'re invisible to the caller\'s tenant rather than producing a not-found error (prevents enumeration attacks). The rule_id you pass is exactly what list_rules returned in `id` or what deploy_rule returned in `rule.id`.',
|
|
37
|
+
'',
|
|
33
38
|
"Error modes. Throws 400 on malformed rule_id (wrong prefix). Returns `{deleted: false}` if rule_id doesn't match any deployed rule (not an error — idempotent-ish). Returns 429 on HTTP rate limit. File-write failures propagate as 500.",
|
|
34
39
|
].join('\n'),
|
|
35
40
|
inputSchema,
|
|
@@ -40,7 +45,8 @@ export function registerDeleteRuleTool(server, customRuleStore) {
|
|
|
40
45
|
openWorldHint: false,
|
|
41
46
|
},
|
|
42
47
|
}, async (args) => {
|
|
43
|
-
|
|
48
|
+
// OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
|
|
49
|
+
const deleted = customRuleStore.delete(LOCAL_TENANT, args.rule_id, 'mcp');
|
|
44
50
|
return {
|
|
45
51
|
content: [
|
|
46
52
|
{
|
|
@@ -23,6 +23,8 @@ export function registerDeleteTraceTool(server, storage) {
|
|
|
23
23
|
description: [
|
|
24
24
|
'Remove a single trace by id. Cascades to spans; eval_results keep the score history with trace_id NULLed.',
|
|
25
25
|
'',
|
|
26
|
+
'Sibling tools — log_trace creates traces, get_traces queries them, evaluate_output / evaluate_with_llm_judge / verify_citations score them. delete_rule handles custom-rule deletion (separate concern); list_rules / deploy_rule manage the custom-rule lifecycle. delete_trace is the DESTRUCTIVE single-row remove for traces; it does NOT touch eval_results (preserved for audit + drift analytics), spans cascade automatically.',
|
|
27
|
+
'',
|
|
26
28
|
'Behavior. DESTRUCTIVE — SQL DELETE scoped to the caller\'s tenant_id. Cascades: spans belonging to this trace are deleted (FK ON DELETE CASCADE); eval_results that referenced this trace have their trace_id set to NULL (FK ON DELETE SET NULL) so aggregate dashboards + historical scores remain valid even after the trace is gone. Not idempotent: deleting an already-deleted trace returns `deleted: false`. Does not emit an audit log entry in v0.4 — traces are user-scope data, not policy changes. Rate-limited to 20 req/min on HTTP MCP.',
|
|
27
29
|
'',
|
|
28
30
|
'Output shape. Returns JSON: `{ "deleted": boolean, "trace_id": string }`. `deleted=true` if a row was removed; `deleted=false` if no trace with that id existed (or it belonged to a different tenant — cross-tenant deletes silently fail).',
|
|
@@ -31,6 +33,8 @@ export function registerDeleteTraceTool(server, storage) {
|
|
|
31
33
|
'',
|
|
32
34
|
"Don't use to clean up OLD data in bulk (use retention config with --retention-days). Don't use to PAUSE a trace — traces are immutable once stored; there's nothing to pause. Don't use to delete eval_results — eval_results survive their trace's deletion intentionally (for audit + drift analysis); they're pruned only by retention.",
|
|
33
35
|
'',
|
|
36
|
+
'Parameters. trace_id is the only parameter; must match 32-char lowercase hex (Zod regex). The trace_id you pass is exactly what log_trace returned in its response, or what get_traces returned per row. Format mismatch fails Zod with 400 BEFORE the storage layer is touched. Cross-tenant trace_ids return `deleted: false` silently — they\'re invisible to the caller\'s tenant (prevents enumeration attacks; matches delete_rule\'s tenant-isolation contract).',
|
|
37
|
+
'',
|
|
34
38
|
"Error modes. Throws 400 on malformed trace_id (wrong format: not 32-char lowercase hex). Returns `{deleted: false}` when the id doesn't exist in the caller's tenant (not an error — the trace may simply have been deleted already). Returns 429 on HTTP rate limit. Storage failures propagate as 500.",
|
|
35
39
|
].join('\n'),
|
|
36
40
|
inputSchema,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* eval_type.
|
|
12
12
|
*/
|
|
13
13
|
import { z } from 'zod';
|
|
14
|
+
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
14
15
|
const CustomRuleDefinitionSchema = z.object({
|
|
15
16
|
name: z.string(),
|
|
16
17
|
type: z.enum([
|
|
@@ -52,6 +53,8 @@ export function registerDeployRuleTool(server, customRuleStore) {
|
|
|
52
53
|
description: [
|
|
53
54
|
'Deploy a new custom evaluation rule that will fire on every future evaluate_output call of its eval category.',
|
|
54
55
|
'',
|
|
56
|
+
'Sibling tools — list_rules enumerates deployed rules, delete_rule removes them, evaluate_output runs them. log_trace / get_traces / delete_trace handle the trace lifecycle separately; evaluate_with_llm_judge / verify_citations run semantic scoring (not heuristic-rule-driven). deploy_rule is the WRITE path that grows the custom-rule library.',
|
|
57
|
+
'',
|
|
55
58
|
'Behavior. Writes a row to ~/.iris/custom-rules.json (atomic write via temp file + rename) and appends a `rule.deploy` entry to the audit log (~/.iris/audit.log). The rule activates immediately for the running process and persists across restarts. Each call mints a fresh rule_id; not idempotent (deploying twice creates two rules). Tenant-scoped in Cloud tier; OSS rules are owned by LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
|
|
56
59
|
'',
|
|
57
60
|
'Output shape. Returns JSON: `{ "rule": { "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition", "enabled": true, "createdAt", "updatedAt", "version": 1, "sourceMomentId?" } }`. The returned rule is the canonical persisted form; save the `id` if you plan to update or delete later.',
|
|
@@ -60,6 +63,8 @@ export function registerDeployRuleTool(server, customRuleStore) {
|
|
|
60
63
|
'',
|
|
61
64
|
"Don't use to VALIDATE a rule before committing — deploy writes immediately. Use the dashboard's preview endpoint (POST /api/v1/rules/custom/preview) for dry-run validation against sample output. Don't use to EDIT an existing rule — this call only creates; edits require a dedicated flow (coming in v0.5). To update a rule today: delete_rule then deploy_rule with the new definition.",
|
|
62
65
|
'',
|
|
66
|
+
'Parameters. name is 1-120 chars (Zod-enforced min/max); appears in eval_result rule_results so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). evalType determines WHEN the rule fires (must match the eval_type your evaluate_output calls use; e.g., a "completeness" rule fires on every evaluate_output where eval_type="completeness" OR eval_type="custom"). severity affects dashboard sort + audit log signal but does NOT affect scoring (scoring uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_usd; min_length needs config.min). sourceMomentId is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). Defaults: severity="medium".',
|
|
67
|
+
'',
|
|
63
68
|
"Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars). Throws 400 on empty `name`. Throws 400 if the eval category mismatches the definition type. Returns 429 when HTTP rate limit exceeded. File-write failures (disk full, read-only fs) propagate as 500; the audit log is best-effort and does not block deploy.",
|
|
64
69
|
].join('\n'),
|
|
65
70
|
inputSchema,
|
|
@@ -70,7 +75,8 @@ export function registerDeployRuleTool(server, customRuleStore) {
|
|
|
70
75
|
openWorldHint: false,
|
|
71
76
|
},
|
|
72
77
|
}, async (args) => {
|
|
73
|
-
|
|
78
|
+
// OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
|
|
79
|
+
const rule = customRuleStore.deploy(LOCAL_TENANT, {
|
|
74
80
|
name: args.name,
|
|
75
81
|
description: args.description,
|
|
76
82
|
evalType: args.evalType,
|
|
@@ -10,18 +10,18 @@ const CustomRuleSchema = z.object({
|
|
|
10
10
|
weight: z.number().optional(),
|
|
11
11
|
});
|
|
12
12
|
const inputSchema = {
|
|
13
|
-
output: z.string().describe('The output text to evaluate'),
|
|
14
|
-
eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).default('completeness').describe('
|
|
15
|
-
expected: z.string().optional().describe('Expected output for comparison'),
|
|
16
|
-
input: z.string().optional().describe('Original input for context'),
|
|
17
|
-
trace_id: z.string().optional().describe('Link evaluation to a trace'),
|
|
18
|
-
custom_rules: z.array(CustomRuleSchema).optional().describe('Custom evaluation rules'),
|
|
19
|
-
cost_usd: z.number().optional().describe('Cost
|
|
13
|
+
output: z.string().describe('The output text to evaluate (the agent\'s response that gets scored against rules)'),
|
|
14
|
+
eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).default('completeness').describe('Rule bundle to apply: completeness | relevance | safety | cost | custom — picks which built-in rules fire'),
|
|
15
|
+
expected: z.string().optional().describe('Expected output for comparison — REQUIRED when eval_type="relevance" (used as keyword-overlap target)'),
|
|
16
|
+
input: z.string().optional().describe('Original input for context — improves relevance scoring (keyword overlap vs input)'),
|
|
17
|
+
trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through'),
|
|
18
|
+
custom_rules: z.array(CustomRuleSchema).optional().describe('Custom evaluation rules — fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these'),
|
|
19
|
+
cost_usd: z.number().optional().describe('Cost in USD — only consulted when eval_type="cost" (compared against cost_threshold rules)'),
|
|
20
20
|
token_usage: z.object({
|
|
21
21
|
prompt_tokens: z.number().optional(),
|
|
22
22
|
completion_tokens: z.number().optional(),
|
|
23
23
|
total_tokens: z.number().optional(),
|
|
24
|
-
}).optional().describe('Token usage
|
|
24
|
+
}).optional().describe('Token usage breakdown — only consulted when eval_type="cost" (used for token-budget rules)'),
|
|
25
25
|
};
|
|
26
26
|
export function registerEvaluateOutputTool(server, storage, evalEngine) {
|
|
27
27
|
server.registerTool('evaluate_output', {
|
|
@@ -29,6 +29,8 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
|
|
|
29
29
|
description: [
|
|
30
30
|
'Score agent output against configurable eval rules and return a 0..1 score + per-rule breakdown.',
|
|
31
31
|
'',
|
|
32
|
+
'Sibling tools — evaluate_with_llm_judge runs semantic LLM-based scoring (slower, costs money; this tool is heuristic, free, deterministic), verify_citations checks citation grounding specifically, log_trace records executions, get_traces queries them, list_rules / deploy_rule / delete_rule manage the custom-rule lifecycle. evaluate_output is the FAST PATH for length / keyword / PII / injection / cost-threshold checks where rules are sufficient.',
|
|
33
|
+
'',
|
|
32
34
|
'Behavior. Deterministic, in-process scoring — same inputs always produce the same result. Writes one eval_result row to Iris storage (linked to trace_id if provided; unlinked otherwise). No external network calls in heuristic mode (v0.4 adds an llm_as_judge eval_type that DOES call LLM APIs; see the separate evaluate_with_llm_judge tool for that). Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Runs in ~5-50ms for rule-based evaluation.',
|
|
33
35
|
'',
|
|
34
36
|
'Output shape. Returns JSON: `{ "id": "<uuid>", "score": 0..1, "passed": boolean, "rule_results": [{ "ruleName", "passed", "score", "message", "skipped?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean }`. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data).',
|
|
@@ -37,6 +39,8 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
|
|
|
37
39
|
'',
|
|
38
40
|
'Don\'t use when the output is empty or has no applicable rules — the eval_type decides which rules apply, and invalid combinations return score=0 + insufficient_data=true (not an error, but not actionable). Don\'t use to VALIDATE JSON schemas directly (use your language\'s JSON Schema validator — Iris\'s `json_schema` custom rule type is for output-shape assertions, not arbitrary validation).',
|
|
39
41
|
'',
|
|
42
|
+
'Parameters. expected is REQUIRED when eval_type="relevance" (used as the comparison target for keyword overlap + topic consistency); ignored for other eval_types. cost_usd + token_usage are ONLY consulted when eval_type="cost" (ignored otherwise). custom_rules ALWAYS fires regardless of eval_type — pass eval_type="custom" if you want ONLY your rules to run (otherwise both your rules AND the eval_type bundle run together). trace_id is optional but recommended (linking the eval to its trace surfaces it in the dashboard\'s drill-through). input adds context to keyword-overlap relevance checks; ignored otherwise. Defaults: eval_type="completeness".',
|
|
43
|
+
'',
|
|
40
44
|
'Error modes. Throws on malformed custom_rules (Zod rejects). Returns 400 on regex patterns that fail safe-regex2 ReDoS check or exceed 1000-char limit. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. The eval itself never throws — failing rules report `passed: false` with a message, they don\'t bubble exceptions.',
|
|
41
45
|
].join('\n'),
|
|
42
46
|
inputSchema,
|
|
@@ -59,6 +59,8 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
|
|
|
59
59
|
description: [
|
|
60
60
|
'Score agent output using an LLM as the judge (Anthropic or OpenAI). Returns a calibrated 0..1 score with rationale, per-dimension breakdown, and exact cost.',
|
|
61
61
|
'',
|
|
62
|
+
'Sibling tools — evaluate_output runs heuristic rules (free, deterministic, ~ms latency, no API key needed); this tool runs LLM-based semantic scoring (paid, 1-10s latency, requires API key). verify_citations is a SPECIALIZED form of LLM judging that focuses on citation grounding only. log_trace / get_traces handle trace I/O; list_rules / deploy_rule / delete_rule manage heuristic-rule lifecycle. evaluate_with_llm_judge is the GENERAL semantic-scoring path.',
|
|
63
|
+
'',
|
|
62
64
|
'Behavior. Calls an external LLM API (Anthropic or OpenAI) — costs money per call, takes 1-10 seconds, respects an IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL cap. Non-deterministic at temperature > 0; default temperature=0 gives near-deterministic scores. Writes one eval_result row to Iris storage (linked to trace_id if provided) plus captures provider response id + latency + token counts + cost in the rule_results payload. Rate-limited to 20 req/min on HTTP MCP; your LLM provider also enforces its own rate limits (we transparently retry once on 429).',
|
|
63
65
|
'',
|
|
64
66
|
'Output shape. Returns JSON: `{ "id": "<uuid>", "score": 0..1, "passed": boolean, "rationale": string, "dimensions": {...}, "model": string, "provider": "anthropic"|"openai", "template": string, "input_tokens": number, "output_tokens": number, "cost_usd": number, "latency_ms": number }`. `dimensions` has per-dimension sub-scores (e.g., accuracy template returns `{factual_claims, citations, internal_consistency}`).',
|
|
@@ -67,6 +69,8 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
|
|
|
67
69
|
'',
|
|
68
70
|
"Don't use for simple regex/length/keyword checks (use evaluate_output with heuristic rules — they're free, deterministic, 1000x faster). Don't use without an API key set (IRIS_ANTHROPIC_API_KEY or IRIS_OPENAI_API_KEY). Don't use on very large outputs (>8K tokens) without raising max_cost_usd — the pre-check will refuse the call.",
|
|
69
71
|
'',
|
|
72
|
+
'Parameters. model is required (no default — pick consciously since cost varies 100x across models). provider is auto-detected from the model name; override only for ambiguous IDs. expected is REQUIRED when template="correctness" (the reference answer to compare against); ignored for other templates. source_material is REQUIRED when template="faithfulness" (the RAG sources to ground against); ignored otherwise. input is optional but improves scoring on helpfulness/safety templates (gives the judge the user prompt that produced the output). max_cost_usd defaults to env var IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL or $0.25 — the worst-case cost is computed BEFORE the call (input_tokens × prompt_price + max_output_tokens × completion_price); call refused upfront if it would exceed. max_output_tokens caps the judge response (default 512, max 4096); higher = more rationale detail + more cost. temperature default 0 (deterministic). timeout_ms default 60000. trace_id optional but recommended (links eval to trace in dashboard). Defaults: temperature=0, max_output_tokens=512, max_cost_usd=$0.25, timeout_ms=60000.',
|
|
73
|
+
'',
|
|
70
74
|
'Error modes. Throws when the required API key env var is missing. Throws when the estimated worst-case cost exceeds max_cost_usd (raise the cap or trim prompts). Throws LLMJudgeError on provider errors — kind=`auth` on 401/403, `rate_limit` on 429 (auto-retried once), `server_error` on 5xx, `timeout` on abort, `malformed_response` when the judge fails to emit valid JSON on both attempts. Throws "Unknown model" for unsupported model IDs — update src/eval/llm-judge/pricing.ts first.',
|
|
71
75
|
].join('\n'),
|
|
72
76
|
inputSchema,
|
package/dist/tools/get-traces.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
3
3
|
const inputSchema = {
|
|
4
|
-
agent_name: z.string().optional().describe('Filter by agent name'),
|
|
5
|
-
framework: z.string().optional().describe('Filter by framework'),
|
|
6
|
-
since: z.string().optional().describe('ISO timestamp lower bound'),
|
|
7
|
-
until: z.string().optional().describe('ISO timestamp upper bound'),
|
|
8
|
-
min_score: z.number().optional().describe('Minimum eval score filter'),
|
|
9
|
-
max_score: z.number().optional().describe('Maximum eval score filter'),
|
|
10
|
-
limit: z.number().default(50).describe('Results per page'),
|
|
11
|
-
offset: z.number().default(0).describe('
|
|
12
|
-
sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp').describe('Sort
|
|
13
|
-
sort_order: z.enum(['asc', 'desc']).default('desc').describe('Sort order'),
|
|
14
|
-
include_summary: z.boolean().default(false).describe('Include dashboard summary stats'),
|
|
4
|
+
agent_name: z.string().optional().describe('Filter by agent name — exact match (no wildcards in v0.4)'),
|
|
5
|
+
framework: z.string().optional().describe('Filter by agent framework — exact match (e.g., langchain, autogen)'),
|
|
6
|
+
since: z.string().optional().describe('ISO timestamp lower bound — return traces with timestamp >= this'),
|
|
7
|
+
until: z.string().optional().describe('ISO timestamp upper bound — return traces with timestamp < this'),
|
|
8
|
+
min_score: z.number().optional().describe('Minimum eval score filter (0..1) — applied to LATEST eval per trace, not all evals'),
|
|
9
|
+
max_score: z.number().optional().describe('Maximum eval score filter (0..1) — applied to LATEST eval per trace'),
|
|
10
|
+
limit: z.number().default(50).describe('Results per page (default 50, max 1000 — values >1000 return 400)'),
|
|
11
|
+
offset: z.number().default(0).describe('Zero-based pagination offset — skip first N results'),
|
|
12
|
+
sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp').describe('Sort by timestamp | latency_ms | cost_usd (default timestamp)'),
|
|
13
|
+
sort_order: z.enum(['asc', 'desc']).default('desc').describe('Sort order: asc | desc (default desc — most recent / highest first)'),
|
|
14
|
+
include_summary: z.boolean().default(false).describe('Include dashboard summary stats in same response — saves a round-trip when ingesting for dashboards'),
|
|
15
15
|
};
|
|
16
16
|
export function registerGetTracesTool(server, storage) {
|
|
17
17
|
server.registerTool('get_traces', {
|
|
@@ -19,6 +19,8 @@ export function registerGetTracesTool(server, storage) {
|
|
|
19
19
|
description: [
|
|
20
20
|
'Query stored agent-execution traces with filters, pagination, and optional dashboard summary.',
|
|
21
21
|
'',
|
|
22
|
+
'Sibling tools — log_trace creates traces, delete_trace removes a single trace, evaluate_output / evaluate_with_llm_judge / verify_citations score them, list_rules / deploy_rule / delete_rule manage the custom-rule lifecycle. get_traces is the READ path for historical agent executions — never mutates anything.',
|
|
23
|
+
'',
|
|
22
24
|
'Behavior. Read-only: never mutates storage, never calls external services. Idempotent: repeated calls with the same args return consistent results (new traces logged after the call obviously show up on subsequent calls). Tenant-scoped: queries only the caller\'s tenant rows (LOCAL_TENANT in OSS). Paginates results (default limit 50, max 1000). Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio.',
|
|
23
25
|
'',
|
|
24
26
|
'Output shape. Returns JSON: `{ "traces": [{...traceRow}], "total": number, "limit": number, "offset": number, "summary"?: { total_traces, avg_latency_ms, total_cost_usd, error_rate, eval_pass_rate, traces_per_hour, top_agents } }`. Each trace row includes trace_id, agent_name, framework, input, output, tool_calls, latency_ms, token_usage, cost_usd, metadata, timestamp. `summary` only included when `include_summary: true`.',
|
|
@@ -27,6 +29,8 @@ export function registerGetTracesTool(server, storage) {
|
|
|
27
29
|
'',
|
|
28
30
|
'Don\'t use to score a trace (use evaluate_output). Don\'t use to create a trace (use log_trace). Don\'t use as a live event stream — it\'s a query, not a subscription; poll with exponential backoff or use the dashboard\'s SSE endpoint for real-time.',
|
|
29
31
|
'',
|
|
32
|
+
'Parameters. limit defaults to 50, max 1000 (anything higher returns 400). offset is zero-based pagination. min_score / max_score filter on the LATEST eval per trace, not all evals (so a trace with one failing + one passing eval may or may not match depending on which landed last). Combining since + sort_by="latency_ms" + sort_order="desc" is the canonical "find slow recent traces" query. include_summary returns dashboard-style aggregates in the SAME response (saves a round-trip; use true for dashboard ingest, false for analytics queries that don\'t need them). agent_name and framework are exact-match (no wildcards in v0.4). Defaults: limit=50, offset=0, sort_by="timestamp", sort_order="desc", include_summary=false.',
|
|
33
|
+
'',
|
|
30
34
|
'Error modes. Returns 400 on invalid sort_by / sort_order (Zod enum). Returns 400 if limit > 1000. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. Empty result with `total: 0` on no matches (not an error).',
|
|
31
35
|
].join('\n'),
|
|
32
36
|
inputSchema,
|
package/dist/tools/list-rules.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* needs to manage the rule set programmatically.
|
|
12
12
|
*/
|
|
13
13
|
import { z } from 'zod';
|
|
14
|
+
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
14
15
|
const inputSchema = {
|
|
15
16
|
eval_type: z
|
|
16
17
|
.enum(['completeness', 'relevance', 'safety', 'cost', 'custom'])
|
|
@@ -27,6 +28,8 @@ export function registerListRulesTool(server, customRuleStore) {
|
|
|
27
28
|
description: [
|
|
28
29
|
'Enumerate deployed custom evaluation rules from the local rule store.',
|
|
29
30
|
'',
|
|
31
|
+
'Sibling tools — deploy_rule adds custom rules, delete_rule removes them, evaluate_output runs them against agent output. log_trace / get_traces / delete_trace handle the trace lifecycle separately. list_rules is the READ path for the custom-rule store; nothing else exposes the inventory.',
|
|
32
|
+
'',
|
|
30
33
|
'Behavior. Pure read of ~/.iris/custom-rules.json (in-memory cached; no disk read per call after server boot). No mutation, no external network. Tenant-scoped in Cloud tier; OSS returns all rules for the single local tenant. Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Returns in <5ms.',
|
|
31
34
|
'',
|
|
32
35
|
'Output shape. Returns JSON: `{ "rules": [{ "id": "rule-XXXX", "name", "description?", "evalType", "severity", "definition": { type, config, weight? }, "enabled": boolean, "deployedAt": ISO timestamp, "sourceMomentId?": string }], "total": number, "enabled_count": number }`. Empty array + total=0 when no rules deployed.',
|
|
@@ -35,6 +38,8 @@ export function registerListRulesTool(server, customRuleStore) {
|
|
|
35
38
|
'',
|
|
36
39
|
"Don't use to count traces or evals (that's get_traces). Don't use to inspect built-in (non-custom) rules — those ship with the iris binary and are listed in docs/api-reference.md, not in the rule store. Don't use to deploy a rule (use deploy_rule); don't use to remove one (use delete_rule).",
|
|
37
40
|
'',
|
|
41
|
+
'Parameters. eval_type filter is exact-match against each rule\'s evalType field (no wildcards). enabled_only excludes rules that are deployed-but-disabled (toggled via the dashboard\'s rule-list affordance — there\'s no MCP toggle tool in v0.4). 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).',
|
|
42
|
+
'',
|
|
38
43
|
"Error modes. Returns empty list if the rule store file doesn't exist (first run). Returns 429 if HTTP rate limit exceeded. Never throws on valid input.",
|
|
39
44
|
].join('\n'),
|
|
40
45
|
inputSchema,
|
|
@@ -45,7 +50,10 @@ export function registerListRulesTool(server, customRuleStore) {
|
|
|
45
50
|
openWorldHint: false,
|
|
46
51
|
},
|
|
47
52
|
}, async (args) => {
|
|
48
|
-
|
|
53
|
+
// OSS: MCP tools operate under LOCAL_TENANT. Cloud multi-tenant
|
|
54
|
+
// exposure is a v0.5 architectural item (MCP SDK doesn't pass
|
|
55
|
+
// session/tenant context to tool handlers).
|
|
56
|
+
let rules = customRuleStore.list(LOCAL_TENANT);
|
|
49
57
|
if (args.eval_type) {
|
|
50
58
|
rules = rules.filter((r) => r.evalType === args.eval_type);
|
|
51
59
|
}
|
package/dist/tools/log-trace.js
CHANGED
|
@@ -31,17 +31,17 @@ const TokenUsageSchema = z.object({
|
|
|
31
31
|
total_tokens: z.number().optional(),
|
|
32
32
|
});
|
|
33
33
|
const inputSchema = {
|
|
34
|
-
agent_name: z.string().describe('
|
|
35
|
-
framework: z.string().optional().describe('Agent framework
|
|
36
|
-
input: z.string().optional().describe('Agent input text'),
|
|
37
|
-
output: z.string().optional().describe('Agent output text'),
|
|
38
|
-
tool_calls: z.array(ToolCallSchema).optional().describe('Tool calls made during execution'),
|
|
39
|
-
latency_ms: z.number().optional().describe('Total execution time in milliseconds'),
|
|
40
|
-
token_usage: TokenUsageSchema.optional().describe('Token usage breakdown'),
|
|
41
|
-
cost_usd: z.number().optional().describe('Total cost in USD'),
|
|
42
|
-
metadata: z.record(z.unknown()).optional().describe('
|
|
43
|
-
spans: z.array(SpanSchema).optional().describe('Detailed execution spans'),
|
|
44
|
-
timestamp: z.string().optional().describe('Trace timestamp (ISO 8601)'),
|
|
34
|
+
agent_name: z.string().describe('Agent name — used for filtering in get_traces (e.g., "customer-support-bot")'),
|
|
35
|
+
framework: z.string().optional().describe('Agent framework identifier (e.g., langchain, autogen, custom)'),
|
|
36
|
+
input: z.string().optional().describe('Agent input text — the user prompt or upstream input that produced this output'),
|
|
37
|
+
output: z.string().optional().describe('Agent output text — what the agent produced (pass to evaluate_output for scoring)'),
|
|
38
|
+
tool_calls: z.array(ToolCallSchema).optional().describe('Tool calls made during execution (per-call latency, errors, input/output)'),
|
|
39
|
+
latency_ms: z.number().optional().describe('Total execution time in milliseconds (end-to-end agent latency)'),
|
|
40
|
+
token_usage: TokenUsageSchema.optional().describe('Token usage breakdown (prompt/completion/total — used for cost analysis)'),
|
|
41
|
+
cost_usd: z.number().optional().describe('Total cost in USD — overrides per-span aggregation when provided (treated as authoritative)'),
|
|
42
|
+
metadata: z.record(z.unknown()).optional().describe('Opaque key-value tags (e.g. {requestId, userId, env}) — queryable in dashboard, not via get_traces filters'),
|
|
43
|
+
spans: z.array(SpanSchema).optional().describe('Detailed execution spans (hierarchical span tree with timings, attributes, events)'),
|
|
44
|
+
timestamp: z.string().optional().describe('Trace timestamp (ISO 8601); defaults to now() when omitted'),
|
|
45
45
|
};
|
|
46
46
|
export function registerLogTraceTool(server, storage) {
|
|
47
47
|
server.registerTool('log_trace', {
|
|
@@ -49,6 +49,8 @@ export function registerLogTraceTool(server, storage) {
|
|
|
49
49
|
description: [
|
|
50
50
|
'Persist a single agent execution trace (input, output, spans, tool calls, cost, latency, token usage).',
|
|
51
51
|
'',
|
|
52
|
+
'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
|
+
'',
|
|
52
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; HTTP mode requires Bearer token. Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Not idempotent: each call mints a fresh trace_id, so resubmitting the same payload creates a duplicate trace.',
|
|
53
55
|
'',
|
|
54
56
|
'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,6 +59,8 @@ export function registerLogTraceTool(server, storage) {
|
|
|
57
59
|
'',
|
|
58
60
|
'Don\'t use when you only need a transient log (use console logging). Don\'t use to update an existing trace — there is no update path in v0.4 (traces are immutable once stored).',
|
|
59
61
|
'',
|
|
62
|
+
'Parameters. agent_name is required; everything else is optional. token_usage and cost_usd are summary fields — if you ALSO pass spans with per-tool-call costs, the summary fields are treated as authoritative (no auto-aggregation). spans without an explicit start_time fall back to the trace timestamp; spans with an end_time get a duration_ms derived. metadata is opaque key-value (queryable in the dashboard, not via get_traces filters). tool_calls record per-tool latency + errors; missing latency_ms means "not reported," not "zero." Defaults: span.kind="INTERNAL", span.status_code="UNSET", timestamp=now() if omitted.',
|
|
63
|
+
'',
|
|
60
64
|
'Error modes. Throws on missing agent_name. Throws on malformed span or tool_call objects (Zod rejects). Returns 500 on storage failure (disk full, DB locked). Never blocks on the agent — returns within ~50ms for typical payloads.',
|
|
61
65
|
].join('\n'),
|
|
62
66
|
inputSchema,
|
|
@@ -53,6 +53,8 @@ export function registerVerifyCitationsTool(server, storage) {
|
|
|
53
53
|
description: [
|
|
54
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
55
|
'',
|
|
56
|
+
'Sibling tools — evaluate_with_llm_judge runs general semantic scoring (accuracy, helpfulness, correctness, faithfulness); this tool is specifically for citation grounding (does the cited source actually support the claim). evaluate_output\'s no_hallucination_markers heuristic detects FABRICATED-looking citations cheaply (free, no fetch); this tool resolves and verifies them (paid, opt-in fetch, SSRF-guarded). log_trace / get_traces handle trace I/O. verify_citations is the GROUNDING-CHECK path — narrowest in scope, deepest in rigor.',
|
|
57
|
+
'',
|
|
56
58
|
'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
59
|
'',
|
|
58
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 / resolved`; `null` when nothing resolvable was found.',
|
|
@@ -61,6 +63,8 @@ export function registerVerifyCitationsTool(server, storage) {
|
|
|
61
63
|
"",
|
|
62
64
|
"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
65
|
'',
|
|
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
|
+
'',
|
|
64
68
|
'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
69
|
].join('\n'),
|
|
66
70
|
inputSchema,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iris-eval/mcp-server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"test:e2e:ui": "playwright test --ui",
|
|
26
26
|
"version:check": "bash scripts/check-version.sh",
|
|
27
27
|
"version:sync": "node scripts/sync-versions.mjs",
|
|
28
|
+
"claims:capture-tests": "node scripts/claims/capture-tests.mjs",
|
|
29
|
+
"claims:generate": "node scripts/claims/generate.mjs",
|
|
30
|
+
"claims:check": "node scripts/claims/generate.mjs --check",
|
|
31
|
+
"claims:check-hardcoded": "node scripts/claims/check-no-hardcoded.mjs",
|
|
28
32
|
"clean": "rm -rf dist coverage",
|
|
29
33
|
"seed:demo": "tsx scripts/seed-demo-data.ts",
|
|
30
34
|
"demo": "tsx scripts/demo.ts",
|
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.4.
|
|
9
|
+
"version": "0.4.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@iris-eval/mcp-server",
|
|
14
|
-
"version": "0.4.
|
|
14
|
+
"version": "0.4.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|