@apex-inc/mcp-server 0.25.0 → 0.27.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
@@ -5,6 +5,8 @@ import { apiGet, apiPost, apiPatch, apiPut, apiDelete, postWithIdempotency, setA
5
5
  import { resolveExperimentHypothesis } from "./experiment-copy.js";
6
6
  import { controlGateGuidanceFor } from "./control-gate-guidance.js";
7
7
  import { getPersonLabel, lowerPerson } from "./person-label.js";
8
+ import { notStartedExplanation, publishedExperimentLine, } from "./publish-communication-copy.js";
9
+ import { activationLiveLine, shouldRefuseActivationWiring, } from "./activation-wiring.js";
8
10
  const APEX = "∧ Apex";
9
11
  /**
10
12
  * MOBX-006 — stable synthetic visitor id for agent-fired events.
@@ -81,28 +83,6 @@ function appUrl(path) {
81
83
  const base = process.env.APEX_URL || process.env.APEX_API_URL || "http://localhost:3001";
82
84
  return `${base.replace(/\/$/, "")}${path}`;
83
85
  }
84
- /**
85
- * Why publishing a comm with variants didn't start an experiment.
86
- *
87
- * Each of these is a missing precondition the user can actually fix, so the
88
- * agent is told what to do rather than that "nothing happened."
89
- */
90
- function notStartedExplanation(reason) {
91
- switch (reason) {
92
- case "no_host":
93
- return "The variants were published but no experiment started: no published journey sends this communication, so there's no traffic to measure. Add it to a journey and publish that journey.";
94
- case "no_goal":
95
- return "The variants were published but no experiment started: the journey sending this communication has no goal event, so there's nothing to optimize toward. Set the journey's goal event first.";
96
- case "ambiguous_host":
97
- return "The variants were published but no experiment started: more than one published journey sends this communication. Re-issue publish_communication with host_journey_id.";
98
- case "conflict":
99
- return "The variants were published but no experiment started: another experiment already holds this communication. End it, then publish again.";
100
- case "no_variants":
101
- return "";
102
- default:
103
- return "The variants were published but the experiment could not be started. Check the communication in the dashboard.";
104
- }
105
- }
106
86
  function journeyLink(id) {
107
87
  return appUrl(`/dashboard/communications/journeys/${id}`);
108
88
  }
@@ -1066,7 +1046,7 @@ export const toolDefinitions = {
1066
1046
  },
1067
1047
  },
