@lexq/cli 1.1.7 → 2.1.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/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Command } from "commander";
5
5
  import { readFileSync as readFileSync4 } from "fs";
6
6
  import { fileURLToPath as fileURLToPath2 } from "url";
7
- import { dirname as dirname2, join as join3 } from "path";
7
+ import { dirname as dirname2, join as join4 } from "path";
8
8
 
9
9
  // src/commands/auth.ts
10
10
  import "commander";
@@ -85,6 +85,11 @@ var ApiError = class extends Error {
85
85
  statusCode;
86
86
  errorCode;
87
87
  };
88
+ function describeForm(form) {
89
+ return [...form.entries()].map(
90
+ ([name, value]) => typeof value === "string" ? ` ${name}: ${value}` : ` ${name}: ${value.name} (${value.size} bytes, ${value.type})`
91
+ );
92
+ }
88
93
  async function doFetch(method, path, options) {
89
94
  const config = loadConfig();
90
95
  const baseUrl = options.baseUrl ?? config.baseUrl;
@@ -99,26 +104,32 @@ async function doFetch(method, path, options) {
99
104
  }
100
105
  }
101
106
  const headers = { "X-API-KEY": apiKey, Accept: "application/json" };
102
- if (options.body) headers["Content-Type"] = "application/json";
107
+ if (options.body && !options.form) headers["Content-Type"] = "application/json";
103
108
  if (options.dryRun) {
104
109
  const masked = apiKey.length > 8 ? apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length - 4) : "****";
105
110
  console.log(`${method} ${url.toString()}`);
106
111
  console.log("Headers:");
107
112
  console.log(` X-API-KEY: ${masked}`);
108
- console.log(` Content-Type: application/json`);
109
- if (options.body) {
113
+ console.log(` Content-Type: ${options.form ? "multipart/form-data" : "application/json"}`);
114
+ if (options.form) {
115
+ console.log("Body:");
116
+ for (const line of describeForm(options.form)) console.log(line);
117
+ } else if (options.body) {
110
118
  console.log("Body:");
111
119
  console.log(` ${stringifyJson(options.body, 2) ?? ""}`);
112
120
  }
113
121
  console.log("\n(Use without --dry-run to execute)");
114
122
  process.exit(0);
115
123
  }
116
- if (options.verbose) console.error(`\u2192 ${method} ${url.toString()}`);
124
+ if (options.verbose) {
125
+ console.error(`\u2192 ${method} ${url.toString()}`);
126
+ if (options.form) for (const line of describeForm(options.form)) console.error(line);
127
+ }
117
128
  const startTime = Date.now();
118
129
  const response = await fetch(url.toString(), {
119
130
  method,
120
131
  headers,
121
- body: options.body ? stringifyJson(options.body) : void 0
132
+ body: options.form ?? (options.body ? stringifyJson(options.body) : void 0)
122
133
  });
