@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.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' (default) for a website, 'mobile' for a native / Capacitor / React-Native app. If the repo is a mobile app (has capacitor.config.* or @apex-inc/capacitor-plugin), set 'mobile' for ALL its experiments even ones resolved via useApexVariant in the web layer so they group under the app, not a website. This sets the dashboard data-source label (Website vs Mobile app)."),
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
- const experimentSurface = args.surface ?? "web";
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 slot overrides (e.g. headline, body, ctaLabel, ctaUrl)"),
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 email with your brand applied. Returns HTML that can be displayed or sent as a test.",
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
- return { content: [{ type: "text", text: `Subject: ${data.subject}\n\nHTML preview generated (${data.html?.length || 0} chars). The email is ready to send.` }] };
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}). Next: set_journey_trigger, then add_journey_step.` }] };
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}. Review its steps with get_journey, then publish_journey.` }] };
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 (a blank journey seeds the starter contracts; get_journey shows the current value).`,
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", "in_app", "mobile_push", "web_push"])).optional().describe("send only: channels to fire (default ['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}). Edit it with edit_communication, then reference it from a journey send step.` }] };
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
- journey_id: z.string().describe("Journey id returned from list_journeys"),
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 journey = await apiGet(`/api/journeys/${encodeURIComponent(journey_id)}`);
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
- journey_id: z.string(),
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 body = await apiGet(`/api/journeys/${encodeURIComponent(journey_id)}/exit-rules`);
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
- journey_id: z.string(),
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 body = await apiPost(`/api/journeys/${encodeURIComponent(journey_id)}/exit-rules`, {
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
- journey_id: z.string(),
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
- await apiDelete(`/api/journeys/${encodeURIComponent(journey_id)}/exit-rules/${encodeURIComponent(rule_id)}`);
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 ${journey_id}.`,
3173
+ text: `Removed exit rule ${rule_id} from journey ${jid}.`,
2903
3174
  },
2904
3175
  ],
2905
3176
  };