@apex-inc/mcp-server 0.9.9 → 0.9.11

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,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { apiGet, apiPost, apiPatch, apiDelete, postWithIdempotency, setActiveWorkspace, getActiveWorkspace, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
3
+ import { resolveExperimentHypothesis } from "./experiment-copy.js";
3
4
  const APEX = "∧ Apex";
4
5
  /**
5
6
  * MOBX-006 — stable synthetic visitor id for agent-fired events.
@@ -273,11 +274,13 @@ export const toolDefinitions = {
273
274
  hypothesisStatement: z.string().optional().describe("Optional hypothesis statement to record alongside a belief created from beliefStatement."),
274
275
  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."),
275
276
  mode: z.enum(["sdk", "snippet"]).optional().describe("Experiment mode: 'sdk' for code-level (default via MCP), 'snippet' for runtime DOM"),
277
+ surface: z.enum(["web", "mobile"]).optional().describe("Where the experiment runs: 'web' (default) for websites and web apps, 'mobile' for native / Capacitor apps. This sets the dashboard's data-source label (Website vs iOS App) — pick 'mobile' for a Capacitor/React-Native/native app so it isn't mislabeled as a website."),
276
278
  preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
277
279
  }),
278
280
  handler: async (args) => {
279
281
  const split = args.trafficSplit ?? 50;
280
282
  const mode = args.mode ?? "sdk";
283
+ const experimentSurface = args.surface ?? "web";
281
284
  const isPreview = args.preview !== false;
282
285
  // p1-metric — resolve the primary metric. Defaults to the form_submit
283
286
  // conversion metric; an explicit primaryMetricEvent is validated against
@@ -331,6 +334,7 @@ export const toolDefinitions = {
331
334
  _apex: true,
332
335
  _type: "experiment_preview",
333
336
  name: args.name,
337
+ surface: experimentSurface,
334
338
  targetUrl: args.targetUrl,
335
339
  targetAnchor: args.targetAnchor || null,
336
340
  targetComponent: args.targetComponent || null,
@@ -342,7 +346,7 @@ export const toolDefinitions = {
342
346
  beliefId: args.beliefId || null,
343
347
  beliefStatement: args.beliefStatement || null,
344
348
  predictionId: args.predictionId || null,
345
- _instructions: "Present this as a summary and ask the user to confirm (1), adjust (2), or cancel (3). Do NOT create the experiment until confirmed.",
349
+ _instructions: "Before proposing variant copy, ground every factual claim in .apex/brand-truth.md (read it; cite the line each claim traces to; if a claim isn't backed there, ask the user instead of inventing it). Then present this as a summary and ask the user to confirm (1), adjust (2), or cancel (3). Do NOT create the experiment until confirmed.",
346
350
  };
347
351
  return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
348
352
  }
@@ -369,33 +373,44 @@ export const toolDefinitions = {
369
373
  });
370
374
  hypothesisId = h.id;
371
375
  }
376
+ // The API requires a non-empty hypothesis on the experiment record
377
+ // (validate-create rejects ""). Resolve one (explicit or derived).
378
+ const resolvedHypothesis = resolveExperimentHypothesis(args.hypothesisStatement, args.name, primaryMetric.label);
372
379
  const controlW = split / 100;
373
- const exp = await apiPost("/api/experiments", {
374
- surface: "web",
375
- name: args.name,
376
- targetUrl: args.targetUrl,
377
- targetAnchor: args.targetAnchor,
378
- createdFrom: "cursor",
379
- status: "draft",
380
- hypothesis: "",
381
- prediction: { direction: "increase", magnitude: "" },
382
- confidence: 50,
383
- beliefId,
384
- hypothesisId,
385
- predictionId: args.predictionId,
386
- primaryMetric,
387
- allocation: { strategy: "hash", weights: { control: controlW, variant_b: 1 - controlW } },
388
- secondaryMetrics: [],
389
- guardrailMetrics: [],
390
- attributionWindow: { unit: "hours", value: 24, startFrom: "first_interaction" },
391
- variants: [
380
+ const createdAtIso = new Date().toISOString();
381
+ // Variants must match the experiment surface (validate-create enforces
382
+ // this). Web arms carry DOMChange[]; mobile arms carry a JSON payload the
383
+ // app reads via Apex.getVariant(). Mislabeling a Capacitor app as web is
384
+ // exactly the bug this fixes.
385
+ const variants = experimentSurface === "mobile"
386
+ ? [
387
+ {
388
+ id: "v_control",
389
+ key: "control",
390
+ label: "Control",
391
+ surface: "mobile",
392
+ createdAt: createdAtIso,
393
+ description: "Original content",
394
+ payload: { content: args.controlContent },
395
+ },
396
+ {
397
+ id: "v_b",
398
+ key: "variant_b",
399
+ label: "Variant B",
400
+ surface: "mobile",
401
+ createdAt: createdAtIso,
402
+ description: "Modified content",
403
+ payload: { content: args.variantContent },
404
+ },
405
+ ]
406
+ : [
392
407
  {
393
408
  id: "v_control",
394
409
  key: "control",
395
410
  label: "Control",
396
411
  surface: "web",
397
412
  mode,
398
- createdAt: new Date().toISOString(),
413
+ createdAt: createdAtIso,
399
414
  description: "Original content",
400
415
  changes: [],
401
416
  },
@@ -405,7 +420,7 @@ export const toolDefinitions = {
405
420
  label: "Variant B",
406
421
  surface: "web",
407
422
  mode,
408
- createdAt: new Date().toISOString(),
423
+ createdAt: createdAtIso,
409
424
  description: "Modified content",
410
425
  changes: [
411
426
  {
@@ -416,7 +431,26 @@ export const toolDefinitions = {
416
431
  },
417
432
  ],
418
433
  },
419
- ],
434
+ ];
435
+ const exp = await apiPost("/api/experiments", {
436
+ surface: experimentSurface,
437
+ name: args.name,
438
+ targetUrl: args.targetUrl,
439
+ targetAnchor: args.targetAnchor,
440
+ createdFrom: "cursor",
441
+ status: "draft",
442
+ hypothesis: resolvedHypothesis,
443
+ prediction: { direction: "increase", magnitude: "" },
444
+ confidence: 50,
445
+ beliefId,
446
+ hypothesisId,
447
+ predictionId: args.predictionId,
448
+ primaryMetric,
449
+ allocation: { strategy: "hash", weights: { control: controlW, variant_b: 1 - controlW } },
450
+ secondaryMetrics: [],
451
+ guardrailMetrics: [],
452
+ attributionWindow: { unit: "hours", value: 24, startFrom: "first_interaction" },
453
+ variants,
420
454
  });
421
455
  let previewUrl = "";
422
456
  try {
@@ -428,11 +462,66 @@ export const toolDefinitions = {
428
462
  previewUrl = vu.toString();
429
463
  }
430
464
  catch { /* non-critical */ }
