@apex-inc/mcp-server 0.13.0 → 0.15.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
@@ -64,6 +64,28 @@ function appUrl(path) {
64
64
  function journeyLink(id) {
65
65
  return appUrl(`/dashboard/communications/journeys/${id}`);
66
66
  }
67
+ /**
68
+ * Render the author-time conflict guard advisory (non-blocking). `selfId` is the
69
+ * candidate experiment's id when known (so the set_experiment_mutex suggestion
70
+ * is copy-pasteable); omitted during pre-create previews.
71
+ */
72
+ function renderConflictAdvisory(data, selfId) {
73
+ const lines = [];
74
+ for (const h of data.differentMetric) {
75
+ const soft = h.confidence === "low" ? " (same data source — only a real conflict if it targets the same screen)" : "";
76
+ lines.push(`⚠ Overlaps "${h.name}" (${h.experimentId}) on ${h.location}${soft}. It measures ${h.primaryMetricLabel}; this measures a different goal — running both at once contaminates both results.`);
77
+ lines.push(selfId
78
+ ? ` → Run both safely in parallel: set_experiment_mutex({ experimentId: "${selfId}", withExperimentId: "${h.experimentId}" }). Or sequence: activate this only after "${h.name}" concludes.`
79
+ : ` → After creating it, run both safely in parallel: set_experiment_mutex({ experimentId: "<new id>", withExperimentId: "${h.experimentId}" }). Or sequence after "${h.name}" concludes.`);
80
+ }
81
+ for (const h of data.sameMetric) {
82
+ const soft = h.confidence === "low" ? " (same data source — only a real conflict if it targets the same screen)" : "";
83
+ lines.push(`↔ "${h.name}" (${h.experimentId}) already tests ${h.location} for ${h.primaryMetricLabel} — the SAME metric${soft}. These look like two arms of one question: consider adding your change as a variant of "${h.name}" rather than a separate experiment.`);
84
+ }
85
+ if (lines.length === 0)
86
+ return "✓ No overlap with live experiments on this surface.";
87
+ return `Conflict guard:\n${lines.join("\n")}`;
88
+ }
67
89
  /**
68
90
  * Detect a MOBILE experiment surface from repo signals (the MCP runs in the
69
91
  * merchant's repo). Walks up a few dirs looking for capacitor.config.* or a
@@ -495,6 +517,25 @@ export const toolDefinitions = {
495
517
  };
496
518
  }
497
519
  if (isPreview) {
520
+ // Author-time conflict guard (best-effort, non-blocking): warn if this
521
+ // overlaps a LIVE experiment on the same surface before it's created.
522
+ let conflictAdvisory;
523
+ try {
524
+ const cc = await apiPost("/api/experiments/check-conflicts", {
525
+ surface: experimentSurface,
526
+ target_component: args.targetComponent,
527
+ target_url: args.targetUrl,
528
+ target_anchor: args.targetAnchor,
529
+ data_source_id: args.dataSourceId,
530
+ primary_metric_event: primaryMetric.source.eventType,
531
+ });
532
+ if (cc.data.sameMetric.length || cc.data.differentMetric.length) {
533
+ conflictAdvisory = renderConflictAdvisory(cc.data);
534
+ }
535
+ }
536
+ catch {
537
+ /* conflict check is advisory; never block the preview */
538
+ }
498
539
  const recipe = {
499
540
  _apex: true,
500
541
  _type: "experiment_preview",
@@ -511,7 +552,8 @@ export const toolDefinitions = {
511
552
  beliefId: args.beliefId || null,
512
553
  beliefStatement: args.beliefStatement || null,
513
554
  predictionId: args.predictionId || null,
514
- _instructions: "Before proposing variant copy, ground every factual claim in .apex/brand-truth.md (read it; cite the line each claim traces to; if a claim isn't backed there, ask the user instead of inventing it). Then present this as a summary and ask the user to confirm (1), adjust (2), or cancel (3). Do NOT create the experiment until confirmed.",
555
+ ...(conflictAdvisory ? { _conflict_advisory: conflictAdvisory } : {}),
556
+ _instructions: `Before proposing variant copy, ground every factual claim in .apex/brand-truth.md (read it; cite the line each claim traces to; if a claim isn't backed there, ask the user instead of inventing it).${conflictAdvisory ? " IMPORTANT: _conflict_advisory is set — show it to the user and resolve the overlap (mutex / variant / sequence) before creating." : ""} Then present this as a summary and ask the user to confirm (1), adjust (2), or cancel (3). Do NOT create the experiment until confirmed.`,
515
557
  };
516
558
  return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
517
559
  }
