@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/api-client.d.ts.map +1 -1
- package/dist/api-client.js +4 -2
- package/dist/api-client.js.map +1 -1
- package/dist/auth.d.ts +8 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +22 -5
- package/dist/auth.js.map +1 -1
- package/dist/experiment-copy.d.ts +16 -0
- package/dist/experiment-copy.d.ts.map +1 -0
- package/dist/experiment-copy.js +23 -0
- package/dist/experiment-copy.js.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/prompts.d.ts +104 -0
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +181 -7
- package/dist/prompts.js.map +1 -1
- package/dist/tools.d.ts +370 -6
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +492 -41
- package/dist/tools.js.map +1 -1
- package/guardrails/apex-experiment-guardrails.mdc +20 -0
- package/guardrails/brand-truth.md +34 -0
- package/guardrails/brand-voice.md +22 -0
- package/guardrails/experiment-rules.md +21 -0
- package/guardrails/lexicon.md +16 -0
- package/package.json +6 -3
- package/skills/apex-experimentation/SKILL.md +17 -0
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: "
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
]
|
|
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
|