@usercase/mcp 0.2.0 → 0.2.1-next.0

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
@@ -79,6 +79,16 @@ A research's task results separate people who **went straight** from those who *
79
79
 
80
80
  Claude reads your code to work out the route each task is meant to take, tells you the route, and walks it in a browser. It then walks it again in a fresh browser to make sure every step is found the same way, shows you the screenshots, and saves the path only after you approve it. Picking one item from a list (a product, a post) is not tied to the item Claude happened to open — any item of that kind counts. A local dev server is fine; only page paths are saved.
81
81
 
82
+ ## Make a question for a precise moment
83
+
84
+ The dashboard's **New question** picks one simple moment (on arrival, after a pause, when leaving, after a click). For anything more precise, ask from your repository:
85
+
86
+ ```
87
+ /mcp__usercase__question people who search but don't open any result for 10 seconds
88
+ ```
89
+
90
+ Claude reads your code for the real routes and button labels, writes the moment as a series of steps, and runs it over your recent real visits to show how many it matches and what those visitors did. It shows you 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. Needs the API key.
91
+
82
92
  ## What the visitors will and won't do
83
93
 
84
94
  - 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,62 @@
1
+ # Make a Usercase question
2
+
3
+ What the user wants: {{ask}}
4
+
5
+ Usercase shows a small question card to real visitors of this site at a chosen moment, and the answers show the team why people get stuck. You write **when** the card appears (a condition), the question and its answers. The dashboard can only make one-step conditions; you can chain steps because **you may read the code** — the routes, the components, the labels on buttons.
6
+
7
+ ## 1. Understand the moment
8
+
9
+ From the user's words, decide what situation they mean: which screen, what the visitor did before, and when it is fair to interrupt. If the request is vague ("ask people on checkout"), ask one short question to pin the moment down. Otherwise go on.
10
+
11
+ ## 2. Read the code and the visits
12
+
13
+ - Find the screens and controls involved in the code: the route patterns, the text on the buttons, field names, form classes.
14
+ - Call `question_context`. It lists the paths and element words real visitors produced recently. **Page globs and targets must match these.** The code says what exists; the visits say what is actually used and how it is labelled at runtime. If they disagree, trust the visits.
15
+
16
+ ## 3. Write the condition
17
+
18
+ A condition has a `page` (the screen the visitor is on when the card appears), `steps` in order, and a `moment`.
19
+
20
+ Step kinds (`do`): `arrive` · `back` · `click` · `input` · `submit` · `scroll` · `idle` · `hide` (tab hidden) · `error` (a JavaScript error).
21
+ Options on a step: `page`, `from` (arrive/back), `target` (click/input/submit), `atLeast`, `percent` (scroll), `seconds` (idle: a pause between two actions), `cleared` (input: typed then deleted), `absent` (this did not happen between the previous step and the moment).
22
+ Moment: `on_match` (as soon as the steps are met) · `idle` with `seconds` (then that long with no action) · `leave` (about to leave; on phones, a long pause instead).
23
+
24
+ Globs: `*` matches anything including `/`; `:name` matches one path segment (`/project/:slug`). Use the route parameter names from the code. Never put `?` or `#` in a glob.
25
+
26
+ Targets match the element's own text, field name or class — case does not matter, and the words only need to be contained. Prefer the words the visitor saw. Use `class:name` for buttons without text and `tag:BUTTON` only when the text differs every time (a list of names).
27
+
28
+ Steps happen in order, with anything allowed in between. All steps must happen; there is no "or" — two situations are two questions.
29
+
30
+ **It cannot see:** what is on the screen (an error message, an empty list, a price), the values typed (only length and whether it was cleared), time limits ("within 10 seconds"), who the visitor is (device, new or returning, campaign), or anything across visits. A visit is one tab; 30 minutes of nothing starts a new one. If the user asks for one of these, say so and offer the nearest thing it can do — usually a behavior that goes with it (a submit followed by no arrival at the next page, instead of "an error was shown").
31
+
32
+ Rules that make a card actually appear and mean something:
33
+ - The card must be shown on the screen where the trouble is. A click that opens another page ends the situation — use the `arrive` on the next page instead.
34
+ - Never ask while they are typing: the last step of an `on_match` condition must not be `input`. Use `idle` or the next step.
35
+ - Never ask the moment someone lands on a sign-in, sign-up or payment screen; use `idle` or `leave` there.
36
+ - The page must name a real screen, not only wildcards.
37
+ - Narrow enough to be one situation: a condition that matches a large share of all visits is too broad.
38
+
39
+ ## 4. Check it
40
+
41
+ Call `question_check` with the condition. Read the result:
42
+ - **Problems**: fix them and check again. Saving is refused while there are any.
43
+ - **Matched 0** with visits present: the paths or words are wrong, or the situation has not happened recently. Compare with `question_context`.
44
+ - **Examples**: read what the matched visitors did. If it is not the situation the user meant, the condition is wrong even if it passes.
45
+ - **Overlaps**: an existing question fires at the same moment; only one card shows per visit. Tell the user.
46
+
47
+ ## 5. Write the question
48
+
49
+ - English, under 15 words, about this moment ("What were you looking for?"), not opinions about the site.
50
+ - Prefer `choice` with 3–5 short answers in the visitor's own words, one of them meaning nothing was wrong, marked `noProblem: true` — when most people pick it, the question turns itself off. Use `text` only when you genuinely cannot guess the likely answers. Avoid `yes_no`: it says that something is wrong, not what.
51
+ - `triggerEn`: one plain sentence a product manager understands, saying when it appears. No paths or class names.
52
+
53
+ ## 6. Show the user, then save
54
+
55
+ Show the user, briefly:
56
+ - when it appears, in plain words, and how many recent visits matched,
57
+ - the question and its answers,
58
+ - any overlap or anything you could not express.
59
+
60
+ On their approval, call `question_save`. Give them the Inbox link it returns and tell them it stays a draft until someone turns it on there. If they want more questions, go back to step 1.
61
+
62
+ Do not change any code.
package/mcp/report.js CHANGED
@@ -183,4 +183,25 @@ const getResearchTasks = (researchId) => keyed("GET", `/agent/researches/${encod
183
183
  /** 과제의 정상 경로를 통째로 바꾼다. 화면을 올리느라 오래 걸릴 수 있다 */
184
184
  const savePath = (taskId, body) => keyed("PUT", `/agent/tasks/${encodeURIComponent(taskId)}/path`, body, 90000);
185
185
 
186
- module.exports = { KEY_ENV, URL_ENV, RUN_REF, describeKey, getInstall, sendVisit, sendDiagnostics, getResearchTasks, savePath };
186
+ /** 질문 만들기(question) — 최근 방문을 읽어야 해서 조금 걸린다 */
187
+ const getQuestionContext = () => keyed("GET", "/agent/questions/context", null, 60000);
188
+ const checkQuestion = (condition) => keyed("POST", "/agent/questions/check", { condition }, 60000);
189
+ const saveQuestion = (body) => keyed("POST", "/agent/questions", body, 60000);
190
+ /** 대시보드 주소. API 주소에서 /koa 를 뗀다 */
191
+ const dashboardUrl = (p) => apiBase().replace(/\/koa$/, "") + p;
192
+
193
+ module.exports = {
194
+ KEY_ENV,
195
+ URL_ENV,
196
+ RUN_REF,
197
+ describeKey,
198
+ getInstall,
199
+ sendVisit,
200
+ sendDiagnostics,
201
+ getResearchTasks,
202
+ savePath,
203
+ getQuestionContext,
204
+ checkQuestion,
205
+ saveQuestion,
206
+ dashboardUrl,
207
+ };
package/mcp/server.js CHANGED
@@ -303,6 +303,109 @@ server.registerTool(
303
303
  }),
304
304
  );
305
305
 
306
+ // ── 질문 만들기 (메인 에이전트, question 절차) ──
307
+ // 대시보드 New question 은 한 단계 조건만 고른다. 여러 단계 조건은 여기서 코드를 읽고 쓴다.
308
+ // 서버가 최근 방문에 돌려 본 것만 초안으로 받는다 — 켜는 건 사람이 Inbox 에서 한다
309
+
310
+ const QStep = z.object({
311
+ do: z.enum(["arrive", "click", "scroll", "input", "submit", "back", "hide", "error", "idle"]),
312
+ page: z.string().optional().describe("Path glob of the screen this happened on. * = anything, :name = one segment"),
313
+ from: z.string().optional().describe("arrive/back only: path glob of the screen they came from"),
314
+ target: z.string().optional().describe("click/input/submit: words in the element's text or field name, or class:name, or tag:BUTTON"),
315
+ atLeast: z.number().int().optional().describe("Happened at least this many times"),
316
+ percent: z.number().optional().describe("scroll: reached at least this % of the page"),
317
+ seconds: z.number().optional().describe("idle: a pause of at least this many seconds between two actions"),
318
+ cleared: z.boolean().optional().describe("input: typed and then deleted"),
319
+ absent: z.boolean().optional().describe("This did NOT happen between the previous step and the moment the question appears"),
320
+ });
321
+ const QCondition = z.object({
322
+ page: z.string().describe("Path glob of the screen the visitor is on when the question appears"),
323
+ steps: z.array(QStep).describe("What happened, in order. Other actions may happen in between"),
324
+ moment: z.object({
325
+ type: z.enum(["on_match", "idle", "leave"]).describe("on_match = as soon as the steps are met · idle = after that, N seconds with no action · leave = when about to leave"),
326
+ seconds: z.number().optional(),
327
+ }),
328
+ });
329
+
330
+ function describeCheck(r) {
331
+ const L = [`Checked against ${r.visits} visits from the last ${r.days} days: matched ${r.matched}.`];
332
+ if (!r.visits) L.push("There are no recent visits yet, so the condition could only be checked for shape, not against real behavior.");
333
+ if (r.issues.length) L.push("", "Problems — fix these before saving:", ...r.issues.map((s) => `- ${s}`));
334
+ else L.push("No problems found.");
335
+ if (r.overlaps.length)
336
+ L.push("", "Fires at the same moment as existing questions (only one card shows per visit, so one of them would rarely appear):", ...r.overlaps.map((o) => `- "${o.question}" (${o.status})`));
337
+ if (r.examples.length) L.push("", "What some matched visitors did, up to the moment the question would appear:", ...r.examples.map((e) => "```\n" + e + "\n```"));
338
+ L.push("", "Condition as it will be saved:", "```json", JSON.stringify(r.condition, null, 2), "```");
339
+ return L.join("\n");
340
+ }
341
+
342
+ server.registerTool(
343
+ "question_context",
344
+ {
345
+ description:
346
+ "Screens and element words from this project's recent real visits. Call first in the question procedure, before writing a condition — page globs and click targets must match what visitors actually saw.",
347
+ inputSchema: {},
348
+ },
349
+ wrap(async () => {
350
+ const c = await report.getQuestionContext();
351
+ if (!c.visits) return text(`No visits in the last ${c.days} days. Conditions can still be saved, but cannot be checked against behavior until visits come in.`);
352
+ return text(
353
+ [
354
+ `${c.visits} visits in the last ${c.days} days.`,
355
+ "",
356
+ "Screens (visits):",
357
+ c.pages,
358
+ "",
359
+ "Every path seen:",
360
+ " " + c.paths.join(" "),
361
+ "",
362
+ "Words on clicked elements and field names, most common first:",
363
+ " " + c.words.map((w) => JSON.stringify(w)).join(" "),
364
+ ].join("\n"),
365
+ );
366
+ }),
367
+ );
368
+
369
+ server.registerTool(
370
+ "question_check",
371
+ {
372
+ description:
373
+ "Run a condition over this project's recent visits: how many it matches, grammar problems, overlap with existing questions, and what matched visitors did. Repeat until there are no problems.",
374
+ inputSchema: { condition: QCondition },
375
+ },
376
+ wrap(async ({ condition }) => text(describeCheck(await report.checkQuestion(condition)))),
377
+ );
378
+
379
+ server.registerTool(
380
+ "question_save",
381
+ {
382
+ description:
383
+ "Save the question as a DRAFT in the dashboard Inbox, after the user approved it. The server checks the condition again and refuses it if there are problems. Nobody sees it until someone turns it on.",
384
+ inputSchema: {
385
+ condition: QCondition,
386
+ question: z.string().describe("English, under 15 words. Asks about this moment"),
387
+ answerType: z.enum(["choice", "yes_no", "text"]).optional(),
388
+ choices: z.array(z.object({ text: z.string(), noProblem: z.boolean().optional() })).optional().describe("For choice: 2–6 choices, exactly one with noProblem: true"),
389
+ allowOther: z.boolean().optional().describe("Let visitors write their own answer (choice only). Default true"),
390
+ title: z.string().optional().describe("English, a few words, for the Inbox"),
391
+ why: z.string().optional().describe("English. What the team wants to learn, shown in the Inbox"),
392
+ triggerEn: z.string().optional().describe("English. One plain sentence: when and in what situation it appears. No paths or class names"),
393
+ },
394
+ },
395
+ wrap(async (args) => {
396
+ let r;
397
+ try {
398
+ r = await report.saveQuestion(args);
399
+ } catch (e) {
400
+ if (e.status === 422) throw new Error("The condition did not pass the check. Run question_check, fix the problems, then save again");
401
+ throw e;
402
+ }
403
+ const L = [`Saved as a draft — matched ${r.matched} of ${r.visits} recent visits.`, `Open it in the Inbox to turn it on: ${report.dashboardUrl(r.inboxPath)}`];
404
+ if (r.overlaps.length) L.push("", "Note: it fires at the same moment as:", ...r.overlaps.map((o) => `- "${o.question}" (${o.status})`));
405
+ return text(L.join("\n"));
406
+ }),
407
+ );
408
+
306
409
  // ── 과제의 정상 경로 (메인 에이전트, path 절차) ──
307
410
 
308
411
  const recorder = new PathRecorder(hub);
@@ -542,6 +645,18 @@ server.registerPrompt(
542
645
  },
543
646
  );
544
647
 
648
+ server.registerPrompt(
649
+ "question",
650
+ { description: "Make a Usercase question that appears at a precise moment on your site", argsSchema: { ask: z.string().optional().describe("Who to ask and what to learn, in your own words") } },
651
+ ({ ask }) => {
652
+ const body = fs
653
+ .readFileSync(path.join(__dirname, "..", "agents", "question.md"), "utf8")
654
+ .replace(/\r\n/g, "\n")
655
+ .replace("{{ask}}", ask || "(ask the user what moment they want to ask about, and what they want to learn)");
656
+ return { messages: [{ role: "user", content: { type: "text", text: body } }] };
657
+ },
658
+ );
659
+
545
660
  async function main() {
546
661
  // 예전에 깐 방문자 에이전트가 있으면 이 버전 것으로 바꿔 둔다 — setup 은 처음 한 번만 불려서 옛 지시문이 남는다.
547
662
  // 리서치를 한 번도 안 쓴 사람에게는 만들지 않는다
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1-next.0",
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",
@@ -26,7 +26,11 @@
26
26
  },
27
27
  "scripts": {
28
28
  "check": "node --check mcp/server.js && node scripts/onboarding.js --check && node scripts/agents.js --check",
29
- "docs": "node scripts/onboarding.js && node scripts/agents.js"
29
+ "docs": "node scripts/onboarding.js && node scripts/agents.js",
30
+ "release:next": "node scripts/release.js next",
31
+ "release:promote": "node scripts/release.js promote",
32
+ "release:rollback": "node scripts/release.js rollback",
33
+ "release:status": "node scripts/release.js status"
30
34
  },
31
35
  "dependencies": {
32
36
  "@modelcontextprotocol/sdk": "^1.30.0",