@@ -602,6 +644,9 @@ export const toolDefinitions = {
602
644
  name: args.name,
603
645
  targetUrl: args.targetUrl,
604
646
  targetAnchor: args.targetAnchor,
647
+ // Persist the component (e.g. "ProductCard") as a precise location
648
+ // signal for the author-time conflict guard — esp. mobile/SDK arms.
649
+ ...(args.targetComponent ? { targetComponent: args.targetComponent } : {}),
605
650
  createdFrom: "cursor",
606
651
  status: "draft",
607
652
  ...(args.dataSourceId ? { dataSourceId: args.dataSourceId } : {}),
@@ -978,6 +1023,17 @@ export const toolDefinitions = {
978
1023
  else if (wiring && !wiring.bothArmsLive && force === true) {
979
1024
  notes.push(` ⚠ Launched with force: true despite an incomplete wiring check`, ` (verdict: ${wiring.verdict}, ${wiring.armsLive}/${wiring.totalArms} arm(s) live).`, ` Some arm(s) may not be collecting data yet.`, ``);
980
1025
  }
1026
+ // Author-time conflict guard (advisory): if this now-running experiment
1027
+ // overlaps another live one on the same surface, surface the fix.
1028
+ try {
1029
+ const cc = await apiPost("/api/experiments/check-conflicts", { experiment_id: experimentId });
1030
+ if (cc.data.sameMetric.length || cc.data.differentMetric.length) {
1031
+ notes.push(` ${renderConflictAdvisory(cc.data, experimentId).split("\n").join("\n ")}`, ``);
1032
+ }
1033
+ }
1034
+ catch {
1035
+ /* advisory only — never affect the launch result */
1036
+ }
981
1037
  return {
982
1038
  content: [
983
1039
  {
@@ -1445,6 +1501,53 @@ export const toolDefinitions = {
1445
1501
  }
1446
1502
  },
1447
1503
  },
1504
+ check_experiment_conflicts: {
1505
+ description: `${APEX} — Before you create or activate an experiment, check whether it overlaps a LIVE experiment on the same surface. Same location + DIFFERENT metric → they'll contaminate each other (make them mutually exclusive with set_experiment_mutex, or sequence). Same location + SAME metric → add a variant to the existing experiment instead. Pass either experimentId (an existing draft) OR the candidate's location + primaryMetricEvent. Advisory only — nothing is blocked.`,
1506
+ schema: z.object({
1507
+ experimentId: z.string().optional().describe("Check an existing experiment by id (loads its location + metric)."),
1508
+ surface: z.enum(["web", "mobile"]).optional(),
1509
+ targetComponent: z.string().optional().describe("Component/file the experiment changes, e.g. 'ProductCard'."),
1510
+ targetUrl: z.string().optional(),
1511
+ targetAnchor: z.string().optional(),
1512
+ dataSourceId: z.string().optional(),
1513
+ primaryMetricEvent: z.string().optional().describe("Canonical primary-metric event, e.g. 'add_to_cart'."),
1514
+ }),
1515
+ handler: async (args) => {
1516
+ try {
1517
+ const res = await apiPost("/api/experiments/check-conflicts", {
1518
+ ...(args.experimentId ? { experiment_id: args.experimentId } : {}),
1519
+ surface: args.surface,
1520
+ target_component: args.targetComponent,
1521
+ target_url: args.targetUrl,
1522
+ target_anchor: args.targetAnchor,
1523
+ data_source_id: args.dataSourceId,
1524
+ primary_metric_event: args.primaryMetricEvent,
1525
+ });
1526
+ return { content: [{ type: "text", text: `${APEX} ${renderConflictAdvisory(res.data, args.experimentId)}` }] };
1527
+ }
1528
+ catch (err) {
1529
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
1530
+ }
1531
+ },
1532
+ },
1533
+ set_experiment_mutex: {
1534
+ 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.`,
1535
+ schema: z.object({
1536
+ experimentId: z.string(),
1537
+ withExperimentId: z.string().describe("The other experiment to make mutually exclusive with."),
1538
+ mode: z.enum(["add", "remove"]).optional().describe("'add' (default) links; 'remove' unlinks."),
1539
+ }),
1540
+ handler: async ({ experimentId, withExperimentId, mode }) => {
1541
+ try {
1542
+ await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/mutex`, { withExperimentId, mode });
1543
+ const verb = mode === "remove" ? "are no longer" : "are now";
1544
+ return { content: [{ type: "text", text: `${APEX} ${experimentId} and ${withExperimentId} ${verb} mutually exclusive — no visitor will be assigned to both.` }] };
1545
+ }
1546
+ catch (err) {
1547
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
1548
+ }
1549
+ },
1550
+ },
1448
1551
  get_growth_reality: {
1449
1552
  description: `${APEX} — "Is our growth real?" Per goal, the cumulative lift of everything Apex did (journeys + experiments) vs a persistent do-nothing global holdout, with a 95% CI. The honest, holdout-gated answer to "would this have happened anyway?" Forward-only; goals still collecting are flagged, not faked.`,
1450
1553
  schema: z.object({
@@ -2919,12 +3022,12 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2919
3022
  },
2920
3023
  },
2921
3024
  add_journey_branch: {
2922
- description: `${APEX} — Insert a CONDITIONAL content branch before the exit: evaluate one condition and send a different communication to each side. Use this for true content branching (e.g. "cart value > $100 → VIP email, else standard email"). For the simpler "skip the next send if they already converted" case, use add_journey_exit instead. The condition is an attribute test (fieldPath + operator + value) or an event test (event_fired / event_not_fired within a window).`,
3025
+ description: `${APEX} — Insert a CONDITIONAL content branch before the exit: evaluate one condition and send a different communication to each side. Use this for true content branching (e.g. "cart value > $100 → VIP email, else standard email"). For the simpler "skip the next send if they already converted" case, use add_journey_exit instead. The condition is an attribute test (field_path + operator + value) or an event test (event_fired / event_not_fired within a window).`,
2923
3026
  schema: z.object({
2924
3027
  journeyId: z.string(),
2925
3028
  condition: z.object({
2926
3029
  kind: z.enum(["attribute", "event_fired", "event_not_fired"]),
2927
- fieldPath: z.string().optional().describe("attribute only: dotted path, e.g. 'cart.valueCents' or 'plan'."),
3030
+ field_path: z.string().optional().describe("attribute only: dotted path, e.g. 'cart.valueCents' or 'plan'."),
2928
3031
  operator: z
2929
3032
  .enum(["equals", "not_equals", "greater_than", "less_than", "in", "exists", "not_exists"])
2930
3033
  .optional()
@@ -2933,8 +3036,8 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2933
3036
  .union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number(), z.boolean()]))])
2934
3037
  .optional()
2935
3038
  .describe("attribute only: the comparison value (omit for exists/not_exists)."),
2936
- eventName: z.string().optional().describe("event only: the canonical event, e.g. 'purchase'."),
2937
- windowDays: z.number().optional().describe("event only: look-back window in days (omit = ever)."),
3039
+ event_name: z.string().optional().describe("event only: the canonical event, e.g. 'purchase'."),
3040
+ window_days: z.number().optional().describe("event only: look-back window in days (omit = ever)."),
2938
3041
  }),
