@apex-inc/mcp-server 0.11.0 → 0.13.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.d.ts +171 -35
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +299 -28
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
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";
|
|
@@ -54,6 +56,91 @@ function titleCaseEvent(event) {
|
|
|
54
56
|
function errMsg(err) {
|
|
55
57
|
return err instanceof Error ? err.message : String(err);
|
|
56
58
|
}
|
|
59
|
+
/** Build a dashboard deep link on the same host the MCP talks to (app origin). */
|
|
60
|
+
function appUrl(path) {
|
|
61
|
+
const base = process.env.APEX_URL || process.env.APEX_API_URL || "http://localhost:3001";
|
|
62
|
+
return `${base.replace(/\/$/, "")}${path}`;
|
|
63
|
+
}
|
|
64
|
+
function journeyLink(id) {
|
|
65
|
+
return appUrl(`/dashboard/communications/journeys/${id}`);
|
|
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
|
+
}
|
|
57
144
|
/**
|
|
58
145
|
* Tenant echo (council CISO): never report a mutation as successful for a record
|
|
59
146
|
* outside the active workspace (guards a leaked/cross-workspace key).
|
|
@@ -329,7 +416,7 @@ export const toolDefinitions = {
|
|
|
329
416
|
guardrailThreshold: z.number().optional().describe("Harm margin for the guardrail, RELATIVE (0.25 = flag a >25% rise). Defaults to 0.25."),
|
|
330
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."),
|
|
331
418
|
mode: z.enum(["sdk", "snippet"]).optional().describe("Experiment mode: 'sdk' for code-level (default via MCP), 'snippet' for runtime DOM"),
|
|
332
|
-
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)."),
|
|
333
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."),
|
|
334
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."),
|
|
335
422
|
preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
|
|
@@ -337,7 +424,23 @@ export const toolDefinitions = {
|
|
|
337
424
|
handler: async (args) => {
|
|
338
425
|
const split = args.trafficSplit ?? 50;
|
|
339
426
|
const mode = args.mode ?? "sdk";
|
|
340
|
-
|
|
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;
|
|
341
444
|
const isPreview = args.preview !== false;
|
|
342
445
|
// p1-metric — resolve the primary metric. Defaults to the form_submit
|
|
343
446
|
// conversion metric; an explicit primaryMetricEvent is validated against
|
|
@@ -2125,11 +2228,11 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2125
2228
|
},
|
|
2126
2229
|
},
|
|
2127
2230
|
edit_communication: {
|
|
2128
|
-
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.",
|
|
2129
2232
|
schema: z.object({
|
|
2130
2233
|
communicationId: z.string().describe("The communication ID to update"),
|
|
2131
2234
|
subject: z.string().optional().describe("New email subject line"),
|
|
2132
|
-
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)."),
|
|
2133
2236
|
channels: z.array(z.string()).optional().describe("Channels: email, in_app_push, mobile_push"),
|
|
2134
2237
|
status: z.enum(["draft", "active", "paused"]).optional().describe("Communication status"),
|
|
2135
2238
|
}),
|
|
@@ -2151,13 +2254,31 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2151
2254
|
},
|
|
2152
2255
|
},
|
|
2153
2256
|
preview_communication: {
|
|
2154
|
-
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.",
|
|
2155
2258
|
schema: z.object({
|
|
2156
2259
|
communicationId: z.string().describe("The communication ID to preview"),
|
|
2157
2260
|
}),
|
|
2158
2261
|
handler: async (args) => {
|
|
2159
2262
|
const data = await apiPost(`/api/communications/${args.communicationId}/preview`, {});
|
|
2160
|
-
|
|
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
|
+
};
|
|
2161
2282
|
},
|
|
2162
2283
|
},
|
|
2163
2284
|
send_test_communication: {
|
|
@@ -2676,7 +2797,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2676
2797
|
const j = await apiPost("/api/journeys/blank", { name });
|
|
2677
2798
|
if (!tenantOk(j.workspaceKey))
|
|
2678
2799
|
return tenantMismatch();
|
|
2679
|
-
return { content: [{ type: "text", text: `${APEX} Created draft journey "${j.name}" (id: ${j.id}).
|
|
2800
|
+
return { content: [{ type: "text", text: `${APEX} Created draft journey "${j.name}" (id: ${j.id}). Open it in the browser: ${journeyLink(j.id)}\nNext: set_journey_trigger, then add_journey_step.` }] };
|
|
2680
2801
|
}
|
|
2681
2802
|
catch (err) {
|
|
2682
2803
|
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
@@ -2691,27 +2812,65 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2691
2812
|
const j = await apiPost("/api/journeys/from-template", { templateId });
|
|
2692
2813
|
if (!tenantOk(j.workspaceKey))
|
|
2693
2814
|
return tenantMismatch();
|
|
2694
|
-
return { content: [{ type: "text", text: `${APEX} Instantiated "${j.name}" (id: ${j.id}) from template ${templateId}.
|
|
2815
|
+
return { content: [{ type: "text", text: `${APEX} Instantiated "${j.name}" (id: ${j.id}) from template ${templateId}. Open it in the browser: ${journeyLink(j.id)}\nReview its steps with get_journey, then publish_journey.` }] };
|
|
2695
2816
|
}
|
|
2696
2817
|
catch (err) {
|
|
2697
2818
|
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
2698
2819
|
}
|
|
2699
2820
|
},
|
|
2700
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
|
+
},
|
|
2701
2842
|
set_journey_trigger: {
|
|
2702
|
-
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).`,
|
|
2703
2844
|
schema: z.object({
|
|
2704
2845
|
journeyId: z.string(),
|
|
2705
|
-
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."),
|
|
2706
2847
|
}),
|
|
2707
2848
|
handler: async ({ journeyId, triggerContractId }) => {
|
|
2708
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
|
+
}
|
|
2709
2868
|
const j = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
|
|
2710
2869
|
const steps = (j.steps ?? []).map((s) => s.type === "trigger" ? { ...s, trigger: { type: "event", triggerContractId } } : s);
|
|
2711
2870
|
const updated = await apiPatch(`/api/journeys/${encodeURIComponent(journeyId)}`, { steps });
|
|
2712
2871
|
if (!tenantOk(updated.workspaceKey))
|
|
2713
2872
|
return tenantMismatch();
|
|
2714
|
-
return { content: [{ type: "text", text: `${APEX} Trigger set to contract ${triggerContractId} on journey ${journeyId}
|
|
2873
|
+
return { content: [{ type: "text", text: `${APEX} Trigger set to contract ${triggerContractId} on journey ${journeyId}. View it: ${journeyLink(journeyId)}` }] };
|
|
2715
2874
|
}
|
|
2716
2875
|
catch (err) {
|
|
2717
2876
|
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
@@ -2726,7 +2885,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2726
2885
|
durationIso: z.string().optional().describe("wait only: ISO-8601 duration, e.g. 'P1D' (1 day), 'PT2H' (2 hours)."),
|
|
2727
2886
|
commId: z.string().optional().describe("send only: the communication id to fire (create_communication or an existing comm)."),
|
|
2728
2887
|
commVersion: z.number().optional().describe("send only: pinned comm version (default 1)."),
|
|
2729
|
-
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."),
|
|
2730
2889
|
}),
|
|
2731
2890
|
handler: async (args) => {
|
|
2732
2891
|
try {
|
|
@@ -2752,7 +2911,99 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2752
2911
|
const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps });
|
|
2753
2912
|
if (!tenantOk(updated.workspaceKey))
|
|
2754
2913
|
return tenantMismatch();
|
|
2755
|
-
return { content: [{ type: "text", text: `${APEX} Added ${args.kind} step to journey ${args.journeyId}
|
|
2914
|
+
return { content: [{ type: "text", text: `${APEX} Added ${args.kind} step to journey ${args.journeyId}. Watch it update in the browser: ${journeyLink(args.journeyId)}` }] };
|
|
2915
|
+
}
|
|
2916
|
+
catch (err) {
|
|
2917
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
2918
|
+
}
|
|
2919
|
+
},
|
|
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 (fieldPath + 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
|
+
fieldPath: 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
|
+
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)."),
|
|
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.fieldPath || !c.operator) {
|
|
2950
|
+
return { content: [{ type: "text", text: `${APEX} An attribute condition needs fieldPath and operator.` }], isError: true };
|
|
2951
|
+
}
|
|
2952
|
+
predicate = {
|
|
2953
|
+
kind: "attribute",
|
|
2954
|
+
fieldPath: c.fieldPath,
|
|
2955
|
+
operator: c.operator,
|
|
2956
|
+
...(c.value !== undefined ? { value: c.value } : {}),
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
else {
|
|
2960
|
+
if (!c.eventName) {
|
|
2961
|
+
return { content: [{ type: "text", text: `${APEX} An event condition needs eventName.` }], isError: true };
|
|
2962
|
+
}
|
|
2963
|
+
predicate = {
|
|
2964
|
+
kind: c.kind,
|
|
2965
|
+
eventName: c.eventName,
|
|
2966
|
+
window: c.windowDays ? { type: "last_n_days", days: c.windowDays } : { 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)}` }] };
|
|
2756
3007
|
}
|
|
2757
3008
|
catch (err) {
|
|
2758
3009
|
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
@@ -2780,7 +3031,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2780
3031
|
const res = await apiPost(path, {});
|
|
2781
3032
|
if (!tenantOk(res.workspaceKey))
|
|
2782
3033
|
return tenantMismatch();
|
|
2783
|
-
return { content: [{ type: "text", text: `${APEX} Published journey ${journeyId} — it is now live on trigger events
|
|
3034
|
+
return { content: [{ type: "text", text: `${APEX} Published journey ${journeyId} — it is now live on trigger events. View it: ${journeyLink(journeyId)}` }] };
|
|
2784
3035
|
}
|
|
2785
3036
|
catch (err) {
|
|
2786
3037
|
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
@@ -2798,7 +3049,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2798
3049
|
const comm = await apiPost("/api/communications/blank", { title, channels });
|
|
2799
3050
|
if (!tenantOk(comm.workspaceKey))
|
|
2800
3051
|
return tenantMismatch();
|
|
2801
|
-
return { content: [{ type: "text", text: `${APEX} Created draft communication "${comm.title}" (id: ${comm.id}).
|
|
3052
|
+
return { content: [{ type: "text", text: `${APEX} Created draft communication "${comm.title}" (id: ${comm.id}). Open it in the browser: ${appUrl(`/dashboard/communications/${comm.id}`)}\nEdit it (or edit_communication), then reference it from a journey send step.` }] };
|
|
2802
3053
|
}
|
|
2803
3054
|
catch (err) {
|
|
2804
3055
|
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
@@ -2830,10 +3081,15 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2830
3081
|
get_journey: {
|
|
2831
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.`,
|
|
2832
3083
|
schema: z.object({
|
|
2833
|
-
|
|
3084
|
+
journeyId: z.string().optional().describe("Journey id returned from list_journeys."),
|
|
3085
|
+
journey_id: z.string().optional().describe("Deprecated alias for journeyId."),
|
|
2834
3086
|
}),
|
|
2835
|
-
handler: async ({ journey_id }) => {
|
|
2836
|
-
const
|
|
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)}`);
|
|
2837
3093
|
return {
|
|
2838
3094
|
content: [
|
|
2839
3095
|
{
|
|
@@ -2847,10 +3103,15 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2847
3103
|
list_journey_exits: {
|
|
2848
3104
|
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?".`,
|
|
2849
3105
|
schema: z.object({
|
|
2850
|
-
|
|
3106
|
+
journeyId: z.string().optional(),
|
|
3107
|
+
journey_id: z.string().optional().describe("Deprecated alias for journeyId."),
|
|
2851
3108
|
}),
|
|
2852
|
-
handler: async ({ journey_id }) => {
|
|
2853
|
-
const
|
|
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`);
|
|
2854
3115
|
return {
|
|
2855
3116
|
content: [
|
|
2856
3117
|
{
|
|
@@ -2864,15 +3125,20 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2864
3125
|
add_journey_exit: {
|
|
2865
3126
|
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.`,
|
|
2866
3127
|
schema: z.object({
|
|
2867
|
-
|
|
3128
|
+
journeyId: z.string().optional(),
|
|
3129
|
+
journey_id: z.string().optional().describe("Deprecated alias for journeyId."),
|
|
2868
3130
|
trigger_contract_id: z
|
|
2869
3131
|
.string()
|
|
2870
3132
|
.describe("Trigger-contract id, e.g. trig-in-app-purchase"),
|
|
2871
3133
|
label: z.string().optional(),
|
|
2872
3134
|
enabled: z.boolean().optional().default(true),
|
|
2873
3135
|
}),
|
|
2874
|
-
handler: async ({ journey_id, trigger_contract_id, label, enabled, }) => {
|
|
2875
|
-
const
|
|
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`, {
|
|
2876
3142
|
triggerContractId: trigger_contract_id,
|
|
2877
3143
|
...(label !== undefined && { label }),
|
|
2878
3144
|
enabled: enabled !== false,
|
|
@@ -2890,16 +3156,21 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2890
3156
|
remove_journey_exit: {
|
|
2891
3157
|
description: `${APEX} — Remove an exit rule from a journey's draft. Re-publish required for the change to apply to live runs.`,
|
|
2892
3158
|
schema: z.object({
|
|
2893
|
-
|
|
3159
|
+
journeyId: z.string().optional(),
|
|
3160
|
+
journey_id: z.string().optional().describe("Deprecated alias for journeyId."),
|
|
2894
3161
|
rule_id: z.string(),
|
|
2895
3162
|
}),
|
|
2896
|
-
handler: async ({ journey_id, rule_id, }) => {
|
|
2897
|
-
|
|
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)}`);
|
|
2898
3169
|
return {
|
|
2899
3170
|
content: [
|
|
2900
3171
|
{
|
|
2901
3172
|
type: "text",
|
|
2902
|
-
text: `Removed exit rule ${rule_id} from journey ${
|
|
3173
|
+
text: `Removed exit rule ${rule_id} from journey ${jid}.`,
|
|
2903
3174
|
},
|
|
2904
3175
|
],
|
|
2905
3176
|
};
|