@usercase/mcp 0.2.11-next.1 → 0.2.11-next.3

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/fix.md ADDED
@@ -0,0 +1,36 @@
1
+ # Fix a problem found by a Usercase research
2
+
3
+ Research: {{research}}
4
+ Finding: {{finding}}
5
+ Report: {{audience}}
6
+
7
+ A Usercase research watched people (or virtual users) do tasks on this site. Its report lists findings — problems on the site, each backed by what people did, what the screen showed in the recordings, and what they wrote. You fix one finding in this repository's code.
8
+
9
+ ## 1. Read the finding
10
+
11
+ Call `report_finding` with the research id, the finding key and the report. If no finding key is given above, call `report_findings` first, show the user the list (key, severity, title, and whether someone is already fixing it), and ask which one to fix.
12
+
13
+ Read all of it: the title and why, who it hit, the screen checks (what the page really showed — these come from the recordings, not from opinions), the paths people took, the groups of what they wrote, and the suggested changes. The suggested changes were written without seeing the code; treat them as a direction, not a spec.
14
+
15
+ ## 2. Find the code
16
+
17
+ From the pages, buttons and texts in the evidence, find the code behind them: routes, the page components, the button and its click handler, the text shown. Confirm you found the right place by matching the exact text on screen (a button label, a heading) to the code.
18
+
19
+ ## 3. Propose the change — before changing anything
20
+
21
+ Tell the user, briefly:
22
+ - what in the code causes the problem, with file paths,
23
+ - the change you propose and why it addresses what people did and said, and
24
+ - anything the finding suggests that you would not do, and why (it would break something, it needs a product decision, the code already does it).
25
+
26
+ Keep the change as small as solves the problem. Do not redesign unrelated parts. Wait for the user's approval or corrections.
27
+
28
+ ## 4. Change the code
29
+
30
+ When the user approves, call `report_fix` with `status` `fixing` and the files you will change. Then make the change. Follow the repository's own conventions, and run its checks (type check, lint, tests) if it has them.
31
+
32
+ ## 5. Record the fix
33
+
34
+ When the change is done, call `report_fix` with `status` `fixed`, the files changed, a one-sentence summary written for the team (what a visitor now sees differently), and the pull request link if you opened one. Do not commit, push or open a pull request unless the user asks.
35
+
36
+ Then tell the user that the finding shows as fixed in the report, and how to check it worked: {{check}}
package/mcp/hub.js CHANGED
@@ -450,7 +450,12 @@ class Hub {
450
450
  external,
451
451
  study: research
452
452
  ? {
453
- press: (k) => study.press(page, k),
453
+ // 패널 옮기기는 막혔던 자리에서 비키게 끈다. 옮기고 나면 그 항목은 목록에서 내린다
454
+ press: async (k) => {
455
+ if (k !== "move") return study.press(page, k);
456
+ await study.movePanel(page, rec.panelInWay);
457
+ rec.panelInWay = null;
458
+ },
454
459
  // 과제 질문·시간 초과 창이 페이지를 덮고 있나 — 그 사이 조작은 페이지에 닿지 않는다
455
460
  blocking: async () => {
456
461
  const w = study.isSurveyUrl(page.url()) ? null : await study.readStudy(page);
@@ -505,6 +510,9 @@ class Hub {
505
510
  if (look.ended) return "This visit has ended.";
506
511
  if (look.card) return renderCard(look.card);
507
512
  // 과제 건너뛰기는 위젯 단추다. 화면 목록 끝에 붙인다 — 누르면 Visit 이 위젯을 누른다
513
+ // 우리 패널이 누르려던 것을 덮었다 — 사람은 패널을 끌어 비킨다(11/04 실험실 covered_by_panel 3건: 캘린더 Week·Month)
514
+ if (st && rec.panelInWay && !look.view.dialog)
515
+ look.view.items.push({ i: look.view.items.length, label: "Drag the study panel aside (it is covering the page)", where: "study panel, by its top bar", study: "move" });
508
516
  if (st && st.skip && !look.view.dialog) look.view.items.push({ i: look.view.items.length, label: `${st.skip} (in the study panel — gives up on this task)`, where: "study panel, corner of the screen", study: "skip" });
509
517
  // 아직 건너뛸 수 없는 과제 — 예전엔 "패널 기다리기" 를 줘서 막히면 몇 분씩 아무것도 안 했다(라이브 11번 × 약 5분).
510
518
  // 사람은 막히면 계속 찾아본다. 그러다 실패 시간이 지나면 SDK 가 묻는 창을 띄우고, 다음 화면에서 그 창이 먼저 보인다
@@ -780,6 +788,7 @@ class Hub {
780
788
  if (TRACE) trace(rec, { ev: "act", action: decision.action, study: r && r.study, actMs: Date.now() - a0, waitTurnMs: v.lastWaitMs || 0, sdk: await sdkSnap(rec.page) });
781
789
  // 패널의 건너뛰기를 눌렀다 — 그 과제를 건너뛴 것으로 남긴다
782
790
  if (r && r.study === "skip" && !r.skipped && rec.study) rec.study.skipped.push(rec.study.lastTask);
791
+ if (r && r.panelAt && rec.study) rec.panelInWay = r.panelAt;
783
792
  if (r.interrupted && r.study) {
784
793
  rec.cutOff = true;
785
794
  return ["Before you could do that, the study asked you something.", "", await this.screen(rec)].join("\n");
package/mcp/report.js CHANGED
@@ -292,6 +292,11 @@ const sendSteps = (session, body) => keyed("PUT", `/agent/questions/steps/${enco
292
292
  /** 리서치 초안(new-research) — 무엇을 리서치할지 고를 근거와, Inbox 초안으로 저장 */
293
293
  const getResearchContext = () => keyed("GET", "/agent/researches/context", null, 30000);
294
294
  const saveResearchDraft = (body) => keyed("POST", "/agent/researches/drafts", body, 30000);
295
+ /** 리서치 Report(fix) — 발견 목록, 발견 하나와 근거, 고친 기록 */
296
+ const rq = (audience) => (audience === "real" ? "?audience=real" : "?audience=virtual");
297
+ const getReport = (researchId, audience) => keyed("GET", `/agent/researches/${encodeURIComponent(researchId)}/report${rq(audience)}`);
298
+ const getFinding = (researchId, key, audience) => keyed("GET", `/agent/researches/${encodeURIComponent(researchId)}/report/findings/${encodeURIComponent(key)}${rq(audience)}`);
299
+ const saveFix = (researchId, key, body) => keyed("PUT", `/agent/researches/${encodeURIComponent(researchId)}/report/findings/${encodeURIComponent(key)}/fix`, body);
295
300
  /** 대시보드 주소. API 주소에서 /koa 를 뗀다 */
296
301
  const dashboardUrl = (p) => apiBase().replace(/\/koa$/, "") + p;
297
302
 
@@ -315,5 +320,8 @@ module.exports = {
315
320
  sendSteps,
316
321
  getResearchContext,
317
322
  saveResearchDraft,
323
+ getReport,
324
+ getFinding,
325
+ saveFix,
318
326
  dashboardUrl,
319
327
  };
package/mcp/server.js CHANGED
@@ -556,6 +556,87 @@ server.registerTool(
556
556
 
557
557
  const recorder = new PathRecorder(hub);
558
558
 
559
+ // ── 리서치 Report 의 발견 고치기(fix) ──
560
+ const audienceArg = z.enum(["virtual", "real"]).optional().describe("Which report: virtual users (default) or real people");
561
+ const fixLine = (f) => (f ? ` — ${f.status === "fixed" ? "fixed" : "being fixed"}${f.agent ? ` by ${f.agent}` : ""}` : "");
562
+ const whoWord = (audience) => (audience === "real" ? "people" : "virtual users");
563
+
564
+ server.registerTool(
565
+ "report_findings",
566
+ {
567
+ description:
568
+ "List the findings of a Usercase research's latest report (key, severity, title, who, and whether one is being fixed). Use when the fix command has no finding key.",
569
+ inputSchema: { research: z.string().describe("The research id"), audience: audienceArg },
570
+ },
571
+ wrap(async ({ research, audience }) => {
572
+ const r = await report.getReport(research, audience);
573
+ if (!r.findings)
574
+ return text(r.making ? "The report is being written right now. Try again in a minute." : `This research has no ${whoWord(r.audience)} report yet. It is written once 5 have finished.`);
575
+ return text(
576
+ [
577
+ `${r.research.title} — report of ${r.people} ${whoWord(r.audience)}`,
578
+ `Summary: ${r.summary}`,
579
+ "",
580
+ ...r.findings.map((f) => `${f.key} [${f.severity}] ${f.title} — ${f.who}${fixLine(f.fix)}`),
581
+ "",
582
+ `Report: ${r.url}`,
583
+ ].join("\n"),
584
+ );
585
+ }),
586
+ );
587
+
588
+ server.registerTool(
589
+ "report_finding",
590
+ {
591
+ description:
592
+ "Read one finding of a Usercase research report with all its evidence: why, who, the screen checks from the recordings, the paths people took, what they wrote, and the suggested changes. Read it before looking for the code.",
593
+ inputSchema: { research: z.string().describe("The research id"), finding: z.string().describe("Finding key, e.g. F1"), audience: audienceArg },
594
+ },
595
+ wrap(async ({ research, finding, audience }) => {
596
+ const r = await report.getFinding(research, finding, audience);
597
+ const f = r.finding;
598
+ const e = r.evidence;
599
+ const L = [
600
+ `${f.key} [${f.severity}] ${f.title}${fixLine(f.fix)}`,
601
+ `Research: ${r.research.title} (report of ${r.people} ${whoWord(r.audience)})`,
602
+ "",
603
+ `Why: ${f.why}`,
604
+ `Who: ${f.who}`,
605
+ `Where: ${f.where}`,
606
+ ];
607
+ if (e.screen.length) L.push("", "What the screen showed (from the recordings):", ...e.screen.map((x) => `- ${x.replace(/^Screen check:\s*/, "")}`));
608
+ if (e.paths.length) L.push("", "Paths people took:", ...e.paths.map((p) => `- ${(p.steps || []).join(" → ")} (${p.people} people)`));
609
+ if (e.words.length) L.push("", "What people wrote (grouped by meaning, with examples):", ...e.words.map((w) => `- ${w.text}`));
610
+ if (e.numbers.length) L.push("", "Numbers:", ...e.numbers.map((x) => `- ${x}`));
611
+ L.push("", "Suggested changes (written without seeing the code):", ...f.changes.map((c, i) => `${i + 1}. ${c}`), `Check after the fix: ${f.measure}`);
612
+ if (r.sessionsUrl) L.push("", `Watch these sessions: ${r.sessionsUrl}`);
613
+ L.push(`Report: ${r.reportUrl}`);
614
+ return text(L.join("\n"));
615
+ }),
616
+ );
617
+
618
+ server.registerTool(
619
+ "report_fix",
620
+ {
621
+ description:
622
+ "Record on the research report that you are fixing a finding (status fixing, once the user approved the change) or have fixed it (status fixed, with the files, a one-sentence summary for the team, and the pull request link if any). The report shows it on that finding.",
623
+ inputSchema: {
624
+ research: z.string().describe("The research id"),
625
+ finding: z.string().describe("Finding key, e.g. F1"),
626
+ audience: audienceArg,
627
+ status: z.enum(["fixing", "fixed"]),
628
+ files: z.array(z.string()).optional().describe("Repository paths of the files changed (or to be changed)"),
629
+ summary: z.string().optional().describe("One sentence for the team: what visitors now see differently"),
630
+ prUrl: z.string().optional().describe("Pull request link, if one was opened"),
631
+ },
632
+ },
633
+ wrap(async ({ research, finding, audience, status, files, summary, prUrl }) => {
634
+ const agent = CODEX ? "Codex" : OTHER ? null : "Claude Code";
635
+ const r = await report.saveFix(research, finding, { audience: audience || "virtual", status, agent, files, summary, prUrl });
636
+ return text(`${r.key} is marked ${r.status} on the report: ${r.reportUrl}`);
637
+ }),
638
+ );
639
+
559
640
  server.registerTool(
560
641
  "path_tasks",
561
642
  {
@@ -1047,6 +1128,21 @@ const COMMANDS = {
1047
1128
  "Only this repository's folder changes. Other repositories keep their own project. Until the new project is approved, the current connection stays.",
1048
1129
  ].join("\n"),
1049
1130
  },
1131
+ // 리서치 Report 의 발견 하나를 고친다 — 대시보드 Report 의 "Fix with your agent" 명령
1132
+ fix: {
1133
+ description: "Fix a finding from a Usercase research report in this repository's code",
1134
+ argsSchema: { research: z.string().optional(), finding: z.string().optional(), audience: z.string().optional() },
1135
+ body: ({ research, finding, audience }) =>
1136
+ agentDoc("fix.md", {
1137
+ research: research || "(ask the user — the dashboard Report shows the command)",
1138
+ finding: finding ? String(finding).toUpperCase() : "(not given — list the findings and ask)",
1139
+ audience: audience === "real" ? "real (the report of real people)" : "virtual (the report of virtual users)",
1140
+ check:
1141
+ audience === "real"
1142
+ ? "the next report, made as more people take part, shows whether people still hit it."
1143
+ : `run the same tasks with virtual users again (${cmd("research")} <site> 5 research=${research || "<research id>"}). The next report shows whether they still hit it.`,
1144
+ }),
1145
+ },
1050
1146
  steps: {
1051
1147
  description: "Write the steps for the question open in your dashboard (New question → Several steps)",
1052
1148
  argsSchema: { session: z.string().optional().describe("The window id shown in the dashboard command") },
@@ -1059,12 +1155,13 @@ if (PLUGIN || CODEX || OTHER) {
1059
1155
  "instructions",
1060
1156
  {
1061
1157
  description:
1062
- "The step-by-step instructions for a Usercase command (research, install, path, question, new-research, steps, connect). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
1158
+ "The step-by-step instructions for a Usercase command (research, install, path, question, new-research, steps, connect, fix). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
1063
1159
  inputSchema: {
1064
1160
  command: z.enum(Object.keys(COMMANDS)),
1065
1161
  ...COMMANDS.research.argsSchema,
1066
1162
  ...COMMANDS.path.argsSchema,
1067
1163
  ...COMMANDS.steps.argsSchema,
1164
+ ...COMMANDS.fix.argsSchema,
1068
1165
  },
1069
1166
  },
1070
1167
  wrap(async ({ command, ...args }) => text(COMMANDS[command].body(byName(args, COMMANDS[command].argsSchema)))),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.11-next.1",
3
+ "version": "0.2.11-next.3",
4
4
  "description": "Virtual users that browse your website and tell you what they thought — an MCP server for Claude Code",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://usercase.io",
@@ -133,7 +133,8 @@ async function coveredBy(page, el) {
133
133
  )
134
134
  .catch(() => null);
135
135
  if (!hit) return null;
136
- if (hit.own === "panel") return { say: "the study panel is on top of it — scroll or move so it is not under the panel" };
136
+ // 패널은 끌어 옮길 수 있다(core Widget 알약 드래그). 막힌 자리를 같이 넘겨 hub 가 "패널 옮기기" 를 화면 목록에 붙인다
137
+ if (hit.own === "panel") return { say: "the study panel is on top of it — drag the panel aside (it is in the list), or scroll so it is not under the panel", panelAt: { x: cx, y: cy } };
137
138
  if (hit.own) return { say: "the survey card is on top of it" };
138
139
  if (hit.text) return { say: `something else on the screen is on top of it ("${hit.text}") — you cannot reach it until that is out of the way` };
139
140
  return { invisible: true, x: cx, y: cy };
@@ -387,6 +388,7 @@ async function clickLikePerson(page, state, el) {
387
388
  if (startY !== null) await page.evaluate((y) => window.scrollTo({ top: y, behavior: "instant" }), startY).catch(() => {});
388
389
  const e = new Error(cover.say);
389
390
  e.human = cover.say;
391
+ if (cover.panelAt) e.panelAt = cover.panelAt;
390
392
  throw e;
391
393
  }
392
394
  // 화면 가장자리 밖으로 잘려 나간 것. 굴려도 옆으로는 안 온다 — 사람은 "잘려 있다" 를 본다.
@@ -180,6 +180,52 @@ const PRESS = {
180
180
  submit: "#eb-plugin #eb-survey-action-btn",
181
181
  };
182
182
 
183
+ /**
184
+ * 리서치 패널을 끌어 비킨다 — 알약(#eb-pill)을 잡고 끌면 패널이 같이 움직인다(core components/widget/Widget.ts setupDrag).
185
+ * 막혔던 자리(away)의 반대쪽 가장자리로. 그래도 덮으면 위아래도 바꾼다. 고객이 끌기를 껐으면(draggable 0) 안 움직인다
186
+ */
187
+ async function movePanel(page, away) {
188
+ const vp = page.viewportSize() || { width: 1280, height: 860 };
189
+ const pill = page.locator("#eb-plugin #eb-pill").first();
190
+ const covers = (pt) =>
191
+ pt
192
+ ? page
193
+ .evaluate(([x, y]) => {
194
+ const h = document.elementFromPoint(x, y);
195
+ return !!(h && h.closest("#eb-plugin"));
196
+ }, [pt.x, pt.y])
197
+ .catch(() => false)
198
+ : Promise.resolve(false);
199
+ const drag = async (tx, ty) => {
200
+ const b = await pill.boundingBox();
201
+ if (!b) throw new Error("the study panel is not on the screen");
202
+ const sx = b.x + b.width / 2;
203
+ const sy = b.y + b.height / 2;
204
+ await page.mouse.move(sx, sy, { steps: 6 });
205
+ await sleep(120 + Math.random() * 200);
206
+ await page.mouse.down();
207
+ await page.mouse.move(sx + (tx - sx) * 0.5 + (Math.random() - 0.5) * 30, sy + (ty - sy) * 0.5 + (Math.random() - 0.5) * 30, { steps: 10 });
208
+ await page.mouse.move(tx, ty, { steps: 8 });
209
+ await sleep(80 + Math.random() * 150);
210
+ await page.mouse.up();
211
+ await sleep(300);
212
+ const a = await pill.boundingBox();
213
+ return a && Math.hypot(a.x - b.x, a.y - b.y) > 20;
214
+ };
215
+ const b = await pill.boundingBox();
216
+ if (!b) throw new Error("the study panel is not on the screen");
217
+ const half = b.width / 2;
218
+ const ax = away ? away.x : b.x + half;
219
+ const ay = away ? away.y : b.y;
220
+ // 옆으로 — 막힌 자리의 반대쪽 끝
221
+ const tx = ax < vp.width / 2 ? vp.width - half - 24 : half + 24;
222
+ if (!(await drag(tx, b.y + b.height / 2))) throw new Error("the panel did not move — it stays where it is");
223
+ if (!(await covers(away))) return;
224
+ // 패널이 길어 아직 덮는다 — 위아래도 반대쪽으로
225
+ const ty = ay < vp.height / 2 ? vp.height - b.height - 24 : 24 + b.height / 2;
226
+ await drag(tx, ty);
227
+ }
228
+
183
229
  /** 위젯 단추를 누른다 */
184
230
  async function press(page, key) {
185
231
  const sel = PRESS[key];
@@ -211,4 +257,4 @@ async function answerQuestion(page, q, answer) {
211
257
  return null;
212
258
  }
213
259
 
214
- module.exports = { readStudy, waitState, join, press, answerQuestion, isSurveyUrl };
260
+ module.exports = { readStudy, waitState, join, press, movePanel, answerQuestion, isSurveyUrl };
package/src/run/visit.js CHANGED
@@ -727,7 +727,7 @@ class Visit {
727
727
  // 리서치 위젯의 단추(과제 건너뛰기). 페이지 조작이 아니라 위젯을 누른다 — 화면 목록에 hub 가 붙여 둔 것이다
728
728
  if (decision.action === "click" && item && item.study && this.study) {
729
729
  record.study = item.study;
730
- await this.study.press(item.study).catch(() => (record.skipped = "it did not respond"));
730
+ await this.study.press(item.study).catch((e) => (record.skipped = item.study === "move" && e && e.message ? e.message : "it did not respond"));
731
731
  await sleep(1500);
732
732
  this.steps.push(record);
733
733
  this.history.push(`click "${item.label}" — ${decision.why}${record.skipped ? ` (${record.skipped})` : ""}`);
@@ -892,7 +892,7 @@ class Visit {
892
892
  let ours = false;
893
893
  const clicked = await hand
894
894
  .clickLikePerson(page, this.handState, el)
895
- .catch((e) => ((record.skipped = e.human || humanClickError(e)), (ours = /eb-insight|eb-plugin/.test(String(e.message))), { missed: false }));
895
+ .catch((e) => ((record.skipped = e.human || humanClickError(e)), (record.panelAt = e.panelAt || undefined), (ours = /eb-insight|eb-plugin/.test(String(e.message))), { missed: false }));
896
896
  // 사이트의 무언가가 덮어 못 눌렀다. 우리 카드·위젯이 덮은 것은 사이트 탓이 아니다
897
897
  if (record.skipped && /on top of it/.test(record.skipped) && !/survey card|study panel/.test(record.skipped) && !ours)
898
898
  await this.shoot(record, "covered", await boxOf(page, el));