@usercase/mcp 0.2.8-next.1 → 0.2.8-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/README.md CHANGED
@@ -103,6 +103,14 @@ Claude reads your question, asks you when it should appear, writes the steps, an
103
103
 
104
104
  Claude suggests the moment, the question and its answers, and saves it only after you approve — as a draft in your Inbox. Nobody sees it until you turn it on there.
105
105
 
106
+ ## Planning a research
107
+
108
+ ```
109
+ /mcp__usercase__new-research
110
+ ```
111
+
112
+ Claude asks whether you already know what you want to learn. If you don't, it reads your project's findings and where visitors left, and suggests two or three researches, saying which evidence comes from real visitors and which from virtual users. With no data yet, it offers to send a few virtual users first. It then reads your code to write the tasks and their success conditions (the page that means done, or the exact button), and saves the research only after you approve it — as a draft in your Inbox, where you create it. It then offers to walk each task in a browser and record its expected path; the paths stay with the draft and move into the research when you create it. A draft you discard, or don't create within 30 days, loses its recorded paths and their screenshots.
113
+
106
114
  ## What the visitors will and won't do
107
115
 
108
116
  - They identify themselves on every request (see below), and their answers are counted separately from real visitors. This cannot be turned off.
@@ -0,0 +1,86 @@
1
+ # Plan a Usercase research
2
+
3
+ A Usercase research invites visitors of this site to do a few tasks while Usercase records how they go, then asks them questions. You plan one with the user: choose what is worth learning from the project's data, then write the tasks and questions. Unlike the dashboard's AI draft, **you may read the code**, so paths, buttons and success conditions come from the real site, not guesses.
4
+
5
+ ## 1. Ask what they want to learn
6
+
7
+ Ask the user, in one short question, whether there is something they already want to learn, or whether you should look at the data and suggest something. Skip the question if they already said.
8
+
9
+ ## 2. Read the data
10
+
11
+ Call `research_context`. Weigh it in this order:
12
+ 1. **Findings from real visitors** — the strongest reason for a research. Findings marked "needs research" are the usual candidates; ones that "can be fixed directly" usually need a fix, not a research.
13
+ 2. **Findings and leave words from virtual users** — useful, but they come from AI visitors. Whenever a suggestion rests on them, say so.
14
+ 3. **Where people left** — a place many leave is worth a look, but by itself it does not say why.
15
+
16
+ Leave out what an existing research or a waiting draft already covers.
17
+
18
+ **When there is no data yet** (no visits and no findings), tell the user plainly and offer two ways:
19
+ - send a few virtual users first with `{{research_cmd}}` (limited to the part of the site they care about, if they named one), then run this command again with their visits as evidence;
20
+ - or go ahead without data: from what the user wants to learn, or from the code alone. Anything you suggest from the code alone is a guess to test, and you say it that way.
21
+
22
+ Do what they choose. If they already know what they want to learn, you do not need data to go on.
23
+
24
+ ## 3. Read the code
25
+
26
+ For the parts of the site involved, read the routes and the components: the pages a participant passes through, the controls they would use, the visible text on those controls, what has to be true for the goal to be reached (signed in, an item in a list). If real visits exist, call `question_context` too: the paths and words visitors actually saw. Where the code and the visits disagree, trust the visits.
27
+
28
+ What you learn from the code helps you write the success conditions. It never goes into what participants read.
29
+
30
+ ## 4. Suggest, unless they already chose
31
+
32
+ If the user did not name a topic, suggest two or three, each in a few lines:
33
+ - what the team would learn, and the decision it would help with,
34
+ - the evidence, labelled by source (real visitors / virtual users / code only),
35
+ - the part of the site a participant would walk through.
36
+
37
+ Let the user pick one or change it.
38
+
39
+ ## 5. Write the research
40
+
41
+ One research is **one flow a participant can walk in one sitting**: 1–4 tasks, about 5–10 minutes in all.
42
+
43
+ **Tasks**
44
+ - The title is a goal in the participant's words: what they want to get done, not how. Never name a button, a menu, a page or a route — that hands them the answer.
45
+ - Use `info_text` only when the participant needs a situation to act in.
46
+ - Choose the success condition from the code:
47
+ - `url_entry` — reaching a page proves the goal: `target_url` is that page's path, with `:name` for the parts that change (names from the route definitions).
48
+ - `element_click` — the goal is done at a click: `xpath_url` is the page where the click happens, `click_text` is the element's exact visible text, `xpath` finds the element by that text.
49
+ - `time_on_page` — the goal is to read or understand something: `timer` is the seconds that mean they read it.
50
+ - Only goals the site really supports, and reachable from where participants start. Do not make anyone pay, delete or send things that affect other people.
51
+
52
+ **Questions**
53
+ - At most 2 follow-up questions per task, about that task as they just experienced it. At most 5 questions at the end, about the whole experience and the evidence that led here.
54
+ - Neutral wording that does not suggest the answer. One thing per question.
55
+ - Choice answers do not overlap and cover the likely cases; add a free-text question when you cannot guess the answers. `likert_scale` always gets labels for both ends.
56
+
57
+ **Invite**: the page glob where visitors should be invited (where the flow begins, or the moment the evidence points to), and one short line asking them to take part.
58
+
59
+ **Wording people read** (the start screen, the invite line):
60
+ - Say plainly what they will do and roughly how long it takes.
61
+ - The participant is the subject. Never ask for help, never make the team or the site the subject, never thank in advance.
62
+ - Do not mention the problem you suspect or what others said.
63
+
64
+ **Language**: English, unless the user asks for another language.
65
+
66
+ ## 6. Show the user, then save
67
+
68
+ Show the whole research compactly: the purpose and its evidence, each task with how it counts as done (in plain words, no paths or XPath), the questions, and the invite. Change whatever they ask for.
69
+
70
+ On their approval, call `research_save` with `basis` (`real` if the evidence came from real visitors, `virtual` if only from virtual users, `none` otherwise) and the ids of the findings it answers. Give them the Inbox link it returns and tell them it stays a draft until they create it in the Inbox, where they also set when the invite appears.
71
+
72
+ ## 7. Record each task's expected path
73
+
74
+ The results tell who went straight from who took a detour only when a task has an expected path, and walking it in a browser confirms the success condition you wrote from the code. Ask the user whether to record the paths now. If they say no, tell them `{{path_cmd}}` with the research id does it after the research is created, and stop.
75
+
76
+ For each task with a page or click to reach (skip tasks that are done by staying on a page), use the task id `research_save` listed for it, `draft:<draft>:<number>`:
77
+
78
+ {{path_walk}}
79
+
80
+ ### Show the user, then save
81
+
82
+ Show the user the steps and the screenshots `path_check` lists. Ask whether this is the route they intend, and whether the task should be done on reaching the last page (`page`), on the last recorded click (`click`), or keep the condition you wrote (`keep`). With page or click the path ends at that point — say so when the route goes on after it.
83
+
84
+ On their approval, call `path_save`. The path stays in the draft and moves into the research when it is created; a draft that is discarded, or not created within 30 days, loses its paths. When all tasks are done, tell the user which have a path and any you could not record and why.
85
+
86
+ Do not change any code.
package/mcp/path.js CHANGED
@@ -150,6 +150,12 @@ class PathRecorder {
150
150
  .join("\n");
151
151
  }
