@sentientui/mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,87 @@
1
+ # SentientUI — AI Agent Guide
2
+
3
+ SentientUI is a UI personalization platform that assigns visitors to variant components and reorders page sections per persona. The MCP gives agents read + limited write access to project data.
4
+
5
+ ## Key Concepts
6
+
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
+
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.
15
+
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).
17
+
18
+ **Shadow mode** — project-level flag (toggled in dashboard settings). When ON: personalization is computed and logged but visitors receive the default layout/variant. Use it to validate the algorithm before committing to live changes.
19
+
20
+ **Insights** — AI-generated. Two tiers: narrator observations (all plans) + advisor recommendations (Growth tier). Insights go stale after 6 hours. Always check `isStale` before presenting them.
21
+
22
+ **Layout stats** — per-persona section ordering driven by a bandit algorithm. `pulls` = times a layout was applied, `avgReward` = normalized conversion signal (not raw CVR).
23
+
24
+ ## Auth
25
+
26
+ Server keys start with `sk_` — required for the management API. Public keys (`pk_`) are for the React SDK only and will be rejected. Key is scoped to the account; all owned projects are accessible.
27
+
28
+ ## Tool Quick Reference
29
+
30
+ | Tool | Notes |
31
+ |------|-------|
32
+ | `list_projects` | Start here to get project IDs |
33
+ | `get_project_stats` | Health status + 24h event/session volume |
34
+ | `list_components` | Components + variant counts + impression totals |
35
+ | `get_variant_performance` | CVR 7d vs prior 7d + momentum direction |
36
+ | `get_persona_breakdown` | Cluster distribution + avg reliability per cluster |
37
+ | `get_goal_funnel` | Goal hit counts + per-variant completion rates |
38
+ | `list_guardrail_events` | Auto-paused variants (last 24h) |
39
+ | `get_layout_stats` | Per-persona layout order + bandit pulls/reward |
40
+ | `get_insights` | Narrator bullets + (Growth) advisor bullets |
41
+ | `refresh_insights` | Async trigger — wait ~15s then call `get_insights` |
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 |
44
+ | `pause_variant` | Stops traffic; no MCP resume |
45
+
46
+ ## Canonical Workflows
47
+
48
+ **Diagnose a conversion drop**
49
+ 1. `get_project_stats` — confirm events are flowing
50
+ 2. `get_goal_funnel` — find which goals/variants are down
51
+ 3. `get_variant_performance` — check CVR momentum
52
+ 4. `list_guardrail_events` — see if the guardrail fired
53
+ 5. `refresh_insights` → wait 15s → `get_insights` — read AI summary
54
+
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)**
65
+ 1. `list_components` — confirm the component ID
66
+ 2. `create_variant` with `content` — creates a managed text draft (generate the copy from `get_variant_brief` context)
67
+ 3. Tell the user to activate it from the dashboard
68
+
69
+ **Understand the audience**
70
+ 1. `get_persona_breakdown` — cluster sizes + reliability
71
+ 2. `get_layout_stats` — what layout order each persona gets
72
+ 3. `get_insights` — AI narrative on what changed
73
+
74
+ **Validate before going live (shadow mode)**
75
+ - Shadow mode is enabled/disabled in the dashboard (Project Settings)
76
+ - When ON: personalization runs silently, no live impact
77
+ - When confident: turn OFF to start serving personalized experiences
78
+
79
+ ## Common Mistakes
80
+
81
+ - Presenting stale insights without checking `isStale` — always check, and offer to `refresh_insights`
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)
85
+ - Calling `pause_variant` without warning the user it's irreversible via MCP
86
+ - Using a `pk_` key instead of `sk_` — it will be rejected
87
+ - Interpreting `avgReward` in layout stats as a conversion rate — it's a bandit reward signal, not CVR
package/README.md CHANGED
@@ -6,7 +6,7 @@ MCP server for [SentientUI](https://sentient-ui.com) — gives AI agents in Clau
6
6
 
7
7
  **1. Get a server key**
8
8
 
9
- Dashboard → **Settings → API KeysCreate server key** (`sk_live_...`).
9
+ Dashboard → Project → **Settings → API keyServer key** → Generate (requires Starter or Growth plan).
10
10
 
11
11
  **2. Add to your AI assistant**
12
12
 
@@ -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
- | `create_variant` | Create a new draft variant (Starter+) |
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
@@ -107,6 +113,17 @@ The server key you provide is passed as a `Bearer` token on every call to the Se
107
113
 
108
114
  Server keys start with `sk_` (not `pk_`). Public keys (`pk_`) are for the React SDK only and will be rejected by the management API.
109
115
 
116
+ ## AI agent context
117
+
118
+ `AGENTS.md` (included in this package) gives your AI assistant deeper context about SentientUI concepts, tool ordering, and common pitfalls. Wire it up once and it applies to every conversation.
119
+
120
+ | Assistant | How to use |
121
+ |-----------|-----------|
122
+ | **Claude Code** | Copy to `~/.claude/skills/sentientui/SKILL.md` |
123
+ | **Cursor** | Copy to `.cursor/rules/sentientui.mdc` |
124
+ | **GitHub Copilot** | Append to `.github/copilot-instructions.md` |
125
+ | **Other** | Paste into your assistant's system prompt |
126
+
110
127
  ## Requirements
111
128
 
112
129
  - Node.js 18+
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
- (c) => `- ${c.label}: ${c.sessionCount} sessions (${(c.sessionCount / data.totalSessions * 100).toFixed(1)}% of traffic, reliability ${(c.avgReliability * 100).toFixed(0)}%)`
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 new draft variant for a component. Requires Starter or Growth plan.",
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: `Variant created: ${result.variantId} ("${result.displayName}") for component ${componentId}. It is in draft state \u2014 activate it from the dashboard.`
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,259 @@ 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
+
317
573
  // src/server.ts
318
574
  function createMcpServer(client) {
319
575
  const server = new import_mcp.McpServer({
@@ -327,6 +583,8 @@ function createMcpServer(client) {
327
583
  registerGoalTools(server, client);
328
584
  registerGuardrailTools(server, client);
329
585
  registerLayoutTools(server, client);
586
+ registerVariantBriefTools(server, client);
587
+ registerTestBriefTools(server, client);
330
588
  registerVariantWriteTools(server, client);
331
589
  return server;
332
590
  }
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
- (c) => `- ${c.label}: ${c.sessionCount} sessions (${(c.sessionCount / data.totalSessions * 100).toFixed(1)}% of traffic, reliability ${(c.avgReliability * 100).toFixed(0)}%)`
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 new draft variant for a component. Requires Starter or Growth plan.",
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: `Variant created: ${result.variantId} ("${result.displayName}") for component ${componentId}. It is in draft state \u2014 activate it from the dashboard.`
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,259 @@ 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
+
316
572
  // src/server.ts
317
573
  function createMcpServer(client) {
318
574
  const server = new McpServer({
@@ -326,6 +582,8 @@ function createMcpServer(client) {
326
582
  registerGoalTools(server, client);
327
583
  registerGuardrailTools(server, client);
328
584
  registerLayoutTools(server, client);
585
+ registerVariantBriefTools(server, client);
586
+ registerTestBriefTools(server, client);
329
587
  registerVariantWriteTools(server, client);
330
588
  return server;
331
589
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentientui/mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for SentientUI — exposes project data and actions to AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,15 +18,9 @@
18
18
  },
19
19
  "files": [
20
20
  "dist",
21
- "README.md"
21
+ "README.md",
22
+ "AGENTS.md"
22
23
  ],
23
- "scripts": {
24
- "build": "tsup",
25
- "test": "vitest run",
26
- "typecheck": "tsc --noEmit",
27
- "dev": "tsx src/index.ts",
28
- "prepublishOnly": "pnpm build && pnpm typecheck"
29
- },
30
24
  "publishConfig": {
31
25
  "access": "public"
32
26
  },
@@ -40,5 +34,11 @@
40
34
  "tsx": "^4.19.2",
41
35
  "typescript": "^5.7.2",
42
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"
43
43
  }
44
- }
44
+ }