@lexq/cli 2.1.1 → 3.0.1
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/AGENTS.md +2 -2
- package/CONTEXT.md +1 -1
- package/README.md +7 -4
- package/dist/index.js +254 -304
- package/dist/mcp/register.d.ts +7 -1
- package/dist/mcp/register.js +288 -315
- package/package.json +3 -1
- package/skills/lexq-execution/SKILL.md +8 -20
- package/skills/lexq-recipes/SKILL.md +9 -13
- package/skills/lexq-shared/SKILL.md +6 -3
package/dist/mcp/register.js
CHANGED
|
@@ -12,6 +12,135 @@ var server_info_default = {
|
|
|
12
12
|
]
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// src/lib/console.ts
|
|
16
|
+
var CONSOLE_URL = "https://console.lexq.io";
|
|
17
|
+
var FAILURE_LOG_CONSOLE_NOTE = `Logs can be read here but not resolved or ignored. An organization admin does that in the LexQ console: Management \u2192 Failure Logs (${CONSOLE_URL}/management/failure-logs).`;
|
|
18
|
+
|
|
19
|
+
// src/lib/config.ts
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
21
|
+
import { homedir } from "os";
|
|
22
|
+
import { join } from "path";
|
|
23
|
+
var CONFIG_DIR = join(homedir(), ".lexq");
|
|
24
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
25
|
+
|
|
26
|
+
// src/lib/lossless-json.ts
|
|
27
|
+
import {
|
|
28
|
+
LosslessNumber,
|
|
29
|
+
isSafeNumber,
|
|
30
|
+
parse as losslessParse,
|
|
31
|
+
stringify as losslessStringify
|
|
32
|
+
} from "lossless-json";
|
|
33
|
+
var survivesDoubleRoundTrip = (literal) => isSafeNumber(literal);
|
|
34
|
+
var parseNumber = (value) => survivesDoubleRoundTrip(value) ? parseFloat(value) : new LosslessNumber(value);
|
|
35
|
+
var parseJson = (text) => losslessParse(text, void 0, parseNumber);
|
|
36
|
+
var stringifyJson = (value, space) => losslessStringify(value, void 0, space);
|
|
37
|
+
var isLosslessNumber = (value) => value instanceof LosslessNumber;
|
|
38
|
+
|
|
39
|
+
// src/lib/api-client.ts
|
|
40
|
+
var ApiError = class extends Error {
|
|
41
|
+
constructor(statusCode, errorCode, message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.statusCode = statusCode;
|
|
44
|
+
this.errorCode = errorCode;
|
|
45
|
+
this.name = "ApiError";
|
|
46
|
+
}
|
|
47
|
+
statusCode;
|
|
48
|
+
errorCode;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/lib/value-domain-warning.ts
|
|
52
|
+
function formatValueDomainMismatchWarning(meta) {
|
|
53
|
+
const mismatch = meta?.valueDomainMismatch;
|
|
54
|
+
if (!mismatch || !Array.isArray(mismatch.versions) || mismatch.versions.length === 0) return null;
|
|
55
|
+
const n = mismatch.totalRuleCount;
|
|
56
|
+
const subject = n === 1 ? "1 rule compares" : `${n} rules compare`;
|
|
57
|
+
const lines = [
|
|
58
|
+
`\u26A0 ${subject} ${mismatch.factKey} with values the new value domain does not accept. The update was still saved.`
|
|
59
|
+
];
|
|
60
|
+
for (const version of mismatch.versions) {
|
|
61
|
+
lines.push(...versionLines(mismatch.factKey, version));
|
|
62
|
+
}
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
function versionLines(factKey, version) {
|
|
66
|
+
const draft = version.versionStatus === "DRAFT";
|
|
67
|
+
const lines = [
|
|
68
|
+
` ${quoted(version.groupName)} v${version.versionNo}, ${draft ? "draft" : "deployed"} (groupId ${version.groupId}, versionId ${version.versionId})`,
|
|
69
|
+
draft ? " Dry runs of this draft already reject calls that send these values (P-052), and live calls will too once it is published and deployed. Saving one of these rules again is refused (P-051) until each listed condition is edited or removed; first check whether each one is still needed." : " Calls to it are checked against the fact definitions captured when it was published, so this change does not affect them. A version cloned from it carries these rules; fix them in the clone before publishing it."
|
|
70
|
+
];
|
|
71
|
+
for (const rule of version.rules) {
|
|
72
|
+
const conditions = rule.conditions.map(
|
|
73
|
+
(c) => `${factKey} ${c.operator} ${escapeUnsafe(stringifyJson(c.value) ?? String(c.value))}`
|
|
74
|
+
).join("; ");
|
|
75
|
+
lines.push(` \u2022 ${quoted(rule.ruleName)} (ruleId ${rule.ruleId}): ${conditions}`);
|
|
76
|
+
}
|
|
77
|
+
const hidden = version.ruleCount - version.rules.length;
|
|
78
|
+
if (hidden > 0) {
|
|
79
|
+
lines.push(` \u2026 and ${hidden} more rule${hidden === 1 ? "" : "s"} in this version.`);
|
|
80
|
+
}
|
|
81
|
+
return lines;
|
|
82
|
+
}
|
|
83
|
+
function quoted(name) {
|
|
84
|
+
return escapeUnsafe(stringifyJson(String(name)) ?? '""');
|
|
85
|
+
}
|
|
86
|
+
var UNSAFE = /[\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
87
|
+
function escapeUnsafe(json) {
|
|
88
|
+
return json.replace(UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/mcp/tools/_shared.ts
|
|
92
|
+
var READ_ONLY = {
|
|
93
|
+
readOnlyHint: true,
|
|
94
|
+
destructiveHint: false,
|
|
95
|
+
openWorldHint: false
|
|
96
|
+
};
|
|
97
|
+
var ADDITIVE = {
|
|
98
|
+
readOnlyHint: false,
|
|
99
|
+
destructiveHint: false,
|
|
100
|
+
openWorldHint: false
|
|
101
|
+
};
|
|
102
|
+
var DESTRUCTIVE = {
|
|
103
|
+
readOnlyHint: false,
|
|
104
|
+
destructiveHint: true,
|
|
105
|
+
openWorldHint: false
|
|
106
|
+
};
|
|
107
|
+
function paginationParams(page, size) {
|
|
108
|
+
const params = {};
|
|
109
|
+
if (page !== void 0) params.page = String(page);
|
|
110
|
+
if (size !== void 0) params.size = String(size);
|
|
111
|
+
return params;
|
|
112
|
+
}
|
|
113
|
+
var MAX_EXPORT_CHARS = 1e5;
|
|
114
|
+
var formatCount = (count) => count.toLocaleString("en-US");
|
|
115
|
+
var EXPORT_LIMIT_NOTE = `An export longer than ${formatCount(MAX_EXPORT_CHARS)} characters is refused.`;
|
|
116
|
+
function limitExport(result, command, narrower) {
|
|
117
|
+
if (result.isError) return result;
|
|
118
|
+
const length = result.content.reduce((sum, block) => sum + block.text.length, 0);
|
|
119
|
+
if (length <= MAX_EXPORT_CHARS) return result;
|
|
120
|
+
const text = [
|
|
121
|
+
`The export is ${formatCount(length)} characters long, and this tool returns at most ${formatCount(MAX_EXPORT_CHARS)}.`,
|
|
122
|
+
narrower,
|
|
123
|
+
`To get the whole file, download it from the LexQ console (${CONSOLE_URL}) or with \`${command}\`.`
|
|
124
|
+
].filter(Boolean).join(" ");
|
|
125
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
126
|
+
}
|
|
127
|
+
function formatUnregisteredFactWarning(meta) {
|
|
128
|
+
const facts = meta?.unregisteredFacts ?? [];
|
|
129
|
+
if (facts.length === 0) return null;
|
|
130
|
+
const lines = facts.map((f) => {
|
|
131
|
+
if (f.inferredType) {
|
|
132
|
+
return ` \u2022 ${f.key} (${f.inferredType}) \u2014 register: lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: "${f.inferredType}" })`;
|
|
133
|
+
}
|
|
134
|
+
const choices = f.candidateTypes?.join(" | ") ?? "STRING | NUMBER | BOOLEAN";
|
|
135
|
+
return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
|
|
136
|
+
});
|
|
137
|
+
return [
|
|
138
|
+
`\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved, but the engine does not check request values for these keys against a type or value domain, and lexq_requirements lists them with type UNKNOWN.`,
|
|
139
|
+
...lines,
|
|
140
|
+
`Register them before the version is published to have those values checked, or call lexq_facts_unregistered to review the version's full list.`
|
|
141
|
+
].join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
15
144
|
// src/mcp/tools/status.ts
|
|
16
145
|
import { z } from "zod";
|
|
17
146
|
function registerStatusTools(server, callApi) {
|
|
@@ -19,7 +148,7 @@ function registerStatusTools(server, callApi) {
|
|
|
19
148
|
"lexq_whoami",
|
|
20
149
|
{
|
|
21
150
|
title: "Who Am I",
|
|
22
|
-
annotations:
|
|
151
|
+
annotations: READ_ONLY,
|
|
23
152
|
description: 'Show current authentication info (tenant ID, user ID, role) and tenantTimezone, the organization time zone that every yyyy-MM-dd date filter uses. Work out "today" or "yesterday" in that zone, not in UTC.',
|
|
24
153
|
inputSchema: z.object({})
|
|
25
154
|
},
|
|
@@ -54,7 +183,6 @@ var SimulationStatus = ["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELED"]
|
|
|
54
183
|
var SimulationMetricType = ["COUNT", "SUM", "AVG"];
|
|
55
184
|
var ExportFormat = ["CSV", "JSON"];
|
|
56
185
|
var FailureStatus = ["PENDING", "RESOLVED", "IGNORED"];
|
|
57
|
-
var FailureAction = ["IGNORE", "RESOLVE"];
|
|
58
186
|
var TaskType = ["PLATFORM_WEBHOOK", "SCHEDULED_DEPLOYMENT"];
|
|
59
187
|
var PlatformEventType = [
|
|
60
188
|
"VERSION_PUBLISHED",
|
|
@@ -91,6 +219,7 @@ var STATS_DEFAULT_WINDOW_DAYS = 30;
|
|
|
91
219
|
var STATS_MAX_WINDOW_DAYS = 31;
|
|
92
220
|
var FAILURE_LOG_LOOKBACK_DAYS = 7;
|
|
93
221
|
var PURGE_AFTER_DAYS = 30;
|
|
222
|
+
var MAX_DRY_RUN_EVALUATIONS_PER_MINUTE = 60;
|
|
94
223
|
|
|
95
224
|
// src/mcp/tools/groups.ts
|
|
96
225
|
function registerGroupTools(server, callApi) {
|
|
@@ -98,7 +227,7 @@ function registerGroupTools(server, callApi) {
|
|
|
98
227
|
"lexq_groups_list",
|
|
99
228
|
{
|
|
100
229
|
title: "List Policy Groups",
|
|
101
|
-
annotations:
|
|
230
|
+
annotations: READ_ONLY,
|
|
102
231
|
description: "List policy groups (tenant-wide, priority ASC). Deleted groups are not included.",
|
|
103
232
|
inputSchema: z2.object({})
|
|
104
233
|
},
|
|
@@ -108,7 +237,7 @@ function registerGroupTools(server, callApi) {
|
|
|
108
237
|
"lexq_groups_get",
|
|
109
238
|
{
|
|
110
239
|
title: "Get Policy Group",
|
|
111
|
-
annotations:
|
|
240
|
+
annotations: READ_ONLY,
|
|
112
241
|
description: "Get a single policy group by ID. Its currentVersionId and currentDeploymentId are what lexq_deploy_rollback expects, and previousVersionName is the version a rollback restores.",
|
|
113
242
|
inputSchema: z2.object({
|
|
114
243
|
groupId: z2.string().uuid().describe("Policy group ID")
|
|
@@ -120,7 +249,7 @@ function registerGroupTools(server, callApi) {
|
|
|
120
249
|
"lexq_groups_create",
|
|
121
250
|
{
|
|
122
251
|
title: "Create Policy Group",
|
|
123
|
-
annotations:
|
|
252
|
+
annotations: ADDITIVE,
|
|
124
253
|
description: `Create a new policy group. Requires name. Priority is auto-assigned (appended last, tenant-wide); use lexq_groups_reorder to change order. Optionally set conflict resolution, activation group, and description. Policy groups that share an activationGroup form a cluster and must share the same activationMode / activationStrategy / executionLimit; executionLimit is how many of those groups run, not how many rules. A tenant can have at most ${MAX_GROUPS_PER_TENANT} groups that are not ARCHIVED; archive groups you no longer use to free a slot.`,
|
|
125
254
|
inputSchema: z2.object({
|
|
126
255
|
name: z2.string().describe("Group name (unique among non-ARCHIVED)"),
|
|
@@ -151,7 +280,7 @@ function registerGroupTools(server, callApi) {
|
|
|
151
280
|
"lexq_groups_update",
|
|
152
281
|
{
|
|
153
282
|
title: "Update Policy Group",
|
|
154
|
-
annotations:
|
|
283
|
+
annotations: DESTRUCTIVE,
|
|
155
284
|
description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged. A deleted group cannot be updated (409 P-059).",
|
|
156
285
|
inputSchema: z2.object({
|
|
157
286
|
groupId: z2.string().uuid().describe("Policy group ID"),
|
|
@@ -176,7 +305,7 @@ function registerGroupTools(server, callApi) {
|
|
|
176
305
|
"lexq_groups_delete",
|
|
177
306
|
{
|
|
178
307
|
title: "Delete Policy Group",
|
|
179
|
-
annotations:
|
|
308
|
+
annotations: DESTRUCTIVE,
|
|
180
309
|
description: `Delete (archive) a policy group. A deployed group cannot be deleted (P-020); undeploy it first with lexq_deploy_undeploy, which also ends a running A/B test. This cannot be undone. The group no longer appears in lexq_groups_list and its name can be reused right away. Calls that change it (settings, versions, rules, starting an A/B test, publish, deploy, rollback, scheduling a deployment) return 409 P-059. If it was ever executed, deployed, or replayed, it is kept for audit; otherwise it is permanently deleted after ${PURGE_AFTER_DAYS} days.`,
|
|
181
310
|
inputSchema: z2.object({
|
|
182
311
|
groupId: z2.string().uuid().describe("Policy group ID")
|
|
@@ -188,7 +317,7 @@ function registerGroupTools(server, callApi) {
|
|
|
188
317
|
"lexq_groups_reorder",
|
|
189
318
|
{
|
|
190
319
|
title: "Reorder Policy Groups",
|
|
191
|
-
annotations:
|
|
320
|
+
annotations: DESTRUCTIVE,
|
|
192
321
|
description: "Reorder policy groups by priority. Priority is tenant-wide and flat (1...N continuous); array index 0 = priority 1 (highest precedence). activationGroup is not affected \u2014 this only changes priority.",
|
|
193
322
|
inputSchema: z2.object({
|
|
194
323
|
groupIds: z2.array(z2.string().uuid()).describe("Group IDs in desired priority order (index 0 = priority 1)")
|
|
@@ -206,8 +335,8 @@ function registerGroupTools(server, callApi) {
|
|
|
206
335
|
"lexq_ab_test_start",
|
|
207
336
|
{
|
|
208
337
|
title: "Start A/B Test",
|
|
209
|
-
annotations:
|
|
210
|
-
description: "Start an A/B test on a policy group. Requires a challenger version ID and traffic rate. The challenger must be a published version of the same group; a version from another group is rejected with P-016. The split is computed from context.trafficKey on each execution request; requests that omit it never reach the challenger and the test stays at 0%.",
|
|
338
|
+
annotations: DESTRUCTIVE,
|
|
339
|
+
description: "Start an A/B test on a policy group. Requires a challenger version ID and traffic rate. The challenger must be a published version of the same group; a version from another group is rejected with P-016. The split is computed from context.trafficKey on each execution request; requests that omit it never reach the challenger and the test stays at 0%. Starting cancels the group's pending scheduled deployment. Starting while a test is already running replaces its challenger and traffic rate.",
|
|
211
340
|
inputSchema: z2.object({
|
|
212
341
|
groupId: z2.string().uuid().describe("Policy group ID"),
|
|
213
342
|
testVersionId: z2.string().uuid().describe("Challenger version ID. Must be a published version of this group"),
|
|
@@ -222,7 +351,7 @@ function registerGroupTools(server, callApi) {
|
|
|
222
351
|
"lexq_ab_test_stop",
|
|
223
352
|
{
|
|
224
353
|
title: "Stop A/B Test",
|
|
225
|
-
annotations:
|
|
354
|
+
annotations: DESTRUCTIVE,
|
|
226
355
|
description: "Stop a running A/B test. All traffic is restored to the control (current) version.",
|
|
227
356
|
inputSchema: z2.object({
|
|
228
357
|
groupId: z2.string().uuid().describe("Policy group ID")
|
|
@@ -234,7 +363,7 @@ function registerGroupTools(server, callApi) {
|
|
|
234
363
|
"lexq_ab_test_adjust",
|
|
235
364
|
{
|
|
236
365
|
title: "Adjust A/B Test",
|
|
237
|
-
annotations:
|
|
366
|
+
annotations: DESTRUCTIVE,
|
|
238
367
|
description: "Adjust traffic rate of a running A/B test.",
|
|
239
368
|
inputSchema: z2.object({
|
|
240
369
|
groupId: z2.string().uuid().describe("Policy group ID"),
|
|
@@ -250,102 +379,6 @@ function registerGroupTools(server, callApi) {
|
|
|
250
379
|
// src/mcp/tools/versions.ts
|
|
251
380
|
import { z as z3 } from "zod";
|
|
252
381
|
|
|
253
|
-
// src/lib/config.ts
|
|
254
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
255
|
-
import { homedir } from "os";
|
|
256
|
-
import { join } from "path";
|
|
257
|
-
var CONFIG_DIR = join(homedir(), ".lexq");
|
|
258
|
-
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
259
|
-
|
|
260
|
-
// src/lib/lossless-json.ts
|
|
261
|
-
import {
|
|
262
|
-
LosslessNumber,
|
|
263
|
-
isSafeNumber,
|
|
264
|
-
parse as losslessParse,
|
|
265
|
-
stringify as losslessStringify
|
|
266
|
-
} from "lossless-json";
|
|
267
|
-
var survivesDoubleRoundTrip = (literal) => isSafeNumber(literal);
|
|
268
|
-
var parseNumber = (value) => survivesDoubleRoundTrip(value) ? parseFloat(value) : new LosslessNumber(value);
|
|
269
|
-
var parseJson = (text) => losslessParse(text, void 0, parseNumber);
|
|
270
|
-
var stringifyJson = (value, space) => losslessStringify(value, void 0, space);
|
|
271
|
-
var isLosslessNumber = (value) => value instanceof LosslessNumber;
|
|
272
|
-
|
|
273
|
-
// src/lib/api-client.ts
|
|
274
|
-
var ApiError = class extends Error {
|
|
275
|
-
constructor(statusCode, errorCode, message) {
|
|
276
|
-
super(message);
|
|
277
|
-
this.statusCode = statusCode;
|
|
278
|
-
this.errorCode = errorCode;
|
|
279
|
-
this.name = "ApiError";
|
|
280
|
-
}
|
|
281
|
-
statusCode;
|
|
282
|
-
errorCode;
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
// src/lib/value-domain-warning.ts
|
|
286
|
-
function formatValueDomainMismatchWarning(meta) {
|
|
287
|
-
const mismatch = meta?.valueDomainMismatch;
|
|
288
|
-
if (!mismatch || !Array.isArray(mismatch.versions) || mismatch.versions.length === 0) return null;
|
|
289
|
-
const n = mismatch.totalRuleCount;
|
|
290
|
-
const subject = n === 1 ? "1 rule compares" : `${n} rules compare`;
|
|
291
|
-
const lines = [
|
|
292
|
-
`\u26A0 ${subject} ${mismatch.factKey} with values the new value domain does not accept. The update was still saved.`
|
|
293
|
-
];
|
|
294
|
-
for (const version of mismatch.versions) {
|
|
295
|
-
lines.push(...versionLines(mismatch.factKey, version));
|
|
296
|
-
}
|
|
297
|
-
return lines.join("\n");
|
|
298
|
-
}
|
|
299
|
-
function versionLines(factKey, version) {
|
|
300
|
-
const draft = version.versionStatus === "DRAFT";
|
|
301
|
-
const lines = [
|
|
302
|
-
` ${quoted(version.groupName)} v${version.versionNo}, ${draft ? "draft" : "deployed"} (groupId ${version.groupId}, versionId ${version.versionId})`,
|
|
303
|
-
draft ? " Dry runs of this draft already reject calls that send these values (P-052), and live calls will too once it is published and deployed. Saving one of these rules again is refused (P-051) until each listed condition is edited or removed; first check whether each one is still needed." : " Calls to it are checked against the fact definitions captured when it was published, so this change does not affect them. A version cloned from it carries these rules; fix them in the clone before publishing it."
|
|
304
|
-
];
|
|
305
|
-
for (const rule of version.rules) {
|
|
306
|
-
const conditions = rule.conditions.map(
|
|
307
|
-
(c) => `${factKey} ${c.operator} ${escapeUnsafe(stringifyJson(c.value) ?? String(c.value))}`
|
|
308
|
-
).join("; ");
|
|
309
|
-
lines.push(` \u2022 ${quoted(rule.ruleName)} (ruleId ${rule.ruleId}): ${conditions}`);
|
|
310
|
-
}
|
|
311
|
-
const hidden = version.ruleCount - version.rules.length;
|
|
312
|
-
if (hidden > 0) {
|
|
313
|
-
lines.push(` \u2026 and ${hidden} more rule${hidden === 1 ? "" : "s"} in this version.`);
|
|
314
|
-
}
|
|
315
|
-
return lines;
|
|
316
|
-
}
|
|
317
|
-
function quoted(name) {
|
|
318
|
-
return escapeUnsafe(stringifyJson(String(name)) ?? '""');
|
|
319
|
-
}
|
|
320
|
-
var UNSAFE = /[\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
|
|
321
|
-
function escapeUnsafe(json) {
|
|
322
|
-
return json.replace(UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// src/mcp/tools/_shared.ts
|
|
326
|
-
function paginationParams(page, size) {
|
|
327
|
-
const params = {};
|
|
328
|
-
if (page !== void 0) params.page = String(page);
|
|
329
|
-
if (size !== void 0) params.size = String(size);
|
|
330
|
-
return params;
|
|
331
|
-
}
|
|
332
|
-
function formatUnregisteredFactWarning(meta) {
|
|
333
|
-
const facts = meta?.unregisteredFacts ?? [];
|
|
334
|
-
if (facts.length === 0) return null;
|
|
335
|
-
const lines = facts.map((f) => {
|
|
336
|
-
if (f.inferredType) {
|
|
337
|
-
return ` \u2022 ${f.key} (${f.inferredType}) \u2014 register: lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: "${f.inferredType}" })`;
|
|
338
|
-
}
|
|
339
|
-
const choices = f.candidateTypes?.join(" | ") ?? "STRING | NUMBER | BOOLEAN";
|
|
340
|
-
return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
|
|
341
|
-
});
|
|
342
|
-
return [
|
|
343
|
-
`\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved \u2014 this does not block \u2014 but undefined facts skip type validation and the dry-run requirements analyzer.`,
|
|
344
|
-
...lines,
|
|
345
|
-
`Register them now so the rule validates as intended, or call lexq_facts_unregistered to review the version's full list.`
|
|
346
|
-
].join("\n");
|
|
347
|
-
}
|
|
348
|
-
|
|
349
382
|
// src/lib/dates.ts
|
|
350
383
|
var ORG_DAY = "yyyy-MM-dd, a whole calendar day in the organization time zone (tenantTimezone in whoami), not UTC";
|
|
351
384
|
var START_DAY = `Start date, ${ORG_DAY}. Inclusive.`;
|
|
@@ -360,7 +393,7 @@ function registerVersionTools(server, callApi) {
|
|
|
360
393
|
"lexq_versions_list",
|
|
361
394
|
{
|
|
362
395
|
title: "List Policy Versions",
|
|
363
|
-
annotations:
|
|
396
|
+
annotations: READ_ONLY,
|
|
364
397
|
description: "List all versions of a policy group.",
|
|
365
398
|
inputSchema: z3.object({
|
|
366
399
|
groupId: z3.string().uuid().describe("Policy group ID"),
|
|
@@ -376,7 +409,7 @@ function registerVersionTools(server, callApi) {
|
|
|
376
409
|
"lexq_versions_get",
|
|
377
410
|
{
|
|
378
411
|
title: "Get Policy Version",
|
|
379
|
-
annotations:
|
|
412
|
+
annotations: READ_ONLY,
|
|
380
413
|
description: "Get a single version by ID, including its rules and fact requirements.",
|
|
381
414
|
inputSchema: z3.object({
|
|
382
415
|
groupId: z3.string().uuid().describe("Policy group ID"),
|
|
@@ -389,7 +422,7 @@ function registerVersionTools(server, callApi) {
|
|
|
389
422
|
"lexq_versions_create",
|
|
390
423
|
{
|
|
391
424
|
title: "Create Policy Version",
|
|
392
|
-
annotations:
|
|
425
|
+
annotations: ADDITIVE,
|
|
393
426
|
description: `Create a new DRAFT version in a policy group. Optionally provide a commit message and effective date range. A group can hold at most ${MAX_DRAFT_VERSIONS_PER_GROUP} DRAFT versions at a time; published versions do not count. Publish or delete drafts to make room.`,
|
|
394
427
|
inputSchema: z3.object({
|
|
395
428
|
groupId: z3.string().uuid().describe("Policy group ID"),
|
|
@@ -404,7 +437,7 @@ function registerVersionTools(server, callApi) {
|
|
|
404
437
|
"lexq_versions_update",
|
|
405
438
|
{
|
|
406
439
|
title: "Update Policy Version",
|
|
407
|
-
annotations:
|
|
440
|
+
annotations: DESTRUCTIVE,
|
|
408
441
|
description: "Update a DRAFT version. Only DRAFT versions can be modified. Omitted fields keep their current values. To remove an end of the effective window, set clearEffectiveFrom or clearEffectiveTo to true instead of sending a value.",
|
|
409
442
|
inputSchema: z3.object({
|
|
410
443
|
groupId: z3.string().uuid().describe("Policy group ID"),
|
|
@@ -412,7 +445,9 @@ function registerVersionTools(server, callApi) {
|
|
|
412
445
|
commitMessage: z3.string().optional().describe("New commit message"),
|
|
413
446
|
effectiveFrom: z3.string().optional().describe(`Effective start, ${INSTANT} Omit it to keep the current start.`),
|
|
414
447
|
effectiveTo: z3.string().optional().describe(`Effective end, ${INSTANT} Omit it to keep the current end.`),
|
|
415
|
-
clearEffectiveFrom: z3.boolean().optional().describe(
|
|
448
|
+
clearEffectiveFrom: z3.boolean().optional().describe(
|
|
449
|
+
"Set to true to remove the effective start. Do not send effectiveFrom with it."
|
|
450
|
+
),
|
|
416
451
|
clearEffectiveTo: z3.boolean().optional().describe("Set to true to remove the effective end. Do not send effectiveTo with it.")
|
|
417
452
|
})
|
|
418
453
|
},
|
|
@@ -422,7 +457,7 @@ function registerVersionTools(server, callApi) {
|
|
|
422
457
|
"lexq_versions_delete",
|
|
423
458
|
{
|
|
424
459
|
title: "Delete Policy Version",
|
|
425
|
-
annotations:
|
|
460
|
+
annotations: DESTRUCTIVE,
|
|
426
461
|
description: "Delete a DRAFT version. Only DRAFT versions can be deleted.",
|
|
427
462
|
inputSchema: z3.object({
|
|
428
463
|
groupId: z3.string().uuid().describe("Policy group ID"),
|
|
@@ -435,7 +470,7 @@ function registerVersionTools(server, callApi) {
|
|
|
435
470
|
"lexq_versions_clone",
|
|
436
471
|
{
|
|
437
472
|
title: "Clone Policy Version",
|
|
438
|
-
annotations:
|
|
473
|
+
annotations: ADDITIVE,
|
|
439
474
|
description: `Clone an existing version to create a new DRAFT. Useful when the source version is already published. The new DRAFT counts toward the limit of ${MAX_DRAFT_VERSIONS_PER_GROUP} DRAFT versions per group, and a source with more than ${MAX_RULES_PER_VERSION} rules cannot be cloned.`,
|
|
440
475
|
inputSchema: z3.object({
|
|
441
476
|
groupId: z3.string().uuid().describe("Policy group ID"),
|
|
@@ -459,7 +494,7 @@ function registerRuleTools(server, callApi) {
|
|
|
459
494
|
"lexq_rules_list",
|
|
460
495
|
{
|
|
461
496
|
title: "List Rules",
|
|
462
|
-
annotations:
|
|
497
|
+
annotations: READ_ONLY,
|
|
463
498
|
description: "List all rules in a version (priority ASC). Returns summary with conditionSummary and actionSummary.",
|
|
464
499
|
inputSchema: z4.object({
|
|
465
500
|
groupId: z4.string().uuid().describe("Policy group ID"),
|
|
@@ -472,7 +507,7 @@ function registerRuleTools(server, callApi) {
|
|
|
472
507
|
"lexq_rules_get",
|
|
473
508
|
{
|
|
474
509
|
title: "Get Rule Detail",
|
|
475
|
-
annotations:
|
|
510
|
+
annotations: READ_ONLY,
|
|
476
511
|
description: "Get full rule detail including condition tree and action definitions.",
|
|
477
512
|
inputSchema: z4.object({
|
|
478
513
|
groupId: z4.string().uuid().describe("Policy group ID"),
|
|
@@ -486,87 +521,39 @@ function registerRuleTools(server, callApi) {
|
|
|
486
521
|
"lexq_rules_create",
|
|
487
522
|
{
|
|
488
523
|
title: "Create Rule",
|
|
489
|
-
annotations:
|
|
524
|
+
annotations: ADDITIVE,
|
|
490
525
|
description: dedent`
|
|
491
|
-
Create a rule in a DRAFT version.
|
|
492
|
-
|
|
493
|
-
Before creating rules with new fact keys, call lexq_facts_list to check existing facts.
|
|
494
|
-
If a required key is missing, ask the user to confirm the type, isRequired, and description
|
|
495
|
-
before calling lexq_facts_create — registering facts enables type validation, Console UI
|
|
496
|
-
autocomplete, and the dry-run requirements analyzer.
|
|
497
|
-
|
|
498
|
-
An action's targetVar (SET_FACT, MUTATE_FACT) must be a registered fact. Saving or
|
|
499
|
-
publishing a rule whose targetVar is not registered fails with ACT-032, so register it
|
|
500
|
-
with lexq_facts_create first. Keys a rule only reads (condition fields, refVar) may stay
|
|
501
|
-
unregistered.
|
|
526
|
+
Create a rule in a DRAFT version. It goes last in priority order; lexq_rules_reorder changes the order.
|
|
502
527
|
|
|
503
|
-
|
|
504
|
-
defined (non-blocking, version-wide) — use it to decide what to register.
|
|
528
|
+
An action's targetVar must be a registered fact (ACT-032 otherwise). Keys a rule only reads (condition fields, refVar) may stay unregistered.
|
|
505
529
|
|
|
506
530
|
Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: ${oneOf(LogicalOperator)}, children: [...] }
|
|
507
531
|
Value types: ${ValueType.join(", ")}
|
|
508
|
-
Limits:
|
|
509
|
-
and nest at most ${MAX_CONDITION_DEPTH} levels below the root. A version holds at most ${MAX_RULES_PER_VERSION} rules.
|
|
532
|
+
Limits: ${MAX_CONDITIONS_PER_RULE} SINGLE conditions per rule (GROUP nodes do not count), ${MAX_CONDITION_DEPTH} levels of nesting below the root, ${MAX_RULES_PER_VERSION} rules per version.
|
|
510
533
|
|
|
511
|
-
Operators
|
|
512
|
-
|
|
513
|
-
-
|
|
514
|
-
-
|
|
515
|
-
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
|
|
519
|
-
- HAS_ANY: fact has at least one of the given values
|
|
520
|
-
- HAS_ALL: fact has all of the given values
|
|
521
|
-
- HAS_NONE: fact has none of the given values
|
|
534
|
+
Operators that work on each fact type:
|
|
535
|
+
- STRING: EQUALS, NOT_EQUALS, CONTAINS (substring), IN, NOT_IN
|
|
536
|
+
- NUMBER: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
|
|
537
|
+
- BOOLEAN: EQUALS, NOT_EQUALS
|
|
538
|
+
- LIST_STRING, LIST_NUMBER: HAS_ANY, HAS_ALL, HAS_NONE
|
|
539
|
+
IN and NOT_IN test whether the fact's value is in the array. HAS_ANY, HAS_ALL and HAS_NONE match when the fact's list holds at least one, all, or none of the array's values. The array holds values of the fact's type, or for a list fact, of its element type.
|
|
522
540
|
Example: { "type": "SINGLE", "field": "userTags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
|
|
523
541
|
|
|
524
|
-
Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
|
|
525
|
-
IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
|
|
526
|
-
|
|
527
542
|
Actions: [{ type, parameters }]
|
|
543
|
+
- MUTATE_FACT: { targetVar, operator: ${oneOf(MutateOperator)}, method: ${oneOf(CalculationMethod)}, operand: number, refVar?, rounding? }
|
|
544
|
+
Reads and writes targetVar. The request must send it (P-015 otherwise), and it must hold a number when the action runs. operand may be negative, and a percent may be over 100.
|
|
545
|
+
AMOUNT PERCENTAGE (base = refVar, or targetVar when omitted)
|
|
546
|
+
ASSIGN targetVar = operand targetVar = base × operand/100
|
|
547
|
+
ADD targetVar += operand targetVar += base × operand/100
|
|
548
|
+
SUB targetVar -= operand targetVar -= base × operand/100
|
|
549
|
+
MUL targetVar *= operand targetVar *= (operand/100 + 1)
|
|
550
|
+
DIV targetVar /= operand invalid
|
|
551
|
+
refVar is allowed only with PERCENTAGE and ASSIGN, ADD or SUB. DIV with AMOUNT needs a nonzero operand.
|
|
552
|
+
rounding: { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: ${oneOf(RoundingPolicy)} }. mode defaults to HALF_UP. Without rounding, the result keeps full precision.
|
|
553
|
+
- SET_FACT: { targetVar, value: string|number|boolean }. The request does not have to send targetVar.
|
|
554
|
+
- BLOCK: { reason? } sets isBlocked to true and blockReason to the reason, or "Policy Violation" without one. It does not stop later actions or rules; the caller enforces the block. Only BLOCK writes these two keys: a SET_FACT or MUTATE_FACT targeting either is rejected (ACT-033).
|
|
528
555
|
|
|
529
|
-
|
|
530
|
-
- MUTATE_FACT: { targetVar: string, operator: ${oneOf(MutateOperator)}, method: ${oneOf(CalculationMethod)}, operand: number, refVar?: string, rounding?: RoundingOption }
|
|
531
|
-
targetVar is the fact this action reads and writes. It must be a registered fact
|
|
532
|
-
(ACT-032 otherwise), and the request must send it (P-015 otherwise). Its value must be
|
|
533
|
-
a number when the action runs.
|
|
534
|
-
A missing required fact throws (no 0 default).
|
|
535
|
-
operand is the arithmetic operand; the unit is dictated by method (percent when
|
|
536
|
-
PERCENTAGE, absolute amount when AMOUNT). Ranges are not constrained — negative values
|
|
537
|
-
and >100 percentages are valid (refunds, surcharges).
|
|
538
|
-
refVar is the base for percentage calculation and is OPTIONAL — omit it to use targetVar
|
|
539
|
-
itself. It is only meaningful in PERCENTAGE × {ASSIGN, ADD, SUB}; specifying it in any
|
|
540
|
-
other cell is an error. Use it when the base differs from the target, e.g.
|
|
541
|
-
"points += orderTotal × 5%" → { targetVar: "points", refVar: "orderTotal",
|
|
542
|
-
operator: "ADD", method: "PERCENTAGE", operand: 5 }.
|
|
543
|
-
operator × method matrix:
|
|
544
|
-
ASSIGN targetVar = operand | targetVar = refVar × operand/100
|
|
545
|
-
ADD targetVar += operand | targetVar += refVar × operand/100
|
|
546
|
-
SUB targetVar -= operand | targetVar -= refVar × operand/100
|
|
547
|
-
MUL targetVar *= operand | targetVar *= (operand/100 + 1)
|
|
548
|
-
DIV targetVar /= operand | invalid
|
|
549
|
-
Constraints: DIV + PERCENTAGE is invalid (use MUL with the inverse). DIV + AMOUNT
|
|
550
|
-
requires operand !== 0.
|
|
551
|
-
- SET_FACT: { targetVar: string, value: string|number|boolean } targetVar must be a
|
|
552
|
-
registered fact (ACT-032 otherwise). The request does not have to send it: SET_FACT is
|
|
553
|
-
the only action whose targetVar may be absent from the input facts. MUTATE_FACT requires
|
|
554
|
-
its target to be present in the input facts.
|
|
555
|
-
- BLOCK: { reason?: string } Records a rejection decision. It does NOT halt rule execution —
|
|
556
|
-
subsequent actions and subsequent winning rules still run. Enforcement is the caller's
|
|
557
|
-
responsibility; the decision surfaces as isBlocked (true) and blockReason (reason, or
|
|
558
|
-
"Policy Violation" when omitted).
|
|
559
|
-
Only BLOCK writes those two keys: saving or publishing a rule whose SET_FACT or
|
|
560
|
-
MUTATE_FACT targets either one fails with ACT-033.
|
|
561
|
-
|
|
562
|
-
Evaluation order: every rule's condition is checked against the input facts before any
|
|
563
|
-
action runs, so a condition never sees a value an action writes in the same request. Then
|
|
564
|
-
the actions of the winning rules run in priority order. Each action works on the values
|
|
565
|
-
the actions before it left, in its own rule or an earlier one. To test a value an action
|
|
566
|
-
writes, send that value as an input fact in a second request. If a rule should run
|
|
567
|
-
whenever another one does, put its actions in that rule instead.
|
|
568
|
-
|
|
569
|
-
RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: ${oneOf(RoundingPolicy)} } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
|
|
556
|
+
Every condition is checked against the input facts before any action runs, so a condition never sees a value an action writes in the same request. The winning rules' actions then run in priority order, each on the values the actions before it left.
|
|
570
557
|
`,
|
|
571
558
|
inputSchema: z4.object({
|
|
572
559
|
groupId: z4.string().uuid().describe("Policy group ID"),
|
|
@@ -585,7 +572,7 @@ function registerRuleTools(server, callApi) {
|
|
|
585
572
|
"lexq_rules_update",
|
|
586
573
|
{
|
|
587
574
|
title: "Update Rule",
|
|
588
|
-
annotations:
|
|
575
|
+
annotations: DESTRUCTIVE,
|
|
589
576
|
description: `Update an existing rule in a DRAFT version. Only provided fields are changed. A new condition tree must stay within ${MAX_CONDITIONS_PER_RULE} conditions (SINGLE nodes) and ${MAX_CONDITION_DEPTH} levels of nesting below the root. Saving re-checks every action in the rule, including ones you did not change, and fails with ACT-032 if any action's targetVar is not a registered fact.`,
|
|
590
577
|
inputSchema: z4.object({
|
|
591
578
|
groupId: z4.string().uuid().describe("Policy group ID"),
|
|
@@ -607,28 +594,21 @@ function registerRuleTools(server, callApi) {
|
|
|
607
594
|
"lexq_rules_delete",
|
|
608
595
|
{
|
|
609
596
|
title: "Delete Rule",
|
|
610
|
-
annotations:
|
|
597
|
+
annotations: DESTRUCTIVE,
|
|
611
598
|
description: "Delete a rule from a DRAFT version.",
|
|
612
599
|
inputSchema: z4.object({
|
|
613
600
|
groupId: z4.string().uuid().describe("Policy group ID"),
|
|
614
601
|
versionId: z4.string().uuid().describe("Version ID"),
|
|
615
|
-
ruleId: z4.string().uuid().describe("Rule ID")
|
|
616
|
-
force: z4.boolean().default(false).describe("Skip confirmation (for the last rule in a version)")
|
|
602
|
+
ruleId: z4.string().uuid().describe("Rule ID")
|
|
617
603
|
})
|
|
618
604
|
},
|
|
619
|
-
async ({ groupId, versionId, ruleId
|
|
620
|
-
const params = {};
|
|
621
|
-
if (force) params.force = "true";
|
|
622
|
-
return callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`, {
|
|
623
|
-
params
|
|
624
|
-
});
|
|
625
|
-
}
|
|
605
|
+
async ({ groupId, versionId, ruleId }) => callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`)
|
|
626
606
|
);
|
|
627
607
|
server.registerTool(
|
|
628
608
|
"lexq_rules_reorder",
|
|
629
609
|
{
|
|
630
610
|
title: "Reorder Rules",
|
|
631
|
-
annotations:
|
|
611
|
+
annotations: DESTRUCTIVE,
|
|
632
612
|
description: "Reorder rules by specifying rule IDs in desired order. Priorities are assigned 1...N (1-based, continuous); array index 0 = priority 1 (highest precedence).",
|
|
633
613
|
inputSchema: z4.object({
|
|
634
614
|
groupId: z4.string().uuid().describe("Policy group ID"),
|
|
@@ -650,7 +630,7 @@ function registerRuleTools(server, callApi) {
|
|
|
650
630
|
"lexq_rules_toggle",
|
|
651
631
|
{
|
|
652
632
|
title: "Toggle Rule",
|
|
653
|
-
annotations:
|
|
633
|
+
annotations: DESTRUCTIVE,
|
|
654
634
|
description: "Enable or disable a rule without deleting it.",
|
|
655
635
|
inputSchema: z4.object({
|
|
656
636
|
groupId: z4.string().uuid().describe("Policy group ID"),
|
|
@@ -673,17 +653,19 @@ var FACT_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
|
673
653
|
|
|
674
654
|
// src/lib/export.ts
|
|
675
655
|
import { writeFileSync as writeFileSync2 } from "fs";
|
|
656
|
+
import { extname } from "path";
|
|
676
657
|
var EXPORT_FORMATS = ExportFormat.map((name) => name.toLowerCase()).sort();
|
|
677
658
|
|
|
678
659
|
// src/mcp/tools/facts.ts
|
|
679
660
|
var VALUE_TYPES = ValueType;
|
|
661
|
+
var IS_REQUIRED_NOTE = "isRequired is stored on the definition only; the engine does not read it.";
|
|
680
662
|
function registerFactTools(server, callApi) {
|
|
681
663
|
server.registerTool(
|
|
682
664
|
"lexq_facts_list",
|
|
683
665
|
{
|
|
684
666
|
title: "List Fact Definitions",
|
|
685
|
-
annotations:
|
|
686
|
-
description:
|
|
667
|
+
annotations: READ_ONLY,
|
|
668
|
+
description: `List all fact definitions (input variable schema): key, name, type, description, PII and system flags, and value domain. ${IS_REQUIRED_NOTE}`,
|
|
687
669
|
inputSchema: z5.object({
|
|
688
670
|
page: z5.number().int().min(0).default(0).describe("Page number"),
|
|
689
671
|
size: z5.number().int().min(1).max(100).default(20).describe("Page size"),
|
|
@@ -700,14 +682,14 @@ function registerFactTools(server, callApi) {
|
|
|
700
682
|
"lexq_facts_create",
|
|
701
683
|
{
|
|
702
684
|
title: "Create Fact Definition",
|
|
703
|
-
annotations:
|
|
685
|
+
annotations: ADDITIVE,
|
|
704
686
|
description: `Register a new input variable. Key starts with a letter, then letters, numbers, and underscores (e.g. paymentAmount). Casing is not enforced. Types: ${VALUE_TYPES.join(", ")}.`,
|
|
705
687
|
inputSchema: z5.object({
|
|
706
688
|
key: z5.string().regex(FACT_KEY_PATTERN).describe("Variable key. Any casing; must start with a letter."),
|
|
707
689
|
name: z5.string().describe("Display name"),
|
|
708
690
|
type: z5.enum(VALUE_TYPES).describe("Value type"),
|
|
709
691
|
description: z5.string().optional().describe("Description"),
|
|
710
|
-
isRequired: z5.boolean().default(false).describe(
|
|
692
|
+
isRequired: z5.boolean().default(false).describe(IS_REQUIRED_NOTE),
|
|
711
693
|
isPii: z5.boolean().default(false).describe(
|
|
712
694
|
"Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
|
|
713
695
|
),
|
|
@@ -728,7 +710,7 @@ function registerFactTools(server, callApi) {
|
|
|
728
710
|
"lexq_facts_update",
|
|
729
711
|
{
|
|
730
712
|
title: "Update Fact Definition",
|
|
731
|
-
annotations:
|
|
713
|
+
annotations: DESTRUCTIVE,
|
|
732
714
|
description: "Update a fact definition. The key is immutable. The type can change only while no rule references the fact; if any does, the call fails with FD-007 and reports the count. Rules in deleted groups do not count. Only the fields you send are changed. System facts (isBlocked, blockReason) accept changes to name, description, PII, and the value domain; isRequired is ignored, and their type cannot change (FD-003).",
|
|
733
715
|
inputSchema: z5.object({
|
|
734
716
|
factId: z5.string().uuid().describe("Fact definition ID"),
|
|
@@ -737,7 +719,7 @@ function registerFactTools(server, callApi) {
|
|
|
737
719
|
type: z5.enum(VALUE_TYPES).optional().describe(
|
|
738
720
|
"Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
|
|
739
721
|
),
|
|
740
|
-
isRequired: z5.boolean().optional().describe(
|
|
722
|
+
isRequired: z5.boolean().optional().describe(IS_REQUIRED_NOTE),
|
|
741
723
|
isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
|
|
742
724
|
valueDomain: z5.object({
|
|
743
725
|
allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
|
|
@@ -756,7 +738,7 @@ function registerFactTools(server, callApi) {
|
|
|
756
738
|
"lexq_facts_delete",
|
|
757
739
|
{
|
|
758
740
|
title: "Delete Fact Definition",
|
|
759
|
-
annotations:
|
|
741
|
+
annotations: DESTRUCTIVE,
|
|
760
742
|
description: "Delete a fact definition. System facts cannot be deleted. Neither can a fact that any rule references: that call fails with FD-006 and reports the count. Rules in deleted groups do not count. Remove the references first.",
|
|
761
743
|
inputSchema: z5.object({
|
|
762
744
|
factId: z5.string().uuid().describe("Fact definition ID")
|
|
@@ -768,7 +750,7 @@ function registerFactTools(server, callApi) {
|
|
|
768
750
|
"lexq_facts_action_metadata",
|
|
769
751
|
{
|
|
770
752
|
title: "Get Action Runtime Fact Metadata",
|
|
771
|
-
annotations:
|
|
753
|
+
annotations: READ_ONLY,
|
|
772
754
|
description: "Retrieve runtime fact requirements per Action type. For each Action, shows which input facts must be present in the execution payload \u2014 e.g. MUTATE_FACT always requires its targetVar fact, plus refVar when one is specified. The factRequired flag describes the FACT, not the parameter: refVar is an optional parameter, but if you specify it the named fact must exist. A required fact absent at runtime throws \u2014 the engine never defaults to 0. These facts must be sent in the request (P-015 otherwise), even when an earlier action in the same rule writes them. SET_FACT requires no input fact: it is the only action whose targetVar the request does not have to send, and that targetVar must be a registered fact (ACT-032). Static data, safe to cache in-session.",
|
|
773
755
|
inputSchema: z5.object({})
|
|
774
756
|
},
|
|
@@ -778,8 +760,8 @@ function registerFactTools(server, callApi) {
|
|
|
778
760
|
"lexq_facts_unregistered",
|
|
779
761
|
{
|
|
780
762
|
title: "List Unregistered Facts",
|
|
781
|
-
annotations:
|
|
782
|
-
description: "List facts referenced by a version's rules but not yet defined (read-only). Keys that rules only read do not block publish/deploy; a rule whose action targetVar is not a registered fact is rejected at save and publish with ACT-032. Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action).
|
|
763
|
+
annotations: READ_ONLY,
|
|
764
|
+
description: "List facts referenced by a version's rules but not yet defined (read-only). Keys that rules only read do not block publish/deploy; a rule whose action targetVar is not a registered fact is rejected at save and publish with ACT-032. Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Registering one with lexq_facts_create before the version is published makes the engine check request values for it against its type (P-060) and any value domain (P-052).",
|
|
783
765
|
inputSchema: z5.object({
|
|
784
766
|
groupId: z5.string().uuid().describe("Policy group ID"),
|
|
785
767
|
versionId: z5.string().uuid().describe("Version ID")
|
|
@@ -791,16 +773,20 @@ function registerFactTools(server, callApi) {
|
|
|
791
773
|
"lexq_facts_export",
|
|
792
774
|
{
|
|
793
775
|
title: "Export Fact Catalog",
|
|
794
|
-
annotations:
|
|
795
|
-
description:
|
|
776
|
+
annotations: READ_ONLY,
|
|
777
|
+
description: `Export the fact catalog. The two formats carry different things: CSV is the catalog as it stands, system facts included, for reading in a spreadsheet, without value domains; JSON lists each definition with only the fields a new definition takes (key, name, type, description, isRequired, isPii, valueDomain), without IDs. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
|
|
796
778
|
inputSchema: z5.object({
|
|
797
779
|
format: z5.enum(EXPORT_FORMATS).default("csv").describe("Export format"),
|
|
798
780
|
keyword: z5.string().optional().describe("Filter by key or name")
|
|
799
781
|
})
|
|
800
782
|
},
|
|
801
|
-
async ({ format, keyword }) =>
|
|
802
|
-
|
|
803
|
-
|
|
783
|
+
async ({ format, keyword }) => limitExport(
|
|
784
|
+
await callApi("GET", "schema/facts/export", {
|
|
785
|
+
params: keyword ? { format, keyword } : { format }
|
|
786
|
+
}),
|
|
787
|
+
`lexq facts export --as ${format} --output <file>`,
|
|
788
|
+
"Pass keyword to export fewer facts."
|
|
789
|
+
)
|
|
804
790
|
);
|
|
805
791
|
}
|
|
806
792
|
|
|
@@ -812,7 +798,7 @@ function registerDeployTools(server, callApi) {
|
|
|
812
798
|
"lexq_deploy_publish",
|
|
813
799
|
{
|
|
814
800
|
title: "Publish Version",
|
|
815
|
-
annotations:
|
|
801
|
+
annotations: DESTRUCTIVE,
|
|
816
802
|
description: `Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts that rules only read do not block publishing; call lexq_facts_unregistered first to review them. Publishing fails if an action's targetVar is not a registered fact (ACT-032), if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB, or if its effective end has already passed (P-058); move the end into the future with lexq_versions_update first.`,
|
|
817
803
|
inputSchema: z6.object({
|
|
818
804
|
groupId: z6.string().uuid().describe("Policy group ID"),
|
|
@@ -826,7 +812,7 @@ function registerDeployTools(server, callApi) {
|
|
|
826
812
|
"lexq_deploy_live",
|
|
827
813
|
{
|
|
828
814
|
title: "Deploy to Live",
|
|
829
|
-
annotations:
|
|
815
|
+
annotations: DESTRUCTIVE,
|
|
830
816
|
description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Versions whose effective start date has not arrived are rejected (P-037) \u2014 use lexq_deploy_schedule for those. Versions whose effective end has passed are rejected (P-058). Undefined facts do not block deployment; use lexq_facts_unregistered to review what the version references but has not defined.",
|
|
831
817
|
inputSchema: z6.object({
|
|
832
818
|
groupId: z6.string().uuid().describe("Policy group ID"),
|
|
@@ -842,7 +828,7 @@ function registerDeployTools(server, callApi) {
|
|
|
842
828
|
"lexq_deploy_rollback",
|
|
843
829
|
{
|
|
844
830
|
title: "Rollback Deployment",
|
|
845
|
-
annotations:
|
|
831
|
+
annotations: DESTRUCTIVE,
|
|
846
832
|
description: dedent2`
|
|
847
833
|
Roll the group back to the version that was live before the current one. Call lexq_groups_get first: currentVersionName is live now, previousVersionName is what a rollback restores, and a rollback is rejected while canRollback is false. A rollback needs a previous version that is still ACTIVE and whose effective end has not passed (P-058).
|
|
848
834
|
|
|
@@ -863,7 +849,7 @@ function registerDeployTools(server, callApi) {
|
|
|
863
849
|
"lexq_deploy_undeploy",
|
|
864
850
|
{
|
|
865
851
|
title: "Undeploy",
|
|
866
|
-
annotations:
|
|
852
|
+
annotations: DESTRUCTIVE,
|
|
867
853
|
description: "Remove the live version from traffic. The version stays ACTIVE but no longer serves requests.",
|
|
868
854
|
inputSchema: z6.object({
|
|
869
855
|
groupId: z6.string().uuid().describe("Policy group ID"),
|
|
@@ -878,7 +864,7 @@ function registerDeployTools(server, callApi) {
|
|
|
878
864
|
"lexq_deploy_schedule",
|
|
879
865
|
{
|
|
880
866
|
title: "Schedule Deployment",
|
|
881
|
-
annotations:
|
|
867
|
+
annotations: DESTRUCTIVE,
|
|
882
868
|
description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). The time is the version's effectiveFrom, set on the DRAFT before publishing; this call takes no time of its own. One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. The snapshot hash is sealed at scheduling and re-verified at execution (fail-closed).",
|
|
883
869
|
inputSchema: z6.object({
|
|
884
870
|
groupId: z6.string().uuid().describe("Policy group ID"),
|
|
@@ -894,7 +880,7 @@ function registerDeployTools(server, callApi) {
|
|
|
894
880
|
"lexq_deploy_unschedule",
|
|
895
881
|
{
|
|
896
882
|
title: "Cancel Scheduled Deployment",
|
|
897
|
-
annotations:
|
|
883
|
+
annotations: DESTRUCTIVE,
|
|
898
884
|
description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
|
|
899
885
|
inputSchema: z6.object({
|
|
900
886
|
groupId: z6.string().uuid().describe("Policy group ID")
|
|
@@ -906,7 +892,7 @@ function registerDeployTools(server, callApi) {
|
|
|
906
892
|
"lexq_deploy_schedules",
|
|
907
893
|
{
|
|
908
894
|
title: "List Scheduled Deployments",
|
|
909
|
-
annotations:
|
|
895
|
+
annotations: READ_ONLY,
|
|
910
896
|
description: `List scheduled deployments across all groups (all statuses: ${DeploymentScheduleStatus.join(", ")}), newest first.`,
|
|
911
897
|
inputSchema: z6.object({
|
|
912
898
|
page: z6.number().int().min(0).default(0).describe("Page number"),
|
|
@@ -919,7 +905,7 @@ function registerDeployTools(server, callApi) {
|
|
|
919
905
|
"lexq_deploy_history",
|
|
920
906
|
{
|
|
921
907
|
title: "Deployment History",
|
|
922
|
-
annotations:
|
|
908
|
+
annotations: READ_ONLY,
|
|
923
909
|
description: "List deployment history across all groups.",
|
|
924
910
|
inputSchema: z6.object({
|
|
925
911
|
page: z6.number().int().min(0).default(0).describe("Page number"),
|
|
@@ -943,7 +929,7 @@ function registerDeployTools(server, callApi) {
|
|
|
943
929
|
"lexq_deploy_detail",
|
|
944
930
|
{
|
|
945
931
|
title: "Deployment Detail",
|
|
946
|
-
annotations:
|
|
932
|
+
annotations: READ_ONLY,
|
|
947
933
|
description: "Get detailed info about a specific deployment including snapshot hash and integrity check.",
|
|
948
934
|
inputSchema: z6.object({
|
|
949
935
|
deploymentId: z6.string().uuid().describe("Deployment ID")
|
|
@@ -955,7 +941,7 @@ function registerDeployTools(server, callApi) {
|
|
|
955
941
|
"lexq_deploy_overview",
|
|
956
942
|
{
|
|
957
943
|
title: "Deployment Overview",
|
|
958
|
-
annotations:
|
|
944
|
+
annotations: READ_ONLY,
|
|
959
945
|
description: "Show current deployment status of all groups \u2014 which version is live, last deployment type, and deployer. Each group also carries currentDeploymentId, previousVersionName (the version a rollback restores) and canRollback, as in lexq_groups_get.",
|
|
960
946
|
inputSchema: z6.object({})
|
|
961
947
|
},
|
|
@@ -965,7 +951,7 @@ function registerDeployTools(server, callApi) {
|
|
|
965
951
|
"lexq_deploy_deployable",
|
|
966
952
|
{
|
|
967
953
|
title: "List Deployable Versions",
|
|
968
|
-
annotations:
|
|
954
|
+
annotations: READ_ONLY,
|
|
969
955
|
description: "List ACTIVE (published) versions that can be deployed for a group. Use this to find which versions are available before calling deploy live.",
|
|
970
956
|
inputSchema: z6.object({
|
|
971
957
|
groupId: z6.string().uuid().describe("Policy group ID")
|
|
@@ -977,7 +963,7 @@ function registerDeployTools(server, callApi) {
|
|
|
977
963
|
"lexq_deploy_diff",
|
|
978
964
|
{
|
|
979
965
|
title: "Deployment Diff",
|
|
980
|
-
annotations:
|
|
966
|
+
annotations: READ_ONLY,
|
|
981
967
|
description: "Compare rule snapshots between two versions. Shows added, removed, and modified rules. Useful for reviewing changes before deploying a new version.",
|
|
982
968
|
inputSchema: z6.object({
|
|
983
969
|
baseVersionId: z6.string().uuid().describe("Base version ID (typically the current live)"),
|
|
@@ -993,6 +979,11 @@ function registerDeployTools(server, callApi) {
|
|
|
993
979
|
// src/mcp/tools/analytics.ts
|
|
994
980
|
import { z as z7 } from "zod";
|
|
995
981
|
import dedent3 from "dedent";
|
|
982
|
+
|
|
983
|
+
// src/lib/dry-run-limit.ts
|
|
984
|
+
var DRY_RUN_LIMIT_NOTE = `Not billed and not counted toward the plan's usage. Dry runs, dry run compares, and single-decision replays share a limit of ${MAX_DRY_RUN_EVALUATIONS_PER_MINUTE} evaluations per minute for each organization; a compare counts as two. Past the limit, a call fails with 429 (C-007) until the next minute starts.`;
|
|
985
|
+
|
|
986
|
+
// src/mcp/tools/analytics.ts
|
|
996
987
|
function datasetBlock(path) {
|
|
997
988
|
const dataset = { type: "UPLOADED", source: "S3_BUCKET", path };
|
|
998
989
|
return {
|
|
@@ -1005,9 +996,9 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1005
996
|
"lexq_dry_run",
|
|
1006
997
|
{
|
|
1007
998
|
title: "Dry Run",
|
|
1008
|
-
annotations:
|
|
999
|
+
annotations: READ_ONLY,
|
|
1009
1000
|
description: dedent3`
|
|
1010
|
-
Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects.
|
|
1001
|
+
Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects. ${DRY_RUN_LIMIT_NOTE}
|
|
1011
1002
|
|
|
1012
1003
|
Returns:
|
|
1013
1004
|
inputFacts — normalized input facts
|
|
@@ -1016,8 +1007,7 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1016
1007
|
executionTraces — per-rule match status
|
|
1017
1008
|
decisionTraces — per-rule decision (SELECTED / NO_MATCH / BLOCKED / etc.)
|
|
1018
1009
|
|
|
1019
|
-
Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }
|
|
1020
|
-
Always dry-run before publishing to validate rule behavior.`,
|
|
1010
|
+
Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }`,
|
|
1021
1011
|
inputSchema: z7.object({
|
|
1022
1012
|
versionId: z7.string().uuid().describe("Policy version ID to test against"),
|
|
1023
1013
|
facts: z7.string().describe('JSON string of facts object, e.g. {"paymentAmount":100000}'),
|
|
@@ -1035,9 +1025,9 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1035
1025
|
"lexq_dry_run_compare",
|
|
1036
1026
|
{
|
|
1037
1027
|
title: "Dry Run Compare",
|
|
1038
|
-
annotations:
|
|
1028
|
+
annotations: READ_ONLY,
|
|
1039
1029
|
description: dedent3`
|
|
1040
|
-
Compare dry run results between two versions using the same input facts. Useful for validating changes.
|
|
1030
|
+
Compare dry run results between two versions using the same input facts. Useful for validating changes. ${DRY_RUN_LIMIT_NOTE}
|
|
1041
1031
|
|
|
1042
1032
|
Returns:
|
|
1043
1033
|
resultA / resultB — full DryRunResponse for each version
|
|
@@ -1060,7 +1050,7 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1060
1050
|
"lexq_requirements",
|
|
1061
1051
|
{
|
|
1062
1052
|
title: "Analyze Requirements",
|
|
1063
|
-
annotations:
|
|
1053
|
+
annotations: READ_ONLY,
|
|
1064
1054
|
description: "Analyze which input facts a version requires. Returns required keys, types, and an example request body.",
|
|
1065
1055
|
inputSchema: z7.object({
|
|
1066
1056
|
groupId: z7.string().uuid().describe("Policy group ID"),
|
|
@@ -1073,10 +1063,13 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1073
1063
|
"lexq_simulation_start",
|
|
1074
1064
|
{
|
|
1075
1065
|
title: "Start Simulation",
|
|
1076
|
-
annotations:
|
|
1066
|
+
annotations: DESTRUCTIVE,
|
|
1077
1067
|
description: dedent3`
|
|
1078
1068
|
Start an Impact Simulation against historical, uploaded, or inline data.
|
|
1079
1069
|
|
|
1070
|
+
Starting checks the plan's simulation usage limit. The records a simulation processes
|
|
1071
|
+
count toward that usage when it completes or is canceled.
|
|
1072
|
+
|
|
1080
1073
|
dataset.type and dataset.source are BOTH required, and must be paired:
|
|
1081
1074
|
HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
|
|
1082
1075
|
in the organization time zone, both inclusive)
|
|
@@ -1117,7 +1110,7 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1117
1110
|
"lexq_simulation_status",
|
|
1118
1111
|
{
|
|
1119
1112
|
title: "Simulation Status",
|
|
1120
|
-
annotations:
|
|
1113
|
+
annotations: READ_ONLY,
|
|
1121
1114
|
description: "Get simulation status and results. Poll until status is COMPLETED or FAILED.",
|
|
1122
1115
|
inputSchema: z7.object({
|
|
1123
1116
|
simulationId: z7.string().uuid().describe("Simulation ID")
|
|
@@ -1129,7 +1122,7 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1129
1122
|
"lexq_simulation_list",
|
|
1130
1123
|
{
|
|
1131
1124
|
title: "List Simulations",
|
|
1132
|
-
annotations:
|
|
1125
|
+
annotations: READ_ONLY,
|
|
1133
1126
|
description: "List simulation history with optional filters.",
|
|
1134
1127
|
inputSchema: z7.object({
|
|
1135
1128
|
page: z7.number().int().min(0).default(0).describe("Page number"),
|
|
@@ -1151,7 +1144,7 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1151
1144
|
"lexq_simulation_cancel",
|
|
1152
1145
|
{
|
|
1153
1146
|
title: "Cancel Simulation",
|
|
1154
|
-
annotations:
|
|
1147
|
+
annotations: DESTRUCTIVE,
|
|
1155
1148
|
description: "Cancel a running or pending simulation.",
|
|
1156
1149
|
inputSchema: z7.object({
|
|
1157
1150
|
simulationId: z7.string().uuid().describe("Simulation ID")
|
|
@@ -1163,22 +1156,25 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1163
1156
|
"lexq_simulation_export",
|
|
1164
1157
|
{
|
|
1165
1158
|
title: "Export Simulation",
|
|
1166
|
-
annotations:
|
|
1167
|
-
description:
|
|
1159
|
+
annotations: READ_ONLY,
|
|
1160
|
+
description: `Export simulation results as JSON or CSV. Returns the raw data. ${EXPORT_LIMIT_NOTE}`,
|
|
1168
1161
|
inputSchema: z7.object({
|
|
1169
1162
|
simulationId: z7.string().uuid().describe("Simulation ID"),
|
|
1170
1163
|
format: z7.enum(EXPORT_FORMATS).default("json").describe("Export format")
|
|
1171
1164
|
})
|
|
1172
1165
|
},
|
|
1173
|
-
async ({ simulationId, format }) =>
|
|
1174
|
-
|
|
1175
|
-
|
|
1166
|
+
async ({ simulationId, format }) => limitExport(
|
|
1167
|
+
await callApi("GET", `analytics/simulations/${simulationId}/export`, {
|
|
1168
|
+
params: { format }
|
|
1169
|
+
}),
|
|
1170
|
+
`lexq analytics simulation export --id ${simulationId} --as ${format} --output <file>`
|
|
1171
|
+
)
|
|
1176
1172
|
);
|
|
1177
1173
|
server.registerTool(
|
|
1178
1174
|
"lexq_dataset_upload",
|
|
1179
1175
|
{
|
|
1180
1176
|
title: "Upload Dataset",
|
|
1181
|
-
annotations:
|
|
1177
|
+
annotations: ADDITIVE,
|
|
1182
1178
|
description: dedent3`
|
|
1183
1179
|
Upload inline CSV or JSON content as a simulation dataset.
|
|
1184
1180
|
The content is uploaded to S3 and a path is returned in the "path" field.
|
|
@@ -1221,7 +1217,7 @@ function registerAnalyticsTools(server, callApi) {
|
|
|
1221
1217
|
"lexq_dataset_template",
|
|
1222
1218
|
{
|
|
1223
1219
|
title: "Download Dataset Template",
|
|
1224
|
-
annotations:
|
|
1220
|
+
annotations: READ_ONLY,
|
|
1225
1221
|
description: "Generate a sample CSV or JSON template based on the required facts of a version. Use this to understand the expected data format before uploading a dataset.",
|
|
1226
1222
|
inputSchema: z7.object({
|
|
1227
1223
|
groupId: z7.string().uuid().describe("Policy group ID"),
|
|
@@ -1260,7 +1256,7 @@ function registerProfileTools(server, callApi) {
|
|
|
1260
1256
|
"lexq_profile_overview",
|
|
1261
1257
|
{
|
|
1262
1258
|
title: "Group Latency Profile",
|
|
1263
|
-
annotations:
|
|
1259
|
+
annotations: READ_ONLY,
|
|
1264
1260
|
description: "Per-rule latency profile of a policy group over a time window: group TOTAL distribution split by cache state (HIT = compiled ruleset cache hit, MISS = deep-load + compile), a per-rule CONDITION/ACTION percentile table, and slow-rule flags. " + RELATIVE_THRESHOLD + ` Every percentile is accompanied by its sample count n; a percentile is withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 needs n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99} \u2014 display gate, separate from the n \u2265 ${MIN_SAMPLES} judgment gate). Baselines report INSUFFICIENT_COHORT when fewer than ${MIN_COHORT_SIZE} rules qualify. Rule detail comes from a deterministic ${PROFILE_SAMPLE_PERMILLE / 10}% sample of calls; TOTAL is recorded for every call. Defaults: last ${PROFILE_DEFAULT_WINDOW_HOURS}h, live version, cacheState HIT.`,
|
|
1265
1261
|
inputSchema: z8.object({
|
|
1266
1262
|
groupId: z8.string().uuid().describe("Policy group ID"),
|
|
@@ -1280,7 +1276,7 @@ function registerProfileTools(server, callApi) {
|
|
|
1280
1276
|
"lexq_profile_rule",
|
|
1281
1277
|
{
|
|
1282
1278
|
title: "Rule Latency Detail",
|
|
1283
|
-
annotations:
|
|
1279
|
+
annotations: READ_ONLY,
|
|
1284
1280
|
description: `Single-rule latency detail: merged phase \xD7 cacheState distributions plus a per-window time series (${LATENCY_WINDOW_MILLIS / 1e3}s windows). Missing windows are genuine gaps \u2014 never interpolated. Series points carry each window's own values; percentiles in merged distributions are withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99}). ` + RELATIVE_THRESHOLD,
|
|
1285
1281
|
inputSchema: z8.object({
|
|
1286
1282
|
groupId: z8.string().uuid().describe("Policy group ID"),
|
|
@@ -1305,8 +1301,8 @@ function registerReplayTools(server, callApi) {
|
|
|
1305
1301
|
"lexq_replay_decision",
|
|
1306
1302
|
{
|
|
1307
1303
|
title: "Replay a Decision",
|
|
1308
|
-
annotations:
|
|
1309
|
-
description:
|
|
1304
|
+
annotations: READ_ONLY,
|
|
1305
|
+
description: `Re-evaluate a past execution (traceId) against a candidate version and return the decision diff (decisionChanged, effect changes, fired rules) plus a determinism verdict. Synchronous. ${DRY_RUN_LIMIT_NOTE} A replay sends no webhook, notification, or event: rule actions produce no outward effects.`,
|
|
1310
1306
|
inputSchema: z9.object({
|
|
1311
1307
|
traceId: z9.string().describe("Trace ID of the past execution to replay"),
|
|
1312
1308
|
candidateVersionId: z9.string().uuid().describe("Version to re-evaluate against")
|
|
@@ -1318,7 +1314,7 @@ function registerReplayTools(server, callApi) {
|
|
|
1318
1314
|
"lexq_replay_start",
|
|
1319
1315
|
{
|
|
1320
1316
|
title: "Start Window Replay (Blast Radius)",
|
|
1321
|
-
annotations:
|
|
1317
|
+
annotations: DESTRUCTIVE,
|
|
1322
1318
|
description: "Submit an async job that replays a date window of past executions against a candidate version and measures the blast radius (how many decisions change). Billed per replayed record (REPLAY metric); VIEWER role cannot submit. Poll with lexq_replay_status.",
|
|
1323
1319
|
inputSchema: z9.object({
|
|
1324
1320
|
candidateVersionId: z9.string().uuid().describe("Version to re-evaluate against"),
|
|
@@ -1335,7 +1331,7 @@ function registerReplayTools(server, callApi) {
|
|
|
1335
1331
|
"lexq_replay_status",
|
|
1336
1332
|
{
|
|
1337
1333
|
title: "Get Replay Job Status",
|
|
1338
|
-
annotations:
|
|
1334
|
+
annotations: READ_ONLY,
|
|
1339
1335
|
description: "Poll a window replay job. RUNNING shows progress 0\u2013100; COMPLETED fills summary and changedSamples; FAILED carries errorMessage. capped=true means the window exceeded the sample cap and only part was replayed.",
|
|
1340
1336
|
inputSchema: z9.object({
|
|
1341
1337
|
jobId: z9.string().describe("Replay job ID from lexq_replay_start")
|
|
@@ -1347,7 +1343,7 @@ function registerReplayTools(server, callApi) {
|
|
|
1347
1343
|
"lexq_replay_list",
|
|
1348
1344
|
{
|
|
1349
1345
|
title: "List Replay Jobs",
|
|
1350
|
-
annotations:
|
|
1346
|
+
annotations: READ_ONLY,
|
|
1351
1347
|
description: "List window replay job history (reverse-chronological). Lightweight items \u2014 use lexq_replay_status for summary and changed samples.",
|
|
1352
1348
|
inputSchema: z9.object({
|
|
1353
1349
|
page: z9.number().int().min(0).default(0).describe("Page number"),
|
|
@@ -1360,7 +1356,7 @@ function registerReplayTools(server, callApi) {
|
|
|
1360
1356
|
"lexq_replay_cancel",
|
|
1361
1357
|
{
|
|
1362
1358
|
title: "Cancel Replay Job",
|
|
1363
|
-
annotations:
|
|
1359
|
+
annotations: DESTRUCTIVE,
|
|
1364
1360
|
description: "Cooperatively cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
|
|
1365
1361
|
inputSchema: z9.object({
|
|
1366
1362
|
jobId: z9.string().describe("Replay job ID")
|
|
@@ -1372,14 +1368,17 @@ function registerReplayTools(server, callApi) {
|
|
|
1372
1368
|
"lexq_replay_export",
|
|
1373
1369
|
{
|
|
1374
1370
|
title: "Export Replay Result",
|
|
1375
|
-
annotations:
|
|
1376
|
-
description:
|
|
1371
|
+
annotations: READ_ONLY,
|
|
1372
|
+
description: `Export a COMPLETED window replay job. A running job is rejected \u2014 a partial result reads as the whole thing on the receiving end. The two formats carry different things: CSV holds the effect blast radius, whose columns are fixed; JSON holds the changed samples and action parameters, which nest and whose keys differ per tenant. JSON is not a superset of CSV. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
|
|
1377
1373
|
inputSchema: z9.object({
|
|
1378
1374
|
jobId: z9.string().describe("Replay job ID"),
|
|
1379
1375
|
format: z9.enum(EXPORT_FORMATS).default("json").describe("Export format")
|
|
1380
1376
|
})
|
|
1381
1377
|
},
|
|
1382
|
-
async ({ jobId, format }) =>
|
|
1378
|
+
async ({ jobId, format }) => limitExport(
|
|
1379
|
+
await callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } }),
|
|
1380
|
+
`lexq replay export --id ${jobId} --as ${format} --output <file>`
|
|
1381
|
+
)
|
|
1383
1382
|
);
|
|
1384
1383
|
}
|
|
1385
1384
|
|
|
@@ -1390,7 +1389,7 @@ function registerHistoryTools(server, callApi) {
|
|
|
1390
1389
|
"lexq_history_list",
|
|
1391
1390
|
{
|
|
1392
1391
|
title: "List Execution History",
|
|
1393
|
-
annotations:
|
|
1392
|
+
annotations: READ_ONLY,
|
|
1394
1393
|
description: "List policy execution history. Shows trace ID, group, version, status, match result, and latency.",
|
|
1395
1394
|
inputSchema: z10.object({
|
|
1396
1395
|
page: z10.number().int().min(0).default(0).describe("Page number"),
|
|
@@ -1418,7 +1417,7 @@ function registerHistoryTools(server, callApi) {
|
|
|
1418
1417
|
"lexq_history_get",
|
|
1419
1418
|
{
|
|
1420
1419
|
title: "Get Execution Detail",
|
|
1421
|
-
annotations:
|
|
1420
|
+
annotations: READ_ONLY,
|
|
1422
1421
|
description: "Get full execution detail including inputFacts, mutatedFacts, generatedVariables, executionTraces, and decisionTraces.",
|
|
1423
1422
|
inputSchema: z10.object({
|
|
1424
1423
|
traceId: z10.string().describe("Trace ID from execution history")
|
|
@@ -1430,7 +1429,7 @@ function registerHistoryTools(server, callApi) {
|
|
|
1430
1429
|
"lexq_history_stats",
|
|
1431
1430
|
{
|
|
1432
1431
|
title: "Execution Statistics",
|
|
1433
|
-
annotations:
|
|
1432
|
+
annotations: READ_ONLY,
|
|
1434
1433
|
description: `Get execution KPIs: total executions, success/failure counts, success rate, and average latency. Without dates the window is the last ${STATS_DEFAULT_WINDOW_DAYS} days through today, in the organization time zone. At most ${STATS_MAX_WINDOW_DAYS} days per call; a longer range is rejected with AN-039. startDate alone runs through today.`,
|
|
1435
1434
|
inputSchema: z10.object({
|
|
1436
1435
|
groupId: z10.string().uuid().optional().describe("Filter by policy group"),
|
|
@@ -1455,7 +1454,7 @@ function registerProvenanceTools(server, callApi) {
|
|
|
1455
1454
|
"lexq_provenance_get",
|
|
1456
1455
|
{
|
|
1457
1456
|
title: "Get Decision Provenance",
|
|
1458
|
-
annotations:
|
|
1457
|
+
annotations: READ_ONLY,
|
|
1459
1458
|
description: "Get the lineage of a single decision: what was decided, deterministic why per rule, input facts (PII facts are masked as \u2022\u2022\u2022\u2022\u2022\u2022 with maskedKeys listing them \u2014 values are revealable only in the console, audited), the authored/published/deployed responsibility chain, and the rule snapshot fingerprint.",
|
|
1460
1459
|
inputSchema: z11.object({
|
|
1461
1460
|
traceId: z11.string().describe("Trace ID of the execution")
|
|
@@ -1467,7 +1466,7 @@ function registerProvenanceTools(server, callApi) {
|
|
|
1467
1466
|
"lexq_pii_reveals_list",
|
|
1468
1467
|
{
|
|
1469
1468
|
title: "List PII Reveal Audits",
|
|
1470
|
-
annotations:
|
|
1469
|
+
annotations: READ_ONLY,
|
|
1471
1470
|
description: "List the PII reveal audit ledger \u2014 who revealed which fact of which trace, and when. Metadata only; revealed values are never stored or returned. Use for monthly access-log inspection and SIEM collection.",
|
|
1472
1471
|
inputSchema: z11.object({
|
|
1473
1472
|
page: z11.number().int().min(0).default(0).describe("Page number"),
|
|
@@ -1493,24 +1492,13 @@ function registerProvenanceTools(server, callApi) {
|
|
|
1493
1492
|
|
|
1494
1493
|
// src/mcp/tools/logs.ts
|
|
1495
1494
|
import { z as z12 } from "zod";
|
|
1496
|
-
|
|
1497
|
-
// src/lib/enum-notes.ts
|
|
1498
|
-
function withNotes(notes) {
|
|
1499
|
-
return Object.entries(notes).map(([value, note]) => note ? `${value} (${note})` : value);
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
// src/mcp/tools/logs.ts
|
|
1503
|
-
var ACTION_NOTES = {
|
|
1504
|
-
RESOLVE: "mark as manually fixed",
|
|
1505
|
-
IGNORE: "skip intentionally"
|
|
1506
|
-
};
|
|
1507
1495
|
function registerLogTools(server, callApi) {
|
|
1508
1496
|
server.registerTool(
|
|
1509
1497
|
"lexq_logs_list",
|
|
1510
1498
|
{
|
|
1511
1499
|
title: "List Failure Logs",
|
|
1512
|
-
annotations:
|
|
1513
|
-
description: `List system failure logs from background tasks (platform event webhooks, scheduled deployments). Without dates the window is ${FAILURE_LOG_LOOKBACK_DAYS} days back through today, in the organization time zone; pass startDate to look further back
|
|
1500
|
+
annotations: READ_ONLY,
|
|
1501
|
+
description: `List system failure logs from background tasks (platform event webhooks, scheduled deployments). Without dates the window is ${FAILURE_LOG_LOOKBACK_DAYS} days back through today, in the organization time zone; pass startDate to look further back. ${FAILURE_LOG_CONSOLE_NOTE}`,
|
|
1514
1502
|
inputSchema: z12.object({
|
|
1515
1503
|
page: z12.number().int().min(0).default(0).describe("Page number"),
|
|
1516
1504
|
size: z12.number().int().min(1).max(100).default(20).describe("Page size"),
|
|
@@ -1535,48 +1523,25 @@ function registerLogTools(server, callApi) {
|
|
|
1535
1523
|
"lexq_logs_get",
|
|
1536
1524
|
{
|
|
1537
1525
|
title: "Get Failure Log",
|
|
1538
|
-
annotations:
|
|
1539
|
-
description:
|
|
1526
|
+
annotations: READ_ONLY,
|
|
1527
|
+
description: `Get failure log detail by ID. ${FAILURE_LOG_CONSOLE_NOTE}`,
|
|
1540
1528
|
inputSchema: z12.object({
|
|
1541
1529
|
logId: z12.string().uuid().describe("Failure log ID")
|
|
1542
1530
|
})
|
|
1543
1531
|
},
|
|
1544
1532
|
async ({ logId }) => callApi("GET", `failure-logs/${logId}`)
|
|
1545
1533
|
);
|
|
1546
|
-
server.registerTool(
|
|
1547
|
-
"lexq_logs_action",
|
|
1548
|
-
{
|
|
1549
|
-
title: "Process Failure Log",
|
|
1550
|
-
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1551
|
-
description: `Process a single failure log: ${withNotes(ACTION_NOTES).join(" or ")}.`,
|
|
1552
|
-
inputSchema: z12.object({
|
|
1553
|
-
logId: z12.string().uuid().describe("Failure log ID"),
|
|
1554
|
-
action: z12.enum(FailureAction).describe("Action to take")
|
|
1555
|
-
})
|
|
1556
|
-
},
|
|
1557
|
-
async ({ logId, action }) => callApi("POST", `failure-logs/${logId}/actions`, {
|
|
1558
|
-
params: { action }
|
|
1559
|
-
})
|
|
1560
|
-
);
|
|
1561
|
-
server.registerTool(
|
|
1562
|
-
"lexq_logs_bulk_action",
|
|
1563
|
-
{
|
|
1564
|
-
title: "Bulk Process Failure Logs",
|
|
1565
|
-
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1566
|
-
description: "Process multiple failure logs at once. Provide an array of log IDs and the action.",
|
|
1567
|
-
inputSchema: z12.object({
|
|
1568
|
-
logIds: z12.array(z12.string().uuid()).describe("Array of failure log IDs"),
|
|
1569
|
-
action: z12.enum(FailureAction).describe("Action to apply to all logs")
|
|
1570
|
-
})
|
|
1571
|
-
},
|
|
1572
|
-
async ({ logIds, action }) => callApi("POST", "failure-logs/bulk-actions", {
|
|
1573
|
-
body: { logIds, action }
|
|
1574
|
-
})
|
|
1575
|
-
);
|
|
1576
1534
|
}
|
|
1577
1535
|
|
|
1578
1536
|
// src/mcp/tools/webhook-subscriptions.ts
|
|
1579
1537
|
import { z as z13 } from "zod";
|
|
1538
|
+
|
|
1539
|
+
// src/lib/enum-notes.ts
|
|
1540
|
+
function withNotes(notes) {
|
|
1541
|
+
return Object.entries(notes).map(([value, note]) => note ? `${value} (${note})` : value);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// src/mcp/tools/webhook-subscriptions.ts
|
|
1580
1545
|
var FORMAT_NOTES = {
|
|
1581
1546
|
GENERIC: "full JSON",
|
|
1582
1547
|
SLACK: '{"text": "..."}'
|
|
@@ -1586,7 +1551,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
|
|
|
1586
1551
|
"lexq_webhook_subscriptions_list",
|
|
1587
1552
|
{
|
|
1588
1553
|
title: "List Webhook Subscriptions",
|
|
1589
|
-
annotations:
|
|
1554
|
+
annotations: READ_ONLY,
|
|
1590
1555
|
description: "List platform event webhook subscriptions. These receive deployment lifecycle notifications: publish, deploy, rollback, and undeploy, plus a deployment being scheduled or a schedule being canceled.",
|
|
1591
1556
|
inputSchema: z13.object({
|
|
1592
1557
|
page: z13.number().int().min(0).default(0).describe("Page number"),
|
|
@@ -1602,7 +1567,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
|
|
|
1602
1567
|
"lexq_webhook_subscriptions_get",
|
|
1603
1568
|
{
|
|
1604
1569
|
title: "Get Webhook Subscription",
|
|
1605
|
-
annotations:
|
|
1570
|
+
annotations: READ_ONLY,
|
|
1606
1571
|
description: "Get webhook subscription detail by ID.",
|
|
1607
1572
|
inputSchema: z13.object({
|
|
1608
1573
|
id: z13.string().uuid().describe("Webhook subscription ID")
|
|
@@ -1614,7 +1579,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
|
|
|
1614
1579
|
"lexq_webhook_subscriptions_save",
|
|
1615
1580
|
{
|
|
1616
1581
|
title: "Save Webhook Subscription",
|
|
1617
|
-
annotations: {
|
|
1582
|
+
annotations: { ...DESTRUCTIVE, openWorldHint: true },
|
|
1618
1583
|
description: `Create or update a webhook subscription. Omit id to create, provide id to update. Events: ${PlatformEventType.join(", ")}. Formats: ${withNotes(FORMAT_NOTES).join(", ")}.`,
|
|
1619
1584
|
inputSchema: z13.object({
|
|
1620
1585
|
id: z13.string().uuid().optional().describe("Subscription ID (omit to create, provide to update)"),
|
|
@@ -1632,7 +1597,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
|
|
|
1632
1597
|
"lexq_webhook_subscriptions_delete",
|
|
1633
1598
|
{
|
|
1634
1599
|
title: "Delete Webhook Subscription",
|
|
1635
|
-
annotations:
|
|
1600
|
+
annotations: DESTRUCTIVE,
|
|
1636
1601
|
description: "Delete a webhook subscription by ID.",
|
|
1637
1602
|
inputSchema: z13.object({
|
|
1638
1603
|
id: z13.string().uuid().describe("Webhook subscription ID")
|
|
@@ -1644,7 +1609,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
|
|
|
1644
1609
|
"lexq_webhook_subscriptions_test",
|
|
1645
1610
|
{
|
|
1646
1611
|
title: "Test Webhook Subscription",
|
|
1647
|
-
annotations: {
|
|
1612
|
+
annotations: { ...DESTRUCTIVE, openWorldHint: true },
|
|
1648
1613
|
description: "Send a test event to verify webhook connectivity. Returns the HTTP status code and success/failure message.",
|
|
1649
1614
|
inputSchema: z13.object({
|
|
1650
1615
|
id: z13.string().uuid().describe("Webhook subscription ID")
|
|
@@ -1661,7 +1626,7 @@ function registerDomainTemplateTools(server, callApi) {
|
|
|
1661
1626
|
"lexq_domain_templates_list",
|
|
1662
1627
|
{
|
|
1663
1628
|
title: "List Domain Templates",
|
|
1664
|
-
annotations:
|
|
1629
|
+
annotations: READ_ONLY,
|
|
1665
1630
|
description: "List all domain templates. A domain template is a starter pack of fact definitions and sample rules for a common decision, such as checkout pricing (ECOMMERCE) or transfer approval (FINTECH). Each entry reports its key, whether it can be applied (isAvailable), and how many facts and sample rules it provisions. Call this before preview or apply to see which templates exist.",
|
|
1666
1631
|
inputSchema: z14.object({})
|
|
1667
1632
|
},
|
|
@@ -1671,7 +1636,7 @@ function registerDomainTemplateTools(server, callApi) {
|
|
|
1671
1636
|
"lexq_domain_templates_preview",
|
|
1672
1637
|
{
|
|
1673
1638
|
title: "Preview Domain Template",
|
|
1674
|
-
annotations:
|
|
1639
|
+
annotations: READ_ONLY,
|
|
1675
1640
|
description: "Preview exactly what a domain template will provision before applying it: the fact definitions it registers (an input with a value domain carries its allowed values or range), the sample rules it creates with a description of each, and an apply plan. This is a read-only dry run; nothing is created. factConflicts lists keys this tenant already has with a different type, or with different allowed values or a different range on an input fact. While it is not empty, apply fails with P-061 and creates nothing: delete those facts or change them to match the template, then apply. plan is null for a template that cannot be applied yet.",
|
|
1676
1641
|
inputSchema: z14.object({
|
|
1677
1642
|
template: z14.string().describe(
|
|
@@ -1685,7 +1650,7 @@ function registerDomainTemplateTools(server, callApi) {
|
|
|
1685
1650
|
"lexq_domain_templates_apply",
|
|
1686
1651
|
{
|
|
1687
1652
|
title: "Apply Domain Template",
|
|
1688
|
-
annotations:
|
|
1653
|
+
annotations: ADDITIVE,
|
|
1689
1654
|
description: "Apply a domain template to the current tenant. Creates the template's fact definitions and a new policy group pre-populated with its sample rules as a DRAFT version. Apply never overwrites existing schema: a key that already exists with the same definition is kept as it is. Run lexq_domain_templates_preview first. If its factConflicts is not empty, apply fails with P-061 and creates nothing until those facts are deleted or changed to match the template. A template whose isAvailable is false cannot be applied.",
|
|
1690
1655
|
inputSchema: z14.object({
|
|
1691
1656
|
template: z14.string().describe("Domain template key to apply (e.g. ECOMMERCE)."),
|
|
@@ -1720,10 +1685,18 @@ function registerAllTools(server, callApi) {
|
|
|
1720
1685
|
registerWebhookSubscriptionTools(server, callApi);
|
|
1721
1686
|
}
|
|
1722
1687
|
var SERVER_INFO = { name: "lexq", ...server_info_default };
|
|
1688
|
+
var SERVER_INSTRUCTIONS = [
|
|
1689
|
+
"LexQ evaluates business rules: a policy group holds versions, a version holds rules, and rules read input facts.",
|
|
1690
|
+
"- Date filters (yyyy-MM-dd) are whole days in the organization's time zone, not UTC. Call lexq_whoami for tenantTimezone before working out the current date or a date range.",
|
|
1691
|
+
"- Before creating rules, call lexq_facts_list to see which facts exist and their types. When a rule needs a fact that is not defined, confirm its type and description with the user before calling lexq_facts_create.",
|
|
1692
|
+
"- Before lexq_deploy_publish, run lexq_dry_run on the version with representative facts. A published version cannot go back to draft.",
|
|
1693
|
+
`- For anything these tools cannot do, point the user to the LexQ console at ${CONSOLE_URL}.`
|
|
1694
|
+
].join("\n");
|
|
1723
1695
|
export {
|
|
1724
1696
|
ApiError,
|
|
1725
1697
|
LosslessNumber,
|
|
1726
1698
|
SERVER_INFO,
|
|
1699
|
+
SERVER_INSTRUCTIONS,
|
|
1727
1700
|
formatUnregisteredFactWarning,
|
|
1728
1701
|
formatValueDomainMismatchWarning,
|
|
1729
1702
|
isLosslessNumber,
|