@usercase/mcp 0.2.8-next.3 → 0.2.8-next.5
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 +1 -1
- package/agents/new-research.md +24 -11
- package/mcp/path.js +36 -7
- package/mcp/server.js +35 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,7 +109,7 @@ Claude suggests the moment, the question and its answers, and saves it only afte
|
|
|
109
109
|
/mcp__usercase__new-research
|
|
110
110
|
```
|
|
111
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
|
|
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, their success conditions (the page that means done, or the exact button) and the route each task is meant to take, and shows you all of it at once. On your approval it walks each route in a browser, checks it, and saves the research with the routes — as a draft in your Inbox, where you create it. The routes 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
113
|
|
|
114
114
|
## What the visitors will and won't do
|
|
115
115
|
|
package/agents/new-research.md
CHANGED
|
@@ -54,33 +54,46 @@ One research is **one flow a participant can walk in one sitting**: 1–4 tasks,
|
|
|
54
54
|
- Neutral wording that does not suggest the answer. One thing per question.
|
|
55
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
56
|
|
|
57
|
-
**Invite**: the page
|
|
57
|
+
**Invite**: the page where visitors are invited, and one short line asking them to take part. Participants start the first task on the page where they accepted the invite — nothing takes them elsewhere. So the invite page is where the first task begins, and the first task is something to do from there, never a trip to where they already are. Write it as one kind of page: an exact path, or a path with `:name` for the one part that changes. A `*` covers pages the first route cannot start from.
|
|
58
|
+
|
|
59
|
+
**Time**: estimate the minutes the whole session takes — every task plus every question — and pass it as `minutes`. The invite card and the start screen show it next to your words.
|
|
58
60
|
|
|
59
61
|
**Wording people read** (the start screen, the invite line):
|
|
60
|
-
- Say plainly what they will do and
|
|
62
|
+
- Say plainly what they will do. Never state a duration or mention recording: the card and the start screen already show both from the research settings, and a second number can disagree.
|
|
61
63
|
- The participant is the subject. Never ask for help, never make the team or the site the subject, never thank in advance.
|
|
62
64
|
- Do not mention the problem you suspect or what others said.
|
|
63
65
|
|
|
64
66
|
**Language**: English, unless the user asks for another language.
|
|
65
67
|
|
|
66
|
-
## 6.
|
|
68
|
+
## 6. Plan each task's route
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
The results tell who went straight from who took a detour only when a task has an expected path: the route the site was built for. Work it out now, from the code, for every task with a page or click to reach (tasks done by staying on a page have none):
|
|
71
|
+
- **where it starts**: the first task starts on the invite page; every later task starts where the previous one ended;
|
|
72
|
+
- **the clicks a participant must make**, in order — the main route the UI offers, not a shortcut only you know;
|
|
73
|
+
- **where it ends**: the page or the click that counts as done. It must agree with the task's success condition.
|
|
74
|
+
- what the route needs: a signed-in demo account, or a write the safety guard does not send (the last click is then recorded without pressing it).
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
## 7. Show the user everything at once
|
|
71
77
|
|
|
72
|
-
|
|
78
|
+
Show the whole research compactly: the purpose and its evidence, each task with how it counts as done (in plain words, no XPath) and its route in one line, the questions, the invite, and the minutes. Say that on their approval you will walk each route in a browser and save the draft with the routes. Change whatever they ask for.
|
|
73
79
|
|
|
74
|
-
|
|
80
|
+
## 8. On approval: record the routes, then save
|
|
75
81
|
|
|
76
|
-
|
|
82
|
+
Do not ask again between these steps. For each task with a route, in order, use the task id `plan-<n>` (n = the task's position in the research, from 1):
|
|
77
83
|
|
|
78
84
|
{{path_walk}}
|
|
79
85
|
|
|
80
|
-
###
|
|
86
|
+
### Save each route
|
|
87
|
+
|
|
88
|
+
When `path_check` passes and the route ends where the plan said, call `path_save` (`page` or `click` when the route's end is the task's success condition, otherwise `keep`). It is kept until the draft is saved. Stop and ask the user only when a route cannot be walked as planned — the site differs from the code, a check keeps failing, or the route needs a write or a sign-in you do not have; leave that task without a route if they say so.
|
|
89
|
+
|
|
90
|
+
### Save the draft
|
|
81
91
|
|
|
82
|
-
|
|
92
|
+
Then 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. It uploads the routes you saved with the draft. Tell the user:
|
|
93
|
+
- the Inbox link, and that it stays a draft until they create it there, where they also set when the invite appears;
|
|
94
|
+
- which tasks have a route and the screenshots `path_check` listed for them, and any task left without one and why;
|
|
95
|
+
- a draft that is discarded, or not created within 30 days, loses its routes.
|
|
83
96
|
|
|
84
|
-
|
|
97
|
+
To redo a route later: `path_start` with `draft:<draft>:<index>` while the draft exists, or `{{path_cmd}}` with the research id once it is created.
|
|
85
98
|
|
|
86
99
|
Do not change any code.
|
package/mcp/path.js
CHANGED
|
@@ -124,9 +124,24 @@ class PathRecorder {
|
|
|
124
124
|
this.hub = hub;
|
|
125
125
|
/** taskId → { research, task } — path_tasks 로 받아 둔다 */
|
|
126
126
|
this.known = new Map();
|
|
127
|
+
/** 아직 저장 안 한 리서치의 과제 경로(plan-<번호>) — 번호 → { body, steps, how }. research_save 가 가져간다 */
|
|
128
|
+
this.planned = new Map();
|
|
127
129
|
this.cur = null;
|
|
128
130
|
}
|
|
129
131
|
|
|
132
|
+
/** 녹화해 둔 계획 과제 경로를 넘기고 비운다 */
|
|
133
|
+
takePlanned() {
|
|
134
|
+
const out = new Map(this.planned);
|
|
135
|
+
this.planned.clear();
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 상대 주소를 풀 사이트 — 연결된 프로젝트의 주소. 못 읽으면 빈 값(전체 주소를 받는다) */
|
|
140
|
+
async projectSite() {
|
|
141
|
+
if (this.site === undefined) this.site = await report.getInstall().then((m) => m.website || "").catch(() => "");
|
|
142
|
+
return this.site;
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
/** 리서치의 과제들 */
|
|
131
146
|
async tasks(researchId) {
|
|
132
147
|
const r = await report.getResearchTasks(researchId);
|
|
@@ -169,20 +184,24 @@ class PathRecorder {
|
|
|
169
184
|
|
|
170
185
|
/** 새로 기록을 시작한다. 같은 과제를 다시 하면 처음부터 */
|
|
171
186
|
async start({ task, url, device = "desktop", account = null }) {
|
|
172
|
-
|
|
187
|
+
// 아직 저장 안 한 리서치의 과제(new-research, plan-<번호>) — 녹화해 두었다가 research_save 가 초안과 함께 올린다
|
|
188
|
+
const plan = String(task).match(/^plan-(\d+)$/i);
|
|
189
|
+
const k = plan ? { research: { website: await this.projectSite() }, task: { title: `Planned task ${plan[1]}` } } : this.known.get(String(task));
|
|
173
190
|
if (!k)
|
|
174
191
|
throw new Error(
|
|
175
192
|
String(task).startsWith("draft:")
|
|
176
193
|
? `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
194
|
: `Task ${task} is not known yet. Call path_tasks with the research id first.`,
|
|
178
195
|
);
|
|
179
|
-
|
|
196
|
+
if (!/^https?:\/\//i.test(url) && !k.research.website) throw new Error("Give the full URL where the route starts — the project has no site address to resolve a path against.");
|
|
197
|
+
const full = /^https?:\/\//i.test(url) ? url : new URL(url, k.research.website).toString();
|
|
180
198
|
await this.stop();
|
|
181
199
|
const s = this.hub.site(full);
|
|
182
200
|
const ctx = await this.open(s, { device, account });
|
|
183
201
|
const page = await ctx.newPage();
|
|
184
202
|
ws.ensure();
|
|
185
|
-
|
|
203
|
+
// 폴더 이름에 : 를 못 쓰는 OS 가 있다(Windows) — draft:<초안>:<번호> 같은 id 를 바꿔 쓴다
|
|
204
|
+
const dir = path.join(ws.DIR, "paths", String(task).replace(/[^\w.-]+/g, "_"));
|
|
186
205
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
187
206
|
fs.mkdirSync(dir, { recursive: true });
|
|
188
207
|
this.cur = { taskId: String(task), title: k.task.title, s, ctx, page, net: watchNet(page), dir, startUrl: full, device, account, steps: [], actions: [], view: null, checked: null };
|
|
@@ -419,15 +438,25 @@ class PathRecorder {
|
|
|
419
438
|
};
|
|
420
439
|
if (success === "page") body.success = { condition: "url_entry", targetUrl: last.url };
|
|
421
440
|
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
|
-
|
|
441
|
+
const cutLine = cut.length
|
|
442
|
+
? `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.`
|
|
443
|
+
: "";
|
|
444
|
+
// 아직 저장 안 한 리서치의 과제 — 올리지 않고 들고 있다가 research_save 가 초안과 함께 올린다
|
|
445
|
+
const plan = c.taskId.match(/^plan-(\d+)$/i);
|
|
446
|
+
if (plan) {
|
|
447
|
+
const n = Number(plan[1]);
|
|
448
|
+
const how = success === "page" ? `done on reaching ${last.url}` : success === "click" ? `done on clicking "${last.label || last.tag}" on ${last.url}` : "success condition as written";
|
|
449
|
+
this.planned.set(n, { body, steps: steps.length, how });
|
|
450
|
+
await this.stop();
|
|
451
|
+
return [`Kept the checked path for planned task ${n}: ${steps.length} steps, ${how}. research_save uploads it with the draft.`, cutLine].filter(Boolean).join("\n");
|
|
452
|
+
}
|
|
453
|
+
// 초안 과제(draft:<초안>:<번호>)는 초안에 둔다 — 리서치로 만들 때 옮겨진다
|
|
423
454
|
const draft = c.taskId.match(/^draft:([^:]+):(\d+)$/);
|
|
424
455
|
const r = draft ? await report.saveDraftPath(draft[1], Number(draft[2]), body) : await report.savePath(c.taskId, body);
|
|
425
456
|
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.";
|
|
426
457
|
const done = [
|
|
427
458
|
`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}`,
|
|
428
|
-
|
|
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.`
|
|
430
|
-
: "",
|
|
459
|
+
cutLine,
|
|
431
460
|
]
|
|
432
461
|
.filter(Boolean)
|
|
433
462
|
.join("\n");
|
package/mcp/server.js
CHANGED
|
@@ -566,7 +566,7 @@ server.registerTool(
|
|
|
566
566
|
description:
|
|
567
567
|
"Open a fresh browser on the page where the task's intended route begins and start recording its expected path. The page itself is step 1. Starting again discards the recording in progress.",
|
|
568
568
|
inputSchema: {
|
|
569
|
-
task: z.string().describe("Task id from path_tasks"),
|
|
569
|
+
task: z.string().describe("Task id from path_tasks · plan-<n> for task n of a research not saved yet (new-research) · draft:<draft>:<index> for a saved draft"),
|
|
570
570
|
url: z.string().describe("Full URL where the route starts — the local dev server is fine; only paths are saved"),
|
|
571
571
|
device: z.enum(["desktop", "mobile"]).optional(),
|
|
572
572
|
account: z.string().optional().describe("Demo account saved by login (a1 …) if the route needs a signed-in user"),
|
|
@@ -685,7 +685,7 @@ server.registerTool(
|
|
|
685
685
|
"research_save",
|
|
686
686
|
{
|
|
687
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.",
|
|
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. Expected paths recorded and saved beforehand as plan-<n> (n = the task's position in tasks, from 1) are uploaded with it.",
|
|
689
689
|
inputSchema: {
|
|
690
690
|
title: z.string().describe("Short, for the team"),
|
|
691
691
|
purpose: z.string().describe("What the team wants to learn and why — the evidence it came from"),
|
|
@@ -706,26 +706,51 @@ server.registerTool(
|
|
|
706
706
|
)
|
|
707
707
|
.optional(),
|
|
708
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(),
|
|
709
|
+
invite: z.object({ page: z.string().describe("Page glob where visitors are invited"), copy: z.string().describe("The invite line, short. No duration — the card shows minutes") }).optional(),
|
|
710
|
+
minutes: z.number().int().min(1).max(60).optional().describe("Minutes the whole session takes (tasks and questions). Shown on the invite card and the start screen"),
|
|
710
711
|
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
712
|
findingIds: z.array(z.string()).optional().describe("Ids of the findings it answers, from research_context"),
|
|
712
713
|
},
|
|
713
714
|
},
|
|
714
715
|
wrap(async (args) => {
|
|
716
|
+
// 참여자는 초대를 수락한 페이지에서 첫 과제를 시작한다(SDK 는 다른 데로 데려가지 않는다).
|
|
717
|
+
// 첫 과제 경로가 다른 페이지에서 시작하면 첫 단계를 아무도 못 지난다 — 저장 전에 막는다. :이름 은 이름과 상관없이 같은 칸
|
|
718
|
+
const first = recorder.planned.get(1);
|
|
719
|
+
if (args.invite && first) {
|
|
720
|
+
const kind = (p) => String(p || "").trim().replace(/:[A-Za-z_]\w*/g, ":x").replace(/\/+$/, "") || "/";
|
|
721
|
+
const start = first.body.steps[0].url;
|
|
722
|
+
if (kind(args.invite.page) !== kind(start))
|
|
723
|
+
throw new Error(
|
|
724
|
+
`Nothing was saved. The invite is on ${args.invite.page}, but the first task's recorded route starts on ${start}. Participants start the first task where they accepted the invite, so these must be the same page: put the invite on ${start}, or record the first route again from the invite page (path_start plan-1). Tell the user which you change.`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
715
727
|
const r = await report.saveResearchDraft(args);
|
|
716
|
-
// 과제 경로 녹화(path_start)에 쓸 번호 — 옛 서버는 tasks 를 안 준다
|
|
717
728
|
const tasks = r.tasks || [];
|
|
718
729
|
if (tasks.length) recorder.addDraft(r.draftId, { title: args.title, website: r.website || "", tasks });
|
|
730
|
+
// 저장 전에 녹화해 둔 경로(plan-<번호>, 번호는 tasks 순서 1부터)를 초안의 그 과제에 올린다
|
|
731
|
+
const planned = recorder.takePlanned();
|
|
732
|
+
const lines = [];
|
|
733
|
+
for (const [n, p] of [...planned.entries()].sort((a, b) => a[0] - b[0])) {
|
|
734
|
+
const t = tasks[n - 1];
|
|
735
|
+
if (!t) {
|
|
736
|
+
lines.push(`- planned task ${n}: not uploaded — the research has ${tasks.length} task(s)`);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
await report.saveDraftPath(r.draftId, t.index, p.body);
|
|
741
|
+
lines.push(`- task ${n} "${t.title}": ${p.steps} steps, ${p.how}`);
|
|
742
|
+
} catch (e) {
|
|
743
|
+
lines.push(`- task ${n} "${t.title}": not uploaded (${e.message}) — record it again with path_start draft:${r.draftId}:${t.index}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const missing = tasks.filter((t, i) => !planned.has(i + 1) && t.condition !== "time_on_page");
|
|
719
747
|
return text(
|
|
720
748
|
[
|
|
721
749
|
`Saved as a draft${r.findings ? `, linked to ${r.findings} finding${r.findings > 1 ? "s" : ""}` : ""}.`,
|
|
722
750
|
`Open it in the Inbox to review and create it: ${report.dashboardUrl(r.inboxPath)}`,
|
|
723
|
-
...(
|
|
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
|
-
]
|
|
751
|
+
...(lines.length ? ["", "Expected paths saved with it:", ...lines] : []),
|
|
752
|
+
...(missing.length
|
|
753
|
+
? ["", "Tasks without an expected path:", ...missing.map((t) => `- ${t.title} (record later with path_start draft:${r.draftId}:${t.index})`)]
|
|
729
754
|
: []),
|
|
730
755
|
].join("\n"),
|
|
731
756
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usercase/mcp",
|
|
3
|
-
"version": "0.2.8-next.
|
|
3
|
+
"version": "0.2.8-next.5",
|
|
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",
|