@iris-eval/mcp-server 0.4.6 → 0.5.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 +94 -44
- package/dist/audit-log-reader.d.ts +0 -2
- package/dist/audit-log-reader.js +3 -3
- package/dist/config/index.js +18 -1
- package/dist/custom-rule-store.js +22 -8
- package/dist/dashboard/assets/index-BZZt8bVh.js +10 -0
- package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/failures.d.ts +3 -0
- package/dist/dashboard/routes/failures.js +76 -0
- package/dist/dashboard/routes/index.d.ts +1 -0
- package/dist/dashboard/routes/index.js +1 -0
- package/dist/dashboard/routes/preferences.js +7 -2
- package/dist/dashboard/routes/rules.js +32 -14
- package/dist/dashboard/routes/traces.d.ts +12 -1
- package/dist/dashboard/routes/traces.js +90 -2
- package/dist/dashboard/seed-demo-data.d.ts +49 -0
- package/dist/dashboard/seed-demo-data.js +1080 -0
- package/dist/dashboard/server.js +81 -15
- package/dist/dashboard/validation.d.ts +74 -0
- package/dist/dashboard/validation.js +31 -2
- package/dist/eval/citation-verify/resolve.js +29 -0
- package/dist/eval/citation-verify/verifier.d.ts +1 -0
- package/dist/eval/citation-verify/verifier.js +12 -4
- package/dist/eval/engine.d.ts +15 -1
- package/dist/eval/engine.js +74 -5
- package/dist/eval/failure-rank.d.ts +14 -0
- package/dist/eval/failure-rank.js +44 -0
- package/dist/eval/rules/custom.d.ts +29 -1
- package/dist/eval/rules/custom.js +155 -19
- package/dist/eval/rules/regex-budget.js +0 -0
- package/dist/eval/rules/regex-sandbox.d.ts +26 -0
- package/dist/eval/rules/regex-sandbox.js +131 -0
- package/dist/eval/rules/relevance.d.ts +0 -2
- package/dist/eval/rules/relevance.js +6 -68
- package/dist/eval/rules/safety.d.ts +10 -0
- package/dist/eval/rules/safety.js +1337 -26
- package/dist/index.js +196 -18
- package/dist/self-test.d.ts +18 -0
- package/dist/self-test.js +329 -0
- package/dist/storage/sqlite-adapter.d.ts +2 -0
- package/dist/storage/sqlite-adapter.js +65 -9
- package/dist/tools/delete-rule.d.ts +2 -1
- package/dist/tools/delete-rule.js +13 -4
- package/dist/tools/delete-trace.js +2 -1
- package/dist/tools/deploy-rule.d.ts +2 -1
- package/dist/tools/deploy-rule.js +29 -7
- package/dist/tools/evaluate-output.js +36 -9
- package/dist/tools/evaluate-with-llm-judge.js +2 -1
- package/dist/tools/get-traces.js +6 -2
- package/dist/tools/index.js +2 -2
- package/dist/tools/list-rules.js +2 -1
- package/dist/tools/log-trace.d.ts +51 -0
- package/dist/tools/log-trace.js +14 -2
- package/dist/tools/strict-input.d.ts +2 -0
- package/dist/tools/strict-input.js +35 -0
- package/dist/tools/verify-citations.js +7 -5
- package/dist/transport/http.js +24 -2
- package/dist/types/decision-moment.d.ts +12 -0
- package/dist/types/eval.d.ts +32 -0
- package/dist/types/query.d.ts +1 -1
- package/dist/utils/write-atomic.d.ts +2 -0
- package/dist/utils/write-atomic.js +34 -2
- package/package.json +3 -2
- package/server.json +3 -3
- package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
- package/dist/dashboard/assets/index-ChcHJDDJ.js +0 -10
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* its own data.
|
|
21
21
|
*/
|
|
22
22
|
import Database from 'better-sqlite3';
|
|
23
|
+
import { ensureOwnerOnly } from '../utils/write-atomic.js';
|
|
23
24
|
import { TenantContextRequiredError } from '../types/tenant.js';
|
|
24
25
|
import { runMigrations } from './migrations/index.js';
|
|
25
26
|
const ALLOWED_SORT_COLUMNS = new Set(['timestamp', 'latency_ms', 'cost_usd']);
|
|
@@ -35,7 +36,9 @@ function assertTenant(tenantId) {
|
|
|
35
36
|
}
|
|
36
37
|
export class SqliteAdapter {
|
|
37
38
|
db;
|
|
39
|
+
dbPath;
|
|
38
40
|
constructor(dbPath) {
|
|
41
|
+
this.dbPath = dbPath;
|
|
39
42
|
this.db = new Database(dbPath);
|
|
40
43
|
}
|
|
41
44
|
async initialize() {
|
|
@@ -43,6 +46,17 @@ export class SqliteAdapter {
|
|
|
43
46
|
this.db.pragma('busy_timeout = 5000');
|
|
44
47
|
this.db.pragma('foreign_keys = ON');
|
|
45
48
|
runMigrations(this.db);
|
|
49
|
+
/*
|
|
50
|
+
* iris.db holds agent inputs and outputs verbatim, and a tool that
|
|
51
|
+
* detects PII necessarily stores the PII it found. better-sqlite3
|
|
52
|
+
* creates the file with the process umask (typically 0644 = readable by
|
|
53
|
+
* every local account), and WAL mode creates two sidecars that hold the
|
|
54
|
+
* same data. Narrow all three after the pragmas, since -wal/-shm do not
|
|
55
|
+
* exist until WAL is enabled. No-op on Windows and on :memory:.
|
|
56
|
+
*/
|
|
57
|
+
if (this.dbPath !== ':memory:') {
|
|
58
|
+
ensureOwnerOnly(this.dbPath, `${this.dbPath}-wal`, `${this.dbPath}-shm`);
|
|
59
|
+
}
|
|
46
60
|
}
|
|
47
61
|
async close() {
|
|
48
62
|
this.db.close();
|
|
@@ -97,13 +111,31 @@ export class SqliteAdapter {
|
|
|
97
111
|
conditions.push('timestamp <= ?');
|
|
98
112
|
params.push(filter.until);
|
|
99
113
|
}
|
|
100
|
-
if (filter?.min_score !== undefined) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
if (filter?.min_score !== undefined || filter?.max_score !== undefined) {
|
|
115
|
+
/*
|
|
116
|
+
* Both bounds apply to the LATEST eval per trace (created_at DESC,
|
|
117
|
+
* rowid breaking ties within the same millisecond) — the semantics
|
|
118
|
+
* the get_traces description promises. These used to be two
|
|
119
|
+
* INDEPENDENT EXISTS subqueries, so a trace with evals at 0.95 and
|
|
120
|
+
* 0.05 matched min_score=0.4 + max_score=0.6: each bound was
|
|
121
|
+
* satisfied by a different eval even though no single eval — let
|
|
122
|
+
* alone the latest — was in range (#332).
|
|
123
|
+
*/
|
|
124
|
+
const scoreBounds = [];
|
|
125
|
+
if (filter.min_score !== undefined) {
|
|
126
|
+
scoreBounds.push('e.score >= ?');
|
|
127
|
+
}
|
|
128
|
+
if (filter.max_score !== undefined) {
|
|
129
|
+
scoreBounds.push('e.score <= ?');
|
|
130
|
+
}
|
|
131
|
+
conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.rowid = ' +
|
|
132
|
+
'(SELECT e2.rowid FROM eval_results e2 WHERE e2.tenant_id = traces.tenant_id AND e2.trace_id = traces.trace_id ' +
|
|
133
|
+
'ORDER BY e2.created_at DESC, e2.rowid DESC LIMIT 1) ' +
|
|
134
|
+
`AND ${scoreBounds.join(' AND ')})`);
|
|
135
|
+
if (filter.min_score !== undefined)
|
|
136
|
+
params.push(filter.min_score);
|
|
137
|
+
if (filter.max_score !== undefined)
|
|
138
|
+
params.push(filter.max_score);
|
|
107
139
|
}
|
|
108
140
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
|
109
141
|
const sortBy = options.sort_by ?? 'timestamp';
|
|
@@ -244,22 +276,46 @@ export class SqliteAdapter {
|
|
|
244
276
|
// ---------------------------------------------------------------------------
|
|
245
277
|
// Eval-stats endpoints (v0.2.0 dashboard)
|
|
246
278
|
// ---------------------------------------------------------------------------
|
|
279
|
+
/*
|
|
280
|
+
* Table-driven rather than a nested ternary: the old form silently fell
|
|
281
|
+
* through to 720 hours for anything that wasn't '24h' or '7d', so a new
|
|
282
|
+
* period value would have quietly returned 30d data rather than failing.
|
|
283
|
+
*/
|
|
284
|
+
static PERIOD_HOURS = {
|
|
285
|
+
'24h': 24,
|
|
286
|
+
'2d': 48,
|
|
287
|
+
'7d': 168,
|
|
288
|
+
'14d': 336,
|
|
289
|
+
'30d': 720,
|
|
290
|
+
'60d': 1440,
|
|
291
|
+
'90d': 2160,
|
|
292
|
+
'180d': 4320,
|
|
293
|
+
};
|
|
247
294
|
periodToSince(period) {
|
|
248
295
|
if (period === 'all')
|
|
249
296
|
return '1970-01-01T00:00:00.000Z';
|
|
250
|
-
const hours = period
|
|
297
|
+
const hours = SqliteAdapter.PERIOD_HOURS[period];
|
|
251
298
|
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
|
252
299
|
}
|
|
253
300
|
async getEvalStats(tenantId, period) {
|
|
254
301
|
assertTenant(tenantId);
|
|
255
302
|
const since = this.periodToSince(period);
|
|
303
|
+
/*
|
|
304
|
+
* No trace_id filter — deliberately. evaluate_output without a
|
|
305
|
+
* trace_id is documented and normal, and every sibling scan (trend,
|
|
306
|
+
* per-rule breakdown, failures) counts unlinked evals. Filtering only
|
|
307
|
+
* this headline made totalEvals disagree with the trend's sum, and —
|
|
308
|
+
* because eval_results.trace_id is ON DELETE SET NULL — deleting a
|
|
309
|
+
* trace retroactively shrank the headline while the trend kept the
|
|
310
|
+
* eval. One population everywhere: every eval in the window.
|
|
311
|
+
*/
|
|
256
312
|
const agg = this.db.prepare(`
|
|
257
313
|
SELECT
|
|
258
314
|
COUNT(*) AS total_evals,
|
|
259
315
|
COALESCE(AVG(score), 0) AS avg_score,
|
|
260
316
|
SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) AS passed_count
|
|
261
317
|
FROM eval_results
|
|
262
|
-
WHERE tenant_id = ? AND created_at >= ?
|
|
318
|
+
WHERE tenant_id = ? AND created_at >= ?
|
|
263
319
|
`).get(tenantId, since);
|
|
264
320
|
const cost = this.db.prepare(`
|
|
265
321
|
SELECT COALESCE(SUM(cost_usd), 0) AS total_cost
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { CustomRuleStore } from '../custom-rule-store.js';
|
|
3
|
-
|
|
3
|
+
import type { EvalEngine } from '../eval/engine.js';
|
|
4
|
+
export declare function registerDeleteRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* delete_rule MCP tool — remove a deployed custom rule.
|
|
3
3
|
*
|
|
4
4
|
* Destructive counterpart to deploy_rule. Removes the rule from
|
|
5
|
-
* ~/.iris/custom-rules.json
|
|
6
|
-
*
|
|
5
|
+
* ~/.iris/custom-rules.json AND unregisters it from the live eval
|
|
6
|
+
* engine, so it stops firing on the very next evaluate_output call —
|
|
7
|
+
* no restart needed. Appends a `rule.delete` entry to the audit log.
|
|
7
8
|
*
|
|
8
9
|
* Past eval_results that referenced this rule stay intact — the
|
|
9
10
|
* history is preserved even after the rule is removed. The audit
|
|
@@ -11,13 +12,14 @@
|
|
|
11
12
|
*/
|
|
12
13
|
import { z } from 'zod';
|
|
13
14
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
15
|
+
import { strictInput } from './strict-input.js';
|
|
14
16
|
const inputSchema = {
|
|
15
17
|
rule_id: z
|
|
16
18
|
.string()
|
|
17
19
|
.regex(/^rule-[a-z0-9]+$/)
|
|
18
20
|
.describe('Rule id to delete (format: rule-<hex>); obtained from list_rules or deploy_rule response'),
|
|
19
21
|
};
|
|
20
|
-
export function registerDeleteRuleTool(server, customRuleStore) {
|
|
22
|
+
export function registerDeleteRuleTool(server, customRuleStore, evalEngine) {
|
|
21
23
|
server.registerTool('delete_rule', {
|
|
22
24
|
title: 'Delete Custom Rule',
|
|
23
25
|
description: [
|
|
@@ -37,7 +39,7 @@ export function registerDeleteRuleTool(server, customRuleStore) {
|
|
|
37
39
|
'',
|
|
38
40
|
"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.",
|
|
39
41
|
].join('\n'),
|
|
40
|
-
inputSchema,
|
|
42
|
+
inputSchema: strictInput(inputSchema),
|
|
41
43
|
annotations: {
|
|
42
44
|
readOnlyHint: false,
|
|
43
45
|
destructiveHint: true,
|
|
@@ -47,6 +49,13 @@ export function registerDeleteRuleTool(server, customRuleStore) {
|
|
|
47
49
|
}, async (args) => {
|
|
48
50
|
// OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
|
|
49
51
|
const deleted = customRuleStore.delete(LOCAL_TENANT, args.rule_id, 'mcp');
|
|
52
|
+
if (deleted) {
|
|
53
|
+
// Hot-remove from the live engine so the rule stops firing on the
|
|
54
|
+
// very next evaluate_output call — the "stops firing immediately on
|
|
55
|
+
// the live process" this description promises (#332). No-op when the
|
|
56
|
+
// rule was never registered in this process.
|
|
57
|
+
evalEngine.unregisterRule(args.rule_id);
|
|
58
|
+
}
|
|
50
59
|
return {
|
|
51
60
|
content: [
|
|
52
61
|
{
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
14
|
+
import { strictInput } from './strict-input.js';
|
|
14
15
|
const inputSchema = {
|
|
15
16
|
trace_id: z
|
|
16
17
|
.string()
|
|
@@ -37,7 +38,7 @@ export function registerDeleteTraceTool(server, storage) {
|
|
|
37
38
|
'',
|
|
38
39
|
"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.",
|
|
39
40
|
].join('\n'),
|
|
40
|
-
inputSchema,
|
|
41
|
+
inputSchema: strictInput(inputSchema),
|
|
41
42
|
annotations: {
|
|
42
43
|
readOnlyHint: false,
|
|
43
44
|
destructiveHint: true,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { CustomRuleStore } from '../custom-rule-store.js';
|
|
3
|
-
|
|
3
|
+
import type { EvalEngine } from '../eval/engine.js';
|
|
4
|
+
export declare function registerDeployRuleTool(server: McpServer, customRuleStore: CustomRuleStore, evalEngine: EvalEngine): void;
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* eval_type.
|
|
12
12
|
*/
|
|
13
13
|
import { z } from 'zod';
|
|
14
|
+
import { createCustomRule } from '../eval/rules/custom.js';
|
|
14
15
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
16
|
+
import { strictInput } from './strict-input.js';
|
|
15
17
|
const CustomRuleDefinitionSchema = z.object({
|
|
16
18
|
name: z.string(),
|
|
17
19
|
type: z.enum([
|
|
@@ -28,7 +30,11 @@ const CustomRuleDefinitionSchema = z.object({
|
|
|
28
30
|
weight: z.number().optional(),
|
|
29
31
|
});
|
|
30
32
|
const inputSchema = {
|
|
31
|
-
|
|
33
|
+
// 80 mirrors the persisted store's cap (custom-rule-store.ts). The tool
|
|
34
|
+
// used to allow 120, so a 100-char name passed the tool schema and then
|
|
35
|
+
// surfaced the store's ZodError as a raw 500 (#332). One limit, enforced
|
|
36
|
+
// at the boundary, fails cleanly as a 400.
|
|
37
|
+
name: z.string().min(1).max(80).describe('Human-readable rule name (1-80 chars; used in eval results)'),
|
|
32
38
|
description: z
|
|
33
39
|
.string()
|
|
34
40
|
.max(500)
|
|
@@ -40,14 +46,14 @@ const inputSchema = {
|
|
|
40
46
|
severity: z
|
|
41
47
|
.enum(['low', 'medium', 'high', 'critical'])
|
|
42
48
|
.default('medium')
|
|
43
|
-
.describe('
|
|
49
|
+
.describe('What a FAILURE of this rule means. low/medium: informational — contributes to the weighted score only (plus dashboard sort + audit alerts). high/critical: hard-fail — a failing evaluation of this rule forces the overall passed=false regardless of the weighted score'),
|
|
44
50
|
definition: CustomRuleDefinitionSchema.describe('Check definition (regex, length, keyword, cost, or schema)'),
|
|
45
51
|
sourceMomentId: z
|
|
46
52
|
.string()
|
|
47
53
|
.optional()
|
|
48
54
|
.describe('Optional Decision Moment ID the rule was derived from (preserves workflow-inversion provenance)'),
|
|
49
55
|
};
|
|
50
|
-
export function registerDeployRuleTool(server, customRuleStore) {
|
|
56
|
+
export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
|
|
51
57
|
server.registerTool('deploy_rule', {
|
|
52
58
|
title: 'Deploy Custom Rule',
|
|
53
59
|
description: [
|
|
@@ -63,11 +69,11 @@ export function registerDeployRuleTool(server, customRuleStore) {
|
|
|
63
69
|
'',
|
|
64
70
|
"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.",
|
|
65
71
|
'',
|
|
66
|
-
'Parameters. name is 1-
|
|
72
|
+
'Parameters. name is 1-80 chars (Zod-enforced min/max — the same cap the persisted store applies); 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 decides what a FAILURE of the rule does: low/medium failures only lower the weighted score (and drive dashboard sort + audit alerts); high/critical failures HARD-FAIL the evaluation — the overall `passed` is forced to false regardless of the weighted score, and the rule is listed in the response\'s `critical_failures`. Severity never changes the numeric score itself (that uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_cost; min_length needs config.min_length; max_length needs config.max_length; contains_keywords/excludes_keywords need config.keywords). Invalid configs are now REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. sourceMomentId is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). Defaults: severity="medium".',
|
|
67
73
|
'',
|
|
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
|
|
74
|
+
"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` or `name` over 80 chars. Any evalType/definition.type combination is valid (a regex_match rule can enforce a safety policy; a max_length rule can express completeness) — there is no category/type mismatch error. 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.",
|
|
69
75
|
].join('\n'),
|
|
70
|
-
inputSchema,
|
|
76
|
+
inputSchema: strictInput(inputSchema),
|
|
71
77
|
annotations: {
|
|
72
78
|
readOnlyHint: false,
|
|
73
79
|
destructiveHint: false,
|
|
@@ -75,16 +81,32 @@ export function registerDeployRuleTool(server, customRuleStore) {
|
|
|
75
81
|
openWorldHint: false,
|
|
76
82
|
},
|
|
77
83
|
}, async (args) => {
|
|
84
|
+
// Server overrides the inner definition's `name` so it always matches
|
|
85
|
+
// the user-facing rule name — same normalization the dashboard's
|
|
86
|
+
// deploy route applies. Also keeps the tool's 80-char cap authoritative
|
|
87
|
+
// (an unchecked definition.name used to reach the store and surface its
|
|
88
|
+
// ZodError as a raw 500).
|
|
89
|
+
const definition = {
|
|
90
|
+
...args.definition,
|
|
91
|
+
name: args.name,
|
|
92
|
+
};
|
|
78
93
|
// OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
|
|
79
94
|
const rule = customRuleStore.deploy(LOCAL_TENANT, {
|
|
80
95
|
name: args.name,
|
|
81
96
|
description: args.description,
|
|
82
97
|
evalType: args.evalType,
|
|
83
98
|
severity: args.severity,
|
|
84
|
-
definition
|
|
99
|
+
definition,
|
|
85
100
|
sourceMomentId: args.sourceMomentId,
|
|
86
101
|
user: 'mcp',
|
|
87
102
|
});
|
|
103
|
+
// Register with the live engine so the rule fires on the very next
|
|
104
|
+
// evaluate_output call — the "activates immediately for the running
|
|
105
|
+
// process" this description promises. Previously only the dashboard's
|
|
106
|
+
// deploy route did this; MCP deploys silently waited for a restart.
|
|
107
|
+
// Registered under its rule id so delete_rule can hot-remove it.
|
|
108
|
+
// Severity rides along: high/critical makes the rule hard-failing.
|
|
109
|
+
evalEngine.registerRule(rule.evalType, createCustomRule(rule.definition, rule.severity), rule.id);
|
|
88
110
|
return {
|
|
89
111
|
content: [
|
|
90
112
|
{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
3
|
+
import { strictInput } from './strict-input.js';
|
|
3
4
|
const CustomRuleSchema = z.object({
|
|
4
5
|
name: z.string(),
|
|
5
6
|
type: z.enum([
|
|
@@ -11,11 +12,20 @@ const CustomRuleSchema = z.object({
|
|
|
11
12
|
});
|
|
12
13
|
const inputSchema = {
|
|
13
14
|
output: z.string().describe('The output text to evaluate (the agent\'s response that gets scored against rules)'),
|
|
14
|
-
|
|
15
|
+
// .optional() rather than .default('completeness') so the handler can tell
|
|
16
|
+
// "caller chose completeness" apart from "caller never chose" — the second
|
|
17
|
+
// case gets a note in the response saying safety rules did not run. The
|
|
18
|
+
// effective default is still completeness.
|
|
19
|
+
eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).optional().describe('Rule bundle to apply: completeness | relevance | safety | cost | custom — picks which built-in rules fire. Defaults to "completeness" when omitted (the response then carries a note that safety rules did not run)'),
|
|
15
20
|
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
|
|
21
|
+
input: z.string().optional().describe('Original input for context (the ask + any source material the agent was given) — improves relevance scoring and grounds the safety bundle\'s hallucination signals'),
|
|
17
22
|
trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through'),
|
|
18
|
-
|
|
23
|
+
// .max(10): inline rules skip the deploy-time probe, and the engine runs
|
|
24
|
+
// rules synchronously — without a cap, one request carrying N sandbox-
|
|
25
|
+
// defeating regex rules stalls the server linearly in N (measured 9.3s at
|
|
26
|
+
// N=50). Ten is ample for per-call rules; persistent sets belong in
|
|
27
|
+
// deploy_rule, where deploy-time validation probes each pattern.
|
|
28
|
+
custom_rules: z.array(CustomRuleSchema).max(10).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'),
|
|
19
29
|
cost_usd: z.number().optional().describe('Cost in USD — only consulted when eval_type="cost" (compared against cost_threshold rules)'),
|
|
20
30
|
token_usage: z.object({
|
|
21
31
|
prompt_tokens: z.number().optional(),
|
|
@@ -33,17 +43,19 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
|
|
|
33
43
|
'',
|
|
34
44
|
'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.',
|
|
35
45
|
'',
|
|
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).',
|
|
46
|
+
'Output shape. Returns JSON: `{ "id": "<uuid>", "eval_type": "<bundle that ran>", "score": 0..1, "passed": boolean, "critical_failures?": string[], "rule_results": [{ "ruleName", "passed", "score", "message", "skipped?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean, "note?": string }`. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data). `note` appears only when eval_type was omitted, naming the defaulted bundle and that safety rules did not run.',
|
|
37
47
|
'',
|
|
38
|
-
'
|
|
48
|
+
'What `passed` means. `score` and `passed` answer different questions. `score` is the weighted average across the rules that ran — a 0..1 quality gradient. `passed` is the ship/no-ship verdict: true only when the score clears the pass threshold (default 0.7, configurable via config `eval.defaultThreshold`) AND no critical rule failed. Critical rules HARD-FAIL: if one fails, `passed` is false regardless of the weighted score, and the culprits are listed in `critical_failures`. The critical rules are the genuine safety violations — `no_pii`, `no_injection_patterns`, `no_blocklist_words` — plus any deployed custom rule with severity high/critical. A leaked SSN can never be averaged away by other rules passing.',
|
|
49
|
+
'',
|
|
50
|
+
'Use when you want a quality score on a specific output — typically after log_trace records the execution. Pass `eval_type` to route to the right rule bundle: `completeness` (length, sentence count, relevance to input), `relevance` (keyword overlap, topic consistency), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection — pass `input` so the hallucination signals can cross-check the output against the material the agent was given), `cost` (budget threshold), or `custom` (bring your own rules via `custom_rules`).',
|
|
39
51
|
'',
|
|
40
52
|
'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).',
|
|
41
53
|
'',
|
|
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".',
|
|
54
|
+
'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 AND grounds the safety bundle\'s hallucination signals (without it those signals stay silent rather than guess); ignored otherwise. Defaults: eval_type="completeness" — and when you rely on that default, the response carries a `note` reminding you that the safety bundle did not run.',
|
|
43
55
|
'',
|
|
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.',
|
|
56
|
+
'Error modes. Throws on unknown argument names (strict schema — a misspelled argument is rejected with the valid argument list, never silently dropped). Throws on malformed custom_rules (Zod rejects) and on more than 10 custom_rules in one call (use deploy_rule for persistent rule sets). 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. A regex that exceeds the 100ms sandbox matching budget on a given output reports skipped with budgetExceeded=true instead of hanging the server (fail-open per rule — gate on that flag if you must fail closed).',
|
|
45
57
|
].join('\n'),
|
|
46
|
-
inputSchema,
|
|
58
|
+
inputSchema: strictInput(inputSchema),
|
|
47
59
|
annotations: {
|
|
48
60
|
readOnlyHint: false, // Writes an eval_result row
|
|
49
61
|
destructiveHint: false, // Creates new data; doesn't overwrite or delete
|
|
@@ -51,7 +63,12 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
|
|
|
51
63
|
openWorldHint: false, // No external network in heuristic mode; LLM-as-judge has its own tool with openWorldHint:true
|
|
52
64
|
},
|
|
53
65
|
}, async (args) => {
|
|
54
|
-
|
|
66
|
+
// Track omission explicitly: a caller who never chose a bundle gets
|
|
67
|
+
// the completeness default AND a note saying so — six of seven UAT
|
|
68
|
+
// personas read passed:true on PII-laden text with no hint that the
|
|
69
|
+
// safety bundle never ran.
|
|
70
|
+
const evalTypeOmitted = args.eval_type === undefined;
|
|
71
|
+
const evalType = (args.eval_type ?? 'completeness');
|
|
55
72
|
const result = evalEngine.evaluate(evalType, {
|
|
56
73
|
output: args.output,
|
|
57
74
|
expected: args.expected,
|
|
@@ -71,13 +88,23 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
|
|
|
71
88
|
type: 'text',
|
|
72
89
|
text: JSON.stringify({
|
|
73
90
|
id: result.id,
|
|
91
|
+
// Echo which bundle actually ran. Without this, a caller who
|
|
92
|
+
// omitted eval_type could not tell a "safety pass" from a
|
|
93
|
+
// completeness eval that never ran a single safety rule.
|
|
94
|
+
eval_type: result.eval_type,
|
|
74
95
|
score: result.score,
|
|
75
96
|
passed: result.passed,
|
|
97
|
+
...(result.critical_failures ? { critical_failures: result.critical_failures } : {}),
|
|
76
98
|
rule_results: result.rule_results,
|
|
77
99
|
suggestions: result.suggestions,
|
|
78
100
|
rules_evaluated: result.rules_evaluated,
|
|
79
101
|
rules_skipped: result.rules_skipped,
|
|
80
102
|
insufficient_data: result.insufficient_data,
|
|
103
|
+
...(evalTypeOmitted
|
|
104
|
+
? {
|
|
105
|
+
note: 'eval_type was omitted, so the default "completeness" bundle ran. Safety rules (PII, injection, blocklist, stub, hallucination) were NOT part of this evaluation — pass eval_type="safety" to run them.',
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
81
108
|
}),
|
|
82
109
|
},
|
|
83
110
|
],
|
|
@@ -3,6 +3,7 @@ import { LOCAL_TENANT } from '../types/tenant.js';
|
|
|
3
3
|
import { evaluateWithLLMJudge } from '../eval/llm-judge/evaluator.js';
|
|
4
4
|
import { findPricing } from '../eval/llm-judge/pricing.js';
|
|
5
5
|
import { generateEvalId } from '../utils/ids.js';
|
|
6
|
+
import { strictInput } from './strict-input.js';
|
|
6
7
|
const inputSchema = {
|
|
7
8
|
output: z.string().min(1).describe('The agent output text to evaluate'),
|
|
8
9
|
template: z
|
|
@@ -73,7 +74,7 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
|
|
|
73
74
|
'',
|
|
74
75
|
'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.',
|
|
75
76
|
].join('\n'),
|
|
76
|
-
inputSchema,
|
|
77
|
+
inputSchema: strictInput(inputSchema),
|
|
77
78
|
annotations: {
|
|
78
79
|
readOnlyHint: false, // Writes eval_result; also spends money (external API cost)
|
|
79
80
|
destructiveHint: false, // Creates data; doesn't overwrite or delete
|
package/dist/tools/get-traces.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
3
|
+
import { strictInput } from './strict-input.js';
|
|
3
4
|
const inputSchema = {
|
|
4
5
|
agent_name: z.string().optional().describe('Filter by agent name — exact match (no wildcards in v0.4)'),
|
|
5
6
|
framework: z.string().optional().describe('Filter by agent framework — exact match (e.g., langchain, autogen)'),
|
|
@@ -7,7 +8,10 @@ const inputSchema = {
|
|
|
7
8
|
until: z.string().optional().describe('ISO timestamp upper bound — return traces with timestamp < this'),
|
|
8
9
|
min_score: z.number().optional().describe('Minimum eval score filter (0..1) — applied to LATEST eval per trace, not all evals'),
|
|
9
10
|
max_score: z.number().optional().describe('Maximum eval score filter (0..1) — applied to LATEST eval per trace'),
|
|
10
|
-
|
|
11
|
+
// Mirrors traceQuerySchema in dashboard/validation.ts — both capture paths
|
|
12
|
+
// (MCP tool, HTTP query) enforce the same 1..1000 bound. Unclamped, limit:-1
|
|
13
|
+
// meant "LIMIT -1" in SQLite, i.e. every row (#332).
|
|
14
|
+
limit: z.number().int().min(1).max(1000).default(50).describe('Results per page (default 50, max 1000 — values >1000 return 400)'),
|
|
11
15
|
offset: z.number().default(0).describe('Zero-based pagination offset — skip first N results'),
|
|
12
16
|
sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp').describe('Sort by timestamp | latency_ms | cost_usd (default timestamp)'),
|
|
13
17
|
sort_order: z.enum(['asc', 'desc']).default('desc').describe('Sort order: asc | desc (default desc — most recent / highest first)'),
|
|
@@ -33,7 +37,7 @@ export function registerGetTracesTool(server, storage) {
|
|
|
33
37
|
'',
|
|
34
38
|
'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).',
|
|
35
39
|
].join('\n'),
|
|
36
|
-
inputSchema,
|
|
40
|
+
inputSchema: strictInput(inputSchema),
|
|
37
41
|
annotations: {
|
|
38
42
|
readOnlyHint: true, // Pure query: never writes, never deletes
|
|
39
43
|
destructiveHint: false, // Inverse of readOnly — trivially false
|
package/dist/tools/index.js
CHANGED
|
@@ -12,8 +12,8 @@ export function registerAllTools(server, storage, evalEngine, customRuleStore) {
|
|
|
12
12
|
registerEvaluateOutputTool(server, storage, evalEngine);
|
|
13
13
|
registerGetTracesTool(server, storage);
|
|
14
14
|
registerListRulesTool(server, customRuleStore);
|
|
15
|
-
registerDeployRuleTool(server, customRuleStore);
|
|
16
|
-
registerDeleteRuleTool(server, customRuleStore);
|
|
15
|
+
registerDeployRuleTool(server, customRuleStore, evalEngine);
|
|
16
|
+
registerDeleteRuleTool(server, customRuleStore, evalEngine);
|
|
17
17
|
registerDeleteTraceTool(server, storage);
|
|
18
18
|
registerEvaluateWithLLMJudgeTool(server, storage);
|
|
19
19
|
registerVerifyCitationsTool(server, storage);
|
package/dist/tools/list-rules.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { z } from 'zod';
|
|
14
14
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
15
|
+
import { strictInput } from './strict-input.js';
|
|
15
16
|
const inputSchema = {
|
|
16
17
|
eval_type: z
|
|
17
18
|
.enum(['completeness', 'relevance', 'safety', 'cost', 'custom'])
|
|
@@ -42,7 +43,7 @@ export function registerListRulesTool(server, customRuleStore) {
|
|
|
42
43
|
'',
|
|
43
44
|
"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.",
|
|
44
45
|
].join('\n'),
|
|
45
|
-
inputSchema,
|
|
46
|
+
inputSchema: strictInput(inputSchema),
|
|
46
47
|
annotations: {
|
|
47
48
|
readOnlyHint: true,
|
|
48
49
|
destructiveHint: false,
|
|
@@ -1,3 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
import type { IStorageAdapter } from '../types/query.js';
|
|
4
|
+
export declare const logTraceInputShape: {
|
|
5
|
+
agent_name: z.ZodString;
|
|
6
|
+
framework: z.ZodOptional<z.ZodString>;
|
|
7
|
+
input: z.ZodOptional<z.ZodString>;
|
|
8
|
+
output: z.ZodOptional<z.ZodString>;
|
|
9
|
+
tool_calls: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
10
|
+
tool_name: z.ZodString;
|
|
11
|
+
input: z.ZodOptional<z.ZodUnknown>;
|
|
12
|
+
output: z.ZodOptional<z.ZodUnknown>;
|
|
13
|
+
latency_ms: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
error: z.ZodOptional<z.ZodString>;
|
|
15
|
+
}, z.core.$strip>>>;
|
|
16
|
+
latency_ms: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
token_usage: z.ZodOptional<z.ZodObject<{
|
|
18
|
+
prompt_tokens: z.ZodOptional<z.ZodNumber>;
|
|
19
|
+
completion_tokens: z.ZodOptional<z.ZodNumber>;
|
|
20
|
+
total_tokens: z.ZodOptional<z.ZodNumber>;
|
|
21
|
+
}, z.core.$strip>>;
|
|
22
|
+
cost_usd: z.ZodOptional<z.ZodNumber>;
|
|
23
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
24
|
+
spans: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
25
|
+
span_id: z.ZodOptional<z.ZodString>;
|
|
26
|
+
parent_span_id: z.ZodOptional<z.ZodString>;
|
|
27
|
+
name: z.ZodString;
|
|
28
|
+
kind: z.ZodDefault<z.ZodEnum<{
|
|
29
|
+
INTERNAL: "INTERNAL";
|
|
30
|
+
SERVER: "SERVER";
|
|
31
|
+
CLIENT: "CLIENT";
|
|
32
|
+
PRODUCER: "PRODUCER";
|
|
33
|
+
CONSUMER: "CONSUMER";
|
|
34
|
+
LLM: "LLM";
|
|
35
|
+
TOOL: "TOOL";
|
|
36
|
+
}>>;
|
|
37
|
+
status_code: z.ZodDefault<z.ZodEnum<{
|
|
38
|
+
UNSET: "UNSET";
|
|
39
|
+
OK: "OK";
|
|
40
|
+
ERROR: "ERROR";
|
|
41
|
+
}>>;
|
|
42
|
+
status_message: z.ZodOptional<z.ZodString>;
|
|
43
|
+
start_time: z.ZodString;
|
|
44
|
+
end_time: z.ZodOptional<z.ZodString>;
|
|
45
|
+
attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
46
|
+
events: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
47
|
+
name: z.ZodString;
|
|
48
|
+
timestamp: z.ZodString;
|
|
49
|
+
attributes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
50
|
+
}, z.core.$strip>>>;
|
|
51
|
+
}, z.core.$strip>>>;
|
|
52
|
+
timestamp: z.ZodOptional<z.ZodString>;
|
|
53
|
+
};
|
|
3
54
|
export declare function registerLogTraceTool(server: McpServer, storage: IStorageAdapter): void;
|
package/dist/tools/log-trace.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { generateTraceId, generateSpanId } from '../utils/ids.js';
|
|
3
3
|
import { LOCAL_TENANT } from '../types/tenant.js';
|
|
4
4
|
import { bestEffortExport } from '../otel/lazy.js';
|
|
5
|
+
import { strictInput } from './strict-input.js';
|
|
5
6
|
const ToolCallSchema = z.object({
|
|
6
7
|
tool_name: z.string(),
|
|
7
8
|
input: z.unknown().optional(),
|
|
@@ -30,7 +31,14 @@ const TokenUsageSchema = z.object({
|
|
|
30
31
|
completion_tokens: z.number().optional(),
|
|
31
32
|
total_tokens: z.number().optional(),
|
|
32
33
|
});
|
|
33
|
-
|
|
34
|
+
/*
|
|
35
|
+
* The log_trace input contract. Exported because POST /api/v1/traces
|
|
36
|
+
* (src/dashboard/routes/traces.ts) accepts the SAME body — one schema,
|
|
37
|
+
* two capture paths. Duplicating it there would let the tool and the
|
|
38
|
+
* HTTP endpoint drift apart silently; importing it means a field added
|
|
39
|
+
* here is accepted (and validated identically) on both.
|
|
40
|
+
*/
|
|
41
|
+
export const logTraceInputShape = {
|
|
34
42
|
agent_name: z.string().describe('Agent name — used for filtering in get_traces (e.g., "customer-support-bot")'),
|
|
35
43
|
framework: z.string().optional().describe('Agent framework identifier (e.g., langchain, autogen, custom)'),
|
|
36
44
|
input: z.string().optional().describe('Agent input text — the user prompt or upstream input that produced this output'),
|
|
@@ -63,7 +71,11 @@ export function registerLogTraceTool(server, storage) {
|
|
|
63
71
|
'',
|
|
64
72
|
'Error modes. Throws on missing agent_name. Throws on malformed span or tool_call objects (Zod rejects). Returns 500 on storage failure (disk full, DB locked). Never blocks on the agent — returns within ~50ms for typical payloads.',
|
|
65
73
|
].join('\n'),
|
|
66
|
-
|
|
74
|
+
// Strict at the MCP boundary (unknown args rejected, not stripped).
|
|
75
|
+
// The dashboard's HTTP ingest builds its own schema FROM this shape
|
|
76
|
+
// (dashboard/validation.ts) and keeps default stripping there on
|
|
77
|
+
// purpose — it relies on it to discard a client-supplied trace_id.
|
|
78
|
+
inputSchema: strictInput(logTraceInputShape),
|
|
67
79
|
annotations: {
|
|
68
80
|
readOnlyHint: false, // Writes a row to storage
|
|
69
81
|
destructiveHint: false, // Creates new data; doesn't overwrite or delete
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/*
|
|
3
|
+
* Wraps a tool's input shape in a STRICT object schema so unknown argument
|
|
4
|
+
* names are REJECTED with an error that names the offending key(s) and
|
|
5
|
+
* lists the valid ones.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: a bare shape (or z.object()) silently STRIPS unknown
|
|
8
|
+
* keys. At the MCP tool boundary that is dangerous, not lenient — an LLM
|
|
9
|
+
* guessing an argument name is the normal case, not an edge case. Before
|
|
10
|
+
* this wrapper, `evaluate_output({ criteria: ["safety"], ... })` (a
|
|
11
|
+
* plausible guess) and `eval_typ: "safety"` (a one-character typo) both
|
|
12
|
+
* "succeeded": the arguments were dropped, the DEFAULT completeness bundle
|
|
13
|
+
* ran instead of the safety rules, and the response said passed:true on
|
|
14
|
+
* text containing real PII — with nothing indicating the arguments were
|
|
15
|
+
* ignored. Meanwhile a missing REQUIRED field produced a precise Zod
|
|
16
|
+
* error, so the failure mode was inconsistent as well as unsafe.
|
|
17
|
+
*
|
|
18
|
+
* The MCP SDK accepts a schema object (not just a raw shape) for
|
|
19
|
+
* inputSchema and validates tool calls through it, so the custom
|
|
20
|
+
* unrecognized-keys message below is exactly what the caller sees.
|
|
21
|
+
* Strictness also reaches tools/list: the generated JSON Schema carries
|
|
22
|
+
* additionalProperties:false, telling well-behaved clients up front.
|
|
23
|
+
*/
|
|
24
|
+
export function strictInput(shape) {
|
|
25
|
+
const validKeys = Object.keys(shape).join(', ');
|
|
26
|
+
return z.strictObject(shape, {
|
|
27
|
+
error: (issue) => issue.code === 'unrecognized_keys'
|
|
28
|
+
? `Unknown argument(s): ${issue.keys.map((k) => `"${k}"`).join(', ')}. ` +
|
|
29
|
+
`Valid arguments: ${validKeys}. ` +
|
|
30
|
+
'Unknown arguments are rejected rather than silently ignored, so a misspelled ' +
|
|
31
|
+
'argument name cannot change what gets evaluated — check the spelling against ' +
|
|
32
|
+
"the tool's input schema and retry."
|
|
33
|
+
: undefined,
|
|
34
|
+
});
|
|
35
|
+
}
|