@flusterduck/mcp-server 0.5.2 → 0.5.4

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.
@@ -1,733 +0,0 @@
1
- // src/index.ts
2
- import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { z } from "zod";
4
-
5
- // src/api-client.ts
6
- var MACHINE_KEY_PATTERN = /^fd_(sec|mcp)_[A-Za-z0-9_-]{48}$/;
7
- var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
8
- function assertMachineKey(value) {
9
- const key = value.trim();
10
- if (!MACHINE_KEY_PATTERN.test(key)) {
11
- throw new Error("Invalid API key format. Expected a high-entropy fd_sec_ or fd_mcp_ key.");
12
- }
13
- return key;
14
- }
15
- function assertUuid(value, name) {
16
- if (!UUID_PATTERN.test(value)) throw new Error(`${name} must be a UUID.`);
17
- return value;
18
- }
19
- function buildQueryUrl(config, route, params = {}) {
20
- const base = assertSafeApiBase(config.baseUrl);
21
- const url = new URL(route.replace(/^\/+/, ""), base);
22
- url.searchParams.set("site_id", assertUuid(config.siteId, "site_id"));
23
- for (const [key, value] of Object.entries(params)) {
24
- if (value === void 0 || value === null || value === "") continue;
25
- url.searchParams.set(key, String(value));
26
- }
27
- return url;
28
- }
29
- function assertSafeApiBase(baseUrl) {
30
- const base = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
31
- if (base.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname)) {
32
- throw new Error("HTTP is only allowed for local Flusterduck API testing.");
33
- }
34
- return base;
35
- }
36
- var FlusterduckAPI = class {
37
- config;
38
- constructor(config) {
39
- this.config = {
40
- baseUrl: config.baseUrl,
41
- apiKey: assertMachineKey(config.apiKey),
42
- siteId: assertUuid(config.siteId, "site_id"),
43
- orgId: config.orgId ? assertUuid(config.orgId, "org_id") : void 0
44
- };
45
- }
46
- getContext() {
47
- return this.request("query/mcp/context");
48
- }
49
- getScores() {
50
- return this.request("query/scores");
51
- }
52
- getPageDetail(page) {
53
- return this.request("query/page", { page });
54
- }
55
- getAlerts(status) {
56
- return this.request("query/alerts", { status });
57
- }
58
- getIssues(status) {
59
- return this.request("query/issues", { status });
60
- }
61
- getElements(page) {
62
- return this.request("query/elements", { page });
63
- }
64
- getSessionDetail(sessionId) {
65
- return this.request("query/session", { session_id: sessionId });
66
- }
67
- getFlows(limit) {
68
- return this.request("query/flows", { limit });
69
- }
70
- getTrends(days = 7, page) {
71
- const safeDays = Math.max(1, Math.min(Math.trunc(Number(days) || 7), 90));
72
- return this.request("query/trends", { days: safeDays, page });
73
- }
74
- getDeployImpact() {
75
- return this.request("query/deploys");
76
- }
77
- getRecommendations() {
78
- return this.request("query/recommendations");
79
- }
80
- compare(a, b) {
81
- return this.request("query/compare", { a, b });
82
- }
83
- getRevenueImpact() {
84
- return this.request("query/revenue");
85
- }
86
- getHeuristics() {
87
- return this.request("query/heuristics");
88
- }
89
- getJourneyFriction(params = {}) {
90
- return this.request("query/journeys/friction", params);
91
- }
92
- queryRawRows(params) {
93
- return this.request("query/raw", params);
94
- }
95
- downloadEventsCsv(params = {}) {
96
- return this.requestText("query/export/events.csv", params, "text/csv");
97
- }
98
- getAuditLog(limit = 100) {
99
- if (!this.config.orgId) throw new Error("orgId is required for audit log queries.");
100
- return this.request("query/audit", { org_id: this.config.orgId, limit }, false);
101
- }
102
- getDegradation() {
103
- if (!this.config.orgId) throw new Error("orgId is required for degradation queries.");
104
- return this.request("query/degradation", { org_id: this.config.orgId }, false);
105
- }
106
- getWebhookDeliveries(limit = 100) {
107
- if (!this.config.orgId) throw new Error("orgId is required for webhook delivery queries.");
108
- return this.request("query/webhook-deliveries", { org_id: this.config.orgId, limit }, false);
109
- }
110
- getIssueDetail(issueId) {
111
- return this.request(`query/issues/${assertUuid(issueId, "issue_id")}`, {});
112
- }
113
- getAlertRules() {
114
- return this.request("manage/alert-rules", {});
115
- }
116
- updateAlertRule(ruleId, update) {
117
- return this.mutate("PATCH", `manage/alert-rules/${assertUuid(ruleId, "rule_id")}`, update);
118
- }
119
- updateIssue(issueId, update) {
120
- return this.mutate("PATCH", `manage/issues/${assertUuid(issueId, "issue_id")}`, update);
121
- }
122
- updateAlert(alertId, status, resolvedReason) {
123
- const body = { status };
124
- if (resolvedReason) body.resolved_reason = resolvedReason;
125
- return this.mutate("PATCH", `manage/alerts/${assertUuid(alertId, "alert_id")}`, body);
126
- }
127
- addAnnotation(message) {
128
- return this.mutate("POST", "manage/annotations", {
129
- site_id: this.config.siteId,
130
- message,
131
- type: "mcp"
132
- });
133
- }
134
- createAlertRule(rule) {
135
- return this.mutate("POST", "manage/alert-rules", {
136
- site_id: this.config.siteId,
137
- ...rule
138
- });
139
- }
140
- deleteAlertRule(ruleId) {
141
- return this.mutate("DELETE", `manage/alert-rules/${assertUuid(ruleId, "rule_id")}`);
142
- }
143
- async mutate(method, route, body) {
144
- const base = assertSafeApiBase(this.config.baseUrl);
145
- const url = new URL(route.replace(/^\/+/, ""), base);
146
- const response = await fetch(url, {
147
- method,
148
- headers: {
149
- authorization: `Bearer ${this.config.apiKey}`,
150
- "content-type": "application/json",
151
- accept: "application/json"
152
- },
153
- body: body !== void 0 ? JSON.stringify(body) : void 0
154
- });
155
- let envelope;
156
- try {
157
- envelope = await response.json();
158
- } catch {
159
- throw new Error(`Flusterduck API returned non-JSON response (${response.status}).`);
160
- }
161
- if (!response.ok || envelope.error) {
162
- const status = envelope.error?.status ?? response.status;
163
- const code = envelope.error?.code ?? "unknown";
164
- const message = envelope.error?.message ?? "Flusterduck API request failed.";
165
- throw new Error(`Flusterduck API error (${status}: ${code}): ${message}`);
166
- }
167
- return envelope.data;
168
- }
169
- async request(route, params = {}, includeSiteId = true) {
170
- const url = includeSiteId ? buildQueryUrl(this.config, route, params) : buildOrgUrl(this.config, route, params);
171
- const response = await fetch(url, {
172
- headers: {
173
- authorization: `Bearer ${this.config.apiKey}`,
174
- accept: "application/json"
175
- }
176
- });
177
- let envelope;
178
- try {
179
- envelope = await response.json();
180
- } catch {
181
- throw new Error(`Flusterduck API returned non-JSON response (${response.status}).`);
182
- }
183
- if (!response.ok || envelope.error) {
184
- const status = envelope.error?.status ?? response.status;
185
- const code = envelope.error?.code ?? "unknown";
186
- throw new Error(`Flusterduck API request failed (${status}: ${code}).`);
187
- }
188
- return envelope.data;
189
- }
190
- async requestText(route, params = {}, accept = "text/plain") {
191
- const url = buildQueryUrl(this.config, route, params);
192
- const response = await fetch(url, {
193
- headers: {
194
- authorization: `Bearer ${this.config.apiKey}`,
195
- accept
196
- }
197
- });
198
- const text = await response.text();
199
- if (!response.ok) {
200
- throw new Error(`Flusterduck API request failed (${response.status}).`);
201
- }
202
- return text;
203
- }
204
- };
205
- function buildOrgUrl(config, route, params) {
206
- const base = assertSafeApiBase(config.baseUrl);
207
- const url = new URL(route.replace(/^\/+/, ""), base);
208
- for (const [key, value] of Object.entries(params)) {
209
- if (value === void 0 || value === null || value === "") continue;
210
- url.searchParams.set(key, String(value));
211
- }
212
- return url;
213
- }
214
-
215
- // src/index.ts
216
- function jsonText(data) {
217
- return JSON.stringify(data, null, 2);
218
- }
219
- function toolResult(data) {
220
- return { content: [{ type: "text", text: jsonText(data) }] };
221
- }
222
- function toolError(error) {
223
- const message = error instanceof Error ? error.message : "Unknown Flusterduck MCP error.";
224
- return { isError: true, content: [{ type: "text", text: message }] };
225
- }
226
- function createFlusterduckMCPServer(config) {
227
- const api = new FlusterduckAPI(config);
228
- const server = new McpServer({ name: "flusterduck-local", version: "0.3.0" });
229
- const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
230
- const writeHint = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
231
- const mutateHint = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
232
- const deleteHint = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
233
- const rawTable = z.enum(["events", "signals", "sessions", "page_scores", "score_history", "ux_issues", "alerts", "deploys"]);
234
- const sortDir = z.enum(["asc", "desc"]).optional();
235
- const rawFilters = {
236
- page: z.string().min(1).max(500).optional(),
237
- since: z.string().datetime().optional(),
238
- until: z.string().datetime().optional(),
239
- event_type: z.string().min(1).max(80).optional(),
240
- signal_type: z.string().min(1).max(80).optional(),
241
- status: z.string().min(1).max(80).optional(),
242
- session_id: z.string().min(16).max(128).optional()
243
- };
244
- server.tool("get_site_context", "Get the full Flusterduck MCP snapshot for the configured site: scores, open issues, active alerts, deploys, and recommendations. Start here for any investigation.", {}, readOnly, async () => {
245
- try {
246
- return toolResult(await api.getContext());
247
- } catch (error) {
248
- return toolError(error);
249
- }
250
- });
251
- server.tool("get_scores", "Get current confusion scores for every tracked page, sorted by friction intensity.", {}, readOnly, async () => {
252
- try {
253
- return toolResult(await api.getScores());
254
- } catch (error) {
255
- return toolError(error);
256
- }
257
- });
258
- server.tool("get_page", "Get the full context for one page: score history, active issues, element friction, deploys, annotations, and confusion budgets.", {
259
- page: z.string().min(1).max(500)
260
- }, readOnly, async ({ page }) => {
261
- try {
262
- return toolResult(await api.getPageDetail(page));
263
- } catch (error) {
264
- return toolError(error);
265
- }
266
- });
267
- server.tool("get_elements", "Get element-level friction summaries: which buttons, forms, and links generate the most behavioral signals.", {
268
- page: z.string().min(1).max(500).optional()
269
- }, readOnly, async ({ page }) => {
270
- try {
271
- return toolResult(await api.getElements(page));
272
- } catch (error) {
273
- return toolError(error);
274
- }
275
- });
276
- server.tool("get_issues", "Get UX issues for the configured site. Filter by status to focus on what needs attention.", {
277
- status: z.enum(["open", "triaged", "in_progress", "ignored", "resolved", "regressed"]).optional()
278
- }, readOnly, async ({ status }) => {
279
- try {
280
- return toolResult(await api.getIssues(status));
281
- } catch (error) {
282
- return toolError(error);
283
- }
284
- });
285
- server.tool("get_issue", "Get a single UX issue with full evidence, session links, verification history, and deploy correlation. Use this after get_issues to drill into a specific issue.", {
286
- issue_id: z.string().uuid()
287
- }, readOnly, async ({ issue_id }) => {
288
- try {
289
- return toolResult(await api.getIssueDetail(issue_id));
290
- } catch (error) {
291
- return toolError(error);
292
- }
293
- });
294
- server.tool("get_alerts", "Get alerts for the configured site. Filter by status to see what is firing vs already resolved.", {
295
- status: z.enum(["open", "acknowledged", "resolved"]).optional()
296
- }, readOnly, async ({ status }) => {
297
- try {
298
- return toolResult(await api.getAlerts(status));
299
- } catch (error) {
300
- return toolError(error);
301
- }
302
- });
303
- server.tool(
304
- "list_alert_rules",
305
- "List all alert rules configured for the site: trigger types, thresholds, channels, and enabled status. Call this before create_alert_rule or delete_alert_rule to see what already exists. Requires manage:write scope on the API key.",
306
- {},
307
- readOnly,
308
- async () => {
309
- try {
310
- return toolResult(await api.getAlertRules());
311
- } catch (error) {
312
- return toolError(error);
313
- }
314
- }
315
- );
316
- server.tool("get_session_detail", "Get the full event timeline for a specific session: every signal, click, scroll, and page view in order.", {
317
- session_id: z.string().min(16).max(128)
318
- }, readOnly, async ({ session_id }) => {
319
- try {
320
- return toolResult(await api.getSessionDetail(session_id));
321
- } catch (error) {
322
- return toolError(error);
323
- }
324
- });
325
- server.tool("get_flows", "Get page-to-page flow edges from recent sessions, showing where users come from and where they go.", {
326
- limit: z.number().int().min(1).max(5e3).optional()
327
- }, readOnly, async ({ limit }) => {
328
- try {
329
- return toolResult(await api.getFlows(limit));
330
- } catch (error) {
331
- return toolError(error);
332
- }
333
- });
334
- server.tool("get_trends", "Get confusion score history for the configured site or a specific page. Use to spot regressions after deploys.", {
335
- days: z.number().int().min(1).max(90).optional(),
336
- page: z.string().min(1).max(500).optional()
337
- }, readOnly, async ({ days, page }) => {
338
- try {
339
- return toolResult(await api.getTrends(days, page));
340
- } catch (error) {
341
- return toolError(error);
342
- }
343
- });
344
- server.tool("get_deploys", "Get deploys known to Flusterduck for this site, including confusion_before/after scores for each.", {}, readOnly, async () => {
345
- try {
346
- return toolResult(await api.getDeployImpact());
347
- } catch (error) {
348
- return toolError(error);
349
- }
350
- });
351
- server.tool("compare_pages", "Compare confusion score records side-by-side for two page paths.", {
352
- a: z.string().min(1).max(500),
353
- b: z.string().min(1).max(500)
354
- }, readOnly, async ({ a, b }) => {
355
- try {
356
- return toolResult(await api.compare(a, b));
357
- } catch (error) {
358
- return toolError(error);
359
- }
360
- });
361
- server.tool("get_recommendations", "Get prioritized fix recommendations ranked by estimated confusion reduction.", {}, readOnly, async () => {
362
- try {
363
- return toolResult(await api.getRecommendations());
364
- } catch (error) {
365
- return toolError(error);
366
- }
367
- });
368
- server.tool("get_revenue_impact", "Get revenue impact estimates for active friction when conversion data is connected.", {}, readOnly, async () => {
369
- try {
370
- return toolResult(await api.getRevenueImpact());
371
- } catch (error) {
372
- return toolError(error);
373
- }
374
- });
375
- server.tool("get_heuristics", "Get the full Flusterduck friction heuristic catalog: all signal types, thresholds, and scoring weights.", {}, readOnly, async () => {
376
- try {
377
- return toolResult(await api.getHeuristics());
378
- } catch (error) {
379
- return toolError(error);
380
- }
381
- });
382
- server.tool("diagnose_journey_friction", "Find high-friction journey edges from recent sessions. Filter by signal type or minimum friction weight to focus on the worst paths.", {
383
- journey_id: z.string().uuid().optional(),
384
- signal_type: z.string().min(1).max(80).optional(),
385
- min_friction_weight: z.number().int().min(0).max(1e3).optional(),
386
- limit: z.number().int().min(1).max(1e3).optional()
387
- }, readOnly, async ({ journey_id, signal_type, min_friction_weight, limit }) => {
388
- try {
389
- return toolResult(await api.getJourneyFriction({ journey_id, signal_type, min_friction_weight, limit }));
390
- } catch (error) {
391
- return toolError(error);
392
- }
393
- });
394
- server.tool("query_raw_rows", "Get SQL-style raw rows from an allowlisted Flusterduck table, scoped to the configured site.", {
395
- table: rawTable,
396
- limit: z.number().int().min(1).max(1e3).optional(),
397
- sort_by: z.string().min(1).max(80).optional(),
398
- sort_dir: sortDir,
399
- ...rawFilters
400
- }, readOnly, async ({ table, limit, sort_by, sort_dir, page, since, until, event_type, signal_type, status, session_id }) => {
401
- try {
402
- return toolResult(await api.queryRawRows({ table, limit, sort_by, sort_dir, page, since, until, event_type, signal_type, status, session_id }));
403
- } catch (error) {
404
- return toolError(error);
405
- }
406
- });
407
- server.tool("download_events_csv", "Return a CSV export of raw event rows for the configured site. Save the returned text as a .csv file.", {
408
- limit: z.number().int().min(1).max(1e4).optional(),
409
- sort_by: z.string().min(1).max(80).optional(),
410
- sort_dir: sortDir,
411
- page: z.string().min(1).max(500).optional(),
412
- since: z.string().datetime().optional(),
413
- until: z.string().datetime().optional(),
414
- event_type: z.string().min(1).max(80).optional(),
415
- signal_type: z.string().min(1).max(80).optional(),
416
- session_id: z.string().min(16).max(128).optional()
417
- }, readOnly, async ({ limit, sort_by, sort_dir, page, since, until, event_type, signal_type, session_id }) => {
418
- try {
419
- return { content: [{ type: "text", text: await api.downloadEventsCsv({ limit, sort_by, sort_dir, page, since, until, event_type, signal_type, session_id }) }] };
420
- } catch (error) {
421
- return toolError(error);
422
- }
423
- });
424
- server.tool("get_audit_log", "Get organization audit log rows. Requires orgId in local MCP config.", {
425
- limit: z.number().int().min(1).max(500).optional()
426
- }, readOnly, async ({ limit }) => {
427
- try {
428
- return toolResult(await api.getAuditLog(limit));
429
- } catch (error) {
430
- return toolError(error);
431
- }
432
- });
433
- server.tool("get_degradation", "Get active and recent backend degradation events. Requires orgId in local MCP config.", {}, readOnly, async () => {
434
- try {
435
- return toolResult(await api.getDegradation());
436
- } catch (error) {
437
- return toolError(error);
438
- }
439
- });
440
- server.tool("get_webhook_deliveries", "Get outbound webhook delivery attempts and failure details. Requires orgId in local MCP config.", {
441
- limit: z.number().int().min(1).max(500).optional()
442
- }, readOnly, async ({ limit }) => {
443
- try {
444
- return toolResult(await api.getWebhookDeliveries(limit));
445
- } catch (error) {
446
- return toolError(error);
447
- }
448
- });
449
- server.tool(
450
- "update_issue",
451
- "Update a UX issue: change its status (open/triaged/in_progress/verified/resolved/ignored), add a triage note, or set an assignee. Resolving or ignoring removes the issue from active triage views. Requires manage:write scope on the API key.",
452
- {
453
- issue_id: z.string().uuid(),
454
- status: z.enum(["open", "triaged", "in_progress", "verified", "resolved", "ignored"]).optional(),
455
- note: z.string().max(1e3).nullable().optional(),
456
- assigned_to: z.string().max(256).nullable().optional()
457
- },
458
- mutateHint,
459
- async ({ issue_id, status, note, assigned_to }) => {
460
- try {
461
- return toolResult(await api.updateIssue(issue_id, { status, note, assigned_to }));
462
- } catch (error) {
463
- return toolError(error);
464
- }
465
- }
466
- );
467
- server.tool(
468
- "update_alert",
469
- "Acknowledge, mark as investigating, or resolve an alert. Resolving stops all channel delivery including PagerDuty. Optionally include a resolved_reason when closing. Requires manage:write scope on the API key.",
470
- {
471
- alert_id: z.string().uuid(),
472
- status: z.enum(["acknowledged", "investigating", "resolved"]),
473
- resolved_reason: z.string().max(500).optional()
474
- },
475
- mutateHint,
476
- async ({ alert_id, status, resolved_reason }) => {
477
- try {
478
- return toolResult(await api.updateAlert(alert_id, status, resolved_reason));
479
- } catch (error) {
480
- return toolError(error);
481
- }
482
- }
483
- );
484
- server.tool(
485
- "add_annotation",
486
- "Add a timeline annotation to the site to mark deploys, investigation findings, resolved incidents, or any notable event for the team. Requires manage:write scope on the API key.",
487
- {
488
- message: z.string().min(1).max(1e3)
489
- },
490
- writeHint,
491
- async ({ message }) => {
492
- try {
493
- return toolResult(await api.addAnnotation(message));
494
- } catch (error) {
495
- return toolError(error);
496
- }
497
- }
498
- );
499
- server.tool(
500
- "create_alert_rule",
501
- "Create a new alert rule. Trigger types: spike, anomaly, new_page, trend, co_occurrence, positive, budget. Channels: email, slack, webhook, mcp, pagerduty. Requires manage:write scope on the API key.",
502
- {
503
- name: z.string().min(1).max(120),
504
- trigger_type: z.enum(["spike", "anomaly", "new_page", "trend", "co_occurrence", "positive", "budget"]),
505
- threshold: z.number().min(0).max(1e3).optional(),
506
- cooldown_minutes: z.number().int().min(1).max(1440).optional(),
507
- channels: z.array(z.enum(["email", "slack", "webhook", "mcp", "pagerduty"])).optional(),
508
- page_pattern: z.string().max(2048).nullable().optional(),
509
- enabled: z.boolean().optional()
510
- },
511
- writeHint,
512
- async ({ name, trigger_type, threshold, cooldown_minutes, channels, page_pattern, enabled }) => {
513
- try {
514
- return toolResult(await api.createAlertRule({ name, trigger_type, threshold, cooldown_minutes, channels, page_pattern, enabled }));
515
- } catch (error) {
516
- return toolError(error);
517
- }
518
- }
519
- );
520
- server.tool(
521
- "update_alert_rule",
522
- "Update an existing alert rule: toggle enabled, change threshold, adjust channels, rename, or update page_pattern. Prefer this over delete_alert_rule when you want to temporarily silence a rule. Requires manage:write scope on the API key.",
523
- {
524
- rule_id: z.string().uuid(),
525
- name: z.string().min(1).max(120).optional(),
526
- trigger_type: z.enum(["spike", "anomaly", "new_page", "trend", "co_occurrence", "positive", "budget"]).optional(),
527
- threshold: z.number().min(0).max(1e3).optional(),
528
- cooldown_minutes: z.number().int().min(1).max(1440).optional(),
529
- channels: z.array(z.enum(["email", "slack", "webhook", "mcp", "pagerduty"])).optional(),
530
- page_pattern: z.string().max(2048).nullable().optional(),
531
- enabled: z.boolean().optional()
532
- },
533
- writeHint,
534
- async ({ rule_id, name, trigger_type, threshold, cooldown_minutes, channels, page_pattern, enabled }) => {
535
- try {
536
- return toolResult(await api.updateAlertRule(rule_id, { name, trigger_type, threshold, cooldown_minutes, channels, page_pattern, enabled }));
537
- } catch (error) {
538
- return toolError(error);
539
- }
540
- }
541
- );
542
- server.tool(
543
- "delete_alert_rule",
544
- "Permanently delete an alert rule by ID. This cannot be undone. Requires manage:write scope on the API key.",
545
- {
546
- rule_id: z.string().uuid()
547
- },
548
- deleteHint,
549
- async ({ rule_id }) => {
550
- try {
551
- return toolResult(await api.deleteAlertRule(rule_id));
552
- } catch (error) {
553
- return toolError(error);
554
- }
555
- }
556
- );
557
- server.resource("current_site_context", "flusterduck://site/context", { description: "Current Flusterduck site context" }, async (uri) => ({
558
- contents: [{ uri: uri.href, mimeType: "application/json", text: jsonText(await api.getContext()) }]
559
- }));
560
- server.resource("page", new ResourceTemplate("flusterduck://page/{path}", { list: void 0 }), { description: "Detailed page context" }, async (uri, variables) => ({
561
- contents: [{ uri: uri.href, mimeType: "application/json", text: jsonText(await api.getPageDetail(String(variables.path))) }]
562
- }));
563
- server.prompt(
564
- "diagnose_page",
565
- "Step-by-step diagnosis of UX friction on a specific page: signals, elements, trends, and a concrete fix recommendation.",
566
- { page_path: z.string().min(1).max(500) },
567
- ({ page_path }) => ({
568
- messages: [{
569
- role: "user",
570
- content: {
571
- type: "text",
572
- text: `Diagnose the UX friction on "${page_path}" using Flusterduck.
573
-
574
- Step 1: Call get_page with page="${page_path}" to get score, trend, active issues, elements, and deploys.
575
- Step 2: Call get_elements with page="${page_path}" to see which specific elements have the highest friction signal counts.
576
- Step 3: Call diagnose_journey_friction to find the highest-friction flow edges across the site. Look for edges where the source or destination path matches "${page_path}" to identify where users struggle entering and exiting this page.
577
- Step 4: Review the score trend. Is this page getting worse, stable, or recovering? How fast?
578
- Step 5: Check recent deploys. Did confusion spike after a specific release?
579
-
580
- Deliver a diagnosis with:
581
- - The three worst friction sources on this page (signal type + element label + count)
582
- - Whether the friction is acute (appeared recently) or chronic (long-standing)
583
- - One specific, actionable fix recommendation with estimated confusion reduction
584
- - Whether any open issues should be triaged or escalated based on the evidence
585
-
586
- Be concrete. Reference actual signal names, element labels, and numbers from the data.`
587
- }
588
- }]
589
- })
590
- );
591
- server.prompt(
592
- "triage_open_issues",
593
- "Systematically triage all open UX issues: classify severity, update the highest-confidence ones, and leave an annotation summarizing what was done.",
594
- {},
595
- () => ({
596
- messages: [{
597
- role: "user",
598
- content: {
599
- type: "text",
600
- text: `Triage all open UX issues for this site.
601
-
602
- Step 1: Call get_issues with status="open" to get the full list.
603
- Step 2: For the top 5 issues by signal count or confusion weight, call get_issue on each to get full evidence and session context.
604
- Step 3: For each reviewed issue, assess:
605
- - Severity: high (score impact > 15), medium (5-15), low (< 5)
606
- - Root cause: which signal type and element dominates
607
- - Confidence: do you have enough evidence to act?
608
-
609
- Step 4: Use update_issue to transition issues where you're confident:
610
- - Noise or edge case with low evidence \u2192 status: "ignored", note: your reasoning
611
- - Reproducible with clear root cause \u2192 status: "triaged", note: specific reproduction steps and affected element
612
- - Acute regression affecting a critical flow \u2192 status: "in_progress", note: urgency rationale
613
-
614
- Step 5: Call add_annotation to record what was done, e.g. "Agent triage: 2 issues moved to triaged, 1 ignored (noise), 2 left open pending more data."
615
-
616
- Report: N issues reviewed, X triaged, Y ignored, Z escalated to in_progress, W left open. One sentence per transitioned issue.`
617
- }
618
- }]
619
- })
620
- );
621
- server.prompt(
622
- "post_deploy_check",
623
- "Check for UX confusion regression after the most recent deploy. Returns a go/no-go verdict with specific pages and signals affected.",
624
- {},
625
- () => ({
626
- messages: [{
627
- role: "user",
628
- content: {
629
- type: "text",
630
- text: `Run a post-deploy UX check for this site.
631
-
632
- Step 1: Call get_deploys to find the most recent deploy. Note the commit SHA, deploy time, and confusion_before/confusion_after scores.
633
- Step 2: Call get_trends with days=3 to see score movement in the 72-hour window around the deploy.
634
- Step 3: Call get_issues with status="regressed" to find auto-detected regressions.
635
- Step 4: For any page where confusion_after > confusion_before by more than 10 points, call get_page for the full signal breakdown.
636
-
637
- Evaluate:
638
- - Did overall site confusion increase after this deploy?
639
- - Which pages regressed, and by how many points?
640
- - What specific signals are driving the regression (rage clicks, dead clicks, form friction)?
641
- - Were any new UX issues created since the deploy?
642
-
643
- Deliver a deploy health report:
644
- - Deploy: [SHA/label], deployed at [time]
645
- - Verdict: PASS / WARN / FAIL
646
- - Score delta: [before] \u2192 [after] per affected page
647
- - Root signal: the dominant friction type if WARN/FAIL
648
- - Recommendation: ship as-is / monitor closely / consider rollback
649
-
650
- If FAIL (> 20 point regression on a critical page), call add_annotation with "Post-deploy check FAILED: [one-line summary of what broke and which page]".`
651
- }
652
- }]
653
- })
654
- );
655
- server.prompt(
656
- "investigate_session",
657
- "Investigate one session in full: event timeline, dominant signals, matching issues, and whether it represents a recurring pattern.",
658
- { session_id: z.string().min(16).max(128) },
659
- ({ session_id }) => ({
660
- messages: [{
661
- role: "user",
662
- content: {
663
- type: "text",
664
- text: `Investigate session "${session_id}" in Flusterduck.
665
-
666
- Step 1: Call get_session_detail with session_id="${session_id}" to get the full event timeline.
667
- Step 2: Identify the dominant friction signals in this session (rage clicks, dead clicks, form abandonment, scroll thrash). Note which elements and pages generated them.
668
- Step 3: For the page with the most friction signals, call get_page to get its score, open issues, and element breakdown.
669
- Step 4: Call get_issues with status="open" to find any existing issues whose affected elements or pages match this session.
670
-
671
- Deliver:
672
- - Session narrative: what this user tried to do, where they struggled, and how the session ended (2-3 sentences)
673
- - Dominant friction: signal type, element label, page path, and count
674
- - Issue match: does this session match an open issue? If yes, cite it by title. If no match, describe what a new issue would cover.
675
- - Representativeness: is this session isolated or part of a recurring pattern? Reference the open issue signal count or page score trend to support your answer.
676
-
677
- Reference specific element labels, signal names, and counts from the data.`
678
- }
679
- }]
680
- })
681
- );
682
- server.prompt(
683
- "weekly_summary",
684
- "Generate the weekly UX friction digest: score trends, active issues, open alerts, and prioritized recommendations. Ready to share with a PM or eng lead.",
685
- {},
686
- () => ({
687
- messages: [{
688
- role: "user",
689
- content: {
690
- type: "text",
691
- text: `Generate the weekly UX friction summary for this site.
692
-
693
- Step 1: Call get_site_context for the high-level overview.
694
- Step 2: Call get_trends with days=7 to see this week's score trajectory.
695
- Step 3: Call get_issues with status="open", then status="regressed" to get the full active problem set.
696
- Step 4: Call get_alerts with status="open" to find alerts still requiring attention.
697
- Step 5: Call get_recommendations for prioritized action items ranked by confusion impact.
698
- Step 6: Call get_revenue_impact to attach dollar estimates if conversion data is connected.
699
-
700
- Format as a weekly digest:
701
-
702
- ## Weekly UX Friction Summary: [site name]
703
-
704
- **Executive summary** (2-3 sentences: are things improving or degrading? What's the headline?)
705
-
706
- **Score trend this week**
707
- - Site-wide confusion: [score] ([+/-X] vs last week), improving / stable / degrading
708
- - Notable pages: [page] +X, [page] -Y
709
-
710
- **Active issues** (top 3 by impact)
711
- 1. [Issue title] | [severity] | [page] | open [N] days | [one-line root cause]
712
- 2. ...
713
-
714
- **Alerts needing attention** (if any)
715
-
716
- **Top 3 recommended fixes**
717
- 1. [Specific fix], estimated [X]-point confusion reduction on [page]
718
- 2. ...
719
-
720
- **Revenue at risk**: $[amount] based on [conversion metric] (if available)
721
-
722
- Keep it under 300 words. This should be readable in 90 seconds by someone who hasn't looked at Flusterduck all week.`
723
- }
724
- }]
725
- })
726
- );
727
- return server;
728
- }
729
-
730
- export {
731
- FlusterduckAPI,
732
- createFlusterduckMCPServer
733
- };