123
134
  if (options.verbose) {
124
135
  console.error(`\u2190 ${response.status} ${response.statusText} (${Date.now() - startTime}ms)`);
@@ -132,7 +143,7 @@ function isFileResponse(response) {
132
143
  return contentType.includes("text/csv") || contentType.includes("application/octet-stream");
133
144
  }
134
145
  function assertOk(response, json) {
135
- if (!response.ok || json.result !== "SUCCESS") {
146
+ if (json.result !== "SUCCESS") {
136
147
  throw new ApiError(
137
148
  response.status,
138
149
  json.errorCode,
@@ -140,12 +151,25 @@ function assertOk(response, json) {
140
151
  );
141
152
  }
142
153
  }
154
+ async function refusal(response) {
155
+ let json;
156
+ try {
157
+ json = parseJson(await response.text());
158
+ } catch {
159
+ }
160
+ return new ApiError(
161
+ response.status,
162
+ json?.errorCode ?? null,
163
+ json?.message ?? `Request failed with status ${response.status}`
164
+ );
165
+ }
143
166
  async function apiRequest(method, path, options = {}) {
144
167
  const { data } = await apiRequestWithMeta(method, path, options);
145
168
  return data;
146
169
  }
147
170
  async function apiRequestWithMeta(method, path, options = {}) {
148
171
  const response = await doFetch(method, path, options);
172
+ if (!response.ok) throw await refusal(response);
149
173
  if (isFileResponse(response)) {
150
174
  return { data: await response.text(), meta: null };
151
175
  }
@@ -349,6 +373,7 @@ var MAX_TRAFFIC_RATE = 99;
349
373
  var REPLAY_WINDOW_MAX_RECORDS = 5e4;
350
374
  var DEFAULT_SIMULATION_RECORDS = 1e3;
351
375
  var MAX_SIMULATION_RECORDS = 1e5;
376
+ var MAX_DATASET_SIZE = 10485760;
352
377
  var PROFILE_SAMPLE_PERMILLE = 10;
353
378
  var LATENCY_WINDOW_MILLIS = 6e4;
354
379
  var TAIL_MIN_OBS = 3;
@@ -1773,7 +1798,13 @@ function registerDeployCommands(program) {
1773
1798
  process.exit(1);
1774
1799
  }
1775
1800
  });
1776
- deploy.command("rollback").description("Rollback to the previous deployed version").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption("--memo <memo>", "Rollback reason").option("--force", "Skip confirmation prompt").addHelpText(
1801
+ deploy.command("rollback").description("Rollback to the previous deployed version").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption("--memo <memo>", "Rollback reason").option(
1802
+ "--expected-current-version-id <versionId>",
1803
+ "currentVersionId from your last read of the group"
1804
+ ).option(
1805
+ "--expected-current-deployment-id <deploymentId>",
1806
+ "currentDeploymentId from your last read of the group"
1807
+ ).option("--force", "Skip confirmation prompt").addHelpText(
1777
1808
  "after",
1778
1809
  dedent7`
1779
1810
 
@@ -1781,8 +1812,20 @@ function registerDeployCommands(program) {
1781
1812
  Only available if the previous version is still ACTIVE and its effective end
1782
1813
  has not passed (P-058).
1783
1814
 
1815
+ Run lexq groups get first: currentVersionName is live now and
1816
+ previousVersionName is what a rollback restores. Pass the group's
1817
+ currentVersionId and currentDeploymentId with the two --expected-* options.
1818
+ The server checks only the values you pass. If anyone deployed, rolled back
1819
+ or undeployed in between, the rollback is rejected with 409 and nothing
1820
+ changes: P-062 if another version or no version is live, P-063 if the same
1821
+ version is live but from a newer deployment. Read the group again before
1822
+ retrying.
1823
+
1784
1824
  Example:
1785
- $ lexq deploy rollback --group-id <gid> --memo "High error rate" --force
1825
+ $ lexq groups get --id <gid>
1826
+ $ lexq deploy rollback --group-id <gid> --memo "High error rate" \
1827
+ --expected-current-version-id <currentVersionId> \
1828
+ --expected-current-deployment-id <currentDeploymentId> --force
1786
1829
  `
1787
1830
  ).action(async (opts) => {
1788
1831
  try {
@@ -1802,7 +1845,11 @@ function registerDeployCommands(program) {
1802
1845
  baseUrl: globalOpts.baseUrl,
1803
1846
  dryRun: globalOpts.dryRun,
1804
1847
  verbose: globalOpts.verbose,
1805
- body: { memo: opts.memo }
1848
+ body: {
1849
+ memo: opts.memo,
1850
+ expectedCurrentVersionId: opts.expectedCurrentVersionId,
1851
+ expectedCurrentDeploymentId: opts.expectedCurrentDeploymentId
1852
+ }
1806
1853
  });
1807
1854
  console.log(`\u2713 Group ${opts.groupId} rolled back.`);
1808
1855
  } catch (error) {
@@ -2135,8 +2182,30 @@ async function warnUnregisteredFacts(globalOpts, groupId, versionId) {
2135
2182
 
2136
2183
  // src/commands/analytics.ts
2137
2184
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
2185
+ import { basename } from "path";
2138
2186
  import "commander";
2139
2187
  import dedent8 from "dedent";
2188
+
2189
+ // src/lib/dataset-upload.ts
2190
+ import { extname } from "path";
2191
+ var DATASET_CONTENT_TYPES = {
2192
+ ".csv": "text/csv",
2193
+ ".json": "application/json"
2194
+ };
2195
+ function datasetContentType(filename) {
2196
+ return DATASET_CONTENT_TYPES[extname(filename).toLowerCase()];
2197
+ }
2198
+ function uploadDataset(bytes, filename, options) {
2199
+ const form = new FormData();
2200
+ const type = datasetContentType(filename) ?? "application/octet-stream";
2201
+ form.append("file", new Blob([bytes], { type }), filename);
2202
+ return apiRequest("POST", "analytics/datasets/upload", {
2203
+ ...options,
2204
+ form
2205
+ });
2206
+ }
2207
+
2208
+ // src/commands/analytics.ts
2140
2209
  function registerAnalyticsCommands(program) {
2141
2210
  const analytics = program.command("analytics").description("Dry run, simulation, and requirements").addHelpText(
2142
2211
  "after",
@@ -2159,7 +2228,7 @@ function registerAnalyticsCommands(program) {
2159
2228
  dedent8`
2160
2229
 
2161
2230
  Examples:
2162
- $ lexq analytics dry-run --version-id <vid> --debug \\
2231
+ $ lexq analytics dry-run --version-id <vid> --debug \
2163
2232
  --json '{"facts": {"paymentAmount": 150000, "customerTier": "VIP"}}'
2164
2233
 
2165
2234
  $ lexq analytics dry-run --version-id <vid> --file test-input.json
@@ -2555,41 +2624,13 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2555
2624
  ).action(async (opts) => {
2556
2625
  try {
2557
2626
  const globalOpts = program.opts();
2558
- const config = loadConfig();
2559
- const baseUrl = globalOpts.baseUrl ?? config.baseUrl;
2560
- const apiKey = globalOpts.apiKey ?? config.apiKey;
2561
- if (!apiKey) {
2562
- throw new Error('Not authenticated. Run "lexq auth login" first.');
2563
- }
2564
2627
  const filePath = opts.file;
2565
- const fileBuffer = readFileSync2(filePath);
2566
- const fileName = filePath.split("/").pop() ?? "dataset";
2567
- const ext = fileName.split(".").pop()?.toLowerCase();
2568
- let contentType = "application/octet-stream";
2569
- if (ext === "csv") contentType = "text/csv";
2570
- else if (ext === "json") contentType = "application/json";
2571
- const blob = new Blob([fileBuffer], { type: contentType });
2572
- const formData = new FormData();
2573
- formData.append("file", blob, fileName);
2574
- const url = new URL(
2575
- "analytics/datasets/upload",
2576
- baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"
2577
- );
2578
- if (globalOpts.verbose) {
2579
- console.error(`\u2192 POST ${url.toString()}`);
2580
- console.error(` File: ${filePath} (${fileBuffer.length} bytes)`);
2581
- }
2582
- const response = await fetch(url.toString(), {
2583
- method: "POST",
2584
- headers: { "X-API-KEY": apiKey },
2585
- body: formData
2628
+ const result = await uploadDataset(readFileSync2(filePath), basename(filePath), {
2629
+ apiKey: globalOpts.apiKey,
2630
+ baseUrl: globalOpts.baseUrl,
2631
+ dryRun: globalOpts.dryRun,
2632
+ verbose: globalOpts.verbose
2586
2633
  });
2587
- if (!response.ok) {
2588
- const errorText = await response.text();
2589
- throw new Error(`Upload failed (${response.status}): ${errorText}`);
2590
- }
2591
- const envelope = await response.json();
2592
- const result = envelope.data;
2593
2634
  console.log(`\u2713 Dataset uploaded: ${result.path}`);
2594
2635
  console.log(` File: ${result.filename} (${result.size} bytes)`);
2595
2636
  printJson(result);
@@ -3526,56 +3567,36 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3526
3567
 
3527
3568
  // src/commands/serve.ts
3528
3569
  import "commander";
3529
- import dedent17 from "dedent";
3570
+ import dedent19 from "dedent";
3530
3571
 
3531
3572
  // src/mcp/server.ts
3532
3573
  import { readFileSync as readFileSync3 } from "fs";
3533
- import { dirname, join as join2 } from "path";
3574
+ import { dirname, join as join3 } from "path";
3534
3575
  import { fileURLToPath } from "url";
3535
3576
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
3536
3577
  import { McpServer } from "@modelcontextprotocol/server";
3537
3578
 
3538
3579
  // src/mcp/tools/_shared.ts
3580
+ function localApiOptions() {
3581
+ const stored = loadConfig();
3582
+ return {
3583
+ baseUrl: process.env.PARTNER_BASE_URL ?? stored.baseUrl,
3584
+ apiKey: process.env.LEXQ_API_KEY ?? stored.apiKey
3585
+ };
3586
+ }
3539
3587
  function createCallApiFromConfig() {
3540
3588
  return async (method, path, opts) => {
3541
3589
  try {
3542
- const stored = loadConfig();
3543
- const config = {
3544
- ...stored,
3545
- baseUrl: process.env.PARTNER_BASE_URL ?? stored.baseUrl,
3546
- apiKey: process.env.LEXQ_API_KEY ?? stored.apiKey
3547
- };
3590
+ let form;
3548
3591
  if (opts?.upload) {
3549
- const url = new URL(
3550
- path,
3551
- config.baseUrl.endsWith("/") ? config.baseUrl : config.baseUrl + "/"
3552
- );
3553
- if (opts.params) {
3554
- for (const [key, value] of Object.entries(opts.params)) {
3555
- if (value !== void 0) url.searchParams.set(key, value);
3556
- }
3557
- }
3558
- if (!config.apiKey) {
3559
- throw new Error('Not authenticated. Run "lexq auth login" first.');
3560
- }
3592
+ form = new FormData();
3561
3593
  const blob = new Blob([opts.upload.content], { type: "text/plain" });
3562
- const formData = new FormData();
3563
- formData.append(opts.upload.fieldName, blob, opts.upload.filename);
3564
- const response = await fetch(url.toString(), {
3565
- method,
3566
- headers: { "X-API-KEY": config.apiKey },
3567
- body: formData
3568
- });
3569
- const data2 = parseJson(await response.text());
3570
- return { content: [{ type: "text", text: stringifyJson(data2, 2) ?? "" }] };
3594
+ form.append(opts.upload.fieldName, blob, opts.upload.filename);
3571
3595
  }
3572
- const clientOpts = {
3573
- apiKey: config.apiKey,
3574
- baseUrl: config.baseUrl
3575
- };
3576
3596
  const { data, meta } = await apiRequestWithMeta(method, path, {
3577
- ...clientOpts,
3597
+ ...localApiOptions(),
3578
3598
  body: opts?.body,
3599
+ form,
3579
3600
  params: opts?.params
3580
3601
  });
3581
3602
  const content = [
@@ -3618,181 +3639,514 @@ function formatUnregisteredFactWarning(meta) {
3618
3639
  ].join("\n");
3619
3640
  }
3620
3641
 
3621
- // src/mcp/server-info.json
3622
- var server_info_default = {
3623
- title: "LexQ",
3624
- description: "The Decision Operations Platform for engineering teams",
3625
- websiteUrl: "https://lexq.io",
3626
- icons: [
3627
- {
3628
- src: "https://lexq.io/logo.png",
3629
- mimeType: "image/png",
3630
- sizes: ["1024x1024"]
3631
- }
3632
- ]
3633
- };
3642
+ // src/mcp/local-tools.ts
3643
+ import { readFile, realpath, stat } from "fs/promises";
3644
+ import { homedir as homedir2 } from "os";
3645
+ import { basename as basename2, isAbsolute, join as join2 } from "path";
3646
+ import { z as z2 } from "zod";
3647
+ import dedent16 from "dedent";
3634
3648
 
3635
- // src/mcp/tools/status.ts
3649
+ // src/mcp/tools/analytics.ts
3636
3650
  import { z } from "zod";
3637
- function registerStatusTools(server, callApi) {
3651
+ import dedent15 from "dedent";
3652
+ function datasetBlock(path) {
3653
+ const dataset = { type: "UPLOADED", source: "S3_BUCKET", path };
3654
+ return {
3655
+ type: "text",
3656
+ text: "Ready-to-use dataset block for lexq_simulation_start:\n" + stringifyJson({ dataset }, 2)
3657
+ };
3658
+ }
3659
+ function registerAnalyticsTools(server, callApi) {
3638
3660
  server.registerTool(
3639
- "lexq_whoami",
3661
+ "lexq_dry_run",
3640
3662
  {
3641
- title: "Who Am I",
3663
+ title: "Dry Run",
3642
3664
  annotations: { readOnlyHint: true },
3643
- 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.',
3644
- inputSchema: z.object({})
3665
+ description: dedent15`
3666
+ Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects.
3667
+
3668
+ Returns:
3669
+ inputFacts — normalized input facts
3670
+ mutatedFacts — input facts changed by rule actions (e.g. MUTATE_FACT mutates paymentAmount)
3671
+ generatedVariables — system-generated values; every fact in mutatedFacts gets a paired {factName}__delta key (signed difference)
3672
+ executionTraces — per-rule match status
3673
+ decisionTraces — per-rule decision (SELECTED / NO_MATCH / BLOCKED / etc.)
3674
+
3675
+ Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }
3676
+ Always dry-run before publishing to validate rule behavior.`,
3677
+ inputSchema: z.object({
3678
+ versionId: z.string().uuid().describe("Policy version ID to test against"),
3679
+ facts: z.string().describe('JSON string of facts object, e.g. {"paymentAmount":100000}'),
3680
+ includeDebugInfo: z.boolean().default(true).describe("Include execution and decision traces")
3681
+ })
3645
3682
  },
3646
- async () => callApi("GET", "whoami")
3683
+ async ({ versionId, facts, includeDebugInfo }) => {
3684
+ const parsedFacts = parseJson(facts);
3685
+ return callApi("POST", `analytics/dry-run/versions/${versionId}`, {
3686
+ body: { facts: parsedFacts, includeDebugInfo }
3687
+ });
3688
+ }
3647
3689
  );
3648
- }
3649
-
3650
- // src/mcp/tools/groups.ts
3651
- import { z as z2 } from "zod";
3652
- function registerGroupTools(server, callApi) {
3653
3690
  server.registerTool(
3654
- "lexq_groups_list",
3691
+ "lexq_dry_run_compare",
3655
3692
  {
3656
- title: "List Policy Groups",
3693
+ title: "Dry Run Compare",
3657
3694
  annotations: { readOnlyHint: true },
3658
- description: "List policy groups (tenant-wide, priority ASC). Deleted groups are not included.",
3659
- inputSchema: z2.object({})
3695
+ description: dedent15`
3696
+ Compare dry run results between two versions using the same input facts. Useful for validating changes.
3697
+
3698
+ Returns:
3699
+ resultA / resultB — full DryRunResponse for each version
3700
+ diff.mutatedDiff — changes in mutatedFacts between A and B (key → {before, after})
3701
+ diff.generatedDiff — changes in generatedVariables between A and B`,
3702
+ inputSchema: z.object({
3703
+ versionIdA: z.string().uuid().describe("Baseline version ID"),
3704
+ versionIdB: z.string().uuid().describe("Candidate version ID"),
3705
+ facts: z.string().describe("JSON string of facts object")
3706
+ })
3660
3707
  },
3661
- async () => callApi("GET", "policy-groups")
3708
+ async ({ versionIdA, versionIdB, facts }) => {
3709
+ const parsedFacts = parseJson(facts);
3710
+ return callApi("POST", "analytics/dry-run/compare", {
3711
+ body: { versionIdA, versionIdB, facts: parsedFacts }
3712
+ });
3713
+ }
3662
3714
  );
3663
3715
  server.registerTool(
3664
- "lexq_groups_get",
3716
+ "lexq_requirements",
3665
3717
  {
3666
- title: "Get Policy Group",
3718
+ title: "Analyze Requirements",
3667
3719
  annotations: { readOnlyHint: true },
3668
- description: "Get a single policy group by ID.",
3669
- inputSchema: z2.object({
3670
- groupId: z2.string().uuid().describe("Policy group ID")
3720
+ description: "Analyze which input facts a version requires. Returns required keys, types, and an example request body.",
3721
+ inputSchema: z.object({
3722
+ groupId: z.string().uuid().describe("Policy group ID"),
3723
+ versionId: z.string().uuid().describe("Version ID")
3671
3724
  })
3672
3725
  },
3673
- async ({ groupId }) => callApi("GET", `policy-groups/${groupId}`)
3726
+ async ({ groupId, versionId }) => callApi("GET", `analytics/groups/${groupId}/versions/${versionId}/requirements`)
3674
3727
  );
3675
3728
  server.registerTool(
3676
- "lexq_groups_create",
3729
+ "lexq_simulation_start",
3677
3730
  {
3678
- title: "Create Policy Group",
3731
+ title: "Start Simulation",
3679
3732
  annotations: { readOnlyHint: false, destructiveHint: false },
3680
- 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.`,
3681
- inputSchema: z2.object({
3682
- name: z2.string().describe("Group name (unique among non-ARCHIVED)"),
3683
- description: z2.string().optional().describe("Group description"),
3684
- activationMode: z2.enum(ConflictResolutionMode).optional().describe("Conflict resolution mode"),
3685
- activationStrategy: z2.enum(ConflictResolutionStrategy).optional().describe("Ranking used to pick the winning groups when mode is EXCLUSIVE or MAX_N"),
3686
- executionLimit: z2.number().int().min(1).optional().describe(
3687
- "How many policy groups sharing this activationGroup may run (required when mode is MAX_N). Counts groups, not rules: every rule of a winning group runs."
3688
- ),
3689
- activationGroup: z2.string().optional().describe(
3690
- "Activation group (Execution Group) cluster key. Policy groups sharing this key compete, and group priority picks the winners."
3691
- )
3733
+ description: dedent15`
3734
+ Start an Impact Simulation against historical, uploaded, or inline data.
3735
+
3736
+ dataset.type and dataset.source are BOTH required, and must be paired:
3737
+ HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
3738
+ in the organization time zone, both inclusive)
3739
+ UPLOADED → source S3_BUCKET, with dataset.path (the path returned by lexq_dataset_upload)
3740
+ MANUAL → source REQUEST_BODY, with dataset.manualData (array of fact records)
3741
+
3742
+ options.maxRecords: number (max ${MAX_SIMULATION_RECORDS}). When omitted, HISTORICAL and UPLOADED run at
3743
+ most ${DEFAULT_SIMULATION_RECORDS} records and MANUAL runs every record sent.
3744
+ options.baselinePolicyVersionId: uuid (optional, for baseline comparison)
3745
+ options.includeRuleStats: boolean
3746
+ options.metricConfig: optional — omit for plain execution count. To aggregate a fact, pass
3747
+ { "targetVariable": "<fact>", "aggregationType": "COUNT" | "SUM" | "AVG" }
3748
+
3749
+ Example (uploaded dataset):
3750
+ {
3751
+ "policyVersionId": "<uuid>",
3752
+ "dataset": { "type": "UPLOADED", "source": "S3_BUCKET", "path": "<path from lexq_dataset_upload>" },
3753
+ "options": { "baselinePolicyVersionId": "<uuid>", "includeRuleStats": true, "maxRecords": 10000 }
3754
+ }
3755
+
3756
+ Example (historical):
3757
+ {
3758
+ "policyVersionId": "<uuid>",
3759
+ "dataset": { "type": "HISTORICAL", "source": "EXECUTION_LOGS", "from": "2026-01-01", "to": "2026-01-31" },
3760
+ "options": { "baselinePolicyVersionId": "<uuid>", "includeRuleStats": true }
3761
+ }
3762
+ `,
3763
+ inputSchema: z.object({
3764
+ body: z.string().describe("JSON string of SimulationRequest")
3692
3765
  })
3693
3766
  },
3694
- async (args) => {
3695
- const body = {
3696
- name: args.name
3697
- };
3698
- if (args.description !== void 0) body.description = args.description;
3699
- if (args.activationMode !== void 0) body.activationMode = args.activationMode;
3700
- if (args.activationStrategy !== void 0) body.activationStrategy = args.activationStrategy;
3701
- if (args.executionLimit !== void 0) body.executionLimit = args.executionLimit;
3702
- if (args.activationGroup !== void 0) body.activationGroup = args.activationGroup;
3703
- return callApi("POST", "policy-groups", { body });
3767
+ async ({ body }) => {
3768
+ const parsed = parseJson(body);
3769
+ return callApi("POST", "analytics/simulations", { body: parsed });
3704
3770
  }
3705
3771
  );
3706
3772
  server.registerTool(
3707
- "lexq_groups_update",
3708
- {
3709
- title: "Update Policy Group",
3710
- annotations: { readOnlyHint: false, destructiveHint: true },
3711
- description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged. A deleted group cannot be updated (409 P-059).",
3712
- inputSchema: z2.object({
3713
- groupId: z2.string().uuid().describe("Policy group ID"),
3714
- name: z2.string().optional().describe("New name"),
3715
- description: z2.string().optional().describe("New description"),
3716
- status: z2.enum(["ACTIVE", "DISABLED"]).optional().describe(
3717
- "DISABLED stops production execution of this group: execution calls return 403 (P-009), and a composite call that includes the group is rejected in full. The deployed version and any A/B test are kept, and dry run, Impact Simulation, and Decision Replay keep working, so a fix can be verified before setting ACTIVE again. To go back to the previous version, use lexq_deploy_rollback. To take the version out of production, use lexq_deploy_undeploy."
3718
- ),
3719
- activationGroup: z2.string().optional().describe(
3720
- "Activation group (Execution Group) cluster key. Policy groups sharing this key compete, and group priority picks the winners."
3721
- ),
3722
- activationMode: z2.enum(ConflictResolutionMode).optional().describe("Conflict resolution mode"),
3723
- activationStrategy: z2.enum(ConflictResolutionStrategy).optional().describe("Ranking used to pick the winning groups"),
3724
- executionLimit: z2.number().int().min(1).optional().describe(
3725
- "How many policy groups sharing this activationGroup may run. Counts groups, not rules."
3726
- )
3727
- })
3728
- },
3729
- async ({ groupId, ...body }) => callApi("PUT", `policy-groups/${groupId}`, { body })
3730
- );
3731
- server.registerTool(
3732
- "lexq_groups_delete",
3773
+ "lexq_simulation_status",
3733
3774
  {
3734
- title: "Delete Policy Group",
3735
- annotations: { readOnlyHint: false, destructiveHint: true },
3736
- 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 30 days.",
3737
- inputSchema: z2.object({
3738
- groupId: z2.string().uuid().describe("Policy group ID")
3775
+ title: "Simulation Status",
3776
+ annotations: { readOnlyHint: true },
3777
+ description: "Get simulation status and results. Poll until status is COMPLETED or FAILED.",
3778
+ inputSchema: z.object({
3779
+ simulationId: z.string().uuid().describe("Simulation ID")
3739
3780
  })
3740
3781
  },
3741
- async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}`)
3782
+ async ({ simulationId }) => callApi("GET", `analytics/simulations/${simulationId}`)
3742
3783
  );
3743
3784
  server.registerTool(
3744
- "lexq_groups_reorder",
3785
+ "lexq_simulation_list",
3745
3786
  {
3746
- title: "Reorder Policy Groups",
3747
- annotations: { readOnlyHint: false, destructiveHint: true },
3748
- 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.",
3749
- inputSchema: z2.object({
3750
- groupIds: z2.array(z2.string().uuid()).describe("Group IDs in desired priority order (index 0 = priority 1)")
3787
+ title: "List Simulations",
3788
+ annotations: { readOnlyHint: true },
3789
+ description: "List simulation history with optional filters.",
3790
+ inputSchema: z.object({
3791
+ page: z.number().int().min(0).default(0).describe("Page number"),
3792
+ size: z.number().int().min(1).max(100).default(20).describe("Page size"),
3793
+ status: z.enum(["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELED"]).optional().describe("Filter by status"),
3794
+ from: z.string().optional().describe(START_DAY),
3795
+ to: z.string().optional().describe(END_DAY)
3751
3796
  })
3752
3797
  },
3753
- async ({ groupIds }) => {
3754
- const groups = groupIds.map((groupId, index) => ({
3755
- groupId,
3756
- priority: index + 1
3757
- }));
3758
- return callApi("PATCH", "policy-groups/reorder", { body: { groups } });
3798
+ async ({ page, size, status, from, to }) => {
3799
+ const params = paginationParams(page, size);
3800
+ if (status) params.status = status;
3801
+ if (from) params.from = from;
3802
+ if (to) params.to = to;
3803
+ return callApi("GET", "analytics/simulations", { params });
3759
3804
  }
3760
3805
  );
3761
3806
  server.registerTool(
3762
- "lexq_ab_test_start",
3807
+ "lexq_simulation_cancel",
3763
3808
  {
3764
- title: "Start A/B Test",
3765
- annotations: { readOnlyHint: false, destructiveHint: false },
3766
- 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%.",
3767
- inputSchema: z2.object({
3768
- groupId: z2.string().uuid().describe("Policy group ID"),
3769
- testVersionId: z2.string().uuid().describe("Challenger version ID. Must be a published version of this group"),
3770
- trafficRate: z2.number().int().min(1).max(99).describe("Traffic percentage routed to challenger (1-99)")
3809
+ title: "Cancel Simulation",
3810
+ annotations: { readOnlyHint: false, destructiveHint: true },
3811
+ description: "Cancel a running or pending simulation.",
3812
+ inputSchema: z.object({
3813
+ simulationId: z.string().uuid().describe("Simulation ID")
3771
3814
  })
3772
3815
  },
3773
- async ({ groupId, ...body }) => callApi("POST", `policy-groups/${groupId}/ab-test`, { body })
3816
+ async ({ simulationId }) => callApi("DELETE", `analytics/simulations/${simulationId}`)
3774
3817
  );
3775
3818
  server.registerTool(
3776
- "lexq_ab_test_stop",
3819
+ "lexq_simulation_export",
3777
3820
  {
3778
- title: "Stop A/B Test",
3779
- annotations: { readOnlyHint: false, destructiveHint: true },
3780
- description: "Stop a running A/B test. All traffic is restored to the control (current) version.",
3781
- inputSchema: z2.object({
3782
- groupId: z2.string().uuid().describe("Policy group ID")
3821
+ title: "Export Simulation",
3822
+ annotations: { readOnlyHint: true },
3823
+ description: "Export simulation results as JSON or CSV. Returns the raw data.",
3824
+ inputSchema: z.object({
3825
+ simulationId: z.string().uuid().describe("Simulation ID"),
3826
+ format: z.enum(EXPORT_FORMATS).default("json").describe("Export format")
3783
3827
  })
3784
3828
  },
3785
- async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}/ab-test`)
3829
+ async ({ simulationId, format }) => callApi("GET", `analytics/simulations/${simulationId}/export`, {
3830
+ params: { format }
3831
+ })
3786
3832
  );
3787
3833
  server.registerTool(
3788
- "lexq_ab_test_adjust",
3834
+ "lexq_dataset_upload",
3835
+ {
3836
+ title: "Upload Dataset",
3837
+ annotations: { readOnlyHint: false, destructiveHint: false },
3838
+ description: dedent15`
3839
+ Upload inline CSV or JSON content as a simulation dataset.
3840
+ The content is uploaded to S3 and a path is returned in the "path" field.
3841
+
3842
+ To use the returned path in lexq_simulation_start, set:
3843
+ dataset: { "type": "UPLOADED", "source": "S3_BUCKET", "path": "<returned path>" }
3844
+
3845
+ The content travels inside the tool call, so this suits small datasets, and the hosted server at
3846
+ mcp.lexq.io limits the size of a request. For a larger file, use lexq_dataset_upload_file on the
3847
+ local server (lexq serve --mcp), or upload it with the CLI or the console and pass the returned path.
3848
+
3849
+ CSV example:
3850
+ userId,paymentAmount
3851
+ user_001,150000
3852
+ user_002,50000
3853
+
3854
+ JSON example: [{"userId":"user_001","paymentAmount":150000}, {"userId":"user_002","paymentAmount":50000}]
3855
+ `,
3856
+ inputSchema: z.object({
3857
+ content: z.string().describe("CSV or JSON content as string"),
3858
+ filename: z.string().default("dataset.csv").describe("Filename with extension (.csv or .json)")
3859
+ })
3860
+ },
3861
+ async ({ content, filename }) => {
3862
+ const result = await callApi("POST", "analytics/datasets/upload", {
3863
+ upload: { content, filename, fieldName: "file" }
3864
+ });
3865
+ if (!result.isError) {
3866
+ try {
3867
+ const uploaded = parseJson(result.content[0]?.text ?? "{}");
3868
+ const path = uploaded.data?.path ?? uploaded.path;
3869
+ if (typeof path === "string" && path) result.content.push(datasetBlock(path));
3870
+ } catch {
3871
+ }
3872
+ }
3873
+ return result;
3874
+ }
3875
+ );
3876
+ server.registerTool(
3877
+ "lexq_dataset_template",
3878
+ {
3879
+ title: "Download Dataset Template",
3880
+ annotations: { readOnlyHint: true },
3881
+ 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.",
3882
+ inputSchema: z.object({
3883
+ groupId: z.string().uuid().describe("Policy group ID"),
3884
+ versionId: z.string().uuid().describe("Version ID"),
3885
+ format: z.enum(EXPORT_FORMATS).default("csv").describe("Template format")
3886
+ })
3887
+ },
3888
+ async ({ groupId, versionId, format }) => callApi("GET", `analytics/groups/${groupId}/versions/${versionId}/dataset-template`, {
3889
+ params: { format }
3890
+ })
3891
+ );
3892
+ }
3893
+
3894
+ // src/mcp/local-tools.ts
3895
+ var MAX_DATASET_MB = MAX_DATASET_SIZE / 1024 / 1024;
3896
+ var NOT_ABSOLUTE = "file must be an absolute path on this computer. A leading ~/ stands for the home directory.";
3897
+ var NOT_A_DATASET = "file must be a .csv or .json file, and a symbolic link must point to one.";
3898
+ var TOO_LARGE = `file is larger than ${MAX_DATASET_MB} MB.`;
3899
+ var UNREADABLE = "file does not exist, or is not a regular file this server can read.";
3900
+ var NOT_AN_ARRAY = "a .json file must hold an array of records.";
3901
+ function refuse(message) {
3902
+ return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
3903
+ }
3904
+ function startsWithArray(bytes) {
3905
+ let i = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
3906
+ while (bytes[i] === 32 || bytes[i] === 9 || bytes[i] === 10 || bytes[i] === 13) i++;
3907
+ return bytes[i] === 91;
3908
+ }
3909
+ async function readDatasetFile(file) {
3910
+ const named = file.startsWith("~/") ? join2(homedir2(), file.slice(2)) : file;
3911
+ if (!isAbsolute(named) || /^[\\/]{2}/.test(named)) return { error: NOT_ABSOLUTE };
3912
+ if (!datasetContentType(named)) return { error: NOT_A_DATASET };
3913
+ let real;
3914
+ try {
3915
+ real = await realpath(named);
3916
+ } catch {
3917
+ return { error: UNREADABLE };
3918
+ }
3919
+ const type = datasetContentType(real);
3920
+ if (!type) return { error: NOT_A_DATASET };
3921
+ try {
3922
+ const info = await stat(real);
3923
+ if (!info.isFile()) return { error: UNREADABLE };
3924
+ if (info.size > MAX_DATASET_SIZE) return { error: TOO_LARGE };
3925
+ const bytes = await readFile(real);
3926
+ if (type === "application/json" && !startsWithArray(bytes)) return { error: NOT_AN_ARRAY };
3927
+ return { bytes, filename: basename2(real) };
3928
+ } catch {
3929
+ return { error: UNREADABLE };
3930
+ }
3931
+ }
3932
+ function registerLocalTools(server) {
3933
+ server.registerTool(
3934
+ "lexq_dataset_upload_file",
3935
+ {
3936
+ title: "Upload Dataset File",
3937
+ annotations: { readOnlyHint: false, destructiveHint: false },
3938
+ description: dedent16`
3939
+ Upload a CSV or JSON file from this computer as a simulation dataset, up to ${MAX_DATASET_MB} MB.
3940
+ The server reads the file from disk, so its content never passes through the conversation.
3941
+ Only the local server (lexq serve --mcp) has this tool.
3942
+
3943
+ file: the absolute path of a .csv or .json file. A leading ~/ stands for the home directory.
3944
+ A symbolic link is followed, and the file it points to must also end in .csv or .json.
3945
+
3946
+ CSV: a header row of fact keys, then one row per record. JSON: an array of objects.
3947
+
3948
+ Returns the stored path and a ready-to-use dataset block for lexq_simulation_start:
3949
+ dataset: { "type": "UPLOADED", "source": "S3_BUCKET", "path": "<returned path>" }
3950
+ `,
3951
+ inputSchema: z2.object({
3952
+ file: z2.string().min(1).describe(
3953
+ "Absolute path of a .csv or .json file. A leading ~/ stands for the home directory."
3954
+ )
3955
+ })
3956
+ },
3957
+ async ({ file }) => {
3958
+ const read = await readDatasetFile(file);
3959
+ if ("error" in read) return refuse(read.error);
3960
+ try {
3961
+ const uploaded = await uploadDataset(read.bytes, read.filename, localApiOptions());
3962
+ return {
3963
+ content: [
3964
+ { type: "text", text: stringifyJson(uploaded, 2) ?? "" },
3965
+ datasetBlock(uploaded.path)
3966
+ ]
3967
+ };
3968
+ } catch (error) {
3969
+ return refuse(error instanceof Error ? error.message : String(error));
3970
+ }
3971
+ }
3972
+ );
3973
+ }
3974
+
3975
+ // src/mcp/server-info.json
3976
+ var server_info_default = {
3977
+ title: "LexQ",
3978
+ description: "The Decision Operations Platform for engineering teams",
3979
+ websiteUrl: "https://lexq.io",
3980
+ icons: [
3981
+ {
3982
+ src: "https://lexq.io/logo.png",
3983
+ mimeType: "image/png",
3984
+ sizes: ["1024x1024"]
3985
+ }
3986
+ ]
3987
+ };
3988
+
3989
+ // src/mcp/tools/status.ts
3990
+ import { z as z3 } from "zod";
3991
+ function registerStatusTools(server, callApi) {
3992
+ server.registerTool(
3993
+ "lexq_whoami",
3994
+ {
3995
+ title: "Who Am I",
3996
+ annotations: { readOnlyHint: true },
3997
+ 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.',
3998
+ inputSchema: z3.object({})
3999
+ },
4000
+ async () => callApi("GET", "whoami")
4001
+ );
4002
+ }
4003
+
4004
+ // src/mcp/tools/groups.ts
4005
+ import { z as z4 } from "zod";
4006
+ function registerGroupTools(server, callApi) {
4007
+ server.registerTool(
4008
+ "lexq_groups_list",
4009
+ {
4010
+ title: "List Policy Groups",
4011
+ annotations: { readOnlyHint: true },
4012
+ description: "List policy groups (tenant-wide, priority ASC). Deleted groups are not included.",
4013
+ inputSchema: z4.object({})
4014
+ },
4015
+ async () => callApi("GET", "policy-groups")
4016
+ );
4017
+ server.registerTool(
4018
+ "lexq_groups_get",
4019
+ {
4020
+ title: "Get Policy Group",
4021
+ annotations: { readOnlyHint: true },
4022
+ 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.",
4023
+ inputSchema: z4.object({
4024
+ groupId: z4.string().uuid().describe("Policy group ID")
4025
+ })
4026
+ },
4027
+ async ({ groupId }) => callApi("GET", `policy-groups/${groupId}`)
4028
+ );
4029
+ server.registerTool(
4030
+ "lexq_groups_create",
4031
+ {
4032
+ title: "Create Policy Group",
4033
+ annotations: { readOnlyHint: false, destructiveHint: false },
4034
+ 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.`,
4035
+ inputSchema: z4.object({
4036
+ name: z4.string().describe("Group name (unique among non-ARCHIVED)"),
4037
+ description: z4.string().optional().describe("Group description"),
4038
+ activationMode: z4.enum(ConflictResolutionMode).optional().describe("Conflict resolution mode"),
4039
+ activationStrategy: z4.enum(ConflictResolutionStrategy).optional().describe("Ranking used to pick the winning groups when mode is EXCLUSIVE or MAX_N"),
4040
+ executionLimit: z4.number().int().min(1).optional().describe(
4041
+ "How many policy groups sharing this activationGroup may run (required when mode is MAX_N). Counts groups, not rules: every rule of a winning group runs."
4042
+ ),
4043
+ activationGroup: z4.string().optional().describe(
4044
+ "Activation group (Execution Group) cluster key. Policy groups sharing this key compete, and group priority picks the winners."
4045
+ )
4046
+ })
4047
+ },
4048
+ async (args) => {
4049
+ const body = {
4050
+ name: args.name
4051
+ };
4052
+ if (args.description !== void 0) body.description = args.description;
4053
+ if (args.activationMode !== void 0) body.activationMode = args.activationMode;
4054
+ if (args.activationStrategy !== void 0) body.activationStrategy = args.activationStrategy;
4055
+ if (args.executionLimit !== void 0) body.executionLimit = args.executionLimit;
4056
+ if (args.activationGroup !== void 0) body.activationGroup = args.activationGroup;
4057
+ return callApi("POST", "policy-groups", { body });
4058
+ }
4059
+ );
4060
+ server.registerTool(
4061
+ "lexq_groups_update",
4062
+ {
4063
+ title: "Update Policy Group",
4064
+ annotations: { readOnlyHint: false, destructiveHint: true },
4065
+ description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged. A deleted group cannot be updated (409 P-059).",
4066
+ inputSchema: z4.object({
4067
+ groupId: z4.string().uuid().describe("Policy group ID"),
4068
+ name: z4.string().optional().describe("New name"),
4069
+ description: z4.string().optional().describe("New description"),
4070
+ status: z4.enum(["ACTIVE", "DISABLED"]).optional().describe(
4071
+ "DISABLED stops production execution of this group: execution calls return 403 (P-009), and a composite call that includes the group is rejected in full. The deployed version and any A/B test are kept, and dry run, Impact Simulation, and Decision Replay keep working, so a fix can be verified before setting ACTIVE again. To go back to the previous version, use lexq_deploy_rollback. To take the version out of production, use lexq_deploy_undeploy."
4072
+ ),
4073
+ activationGroup: z4.string().optional().describe(
4074
+ "Activation group (Execution Group) cluster key. Policy groups sharing this key compete, and group priority picks the winners."
4075
+ ),
4076
+ activationMode: z4.enum(ConflictResolutionMode).optional().describe("Conflict resolution mode"),
4077
+ activationStrategy: z4.enum(ConflictResolutionStrategy).optional().describe("Ranking used to pick the winning groups"),
4078
+ executionLimit: z4.number().int().min(1).optional().describe(
4079
+ "How many policy groups sharing this activationGroup may run. Counts groups, not rules."
4080
+ )
4081
+ })
4082
+ },
4083
+ async ({ groupId, ...body }) => callApi("PUT", `policy-groups/${groupId}`, { body })
4084
+ );
4085
+ server.registerTool(
4086
+ "lexq_groups_delete",
4087
+ {
4088
+ title: "Delete Policy Group",
4089
+ annotations: { readOnlyHint: false, destructiveHint: true },
4090
+ 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 30 days.",
4091
+ inputSchema: z4.object({
4092
+ groupId: z4.string().uuid().describe("Policy group ID")
4093
+ })
4094
+ },
4095
+ async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}`)
4096
+ );
4097
+ server.registerTool(
4098
+ "lexq_groups_reorder",
4099
+ {
4100
+ title: "Reorder Policy Groups",
4101
+ annotations: { readOnlyHint: false, destructiveHint: true },
4102
+ 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.",
4103
+ inputSchema: z4.object({
4104
+ groupIds: z4.array(z4.string().uuid()).describe("Group IDs in desired priority order (index 0 = priority 1)")
4105
+ })
4106
+ },
4107
+ async ({ groupIds }) => {
4108
+ const groups = groupIds.map((groupId, index) => ({
4109
+ groupId,
4110
+ priority: index + 1
4111
+ }));
4112
+ return callApi("PATCH", "policy-groups/reorder", { body: { groups } });
4113
+ }
4114
+ );
4115
+ server.registerTool(
4116
+ "lexq_ab_test_start",
4117
+ {
4118
+ title: "Start A/B Test",
4119
+ annotations: { readOnlyHint: false, destructiveHint: false },
4120
+ 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%.",
4121
+ inputSchema: z4.object({
4122
+ groupId: z4.string().uuid().describe("Policy group ID"),
4123
+ testVersionId: z4.string().uuid().describe("Challenger version ID. Must be a published version of this group"),
4124
+ trafficRate: z4.number().int().min(1).max(99).describe("Traffic percentage routed to challenger (1-99)")
4125
+ })
4126
+ },
4127
+ async ({ groupId, ...body }) => callApi("POST", `policy-groups/${groupId}/ab-test`, { body })
4128
+ );
4129
+ server.registerTool(
4130
+ "lexq_ab_test_stop",
4131
+ {
4132
+ title: "Stop A/B Test",
4133
+ annotations: { readOnlyHint: false, destructiveHint: true },
4134
+ description: "Stop a running A/B test. All traffic is restored to the control (current) version.",
4135
+ inputSchema: z4.object({
4136
+ groupId: z4.string().uuid().describe("Policy group ID")
4137
+ })
4138
+ },
4139
+ async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}/ab-test`)
4140
+ );
4141
+ server.registerTool(
4142
+ "lexq_ab_test_adjust",
3789
4143
  {
3790
4144
  title: "Adjust A/B Test",
3791
4145
  annotations: { readOnlyHint: false, destructiveHint: true },
3792
4146
  description: "Adjust traffic rate of a running A/B test.",
3793
- inputSchema: z2.object({
3794
- groupId: z2.string().uuid().describe("Policy group ID"),
3795
- trafficRate: z2.number().int().min(1).max(99).describe("New traffic percentage for challenger (1-99)")
4147
+ inputSchema: z4.object({
4148
+ groupId: z4.string().uuid().describe("Policy group ID"),
4149
+ trafficRate: z4.number().int().min(1).max(99).describe("New traffic percentage for challenger (1-99)")
3796
4150
  })
3797
4151
  },
3798
4152
  async ({ groupId, ...body }) => callApi("PATCH", `policy-groups/${groupId}/ab-test/traffic-rate`, { body })
@@ -3800,7 +4154,7 @@ function registerGroupTools(server, callApi) {
3800
4154
  }
3801
4155
 
3802
4156
  // src/mcp/tools/versions.ts
3803
- import { z as z3 } from "zod";
4157
+ import { z as z5 } from "zod";
3804
4158
  function registerVersionTools(server, callApi) {
3805
4159
  server.registerTool(
3806
4160
  "lexq_versions_list",
@@ -3808,10 +4162,10 @@ function registerVersionTools(server, callApi) {
3808
4162
  title: "List Policy Versions",
3809
4163
  annotations: { readOnlyHint: true },
3810
4164
  description: "List all versions of a policy group.",
3811
- inputSchema: z3.object({
3812
- groupId: z3.string().uuid().describe("Policy group ID"),
3813
- page: z3.number().int().min(0).default(0).describe("Page number"),
3814
- size: z3.number().int().min(1).max(100).default(20).describe("Page size")
4165
+ inputSchema: z5.object({
4166
+ groupId: z5.string().uuid().describe("Policy group ID"),
4167
+ page: z5.number().int().min(0).default(0).describe("Page number"),
4168
+ size: z5.number().int().min(1).max(100).default(20).describe("Page size")
3815
4169
  })
3816
4170
  },
3817
4171
  async ({ groupId, page, size }) => callApi("GET", `policy-groups/${groupId}/versions`, {
@@ -3824,9 +4178,9 @@ function registerVersionTools(server, callApi) {
3824
4178
  title: "Get Policy Version",
3825
4179
  annotations: { readOnlyHint: true },
3826
4180
  description: "Get a single version by ID, including its rules and fact requirements.",
3827
- inputSchema: z3.object({
3828
- groupId: z3.string().uuid().describe("Policy group ID"),
3829
- versionId: z3.string().uuid().describe("Version ID")
4181
+ inputSchema: z5.object({
4182
+ groupId: z5.string().uuid().describe("Policy group ID"),
4183
+ versionId: z5.string().uuid().describe("Version ID")
3830
4184
  })
3831
4185
  },
3832
4186
  async ({ groupId, versionId }) => callApi("GET", `policy-groups/${groupId}/versions/${versionId}`)
@@ -3837,11 +4191,11 @@ function registerVersionTools(server, callApi) {
3837
4191
  title: "Create Policy Version",
3838
4192
  annotations: { readOnlyHint: false, destructiveHint: false },
3839
4193
  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.`,
3840
- inputSchema: z3.object({
3841
- groupId: z3.string().uuid().describe("Policy group ID"),
3842
- commitMessage: z3.string().optional().describe("Commit message describing this version"),
3843
- effectiveFrom: z3.string().optional().describe(`Effective start, ${INSTANT}`),
3844
- effectiveTo: z3.string().optional().describe(`Effective end, ${INSTANT}`)
4194
+ inputSchema: z5.object({
4195
+ groupId: z5.string().uuid().describe("Policy group ID"),
4196
+ commitMessage: z5.string().optional().describe("Commit message describing this version"),
4197
+ effectiveFrom: z5.string().optional().describe(`Effective start, ${INSTANT}`),
4198
+ effectiveTo: z5.string().optional().describe(`Effective end, ${INSTANT}`)
3845
4199
  })
3846
4200
  },
3847
4201
  async ({ groupId, ...body }) => callApi("POST", `policy-groups/${groupId}/versions`, { body })
@@ -3852,14 +4206,14 @@ function registerVersionTools(server, callApi) {
3852
4206
  title: "Update Policy Version",
3853
4207
  annotations: { readOnlyHint: false, destructiveHint: true },
3854
4208
  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.",
3855
- inputSchema: z3.object({
3856
- groupId: z3.string().uuid().describe("Policy group ID"),
3857
- versionId: z3.string().uuid().describe("Version ID"),
3858
- commitMessage: z3.string().optional().describe("New commit message"),
3859
- effectiveFrom: z3.string().optional().describe(`Effective start, ${INSTANT} Omit it to keep the current start.`),
3860
- effectiveTo: z3.string().optional().describe(`Effective end, ${INSTANT} Omit it to keep the current end.`),
3861
- clearEffectiveFrom: z3.boolean().optional().describe("Set to true to remove the effective start. Do not send effectiveFrom with it."),
3862
- clearEffectiveTo: z3.boolean().optional().describe("Set to true to remove the effective end. Do not send effectiveTo with it.")
4209
+ inputSchema: z5.object({
4210
+ groupId: z5.string().uuid().describe("Policy group ID"),
4211
+ versionId: z5.string().uuid().describe("Version ID"),
4212
+ commitMessage: z5.string().optional().describe("New commit message"),
4213
+ effectiveFrom: z5.string().optional().describe(`Effective start, ${INSTANT} Omit it to keep the current start.`),
4214
+ effectiveTo: z5.string().optional().describe(`Effective end, ${INSTANT} Omit it to keep the current end.`),
4215
+ clearEffectiveFrom: z5.boolean().optional().describe("Set to true to remove the effective start. Do not send effectiveFrom with it."),
4216
+ clearEffectiveTo: z5.boolean().optional().describe("Set to true to remove the effective end. Do not send effectiveTo with it.")
3863
4217
  })
3864
4218
  },
3865
4219
  async ({ groupId, versionId, ...body }) => callApi("PUT", `policy-groups/${groupId}/versions/${versionId}`, { body })
@@ -3870,9 +4224,9 @@ function registerVersionTools(server, callApi) {
3870
4224
  title: "Delete Policy Version",
3871
4225
  annotations: { readOnlyHint: false, destructiveHint: true },
3872
4226
  description: "Delete a DRAFT version. Only DRAFT versions can be deleted.",
3873
- inputSchema: z3.object({
3874
- groupId: z3.string().uuid().describe("Policy group ID"),
3875
- versionId: z3.string().uuid().describe("Version ID")
4227
+ inputSchema: z5.object({
4228
+ groupId: z5.string().uuid().describe("Policy group ID"),
4229
+ versionId: z5.string().uuid().describe("Version ID")
3876
4230
  })
3877
4231
  },
3878
4232
  async ({ groupId, versionId }) => callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}`)
@@ -3883,9 +4237,9 @@ function registerVersionTools(server, callApi) {
3883
4237
  title: "Clone Policy Version",
3884
4238
  annotations: { readOnlyHint: false, destructiveHint: false },
3885
4239
  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.`,
3886
- inputSchema: z3.object({
3887
- groupId: z3.string().uuid().describe("Policy group ID"),
3888
- versionId: z3.string().uuid().describe("Source version ID to clone")
4240
+ inputSchema: z5.object({
4241
+ groupId: z5.string().uuid().describe("Policy group ID"),
4242
+ versionId: z5.string().uuid().describe("Source version ID to clone")
3889
4243
  })
3890
4244
  },
3891
4245
  async ({ groupId, versionId }) => {
@@ -3897,8 +4251,8 @@ function registerVersionTools(server, callApi) {
3897
4251
  }
3898
4252
 
3899
4253
  // src/mcp/tools/rules.ts
3900
- import { z as z4 } from "zod";
3901
- import dedent15 from "dedent";
4254
+ import { z as z6 } from "zod";
4255
+ import dedent17 from "dedent";
3902
4256
  function registerRuleTools(server, callApi) {
3903
4257
  server.registerTool(
3904
4258
  "lexq_rules_list",
@@ -3906,9 +4260,9 @@ function registerRuleTools(server, callApi) {
3906
4260
  title: "List Rules",
3907
4261
  annotations: { readOnlyHint: true },
3908
4262
  description: "List all rules in a version (priority ASC). Returns summary with conditionSummary and actionSummary.",
3909
- inputSchema: z4.object({
3910
- groupId: z4.string().uuid().describe("Policy group ID"),
3911
- versionId: z4.string().uuid().describe("Version ID")
4263
+ inputSchema: z6.object({
4264
+ groupId: z6.string().uuid().describe("Policy group ID"),
4265
+ versionId: z6.string().uuid().describe("Version ID")
3912
4266
  })
3913
4267
  },
3914
4268
  async ({ groupId, versionId }) => callApi("GET", `policy-groups/${groupId}/versions/${versionId}/rules`)
@@ -3919,10 +4273,10 @@ function registerRuleTools(server, callApi) {
3919
4273
  title: "Get Rule Detail",
3920
4274
  annotations: { readOnlyHint: true },
3921
4275
  description: "Get full rule detail including condition tree and action definitions.",
3922
- inputSchema: z4.object({
3923
- groupId: z4.string().uuid().describe("Policy group ID"),
3924
- versionId: z4.string().uuid().describe("Version ID"),
3925
- ruleId: z4.string().uuid().describe("Rule ID")
4276
+ inputSchema: z6.object({
4277
+ groupId: z6.string().uuid().describe("Policy group ID"),
4278
+ versionId: z6.string().uuid().describe("Version ID"),
4279
+ ruleId: z6.string().uuid().describe("Rule ID")
3926
4280
  })
3927
4281
  },
3928
4282
  async ({ groupId, versionId, ruleId }) => callApi("GET", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`)
@@ -3932,7 +4286,7 @@ function registerRuleTools(server, callApi) {
3932
4286
  {
3933
4287
  title: "Create Rule",
3934
4288
  annotations: { readOnlyHint: false, destructiveHint: false },
3935
- description: dedent15`
4289
+ description: dedent17`
3936
4290
  Create a rule in a DRAFT version. Requires name, condition tree, and actions array. priority is auto-assigned (appended last); use lexq_rules_reorder to change order.
3937
4291
 
3938
4292
  Before creating rules with new fact keys, call lexq_facts_list to check existing facts.
@@ -4013,10 +4367,10 @@ function registerRuleTools(server, callApi) {
4013
4367
 
4014
4368
  RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
4015
4369
  `,
4016
- inputSchema: z4.object({
4017
- groupId: z4.string().uuid().describe("Policy group ID"),
4018
- versionId: z4.string().uuid().describe("Version ID"),
4019
- rule: z4.string().describe(
4370
+ inputSchema: z6.object({
4371
+ groupId: z6.string().uuid().describe("Policy group ID"),
4372
+ versionId: z6.string().uuid().describe("Version ID"),
4373
+ rule: z6.string().describe(
4020
4374
  "JSON string of CreateRuleRequest: { name, condition, actions, mutexGroup?, mutexMode?, mutexStrategy?, mutexLimit?, isEnabled? }"
4021
4375
  )
4022
4376
  })
@@ -4032,11 +4386,11 @@ function registerRuleTools(server, callApi) {
4032
4386
  title: "Update Rule",
4033
4387
  annotations: { readOnlyHint: false, destructiveHint: true },
4034
4388
  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.`,
4035
- inputSchema: z4.object({
4036
- groupId: z4.string().uuid().describe("Policy group ID"),
4037
- versionId: z4.string().uuid().describe("Version ID"),
4038
- ruleId: z4.string().uuid().describe("Rule ID"),
4039
- rule: z4.string().describe(
4389
+ inputSchema: z6.object({
4390
+ groupId: z6.string().uuid().describe("Policy group ID"),
4391
+ versionId: z6.string().uuid().describe("Version ID"),
4392
+ ruleId: z6.string().uuid().describe("Rule ID"),
4393
+ rule: z6.string().describe(
4040
4394
  "JSON string of UpdateRuleRequest: { name?, condition?, actions?, mutexGroup?, mutexMode?, mutexStrategy?, mutexLimit?, isEnabled? }"
4041
4395
  )
4042
4396
  })
@@ -4054,11 +4408,11 @@ function registerRuleTools(server, callApi) {
4054
4408
  title: "Delete Rule",
4055
4409
  annotations: { readOnlyHint: false, destructiveHint: true },
4056
4410
  description: "Delete a rule from a DRAFT version.",
4057
- inputSchema: z4.object({
4058
- groupId: z4.string().uuid().describe("Policy group ID"),
4059
- versionId: z4.string().uuid().describe("Version ID"),
4060
- ruleId: z4.string().uuid().describe("Rule ID"),
4061
- force: z4.boolean().default(false).describe("Skip confirmation (for the last rule in a version)")
4411
+ inputSchema: z6.object({
4412
+ groupId: z6.string().uuid().describe("Policy group ID"),
4413
+ versionId: z6.string().uuid().describe("Version ID"),
4414
+ ruleId: z6.string().uuid().describe("Rule ID"),
4415
+ force: z6.boolean().default(false).describe("Skip confirmation (for the last rule in a version)")
4062
4416
  })
4063
4417
  },
4064
4418
  async ({ groupId, versionId, ruleId, force }) => {
@@ -4075,10 +4429,10 @@ function registerRuleTools(server, callApi) {
4075
4429
  title: "Reorder Rules",
4076
4430
  annotations: { readOnlyHint: false, destructiveHint: true },
4077
4431
  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).",
4078
- inputSchema: z4.object({
4079
- groupId: z4.string().uuid().describe("Policy group ID"),
4080
- versionId: z4.string().uuid().describe("Version ID"),
4081
- ruleIds: z4.array(z4.string().uuid()).describe("Rule IDs in desired priority order")
4432
+ inputSchema: z6.object({
4433
+ groupId: z6.string().uuid().describe("Policy group ID"),
4434
+ versionId: z6.string().uuid().describe("Version ID"),
4435
+ ruleIds: z6.array(z6.string().uuid()).describe("Rule IDs in desired priority order")
4082
4436
  })
4083
4437
  },
4084
4438
  async ({ groupId, versionId, ruleIds }) => {
@@ -4097,11 +4451,11 @@ function registerRuleTools(server, callApi) {
4097
4451
  title: "Toggle Rule",
4098
4452
  annotations: { readOnlyHint: false, destructiveHint: true },
4099
4453
  description: "Enable or disable a rule without deleting it.",
4100
- inputSchema: z4.object({
4101
- groupId: z4.string().uuid().describe("Policy group ID"),
4102
- versionId: z4.string().uuid().describe("Version ID"),
4103
- ruleId: z4.string().uuid().describe("Rule ID"),
4104
- isEnabled: z4.boolean().describe("true to enable, false to disable")
4454
+ inputSchema: z6.object({
4455
+ groupId: z6.string().uuid().describe("Policy group ID"),
4456
+ versionId: z6.string().uuid().describe("Version ID"),
4457
+ ruleId: z6.string().uuid().describe("Rule ID"),
4458
+ isEnabled: z6.boolean().describe("true to enable, false to disable")
4105
4459
  })
4106
4460
  },
4107
4461
  async ({ groupId, versionId, ruleId, isEnabled }) => callApi("PATCH", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}/enabled`, {
@@ -4111,7 +4465,7 @@ function registerRuleTools(server, callApi) {
4111
4465
  }
4112
4466
 
4113
4467
  // src/mcp/tools/facts.ts
4114
- import { z as z5 } from "zod";
4468
+ import { z as z7 } from "zod";
4115
4469
 
4116
4470
  // src/types/facts.ts
4117
4471
  var FACT_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
@@ -4125,10 +4479,10 @@ function registerFactTools(server, callApi) {
4125
4479
  title: "List Fact Definitions",
4126
4480
  annotations: { readOnlyHint: true },
4127
4481
  description: "List all fact definitions (input variable schema). Shows key, type, required, and PII status. Always check this before creating rules.",
4128
- inputSchema: z5.object({
4129
- page: z5.number().int().min(0).default(0).describe("Page number"),
4130
- size: z5.number().int().min(1).max(100).default(20).describe("Page size"),
4131
- keyword: z5.string().optional().describe("Search keyword")
4482
+ inputSchema: z7.object({
4483
+ page: z7.number().int().min(0).default(0).describe("Page number"),
4484
+ size: z7.number().int().min(1).max(100).default(20).describe("Page size"),
4485
+ keyword: z7.string().optional().describe("Search keyword")
4132
4486
  })
4133
4487
  },
4134
4488
  async ({ page, size, keyword }) => {
@@ -4143,21 +4497,21 @@ function registerFactTools(server, callApi) {
4143
4497
  title: "Create Fact Definition",
4144
4498
  annotations: { readOnlyHint: false, destructiveHint: false },
4145
4499
  description: "Register a new input variable. Key starts with a letter, then letters, numbers, and underscores (e.g. paymentAmount). Casing is not enforced. Types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER.",
4146
- inputSchema: z5.object({
4147
- key: z5.string().regex(FACT_KEY_PATTERN).describe("Variable key. Any casing; must start with a letter."),
4148
- name: z5.string().describe("Display name"),
4149
- type: z5.enum(VALUE_TYPES).describe("Value type"),
4150
- description: z5.string().optional().describe("Description"),
4151
- isRequired: z5.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
4152
- isPii: z5.boolean().default(false).describe(
4500
+ inputSchema: z7.object({
4501
+ key: z7.string().regex(FACT_KEY_PATTERN).describe("Variable key. Any casing; must start with a letter."),
4502
+ name: z7.string().describe("Display name"),
4503
+ type: z7.enum(VALUE_TYPES).describe("Value type"),
4504
+ description: z7.string().optional().describe("Description"),
4505
+ isRequired: z7.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
4506
+ isPii: z7.boolean().default(false).describe(
4153
4507
  "Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
4154
4508
  ),
4155
- valueDomain: z5.object({
4156
- allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
4509
+ valueDomain: z7.object({
4510
+ allowedValues: z7.array(z7.union([z7.string(), z7.number()])).optional().describe(
4157
4511
  "Accepted values. For list types the value domain applies to each element, not to the list as a whole."
4158
4512
  ),
4159
- min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
4160
- max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
4513
+ min: z7.number().optional().describe("Lower bound, inclusive. Numeric types only."),
4514
+ max: z7.number().optional().describe("Upper bound, inclusive. Numeric types only.")
4161
4515
  }).optional().describe(
4162
4516
  "Which values this fact accepts. Omit for no value domain. STRING and LIST_STRING take allowedValues only; NUMBER and LIST_NUMBER take all three; BOOLEAN takes none. Declaring it lets the rule editor offer a dropdown and rejects a rule literal that could never match."
4163
4517
  )
@@ -4171,21 +4525,21 @@ function registerFactTools(server, callApi) {
4171
4525
  title: "Update Fact Definition",
4172
4526
  annotations: { readOnlyHint: false, destructiveHint: true },
4173
4527
  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).",
4174
- inputSchema: z5.object({
4175
- factId: z5.string().uuid().describe("Fact definition ID"),
4176
- name: z5.string().optional().describe("Display name"),
4177
- description: z5.string().optional().describe('Description. Send "" to remove it.'),
4178
- type: z5.enum(VALUE_TYPES).optional().describe(
4528
+ inputSchema: z7.object({
4529
+ factId: z7.string().uuid().describe("Fact definition ID"),
4530
+ name: z7.string().optional().describe("Display name"),
4531
+ description: z7.string().optional().describe('Description. Send "" to remove it.'),
4532
+ type: z7.enum(VALUE_TYPES).optional().describe(
4179
4533
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
4180
4534
  ),
4181
- isRequired: z5.boolean().optional().describe("Required flag"),
4182
- isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
4183
- valueDomain: z5.object({
4184
- allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
4535
+ isRequired: z7.boolean().optional().describe("Required flag"),
4536
+ isPii: z7.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
4537
+ valueDomain: z7.object({
4538
+ allowedValues: z7.array(z7.union([z7.string(), z7.number()])).optional().describe(
4185
4539
  "Accepted values. For list types the value domain applies to each element, not to the list as a whole."
4186
4540
  ),
4187
- min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
4188
- max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
4541
+ min: z7.number().optional().describe("Lower bound, inclusive. Numeric types only."),
4542
+ max: z7.number().optional().describe("Upper bound, inclusive. Numeric types only.")
4189
4543
  }).optional().describe(
4190
4544
  "Which values this fact accepts. Three states: omit the field to leave the value domain alone, send {} to remove it, or send a populated object to replace it. Narrowing it never blocks the update and does not rewrite rules. When rules compare this fact with a value the new value domain excludes, the result ends with a warning that lists them by version: drafts and the versions currently deployed."
4191
4545
  )
@@ -4199,8 +4553,8 @@ function registerFactTools(server, callApi) {
4199
4553
  title: "Delete Fact Definition",
4200
4554
  annotations: { readOnlyHint: false, destructiveHint: true },
4201
4555
  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.",
4202
- inputSchema: z5.object({
4203
- factId: z5.string().uuid().describe("Fact definition ID")
4556
+ inputSchema: z7.object({
4557
+ factId: z7.string().uuid().describe("Fact definition ID")
4204
4558
  })
4205
4559
  },
4206
4560
  async ({ factId }) => callApi("DELETE", `schema/facts/${factId}`)
@@ -4211,7 +4565,7 @@ function registerFactTools(server, callApi) {
4211
4565
  title: "Get Action Runtime Fact Metadata",
4212
4566
  annotations: { readOnlyHint: true },
4213
4567
  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.",
4214
- inputSchema: z5.object({})
4568
+ inputSchema: z7.object({})
4215
4569
  },
4216
4570
  async () => callApi("GET", "schema/action-metadata")
4217
4571
  );
@@ -4221,9 +4575,9 @@ function registerFactTools(server, callApi) {
4221
4575
  title: "List Unregistered Facts",
4222
4576
  annotations: { readOnlyHint: true },
4223
4577
  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). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
4224
- inputSchema: z5.object({
4225
- groupId: z5.string().uuid().describe("Policy group ID"),
4226
- versionId: z5.string().uuid().describe("Version ID")
4578
+ inputSchema: z7.object({
4579
+ groupId: z7.string().uuid().describe("Policy group ID"),
4580
+ versionId: z7.string().uuid().describe("Version ID")
4227
4581
  })
4228
4582
  },
4229
4583
  async ({ groupId, versionId }) => callApi("GET", `policy-groups/${groupId}/versions/${versionId}/unregistered-facts`)
@@ -4234,9 +4588,9 @@ function registerFactTools(server, callApi) {
4234
4588
  title: "Export Fact Catalog",
4235
4589
  annotations: { readOnlyHint: true },
4236
4590
  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; JSON matches the shape that batch create accepts, so it can be fed straight back in, which is why it leaves out the fields that endpoint does not take. Returns the file contents as text.",
4237
- inputSchema: z5.object({
4238
- format: z5.enum(EXPORT_FORMATS).default("csv").describe("Export format"),
4239
- keyword: z5.string().optional().describe("Filter by key or name")
4591
+ inputSchema: z7.object({
4592
+ format: z7.enum(EXPORT_FORMATS).default("csv").describe("Export format"),
4593
+ keyword: z7.string().optional().describe("Filter by key or name")
4240
4594
  })
4241
4595
  },
4242
4596
  async ({ format, keyword }) => callApi("GET", "schema/facts/export", {
@@ -4246,7 +4600,8 @@ function registerFactTools(server, callApi) {
4246
4600
  }
4247
4601
 
4248
4602
  // src/mcp/tools/deploy.ts
4249
- import { z as z6 } from "zod";
4603
+ import dedent18 from "dedent";
4604
+ import { z as z8 } from "zod";
4250
4605
  function registerDeployTools(server, callApi) {
4251
4606
  server.registerTool(
4252
4607
  "lexq_deploy_publish",
@@ -4254,10 +4609,10 @@ function registerDeployTools(server, callApi) {
4254
4609
  title: "Publish Version",
4255
4610
  annotations: { readOnlyHint: false, destructiveHint: false },
4256
4611
  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.`,
4257
- inputSchema: z6.object({
4258
- groupId: z6.string().uuid().describe("Policy group ID"),
4259
- versionId: z6.string().uuid().describe("Version ID to publish"),
4260
- memo: z6.string().min(1).describe("Publish memo (required)")
4612
+ inputSchema: z8.object({
4613
+ groupId: z8.string().uuid().describe("Policy group ID"),
4614
+ versionId: z8.string().uuid().describe("Version ID to publish"),
4615
+ memo: z8.string().min(1).describe("Publish memo (required)")
4261
4616
  })
4262
4617
  },
4263
4618
  async ({ groupId, versionId, memo }) => callApi("POST", `policy-groups/${groupId}/versions/${versionId}/publish`, { body: { memo } })
@@ -4268,10 +4623,10 @@ function registerDeployTools(server, callApi) {
4268
4623
  title: "Deploy to Live",
4269
4624
  annotations: { readOnlyHint: false, destructiveHint: true },
4270
4625
  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.",
4271
- inputSchema: z6.object({
4272
- groupId: z6.string().uuid().describe("Policy group ID"),
4273
- versionId: z6.string().uuid().describe("Version ID to deploy"),
4274
- memo: z6.string().min(1).describe("Deployment memo (required)")
4626
+ inputSchema: z8.object({
4627
+ groupId: z8.string().uuid().describe("Policy group ID"),
4628
+ versionId: z8.string().uuid().describe("Version ID to deploy"),
4629
+ memo: z8.string().min(1).describe("Deployment memo (required)")
4275
4630
  })
4276
4631
  },
4277
4632
  async ({ groupId, versionId, memo }) => callApi("POST", `policy-groups/${groupId}/deploy`, {
@@ -4283,14 +4638,20 @@ function registerDeployTools(server, callApi) {
4283
4638
  {
4284
4639
  title: "Rollback Deployment",
4285
4640
  annotations: { readOnlyHint: false, destructiveHint: true },
4286
- description: "Rollback to the previous deployed version. Only available if there is a previous version that is still ACTIVE and whose effective end has not passed (P-058).",
4287
- inputSchema: z6.object({
4288
- groupId: z6.string().uuid().describe("Policy group ID"),
4289
- memo: z6.string().min(1).describe("Rollback reason (required)")
4641
+ description: dedent18`
4642
+ 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).
4643
+
4644
+ Pass the currentVersionId and currentDeploymentId from that call as expectedCurrentVersionId and expectedCurrentDeploymentId. If anyone deployed, rolled back or undeployed in between, the rollback is rejected with 409 and nothing changes: P-062 if another version or no version is live, P-063 if the same version is live but from a newer deployment. Read the group again and check with the user before retrying.
4645
+ `,
4646
+ inputSchema: z8.object({
4647
+ groupId: z8.string().uuid().describe("Policy group ID"),
4648
+ expectedCurrentVersionId: z8.string().uuid().describe("currentVersionId from your last read of the group"),
4649
+ expectedCurrentDeploymentId: z8.string().uuid().describe("currentDeploymentId from your last read of the group"),
4650
+ memo: z8.string().min(1).describe("Rollback reason (required)")
4290
4651
  })
4291
4652
  },
4292
- async ({ groupId, memo }) => callApi("POST", `policy-groups/${groupId}/rollback`, {
4293
- body: { memo }
4653
+ async ({ groupId, expectedCurrentVersionId, expectedCurrentDeploymentId, memo }) => callApi("POST", `policy-groups/${groupId}/rollback`, {
4654
+ body: { memo, expectedCurrentVersionId, expectedCurrentDeploymentId }
4294
4655
  })
4295
4656
  );
4296
4657
  server.registerTool(
@@ -4299,372 +4660,133 @@ function registerDeployTools(server, callApi) {
4299
4660
  title: "Undeploy",
4300
4661
  annotations: { readOnlyHint: false, destructiveHint: true },
4301
4662
  description: "Remove the live version from traffic. The version stays ACTIVE but no longer serves requests.",
4302
- inputSchema: z6.object({
4303
- groupId: z6.string().uuid().describe("Policy group ID"),
4304
- memo: z6.string().min(1).describe("Undeploy reason (required)")
4305
- })
4306
- },
4307
- async ({ groupId, memo }) => callApi("POST", `policy-groups/${groupId}/undeploy`, {
4308
- body: { memo }
4309
- })
4310
- );
4311
- server.registerTool(
4312
- "lexq_deploy_schedule",
4313
- {
4314
- title: "Schedule Deployment",
4315
- annotations: { readOnlyHint: false, destructiveHint: false },
4316
- 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).",
4317
- inputSchema: z6.object({
4318
- groupId: z6.string().uuid().describe("Policy group ID"),
4319
- versionId: z6.string().uuid().describe("ACTIVE version ID with a future effective start date"),
4320
- memo: z6.string().min(1).describe("Schedule memo (required)")
4321
- })
4322
- },
4323
- async ({ groupId, versionId, memo }) => callApi("POST", `policy-groups/${groupId}/schedule`, {
4324
- body: { versionId, memo }
4325
- })
4326
- );
4327
- server.registerTool(
4328
- "lexq_deploy_unschedule",
4329
- {
4330
- title: "Cancel Scheduled Deployment",
4331
- annotations: { readOnlyHint: false, destructiveHint: true },
4332
- description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
4333
- inputSchema: z6.object({
4334
- groupId: z6.string().uuid().describe("Policy group ID")
4335
- })
4336
- },
4337
- async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}/schedule`)
4338
- );
4339
- server.registerTool(
4340
- "lexq_deploy_schedules",
4341
- {
4342
- title: "List Scheduled Deployments",
4343
- annotations: { readOnlyHint: true },
4344
- description: "List scheduled deployments across all groups (all statuses: PENDING, EXECUTED, CANCELED, FAILED), newest first.",
4345
- inputSchema: z6.object({
4346
- page: z6.number().int().min(0).default(0).describe("Page number"),
4347
- size: z6.number().int().min(1).max(100).default(20).describe("Page size")
4348
- })
4349
- },
4350
- async ({ page, size }) => callApi("GET", "policy-groups/schedules", { params: paginationParams(page, size) })
4351
- );
4352
- server.registerTool(
4353
- "lexq_deploy_history",
4354
- {
4355
- title: "Deployment History",
4356
- annotations: { readOnlyHint: true },
4357
- description: "List deployment history across all groups.",
4358
- inputSchema: z6.object({
4359
- page: z6.number().int().min(0).default(0).describe("Page number"),
4360
- size: z6.number().int().min(1).max(100).default(20).describe("Page size"),
4361
- groupId: z6.string().uuid().optional().describe("Filter by group ID"),
4362
- types: z6.string().optional().describe("Filter by deployment types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)"),
4363
- startDate: z6.string().optional().describe(START_DAY),
4364
- endDate: z6.string().optional().describe(END_DAY)
4365
- })
4366
- },
4367
- async ({ page, size, groupId, types, startDate, endDate }) => {
4368
- const params = paginationParams(page, size);
4369
- if (groupId) params.groupId = groupId;
4370
- if (types) params.types = types;
4371
- if (startDate) params.startDate = startDate;
4372
- if (endDate) params.endDate = endDate;
4373
- return callApi("GET", "deployments", { params });
4374
- }
4375
- );
4376
- server.registerTool(
4377
- "lexq_deploy_detail",
4378
- {
4379
- title: "Deployment Detail",
4380
- annotations: { readOnlyHint: true },
4381
- description: "Get detailed info about a specific deployment including snapshot hash and integrity check.",
4382
- inputSchema: z6.object({
4383
- deploymentId: z6.string().uuid().describe("Deployment ID")
4384
- })
4385
- },
4386
- async ({ deploymentId }) => callApi("GET", `deployments/${deploymentId}`)
4387
- );
4388
- server.registerTool(
4389
- "lexq_deploy_overview",
4390
- {
4391
- title: "Deployment Overview",
4392
- annotations: { readOnlyHint: true },
4393
- description: "Show current deployment status of all groups \u2014 which version is live, last deployment type, and deployer.",
4394
- inputSchema: z6.object({})
4395
- },
4396
- async () => callApi("GET", "deployments/overview")
4397
- );
4398
- server.registerTool(
4399
- "lexq_deploy_deployable",
4400
- {
4401
- title: "List Deployable Versions",
4402
- annotations: { readOnlyHint: true },
4403
- description: "List ACTIVE (published) versions that can be deployed for a group. Use this to find which versions are available before calling deploy live.",
4404
- inputSchema: z6.object({
4405
- groupId: z6.string().uuid().describe("Policy group ID")
4406
- })
4407
- },
4408
- async ({ groupId }) => callApi("GET", `deployments/groups/${groupId}/deployable-versions`)
4409
- );
4410
- server.registerTool(
4411
- "lexq_deploy_diff",
4412
- {
4413
- title: "Deployment Diff",
4414
- annotations: { readOnlyHint: true },
4415
- description: "Compare rule snapshots between two versions. Shows added, removed, and modified rules. Useful for reviewing changes before deploying a new version.",
4416
- inputSchema: z6.object({
4417
- baseVersionId: z6.string().uuid().describe("Base version ID (typically the current live)"),
4418
- targetVersionId: z6.string().uuid().describe("Target version ID (the one you want to deploy)")
4419
- })
4420
- },
4421
- async ({ baseVersionId, targetVersionId }) => callApi("GET", "deployments/diff", {
4422
- params: { baseVersionId, targetVersionId }
4423
- })
4424
- );
4425
- }
4426
-
4427
- // src/mcp/tools/analytics.ts
4428
- import { z as z7 } from "zod";
4429
- import dedent16 from "dedent";
4430
- function registerAnalyticsTools(server, callApi) {
4431
- server.registerTool(
4432
- "lexq_dry_run",
4433
- {
4434
- title: "Dry Run",
4435
- annotations: { readOnlyHint: true },
4436
- description: dedent16`
4437
- Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects.
4438
-
4439
- Returns:
4440
- inputFacts — normalized input facts
4441
- mutatedFacts — input facts changed by rule actions (e.g. MUTATE_FACT mutates paymentAmount)
4442
- generatedVariables — system-generated values; every fact in mutatedFacts gets a paired {factName}__delta key (signed difference)
4443
- executionTraces — per-rule match status
4444
- decisionTraces — per-rule decision (SELECTED / NO_MATCH / BLOCKED / etc.)
4445
-
4446
- Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }
4447
- Always dry-run before publishing to validate rule behavior.`,
4448
- inputSchema: z7.object({
4449
- versionId: z7.string().uuid().describe("Policy version ID to test against"),
4450
- facts: z7.string().describe('JSON string of facts object, e.g. {"paymentAmount":100000}'),
4451
- includeDebugInfo: z7.boolean().default(true).describe("Include execution and decision traces")
4452
- })
4453
- },
4454
- async ({ versionId, facts, includeDebugInfo }) => {
4455
- const parsedFacts = parseJson(facts);
4456
- return callApi("POST", `analytics/dry-run/versions/${versionId}`, {
4457
- body: { facts: parsedFacts, includeDebugInfo }
4458
- });
4459
- }
4460
- );
4461
- server.registerTool(
4462
- "lexq_dry_run_compare",
4463
- {
4464
- title: "Dry Run Compare",
4465
- annotations: { readOnlyHint: true },
4466
- description: dedent16`
4467
- Compare dry run results between two versions using the same input facts. Useful for validating changes.
4468
-
4469
- Returns:
4470
- resultA / resultB — full DryRunResponse for each version
4471
- diff.mutatedDiff — changes in mutatedFacts between A and B (key → {before, after})
4472
- diff.generatedDiff — changes in generatedVariables between A and B`,
4473
- inputSchema: z7.object({
4474
- versionIdA: z7.string().uuid().describe("Baseline version ID"),
4475
- versionIdB: z7.string().uuid().describe("Candidate version ID"),
4476
- facts: z7.string().describe("JSON string of facts object")
4477
- })
4478
- },
4479
- async ({ versionIdA, versionIdB, facts }) => {
4480
- const parsedFacts = parseJson(facts);
4481
- return callApi("POST", "analytics/dry-run/compare", {
4482
- body: { versionIdA, versionIdB, facts: parsedFacts }
4483
- });
4484
- }
4663
+ inputSchema: z8.object({
4664
+ groupId: z8.string().uuid().describe("Policy group ID"),
4665
+ memo: z8.string().min(1).describe("Undeploy reason (required)")
4666
+ })
4667
+ },
4668
+ async ({ groupId, memo }) => callApi("POST", `policy-groups/${groupId}/undeploy`, {
4669
+ body: { memo }
4670
+ })
4485
4671
  );
4486
4672
  server.registerTool(
4487
- "lexq_requirements",
4673
+ "lexq_deploy_schedule",
4488
4674
  {
4489
- title: "Analyze Requirements",
4490
- annotations: { readOnlyHint: true },
4491
- description: "Analyze which input facts a version requires. Returns required keys, types, and an example request body.",
4492
- inputSchema: z7.object({
4493
- groupId: z7.string().uuid().describe("Policy group ID"),
4494
- versionId: z7.string().uuid().describe("Version ID")
4675
+ title: "Schedule Deployment",
4676
+ annotations: { readOnlyHint: false, destructiveHint: false },
4677
+ 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).",
4678
+ inputSchema: z8.object({
4679
+ groupId: z8.string().uuid().describe("Policy group ID"),
4680
+ versionId: z8.string().uuid().describe("ACTIVE version ID with a future effective start date"),
4681
+ memo: z8.string().min(1).describe("Schedule memo (required)")
4495
4682
  })
4496
4683
  },
4497
- async ({ groupId, versionId }) => callApi("GET", `analytics/groups/${groupId}/versions/${versionId}/requirements`)
4684
+ async ({ groupId, versionId, memo }) => callApi("POST", `policy-groups/${groupId}/schedule`, {
4685
+ body: { versionId, memo }
4686
+ })
4498
4687
  );
4499
4688
  server.registerTool(
4500
- "lexq_simulation_start",
4689
+ "lexq_deploy_unschedule",
4501
4690
  {
4502
- title: "Start Simulation",
4503
- annotations: { readOnlyHint: false, destructiveHint: false },
4504
- description: dedent16`
4505
- Start an Impact Simulation against historical, uploaded, or inline data.
4506
-
4507
- dataset.type and dataset.source are BOTH required, and must be paired:
4508
- HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
4509
- in the organization time zone, both inclusive)
4510
- UPLOADED → source S3_BUCKET, with dataset.path (the path returned by lexq_dataset_upload)
4511
- MANUAL → source REQUEST_BODY, with dataset.manualData (array of fact records)
4512
-
4513
- options.maxRecords: number (max ${MAX_SIMULATION_RECORDS}). When omitted, HISTORICAL and UPLOADED run at
4514
- most ${DEFAULT_SIMULATION_RECORDS} records and MANUAL runs every record sent.
4515
- options.baselinePolicyVersionId: uuid (optional, for baseline comparison)
4516
- options.includeRuleStats: boolean
4517
- options.metricConfig: optional — omit for plain execution count. To aggregate a fact, pass
4518
- { "targetVariable": "<fact>", "aggregationType": "COUNT" | "SUM" | "AVG" }
4519
-
4520
- Example (uploaded dataset):
4521
- {
4522
- "policyVersionId": "<uuid>",
4523
- "dataset": { "type": "UPLOADED", "source": "S3_BUCKET", "path": "<path from lexq_dataset_upload>" },
4524
- "options": { "baselinePolicyVersionId": "<uuid>", "includeRuleStats": true, "maxRecords": 10000 }
4525
- }
4526
-
4527
- Example (historical):
4528
- {
4529
- "policyVersionId": "<uuid>",
4530
- "dataset": { "type": "HISTORICAL", "source": "EXECUTION_LOGS", "from": "2026-01-01", "to": "2026-01-31" },
4531
- "options": { "baselinePolicyVersionId": "<uuid>", "includeRuleStats": true }
4532
- }
4533
- `,
4534
- inputSchema: z7.object({
4535
- body: z7.string().describe("JSON string of SimulationRequest")
4691
+ title: "Cancel Scheduled Deployment",
4692
+ annotations: { readOnlyHint: false, destructiveHint: true },
4693
+ description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
4694
+ inputSchema: z8.object({
4695
+ groupId: z8.string().uuid().describe("Policy group ID")
4536
4696
  })
4537
4697
  },
4538
- async ({ body }) => {
4539
- const parsed = parseJson(body);
4540
- return callApi("POST", "analytics/simulations", { body: parsed });
4541
- }
4698
+ async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}/schedule`)
4542
4699
  );
4543
4700
  server.registerTool(
4544
- "lexq_simulation_status",
4701
+ "lexq_deploy_schedules",
4545
4702
  {
4546
- title: "Simulation Status",
4703
+ title: "List Scheduled Deployments",
4547
4704
  annotations: { readOnlyHint: true },
4548
- description: "Get simulation status and results. Poll until status is COMPLETED or FAILED.",
4549
- inputSchema: z7.object({
4550
- simulationId: z7.string().uuid().describe("Simulation ID")
4705
+ description: "List scheduled deployments across all groups (all statuses: PENDING, EXECUTED, CANCELED, FAILED), newest first.",
4706
+ inputSchema: z8.object({
4707
+ page: z8.number().int().min(0).default(0).describe("Page number"),
4708
+ size: z8.number().int().min(1).max(100).default(20).describe("Page size")
4551
4709
  })
4552
4710
  },
4553
- async ({ simulationId }) => callApi("GET", `analytics/simulations/${simulationId}`)
4711
+ async ({ page, size }) => callApi("GET", "policy-groups/schedules", { params: paginationParams(page, size) })
4554
4712
  );
4555
4713
  server.registerTool(
4556
- "lexq_simulation_list",
4714
+ "lexq_deploy_history",
4557
4715
  {
4558
- title: "List Simulations",
4716
+ title: "Deployment History",
4559
4717
  annotations: { readOnlyHint: true },
4560
- description: "List simulation history with optional filters.",
4561
- inputSchema: z7.object({
4562
- page: z7.number().int().min(0).default(0).describe("Page number"),
4563
- size: z7.number().int().min(1).max(100).default(20).describe("Page size"),
4564
- status: z7.enum(["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELED"]).optional().describe("Filter by status"),
4565
- from: z7.string().optional().describe(START_DAY),
4566
- to: z7.string().optional().describe(END_DAY)
4718
+ description: "List deployment history across all groups.",
4719
+ inputSchema: z8.object({
4720
+ page: z8.number().int().min(0).default(0).describe("Page number"),
4721
+ size: z8.number().int().min(1).max(100).default(20).describe("Page size"),
4722
+ groupId: z8.string().uuid().optional().describe("Filter by group ID"),
4723
+ types: z8.string().optional().describe("Filter by deployment types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)"),
4724
+ startDate: z8.string().optional().describe(START_DAY),
4725
+ endDate: z8.string().optional().describe(END_DAY)
4567
4726
  })
4568
4727
  },
4569
- async ({ page, size, status, from, to }) => {
4728
+ async ({ page, size, groupId, types, startDate, endDate }) => {
4570
4729
  const params = paginationParams(page, size);
4571
- if (status) params.status = status;
4572
- if (from) params.from = from;
4573
- if (to) params.to = to;
4574
- return callApi("GET", "analytics/simulations", { params });
4730
+ if (groupId) params.groupId = groupId;
4731
+ if (types) params.types = types;
4732
+ if (startDate) params.startDate = startDate;
4733
+ if (endDate) params.endDate = endDate;
4734
+ return callApi("GET", "deployments", { params });
4575
4735
  }
4576
4736
  );
4577
4737
  server.registerTool(
4578
- "lexq_simulation_cancel",
4738
+ "lexq_deploy_detail",
4579
4739
  {
4580
- title: "Cancel Simulation",
4581
- annotations: { readOnlyHint: false, destructiveHint: true },
4582
- description: "Cancel a running or pending simulation.",
4583
- inputSchema: z7.object({
4584
- simulationId: z7.string().uuid().describe("Simulation ID")
4740
+ title: "Deployment Detail",
4741
+ annotations: { readOnlyHint: true },
4742
+ description: "Get detailed info about a specific deployment including snapshot hash and integrity check.",
4743
+ inputSchema: z8.object({
4744
+ deploymentId: z8.string().uuid().describe("Deployment ID")
4585
4745
  })
4586
4746
  },
4587
- async ({ simulationId }) => callApi("DELETE", `analytics/simulations/${simulationId}`)
4747
+ async ({ deploymentId }) => callApi("GET", `deployments/${deploymentId}`)
4588
4748
  );
4589
4749
  server.registerTool(
4590
- "lexq_simulation_export",
4750
+ "lexq_deploy_overview",
4591
4751
  {
4592
- title: "Export Simulation",
4752
+ title: "Deployment Overview",
4593
4753
  annotations: { readOnlyHint: true },
4594
- description: "Export simulation results as JSON or CSV. Returns the raw data.",
4595
- inputSchema: z7.object({
4596
- simulationId: z7.string().uuid().describe("Simulation ID"),
4597
- format: z7.enum(EXPORT_FORMATS).default("json").describe("Export format")
4598
- })
4754
+ 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.",
4755
+ inputSchema: z8.object({})
4599
4756
  },
4600
- async ({ simulationId, format }) => callApi("GET", `analytics/simulations/${simulationId}/export`, {
4601
- params: { format }
4602
- })
4757
+ async () => callApi("GET", "deployments/overview")
4603
4758
  );
4604
4759
  server.registerTool(
4605
- "lexq_dataset_upload",
4760
+ "lexq_deploy_deployable",
4606
4761
  {
4607
- title: "Upload Dataset",
4608
- annotations: { readOnlyHint: false, destructiveHint: false },
4609
- description: dedent16`
4610
- Upload inline CSV or JSON content as a simulation dataset.
4611
- The content is uploaded to S3 and a path is returned in the "path" field.
4612
-
4613
- To use the returned path in lexq_simulation_start, set:
4614
- dataset: { "type": "UPLOADED", "source": "S3_BUCKET", "path": "<returned path>" }
4615
-
4616
- CSV example:
4617
- userId,paymentAmount
4618
- user_001,150000
4619
- user_002,50000
4620
-
4621
- JSON example: [{"userId":"user_001","paymentAmount":150000}, {"userId":"user_002","paymentAmount":50000}]
4622
- `,
4623
- inputSchema: z7.object({
4624
- content: z7.string().describe("CSV or JSON content as string"),
4625
- filename: z7.string().default("dataset.csv").describe("Filename with extension (.csv or .json)")
4762
+ title: "List Deployable Versions",
4763
+ annotations: { readOnlyHint: true },
4764
+ description: "List ACTIVE (published) versions that can be deployed for a group. Use this to find which versions are available before calling deploy live.",
4765
+ inputSchema: z8.object({
4766
+ groupId: z8.string().uuid().describe("Policy group ID")
4626
4767
  })
4627
4768
  },
4628
- async ({ content, filename }) => {
4629
- const result = await callApi("POST", "analytics/datasets/upload", {
4630
- upload: { content, filename, fieldName: "file" }
4631
- });
4632
- if (!result.isError) {
4633
- try {
4634
- const uploaded = parseJson(result.content[0]?.text ?? "{}");
4635
- if (uploaded.path) {
4636
- const dataset = { type: "UPLOADED", source: "S3_BUCKET", path: uploaded.path };
4637
- result.content.push({
4638
- type: "text",
4639
- text: "Ready-to-use dataset block for lexq_simulation_start:\n" + stringifyJson({ dataset }, 2)
4640
- });
4641
- }
4642
- } catch {
4643
- }
4644
- }
4645
- return result;
4646
- }
4769
+ async ({ groupId }) => callApi("GET", `deployments/groups/${groupId}/deployable-versions`)
4647
4770
  );
4648
4771
  server.registerTool(
4649
- "lexq_dataset_template",
4772
+ "lexq_deploy_diff",
4650
4773
  {
4651
- title: "Download Dataset Template",
4774
+ title: "Deployment Diff",
4652
4775
  annotations: { readOnlyHint: true },
4653
- 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.",
4654
- inputSchema: z7.object({
4655
- groupId: z7.string().uuid().describe("Policy group ID"),
4656
- versionId: z7.string().uuid().describe("Version ID"),
4657
- format: z7.enum(EXPORT_FORMATS).default("csv").describe("Template format")
4776
+ description: "Compare rule snapshots between two versions. Shows added, removed, and modified rules. Useful for reviewing changes before deploying a new version.",
4777
+ inputSchema: z8.object({
4778
+ baseVersionId: z8.string().uuid().describe("Base version ID (typically the current live)"),
4779
+ targetVersionId: z8.string().uuid().describe("Target version ID (the one you want to deploy)")
4658
4780
  })
4659
4781
  },
4660
- async ({ groupId, versionId, format }) => callApi("GET", `analytics/groups/${groupId}/versions/${versionId}/dataset-template`, {
4661
- params: { format }
4782
+ async ({ baseVersionId, targetVersionId }) => callApi("GET", "deployments/diff", {
4783
+ params: { baseVersionId, targetVersionId }
4662
4784
  })
4663
4785
  );
4664
4786
  }
4665
4787
 
4666
4788
  // src/mcp/tools/profile.ts
4667
- import { z as z8 } from "zod";
4789
+ import { z as z9 } from "zod";
4668
4790
  var RELATIVE_THRESHOLD = `flagged = p50 \u2265 ${SLOW_MULTIPLIER}\xD7 median of per-rule p50s within the group; absolute thresholds are intentionally not supported.`;
4669
4791
  function profileParams(opts) {
4670
4792
  const params = {};
@@ -4681,14 +4803,14 @@ function registerProfileTools(server, callApi) {
4681
4803
  title: "Group Latency Profile",
4682
4804
  annotations: { readOnlyHint: true },
4683
4805
  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 6, p95 n \u2265 60, p99 n \u2265 300 \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.`,
4684
- inputSchema: z8.object({
4685
- groupId: z8.string().uuid().describe("Policy group ID"),
4686
- versionId: z8.string().uuid().optional().describe("Version to inspect (default: live version)"),
4687
- from: z8.string().optional().describe(
4806
+ inputSchema: z9.object({
4807
+ groupId: z9.string().uuid().describe("Policy group ID"),
4808
+ versionId: z9.string().uuid().optional().describe("Version to inspect (default: live version)"),
4809
+ from: z9.string().optional().describe(
4688
4810
  `Window start, ISO-8601 instant (e.g. 2026-07-01T00:00:00Z). Default: ${PROFILE_DEFAULT_WINDOW_HOURS}h ago`
4689
4811
  ),
4690
- to: z8.string().optional().describe("Window end, ISO-8601 instant. Default: now"),
4691
- cacheState: z8.enum(["HIT", "MISS"]).optional().describe("Cache dimension for the rule table and judgment (default: HIT)")
4812
+ to: z9.string().optional().describe("Window end, ISO-8601 instant. Default: now"),
4813
+ cacheState: z9.enum(["HIT", "MISS"]).optional().describe("Cache dimension for the rule table and judgment (default: HIT)")
4692
4814
  })
4693
4815
  },
4694
4816
  async ({ groupId, versionId, from, to, cacheState }) => callApi("GET", `policy-groups/${groupId}/profile`, {
@@ -4701,14 +4823,14 @@ function registerProfileTools(server, callApi) {
4701
4823
  title: "Rule Latency Detail",
4702
4824
  annotations: { readOnlyHint: true },
4703
4825
  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 6, p95 n \u2265 60, p99 n \u2265 300). ` + RELATIVE_THRESHOLD,
4704
- inputSchema: z8.object({
4705
- groupId: z8.string().uuid().describe("Policy group ID"),
4706
- ruleId: z8.string().uuid().describe("Rule ID (from lexq_profile_overview)"),
4707
- versionId: z8.string().uuid().optional().describe("Version to inspect (default: live version)"),
4708
- from: z8.string().optional().describe(
4826
+ inputSchema: z9.object({
4827
+ groupId: z9.string().uuid().describe("Policy group ID"),
4828
+ ruleId: z9.string().uuid().describe("Rule ID (from lexq_profile_overview)"),
4829
+ versionId: z9.string().uuid().optional().describe("Version to inspect (default: live version)"),
4830
+ from: z9.string().optional().describe(
4709
4831
  `Window start, ISO-8601 instant. Default: ${PROFILE_DEFAULT_WINDOW_HOURS}h ago`
4710
4832
  ),
4711
- to: z8.string().optional().describe("Window end, ISO-8601 instant. Default: now")
4833
+ to: z9.string().optional().describe("Window end, ISO-8601 instant. Default: now")
4712
4834
  })
4713
4835
  },
4714
4836
  async ({ groupId, ruleId, versionId, from, to }) => callApi("GET", `policy-groups/${groupId}/profile/rules/${ruleId}`, {
@@ -4718,7 +4840,7 @@ function registerProfileTools(server, callApi) {
4718
4840
  }
4719
4841
 
4720
4842
  // src/mcp/tools/replay.ts
4721
- import { z as z9 } from "zod";
4843
+ import { z as z10 } from "zod";
4722
4844
  function registerReplayTools(server, callApi) {
4723
4845
  server.registerTool(
4724
4846
  "lexq_replay_decision",
@@ -4726,9 +4848,9 @@ function registerReplayTools(server, callApi) {
4726
4848
  title: "Replay a Decision",
4727
4849
  annotations: { readOnlyHint: true },
4728
4850
  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 and free of charge (TPS throttle only). A replay sends no webhook, notification, or event: rule actions produce no outward effects.",
4729
- inputSchema: z9.object({
4730
- traceId: z9.string().describe("Trace ID of the past execution to replay"),
4731
- candidateVersionId: z9.string().uuid().describe("Version to re-evaluate against")
4851
+ inputSchema: z10.object({
4852
+ traceId: z10.string().describe("Trace ID of the past execution to replay"),
4853
+ candidateVersionId: z10.string().uuid().describe("Version to re-evaluate against")
4732
4854
  })
4733
4855
  },
4734
4856
  async ({ traceId, candidateVersionId }) => callApi("POST", "replay/decisions", { body: { traceId, candidateVersionId } })
@@ -4739,11 +4861,11 @@ function registerReplayTools(server, callApi) {
4739
4861
  title: "Start Window Replay (Blast Radius)",
4740
4862
  annotations: { readOnlyHint: false, destructiveHint: false },
4741
4863
  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.",
4742
- inputSchema: z9.object({
4743
- candidateVersionId: z9.string().uuid().describe("Version to re-evaluate against"),
4744
- from: z9.string().describe(WINDOW_START_DAY),
4745
- to: z9.string().describe(WINDOW_END_DAY),
4746
- maxRecords: z9.number().int().min(1).optional().describe(
4864
+ inputSchema: z10.object({
4865
+ candidateVersionId: z10.string().uuid().describe("Version to re-evaluate against"),
4866
+ from: z10.string().describe(WINDOW_START_DAY),
4867
+ to: z10.string().describe(WINDOW_END_DAY),
4868
+ maxRecords: z10.number().int().min(1).optional().describe(
4747
4869
  `Sample cap (server default applies; hard cap ${REPLAY_WINDOW_MAX_RECORDS / 1e3}k)`
4748
4870
  )
4749
4871
  })
@@ -4756,8 +4878,8 @@ function registerReplayTools(server, callApi) {
4756
4878
  title: "Get Replay Job Status",
4757
4879
  annotations: { readOnlyHint: true },
4758
4880
  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.",
4759
- inputSchema: z9.object({
4760
- jobId: z9.string().describe("Replay job ID from lexq_replay_start")
4881
+ inputSchema: z10.object({
4882
+ jobId: z10.string().describe("Replay job ID from lexq_replay_start")
4761
4883
  })
4762
4884
  },
4763
4885
  async ({ jobId }) => callApi("GET", `replay/jobs/${jobId}`)
@@ -4768,9 +4890,9 @@ function registerReplayTools(server, callApi) {
4768
4890
  title: "List Replay Jobs",
4769
4891
  annotations: { readOnlyHint: true },
4770
4892
  description: "List window replay job history (reverse-chronological). Lightweight items \u2014 use lexq_replay_status for summary and changed samples.",
4771
- inputSchema: z9.object({
4772
- page: z9.number().int().min(0).default(0).describe("Page number"),
4773
- size: z9.number().int().min(1).max(100).default(20).describe("Page size")
4893
+ inputSchema: z10.object({
4894
+ page: z10.number().int().min(0).default(0).describe("Page number"),
4895
+ size: z10.number().int().min(1).max(100).default(20).describe("Page size")
4774
4896
  })
4775
4897
  },
4776
4898
  async ({ page, size }) => callApi("GET", "replay/jobs", { params: paginationParams(page, size) })
@@ -4781,8 +4903,8 @@ function registerReplayTools(server, callApi) {
4781
4903
  title: "Cancel Replay Job",
4782
4904
  annotations: { readOnlyHint: false, destructiveHint: true },
4783
4905
  description: "Cooperatively cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
4784
- inputSchema: z9.object({
4785
- jobId: z9.string().describe("Replay job ID")
4906
+ inputSchema: z10.object({
4907
+ jobId: z10.string().describe("Replay job ID")
4786
4908
  })
4787
4909
  },
4788
4910
  async ({ jobId }) => callApi("POST", `replay/jobs/${jobId}/cancel`)
@@ -4793,9 +4915,9 @@ function registerReplayTools(server, callApi) {
4793
4915
  title: "Export Replay Result",
4794
4916
  annotations: { readOnlyHint: true },
4795
4917
  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.",
4796
- inputSchema: z9.object({
4797
- jobId: z9.string().describe("Replay job ID"),
4798
- format: z9.enum(EXPORT_FORMATS).default("json").describe("Export format")
4918
+ inputSchema: z10.object({
4919
+ jobId: z10.string().describe("Replay job ID"),
4920
+ format: z10.enum(EXPORT_FORMATS).default("json").describe("Export format")
4799
4921
  })
4800
4922
  },
4801
4923
  async ({ jobId, format }) => callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } })
@@ -4803,7 +4925,7 @@ function registerReplayTools(server, callApi) {
4803
4925
  }
4804
4926
 
4805
4927
  // src/mcp/tools/history.ts
4806
- import { z as z10 } from "zod";
4928
+ import { z as z11 } from "zod";
4807
4929
  function registerHistoryTools(server, callApi) {
4808
4930
  server.registerTool(
4809
4931
  "lexq_history_list",
@@ -4811,15 +4933,15 @@ function registerHistoryTools(server, callApi) {
4811
4933
  title: "List Execution History",
4812
4934
  annotations: { readOnlyHint: true },
4813
4935
  description: "List policy execution history. Shows trace ID, group, version, status, match result, and latency.",
4814
- inputSchema: z10.object({
4815
- page: z10.number().int().min(0).default(0).describe("Page number"),
4816
- size: z10.number().int().min(1).max(100).default(20).describe("Page size"),
4817
- traceId: z10.string().optional().describe("Filter by trace ID"),
4818
- groupId: z10.string().uuid().optional().describe("Filter by policy group"),
4819
- versionId: z10.string().uuid().optional().describe("Filter by version"),
4820
- status: z10.enum(["SUCCESS", "NO_MATCH", "ERROR", "TIMEOUT"]).optional().describe("Filter by execution status"),
4821
- startDate: z10.string().optional().describe(START_DAY),
4822
- endDate: z10.string().optional().describe(END_DAY)
4936
+ inputSchema: z11.object({
4937
+ page: z11.number().int().min(0).default(0).describe("Page number"),
4938
+ size: z11.number().int().min(1).max(100).default(20).describe("Page size"),
4939
+ traceId: z11.string().optional().describe("Filter by trace ID"),
4940
+ groupId: z11.string().uuid().optional().describe("Filter by policy group"),
4941
+ versionId: z11.string().uuid().optional().describe("Filter by version"),
4942
+ status: z11.enum(["SUCCESS", "NO_MATCH", "ERROR", "TIMEOUT"]).optional().describe("Filter by execution status"),
4943
+ startDate: z11.string().optional().describe(START_DAY),
4944
+ endDate: z11.string().optional().describe(END_DAY)
4823
4945
  })
4824
4946
  },
4825
4947
  async ({ page, size, traceId, groupId, versionId, status, startDate, endDate }) => {
@@ -4839,8 +4961,8 @@ function registerHistoryTools(server, callApi) {
4839
4961
  title: "Get Execution Detail",
4840
4962
  annotations: { readOnlyHint: true },
4841
4963
  description: "Get full execution detail including inputFacts, mutatedFacts, generatedVariables, executionTraces, and decisionTraces.",
4842
- inputSchema: z10.object({
4843
- traceId: z10.string().describe("Trace ID from execution history")
4964
+ inputSchema: z11.object({
4965
+ traceId: z11.string().describe("Trace ID from execution history")
4844
4966
  })
4845
4967
  },
4846
4968
  async ({ traceId }) => callApi("GET", `execution/history/${traceId}`)
@@ -4851,10 +4973,10 @@ function registerHistoryTools(server, callApi) {
4851
4973
  title: "Execution Statistics",
4852
4974
  annotations: { readOnlyHint: true },
4853
4975
  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.`,
4854
- inputSchema: z10.object({
4855
- groupId: z10.string().uuid().optional().describe("Filter by policy group"),
4856
- startDate: z10.string().optional().describe(START_DAY),
4857
- endDate: z10.string().optional().describe(END_DAY)
4976
+ inputSchema: z11.object({
4977
+ groupId: z11.string().uuid().optional().describe("Filter by policy group"),
4978
+ startDate: z11.string().optional().describe(START_DAY),
4979
+ endDate: z11.string().optional().describe(END_DAY)
4858
4980
  })
4859
4981
  },
4860
4982
  async ({ groupId, startDate, endDate }) => {
@@ -4868,7 +4990,7 @@ function registerHistoryTools(server, callApi) {
4868
4990
  }
4869
4991
 
4870
4992
  // src/mcp/tools/provenance.ts
4871
- import { z as z11 } from "zod";
4993
+ import { z as z12 } from "zod";
4872
4994
  function registerProvenanceTools(server, callApi) {
4873
4995
  server.registerTool(
4874
4996
  "lexq_provenance_get",
@@ -4876,8 +4998,8 @@ function registerProvenanceTools(server, callApi) {
4876
4998
  title: "Get Decision Provenance",
4877
4999
  annotations: { readOnlyHint: true },
4878
5000
  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.",
4879
- inputSchema: z11.object({
4880
- traceId: z11.string().describe("Trace ID of the execution")
5001
+ inputSchema: z12.object({
5002
+ traceId: z12.string().describe("Trace ID of the execution")
4881
5003
  })
4882
5004
  },
4883
5005
  async ({ traceId }) => callApi("GET", `provenance/${traceId}`)
@@ -4888,14 +5010,14 @@ function registerProvenanceTools(server, callApi) {
4888
5010
  title: "List PII Reveal Audits",
4889
5011
  annotations: { readOnlyHint: true },
4890
5012
  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.",
4891
- inputSchema: z11.object({
4892
- page: z11.number().int().min(0).default(0).describe("Page number"),
4893
- size: z11.number().int().min(1).max(100).default(20).describe("Page size"),
4894
- traceId: z11.string().optional().describe("Filter by trace ID (exact match)"),
4895
- revealedBy: z11.string().optional().describe("Filter by operator ID (exact match)"),
4896
- factKey: z11.string().optional().describe("Filter by fact key (partial match, case-insensitive)"),
4897
- startDate: z11.string().optional().describe(START_DAY),
4898
- endDate: z11.string().optional().describe(END_DAY)
5013
+ inputSchema: z12.object({
5014
+ page: z12.number().int().min(0).default(0).describe("Page number"),
5015
+ size: z12.number().int().min(1).max(100).default(20).describe("Page size"),
5016
+ traceId: z12.string().optional().describe("Filter by trace ID (exact match)"),
5017
+ revealedBy: z12.string().optional().describe("Filter by operator ID (exact match)"),
5018
+ factKey: z12.string().optional().describe("Filter by fact key (partial match, case-insensitive)"),
5019
+ startDate: z12.string().optional().describe(START_DAY),
5020
+ endDate: z12.string().optional().describe(END_DAY)
4899
5021
  })
4900
5022
  },
4901
5023
  async ({ page, size, traceId, revealedBy, factKey, startDate, endDate }) => {
@@ -4911,7 +5033,7 @@ function registerProvenanceTools(server, callApi) {
4911
5033
  }
4912
5034
 
4913
5035
  // src/mcp/tools/logs.ts
4914
- import { z as z12 } from "zod";
5036
+ import { z as z13 } from "zod";
4915
5037
  function registerLogTools(server, callApi) {
4916
5038
  server.registerTool(
4917
5039
  "lexq_logs_list",
@@ -4919,14 +5041,14 @@ function registerLogTools(server, callApi) {
4919
5041
  title: "List Failure Logs",
4920
5042
  annotations: { readOnlyHint: true },
4921
5043
  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.`,
4922
- inputSchema: z12.object({
4923
- page: z12.number().int().min(0).default(0).describe("Page number"),
4924
- size: z12.number().int().min(1).max(100).default(20).describe("Page size"),
4925
- taskType: z12.enum(TaskType).optional().describe("Task type"),
4926
- status: z12.enum(FailureStatus).optional().describe("Log status"),
4927
- keyword: z12.string().optional().describe("Search in refId, refSubId, errorMessage"),
4928
- startDate: z12.string().optional().describe(START_DAY),
4929
- endDate: z12.string().optional().describe(END_DAY)
5044
+ inputSchema: z13.object({
5045
+ page: z13.number().int().min(0).default(0).describe("Page number"),
5046
+ size: z13.number().int().min(1).max(100).default(20).describe("Page size"),
5047
+ taskType: z13.enum(TaskType).optional().describe("Task type"),
5048
+ status: z13.enum(FailureStatus).optional().describe("Log status"),
5049
+ keyword: z13.string().optional().describe("Search in refId, refSubId, errorMessage"),
5050
+ startDate: z13.string().optional().describe(START_DAY),
5051
+ endDate: z13.string().optional().describe(END_DAY)
4930
5052
  })
4931
5053
  },
4932
5054
  async ({ page, size, taskType, status, keyword, startDate, endDate }) => {
@@ -4945,8 +5067,8 @@ function registerLogTools(server, callApi) {
4945
5067
  title: "Get Failure Log",
4946
5068
  annotations: { readOnlyHint: true },
4947
5069
  description: "Get failure log detail by ID.",
4948
- inputSchema: z12.object({
4949
- logId: z12.string().uuid().describe("Failure log ID")
5070
+ inputSchema: z13.object({
5071
+ logId: z13.string().uuid().describe("Failure log ID")
4950
5072
  })
4951
5073
  },
4952
5074
  async ({ logId }) => callApi("GET", `failure-logs/${logId}`)
@@ -4957,9 +5079,9 @@ function registerLogTools(server, callApi) {
4957
5079
  title: "Process Failure Log",
4958
5080
  annotations: { readOnlyHint: false, destructiveHint: false },
4959
5081
  description: "Process a single failure log: RESOLVE (mark as manually fixed) or IGNORE (skip intentionally).",
4960
- inputSchema: z12.object({
4961
- logId: z12.string().uuid().describe("Failure log ID"),
4962
- action: z12.enum(FailureAction).describe("Action to take")
5082
+ inputSchema: z13.object({
5083
+ logId: z13.string().uuid().describe("Failure log ID"),
5084
+ action: z13.enum(FailureAction).describe("Action to take")
4963
5085
  })
4964
5086
  },
4965
5087
  async ({ logId, action }) => callApi("POST", `failure-logs/${logId}/actions`, {
@@ -4972,9 +5094,9 @@ function registerLogTools(server, callApi) {
4972
5094
  title: "Bulk Process Failure Logs",
4973
5095
  annotations: { readOnlyHint: false, destructiveHint: false },
4974
5096
  description: "Process multiple failure logs at once. Provide an array of log IDs and the action.",
4975
- inputSchema: z12.object({
4976
- logIds: z12.array(z12.string().uuid()).describe("Array of failure log IDs"),
4977
- action: z12.enum(FailureAction).describe("Action to apply to all logs")
5097
+ inputSchema: z13.object({
5098
+ logIds: z13.array(z13.string().uuid()).describe("Array of failure log IDs"),
5099
+ action: z13.enum(FailureAction).describe("Action to apply to all logs")
4978
5100
  })
4979
5101
  },
4980
5102
  async ({ logIds, action }) => callApi("POST", "failure-logs/bulk-actions", {
@@ -4984,7 +5106,7 @@ function registerLogTools(server, callApi) {
4984
5106
  }
4985
5107
 
4986
5108
  // src/mcp/tools/webhook-subscriptions.ts
4987
- import { z as z13 } from "zod";
5109
+ import { z as z14 } from "zod";
4988
5110
  function registerWebhookSubscriptionTools(server, callApi) {
4989
5111
  server.registerTool(
4990
5112
  "lexq_webhook_subscriptions_list",
@@ -4992,9 +5114,9 @@ function registerWebhookSubscriptionTools(server, callApi) {
4992
5114
  title: "List Webhook Subscriptions",
4993
5115
  annotations: { readOnlyHint: true },
4994
5116
  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.",
4995
- inputSchema: z13.object({
4996
- page: z13.number().int().min(0).default(0).describe("Page number"),
4997
- size: z13.number().int().min(1).max(100).default(20).describe("Page size")
5117
+ inputSchema: z14.object({
5118
+ page: z14.number().int().min(0).default(0).describe("Page number"),
5119
+ size: z14.number().int().min(1).max(100).default(20).describe("Page size")
4998
5120
  })
4999
5121
  },
5000
5122
  async ({ page, size }) => {
@@ -5008,8 +5130,8 @@ function registerWebhookSubscriptionTools(server, callApi) {
5008
5130
  title: "Get Webhook Subscription",
5009
5131
  annotations: { readOnlyHint: true },
5010
5132
  description: "Get webhook subscription detail by ID.",
5011
- inputSchema: z13.object({
5012
- id: z13.string().uuid().describe("Webhook subscription ID")
5133
+ inputSchema: z14.object({
5134
+ id: z14.string().uuid().describe("Webhook subscription ID")
5013
5135
  })
5014
5136
  },
5015
5137
  async ({ id }) => callApi("GET", `webhook-subscriptions/${id}`)
@@ -5020,14 +5142,14 @@ function registerWebhookSubscriptionTools(server, callApi) {
5020
5142
  title: "Save Webhook Subscription",
5021
5143
  annotations: { readOnlyHint: false, destructiveHint: true },
5022
5144
  description: `Create or update a webhook subscription. Omit id to create, provide id to update. Events: ${PlatformEventType.join(", ")}. Formats: GENERIC (full JSON), SLACK ({"text": "..."}).`,
5023
- inputSchema: z13.object({
5024
- id: z13.string().uuid().optional().describe("Subscription ID (omit to create, provide to update)"),
5025
- name: z13.string().min(1).describe("Subscription name (unique per tenant)"),
5026
- webhookUrl: z13.string().url().describe("Webhook endpoint URL"),
5027
- subscribedEvents: z13.array(z13.enum(PlatformEventType)).min(1).describe("Events to subscribe to"),
5028
- payloadFormat: z13.enum(WebhookPayloadFormat).optional().default("GENERIC").describe("Payload format"),
5029
- secret: z13.string().optional().describe("HMAC-SHA256 signing secret"),
5030
- isActive: z13.boolean().optional().default(true).describe("Whether the subscription is active")
5145
+ inputSchema: z14.object({
5146
+ id: z14.string().uuid().optional().describe("Subscription ID (omit to create, provide to update)"),
5147
+ name: z14.string().min(1).describe("Subscription name (unique per tenant)"),
5148
+ webhookUrl: z14.string().url().describe("Webhook endpoint URL"),
5149
+ subscribedEvents: z14.array(z14.enum(PlatformEventType)).min(1).describe("Events to subscribe to"),
5150
+ payloadFormat: z14.enum(WebhookPayloadFormat).optional().default("GENERIC").describe("Payload format"),
5151
+ secret: z14.string().optional().describe("HMAC-SHA256 signing secret"),
5152
+ isActive: z14.boolean().optional().default(true).describe("Whether the subscription is active")
5031
5153
  })
5032
5154
  },
5033
5155
  async ({ ...body }) => callApi("POST", "webhook-subscriptions", { body })
@@ -5038,8 +5160,8 @@ function registerWebhookSubscriptionTools(server, callApi) {
5038
5160
  title: "Delete Webhook Subscription",
5039
5161
  annotations: { readOnlyHint: false, destructiveHint: true },
5040
5162
  description: "Delete a webhook subscription by ID.",
5041
- inputSchema: z13.object({
5042
- id: z13.string().uuid().describe("Webhook subscription ID")
5163
+ inputSchema: z14.object({
5164
+ id: z14.string().uuid().describe("Webhook subscription ID")
5043
5165
  })
5044
5166
  },
5045
5167
  async ({ id }) => callApi("DELETE", `webhook-subscriptions/${id}`)
@@ -5050,8 +5172,8 @@ function registerWebhookSubscriptionTools(server, callApi) {
5050
5172
  title: "Test Webhook Subscription",
5051
5173
  annotations: { readOnlyHint: false, destructiveHint: false },
5052
5174
  description: "Send a test event to verify webhook connectivity. Returns the HTTP status code and success/failure message.",
5053
- inputSchema: z13.object({
5054
- id: z13.string().uuid().describe("Webhook subscription ID")
5175
+ inputSchema: z14.object({
5176
+ id: z14.string().uuid().describe("Webhook subscription ID")
5055
5177
  })
5056
5178
  },
5057
5179
  async ({ id }) => callApi("POST", `webhook-subscriptions/${id}/test`)
@@ -5059,7 +5181,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5059
5181
  }
5060
5182
 
5061
5183
  // src/mcp/tools/domain-templates.ts
5062
- import { z as z14 } from "zod";
5184
+ import { z as z15 } from "zod";
5063
5185
  function registerDomainTemplateTools(server, callApi) {
5064
5186
  server.registerTool(
5065
5187
  "lexq_domain_templates_list",
@@ -5067,7 +5189,7 @@ function registerDomainTemplateTools(server, callApi) {
5067
5189
  title: "List Domain Templates",
5068
5190
  annotations: { readOnlyHint: true },
5069
5191
  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.",
5070
- inputSchema: z14.object({})
5192
+ inputSchema: z15.object({})
5071
5193
  },
5072
5194
  async () => callApi("GET", "domain-templates")
5073
5195
  );
@@ -5077,8 +5199,8 @@ function registerDomainTemplateTools(server, callApi) {
5077
5199
  title: "Preview Domain Template",
5078
5200
  annotations: { readOnlyHint: true },
5079
5201
  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.",
5080
- inputSchema: z14.object({
5081
- template: z14.string().describe(
5202
+ inputSchema: z15.object({
5203
+ template: z15.string().describe(
5082
5204
  "Domain template key (e.g. ECOMMERCE). Use lexq_domain_templates_list to see the keys."
5083
5205
  )
5084
5206
  })
@@ -5091,9 +5213,9 @@ function registerDomainTemplateTools(server, callApi) {
5091
5213
  title: "Apply Domain Template",
5092
5214
  annotations: { readOnlyHint: false, destructiveHint: false },
5093
5215
  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.",
5094
- inputSchema: z14.object({
5095
- template: z14.string().describe("Domain template key to apply (e.g. ECOMMERCE)."),
5096
- customName: z14.string().optional().describe(
5216
+ inputSchema: z15.object({
5217
+ template: z15.string().describe("Domain template key to apply (e.g. ECOMMERCE)."),
5218
+ customName: z15.string().optional().describe(
5097
5219
  "Optional custom name for the policy group that gets created. If omitted, the template's default name is used."
5098
5220
  )
5099
5221
  })
@@ -5129,7 +5251,7 @@ var SERVER_INFO = { name: "lexq", ...server_info_default };
5129
5251
  var __dirname = dirname(fileURLToPath(import.meta.url));
5130
5252
  function getVersion() {
5131
5253
  try {
5132
- const pkg = JSON.parse(readFileSync3(join2(__dirname, "..", "package.json"), "utf-8"));
5254
+ const pkg = JSON.parse(readFileSync3(join3(__dirname, "..", "package.json"), "utf-8"));
5133
5255
  return pkg.version;
5134
5256
  } catch {
5135
5257
  return "0.1.0";
@@ -5142,6 +5264,7 @@ function startMcpServer() {
5142
5264
  () => {
5143
5265
  const server = new McpServer({ ...SERVER_INFO, version });
5144
5266
  registerAllTools(server, callApi);
5267
+ registerLocalTools(server);
5145
5268
  return server;
5146
5269
  },
5147
5270
  {
@@ -5159,7 +5282,7 @@ function startMcpServer() {
5159
5282
  function registerServeCommand(program) {
5160
5283
  program.command("serve").description("Start LexQ as a server for AI agent integrations").option("--mcp", "Start as MCP (Model Context Protocol) server over stdio").addHelpText(
5161
5284
  "after",
5162
- dedent17`
5285
+ dedent19`
5163
5286
 
5164
5287
  Example:
5165
5288
  $ lexq serve --mcp
@@ -5190,11 +5313,11 @@ function registerServeCommand(program) {
5190
5313
 
5191
5314
  // src/commands/domain-templates.ts
5192
5315
  import "commander";
5193
- import dedent18 from "dedent";
5316
+ import dedent20 from "dedent";
5194
5317
  function registerDomainTemplateCommands(program) {
5195
5318
  const templates = program.command("domain-templates").description("Browse and apply domain templates").addHelpText(
5196
5319
  "after",
5197
- dedent18`
5320
+ dedent20`
5198
5321
 
5199
5322
  A domain template is an industry-specific starter pack of fact
5200
5323
  definitions and sample rules. Applying one provisions a ready-to-use
@@ -5242,7 +5365,7 @@ function registerDomainTemplateCommands(program) {
5242
5365
  });
5243
5366
  templates.command("preview").description("Preview what a domain template provisions").requiredOption("--template <key>", "Domain template key (e.g. ECOMMERCE)").addHelpText(
5244
5367
  "after",
5245
- dedent18`
5368
+ dedent20`
5246
5369
 
5247
5370
  Read-only dry run — shows the fact definitions and sample rules the
5248
5371
  template will create. Nothing is provisioned.
@@ -5271,7 +5394,7 @@ function registerDomainTemplateCommands(program) {
5271
5394
  });
5272
5395
  templates.command("apply").description("Apply a domain template to the current tenant").requiredOption("--template <key>", "Domain template key (e.g. ECOMMERCE)").option("--name <n>", "Custom name for the policy group that gets created").option("--force", "Skip confirmation prompt").addHelpText(
5273
5396
  "after",
5274
- dedent18`
5397
+ dedent20`
5275
5398
 
5276
5399
  Creates the template's fact definitions and a new DRAFT policy group
5277
5400
  populated with its sample rules. A fact that already exists with the
@@ -5325,7 +5448,7 @@ function registerDomainTemplateCommands(program) {
5325
5448
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
5326
5449
  function getVersion2() {
5327
5450
  try {
5328
- const pkg = JSON.parse(readFileSync4(join3(__dirname2, "..", "package.json"), "utf-8"));
5451
+ const pkg = JSON.parse(readFileSync4(join4(__dirname2, "..", "package.json"), "utf-8"));
5329
5452
  return pkg.version;
5330
5453
  } catch {
5331
5454
  return "0.0.0";