465
+ const isMobile = experimentSurface === "mobile";
466
+ // Grounding guidance — prepended to every recipe. The agent must write
467
+ // variant copy against the merchant's brand-truth file, not invent
468
+ // claims (the 2026-06-15 "free shipping on every order" incident, where
469
+ // the app actually charges $8 under $100).
470
+ const groundingGuidance = [
471
+ "GROUND THE COPY — do this BEFORE writing any variant text:",
472
+ "- Read .apex/brand-truth.md, .apex/brand-voice.md, and .apex/lexicon.md if they exist in the repo.",
473
+ "- Every factual claim in the variant copy MUST trace to a specific line in .apex/brand-truth.md. State which line each claim comes from. If a claim isn't backed there, ASK the user — do NOT invent it.",
474
+ "- If .apex/brand-truth.md is missing, scaffold the guardrails first (Apex → Set up Apex → Intelligence → \"Set up experiment guardrails\"), then ask the user to fill in the facts before writing copy.",
475
+ "",
476
+ ];
477
+ // Style/visual-change guidance — appended to every SDK recipe. The
478
+ // classic failure (QA 2026-06-15): a `bg-black` class collided with
479
+ // `bg-primary` because the codebase's cn() doesn't tailwind-merge, so
480
+ // both arms rendered identical and the test measured nothing.
481
+ const styleGuidance = [
482
+ "",
483
+ "VISUAL / STYLE CHANGES — read before editing:",
484
+ "- If the change is visual (color, size, layout), a variant that renders identical to control measures NOTHING. Always visually diff the two arms (screenshot both) before activating.",
485
+ "- Do NOT rely on a utility CLASS to override an existing one unless the codebase uses tailwind-merge. Plain className concatenation keeps BOTH classes (e.g. `bg-primary` + `bg-black`) and the original usually wins. Prefer an inline `style={{...}}` override or a class with guaranteed precedence.",
486
+ "- Change exactly ONE thing (the variable under test); keep everything else identical to control.",
487
+ ];
488
+ const webSteps = [
489
+ "IMPLEMENT THE EXPERIMENT IN CODE (web):",
490
+ `1. Open the target file${args.targetComponent ? ` (${args.targetComponent})` : ""}`,
491
+ `2. Install the hook package: npm i @apex-inc/react`,
492
+ `3. Add: import { useApexVariant } from "@apex-inc/react"`,
493
+ `4. In the component, add: const variant = useApexVariant("${exp.id}") // fires experiment_exposure automatically`,
494
+ `5. Wrap the target content in a conditional (fall back to control on null/error):`,
495
+ ` {variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
496
+ `6. Screenshots for the dashboard:`,
497
+ ` - Public web page: Apex auto-captures on create — nothing to do.`,
498
+ ` - localhost / auth-gated: capture BOTH arms, then call attach_experiment_asset({experimentId, variantKey, imageBase64}).`,
499
+ `7. Show the user the diff and ask them to preview at: ${previewUrl}`,
500
+ `8. After preview approval, commit and push`,
501
+ `9. Call track_deployment with the experiment ID and commit SHA`,
502
+ `10. Call verify_experiment_wiring, then activate_experiment once both arms report exposures`,
503
+ ];
504
+ const mobileSteps = [
505
+ "IMPLEMENT THE EXPERIMENT IN CODE (mobile / Capacitor):",
506
+ `1. Open the target screen${args.targetComponent ? ` (${args.targetComponent})` : ""}`,
507
+ `2. Install the plugin: npm i @apex-inc/capacitor-plugin`,
508
+ `3. Add: import { Apex } from "@apex-inc/capacitor-plugin"`,
509
+ `4. Resolve the variant: const { value: variant } = await Apex.getVariant({ experimentId: "${exp.id}" }) // fires experiment_exposure automatically`,
510
+ `5. Render conditionally (fall back to control on null/error): variant === "variant_b" ? <VARIANT> : <CONTROL>`,
511
+ `6. Screenshot both arms — add ONE debug-gated line on the screen that renders the variant, keyed to the resolved variant so it fires once the arm is on-screen:`,
512
+ ` useEffect(() => { if (variant) Apex.captureVariantScreenshot({ experimentId: "${exp.id}", variantKey: variant }); }, [variant]);`,
513
+ ` It is debug-gated (init Apex with debug:true) — a no-op in production, so real users are never screenshotted.`,
514
+ `7. Show the user the diff and a side-by-side of both arms`,
515
+ `8. After approval, commit and push`,
516
+ `9. Call track_deployment with the experiment ID and commit SHA`,
517
+ `10. Call verify_experiment_wiring, then activate_experiment once both arms report exposures`,
518
+ ];
431
519
  const recipe = {
432
520
  _apex: true,
433
521
  _type: "experiment_created",
434
522
  experimentId: exp.id,
435
523
  name: exp.name,
524
+ surface: experimentSurface,
436
525
  status: exp.status,
437
526
  mode,
438
527
  primaryMetric: primaryMetric.key,
@@ -440,9 +529,17 @@ export const toolDefinitions = {
440
529
  hypothesisId: hypothesisId || null,
441
530
  predictionId: args.predictionId || null,
442
531
  sdk: mode === "sdk" ? {
443
- hook: "useApexVariant",
444
- import: "@apex-inc/react",
445
- install: "npm i @apex-inc/react",
532
+ ...(isMobile
533
+ ? {
534
+ method: "Apex.getVariant",
535
+ import: "@apex-inc/capacitor-plugin",
536
+ install: "npm i @apex-inc/capacitor-plugin",
537
+ }
538
+ : {
539
+ hook: "useApexVariant",
540
+ import: "@apex-inc/react",
541
+ install: "npm i @apex-inc/react",
542
+ }),
446
543
  experimentId: exp.id,
447
544
  targetComponent: args.targetComponent || null,
448
545
  targetAnchor: args.targetAnchor || null,
@@ -452,25 +549,18 @@ export const toolDefinitions = {
452
549
  previewUrl,
453
550
  trafficSplit: { control: split, variant: 100 - split },
454
551
  _instructions: mode === "sdk"
455
- ? [
456
- "IMPLEMENT THE EXPERIMENT IN CODE:",
457
- `1. Open the target file${args.targetComponent ? ` (${args.targetComponent})` : ""}`,
458
- `2. Install the hook package: npm i @apex-inc/react`,
459
- `3. Add: import { useApexVariant } from "@apex-inc/react"`,
460
- `4. In the component, add: const variant = useApexVariant("${exp.id}")`,
461
- `5. Wrap the target content in a conditional:`,
462
- ` {variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
463
- `6. Show the user the diff and ask them to preview at: ${previewUrl}`,
464
- `7. After preview approval, commit and push`,
465
- `8. Call track_deployment with the experiment ID and commit SHA`,
466
- `9. Call activate_experiment after deployment confirms`,
467
- ].join("\n")
468
- : [
552
+ ? groundingGuidance
553
+ .concat(isMobile ? mobileSteps : webSteps)
554
+ .concat(styleGuidance)
555
+ .join("\n")
556
+ : groundingGuidance
557
+ .concat([
469
558
  "SNIPPET MODE — no code changes needed.",
470
559
  "The experiment will be applied via the Apex snippet at runtime.",
471
560
  `Preview: ${previewUrl}`,
472
561
  "Ask the user to activate when ready.",
473
- ].join("\n"),
562
+ ])
563
+ .join("\n"),
474
564
  };
475
565
  return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
476
566
  },
@@ -2590,5 +2680,366 @@ _Suggest the next step the user should tackle based on what's incomplete in the
2590
2680
  };
2591
2681
  },
2592
2682
  },
2683
+ // ─── Graduation Pipeline — Engineering Links (Wave 2) ─────────────────
2684
+ link_experiment: {
2685
+ description: `${APEX} — Link an experiment to a GitHub pull request or issue. The link is what auto-starts the experiment when the PR merges. The convention "apex/exp_XXX" in your PR title also auto-links, but call this explicitly to be sure.`,
2686
+ schema: z.object({
2687
+ experimentId: z.string().describe("The experiment ID to link"),
2688
+ system: z
2689
+ .enum(["github", "github-issues", "linear", "jira", "trello"])
2690
+ .describe("Which dev tool the work item lives in (github for PRs)"),
2691
+ kind: z.enum(["pr", "issue"]).describe("Pull request or issue/ticket"),
2692
+ repo: z
2693
+ .string()
2694
+ .optional()
2695
+ .describe('"owner/repo" slug for GitHub links'),
2696
+ externalId: z
2697
+ .string()
2698
+ .describe("PR number or issue number (e.g. 482)"),
2699
+ url: z.string().url().optional().describe("Canonical URL of the work item"),
2700
+ }),
2701
+ handler: async (args) => {
2702
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(args.experimentId)}/links`, {
2703
+ system: args.system,
2704
+ kind: args.kind,
2705
+ repo: args.repo,
2706
+ externalId: args.externalId,
2707
+ url: args.url,
2708
+ });
2709
+ const text = res.data
2710
+ ? `Linked experiment ${args.experimentId} to ${args.system} ${args.kind} ${args.repo ?? ""}#${args.externalId}.\n${res.data.url}`
2711
+ : `Could not link: ${res.error ?? "unknown error"}`;
2712
+ return { content: [{ type: "text", text }] };
2713
+ },
2714
+ },
2715
+ unlink_experiment: {
2716
+ description: `${APEX} — Remove an Engineering Link from an experiment.`,
2717
+ schema: z.object({
2718
+ experimentId: z.string().describe("The experiment ID"),
2719
+ linkId: z
2720
+ .string()
2721
+ .describe('The link id, or "{system}#{externalId}" (e.g. github#482)'),
2722
+ }),
2723
+ handler: async ({ experimentId, linkId }) => {
2724
+ await apiDelete(`/api/experiments/${encodeURIComponent(experimentId)}/links/${encodeURIComponent(linkId)}`);
2725
+ return {
2726
+ content: [
2727
+ { type: "text", text: `Unlinked ${linkId} from experiment ${experimentId}.` },
2728
+ ],
2729
+ };
2730
+ },
2731
+ },
2732
+ // ─── Apex Developer CLI (Wave 6.5) — IDE debugging helpers ───────────
2733
+ apex_test_event: {
2734
+ description: `${APEX} — Send a test event through the public ingestion endpoint and confirm it was accepted. The fastest way to verify event tracking works after installing the SDK.`,
2735
+ schema: z.object({
2736
+ eventName: z.string().describe("Event name, e.g. signup_completed"),
2737
+ attributes: z.record(z.unknown()).optional().describe("Event attributes"),
2738
+ visitorId: z.string().optional().describe("Visitor id (default a test id)"),
2739
+ }),
2740
+ handler: async (args) => {
2741
+ const workspaceKey = getActiveWorkspace();
2742
+ if (!workspaceKey) {
2743
+ return { content: [{ type: "text", text: "No active workspace. Run /apex-login or set APEX_PROJECT_KEY." }] };
2744
+ }
2745
+ const visitorId = args.visitorId ?? `test-${Date.now()}`;
2746
+ const res = await apiPost("/api/events", {
2747
+ workspaceKey,
2748
+ type: args.eventName,
2749
+ visitorId,
2750
+ timestamp: new Date().toISOString(),
2751
+ data: { ...(args.attributes ?? {}), endUserId: visitorId },
2752
+ });
2753
+ const text = res.eventId
2754
+ ? `Test event "${args.eventName}" accepted (id ${res.eventId}). It's flowing into ${workspaceKey}.`
2755
+ : `Event rejected: ${res.error ?? "unknown error"}`;
2756
+ return { content: [{ type: "text", text }] };
2757
+ },
2758
+ },
2759
+ apex_recent_events: {
2760
+ description: `${APEX} — List recent events for the workspace (optionally filtered by type). Use to debug SDK wiring without opening the dashboard.`,
2761
+ schema: z.object({
2762
+ type: z.string().optional().describe("Filter by event type"),
2763
+ sinceMinutes: z.number().optional().describe("Lookback window in minutes (default 60)"),
2764
+ }),
2765
+ handler: async (args) => {
2766
+ const qs = new URLSearchParams();
2767
+ if (args.type)
2768
+ qs.set("type", args.type);
2769
+ qs.set("sinceMinutes", String(args.sinceMinutes ?? 60));
2770
+ const res = await apiGet(`/api/events/stream?${qs.toString()}`);
2771
+ const events = res.events ?? [];
2772
+ if (events.length === 0) {
2773
+ return { content: [{ type: "text", text: "No recent events in that window." }] };
2774
+ }
2775
+ const lines = events.slice(0, 25).map((e) => `- ${e.timestamp} ${e.type}${e.visitorId ? ` (${e.visitorId})` : ""}`);
2776
+ return { content: [{ type: "text", text: `# Recent events\n${lines.join("\n")}` }] };
2777
+ },
2778
+ },
2779
+ apex_health: {
2780
+ description: `${APEX} — One-shot health check of every connected integration (last sync, status). Use before adding a feature that depends on a connector.`,
2781
+ schema: z.object({}),
2782
+ handler: async () => {
2783
+ const connectors = await apiGet("/api/connectors");
2784
+ const list = Array.isArray(connectors) ? connectors : [];
2785
+ const connected = list.filter((c) => c.isConnected || c.status === "connected");
2786
+ if (connected.length === 0) {
2787
+ return { content: [{ type: "text", text: "No connected integrations." }] };
2788
+ }
2789
+ const lines = connected.map((c) => `- ${c.name ?? c.type}: ${c.status ?? "connected"}${c.lastSyncAt ? ` (last sync ${c.lastSyncAt})` : ""}`);
2790
+ return { content: [{ type: "text", text: `# Integration health\n${lines.join("\n")}` }] };
2791
+ },
2792
+ },
2793
+ apex_spec_validate: {
2794
+ description: `${APEX} — Fetch the canonical Apex Spec event taxonomy so you can validate the track()/identify()/useApexVariant() calls in the user's files against it (ESLint-like for Apex usage). Read the user's files, find the calls, and flag event names + attributes that don't match the returned spec.`,
2795
+ schema: z.object({}),
2796
+ handler: async () => {
2797
+ const spec = await apiGet("/api/spec/events");
2798
+ return {
2799
+ content: [
2800
+ {
2801
+ type: "text",
2802
+ text: `Apex Spec (validate the user's track/identify/useApexVariant calls against this — flag unknown event names + attribute shapes):\n\n${JSON.stringify(spec, null, 2)}`,
2803
+ },
2804
+ ],
2805
+ };
2806
+ },
2807
+ },
2808
+ check_workspace_readiness: {
2809
+ description: `${APEX} — Check whether the workspace is ready for the Graduation Pipeline flow: Apex auth, GitHub App install, and (if you pass a repo) whether Apex may write to it. Run this at the top of /apex-experiment, /apex-graduate, etc., and resolve any gaps before proceeding.`,
2810
+ schema: z.object({
2811
+ repo: z.string().optional().describe('Current repo "owner/name" to check write opt-in for'),
2812
+ }),
2813
+ handler: async (args) => {
2814
+ const apexAuthed = Boolean(getActiveWorkspace());
2815
+ let githubAppInstalled = false;
2816
+ let currentRepoWritable = false;
2817
+ try {
2818
+ const connectors = await apiGet("/api/connectors");
2819
+ const list = Array.isArray(connectors) ? connectors : [];
2820
+ githubAppInstalled = list.some((c) => c.type === "github" && (c.isConnected || c.status === "connected"));
2821
+ }
2822
+ catch { /* unauth or unreachable */ }
2823
+ if (args.repo && githubAppInstalled) {
2824
+ try {
2825
+ const auto = await apiGet("/api/graduation-pipeline/automation");
2826
+ currentRepoWritable = (auto.data?.writeRepos ?? []).includes(args.repo);
2827
+ }
2828
+ catch { /* ignore */ }
2829
+ }
2830
+ const readiness = {
2831
+ apexAuthed,
2832
+ githubAppInstalled,
2833
+ currentRepoWritable,
2834
+ // The agent verifies these locally (presence of @apex-inc/sdk + repo access).
2835
+ sdkInstalled: "check-locally",
2836
+ currentRepoAuthorized: githubAppInstalled ? "check-locally" : false,
2837
+ };
2838
+ return { content: [{ type: "text", text: JSON.stringify(readiness, null, 2) }] };
2839
+ },
2840
+ },
2841
+ start_apex_login: {
2842
+ description: `${APEX} — Begin the in-IDE Apex sign-in. Returns a browser URL; open it, the developer signs in (Google / GitHub / email / SSO — unchanged), and this tool polls until a workspace-scoped API key is minted. Save the returned key to your Cursor MCP config (with the developer's consent).`,
2843
+ schema: z.object({}),
2844
+ handler: async () => {
2845
+ const start = await apiPost("/api/auth/cli-login/start", {});
2846
+ const lines = [
2847
+ `Open this URL in your browser to sign in to Apex:`,
2848
+ start.url,
2849
+ ``,
2850
+ `Waiting for sign-in…`,
2851
+ ];
2852
+ // Poll up to ~90s.
2853
+ for (let i = 0; i < 45; i++) {
2854
+ await new Promise((r) => setTimeout(r, 2000));
2855
+ try {
2856
+ const status = await apiGet(`/api/auth/login-status?state=${encodeURIComponent(start.state)}`);
2857
+ if (status.state === "logged_in" && status.apiKey) {
2858
+ return {
2859
+ content: [
2860
+ {
2861
+ type: "text",
2862
+ text: `Signed in. Save this API key to your Cursor MCP config (APEX_API_KEY) with the developer's consent:\n\n${status.apiKey}\n\nWorkspace: ${status.workspace || "(default)"}`,
2863
+ },
2864
+ ],
2865
+ };
2866
+ }
2867
+ if (status.state === "failed") {
2868
+ return { content: [{ type: "text", text: `Sign-in session expired. Re-run /apex-login.` }] };
2869
+ }
2870
+ }
2871
+ catch { /* keep polling */ }
2872
+ }
2873
+ return { content: [{ type: "text", text: `${lines.join("\n")}\n\nStill waiting — re-run /apex-login if you didn't finish in the browser.` }] };
2874
+ },
2875
+ },
2876
+ start_integration_install: {
2877
+ description: `${APEX} — Begin an in-IDE integration install (GitHub today; Stripe/HubSpot/etc. later). Returns a browser URL; open it, the developer completes the install, and this tool polls until it's connected. Generalized by integration type.`,
2878
+ schema: z.object({
2879
+ type: z.string().describe('Integration type, e.g. "github"'),
2880
+ }),
2881
+ handler: async ({ type }) => {
2882
+ const start = await apiPost("/api/integrations/cli-install/start", { type });
2883
+ for (let i = 0; i < 60; i++) {
2884
+ await new Promise((r) => setTimeout(r, 2000));
2885
+ try {
2886
+ const status = await apiGet(`/api/integrations/install-status?state=${encodeURIComponent(start.state)}`);
2887
+ if (status.state === "installed") {
2888
+ return { content: [{ type: "text", text: `${type} connected. Continuing.` }] };
2889
+ }
2890
+ if (status.state === "failed") {
2891
+ return { content: [{ type: "text", text: `Install session expired. Re-run the install.` }] };
2892
+ }
2893
+ }
2894
+ catch { /* keep polling */ }
2895
+ }
2896
+ return {
2897
+ content: [
2898
+ {
2899
+ type: "text",
2900
+ text: `Open this URL to install ${type}:\n${start.url}\n\nI'll keep waiting — say "continue" once you've finished, or skip for now (the merge gate won't arm until it's connected).`,
2901
+ },
2902
+ ],
2903
+ };
2904
+ },
2905
+ },
2906
+ create_api_key: {
2907
+ description: `${APEX} — Create a new Apex API key for the current user. Returns the raw key once.`,
2908
+ schema: z.object({ name: z.string().optional().describe("A label for the key") }),
2909
+ handler: async ({ name }) => {
2910
+ const res = await apiPost("/api/user/api-keys", {
2911
+ name: name ?? "Cursor (Apex MCP)",
2912
+ });
2913
+ const text = res.rawKey
2914
+ ? `Created API key "${name ?? "Cursor (Apex MCP)"}". Store it securely — shown once:\n${res.rawKey}`
2915
+ : `Could not create API key.`;
2916
+ return { content: [{ type: "text", text }] };
2917
+ },
2918
+ },
2919
+ list_api_keys: {
2920
+ description: `${APEX} — List the current user's Apex API keys (names + ids; never the secret).`,
2921
+ schema: z.object({}),
2922
+ handler: async () => {
2923
+ const keys = await apiGet("/api/user/api-keys");
2924
+ const list = Array.isArray(keys) ? keys : [];
2925
+ if (list.length === 0)
2926
+ return { content: [{ type: "text", text: "No API keys." }] };
2927
+ const lines = list.map((k) => `- ${k.name} (${k.id})${k.lastUsedAt ? ` — last used ${k.lastUsedAt}` : ""}`);
2928
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2929
+ },
2930
+ },
2931
+ revoke_api_key: {
2932
+ description: `${APEX} — Revoke (delete) an Apex API key by id.`,
2933
+ schema: z.object({ keyId: z.string().describe("The API key id to revoke") }),
2934
+ handler: async ({ keyId }) => {
2935
+ await apiDelete(`/api/user/api-keys/${encodeURIComponent(keyId)}`);
2936
+ return { content: [{ type: "text", text: `Revoked API key ${keyId}.` }] };
2937
+ },
2938
+ },
2939
+ gate_experiment_on_merge: {
2940
+ description: `${APEX} — Arm a merge gate: bind an experiment to a linked PR so merging it auto-starts the test. Requires a frozen design spec (primary metric, minimum sample size, max duration, stopping rule) — this is the rigor gate that stops PR latency from changing the rules of the test. Link the PR first with link_experiment.`,
2941
+ schema: z.object({
2942
+ experimentId: z.string().describe("The experiment ID"),
2943
+ engineeringLinkId: z.string().describe("The link id of the PR (from list_engineering_links)"),
2944
+ rampOnMerge: z.number().min(0).max(100).optional().describe("Variant traffic split applied on merge (default 50)"),
2945
+ primaryMetric: z.string().describe("Primary metric key (must be one of the experiment's metrics)"),
2946
+ minSampleSize: z.number().int().positive().describe("Minimum per-variant sample size (must exceed the default 10)"),
2947
+ maxDurationDays: z.number().int().positive().describe("Max run duration in days (auto-stop)"),
2948
+ stoppingRule: z.string().describe('Stopping rule, e.g. "bayesian-95" or "fixed-horizon"'),
2949
+ }),
2950
+ handler: async (args) => {
2951
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(args.experimentId)}/gate`, {
2952
+ engineeringLinkId: args.engineeringLinkId,
2953
+ rampOnMerge: args.rampOnMerge ?? 50,
2954
+ designSpec: {
2955
+ primaryMetric: args.primaryMetric,
2956
+ minSampleSize: args.minSampleSize,
2957
+ maxDurationDays: args.maxDurationDays,
2958
+ stoppingRule: args.stoppingRule,
2959
+ },
2960
+ });
2961
+ const text = res.data
2962
+ ? `Merge gate armed for ${args.experimentId}. Status: ${res.data.status} (waiting on the PR to merge).`
2963
+ : `Could not arm gate: ${res.error ?? "unknown error"}${res.code ? ` (${res.code})` : ""}`;
2964
+ return { content: [{ type: "text", text }] };
2965
+ },
2966
+ },
2967
+ open_graduation_pr: {
2968
+ description: `${APEX} — Open an Apex-authored Graduation PR that inlines the winning variant in the merchant's repo. Requires the repo to be opted in for Apex writes (Automation Controls). The experiment must be promoted with a winner. No LLM — a deterministic, reviewable manifest + branded PR.`,
2969
+ schema: z.object({
2970
+ experimentId: z.string().describe("The experiment ID (must be promoted with a winner)"),
2971
+ }),
2972
+ handler: async ({ experimentId }) => {
2973
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/promote`, { openGraduationPr: true });
2974
+ const url = res.data?.graduationPrUrl;
2975
+ const text = url
2976
+ ? `Graduation PR opened: ${url}`
2977
+ : `Could not open graduation PR: ${res.error ?? "unknown error"}`;
2978
+ return { content: [{ type: "text", text }] };
2979
+ },
2980
+ },
2981
+ open_revert_pr: {
2982
+ description: `${APEX} — Open a native-format Revert PR removing a losing experiment's variant. Requires the repo to be opted in for Apex writes.`,
2983
+ schema: z.object({
2984
+ experimentId: z.string().describe("The experiment ID"),
2985
+ }),
2986
+ handler: async ({ experimentId }) => {
2987
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/open-revert-pr`, {});
2988
+ const url = res.data?.prUrl;
2989
+ const text = url
2990
+ ? `Revert PR opened: ${url}`
2991
+ : `Could not open revert PR: ${res.error ?? "unknown error"}`;
2992
+ return { content: [{ type: "text", text }] };
2993
+ },
2994
+ },
2995
+ get_automation_controls: {
2996
+ description: `${APEX} — Read the workspace's Graduation Pipeline Automation Controls (on-conclusion behavior, per-repo write allowlist, GitHub visibility, commit/branch conventions).`,
2997
+ schema: z.object({}),
2998
+ handler: async () => {
2999
+ const res = await apiGet(`/api/graduation-pipeline/automation`);
3000
+ return {
3001
+ content: [{ type: "text", text: JSON.stringify(res.data ?? res, null, 2) }],
3002
+ };
3003
+ },
3004
+ },
3005
+ update_automation_controls: {
3006
+ description: `${APEX} — Update the workspace's Graduation Pipeline Automation Controls. Only the fields you pass change. To let Apex open PRs to a repo, add it via writeRepos (or use the dedicated repos endpoint).`,
3007
+ schema: z.object({
3008
+ onWin: z.enum(["do_nothing", "notify", "auto"]).optional(),
3009
+ onLose: z.enum(["do_nothing", "notify", "auto"]).optional(),
3010
+ writeRepos: z.array(z.string()).optional(),
3011
+ githubVisibility: z.enum(["full", "minimal", "off"]).optional(),
3012
+ commitConvention: z.enum(["conventional", "none", "custom"]).optional(),
3013
+ branchConvention: z.enum(["apex", "feature", "feat", "chore", "custom"]).optional(),
3014
+ workingHours: z.enum(["anytime", "off_hours", "business_hours"]).optional(),
3015
+ }),
3016
+ handler: async (args) => {
3017
+ const res = await apiPatch(`/api/graduation-pipeline/automation`, args);
3018
+ return {
3019
+ content: [{ type: "text", text: `Automation Controls updated.\n${JSON.stringify(res.data ?? res, null, 2)}` }],
3020
+ };
3021
+ },
3022
+ },
3023
+ list_engineering_links: {
3024
+ description: `${APEX} — List the PRs and issues linked to an experiment, with their current state.`,
3025
+ schema: z.object({
3026
+ experimentId: z.string().describe("The experiment ID"),
3027
+ }),
3028
+ handler: async ({ experimentId }) => {
3029
+ const res = await apiGet(`/api/experiments/${encodeURIComponent(experimentId)}/links`);
3030
+ const links = res.data ?? [];
3031
+ if (links.length === 0) {
3032
+ return {
3033
+ content: [{ type: "text", text: `No engineering links for experiment ${experimentId}.` }],
3034
+ };
3035
+ }
3036
+ const lines = links.map((l) => `- ${l.system} ${l.kind} ${l.repo ?? ""}#${l.externalId}${l.state ? ` (${l.state})` : ""}${l.title ? ` — ${l.title}` : ""}\n ${l.url}`);
3037
+ return {
3038
+ content: [
3039
+ { type: "text", text: `# Engineering links — ${experimentId}\n\n${lines.join("\n")}` },
3040
+ ],
3041
+ };
3042
+ },
3043
+ },
2593
3044
  };
2594
3045
  //# sourceMappingURL=tools.js.map