@sentientui/mcp 0.3.4 → 0.4.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.
@@ -0,0 +1,725 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/api-client.ts
4
+ var ApiError = class extends Error {
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.status = status;
8
+ this.name = "ApiError";
9
+ }
10
+ status;
11
+ };
12
+ var ApiClient = class {
13
+ baseUrl;
14
+ apiKey;
15
+ constructor(opts) {
16
+ this.apiKey = opts.apiKey;
17
+ this.baseUrl = (opts.baseUrl ?? "https://api.sentient-ui.com").replace(/\/$/, "");
18
+ }
19
+ async get(path) {
20
+ const res = await fetch(`${this.baseUrl}/v1/mgmt${path}`, {
21
+ headers: {
22
+ authorization: `Bearer ${this.apiKey}`,
23
+ "content-type": "application/json"
24
+ }
25
+ });
26
+ if (!res.ok) {
27
+ const body = await res.json().catch(() => ({}));
28
+ throw new ApiError(res.status, String(body.error ?? res.statusText));
29
+ }
30
+ return res.json();
31
+ }
32
+ async post(path, body) {
33
+ const res = await fetch(`${this.baseUrl}/v1/mgmt${path}`, {
34
+ method: "POST",
35
+ headers: {
36
+ authorization: `Bearer ${this.apiKey}`,
37
+ "content-type": "application/json"
38
+ },
39
+ body: body !== void 0 ? JSON.stringify(body) : void 0
40
+ });
41
+ if (!res.ok) {
42
+ const errBody = await res.json().catch(() => ({}));
43
+ throw new ApiError(res.status, String(errBody.error ?? res.statusText));
44
+ }
45
+ return res.json();
46
+ }
47
+ };
48
+
49
+ // src/server.ts
50
+ import { createRequire } from "module";
51
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
52
+
53
+ // src/tools/projects.ts
54
+ import { z } from "zod";
55
+ var projectIdSchema = z.string().uuid().describe("The project UUID");
56
+ function createProjectGuidance(err) {
57
+ switch (err.message) {
58
+ case "insufficient_scope":
59
+ return "Creating a project needs an account login. Connect via the hosted MCP URL (https://api.sentient-ui.com/mcp) and sign in \u2014 a project-scoped server key (sk_\u2026) cannot create projects.";
60
+ case "demo_read_only":
61
+ return "Demo mode is read-only. Create a SentientUI account and sign in to make projects.";
62
+ case "insufficient_role":
63
+ return "Your account role cannot create projects \u2014 this needs the account owner or an admin.";
64
+ case "project_limit_reached":
65
+ return "You've reached your plan's project limit. Upgrade your plan or remove an existing project, then try again.";
66
+ case "name_required":
67
+ return "A project name is required to create a project.";
68
+ default:
69
+ return null;
70
+ }
71
+ }
72
+ function registerProjectTools(server, client) {
73
+ server.tool(
74
+ "create_project",
75
+ "Create a NEW SentientUI project (onboarding). Returns the project id and its pk_ public key for the SDK. Requires an account login: this works when connected via OAuth (the hosted MCP URL) but NOT with a project-scoped sk_ server key or an anonymous demo token. After it succeeds, call get_integration_guide and help the user install @sentientui/react with the returned key.",
76
+ {
77
+ name: z.string().min(1).describe("Human-readable project name"),
78
+ contextType: z.enum(["saas", "ecommerce", "marketing", "internal"]).optional().describe("What kind of product this is; defaults to saas"),
79
+ framework: z.enum(["next-app", "next-pages", "react", "core"]).optional().describe("Frontend framework, used to tailor setup; defaults to next-app"),
80
+ websiteUrl: z.string().optional().describe("Production site origin to allow-list so the SDK's events aren't origin-blocked on day one")
81
+ },
82
+ async ({ name, contextType, framework, websiteUrl }) => {
83
+ try {
84
+ const created = await client.post("/projects", {
85
+ name,
86
+ contextType,
87
+ framework,
88
+ origin: websiteUrl
89
+ });
90
+ return {
91
+ content: [{
92
+ type: "text",
93
+ text: [
94
+ `Created project "${name}" (id: ${created.id}, type: ${contextType ?? "saas"}).`,
95
+ `Public key: ${created.apiKey}`,
96
+ `Next: install @sentientui/react with this key. Ask me to pull the setup guide (get_integration_guide) and I'll wrap your first component.`
97
+ ].join("\n")
98
+ }]
99
+ };
100
+ } catch (err) {
101
+ if (err instanceof ApiError) {
102
+ const guidance = createProjectGuidance(err);
103
+ if (guidance) {
104
+ return { content: [{ type: "text", text: guidance }], isError: true };
105
+ }
106
+ }
107
+ throw err;
108
+ }
109
+ }
110
+ );
111
+ server.tool(
112
+ "list_projects",
113
+ "List all SentientUI projects for the authenticated account.",
114
+ {},
115
+ async () => {
116
+ const projects = await client.get("/projects");
117
+ const text = projects.length === 0 ? "No projects found." : projects.map(
118
+ (p) => `- ${p.name} (id: ${p.id}, type: ${p.context_type}, created: ${p.created_at.slice(0, 10)})`
119
+ ).join("\n");
120
+ return { content: [{ type: "text", text }] };
121
+ }
122
+ );
123
+ server.tool(
124
+ "get_project_stats",
125
+ "Get health stats for a project: event volume, session count, agent calls, and status.",
126
+ { projectId: projectIdSchema },
127
+ async ({ projectId }) => {
128
+ const id = encodeURIComponent(projectId);
129
+ const stats = await client.get(`/projects/${id}/health`);
130
+ const text = [
131
+ `Status: ${stats.status}`,
132
+ `Events (24h): ${stats.events24h}`,
133
+ `Sessions (24h): ${stats.sessions24h}`,
134
+ `Agent calls (total): ${stats.agentCalls}`,
135
+ `Last event: ${stats.lastEventAt ?? "never"}`
136
+ ].join("\n");
137
+ return { content: [{ type: "text", text }] };
138
+ }
139
+ );
140
+ }
141
+
142
+ // src/tools/components.ts
143
+ import { z as z2 } from "zod";
144
+ var projectIdSchema2 = z2.string().uuid().describe("The project UUID");
145
+ function registerComponentTools(server, client) {
146
+ server.tool(
147
+ "list_components",
148
+ "List all adaptive components in a project with variant counts and impression totals.",
149
+ { projectId: projectIdSchema2 },
150
+ async ({ projectId }) => {
151
+ const id = encodeURIComponent(projectId);
152
+ const components = await client.get(`/projects/${id}/components`);
153
+ if (!components.length) {
154
+ return { content: [{ type: "text", text: "No components found for this project." }] };
155
+ }
156
+ const text = components.map(
157
+ (c) => `- ${c.component_id}: ${c.variants.length} variants, ${c.total_impressions} impressions, ${c.total_conversions} conversions`
158
+ ).join("\n");
159
+ return { content: [{ type: "text", text }] };
160
+ }
161
+ );
162
+ server.tool(
163
+ "get_variant_performance",
164
+ "Get CVR and momentum for all variants in a project over the last 7 days vs prior 7 days.",
165
+ { projectId: projectIdSchema2 },
166
+ async ({ projectId }) => {
167
+ const id = encodeURIComponent(projectId);
168
+ const data = await client.get(`/projects/${id}/trends`);
169
+ if (!data.cvr?.length) {
170
+ return { content: [{ type: "text", text: "No variant data available yet." }] };
171
+ }
172
+ const momentumMap = new Map(data.momentum.map((m) => [m.variantId, m.direction]));
173
+ const text = data.cvr.map(
174
+ (v) => `- ${v.variantId}: CVR ${(v.currentCvr * 100).toFixed(2)}% (prior ${(v.priorCvr * 100).toFixed(2)}%, ${v.deltaPp > 0 ? "+" : ""}${v.deltaPp.toFixed(1)} pp, ${momentumMap.get(v.variantId) ?? "stable"})`
175
+ ).join("\n");
176
+ return { content: [{ type: "text", text }] };
177
+ }
178
+ );
179
+ }
180
+
181
+ // src/tools/insights.ts
182
+ import { z as z3 } from "zod";
183
+ var projectIdSchema3 = z3.string().uuid().describe("The project UUID");
184
+ function registerInsightTools(server, client) {
185
+ server.tool(
186
+ "get_insights",
187
+ "Get the latest AI-generated insights: narrator observations and (Growth tier) advisor recommendations.",
188
+ { projectId: projectIdSchema3 },
189
+ async ({ projectId }) => {
190
+ const id = encodeURIComponent(projectId);
191
+ const data = await client.get(`/projects/${id}/insights`);
192
+ if (data.status === "empty") {
193
+ return { content: [{ type: "text", text: "No insights generated yet. Use refresh_insights to generate." }] };
194
+ }
195
+ const lines = [];
196
+ if (data.isStale) lines.push("\u26A0 Insights are stale (>6h old). Consider calling refresh_insights.");
197
+ if (data.generatedAt) lines.push(`Generated: ${new Date(data.generatedAt).toUTCString()}`);
198
+ lines.push("");
199
+ lines.push("Observations:");
200
+ (data.narratorBullets ?? []).forEach((b) => lines.push(`- ${b}`));
201
+ if (data.advisorBullets?.length) {
202
+ lines.push("");
203
+ lines.push("Recommendations:");
204
+ data.advisorBullets.forEach((b) => lines.push(`- ${b}`));
205
+ }
206
+ return { content: [{ type: "text", text: lines.join("\n") }] };
207
+ }
208
+ );
209
+ }
210
+
211
+ // src/tools/personas.ts
212
+ import { z as z4 } from "zod";
213
+ var projectIdSchema4 = z4.string().uuid().describe("The project UUID");
214
+ function registerPersonaTools(server, client) {
215
+ server.tool(
216
+ "get_persona_breakdown",
217
+ "Get the distribution of visitor persona clusters with session counts and reliability scores.",
218
+ { projectId: projectIdSchema4 },
219
+ async ({ projectId }) => {
220
+ const id = encodeURIComponent(projectId);
221
+ const data = await client.get(`/projects/${id}/portraits`);
222
+ if (!data.clusters.length) {
223
+ return { content: [{ type: "text", text: "No persona clusters yet. More visitor data is needed." }] };
224
+ }
225
+ const lines = [
226
+ `Total sessions: ${data.totalSessions}`,
227
+ "",
228
+ "Clusters:",
229
+ ...data.clusters.map((c) => {
230
+ const pct = data.totalSessions > 0 ? c.sessionCount / data.totalSessions * 100 : 0;
231
+ return `- ${c.label}: ${c.sessionCount} sessions (${pct.toFixed(1)}% of traffic, reliability ${(c.avgReliability * 100).toFixed(0)}%)`;
232
+ })
233
+ ];
234
+ return { content: [{ type: "text", text: lines.join("\n") }] };
235
+ }
236
+ );
237
+ }
238
+
239
+ // src/tools/goals.ts
240
+ import { z as z5 } from "zod";
241
+ var projectIdSchema5 = z5.string().uuid().describe("The project UUID");
242
+ function registerGoalTools(server, client) {
243
+ server.tool(
244
+ "get_goal_funnel",
245
+ "Get goal hit counts, unique-session conversion rates, and per-variant breakdown.",
246
+ { projectId: projectIdSchema5 },
247
+ async ({ projectId }) => {
248
+ const id = encodeURIComponent(projectId);
249
+ const data = await client.get(`/projects/${id}/goals`);
250
+ if (!data.goals.length) {
251
+ return { content: [{ type: "text", text: "No goals configured for this project." }] };
252
+ }
253
+ const lines = data.goals.flatMap((g) => [
254
+ `${g.goalName}: ${g.hits} hits, ${g.uniqueSessions} unique sessions, ${(g.pct * 100).toFixed(1)}% conversion`,
255
+ ...g.variants.map((v) => ` ${v.componentId}/${v.variantId}: ${(v.completionRate * 100).toFixed(1)}% per assigned session`),
256
+ ""
257
+ ]);
258
+ return { content: [{ type: "text", text: lines.join("\n").trim() }] };
259
+ }
260
+ );
261
+ }
262
+
263
+ // src/tools/guardrails.ts
264
+ import { z as z6 } from "zod";
265
+ var projectIdSchema6 = z6.string().uuid().describe("The project UUID");
266
+ function registerGuardrailTools(server, client) {
267
+ server.tool(
268
+ "list_guardrail_events",
269
+ "List variants currently paused by the guardrail in the last 24 hours.",
270
+ { projectId: projectIdSchema6 },
271
+ async ({ projectId }) => {
272
+ const id = encodeURIComponent(projectId);
273
+ const data = await client.get(`/projects/${id}/guardrail-events`);
274
+ if (!data.guardrailEvents.length) {
275
+ return { content: [{ type: "text", text: "No active guardrail events in the last 24 hours." }] };
276
+ }
277
+ const lines = data.guardrailEvents.map(
278
+ (e) => `- ${e.componentId}: variants [${e.variantIds.join(", ")}] paused${e.pausedAt ? ` at ${e.pausedAt}` : ""}`
279
+ );
280
+ return { content: [{ type: "text", text: lines.join("\n") }] };
281
+ }
282
+ );
283
+ }
284
+
285
+ // src/tools/layout.ts
286
+ import { z as z7 } from "zod";
287
+ var projectIdSchema7 = z7.string().uuid().describe("The project UUID");
288
+ function registerLayoutTools(server, client) {
289
+ server.tool(
290
+ "get_layout_stats",
291
+ "Get per-persona section layout rankings and bandit reward weights.",
292
+ { projectId: projectIdSchema7 },
293
+ async ({ projectId }) => {
294
+ const id = encodeURIComponent(projectId);
295
+ const stats = await client.get(`/projects/${id}/layout-stats`);
296
+ if (!stats.length) {
297
+ return { content: [{ type: "text", text: "No layout data yet. More visitor sessions are needed." }] };
298
+ }
299
+ const text = stats.map(
300
+ (s) => `- ${s.persona}: [${s.layoutOrder.join(" \u2192 ")}] (avg reward: ${s.avgReward.toFixed(2)}, ${s.pulls} pulls)`
301
+ ).join("\n");
302
+ return { content: [{ type: "text", text }] };
303
+ }
304
+ );
305
+ }
306
+
307
+ // src/tools/variants.ts
308
+ import { z as z8 } from "zod";
309
+ var projectIdSchema8 = z8.string().uuid().describe("The project UUID");
310
+ function registerVariantWriteTools(server, client) {
311
+ server.tool(
312
+ "create_variant",
313
+ "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 a paid plan (server keys are Starter+; anonymous demo tokens are read-only).",
314
+ {
315
+ projectId: projectIdSchema8,
316
+ componentId: z8.string().describe("The component ID to add a variant to"),
317
+ displayName: z8.string().describe("Human-readable name for the new variant"),
318
+ 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.")
319
+ },
320
+ async ({ projectId, componentId, displayName, content }) => {
321
+ const id = encodeURIComponent(projectId);
322
+ const result = await client.post(
323
+ `/projects/${id}/variants`,
324
+ { componentId, displayName, content }
325
+ );
326
+ const contentNote = content ? " with the provided text content" : " (empty \u2014 add its text content from the dashboard or via a follow-up update)";
327
+ return {
328
+ content: [{
329
+ type: "text",
330
+ 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).`
331
+ }]
332
+ };
333
+ }
334
+ );
335
+ server.tool(
336
+ "pause_variant",
337
+ "Pause a variant, stopping traffic from being assigned to it.",
338
+ {
339
+ projectId: projectIdSchema8,
340
+ componentId: z8.string().describe("The component ID"),
341
+ variantId: z8.string().describe("The variant ID to pause")
342
+ },
343
+ async ({ projectId, componentId, variantId }) => {
344
+ const id = encodeURIComponent(projectId);
345
+ await client.post(`/projects/${id}/variants/pause`, { componentId, variantId });
346
+ return {
347
+ content: [{
348
+ type: "text",
349
+ text: `Variant ${variantId} in component ${componentId} has been paused. No new traffic will be assigned to it.`
350
+ }]
351
+ };
352
+ }
353
+ );
354
+ server.tool(
355
+ "refresh_insights",
356
+ "Trigger fresh AI insight generation for a project. Returns immediately; use get_insights in ~15 seconds to see results.",
357
+ { projectId: projectIdSchema8 },
358
+ async ({ projectId }) => {
359
+ const id = encodeURIComponent(projectId);
360
+ await client.post(`/projects/${id}/insights/refresh`);
361
+ return {
362
+ content: [{
363
+ type: "text",
364
+ text: `Insights are generating for project ${projectId}. Call get_insights in ~15 seconds to see the results.`
365
+ }]
366
+ };
367
+ }
368
+ );
369
+ }
370
+
371
+ // src/tools/variant-brief.ts
372
+ import { z as z9 } from "zod";
373
+ var projectIdSchema9 = z9.string().uuid().describe("The project UUID");
374
+ var GOAL_TARGET = 500;
375
+ var BEST_PRACTICE_PRIORS = {
376
+ ecommerce: [
377
+ "Lead with the core benefit/value, not features.",
378
+ "Make the primary action (add to cart / buy) unmistakable and high-contrast.",
379
+ "Reduce purchase anxiety near the decision: free returns, shipping, secure checkout, guarantees.",
380
+ 'Add credible social proof (ratings, review count, "X sold").',
381
+ "Use urgency/scarcity only when it is genuinely true (low stock, real deadline).",
382
+ "Cut friction: fewer steps, clearer pricing, no surprise costs."
383
+ ],
384
+ saas: [
385
+ "Lead with the outcome the user gets, not the mechanism.",
386
+ 'Make the primary CTA action-oriented and specific (e.g. "Start free trial").',
387
+ 'Reduce signup friction (fewer fields, SSO, "no credit card required").',
388
+ "Add proof near the CTA: customer logos, a hard metric, a short testimonial.",
389
+ "Address the target persona's top objection inline."
390
+ ],
391
+ landing: [
392
+ "One clear message and one primary action above the fold.",
393
+ "Match the headline to the traffic source / campaign intent.",
394
+ "Make the CTA specific and benefit-led.",
395
+ "Add a single strong proof point; remove competing distractions."
396
+ ],
397
+ marketplace: [
398
+ "Reduce choice overload: guide the visitor to a clear next step.",
399
+ "Surface trust and liquidity signals (ratings, counts, recency).",
400
+ "Make the primary action on each listing obvious.",
401
+ "Reassure on safety/guarantees near the point of decision."
402
+ ]
403
+ };
404
+ var GENERIC_PRIORS = [
405
+ "Make the primary action unmistakable and benefit-led.",
406
+ "Lead with the outcome/value for the visitor.",
407
+ "Remove friction and distractions around the decision.",
408
+ "Add one credible proof point near the action."
409
+ ];
410
+ function priorsFor(contextType) {
411
+ return BEST_PRACTICE_PRIORS[contextType] ?? GENERIC_PRIORS;
412
+ }
413
+ function computeDataState(impressions, insights, avgReliability) {
414
+ if (impressions === 0) return "empty";
415
+ const insightsReady = insights?.status === "ok" && !insights.isStale;
416
+ const reliable = avgReliability === null || avgReliability >= 0.3;
417
+ if (impressions < GOAL_TARGET || !insightsReady || !reliable) return "collecting";
418
+ return "sufficient";
419
+ }
420
+ function guidanceFor(dataState, contextType) {
421
+ switch (dataState) {
422
+ case "sufficient":
423
+ 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.";
424
+ case "collecting":
425
+ 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.";
426
+ case "empty":
427
+ 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.`;
428
+ }
429
+ }
430
+ async function settled(p) {
431
+ try {
432
+ return await p;
433
+ } catch {
434
+ return null;
435
+ }
436
+ }
437
+ function registerVariantBriefTools(server, client) {
438
+ server.tool(
439
+ "get_variant_brief",
440
+ "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.",
441
+ {
442
+ projectId: projectIdSchema9,
443
+ componentId: z9.string().describe('The component ID to write a new variant for (matches <Adaptive id="...">).')
444
+ },
445
+ async ({ projectId, componentId }) => {
446
+ const id = encodeURIComponent(projectId);
447
+ const [projects, components, trends, portraits, insights] = await Promise.all([
448
+ settled(client.get("/projects")),
449
+ settled(client.get(`/projects/${id}/components`)),
450
+ settled(client.get(`/projects/${id}/trends`)),
451
+ settled(client.get(`/projects/${id}/portraits`)),
452
+ settled(client.get(`/projects/${id}/insights`))
453
+ ]);
454
+ const project = projects?.find((p) => p.id === projectId) ?? null;
455
+ const contextType = project?.context_type ?? "unknown";
456
+ const component = components?.find((c) => c.component_id === componentId) ?? null;
457
+ const impressions = component?.total_impressions ?? 0;
458
+ const conversions = component?.total_conversions ?? 0;
459
+ const componentCvr = impressions > 0 ? conversions / impressions * 100 : 0;
460
+ const existingVariantIds = component?.variants.map((v) => v.variant_id) ?? [];
461
+ const variantIdSet = new Set(existingVariantIds);
462
+ const momentumMap = new Map((trends?.momentum ?? []).map((m) => [m.variantId, m.direction]));
463
+ const variantPerf = (trends?.cvr ?? []).filter((v) => variantIdSet.has(v.variantId));
464
+ const clusters = portraits?.clusters ?? [];
465
+ const totalSessions = portraits?.totalSessions ?? 0;
466
+ const avgReliability = clusters.length ? clusters.reduce((s, c) => s + c.avgReliability, 0) / clusters.length : null;
467
+ const dataState = computeDataState(impressions, insights, avgReliability);
468
+ const lines = [];
469
+ lines.push(`# Variant brief \u2014 ${componentId}`);
470
+ lines.push(`Project context type: ${contextType}`);
471
+ lines.push("");
472
+ if (!component) {
473
+ lines.push(
474
+ `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.`
475
+ );
476
+ lines.push("");
477
+ } else {
478
+ lines.push(
479
+ `Component performance: ${impressions} impressions, ${conversions} conversions, ${componentCvr.toFixed(2)}% CVR.`
480
+ );
481
+ lines.push(
482
+ `Existing variant IDs (do not reuse these): ${existingVariantIds.length ? existingVariantIds.join(", ") : "(none)"}`
483
+ );
484
+ lines.push("");
485
+ }
486
+ if (variantPerf.length) {
487
+ lines.push("Current variant performance (7d vs prior 7d):");
488
+ for (const v of variantPerf) {
489
+ lines.push(
490
+ `- ${v.variantId}: ${(v.currentCvr * 100).toFixed(2)}% CVR (${v.deltaPp > 0 ? "+" : ""}${v.deltaPp.toFixed(1)} pp, ${momentumMap.get(v.variantId) ?? "stable"})`
491
+ );
492
+ }
493
+ lines.push("");
494
+ }
495
+ if (clusters.length) {
496
+ lines.push(`Audience (${totalSessions} sessions):`);
497
+ for (const c of clusters) {
498
+ const share = totalSessions > 0 ? (c.sessionCount / totalSessions * 100).toFixed(0) : "0";
499
+ lines.push(`- ${c.label}: ${share}% of traffic (reliability ${(c.avgReliability * 100).toFixed(0)}%)`);
500
+ }
501
+ lines.push("");
502
+ }
503
+ if (insights && insights.status === "ok") {
504
+ if (insights.isStale) lines.push("\u26A0 Insights are stale (>6h). Consider refresh_insights for a fresher read.");
505
+ const narrator = insights.narratorBullets ?? [];
506
+ const advisor = insights.advisorBullets ?? [];
507
+ if (narrator.length) {
508
+ lines.push("Insights \u2014 observations:");
509
+ narrator.forEach((b) => lines.push(`- ${b}`));
510
+ }
511
+ if (advisor.length) {
512
+ lines.push("Insights \u2014 recommendations:");
513
+ advisor.forEach((b) => lines.push(`- ${b}`));
514
+ }
515
+ lines.push("");
516
+ } else {
517
+ lines.push("Insights: none generated yet.");
518
+ lines.push("");
519
+ }
520
+ lines.push(`## Data sufficiency: ${dataState.toUpperCase()}`);
521
+ lines.push(guidanceFor(dataState, contextType));
522
+ lines.push("");
523
+ lines.push(`## Best-practice priors (${contextType})`);
524
+ priorsFor(contextType).forEach((p) => lines.push(`- ${p}`));
525
+ lines.push("");
526
+ lines.push("## How to implement (code-native variant)");
527
+ lines.push(`1. Search the repository for <Adaptive id="${componentId}"> (and any matching useAssignment("${componentId}") usage).`);
528
+ 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").');
529
+ 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.");
530
+ 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.");
531
+ lines.push("5. Commit, push, and deploy. Optionally enable shadow mode for this component first if you want to validate before serving real traffic.");
532
+ lines.push("");
533
+ lines.push("Make the change reflect the data sufficiency above: data-driven when SUFFICIENT, best-practice-led when COLLECTING or EMPTY.");
534
+ return { content: [{ type: "text", text: lines.join("\n") }] };
535
+ }
536
+ );
537
+ }
538
+
539
+ // src/tools/test-brief.ts
540
+ import { z as z10 } from "zod";
541
+ var projectIdSchema10 = z10.string().uuid().describe("The project UUID");
542
+ async function settled2(p) {
543
+ try {
544
+ return await p;
545
+ } catch {
546
+ return null;
547
+ }
548
+ }
549
+ function registerTestBriefTools(server, client) {
550
+ server.tool(
551
+ "get_test_brief",
552
+ "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).",
553
+ {
554
+ projectId: projectIdSchema10,
555
+ componentId: z10.string().describe('The component ID to write a test for (matches <Adaptive id="...">).')
556
+ },
557
+ async ({ projectId, componentId }) => {
558
+ const id = encodeURIComponent(projectId);
559
+ const [components, goalsRes] = await Promise.all([
560
+ settled2(client.get(`/projects/${id}/components`)),
561
+ settled2(client.get(`/projects/${id}/goals`))
562
+ ]);
563
+ const component = components?.find((c) => c.component_id === componentId) ?? null;
564
+ const variantIds = component?.variants.map((v) => v.variant_id) ?? [];
565
+ const goals = Array.isArray(goalsRes) ? goalsRes : goalsRes?.goals ?? [];
566
+ const goalName = goals[0]?.goalName ?? "signup";
567
+ const controlId = variantIds[0] ?? "control";
568
+ const forcedId = variantIds.find((v) => v !== controlId) ?? "variant_b";
569
+ const lines = [];
570
+ lines.push(`# Test brief \u2014 ${componentId}`);
571
+ lines.push("");
572
+ 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.");
573
+ if (!component) {
574
+ lines.push("");
575
+ 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 }}\`.`);
576
+ }
577
+ lines.push("");
578
+ lines.push("## React Testing Library");
579
+ lines.push("```tsx");
580
+ lines.push(`import { renderWithSentient } from '@sentientui/react/testing';`);
581
+ lines.push(`import { screen } from '@testing-library/react';`);
582
+ lines.push("");
583
+ lines.push(`test('${componentId}: forces the "${forcedId}" variant', () => {`);
584
+ lines.push(` renderWithSentient(<YourPage />, { variants: { ${componentId}: '${forcedId}' } });`);
585
+ lines.push(` // assert on the ${forcedId} variant's content:`);
586
+ lines.push(` // expect(screen.getByText('\u2026')).toBeInTheDocument();`);
587
+ lines.push("});");
588
+ lines.push("```");
589
+ lines.push("");
590
+ lines.push("## Assert a goal fires (with the mock server)");
591
+ lines.push("```tsx");
592
+ lines.push(`import { setupSentientServer } from '@sentientui/react/testing/node';`);
593
+ lines.push(`import { getSentientEvents, hasFiredGoal } from '@sentientui/react/testing';`);
594
+ lines.push("");
595
+ lines.push(`const s = setupSentientServer();`);
596
+ lines.push(`afterAll(() => s.server.close());`);
597
+ lines.push("");
598
+ lines.push(`test('${componentId}: fires the ${goalName} goal', async () => {`);
599
+ lines.push(` s.use({ variants: { ${componentId}: '${forcedId}' } });`);
600
+ lines.push(` // \u2026render with a live client, trigger the interaction\u2026`);
601
+ lines.push(` expect(hasFiredGoal(getSentientEvents(), '${goalName}')).toBe(true);`);
602
+ lines.push("});");
603
+ lines.push("```");
604
+ lines.push("");
605
+ lines.push("## E2E (Playwright / Cypress)");
606
+ lines.push(`Use \`mockSentient\` to force variants/layout, stub the API, and capture events:`);
607
+ lines.push("```ts");
608
+ lines.push(`import { mockSentient } from '@sentientui/react/testing';`);
609
+ lines.push("");
610
+ lines.push(`const s = await mockSentient(page, { variants: { ${componentId}: '${forcedId}' } });`);
611
+ lines.push(`await page.goto('/');`);
612
+ lines.push(`expect(s.events().some((e) => e.goalType === '${goalName}')).toBe(true);`);
613
+ lines.push("```");
614
+ lines.push(`Cypress: \`mockSentientCypress(cy, scenario)\` in a beforeEach. **Prefer mockSentient in CI \u2014 it writes nothing.**`);
615
+ lines.push(`The URL param below is fine for a quick local pin, but a live client still creates an (automation-flagged) session:`);
616
+ lines.push("```ts");
617
+ lines.push(`await page.goto('/?sentient_variant=${componentId}:${forcedId}');`);
618
+ lines.push("```");
619
+ return { content: [{ type: "text", text: lines.join("\n") }] };
620
+ }
621
+ );
622
+ }
623
+
624
+ // src/tools/integration-guide.ts
625
+ var GUIDE = `# SentientUI integration guide \u2014 the adaptive ladder
626
+
627
+ SentientUI adapts a site per visitor type (personas: buyer, researcher, deal_seeker, browser,
628
+ unknown), learning from real conversions. Decisions are locked per session: Visit 1 learns,
629
+ Visit 2 converts. Integrate one rung at a time.
630
+
631
+ ## Setup (60 seconds, no account)
632
+
633
+ 1. Run \`npx @sentientui/cli init\` (detects Next App/Pages, Vite, Remix, CRA; installs
634
+ @sentientui/react; writes .env.local; scaffolds an example). It does NOT edit your layout \u2014
635
+ it prints the wrap snippet for step 2.
636
+ 2. Wrap the root layout with <AdaptiveRoot apiKey context> (from '@sentientui/react/next';
637
+ other React apps use <AdaptiveProvider> from '@sentientui/react') and add
638
+ suppressHydrationWarning to <html> \u2014 an inline script sets persona attributes pre-paint.
639
+ Nothing adapts and nothing is tracked until this wrap is in place.
640
+ 3. \`npm run dev\`, then open the app with \`?sentient_persona=buyer\` vs
641
+ \`?sentient_persona=deal_seeker\` to see it adapt. No API key needed (keyless local mode).
642
+ 4. To learn from real traffic: create a project at https://sentient-ui.com and set
643
+ NEXT_PUBLIC_SENTIENT_API_KEY=pk_... in .env.local.
644
+
645
+ ## Rung 1 \u2014 Style (CSS only)
646
+
647
+ Persona attributes on <html> (zero declaration):
648
+
649
+ html[data-sentient-persona='deal_seeker'] .discount-banner { display: block; }
650
+ html[data-sentient-confidence='low'] .discount-banner { display: none; }
651
+
652
+ Learned style tokens (element-scoped, SSR-safe):
653
+
654
+ const t = useAdaptiveTokens('hero', {
655
+ tone: ['calm', 'urgent'], // first value = baseline
656
+ });
657
+ return <section {...t.props} className="hero">\u2026</section>;
658
+ // CSS: .hero[data-tone='urgent'] .cta { font-weight: 700; }
659
+
660
+ Rules: 1-4 dims, 2-6 values each, enum values only. For animation values, always add a
661
+ prefers-reduced-motion: reduce override in CSS.
662
+
663
+ ## Rung 2 \u2014 Swap (alternate content)
664
+
665
+ const { value, bind } = useAdaptive('buy-box', {
666
+ variants: { calm: <CalmBuyBox/>, urgent: <UrgentBuyBox/> }, // first key = baseline
667
+ goal: 'buy_click', // REQUIRED
668
+ });
669
+ return <div {...bind}>{value}</div>;
670
+
671
+ Always attach bind \u2014 it wires exposure tracking and goal listeners. <Adaptive> is the wrapper
672
+ form; <AdaptiveText> swaps dashboard-managed text.
673
+
674
+ ## Rung 3 \u2014 Reorder (structure)
675
+
676
+ <AdaptiveGroup id="pricing-area" arrangements={{
677
+ standard: ['plans', 'faq', 'social'], // first key = baseline
678
+ social_first: ['social', 'plans', 'faq'],
679
+ }}>
680
+ <PlanGrid key="plans"/> <Faq key="faq"/> <Testimonials key="social"/>
681
+ </AdaptiveGroup>
682
+
683
+ Declared orders of keyed children only. Page-level: sections={[...]} on AdaptiveRoot +
684
+ useLayoutOrder().
685
+
686
+ ## Testing the integration
687
+
688
+ Use '@sentientui/react/testing': renderWithSentient(ui, { variants, slots, persona }) forces
689
+ deterministic outcomes so tests never depend on what the optimizer serves.
690
+ `;
691
+ function registerIntegrationGuideTools(server) {
692
+ server.tool(
693
+ "get_integration_guide",
694
+ "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.",
695
+ {},
696
+ async () => ({ content: [{ type: "text", text: GUIDE }] })
697
+ );
698
+ }
699
+
700
+ // src/server.ts
701
+ var { version: PKG_VERSION } = createRequire(import.meta.url)("../package.json");
702
+ function createMcpServer(client) {
703
+ const server = new McpServer({
704
+ name: "@sentientui/mcp",
705
+ version: PKG_VERSION
706
+ });
707
+ registerProjectTools(server, client);
708
+ registerComponentTools(server, client);
709
+ registerInsightTools(server, client);
710
+ registerPersonaTools(server, client);
711
+ registerGoalTools(server, client);
712
+ registerGuardrailTools(server, client);
713
+ registerLayoutTools(server, client);
714
+ registerVariantBriefTools(server, client);
715
+ registerTestBriefTools(server, client);
716
+ registerVariantWriteTools(server, client);
717
+ registerIntegrationGuideTools(server);
718
+ return server;
719
+ }
720
+
721
+ export {
722
+ ApiError,
723
+ ApiClient,
724
+ createMcpServer
725
+ };