@apex-inc/mcp-server 0.28.1 → 0.29.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/tools.js CHANGED
@@ -10,6 +10,7 @@ import { notStartedExplanation, publishedExperimentLine, } from "./publish-commu
10
10
  import { activationLiveLine, shouldRefuseActivationWiring, } from "./activation-wiring.js";
11
11
  import { milestoneWriteBody, preflightAdoptionMilestone, } from "./adoption-compose.js";
12
12
  import { applyAddJourneyStep } from "./add-journey-step.js";
13
+ import { fetchLiveTestHits, formatLiveTestStop, isFileStop, isObjectStop, refuseLiveApexObject, } from "./live-test-guard.js";
13
14
  const APEX = "Apex";
14
15
  /**
15
16
  * MOBX-006 — stable synthetic visitor id for agent-fired events.
@@ -1868,6 +1869,99 @@ export const toolDefinitions = {
1868
1869
  }
1869
1870
  },
1870
1871
  },
1872
+ check_live_tests: {
1873
+ description: `${APEX} — Before you edit a page, commit, or push, call this even if the user did not mention experiments. Also call it before you change a letter, Adaptive Journey, or Milestone. If something live is on those files or ids, stop and ask. Do not edit. Do not commit.`,
1874
+ schema: z.object({
1875
+ file_paths: z
1876
+ .array(z.string())
1877
+ .optional()
1878
+ .describe("Repo-relative paths you are about to edit or commit. Full file is read locally when content is omitted."),
1879
+ file_contents: z
1880
+ .array(z.object({
1881
+ path: z.string(),
1882
+ content: z.string().describe("Full file at HEAD, not the diff hunk."),
1883
+ }))
1884
+ .optional(),
1885
+ urls: z
1886
+ .array(z.string())
1887
+ .optional()
1888
+ .describe("Page URLs you are about to change. URL-only hits are a warning, not a stop."),
1889
+ communication_ids: z
1890
+ .array(z.string())
1891
+ .optional()
1892
+ .describe("Letters you are about to edit or publish."),
1893
+ journey_ids: z
1894
+ .array(z.string())
1895
+ .optional()
1896
+ .describe("Adaptive Journeys you are about to change or publish."),
1897
+ milestone_ids: z
1898
+ .array(z.string())
1899
+ .optional()
1900
+ .describe("Milestones you are about to change or delete."),
1901
+ }),
1902
+ handler: async (args) => {
1903
+ try {
1904
+ const byPath = new Map();
1905
+ for (const row of args.file_contents ?? []) {
1906
+ if (row?.path)
1907
+ byPath.set(row.path, { path: row.path, content: row.content });
1908
+ }
1909
+ for (const raw of args.file_paths ?? []) {
1910
+ if (!raw || byPath.has(raw))
1911
+ continue;
1912
+ const abs = raw.startsWith("/") ? raw : join(process.cwd(), raw);
1913
+ byPath.set(raw, {
1914
+ path: raw,
1915
+ content: existsSync(abs) ? readFileSync(abs, "utf8") : undefined,
1916
+ });
1917
+ }
1918
+ const files = [...byPath.values()];
1919
+ const hits = await fetchLiveTestHits({
1920
+ files,
1921
+ urls: args.urls,
1922
+ communicationIds: args.communication_ids,
1923
+ journeyIds: args.journey_ids,
1924
+ milestoneIds: args.milestone_ids,
1925
+ includePublishedJourneys: (args.journey_ids ?? []).length > 0,
1926
+ });
1927
+ const stopping = hits.filter((h) => isFileStop(h) || isObjectStop(h));
1928
+ if (stopping.length === 0) {
1929
+ if (hits.length === 0) {
1930
+ return {
1931
+ content: [
1932
+ {
1933
+ type: "text",
1934
+ text: `${APEX} Nothing live is on those files or ids. You can edit and commit.`,
1935
+ },
1936
+ ],
1937
+ };
1938
+ }
1939
+ const soft = hits
1940
+ .map((h) => `${h.name} (${h.status}, ${h.confidence}: ${h.reason})`)
1941
+ .join("; ");
1942
+ return {
1943
+ content: [
1944
+ {
1945
+ type: "text",
1946
+ text: `${APEX} Soft warning only — not a stop: ${soft}. If you turn a paused test back on, this is the page.`,
1947
+ },
1948
+ ],
1949
+ };
1950
+ }
1951
+ return {
1952
+ content: [
1953
+ {
1954
+ type: "text",
1955
+ text: formatLiveTestStop(stopping),
1956
+ },
1957
+ ],
1958
+ };
1959
+ }
1960
+ catch (err) {
1961
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
1962
+ }
1963
+ },
1964
+ },
1871
1965
  set_experiment_mutex: {
1872
1966
  description: `${APEX} — Put two experiments in a mutual-exclusion group: no visitor is ever assigned to both, so they run concurrently with partitioned traffic (the scientifically clean way to run two experiments that touch the same surface). Bidirectional. Pass mode:"remove" to unlink. Safe on running experiments — only affects future assignments.`,
1873
1967
  schema: z.object({
@@ -3368,9 +3462,17 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3368
3462
  confirmLiveExperiment: z
3369
3463
  .boolean()
3370
3464
  .optional()
3371
- .describe("Edit a communication that is the treatment in a RUNNING experiment. This marks that experiment invalid and excludes its result from the belief graph. Only pass true when the user has explicitly accepted losing the experiment."),
3465
+ .describe("Edit a communication that is the treatment in a RUNNING experiment. Marks the numbers untrustworthy: we will not call a winner and we will not learn from mixed counts. Only pass true when they picked an out."),
3372
3466
  }),
3373
3467
  handler: async (args) => {
3468
+ const guard = await refuseLiveApexObject({
3469
+ communicationIds: [args.communicationId],
3470
+ confirmed: args.confirmLiveExperiment === true,
3471
+ purpose: "draft",
3472
+ });
3473
+ if (guard.stop) {
3474
+ return { content: [{ type: "text", text: guard.text }] };
3475
+ }
3374
3476
  const body = {};
3375
3477
  if (args.status)
3376
3478
  body.status = args.status;
@@ -3427,7 +3529,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3427
3529
  confirmLiveExperiment: z
3428
3530
  .boolean()
3429
3531
  .optional()
3430
- .describe("Publish over a communication that is the treatment in a RUNNING experiment. Marks that experiment invalid and excludes its result from the belief graph. Only pass true when the user has explicitly accepted losing the experiment."),
3532
+ .describe("Publish over a communication that is the treatment in a RUNNING experiment. Marks the numbers untrustworthy: we will not call a winner and we will not learn from mixed counts. Only pass true when they picked an out."),
3431
3533
  hostJourneyId: z
3432
3534
  .string()
3433
3535
  .optional()
@@ -3450,6 +3552,14 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3450
3552
  .describe("Override the prefilled decision window in days."),
3451
3553
  }),
3452
3554
  handler: async (args) => {
3555
+ const guard = await refuseLiveApexObject({
3556
+ communicationIds: [args.communicationId],
3557
+ confirmed: args.confirmLiveExperiment === true,
3558
+ purpose: "publish",
3559
+ });
3560
+ if (guard.stop) {
3561
+ return { content: [{ type: "text", text: guard.text }] };
3562
+ }
3453
3563
  const body = {};
3454
3564
  if (args.intent)
3455
3565
  body.intent = args.intent;
@@ -4220,6 +4330,13 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
4220
4330
  }),
4221
4331
  handler: async (args) => {
4222
4332
  try {
4333
+ const guard = await refuseLiveApexObject({
4334
+ journeyIds: [args.journeyId],
4335
+ purpose: "draft",
4336
+ });
4337
+ if (guard.stop) {
4338
+ return { content: [{ type: "text", text: guard.text }] };
4339
+ }
4223
4340
  const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
4224
4341
  const newId = `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
4225
4342
  const applied = applyAddJourneyStep(j.steps ?? [], args, newId);
@@ -4246,6 +4363,14 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
4246
4363
  }),
4247
4364
  handler: async (args) => {
4248
4365
  try {
4366
+ const guard = await refuseLiveApexObject({
4367
+ journeyIds: [args.journeyId],
4368
+ communicationIds: [args.commId],
4369
+ purpose: "draft",
4370
+ });
4371
+ if (guard.stop) {
4372
+ return { content: [{ type: "text", text: guard.text }] };
4373
+ }
4249
4374
  const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
4250
4375
  const steps = [...(j.steps ?? [])];
4251
4376
  const sends = steps.filter((s) => s.type === "send");
@@ -4306,6 +4431,14 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
4306
4431
  }),
4307
4432
  handler: async (args) => {
4308
4433
  try {
4434
+ const guard = await refuseLiveApexObject({
4435
+ journeyIds: [args.journeyId],
4436
+ communicationIds: [args.whenTrueCommId, args.whenFalseCommId].filter((id) => Boolean(id)),
4437
+ purpose: "draft",
4438
+ });
4439
+ if (guard.stop) {
4440
+ return { content: [{ type: "text", text: guard.text }] };
4441
+ }
4309
4442
  const c = args.condition;
4310
4443
  // Build the predicate leaf (AudiencePredicate is a bare leaf here).
4311
4444
  let predicate;
@@ -4375,12 +4508,16 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
4375
4508
  },
4376
4509
  },
4377
4510
  publish_journey: {
4378
- description: `${APEX} — Publish a draft journey so it runs on live trigger events. SAFETY: defaults to a dry-run (validation only) — pass confirmLive:true to actually publish to real customers. Email sends require a verified sender domain or publish is blocked.`,
4511
+ description: `${APEX} — Publish a draft journey so it runs on live trigger events. SAFETY: defaults to a dry-run (validation only) — pass confirmLive:true to actually publish to real customers. Email sends require a verified sender domain or publish is blocked. If this path is already live or a test is measuring it, stop and ask. Do not pass confirmTreatmentChange unless they picked an out.`,
4379
4512
  schema: z.object({
4380
4513
  journeyId: z.string(),
4381
4514
  confirmLive: z.boolean().optional().describe("Set true to publish for real. Omitted/false = dry-run validation only."),
4515
+ confirmTreatmentChange: z
4516
+ .boolean()
4517
+ .optional()
4518
+ .describe("Publish anyway when this path is live or a test is measuring it. Only pass true after they picked an out."),
4382
4519
  }),
4383
- handler: async ({ journeyId, confirmLive }) => {
4520
+ handler: async ({ journeyId, confirmLive, confirmTreatmentChange, }) => {
4384
4521
  const path = `/api/journeys/${encodeURIComponent(journeyId)}/publish`;
4385
4522
  // Non-blocking coverage warnings ("we couldn't verify these until the
4386
4523
  // journey fires") — surfaced so an agent can preview before publishing.
@@ -4402,6 +4539,15 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
4402
4539
  return { content: [{ type: "text", text: `${APEX} Dry-run found a problem before publishing: ${errMsg(err)}\nIf a variable won't resolve, map it to a field the trigger provides or remove it, then preview with example data.` }], isError: true };
4403
4540
  }
4404
4541
  }
4542
+ const guard = await refuseLiveApexObject({
4543
+ journeyIds: [journeyId],
4544
+ includePublishedJourneys: true,
4545
+ confirmed: confirmTreatmentChange === true,
4546
+ purpose: "publish",
4547
+ });
4548
+ if (guard.stop) {
4549
+ return { content: [{ type: "text", text: guard.text }] };
4550
+ }
4405
4551
  try {
4406
4552
  const res = await apiPost(path, {});
4407
4553
  if (!tenantOk(res.workspaceKey))
@@ -5482,12 +5628,12 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5482
5628
  },
5483
5629
  },
5484
5630
  create_adoption_milestone: {
5485
- description: `${APEX} — Create an adoption milestone. "Adopted" is one event or one trait. Default enroll_mode is "future" (only people who sign up after this is created). Use enroll_mode="all" to include everyone already here. gap_hours wins over gap_days. Starts OFF unless active=true.`,
5631
+ description: `${APEX} — Create an adoption milestone. "Adopted" is one event or one trait. Omit adopted_when_kind to create the milestone before its finishing moment is chosen, then set it later with update_adoption_milestone; a milestone with no moment counts nobody, mails nobody, and cannot be turned on. Default enroll_mode is "future" (only people who sign up after this is created). Use enroll_mode="all" to include everyone already here. gap_hours wins over gap_days. Starts OFF unless active=true.`,
5486
5632
  schema: z.object({
5487
5633
  name: z.string().describe("Human label, e.g. 'Ran first report'"),
5488
5634
  featureKey: z.string().describe("Stable feature key, e.g. 'reporting'"),
5489
5635
  actionLabel: z.string().optional().describe("Sentence verb: hasn't {action_label}. Never the raw event key."),
5490
- adoptedWhenKind: z.enum(["event", "trait"]).describe("How adoption is detected"),
5636
+ adoptedWhenKind: z.enum(["event", "trait"]).optional().describe("How adoption is detected. Omit to decide the finishing moment later; the milestone stays inert until it is set."),
5491
5637
  eventName: z.string().optional().describe("event kind: the event whose firing means adopted"),
5492
5638
  traitPath: z.string().optional().describe("trait kind: dotted trait path, e.g. 'plan'"),
5493
5639
  traitValue: z.string().optional().describe("trait kind: value to match (equals)"),
@@ -5499,10 +5645,14 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5499
5645
  gapKind: z.enum(["days_since_signup", "event_not_fired"]).optional().describe("Anchor. Default days_since_signup. event_not_fired needs since_event_name."),
5500
5646
  sinceEventName: z.string().optional().describe("event_not_fired: the event the wait starts from."),
5501
5647
  enrollMode: z.enum(["all", "future"]).optional().describe("future (default) = only people who sign up from now on. all = historical roster too."),
5648
+ finishWhen: z.enum(["this_person", "anyone_here"]).optional().describe("this_person (default) = only that person finishes. anyone_here = first person on the same Account finishes it for the company. Event finishes only."),
5502
5649
  active: z.boolean().optional().describe("Turn the milestone on. Default false."),
5503
5650
  }),
5504
5651
  handler: async (args) => {
5505
5652
  const warnings = await preflightAdoptionMilestone(args);
5653
+ if (!args.adoptedWhenKind) {
5654
+ warnings.push("No finishing moment set. This milestone counts nobody and cannot be turned on until update_adoption_milestone sets adopted_when_kind.");
5655
+ }
5506
5656
  const body = milestoneWriteBody({
5507
5657
  ...args,
5508
5658
  enrollMode: args.enrollMode ?? "future",
@@ -5519,11 +5669,16 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5519
5669
  },
5520
5670
  },
5521
5671
  update_adoption_milestone: {
5522
- description: `${APEX} — Update an adoption milestone (name, priority, active, gap days/hours, enroll mode, nudge or celebration journey).`,
5672
+ description: `${APEX} — Update an adoption milestone (finishing moment, name, priority, active, gap days/hours, enroll mode, who finishing counts, nudge journey). Pass adopted_when_kind plus event_name or trait_path to set the finishing moment on a milestone created without one. active=true is REFUSED while there is no moment, because nobody could ever finish it and everyone would stay late forever. Changing the moment resets the lift window. If the Milestone is ON, changing what "finished" means stops and asks — do not pass confirmLiveMilestone unless they picked an out. A Milestone keeps BOTH letters on its one Adaptive Journey: the Celebration is a Send on the finish arm, not a second journey. finish_when=anyone_here on a live event Milestone backfills leftover Nudges for Accounts that already have a doer.`,
5523
5673
  schema: z.object({
5524
5674
  milestoneId: z.string(),
5525
5675
  name: z.string().optional(),
5526
5676
  actionLabel: z.string().optional().describe("Sentence verb: hasn't {action_label}."),
5677
+ adoptedWhenKind: z.enum(["event", "trait"]).optional().describe("Set the finishing moment. Changing it resets the lift window."),
5678
+ eventName: z.string().optional().describe("event kind: the event whose firing means adopted"),
5679
+ traitPath: z.string().optional().describe("trait kind: dotted trait path, e.g. 'plan'"),
5680
+ traitValue: z.string().optional().describe("trait kind: value to match (equals)"),
5681
+ traitOp: z.enum(["equals", "not_equals", "exists"]).optional().describe("trait kind: comparison. Default equals."),
5527
5682
  priority: z.number().optional(),
5528
5683
  order: z.number().optional().describe("0-based sequence order. Prefer reorder_adoption_milestones to sequence the whole set."),
5529
5684
  active: z.boolean().optional(),
@@ -5532,10 +5687,34 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5532
5687
  gapKind: z.enum(["days_since_signup", "event_not_fired"]).optional(),
5533
5688
  sinceEventName: z.string().optional(),
5534
5689
  enrollMode: z.enum(["all", "future"]).optional(),
5690
+ finishWhen: z.enum(["this_person", "anyone_here"]).optional().describe("this_person = only that person. anyone_here = first person on the Account. Event finishes only."),
5535
5691
  nudgeJourneyId: z.string().optional(),
5536
- celebrationJourneyId: z.string().optional(),
5692
+ confirmLiveMilestone: z
5693
+ .boolean()
5694
+ .optional()
5695
+ .describe("Change an ON Milestone anyway. Only pass true after they picked an out."),
5537
5696
  }),
5538
5697
  handler: async (args) => {
5698
+ const changesWhatCounts = args.adoptedWhenKind !== undefined ||
5699
+ args.eventName !== undefined ||
5700
+ args.traitPath !== undefined ||
5701
+ args.gapDays !== undefined ||
5702
+ args.gapHours !== undefined ||
5703
+ args.gapKind !== undefined ||
5704
+ args.active !== undefined ||
5705
+ args.enrollMode !== undefined ||
5706
+ args.finishWhen !== undefined ||
5707
+ args.nudgeJourneyId !== undefined;
5708
+ if (changesWhatCounts) {
5709
+ const guard = await refuseLiveApexObject({
5710
+ milestoneIds: [args.milestoneId],
5711
+ confirmed: args.confirmLiveMilestone === true,
5712
+ purpose: "milestone",
5713
+ });
5714
+ if (guard.stop) {
5715
+ return { content: [{ type: "text", text: guard.text }] };
5716
+ }
5717
+ }
5539
5718
  const updates = milestoneWriteBody(args);
5540
5719
  if (args.gapDays === undefined && args.gapHours === undefined && args.gapKind === undefined && args.sinceEventName === undefined) {
5541
5720
  delete updates.gapPolicy;
@@ -5547,9 +5726,23 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5547
5726
  },
5548
5727
  },
5549
5728
  delete_adoption_milestone: {
5550
- description: `${APEX} — Delete an adoption milestone.`,
5551
- schema: z.object({ milestoneId: z.string() }),
5552
- handler: async ({ milestoneId }) => {
5729
+ description: `${APEX} — Delete an adoption milestone. If it is on, stop and ask. Do not pass confirmLiveMilestone unless they picked an out.`,
5730
+ schema: z.object({
5731
+ milestoneId: z.string(),
5732
+ confirmLiveMilestone: z
5733
+ .boolean()
5734
+ .optional()
5735
+ .describe("Delete an ON Milestone anyway. Only pass true after they picked an out."),
5736
+ }),
5737
+ handler: async ({ milestoneId, confirmLiveMilestone, }) => {
5738
+ const guard = await refuseLiveApexObject({
5739
+ milestoneIds: [milestoneId],
5740
+ confirmed: confirmLiveMilestone === true,
5741
+ purpose: "milestone",
5742
+ });
5743
+ if (guard.stop) {
5744
+ return { content: [{ type: "text", text: guard.text }] };
5745
+ }
5553
5746
  await apiDelete(`/api/adoption/milestones/${encodeURIComponent(milestoneId)}`);
5554
5747
  return { content: [{ type: "text", text: `Deleted milestone ${milestoneId}.` }] };
5555
5748
  },
@@ -5579,6 +5772,7 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5579
5772
  gapKind: z.enum(["days_since_signup", "event_not_fired"]).optional(),
5580
5773
  sinceEventName: z.string().optional(),
5581
5774
  enrollMode: z.enum(["all", "future"]).optional().describe("future (default) = only people who sign up from now on."),
5775
+ finishWhen: z.enum(["this_person", "anyone_here"]).optional().describe("this_person (default) = only that person finishes. anyone_here = first person on the same Account. Event finishes only."),
5582
5776
  active: z.boolean().optional().describe("Turn the milestone on. Default false."),
5583
5777
  scaffold: z.enum(["both", "celebration", "nudge", "none"]).optional().describe("Which letters to include. Default both."),
5584
5778
  journeyName: z.string().optional().describe("Name for the Adaptive Journey. Default is the Milestone name."),
@@ -5788,8 +5982,27 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5788
5982
  };
5789
5983
  },
5790
5984
  },
5985
+ get_adoption_account_progress: {
5986
+ description: `${APEX} — One Account's Path: which Milestones this company has finished, who did them, and how many people are Not yet / Started / Done. account_id is the company id from identify({ account: { id } }). Never includes holdout.`,
5987
+ schema: z.object({
5988
+ account_id: z
5989
+ .string()
5990
+ .describe("The Account id (identify account.id or the Apex acct_ id)"),
5991
+ }),
5992
+ handler: async ({ account_id }) => {
5993
+ const data = await apiGet(`/api/adoption/progress?account_id=${encodeURIComponent(account_id)}`);
5994
+ return {
5995
+ content: [
5996
+ {
5997
+ type: "text",
5998
+ text: JSON.stringify(data?.data ?? {}, null, 2),
5999
+ },
6000
+ ],
6001
+ };
6002
+ },
6003
+ },
5791
6004
  get_adoption_user_progress: {
5792
- description: `${APEX} — One user's adoption progress across milestones (status + timestamps). Answers "where is this user stuck?". Returns status/timestamps ONLY never email, name, or holdout membership (holdout is withheld to keep the experiment unbiased). end_user_id must be a valid user id, not an email.`,
6005
+ description: `${APEX} — One person's Path (Not yet / Started / Done) across every active Milestone. Includes finish_when and inherited when Anyone here finished for their company. Never email, name, or holdout. end_user_id must be a valid user id, not an email.`,
5793
6006
  schema: z.object({
5794
6007
  end_user_id: z
5795
6008
  .string()