@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/index.js +29 -2
- package/dist/index.js.map +1 -1
- package/dist/param-casing.d.ts +54 -0
- package/dist/param-casing.d.ts.map +1 -0
- package/dist/param-casing.js +101 -0
- package/dist/param-casing.js.map +1 -0
- package/dist/tools.d.ts +128 -90
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +131 -48
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/apex-experimentation/SKILL.md +5 -1
- package/skills/apex-journeys/SKILL.md +8 -0
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
2937
|
-
|
|
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.
|
|
2950
|
-
return { content: [{ type: "text", text: `${APEX} An attribute condition needs
|
|
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.
|
|
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.
|
|
2961
|
-
return { content: [{ type: "text", text: `${APEX} An event condition needs
|
|
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.
|
|
2966
|
-
window: c.
|
|
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().
|
|
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
|
|
3088
|
-
const
|
|
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()
|
|
3107
|
-
journey_id: z.string().optional().describe("Deprecated alias for journeyId."),
|
|
3204
|
+
journeyId: z.string(),
|
|
3108
3205
|
}),
|
|
3109
|
-
handler: async ({ journeyId
|
|
3110
|
-
const
|
|
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()
|
|
3129
|
-
|
|
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,
|
|
3137
|
-
const
|
|
3138
|
-
|
|
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()
|
|
3160
|
-
|
|
3161
|
-
rule_id: z.string(),
|
|
3247
|
+
journeyId: z.string(),
|
|
3248
|
+
ruleId: z.string(),
|
|
3162
3249
|
}),
|
|
3163
|
-
handler: async ({ journeyId,
|
|
3164
|
-
|
|
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 ${
|
|
3256
|
+
text: `Removed exit rule ${ruleId} from journey ${journeyId}.`,
|
|
3174
3257
|
},
|
|
3175
3258
|
],
|
|
3176
3259
|
};
|