1068
1048
  activate_experiment: {
1069
- description: `${APEX} — Launch an experiment. Changes its status from draft to running. Traffic will be split immediately. Before flipping to running, this verifies BOTH arms are producing \`experiment_exposure\` events (wiring check). Code-wired (SDK-hook) experiments may be activated before their code ships: the server holds them in \`pending_deployment\` ("Waiting on deploy") and starts on the first exposure. Snippet experiments are applied by the Apex snippet at runtime, so they also don't need pre-activation exposures — they launch and wiring is verified afterward. Only wired experiments that genuinely can't self-start are refused unless force is true. Ask the user to confirm before calling.`,
1049
+ description: `${APEX} — Launch an experiment. Changes its status from draft to running. Traffic will be split immediately. Before flipping to running, this verifies BOTH arms are producing \`experiment_exposure\` events (wiring check). Code-wired (SDK-hook) experiments may be activated before their code ships: the server holds them in \`pending_deployment\` ("Waiting on deploy") and starts on the first exposure. Snippet experiments and journey_arm message tests do not need pre-activation exposures — journey traffic starts on the next send once running. Only wired web experiments that genuinely can't self-start are refused unless force is true. Ask the user to confirm before calling.`,
1070
1050
  schema: z.object({
1071
1051
  experimentId: z.string().describe("The experiment ID to activate"),
1072
1052
  force: z
@@ -1091,11 +1071,7 @@ export const toolDefinitions = {
1091
1071
  // the experiment ONCE IT'S RUNNING, so it can't have pre-activation
1092
1072
  // exposures — blocking on them is a chicken-and-egg. Both are safe to
1093
1073
  // launch "early"; wiring is verified AFTER via verify_experiment_wiring.
1094
- if (wiring &&
1095
- !wiring.bothArmsLive &&
1096
- force !== true &&
1097
- !wiring.sdkMode &&
1098
- !wiring.snippetMode) {
1074
+ if (wiring && shouldRefuseActivationWiring(wiring, force)) {
1099
1075
  const armLines = wiring.perArm.map(formatArmLine);
1100
1076
  const zeroArms = wiring.perArm
1101
1077
  .filter((a) => a.exposures === 0)
@@ -1194,9 +1170,11 @@ export const toolDefinitions = {
1194
1170
  // PATCH returns a minimal/legacy body — re-read the unified record to
1195
1171
  // derive the live traffic split from the allocation weights.
1196
1172
  let controlPct = 50;
1173
+ let liveSurface = exp.surface;
1197
1174
  try {
1198
1175
  const unified = await apiGet(`/api/experiments/${experimentId}?unified=true`);
1199
1176
  controlPct = controlPctOf(unified);
1177
+ liveSurface = unified.surface ?? liveSurface;
1200
1178
  }
1201
1179
  catch { /* fall back to 50/50 in the message */ }
1202
1180
  // Surface why the wiring gate didn't block: either it couldn't be
@@ -1235,7 +1213,7 @@ export const toolDefinitions = {
1235
1213
  ` Traffic is being split ${controlPct}% / ${100 - controlPct}%`,
1236
1214
  ``,
1237
1215
  ...notes,
1238
- ` Visitors will now see either the control or variant.`,
1216
+ ` ${activationLiveLine(liveSurface)}`,
1239
1217
  ` Results will appear in the dashboard and via get_results.`,
1240
1218
  ``,
1241
1219
  `${"═".repeat(40)}`,
@@ -1746,7 +1724,15 @@ export const toolDefinitions = {
1746
1724
  content: [
1747
1725
  {
1748
1726
  type: "text",
1749
- text: `${APEX} Forked into a fresh draft "${forked.name}" (id: ${forked.id}). Edit it with update_experiment, then activate.`,
1727
+ text: [
1728
+ `${APEX} Forked into a fresh draft "${forked.name}" (id: ${forked.id}).`,
1729
+ ``,
1730
+ `A copy gets a new id. The page still asks the old one until you replace it.`,
1731
+ ` Find: const variant = useApexVariant("${experimentId}");`,
1732
+ ` Replace: const variant = useApexVariant("${forked.id}");`,
1733
+ `Deploy that change, then activate.`,
1734
+ `Preview still works via _apex_exp. A normal visit shows control until the new id is shipped.`,
1735
+ ].join("\n"),
1750
1736
  },
1751
1737
  ],
1752
1738
  };
@@ -2742,9 +2728,9 @@ Identical to the SDK's track() and the snippet's apex.track(). Use this to instr
2742
2728
 
2743
2729
  The Apex Spec ships two peer registries:
2744
2730
 
2745
- \u2022 **App events** (\`APEX_EVENTS\`) — the canonical SDK vocabulary the customer's app fires. Names you'll commonly use here: \`page_view\`, \`product_view\`, \`add_to_cart\`, \`checkout_started\`, \`in_app_purchase\`, \`purchase_refunded\`, \`subscription_event\`, \`user_signed_up\`, \`user_signed_in\`, \`user_identified\`, \`app_open\`, \`session_start\`, \`form_submit\`, \`click\`, \`search\`, \`share\`, \`content_view\`, \`goal_conversion\`, \`email_opened\`, \`email_clicked\`, \`push_opened\`, \`in_app_message_seen\`, \`deep_link_open\`. Field names are flat snake_case (e.g. \`product_id\`, \`order_id\`, \`value\`, \`currency\`).
2731
+ \u2022 **App events** (\`APEX_EVENTS\`) — the canonical SDK vocabulary the customer's app fires. Names you'll commonly use here: \`page_view\`, \`product_view\`, \`add_to_cart\`, \`checkout_started\`, \`in_app_purchase\`, \`purchase_refunded\`, \`subscription_event\`, \`user_signed_up\`, \`user_signed_in\`, \`user_identified\`, \`app_open\`, \`session_start\`, \`form_submit\`, \`click\`, \`ui_action\`, \`search\`, \`share\`, \`content_view\`, \`goal_conversion\`, \`email_opened\`, \`email_clicked\`, \`push_opened\`, \`in_app_message_seen\`, \`deep_link_open\`. Field names are flat snake_case (e.g. \`product_id\`, \`order_id\`, \`value\`, \`currency\`). Named in-product controls (buttons, tabs, widget chrome) are \`ui_action\` with \`action\` + \`name\` + optional \`location\` — do not invent one event per button. SDK helper: \`trackUiAction({ action, name, location })\`.
2746
2732
 
2747
- \u2022 **Platform events** (\`APEX_PLATFORM_EVENTS\`) — events Apex's own dashboard fires as merchants use it. Names: \`user_invitation_created\`, \`user_invitation_accepted\`, \`developer_invite_sent\`, \`integration_connected\`, \`integration_sync_completed\`, \`integration_sync_failed\`, \`experiment_launched\`, \`experiment_significant\`, \`experiment_concluded\`, \`experiment_promoted\`, \`portfolio_access_requested\`, \`portfolio_access_approved\`, \`portfolio_access_rejected\`, \`portfolio_share_accepted\`, \`subscription_upgraded\`, \`subscription_downgraded\`, \`tier_limit_approaching\`, \`anomaly_detected\`, \`weekly_digest_scheduled\`, \`cognito_verification_code\`, \`user_signup\`. Use these when an agent is acting *as* the Apex platform (admin tooling, dashboard automation), not as the customer's product.
2733
+ \u2022 **Platform events** (\`APEX_PLATFORM_EVENTS\`) — events Apex's own dashboard fires as merchants use it. Names: \`user_invitation_created\`, \`user_invitation_accepted\`, \`developer_invite_sent\`, \`integration_selected\`, \`integration_connected\`, \`setup_step_continued\`, \`integration_sync_completed\`, \`integration_sync_failed\`, \`experiment_launched\`, \`experiment_significant\`, \`experiment_concluded\`, \`experiment_promoted\`, \`portfolio_access_requested\`, \`portfolio_access_approved\`, \`portfolio_access_rejected\`, \`portfolio_share_accepted\`, \`subscription_upgraded\`, \`subscription_downgraded\`, \`tier_limit_approaching\`, \`anomaly_detected\`, \`weekly_digest_scheduled\`, \`cognito_verification_code\`, \`user_signup\`. Use these when an agent is acting *as* the Apex platform (admin tooling, dashboard automation), not as the customer's product.
2748
2734
 
2749
2735
  Custom event names are accepted (the spec is open by default), but agents should prefer canonical names from one of the two registries so the event flows directly into the catalog templates, the journey trigger system, and the conversion-goal helpers without manual wiring. The full spec lives at https://docs.apex.inc/spec.
2750
2736
 
@@ -2791,9 +2777,17 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2791
2777
  visitorId: z.string().optional().describe("Visitor ID to stitch back to a web/mobile session (apex_vid)"),
2792
2778
  email: z.string().optional().describe("Email to stitch by identity when no visitorId is known"),
2793
2779
  data: z.record(z.unknown()).optional().describe("Event payload (value, currency, order_id, …)"),
2780
+ environment: z
2781
+ .enum(["production", "preview", "localhost"])
2782
+ .optional()
2783
+ .describe("Dashboard slice only. Omit to leave Unclassified. Never use this to force Production dollars."),
2784
+ release_channel: z
2785
+ .string()
2786
+ .optional()
2787
+ .describe("Optional OS channel (testflight, app-store, xcode-debug, …). Dashboard slice only."),
2794
2788
  idempotencyKey: z.string().optional().describe("Stable key to prevent double-counting on retry (defaults to a fresh UUID)"),
2795
2789
  }),
2796
- handler: async ({ type, visitorId, email, data, idempotencyKey, }) => {
2790
+ handler: async ({ type, visitorId, email, data, environment, release_channel, idempotencyKey, }) => {
2797
2791
  const key = idempotencyKey ??
2798
2792
  (typeof globalThis.crypto?.randomUUID === "function"
2799
2793
  ? globalThis.crypto.randomUUID()
@@ -2805,6 +2799,8 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2805
2799
  visitorId,
2806
2800
  email,
2807
2801
  data,
2802
+ environment,
2803
+ release_channel,
2808
2804
  timestamp: new Date().toISOString(),
2809
2805
  },
2810
2806
  ],
@@ -3271,6 +3267,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3271
3267
  slots: z.record(z.string()).optional().describe("Content overrides: headline, body, ctaLabel, ctaUrl. These now sync into the rendered email body (not just metadata)."),
3272
3268
  channels: z.array(z.string()).optional().describe("Channels: email, in_app_push, mobile_push"),
3273
3269
  status: z.enum(["draft", "active", "paused"]).optional().describe("Communication status"),
3270
+ audience_bucket: z
3271
+ .enum(["production", "beta", "dev"])
3272
+ .optional()
3273
+ .describe("Who this send is for. Default production. Never All or Unclassified."),
3274
3274
  intent: z
3275
3275
  .enum(["compete", "replace"])
3276
3276
  .optional()
@@ -3286,6 +3286,8 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3286
3286
  body.status = args.status;
3287
3287
  if (args.channels)
3288
3288
  body.channels = args.channels;
3289
+ if (args.audience_bucket)
3290
+ body.audienceBucket = args.audience_bucket;
3289
3291
  if (args.intent)
3290
3292
  body.intent = args.intent;
3291
3293
  if (args.confirmLiveExperiment)
@@ -3325,13 +3327,13 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3325
3327
  },
3326
3328
  },
3327
3329
  publish_communication: {
3328
- description: "Publish a communication's draft as the live version — this is what makes an edit reach recipients. Editing only saves a draft.\n\nIf the communication has variants, publishing STARTS the experiment that measures them against the control. That is the point of authoring a variant: a variant that is never published is measured by nothing.\n\nTwo conflicts need a decision rather than a retry. `ambiguous_experiment_host` means several published journeys send this communication, so Apex cannot tell where the experiment belongs — the candidates are returned; ask the user and re-issue with host_journey_id. `comm_already_in_experiment` means one is already running; end it first.",
3330
+ description: "Publish a communication's draft as the live version — this is what makes an edit reach recipients. Editing only saves a draft.\n\nCompete (or publishing a letter that already has variants) creates a prefilled DRAFT experiment. It does not start the test. After the user reviews the draft, call activate_experiment to begin it. Replace publishes the letter for everyone and never creates an experiment.\n\nOptional overrides: hypothesis, belief_id, confidence, duration_days.\n\nTwo conflicts need a decision rather than a retry. `ambiguous_experiment_host` means several published journeys send this communication — the candidates are returned; ask the user and re-issue with host_journey_id. `comm_already_in_experiment` means one is already running; end it first.",
3329
3331
  schema: z.object({
3330
3332
  communicationId: z.string().describe("The communication ID to publish"),
3331
3333
  intent: z
3332
3334
  .enum(["compete", "replace"])
3333
3335
  .optional()
3334
- .describe('How to apply the draft when the Control is protected. "compete" = the current content keeps sending and your draft runs against it as an experiment (safe default). "replace" = your draft becomes what everyone receives, superseding any prior win. Required only when the comm won an experiment or is live in a published journey.'),
3336
+ .describe('How to apply the draft when the Control is protected. "compete" = the current content keeps sending and your draft becomes a filled-in experiment draft (then activate_experiment). "replace" = your draft becomes what everyone receives. Required only when the comm won an experiment or is live in a published journey.'),
3335
3337
  confirmLiveExperiment: z
3336
3338
  .boolean()
3337
3339
  .optional()
@@ -3340,6 +3342,22 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3340
3342
  .string()
3341
3343
  .optional()
3342
3344
  .describe("Which published journey hosts the experiment. Only needed after a 409 ambiguous_experiment_host, using one of the returned candidate ids."),
3345
+ hypothesis: z
3346
+ .string()
3347
+ .optional()
3348
+ .describe("Override the Suggested hypothesis on the draft experiment."),
3349
+ beliefId: z
3350
+ .string()
3351
+ .optional()
3352
+ .describe("Link the draft experiment to an existing belief."),
3353
+ confidence: z
3354
+ .number()
3355
+ .optional()
3356
+ .describe("Override the prefilled confidence (percent)."),
3357
+ durationDays: z
3358
+ .number()
3359
+ .optional()
3360
+ .describe("Override the prefilled decision window in days."),
3343
3361
  }),
3344
3362
  handler: async (args) => {
3345
3363
  const body = {};
@@ -3349,6 +3367,14 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3349
3367
  body.confirmLiveExperiment = true;
3350
3368
  if (args.hostJourneyId)
3351
3369
  body.hostJourneyId = args.hostJourneyId;
3370
+ if (args.hypothesis)
3371
+ body.hypothesis = args.hypothesis;
3372
+ if (args.beliefId)
3373
+ body.beliefId = args.beliefId;
3374
+ if (typeof args.confidence === "number")
3375
+ body.confidence = args.confidence;
3376
+ if (typeof args.durationDays === "number")
3377
+ body.durationDays = args.durationDays;
3352
3378
  try {
3353
3379
  const data = await apiPost(`/api/communications/${args.communicationId}/publish`, body);
3354
3380
  const lines = [];
@@ -3362,7 +3388,11 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3362
3388
  lines.push(`${data.repinned.length} send step(s) moved to the new version.`);
3363
3389
  }
3364
3390
  if (data.experiment) {
3365
- lines.push(`Experiment started: ${data.experiment.id}. Watch it at ${appUrl(`/dashboard/experiments/${data.experiment.id}`)}`);
3391
+ lines.push(publishedExperimentLine({
3392
+ id: data.experiment.id,
3393
+ status: data.experiment.status ?? "draft",
3394
+ url: appUrl(`/dashboard/experiments/${data.experiment.id}`),
3395
+ }));
3366
3396
  }
3367
3397
  else if (data.experimentNotStarted) {
3368
3398
  lines.push(notStartedExplanation(data.experimentNotStarted));
@@ -3480,7 +3510,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3480
3510
  },
3481
3511
  },
3482
3512
  create_comm_experiment: {
3483
- description: "A/B test a communication's subject, body, or CTA. Creates an experiment with variants that split traffic using Thompson Sampling.",
3513
+ description: "Legacy helper. Prefer the composer loop: edit_communication or add_communication_variant, then publish_communication (compete creates a prefilled draft), then activate_experiment to start the test. Do not teach this as the default.",
3484
3514
  schema: z.object({
3485
3515
  communicationId: z.string().describe("The communication to experiment on"),
3486
3516
  splitLevel: z.enum(["subject", "body", "cta", "full"]).describe("What to vary between variants"),
@@ -3963,11 +3993,20 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3963
3993
  // Agent-friendly arg names (snake_case) are mapped to the REST
3964
3994
  // routes inside each handler.
3965
3995
  create_journey: {
3966
- description: `${APEX} — Create a blank draft journey (an unconfigured event trigger wired to an exit). Then set_journey_trigger and add_journey_step to build it, and publish_journey to launch. For e-commerce flows like cart recovery, prefer create_journey_from_template.`,
3967
- schema: z.object({ name: z.string().optional().describe("Journey name (default 'Untitled journey').") }),
3968
- handler: async ({ name }) => {
3996
+ description: `${APEX} — Create a blank draft journey (an unconfigured event trigger wired to an exit). Then set_journey_trigger and add_journey_step to build it, and publish_journey to launch. For e-commerce flows like cart recovery, prefer create_journey_from_template. audience_bucket defaults to production (live customers). Same email on TestFlight and the store is two people — do not create a staging copy of the journey.`,
3997
+ schema: z.object({
3998
+ name: z.string().optional().describe("Journey name (default 'Untitled journey')."),
3999
+ audience_bucket: z
4000
+ .enum(["production", "beta", "dev"])
4001
+ .optional()
4002
+ .describe("Who this journey enrolls. Default production. Never All or Unclassified."),
4003
+ }),
4004
+ handler: async ({ name, audience_bucket }) => {
3969
4005
  try {
3970
4006
  const j = await apiPost("/api/journeys/blank", { name });
4007
+ if (audience_bucket && audience_bucket !== "production") {
4008
+ await apiPatch(`/api/journeys/${j.id}`, { audienceBucket: audience_bucket });
4009
+ }
3971
4010
  if (!tenantOk(j.workspaceKey))
3972
4011
  return tenantMismatch();
3973
4012
  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.` }] };
@@ -4223,14 +4262,21 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
4223
4262
  },
4224
4263
  },
4225
4264
  create_communication: {
4226
- description: `${APEX} — Create a blank custom communication (draft, no send path) you can reference from a journey send step. Distinct from generate_communications, which generates pre-written messages from catalog templates; use this when you need a net-new message (e.g. a cart-recovery email not in the catalog).`,
4265
+ description: `${APEX} — Create a blank custom communication (draft, no send path) you can reference from a journey send step. Distinct from generate_communications, which generates pre-written messages from catalog templates; use this when you need a net-new message (e.g. a cart-recovery email not in the catalog). audience_bucket defaults to production.`,
4227
4266
  schema: z.object({
4228
4267
  title: z.string().optional(),
4229
4268
  channels: z.array(z.enum(["email", "in_app_push", "mobile_push"])).optional(),
4269
+ audience_bucket: z
4270
+ .enum(["production", "beta", "dev"])
4271
+ .optional()
4272
+ .describe("Who this send is for. Default production. Never All or Unclassified."),
4230
4273
  }),
4231
- handler: async ({ title, channels }) => {
4274
+ handler: async ({ title, channels, audience_bucket }) => {
4232
4275
  try {
4233
4276
  const comm = await apiPost("/api/communications/blank", { title, channels });
4277
+ if (audience_bucket && audience_bucket !== "production") {
4278
+ await apiPatch(`/api/communications/${comm.id}`, { audienceBucket: audience_bucket });
4279
+ }
4234
4280
  if (!tenantOk(comm.workspaceKey))
4235
4281
  return tenantMismatch();
4236
4282
  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.` }] };
@@ -4521,6 +4567,32 @@ _Suggest the next step the user should tackle based on what's incomplete in the
4521
4567
  return { content: [{ type: "text", text }] };
4522
4568
  },
4523
4569
  },
4570
+ list_workspace_environments: {
4571
+ description: `${APEX} — Read the sites this workspace named as Production and Beta, plus unclassified sites Apex has seen. Name sites before you trust the Production slice. Do not invent a second workspace.`,
4572
+ schema: z.object({}),
4573
+ handler: async () => {
4574
+ const json = await apiGet("/api/workspace/environments");
4575
+ return {
4576
+ content: [{ type: "text", text: JSON.stringify(json.data, null, 2) }],
4577
+ };
4578
+ },
4579
+ },
4580
+ set_workspace_environments: {
4581
+ description: `${APEX} — Name Production sites (and optional Beta sites). Apex will not guess staging. / qa. / dev. Naming reclassifies events from those sites at read time.`,
4582
+ schema: z.object({
4583
+ production_hosts: z.array(z.string()).describe("Hostnames that are live (shop.example.com)."),
4584
+ beta_hosts: z.array(z.string()).optional().describe("Hostnames that are testers. Never guessed from staging."),
4585
+ }),
4586
+ handler: async ({ production_hosts, beta_hosts, }) => {
4587
+ const json = await apiPut("/api/workspace/environments", {
4588
+ production_hosts,
4589
+ beta_hosts: beta_hosts ?? [],
4590
+ });
4591
+ return {
4592
+ content: [{ type: "text", text: JSON.stringify(json.data, null, 2) }],
4593
+ };
4594
+ },
4595
+ },
4524
4596
  // ─── Data Sources + unified analytics ──────────────────────────────────
4525
4597
  list_data_sources: {
4526
4598
  description: `${APEX} — List the workspace's Data Sources (the producers that send events: Website, iOS/Android apps, Backend). Shows each source's live status (live / listening / stalled / not-started). Call when the user asks "where is my data coming from?", "which sources are live?", or to audit collection coverage.`,