2939
3042
  whenTrueCommId: z.string().describe("Communication to send when the condition matches."),
2940
3043
  whenFalseCommId: z.string().optional().describe("Communication to send when it doesn't match (omit to just continue to exit)."),
@@ -2946,24 +3049,24 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2946
3049
  // Build the predicate leaf (AudiencePredicate is a bare leaf here).
2947
3050
  let predicate;
2948
3051
  if (c.kind === "attribute") {
2949
- if (!c.fieldPath || !c.operator) {
2950
- return { content: [{ type: "text", text: `${APEX} An attribute condition needs fieldPath and operator.` }], isError: true };
3052
+ if (!c.field_path || !c.operator) {
3053
+ return { content: [{ type: "text", text: `${APEX} An attribute condition needs field_path and operator.` }], isError: true };
2951
3054
  }
2952
3055
  predicate = {
2953
3056
  kind: "attribute",
2954
- fieldPath: c.fieldPath,
3057
+ fieldPath: c.field_path,
2955
3058
  operator: c.operator,
2956
3059
  ...(c.value !== undefined ? { value: c.value } : {}),
2957
3060
  };
2958
3061
  }
2959
3062
  else {
2960
- if (!c.eventName) {
2961
- return { content: [{ type: "text", text: `${APEX} An event condition needs eventName.` }], isError: true };
3063
+ if (!c.event_name) {
3064
+ return { content: [{ type: "text", text: `${APEX} An event condition needs event_name.` }], isError: true };
2962
3065
  }
2963
3066
  predicate = {
2964
3067
  kind: c.kind,
2965
- eventName: c.eventName,
2966
- window: c.windowDays ? { type: "last_n_days", days: c.windowDays } : { type: "ever" },
3068
+ eventName: c.event_name,
3069
+ window: c.window_days ? { type: "last_n_days", days: c.window_days } : { type: "ever" },
2967
3070
  };
2968
3071
  }
2969
3072
  const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
@@ -3081,15 +3184,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3081
3184
  get_journey: {
3082
3185
  description: `${APEX} — Read the full configuration for a single journey, including steps, goal event, attribution window, and exit rules. Use after list_journeys to inspect a journey's wiring before recommending edits.`,
3083
3186
  schema: z.object({
3084
- journeyId: z.string().optional().describe("Journey id returned from list_journeys."),
3085
- journey_id: z.string().optional().describe("Deprecated alias for journeyId."),
3187
+ journeyId: z.string().describe("Journey id returned from list_journeys."),
3086
3188
  }),
3087
- handler: async ({ journeyId, journey_id }) => {
3088
- const jid = journeyId ?? journey_id;
3089
- if (!jid) {
3090
- return { content: [{ type: "text", text: `${APEX} journeyId is required.` }], isError: true };
3091
- }
3092
- const journey = await apiGet(`/api/journeys/${encodeURIComponent(jid)}`);
3189
+ handler: async ({ journeyId }) => {
3190
+ const journey = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
3093
3191
  return {
3094
3192
  content: [
3095
3193
  {
@@ -3103,15 +3201,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3103
3201
  list_journey_exits: {
3104
3202
  description: `${APEX} — Just the exit-rules block of one journey. Cheaper than get_journey for audit loops that only need to know "does this journey suppress purchase / unsubscribe?".`,
3105
3203
  schema: z.object({
3106
- journeyId: z.string().optional(),
3107
- journey_id: z.string().optional().describe("Deprecated alias for journeyId."),
3204
+ journeyId: z.string(),
3108
3205
  }),
3109
- handler: async ({ journeyId, journey_id }) => {
3110
- const jid = journeyId ?? journey_id;
3111
- if (!jid) {
3112
- return { content: [{ type: "text", text: `${APEX} journeyId is required.` }], isError: true };
3113
- }
3114
- const body = await apiGet(`/api/journeys/${encodeURIComponent(jid)}/exit-rules`);
3206
+ handler: async ({ journeyId }) => {
3207
+ const body = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules`);
3115
3208
  return {
3116
3209
  content: [
3117
3210
  {
@@ -3125,21 +3218,16 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3125
3218
  add_journey_exit: {
3126
3219
  description: `${APEX} — Append a new exit rule to a journey's draft. Rule fires when the chosen trigger contract's event lands on a contact with an in-flight execution — Apex stops the execution and skips any pending sends. Re-publish required for the rule to take effect on live runs.`,
3127
3220
  schema: z.object({
3128
- journeyId: z.string().optional(),
3129
- journey_id: z.string().optional().describe("Deprecated alias for journeyId."),
3130
- trigger_contract_id: z
3221
+ journeyId: z.string(),
3222
+ triggerContractId: z
3131
3223
  .string()
3132
3224
  .describe("Trigger-contract id, e.g. trig-in-app-purchase"),
3133
3225
  label: z.string().optional(),
3134
3226
  enabled: z.boolean().optional().default(true),
3135
3227
  }),
3136
- handler: async ({ journeyId, journey_id, trigger_contract_id, label, enabled, }) => {
3137
- const jid = journeyId ?? journey_id;
3138
- if (!jid) {
3139
- return { content: [{ type: "text", text: `${APEX} journeyId is required.` }], isError: true };
3140
- }
3141
- const body = await apiPost(`/api/journeys/${encodeURIComponent(jid)}/exit-rules`, {
3142
- triggerContractId: trigger_contract_id,
3228
+ handler: async ({ journeyId, triggerContractId, label, enabled, }) => {
3229
+ const body = await apiPost(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules`, {
3230
+ triggerContractId,
3143
3231
  ...(label !== undefined && { label }),
3144
3232
  enabled: enabled !== false,
3145
3233
  });
@@ -3156,21 +3244,16 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3156
3244
  remove_journey_exit: {
3157
3245
  description: `${APEX} — Remove an exit rule from a journey's draft. Re-publish required for the change to apply to live runs.`,
3158
3246
  schema: z.object({
3159
- journeyId: z.string().optional(),
3160
- journey_id: z.string().optional().describe("Deprecated alias for journeyId."),
3161
- rule_id: z.string(),
3247
+ journeyId: z.string(),
3248
+ ruleId: z.string(),
3162
3249
  }),
3163
- handler: async ({ journeyId, journey_id, rule_id, }) => {
3164
- const jid = journeyId ?? journey_id;
3165
- if (!jid) {
3166
- return { content: [{ type: "text", text: `${APEX} journeyId is required.` }], isError: true };
3167
- }
3168
- await apiDelete(`/api/journeys/${encodeURIComponent(jid)}/exit-rules/${encodeURIComponent(rule_id)}`);
3250
+ handler: async ({ journeyId, ruleId, }) => {
3251
+ await apiDelete(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules/${encodeURIComponent(ruleId)}`);
3169
3252
  return {
3170
3253
  content: [
3171
3254
  {
3172
3255
  type: "text",
3173
- text: `Removed exit rule ${rule_id} from journey ${jid}.`,
3256
+ text: `Removed exit rule ${ruleId} from journey ${journeyId}.`,
3174
3257
  },
3175
3258
  ],
3176
3259
  };