@sentientui/mcp 0.2.2 → 0.3.1
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/AGENTS.md +21 -4
- package/README.md +7 -1
- package/dist/index.cjs +342 -8
- package/dist/index.js +342 -8
- package/package.json +8 -9
package/AGENTS.md
CHANGED
|
@@ -6,7 +6,12 @@ SentientUI is a UI personalization platform that assigns visitors to variant com
|
|
|
6
6
|
|
|
7
7
|
**Persona / Cluster** — visitors are clustered by behavior into labeled groups. A `reliability_score` (0–1) measures confidence in the assignment. Low reliability means the engine won't personalize for that session even if a cluster is assigned.
|
|
8
8
|
|
|
9
|
-
**Variant** — a version of an adaptive component.
|
|
9
|
+
**Variant** — a version of an adaptive component. There are **two kinds**, and the difference matters:
|
|
10
|
+
|
|
11
|
+
- **Code-native variants** are declared in the customer's code (`<Adaptive variants={{…}}>`). They **register automatically** the first time the SDK requests an assignment after deploy, and they go **live immediately** — there is no draft step and no `create_variant` call. Their content lives in the repo, not in SentientUI.
|
|
12
|
+
- **No-code / managed variants** have no code; their content is stored on the SentientUI API (dashboard WYSIWYG, `<AdaptiveText>`, or `create_variant`). These start as **draft** after `create_variant` and must be activated from the dashboard before traffic is assigned. There is no MCP tool to activate or resume variants.
|
|
13
|
+
|
|
14
|
+
Only call `create_variant` for the no-code path. If the user is writing (or you are writing) the variant in code, do **not** call `create_variant` — deploying the code registers it.
|
|
10
15
|
|
|
11
16
|
**Guardrail** — automatic safety circuit that pauses variants whose CVR drops below threshold. `list_guardrail_events` shows what's been paused. Pausing via `pause_variant` is irreversible via MCP (no resume tool).
|
|
12
17
|
|
|
@@ -34,7 +39,8 @@ Server keys start with `sk_` — required for the management API. Public keys (`
|
|
|
34
39
|
| `get_layout_stats` | Per-persona layout order + bandit pulls/reward |
|
|
35
40
|
| `get_insights` | Narrator bullets + (Growth) advisor bullets |
|
|
36
41
|
| `refresh_insights` | Async trigger — wait ~15s then call `get_insights` |
|
|
37
|
-
| `
|
|
42
|
+
| `get_variant_brief` | **Start here to optimize a component.** Insight-driven brief for writing a new CODE-NATIVE variant: performance, audience, insights, data-sufficiency + best-practice fallback, and code instructions |
|
|
43
|
+
| `create_variant` | Creates a no-code **managed text** DRAFT — user must activate in dashboard. Fallback only; not for code-native variants |
|
|
38
44
|
| `pause_variant` | Stops traffic; no MCP resume |
|
|
39
45
|
|
|
40
46
|
## Canonical Workflows
|
|
@@ -46,9 +52,18 @@ Server keys start with `sk_` — required for the management API. Public keys (`
|
|
|
46
52
|
4. `list_guardrail_events` — see if the guardrail fired
|
|
47
53
|
5. `refresh_insights` → wait 15s → `get_insights` — read AI summary
|
|
48
54
|
|
|
49
|
-
**
|
|
55
|
+
**Optimize a component with a new variant (code-native — recommended)**
|
|
56
|
+
1. `list_components` — confirm the component ID
|
|
57
|
+
2. `get_variant_brief` — pull performance, audience, insights, data-sufficiency, best-practice priors, and code instructions for that component
|
|
58
|
+
3. Find `<Adaptive id="...">` in the repo and add a new on-brand variant key to its `variants` map (and to the `AdaptiveRoot` `components` list if SSR). Match the project's existing components and design system.
|
|
59
|
+
4. Do **not** call `create_variant`. Commit, push, deploy — the variant auto-registers on the first assignment and goes live.
|
|
60
|
+
5. Optional: enable shadow mode for the component first to validate before serving.
|
|
61
|
+
|
|
62
|
+
Data sufficiency (from the brief) drives the change: when it reports **SUFFICIENT**, target the specific weakness; when **COLLECTING** or **EMPTY** (no data yet), apply the best-practice priors for the project's context type — use your best judgment and make one conservative change.
|
|
63
|
+
|
|
64
|
+
**Add a no-code managed text variant (fallback — text only, less powerful)**
|
|
50
65
|
1. `list_components` — confirm the component ID
|
|
51
|
-
2. `create_variant` — creates draft
|
|
66
|
+
2. `create_variant` with `content` — creates a managed text draft (generate the copy from `get_variant_brief` context)
|
|
52
67
|
3. Tell the user to activate it from the dashboard
|
|
53
68
|
|
|
54
69
|
**Understand the audience**
|
|
@@ -65,6 +80,8 @@ Server keys start with `sk_` — required for the management API. Public keys (`
|
|
|
65
80
|
|
|
66
81
|
- Presenting stale insights without checking `isStale` — always check, and offer to `refresh_insights`
|
|
67
82
|
- Assuming `create_variant` is live — it's always a draft
|
|
83
|
+
- Calling `create_variant` for a variant that's being written in code — it creates an empty managed draft, not your code variant. Code-native variants auto-register on deploy; only use `create_variant` for no-code/managed variants whose content lives in SentientUI
|
|
84
|
+
- Optimizing a component without calling `get_variant_brief` first — it gives you the performance, audience, insights, and data-sufficiency fallback you need to write a variant that actually makes sense (and the right steps when there is no data yet)
|
|
68
85
|
- Calling `pause_variant` without warning the user it's irreversible via MCP
|
|
69
86
|
- Using a `pk_` key instead of `sk_` — it will be rejected
|
|
70
87
|
- Interpreting `avgReward` in layout stats as a conversion rate — it's a bandit reward signal, not CVR
|
package/README.md
CHANGED
|
@@ -82,9 +82,14 @@ A sandboxed demo token is provisioned automatically — 10 calls/month, no sign-
|
|
|
82
82
|
| `get_goal_funnel` | Goal hit counts and conversion rates per variant |
|
|
83
83
|
| `list_guardrail_events` | Variants auto-paused by the guardrail (last 24h) |
|
|
84
84
|
| `get_layout_stats` | Per-persona section layout rankings and reward weights |
|
|
85
|
-
| `
|
|
85
|
+
| `get_variant_brief` | Insight-driven brief for writing a new **code-native** variant: performance, audience, insights, data-sufficiency (with a best-practice fallback when there's no data yet), and step-by-step code instructions |
|
|
86
|
+
| `create_variant` | Create a no-code (managed) text variant (Starter+) — fallback for text-only variants without a code change |
|
|
86
87
|
| `pause_variant` | Pause a variant to stop traffic assignment |
|
|
87
88
|
|
|
89
|
+
> **Code-native vs no-code variants.** Variants you declare in your app (`<Adaptive variants={{…}}>`) register automatically the first time the SDK requests an assignment after you deploy — they go live immediately and need **no** `create_variant` call. Use `create_variant` only for **no-code** variants whose content is stored in SentientUI and rendered without a code change; these start as drafts you activate from the dashboard.
|
|
90
|
+
>
|
|
91
|
+
> **Optimizing a component?** Call `get_variant_brief` first. It returns the component's performance, audience, insights, and a data-sufficiency assessment — then your AI assistant writes a new on-brand variant directly into your code (which auto-registers on deploy). When there's no data yet, the brief falls back to best-practice priors for your project's context type so the assistant still makes a sensible change.
|
|
92
|
+
|
|
88
93
|
## Example prompts
|
|
89
94
|
|
|
90
95
|
- *"What projects do I have?"*
|
|
@@ -92,6 +97,7 @@ A sandboxed demo token is provisioned automatically — 10 calls/month, no sign-
|
|
|
92
97
|
- *"Are any variants paused by the guardrail right now?"*
|
|
93
98
|
- *"What do the AI insights say about what changed this week?"*
|
|
94
99
|
- *"Refresh insights for project X"*
|
|
100
|
+
- *"Optimize my hero CTA — pull the brief and add a new variant in the code"*
|
|
95
101
|
- *"Create a new variant called 'short-copy' for the pricing CTA"*
|
|
96
102
|
|
|
97
103
|
## Configuration
|
package/dist/index.cjs
CHANGED
|
@@ -175,9 +175,10 @@ function registerPersonaTools(server, client) {
|
|
|
175
175
|
`Total sessions: ${data.totalSessions}`,
|
|
176
176
|
"",
|
|
177
177
|
"Clusters:",
|
|
178
|
-
...data.clusters.map(
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
...data.clusters.map((c) => {
|
|
179
|
+
const pct = data.totalSessions > 0 ? c.sessionCount / data.totalSessions * 100 : 0;
|
|
180
|
+
return `- ${c.label}: ${c.sessionCount} sessions (${pct.toFixed(1)}% of traffic, reliability ${(c.avgReliability * 100).toFixed(0)}%)`;
|
|
181
|
+
})
|
|
181
182
|
];
|
|
182
183
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
183
184
|
}
|
|
@@ -258,22 +259,24 @@ var projectIdSchema8 = import_zod8.z.string().uuid().describe("The project UUID"
|
|
|
258
259
|
function registerVariantWriteTools(server, client) {
|
|
259
260
|
server.tool(
|
|
260
261
|
"create_variant",
|
|
261
|
-
"Create a
|
|
262
|
+
"Create a NO-CODE managed text variant for a component (content stored in SentientUI, rendered by <AdaptiveText>). Use this ONLY for text-only variants the user wants without a code change. For variants that will live in the codebase (full components \u2014 copy, markup, styling), use get_variant_brief and write the variant in code instead; those auto-register on deploy and do not need create_variant. Requires Starter or Growth plan.",
|
|
262
263
|
{
|
|
263
264
|
projectId: projectIdSchema8,
|
|
264
265
|
componentId: import_zod8.z.string().describe("The component ID to add a variant to"),
|
|
265
|
-
displayName: import_zod8.z.string().describe("Human-readable name for the new variant")
|
|
266
|
+
displayName: import_zod8.z.string().describe("Human-readable name for the new variant"),
|
|
267
|
+
content: import_zod8.z.string().optional().describe("The text content for this managed variant (rendered by <AdaptiveText>). Generate it from get_variant_brief context; omit only to create an empty placeholder to fill in from the dashboard.")
|
|
266
268
|
},
|
|
267
|
-
async ({ projectId, componentId, displayName }) => {
|
|
269
|
+
async ({ projectId, componentId, displayName, content }) => {
|
|
268
270
|
const id = encodeURIComponent(projectId);
|
|
269
271
|
const result = await client.post(
|
|
270
272
|
`/projects/${id}/variants`,
|
|
271
|
-
{ componentId, displayName }
|
|
273
|
+
{ componentId, displayName, content }
|
|
272
274
|
);
|
|
275
|
+
const contentNote = content ? " with the provided text content" : " (empty \u2014 add its text content from the dashboard or via a follow-up update)";
|
|
273
276
|
return {
|
|
274
277
|
content: [{
|
|
275
278
|
type: "text",
|
|
276
|
-
text: `
|
|
279
|
+
text: `Managed text variant created: ${result.variantId} ("${result.displayName}") for component ${componentId}${contentNote}. It is in draft state \u2014 activate it from the dashboard. Reminder: this is a no-code managed variant; for code-native variants, edit the code instead (see get_variant_brief).`
|
|
277
280
|
}]
|
|
278
281
|
};
|
|
279
282
|
}
|
|
@@ -314,6 +317,334 @@ function registerVariantWriteTools(server, client) {
|
|
|
314
317
|
);
|
|
315
318
|
}
|
|
316
319
|
|
|
320
|
+
// src/tools/variant-brief.ts
|
|
321
|
+
var import_zod9 = require("zod");
|
|
322
|
+
var projectIdSchema9 = import_zod9.z.string().uuid().describe("The project UUID");
|
|
323
|
+
var GOAL_TARGET = 500;
|
|
324
|
+
var BEST_PRACTICE_PRIORS = {
|
|
325
|
+
ecommerce: [
|
|
326
|
+
"Lead with the core benefit/value, not features.",
|
|
327
|
+
"Make the primary action (add to cart / buy) unmistakable and high-contrast.",
|
|
328
|
+
"Reduce purchase anxiety near the decision: free returns, shipping, secure checkout, guarantees.",
|
|
329
|
+
'Add credible social proof (ratings, review count, "X sold").',
|
|
330
|
+
"Use urgency/scarcity only when it is genuinely true (low stock, real deadline).",
|
|
331
|
+
"Cut friction: fewer steps, clearer pricing, no surprise costs."
|
|
332
|
+
],
|
|
333
|
+
saas: [
|
|
334
|
+
"Lead with the outcome the user gets, not the mechanism.",
|
|
335
|
+
'Make the primary CTA action-oriented and specific (e.g. "Start free trial").',
|
|
336
|
+
'Reduce signup friction (fewer fields, SSO, "no credit card required").',
|
|
337
|
+
"Add proof near the CTA: customer logos, a hard metric, a short testimonial.",
|
|
338
|
+
"Address the target persona's top objection inline."
|
|
339
|
+
],
|
|
340
|
+
landing: [
|
|
341
|
+
"One clear message and one primary action above the fold.",
|
|
342
|
+
"Match the headline to the traffic source / campaign intent.",
|
|
343
|
+
"Make the CTA specific and benefit-led.",
|
|
344
|
+
"Add a single strong proof point; remove competing distractions."
|
|
345
|
+
],
|
|
346
|
+
marketplace: [
|
|
347
|
+
"Reduce choice overload: guide the visitor to a clear next step.",
|
|
348
|
+
"Surface trust and liquidity signals (ratings, counts, recency).",
|
|
349
|
+
"Make the primary action on each listing obvious.",
|
|
350
|
+
"Reassure on safety/guarantees near the point of decision."
|
|
351
|
+
]
|
|
352
|
+
};
|
|
353
|
+
var GENERIC_PRIORS = [
|
|
354
|
+
"Make the primary action unmistakable and benefit-led.",
|
|
355
|
+
"Lead with the outcome/value for the visitor.",
|
|
356
|
+
"Remove friction and distractions around the decision.",
|
|
357
|
+
"Add one credible proof point near the action."
|
|
358
|
+
];
|
|
359
|
+
function priorsFor(contextType) {
|
|
360
|
+
return BEST_PRACTICE_PRIORS[contextType] ?? GENERIC_PRIORS;
|
|
361
|
+
}
|
|
362
|
+
function computeDataState(impressions, insights, avgReliability) {
|
|
363
|
+
if (impressions === 0) return "empty";
|
|
364
|
+
const insightsReady = insights?.status === "ok" && !insights.isStale;
|
|
365
|
+
const reliable = avgReliability === null || avgReliability >= 0.3;
|
|
366
|
+
if (impressions < GOAL_TARGET || !insightsReady || !reliable) return "collecting";
|
|
367
|
+
return "sufficient";
|
|
368
|
+
}
|
|
369
|
+
function guidanceFor(dataState, contextType) {
|
|
370
|
+
switch (dataState) {
|
|
371
|
+
case "sufficient":
|
|
372
|
+
return "There is enough data. Target the specific weakness above \u2014 the underperforming variant/persona \u2014 with your change. Set basis to the signal you used.";
|
|
373
|
+
case "collecting":
|
|
374
|
+
return "Limited data so far. Lean on the best-practice priors below, lightly informed by the early signal. Make one focused change rather than a redesign.";
|
|
375
|
+
case "empty":
|
|
376
|
+
return `No data yet \u2014 use your best judgment. Apply the best-practice priors for a ${contextType} surface below. Make one conservative, high-confidence change, and consider enabling shadow mode for this component so it is validated before it serves real traffic.`;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async function settled(p) {
|
|
380
|
+
try {
|
|
381
|
+
return await p;
|
|
382
|
+
} catch {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function registerVariantBriefTools(server, client) {
|
|
387
|
+
server.tool(
|
|
388
|
+
"get_variant_brief",
|
|
389
|
+
"Get an insight-driven brief for creating a new CODE-NATIVE variant of a component. Returns current variant performance, audience, insights, a data-sufficiency assessment (with a best-practice fallback when there is no data yet), and step-by-step instructions for writing the variant in the customer's code. Use this instead of create_variant when the variant will live in the codebase.",
|
|
390
|
+
{
|
|
391
|
+
projectId: projectIdSchema9,
|
|
392
|
+
componentId: import_zod9.z.string().describe('The component ID to write a new variant for (matches <Adaptive id="...">).')
|
|
393
|
+
},
|
|
394
|
+
async ({ projectId, componentId }) => {
|
|
395
|
+
const id = encodeURIComponent(projectId);
|
|
396
|
+
const [projects, components, trends, portraits, insights] = await Promise.all([
|
|
397
|
+
settled(client.get("/projects")),
|
|
398
|
+
settled(client.get(`/projects/${id}/components`)),
|
|
399
|
+
settled(client.get(`/projects/${id}/trends`)),
|
|
400
|
+
settled(client.get(`/projects/${id}/portraits`)),
|
|
401
|
+
settled(client.get(`/projects/${id}/insights`))
|
|
402
|
+
]);
|
|
403
|
+
const project = projects?.find((p) => p.id === projectId) ?? null;
|
|
404
|
+
const contextType = project?.context_type ?? "unknown";
|
|
405
|
+
const component = components?.find((c) => c.component_id === componentId) ?? null;
|
|
406
|
+
const impressions = component?.total_impressions ?? 0;
|
|
407
|
+
const conversions = component?.total_conversions ?? 0;
|
|
408
|
+
const componentCvr = impressions > 0 ? conversions / impressions * 100 : 0;
|
|
409
|
+
const existingVariantIds = component?.variants.map((v) => v.variant_id) ?? [];
|
|
410
|
+
const variantIdSet = new Set(existingVariantIds);
|
|
411
|
+
const momentumMap = new Map((trends?.momentum ?? []).map((m) => [m.variantId, m.direction]));
|
|
412
|
+
const variantPerf = (trends?.cvr ?? []).filter((v) => variantIdSet.has(v.variantId));
|
|
413
|
+
const clusters = portraits?.clusters ?? [];
|
|
414
|
+
const totalSessions = portraits?.totalSessions ?? 0;
|
|
415
|
+
const avgReliability = clusters.length ? clusters.reduce((s, c) => s + c.avgReliability, 0) / clusters.length : null;
|
|
416
|
+
const dataState = computeDataState(impressions, insights, avgReliability);
|
|
417
|
+
const lines = [];
|
|
418
|
+
lines.push(`# Variant brief \u2014 ${componentId}`);
|
|
419
|
+
lines.push(`Project context type: ${contextType}`);
|
|
420
|
+
lines.push("");
|
|
421
|
+
if (!component) {
|
|
422
|
+
lines.push(
|
|
423
|
+
`Note: no component named "${componentId}" has reported data yet. If this is a new <Adaptive> you are adding, that is expected \u2014 it registers automatically on first assignment after deploy. Proceed using the best-practice priors below.`
|
|
424
|
+
);
|
|
425
|
+
lines.push("");
|
|
426
|
+
} else {
|
|
427
|
+
lines.push(
|
|
428
|
+
`Component performance: ${impressions} impressions, ${conversions} conversions, ${componentCvr.toFixed(2)}% CVR.`
|
|
429
|
+
);
|
|
430
|
+
lines.push(
|
|
431
|
+
`Existing variant IDs (do not reuse these): ${existingVariantIds.length ? existingVariantIds.join(", ") : "(none)"}`
|
|
432
|
+
);
|
|
433
|
+
lines.push("");
|
|
434
|
+
}
|
|
435
|
+
if (variantPerf.length) {
|
|
436
|
+
lines.push("Current variant performance (7d vs prior 7d):");
|
|
437
|
+
for (const v of variantPerf) {
|
|
438
|
+
lines.push(
|
|
439
|
+
`- ${v.variantId}: ${(v.currentCvr * 100).toFixed(2)}% CVR (${v.deltaPp > 0 ? "+" : ""}${v.deltaPp.toFixed(1)} pp, ${momentumMap.get(v.variantId) ?? "stable"})`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
lines.push("");
|
|
443
|
+
}
|
|
444
|
+
if (clusters.length) {
|
|
445
|
+
lines.push(`Audience (${totalSessions} sessions):`);
|
|
446
|
+
for (const c of clusters) {
|
|
447
|
+
const share = totalSessions > 0 ? (c.sessionCount / totalSessions * 100).toFixed(0) : "0";
|
|
448
|
+
lines.push(`- ${c.label}: ${share}% of traffic (reliability ${(c.avgReliability * 100).toFixed(0)}%)`);
|
|
449
|
+
}
|
|
450
|
+
lines.push("");
|
|
451
|
+
}
|
|
452
|
+
if (insights && insights.status === "ok") {
|
|
453
|
+
if (insights.isStale) lines.push("\u26A0 Insights are stale (>6h). Consider refresh_insights for a fresher read.");
|
|
454
|
+
const narrator = insights.narratorBullets ?? [];
|
|
455
|
+
const advisor = insights.advisorBullets ?? [];
|
|
456
|
+
if (narrator.length) {
|
|
457
|
+
lines.push("Insights \u2014 observations:");
|
|
458
|
+
narrator.forEach((b) => lines.push(`- ${b}`));
|
|
459
|
+
}
|
|
460
|
+
if (advisor.length) {
|
|
461
|
+
lines.push("Insights \u2014 recommendations:");
|
|
462
|
+
advisor.forEach((b) => lines.push(`- ${b}`));
|
|
463
|
+
}
|
|
464
|
+
lines.push("");
|
|
465
|
+
} else {
|
|
466
|
+
lines.push("Insights: none generated yet.");
|
|
467
|
+
lines.push("");
|
|
468
|
+
}
|
|
469
|
+
lines.push(`## Data sufficiency: ${dataState.toUpperCase()}`);
|
|
470
|
+
lines.push(guidanceFor(dataState, contextType));
|
|
471
|
+
lines.push("");
|
|
472
|
+
lines.push(`## Best-practice priors (${contextType})`);
|
|
473
|
+
priorsFor(contextType).forEach((p) => lines.push(`- ${p}`));
|
|
474
|
+
lines.push("");
|
|
475
|
+
lines.push("## How to implement (code-native variant)");
|
|
476
|
+
lines.push(`1. Search the repository for <Adaptive id="${componentId}"> (and any matching useAssignment("${componentId}") usage).`);
|
|
477
|
+
lines.push('2. Add a new key to its `variants` map with on-brand JSX that matches the surrounding components and the project\'s design system. Pick a short, descriptive new variant id that is not in the existing list above (e.g. "value_led", "social_proof", "urgency").');
|
|
478
|
+
lines.push("3. If the component is server-rendered via <AdaptiveRoot>, add the new variant id to that component's entry in the `components` list so it is included in SSR preloading.");
|
|
479
|
+
lines.push("4. Do NOT call create_variant \u2014 that creates a separate no-code MANAGED draft. Code-native variants register automatically on the first assignment after you deploy, and go live immediately.");
|
|
480
|
+
lines.push("5. Commit, push, and deploy. Optionally enable shadow mode for this component first if you want to validate before serving real traffic.");
|
|
481
|
+
lines.push("");
|
|
482
|
+
lines.push("Make the change reflect the data sufficiency above: data-driven when SUFFICIENT, best-practice-led when COLLECTING or EMPTY.");
|
|
483
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
484
|
+
}
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/tools/test-brief.ts
|
|
489
|
+
var import_zod10 = require("zod");
|
|
490
|
+
var projectIdSchema10 = import_zod10.z.string().uuid().describe("The project UUID");
|
|
491
|
+
async function settled2(p) {
|
|
492
|
+
try {
|
|
493
|
+
return await p;
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function registerTestBriefTools(server, client) {
|
|
499
|
+
server.tool(
|
|
500
|
+
"get_test_brief",
|
|
501
|
+
"Get a ready-to-paste test for a SentientUI-wrapped component, populated with the component's real variants and goals. This project uses @sentientui/react/testing. Use this so your tests force a specific variant/layout deterministically and never break when the optimizer serves a different version. Returns a React Testing Library example plus the URL-param recipe for E2E (Playwright/Cypress).",
|
|
502
|
+
{
|
|
503
|
+
projectId: projectIdSchema10,
|
|
504
|
+
componentId: import_zod10.z.string().describe('The component ID to write a test for (matches <Adaptive id="...">).')
|
|
505
|
+
},
|
|
506
|
+
async ({ projectId, componentId }) => {
|
|
507
|
+
const id = encodeURIComponent(projectId);
|
|
508
|
+
const [components, goalsRes] = await Promise.all([
|
|
509
|
+
settled2(client.get(`/projects/${id}/components`)),
|
|
510
|
+
settled2(client.get(`/projects/${id}/goals`))
|
|
511
|
+
]);
|
|
512
|
+
const component = components?.find((c) => c.component_id === componentId) ?? null;
|
|
513
|
+
const variantIds = component?.variants.map((v) => v.variant_id) ?? [];
|
|
514
|
+
const goals = Array.isArray(goalsRes) ? goalsRes : goalsRes?.goals ?? [];
|
|
515
|
+
const goalName = goals[0]?.goalName ?? "signup";
|
|
516
|
+
const controlId = variantIds[0] ?? "control";
|
|
517
|
+
const forcedId = variantIds.find((v) => v !== controlId) ?? "variant_b";
|
|
518
|
+
const lines = [];
|
|
519
|
+
lines.push(`# Test brief \u2014 ${componentId}`);
|
|
520
|
+
lines.push("");
|
|
521
|
+
lines.push("This project uses **`@sentientui/react/testing`**. By default SentientUI serves the *control* variant and default layout in tests and sends no events, so existing tests are unaffected. Pass a scenario to force a specific variant/layout.");
|
|
522
|
+
if (!component) {
|
|
523
|
+
lines.push("");
|
|
524
|
+
lines.push(`Note: no component named "${componentId}" has reported data yet. If you are adding this \`<Adaptive>\`, that is expected \u2014 the example below uses placeholder variant IDs; replace them with the ones you declare in \`variants={{ \u2026 }}\`.`);
|
|
525
|
+
}
|
|
526
|
+
lines.push("");
|
|
527
|
+
lines.push("## React Testing Library");
|
|
528
|
+
lines.push("```tsx");
|
|
529
|
+
lines.push(`import { renderWithSentient } from '@sentientui/react/testing';`);
|
|
530
|
+
lines.push(`import { screen } from '@testing-library/react';`);
|
|
531
|
+
lines.push("");
|
|
532
|
+
lines.push(`test('${componentId}: forces the "${forcedId}" variant', () => {`);
|
|
533
|
+
lines.push(` renderWithSentient(<YourPage />, { variants: { ${componentId}: '${forcedId}' } });`);
|
|
534
|
+
lines.push(` // assert on the ${forcedId} variant's content:`);
|
|
535
|
+
lines.push(` // expect(screen.getByText('\u2026')).toBeInTheDocument();`);
|
|
536
|
+
lines.push("});");
|
|
537
|
+
lines.push("```");
|
|
538
|
+
lines.push("");
|
|
539
|
+
lines.push("## Assert a goal fires (with the mock server)");
|
|
540
|
+
lines.push("```tsx");
|
|
541
|
+
lines.push(`import { setupSentientServer } from '@sentientui/react/testing/node';`);
|
|
542
|
+
lines.push(`import { getSentientEvents, hasFiredGoal } from '@sentientui/react/testing';`);
|
|
543
|
+
lines.push("");
|
|
544
|
+
lines.push(`const s = setupSentientServer();`);
|
|
545
|
+
lines.push(`afterAll(() => s.server.close());`);
|
|
546
|
+
lines.push("");
|
|
547
|
+
lines.push(`test('${componentId}: fires the ${goalName} goal', async () => {`);
|
|
548
|
+
lines.push(` s.use({ variants: { ${componentId}: '${forcedId}' } });`);
|
|
549
|
+
lines.push(` // \u2026render with a live client, trigger the interaction\u2026`);
|
|
550
|
+
lines.push(` expect(hasFiredGoal(getSentientEvents(), '${goalName}')).toBe(true);`);
|
|
551
|
+
lines.push("});");
|
|
552
|
+
lines.push("```");
|
|
553
|
+
lines.push("");
|
|
554
|
+
lines.push("## E2E (Playwright / Cypress)");
|
|
555
|
+
lines.push(`Use \`mockSentient\` to force variants/layout, stub the API, and capture events:`);
|
|
556
|
+
lines.push("```ts");
|
|
557
|
+
lines.push(`import { mockSentient } from '@sentientui/react/testing';`);
|
|
558
|
+
lines.push("");
|
|
559
|
+
lines.push(`const s = await mockSentient(page, { variants: { ${componentId}: '${forcedId}' } });`);
|
|
560
|
+
lines.push(`await page.goto('/');`);
|
|
561
|
+
lines.push(`expect(s.events().some((e) => e.goalType === '${goalName}')).toBe(true);`);
|
|
562
|
+
lines.push("```");
|
|
563
|
+
lines.push(`Cypress: \`mockSentientCypress(cy, scenario)\` in a beforeEach. **Prefer mockSentient in CI \u2014 it writes nothing.**`);
|
|
564
|
+
lines.push(`The URL param below is fine for a quick local pin, but a live client still creates an (automation-flagged) session:`);
|
|
565
|
+
lines.push("```ts");
|
|
566
|
+
lines.push(`await page.goto('/?sentient_variant=${componentId}:${forcedId}');`);
|
|
567
|
+
lines.push("```");
|
|
568
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/tools/integration-guide.ts
|
|
574
|
+
var GUIDE = `# SentientUI integration guide \u2014 the adaptive ladder
|
|
575
|
+
|
|
576
|
+
SentientUI adapts a site per visitor type (personas: buyer, researcher, deal_seeker, browser,
|
|
577
|
+
unknown), learning from real conversions. Decisions are locked per session: Visit 1 learns,
|
|
578
|
+
Visit 2 converts. Integrate one rung at a time.
|
|
579
|
+
|
|
580
|
+
## Setup (60 seconds, no account)
|
|
581
|
+
|
|
582
|
+
1. Run \`npx @sentientui/cli init\` (detects Next App/Pages, Vite, Remix, CRA; installs
|
|
583
|
+
@sentientui/react; wraps the app; writes .env.local; scaffolds an example).
|
|
584
|
+
2. \`npm run dev\`, then open the app with \`?sentient_persona=buyer\` vs
|
|
585
|
+
\`?sentient_persona=deal_seeker\` to see it adapt. No API key needed (keyless local mode).
|
|
586
|
+
3. To learn from real traffic: create a project at https://sentient-ui.com and set
|
|
587
|
+
NEXT_PUBLIC_SENTIENT_API_KEY=pk_... in .env.local.
|
|
588
|
+
|
|
589
|
+
Manual setup: wrap the root layout with <AdaptiveRoot apiKey context> (from
|
|
590
|
+
'@sentientui/react/next'; other React apps use <AdaptiveProvider> from '@sentientui/react')
|
|
591
|
+
and add suppressHydrationWarning to <html> \u2014 an inline script sets persona attributes pre-paint.
|
|
592
|
+
|
|
593
|
+
## Rung 1 \u2014 Style (CSS only)
|
|
594
|
+
|
|
595
|
+
Persona attributes on <html> (zero declaration):
|
|
596
|
+
|
|
597
|
+
html[data-sentient-persona='deal_seeker'] .discount-banner { display: block; }
|
|
598
|
+
html[data-sentient-confidence='low'] .discount-banner { display: none; }
|
|
599
|
+
|
|
600
|
+
Learned style tokens (element-scoped, SSR-safe):
|
|
601
|
+
|
|
602
|
+
const t = useAdaptiveTokens('hero', {
|
|
603
|
+
tone: ['calm', 'urgent'], // first value = baseline
|
|
604
|
+
});
|
|
605
|
+
return <section {...t.props} className="hero">\u2026</section>;
|
|
606
|
+
// CSS: .hero[data-tone='urgent'] .cta { font-weight: 700; }
|
|
607
|
+
|
|
608
|
+
Rules: 1-4 dims, 2-6 values each, enum values only. For animation values, always add a
|
|
609
|
+
prefers-reduced-motion: reduce override in CSS.
|
|
610
|
+
|
|
611
|
+
## Rung 2 \u2014 Swap (alternate content)
|
|
612
|
+
|
|
613
|
+
const { value, bind } = useAdaptive('buy-box', {
|
|
614
|
+
variants: { calm: <CalmBuyBox/>, urgent: <UrgentBuyBox/> }, // first key = baseline
|
|
615
|
+
goal: 'buy_click', // REQUIRED
|
|
616
|
+
});
|
|
617
|
+
return <div {...bind}>{value}</div>;
|
|
618
|
+
|
|
619
|
+
Always attach bind \u2014 it wires exposure tracking and goal listeners. <Adaptive> is the wrapper
|
|
620
|
+
form; <AdaptiveText> swaps dashboard-managed text.
|
|
621
|
+
|
|
622
|
+
## Rung 3 \u2014 Reorder (structure)
|
|
623
|
+
|
|
624
|
+
<AdaptiveGroup id="pricing-area" arrangements={{
|
|
625
|
+
standard: ['plans', 'faq', 'social'], // first key = baseline
|
|
626
|
+
social_first: ['social', 'plans', 'faq'],
|
|
627
|
+
}}>
|
|
628
|
+
<PlanGrid key="plans"/> <Faq key="faq"/> <Testimonials key="social"/>
|
|
629
|
+
</AdaptiveGroup>
|
|
630
|
+
|
|
631
|
+
Declared orders of keyed children only. Page-level: sections={[...]} on AdaptiveRoot +
|
|
632
|
+
useLayoutOrder().
|
|
633
|
+
|
|
634
|
+
## Testing the integration
|
|
635
|
+
|
|
636
|
+
Use '@sentientui/react/testing': renderWithSentient(ui, { variants, slots, persona }) forces
|
|
637
|
+
deterministic outcomes so tests never depend on what the optimizer serves.
|
|
638
|
+
`;
|
|
639
|
+
function registerIntegrationGuideTools(server) {
|
|
640
|
+
server.tool(
|
|
641
|
+
"get_integration_guide",
|
|
642
|
+
"Get the SentientUI adaptive-ladder integration guide: setup (keyless and keyed) plus copy-pasteable examples for every rung (Style, Swap, Reorder). Use this to integrate SentientUI into a codebase.",
|
|
643
|
+
{},
|
|
644
|
+
async () => ({ content: [{ type: "text", text: GUIDE }] })
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
317
648
|
// src/server.ts
|
|
318
649
|
function createMcpServer(client) {
|
|
319
650
|
const server = new import_mcp.McpServer({
|
|
@@ -327,7 +658,10 @@ function createMcpServer(client) {
|
|
|
327
658
|
registerGoalTools(server, client);
|
|
328
659
|
registerGuardrailTools(server, client);
|
|
329
660
|
registerLayoutTools(server, client);
|
|
661
|
+
registerVariantBriefTools(server, client);
|
|
662
|
+
registerTestBriefTools(server, client);
|
|
330
663
|
registerVariantWriteTools(server, client);
|
|
664
|
+
registerIntegrationGuideTools(server);
|
|
331
665
|
return server;
|
|
332
666
|
}
|
|
333
667
|
|
package/dist/index.js
CHANGED
|
@@ -174,9 +174,10 @@ function registerPersonaTools(server, client) {
|
|
|
174
174
|
`Total sessions: ${data.totalSessions}`,
|
|
175
175
|
"",
|
|
176
176
|
"Clusters:",
|
|
177
|
-
...data.clusters.map(
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
...data.clusters.map((c) => {
|
|
178
|
+
const pct = data.totalSessions > 0 ? c.sessionCount / data.totalSessions * 100 : 0;
|
|
179
|
+
return `- ${c.label}: ${c.sessionCount} sessions (${pct.toFixed(1)}% of traffic, reliability ${(c.avgReliability * 100).toFixed(0)}%)`;
|
|
180
|
+
})
|
|
180
181
|
];
|
|
181
182
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
182
183
|
}
|
|
@@ -257,22 +258,24 @@ var projectIdSchema8 = z8.string().uuid().describe("The project UUID");
|
|
|
257
258
|
function registerVariantWriteTools(server, client) {
|
|
258
259
|
server.tool(
|
|
259
260
|
"create_variant",
|
|
260
|
-
"Create a
|
|
261
|
+
"Create a NO-CODE managed text variant for a component (content stored in SentientUI, rendered by <AdaptiveText>). Use this ONLY for text-only variants the user wants without a code change. For variants that will live in the codebase (full components \u2014 copy, markup, styling), use get_variant_brief and write the variant in code instead; those auto-register on deploy and do not need create_variant. Requires Starter or Growth plan.",
|
|
261
262
|
{
|
|
262
263
|
projectId: projectIdSchema8,
|
|
263
264
|
componentId: z8.string().describe("The component ID to add a variant to"),
|
|
264
|
-
displayName: z8.string().describe("Human-readable name for the new variant")
|
|
265
|
+
displayName: z8.string().describe("Human-readable name for the new variant"),
|
|
266
|
+
content: z8.string().optional().describe("The text content for this managed variant (rendered by <AdaptiveText>). Generate it from get_variant_brief context; omit only to create an empty placeholder to fill in from the dashboard.")
|
|
265
267
|
},
|
|
266
|
-
async ({ projectId, componentId, displayName }) => {
|
|
268
|
+
async ({ projectId, componentId, displayName, content }) => {
|
|
267
269
|
const id = encodeURIComponent(projectId);
|
|
268
270
|
const result = await client.post(
|
|
269
271
|
`/projects/${id}/variants`,
|
|
270
|
-
{ componentId, displayName }
|
|
272
|
+
{ componentId, displayName, content }
|
|
271
273
|
);
|
|
274
|
+
const contentNote = content ? " with the provided text content" : " (empty \u2014 add its text content from the dashboard or via a follow-up update)";
|
|
272
275
|
return {
|
|
273
276
|
content: [{
|
|
274
277
|
type: "text",
|
|
275
|
-
text: `
|
|
278
|
+
text: `Managed text variant created: ${result.variantId} ("${result.displayName}") for component ${componentId}${contentNote}. It is in draft state \u2014 activate it from the dashboard. Reminder: this is a no-code managed variant; for code-native variants, edit the code instead (see get_variant_brief).`
|
|
276
279
|
}]
|
|
277
280
|
};
|
|
278
281
|
}
|
|
@@ -313,6 +316,334 @@ function registerVariantWriteTools(server, client) {
|
|
|
313
316
|
);
|
|
314
317
|
}
|
|
315
318
|
|
|
319
|
+
// src/tools/variant-brief.ts
|
|
320
|
+
import { z as z9 } from "zod";
|
|
321
|
+
var projectIdSchema9 = z9.string().uuid().describe("The project UUID");
|
|
322
|
+
var GOAL_TARGET = 500;
|
|
323
|
+
var BEST_PRACTICE_PRIORS = {
|
|
324
|
+
ecommerce: [
|
|
325
|
+
"Lead with the core benefit/value, not features.",
|
|
326
|
+
"Make the primary action (add to cart / buy) unmistakable and high-contrast.",
|
|
327
|
+
"Reduce purchase anxiety near the decision: free returns, shipping, secure checkout, guarantees.",
|
|
328
|
+
'Add credible social proof (ratings, review count, "X sold").',
|
|
329
|
+
"Use urgency/scarcity only when it is genuinely true (low stock, real deadline).",
|
|
330
|
+
"Cut friction: fewer steps, clearer pricing, no surprise costs."
|
|
331
|
+
],
|
|
332
|
+
saas: [
|
|
333
|
+
"Lead with the outcome the user gets, not the mechanism.",
|
|
334
|
+
'Make the primary CTA action-oriented and specific (e.g. "Start free trial").',
|
|
335
|
+
'Reduce signup friction (fewer fields, SSO, "no credit card required").',
|
|
336
|
+
"Add proof near the CTA: customer logos, a hard metric, a short testimonial.",
|
|
337
|
+
"Address the target persona's top objection inline."
|
|
338
|
+
],
|
|
339
|
+
landing: [
|
|
340
|
+
"One clear message and one primary action above the fold.",
|
|
341
|
+
"Match the headline to the traffic source / campaign intent.",
|
|
342
|
+
"Make the CTA specific and benefit-led.",
|
|
343
|
+
"Add a single strong proof point; remove competing distractions."
|
|
344
|
+
],
|
|
345
|
+
marketplace: [
|
|
346
|
+
"Reduce choice overload: guide the visitor to a clear next step.",
|
|
347
|
+
"Surface trust and liquidity signals (ratings, counts, recency).",
|
|
348
|
+
"Make the primary action on each listing obvious.",
|
|
349
|
+
"Reassure on safety/guarantees near the point of decision."
|
|
350
|
+
]
|
|
351
|
+
};
|
|
352
|
+
var GENERIC_PRIORS = [
|
|
353
|
+
"Make the primary action unmistakable and benefit-led.",
|
|
354
|
+
"Lead with the outcome/value for the visitor.",
|
|
355
|
+
"Remove friction and distractions around the decision.",
|
|
356
|
+
"Add one credible proof point near the action."
|
|
357
|
+
];
|
|
358
|
+
function priorsFor(contextType) {
|
|
359
|
+
return BEST_PRACTICE_PRIORS[contextType] ?? GENERIC_PRIORS;
|
|
360
|
+
}
|
|
361
|
+
function computeDataState(impressions, insights, avgReliability) {
|
|
362
|
+
if (impressions === 0) return "empty";
|
|
363
|
+
const insightsReady = insights?.status === "ok" && !insights.isStale;
|
|
364
|
+
const reliable = avgReliability === null || avgReliability >= 0.3;
|
|
365
|
+
if (impressions < GOAL_TARGET || !insightsReady || !reliable) return "collecting";
|
|
366
|
+
return "sufficient";
|
|
367
|
+
}
|
|
368
|
+
function guidanceFor(dataState, contextType) {
|
|
369
|
+
switch (dataState) {
|
|
370
|
+
case "sufficient":
|
|
371
|
+
return "There is enough data. Target the specific weakness above \u2014 the underperforming variant/persona \u2014 with your change. Set basis to the signal you used.";
|
|
372
|
+
case "collecting":
|
|
373
|
+
return "Limited data so far. Lean on the best-practice priors below, lightly informed by the early signal. Make one focused change rather than a redesign.";
|
|
374
|
+
case "empty":
|
|
375
|
+
return `No data yet \u2014 use your best judgment. Apply the best-practice priors for a ${contextType} surface below. Make one conservative, high-confidence change, and consider enabling shadow mode for this component so it is validated before it serves real traffic.`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function settled(p) {
|
|
379
|
+
try {
|
|
380
|
+
return await p;
|
|
381
|
+
} catch {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function registerVariantBriefTools(server, client) {
|
|
386
|
+
server.tool(
|
|
387
|
+
"get_variant_brief",
|
|
388
|
+
"Get an insight-driven brief for creating a new CODE-NATIVE variant of a component. Returns current variant performance, audience, insights, a data-sufficiency assessment (with a best-practice fallback when there is no data yet), and step-by-step instructions for writing the variant in the customer's code. Use this instead of create_variant when the variant will live in the codebase.",
|
|
389
|
+
{
|
|
390
|
+
projectId: projectIdSchema9,
|
|
391
|
+
componentId: z9.string().describe('The component ID to write a new variant for (matches <Adaptive id="...">).')
|
|
392
|
+
},
|
|
393
|
+
async ({ projectId, componentId }) => {
|
|
394
|
+
const id = encodeURIComponent(projectId);
|
|
395
|
+
const [projects, components, trends, portraits, insights] = await Promise.all([
|
|
396
|
+
settled(client.get("/projects")),
|
|
397
|
+
settled(client.get(`/projects/${id}/components`)),
|
|
398
|
+
settled(client.get(`/projects/${id}/trends`)),
|
|
399
|
+
settled(client.get(`/projects/${id}/portraits`)),
|
|
400
|
+
settled(client.get(`/projects/${id}/insights`))
|
|
401
|
+
]);
|
|
402
|
+
const project = projects?.find((p) => p.id === projectId) ?? null;
|
|
403
|
+
const contextType = project?.context_type ?? "unknown";
|
|
404
|
+
const component = components?.find((c) => c.component_id === componentId) ?? null;
|
|
405
|
+
const impressions = component?.total_impressions ?? 0;
|
|
406
|
+
const conversions = component?.total_conversions ?? 0;
|
|
407
|
+
const componentCvr = impressions > 0 ? conversions / impressions * 100 : 0;
|
|
408
|
+
const existingVariantIds = component?.variants.map((v) => v.variant_id) ?? [];
|
|
409
|
+
const variantIdSet = new Set(existingVariantIds);
|
|
410
|
+
const momentumMap = new Map((trends?.momentum ?? []).map((m) => [m.variantId, m.direction]));
|
|
411
|
+
const variantPerf = (trends?.cvr ?? []).filter((v) => variantIdSet.has(v.variantId));
|
|
412
|
+
const clusters = portraits?.clusters ?? [];
|
|
413
|
+
const totalSessions = portraits?.totalSessions ?? 0;
|
|
414
|
+
const avgReliability = clusters.length ? clusters.reduce((s, c) => s + c.avgReliability, 0) / clusters.length : null;
|
|
415
|
+
const dataState = computeDataState(impressions, insights, avgReliability);
|
|
416
|
+
const lines = [];
|
|
417
|
+
lines.push(`# Variant brief \u2014 ${componentId}`);
|
|
418
|
+
lines.push(`Project context type: ${contextType}`);
|
|
419
|
+
lines.push("");
|
|
420
|
+
if (!component) {
|
|
421
|
+
lines.push(
|
|
422
|
+
`Note: no component named "${componentId}" has reported data yet. If this is a new <Adaptive> you are adding, that is expected \u2014 it registers automatically on first assignment after deploy. Proceed using the best-practice priors below.`
|
|
423
|
+
);
|
|
424
|
+
lines.push("");
|
|
425
|
+
} else {
|
|
426
|
+
lines.push(
|
|
427
|
+
`Component performance: ${impressions} impressions, ${conversions} conversions, ${componentCvr.toFixed(2)}% CVR.`
|
|
428
|
+
);
|
|
429
|
+
lines.push(
|
|
430
|
+
`Existing variant IDs (do not reuse these): ${existingVariantIds.length ? existingVariantIds.join(", ") : "(none)"}`
|
|
431
|
+
);
|
|
432
|
+
lines.push("");
|
|
433
|
+
}
|
|
434
|
+
if (variantPerf.length) {
|
|
435
|
+
lines.push("Current variant performance (7d vs prior 7d):");
|
|
436
|
+
for (const v of variantPerf) {
|
|
437
|
+
lines.push(
|
|
438
|
+
`- ${v.variantId}: ${(v.currentCvr * 100).toFixed(2)}% CVR (${v.deltaPp > 0 ? "+" : ""}${v.deltaPp.toFixed(1)} pp, ${momentumMap.get(v.variantId) ?? "stable"})`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
lines.push("");
|
|
442
|
+
}
|
|
443
|
+
if (clusters.length) {
|
|
444
|
+
lines.push(`Audience (${totalSessions} sessions):`);
|
|
445
|
+
for (const c of clusters) {
|
|
446
|
+
const share = totalSessions > 0 ? (c.sessionCount / totalSessions * 100).toFixed(0) : "0";
|
|
447
|
+
lines.push(`- ${c.label}: ${share}% of traffic (reliability ${(c.avgReliability * 100).toFixed(0)}%)`);
|
|
448
|
+
}
|
|
449
|
+
lines.push("");
|
|
450
|
+
}
|
|
451
|
+
if (insights && insights.status === "ok") {
|
|
452
|
+
if (insights.isStale) lines.push("\u26A0 Insights are stale (>6h). Consider refresh_insights for a fresher read.");
|
|
453
|
+
const narrator = insights.narratorBullets ?? [];
|
|
454
|
+
const advisor = insights.advisorBullets ?? [];
|
|
455
|
+
if (narrator.length) {
|
|
456
|
+
lines.push("Insights \u2014 observations:");
|
|
457
|
+
narrator.forEach((b) => lines.push(`- ${b}`));
|
|
458
|
+
}
|
|
459
|
+
if (advisor.length) {
|
|
460
|
+
lines.push("Insights \u2014 recommendations:");
|
|
461
|
+
advisor.forEach((b) => lines.push(`- ${b}`));
|
|
462
|
+
}
|
|
463
|
+
lines.push("");
|
|
464
|
+
} else {
|
|
465
|
+
lines.push("Insights: none generated yet.");
|
|
466
|
+
lines.push("");
|
|
467
|
+
}
|
|
468
|
+
lines.push(`## Data sufficiency: ${dataState.toUpperCase()}`);
|
|
469
|
+
lines.push(guidanceFor(dataState, contextType));
|
|
470
|
+
lines.push("");
|
|
471
|
+
lines.push(`## Best-practice priors (${contextType})`);
|
|
472
|
+
priorsFor(contextType).forEach((p) => lines.push(`- ${p}`));
|
|
473
|
+
lines.push("");
|
|
474
|
+
lines.push("## How to implement (code-native variant)");
|
|
475
|
+
lines.push(`1. Search the repository for <Adaptive id="${componentId}"> (and any matching useAssignment("${componentId}") usage).`);
|
|
476
|
+
lines.push('2. Add a new key to its `variants` map with on-brand JSX that matches the surrounding components and the project\'s design system. Pick a short, descriptive new variant id that is not in the existing list above (e.g. "value_led", "social_proof", "urgency").');
|
|
477
|
+
lines.push("3. If the component is server-rendered via <AdaptiveRoot>, add the new variant id to that component's entry in the `components` list so it is included in SSR preloading.");
|
|
478
|
+
lines.push("4. Do NOT call create_variant \u2014 that creates a separate no-code MANAGED draft. Code-native variants register automatically on the first assignment after you deploy, and go live immediately.");
|
|
479
|
+
lines.push("5. Commit, push, and deploy. Optionally enable shadow mode for this component first if you want to validate before serving real traffic.");
|
|
480
|
+
lines.push("");
|
|
481
|
+
lines.push("Make the change reflect the data sufficiency above: data-driven when SUFFICIENT, best-practice-led when COLLECTING or EMPTY.");
|
|
482
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
483
|
+
}
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/tools/test-brief.ts
|
|
488
|
+
import { z as z10 } from "zod";
|
|
489
|
+
var projectIdSchema10 = z10.string().uuid().describe("The project UUID");
|
|
490
|
+
async function settled2(p) {
|
|
491
|
+
try {
|
|
492
|
+
return await p;
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function registerTestBriefTools(server, client) {
|
|
498
|
+
server.tool(
|
|
499
|
+
"get_test_brief",
|
|
500
|
+
"Get a ready-to-paste test for a SentientUI-wrapped component, populated with the component's real variants and goals. This project uses @sentientui/react/testing. Use this so your tests force a specific variant/layout deterministically and never break when the optimizer serves a different version. Returns a React Testing Library example plus the URL-param recipe for E2E (Playwright/Cypress).",
|
|
501
|
+
{
|
|
502
|
+
projectId: projectIdSchema10,
|
|
503
|
+
componentId: z10.string().describe('The component ID to write a test for (matches <Adaptive id="...">).')
|
|
504
|
+
},
|
|
505
|
+
async ({ projectId, componentId }) => {
|
|
506
|
+
const id = encodeURIComponent(projectId);
|
|
507
|
+
const [components, goalsRes] = await Promise.all([
|
|
508
|
+
settled2(client.get(`/projects/${id}/components`)),
|
|
509
|
+
settled2(client.get(`/projects/${id}/goals`))
|
|
510
|
+
]);
|
|
511
|
+
const component = components?.find((c) => c.component_id === componentId) ?? null;
|
|
512
|
+
const variantIds = component?.variants.map((v) => v.variant_id) ?? [];
|
|
513
|
+
const goals = Array.isArray(goalsRes) ? goalsRes : goalsRes?.goals ?? [];
|
|
514
|
+
const goalName = goals[0]?.goalName ?? "signup";
|
|
515
|
+
const controlId = variantIds[0] ?? "control";
|
|
516
|
+
const forcedId = variantIds.find((v) => v !== controlId) ?? "variant_b";
|
|
517
|
+
const lines = [];
|
|
518
|
+
lines.push(`# Test brief \u2014 ${componentId}`);
|
|
519
|
+
lines.push("");
|
|
520
|
+
lines.push("This project uses **`@sentientui/react/testing`**. By default SentientUI serves the *control* variant and default layout in tests and sends no events, so existing tests are unaffected. Pass a scenario to force a specific variant/layout.");
|
|
521
|
+
if (!component) {
|
|
522
|
+
lines.push("");
|
|
523
|
+
lines.push(`Note: no component named "${componentId}" has reported data yet. If you are adding this \`<Adaptive>\`, that is expected \u2014 the example below uses placeholder variant IDs; replace them with the ones you declare in \`variants={{ \u2026 }}\`.`);
|
|
524
|
+
}
|
|
525
|
+
lines.push("");
|
|
526
|
+
lines.push("## React Testing Library");
|
|
527
|
+
lines.push("```tsx");
|
|
528
|
+
lines.push(`import { renderWithSentient } from '@sentientui/react/testing';`);
|
|
529
|
+
lines.push(`import { screen } from '@testing-library/react';`);
|
|
530
|
+
lines.push("");
|
|
531
|
+
lines.push(`test('${componentId}: forces the "${forcedId}" variant', () => {`);
|
|
532
|
+
lines.push(` renderWithSentient(<YourPage />, { variants: { ${componentId}: '${forcedId}' } });`);
|
|
533
|
+
lines.push(` // assert on the ${forcedId} variant's content:`);
|
|
534
|
+
lines.push(` // expect(screen.getByText('\u2026')).toBeInTheDocument();`);
|
|
535
|
+
lines.push("});");
|
|
536
|
+
lines.push("```");
|
|
537
|
+
lines.push("");
|
|
538
|
+
lines.push("## Assert a goal fires (with the mock server)");
|
|
539
|
+
lines.push("```tsx");
|
|
540
|
+
lines.push(`import { setupSentientServer } from '@sentientui/react/testing/node';`);
|
|
541
|
+
lines.push(`import { getSentientEvents, hasFiredGoal } from '@sentientui/react/testing';`);
|
|
542
|
+
lines.push("");
|
|
543
|
+
lines.push(`const s = setupSentientServer();`);
|
|
544
|
+
lines.push(`afterAll(() => s.server.close());`);
|
|
545
|
+
lines.push("");
|
|
546
|
+
lines.push(`test('${componentId}: fires the ${goalName} goal', async () => {`);
|
|
547
|
+
lines.push(` s.use({ variants: { ${componentId}: '${forcedId}' } });`);
|
|
548
|
+
lines.push(` // \u2026render with a live client, trigger the interaction\u2026`);
|
|
549
|
+
lines.push(` expect(hasFiredGoal(getSentientEvents(), '${goalName}')).toBe(true);`);
|
|
550
|
+
lines.push("});");
|
|
551
|
+
lines.push("```");
|
|
552
|
+
lines.push("");
|
|
553
|
+
lines.push("## E2E (Playwright / Cypress)");
|
|
554
|
+
lines.push(`Use \`mockSentient\` to force variants/layout, stub the API, and capture events:`);
|
|
555
|
+
lines.push("```ts");
|
|
556
|
+
lines.push(`import { mockSentient } from '@sentientui/react/testing';`);
|
|
557
|
+
lines.push("");
|
|
558
|
+
lines.push(`const s = await mockSentient(page, { variants: { ${componentId}: '${forcedId}' } });`);
|
|
559
|
+
lines.push(`await page.goto('/');`);
|
|
560
|
+
lines.push(`expect(s.events().some((e) => e.goalType === '${goalName}')).toBe(true);`);
|
|
561
|
+
lines.push("```");
|
|
562
|
+
lines.push(`Cypress: \`mockSentientCypress(cy, scenario)\` in a beforeEach. **Prefer mockSentient in CI \u2014 it writes nothing.**`);
|
|
563
|
+
lines.push(`The URL param below is fine for a quick local pin, but a live client still creates an (automation-flagged) session:`);
|
|
564
|
+
lines.push("```ts");
|
|
565
|
+
lines.push(`await page.goto('/?sentient_variant=${componentId}:${forcedId}');`);
|
|
566
|
+
lines.push("```");
|
|
567
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
568
|
+
}
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/tools/integration-guide.ts
|
|
573
|
+
var GUIDE = `# SentientUI integration guide \u2014 the adaptive ladder
|
|
574
|
+
|
|
575
|
+
SentientUI adapts a site per visitor type (personas: buyer, researcher, deal_seeker, browser,
|
|
576
|
+
unknown), learning from real conversions. Decisions are locked per session: Visit 1 learns,
|
|
577
|
+
Visit 2 converts. Integrate one rung at a time.
|
|
578
|
+
|
|
579
|
+
## Setup (60 seconds, no account)
|
|
580
|
+
|
|
581
|
+
1. Run \`npx @sentientui/cli init\` (detects Next App/Pages, Vite, Remix, CRA; installs
|
|
582
|
+
@sentientui/react; wraps the app; writes .env.local; scaffolds an example).
|
|
583
|
+
2. \`npm run dev\`, then open the app with \`?sentient_persona=buyer\` vs
|
|
584
|
+
\`?sentient_persona=deal_seeker\` to see it adapt. No API key needed (keyless local mode).
|
|
585
|
+
3. To learn from real traffic: create a project at https://sentient-ui.com and set
|
|
586
|
+
NEXT_PUBLIC_SENTIENT_API_KEY=pk_... in .env.local.
|
|
587
|
+
|
|
588
|
+
Manual setup: wrap the root layout with <AdaptiveRoot apiKey context> (from
|
|
589
|
+
'@sentientui/react/next'; other React apps use <AdaptiveProvider> from '@sentientui/react')
|
|
590
|
+
and add suppressHydrationWarning to <html> \u2014 an inline script sets persona attributes pre-paint.
|
|
591
|
+
|
|
592
|
+
## Rung 1 \u2014 Style (CSS only)
|
|
593
|
+
|
|
594
|
+
Persona attributes on <html> (zero declaration):
|
|
595
|
+
|
|
596
|
+
html[data-sentient-persona='deal_seeker'] .discount-banner { display: block; }
|
|
597
|
+
html[data-sentient-confidence='low'] .discount-banner { display: none; }
|
|
598
|
+
|
|
599
|
+
Learned style tokens (element-scoped, SSR-safe):
|
|
600
|
+
|
|
601
|
+
const t = useAdaptiveTokens('hero', {
|
|
602
|
+
tone: ['calm', 'urgent'], // first value = baseline
|
|
603
|
+
});
|
|
604
|
+
return <section {...t.props} className="hero">\u2026</section>;
|
|
605
|
+
// CSS: .hero[data-tone='urgent'] .cta { font-weight: 700; }
|
|
606
|
+
|
|
607
|
+
Rules: 1-4 dims, 2-6 values each, enum values only. For animation values, always add a
|
|
608
|
+
prefers-reduced-motion: reduce override in CSS.
|
|
609
|
+
|
|
610
|
+
## Rung 2 \u2014 Swap (alternate content)
|
|
611
|
+
|
|
612
|
+
const { value, bind } = useAdaptive('buy-box', {
|
|
613
|
+
variants: { calm: <CalmBuyBox/>, urgent: <UrgentBuyBox/> }, // first key = baseline
|
|
614
|
+
goal: 'buy_click', // REQUIRED
|
|
615
|
+
});
|
|
616
|
+
return <div {...bind}>{value}</div>;
|
|
617
|
+
|
|
618
|
+
Always attach bind \u2014 it wires exposure tracking and goal listeners. <Adaptive> is the wrapper
|
|
619
|
+
form; <AdaptiveText> swaps dashboard-managed text.
|
|
620
|
+
|
|
621
|
+
## Rung 3 \u2014 Reorder (structure)
|
|
622
|
+
|
|
623
|
+
<AdaptiveGroup id="pricing-area" arrangements={{
|
|
624
|
+
standard: ['plans', 'faq', 'social'], // first key = baseline
|
|
625
|
+
social_first: ['social', 'plans', 'faq'],
|
|
626
|
+
}}>
|
|
627
|
+
<PlanGrid key="plans"/> <Faq key="faq"/> <Testimonials key="social"/>
|
|
628
|
+
</AdaptiveGroup>
|
|
629
|
+
|
|
630
|
+
Declared orders of keyed children only. Page-level: sections={[...]} on AdaptiveRoot +
|
|
631
|
+
useLayoutOrder().
|
|
632
|
+
|
|
633
|
+
## Testing the integration
|
|
634
|
+
|
|
635
|
+
Use '@sentientui/react/testing': renderWithSentient(ui, { variants, slots, persona }) forces
|
|
636
|
+
deterministic outcomes so tests never depend on what the optimizer serves.
|
|
637
|
+
`;
|
|
638
|
+
function registerIntegrationGuideTools(server) {
|
|
639
|
+
server.tool(
|
|
640
|
+
"get_integration_guide",
|
|
641
|
+
"Get the SentientUI adaptive-ladder integration guide: setup (keyless and keyed) plus copy-pasteable examples for every rung (Style, Swap, Reorder). Use this to integrate SentientUI into a codebase.",
|
|
642
|
+
{},
|
|
643
|
+
async () => ({ content: [{ type: "text", text: GUIDE }] })
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
316
647
|
// src/server.ts
|
|
317
648
|
function createMcpServer(client) {
|
|
318
649
|
const server = new McpServer({
|
|
@@ -326,7 +657,10 @@ function createMcpServer(client) {
|
|
|
326
657
|
registerGoalTools(server, client);
|
|
327
658
|
registerGuardrailTools(server, client);
|
|
328
659
|
registerLayoutTools(server, client);
|
|
660
|
+
registerVariantBriefTools(server, client);
|
|
661
|
+
registerTestBriefTools(server, client);
|
|
329
662
|
registerVariantWriteTools(server, client);
|
|
663
|
+
registerIntegrationGuideTools(server);
|
|
330
664
|
return server;
|
|
331
665
|
}
|
|
332
666
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentientui/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "MCP server for SentientUI — exposes project data and actions to AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,13 +21,6 @@
|
|
|
21
21
|
"README.md",
|
|
22
22
|
"AGENTS.md"
|
|
23
23
|
],
|
|
24
|
-
"scripts": {
|
|
25
|
-
"build": "tsup",
|
|
26
|
-
"test": "vitest run",
|
|
27
|
-
"typecheck": "tsc --noEmit",
|
|
28
|
-
"dev": "tsx src/index.ts",
|
|
29
|
-
"prepublishOnly": "pnpm build && pnpm typecheck"
|
|
30
|
-
},
|
|
31
24
|
"publishConfig": {
|
|
32
25
|
"access": "public"
|
|
33
26
|
},
|
|
@@ -41,5 +34,11 @@
|
|
|
41
34
|
"tsx": "^4.19.2",
|
|
42
35
|
"typescript": "^5.7.2",
|
|
43
36
|
"vitest": "^2.1.8"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"dev": "tsx src/index.ts"
|
|
44
43
|
}
|
|
45
|
-
}
|
|
44
|
+
}
|