@lexq/cli 2.1.1 → 3.0.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
@@ -425,7 +425,6 @@ var SimulationDatasetSource = ["EXECUTION_LOGS", "S3_BUCKET", "REQUEST_BODY"];
425
425
  var SimulationMetricType = ["COUNT", "SUM", "AVG"];
426
426
  var ExportFormat = ["CSV", "JSON"];
427
427
  var FailureStatus = ["PENDING", "RESOLVED", "IGNORED"];
428
- var FailureAction = ["IGNORE", "RESOLVE"];
429
428
  var TaskType = ["PLATFORM_WEBHOOK", "SCHEDULED_DEPLOYMENT"];
430
429
  var PlatformEventType = [
431
430
  "VERSION_PUBLISHED",
@@ -2608,17 +2607,13 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2608
2607
  ).action(async (opts) => {
2609
2608
  try {
2610
2609
  const globalOpts = program.opts();
2611
- await runExport(
2612
- `analytics/simulations/${opts.id}/export`,
2613
- parseFormat(opts.as),
2614
- {
2615
- apiKey: globalOpts.apiKey,
2616
- baseUrl: globalOpts.baseUrl,
2617
- dryRun: globalOpts.dryRun,
2618
- verbose: globalOpts.verbose,
2619
- output: opts.output
2620
- }
2621
- );
2610
+ await runExport(`analytics/simulations/${opts.id}/export`, parseFormat(opts.as), {
2611
+ apiKey: globalOpts.apiKey,
2612
+ baseUrl: globalOpts.baseUrl,
2613
+ dryRun: globalOpts.dryRun,
2614
+ verbose: globalOpts.verbose,
2615
+ output: opts.output
2616
+ });
2622
2617
  } catch (error) {
2623
2618
  printError(error);
2624
2619
  process.exit(1);
@@ -3254,6 +3249,10 @@ function withNotes(notes) {
3254
3249
  return Object.entries(notes).map(([value, note]) => note ? `${value} (${note})` : value);
3255
3250
  }
3256
3251
 
3252
+ // src/lib/console.ts
3253
+ var CONSOLE_URL = "https://console.lexq.io";
3254
+ var FAILURE_LOG_CONSOLE_NOTE = `Logs can be read here but not resolved or ignored. An organization admin does that in the LexQ console: Management \u2192 Failure Logs (${CONSOLE_URL}/management/failure-logs).`;
3255
+
3257
3256
  // src/commands/logs.ts
3258
3257
  var STATUS_NOTES = {
3259
3258
  PENDING: "needs attention",
@@ -3270,8 +3269,8 @@ function registerLogCommands(program) {
3270
3269
  Commands:
3271
3270
  list List failure logs with filters
3272
3271
  get Get failure log detail (includes the original payload)
3273
- action Process a single log (${FailureAction.join(", ")})
3274
- bulk-action Process multiple logs at once
3272
+
3273
+ ${FAILURE_LOG_CONSOLE_NOTE}
3275
3274
 
3276
3275
  Statuses: ${withNotes(STATUS_NOTES).join(", ")}
3277
3276
  `
@@ -3332,7 +3331,8 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3332
3331
  dedent13`
3333
3332
 
3334
3333
  Includes the full payload that was used for the failed operation.
3335
- Use this to inspect what went wrong before deciding to ${FailureAction.join(" or ")}.
3334
+ Use this to inspect what went wrong. Resolving or ignoring a log happens in the
3335
+ console; see lexq logs --help.
3336
3336
  `
3337
3337
  ).action(async (opts) => {
3338
3338
  try {
@@ -3349,63 +3349,6 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3349
3349
  process.exit(1);
3350
3350
  }
3351
3351
  });
3352
- logs.command("action").description(`Process a failure log action (${FailureAction.join(", ")})`).requiredOption("--id <logId>", "Log ID").requiredOption("--action <action>", `Action: ${FailureAction.join(" or ")}`).addHelpText(
3353
- "after",
3354
- dedent13`
3355
-
3356
- Actions:
3357
- IGNORE Mark as intentionally skipped (won't appear in PENDING)
3358
- RESOLVE Mark as manually resolved (e.g., fixed via external system)
3359
-
3360
- Example:
3361
- $ lexq logs action --id <logId> --action RESOLVE
3362
- `
3363
- ).action(async (opts) => {
3364
- try {
3365
- const globalOpts = program.opts();
3366
- const data = await apiRequest(
3367
- "POST",
3368
- `failure-logs/${opts.id}/actions`,
3369
- {
3370
- apiKey: globalOpts.apiKey,
3371
- baseUrl: globalOpts.baseUrl,
3372
- dryRun: globalOpts.dryRun,
3373
- verbose: globalOpts.verbose,
3374
- params: { action: opts.action }
3375
- }
3376
- );
3377
- printJson(data);
3378
- } catch (error) {
3379
- printError(error);
3380
- process.exit(1);
3381
- }
3382
- });
3383
- logs.command("bulk-action").description("Bulk process failure logs").requiredOption("--ids <logIds>", "Comma-separated log IDs").requiredOption("--action <action>", `Action: ${FailureAction.join(" or ")}`).addHelpText(
3384
- "after",
3385
- dedent13`
3386
-
3387
- Processes each log individually. Failures are skipped with a warning.
3388
-
3389
- Example:
3390
- $ lexq logs bulk-action --ids "id1,id2,id3" --action RESOLVE
3391
- `
3392
- ).action(async (opts) => {
3393
- try {
3394
- const globalOpts = program.opts();
3395
- const logIds = opts.ids.split(",").map((id) => id.trim());
3396
- const data = await apiRequest("POST", "failure-logs/bulk-actions", {
3397
- apiKey: globalOpts.apiKey,
3398
- baseUrl: globalOpts.baseUrl,
3399
- dryRun: globalOpts.dryRun,
3400
- verbose: globalOpts.verbose,
3401
- body: { logIds, action: opts.action }
3402
- });
3403
- printJson(data);
3404
- } catch (error) {
3405
- printError(error);
3406
- process.exit(1);
3407
- }
3408
- });
3409
3352
  }
3410
3353
 
3411
3354
  // src/commands/webhook-subscriptions.ts
@@ -3627,6 +3570,21 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
3627
3570
  import { McpServer } from "@modelcontextprotocol/server";
3628
3571
 
3629
3572
  // src/mcp/tools/_shared.ts
3573
+ var READ_ONLY = {
3574
+ readOnlyHint: true,
3575
+ destructiveHint: false,
3576
+ openWorldHint: false
3577
+ };
3578
+ var ADDITIVE = {
3579
+ readOnlyHint: false,
3580
+ destructiveHint: false,
3581
+ openWorldHint: false
3582
+ };
3583
+ var DESTRUCTIVE = {
3584
+ readOnlyHint: false,
3585
+ destructiveHint: true,
3586
+ openWorldHint: false
3587
+ };
3630
3588
  function localApiOptions() {
3631
3589
  const stored = loadConfig();
3632
3590
  return {
@@ -3649,9 +3607,8 @@ function createCallApiFromConfig() {
3649
3607
  form,
3650
3608
  params: opts?.params
3651
3609
  });
3652
- const content = [
3653
- { type: "text", text: stringifyJson(data, 2) ?? "" }
3654
- ];
3610
+ const text = typeof data === "string" ? data : stringifyJson(data, 2) ?? "";
3611
+ const content = [{ type: "text", text }];
3655
3612
  const warning = formatUnregisteredFactWarning(meta);
3656
3613
  if (warning) content.push({ type: "text", text: warning });
3657
3614
  const domainWarning = formatValueDomainMismatchWarning(meta);
@@ -3672,6 +3629,20 @@ function paginationParams(page, size) {
3672
3629
  if (size !== void 0) params.size = String(size);
3673
3630
  return params;
3674
3631
  }
3632
+ var MAX_EXPORT_CHARS = 1e5;
3633
+ var formatCount = (count) => count.toLocaleString("en-US");
3634
+ var EXPORT_LIMIT_NOTE = `An export longer than ${formatCount(MAX_EXPORT_CHARS)} characters is refused.`;
3635
+ function limitExport(result, command, narrower) {
3636
+ if (result.isError) return result;
3637
+ const length = result.content.reduce((sum, block) => sum + block.text.length, 0);
3638
+ if (length <= MAX_EXPORT_CHARS) return result;
3639
+ const text = [
3640
+ `The export is ${formatCount(length)} characters long, and this tool returns at most ${formatCount(MAX_EXPORT_CHARS)}.`,
3641
+ narrower,
3642
+ `To get the whole file, download it from the LexQ console (${CONSOLE_URL}) or with \`${command}\`.`
3643
+ ].filter(Boolean).join(" ");
3644
+ return { content: [{ type: "text", text }], isError: true };
3645
+ }
3675
3646
  function formatUnregisteredFactWarning(meta) {
3676
3647
  const facts = meta?.unregisteredFacts ?? [];
3677
3648
  if (facts.length === 0) return null;
@@ -3683,9 +3654,9 @@ function formatUnregisteredFactWarning(meta) {
3683
3654
  return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
3684
3655
  });
3685
3656
  return [
3686
- `\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved \u2014 this does not block \u2014 but undefined facts skip type validation and the dry-run requirements analyzer.`,
3657
+ `\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved, but the engine does not check request values for these keys against a type or value domain, and lexq_requirements lists them with type UNKNOWN.`,
3687
3658
  ...lines,
3688
- `Register them now so the rule validates as intended, or call lexq_facts_unregistered to review the version's full list.`
3659
+ `Register them before the version is published to have those values checked, or call lexq_facts_unregistered to review the version's full list.`
3689
3660
  ].join("\n");
3690
3661
  }
3691
3662
 
@@ -3711,7 +3682,7 @@ function registerAnalyticsTools(server, callApi) {
3711
3682
  "lexq_dry_run",
3712
3683
  {
3713
3684
  title: "Dry Run",
3714
- annotations: { readOnlyHint: true },
3685
+ annotations: READ_ONLY,
3715
3686
  description: dedent15`
3716
3687
  Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects.
3717
3688
 
@@ -3722,8 +3693,7 @@ function registerAnalyticsTools(server, callApi) {
3722
3693
  executionTraces — per-rule match status
3723
3694
  decisionTraces — per-rule decision (SELECTED / NO_MATCH / BLOCKED / etc.)
3724
3695
 
3725
- Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }
3726
- Always dry-run before publishing to validate rule behavior.`,
3696
+ Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }`,
3727
3697
  inputSchema: z.object({
3728
3698
  versionId: z.string().uuid().describe("Policy version ID to test against"),
3729
3699
  facts: z.string().describe('JSON string of facts object, e.g. {"paymentAmount":100000}'),
@@ -3741,7 +3711,7 @@ function registerAnalyticsTools(server, callApi) {
3741
3711
  "lexq_dry_run_compare",
3742
3712
  {
3743
3713
  title: "Dry Run Compare",
3744
- annotations: { readOnlyHint: true },
3714
+ annotations: READ_ONLY,
3745
3715
  description: dedent15`
3746
3716
  Compare dry run results between two versions using the same input facts. Useful for validating changes.
3747
3717
 
@@ -3766,7 +3736,7 @@ function registerAnalyticsTools(server, callApi) {
3766
3736
  "lexq_requirements",
3767
3737
  {
3768
3738
  title: "Analyze Requirements",
3769
- annotations: { readOnlyHint: true },
3739
+ annotations: READ_ONLY,
3770
3740
  description: "Analyze which input facts a version requires. Returns required keys, types, and an example request body.",
3771
3741
  inputSchema: z.object({
3772
3742
  groupId: z.string().uuid().describe("Policy group ID"),
@@ -3779,10 +3749,13 @@ function registerAnalyticsTools(server, callApi) {
3779
3749
  "lexq_simulation_start",
3780
3750
  {
3781
3751
  title: "Start Simulation",
3782
- annotations: { readOnlyHint: false, destructiveHint: false },
3752
+ annotations: DESTRUCTIVE,
3783
3753
  description: dedent15`
3784
3754
  Start an Impact Simulation against historical, uploaded, or inline data.
3785
3755
 
3756
+ Starting checks the plan's simulation usage limit. The records a simulation processes
3757
+ count toward that usage when it completes or is canceled.
3758
+
3786
3759
  dataset.type and dataset.source are BOTH required, and must be paired:
3787
3760
  HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
3788
3761
  in the organization time zone, both inclusive)
@@ -3823,7 +3796,7 @@ function registerAnalyticsTools(server, callApi) {
3823
3796
  "lexq_simulation_status",
3824
3797
  {
3825
3798
  title: "Simulation Status",
3826
- annotations: { readOnlyHint: true },
3799
+ annotations: READ_ONLY,
3827
3800
  description: "Get simulation status and results. Poll until status is COMPLETED or FAILED.",
3828
3801
  inputSchema: z.object({
3829
3802
  simulationId: z.string().uuid().describe("Simulation ID")
@@ -3835,7 +3808,7 @@ function registerAnalyticsTools(server, callApi) {
3835
3808
  "lexq_simulation_list",
3836
3809
  {
3837
3810
  title: "List Simulations",
3838
- annotations: { readOnlyHint: true },
3811
+ annotations: READ_ONLY,
3839
3812
  description: "List simulation history with optional filters.",
3840
3813
  inputSchema: z.object({
3841
3814
  page: z.number().int().min(0).default(0).describe("Page number"),
@@ -3857,7 +3830,7 @@ function registerAnalyticsTools(server, callApi) {
3857
3830
  "lexq_simulation_cancel",
3858
3831
  {
3859
3832
  title: "Cancel Simulation",
3860
- annotations: { readOnlyHint: false, destructiveHint: true },
3833
+ annotations: DESTRUCTIVE,
3861
3834
  description: "Cancel a running or pending simulation.",
3862
3835
  inputSchema: z.object({
3863
3836
  simulationId: z.string().uuid().describe("Simulation ID")
@@ -3869,22 +3842,25 @@ function registerAnalyticsTools(server, callApi) {
3869
3842
  "lexq_simulation_export",
3870
3843
  {
3871
3844
  title: "Export Simulation",
3872
- annotations: { readOnlyHint: true },
3873
- description: "Export simulation results as JSON or CSV. Returns the raw data.",
3845
+ annotations: READ_ONLY,
3846
+ description: `Export simulation results as JSON or CSV. Returns the raw data. ${EXPORT_LIMIT_NOTE}`,
3874
3847
  inputSchema: z.object({
3875
3848
  simulationId: z.string().uuid().describe("Simulation ID"),
3876
3849
  format: z.enum(EXPORT_FORMATS).default("json").describe("Export format")
3877
3850
  })
3878
3851
  },
3879
- async ({ simulationId, format }) => callApi("GET", `analytics/simulations/${simulationId}/export`, {
3880
- params: { format }
3881
- })
3852
+ async ({ simulationId, format }) => limitExport(
3853
+ await callApi("GET", `analytics/simulations/${simulationId}/export`, {
3854
+ params: { format }
3855
+ }),
3856
+ `lexq analytics simulation export --id ${simulationId} --as ${format} --output <file>`
3857
+ )
3882
3858
  );
3883
3859
  server.registerTool(
3884
3860
  "lexq_dataset_upload",
3885
3861
  {
3886
3862
  title: "Upload Dataset",
3887
- annotations: { readOnlyHint: false, destructiveHint: false },
3863
+ annotations: ADDITIVE,
3888
3864
  description: dedent15`
3889
3865
  Upload inline CSV or JSON content as a simulation dataset.
3890
3866
  The content is uploaded to S3 and a path is returned in the "path" field.
@@ -3927,7 +3903,7 @@ function registerAnalyticsTools(server, callApi) {
3927
3903
  "lexq_dataset_template",
3928
3904
  {
3929
3905
  title: "Download Dataset Template",
3930
- annotations: { readOnlyHint: true },
3906
+ annotations: READ_ONLY,
3931
3907
  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.",
3932
3908
  inputSchema: z.object({
3933
3909
  groupId: z.string().uuid().describe("Policy group ID"),
@@ -3984,7 +3960,7 @@ function registerLocalTools(server) {
3984
3960
  "lexq_dataset_upload_file",
3985
3961
  {
3986
3962
  title: "Upload Dataset File",
3987
- annotations: { readOnlyHint: false, destructiveHint: false },
3963
+ annotations: ADDITIVE,
3988
3964
  description: dedent16`
3989
3965
  Upload a CSV or JSON file from this computer as a simulation dataset, up to ${MAX_DATASET_MB} MB.
3990
3966
  The server reads the file from disk, so its content never passes through the conversation.
@@ -4043,7 +4019,7 @@ function registerStatusTools(server, callApi) {
4043
4019
  "lexq_whoami",
4044
4020
  {
4045
4021
  title: "Who Am I",
4046
- annotations: { readOnlyHint: true },
4022
+ annotations: READ_ONLY,
4047
4023
  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.',
4048
4024
  inputSchema: z3.object({})
4049
4025
  },
@@ -4058,7 +4034,7 @@ function registerGroupTools(server, callApi) {
4058
4034
  "lexq_groups_list",
4059
4035
  {
4060
4036
  title: "List Policy Groups",
4061
- annotations: { readOnlyHint: true },
4037
+ annotations: READ_ONLY,
4062
4038
  description: "List policy groups (tenant-wide, priority ASC). Deleted groups are not included.",
4063
4039
  inputSchema: z4.object({})
4064
4040
  },
@@ -4068,7 +4044,7 @@ function registerGroupTools(server, callApi) {
4068
4044
  "lexq_groups_get",
4069
4045
  {
4070
4046
  title: "Get Policy Group",
4071
- annotations: { readOnlyHint: true },
4047
+ annotations: READ_ONLY,
4072
4048
  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.",
4073
4049
  inputSchema: z4.object({
4074
4050
  groupId: z4.string().uuid().describe("Policy group ID")
@@ -4080,7 +4056,7 @@ function registerGroupTools(server, callApi) {
4080
4056
  "lexq_groups_create",
4081
4057
  {
4082
4058
  title: "Create Policy Group",
4083
- annotations: { readOnlyHint: false, destructiveHint: false },
4059
+ annotations: ADDITIVE,
4084
4060
  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.`,
4085
4061
  inputSchema: z4.object({
4086
4062
  name: z4.string().describe("Group name (unique among non-ARCHIVED)"),
@@ -4111,7 +4087,7 @@ function registerGroupTools(server, callApi) {
4111
4087
  "lexq_groups_update",
4112
4088
  {
4113
4089
  title: "Update Policy Group",
4114
- annotations: { readOnlyHint: false, destructiveHint: true },
4090
+ annotations: DESTRUCTIVE,
4115
4091
  description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged. A deleted group cannot be updated (409 P-059).",
4116
4092
  inputSchema: z4.object({
4117
4093
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -4136,7 +4112,7 @@ function registerGroupTools(server, callApi) {
4136
4112
  "lexq_groups_delete",
4137
4113
  {
4138
4114
  title: "Delete Policy Group",
4139
- annotations: { readOnlyHint: false, destructiveHint: true },
4115
+ annotations: DESTRUCTIVE,
4140
4116
  description: `Delete (archive) a policy group. A deployed group cannot be deleted (P-020); undeploy it first with lexq_deploy_undeploy, which also ends a running A/B test. This cannot be undone. The group no longer appears in lexq_groups_list and its name can be reused right away. Calls that change it (settings, versions, rules, starting an A/B test, publish, deploy, rollback, scheduling a deployment) return 409 P-059. If it was ever executed, deployed, or replayed, it is kept for audit; otherwise it is permanently deleted after ${PURGE_AFTER_DAYS} days.`,
4141
4117
  inputSchema: z4.object({
4142
4118
  groupId: z4.string().uuid().describe("Policy group ID")
@@ -4148,7 +4124,7 @@ function registerGroupTools(server, callApi) {
4148
4124
  "lexq_groups_reorder",
4149
4125
  {
4150
4126
  title: "Reorder Policy Groups",
4151
- annotations: { readOnlyHint: false, destructiveHint: true },
4127
+ annotations: DESTRUCTIVE,
4152
4128
  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.",
4153
4129
  inputSchema: z4.object({
4154
4130
  groupIds: z4.array(z4.string().uuid()).describe("Group IDs in desired priority order (index 0 = priority 1)")
@@ -4166,8 +4142,8 @@ function registerGroupTools(server, callApi) {
4166
4142
  "lexq_ab_test_start",
4167
4143
  {
4168
4144
  title: "Start A/B Test",
4169
- annotations: { readOnlyHint: false, destructiveHint: false },
4170
- 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%.",
4145
+ annotations: DESTRUCTIVE,
4146
+ description: "Start an A/B test on a policy group. Requires a challenger version ID and traffic rate. The challenger must be a published version of the same group; a version from another group is rejected with P-016. The split is computed from context.trafficKey on each execution request; requests that omit it never reach the challenger and the test stays at 0%. Starting cancels the group's pending scheduled deployment. Starting while a test is already running replaces its challenger and traffic rate.",
4171
4147
  inputSchema: z4.object({
4172
4148
  groupId: z4.string().uuid().describe("Policy group ID"),
4173
4149
  testVersionId: z4.string().uuid().describe("Challenger version ID. Must be a published version of this group"),
@@ -4182,7 +4158,7 @@ function registerGroupTools(server, callApi) {
4182
4158
  "lexq_ab_test_stop",
4183
4159
  {
4184
4160
  title: "Stop A/B Test",
4185
- annotations: { readOnlyHint: false, destructiveHint: true },
4161
+ annotations: DESTRUCTIVE,
4186
4162
  description: "Stop a running A/B test. All traffic is restored to the control (current) version.",
4187
4163
  inputSchema: z4.object({
4188
4164
  groupId: z4.string().uuid().describe("Policy group ID")
@@ -4194,7 +4170,7 @@ function registerGroupTools(server, callApi) {
4194
4170
  "lexq_ab_test_adjust",
4195
4171
  {
4196
4172
  title: "Adjust A/B Test",
4197
- annotations: { readOnlyHint: false, destructiveHint: true },
4173
+ annotations: DESTRUCTIVE,
4198
4174
  description: "Adjust traffic rate of a running A/B test.",
4199
4175
  inputSchema: z4.object({
4200
4176
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -4214,7 +4190,7 @@ function registerVersionTools(server, callApi) {
4214
4190
  "lexq_versions_list",
4215
4191
  {
4216
4192
  title: "List Policy Versions",
4217
- annotations: { readOnlyHint: true },
4193
+ annotations: READ_ONLY,
4218
4194
  description: "List all versions of a policy group.",
4219
4195
  inputSchema: z5.object({
4220
4196
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4230,7 +4206,7 @@ function registerVersionTools(server, callApi) {
4230
4206
  "lexq_versions_get",
4231
4207
  {
4232
4208
  title: "Get Policy Version",
4233
- annotations: { readOnlyHint: true },
4209
+ annotations: READ_ONLY,
4234
4210
  description: "Get a single version by ID, including its rules and fact requirements.",
4235
4211
  inputSchema: z5.object({
4236
4212
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4243,7 +4219,7 @@ function registerVersionTools(server, callApi) {
4243
4219
  "lexq_versions_create",
4244
4220
  {
4245
4221
  title: "Create Policy Version",
4246
- annotations: { readOnlyHint: false, destructiveHint: false },
4222
+ annotations: ADDITIVE,
4247
4223
  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.`,
4248
4224
  inputSchema: z5.object({
4249
4225
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4258,7 +4234,7 @@ function registerVersionTools(server, callApi) {
4258
4234
  "lexq_versions_update",
4259
4235
  {
4260
4236
  title: "Update Policy Version",
4261
- annotations: { readOnlyHint: false, destructiveHint: true },
4237
+ annotations: DESTRUCTIVE,
4262
4238
  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.",
4263
4239
  inputSchema: z5.object({
4264
4240
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4266,7 +4242,9 @@ function registerVersionTools(server, callApi) {
4266
4242
  commitMessage: z5.string().optional().describe("New commit message"),
4267
4243
  effectiveFrom: z5.string().optional().describe(`Effective start, ${INSTANT} Omit it to keep the current start.`),
4268
4244
  effectiveTo: z5.string().optional().describe(`Effective end, ${INSTANT} Omit it to keep the current end.`),
4269
- clearEffectiveFrom: z5.boolean().optional().describe("Set to true to remove the effective start. Do not send effectiveFrom with it."),
4245
+ clearEffectiveFrom: z5.boolean().optional().describe(
4246
+ "Set to true to remove the effective start. Do not send effectiveFrom with it."
4247
+ ),
4270
4248
  clearEffectiveTo: z5.boolean().optional().describe("Set to true to remove the effective end. Do not send effectiveTo with it.")
4271
4249
  })
4272
4250
  },
@@ -4276,7 +4254,7 @@ function registerVersionTools(server, callApi) {
4276
4254
  "lexq_versions_delete",
4277
4255
  {
4278
4256
  title: "Delete Policy Version",
4279
- annotations: { readOnlyHint: false, destructiveHint: true },
4257
+ annotations: DESTRUCTIVE,
4280
4258
  description: "Delete a DRAFT version. Only DRAFT versions can be deleted.",
4281
4259
  inputSchema: z5.object({
4282
4260
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4289,7 +4267,7 @@ function registerVersionTools(server, callApi) {
4289
4267
  "lexq_versions_clone",
4290
4268
  {
4291
4269
  title: "Clone Policy Version",
4292
- annotations: { readOnlyHint: false, destructiveHint: false },
4270
+ annotations: ADDITIVE,
4293
4271
  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.`,
4294
4272
  inputSchema: z5.object({
4295
4273
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4313,7 +4291,7 @@ function registerRuleTools(server, callApi) {
4313
4291
  "lexq_rules_list",
4314
4292
  {
4315
4293
  title: "List Rules",
4316
- annotations: { readOnlyHint: true },
4294
+ annotations: READ_ONLY,
4317
4295
  description: "List all rules in a version (priority ASC). Returns summary with conditionSummary and actionSummary.",
4318
4296
  inputSchema: z6.object({
4319
4297
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4326,7 +4304,7 @@ function registerRuleTools(server, callApi) {
4326
4304
  "lexq_rules_get",
4327
4305
  {
4328
4306
  title: "Get Rule Detail",
4329
- annotations: { readOnlyHint: true },
4307
+ annotations: READ_ONLY,
4330
4308
  description: "Get full rule detail including condition tree and action definitions.",
4331
4309
  inputSchema: z6.object({
4332
4310
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4340,87 +4318,39 @@ function registerRuleTools(server, callApi) {
4340
4318
  "lexq_rules_create",
4341
4319
  {
4342
4320
  title: "Create Rule",
4343
- annotations: { readOnlyHint: false, destructiveHint: false },
4321
+ annotations: ADDITIVE,
4344
4322
  description: dedent17`
4345
- 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.
4346
-
4347
- Before creating rules with new fact keys, call lexq_facts_list to check existing facts.
4348
- If a required key is missing, ask the user to confirm the type, isRequired, and description
4349
- before calling lexq_facts_create — registering facts enables type validation, Console UI
4350
- autocomplete, and the dry-run requirements analyzer.
4323
+ Create a rule in a DRAFT version. It goes last in priority order; lexq_rules_reorder changes the order.
4351
4324
 
4352
- An action's targetVar (SET_FACT, MUTATE_FACT) must be a registered fact. Saving or
4353
- publishing a rule whose targetVar is not registered fails with ACT-032, so register it
4354
- with lexq_facts_create first. Keys a rule only reads (condition fields, refVar) may stay
4355
- unregistered.
4356
-
4357
- After saving, lexq_facts_unregistered lists any keys this version references but has not
4358
- defined (non-blocking, version-wide) — use it to decide what to register.
4325
+ An action's targetVar must be a registered fact (ACT-032 otherwise). Keys a rule only reads (condition fields, refVar) may stay unregistered.
4359
4326
 
4360
4327
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: ${oneOf(LogicalOperator)}, children: [...] }
4361
4328
  Value types: ${ValueType.join(", ")}
4362
- Limits: a condition tree may hold at most ${MAX_CONDITIONS_PER_RULE} conditions (SINGLE nodes; GROUP nodes do not count)
4363
- and nest at most ${MAX_CONDITION_DEPTH} levels below the root. A version holds at most ${MAX_RULES_PER_VERSION} rules.
4364
-
4365
- Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
4366
- its type is rejected by the server — check the fact type before choosing an operator.
4367
- - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
4368
- - NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
4369
- - BOOLEAN fact: EQUALS, NOT_EQUALS
4370
- - LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
4371
-
4372
- HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
4373
- - HAS_ANY: fact has at least one of the given values
4374
- - HAS_ALL: fact has all of the given values
4375
- - HAS_NONE: fact has none of the given values
4329
+ Limits: ${MAX_CONDITIONS_PER_RULE} SINGLE conditions per rule (GROUP nodes do not count), ${MAX_CONDITION_DEPTH} levels of nesting below the root, ${MAX_RULES_PER_VERSION} rules per version.
4330
+
4331
+ Operators that work on each fact type:
4332
+ - STRING: EQUALS, NOT_EQUALS, CONTAINS (substring), IN, NOT_IN
4333
+ - NUMBER: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
4334
+ - BOOLEAN: EQUALS, NOT_EQUALS
4335
+ - LIST_STRING, LIST_NUMBER: HAS_ANY, HAS_ALL, HAS_NONE
4336
+ IN and NOT_IN test whether the fact's value is in the array. HAS_ANY, HAS_ALL and HAS_NONE match when the fact's list holds at least one, all, or none of the array's values. The array holds values of the fact's type, or for a list fact, of its element type.
4376
4337
  Example: { "type": "SINGLE", "field": "userTags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
4377
4338
 
4378
- Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
4379
- IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
4380
-
4381
4339
  Actions: [{ type, parameters }]
4382
-
4383
- Action parameter schemas:
4384
- - MUTATE_FACT: { targetVar: string, operator: ${oneOf(MutateOperator)}, method: ${oneOf(CalculationMethod)}, operand: number, refVar?: string, rounding?: RoundingOption }
4385
- targetVar is the fact this action reads and writes. It must be a registered fact
4386
- (ACT-032 otherwise), and the request must send it (P-015 otherwise). Its value must be
4387
- a number when the action runs.
4388
- A missing required fact throws (no 0 default).
4389
- operand is the arithmetic operand; the unit is dictated by method (percent when
4390
- PERCENTAGE, absolute amount when AMOUNT). Ranges are not constrained — negative values
4391
- and >100 percentages are valid (refunds, surcharges).
4392
- refVar is the base for percentage calculation and is OPTIONAL — omit it to use targetVar
4393
- itself. It is only meaningful in PERCENTAGE × {ASSIGN, ADD, SUB}; specifying it in any
4394
- other cell is an error. Use it when the base differs from the target, e.g.
4395
- "points += orderTotal × 5%" → { targetVar: "points", refVar: "orderTotal",
4396
- operator: "ADD", method: "PERCENTAGE", operand: 5 }.
4397
- operator × method matrix:
4398
- ASSIGN targetVar = operand | targetVar = refVar × operand/100
4399
- ADD targetVar += operand | targetVar += refVar × operand/100
4400
- SUB targetVar -= operand | targetVar -= refVar × operand/100
4401
- MUL targetVar *= operand | targetVar *= (operand/100 + 1)
4402
- DIV targetVar /= operand | invalid
4403
- Constraints: DIV + PERCENTAGE is invalid (use MUL with the inverse). DIV + AMOUNT
4404
- requires operand !== 0.
4405
- - SET_FACT: { targetVar: string, value: string|number|boolean } targetVar must be a
4406
- registered fact (ACT-032 otherwise). The request does not have to send it: SET_FACT is
4407
- the only action whose targetVar may be absent from the input facts. MUTATE_FACT requires
4408
- its target to be present in the input facts.
4409
- - BLOCK: { reason?: string } Records a rejection decision. It does NOT halt rule execution —
4410
- subsequent actions and subsequent winning rules still run. Enforcement is the caller's
4411
- responsibility; the decision surfaces as isBlocked (true) and blockReason (reason, or
4412
- "Policy Violation" when omitted).
4413
- Only BLOCK writes those two keys: saving or publishing a rule whose SET_FACT or
4414
- MUTATE_FACT targets either one fails with ACT-033.
4415
-
4416
- Evaluation order: every rule's condition is checked against the input facts before any
4417
- action runs, so a condition never sees a value an action writes in the same request. Then
4418
- the actions of the winning rules run in priority order. Each action works on the values
4419
- the actions before it left, in its own rule or an earlier one. To test a value an action
4420
- writes, send that value as an input fact in a second request. If a rule should run
4421
- whenever another one does, put its actions in that rule instead.
4422
-
4423
- RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: ${oneOf(RoundingPolicy)} } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
4340
+ - MUTATE_FACT: { targetVar, operator: ${oneOf(MutateOperator)}, method: ${oneOf(CalculationMethod)}, operand: number, refVar?, rounding? }
4341
+ Reads and writes targetVar. The request must send it (P-015 otherwise), and it must hold a number when the action runs. operand may be negative, and a percent may be over 100.
4342
+ AMOUNT PERCENTAGE (base = refVar, or targetVar when omitted)
4343
+ ASSIGN targetVar = operand targetVar = base × operand/100
4344
+ ADD targetVar += operand targetVar += base × operand/100
4345
+ SUB targetVar -= operand targetVar -= base × operand/100
4346
+ MUL targetVar *= operand targetVar *= (operand/100 + 1)
4347
+ DIV targetVar /= operand invalid
4348
+ refVar is allowed only with PERCENTAGE and ASSIGN, ADD or SUB. DIV with AMOUNT needs a nonzero operand.
4349
+ rounding: { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: ${oneOf(RoundingPolicy)} }. mode defaults to HALF_UP. Without rounding, the result keeps full precision.
4350
+ - SET_FACT: { targetVar, value: string|number|boolean }. The request does not have to send targetVar.
4351
+ - BLOCK: { reason? } sets isBlocked to true and blockReason to the reason, or "Policy Violation" without one. It does not stop later actions or rules; the caller enforces the block. Only BLOCK writes these two keys: a SET_FACT or MUTATE_FACT targeting either is rejected (ACT-033).
4352
+
4353
+ Every condition is checked against the input facts before any action runs, so a condition never sees a value an action writes in the same request. The winning rules' actions then run in priority order, each on the values the actions before it left.
4424
4354
  `,
4425
4355
  inputSchema: z6.object({
4426
4356
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4439,7 +4369,7 @@ function registerRuleTools(server, callApi) {
4439
4369
  "lexq_rules_update",
4440
4370
  {
4441
4371
  title: "Update Rule",
4442
- annotations: { readOnlyHint: false, destructiveHint: true },
4372
+ annotations: DESTRUCTIVE,
4443
4373
  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.`,
4444
4374
  inputSchema: z6.object({
4445
4375
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4461,28 +4391,21 @@ function registerRuleTools(server, callApi) {
4461
4391
  "lexq_rules_delete",
4462
4392
  {
4463
4393
  title: "Delete Rule",
4464
- annotations: { readOnlyHint: false, destructiveHint: true },
4394
+ annotations: DESTRUCTIVE,
4465
4395
  description: "Delete a rule from a DRAFT version.",
4466
4396
  inputSchema: z6.object({
4467
4397
  groupId: z6.string().uuid().describe("Policy group ID"),
4468
4398
  versionId: z6.string().uuid().describe("Version ID"),
4469
- ruleId: z6.string().uuid().describe("Rule ID"),
4470
- force: z6.boolean().default(false).describe("Skip confirmation (for the last rule in a version)")
4399
+ ruleId: z6.string().uuid().describe("Rule ID")
4471
4400
  })
4472
4401
  },
4473
- async ({ groupId, versionId, ruleId, force }) => {
4474
- const params = {};
4475
- if (force) params.force = "true";
4476
- return callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`, {
4477
- params
4478
- });
4479
- }
4402
+ async ({ groupId, versionId, ruleId }) => callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`)
4480
4403
  );
4481
4404
  server.registerTool(
4482
4405
  "lexq_rules_reorder",
4483
4406
  {
4484
4407
  title: "Reorder Rules",
4485
- annotations: { readOnlyHint: false, destructiveHint: true },
4408
+ annotations: DESTRUCTIVE,
4486
4409
  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).",
4487
4410
  inputSchema: z6.object({
4488
4411
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4504,7 +4427,7 @@ function registerRuleTools(server, callApi) {
4504
4427
  "lexq_rules_toggle",
4505
4428
  {
4506
4429
  title: "Toggle Rule",
4507
- annotations: { readOnlyHint: false, destructiveHint: true },
4430
+ annotations: DESTRUCTIVE,
4508
4431
  description: "Enable or disable a rule without deleting it.",
4509
4432
  inputSchema: z6.object({
4510
4433
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4527,13 +4450,14 @@ var FACT_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
4527
4450
 
4528
4451
  // src/mcp/tools/facts.ts
4529
4452
  var VALUE_TYPES = ValueType;
4453
+ var IS_REQUIRED_NOTE = "isRequired is stored on the definition only; the engine does not read it.";
4530
4454
  function registerFactTools(server, callApi) {
4531
4455
  server.registerTool(
4532
4456
  "lexq_facts_list",
4533
4457
  {
4534
4458
  title: "List Fact Definitions",
4535
- annotations: { readOnlyHint: true },
4536
- description: "List all fact definitions (input variable schema). Shows key, type, required, and PII status. Always check this before creating rules.",
4459
+ annotations: READ_ONLY,
4460
+ description: `List all fact definitions (input variable schema): key, name, type, description, PII and system flags, and value domain. ${IS_REQUIRED_NOTE}`,
4537
4461
  inputSchema: z7.object({
4538
4462
  page: z7.number().int().min(0).default(0).describe("Page number"),
4539
4463
  size: z7.number().int().min(1).max(100).default(20).describe("Page size"),
@@ -4550,14 +4474,14 @@ function registerFactTools(server, callApi) {
4550
4474
  "lexq_facts_create",
4551
4475
  {
4552
4476
  title: "Create Fact Definition",
4553
- annotations: { readOnlyHint: false, destructiveHint: false },
4477
+ annotations: ADDITIVE,
4554
4478
  description: `Register a new input variable. Key starts with a letter, then letters, numbers, and underscores (e.g. paymentAmount). Casing is not enforced. Types: ${VALUE_TYPES.join(", ")}.`,
4555
4479
  inputSchema: z7.object({
4556
4480
  key: z7.string().regex(FACT_KEY_PATTERN).describe("Variable key. Any casing; must start with a letter."),
4557
4481
  name: z7.string().describe("Display name"),
4558
4482
  type: z7.enum(VALUE_TYPES).describe("Value type"),
4559
4483
  description: z7.string().optional().describe("Description"),
4560
- isRequired: z7.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
4484
+ isRequired: z7.boolean().default(false).describe(IS_REQUIRED_NOTE),
4561
4485
  isPii: z7.boolean().default(false).describe(
4562
4486
  "Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
4563
4487
  ),
@@ -4578,7 +4502,7 @@ function registerFactTools(server, callApi) {
4578
4502
  "lexq_facts_update",
4579
4503
  {
4580
4504
  title: "Update Fact Definition",
4581
- annotations: { readOnlyHint: false, destructiveHint: true },
4505
+ annotations: DESTRUCTIVE,
4582
4506
  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).",
4583
4507
  inputSchema: z7.object({
4584
4508
  factId: z7.string().uuid().describe("Fact definition ID"),
@@ -4587,7 +4511,7 @@ function registerFactTools(server, callApi) {
4587
4511
  type: z7.enum(VALUE_TYPES).optional().describe(
4588
4512
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
4589
4513
  ),
4590
- isRequired: z7.boolean().optional().describe("Required flag"),
4514
+ isRequired: z7.boolean().optional().describe(IS_REQUIRED_NOTE),
4591
4515
  isPii: z7.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
4592
4516
  valueDomain: z7.object({
4593
4517
  allowedValues: z7.array(z7.union([z7.string(), z7.number()])).optional().describe(
@@ -4606,7 +4530,7 @@ function registerFactTools(server, callApi) {
4606
4530
  "lexq_facts_delete",
4607
4531
  {
4608
4532
  title: "Delete Fact Definition",
4609
- annotations: { readOnlyHint: false, destructiveHint: true },
4533
+ annotations: DESTRUCTIVE,
4610
4534
  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.",
4611
4535
  inputSchema: z7.object({
4612
4536
  factId: z7.string().uuid().describe("Fact definition ID")
@@ -4618,7 +4542,7 @@ function registerFactTools(server, callApi) {
4618
4542
  "lexq_facts_action_metadata",
4619
4543
  {
4620
4544
  title: "Get Action Runtime Fact Metadata",
4621
- annotations: { readOnlyHint: true },
4545
+ annotations: READ_ONLY,
4622
4546
  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.",
4623
4547
  inputSchema: z7.object({})
4624
4548
  },
@@ -4628,8 +4552,8 @@ function registerFactTools(server, callApi) {
4628
4552
  "lexq_facts_unregistered",
4629
4553
  {
4630
4554
  title: "List Unregistered Facts",
4631
- annotations: { readOnlyHint: true },
4632
- 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.",
4555
+ annotations: READ_ONLY,
4556
+ description: "List facts referenced by a version's rules but not yet defined (read-only). Keys that rules only read do not block publish/deploy; a rule whose action targetVar is not a registered fact is rejected at save and publish with ACT-032. Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Registering one with lexq_facts_create before the version is published makes the engine check request values for it against its type (P-060) and any value domain (P-052).",
4633
4557
  inputSchema: z7.object({
4634
4558
  groupId: z7.string().uuid().describe("Policy group ID"),
4635
4559
  versionId: z7.string().uuid().describe("Version ID")
@@ -4641,16 +4565,20 @@ function registerFactTools(server, callApi) {
4641
4565
  "lexq_facts_export",
4642
4566
  {
4643
4567
  title: "Export Fact Catalog",
4644
- annotations: { readOnlyHint: true },
4645
- 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.",
4568
+ annotations: READ_ONLY,
4569
+ description: `Export the fact catalog. The two formats carry different things: CSV is the catalog as it stands, system facts included, for reading in a spreadsheet, without value domains; JSON lists each definition with only the fields a new definition takes (key, name, type, description, isRequired, isPii, valueDomain), without IDs. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
4646
4570
  inputSchema: z7.object({
4647
4571
  format: z7.enum(EXPORT_FORMATS).default("csv").describe("Export format"),
4648
4572
  keyword: z7.string().optional().describe("Filter by key or name")
4649
4573
  })
4650
4574
  },
4651
- async ({ format, keyword }) => callApi("GET", "schema/facts/export", {
4652
- params: keyword ? { format, keyword } : { format }
4653
- })
4575
+ async ({ format, keyword }) => limitExport(
4576
+ await callApi("GET", "schema/facts/export", {
4577
+ params: keyword ? { format, keyword } : { format }
4578
+ }),
4579
+ `lexq facts export --as ${format} --output <file>`,
4580
+ "Pass keyword to export fewer facts."
4581
+ )
4654
4582
  );
4655
4583
  }
4656
4584
 
@@ -4662,7 +4590,7 @@ function registerDeployTools(server, callApi) {
4662
4590
  "lexq_deploy_publish",
4663
4591
  {
4664
4592
  title: "Publish Version",
4665
- annotations: { readOnlyHint: false, destructiveHint: false },
4593
+ annotations: DESTRUCTIVE,
4666
4594
  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.`,
4667
4595
  inputSchema: z8.object({
4668
4596
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4676,7 +4604,7 @@ function registerDeployTools(server, callApi) {
4676
4604
  "lexq_deploy_live",
4677
4605
  {
4678
4606
  title: "Deploy to Live",
4679
- annotations: { readOnlyHint: false, destructiveHint: true },
4607
+ annotations: DESTRUCTIVE,
4680
4608
  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.",
4681
4609
  inputSchema: z8.object({
4682
4610
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4692,7 +4620,7 @@ function registerDeployTools(server, callApi) {
4692
4620
  "lexq_deploy_rollback",
4693
4621
  {
4694
4622
  title: "Rollback Deployment",
4695
- annotations: { readOnlyHint: false, destructiveHint: true },
4623
+ annotations: DESTRUCTIVE,
4696
4624
  description: dedent18`
4697
4625
  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).
4698
4626
 
@@ -4713,7 +4641,7 @@ function registerDeployTools(server, callApi) {
4713
4641
  "lexq_deploy_undeploy",
4714
4642
  {
4715
4643
  title: "Undeploy",
4716
- annotations: { readOnlyHint: false, destructiveHint: true },
4644
+ annotations: DESTRUCTIVE,
4717
4645
  description: "Remove the live version from traffic. The version stays ACTIVE but no longer serves requests.",
4718
4646
  inputSchema: z8.object({
4719
4647
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4728,7 +4656,7 @@ function registerDeployTools(server, callApi) {
4728
4656
  "lexq_deploy_schedule",
4729
4657
  {
4730
4658
  title: "Schedule Deployment",
4731
- annotations: { readOnlyHint: false, destructiveHint: false },
4659
+ annotations: DESTRUCTIVE,
4732
4660
  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).",
4733
4661
  inputSchema: z8.object({
4734
4662
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4744,7 +4672,7 @@ function registerDeployTools(server, callApi) {
4744
4672
  "lexq_deploy_unschedule",
4745
4673
  {
4746
4674
  title: "Cancel Scheduled Deployment",
4747
- annotations: { readOnlyHint: false, destructiveHint: true },
4675
+ annotations: DESTRUCTIVE,
4748
4676
  description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
4749
4677
  inputSchema: z8.object({
4750
4678
  groupId: z8.string().uuid().describe("Policy group ID")
@@ -4756,7 +4684,7 @@ function registerDeployTools(server, callApi) {
4756
4684
  "lexq_deploy_schedules",
4757
4685
  {
4758
4686
  title: "List Scheduled Deployments",
4759
- annotations: { readOnlyHint: true },
4687
+ annotations: READ_ONLY,
4760
4688
  description: `List scheduled deployments across all groups (all statuses: ${DeploymentScheduleStatus.join(", ")}), newest first.`,
4761
4689
  inputSchema: z8.object({
4762
4690
  page: z8.number().int().min(0).default(0).describe("Page number"),
@@ -4769,7 +4697,7 @@ function registerDeployTools(server, callApi) {
4769
4697
  "lexq_deploy_history",
4770
4698
  {
4771
4699
  title: "Deployment History",
4772
- annotations: { readOnlyHint: true },
4700
+ annotations: READ_ONLY,
4773
4701
  description: "List deployment history across all groups.",
4774
4702
  inputSchema: z8.object({
4775
4703
  page: z8.number().int().min(0).default(0).describe("Page number"),
@@ -4793,7 +4721,7 @@ function registerDeployTools(server, callApi) {
4793
4721
  "lexq_deploy_detail",
4794
4722
  {
4795
4723
  title: "Deployment Detail",
4796
- annotations: { readOnlyHint: true },
4724
+ annotations: READ_ONLY,
4797
4725
  description: "Get detailed info about a specific deployment including snapshot hash and integrity check.",
4798
4726
  inputSchema: z8.object({
4799
4727
  deploymentId: z8.string().uuid().describe("Deployment ID")
@@ -4805,7 +4733,7 @@ function registerDeployTools(server, callApi) {
4805
4733
  "lexq_deploy_overview",
4806
4734
  {
4807
4735
  title: "Deployment Overview",
4808
- annotations: { readOnlyHint: true },
4736
+ annotations: READ_ONLY,
4809
4737
  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.",
4810
4738
  inputSchema: z8.object({})
4811
4739
  },
@@ -4815,7 +4743,7 @@ function registerDeployTools(server, callApi) {
4815
4743
  "lexq_deploy_deployable",
4816
4744
  {
4817
4745
  title: "List Deployable Versions",
4818
- annotations: { readOnlyHint: true },
4746
+ annotations: READ_ONLY,
4819
4747
  description: "List ACTIVE (published) versions that can be deployed for a group. Use this to find which versions are available before calling deploy live.",
4820
4748
  inputSchema: z8.object({
4821
4749
  groupId: z8.string().uuid().describe("Policy group ID")
@@ -4827,7 +4755,7 @@ function registerDeployTools(server, callApi) {
4827
4755
  "lexq_deploy_diff",
4828
4756
  {
4829
4757
  title: "Deployment Diff",
4830
- annotations: { readOnlyHint: true },
4758
+ annotations: READ_ONLY,
4831
4759
  description: "Compare rule snapshots between two versions. Shows added, removed, and modified rules. Useful for reviewing changes before deploying a new version.",
4832
4760
  inputSchema: z8.object({
4833
4761
  baseVersionId: z8.string().uuid().describe("Base version ID (typically the current live)"),
@@ -4856,7 +4784,7 @@ function registerProfileTools(server, callApi) {
4856
4784
  "lexq_profile_overview",
4857
4785
  {
4858
4786
  title: "Group Latency Profile",
4859
- annotations: { readOnlyHint: true },
4787
+ annotations: READ_ONLY,
4860
4788
  description: "Per-rule latency profile of a policy group over a time window: group TOTAL distribution split by cache state (HIT = compiled ruleset cache hit, MISS = deep-load + compile), a per-rule CONDITION/ACTION percentile table, and slow-rule flags. " + RELATIVE_THRESHOLD + ` Every percentile is accompanied by its sample count n; a percentile is withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 needs n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99} \u2014 display gate, separate from the n \u2265 ${MIN_SAMPLES} judgment gate). Baselines report INSUFFICIENT_COHORT when fewer than ${MIN_COHORT_SIZE} rules qualify. Rule detail comes from a deterministic ${PROFILE_SAMPLE_PERMILLE / 10}% sample of calls; TOTAL is recorded for every call. Defaults: last ${PROFILE_DEFAULT_WINDOW_HOURS}h, live version, cacheState HIT.`,
4861
4789
  inputSchema: z9.object({
4862
4790
  groupId: z9.string().uuid().describe("Policy group ID"),
@@ -4876,7 +4804,7 @@ function registerProfileTools(server, callApi) {
4876
4804
  "lexq_profile_rule",
4877
4805
  {
4878
4806
  title: "Rule Latency Detail",
4879
- annotations: { readOnlyHint: true },
4807
+ annotations: READ_ONLY,
4880
4808
  description: `Single-rule latency detail: merged phase \xD7 cacheState distributions plus a per-window time series (${LATENCY_WINDOW_MILLIS / 1e3}s windows). Missing windows are genuine gaps \u2014 never interpolated. Series points carry each window's own values; percentiles in merged distributions are withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99}). ` + RELATIVE_THRESHOLD,
4881
4809
  inputSchema: z9.object({
4882
4810
  groupId: z9.string().uuid().describe("Policy group ID"),
@@ -4901,7 +4829,7 @@ function registerReplayTools(server, callApi) {
4901
4829
  "lexq_replay_decision",
4902
4830
  {
4903
4831
  title: "Replay a Decision",
4904
- annotations: { readOnlyHint: true },
4832
+ annotations: READ_ONLY,
4905
4833
  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.",
4906
4834
  inputSchema: z10.object({
4907
4835
  traceId: z10.string().describe("Trace ID of the past execution to replay"),
@@ -4914,7 +4842,7 @@ function registerReplayTools(server, callApi) {
4914
4842
  "lexq_replay_start",
4915
4843
  {
4916
4844
  title: "Start Window Replay (Blast Radius)",
4917
- annotations: { readOnlyHint: false, destructiveHint: false },
4845
+ annotations: DESTRUCTIVE,
4918
4846
  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.",
4919
4847
  inputSchema: z10.object({
4920
4848
  candidateVersionId: z10.string().uuid().describe("Version to re-evaluate against"),
@@ -4931,7 +4859,7 @@ function registerReplayTools(server, callApi) {
4931
4859
  "lexq_replay_status",
4932
4860
  {
4933
4861
  title: "Get Replay Job Status",
4934
- annotations: { readOnlyHint: true },
4862
+ annotations: READ_ONLY,
4935
4863
  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.",
4936
4864
  inputSchema: z10.object({
4937
4865
  jobId: z10.string().describe("Replay job ID from lexq_replay_start")
@@ -4943,7 +4871,7 @@ function registerReplayTools(server, callApi) {
4943
4871
  "lexq_replay_list",
4944
4872
  {
4945
4873
  title: "List Replay Jobs",
4946
- annotations: { readOnlyHint: true },
4874
+ annotations: READ_ONLY,
4947
4875
  description: "List window replay job history (reverse-chronological). Lightweight items \u2014 use lexq_replay_status for summary and changed samples.",
4948
4876
  inputSchema: z10.object({
4949
4877
  page: z10.number().int().min(0).default(0).describe("Page number"),
@@ -4956,7 +4884,7 @@ function registerReplayTools(server, callApi) {
4956
4884
  "lexq_replay_cancel",
4957
4885
  {
4958
4886
  title: "Cancel Replay Job",
4959
- annotations: { readOnlyHint: false, destructiveHint: true },
4887
+ annotations: DESTRUCTIVE,
4960
4888
  description: "Cooperatively cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
4961
4889
  inputSchema: z10.object({
4962
4890
  jobId: z10.string().describe("Replay job ID")
@@ -4968,14 +4896,17 @@ function registerReplayTools(server, callApi) {
4968
4896
  "lexq_replay_export",
4969
4897
  {
4970
4898
  title: "Export Replay Result",
4971
- annotations: { readOnlyHint: true },
4972
- 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.",
4899
+ annotations: READ_ONLY,
4900
+ description: `Export a COMPLETED window replay job. A running job is rejected \u2014 a partial result reads as the whole thing on the receiving end. The two formats carry different things: CSV holds the effect blast radius, whose columns are fixed; JSON holds the changed samples and action parameters, which nest and whose keys differ per tenant. JSON is not a superset of CSV. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
4973
4901
  inputSchema: z10.object({
4974
4902
  jobId: z10.string().describe("Replay job ID"),
4975
4903
  format: z10.enum(EXPORT_FORMATS).default("json").describe("Export format")
4976
4904
  })
4977
4905
  },
4978
- async ({ jobId, format }) => callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } })
4906
+ async ({ jobId, format }) => limitExport(
4907
+ await callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } }),
4908
+ `lexq replay export --id ${jobId} --as ${format} --output <file>`
4909
+ )
4979
4910
  );
4980
4911
  }
4981
4912
 
@@ -4986,7 +4917,7 @@ function registerHistoryTools(server, callApi) {
4986
4917
  "lexq_history_list",
4987
4918
  {
4988
4919
  title: "List Execution History",
4989
- annotations: { readOnlyHint: true },
4920
+ annotations: READ_ONLY,
4990
4921
  description: "List policy execution history. Shows trace ID, group, version, status, match result, and latency.",
4991
4922
  inputSchema: z11.object({
4992
4923
  page: z11.number().int().min(0).default(0).describe("Page number"),
@@ -5014,7 +4945,7 @@ function registerHistoryTools(server, callApi) {
5014
4945
  "lexq_history_get",
5015
4946
  {
5016
4947
  title: "Get Execution Detail",
5017
- annotations: { readOnlyHint: true },
4948
+ annotations: READ_ONLY,
5018
4949
  description: "Get full execution detail including inputFacts, mutatedFacts, generatedVariables, executionTraces, and decisionTraces.",
5019
4950
  inputSchema: z11.object({
5020
4951
  traceId: z11.string().describe("Trace ID from execution history")
@@ -5026,7 +4957,7 @@ function registerHistoryTools(server, callApi) {
5026
4957
  "lexq_history_stats",
5027
4958
  {
5028
4959
  title: "Execution Statistics",
5029
- annotations: { readOnlyHint: true },
4960
+ annotations: READ_ONLY,
5030
4961
  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.`,
5031
4962
  inputSchema: z11.object({
5032
4963
  groupId: z11.string().uuid().optional().describe("Filter by policy group"),
@@ -5051,7 +4982,7 @@ function registerProvenanceTools(server, callApi) {
5051
4982
  "lexq_provenance_get",
5052
4983
  {
5053
4984
  title: "Get Decision Provenance",
5054
- annotations: { readOnlyHint: true },
4985
+ annotations: READ_ONLY,
5055
4986
  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.",
5056
4987
  inputSchema: z12.object({
5057
4988
  traceId: z12.string().describe("Trace ID of the execution")
@@ -5063,7 +4994,7 @@ function registerProvenanceTools(server, callApi) {
5063
4994
  "lexq_pii_reveals_list",
5064
4995
  {
5065
4996
  title: "List PII Reveal Audits",
5066
- annotations: { readOnlyHint: true },
4997
+ annotations: READ_ONLY,
5067
4998
  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.",
5068
4999
  inputSchema: z12.object({
5069
5000
  page: z12.number().int().min(0).default(0).describe("Page number"),
@@ -5089,17 +5020,13 @@ function registerProvenanceTools(server, callApi) {
5089
5020
 
5090
5021
  // src/mcp/tools/logs.ts
5091
5022
  import { z as z13 } from "zod";
5092
- var ACTION_NOTES = {
5093
- RESOLVE: "mark as manually fixed",
5094
- IGNORE: "skip intentionally"
5095
- };
5096
5023
  function registerLogTools(server, callApi) {
5097
5024
  server.registerTool(
5098
5025
  "lexq_logs_list",
5099
5026
  {
5100
5027
  title: "List Failure Logs",
5101
- annotations: { readOnlyHint: true },
5102
- 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.`,
5028
+ annotations: READ_ONLY,
5029
+ description: `List system failure logs from background tasks (platform event webhooks, scheduled deployments). Without dates the window is ${FAILURE_LOG_LOOKBACK_DAYS} days back through today, in the organization time zone; pass startDate to look further back. ${FAILURE_LOG_CONSOLE_NOTE}`,
5103
5030
  inputSchema: z13.object({
5104
5031
  page: z13.number().int().min(0).default(0).describe("Page number"),
5105
5032
  size: z13.number().int().min(1).max(100).default(20).describe("Page size"),
@@ -5124,44 +5051,14 @@ function registerLogTools(server, callApi) {
5124
5051
  "lexq_logs_get",
5125
5052
  {
5126
5053
  title: "Get Failure Log",
5127
- annotations: { readOnlyHint: true },
5128
- description: "Get failure log detail by ID.",
5054
+ annotations: READ_ONLY,
5055
+ description: `Get failure log detail by ID. ${FAILURE_LOG_CONSOLE_NOTE}`,
5129
5056
  inputSchema: z13.object({
5130
5057
  logId: z13.string().uuid().describe("Failure log ID")
5131
5058
  })
5132
5059
  },
5133
5060
  async ({ logId }) => callApi("GET", `failure-logs/${logId}`)
5134
5061
  );
5135
- server.registerTool(
5136
- "lexq_logs_action",
5137
- {
5138
- title: "Process Failure Log",
5139
- annotations: { readOnlyHint: false, destructiveHint: false },
5140
- description: `Process a single failure log: ${withNotes(ACTION_NOTES).join(" or ")}.`,
5141
- inputSchema: z13.object({
5142
- logId: z13.string().uuid().describe("Failure log ID"),
5143
- action: z13.enum(FailureAction).describe("Action to take")
5144
- })
5145
- },
5146
- async ({ logId, action }) => callApi("POST", `failure-logs/${logId}/actions`, {
5147
- params: { action }
5148
- })
5149
- );
5150
- server.registerTool(
5151
- "lexq_logs_bulk_action",
5152
- {
5153
- title: "Bulk Process Failure Logs",
5154
- annotations: { readOnlyHint: false, destructiveHint: false },
5155
- description: "Process multiple failure logs at once. Provide an array of log IDs and the action.",
5156
- inputSchema: z13.object({
5157
- logIds: z13.array(z13.string().uuid()).describe("Array of failure log IDs"),
5158
- action: z13.enum(FailureAction).describe("Action to apply to all logs")
5159
- })
5160
- },
5161
- async ({ logIds, action }) => callApi("POST", "failure-logs/bulk-actions", {
5162
- body: { logIds, action }
5163
- })
5164
- );
5165
5062
  }
5166
5063
 
5167
5064
  // src/mcp/tools/webhook-subscriptions.ts
@@ -5175,7 +5072,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5175
5072
  "lexq_webhook_subscriptions_list",
5176
5073
  {
5177
5074
  title: "List Webhook Subscriptions",
5178
- annotations: { readOnlyHint: true },
5075
+ annotations: READ_ONLY,
5179
5076
  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.",
5180
5077
  inputSchema: z14.object({
5181
5078
  page: z14.number().int().min(0).default(0).describe("Page number"),
@@ -5191,7 +5088,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5191
5088
  "lexq_webhook_subscriptions_get",
5192
5089
  {
5193
5090
  title: "Get Webhook Subscription",
5194
- annotations: { readOnlyHint: true },
5091
+ annotations: READ_ONLY,
5195
5092
  description: "Get webhook subscription detail by ID.",
5196
5093
  inputSchema: z14.object({
5197
5094
  id: z14.string().uuid().describe("Webhook subscription ID")
@@ -5203,7 +5100,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5203
5100
  "lexq_webhook_subscriptions_save",
5204
5101
  {
5205
5102
  title: "Save Webhook Subscription",
5206
- annotations: { readOnlyHint: false, destructiveHint: true },
5103
+ annotations: { ...DESTRUCTIVE, openWorldHint: true },
5207
5104
  description: `Create or update a webhook subscription. Omit id to create, provide id to update. Events: ${PlatformEventType.join(", ")}. Formats: ${withNotes(FORMAT_NOTES2).join(", ")}.`,
5208
5105
  inputSchema: z14.object({
5209
5106
  id: z14.string().uuid().optional().describe("Subscription ID (omit to create, provide to update)"),
@@ -5221,7 +5118,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5221
5118
  "lexq_webhook_subscriptions_delete",
5222
5119
  {
5223
5120
  title: "Delete Webhook Subscription",
5224
- annotations: { readOnlyHint: false, destructiveHint: true },
5121
+ annotations: DESTRUCTIVE,
5225
5122
  description: "Delete a webhook subscription by ID.",
5226
5123
  inputSchema: z14.object({
5227
5124
  id: z14.string().uuid().describe("Webhook subscription ID")
@@ -5233,7 +5130,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5233
5130
  "lexq_webhook_subscriptions_test",
5234
5131
  {
5235
5132
  title: "Test Webhook Subscription",
5236
- annotations: { readOnlyHint: false, destructiveHint: false },
5133
+ annotations: { ...DESTRUCTIVE, openWorldHint: true },
5237
5134
  description: "Send a test event to verify webhook connectivity. Returns the HTTP status code and success/failure message.",
5238
5135
  inputSchema: z14.object({
5239
5136
  id: z14.string().uuid().describe("Webhook subscription ID")
@@ -5250,7 +5147,7 @@ function registerDomainTemplateTools(server, callApi) {
5250
5147
  "lexq_domain_templates_list",
5251
5148
  {
5252
5149
  title: "List Domain Templates",
5253
- annotations: { readOnlyHint: true },
5150
+ annotations: READ_ONLY,
5254
5151
  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.",
5255
5152
  inputSchema: z15.object({})
5256
5153
  },
@@ -5260,7 +5157,7 @@ function registerDomainTemplateTools(server, callApi) {
5260
5157
  "lexq_domain_templates_preview",
5261
5158
  {
5262
5159
  title: "Preview Domain Template",
5263
- annotations: { readOnlyHint: true },
5160
+ annotations: READ_ONLY,
5264
5161
  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.",
5265
5162
  inputSchema: z15.object({
5266
5163
  template: z15.string().describe(
@@ -5274,7 +5171,7 @@ function registerDomainTemplateTools(server, callApi) {
5274
5171
  "lexq_domain_templates_apply",
5275
5172
  {
5276
5173
  title: "Apply Domain Template",
5277
- annotations: { readOnlyHint: false, destructiveHint: false },
5174
+ annotations: ADDITIVE,
5278
5175
  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.",
5279
5176
  inputSchema: z15.object({
5280
5177
  template: z15.string().describe("Domain template key to apply (e.g. ECOMMERCE)."),
@@ -5309,6 +5206,13 @@ function registerAllTools(server, callApi) {
5309
5206
  registerWebhookSubscriptionTools(server, callApi);
5310
5207
  }
5311
5208
  var SERVER_INFO = { name: "lexq", ...server_info_default };
5209
+ var SERVER_INSTRUCTIONS = [
5210
+ "LexQ evaluates business rules: a policy group holds versions, a version holds rules, and rules read input facts.",
5211
+ "- Date filters (yyyy-MM-dd) are whole days in the organization's time zone, not UTC. Call lexq_whoami for tenantTimezone before working out the current date or a date range.",
5212
+ "- Before creating rules, call lexq_facts_list to see which facts exist and their types. When a rule needs a fact that is not defined, confirm its type and description with the user before calling lexq_facts_create.",
5213
+ "- Before lexq_deploy_publish, run lexq_dry_run on the version with representative facts. A published version cannot go back to draft.",
5214
+ `- For anything these tools cannot do, point the user to the LexQ console at ${CONSOLE_URL}.`
5215
+ ].join("\n");
5312
5216
 
5313
5217
  // src/mcp/server.ts
5314
5218
  var __dirname = dirname(fileURLToPath(import.meta.url));
@@ -5325,7 +5229,10 @@ function startMcpServer() {
5325
5229
  const callApi = createCallApiFromConfig();
5326
5230
  serveStdio(
5327
5231
  () => {
5328
- const server = new McpServer({ ...SERVER_INFO, version });
5232
+ const server = new McpServer(
5233
+ { ...SERVER_INFO, version },
5234
+ { instructions: SERVER_INSTRUCTIONS }
5235
+ );
5329
5236
  registerAllTools(server, callApi);
5330
5237
  registerLocalTools(server);
5331
5238
  return server;