@apex-inc/mcp-server 0.50.0 → 0.52.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.
@@ -17,6 +17,27 @@ const NETWORK_WORD = {
17
17
  function textError(text) {
18
18
  return { content: [{ type: "text", text: `${APEX} ${text}` }], isError: true };
19
19
  }
20
+ /**
21
+ * The reason a goal event can't be used in this workspace, or null when it
22
+ * can. Same check the experiments API enforces on create and edit; when the
23
+ * check itself can't be reached, the API's own refusal still applies.
24
+ */
25
+ async function refuseGoalEvent(event) {
26
+ let check;
27
+ try {
28
+ check = await apiGet(`/api/experiments/goal-check?event=${encodeURIComponent(event)}`);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ if (check.data?.accepted !== false)
34
+ return null;
35
+ const suggestions = check.data.suggestions ?? [];
36
+ return [
37
+ check.error ?? `"${event}" isn't an event this workspace receives or defined.`,
38
+ suggestions.length ? `Closest events here: ${suggestions.join(", ")}.` : "Call list_events to see what this workspace receives.",
39
+ ].join(" ");
40
+ }
20
41
  async function createAdHoldout(args) {
21
42
  if (args.surface && args.surface !== "paid_channel") {
22
43
  return textError(`paid_channel only applies to surface "paid_channel" (got "${args.surface}").`);
@@ -26,9 +47,10 @@ async function createAdHoldout(args) {
26
47
  return textError(`An Ad holdout needs a paid_channel block: { network, ad_set_ids, holdout_percent, attach_mode? }. Use list_ad_measurement or the dashboard's Ad holdout page to find the ad set ids.`);
27
48
  }
28
49
  const networkWord = NETWORK_WORD[pc.network] ?? pc.network;
29
- const attachMode = pc.attach_mode ?? (pc.network === "meta" ? "apex" : "manual");
30
- if (attachMode === "apex" && pc.network !== "meta") {
31
- return textError(`Apex can put the list on the ad sets itself on Meta only. For ${networkWord} pass attach_mode "manual".`);
50
+ const apexAttaches = pc.network === "meta" || pc.network === "tiktok";
51
+ const attachMode = pc.attach_mode ?? (apexAttaches ? "apex" : "manual");
52
+ if (attachMode === "apex" && !apexAttaches) {
53
+ return textError(`Apex can put the list on the ad sets itself on Meta and TikTok only. For ${networkWord} pass attach_mode "manual".`);
32
54
  }
33
55
  const adSetIds = [...new Set(pc.ad_set_ids.map((s) => s.trim()).filter(Boolean))];
34
56
  if (adSetIds.length === 0)
@@ -317,7 +339,7 @@ export const experimentTools = {
317
339
  beliefStatement: z.string().optional().describe("If you don't have a beliefId, pass the assumption statement and Apex will create the belief (and a hypothesis) and link them to this experiment."),
318
340
  beliefConfidence: z.number().min(0).max(100).optional().describe("Confidence (0-100%) for the belief created from beliefStatement. Defaults to 50."),
319
341
  hypothesisStatement: z.string().optional().describe("Optional hypothesis statement to record alongside a belief created from beliefStatement."),
320
- primaryMetricEvent: z.string().optional().describe("Canonical event name the experiment optimizes, e.g. 'add_to_cart', 'checkout_completed', 'form_submit'. Validated against the workspace event spec. Prefer an OUTCOME event (purchase/signup) over a proxy (email_open) — use list_metrics to see which are DETECTED with volume."),
342
+ primaryMetricEvent: z.string().optional().describe("Event the experiment optimizes: a standard Apex event (e.g. 'checkout_completed') or any event this workspace receives or defined with define_event (e.g. 'demo_booked'). Checked against this workspace; a name it has never seen is refused with the closest matches. Prefer an OUTCOME event (purchase/signup) over a proxy (email_open) — use list_metrics to see which are DETECTED with volume."),
321
343
  primaryMetricType: z.enum(["rate", "count", "revenue", "duration"]).optional().describe("How the goal is measured. 'rate' (default) = did it happen. 'revenue'/'duration' = a continuous value summed per subject (set primaryMetricValueProperty). Use 'revenue' for Stripe/purchase-value goals."),
322
344
  primaryMetricValueProperty: z.string().optional().describe("For revenue/duration goals: the event property to sum, e.g. 'value'. Defaults to 'value'."),
323
345
  guardrailEvent: z.string().optional().describe("Canonical protective event for an EXTRA guardrail that must not regress (e.g. 'purchase_refunded', 'app_uninstall', 'js_error'). Apex already auto-protects revenue/refunds/errors by default — only pass this to add a test-specific harm signal. Use list_metrics({role:'guardrail'}) to see what's firing. Choose one ORTHOGONAL to the primary goal."),
@@ -327,11 +349,11 @@ export const experimentTools = {
327
349
  surface: z.enum(["web", "mobile", "paid_channel"]).optional().describe("The PROPERTY the experiment runs in (not the SDK used): 'web' for a website, 'mobile' for a native / Capacitor / React-Native app, 'paid_channel' for an Ad holdout (Apex holds a share of known people back from the workspace's ads on one network and measures the installs the ads actually caused — pass paid_channel too). OPTIONAL for web/mobile — 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 vs Ads)."),
328
350
  paid_channel: z
329
351
  .object({
330
- network: z.enum(["meta", "google", "tiktok", "linkedin"]).describe("Ad network the ad sets run on. Apex attaches the do-not-show list itself on Meta only; other networks are manual attach."),
352
+ network: z.enum(["meta", "google", "tiktok", "linkedin"]).describe("Ad network the ad sets run on (TikTok: ad group ids). Apex attaches the do-not-show list itself on Meta and TikTok; Google and LinkedIn are manual attach."),
331
353
  ad_account_id: z.string().optional().describe("The connected ad account the ad sets belong to. Omit to use the account the workspace connected for this network."),
332
354
  ad_set_ids: z.array(z.string()).min(1).describe("Ad set ids to hold people back from (max 20). Frozen at create; changing them is a new holdout."),
333
355
  holdout_percent: z.number().min(0.05).max(0.5).describe("Share of the known population held back, 0.05–0.5 (0.2 = one in five)."),
334
- attach_mode: z.enum(["apex", "manual"]).optional().describe("'apex' = Apex puts the list on the ad sets and checks daily (Meta only). 'manual' = the owner adds the audience as an exclusion by hand; Apex checks daily and counts nothing until it is on. Defaults to 'apex' on Meta, 'manual' elsewhere."),
356
+ attach_mode: z.enum(["apex", "manual"]).optional().describe("'apex' = Apex puts the list on the ad sets and checks daily (Meta and TikTok). 'manual' = the owner adds the audience as an exclusion by hand; Apex checks daily and counts nothing until it is on. Defaults to 'apex' on Meta and TikTok, 'manual' elsewhere."),
335
357
  washout_days: z.number().int().min(0).max(30).optional().describe("Days after the list lands before installs count, so pre-list ad exposure washes out. Default 3."),
336
358
  population_segment_id: z.string().optional().describe("Optional Segment id narrowing who can be drawn."),
337
359
  })
@@ -416,31 +438,9 @@ export const experimentTools = {
416
438
  };
417
439
  if (args.primaryMetricEvent) {
418
440
  const event = args.primaryMetricEvent.trim();
419
- let validEvents = [];
420
- try {
421
- const spec = await apiGet("/api/spec/events");
422
- validEvents = Object.keys(spec.data?.events ?? {});
423
- }
424
- catch (err) {
425
- const message = err instanceof Error ? err.message : String(err);
426
- return {
427
- content: [{
428
- type: "text",
429
- text: `Could not load the event spec to validate primaryMetricEvent "${event}" (${message}). Fix the connection, or omit primaryMetricEvent to create the draft without a conversion goal (you'll set it before starting).`,
430
- }],
431
- isError: true,
432
- };
433
- }
434
- if (validEvents.length && !validEvents.includes(event)) {
435
- const sample = validEvents.slice(0, 12).join(", ");
436
- return {
437
- content: [{
438
- type: "text",
439
- text: `"${event}" is not a canonical event in this workspace's event spec. Valid options include: ${sample}${validEvents.length > 12 ? ", …" : ""}. Use get_event_spec for the full list.`,
440
- }],
441
- isError: true,
442
- };
443
- }
441
+ const refused = await refuseGoalEvent(event);
442
+ if (refused)
443
+ return textError(refused);
444
444
  const metricType = args.primaryMetricType ?? "rate";
445
445
  const isValue = metricType === "revenue" || metricType === "duration";
446
446
  const valueProperty = args.primaryMetricValueProperty ?? "value";
@@ -1660,7 +1660,7 @@ export const experimentTools = {
1660
1660
  },
1661
1661
  },
1662
1662
  attach_ad_exclusion: {
1663
- description: `${APEX} — Ad holdout only (surface paid_channel, Meta only). Apex puts the held-back list on the holdout's ad sets itself: exactly one exclusion added per ad set, every other targeting key (locations, age, placements, ...) left as read, then read back to confirm. Two steps, always in this order: (1) call with preview=true (default) and SHOW the workspace the before/after per ad set — which ad sets get the exclusion, which already have it, which are on another account and will not be touched; (2) once the workspace confirms, call with preview=false. The first real attach in a workspace needs acknowledge=true, which records the workspace's standing allowance for Apex to edit ad-set targeting (say so before passing it; never pass it without the workspace having seen the preview). Refuses with needs_targeting_ack when it is missing, list_not_on_network while the list is still syncing, and not for Google/TikTok/LinkedIn (there the owner adds the list in the network's tools and verify_ad_exclusion checks). Ending, pausing or archiving the holdout takes the list off again on its own. Not billed.`,
1663
+ description: `${APEX} — Ad holdout only (surface paid_channel, Meta and TikTok). Apex puts the held-back list on the holdout's ad sets (TikTok: ad groups) itself: exactly one exclusion added per ad set, every other targeting key (locations, age, placements, ...) left as read, then read back to confirm. Two steps, always in this order: (1) call with preview=true (default) and SHOW the workspace the before/after per ad set — which ad sets get the exclusion, which already have it, which are on another account and will not be touched; (2) once the workspace confirms, call with preview=false. The first real attach in a workspace needs acknowledge=true, which records the workspace's standing allowance for Apex to edit ad-set targeting (say so before passing it; never pass it without the workspace having seen the preview). Refuses with needs_targeting_ack when it is missing, list_not_on_network while the list is still syncing, and not for Google/LinkedIn (there the owner adds the list in the network's tools and verify_ad_exclusion checks). Ending, pausing or archiving the holdout takes the list off again on its own. Not billed.`,
1664
1664
  schema: z.object({
1665
1665
  experimentId: z.string().describe("The Ad holdout experiment id."),
1666
1666
  preview: z.boolean().optional().describe("true (default): read-only — return the before/after per ad set and whether the allowance is on file. false: make the edit."),
@@ -1686,8 +1686,8 @@ export const experimentTools = {
1686
1686
  note: a.message ?? null,
1687
1687
  }));
1688
1688
  const reasonWords = {
1689
- no_connection: "Meta Ads is not connected with outbound access.",
1690
- list_not_on_network: "The held-back list has not reached Meta yet. Activate the holdout and wait for the sync; then attach.",
1689
+ no_connection: "The ad platform is not connected with outbound access.",
1690
+ list_not_on_network: "The held-back list has not reached the ad platform yet. Activate the holdout and wait for the sync; then attach.",
1691
1691
  adsets_not_ready: "At least one ad set could not be read or is on another ad account. Apex will not touch those.",
1692
1692
  };
1693
1693
  return {
@@ -1715,10 +1715,10 @@ export const experimentTools = {
1715
1715
  const r = await apiPost(path, args.acknowledge ? { acknowledge: true } : {});
1716
1716
  const d = r.data;
1717
1717
  const words = {
1718
- attached: "The list is on every ad set and Meta confirmed it. Counting starts after the washout.",
1719
- syncing: "Apex put the list on every ad set. It is checking Meta has taken it; verify_ad_exclusion reads the answer.",
1718
+ attached: "The list is on every ad set and the ad platform confirmed it. Counting starts after the washout.",
1719
+ syncing: "Apex put the list on every ad set. It is checking the ad platform has taken it; verify_ad_exclusion reads the answer.",
1720
1720
  failed_verify: "The list is on some ad sets, not all. See the changes below and last_error.",
1721
- token_expired: "Meta no longer accepts Apex's connection. Reconnect Meta Ads and attach again.",
1721
+ token_expired: "The ad platform no longer accepts Apex's connection. Sign in to it again and attach again.",
1722
1722
  };
1723
1723
  return {
1724
1724
  content: [{
@@ -1740,7 +1740,7 @@ export const experimentTools = {
1740
1740
  })),
1741
1741
  last_error: d.lastVerifyError ?? null,
1742
1742
  _next: d.allSettled
1743
- ? `verify_ad_exclusion({ experiment_id: "${id}" }) once Meta has had a minute, or get_results after the washout.`
1743
+ ? `verify_ad_exclusion({ experiment_id: "${id}" }) once the ad platform has had a minute, or get_results after the washout.`
1744
1744
  : "Read each change's note; a rate-limited or locked ad set can simply be attached again.",
1745
1745
  }, null, 2),
1746
1746
  }],
@@ -1752,14 +1752,14 @@ export const experimentTools = {
1752
1752
  return textError(`Apex will not edit ad sets until the owner has allowed it. Call attach_ad_exclusion with preview=true, show the before/after, and on the owner's word call again with preview=false and acknowledge=true.`);
1753
1753
  }
1754
1754
  if (/network_not_supported/.test(message)) {
1755
- return textError("Apex attaches the list itself on Meta only. On other networks the owner adds the list in the network's own tools; verify_ad_exclusion checks it is on.");
1755
+ return textError("Apex attaches the list itself on Meta and TikTok only. On other networks the owner adds the list in the network's own tools; verify_ad_exclusion checks it is on.");
1756
1756
  }
1757
1757
  return textError(`Could not attach the held-back list: ${message}`);
1758
1758
  }
1759
1759
  },
1760
1760
  },
1761
1761
  detach_ad_exclusion: {
1762
- description: `${APEX} — Ad holdout only (surface paid_channel, Meta only). Take Apex's exclusion back off the holdout's ad sets and delete the held-back list from Meta. Removes only what Apex added: a list the owner attached by hand is refused (not_apex_attached) and the owner removes it in Ads Manager. Ending, pausing or archiving the holdout does this on its own; call this only to undo an attach early. Admin only. Confirm with the workspace first: the held-back people start seeing the ads again and the holdout stops measuring.`,
1762
+ description: `${APEX} — Ad holdout only (surface paid_channel, Meta and TikTok). Take Apex's exclusion back off the holdout's ad sets and delete the held-back list from the ad platform. Removes only what Apex added: a list the owner attached by hand is refused (not_apex_attached) and the owner removes it in Ads Manager. Ending, pausing or archiving the holdout does this on its own; call this only to undo an attach early. Admin only. Confirm with the workspace first: the held-back people start seeing the ads again and the holdout stops measuring.`,
1763
1763
  schema: z.object({
1764
1764
  experimentId: z.string().describe("The Ad holdout experiment id."),
1765
1765
  }),
@@ -1806,11 +1806,12 @@ export const experimentTools = {
1806
1806
  hypothesis: z.string().optional().describe("Edit the hypothesis — draft only (pre-registration)."),
1807
1807
  targetUrl: z.string().optional().describe("Retarget a DRAFT to a URL on the ACTIVE workspace. Same host rules as create_experiment."),
1808
1808
  predictionId: z.string().optional().describe("Attach a minted prediction id (from log_prediction) to this draft. Also stamps the prediction onto the experiment's hypothesis when that link is missing. Required before activation."),
1809
- primaryMetricEvent: z.string().optional().describe("Change the primary metric's canonical event — draft only."),
1809
+ primaryMetricEvent: z.string().optional().describe("Change the primary metric's event — draft only. Same check as create_experiment: a standard Apex event, or any event this workspace receives or defined."),
1810
1810
  primaryMetricType: z.enum(["rate", "count", "revenue", "duration"]).optional(),
1811
1811
  guardrailEvent: z.string().optional().describe("Replace the guardrail with this canonical protective event — draft only."),
1812
1812
  minSamplePerArm: z.number().int().min(30).optional().describe("Set the minimum exposed subjects PER ARM before a winner can be claimed (the sample gate; floor 30). Draft only — it's part of the pre-registration and freezes at launch (a running experiment returns experiment_locked; fork to change it)."),
1813
1813
  runtimeDays: z.number().int().min(1).max(365).optional().describe("Set how long to run the experiment, in days (the decision window Apex force-concludes at). Draft only — part of the pre-registration; freezes at launch (fork to change it)."),
1814
+ allocation: z.enum(["even", "adaptive"]).optional().describe("Journey message tests (surface journey_arm) only, draft only. 'even' keeps every version on an equal share for the whole test (the default, cleanest read on the difference). 'adaptive' starts even, then once every version has reached the minimum per version, sends more people to the version that is ahead. Ask the owner which they want; do not pick for them."),
1814
1815
  }),
1815
1816
  handler: async (args) => {
1816
1817
  if (args.targetUrl) {
@@ -1823,6 +1824,25 @@ export const experimentTools = {
1823
1824
  }
1824
1825
  }
1825
1826
  const body = { id: args.experimentId };
1827
+ if (args.allocation !== undefined) {
1828
+ let existing;
1829
+ try {
1830
+ existing = await apiGet(`/api/experiments/${args.experimentId}?unified=true`);
1831
+ }
1832
+ catch (err) {
1833
+ return textError(`Could not read the experiment: ${errMsg(err)}`);
1834
+ }
1835
+ if (existing.surface !== "journey_arm") {
1836
+ return textError("allocation only applies to journey message tests. Website and app experiments keep their own split.");
1837
+ }
1838
+ if (existing.status !== "draft") {
1839
+ return textError("The split is part of the plan and locks when the test starts. Fork the experiment to change it.");
1840
+ }
1841
+ body.allocation = {
1842
+ ...existing.allocation,
1843
+ strategy: args.allocation === "adaptive" ? "thompson" : "even",
1844
+ };
1845
+ }
1826
1846
  if (args.name !== undefined)
1827
1847
  body.name = args.name;
1828
1848
  if (args.surface !== undefined)
@@ -1844,6 +1864,9 @@ export const experimentTools = {
1844
1864
  body.evaluationWindow = ew;
1845
1865
  }
1846
1866
  if (args.primaryMetricEvent) {
1867
+ const refused = await refuseGoalEvent(args.primaryMetricEvent.trim());
1868
+ if (refused)
1869
+ return textError(refused);
1847
1870
  const t = args.primaryMetricType ?? "rate";
1848
1871
  const isValue = t === "revenue" || t === "duration";
1849
1872
  body.primaryMetric = {