@apex-inc/mcp-server 0.12.0 → 0.14.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 +135 -43
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +268 -25
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/apex-experimentation/SKILL.md +3 -1
- package/skills/apex-journeys/SKILL.md +8 -0
package/dist/tools.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
2
4
|
import { apiGet, apiPost, apiPatch, apiDelete, postWithIdempotency, setActiveWorkspace, getActiveWorkspace, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
|
|
3
5
|
import { resolveExperimentHypothesis } from "./experiment-copy.js";
|
|
4
6
|
const APEX = "∧ Apex";
|
|
@@ -62,6 +64,83 @@ function appUrl(path) {
|
|
|
62
64
|
function journeyLink(id) {
|
|
63
65
|
return appUrl(`/dashboard/communications/journeys/${id}`);
|
|
64
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Detect a MOBILE experiment surface from repo signals (the MCP runs in the
|
|
69
|
+
* merchant's repo). Walks up a few dirs looking for capacitor.config.* or a
|
|
70
|
+
* Capacitor/RN dependency. Returns null when there's no mobile signal (web is
|
|
71
|
+
* the safe default — a website repo carries no special marker). QA 2026-06-18.
|
|
72
|
+
*/
|
|
73
|
+
function detectSurfaceFromRepo() {
|
|
74
|
+
try {
|
|
75
|
+
let dir = process.cwd();
|
|
76
|
+
for (let i = 0; i < 4; i++) {
|
|
77
|
+
for (const f of [
|
|
78
|
+
"capacitor.config.ts",
|
|
79
|
+
"capacitor.config.js",
|
|
80
|
+
"capacitor.config.json",
|
|
81
|
+
"capacitor.config.mjs",
|
|
82
|
+
]) {
|
|
83
|
+
if (existsSync(join(dir, f)))
|
|
84
|
+
return "mobile";
|
|
85
|
+
}
|
|
86
|
+
const pkgPath = join(dir, "package.json");
|
|
87
|
+
if (existsSync(pkgPath)) {
|
|
88
|
+
try {
|
|
89
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
90
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
91
|
+
if (deps["@apex-inc/capacitor-plugin"] ||
|
|
92
|
+
deps["@capacitor/core"] ||
|
|
93
|
+
deps["react-native"]) {
|
|
94
|
+
return "mobile";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* unreadable package.json — keep walking */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const parent = dirname(dir);
|
|
102
|
+
if (parent === dir)
|
|
103
|
+
break;
|
|
104
|
+
dir = parent;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* fs not available — fall through */
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the experiment surface. Explicit wins; otherwise detect from repo
|
|
114
|
+
* signals, then the workspace's registered data sources. Returns an `ambiguous`
|
|
115
|
+
* error only when both web and mobile data sources exist and there's no repo
|
|
116
|
+
* signal — in that case the agent must choose. (QA: stop defaulting to web.)
|
|
117
|
+
*/
|
|
118
|
+
async function resolveCreateSurface(explicit) {
|
|
119
|
+
if (explicit)
|
|
120
|
+
return { surface: explicit };
|
|
121
|
+
const repo = detectSurfaceFromRepo();
|
|
122
|
+
if (repo)
|
|
123
|
+
return { surface: repo };
|
|
124
|
+
try {
|
|
125
|
+
const ws = getActiveWorkspace();
|
|
126
|
+
if (ws) {
|
|
127
|
+
const r = await apiGet(`/api/workspaces/${encodeURIComponent(ws)}/readiness`);
|
|
128
|
+
const sources = r.data?.sources ?? [];
|
|
129
|
+
const hasMobile = sources.some((s) => s.kind === "ios" || s.kind === "android");
|
|
130
|
+
const hasWeb = sources.some((s) => s.kind === "website");
|
|
131
|
+
if (hasMobile && !hasWeb)
|
|
132
|
+
return { surface: "mobile" };
|
|
133
|
+
if (hasWeb && !hasMobile)
|
|
134
|
+
return { surface: "web" };
|
|
135
|
+
if (hasMobile && hasWeb)
|
|
136
|
+
return { ambiguous: true };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* readiness unavailable — fall back to web */
|
|
141
|
+
}
|
|
142
|
+
return { surface: "web" };
|
|
143
|
+
}
|
|
65
144
|
/**
|
|
66
145
|
* Tenant echo (council CISO): never report a mutation as successful for a record
|
|
67
146
|
* outside the active workspace (guards a leaked/cross-workspace key).
|
|
@@ -337,7 +416,7 @@ export const toolDefinitions = {
|
|
|
337
416
|
guardrailThreshold: z.number().optional().describe("Harm margin for the guardrail, RELATIVE (0.25 = flag a >25% rise). Defaults to 0.25."),
|
|
338
417
|
predictionMagnitude: z.string().optional().describe("Your committed prediction of the effect, e.g. '10% lift'. Recorded with 'agent' provenance and used for calibration. Required before the experiment can be ACTIVATED."),
|
|
339
418
|
mode: z.enum(["sdk", "snippet"]).optional().describe("Experiment mode: 'sdk' for code-level (default via MCP), 'snippet' for runtime DOM"),
|
|
340
|
-
surface: z.enum(["web", "mobile"]).optional().describe("The PROPERTY the experiment runs in (not the SDK used): 'web'
|
|
419
|
+
surface: z.enum(["web", "mobile"]).optional().describe("The PROPERTY the experiment runs in (not the SDK used): 'web' for a website, 'mobile' for a native / Capacitor / React-Native app. OPTIONAL — when omitted it is auto-detected from repo signals (capacitor.config.* / @apex-inc/capacitor-plugin → mobile) and the workspace's registered data sources; pass it explicitly only to override or when both web and mobile sources exist. Sets the dashboard data-source label (Website vs Mobile app)."),
|
|
341
420
|
dataSourceId: z.string().optional().describe("Optional explicit Data Source (property) id this experiment runs in, e.g. 'ds_website' or 'ds_ios'. Usually inferred from surface; pass it to bind to a specific registered source. Tenant-validated server-side."),
|
|
342
421
|
randomizationUnit: z.enum(["device", "visitor", "person", "account", "org"]).optional().describe("What to bucket on. Default 'visitor' (per-device). Use 'person' to give one stitched user the same variant across their devices, or 'account'/'org' for B2B account-level tests where everyone in an account should see the same variant."),
|
|
343
422
|
preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
|
|
@@ -345,7 +424,23 @@ export const toolDefinitions = {
|
|
|
345
424
|
handler: async (args) => {
|
|
346
425
|
const split = args.trafficSplit ?? 50;
|
|
347
426
|
const mode = args.mode ?? "sdk";
|
|
348
|
-
|
|
427
|
+
// Surface auto-detect (QA 2026-06-18): when the agent doesn't pass a
|
|
428
|
+
// surface, infer it from repo signals + registered data sources instead of
|
|
429
|
+
// silently defaulting to web (which mislabels mobile apps). Ambiguous →
|
|
430
|
+
// ask the agent to choose.
|
|
431
|
+
const surfaceResolution = await resolveCreateSurface(args.surface);
|
|
432
|
+
if ("ambiguous" in surfaceResolution) {
|
|
433
|
+
return {
|
|
434
|
+
content: [
|
|
435
|
+
{
|
|
436
|
+
type: "text",
|
|
437
|
+
text: `${APEX} This workspace has both web and mobile data sources — pass surface ("web" or "mobile") explicitly so the experiment is labeled and grouped correctly.`,
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
isError: true,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const experimentSurface = surfaceResolution.surface;
|
|
349
444
|
const isPreview = args.preview !== false;
|
|
350
445
|
// p1-metric — resolve the primary metric. Defaults to the form_submit
|
|
351
446
|
// conversion metric; an explicit primaryMetricEvent is validated against
|
|
@@ -2133,11 +2228,11 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2133
2228
|
},
|
|
2134
2229
|
},
|
|
2135
2230
|
edit_communication: {
|
|
2136
|
-
description: "Update a communication's subject, body, CTA, channels, or status. Pass the communication ID and the fields to update.",
|
|
2231
|
+
description: "Update a communication's subject, body, CTA, channels, or status. Pass the communication ID and the fields to update. For blank/builder comms, the headline/body/ctaLabel/ctaUrl slots are synced into the rendered email body — verify with preview_communication.",
|
|
2137
2232
|
schema: z.object({
|
|
2138
2233
|
communicationId: z.string().describe("The communication ID to update"),
|
|
2139
2234
|
subject: z.string().optional().describe("New email subject line"),
|
|
2140
|
-
slots: z.record(z.string()).optional().describe("Content
|
|
2235
|
+
slots: z.record(z.string()).optional().describe("Content overrides: headline, body, ctaLabel, ctaUrl. These now sync into the rendered email body (not just metadata)."),
|
|
2141
2236
|
channels: z.array(z.string()).optional().describe("Channels: email, in_app_push, mobile_push"),
|
|
2142
2237
|
status: z.enum(["draft", "active", "paused"]).optional().describe("Communication status"),
|
|
2143
2238
|
}),
|
|
@@ -2159,13 +2254,31 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2159
2254
|
},
|
|
2160
2255
|
},
|
|
2161
2256
|
preview_communication: {
|
|
2162
|
-
description: "See the rendered
|
|
2257
|
+
description: "See the rendered communication with your brand applied. Returns the subject, the rendered plain-text body (so you can verify edits actually landed), and a browser preview link.",
|
|
2163
2258
|
schema: z.object({
|
|
2164
2259
|
communicationId: z.string().describe("The communication ID to preview"),
|
|
2165
2260
|
}),
|
|
2166
2261
|
handler: async (args) => {
|
|
2167
2262
|
const data = await apiPost(`/api/communications/${args.communicationId}/preview`, {});
|
|
2168
|
-
|
|
2263
|
+
// Strip tags from subject (preview may annotate it with a slot span).
|
|
2264
|
+
const subject = (data.subject ?? "").replace(/<[^>]+>/g, "");
|
|
2265
|
+
const text = (data.text ?? "").trim();
|
|
2266
|
+
const link = appUrl(`/dashboard/communications/${args.communicationId}`);
|
|
2267
|
+
return {
|
|
2268
|
+
content: [
|
|
2269
|
+
{
|
|
2270
|
+
type: "text",
|
|
2271
|
+
text: [
|
|
2272
|
+
`Subject: ${subject || "(none)"}`,
|
|
2273
|
+
``,
|
|
2274
|
+
`Rendered body:`,
|
|
2275
|
+
text ? text : "(empty — add content via edit_communication)",
|
|
2276
|
+
``,
|
|
2277
|
+
`Open in the browser to see the full styled email: ${link}`,
|
|
2278
|
+
].join("\n"),
|
|
2279
|
+
},
|
|
2280
|
+
],
|
|
2281
|
+
};
|
|
2169
2282
|
},
|
|
2170
2283
|
},
|
|
2171
2284
|
send_test_communication: {
|
|
@@ -2706,14 +2819,52 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2706
2819
|
}
|
|
2707
2820
|
},
|
|
2708
2821
|
},
|
|
2822
|
+
list_trigger_contracts: {
|
|
2823
|
+
description: `${APEX} — List the workspace's trigger contracts (the event bindings you pass to set_journey_trigger / add_journey_exit). Returns each contract's id + the event it fires on. Call this before set_journey_trigger so you use a real id instead of guessing.`,
|
|
2824
|
+
schema: z.object({}),
|
|
2825
|
+
handler: async () => {
|
|
2826
|
+
const data = await apiGet("/api/journeys/trigger-contracts");
|
|
2827
|
+
const contracts = data.contracts ?? [];
|
|
2828
|
+
if (contracts.length === 0) {
|
|
2829
|
+
return { content: [{ type: "text", text: `${APEX} No trigger contracts registered yet. Creating a blank journey seeds the starter set.` }] };
|
|
2830
|
+
}
|
|
2831
|
+
const lines = contracts.map((c) => ` • ${c.id} → ${c.eventName ?? "?"}`);
|
|
2832
|
+
return {
|
|
2833
|
+
content: [
|
|
2834
|
+
{
|
|
2835
|
+
type: "text",
|
|
2836
|
+
text: [`${APEX} Trigger contracts (${contracts.length})`, ...lines, ``, `Pass one of these ids as triggerContractId to set_journey_trigger.`].join("\n"),
|
|
2837
|
+
},
|
|
2838
|
+
],
|
|
2839
|
+
};
|
|
2840
|
+
},
|
|
2841
|
+
},
|
|
2709
2842
|
set_journey_trigger: {
|
|
2710
|
-
description: `${APEX} — Set the entry trigger (the event that enrolls subjects) on a draft journey's trigger step. Pass a triggerContractId
|
|
2843
|
+
description: `${APEX} — Set the entry trigger (the event that enrolls subjects) on a draft journey's trigger step. Pass a triggerContractId — discover valid IDs with list_trigger_contracts (an invalid id is rejected with the list of options).`,
|
|
2711
2844
|
schema: z.object({
|
|
2712
2845
|
journeyId: z.string(),
|
|
2713
|
-
triggerContractId: z.string().describe("The trigger contract id (event binding) to enroll on."),
|
|
2846
|
+
triggerContractId: z.string().describe("The trigger contract id (event binding) to enroll on — see list_trigger_contracts."),
|
|
2714
2847
|
}),
|
|
2715
2848
|
handler: async ({ journeyId, triggerContractId }) => {
|
|
2716
2849
|
try {
|
|
2850
|
+
// Validate the contract id up front so a typo doesn't silently set a
|
|
2851
|
+
// dead trigger that only fails at publish — enumerate valid options.
|
|
2852
|
+
const cdata = await apiGet("/api/journeys/trigger-contracts");
|
|
2853
|
+
const contracts = cdata.contracts ?? [];
|
|
2854
|
+
if (contracts.length > 0 && !contracts.some((c) => c.id === triggerContractId)) {
|
|
2855
|
+
const opts = contracts
|
|
2856
|
+
.map((c) => `${c.id}${c.eventName ? ` (${c.eventName})` : ""}`)
|
|
2857
|
+
.join(", ");
|
|
2858
|
+
return {
|
|
2859
|
+
content: [
|
|
2860
|
+
{
|
|
2861
|
+
type: "text",
|
|
2862
|
+
text: `${APEX} Unknown triggerContractId "${triggerContractId}". Valid options: ${opts}`,
|
|
2863
|
+
},
|
|
2864
|
+
],
|
|
2865
|
+
isError: true,
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2717
2868
|
const j = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
|
|
2718
2869
|
const steps = (j.steps ?? []).map((s) => s.type === "trigger" ? { ...s, trigger: { type: "event", triggerContractId } } : s);
|
|
2719
2870
|
const updated = await apiPatch(`/api/journeys/${encodeURIComponent(journeyId)}`, { steps });
|
|
@@ -2734,7 +2885,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2734
2885
|
durationIso: z.string().optional().describe("wait only: ISO-8601 duration, e.g. 'P1D' (1 day), 'PT2H' (2 hours)."),
|
|
2735
2886
|
commId: z.string().optional().describe("send only: the communication id to fire (create_communication or an existing comm)."),
|
|
2736
2887
|
commVersion: z.number().optional().describe("send only: pinned comm version (default 1)."),
|
|
2737
|
-
channels: z.array(z.enum(["email", "
|
|
2888
|
+
channels: z.array(z.enum(["email", "in_app_push", "mobile_push", "web_push"])).optional().describe("send only: channels to fire (default ['email']). Use the SAME tokens as create_communication (email, in_app_push, mobile_push) so a comm's channels map cleanly onto the send step; web_push is send-only."),
|
|
2738
2889
|
}),
|
|
2739
2890
|
handler: async (args) => {
|
|
2740
2891
|
try {
|
|
@@ -2767,6 +2918,98 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2767
2918
|
}
|
|
2768
2919
|
},
|
|
2769
2920
|
},
|
|
2921
|
+
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 (field_path + operator + value) or an event test (event_fired / event_not_fired within a window).`,
|
|
2923
|
+
schema: z.object({
|
|
2924
|
+
journeyId: z.string(),
|
|
2925
|
+
condition: z.object({
|
|
2926
|
+
kind: z.enum(["attribute", "event_fired", "event_not_fired"]),
|
|
2927
|
+
field_path: z.string().optional().describe("attribute only: dotted path, e.g. 'cart.valueCents' or 'plan'."),
|
|
2928
|
+
operator: z
|
|
2929
|
+
.enum(["equals", "not_equals", "greater_than", "less_than", "in", "exists", "not_exists"])
|
|
2930
|
+
.optional()
|
|
2931
|
+
.describe("attribute only."),
|
|
2932
|
+
value: z
|
|
2933
|
+
.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number(), z.boolean()]))])
|
|
2934
|
+
.optional()
|
|
2935
|
+
.describe("attribute only: the comparison value (omit for exists/not_exists)."),
|
|
2936
|
+
event_name: z.string().optional().describe("event only: the canonical event, e.g. 'purchase'."),
|
|
2937
|
+
window_days: z.number().optional().describe("event only: look-back window in days (omit = ever)."),
|
|
2938
|
+
}),
|
|
2939
|
+
whenTrueCommId: z.string().describe("Communication to send when the condition matches."),
|
|
2940
|
+
whenFalseCommId: z.string().optional().describe("Communication to send when it doesn't match (omit to just continue to exit)."),
|
|
2941
|
+
channels: z.array(z.enum(["email", "in_app_push", "mobile_push", "web_push"])).optional().describe("Channels for both send arms (default ['email'])."),
|
|
2942
|
+
}),
|
|
2943
|
+
handler: async (args) => {
|
|
2944
|
+
try {
|
|
2945
|
+
const c = args.condition;
|
|
2946
|
+
// Build the predicate leaf (AudiencePredicate is a bare leaf here).
|
|
2947
|
+
let predicate;
|
|
2948
|
+
if (c.kind === "attribute") {
|
|
2949
|
+
if (!c.field_path || !c.operator) {
|
|
2950
|
+
return { content: [{ type: "text", text: `${APEX} An attribute condition needs field_path and operator.` }], isError: true };
|
|
2951
|
+
}
|
|
2952
|
+
predicate = {
|
|
2953
|
+
kind: "attribute",
|
|
2954
|
+
fieldPath: c.field_path,
|
|
2955
|
+
operator: c.operator,
|
|
2956
|
+
...(c.value !== undefined ? { value: c.value } : {}),
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
else {
|
|
2960
|
+
if (!c.event_name) {
|
|
2961
|
+
return { content: [{ type: "text", text: `${APEX} An event condition needs event_name.` }], isError: true };
|
|
2962
|
+
}
|
|
2963
|
+
predicate = {
|
|
2964
|
+
kind: c.kind,
|
|
2965
|
+
eventName: c.event_name,
|
|
2966
|
+
window: c.window_days ? { type: "last_n_days", days: c.window_days } : { type: "ever" },
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
|
|
2970
|
+
const steps = [...(j.steps ?? [])];
|
|
2971
|
+
const exit = steps.find((s) => s.type === "exit");
|
|
2972
|
+
if (!exit) {
|
|
2973
|
+
return { content: [{ type: "text", text: `${APEX} Journey has no exit step; cannot insert a branch.` }], isError: true };
|
|
2974
|
+
}
|
|
2975
|
+
const exitId = exit.id;
|
|
2976
|
+
const channels = args.channels ?? ["email"];
|
|
2977
|
+
const mkId = () => `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
2978
|
+
const trueId = mkId();
|
|
2979
|
+
const trueStep = { id: trueId, type: "send", label: "Send (match)", commId: args.whenTrueCommId, commVersion: 1, channels, next: exitId };
|
|
2980
|
+
const newSteps = [trueStep];
|
|
2981
|
+
let defaultNext = exitId;
|
|
2982
|
+
if (args.whenFalseCommId) {
|
|
2983
|
+
const falseId = mkId();
|
|
2984
|
+
newSteps.push({ id: falseId, type: "send", label: "Send (no match)", commId: args.whenFalseCommId, commVersion: 1, channels, next: exitId });
|
|
2985
|
+
defaultNext = falseId;
|
|
2986
|
+
}
|
|
2987
|
+
const branchId = mkId();
|
|
2988
|
+
const branchStep = {
|
|
2989
|
+
id: branchId,
|
|
2990
|
+
type: "branch",
|
|
2991
|
+
label: "Branch",
|
|
2992
|
+
mode: "condition",
|
|
2993
|
+
conditions: [{ predicate, next: trueId }],
|
|
2994
|
+
defaultNext,
|
|
2995
|
+
};
|
|
2996
|
+
// Insert the branch before the exit: rewire whatever currently points
|
|
2997
|
+
// to the exit so the branch sits in the linear flow. (newSteps aren't in
|
|
2998
|
+
// `steps` yet, so this only matches the existing pre-exit step.)
|
|
2999
|
+
const pre = steps.find((s) => s.next === exitId);
|
|
3000
|
+
if (pre)
|
|
3001
|
+
pre.next = branchId;
|
|
3002
|
+
steps.push(branchStep, ...newSteps);
|
|
3003
|
+
const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps });
|
|
3004
|
+
if (!tenantOk(updated.workspaceKey))
|
|
3005
|
+
return tenantMismatch();
|
|
3006
|
+
return { content: [{ type: "text", text: `${APEX} Added a conditional branch to journey ${args.journeyId}. Watch it update in the browser: ${journeyLink(args.journeyId)}` }] };
|
|
3007
|
+
}
|
|
3008
|
+
catch (err) {
|
|
3009
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
3010
|
+
}
|
|
3011
|
+
},
|
|
3012
|
+
},
|
|
2770
3013
|
publish_journey: {
|
|
2771
3014
|
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.`,
|
|
2772
3015
|
schema: z.object({
|
|
@@ -2838,10 +3081,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2838
3081
|
get_journey: {
|
|
2839
3082
|
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.`,
|
|
2840
3083
|
schema: z.object({
|
|
2841
|
-
|
|
3084
|
+
journeyId: z.string().describe("Journey id returned from list_journeys."),
|
|
2842
3085
|
}),
|
|
2843
|
-
handler: async ({
|
|
2844
|
-
const journey = await apiGet(`/api/journeys/${encodeURIComponent(
|
|
3086
|
+
handler: async ({ journeyId }) => {
|
|
3087
|
+
const journey = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
|
|
2845
3088
|
return {
|
|
2846
3089
|
content: [
|
|
2847
3090
|
{
|
|
@@ -2855,10 +3098,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2855
3098
|
list_journey_exits: {
|
|
2856
3099
|
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?".`,
|
|
2857
3100
|
schema: z.object({
|
|
2858
|
-
|
|
3101
|
+
journeyId: z.string(),
|
|
2859
3102
|
}),
|
|
2860
|
-
handler: async ({
|
|
2861
|
-
const body = await apiGet(`/api/journeys/${encodeURIComponent(
|
|
3103
|
+
handler: async ({ journeyId }) => {
|
|
3104
|
+
const body = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules`);
|
|
2862
3105
|
return {
|
|
2863
3106
|
content: [
|
|
2864
3107
|
{
|
|
@@ -2872,16 +3115,16 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2872
3115
|
add_journey_exit: {
|
|
2873
3116
|
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.`,
|
|
2874
3117
|
schema: z.object({
|
|
2875
|
-
|
|
2876
|
-
|
|
3118
|
+
journeyId: z.string(),
|
|
3119
|
+
triggerContractId: z
|
|
2877
3120
|
.string()
|
|
2878
3121
|
.describe("Trigger-contract id, e.g. trig-in-app-purchase"),
|
|
2879
3122
|
label: z.string().optional(),
|
|
2880
3123
|
enabled: z.boolean().optional().default(true),
|
|
2881
3124
|
}),
|
|
2882
|
-
handler: async ({
|
|
2883
|
-
const body = await apiPost(`/api/journeys/${encodeURIComponent(
|
|
2884
|
-
triggerContractId
|
|
3125
|
+
handler: async ({ journeyId, triggerContractId, label, enabled, }) => {
|
|
3126
|
+
const body = await apiPost(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules`, {
|
|
3127
|
+
triggerContractId,
|
|
2885
3128
|
...(label !== undefined && { label }),
|
|
2886
3129
|
enabled: enabled !== false,
|
|
2887
3130
|
});
|
|
@@ -2898,16 +3141,16 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2898
3141
|
remove_journey_exit: {
|
|
2899
3142
|
description: `${APEX} — Remove an exit rule from a journey's draft. Re-publish required for the change to apply to live runs.`,
|
|
2900
3143
|
schema: z.object({
|
|
2901
|
-
|
|
2902
|
-
|
|
3144
|
+
journeyId: z.string(),
|
|
3145
|
+
ruleId: z.string(),
|
|
2903
3146
|
}),
|
|
2904
|
-
handler: async ({
|
|
2905
|
-
await apiDelete(`/api/journeys/${encodeURIComponent(
|
|
3147
|
+
handler: async ({ journeyId, ruleId, }) => {
|
|
3148
|
+
await apiDelete(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules/${encodeURIComponent(ruleId)}`);
|
|
2906
3149
|
return {
|
|
2907
3150
|
content: [
|
|
2908
3151
|
{
|
|
2909
3152
|
type: "text",
|
|
2910
|
-
text: `Removed exit rule ${
|
|
3153
|
+
text: `Removed exit rule ${ruleId} from journey ${journeyId}.`,
|
|
2911
3154
|
},
|
|
2912
3155
|
],
|
|
2913
3156
|
};
|