152
152
 
153
+ /** 방금 저장한 리서치 초안의 과제들(new-research) — draft:<초안>:<번호> 로 path_start 에 쓴다 */
154
+ addDraft(draftId, { title, website, tasks }) {
155
+ const research = { title, status: "draft", website };
156
+ for (const t of tasks) this.known.set(`draft:${draftId}:${t.index}`, { research, task: { taskId: `draft:${draftId}:${t.index}`, title: t.title } });
157
+ }
158
+
153
159
  async open(s, { device, account }) {
154
160
  let storageState;
155
161
  if (account) {
@@ -164,7 +170,12 @@ class PathRecorder {
164
170
  /** 새로 기록을 시작한다. 같은 과제를 다시 하면 처음부터 */
165
171
  async start({ task, url, device = "desktop", account = null }) {
166
172
  const k = this.known.get(String(task));
167
- if (!k) throw new Error(`Task ${task} is not known yet. Call path_tasks with the research id first.`);
173
+ if (!k)
174
+ throw new Error(
175
+ String(task).startsWith("draft:")
176
+ ? `Draft task ${task} is not known in this session. Draft tasks are known right after research_save; once the research is created, use path_tasks with its id instead.`
177
+ : `Task ${task} is not known yet. Call path_tasks with the research id first.`,
178
+ );
168
179
  const full = /^https?:\/\//i.test(url) ? url : new URL(url, k.research.website || undefined).toString();
169
180
  await this.stop();
170
181
  const s = this.hub.site(full);
@@ -407,10 +418,13 @@ class PathRecorder {
407
418
  steps: steps.map((s) => ({ type: s.type, url: s.url, xpath: s.xpath || "", classes: s.classes || [], ids: s.ids || [], fingerprint: s.fingerprint || null, image: fs.readFileSync(s.image).toString("base64") })),
408
419
  };
409
420
  if (success === "page") body.success = { condition: "url_entry", targetUrl: last.url };
410
- else if (success === "click") body.success = { condition: "element_click", xpath: last.xpath, xpathUrl: last.url, classes: last.classes, ids: last.ids };
411
- const r = await report.savePath(c.taskId, body);
421
+ else if (success === "click") body.success = { condition: "element_click", xpath: last.xpath, xpathUrl: last.url, classes: last.classes, ids: last.ids, label: last.label || "" };
422
+ // 초안 과제(new-research, draft:<초안>:<번호>)는 초안에 둔다 — 리서치로 만들 때 옮겨진다
423
+ const draft = c.taskId.match(/^draft:([^:]+):(\d+)$/);
424
+ const r = draft ? await report.saveDraftPath(draft[1], Number(draft[2]), body) : await report.savePath(c.taskId, body);
425
+ const where = draft ? "The path is kept in the draft and moves into the research when it is created." : "The dashboard's task results now separate going straight from detours.";
412
426
  const done = [
413
- `Saved the expected path for task ${c.taskId} ("${c.title}"): ${r.steps} steps${r.success ? `, success condition set to ${r.success === "url_entry" ? `reaching ${last.url}` : `clicking "${last.label || last.tag}"`}` : ""}. The dashboard's task results now separate going straight from detours.`,
427
+ `Saved the expected path for task ${c.taskId} ("${c.title}"): ${r.steps} steps${r.success ? `, success condition set to ${r.success === "url_entry" ? `reaching ${last.url}` : `clicking "${last.label || last.tag}"`}` : ""}. ${where}`,
414
428
  cut.length
415
429
  ? `Left out the ${cut.length} step(s) after the success point (${cut.map((s) => (s.type === "URL" ? `page ${s.url}` : `click "${s.label || s.tag}"`)).join(", ")}) — the task ends at the success point, so no participant could ever reach them. Tell the user.`
416
430
  : "",
package/mcp/report.js CHANGED
@@ -279,6 +279,8 @@ const getResearchTasks = (researchId) => keyed("GET", `/agent/researches/${encod
279
279
 
280
280
  /** 과제의 정상 경로를 통째로 바꾼다. 화면을 올리느라 오래 걸릴 수 있다 */
281
281
  const savePath = (taskId, body) => keyed("PUT", `/agent/tasks/${encodeURIComponent(taskId)}/path`, body, 90000);
282
+ /** 아직 리서치가 안 된 초안 과제의 경로(new-research) — 초안에 둔다 */
283
+ const saveDraftPath = (draftId, index, body) => keyed("PUT", `/agent/researches/drafts/${encodeURIComponent(draftId)}/tasks/${index}/path`, body, 90000);
282
284
 
283
285
  /** 질문 만들기(question) — 최근 방문을 읽어야 해서 조금 걸린다 */
284
286
  const getQuestionContext = () => keyed("GET", "/agent/questions/context", null, 60000);
@@ -287,6 +289,9 @@ const saveQuestion = (body) => keyed("POST", "/agent/questions", body, 60000);
287
289
  /** New question 창(Several steps) — 창에 쓴 것을 읽고, 조건만 돌려보낸다 */
288
290
  const getStepsBrief = (session) => keyed("GET", `/agent/questions/steps/${encodeURIComponent(session)}`);
289
291
  const sendSteps = (session, body) => keyed("PUT", `/agent/questions/steps/${encodeURIComponent(session)}`, body, 60000);
292
+ /** 리서치 초안(new-research) — 무엇을 리서치할지 고를 근거와, Inbox 초안으로 저장 */
293
+ const getResearchContext = () => keyed("GET", "/agent/researches/context", null, 30000);
294
+ const saveResearchDraft = (body) => keyed("POST", "/agent/researches/drafts", body, 30000);
290
295
  /** 대시보드 주소. API 주소에서 /koa 를 뗀다 */
291
296
  const dashboardUrl = (p) => apiBase().replace(/\/koa$/, "") + p;
292
297
 
@@ -302,10 +307,13 @@ module.exports = {
302
307
  sendDiagnostics,
303
308
  getResearchTasks,
304
309
  savePath,
310
+ saveDraftPath,
305
311
  getQuestionContext,
306
312
  checkQuestion,
307
313
  saveQuestion,
308
314
  getStepsBrief,
309
315
  sendSteps,
316
+ getResearchContext,
317
+ saveResearchDraft,
310
318
  dashboardUrl,
311
319
  };
package/mcp/server.js CHANGED
@@ -634,6 +634,104 @@ server.registerTool(
634
634
  wrap(async ({ success }) => text(await recorder.save({ success }))),
635
635
  );
636
636
 
637
+ // ── 리서치 초안 (메인 에이전트, new-research 절차) ──
638
+ // 대시보드 New research 의 AI 초안은 문장 한 줄과 사이트 주소만 본다. 여기서는 데이터로 고르고, 코드를 읽어 경로·버튼을 채운다.
639
+ // 저장은 Inbox 리서치 초안으로 — 리서치로 만드는 건 사람이 Inbox 에서 한다
640
+
641
+ const counts = (rows, key) => (rows.length ? rows.map((r) => `${r[key]} (${r.n})`).join(" · ") : "none");
642
+
643
+ server.registerTool(
644
+ "research_context",
645
+ {
646
+ description:
647
+ "What this project's data says is worth researching: open findings, where real visitors and virtual users left, what virtual users said when they left without getting what they came for, and the researches and drafts that already exist. Call first in the new-research procedure.",
648
+ inputSchema: {},
649
+ },
650
+ wrap(async () => {
651
+ const c = await report.getResearchContext();
652
+ const L = [`Project: "${c.project.title}"${c.project.website ? ` — ${c.project.website}` : ""}`, `Last ${c.days} days.`, ""];
653
+ L.push(`## Real visitors: ${c.real.visits} visits`);
654
+ if (c.real.visits) L.push(`Where they left: ${counts(c.real.exits, "left_at")}`);
655
+ L.push("", `## Virtual users: ${c.virtual.visits} visits, ${c.virtual.leftUnhappy} left without getting what they came for`);
656
+ if (c.virtual.visits) {
657
+ L.push(`Why they left: ${counts(c.virtual.reasons, "left_reason")}`, `Where they left: ${counts(c.virtual.exits, "left_at")}`);
658
+ if (c.virtual.unhappyWords.length) L.push("Their words when leaving unhappy, most recent first:", ...c.virtual.unhappyWords.map((w) => `- ${w.at}: ${w.why}`));
659
+ }
660
+ L.push("", `## Open findings not yet researched: ${c.findings.length}`);
661
+ for (const f of c.findings)
662
+ L.push(
663
+ `- [${f.id}] (${f.source === "real" ? "real visitors" : "virtual users"}, ${f.answers} answers, ${f.nextStep === "research" ? "needs research" : "can be fixed directly"}) ${f.title}`,
664
+ ` on ${f.page || "any page"} · asked: "${f.question}"`,
665
+ ` seen: ${f.observation}`,
666
+ ` likely: ${f.hypothesis}`,
667
+ );
668
+ L.push("", "## Already there");
669
+ L.push(`Researches: ${c.researches.length ? c.researches.map((r) => `"${r.title}" (${r.status})`).join(" · ") : "none"}`);
670
+ L.push(`Drafts waiting in the Inbox: ${c.drafts.length ? c.drafts.map((t) => `"${t}"`).join(" · ") : "none"}`);
671
+ if (!c.real.visits && !c.virtual.visits && !c.findings.length) L.push("", "■ There is no data yet — no visits and no findings.");
672
+ return text(L.join("\n"));
673
+ }),
674
+ );
675
+
676
+ const RQuestion = z.object({
677
+ title: z.string().describe("The question, as the participant reads it"),
678
+ subtitle: z.string().optional(),
679
+ type: z.enum(["short_answer", "long_answer", "likert_scale", "yes_no", "single_choice", "multiple_choice"]),
680
+ choices: z.array(z.string()).optional().describe("single_choice / multiple_choice: 2–8 answers"),
681
+ scale_labels: z.array(z.string()).length(2).optional().describe("likert_scale: what 1 and what 5 mean"),
682
+ });
683
+
684
+ server.registerTool(
685
+ "research_save",
686
+ {
687
+ description:
688
+ "Save the research as a DRAFT in the dashboard Inbox, after the user approved it. It becomes a research only when someone creates it there. Paths are page paths starting with / (no host), with :name for the parts that change.",
689
+ inputSchema: {
690
+ title: z.string().describe("Short, for the team"),
691
+ purpose: z.string().describe("What the team wants to learn and why — the evidence it came from"),
692
+ start_screen: z.object({ title: z.string(), subtitle: z.string() }).optional().describe("What participants read before the first task"),
693
+ tasks: z
694
+ .array(
695
+ z.object({
696
+ title: z.string().describe("The goal, in the participant's words. Never names buttons, menus or pages"),
697
+ info_text: z.string().optional().describe("The situation the participant is in, if needed"),
698
+ success_condition: z.enum(["url_entry", "element_click", "time_on_page"]),
699
+ target_url: z.string().optional().describe("url_entry: the page path that means done"),
700
+ xpath_url: z.string().optional().describe("element_click: the page path where the click happens"),
701
+ click_text: z.string().optional().describe("element_click: the exact visible text of the element"),
702
+ xpath: z.string().optional().describe("element_click: XPath that finds that element by its text"),
703
+ timer: z.number().int().optional().describe("time_on_page: seconds on the page that mean done"),
704
+ follow_up_questions: z.array(RQuestion).optional().describe("Asked right after this task, at most 2"),
705
+ }),
706
+ )
707
+ .optional(),
708
+ questions: z.array(RQuestion).optional().describe("Asked after all tasks"),
709
+ invite: z.object({ page: z.string().describe("Page glob where visitors are invited"), copy: z.string().describe("The invite line, short") }).optional(),
710
+ basis: z.enum(["real", "virtual", "none"]).describe("What the research came from: real visitor data, virtual user data, or neither (the user's question or the code)"),
711
+ findingIds: z.array(z.string()).optional().describe("Ids of the findings it answers, from research_context"),
712
+ },
713
+ },
714
+ wrap(async (args) => {
715
+ const r = await report.saveResearchDraft(args);
716
+ // 과제 경로 녹화(path_start)에 쓸 번호 — 옛 서버는 tasks 를 안 준다
717
+ const tasks = r.tasks || [];
718
+ if (tasks.length) recorder.addDraft(r.draftId, { title: args.title, website: r.website || "", tasks });
719
+ return text(
720
+ [
721
+ `Saved as a draft${r.findings ? `, linked to ${r.findings} finding${r.findings > 1 ? "s" : ""}` : ""}.`,
722
+ `Open it in the Inbox to review and create it: ${report.dashboardUrl(r.inboxPath)}`,
723
+ ...(tasks.length
724
+ ? [
725
+ "",
726
+ `Task ids for recording their expected paths with path_start${r.website ? ` (relative URLs resolve against ${r.website})` : ""}:`,
727
+ ...tasks.map((t) => `- draft:${r.draftId}:${t.index} — ${t.title}`),
728
+ ]
729
+ : []),
730
+ ].join("\n"),
731
+ );
732
+ }),
733
+ );
734
+
637
735
  // ── 가상 사용자 서브에이전트용 ──
638
736
 
639
737
  // Codex 는 도구마다 처음 부를 때 허용을 묻는다. 방문 도중에 물으면 그동안 첫 화면에 가만히 있던 사람이 된다(10/28 첫 행동 130초).
@@ -751,10 +849,23 @@ const doc = (name) => fs.readFileSync(path.join(__dirname, "..", "agents", name)
751
849
  /** 조건 쓰는 규칙은 question·steps 가 같이 쓴다 — 한 벌만 둔다 */
752
850
  const agentDoc = (name, vars = {}) => {
753
851
  let body = doc(name).replace("{{condition_rules}}", doc("condition.md").trim());
754
- for (const [k, v] of Object.entries(vars)) body = body.replace(`{{${k}}}`, v);
852
+ for (const [k, v] of Object.entries(vars)) body = body.replaceAll(`{{${k}}}`, v);
755
853
  return body;
756
854
  };
757
855
 
856
+ /** path.md 의 "## 3. Walk it" ~ "## 4. Check it" 끝까지 — 제목은 한 단계 낮추고 번호를 뗀다 */
857
+ const pathWalkRules = () => {
858
+ const d = doc("path.md");
859
+ const from = d.indexOf("## 3. Walk it");
860
+ const to = d.indexOf("## 5.");
861
+ if (from < 0 || to < 0) throw new Error("agents/path.md lost its Walk it / Check it sections");
862
+ return d
863
+ .slice(from, to)
864
+ .replace(/^## \d+\. /gm, "### ")
865
+ .replace(/\n*Do not change any code\.\n*/g, "\n\n")
866
+ .trim();
867
+ };
868
+
758
869
  /**
759
870
  * 명령 지시문. 따로 쓰면 MCP 프롬프트(/mcp__usercase__research), 플러그인이면 instructions 도구(/usercase:research 스킬이 부른다)로
760
871
  * 같은 글이 나간다
@@ -819,6 +930,12 @@ const COMMANDS = {
819
930
  description: "Make a Usercase question for a precise moment on your site — your agent suggests the moment, question and answers",
820
931
  body: () => agentDoc("question.md"),
821
932
  },
933
+ // 리서치 초안 — 무엇을 알고 싶은지는 대화로 묻는다(인자 없음, question 과 같은 이유)
934
+ "new-research": {
935
+ description: "Plan a Usercase research from your data and code — your agent suggests what to research, writes the tasks and questions, and saves a draft",
936
+ // 경로를 걷고 확인하는 규칙은 path 명령과 한 벌 — path.md 의 3·4 단계를 그대로 싣는다
937
+ body: () => agentDoc("new-research.md", { research_cmd: cmd("research"), path_cmd: cmd("path"), path_walk: pathWalkRules() }),
938
+ },
822
939
  // 프로젝트 바꾸기 — 연결은 저장소 폴더마다라 이 저장소만 바뀐다
823
940
  connect: {
824
941
  description: "Connect this repository to a Usercase project, or switch it to another one",
@@ -845,7 +962,7 @@ if (PLUGIN || CODEX || OTHER) {
845
962
  "instructions",
846
963
  {
847
964
  description:
848
- "The step-by-step instructions for a Usercase command (research, install, path, question, steps, connect). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
965
+ "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.",
849
966
  inputSchema: {
850
967
  command: z.enum(Object.keys(COMMANDS)),
851
968
  ...COMMANDS.research.argsSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.8-next.1",
3
+ "version": "0.2.8-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",