@apex-inc/mcp-server 0.9.4 → 0.9.8
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/resources.d.ts.map +1 -1
- package/dist/resources.js +14 -11
- package/dist/resources.js.map +1 -1
- package/dist/tools.d.ts +143 -29
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +479 -95
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/apex-experimentation/SKILL.md +18 -1
package/dist/tools.js
CHANGED
|
@@ -11,6 +11,107 @@ const APEX = "∧ Apex";
|
|
|
11
11
|
* separate agent sessions from each other.
|
|
12
12
|
*/
|
|
13
13
|
const MCP_AGENT_VISITOR_ID = `mcp-agent-${Math.random().toString(36).slice(2, 10)}`;
|
|
14
|
+
/** Control traffic % derived from the unified allocation weights (default 50). */
|
|
15
|
+
function controlPctOf(exp) {
|
|
16
|
+
const w = exp.allocation?.weights?.control;
|
|
17
|
+
return typeof w === "number" ? Math.round(w * 100) : 50;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Days running, derived locally from startedAt/createdAt → completedAt/now.
|
|
21
|
+
* The legacy web shape carried a server-computed `daysRunning`; the unified
|
|
22
|
+
* shape does not, so we compute it from the lifecycle timestamps.
|
|
23
|
+
*/
|
|
24
|
+
function daysRunningOf(exp) {
|
|
25
|
+
const start = exp.startedAt || exp.createdAt;
|
|
26
|
+
if (!start)
|
|
27
|
+
return 0;
|
|
28
|
+
const startMs = new Date(start).getTime();
|
|
29
|
+
if (Number.isNaN(startMs))
|
|
30
|
+
return 0;
|
|
31
|
+
const endMs = exp.completedAt ? new Date(exp.completedAt).getTime() : Date.now();
|
|
32
|
+
return Math.max(0, Math.floor((endMs - startMs) / 86_400_000));
|
|
33
|
+
}
|
|
34
|
+
/** Find a unified variant by its key (e.g. "control", "variant_b"). */
|
|
35
|
+
function findVariant(exp, key) {
|
|
36
|
+
return exp.variants?.find((v) => v.key === key);
|
|
37
|
+
}
|
|
38
|
+
/** Render a per-arm exposure line for wiring summaries. */
|
|
39
|
+
function formatArmLine(arm) {
|
|
40
|
+
const live = arm.exposures > 0;
|
|
41
|
+
const dot = live ? "●" : "○";
|
|
42
|
+
const seen = arm.lastSeenAt ? `, last seen ${arm.lastSeenAt}` : "";
|
|
43
|
+
return ` ${dot} ${arm.label} (${arm.variantKey}): ${arm.exposures} exposure${arm.exposures === 1 ? "" : "s"}${seen}`;
|
|
44
|
+
}
|
|
45
|
+
/** "add_to_cart" → "Add To Cart" (for primaryMetric labels). */
|
|
46
|
+
function titleCaseEvent(event) {
|
|
47
|
+
return event
|
|
48
|
+
.split(/[_\s]+/)
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
51
|
+
.join(" ");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Shared schema + handler for `switch_workspace` (canonical) and its
|
|
55
|
+
* deprecated alias `switch_project`. Both register against the same handler
|
|
56
|
+
* so existing agent calls keep working through the project→workspace rename.
|
|
57
|
+
*
|
|
58
|
+
* After updating the active workspace locally, we VALIDATE the switch with an
|
|
59
|
+
* authed read against `/api/setup-state` (which `apiGet` scopes to the active
|
|
60
|
+
* workspace). Validation is non-fatal: the local switch always succeeds, but
|
|
61
|
+
* we surface an auth/reachability warning so the user knows to connect or
|
|
62
|
+
* fix the workspace key.
|
|
63
|
+
*/
|
|
64
|
+
const switchWorkspaceSchema = z.object({
|
|
65
|
+
workspaceKey: z.string().optional().describe("The workspace key to switch to."),
|
|
66
|
+
projectKey: z
|
|
67
|
+
.string()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe("Deprecated alias of workspaceKey. Use workspaceKey."),
|
|
70
|
+
});
|
|
71
|
+
async function switchWorkspaceHandler(args) {
|
|
72
|
+
const key = args.workspaceKey ?? args.projectKey;
|
|
73
|
+
if (!key) {
|
|
74
|
+
return {
|
|
75
|
+
content: [{
|
|
76
|
+
type: "text",
|
|
77
|
+
text: "Provide a workspaceKey to switch to (projectKey is accepted as a deprecated alias).",
|
|
78
|
+
}],
|
|
79
|
+
isError: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
setActiveWorkspace(key);
|
|
83
|
+
const deprecationNote = !args.workspaceKey && args.projectKey
|
|
84
|
+
? " (note: `projectKey` is deprecated — use `workspaceKey`.)"
|
|
85
|
+
: "";
|
|
86
|
+
// Validate the switch by reading setup-state for the now-active workspace.
|
|
87
|
+
let validation = " Verified — Apex can read this workspace.";
|
|
88
|
+
try {
|
|
89
|
+
await apiGet("/api/setup-state");
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
93
|
+
if (/\(401\)|\(403\)|authentication failed/i.test(message)) {
|
|
94
|
+
validation =
|
|
95
|
+
"\n\n⚠️ Switched locally, but could not verify the workspace — authentication failed. " +
|
|
96
|
+
"Connect the MCP server to Apex (run any Apex tool to get a one-time approval link), " +
|
|
97
|
+
"or regenerate APEX_API_KEY in Apex → Settings → AI Agents, then try again.";
|
|
98
|
+
}
|
|
99
|
+
else if (/\(404\)/.test(message)) {
|
|
100
|
+
validation =
|
|
101
|
+
`\n\n⚠️ Switched locally, but Apex returned 404 for "${key}" — the workspace key may be wrong. ` +
|
|
102
|
+
"Use list_projects to see the valid keys for your API key.";
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
validation = `\n\n⚠️ Switched locally, but could not reach Apex to verify it (${message}).`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
content: [{
|
|
110
|
+
type: "text",
|
|
111
|
+
text: `Switched to workspace: ${key}. All subsequent calls will use this workspace.${deprecationNote}${validation}`,
|
|
112
|
+
}],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
14
115
|
export const toolDefinitions = {
|
|
15
116
|
plan_experiment: {
|
|
16
117
|
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.`,
|
|
@@ -21,7 +122,7 @@ export const toolDefinitions = {
|
|
|
21
122
|
handler: async ({ goal, targetUrl }) => {
|
|
22
123
|
const [beliefs, experiments] = await Promise.all([
|
|
23
124
|
apiGet("/api/beliefs"),
|
|
24
|
-
apiGet("/api/experiments"),
|
|
125
|
+
apiGet("/api/experiments?unified=true"),
|
|
25
126
|
]);
|
|
26
127
|
const keywords = goal
|
|
27
128
|
.toLowerCase()
|
|
@@ -167,6 +268,10 @@ export const toolDefinitions = {
|
|
|
167
268
|
beliefId: z.string().optional().describe("ID of the assumption this tests"),
|
|
168
269
|
hypothesisId: z.string().optional().describe("ID of the expected outcome"),
|
|
169
270
|
predictionId: z.string().optional().describe("ID of the expected impact"),
|
|
271
|
+
beliefStatement: z.string().optional().describe("If you don't have a beliefId, pass the assumption statement and Apex will create the belief (and a hypothesis) and link them to this experiment."),
|
|
272
|
+
beliefConfidence: z.number().min(0).max(100).optional().describe("Confidence (0-100%) for the belief created from beliefStatement. Defaults to 50."),
|
|
273
|
+
hypothesisStatement: z.string().optional().describe("Optional hypothesis statement to record alongside a belief created from beliefStatement."),
|
|
274
|
+
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."),
|
|
170
275
|
mode: z.enum(["sdk", "snippet"]).optional().describe("Experiment mode: 'sdk' for code-level (default via MCP), 'snippet' for runtime DOM"),
|
|
171
276
|
preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
|
|
172
277
|
}),
|
|
@@ -174,6 +279,53 @@ export const toolDefinitions = {
|
|
|
174
279
|
const split = args.trafficSplit ?? 50;
|
|
175
280
|
const mode = args.mode ?? "sdk";
|
|
176
281
|
const isPreview = args.preview !== false;
|
|
282
|
+
// p1-metric — resolve the primary metric. Defaults to the form_submit
|
|
283
|
+
// conversion metric; an explicit primaryMetricEvent is validated against
|
|
284
|
+
// the workspace's canonical event spec before we accept it.
|
|
285
|
+
let primaryMetric = {
|
|
286
|
+
key: "conversion_rate",
|
|
287
|
+
label: "Conversion rate",
|
|
288
|
+
type: "rate",
|
|
289
|
+
unit: "%",
|
|
290
|
+
direction: "increase",
|
|
291
|
+
source: { kind: "event", eventType: "form_submit" },
|
|
292
|
+
};
|
|
293
|
+
if (args.primaryMetricEvent) {
|
|
294
|
+
const event = args.primaryMetricEvent.trim();
|
|
295
|
+
let validEvents = [];
|
|
296
|
+
try {
|
|
297
|
+
const spec = await apiGet("/api/spec/events");
|
|
298
|
+
validEvents = Object.keys(spec.data?.events ?? {});
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
302
|
+
return {
|
|
303
|
+
content: [{
|
|
304
|
+
type: "text",
|
|
305
|
+
text: `Could not load the event spec to validate primaryMetricEvent "${event}" (${message}). Fix the connection or omit primaryMetricEvent to use the default conversion metric.`,
|
|
306
|
+
}],
|
|
307
|
+
isError: true,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if (validEvents.length && !validEvents.includes(event)) {
|
|
311
|
+
const sample = validEvents.slice(0, 12).join(", ");
|
|
312
|
+
return {
|
|
313
|
+
content: [{
|
|
314
|
+
type: "text",
|
|
315
|
+
text: `"${event}" is not a canonical event in this workspace's event spec. Valid options include: ${sample}${validEvents.length > 12 ? ", …" : ""}. Use get_event_spec for the full list.`,
|
|
316
|
+
}],
|
|
317
|
+
isError: true,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
primaryMetric = {
|
|
321
|
+
key: `${event}_rate`,
|
|
322
|
+
label: `${titleCaseEvent(event)} rate`,
|
|
323
|
+
type: "rate",
|
|
324
|
+
unit: "%",
|
|
325
|
+
direction: "increase",
|
|
326
|
+
source: { kind: "event", eventType: event },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
177
329
|
if (isPreview) {
|
|
178
330
|
const recipe = {
|
|
179
331
|
_apex: true,
|
|
@@ -186,25 +338,74 @@ export const toolDefinitions = {
|
|
|
186
338
|
trafficSplit: { control: split, variant: 100 - split },
|
|
187
339
|
control: args.controlContent,
|
|
188
340
|
variant: args.variantContent,
|
|
341
|
+
primaryMetric: primaryMetric.key,
|
|
189
342
|
beliefId: args.beliefId || null,
|
|
343
|
+
beliefStatement: args.beliefStatement || null,
|
|
190
344
|
predictionId: args.predictionId || null,
|
|
191
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.",
|
|
192
346
|
};
|
|
193
347
|
return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
|
|
194
348
|
}
|
|
349
|
+
// p2-linking — if the caller passed a belief statement instead of a
|
|
350
|
+
// pre-created beliefId, create the belief + hypothesis (mirroring
|
|
351
|
+
// start_reasoning) and link them to this experiment.
|
|
352
|
+
let beliefId = args.beliefId;
|
|
353
|
+
let hypothesisId = args.hypothesisId;
|
|
354
|
+
if (!beliefId && args.beliefStatement) {
|
|
355
|
+
const confidence = args.beliefConfidence != null ? args.beliefConfidence / 100 : 0.5;
|
|
356
|
+
const b = await apiPost("/api/beliefs", {
|
|
357
|
+
statement: args.beliefStatement,
|
|
358
|
+
confidence,
|
|
359
|
+
});
|
|
360
|
+
beliefId = b.id;
|
|
361
|
+
const hypothesisStatement = args.hypothesisStatement ??
|
|
362
|
+
`If we act on "${args.beliefStatement.slice(0, 80)}", then we should see a measurable improvement in "${args.name}".`;
|
|
363
|
+
const h = await apiPost("/api/hypotheses", {
|
|
364
|
+
beliefId: b.id,
|
|
365
|
+
statement: hypothesisStatement,
|
|
366
|
+
predictedEffect: `Impact related to: ${args.name.slice(0, 60)}`,
|
|
367
|
+
confidence,
|
|
368
|
+
status: "active",
|
|
369
|
+
});
|
|
370
|
+
hypothesisId = h.id;
|
|
371
|
+
}
|
|
372
|
+
const controlW = split / 100;
|
|
195
373
|
const exp = await apiPost("/api/experiments", {
|
|
374
|
+
surface: "web",
|
|
196
375
|
name: args.name,
|
|
197
376
|
targetUrl: args.targetUrl,
|
|
198
|
-
trafficSplit: split,
|
|
199
|
-
beliefId: args.beliefId,
|
|
200
|
-
hypothesisId: args.hypothesisId,
|
|
201
|
-
predictionId: args.predictionId,
|
|
202
|
-
mode,
|
|
203
377
|
targetAnchor: args.targetAnchor,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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: [
|
|
392
|
+
{
|
|
393
|
+
id: "v_control",
|
|
394
|
+
key: "control",
|
|
395
|
+
label: "Control",
|
|
396
|
+
surface: "web",
|
|
397
|
+
mode,
|
|
398
|
+
createdAt: new Date().toISOString(),
|
|
399
|
+
description: "Original content",
|
|
400
|
+
changes: [],
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
id: "v_b",
|
|
404
|
+
key: "variant_b",
|
|
405
|
+
label: "Variant B",
|
|
406
|
+
surface: "web",
|
|
407
|
+
mode,
|
|
408
|
+
createdAt: new Date().toISOString(),
|
|
208
409
|
description: "Modified content",
|
|
209
410
|
changes: [
|
|
210
411
|
{
|
|
@@ -215,7 +416,7 @@ export const toolDefinitions = {
|
|
|
215
416
|
},
|
|
216
417
|
],
|
|
217
418
|
},
|
|
218
|
-
|
|
419
|
+
],
|
|
219
420
|
});
|
|
220
421
|
let previewUrl = "";
|
|
221
422
|
try {
|
|
@@ -234,9 +435,14 @@ export const toolDefinitions = {
|
|
|
234
435
|
name: exp.name,
|
|
235
436
|
status: exp.status,
|
|
236
437
|
mode,
|
|
438
|
+
primaryMetric: primaryMetric.key,
|
|
439
|
+
beliefId: beliefId || null,
|
|
440
|
+
hypothesisId: hypothesisId || null,
|
|
441
|
+
predictionId: args.predictionId || null,
|
|
237
442
|
sdk: mode === "sdk" ? {
|
|
238
443
|
hook: "useApexVariant",
|
|
239
|
-
import: "
|
|
444
|
+
import: "@apex-inc/react",
|
|
445
|
+
install: "npm i @apex-inc/react",
|
|
240
446
|
experimentId: exp.id,
|
|
241
447
|
targetComponent: args.targetComponent || null,
|
|
242
448
|
targetAnchor: args.targetAnchor || null,
|
|
@@ -249,14 +455,15 @@ export const toolDefinitions = {
|
|
|
249
455
|
? [
|
|
250
456
|
"IMPLEMENT THE EXPERIMENT IN CODE:",
|
|
251
457
|
`1. Open the target file${args.targetComponent ? ` (${args.targetComponent})` : ""}`,
|
|
252
|
-
`2.
|
|
253
|
-
`3.
|
|
254
|
-
`4.
|
|
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:`,
|
|
255
462
|
` {variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
|
|
256
|
-
`
|
|
257
|
-
`
|
|
258
|
-
`
|
|
259
|
-
`
|
|
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`,
|
|
260
467
|
].join("\n")
|
|
261
468
|
: [
|
|
262
469
|
"SNIPPET MODE — no code changes needed.",
|
|
@@ -268,16 +475,207 @@ export const toolDefinitions = {
|
|
|
268
475
|
return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
|
|
269
476
|
},
|
|
270
477
|
},
|
|
478
|
+
attach_experiment_asset: {
|
|
479
|
+
description: `${APEX} — Attach a variant screenshot to an experiment. Use after capturing a control/variant screenshot: for localhost or auth-gated pages the agent captures, pass imageBase64; for an already-hosted image, pass url. The stored asset shows on the dashboard experiment card and the experiment detail gallery.`,
|
|
480
|
+
schema: z.object({
|
|
481
|
+
experimentId: z.string().describe("The experiment ID to attach the screenshot to"),
|
|
482
|
+
variantKey: z.string().describe("control | variant_b | …"),
|
|
483
|
+
imageBase64: z.string().optional().describe("Base64-encoded PNG/JPEG. For localhost/auth-gated pages captured by the agent."),
|
|
484
|
+
url: z.string().url().optional().describe("Already-hosted public image URL."),
|
|
485
|
+
viewport: z.enum(["desktop", "tablet", "mobile"]).optional().describe("Viewport the screenshot was captured at (default desktop)."),
|
|
486
|
+
format: z.enum(["png", "jpeg"]).optional().describe("Image format (default png)."),
|
|
487
|
+
label: z.string().optional().describe("Optional caption for the asset."),
|
|
488
|
+
commitSha: z.string().optional().describe("Optional commit SHA the screenshot corresponds to."),
|
|
489
|
+
}),
|
|
490
|
+
handler: async (args) => {
|
|
491
|
+
if (!args.imageBase64 && !args.url) {
|
|
492
|
+
return {
|
|
493
|
+
content: [{
|
|
494
|
+
type: "text",
|
|
495
|
+
text: "Provide either imageBase64 (for localhost/auth-gated captures) or url (an already-hosted image).",
|
|
496
|
+
}],
|
|
497
|
+
isError: true,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const res = await apiPost(`/api/experiments/${encodeURIComponent(args.experimentId)}/assets`, {
|
|
501
|
+
variantKey: args.variantKey,
|
|
502
|
+
source: "agent",
|
|
503
|
+
viewport: args.viewport ?? "desktop",
|
|
504
|
+
imageBase64: args.imageBase64,
|
|
505
|
+
url: args.url,
|
|
506
|
+
format: args.format ?? "png",
|
|
507
|
+
label: args.label,
|
|
508
|
+
commitSha: args.commitSha,
|
|
509
|
+
});
|
|
510
|
+
const asset = res.data;
|
|
511
|
+
return {
|
|
512
|
+
content: [{
|
|
513
|
+
type: "text",
|
|
514
|
+
text: [
|
|
515
|
+
`${APEX} Screenshot attached to experiment ${args.experimentId}`,
|
|
516
|
+
``,
|
|
517
|
+
` Variant: ${asset.variantKey}`,
|
|
518
|
+
` Viewport: ${asset.viewport}`,
|
|
519
|
+
` Stored at: ${asset.url}`,
|
|
520
|
+
``,
|
|
521
|
+
`It now shows on the dashboard experiment card and the experiment detail gallery.`,
|
|
522
|
+
].join("\n"),
|
|
523
|
+
}],
|
|
524
|
+
};
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
verify_experiment_wiring: {
|
|
528
|
+
description: `${APEX} — Verify an experiment is actually wired up before launch: check whether \`experiment_exposure\` events are arriving for BOTH arms. Use this before activate_experiment to avoid launching an experiment that silently collects no data. Returns a per-arm exposure breakdown and an actionable next step.`,
|
|
529
|
+
schema: z.object({
|
|
530
|
+
experimentId: z.string().describe("The experiment ID to verify wiring for"),
|
|
531
|
+
}),
|
|
532
|
+
handler: async ({ experimentId }) => {
|
|
533
|
+
const wiring = (await apiGet(`/api/experiments/${experimentId}/wiring`)).data;
|
|
534
|
+
const armLines = wiring.perArm.map(formatArmLine);
|
|
535
|
+
const zeroArms = wiring.perArm.filter((a) => a.exposures === 0);
|
|
536
|
+
let verdictLine;
|
|
537
|
+
let nextStep;
|
|
538
|
+
switch (wiring.verdict) {
|
|
539
|
+
case "wired":
|
|
540
|
+
verdictLine = `WIRED — all ${wiring.totalArms} arm(s) are producing exposures`;
|
|
541
|
+
nextStep =
|
|
542
|
+
"Safe to activate. Call activate_experiment to launch — both arms are " +
|
|
543
|
+
"already receiving traffic and recording exposures.";
|
|
544
|
+
break;
|
|
545
|
+
case "partial": {
|
|
546
|
+
const names = zeroArms
|
|
547
|
+
.map((a) => `${a.label} (${a.variantKey})`)
|
|
548
|
+
.join(", ");
|
|
549
|
+
verdictLine = `PARTIAL — ${wiring.armsLive}/${wiring.totalArms} arm(s) live`;
|
|
550
|
+
nextStep =
|
|
551
|
+
`These arm(s) have ZERO exposures: ${names}. ` +
|
|
552
|
+
"Make sure the variant-serving code returns every variant key (not just the " +
|
|
553
|
+
"control) and that traffic is reaching each branch, then re-check. Don't " +
|
|
554
|
+
"activate until every arm is producing exposures.";
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case "no_exposures":
|
|
558
|
+
default:
|
|
559
|
+
verdictLine = "NO EXPOSURES — no exposures recorded for any arm";
|
|
560
|
+
nextStep =
|
|
561
|
+
"No `experiment_exposure` events are arriving. The variant-serving code " +
|
|
562
|
+
"(useApexVariant / Apex.getVariant) probably isn't deployed or running yet, " +
|
|
563
|
+
"so nothing is being assigned. Deploy the experiment code and confirm it runs " +
|
|
564
|
+
"on the target surface, then re-run verify_experiment_wiring.";
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
const summary = [
|
|
568
|
+
`${APEX} Experiment Wiring Check`,
|
|
569
|
+
`${"═".repeat(40)}`,
|
|
570
|
+
``,
|
|
571
|
+
` Experiment: ${wiring.experimentId}`,
|
|
572
|
+
` Surface: ${wiring.surface}`,
|
|
573
|
+
` Status: ${wiring.status}`,
|
|
574
|
+
` Verdict: ${verdictLine}`,
|
|
575
|
+
` Total exposures: ${wiring.totalExposures}`,
|
|
576
|
+
``,
|
|
577
|
+
` Per-arm exposures:`,
|
|
578
|
+
...armLines,
|
|
579
|
+
``,
|
|
580
|
+
` Next step: ${nextStep}`,
|
|
581
|
+
``,
|
|
582
|
+
`${"═".repeat(40)}`,
|
|
583
|
+
].join("\n");
|
|
584
|
+
return {
|
|
585
|
+
content: [
|
|
586
|
+
{ type: "text", text: summary },
|
|
587
|
+
{
|
|
588
|
+
type: "text",
|
|
589
|
+
text: JSON.stringify({ _apex: true, _type: "wiring_check", ...wiring }, null, 2),
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
};
|
|
593
|
+
},
|
|
594
|
+
},
|
|
271
595
|
activate_experiment: {
|
|
272
|
-
description: `${APEX} — Launch an experiment. Changes its status from draft to running. Traffic will be split immediately. Ask the user to confirm before calling.`,
|
|
596
|
+
description: `${APEX} — Launch an experiment. Changes its status from draft to running. Traffic will be split immediately. Before flipping to running, this verifies BOTH arms are producing \`experiment_exposure\` events (wiring check) and refuses to launch an unwired experiment unless force is true. Ask the user to confirm before calling.`,
|
|
273
597
|
schema: z.object({
|
|
274
598
|
experimentId: z.string().describe("The experiment ID to activate"),
|
|
599
|
+
force: z
|
|
600
|
+
.boolean()
|
|
601
|
+
.optional()
|
|
602
|
+
.describe("Override the wiring safety check and activate even if both arms aren't producing exposures yet. Use only when you knowingly accept launching without verified wiring."),
|
|
275
603
|
}),
|
|
276
|
-
handler: async ({ experimentId }) => {
|
|
604
|
+
handler: async ({ experimentId, force }) => {
|
|
605
|
+
// Best-effort wiring gate: don't launch an experiment that silently
|
|
606
|
+
// collects no data. If the wiring endpoint itself errors, don't hard-block.
|
|
607
|
+
let wiring;
|
|
608
|
+
let wiringError;
|
|
609
|
+
try {
|
|
610
|
+
wiring = (await apiGet(`/api/experiments/${experimentId}/wiring`)).data;
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
wiringError = err instanceof Error ? err.message : String(err);
|
|
614
|
+
}
|
|
615
|
+
if (wiring && !wiring.bothArmsLive && force !== true) {
|
|
616
|
+
const armLines = wiring.perArm.map(formatArmLine);
|
|
617
|
+
const zeroArms = wiring.perArm
|
|
618
|
+
.filter((a) => a.exposures === 0)
|
|
619
|
+
.map((a) => `${a.label} (${a.variantKey})`);
|
|
620
|
+
const refusal = [
|
|
621
|
+
`${APEX} Activation Refused — experiment is not wired up`,
|
|
622
|
+
`${"═".repeat(40)}`,
|
|
623
|
+
``,
|
|
624
|
+
` Did NOT launch ${experimentId}.`,
|
|
625
|
+
``,
|
|
626
|
+
` Both arms must be producing \`experiment_exposure\` events before`,
|
|
627
|
+
` launch. Otherwise you'd start an experiment that silently collects`,
|
|
628
|
+
` no data on one or more arms.`,
|
|
629
|
+
``,
|
|
630
|
+
` Verdict: ${wiring.verdict} (${wiring.armsLive}/${wiring.totalArms} arm(s) live)`,
|
|
631
|
+
` Arm(s) with zero exposures: ${zeroArms.length ? zeroArms.join(", ") : "none"}`,
|
|
632
|
+
``,
|
|
633
|
+
` Per-arm exposures:`,
|
|
634
|
+
...armLines,
|
|
635
|
+
``,
|
|
636
|
+
` To fix: wire up and deploy the variant-serving code`,
|
|
637
|
+
` (useApexVariant / Apex.getVariant) so every arm records exposures,`,
|
|
638
|
+
` then re-run activate_experiment. Or, to override this check and`,
|
|
639
|
+
` launch anyway, re-run with force: true.`,
|
|
640
|
+
``,
|
|
641
|
+
`${"═".repeat(40)}`,
|
|
642
|
+
].join("\n");
|
|
643
|
+
return {
|
|
644
|
+
content: [
|
|
645
|
+
{ type: "text", text: refusal },
|
|
646
|
+
{
|
|
647
|
+
type: "text",
|
|
648
|
+
text: JSON.stringify({
|
|
649
|
+
_apex: true,
|
|
650
|
+
_type: "activation_refused",
|
|
651
|
+
reason: "not_wired",
|
|
652
|
+
...wiring,
|
|
653
|
+
}, null, 2),
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
};
|
|
657
|
+
}
|
|
277
658
|
const exp = await apiPatch("/api/experiments", {
|
|
278
659
|
id: experimentId,
|
|
279
660
|
status: "running",
|
|
280
661
|
});
|
|
662
|
+
// PATCH returns a minimal/legacy body — re-read the unified record to
|
|
663
|
+
// derive the live traffic split from the allocation weights.
|
|
664
|
+
let controlPct = 50;
|
|
665
|
+
try {
|
|
666
|
+
const unified = await apiGet(`/api/experiments/${experimentId}?unified=true`);
|
|
667
|
+
controlPct = controlPctOf(unified);
|
|
668
|
+
}
|
|
669
|
+
catch { /* fall back to 50/50 in the message */ }
|
|
670
|
+
// Surface why the wiring gate didn't block: either it couldn't be
|
|
671
|
+
// verified (best-effort) or the user explicitly forced past it.
|
|
672
|
+
const notes = [];
|
|
673
|
+
if (wiringError) {
|
|
674
|
+
notes.push(` ⚠ Wiring could not be verified before launch (${wiringError}).`, ` Confirm both arms are recording exposures via verify_experiment_wiring.`, ``);
|
|
675
|
+
}
|
|
676
|
+
else if (wiring && !wiring.bothArmsLive && force === true) {
|
|
677
|
+
notes.push(` ⚠ Launched with force: true despite an incomplete wiring check`, ` (verdict: ${wiring.verdict}, ${wiring.armsLive}/${wiring.totalArms} arm(s) live).`, ` Some arm(s) may not be collecting data yet.`, ``);
|
|
678
|
+
}
|
|
281
679
|
return {
|
|
282
680
|
content: [
|
|
283
681
|
{
|
|
@@ -288,8 +686,9 @@ export const toolDefinitions = {
|
|
|
288
686
|
``,
|
|
289
687
|
` "${exp.name}" is now LIVE`,
|
|
290
688
|
` ID: ${exp.id}`,
|
|
291
|
-
` Traffic is being split ${
|
|
689
|
+
` Traffic is being split ${controlPct}% / ${100 - controlPct}%`,
|
|
292
690
|
``,
|
|
691
|
+
...notes,
|
|
293
692
|
` Visitors will now see either the control or variant.`,
|
|
294
693
|
` Results will appear in the dashboard and via get_results.`,
|
|
295
694
|
``,
|
|
@@ -364,7 +763,7 @@ export const toolDefinitions = {
|
|
|
364
763
|
experimentId: z.string().describe("The experiment ID to check"),
|
|
365
764
|
}),
|
|
366
765
|
handler: async ({ experimentId }) => {
|
|
367
|
-
const exp = await apiGet(`/api/experiments/${experimentId}`);
|
|
766
|
+
const exp = await apiGet(`/api/experiments/${experimentId}?unified=true`);
|
|
368
767
|
if (!exp) {
|
|
369
768
|
return { content: [{ type: "text", text: JSON.stringify({ _apex: true, error: "Experiment not found" }) }] };
|
|
370
769
|
}
|
|
@@ -437,7 +836,7 @@ export const toolDefinitions = {
|
|
|
437
836
|
.describe("Filter by status. Defaults to showing all except archived."),
|
|
438
837
|
}),
|
|
439
838
|
handler: async ({ status }) => {
|
|
440
|
-
const experiments = await apiGet("/api/experiments");
|
|
839
|
+
const experiments = await apiGet("/api/experiments?unified=true");
|
|
441
840
|
const filtered = status === "all"
|
|
442
841
|
? experiments
|
|
443
842
|
: status
|
|
@@ -446,26 +845,27 @@ export const toolDefinitions = {
|
|
|
446
845
|
if (filtered.length === 0) {
|
|
447
846
|
return { content: [{ type: "text", text: `${APEX} No experiments found.` }] };
|
|
448
847
|
}
|
|
449
|
-
const pendingGraduation = filtered.filter((e) => e.promoted && !e.graduated
|
|
848
|
+
const pendingGraduation = filtered.filter((e) => e.promoted && !e.graduated);
|
|
450
849
|
const lines = filtered.map((e) => {
|
|
451
|
-
const ctrl = e.results?.control;
|
|
452
|
-
const varB = e.results?.variant_b;
|
|
453
|
-
const isPendingGrad = e.promoted && !e.graduated
|
|
850
|
+
const ctrl = e.results?.perVariant?.control;
|
|
851
|
+
const varB = e.results?.perVariant?.variant_b;
|
|
852
|
+
const isPendingGrad = !!(e.promoted && !e.graduated);
|
|
454
853
|
const statusIcon = isPendingGrad
|
|
455
854
|
? "⬆"
|
|
456
855
|
: e.status === "running" ? "●" : e.status === "draft" ? "○" : e.status === "paused" ? "◐" : "◉";
|
|
457
856
|
const statusLabel = isPendingGrad
|
|
458
857
|
? `${e.status}] [PENDING GRADUATION`
|
|
459
858
|
: e.status;
|
|
460
|
-
const
|
|
461
|
-
|
|
859
|
+
const winnerVariant = e.winner ? findVariant(e, e.winner) : undefined;
|
|
860
|
+
const changeSummary = isPendingGrad && winnerVariant?.changes?.length
|
|
861
|
+
? ` Changes to graduate: ${winnerVariant.changes.map((c) => `"${c.originalValue ?? c.value}" → "${c.value}"`).join(", ")}`
|
|
462
862
|
: "";
|
|
463
863
|
return [
|
|
464
864
|
`${statusIcon} ${e.name} [${statusLabel}]`,
|
|
465
865
|
` ID: ${e.id}`,
|
|
466
|
-
` URL: ${e.targetUrl} | ${e
|
|
866
|
+
` URL: ${e.targetUrl ?? "—"} | ${daysRunningOf(e)}d running`,
|
|
467
867
|
ctrl && varB
|
|
468
|
-
? ` Control: ${ctrl.conversionRate}% CVR (${ctrl.
|
|
868
|
+
? ` Control: ${ctrl.conversionRate ?? 0}% CVR (${ctrl.assigned} visitors) | Variant: ${varB.conversionRate ?? 0}% CVR (${varB.assigned} visitors)`
|
|
469
869
|
: " No results yet",
|
|
470
870
|
e.beliefId ? ` Belief: ${e.beliefId}` : "",
|
|
471
871
|
changeSummary,
|
|
@@ -486,12 +886,12 @@ export const toolDefinitions = {
|
|
|
486
886
|
experimentId: z.string().describe("The experiment ID"),
|
|
487
887
|
}),
|
|
488
888
|
handler: async ({ experimentId }) => {
|
|
489
|
-
const exp = await apiGet(`/api/experiments/${experimentId}`);
|
|
889
|
+
const exp = await apiGet(`/api/experiments/${experimentId}?unified=true`);
|
|
490
890
|
if (!exp) {
|
|
491
891
|
return { content: [{ type: "text", text: `${APEX} Experiment ${experimentId} not found.` }] };
|
|
492
892
|
}
|
|
493
|
-
const ctrl = exp.results?.control;
|
|
494
|
-
const varB = exp.results?.variant_b;
|
|
893
|
+
const ctrl = exp.results?.perVariant?.control;
|
|
894
|
+
const varB = exp.results?.perVariant?.variant_b;
|
|
495
895
|
return {
|
|
496
896
|
content: [
|
|
497
897
|
{
|
|
@@ -501,17 +901,17 @@ export const toolDefinitions = {
|
|
|
501
901
|
`${"═".repeat(40)}`,
|
|
502
902
|
``,
|
|
503
903
|
` Status: ${exp.status}`,
|
|
504
|
-
` Running: ${exp
|
|
904
|
+
` Running: ${daysRunningOf(exp)} days`,
|
|
505
905
|
` Confidence: ${exp.confidence ?? 0}%`,
|
|
506
|
-
` URL: ${exp.targetUrl}`,
|
|
906
|
+
` URL: ${exp.targetUrl ?? "—"}`,
|
|
507
907
|
``,
|
|
508
908
|
` Control:`,
|
|
509
909
|
ctrl
|
|
510
|
-
? ` ${ctrl.
|
|
910
|
+
? ` ${ctrl.assigned} visitors | ${ctrl.converted} conversions | ${ctrl.conversionRate ?? 0}% CVR`
|
|
511
911
|
: " No data yet",
|
|
512
912
|
` Variant B:`,
|
|
513
913
|
varB
|
|
514
|
-
? ` ${varB.
|
|
914
|
+
? ` ${varB.assigned} visitors | ${varB.converted} conversions | ${varB.conversionRate ?? 0}% CVR`
|
|
515
915
|
: " No data yet",
|
|
516
916
|
exp.beliefId ? `\n Linked belief: ${exp.beliefId}` : "",
|
|
517
917
|
``,
|
|
@@ -541,7 +941,7 @@ export const toolDefinitions = {
|
|
|
541
941
|
handler: async ({ context }) => {
|
|
542
942
|
const [beliefs, experiments] = await Promise.all([
|
|
543
943
|
apiGet("/api/beliefs"),
|
|
544
|
-
apiGet("/api/experiments"),
|
|
944
|
+
apiGet("/api/experiments?unified=true"),
|
|
545
945
|
]);
|
|
546
946
|
const lowConfidence = beliefs.filter((b) => b.confidence < 0.6 && b.confidence > 0.05);
|
|
547
947
|
const untested = beliefs.filter((b) => b.confidence > 0.05 && !experiments.some((e) => e.beliefId === b.id));
|
|
@@ -574,10 +974,12 @@ export const toolDefinitions = {
|
|
|
574
974
|
}
|
|
575
975
|
if (relevantExperiments.length > 0) {
|
|
576
976
|
recs.push(`**Past experiments in this area** (${relevantExperiments.length}):`, ...relevantExperiments.slice(0, 3).map((e) => {
|
|
577
|
-
const ctrl = e.results?.control;
|
|
578
|
-
const varB = e.results?.variant_b;
|
|
579
|
-
const
|
|
580
|
-
|
|
977
|
+
const ctrl = e.results?.perVariant?.control;
|
|
978
|
+
const varB = e.results?.perVariant?.variant_b;
|
|
979
|
+
const ctrlCvr = ctrl?.conversionRate ?? 0;
|
|
980
|
+
const varCvr = varB?.conversionRate ?? 0;
|
|
981
|
+
const lift = ctrl && varB && ctrlCvr > 0
|
|
982
|
+
? ((varCvr - ctrlCvr) / ctrlCvr * 100).toFixed(1)
|
|
581
983
|
: "N/A";
|
|
582
984
|
return ` - "${e.name}" — ${lift}% lift, ${e.confidence}% confidence`;
|
|
583
985
|
}), "");
|
|
@@ -605,7 +1007,7 @@ export const toolDefinitions = {
|
|
|
605
1007
|
winner: z.enum(["control", "variant_b"]).describe("Which variant won"),
|
|
606
1008
|
}),
|
|
607
1009
|
handler: async ({ experimentId, winner }) => {
|
|
608
|
-
const existing = await apiGet(`/api/experiments/${experimentId}`);
|
|
1010
|
+
const existing = await apiGet(`/api/experiments/${experimentId}?unified=true`);
|
|
609
1011
|
if (existing.status !== "completed" || !existing.winner) {
|
|
610
1012
|
await apiPatch(`/api/experiments`, {
|
|
611
1013
|
id: experimentId,
|
|
@@ -617,10 +1019,13 @@ export const toolDefinitions = {
|
|
|
617
1019
|
id: experimentId,
|
|
618
1020
|
action: "promote",
|
|
619
1021
|
});
|
|
1022
|
+
// PATCH responses are minimal — winner content / belief link / mode come
|
|
1023
|
+
// from the unified record fetched at the top.
|
|
1024
|
+
const beliefId = existing.beliefId;
|
|
620
1025
|
let beliefUpdate = "";
|
|
621
|
-
if (
|
|
1026
|
+
if (beliefId) {
|
|
622
1027
|
try {
|
|
623
|
-
const result = await apiPost(`/api/beliefs/${
|
|
1028
|
+
const result = await apiPost(`/api/beliefs/${beliefId}/update-confidence`, {
|
|
624
1029
|
experimentId,
|
|
625
1030
|
outcome: winner === "variant_b" ? "confirmed" : "contradicted",
|
|
626
1031
|
metricDelta: winner === "variant_b" ? 0.15 : -0.1,
|
|
@@ -631,11 +1036,11 @@ export const toolDefinitions = {
|
|
|
631
1036
|
beliefUpdate = "Could not update assumption certainty automatically.";
|
|
632
1037
|
}
|
|
633
1038
|
}
|
|
634
|
-
const winnerVariant =
|
|
1039
|
+
const winnerVariant = findVariant(existing, winner);
|
|
635
1040
|
const changeSummary = winnerVariant?.changes?.length
|
|
636
1041
|
? winnerVariant.changes.map((c) => ` • ${c.type}: "${c.originalValue ?? "(unknown)"}" → "${c.value}" (${c.selector})`).join("\n")
|
|
637
1042
|
: " No DOM changes recorded.";
|
|
638
|
-
const nextStep =
|
|
1043
|
+
const nextStep = winnerVariant?.mode === "sdk"
|
|
639
1044
|
? `Use graduate_experiment("${experimentId}") to get cleanup instructions for removing useApexVariant() conditionals from your code.`
|
|
640
1045
|
: `Use graduate_experiment("${experimentId}") to get instructions for making these changes permanent in your source code. The Apex snippet handles serving the winner in the meantime.`;
|
|
641
1046
|
const output = [
|
|
@@ -829,7 +1234,7 @@ export const toolDefinitions = {
|
|
|
829
1234
|
handler: async ({ feature, targetMetric }) => {
|
|
830
1235
|
const [beliefs, experiments] = await Promise.all([
|
|
831
1236
|
apiGet("/api/beliefs"),
|
|
832
|
-
apiGet("/api/experiments"),
|
|
1237
|
+
apiGet("/api/experiments?unified=true"),
|
|
833
1238
|
]);
|
|
834
1239
|
const keywords = feature
|
|
835
1240
|
.toLowerCase()
|
|
@@ -850,10 +1255,12 @@ export const toolDefinitions = {
|
|
|
850
1255
|
const lowConfRelevant = relevantBeliefs.filter((b) => b.confidence < 0.6);
|
|
851
1256
|
const avgLift = relevantExperiments.length > 0
|
|
852
1257
|
? relevantExperiments.reduce((sum, e) => {
|
|
853
|
-
const ctrl = e.results?.control;
|
|
854
|
-
const varB = e.results?.variant_b;
|
|
855
|
-
|
|
856
|
-
|
|
1258
|
+
const ctrl = e.results?.perVariant?.control;
|
|
1259
|
+
const varB = e.results?.perVariant?.variant_b;
|
|
1260
|
+
const ctrlCvr = ctrl?.conversionRate ?? 0;
|
|
1261
|
+
const varCvr = varB?.conversionRate ?? 0;
|
|
1262
|
+
if (ctrl && varB && ctrlCvr > 0) {
|
|
1263
|
+
return sum + ((varCvr - ctrlCvr) / ctrlCvr) * 100;
|
|
857
1264
|
}
|
|
858
1265
|
return sum;
|
|
859
1266
|
}, 0) / relevantExperiments.length
|
|
@@ -874,10 +1281,12 @@ export const toolDefinitions = {
|
|
|
874
1281
|
}
|
|
875
1282
|
if (relevantExperiments.length > 0) {
|
|
876
1283
|
lines.push(`**Historical evidence** (${relevantExperiments.length} related experiments):`, ...relevantExperiments.slice(0, 5).map((e) => {
|
|
877
|
-
const ctrl = e.results?.control;
|
|
878
|
-
const varB = e.results?.variant_b;
|
|
879
|
-
const
|
|
880
|
-
|
|
1284
|
+
const ctrl = e.results?.perVariant?.control;
|
|
1285
|
+
const varB = e.results?.perVariant?.variant_b;
|
|
1286
|
+
const ctrlCvr = ctrl?.conversionRate ?? 0;
|
|
1287
|
+
const varCvr = varB?.conversionRate ?? 0;
|
|
1288
|
+
const lift = ctrl && varB && ctrlCvr > 0
|
|
1289
|
+
? `${((varCvr - ctrlCvr) / ctrlCvr * 100).toFixed(1)}%`
|
|
881
1290
|
: "N/A";
|
|
882
1291
|
return ` - "${e.name}": ${lift} lift`;
|
|
883
1292
|
}), avgLift !== null ? ` Average lift in this area: ${avgLift > 0 ? "+" : ""}${avgLift.toFixed(1)}%` : "", "");
|
|
@@ -1152,44 +1561,19 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
1152
1561
|
};
|
|
1153
1562
|
},
|
|
1154
1563
|
},
|
|
1564
|
+
// `switch_workspace` is canonical; `switch_project` is the deprecated alias
|
|
1565
|
+
// kept for the project→workspace rename. Both point at the same handler/
|
|
1566
|
+
// schema so existing agent calls keep working. (Auth-experience fix
|
|
1567
|
+
// 2026-06-13; deprecation alias formalized 2026-06-14.)
|
|
1568
|
+
switch_workspace: {
|
|
1569
|
+
description: "Switch the active workspace for this session. All subsequent API calls will use this workspace. After switching, Apex verifies it can read the workspace and warns if authentication or the workspace key looks wrong.",
|
|
1570
|
+
schema: switchWorkspaceSchema,
|
|
1571
|
+
handler: switchWorkspaceHandler,
|
|
1572
|
+
},
|
|
1155
1573
|
switch_project: {
|
|
1156
|
-
description: "
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
// hard-rejecting `projectKey` broke existing agent calls and diverged
|
|
1160
|
-
// from the SDK, which still aliases it. (Auth-experience fix 2026-06-13.)
|
|
1161
|
-
schema: z.object({
|
|
1162
|
-
workspaceKey: z
|
|
1163
|
-
.string()
|
|
1164
|
-
.optional()
|
|
1165
|
-
.describe("The workspace key to switch to"),
|
|
1166
|
-
projectKey: z
|
|
1167
|
-
.string()
|
|
1168
|
-
.optional()
|
|
1169
|
-
.describe("Deprecated alias of workspaceKey. Use workspaceKey."),
|
|
1170
|
-
}),
|
|
1171
|
-
handler: async (args) => {
|
|
1172
|
-
const key = args.workspaceKey ?? args.projectKey;
|
|
1173
|
-
if (!key) {
|
|
1174
|
-
return {
|
|
1175
|
-
content: [{
|
|
1176
|
-
type: "text",
|
|
1177
|
-
text: "Provide a workspaceKey to switch to (projectKey is accepted as a deprecated alias).",
|
|
1178
|
-
}],
|
|
1179
|
-
isError: true,
|
|
1180
|
-
};
|
|
1181
|
-
}
|
|
1182
|
-
setActiveWorkspace(key);
|
|
1183
|
-
const deprecationNote = !args.workspaceKey && args.projectKey
|
|
1184
|
-
? " (note: `projectKey` is deprecated — use `workspaceKey`.)"
|
|
1185
|
-
: "";
|
|
1186
|
-
return {
|
|
1187
|
-
content: [{
|
|
1188
|
-
type: "text",
|
|
1189
|
-
text: `Switched to workspace: ${key}. All subsequent calls will use this workspace.${deprecationNote}`,
|
|
1190
|
-
}],
|
|
1191
|
-
};
|
|
1192
|
-
},
|
|
1574
|
+
description: "DEPRECATED alias of switch_workspace — use switch_workspace. Switches the active workspace for this session; all subsequent API calls use it.",
|
|
1575
|
+
schema: switchWorkspaceSchema,
|
|
1576
|
+
handler: switchWorkspaceHandler,
|
|
1193
1577
|
},
|
|
1194
1578
|
list_orgs: {
|
|
1195
1579
|
description: "List all organizations you have access to, with their workspaces. Use this to see which orgs and workspaces are available.",
|