@iris-eval/mcp-server 0.8.2 → 0.10.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 +9 -2
- package/dist/capabilities.d.ts +64 -0
- package/dist/capabilities.js +65 -0
- package/dist/config/defaults.js +17 -0
- package/dist/custom-rule-store.d.ts +4 -0
- package/dist/custom-rule-store.js +8 -3
- package/dist/dashboard/assets/{index-CyzO6OC7.js → index-CeJbaq6m.js} +1 -1
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/routes/capabilities.d.ts +3 -0
- package/dist/dashboard/routes/capabilities.js +11 -0
- package/dist/dashboard/routes/health.d.ts +5 -1
- package/dist/dashboard/routes/health.js +15 -3
- package/dist/dashboard/routes/rules.js +4 -1
- package/dist/dashboard/routes/traces.d.ts +3 -0
- package/dist/dashboard/routes/traces.js +11 -30
- package/dist/dashboard/seed-demo-data.js +1 -1
- package/dist/dashboard/server.d.ts +2 -0
- package/dist/dashboard/server.js +6 -2
- package/dist/eval/accuracy.d.ts +41 -0
- package/dist/eval/accuracy.js +97 -0
- package/dist/eval/citation-verify/verifier.d.ts +16 -1
- package/dist/eval/citation-verify/verifier.js +14 -4
- package/dist/eval/compose.d.ts +57 -0
- package/dist/eval/compose.js +179 -0
- package/dist/eval/criticality.d.ts +15 -1
- package/dist/eval/criticality.js +6 -0
- package/dist/eval/decision-moment.js +33 -4
- package/dist/eval/dormant.d.ts +4 -0
- package/dist/eval/dormant.js +22 -0
- package/dist/eval/engine.d.ts +6 -2
- package/dist/eval/engine.js +126 -12
- package/dist/eval/failure-classes.d.ts +8 -0
- package/dist/eval/failure-classes.js +18 -0
- package/dist/eval/llm-judge/evaluator.d.ts +30 -0
- package/dist/eval/llm-judge/evaluator.js +26 -2
- package/dist/eval/published-accuracy.d.ts +230 -0
- package/dist/eval/published-accuracy.js +86 -0
- package/dist/eval/questions.d.ts +12 -0
- package/dist/eval/questions.js +14 -0
- package/dist/eval/response-schema.d.ts +652 -0
- package/dist/eval/response-schema.js +130 -0
- package/dist/eval/response.d.ts +12 -0
- package/dist/eval/response.js +30 -0
- package/dist/eval/risk.d.ts +60 -0
- package/dist/eval/risk.js +187 -0
- package/dist/eval/rules/completeness.js +36 -1
- package/dist/eval/rules/cost.d.ts +2 -2
- package/dist/eval/rules/cost.js +50 -6
- package/dist/eval/rules/custom.d.ts +0 -12
- package/dist/eval/rules/custom.js +22 -0
- package/dist/eval/rules/relevance.js +23 -2
- package/dist/eval/rules/safety.d.ts +6 -2
- package/dist/eval/rules/safety.js +224 -51
- package/dist/eval/seeded-random.d.ts +4 -0
- package/dist/eval/seeded-random.js +36 -0
- package/dist/eval/stamp.d.ts +14 -0
- package/dist/eval/stamp.js +89 -0
- package/dist/eval/stats.d.ts +33 -0
- package/dist/eval/stats.js +109 -0
- package/dist/eval/text/checksums.d.ts +23 -0
- package/dist/eval/text/checksums.js +97 -0
- package/dist/eval/text/normalise.d.ts +30 -0
- package/dist/eval/text/normalise.js +265 -0
- package/dist/eval/text/sentences.d.ts +15 -0
- package/dist/eval/text/sentences.js +149 -0
- package/dist/eval/verdict.d.ts +34 -0
- package/dist/eval/verdict.js +131 -0
- package/dist/index.js +5 -28
- package/dist/instructions.d.ts +17 -0
- package/dist/instructions.js +53 -0
- package/dist/judge-enablement.d.ts +34 -0
- package/dist/judge-enablement.js +78 -0
- package/dist/judge-enablement.json +10 -0
- package/dist/preferences.d.ts +1 -1
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.js +29 -0
- package/dist/resources/index.d.ts +5 -2
- package/dist/resources/index.js +65 -5
- package/dist/resources/uris.d.ts +12 -0
- package/dist/resources/uris.js +24 -0
- package/dist/retention.d.ts +20 -0
- package/dist/retention.js +44 -0
- package/dist/self-test.d.ts +1 -0
- package/dist/self-test.js +17 -3
- package/dist/server.d.ts +10 -1
- package/dist/server.js +34 -7
- package/dist/storage/index.js +1 -1
- package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
- package/dist/storage/migrations/007-eval-provenance.js +30 -0
- package/dist/storage/migrations/index.js +24 -4
- package/dist/storage/sqlite-adapter.d.ts +26 -1
- package/dist/storage/sqlite-adapter.js +149 -15
- package/dist/tools/delete-rule.d.ts +8 -0
- package/dist/tools/delete-rule.js +30 -38
- package/dist/tools/delete-trace.d.ts +5 -0
- package/dist/tools/delete-trace.js +24 -27
- package/dist/tools/deploy-rule.d.ts +13 -1
- package/dist/tools/deploy-rule.js +37 -34
- package/dist/tools/describe.d.ts +20 -0
- package/dist/tools/describe.js +36 -0
- package/dist/tools/errors.d.ts +36 -0
- package/dist/tools/errors.js +134 -0
- package/dist/tools/evaluate-output.d.ts +8 -1
- package/dist/tools/evaluate-output.js +39 -60
- package/dist/tools/evaluate-with-llm-judge.d.ts +34 -0
- package/dist/tools/evaluate-with-llm-judge.js +124 -69
- package/dist/tools/get-traces.d.ts +9 -0
- package/dist/tools/get-traces.js +29 -28
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +22 -1
- package/dist/tools/list-rules.d.ts +13 -0
- package/dist/tools/list-rules.js +43 -46
- package/dist/tools/log-trace.d.ts +4 -0
- package/dist/tools/log-trace.js +31 -29
- package/dist/tools/respond.d.ts +42 -0
- package/dist/tools/respond.js +90 -0
- package/dist/tools/strict-input.js +1 -1
- package/dist/tools/trace-link.d.ts +2 -0
- package/dist/tools/trace-link.js +13 -2
- package/dist/tools/verify-citations.d.ts +18 -2
- package/dist/tools/verify-citations.js +122 -96
- package/dist/types/config.d.ts +44 -0
- package/dist/types/eval.d.ts +309 -0
- package/dist/types/eval.js +2 -1
- package/dist/types/query.d.ts +2 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/dist/resources/dashboard-summary.d.ts +0 -3
- package/dist/resources/dashboard-summary.js +0 -16
- package/dist/resources/trace-detail.d.ts +0 -3
- package/dist/resources/trace-detail.js +0 -30
|
@@ -14,11 +14,14 @@ import { z } from 'zod';
|
|
|
14
14
|
import { createCustomRule } from '../eval/rules/custom.js';
|
|
15
15
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
16
16
|
import { strictInput, strictNested } from './strict-input.js';
|
|
17
|
+
import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
|
|
18
|
+
import { guarded, respond } from './respond.js';
|
|
17
19
|
const EvalTypeSchema = z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']);
|
|
18
20
|
/**
|
|
19
21
|
* A rule with this name is already deployed and the caller did not ask to
|
|
20
22
|
* replace it. Carries the existing rule(s) so an HTTP surface can answer
|
|
21
|
-
* 409 with them beside the same message the MCP tool
|
|
23
|
+
* 409 with them beside the same message the MCP tool returns as
|
|
24
|
+
* IRIS_DUPLICATE_RULE (src/tools/errors.ts maps it by name).
|
|
22
25
|
*/
|
|
23
26
|
export class DuplicateRuleNameError extends Error {
|
|
24
27
|
existing;
|
|
@@ -145,34 +148,41 @@ const inputSchemaWithAliases = strictInput(inputSchema).superRefine((args, ctx)
|
|
|
145
148
|
ctx.addIssue({ code: 'custom', path: ['sourceMomentId'], message: 'pass either source_moment_id or sourceMomentId, not both' });
|
|
146
149
|
}
|
|
147
150
|
});
|
|
151
|
+
export const deployRuleOutputSchema = z.looseObject({
|
|
152
|
+
rule: z.looseObject({ id: z.string(), name: z.string() }).describe('the rule as persisted: id (rule-<hex>, keep it for delete_rule), name, description, evalType, severity, definition, enabled, createdAt, updatedAt, version, sourceMomentId'),
|
|
153
|
+
replaced: z.array(z.looseObject({ id: z.string() })).optional().describe('with replace: true, the earlier rule(s) of the same name that were retired'),
|
|
154
|
+
warning: z.string().optional().describe('with replace: true, one sentence naming what was retired'),
|
|
155
|
+
});
|
|
148
156
|
export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
|
|
149
157
|
server.registerTool('deploy_rule', {
|
|
150
158
|
title: 'Deploy Custom Rule',
|
|
151
|
-
description:
|
|
152
|
-
'Deploy a
|
|
153
|
-
''
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
'',
|
|
158
|
-
|
|
159
|
-
''
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
159
|
+
description: describeTool({
|
|
160
|
+
summary: 'Deploy a custom rule that fires on every future evaluate_output call of its bundle — persisted, active immediately, audited.',
|
|
161
|
+
does: 'Writes the rule to ~/.iris/custom-rules.json, appends a rule.deploy audit entry and registers it with the running engine, so it fires on the very next call and survives restarts. ' +
|
|
162
|
+
'eval_type says WHEN it fires (that bundle, and eval_type="all"); severity says what a failure DOES: low and medium only lower the weighted score, high and critical force passed to false and list the rule in critical_failures. ' +
|
|
163
|
+
'definition.type picks the check (regex_match, regex_no_match, min_length, max_length, contains_keywords, excludes_keywords, json_schema, cost_threshold) and definition.config carries its keys (pattern; min_length; max_length; keywords; max_cost). ' +
|
|
164
|
+
'Any bundle and type combine. Names are unique: a taken name is refused unless replace is true, which retires the earlier rule(s) first and reports them. Argument names are snake_case; the camelCase aliases evalType and sourceMomentId are accepted — pass one spelling of each.',
|
|
165
|
+
whenNot: 'To try a rule first: POST /api/v1/rules/custom/preview on the dashboard replays a definition against stored traces without deploying. For a one-off check on one call: the custom_rules argument of evaluate_output. To pause a rule: delete_rule with enabled: false.',
|
|
166
|
+
returns: deployRuleOutputSchema,
|
|
167
|
+
errors: 'IRIS_DUPLICATE_RULE when the name is deployed and replace is false (the message names the existing id). ' +
|
|
168
|
+
'IRIS_INVALID_RULE_CONFIG when the definition is rejected — a regex that fails the ReDoS check or exceeds 1000 characters, a missing config key — naming the field; nothing is deployed. ' +
|
|
169
|
+
'IRIS_STORAGE_ERROR when the store cannot be written. An unknown key in definition, a name over 80 characters, a non-positive weight or both spellings of an alias are refused before the handler runs. ' +
|
|
170
|
+
ERROR_ENVELOPE_SENTENCE,
|
|
171
|
+
siblings: {
|
|
172
|
+
list_rules: 'see what is deployed and the built-in roster',
|
|
173
|
+
delete_rule: 'remove, disable or re-enable',
|
|
174
|
+
evaluate_output: 'where the rule fires',
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
168
177
|
inputSchema: inputSchemaWithAliases,
|
|
178
|
+
outputSchema: deployRuleOutputSchema,
|
|
169
179
|
annotations: {
|
|
170
180
|
readOnlyHint: false,
|
|
171
181
|
destructiveHint: false,
|
|
172
182
|
idempotentHint: false,
|
|
173
183
|
openWorldHint: false,
|
|
174
184
|
},
|
|
175
|
-
}, async (args) => {
|
|
185
|
+
}, guarded(async (args) => {
|
|
176
186
|
const evalType = (args.eval_type ?? args.evalType);
|
|
177
187
|
const sourceMomentId = args.source_moment_id ?? args.sourceMomentId;
|
|
178
188
|
// Server overrides the inner definition's `name` so it always matches
|
|
@@ -186,10 +196,12 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
|
|
|
186
196
|
};
|
|
187
197
|
// Same-name redeploy (#373) — shared with the dashboard's deploy
|
|
188
198
|
// route; see retireSameNamedRules above. Throws (nothing deployed)
|
|
189
|
-
// when the name is taken and replace is false
|
|
199
|
+
// when the name is taken and replace is false; `guarded` turns that
|
|
200
|
+
// into IRIS_DUPLICATE_RULE.
|
|
190
201
|
const replaced = retireSameNamedRules(customRuleStore, evalEngine, LOCAL_TENANT, args.name, args.replace, 'mcp');
|
|
191
202
|
// OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
|
|
192
203
|
const rule = customRuleStore.deploy(LOCAL_TENANT, {
|
|
204
|
+
replaces: replaced.map((r) => r.id),
|
|
193
205
|
name: args.name,
|
|
194
206
|
description: args.description,
|
|
195
207
|
evalType,
|
|
@@ -205,18 +217,9 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
|
|
|
205
217
|
// Registered under its rule id so delete_rule can hot-remove it.
|
|
206
218
|
// Severity rides along: high/critical makes the rule hard-failing.
|
|
207
219
|
evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
|
|
208
|
-
return {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
rule,
|
|
214
|
-
...(replaced.length > 0
|
|
215
|
-
? { replaced, warning: replacedRulesWarning(args.name, replaced) }
|
|
216
|
-
: {}),
|
|
217
|
-
}),
|
|
218
|
-
},
|
|
219
|
-
],
|
|
220
|
-
};
|
|
221
|
-
});
|
|
220
|
+
return respond(deployRuleOutputSchema, {
|
|
221
|
+
rule,
|
|
222
|
+
...(replaced.length > 0 ? { replaced, warning: replacedRulesWarning(args.name, replaced) } : {}),
|
|
223
|
+
});
|
|
224
|
+
}));
|
|
222
225
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
export declare const DESCRIPTION_HEADINGS: readonly ["What it does.", "When not to use it.", "Returns.", "Errors.", "Siblings."];
|
|
3
|
+
export declare const DESCRIPTION_WORD_CAP = 450;
|
|
4
|
+
export interface ToolDescriptionSpec {
|
|
5
|
+
/** One sentence: what calling this does. */
|
|
6
|
+
summary: string;
|
|
7
|
+
does: string;
|
|
8
|
+
whenNot: string;
|
|
9
|
+
/** The tool's output schema; every top-level field must carry a description. */
|
|
10
|
+
returns: z.ZodObject<z.ZodRawShape>;
|
|
11
|
+
errors: string;
|
|
12
|
+
/** Sibling tool → one clause on when it is the better call. */
|
|
13
|
+
siblings: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
/** `field` (its description); … — from the schema, never retyped. */
|
|
16
|
+
export declare function returnsFrom(schema: z.ZodObject<z.ZodRawShape>): string;
|
|
17
|
+
export declare function wordCount(text: string): number;
|
|
18
|
+
export declare function describeTool(spec: ToolDescriptionSpec): string;
|
|
19
|
+
/** The sentence every "Errors" heading ends with, so the envelope is stated once. */
|
|
20
|
+
export declare const ERROR_ENVELOPE_SENTENCE = "Every failure returns {\"error\":{\"code\",\"message\",\"recovery\":[]}} with isError true; follow recovery before retrying.";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const DESCRIPTION_HEADINGS = ['What it does.', 'When not to use it.', 'Returns.', 'Errors.', 'Siblings.'];
|
|
2
|
+
export const DESCRIPTION_WORD_CAP = 450;
|
|
3
|
+
/** `field` (its description); … — from the schema, never retyped. */
|
|
4
|
+
export function returnsFrom(schema) {
|
|
5
|
+
const parts = [];
|
|
6
|
+
for (const [key, field] of Object.entries(schema.shape)) {
|
|
7
|
+
const description = field.description;
|
|
8
|
+
if (!description)
|
|
9
|
+
throw new Error(`output field "${key}" has no description; add .describe() on the schema`);
|
|
10
|
+
parts.push(`\`${key}\` (${description})`);
|
|
11
|
+
}
|
|
12
|
+
return `JSON with ${parts.join('; ')}.`;
|
|
13
|
+
}
|
|
14
|
+
export function wordCount(text) {
|
|
15
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
16
|
+
}
|
|
17
|
+
export function describeTool(spec) {
|
|
18
|
+
const siblings = Object.entries(spec.siblings)
|
|
19
|
+
.map(([name, when]) => `${name} — ${when}`)
|
|
20
|
+
.join('; ');
|
|
21
|
+
const text = [
|
|
22
|
+
spec.summary,
|
|
23
|
+
`${DESCRIPTION_HEADINGS[0]} ${spec.does}`,
|
|
24
|
+
`${DESCRIPTION_HEADINGS[1]} ${spec.whenNot}`,
|
|
25
|
+
`${DESCRIPTION_HEADINGS[2]} ${returnsFrom(spec.returns)}`,
|
|
26
|
+
`${DESCRIPTION_HEADINGS[3]} ${spec.errors}`,
|
|
27
|
+
`${DESCRIPTION_HEADINGS[4]} ${siblings}.`,
|
|
28
|
+
].join('\n\n');
|
|
29
|
+
const words = wordCount(text);
|
|
30
|
+
if (words > DESCRIPTION_WORD_CAP) {
|
|
31
|
+
throw new Error(`tool description is ${words} words; the cap is ${DESCRIPTION_WORD_CAP}`);
|
|
32
|
+
}
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
/** The sentence every "Errors" heading ends with, so the envelope is stated once. */
|
|
36
|
+
export const ERROR_ENVELOPE_SENTENCE = 'Every failure returns {"error":{"code","message","recovery":[]}} with isError true; follow recovery before retrying.';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { LLMJudgeError } from '../eval/llm-judge/client.js';
|
|
2
|
+
export declare const ERROR_CODE_CATALOGUE: readonly ["IRIS_INVALID_ARGUMENT", "IRIS_UNKNOWN_TRACE", "IRIS_DUPLICATE_RULE", "IRIS_INVALID_RULE_CONFIG", "IRIS_JUDGE_NOT_ENABLED", "IRIS_JUDGE_UNKNOWN_MODEL", "IRIS_BUDGET_EXCEEDED", "IRIS_PROVIDER_ERROR", "IRIS_JUDGE_FAILED", "IRIS_STORAGE_ERROR", "IRIS_INTERNAL_ERROR"];
|
|
3
|
+
export type IrisErrorCode = (typeof ERROR_CODE_CATALOGUE)[number];
|
|
4
|
+
export type ProviderErrorKind = LLMJudgeError['kind'];
|
|
5
|
+
export interface IrisErrorEnvelope {
|
|
6
|
+
code: IrisErrorCode;
|
|
7
|
+
/** One sentence a person can act on. */
|
|
8
|
+
message: string;
|
|
9
|
+
/** What to do, in order, before retrying. */
|
|
10
|
+
recovery: string[];
|
|
11
|
+
/** True when the same call can succeed later without a change from the caller. */
|
|
12
|
+
retryable: boolean;
|
|
13
|
+
/** The argument or environment variable at fault, when the failure is about one. */
|
|
14
|
+
field?: string;
|
|
15
|
+
/** The values the field accepts, when there is a closed list. */
|
|
16
|
+
valid?: string[];
|
|
17
|
+
/** A resource that explains the limit or the state. */
|
|
18
|
+
see?: string;
|
|
19
|
+
/** IRIS_PROVIDER_ERROR only: the provider failure class. */
|
|
20
|
+
kind?: ProviderErrorKind;
|
|
21
|
+
/** When the provider said how long to wait. */
|
|
22
|
+
retryAfterMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class IrisError extends Error {
|
|
25
|
+
readonly envelope: IrisErrorEnvelope;
|
|
26
|
+
constructor(envelope: Omit<IrisErrorEnvelope, 'recovery' | 'retryable'> & Partial<Pick<IrisErrorEnvelope, 'recovery' | 'retryable'>>);
|
|
27
|
+
}
|
|
28
|
+
export declare function irisError(code: IrisErrorCode, message: string, extra?: Partial<Omit<IrisErrorEnvelope, 'code' | 'message'>>): IrisError;
|
|
29
|
+
/**
|
|
30
|
+
* Every error a handler can throw, mapped to its code. Typed errors are
|
|
31
|
+
* matched by class or by name (the name check avoids importing the tool
|
|
32
|
+
* that defines the class, which would be a cycle); anything unrecognised
|
|
33
|
+
* is an internal error — reported as such, never dressed up as a caller
|
|
34
|
+
* mistake.
|
|
35
|
+
*/
|
|
36
|
+
export declare function toIrisError(err: unknown): IrisError;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Structured tool errors.
|
|
3
|
+
*
|
|
4
|
+
* A thrown Error reaches an MCP client as one line of text with isError
|
|
5
|
+
* set (the SDK flattens it), so the only thing an agent could do with
|
|
6
|
+
* "Anthropic judge requires IRIS_ANTHROPIC_API_KEY" was guess. Every
|
|
7
|
+
* failure a tool can produce is now a code from ONE catalogue with a
|
|
8
|
+
* message, the steps that clear it, whether a retry can help, and — when
|
|
9
|
+
* the failure is about one argument — the field and the valid values.
|
|
10
|
+
* Tools return these (src/tools/respond.ts wraps the handler); they never
|
|
11
|
+
* throw past the handler.
|
|
12
|
+
*
|
|
13
|
+
* The catalogue is exhaustive on purpose: tests/unit/tools/error-codes.test.ts
|
|
14
|
+
* provokes every code over a real transport and asserts the provoked set
|
|
15
|
+
* EQUALS this list, so a code nothing can raise cannot linger here and a
|
|
16
|
+
* new throw site cannot ship without a code.
|
|
17
|
+
*
|
|
18
|
+
* Two errors are not this module's: an argument the input schema rejects
|
|
19
|
+
* is refused by the SDK before the handler runs (the text names
|
|
20
|
+
* IRIS_INVALID_ARGUMENT and the valid arguments — see strict-input.ts), and
|
|
21
|
+
* an HTTP rate limit answers 429 at the transport before any tool runs.
|
|
22
|
+
*/
|
|
23
|
+
import { ZodError } from 'zod';
|
|
24
|
+
import { LLMJudgeError } from '../eval/llm-judge/client.js';
|
|
25
|
+
import { CAPABILITIES_RESOURCE_URI } from '../resources/uris.js';
|
|
26
|
+
export const ERROR_CODE_CATALOGUE = [
|
|
27
|
+
'IRIS_INVALID_ARGUMENT',
|
|
28
|
+
'IRIS_UNKNOWN_TRACE',
|
|
29
|
+
'IRIS_DUPLICATE_RULE',
|
|
30
|
+
'IRIS_INVALID_RULE_CONFIG',
|
|
31
|
+
'IRIS_JUDGE_NOT_ENABLED',
|
|
32
|
+
'IRIS_JUDGE_UNKNOWN_MODEL',
|
|
33
|
+
'IRIS_BUDGET_EXCEEDED',
|
|
34
|
+
'IRIS_PROVIDER_ERROR',
|
|
35
|
+
'IRIS_JUDGE_FAILED',
|
|
36
|
+
'IRIS_STORAGE_ERROR',
|
|
37
|
+
'IRIS_INTERNAL_ERROR',
|
|
38
|
+
];
|
|
39
|
+
export class IrisError extends Error {
|
|
40
|
+
envelope;
|
|
41
|
+
constructor(envelope) {
|
|
42
|
+
super(envelope.message);
|
|
43
|
+
this.name = 'IrisError';
|
|
44
|
+
this.envelope = { retryable: false, recovery: [], ...envelope };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function irisError(code, message, extra = {}) {
|
|
48
|
+
return new IrisError({ code, message, ...extra });
|
|
49
|
+
}
|
|
50
|
+
const CAPABILITIES = CAPABILITIES_RESOURCE_URI;
|
|
51
|
+
function isSqliteError(err) {
|
|
52
|
+
const code = err.code;
|
|
53
|
+
if (typeof code === 'string' && code.startsWith('SQLITE_'))
|
|
54
|
+
return true;
|
|
55
|
+
const message = err instanceof Error ? err.message : '';
|
|
56
|
+
return /SQLITE|database is locked|disk I\/O error|readonly database/i.test(message);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Every error a handler can throw, mapped to its code. Typed errors are
|
|
60
|
+
* matched by class or by name (the name check avoids importing the tool
|
|
61
|
+
* that defines the class, which would be a cycle); anything unrecognised
|
|
62
|
+
* is an internal error — reported as such, never dressed up as a caller
|
|
63
|
+
* mistake.
|
|
64
|
+
*/
|
|
65
|
+
export function toIrisError(err) {
|
|
66
|
+
if (err instanceof IrisError)
|
|
67
|
+
return err;
|
|
68
|
+
const name = err?.name;
|
|
69
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
70
|
+
if (name === 'DuplicateRuleNameError') {
|
|
71
|
+
return irisError('IRIS_DUPLICATE_RULE', message, {
|
|
72
|
+
field: 'name',
|
|
73
|
+
recovery: [
|
|
74
|
+
'Pass replace: true to retire the rule(s) with this name and deploy this one in their place.',
|
|
75
|
+
'Or call delete_rule with the existing rule id first.',
|
|
76
|
+
'Or choose a different name.',
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (name === 'CostCapError') {
|
|
81
|
+
const e = err;
|
|
82
|
+
return irisError('IRIS_BUDGET_EXCEEDED', message, {
|
|
83
|
+
field: 'max_cost_usd',
|
|
84
|
+
recovery: [
|
|
85
|
+
`Raise max_cost_usd on the call (the worst case was ${e.estimatedUsd?.toFixed(4) ?? '?'} USD against a cap of ${e.capUsd?.toFixed(4) ?? '?'} USD).`,
|
|
86
|
+
'Or trim the output, the input or max_output_tokens so the worst case fits.',
|
|
87
|
+
'Nothing was spent.',
|
|
88
|
+
],
|
|
89
|
+
see: CAPABILITIES,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (err instanceof LLMJudgeError) {
|
|
93
|
+
const retryable = err.kind === 'rate_limit' || err.kind === 'timeout' || err.kind === 'server_error';
|
|
94
|
+
const recovery = {
|
|
95
|
+
auth: ['The provider refused the key. Check that the key in the env block of your MCP config is valid and live, then restart the session.'],
|
|
96
|
+
rate_limit: ['The provider rate-limited the call. Wait and retry; Iris already retried once.'],
|
|
97
|
+
bad_request: ['The provider rejected the request. Check the model name and the template inputs.'],
|
|
98
|
+
server_error: ['The provider failed on its side. Retry later.'],
|
|
99
|
+
timeout: ['The provider did not answer within timeout_ms. Retry, or raise timeout_ms.'],
|
|
100
|
+
malformed_response: ['The judge did not return valid JSON on two attempts. Retry; if it recurs, pick another model.'],
|
|
101
|
+
unknown: ['Retry once; if it recurs, report the message with the provider and model.'],
|
|
102
|
+
};
|
|
103
|
+
return irisError('IRIS_PROVIDER_ERROR', message, {
|
|
104
|
+
kind: err.kind,
|
|
105
|
+
retryable,
|
|
106
|
+
recovery: recovery[err.kind],
|
|
107
|
+
...(err.retryAfterSeconds !== undefined ? { retryAfterMs: err.retryAfterSeconds * 1000 } : {}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (err instanceof ZodError) {
|
|
111
|
+
// The SDK validates tool input before the handler runs, so a ZodError
|
|
112
|
+
// inside a handler is the rule store rejecting a definition.
|
|
113
|
+
const first = err.issues[0];
|
|
114
|
+
const field = first?.path?.length ? first.path.map(String).join('.') : undefined;
|
|
115
|
+
return irisError('IRIS_INVALID_RULE_CONFIG', `The rule definition was rejected: ${err.issues.map((i) => `${i.path.map(String).join('.') || 'definition'}: ${i.message}`).join('; ')}`, {
|
|
116
|
+
...(field ? { field } : {}),
|
|
117
|
+
recovery: ['Fix the named field and deploy again. Nothing was deployed.'],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (isSqliteError(err)) {
|
|
121
|
+
const code = err.code ?? '';
|
|
122
|
+
return irisError('IRIS_STORAGE_ERROR', `Iris storage failed: ${message}`, {
|
|
123
|
+
retryable: code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED',
|
|
124
|
+
recovery: [
|
|
125
|
+
'Retry once. If it recurs, check that the database path is writable and the disk is not full.',
|
|
126
|
+
'Run `npx @iris-eval/mcp-server --self-test` to probe the configured home and database.',
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return irisError('IRIS_INTERNAL_ERROR', message || 'Iris hit an unexpected error.', {
|
|
131
|
+
recovery: ['Retry once. If it recurs, report the message with the Iris version from iris://capabilities.'],
|
|
132
|
+
see: CAPABILITIES,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import type { DormantRule } from '../eval/dormant.js';
|
|
1
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import type { IStorageAdapter } from '../types/query.js';
|
|
3
4
|
import type { EvalEngine } from '../eval/engine.js';
|
|
4
|
-
|
|
5
|
+
/** The most inline custom rules one call may carry (see the argument description). */
|
|
6
|
+
export declare const MAX_INLINE_CUSTOM_RULES = 10;
|
|
7
|
+
export interface EvaluateOutputOptions {
|
|
8
|
+
/** The quarantined gating rules on this server, for coverage.dormant. */
|
|
9
|
+
dormant?: () => DormantRule[];
|
|
10
|
+
}
|
|
11
|
+
export declare function registerEvaluateOutputTool(server: McpServer, storage: IStorageAdapter, evalEngine: EvalEngine, options?: EvaluateOutputOptions): void;
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { toEvaluationResponse } from '../eval/response.js';
|
|
3
|
+
import { evaluateOutputResponseSchema } from '../eval/response-schema.js';
|
|
2
4
|
import { DEFAULT_EVAL_TYPE, DEFAULT_EVAL_TYPE_NOTE } from '../eval/engine.js';
|
|
3
5
|
import { INJECTION_SCOPE_SENTENCE } from '../eval/rules/safety.js';
|
|
4
6
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
5
7
|
import { strictInput, strictNested } from './strict-input.js';
|
|
6
8
|
import { toolCallSchema } from './log-trace.js';
|
|
7
9
|
import { getTraceOrThrow, insertLinkedEvalResult } from './trace-link.js';
|
|
10
|
+
import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
|
|
11
|
+
import { evaluationLinks, guarded, respond } from './respond.js';
|
|
12
|
+
/** The most inline custom rules one call may carry (see the argument description). */
|
|
13
|
+
export const MAX_INLINE_CUSTOM_RULES = 10;
|
|
8
14
|
/*
|
|
9
15
|
* Strict one level down (#376): `{ name, type, config, wieght: 5 }` used to
|
|
10
16
|
* parse with `wieght` silently discarded, so the rule ran at weight 1 and
|
|
@@ -31,13 +37,13 @@ const inputSchema = {
|
|
|
31
37
|
eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom', 'all']).optional().describe('Rule bundle to apply: completeness | relevance | safety | cost | custom | all — picks which built-in rules fire. "all" runs every bundle in one call and adds a per-category breakdown. Defaults to "all" when omitted — every bundle runs, safety included, and the response carries a note saying the default ran'),
|
|
32
38
|
expected: z.string().optional().describe('Expected output for comparison — consulted only by the completeness bundle\'s expected_coverage rule; NOT used by relevance (the relevance rules compare the output against `input`)'),
|
|
33
39
|
input: z.string().optional().describe('Original input for context (the ask + any source material the agent was given) — REQUIRED when eval_type="relevance" (keyword_overlap and topic_consistency compare the output against it and skip without it); also grounds the safety bundle\'s hallucination signals'),
|
|
34
|
-
trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through. Must be the id of a stored trace (from log_trace / get_traces); an unknown id is rejected before anything is evaluated'),
|
|
40
|
+
trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through and lets the tool reuse the trace\'s stored tool_calls. Must be the id of a stored trace (from log_trace / get_traces); an unknown id is rejected before anything is evaluated'),
|
|
35
41
|
// .max(10): inline rules skip the deploy-time probe, and the engine runs
|
|
36
42
|
// rules synchronously — without a cap, one request carrying N sandbox-
|
|
37
43
|
// defeating regex rules stalls the server linearly in N (measured 9.3s at
|
|
38
44
|
// N=50). Ten is ample for per-call rules; persistent sets belong in
|
|
39
45
|
// deploy_rule, where deploy-time validation probes each pattern.
|
|
40
|
-
custom_rules: z.array(CustomRuleSchema).max(
|
|
46
|
+
custom_rules: z.array(CustomRuleSchema).max(MAX_INLINE_CUSTOM_RULES).optional().describe('Custom evaluation rules, max 10 per call (deploy persistent rule sets via deploy_rule instead) — fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these. Each entry accepts exactly name, type, config, weight — an unknown key (e.g. a misspelled weight) is rejected'),
|
|
41
47
|
cost_usd: z.number().optional().describe('Cost in USD — consulted by the cost bundle (eval_type="cost" or "all") AND by any cost_threshold custom rule regardless of eval_type; omit it and such a rule skips rather than passes (a critical one is listed in critical_skipped)'),
|
|
42
48
|
token_usage: z.object({
|
|
43
49
|
prompt_tokens: z.number().optional(),
|
|
@@ -49,38 +55,38 @@ const inputSchema = {
|
|
|
49
55
|
// is how that field goes missing on one path and not the other.
|
|
50
56
|
tool_calls: z.array(toolCallSchema).optional().describe('What the agent DID — the tool calls it made, in order, each { tool_name, input?, output?, latency_ms?, error? } exactly as log_trace records them. Read by the trajectory rules — the rules that judge what the agent DID rather than what it wrote. Omit it and those rules SKIP rather than pass — an evaluation with no trajectory data reports "not judged", never "clean". When trace_id names a stored trace and this argument is omitted, the tool_calls stored on that trace are loaded and used, so a caller who already logged them need not resend them'),
|
|
51
57
|
};
|
|
52
|
-
export function registerEvaluateOutputTool(server, storage, evalEngine) {
|
|
58
|
+
export function registerEvaluateOutputTool(server, storage, evalEngine, options) {
|
|
53
59
|
server.registerTool('evaluate_output', {
|
|
54
60
|
title: 'Evaluate Output',
|
|
55
|
-
description:
|
|
56
|
-
'Score agent output against
|
|
57
|
-
''
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
''
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
''
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
].join('\n'),
|
|
61
|
+
description: describeTool({
|
|
62
|
+
summary: 'Score an agent output against the deterministic rule bundles: the ship verdict with its basis, every rule result with evidence and uncertainty, and what was not judged.',
|
|
63
|
+
does: 'In-process, no network, no key. eval_type picks one bundle (completeness, relevance, safety, cost, custom) or all (the default): every bundle plus deployed and inline custom rules, with a per-bundle breakdown in categories. ' +
|
|
64
|
+
'Inputs decide what can be judged: input is REQUIRED when eval_type="relevance" (keyword_overlap and topic_consistency compare the output against it and skip without it) and grounds the hallucination signals; ' +
|
|
65
|
+
'tool_calls, or a trace_id whose stored tool_calls are reused, feed the trajectory rules; cost_usd and token_usage feed the cost rules; expected feeds only expected_coverage. ' +
|
|
66
|
+
'A rule without its input SKIPS, is named, and never counts as a pass. custom_rules always fire. One row is stored, linked to trace_id when given.',
|
|
67
|
+
whenNot: 'To validate arbitrary JSON Schema (the json_schema custom type asserts an output\'s shape only). ' +
|
|
68
|
+
`To screen inputs before they reach an agent: ${INJECTION_SCOPE_SENTENCE} ` +
|
|
69
|
+
'For semantic judgment, evaluate_with_llm_judge and verify_citations need a key you supply.',
|
|
70
|
+
returns: evaluateOutputResponseSchema,
|
|
71
|
+
errors: 'IRIS_UNKNOWN_TRACE when trace_id names no stored trace — checked first, nothing scored or written. IRIS_STORAGE_ERROR when the row cannot be written. ' +
|
|
72
|
+
'Unknown arguments or keys are refused before the handler runs, naming the valid ones; a regex rule over its budget or with a broken config reports skipped, not an error. ' +
|
|
73
|
+
ERROR_ENVELOPE_SENTENCE,
|
|
74
|
+
siblings: {
|
|
75
|
+
log_trace: 'record the execution first',
|
|
76
|
+
evaluate_with_llm_judge: 'semantic scoring on your key',
|
|
77
|
+
verify_citations: 'citation grounding on your key',
|
|
78
|
+
list_rules: 'the roster, needs and published accuracy',
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
76
81
|
inputSchema: strictInput(inputSchema),
|
|
82
|
+
outputSchema: evaluateOutputResponseSchema,
|
|
77
83
|
annotations: {
|
|
78
84
|
readOnlyHint: false, // Writes an eval_result row
|
|
79
85
|
destructiveHint: false, // Creates new data; doesn't overwrite or delete
|
|
80
86
|
idempotentHint: true, // Deterministic: same inputs → same score (each call writes a distinct result row, but the SCORE is stable)
|
|
81
87
|
openWorldHint: false, // No external network in heuristic mode; LLM-as-judge has its own tool with openWorldHint:true
|
|
82
88
|
},
|
|
83
|
-
}, async (args) => {
|
|
89
|
+
}, guarded(async (args) => {
|
|
84
90
|
// Refuse an unknown trace_id up front (#376): the old path ran the
|
|
85
91
|
// evaluation and then surfaced SQLite's "FOREIGN KEY constraint
|
|
86
92
|
// failed", which names neither the field nor the fix.
|
|
@@ -110,45 +116,18 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
|
|
|
110
116
|
};
|
|
111
117
|
const customRules = args.custom_rules;
|
|
112
118
|
const result = evalType === 'all'
|
|
113
|
-
? evalEngine.evaluateAll(context, customRules)
|
|
114
|
-
: evalEngine.evaluate(evalType, context, customRules);
|
|
119
|
+
? await evalEngine.evaluateAll(context, customRules)
|
|
120
|
+
: await evalEngine.evaluate(evalType, context, customRules);
|
|
115
121
|
if (args.trace_id) {
|
|
116
122
|
result.trace_id = args.trace_id;
|
|
117
123
|
}
|
|
118
124
|
// OSS single-tenant: MCP tool callers are the local user. Cloud
|
|
119
125
|
// will derive tenant from the authenticated MCP session.
|
|
120
126
|
await insertLinkedEvalResult(storage, LOCAL_TENANT, result);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// Echo which bundle actually ran. Without this, a caller who
|
|
128
|
-
// omitted eval_type could not tell a "safety pass" from a
|
|
129
|
-
// completeness eval that never ran a single safety rule.
|
|
130
|
-
eval_type: result.eval_type,
|
|
131
|
-
score: result.score,
|
|
132
|
-
passed: result.passed,
|
|
133
|
-
...(result.critical_failures ? { critical_failures: result.critical_failures } : {}),
|
|
134
|
-
// The other half of the veto contract. The engine names every
|
|
135
|
-
// critical rule that SKIPPED (budget-killed regex, missing cost
|
|
136
|
-
// data) so a fail-closed gate can treat the eval as unknown;
|
|
137
|
-
// this response used to drop the field, so the gate the
|
|
138
|
-
// description tells users to write keyed on something that
|
|
139
|
-
// never arrived and read passed:true as clean.
|
|
140
|
-
...(result.critical_skipped ? { critical_skipped: result.critical_skipped } : {}),
|
|
141
|
-
rule_results: result.rule_results,
|
|
142
|
-
suggestions: result.suggestions,
|
|
143
|
-
rules_evaluated: result.rules_evaluated,
|
|
144
|
-
rules_skipped: result.rules_skipped,
|
|
145
|
-
insufficient_data: result.insufficient_data,
|
|
146
|
-
// Per-bundle breakdown — eval_type="all" only.
|
|
147
|
-
...(result.categories ? { categories: result.categories } : {}),
|
|
148
|
-
...(evalTypeOmitted ? { note: DEFAULT_EVAL_TYPE_NOTE } : {}),
|
|
149
|
-
}),
|
|
150
|
-
},
|
|
151
|
-
],
|
|
152
|
-
};
|
|
153
|
-
});
|
|
127
|
+
// One serializer for every evaluation surface (src/eval/response.ts):
|
|
128
|
+
// the tool, the HTTP ingest route, the resources and the drift-lock
|
|
129
|
+
// all read the same object, so a field added there reaches every
|
|
130
|
+
// reader at once.
|
|
131
|
+
return respond(evaluateOutputResponseSchema, toEvaluationResponse(result, { traceId: args.trace_id, dormant: options?.dormant?.(), ...(evalTypeOmitted ? { note: DEFAULT_EVAL_TYPE_NOTE } : {}) }), evaluationLinks(result.id, args.trace_id));
|
|
132
|
+
}));
|
|
154
133
|
}
|
|
@@ -1,3 +1,37 @@
|
|
|
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
|
+
import type { LLMProvider } from '../eval/llm-judge/client.js';
|
|
5
|
+
/** The models the pricing table knows, so an unknown one can be refused with the valid list. */
|
|
6
|
+
export declare function supportedModels(): string[];
|
|
7
|
+
export declare function inferProvider(model: string): LLMProvider;
|
|
8
|
+
/**
|
|
9
|
+
* The key for the provider, from this process's environment. Missing is
|
|
10
|
+
* IRIS_JUDGE_NOT_ENABLED with the enable steps as recovery — the fact
|
|
11
|
+
* users get wrong is that a shell export does not reach the process an
|
|
12
|
+
* MCP client spawns, and the steps say so.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveApiKey(provider: LLMProvider, toolName?: string): string;
|
|
15
|
+
export declare const judgeOutputSchema: z.ZodObject<{
|
|
16
|
+
id: z.ZodString;
|
|
17
|
+
trace_id: z.ZodOptional<z.ZodString>;
|
|
18
|
+
score: z.ZodNumber;
|
|
19
|
+
passed: z.ZodBoolean;
|
|
20
|
+
pass_threshold: z.ZodNumber;
|
|
21
|
+
self_reported_pass: z.ZodOptional<z.ZodBoolean>;
|
|
22
|
+
disagreement: z.ZodOptional<z.ZodBoolean>;
|
|
23
|
+
rationale: z.ZodString;
|
|
24
|
+
dimensions: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
25
|
+
model: z.ZodString;
|
|
26
|
+
provider: z.ZodEnum<{
|
|
27
|
+
anthropic: "anthropic";
|
|
28
|
+
openai: "openai";
|
|
29
|
+
}>;
|
|
30
|
+
template: z.ZodString;
|
|
31
|
+
input_tokens: z.ZodNumber;
|
|
32
|
+
output_tokens: z.ZodNumber;
|
|
33
|
+
cost_usd: z.ZodNullable<z.ZodNumber>;
|
|
34
|
+
latency_ms: z.ZodNumber;
|
|
35
|
+
raw_response_id: z.ZodOptional<z.ZodString>;
|
|
36
|
+
}, z.core.$loose>;
|
|
3
37
|
export declare function registerEvaluateWithLLMJudgeTool(server: McpServer, storage: IStorageAdapter): void;
|