@apex-inc/mcp-server 0.1.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 ADDED
@@ -0,0 +1,893 @@
1
+ import { z } from "zod";
2
+ import { apiGet, apiPost, apiPatch } from "./api-client.js";
3
+ const APEX = "∧ Apex";
4
+ export const toolDefinitions = {
5
+ plan_experiment: {
6
+ description: `${APEX} — PLANNING ONLY, does not create anything. Analyzes a goal against existing beliefs and experiments, then returns structured options for the user to choose from. After calling, you MUST present the returned options as a numbered list or poll and wait for the user to pick before calling any other Apex tools.`,
7
+ schema: z.object({
8
+ goal: z.string().describe("What the user wants to improve or test (e.g. 'improve signup conversion on landing page')"),
9
+ targetUrl: z.string().optional().describe("URL of the page to test, if known"),
10
+ }),
11
+ handler: async ({ goal, targetUrl }) => {
12
+ const [beliefs, experiments] = await Promise.all([
13
+ apiGet("/api/beliefs"),
14
+ apiGet("/api/experiments"),
15
+ ]);
16
+ const keywords = goal
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9\s]/g, "")
19
+ .split(/\s+/)
20
+ .filter((w) => w.length > 3);
21
+ const relatedBeliefs = beliefs.filter((b) => {
22
+ const words = b.statement.toLowerCase();
23
+ return keywords.some((kw) => words.includes(kw));
24
+ });
25
+ const relatedExperiments = experiments.filter((e) => {
26
+ const words = e.name.toLowerCase();
27
+ return keywords.some((kw) => words.includes(kw));
28
+ });
29
+ const plan = {
30
+ _apex: true,
31
+ goal,
32
+ targetUrl: targetUrl || null,
33
+ relatedBeliefs: relatedBeliefs.slice(0, 5).map((b) => ({
34
+ id: b.id,
35
+ statement: b.statement,
36
+ confidence: Math.round(b.confidence * 100),
37
+ tested: experiments.some((e) => e.beliefId === b.id),
38
+ })),
39
+ relatedExperiments: relatedExperiments.slice(0, 3).map((e) => ({
40
+ id: e.id,
41
+ name: e.name,
42
+ status: e.status,
43
+ })),
44
+ suggestedBeliefs: [
45
+ `Users who see [specific change] will convert at a higher rate`,
46
+ `The current [element] is the primary friction point for [goal]`,
47
+ `A clearer value proposition will reduce bounce rate`,
48
+ ],
49
+ suggestedMetrics: [
50
+ "signup_conversion_rate",
51
+ "bounce_rate",
52
+ "click_through_rate",
53
+ "time_on_page",
54
+ ],
55
+ suggestedConfidenceLevels: [
56
+ { label: "Low (30%) — Gut feeling, no supporting data yet", value: 30 },
57
+ { label: "Medium (55%) — Some signals, but hasn't been tested", value: 55 },
58
+ { label: "High (75%) — Strong evidence from past experiments or data", value: 75 },
59
+ { label: "Very High (90%) — Near-certain, well-established pattern", value: 90 },
60
+ ],
61
+ suggestedTrafficAllocations: [
62
+ { label: "Adaptive (recommended) — Automatically shifts traffic toward the winning variant", control: 50, variant: 50, adaptive: true },
63
+ { label: "Balanced 50/50 — Equal split, fastest to reach significance", control: 50, variant: 50, adaptive: false },
64
+ { label: "Conservative 80/20 — Minimal exposure to variant, lower risk", control: 80, variant: 20, adaptive: false },
65
+ ],
66
+ _instructions: "IMPORTANT: Present each section as a numbered selection the user can pick from. Use a poll or numbered list for beliefs, confidence, metrics, and traffic split. Do NOT auto-fill any values.",
67
+ };
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text",
72
+ text: JSON.stringify(plan, null, 2),
73
+ },
74
+ ],
75
+ };
76
+ },
77
+ },
78
+ start_reasoning: {
79
+ description: `${APEX} — Record a belief and hypothesis chain. Only call AFTER the user has picked a belief statement and confidence level from the options you presented.`,
80
+ schema: z.object({
81
+ decision: z.string().describe("What you want to improve or change"),
82
+ belief: z.string().describe("The assumption — must be confirmed by the user"),
83
+ confidence: z
84
+ .number()
85
+ .min(0)
86
+ .max(100)
87
+ .describe("How sure the user is about this assumption (0-100%) — must be chosen by the user"),
88
+ }),
89
+ handler: async ({ decision, belief, confidence }) => {
90
+ const b = await apiPost("/api/beliefs", {
91
+ statement: belief,
92
+ confidence: confidence / 100,
93
+ });
94
+ const hypothesis = `If we act on "${belief.slice(0, 80)}", then we should see a measurable improvement in our target metric.`;
95
+ const h = await apiPost("/api/hypotheses", {
96
+ beliefId: b.id,
97
+ statement: hypothesis,
98
+ predictedEffect: `Impact related to: ${decision.slice(0, 60)}`,
99
+ confidence: confidence / 100,
100
+ status: "active",
101
+ });
102
+ return {
103
+ content: [
104
+ {
105
+ type: "text",
106
+ text: [
107
+ `${APEX} Belief recorded`,
108
+ ``,
109
+ ` ID: ${b.id}`,
110
+ ` Statement: "${b.statement}"`,
111
+ ` Confidence: ${Math.round(b.confidence * 100)}%`,
112
+ ` Hypothesis: ${h.id}`,
113
+ ``,
114
+ `Present the user with these next steps as selections:`,
115
+ ` 1. Log a prediction — how much impact do you expect?`,
116
+ ` 2. Design the experiment — what should we change?`,
117
+ ` 3. Adjust the belief — refine the assumption first`,
118
+ ].join("\n"),
119
+ },
120
+ ],
121
+ };
122
+ },
123
+ },
124
+ create_belief: {
125
+ description: `${APEX} — Record an assumption your team is making. Confirm the belief statement and confidence with the user before calling.`,
126
+ schema: z.object({
127
+ statement: z.string().describe("The assumption statement"),
128
+ confidence: z.number().min(0).max(100).describe("How sure you are (0-100%)"),
129
+ parentBeliefId: z.string().optional().describe("ID of a parent assumption, if this is a sub-assumption"),
130
+ }),
131
+ handler: async ({ statement, confidence, parentBeliefId }) => {
132
+ const b = await apiPost("/api/beliefs", {
133
+ statement,
134
+ confidence: confidence / 100,
135
+ parentBeliefId,
136
+ });
137
+ return {
138
+ content: [
139
+ {
140
+ type: "text",
141
+ text: `${APEX} Assumption tracked [${b.id}]: "${b.statement}" at ${Math.round(b.confidence * 100)}% certainty.`,
142
+ },
143
+ ],
144
+ };
145
+ },
146
+ },
147
+ create_experiment: {
148
+ description: `${APEX} — Create an experiment record. Returns a structured recipe for the AI agent to implement. The agent makes the actual code changes using useApexVariant hook — Apex never generates code. Always call with preview=true first so the user sees a summary.`,
149
+ schema: z.object({
150
+ name: z.string().describe("Experiment name"),
151
+ targetUrl: z.string().describe("URL of the page to test (include #anchor for section targeting)"),
152
+ controlContent: z.string().describe("The current content that the control shows"),
153
+ variantContent: z.string().describe("The new content for the variant"),
154
+ targetComponent: z.string().optional().describe("Component or file path where the change should be made"),
155
+ targetAnchor: z.string().optional().describe("Section anchor/ID on the page (e.g. 'pricing', 'hero')"),
156
+ trafficSplit: z.number().min(1).max(99).optional().describe("Percentage of traffic to control (default 50)"),
157
+ beliefId: z.string().optional().describe("ID of the assumption this tests"),
158
+ hypothesisId: z.string().optional().describe("ID of the expected outcome"),
159
+ predictionId: z.string().optional().describe("ID of the expected impact"),
160
+ mode: z.enum(["sdk", "snippet"]).optional().describe("Experiment mode: 'sdk' for code-level (default via MCP), 'snippet' for runtime DOM"),
161
+ preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
162
+ }),
163
+ handler: async (args) => {
164
+ const split = args.trafficSplit ?? 50;
165
+ const mode = args.mode ?? "sdk";
166
+ const isPreview = args.preview !== false;
167
+ if (isPreview) {
168
+ const recipe = {
169
+ _apex: true,
170
+ _type: "experiment_preview",
171
+ name: args.name,
172
+ targetUrl: args.targetUrl,
173
+ targetAnchor: args.targetAnchor || null,
174
+ targetComponent: args.targetComponent || null,
175
+ mode,
176
+ trafficSplit: { control: split, variant: 100 - split },
177
+ control: args.controlContent,
178
+ variant: args.variantContent,
179
+ beliefId: args.beliefId || null,
180
+ predictionId: args.predictionId || null,
181
+ _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.",
182
+ };
183
+ return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
184
+ }
185
+ const exp = await apiPost("/api/experiments", {
186
+ name: args.name,
187
+ targetUrl: args.targetUrl,
188
+ trafficSplit: split,
189
+ beliefId: args.beliefId,
190
+ hypothesisId: args.hypothesisId,
191
+ predictionId: args.predictionId,
192
+ mode,
193
+ targetAnchor: args.targetAnchor,
194
+ variants: {
195
+ control: { name: "Control", description: "Original content", changes: [] },
196
+ variant_b: {
197
+ name: "Variant B",
198
+ description: "Modified content",
199
+ changes: [
200
+ {
201
+ selector: "*",
202
+ type: "text",
203
+ value: args.variantContent,
204
+ originalValue: args.controlContent,
205
+ },
206
+ ],
207
+ },
208
+ },
209
+ });
210
+ let previewUrl = "";
211
+ try {
212
+ const vu = new URL(exp.targetUrl || args.targetUrl);
213
+ vu.searchParams.set("_apex_preview", "variant_b");
214
+ vu.searchParams.set("_apex_exp", exp.id);
215
+ if (args.targetAnchor)
216
+ vu.hash = args.targetAnchor;
217
+ previewUrl = vu.toString();
218
+ }
219
+ catch { /* non-critical */ }
220
+ const recipe = {
221
+ _apex: true,
222
+ _type: "experiment_created",
223
+ experimentId: exp.id,
224
+ name: exp.name,
225
+ status: exp.status,
226
+ mode,
227
+ sdk: mode === "sdk" ? {
228
+ hook: "useApexVariant",
229
+ import: "@/lib/apex-sdk",
230
+ experimentId: exp.id,
231
+ targetComponent: args.targetComponent || null,
232
+ targetAnchor: args.targetAnchor || null,
233
+ control: args.controlContent,
234
+ variant: args.variantContent,
235
+ } : null,
236
+ previewUrl,
237
+ trafficSplit: { control: split, variant: 100 - split },
238
+ _instructions: mode === "sdk"
239
+ ? [
240
+ "IMPLEMENT THE EXPERIMENT IN CODE:",
241
+ `1. Open the target file${args.targetComponent ? ` (${args.targetComponent})` : ""}`,
242
+ `2. Add: import { useApexVariant } from "@/lib/apex-sdk"`,
243
+ `3. In the component, add: const variant = useApexVariant("${exp.id}")`,
244
+ `4. Wrap the target content in a conditional:`,
245
+ ` {variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
246
+ `5. Show the user the diff and ask them to preview at: ${previewUrl}`,
247
+ `6. After preview approval, commit and push`,
248
+ `7. Call track_deployment with the experiment ID and commit SHA`,
249
+ `8. Call activate_experiment after deployment confirms`,
250
+ ].join("\n")
251
+ : [
252
+ "SNIPPET MODE — no code changes needed.",
253
+ "The experiment will be applied via the Apex snippet at runtime.",
254
+ `Preview: ${previewUrl}`,
255
+ "Ask the user to activate when ready.",
256
+ ].join("\n"),
257
+ };
258
+ return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
259
+ },
260
+ },
261
+ activate_experiment: {
262
+ description: `${APEX} — Launch an experiment. Changes its status from draft to running. Traffic will be split immediately. Ask the user to confirm before calling.`,
263
+ schema: z.object({
264
+ experimentId: z.string().describe("The experiment ID to activate"),
265
+ }),
266
+ handler: async ({ experimentId }) => {
267
+ const exp = await apiPatch("/api/experiments", {
268
+ id: experimentId,
269
+ status: "running",
270
+ });
271
+ return {
272
+ content: [
273
+ {
274
+ type: "text",
275
+ text: [
276
+ `${APEX} Experiment Launched`,
277
+ `${"═".repeat(40)}`,
278
+ ``,
279
+ ` "${exp.name}" is now LIVE`,
280
+ ` ID: ${exp.id}`,
281
+ ` Traffic is being split ${exp.trafficSplit}% / ${100 - exp.trafficSplit}%`,
282
+ ``,
283
+ ` Visitors will now see either the control or variant.`,
284
+ ` Results will appear in the dashboard and via get_results.`,
285
+ ``,
286
+ `${"═".repeat(40)}`,
287
+ ].join("\n"),
288
+ },
289
+ ],
290
+ };
291
+ },
292
+ },
293
+ pause_experiment: {
294
+ description: `${APEX} — Pause a running experiment. All visitors will see the control until resumed. Ask the user to confirm before calling.`,
295
+ schema: z.object({
296
+ experimentId: z.string().describe("The experiment ID to pause"),
297
+ }),
298
+ handler: async ({ experimentId }) => {
299
+ const exp = await apiPatch("/api/experiments", {
300
+ id: experimentId,
301
+ status: "paused",
302
+ });
303
+ return {
304
+ content: [
305
+ {
306
+ type: "text",
307
+ text: [
308
+ `${APEX} Experiment Paused`,
309
+ ``,
310
+ ` "${exp.name}" is now paused.`,
311
+ ` All visitors will see the control version.`,
312
+ ` Data collected so far is preserved.`,
313
+ ``,
314
+ ` To resume, use activate_experiment.`,
315
+ ].join("\n"),
316
+ },
317
+ ],
318
+ };
319
+ },
320
+ },
321
+ track_deployment: {
322
+ description: `${APEX} — Record that an experiment's code has been committed and pushed. Stores the commit SHA and monitors deployment status. Call after pushing the experiment code.`,
323
+ schema: z.object({
324
+ experimentId: z.string().describe("The experiment ID"),
325
+ commitSha: z.string().describe("The git commit SHA containing the experiment code"),
326
+ branch: z.string().optional().describe("The git branch (default: main)"),
327
+ }),
328
+ handler: async ({ experimentId, commitSha, branch }) => {
329
+ const exp = await apiPatch("/api/experiments", {
330
+ id: experimentId,
331
+ deploymentCommit: commitSha,
332
+ deploymentStatus: "building",
333
+ });
334
+ const recipe = {
335
+ _apex: true,
336
+ _type: "deployment_tracked",
337
+ experimentId: exp.id,
338
+ commitSha,
339
+ branch: branch || "main",
340
+ deploymentStatus: "building",
341
+ _instructions: [
342
+ "The experiment code is now being deployed.",
343
+ "Wait for the build to complete, then call check_deployment.",
344
+ "Once deployed, call activate_experiment to start collecting data.",
345
+ "Ask the user if they want to wait for deployment or activate later.",
346
+ ].join("\n"),
347
+ };
348
+ return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
349
+ },
350
+ },
351
+ check_deployment: {
352
+ description: `${APEX} — Check if an experiment's code has been deployed. Verifies that the assignment endpoint responds for this experiment. Call after track_deployment.`,
353
+ schema: z.object({
354
+ experimentId: z.string().describe("The experiment ID to check"),
355
+ }),
356
+ handler: async ({ experimentId }) => {
357
+ const exp = await apiGet(`/api/experiments/${experimentId}`);
358
+ if (!exp) {
359
+ return { content: [{ type: "text", text: JSON.stringify({ _apex: true, error: "Experiment not found" }) }] };
360
+ }
361
+ let isReachable = false;
362
+ try {
363
+ const BASE_URL = process.env.APEX_URL || "http://localhost:3001";
364
+ const res = await fetch(`${BASE_URL}/api/experiments/${experimentId}/assign`, {
365
+ signal: AbortSignal.timeout(5000),
366
+ });
367
+ isReachable = res.ok;
368
+ }
369
+ catch { /* unreachable */ }
370
+ const status = isReachable ? "deployed" : (exp.deploymentStatus || "pending");
371
+ if (isReachable && exp.deploymentStatus !== "deployed") {
372
+ await apiPatch("/api/experiments", {
373
+ id: experimentId,
374
+ deploymentStatus: "deployed",
375
+ deployedAt: new Date().toISOString(),
376
+ });
377
+ }
378
+ const recipe = {
379
+ _apex: true,
380
+ _type: "deployment_check",
381
+ experimentId,
382
+ deploymentStatus: status,
383
+ commitSha: exp.deploymentCommit || null,
384
+ isReachable,
385
+ _instructions: isReachable
386
+ ? "Deployment confirmed. Ask the user if they want to activate the experiment now."
387
+ : "Deployment not yet confirmed. The build may still be in progress. Try again in a minute or ask the user to check their CI/CD.",
388
+ };
389
+ return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
390
+ },
391
+ },
392
+ archive_experiment: {
393
+ description: `${APEX} — Archive an experiment. Removes it from active views but preserves all data, learnings, and belief graph connections. Archived experiments still contribute to intelligence scoring and recommendations. Ask the user to confirm before calling.`,
394
+ schema: z.object({
395
+ experimentId: z.string().describe("The experiment ID to archive"),
396
+ }),
397
+ handler: async ({ experimentId }) => {
398
+ const exp = await apiPatch("/api/experiments", {
399
+ id: experimentId,
400
+ status: "archived",
401
+ });
402
+ return {
403
+ content: [
404
+ {
405
+ type: "text",
406
+ text: [
407
+ `${APEX} Experiment Archived`,
408
+ ``,
409
+ ` "${exp.name}" has been archived.`,
410
+ ` It no longer appears in active views, but all data is preserved.`,
411
+ ` Learnings and belief graph connections remain intact.`,
412
+ ``,
413
+ ` To view archived experiments: list_experiments with status "archived"`,
414
+ ` To restore: activate_experiment to resume, or pause_experiment to keep inactive.`,
415
+ ].join("\n"),
416
+ },
417
+ ],
418
+ };
419
+ },
420
+ },
421
+ list_experiments: {
422
+ description: `${APEX} — List experiments. Archived experiments are hidden by default — pass status "archived" to see them, or "all" to see everything.`,
423
+ schema: z.object({
424
+ status: z
425
+ .enum(["all", "running", "completed", "draft", "paused", "archived"])
426
+ .optional()
427
+ .describe("Filter by status. Defaults to showing all except archived."),
428
+ }),
429
+ handler: async ({ status }) => {
430
+ const experiments = await apiGet("/api/experiments");
431
+ const filtered = status === "all"
432
+ ? experiments
433
+ : status
434
+ ? experiments.filter((e) => e.status === status)
435
+ : experiments.filter((e) => e.status !== "archived");
436
+ if (filtered.length === 0) {
437
+ return { content: [{ type: "text", text: `${APEX} No experiments found.` }] };
438
+ }
439
+ const lines = filtered.map((e) => {
440
+ const ctrl = e.results?.control;
441
+ const varB = e.results?.variant_b;
442
+ const statusIcon = e.status === "running" ? "●" : e.status === "draft" ? "○" : e.status === "paused" ? "◐" : "◉";
443
+ return [
444
+ `${statusIcon} ${e.name} [${e.status}]`,
445
+ ` ID: ${e.id}`,
446
+ ` URL: ${e.targetUrl} | ${e.daysRunning ?? 0}d running`,
447
+ ctrl && varB
448
+ ? ` Control: ${ctrl.conversionRate}% CVR (${ctrl.visitors} visitors) | Variant: ${varB.conversionRate}% CVR (${varB.visitors} visitors)`
449
+ : " No results yet",
450
+ e.beliefId ? ` Belief: ${e.beliefId}` : "",
451
+ ]
452
+ .filter(Boolean)
453
+ .join("\n");
454
+ });
455
+ return { content: [{ type: "text", text: `${APEX} Experiments (${filtered.length})\n${"─".repeat(40)}\n\n${lines.join("\n\n")}` }] };
456
+ },
457
+ },
458
+ get_results: {
459
+ description: `${APEX} — Get detailed results for a specific experiment.`,
460
+ schema: z.object({
461
+ experimentId: z.string().describe("The experiment ID"),
462
+ }),
463
+ handler: async ({ experimentId }) => {
464
+ const exp = await apiGet(`/api/experiments/${experimentId}`);
465
+ if (!exp) {
466
+ return { content: [{ type: "text", text: `${APEX} Experiment ${experimentId} not found.` }] };
467
+ }
468
+ const ctrl = exp.results?.control;
469
+ const varB = exp.results?.variant_b;
470
+ return {
471
+ content: [
472
+ {
473
+ type: "text",
474
+ text: [
475
+ `${APEX} Results: ${exp.name}`,
476
+ `${"═".repeat(40)}`,
477
+ ``,
478
+ ` Status: ${exp.status}`,
479
+ ` Running: ${exp.daysRunning ?? 0} days`,
480
+ ` Confidence: ${exp.confidence ?? 0}%`,
481
+ ` URL: ${exp.targetUrl}`,
482
+ ``,
483
+ ` Control:`,
484
+ ctrl
485
+ ? ` ${ctrl.visitors} visitors | ${ctrl.leads} conversions | ${ctrl.conversionRate}% CVR`
486
+ : " No data yet",
487
+ ` Variant B:`,
488
+ varB
489
+ ? ` ${varB.visitors} visitors | ${varB.leads} conversions | ${varB.conversionRate}% CVR`
490
+ : " No data yet",
491
+ exp.beliefId ? `\n Linked belief: ${exp.beliefId}` : "",
492
+ ``,
493
+ `${"═".repeat(40)}`,
494
+ ``,
495
+ `Present the user with options:`,
496
+ ` 1. Keep running — need more data`,
497
+ ` 2. Promote winner — if the result is clear`,
498
+ ` 3. Pause — stop collecting temporarily`,
499
+ ` 4. Suggest next experiment — what to test next`,
500
+ ]
501
+ .filter(Boolean)
502
+ .join("\n"),
503
+ },
504
+ ],
505
+ };
506
+ },
507
+ },
508
+ suggest_experiment: {
509
+ description: `${APEX} — Get smart experiment suggestions based on context. Analyzes your assumptions, past experiments, and confidence gaps to recommend what to test next.`,
510
+ schema: z.object({
511
+ context: z
512
+ .string()
513
+ .optional()
514
+ .describe("What you're working on (e.g. 'redesigning onboarding', 'pricing page optimization')"),
515
+ }),
516
+ handler: async ({ context }) => {
517
+ const [beliefs, experiments] = await Promise.all([
518
+ apiGet("/api/beliefs"),
519
+ apiGet("/api/experiments"),
520
+ ]);
521
+ const lowConfidence = beliefs.filter((b) => b.confidence < 0.6 && b.confidence > 0.05);
522
+ const untested = beliefs.filter((b) => b.confidence > 0.05 && !experiments.some((e) => e.beliefId === b.id));
523
+ const completed = experiments.filter((e) => e.status === "completed");
524
+ const running = experiments.filter((e) => e.status === "running");
525
+ const contextKeywords = context
526
+ ? context.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length > 3)
527
+ : [];
528
+ const relevantBeliefs = contextKeywords.length > 0
529
+ ? beliefs.filter((b) => {
530
+ const words = b.statement.toLowerCase();
531
+ return contextKeywords.some((kw) => words.includes(kw));
532
+ })
533
+ : [];
534
+ const relevantExperiments = contextKeywords.length > 0
535
+ ? completed.filter((e) => {
536
+ const words = e.name.toLowerCase();
537
+ return contextKeywords.some((kw) => words.includes(kw));
538
+ })
539
+ : [];
540
+ const recs = [];
541
+ if (context) {
542
+ recs.push(`**Context**: ${context}`, "");
543
+ }
544
+ if (relevantBeliefs.length > 0) {
545
+ recs.push(`**Related assumptions** (${relevantBeliefs.length}):`, ...relevantBeliefs.slice(0, 5).map((b) => {
546
+ const tested = experiments.some((e) => e.beliefId === b.id);
547
+ return ` - "${b.statement}" (${Math.round(b.confidence * 100)}% sure) — ${tested ? "tested" : "untested"}`;
548
+ }), "");
549
+ }
550
+ if (relevantExperiments.length > 0) {
551
+ recs.push(`**Past experiments in this area** (${relevantExperiments.length}):`, ...relevantExperiments.slice(0, 3).map((e) => {
552
+ const ctrl = e.results?.control;
553
+ const varB = e.results?.variant_b;
554
+ const lift = ctrl && varB && ctrl.conversionRate > 0
555
+ ? ((varB.conversionRate - ctrl.conversionRate) / ctrl.conversionRate * 100).toFixed(1)
556
+ : "N/A";
557
+ return ` - "${e.name}" — ${lift}% lift, ${e.confidence}% confidence`;
558
+ }), "");
559
+ }
560
+ if (untested.length > 0) {
561
+ recs.push(`**Untested assumptions** (${untested.length}):`, ...untested.slice(0, 3).map((b) => ` - "${b.statement}" (${Math.round(b.confidence * 100)}% sure)`), "");
562
+ }
563
+ if (lowConfidence.length > 0) {
564
+ recs.push(`**Low-confidence assumptions that need data**:`, ...lowConfidence.slice(0, 3).map((b) => ` - "${b.statement}" at ${Math.round(b.confidence * 100)}%`), "");
565
+ }
566
+ if (completed.length > 0) {
567
+ recs.push(`**Follow-up opportunities** from ${completed.length} completed experiments:`, ...completed.slice(0, 3).map((e) => ` - "${e.name}" — consider a follow-up variant`), "");
568
+ }
569
+ recs.push(`**Current state**: ${beliefs.length} assumptions, ${running.length} running, ${completed.length} completed`);
570
+ if (recs.length <= 1) {
571
+ recs.push("", "No specific recommendations yet. Start by tracking an assumption using start_reasoning.");
572
+ }
573
+ return { content: [{ type: "text", text: recs.join("\n") }] };
574
+ },
575
+ },
576
+ promote_winner: {
577
+ description: `${APEX} — Mark the winning variant and complete the experiment. Returns cleanup instructions for SDK experiments. Show the user the results and ask them to confirm the winner before calling.`,
578
+ schema: z.object({
579
+ experimentId: z.string().describe("The experiment ID"),
580
+ winner: z.enum(["control", "variant_b"]).describe("Which variant won"),
581
+ }),
582
+ handler: async ({ experimentId, winner }) => {
583
+ const exp = await apiPatch(`/api/experiments`, {
584
+ id: experimentId,
585
+ status: "completed",
586
+ winner,
587
+ });
588
+ let beliefUpdate = "";
589
+ if (exp.beliefId) {
590
+ try {
591
+ const result = await apiPost(`/api/beliefs/${exp.beliefId}/update-confidence`, {
592
+ experimentId,
593
+ outcome: winner === "variant_b" ? "confirmed" : "contradicted",
594
+ metricDelta: winner === "variant_b" ? 0.15 : -0.1,
595
+ });
596
+ beliefUpdate = `Assumption "${result.belief.statement}" updated to ${Math.round(result.belief.confidence * 100)}% certainty.`;
597
+ }
598
+ catch {
599
+ beliefUpdate = "Could not update assumption certainty automatically.";
600
+ }
601
+ }
602
+ const recipe = {
603
+ _apex: true,
604
+ _type: "experiment_completed",
605
+ experimentId: exp.id,
606
+ name: exp.name,
607
+ winner,
608
+ beliefUpdate: beliefUpdate || null,
609
+ _instructions: exp.mode === "sdk"
610
+ ? [
611
+ `GRADUATE THE EXPERIMENT CODE:`,
612
+ `Winner is "${winner}".`,
613
+ `1. Find all useApexVariant("${exp.id}") calls in the codebase`,
614
+ winner === "variant_b"
615
+ ? `2. Replace each variant conditional with ONLY the variant_b content (remove the control branch)`
616
+ : `2. Replace each variant conditional with ONLY the control content (remove the variant_b branch)`,
617
+ `3. Remove the useApexVariant import if no other experiments use it`,
618
+ `4. Commit: "Graduate experiment ${exp.name}: ${winner} wins"`,
619
+ `5. Push to deploy the clean code`,
620
+ ].join("\n")
621
+ : `Experiment completed. Winner: ${winner}. No code cleanup needed for snippet-mode experiments.`,
622
+ };
623
+ return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
624
+ },
625
+ },
626
+ record_outcome: {
627
+ description: `${APEX} — Log the outcome of an experiment and update belief confidence. Present the outcome interpretation to the user and let them confirm before recording.`,
628
+ schema: z.object({
629
+ experimentId: z.string().describe("The experiment ID"),
630
+ beliefId: z.string().describe("The assumption ID to update"),
631
+ outcome: z.enum(["confirmed", "contradicted", "inconclusive"]).describe("Whether the expected outcome happened"),
632
+ metricDelta: z.number().describe("The observed metric change (e.g. 0.15 for +15%)"),
633
+ notes: z.string().optional().describe("Optional notes about the outcome"),
634
+ }),
635
+ handler: async (args) => {
636
+ const result = await apiPost(`/api/beliefs/${args.beliefId}/update-confidence`, {
637
+ experimentId: args.experimentId,
638
+ outcome: args.outcome,
639
+ metricDelta: args.metricDelta,
640
+ notes: args.notes,
641
+ });
642
+ return {
643
+ content: [
644
+ {
645
+ type: "text",
646
+ text: [
647
+ `Outcome recorded for experiment ${args.experimentId}:`,
648
+ ` Result: ${args.outcome} (metric delta: ${args.metricDelta > 0 ? "+" : ""}${(args.metricDelta * 100).toFixed(1)}%)`,
649
+ ` Assumption "${result.belief.statement}" updated: ${Math.round(result.belief.confidence * 100)}%`,
650
+ ` Learning logged: ${result.ledgerEntry.decision}`,
651
+ args.notes ? ` Notes: ${args.notes}` : "",
652
+ ]
653
+ .filter(Boolean)
654
+ .join("\n"),
655
+ },
656
+ ],
657
+ };
658
+ },
659
+ },
660
+ predict_impact: {
661
+ description: `${APEX} — Decision guardrail: predict the impact of a proposed change by searching history for similar experiments. Returns historical data, confidence, and a recommendation.`,
662
+ schema: z.object({
663
+ description: z
664
+ .string()
665
+ .describe("Description of the proposed change (e.g. 'increase signup form fields from 3 to 5')"),
666
+ }),
667
+ handler: async ({ description }) => {
668
+ const result = await apiPost("/api/predictions/impact", { description });
669
+ const lines = [
670
+ `**Impact Prediction**: ${description}`,
671
+ "",
672
+ result.summary,
673
+ "",
674
+ ];
675
+ if (result.relatedExperiments.length > 0) {
676
+ lines.push("**Related experiments:**");
677
+ for (const exp of result.relatedExperiments) {
678
+ const lift = exp.conversionLift !== null ? `${exp.conversionLift > 0 ? "+" : ""}${exp.conversionLift}%` : "N/A";
679
+ lines.push(` - ${exp.experimentName}: ${lift} lift (${exp.visitors} visitors, ${exp.confidence}% confidence)`);
680
+ }
681
+ lines.push("");
682
+ }
683
+ if (result.relatedBeliefs.length > 0) {
684
+ lines.push("**Related assumptions:**");
685
+ for (const b of result.relatedBeliefs) {
686
+ lines.push(` - "${b.statement}" (${Math.round(b.confidence * 100)}% sure)`);
687
+ }
688
+ lines.push("");
689
+ }
690
+ lines.push(`**Recommendation**: ${result.recommendation.toUpperCase()}`);
691
+ return { content: [{ type: "text", text: lines.join("\n") }] };
692
+ },
693
+ },
694
+ evaluate_feature: {
695
+ description: `${APEX} — Pre-build decision intelligence: evaluate a feature idea against the belief graph, past experiments, and intelligence score before committing to building it.`,
696
+ schema: z.object({
697
+ feature: z.string().describe("Description of the feature you're considering building"),
698
+ targetMetric: z
699
+ .string()
700
+ .optional()
701
+ .describe("The metric you expect this feature to improve (e.g. 'conversion_rate', 'activation')"),
702
+ }),
703
+ handler: async ({ feature, targetMetric }) => {
704
+ const [beliefs, experiments] = await Promise.all([
705
+ apiGet("/api/beliefs"),
706
+ apiGet("/api/experiments"),
707
+ ]);
708
+ const keywords = feature
709
+ .toLowerCase()
710
+ .replace(/[^a-z0-9\s]/g, "")
711
+ .split(/\s+/)
712
+ .filter((w) => w.length > 3);
713
+ const relevantBeliefs = beliefs.filter((b) => {
714
+ const words = b.statement.toLowerCase();
715
+ return keywords.some((kw) => words.includes(kw));
716
+ });
717
+ const completed = experiments.filter((e) => e.status === "completed");
718
+ const relevantExperiments = completed.filter((e) => {
719
+ const words = e.name.toLowerCase();
720
+ return keywords.some((kw) => words.includes(kw));
721
+ });
722
+ const testedBeliefIds = new Set(experiments.map((e) => e.beliefId).filter(Boolean));
723
+ const untestedRelevant = relevantBeliefs.filter((b) => !testedBeliefIds.has(b.id));
724
+ const lowConfRelevant = relevantBeliefs.filter((b) => b.confidence < 0.6);
725
+ const avgLift = relevantExperiments.length > 0
726
+ ? relevantExperiments.reduce((sum, e) => {
727
+ const ctrl = e.results?.control;
728
+ const varB = e.results?.variant_b;
729
+ if (ctrl && varB && ctrl.conversionRate > 0) {
730
+ return sum + ((varB.conversionRate - ctrl.conversionRate) / ctrl.conversionRate) * 100;
731
+ }
732
+ return sum;
733
+ }, 0) / relevantExperiments.length
734
+ : null;
735
+ const lines = [
736
+ `**Feature Evaluation**: ${feature}`,
737
+ targetMetric ? `**Target metric**: ${targetMetric}` : "",
738
+ "",
739
+ ].filter(Boolean);
740
+ if (relevantBeliefs.length > 0) {
741
+ lines.push(`**What the belief graph says** (${relevantBeliefs.length} related assumptions):`, ...relevantBeliefs.slice(0, 5).map((b) => {
742
+ const tested = testedBeliefIds.has(b.id) ? "tested" : "untested";
743
+ return ` - "${b.statement}" — ${Math.round(b.confidence * 100)}% sure (${tested})`;
744
+ }), "");
745
+ }
746
+ else {
747
+ lines.push("**Belief graph**: No existing assumptions about this area. This is a blind spot.", "");
748
+ }
749
+ if (relevantExperiments.length > 0) {
750
+ lines.push(`**Historical evidence** (${relevantExperiments.length} related experiments):`, ...relevantExperiments.slice(0, 5).map((e) => {
751
+ const ctrl = e.results?.control;
752
+ const varB = e.results?.variant_b;
753
+ const lift = ctrl && varB && ctrl.conversionRate > 0
754
+ ? `${((varB.conversionRate - ctrl.conversionRate) / ctrl.conversionRate * 100).toFixed(1)}%`
755
+ : "N/A";
756
+ return ` - "${e.name}": ${lift} lift`;
757
+ }), avgLift !== null ? ` Average lift in this area: ${avgLift > 0 ? "+" : ""}${avgLift.toFixed(1)}%` : "", "");
758
+ }
759
+ else {
760
+ lines.push("**Historical evidence**: No experiments in this area yet.", "");
761
+ }
762
+ if (untestedRelevant.length > 0 || lowConfRelevant.length > 0) {
763
+ lines.push("**Confidence gaps**:");
764
+ if (untestedRelevant.length > 0) {
765
+ lines.push(` - ${untestedRelevant.length} untested assumption(s) in this area`);
766
+ }
767
+ if (lowConfRelevant.length > 0) {
768
+ lines.push(` - ${lowConfRelevant.length} low-confidence assumption(s) that need more data`);
769
+ }
770
+ lines.push("");
771
+ }
772
+ const hasEvidence = relevantExperiments.length > 0;
773
+ const hasAssumptions = relevantBeliefs.length > 0;
774
+ if (hasEvidence && avgLift !== null && avgLift > 5) {
775
+ lines.push("**Suggestion**: Historical data supports this direction. Consider building with a measurable experiment wrapped around it.");
776
+ }
777
+ else if (hasEvidence && avgLift !== null && avgLift < -5) {
778
+ lines.push("**Suggestion**: Historical data suggests caution — similar changes had negative impact. Run a focused experiment first.");
779
+ }
780
+ else if (hasAssumptions && !hasEvidence) {
781
+ lines.push("**Suggestion**: You have assumptions but no test data. Run an experiment before committing to a full build.");
782
+ }
783
+ else if (!hasAssumptions && !hasEvidence) {
784
+ lines.push("**Suggestion**: This is uncharted territory. Record your assumptions with create_belief and design an experiment first.");
785
+ }
786
+ else {
787
+ lines.push("**Suggestion**: Mixed signals. Run a targeted experiment to build confidence before a full build.");
788
+ }
789
+ return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }] };
790
+ },
791
+ },
792
+ track_event: {
793
+ description: `${APEX} — Track a custom event. Works identically to the SDK's track() and the snippet's apex.track(). Use this to instrument events from agent workflows, CI pipelines, or IDE actions.`,
794
+ schema: z.object({
795
+ event: z.string().describe("Event name (e.g. 'page_view', 'form_submit', 'cta_click')"),
796
+ properties: z.record(z.unknown()).optional().describe("Event properties (url, formId, variant, etc.)"),
797
+ }),
798
+ handler: async ({ event, properties }) => {
799
+ const result = await apiPost("/api/events", {
800
+ projectKey: process.env.APEX_PROJECT_KEY || "default",
801
+ userId: "mcp-agent",
802
+ events: [
803
+ {
804
+ type: event,
805
+ payload: { ...properties, source: "mcp" },
806
+ timestamp: new Date().toISOString(),
807
+ },
808
+ ],
809
+ });
810
+ return {
811
+ content: [{
812
+ type: "text",
813
+ text: `Event tracked: "${event}" — ${result.received} event(s) stored.${properties ? `\nProperties: ${JSON.stringify(properties)}` : ""}`,
814
+ }],
815
+ };
816
+ },
817
+ },
818
+ identify_user: {
819
+ description: `${APEX} — Identify a user by email for identity stitching. Works identically to the SDK's identify() and the snippet's apex.identify(). Links an anonymous visitor to a known email/lead.`,
820
+ schema: z.object({
821
+ email: z.string().describe("User email address"),
822
+ name: z.string().optional().describe("User name"),
823
+ company: z.string().optional().describe("Company name"),
824
+ metadata: z.record(z.unknown()).optional().describe("Additional traits"),
825
+ }),
826
+ handler: async ({ email, name, company, metadata }) => {
827
+ await apiPost("/api/identity/stitch", {
828
+ visitorId: `mcp-${email}`,
829
+ email,
830
+ projectKey: process.env.APEX_PROJECT_KEY || "default",
831
+ metadata: { name, company, ...metadata, source: "mcp" },
832
+ });
833
+ return {
834
+ content: [{
835
+ type: "text",
836
+ text: `Identity stitched: ${email}${name ? ` (${name})` : ""}${company ? ` at ${company}` : ""}\nA lead record has been created/linked in Apex.`,
837
+ }],
838
+ };
839
+ },
840
+ },
841
+ log_prediction: {
842
+ description: `${APEX} — Log a prediction about a metric change. Feeds the calibration loop. Before calling, present the metric, expected change, and confidence as options the user can pick from or adjust.`,
843
+ schema: z.object({
844
+ metric: z.string().describe("The metric being predicted (e.g. 'conversion_rate', 'signup_rate')"),
845
+ expectedChange: z.number().describe("Expected change as a decimal (e.g. 0.15 for +15%)"),
846
+ confidence: z.number().min(0).max(100).describe("How sure you are about this prediction (0-100%)"),
847
+ timeHorizon: z
848
+ .number()
849
+ .optional()
850
+ .describe("Days until the prediction should be evaluated (default: 14)"),
851
+ hypothesisId: z.string().optional().describe("ID of the linked hypothesis"),
852
+ beliefId: z.string().optional().describe("ID of the linked assumption"),
853
+ }),
854
+ handler: async (args) => {
855
+ const direction = args.expectedChange > 0 ? "increase" : args.expectedChange < 0 ? "decrease" : "no_change";
856
+ const prediction = await apiPost("/api/predictions", {
857
+ metric: args.metric,
858
+ expectedChange: args.expectedChange,
859
+ direction,
860
+ expectedRange: {
861
+ low: Math.min(0, args.expectedChange),
862
+ high: Math.max(0, args.expectedChange),
863
+ },
864
+ confidence: args.confidence / 100,
865
+ timeHorizon: args.timeHorizon ?? 14,
866
+ evaluationWindow: args.timeHorizon ?? 14,
867
+ hypothesisId: args.hypothesisId,
868
+ beliefId: args.beliefId,
869
+ });
870
+ const changeStr = `${args.expectedChange > 0 ? "+" : ""}${(args.expectedChange * 100).toFixed(1)}%`;
871
+ return {
872
+ content: [
873
+ {
874
+ type: "text",
875
+ text: [
876
+ `Prediction logged [${prediction.id}]:`,
877
+ ` Metric: ${args.metric}`,
878
+ ` Expected: ${changeStr} ${direction}`,
879
+ ` Confidence: ${args.confidence}%`,
880
+ ` Evaluate in: ${args.timeHorizon ?? 14} days`,
881
+ args.beliefId ? ` Linked assumption: ${args.beliefId}` : "",
882
+ "",
883
+ "This feeds the calibration loop. After the experiment runs, the actual result will be compared to this prediction to track forecasting accuracy.",
884
+ ]
885
+ .filter(Boolean)
886
+ .join("\n"),
887
+ },
888
+ ],
889
+ };
890
+ },
891
+ },
892
+ };
893
+ //# sourceMappingURL=tools.js.map