@iris-eval/mcp-server 0.8.1 → 0.9.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 +2 -0
- package/dist/custom-rule-store.d.ts +4 -0
- package/dist/custom-rule-store.js +8 -3
- package/dist/dashboard/assets/{index-BfMShR3p.js → index-Cz8_oOqG.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 +9 -28
- 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/criticality.d.ts +8 -1
- package/dist/eval/criticality.js +6 -0
- package/dist/eval/dormant.d.ts +4 -0
- package/dist/eval/dormant.js +22 -0
- package/dist/eval/engine.d.ts +1 -0
- package/dist/eval/engine.js +49 -3
- 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 +10 -0
- package/dist/eval/llm-judge/evaluator.js +16 -1
- 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/rules/completeness.js +31 -0
- package/dist/eval/rules/cost.d.ts +1 -1
- package/dist/eval/rules/cost.js +44 -0
- package/dist/eval/rules/custom.d.ts +0 -12
- package/dist/eval/rules/custom.js +21 -0
- package/dist/eval/rules/relevance.js +16 -0
- package/dist/eval/rules/safety.js +178 -1
- package/dist/eval/stamp.d.ts +14 -0
- package/dist/eval/stamp.js +88 -0
- package/dist/eval/stats.d.ts +33 -0
- package/dist/eval/stats.js +109 -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 +14 -0
- 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 +135 -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 +37 -58
- package/dist/tools/evaluate-with-llm-judge.d.ts +31 -0
- package/dist/tools/evaluate-with-llm-judge.js +96 -69
- package/dist/tools/get-traces.d.ts +9 -0
- package/dist/tools/get-traces.js +30 -29
- 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 +17 -2
- package/dist/tools/verify-citations.js +98 -93
- package/dist/types/config.d.ts +9 -0
- package/dist/types/eval.d.ts +258 -0
- package/dist/types/eval.js +2 -1
- package/dist/types/query.d.ts +2 -0
- package/package.json +1 -1
- package/server.json +72 -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
package/dist/self-test.js
CHANGED
|
@@ -48,6 +48,7 @@ import { irisHome } from './utils/iris-home.js';
|
|
|
48
48
|
import { EvalEngine } from './eval/engine.js';
|
|
49
49
|
import { generateTraceId } from './utils/ids.js';
|
|
50
50
|
import { LOCAL_TENANT } from './types/tenant.js';
|
|
51
|
+
import { judgeState, judgeStateLine } from './judge-enablement.js';
|
|
51
52
|
const CHECK = '✓';
|
|
52
53
|
const CROSS = '✗';
|
|
53
54
|
/*
|
|
@@ -57,6 +58,7 @@ const CROSS = '✗';
|
|
|
57
58
|
*/
|
|
58
59
|
export const SELF_TEST_STEPS = {
|
|
59
60
|
configuredHome: 'configured IRIS_HOME is writable',
|
|
61
|
+
judge: 'judge key in this shell',
|
|
60
62
|
tempHome: 'create isolated temp home',
|
|
61
63
|
storage: 'initialize storage',
|
|
62
64
|
trace: 'log a trace',
|
|
@@ -238,6 +240,18 @@ export async function runSelfTest(write = stdoutLine) {
|
|
|
238
240
|
await step(SELF_TEST_STEPS.configuredHome, () => probeConfiguredHome(userHome, userStoragePath), {
|
|
239
241
|
independent: true,
|
|
240
242
|
});
|
|
243
|
+
/*
|
|
244
|
+
* The judge line, read from THIS shell's environment before the scrub
|
|
245
|
+
* (the judge variables are not scrubbed, but the order keeps the claim
|
|
246
|
+
* honest). Informational — a missing key is not a failed install — and
|
|
247
|
+
* it never calls a provider. The sentence about the client's env block
|
|
248
|
+
* is the whole point: a key exported here can print enabled while the
|
|
249
|
+
* process an MCP client spawns never receives it.
|
|
250
|
+
*/
|
|
251
|
+
await step(SELF_TEST_STEPS.judge, () => {
|
|
252
|
+
const state = judgeState();
|
|
253
|
+
return `${judgeStateLine(state)}; your MCP client passes only what its config env block lists — confirm with iris://capabilities from inside the client`;
|
|
254
|
+
}, { independent: true });
|
|
241
255
|
await step(SELF_TEST_STEPS.tempHome, () => {
|
|
242
256
|
tempHome = mkdtempSync(join(tmpdir(), 'iris-self-test-'));
|
|
243
257
|
for (const key of SCRUBBED_ENV_VARS) {
|
package/dist/server.d.ts
CHANGED
|
@@ -3,9 +3,18 @@ import type { IrisConfig } from './types/index.js';
|
|
|
3
3
|
import type { IStorageAdapter } from './types/query.js';
|
|
4
4
|
import type { CustomRuleStore } from './custom-rule-store.js';
|
|
5
5
|
import { EvalEngine } from './eval/engine.js';
|
|
6
|
+
import { type Capabilities } from './capabilities.js';
|
|
6
7
|
export interface IrisServer {
|
|
7
8
|
mcpServer: McpServer;
|
|
8
9
|
evalEngine: EvalEngine;
|
|
9
10
|
customRuleStore: CustomRuleStore;
|
|
11
|
+
/** The instructions the client received at initialize — built from this server's runtime state. */
|
|
12
|
+
instructions: string;
|
|
13
|
+
/** What this server can do, as iris://capabilities and /api/v1/capabilities serve it. */
|
|
14
|
+
capabilities: () => Capabilities;
|
|
10
15
|
}
|
|
11
|
-
export
|
|
16
|
+
export interface IrisServerOptions {
|
|
17
|
+
/** `demo` when the server runs against the disposable demo database. */
|
|
18
|
+
mode?: 'real' | 'demo';
|
|
19
|
+
}
|
|
20
|
+
export declare function createIrisServer(config: IrisConfig, storage: IStorageAdapter, customRuleStore?: CustomRuleStore, options?: IrisServerOptions): IrisServer;
|
package/dist/server.js
CHANGED
|
@@ -1,20 +1,47 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { EvalEngine } from './eval/engine.js';
|
|
3
|
+
import { rulesByType } from './eval/rules/index.js';
|
|
4
|
+
import { builtInRuleRoster } from './eval/criticality.js';
|
|
3
5
|
import { registerAllTools } from './tools/index.js';
|
|
4
6
|
import { registerAllResources } from './resources/index.js';
|
|
7
|
+
import { registerPrompts } from './prompts.js';
|
|
5
8
|
import { createCustomRuleStore } from './custom-rule-store.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
});
|
|
9
|
+
import { buildInstructions } from './instructions.js';
|
|
10
|
+
import { buildCapabilities } from './capabilities.js';
|
|
11
|
+
import { judgeState } from './judge-enablement.js';
|
|
12
|
+
export function createIrisServer(config, storage, customRuleStore, options) {
|
|
11
13
|
const evalEngine = new EvalEngine(config.eval.defaultThreshold, config.eval.ruleThresholds, config.eval);
|
|
12
14
|
// Caller can inject a shared rule store (e.g. index.ts passes the
|
|
13
15
|
// same instance the HTTP dashboard uses, so a rule deployed via MCP
|
|
14
16
|
// is immediately visible in the dashboard without a restart). If
|
|
15
17
|
// none provided, create a fresh one loading from the default path.
|
|
16
18
|
const ruleStore = customRuleStore ?? createCustomRuleStore();
|
|
19
|
+
/*
|
|
20
|
+
* The instructions are built from what THIS process will do: the
|
|
21
|
+
* roster and bundles from the registry, the critical list after this
|
|
22
|
+
* config's promotions and demotions, and whether a judge key reached
|
|
23
|
+
* this environment. The key is read here once more at boot only to
|
|
24
|
+
* describe the state; the judge tools resolve it again per call, and
|
|
25
|
+
* both reads see the same environment because a process's environment
|
|
26
|
+
* is fixed when its client spawns it.
|
|
27
|
+
*/
|
|
28
|
+
const roster = builtInRuleRoster((rule) => evalEngine.effectiveCriticality(rule));
|
|
29
|
+
const instructions = buildInstructions({
|
|
30
|
+
ruleCount: roster.length,
|
|
31
|
+
categories: Object.entries(rulesByType)
|
|
32
|
+
.filter(([, rules]) => rules.length > 0)
|
|
33
|
+
.map(([category]) => category),
|
|
34
|
+
threshold: config.eval.defaultThreshold,
|
|
35
|
+
critical: roster.filter((r) => r.critical).map((r) => r.name),
|
|
36
|
+
judge: judgeState(),
|
|
37
|
+
});
|
|
38
|
+
const mcpServer = new McpServer({
|
|
39
|
+
name: config.server.name,
|
|
40
|
+
version: config.server.version,
|
|
41
|
+
}, { instructions });
|
|
42
|
+
const capabilities = () => buildCapabilities({ config, evalEngine, customRuleStore: ruleStore, mode: options?.mode });
|
|
17
43
|
registerAllTools(mcpServer, storage, evalEngine, ruleStore);
|
|
18
|
-
registerAllResources(mcpServer, storage);
|
|
19
|
-
|
|
44
|
+
registerAllResources(mcpServer, storage, capabilities);
|
|
45
|
+
registerPrompts(mcpServer, config.server.version);
|
|
46
|
+
return { mcpServer, evalEngine, customRuleStore: ruleStore, instructions, capabilities };
|
|
20
47
|
}
|
package/dist/storage/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { SqliteAdapter } from './sqlite-adapter.js';
|
|
|
2
2
|
export function createStorage(config) {
|
|
3
3
|
switch (config.storage.type) {
|
|
4
4
|
case 'sqlite':
|
|
5
|
-
return new SqliteAdapter(config.storage.path);
|
|
5
|
+
return new SqliteAdapter(config.storage.path, { redact: config.storage.redact ?? 'none' });
|
|
6
6
|
default:
|
|
7
7
|
throw new Error(`Unsupported storage type: ${config.storage.type} (supported: sqlite)`);
|
|
8
8
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const id = '007-eval-provenance';
|
|
2
|
+
/*
|
|
3
|
+
* What an evaluation cannot reconstruct about itself after the fact: the
|
|
4
|
+
* Iris version, the ruleset and configuration hashes and the threshold that
|
|
5
|
+
* produced it (arc zero: "why did this pass on that day" was unanswerable
|
|
6
|
+
* from Iris alone), the judge's spend (the tool description said it was
|
|
7
|
+
* kept; the write path stored none of it), and the erasure stamp the
|
|
8
|
+
* right-to-erasure fix sets when a trace is deleted. `writer_version` on the
|
|
9
|
+
* migration ledger lets a downgraded binary refuse a database it cannot
|
|
10
|
+
* read instead of reading half a schema.
|
|
11
|
+
*
|
|
12
|
+
* `verdict`, `coverage` and `critical_skipped` are NOT columns: every one of
|
|
13
|
+
* them is derived on read from the stored rule_results plus the threshold
|
|
14
|
+
* kept here, so rows written before this migration read back the same way
|
|
15
|
+
* without a backfill.
|
|
16
|
+
*/
|
|
17
|
+
export function up(db) {
|
|
18
|
+
db.exec(`
|
|
19
|
+
ALTER TABLE eval_results ADD COLUMN provenance TEXT;
|
|
20
|
+
ALTER TABLE eval_results ADD COLUMN engine_version TEXT;
|
|
21
|
+
ALTER TABLE eval_results ADD COLUMN ruleset_hash TEXT;
|
|
22
|
+
ALTER TABLE eval_results ADD COLUMN config_hash TEXT;
|
|
23
|
+
ALTER TABLE eval_results ADD COLUMN threshold REAL;
|
|
24
|
+
ALTER TABLE eval_results ADD COLUMN eval_cost_usd REAL;
|
|
25
|
+
ALTER TABLE eval_results ADD COLUMN eval_tokens INTEGER;
|
|
26
|
+
ALTER TABLE eval_results ADD COLUMN erased_at TEXT;
|
|
27
|
+
ALTER TABLE _iris_migrations ADD COLUMN writer_version TEXT;
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_eval_results_tenant_engine ON eval_results(tenant_id, engine_version, ruleset_hash);
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
@@ -4,6 +4,8 @@ import * as migration003 from './003-eval-passed-index.js';
|
|
|
4
4
|
import * as migration004 from './004-tenant-id.js';
|
|
5
5
|
import * as migration005 from './005-normalize-created-at.js';
|
|
6
6
|
import * as migration006 from './006-eval-critical-failures.js';
|
|
7
|
+
import * as migration007 from './007-eval-provenance.js';
|
|
8
|
+
import { PKG_VERSION } from '../../config/defaults.js';
|
|
7
9
|
const migrations = [
|
|
8
10
|
migration001,
|
|
9
11
|
migration002,
|
|
@@ -11,6 +13,7 @@ const migrations = [
|
|
|
11
13
|
migration004,
|
|
12
14
|
migration005,
|
|
13
15
|
migration006,
|
|
16
|
+
migration007,
|
|
14
17
|
];
|
|
15
18
|
export function runMigrations(db) {
|
|
16
19
|
db.exec(`
|
|
@@ -19,10 +22,24 @@ export function runMigrations(db) {
|
|
|
19
22
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
20
23
|
)
|
|
21
24
|
`);
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
.
|
|
25
|
+
const known = new Set(migrations.map((m) => m.id));
|
|
26
|
+
const hasWriterVersion = db.prepare("PRAGMA table_info('_iris_migrations')").all().some((c) => c.name === 'writer_version');
|
|
27
|
+
const appliedRows = db
|
|
28
|
+
.prepare(hasWriterVersion ? 'SELECT id, writer_version FROM _iris_migrations' : 'SELECT id, NULL AS writer_version FROM _iris_migrations')
|
|
29
|
+
.all();
|
|
30
|
+
/*
|
|
31
|
+
* A downgrade guard (0.9.0). Before it, a binary that did not know a
|
|
32
|
+
* migration silently ignored it and read a schema newer than itself —
|
|
33
|
+
* half the columns, none of the meaning. Now an applied id this build has
|
|
34
|
+
* never heard of refuses to start, naming the version that wrote it, so
|
|
35
|
+
* the operator upgrades instead of corrupting.
|
|
36
|
+
*/
|
|
37
|
+
const unknown = appliedRows.filter((r) => !known.has(r.id));
|
|
38
|
+
if (unknown.length > 0) {
|
|
39
|
+
const writers = [...new Set(unknown.map((r) => r.writer_version ?? 'an unknown version'))].join(', ');
|
|
40
|
+
throw new Error(`This database was migrated by a newer Iris (${writers}) — migration(s) ${unknown.map((r) => r.id).join(', ')} are unknown to v${PKG_VERSION}. Upgrade Iris, or point IRIS_DB_PATH at a database this version wrote.`);
|
|
41
|
+
}
|
|
42
|
+
const applied = new Set(appliedRows.map((r) => r.id));
|
|
26
43
|
for (const migration of migrations) {
|
|
27
44
|
if (!applied.has(migration.id)) {
|
|
28
45
|
db.transaction(() => {
|
|
@@ -31,4 +48,7 @@ export function runMigrations(db) {
|
|
|
31
48
|
})();
|
|
32
49
|
}
|
|
33
50
|
}
|
|
51
|
+
// Every applied migration names the binary that applied it (this one, for
|
|
52
|
+
// rows written before the column existed — the closest true statement).
|
|
53
|
+
db.prepare('UPDATE _iris_migrations SET writer_version = ? WHERE writer_version IS NULL').run(PKG_VERSION);
|
|
34
54
|
}
|
|
@@ -2,10 +2,19 @@ import type { IStorageAdapter, DashboardSummary, TraceQueryOptions, TraceQueryRe
|
|
|
2
2
|
import type { Trace, Span } from '../types/trace.js';
|
|
3
3
|
import type { EvalResult } from '../types/eval.js';
|
|
4
4
|
import type { TenantId } from '../types/tenant.js';
|
|
5
|
+
export type RedactMode = 'none' | 'critical_spans';
|
|
6
|
+
export interface SqliteAdapterOptions {
|
|
7
|
+
/** storage.redact — replace the spans a critical detector flagged in the stored text. */
|
|
8
|
+
redact?: RedactMode;
|
|
9
|
+
}
|
|
10
|
+
/** What every text field of an erased evaluation reads afterwards. */
|
|
11
|
+
export declare const ERASED_MESSAGE = "erased with the trace";
|
|
5
12
|
export declare class SqliteAdapter implements IStorageAdapter {
|
|
6
13
|
private db;
|
|
7
14
|
private readonly dbPath;
|
|
8
|
-
|
|
15
|
+
/** storage.redact — see SqliteAdapterOptions. */
|
|
16
|
+
private readonly redact;
|
|
17
|
+
constructor(dbPath: string, options?: SqliteAdapterOptions);
|
|
9
18
|
initialize(): Promise<void>;
|
|
10
19
|
close(): Promise<void>;
|
|
11
20
|
insertTrace(tenantId: TenantId, trace: Trace): Promise<void>;
|
|
@@ -15,6 +24,7 @@ export declare class SqliteAdapter implements IStorageAdapter {
|
|
|
15
24
|
getSpansByTraceId(tenantId: TenantId, traceId: string): Promise<Span[]>;
|
|
16
25
|
insertEvalResult(tenantId: TenantId, result: EvalResult): Promise<void>;
|
|
17
26
|
getEvalsByTraceId(tenantId: TenantId, traceId: string): Promise<EvalResult[]>;
|
|
27
|
+
getEvalById(tenantId: TenantId, id: string): Promise<EvalResult | null>;
|
|
18
28
|
queryEvalResults(tenantId: TenantId, options: {
|
|
19
29
|
eval_type?: string;
|
|
20
30
|
passed?: boolean;
|
|
@@ -41,6 +51,21 @@ export declare class SqliteAdapter implements IStorageAdapter {
|
|
|
41
51
|
}>;
|
|
42
52
|
checkpoint(): Promise<void>;
|
|
43
53
|
deleteTrace(tenantId: TenantId, traceId: string): Promise<boolean>;
|
|
54
|
+
/**
|
|
55
|
+
* Blank every text field of the evaluations linked to these traces and
|
|
56
|
+
* stamp erased_at (migration 007). Verdict, scores, criticality and the
|
|
57
|
+
* evidence OFFSETS stay — they carry no text — so history and drift
|
|
58
|
+
* analytics keep working over an erased row.
|
|
59
|
+
*/
|
|
60
|
+
private eraseEvaluationsOfTraces;
|
|
61
|
+
/**
|
|
62
|
+
* storage.redact = 'critical_spans': the spans a critical detector fired
|
|
63
|
+
* on are replaced in the STORED text by [REDACTED:<pattern>]. The
|
|
64
|
+
* evidence offsets are left as computed — they index the text the caller
|
|
65
|
+
* saw, which is what a reader of the evidence needs — and the option's
|
|
66
|
+
* documentation says so.
|
|
67
|
+
*/
|
|
68
|
+
private storedOutputText;
|
|
44
69
|
getDistinctValues(tenantId: TenantId, column: string): Promise<string[]>;
|
|
45
70
|
private rowToTrace;
|
|
46
71
|
private rowToSpan;
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import Database from 'better-sqlite3';
|
|
23
23
|
import { ensureOwnerOnly } from '../utils/write-atomic.js';
|
|
24
|
+
import { deriveCoverage, deriveCriticalSkipped, deriveVerdict } from '../eval/verdict.js';
|
|
24
25
|
import { TenantContextRequiredError } from '../types/tenant.js';
|
|
25
26
|
import { runMigrations } from './migrations/index.js';
|
|
26
27
|
const ALLOWED_SORT_COLUMNS = new Set(['timestamp', 'latency_ms', 'cost_usd']);
|
|
@@ -34,11 +35,16 @@ function assertTenant(tenantId) {
|
|
|
34
35
|
throw new TenantContextRequiredError('SqliteAdapter method invoked without a valid TenantId; refusing to query');
|
|
35
36
|
}
|
|
36
37
|
}
|
|
38
|
+
/** What every text field of an erased evaluation reads afterwards. */
|
|
39
|
+
export const ERASED_MESSAGE = 'erased with the trace';
|
|
37
40
|
export class SqliteAdapter {
|
|
38
41
|
db;
|
|
39
42
|
dbPath;
|
|
40
|
-
|
|
43
|
+
/** storage.redact — see SqliteAdapterOptions. */
|
|
44
|
+
redact;
|
|
45
|
+
constructor(dbPath, options) {
|
|
41
46
|
this.dbPath = dbPath;
|
|
47
|
+
this.redact = options?.redact ?? 'none';
|
|
42
48
|
this.db = new Database(dbPath);
|
|
43
49
|
}
|
|
44
50
|
async initialize() {
|
|
@@ -55,7 +61,14 @@ export class SqliteAdapter {
|
|
|
55
61
|
* alternative is the whole point of #372.
|
|
56
62
|
*/
|
|
57
63
|
this.db.pragma('secure_delete = ON');
|
|
58
|
-
|
|
64
|
+
try {
|
|
65
|
+
runMigrations(this.db);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// A refused boot (a newer writer, a failed migration) must not leak the handle.
|
|
69
|
+
this.db.close();
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
59
72
|
/*
|
|
60
73
|
* iris.db holds agent inputs and outputs verbatim, and a tool that
|
|
61
74
|
* detects PII necessarily stores the PII it found. better-sqlite3
|
|
@@ -203,10 +216,16 @@ export class SqliteAdapter {
|
|
|
203
216
|
* no surface could filter, count, or explain the release's flagship
|
|
204
217
|
* behaviour. NULL when nothing vetoed.
|
|
205
218
|
*/
|
|
219
|
+
/*
|
|
220
|
+
* Provenance (migration 007) is the part of the receipt a row cannot
|
|
221
|
+
* reconstruct: the release, the ruleset and config hashes, the threshold.
|
|
222
|
+
* verdict / coverage / critical_skipped are derived on every read from
|
|
223
|
+
* rule_results plus that threshold, so they are not columns.
|
|
224
|
+
*/
|
|
206
225
|
this.db.prepare(`
|
|
207
|
-
INSERT INTO eval_results (tenant_id, id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data, critical_failures, created_at)
|
|
208
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
209
|
-
`).run(tenantId, result.id, result.trace_id ?? null, result.eval_type, result
|
|
226
|
+
INSERT INTO eval_results (tenant_id, id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data, critical_failures, created_at, provenance, engine_version, ruleset_hash, config_hash, threshold, eval_cost_usd, eval_tokens)
|
|
227
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
228
|
+
`).run(tenantId, result.id, result.trace_id ?? null, result.eval_type, this.storedOutputText(result), result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions), result.rules_evaluated ?? null, result.rules_skipped ?? null, result.insufficient_data ? 1 : 0, result.critical_failures?.length ? JSON.stringify(result.critical_failures) : null, new Date().toISOString(), result.provenance ? JSON.stringify(result.provenance) : null, result.provenance?.irisVersion ?? null, result.provenance?.rulesetHash ?? null, result.provenance?.configHash ?? null, result.provenance?.thresholds.default ?? null, result.eval_cost_usd ?? null, result.eval_tokens ?? null);
|
|
210
229
|
}
|
|
211
230
|
async getEvalsByTraceId(tenantId, traceId) {
|
|
212
231
|
assertTenant(tenantId);
|
|
@@ -215,6 +234,13 @@ export class SqliteAdapter {
|
|
|
215
234
|
.all(tenantId, traceId);
|
|
216
235
|
return rows.map((row) => this.rowToEvalResult(row));
|
|
217
236
|
}
|
|
237
|
+
async getEvalById(tenantId, id) {
|
|
238
|
+
assertTenant(tenantId);
|
|
239
|
+
const row = this.db
|
|
240
|
+
.prepare('SELECT * FROM eval_results WHERE tenant_id = ? AND id = ?')
|
|
241
|
+
.get(tenantId, id);
|
|
242
|
+
return row ? this.rowToEvalResult(row) : null;
|
|
243
|
+
}
|
|
218
244
|
async queryEvalResults(tenantId, options) {
|
|
219
245
|
assertTenant(tenantId);
|
|
220
246
|
const conditions = ['tenant_id = ?'];
|
|
@@ -504,10 +530,14 @@ export class SqliteAdapter {
|
|
|
504
530
|
async deleteTracesOlderThan(tenantId, days) {
|
|
505
531
|
assertTenant(tenantId);
|
|
506
532
|
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
533
|
+
// Same erasure as deleteTrace: an evaluation younger than the window
|
|
534
|
+
// whose trace is swept keeps its verdict and loses its text.
|
|
535
|
+
const run = this.db.transaction((tid, cut) => {
|
|
536
|
+
const ids = this.db.prepare('SELECT trace_id FROM traces WHERE tenant_id = ? AND timestamp < ?').all(tid, cut).map((r) => r.trace_id);
|
|
537
|
+
this.eraseEvaluationsOfTraces(tid, ids);
|
|
538
|
+
return this.db.prepare('DELETE FROM traces WHERE tenant_id = ? AND timestamp < ?').run(tid, cut).changes;
|
|
539
|
+
});
|
|
540
|
+
return run(tenantId, cutoff);
|
|
511
541
|
}
|
|
512
542
|
async deleteEvalResultsOlderThan(tenantId, days) {
|
|
513
543
|
assertTenant(tenantId);
|
|
@@ -563,10 +593,80 @@ export class SqliteAdapter {
|
|
|
563
593
|
// Tenant-scoped: a trace id owned by a different tenant is
|
|
564
594
|
// untouchable from this call. Cross-tenant deletions are not just
|
|
565
595
|
// denied — they're invisible (no indication the id even exists).
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
596
|
+
//
|
|
597
|
+
// The right-to-erasure fix: eval_results.trace_id is ON DELETE SET
|
|
598
|
+
// NULL, so the delete alone left every linked evaluation behind with
|
|
599
|
+
// output_text verbatim — including what no_pii had flagged — orphaned
|
|
600
|
+
// and readable. The text is erased in the same transaction, BEFORE the
|
|
601
|
+
// FK can orphan the rows.
|
|
602
|
+
const run = this.db.transaction((tid, id) => {
|
|
603
|
+
const exists = this.db.prepare('SELECT 1 FROM traces WHERE tenant_id = ? AND trace_id = ?').get(tid, id);
|
|
604
|
+
if (!exists)
|
|
605
|
+
return 0;
|
|
606
|
+
this.eraseEvaluationsOfTraces(tid, [id]);
|
|
607
|
+
return this.db.prepare('DELETE FROM traces WHERE tenant_id = ? AND trace_id = ?').run(tid, id).changes;
|
|
608
|
+
});
|
|
609
|
+
return run(tenantId, traceId) > 0;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Blank every text field of the evaluations linked to these traces and
|
|
613
|
+
* stamp erased_at (migration 007). Verdict, scores, criticality and the
|
|
614
|
+
* evidence OFFSETS stay — they carry no text — so history and drift
|
|
615
|
+
* analytics keep working over an erased row.
|
|
616
|
+
*/
|
|
617
|
+
eraseEvaluationsOfTraces(tenantId, traceIds) {
|
|
618
|
+
if (traceIds.length === 0)
|
|
619
|
+
return 0;
|
|
620
|
+
const now = new Date().toISOString();
|
|
621
|
+
const select = this.db.prepare('SELECT id, rule_results FROM eval_results WHERE tenant_id = ? AND trace_id = ?');
|
|
622
|
+
const update = this.db.prepare('UPDATE eval_results SET output_text = ?, expected_text = NULL, suggestions = ?, rule_results = ?, erased_at = ? WHERE tenant_id = ? AND id = ?');
|
|
623
|
+
let erased = 0;
|
|
624
|
+
for (const traceId of traceIds) {
|
|
625
|
+
for (const row of select.all(tenantId, traceId)) {
|
|
626
|
+
let rules = [];
|
|
627
|
+
try {
|
|
628
|
+
rules = JSON.parse(row.rule_results);
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
rules = [];
|
|
632
|
+
}
|
|
633
|
+
const erasedRules = rules.map((r) => ({
|
|
634
|
+
...r,
|
|
635
|
+
message: ERASED_MESSAGE,
|
|
636
|
+
...(r.skipReason ? { skipReason: ERASED_MESSAGE } : {}),
|
|
637
|
+
}));
|
|
638
|
+
update.run('', '[]', JSON.stringify(erasedRules), now, tenantId, row.id);
|
|
639
|
+
erased += 1;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return erased;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* storage.redact = 'critical_spans': the spans a critical detector fired
|
|
646
|
+
* on are replaced in the STORED text by [REDACTED:<pattern>]. The
|
|
647
|
+
* evidence offsets are left as computed — they index the text the caller
|
|
648
|
+
* saw, which is what a reader of the evidence needs — and the option's
|
|
649
|
+
* documentation says so.
|
|
650
|
+
*/
|
|
651
|
+
storedOutputText(result) {
|
|
652
|
+
const text = result.output_text;
|
|
653
|
+
if (this.redact !== 'critical_spans' || !text)
|
|
654
|
+
return text ?? null;
|
|
655
|
+
const spans = result.rule_results
|
|
656
|
+
.filter((r) => r.critical === true && !r.passed && !r.skipped)
|
|
657
|
+
.flatMap((r) => (r.evidence ?? []).filter((e) => e.type === 'span' && e.source === 'output'));
|
|
658
|
+
if (spans.length === 0)
|
|
659
|
+
return text;
|
|
660
|
+
const seen = new Set();
|
|
661
|
+
let out = text;
|
|
662
|
+
for (const s of [...spans].sort((a, b) => b.start - a.start)) {
|
|
663
|
+
const key = `${s.start}:${s.end}`;
|
|
664
|
+
if (seen.has(key) || s.start >= s.end || s.end > out.length)
|
|
665
|
+
continue;
|
|
666
|
+
seen.add(key);
|
|
667
|
+
out = `${out.slice(0, s.start)}[REDACTED:${s.label}]${out.slice(s.end)}`;
|
|
668
|
+
}
|
|
669
|
+
return out;
|
|
570
670
|
}
|
|
571
671
|
async getDistinctValues(tenantId, column) {
|
|
572
672
|
assertTenant(tenantId);
|
|
@@ -613,7 +713,7 @@ export class SqliteAdapter {
|
|
|
613
713
|
};
|
|
614
714
|
}
|
|
615
715
|
rowToEvalResult(row) {
|
|
616
|
-
|
|
716
|
+
const result = {
|
|
617
717
|
id: row.id,
|
|
618
718
|
trace_id: row.trace_id,
|
|
619
719
|
eval_type: row.eval_type,
|
|
@@ -623,7 +723,8 @@ export class SqliteAdapter {
|
|
|
623
723
|
* regroup the per-bundle breakdown from what IS stored.
|
|
624
724
|
*/
|
|
625
725
|
output_text: row.output_text,
|
|
626
|
-
expected_text: row.expected_text,
|
|
726
|
+
expected_text: row.expected_text ?? undefined,
|
|
727
|
+
...(row.erased_at ? { erased_at: row.erased_at } : {}),
|
|
627
728
|
score: row.score,
|
|
628
729
|
passed: row.passed === 1,
|
|
629
730
|
rule_results: JSON.parse(row.rule_results),
|
|
@@ -640,6 +741,25 @@ export class SqliteAdapter {
|
|
|
640
741
|
...(row.critical_failures != null
|
|
641
742
|
? { critical_failures: JSON.parse(row.critical_failures) }
|
|
642
743
|
: {}),
|
|
744
|
+
...(row.eval_cost_usd != null ? { eval_cost_usd: row.eval_cost_usd } : {}),
|
|
745
|
+
...(row.eval_tokens != null ? { eval_tokens: row.eval_tokens } : {}),
|
|
746
|
+
...(row.provenance != null ? { provenance: JSON.parse(row.provenance) } : {}),
|
|
643
747
|
};
|
|
748
|
+
/*
|
|
749
|
+
* Derived on every read, never stored (0.9.0): the critical rules that
|
|
750
|
+
* skipped (from the stamped flags — absent for rows older than those
|
|
751
|
+
* flags, never []), the coverage by question, and the verdict with its
|
|
752
|
+
* basis — the last only when the row carries the threshold it was judged
|
|
753
|
+
* against, because a basis guessed against today's threshold would be a
|
|
754
|
+
* fabrication about that day.
|
|
755
|
+
*/
|
|
756
|
+
const criticalSkipped = deriveCriticalSkipped(result.rule_results);
|
|
757
|
+
if (criticalSkipped)
|
|
758
|
+
result.critical_skipped = criticalSkipped;
|
|
759
|
+
if (result.rule_results.some((r) => r.question !== undefined))
|
|
760
|
+
result.coverage = deriveCoverage(result.rule_results);
|
|
761
|
+
if (result.provenance)
|
|
762
|
+
result.verdict = deriveVerdict(result, result.provenance.thresholds.default);
|
|
763
|
+
return result;
|
|
644
764
|
}
|
|
645
765
|
}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import type { CustomRuleStore } from '../custom-rule-store.js';
|
|
3
4
|
import type { EvalEngine } from '../eval/engine.js';
|
|
5
|
+
export declare const deleteRuleOutputSchema: z.ZodObject<{
|
|
6
|
+
deleted: z.ZodBoolean;
|
|
7
|
+
rule_id: z.ZodString;
|
|
8
|
+
toggled: z.ZodOptional<z.ZodBoolean>;
|
|
9
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
10
|
+
rule: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
|
|
11
|
+
}, z.core.$loose>;
|
|
4
12
|
export declare function registerDeleteRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;
|
|
@@ -22,6 +22,8 @@ import { z } from 'zod';
|
|
|
22
22
|
import { createCustomRule } from '../eval/rules/custom.js';
|
|
23
23
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
24
24
|
import { strictInput } from './strict-input.js';
|
|
25
|
+
import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
|
|
26
|
+
import { guarded, respond } from './respond.js';
|
|
25
27
|
const inputSchema = {
|
|
26
28
|
rule_id: z
|
|
27
29
|
.string()
|
|
@@ -32,41 +34,45 @@ const inputSchema = {
|
|
|
32
34
|
.optional()
|
|
33
35
|
.describe('When present the rule is NOT deleted: false DISABLES it (kept in the store, stops firing immediately, history and provenance preserved); true RE-ENABLES a disabled rule. Omit to delete'),
|
|
34
36
|
};
|
|
37
|
+
export const deleteRuleOutputSchema = z.looseObject({
|
|
38
|
+
deleted: z.boolean().describe('true when a rule was removed; always false on a toggle'),
|
|
39
|
+
rule_id: z.string().describe('the id that was asked for'),
|
|
40
|
+
toggled: z.boolean().optional().describe('toggle only: true when the rule exists (also when it was already in the requested state)'),
|
|
41
|
+
enabled: z.boolean().optional().describe('toggle only: the rule\'s state after the call'),
|
|
42
|
+
rule: z.looseObject({}).optional().describe('toggle only: the rule as stored'),
|
|
43
|
+
});
|
|
35
44
|
export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
|
|
36
45
|
server.registerTool('delete_rule', {
|
|
37
46
|
title: 'Delete or Disable Custom Rule',
|
|
38
|
-
description:
|
|
39
|
-
'Remove a deployed custom
|
|
40
|
-
'',
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"Error modes. Throws 400 on malformed rule_id (wrong prefix) or an unknown argument. Returns `{deleted: false}` (or `{toggled: 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.",
|
|
54
|
-
].join('\n'),
|
|
47
|
+
description: describeTool({
|
|
48
|
+
summary: 'Remove a deployed custom rule — or, with enabled, disable or re-enable it without removing it — effective on the next evaluate_output call.',
|
|
49
|
+
does: 'Without enabled: deletes the rule from ~/.iris/custom-rules.json, appends a rule.delete audit entry and unregisters it from the running engine; deleted is false when no rule has that id (already gone, or not this tenant\'s), and no audit row is written twice. ' +
|
|
50
|
+
'With enabled: the rule stays with its history and provenance; false stops it firing at once and keeps it off across restarts, true brings it back under the same id; a rule.toggle audit entry is written unless the flag was already in that state. ' +
|
|
51
|
+
'Past evaluations that referenced the rule are untouched either way.',
|
|
52
|
+
whenNot: 'On built-in rules: they are not in the store and cannot be deleted or disabled. To delete a trace (delete_trace). To replace a rule: deploy_rule with the same name and replace: true.',
|
|
53
|
+
returns: deleteRuleOutputSchema,
|
|
54
|
+
errors: 'IRIS_STORAGE_ERROR when the store cannot be written. A malformed rule_id (not rule-<hex>) or an unknown argument is refused before the handler runs. ' +
|
|
55
|
+
ERROR_ENVELOPE_SENTENCE,
|
|
56
|
+
siblings: {
|
|
57
|
+
deploy_rule: 'add or replace a rule',
|
|
58
|
+
list_rules: 'find the id',
|
|
59
|
+
evaluate_output: 'where the rule fires',
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
55
62
|
inputSchema: strictInput(inputSchema),
|
|
63
|
+
outputSchema: deleteRuleOutputSchema,
|
|
56
64
|
annotations: {
|
|
57
65
|
readOnlyHint: false,
|
|
58
66
|
destructiveHint: true,
|
|
59
67
|
idempotentHint: false,
|
|
60
68
|
openWorldHint: false,
|
|
61
69
|
},
|
|
62
|
-
}, async (args) => {
|
|
70
|
+
}, guarded(async (args) => {
|
|
63
71
|
// OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
|
|
64
72
|
if (args.enabled !== undefined) {
|
|
65
73
|
const rule = customRuleStore.setEnabled(LOCAL_TENANT, args.rule_id, args.enabled, 'mcp');
|
|
66
74
|
if (!rule) {
|
|
67
|
-
return {
|
|
68
|
-
content: [{ type: 'text', text: JSON.stringify({ deleted: false, toggled: false, rule_id: args.rule_id }) }],
|
|
69
|
-
};
|
|
75
|
+
return respond(deleteRuleOutputSchema, { deleted: false, toggled: false, rule_id: args.rule_id });
|
|
70
76
|
}
|
|
71
77
|
// Mirror the store in the live engine, so the toggle is immediate
|
|
72
78
|
// (registerRule is idempotent by id — re-enabling an already-live
|
|
@@ -77,14 +83,7 @@ export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
|
|
|
77
83
|
else {
|
|
78
84
|
evalEngine.unregisterRule(rule.id);
|
|
79
85
|
}
|
|
80
|
-
return {
|
|
81
|
-
content: [
|
|
82
|
-
{
|
|
83
|
-
type: 'text',
|
|
84
|
-
text: JSON.stringify({ deleted: false, toggled: true, rule_id: args.rule_id, enabled: rule.enabled, rule }),
|
|
85
|
-
},
|
|
86
|
-
],
|
|
87
|
-
};
|
|
86
|
+
return respond(deleteRuleOutputSchema, { deleted: false, toggled: true, rule_id: args.rule_id, enabled: rule.enabled, rule });
|
|
88
87
|
}
|
|
89
88
|
const deleted = customRuleStore.delete(LOCAL_TENANT, args.rule_id, 'mcp');
|
|
90
89
|
if (deleted) {
|
|
@@ -94,13 +93,6 @@ export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
|
|
|
94
93
|
// rule was never registered in this process.
|
|
95
94
|
evalEngine.unregisterRule(args.rule_id);
|
|
96
95
|
}
|
|
97
|
-
return {
|
|
98
|
-
|
|
99
|
-
{
|
|
100
|
-
type: 'text',
|
|
101
|
-
text: JSON.stringify({ deleted, rule_id: args.rule_id }),
|
|
102
|
-
},
|
|
103
|
-
],
|
|
104
|
-
};
|
|
105
|
-
});
|
|
96
|
+
return respond(deleteRuleOutputSchema, { deleted, rule_id: args.rule_id });
|
|
97
|
+
}));
|
|
106
98
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import type { IStorageAdapter } from '../types/query.js';
|
|
4
|
+
export declare const deleteTraceOutputSchema: z.ZodObject<{
|
|
5
|
+
deleted: z.ZodBoolean;
|
|
6
|
+
trace_id: z.ZodString;
|
|
7
|
+
}, z.core.$loose>;
|
|
3
8
|
export declare function registerDeleteTraceTool(server: McpServer, storage: IStorageAdapter): void;
|