@usercase/mcp 0.0.0-stage → 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/mcp/server.js ADDED
@@ -0,0 +1,679 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * 어닝버드 MCP 서버(stdio). 고객의 AI 도구(Claude Code 등)가 저장소 폴더에서 띄운다.
5
+ *
6
+ * 역할이 둘로 갈린다(PLAN.md).
7
+ * - **메인 에이전트**: 저장소를 읽고 사이트를 이해해 가상 사용자를 만든다(save_people), 점검한다(check_site),
8
+ * 사람마다 서브에이전트를 띄우고 결과를 모은다(results)
9
+ * - **가상 사용자 서브에이전트**(`usercase-visitor`): 그 사람이 되어 open_visit → act → answer_card 로 한 걸음씩 걷는다.
10
+ * **파일을 보지 않는다** — 도구가 이 서버의 것만 허용된 에이전트다. 코드를 알면 사람이 못 찾는 버튼을 바로 찾아 QA 값이 사라진다
11
+ *
12
+ * AI 비용은 고객 구독에서 나간다. 우리는 몸(브라우저·사람 손·막는 선)만 준다.
13
+ * **stdout 은 MCP 통신이다. 로그는 stderr 로만.**
14
+ */
15
+ const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
16
+ const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
17
+ const { z } = require("zod");
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+ const os = require("os");
21
+ const { Hub } = require("./hub.js");
22
+ const ws = require("./workspace.js");
23
+ const persona = require("../src/persona/persona.js");
24
+ const report = require("./report.js");
25
+ const peopleRules = require("../src/persona/people.js");
26
+ const { PathRecorder } = require("./path.js");
27
+
28
+ const hub = new Hub();
29
+ const server = new McpServer({ name: "usercase", version: require("../package.json").version });
30
+
31
+ const text = (t) => ({ content: [{ type: "text", text: String(t) }] });
32
+ const fail = (e) => ({ content: [{ type: "text", text: `Error: ${e.message}` }], isError: true });
33
+ const wrap = (fn) => async (args) => {
34
+ try {
35
+ return await fn(args);
36
+ } catch (e) {
37
+ return fail(e);
38
+ }
39
+ };
40
+
41
+ // ── 메인 에이전트용 ──
42
+
43
+ server.registerTool(
44
+ "check_site",
45
+ {
46
+ description:
47
+ "Open the site once as a virtual user and report: does it load, is the Usercase SDK running (which project), is a firewall/bot protection blocking us (and how to unblock, per vendor), is the secret key set. Always run this before starting visits. If it reports a block, stop and tell the user.",
48
+ inputSchema: { url: z.string().url().describe("The site to test, e.g. https://staging.example.com") },
49
+ },
50
+ wrap(async ({ url }) => text(await hub.checkSite(url))),
51
+ );
52
+
53
+ /** 설치 스크립트를 어디에 넣나 — 프레임워크별. 고객 저장소를 읽는 건 메인 에이전트다 */
54
+ const PLACEMENT = [
55
+ "## Where to put it",
56
+ "Once, in the place every page goes through, inside <head> (or as early in <body> as the framework allows):",
57
+ "- Next.js App Router: app/layout.tsx — use next/script: <Script id=\"usercase\" strategy=\"afterInteractive\" dangerouslySetInnerHTML={{ __html: \"<the code inside the script tag>\" }} />",
58
+ "- Next.js Pages Router: pages/_document.tsx, inside <Head>, as a <script dangerouslySetInnerHTML={{ __html: ... }} />",
59
+ "- Plain HTML, Vite, Create React App: index.html (or every .html page), right before </head>",
60
+ "- Remix, Nuxt, SvelteKit, Astro, Gatsby: the root document or layout's head",
61
+ "- WordPress, Shopify and other themes: the header template (header.php, theme.liquid) before </head>",
62
+ "- Google Tag Manager only: tell the user to add it as a Custom HTML tag on All Pages instead of editing code",
63
+ "Do not change the code inside the tag: it carries this project's id. In JSX, keep it as a string, not parsed markup.",
64
+ ].join("\n");
65
+
66
+ server.registerTool(
67
+ "install_snippet",
68
+ {
69
+ description:
70
+ "Get the Usercase script for the project this API key belongs to, with where to put it per framework. Use when check_site says the SDK is not running and you are in the site's repository: add the script once to the root layout/document, show the user the diff, then run check_site on the local site to confirm. Do not commit or deploy unless asked.",
71
+ inputSchema: {},
72
+ },
73
+ wrap(async () => {
74
+ const me = await report.getInstall();
75
+ return text(
76
+ [
77
+ `Project: "${me.title}" (${me.projectId})${me.website ? ` — ${me.website}` : ""}`,
78
+ "",
79
+ "## The script",
80
+ "```html",
81
+ me.snippet,
82
+ "```",
83
+ "",
84
+ PLACEMENT,
85
+ "",
86
+ `If the repository already contains "Usercase.project" or "Earningbirds.project": same id → already installed; a different id → ask the user which project is right.`,
87
+ "",
88
+ "## After adding it",
89
+ "1. Show the user the diff. Do not commit or deploy unless asked.",
90
+ "2. If the site runs locally (it may already be running — try its usual dev address), call check_site on it. Done when it says \"Usercase SDK is running\" with this project.",
91
+ "3. Tell the user real visitors are recorded once this is deployed, and offer to send a few virtual users now (the research prompt).",
92
+ ].join("\n"),
93
+ );
94
+ }),
95
+ );
96
+
97
+ /** 사람 한 줄: 기기 · 유입 → 착지 · 회원 · [기능] — 목적 */
98
+ const personLine = (p) =>
99
+ `${p.device} · ${p.arrival}${p.landing ? ` → ${p.landing}` : ""}${p.member ? " · member" : ""}${p.feature ? ` · [${p.feature}]` : ""} — ${p.goal}`;
100
+
101
+ /** 사람들이 어느 기능에 몰려 있나 — 다음 판에 빈 곳을 채우라고 보여 준다 */
102
+ function coverageLine(people) {
103
+ const c = {};
104
+ for (const p of people) {
105
+ const k = p.feature || "(no feature)";
106
+ c[k] = (c[k] || 0) + 1;
107
+ }
108
+ return "Features so far: " + Object.entries(c).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(" · ");
109
+ }
110
+
111
+ const PERSON = z.object({
112
+ device: z.enum(["desktop", "mobile"]),
113
+ background: z.string().describe("Who they are, in one or two sentences"),
114
+ goal: z.string().describe("What they came to this site to do or find out, in their own terms"),
115
+ moment: z.string().describe("Their state right now: where they are, how much time, mood"),
116
+ attention: z.enum(["skims", "reads", "studies"]),
117
+ surveyHabit: z.enum(["ignores", "sometimes", "willing"]).describe("How they usually treat pop-up survey cards. Most people ignore them"),
118
+ writing: z.enum(["careless", "careful"]),
119
+ arrival: z.enum(["search", "social", "shared", "direct"]).describe("How they got here: search engine, a link in a feed or chat, a link someone sent, or typed the address / a bookmark"),
120
+ landing: z
121
+ .string()
122
+ .optional()
123
+ .describe(
124
+ "The page they land on, as a path on this site (e.g. /bluecard/6553c3ba5fae82d8976b6806, /watchlist, /calendar). The page their search result, link or bookmark would really open — for someone who searched for one announcement, that announcement. Take real paths from check_site's page list or the routes in the code. Leave out to land on the front page",
125
+ ),
126
+ feature: z
127
+ .string()
128
+ .optional()
129
+ .describe("Which part of the site this visit is about, from your feature map (e.g. calendar, comments, profile settings). For your coverage only — the visitor never sees it"),
130
+ member: z
131
+ .boolean()
132
+ .optional()
133
+ .describe("Already has an account and is signed in (uses a demo account saved by `login`). Mix members and non-members — all members hides what newcomers hit"),
134
+ account: z
135
+ .string()
136
+ .optional()
137
+ .describe("Members only: which demo account (a1, a2 …). Leave out — members are spread across the signed-in accounts and keep the same one"),
138
+ traits: z
139
+ .object({
140
+ readCps: z.number().describe("reading speed, chars/sec, 6–60"),
141
+ patience: z.number().describe("0.05–1"),
142
+ decisiveness: z.number().describe("0.05–1"),
143
+ distraction: z.number().describe("0–1"),
144
+ thoroughness: z.number().describe("0–1"),
145
+ mobileEase: z.number().describe("0–1"),
146
+ })
147
+ .partial()
148
+ .optional()
149
+ .describe("How they move. Omit to derive automatically"),
150
+ });
151
+
152
+ server.registerTool(
153
+ "save_people",
154
+ {
155
+ description:
156
+ "Save virtual users to .usercase/people.json (adds to what is there). People have no names — like real visitors; each gets a number (p1, p2…) to refer to them by. Make people who would really come to this site — varied goals, devices, arrival paths, and states of mind. Do not make everyone a perfect fit for one feature. Show the list to the user before running visits.",
157
+ inputSchema: {
158
+ site: z.string().url().describe("Site origin the visits go to"),
159
+ site_about: z.string().describe("One or two English sentences on what the site is, as a visitor would understand it"),
160
+ people: z.array(PERSON).min(1).max(50),
161
+ },
162
+ },
163
+ wrap(async ({ site, site_about, people }) => {
164
+ const doc = ws.loadPeople();
165
+ doc.site = new URL(site).origin;
166
+ doc.siteAbout = site_about;
167
+ const at = new Date().toISOString();
168
+ // 착지 화면은 이 사이트 경로만. 전체 주소로 줘도 경로로 바꾸고, 다른 사이트면 버린다(그 사람은 홈으로 들어온다)
169
+ const landingOf = (l) => {
170
+ if (!l) return undefined;
171
+ try {
172
+ const u = new URL(l, doc.site);
173
+ return u.origin === doc.site ? u.pathname + u.search : undefined;
174
+ } catch {
175
+ return undefined;
176
+ }
177
+ };
178
+ const ids = peopleRules.withIds(doc.people, people.map((p) => ({ ...p, landing: landingOf(p.landing), createdAt: at, usedAt: null })));
179
+ for (const p of ids.incoming) p.traits = persona.coerceTraits(p.traits, peopleRules.personKey(p)).traits;
180
+ doc.people = [...ids.existing, ...ids.incoming];
181
+ ws.savePeople(doc);
182
+ return text(
183
+ [
184
+ `Saved ${people.length} people → ${ws.PEOPLE}`,
185
+ ...ids.incoming.map((p) => `- ${p.id} · ${personLine(p)}`),
186
+ `${doc.people.length} in total (${doc.people.filter((x) => !x.usedAt).length} not visited yet)`,
187
+ coverageLine(doc.people),
188
+ ].join("\n"),
189
+ );
190
+ }),
191
+ );
192
+
193
+ server.registerTool(
194
+ "list_people",
195
+ { description: "List the virtual users in .usercase/people.json and whether each has visited yet.", inputSchema: {} },
196
+ wrap(async () => {
197
+ const doc = ws.loadPeople();
198
+ if (!doc.people.length) return text("No people yet. Create them with save_people.");
199
+ return text(
200
+ [`site: ${doc.site}`, ...doc.people.map((p) => `- ${p.id ?? "?"} · ${p.usedAt ? "visited" : "not visited"} · ${personLine(p)}`), coverageLine(doc.people)].join("\n"),
201
+ );
202
+ }),
203
+ );
204
+
205
+ server.registerTool(
206
+ "results",
207
+ {
208
+ description: "Recent visit results (one summary per visit) and what the safety guard blocked. Call after each round of visitor sub-agents finishes — it also says whether the research run goes on to another round.",
209
+ inputSchema: { limit: z.number().int().min(1).max(200).optional() },
210
+ },
211
+ wrap(async ({ limit = 30 }) => {
212
+ const rows = ws.recentRuns(limit);
213
+ const { summarize } = require("./hub.js");
214
+ const lines = rows.map((r) => summarize(r)).join("\n\n");
215
+ const st = hub.status();
216
+ const next = runStatus();
217
+ return text([lines || "No visits yet.", st ? `\n---\n${st}` : "", next ? `\n---\n${next}` : ""].join("\n"));
218
+ }),
219
+ );
220
+
221
+ server.registerTool(
222
+ "login",
223
+ {
224
+ description:
225
+ "Open a visible browser on the user's screen so THEY can sign in to the site with one demo account. Only the resulting session (cookies) is saved, under an account name in .usercase/sessions/ — the password never passes through you or us. Call it once per demo account, one at a time. Returns immediately; tell the user to sign in and close the window, then run check_site — it checks whether the sign-in really worked. If an account is not signed in or has expired, call login again with that same account name. Members (member=true) are spread across the signed-in accounts. Never ask the user for their password.",
226
+ inputSchema: {
227
+ url: z.string().url().describe("The sign-in page, or any page of the site"),
228
+ account: z.string().optional().describe('Account name to save it as (a1, a2 …). Leave out for a new account; give an existing name to sign in again'),
229
+ window: z
230
+ .enum(["chrome", "playwright"])
231
+ .optional()
232
+ .describe(
233
+ "Which window. chrome (default): the user's normal Chrome — Google and other sign-in buttons work, but a sign-in cookie that lasts only until the browser closes is lost. playwright: keeps such cookies, but Google sign-in is blocked there. Choose from the site's auth code; leave out if unsure",
234
+ ),
235
+ lifetimeHours: z
236
+ .number()
237
+ .positive()
238
+ .optional()
239
+ .describe("How long a sign-in lasts, if the auth code says (e.g. a session maxAge or ttl). Shown as time left, and the account is not used after it"),
240
+ },
241
+ },
242
+ wrap(async ({ url, account, window, lifetimeHours }) => text(await hub.login(url, account, { window, lifetimeH: lifetimeHours }))),
243
+ );
244
+
245
+ server.registerTool(
246
+ "set_writes",
247
+ {
248
+ description:
249
+ "Choose whether visitors' writes reach the site: signing up, posting, commenting, saving, liking. 'block' (the default) sends nothing to the site; 'send' lets those through for real. Only the user can decide this — ask them, and only call this with their answer. 'send' suits a test or staging site, or data that may be messed up; on a live site real users will see what visitors post. Payments, deleting, wallet connections and sign-out stay blocked either way. Saved in .usercase/settings.json.",
250
+ inputSchema: { mode: z.enum(["block", "send"]).describe("block = nothing is sent to the site; send = writes really reach the site") },
251
+ },
252
+ wrap(async ({ mode }) => text(hub.setWrites(mode))),
253
+ );
254
+
255
+ const AGENT_SRC = path.join(__dirname, "..", "agents", "usercase-visitor.md");
256
+ // 사용자 전체(~/.claude/agents/)에 둔다 — 사용자 저장소에 파일을 늘리지 않는다
257
+ const AGENT_DEST = path.join(os.homedir(), ".claude", "agents", "usercase-visitor.md");
258
+ // 0.1.6 까지는 저장소에 깔았다. 같은 이름이면 저장소 것이 이겨서 옛 지시문이 남으니 치운다.
259
+ // 0.1.x 까지는 이름이 earningbirds-visitor 였다 — 옛 도구 이름(mcp__earningbirds__*)을 가리키니 같이 치운다
260
+ const AGENT_OLD = [
261
+ path.join(process.cwd(), ".claude", "agents", "earningbirds-visitor.md"),
262
+ path.join(os.homedir(), ".claude", "agents", "earningbirds-visitor.md"),
263
+ ];
264
+
265
+ /** 예전에 깐 것을 지운다. 우리가 만든 파일(scripts/agents.js 표시)만 */
266
+ function removeOldAgent() {
267
+ let removed = false;
268
+ for (const p of AGENT_OLD) {
269
+ try {
270
+ if (!fs.readFileSync(p, "utf8").includes("scripts/agents.js")) continue;
271
+ fs.unlinkSync(p);
272
+ if (p.startsWith(process.cwd())) try { fs.rmdirSync(path.dirname(p)); } catch {} // 비었을 때만 지워진다
273
+ removed = true;
274
+ } catch {}
275
+ }
276
+ return removed;
277
+ }
278
+
279
+ /** 방문자 에이전트를 ~/.claude/agents/ 에 둔다. "installed" | "updated" | "same" */
280
+ function installAgent() {
281
+ const moved = removeOldAgent();
282
+ const had = fs.existsSync(AGENT_DEST);
283
+ if (had && fs.readFileSync(AGENT_DEST, "utf8") === fs.readFileSync(AGENT_SRC, "utf8")) return moved ? "updated" : "same";
284
+ fs.mkdirSync(path.dirname(AGENT_DEST), { recursive: true });
285
+ fs.copyFileSync(AGENT_SRC, AGENT_DEST);
286
+ return had || moved ? "updated" : "installed";
287
+ }
288
+
289
+ server.registerTool(
290
+ "setup",
291
+ {
292
+ description:
293
+ "Install the usercase-visitor sub-agent definition into ~/.claude/agents/ (user-wide, nothing is added to the repository) so visitors can be started. Run once if that agent type is not available. The user may need to restart Claude Code afterwards.",
294
+ inputSchema: {},
295
+ },
296
+ wrap(async () => {
297
+ const r = installAgent();
298
+ return text(
299
+ r === "same"
300
+ ? `Already up to date — ${AGENT_DEST}`
301
+ : `${r === "updated" ? "Updated" : "Installed"} → ${AGENT_DEST}\nThis agent can only use the three Usercase tools (open_visit, act, answer_card) and cannot read files.\nRestart Claude Code so it picks up the ${r === "updated" ? "new version" : "agent"}.`,
302
+ );
303
+ }),
304
+ );
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
+
409
+ // ── 과제의 정상 경로 (메인 에이전트, path 절차) ──
410
+
411
+ const recorder = new PathRecorder(hub);
412
+
413
+ server.registerTool(
414
+ "path_tasks",
415
+ {
416
+ description: "List the tasks of a Usercase research (from the dashboard) and whether each already has an expected path. Call first in the path procedure.",
417
+ inputSchema: { research: z.string().describe("The research id, as shown in the dashboard command") },
418
+ },
419
+ wrap(async ({ research }) => text(await recorder.tasks(research))),
420
+ );
421
+
422
+ server.registerTool(
423
+ "path_start",
424
+ {
425
+ description:
426
+ "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.",
427
+ inputSchema: {
428
+ task: z.string().describe("Task id from path_tasks"),
429
+ url: z.string().describe("Full URL where the route starts — the local dev server is fine; only paths are saved"),
430
+ device: z.enum(["desktop", "mobile"]).optional(),
431
+ account: z.string().optional().describe("Demo account saved by login (a1 …) if the route needs a signed-in user"),
432
+ },
433
+ },
434
+ wrap(async ({ task, url, device, account }) => text(await recorder.start({ task, url, device, account }))),
435
+ );
436
+
437
+ server.registerTool(
438
+ "path_click",
439
+ {
440
+ description:
441
+ "Click a numbered element from the latest screen. By default it becomes a step of the path (the element is recorded; a new page reached is recorded too). record=false for clicks that are not part of the route (closing a cookie banner) — they are still repeated by path_check. press=false records the element as the final click without pressing it (a Pay or Submit button the safety guard will not press).",
442
+ inputSchema: {
443
+ index: z.number().int(),
444
+ record: z.boolean().optional(),
445
+ press: z.boolean().optional(),
446
+ },
447
+ },
448
+ wrap(async ({ index, record, press }) => text(await recorder.click({ index, record, press }))),
449
+ );
450
+
451
+ server.registerTool(
452
+ "path_type",
453
+ {
454
+ description: "Type into a numbered field (a search box, a quantity). Not a path step, but repeated by path_check.",
455
+ inputSchema: { index: z.number().int(), text: z.string() },
456
+ },
457
+ wrap(async ({ index, text: t }) => text(await recorder.type({ index, text: t }))),
458
+ );
459
+
460
+ server.registerTool(
461
+ "path_scroll",
462
+ { description: "Scroll the recording page to see more.", inputSchema: { direction: z.enum(["down", "up"]).optional() } },
463
+ wrap(async ({ direction }) => text(await recorder.scroll({ up: direction === "up" }))),
464
+ );
465
+
466
+ server.registerTool(
467
+ "path_pattern",
468
+ {
469
+ description:
470
+ "Make a page step match any page of its kind: /notice/6553c3ba → /notice/:id. Use when participants would open a different item than the one you did (the route reads from the code). Applies to every step on that page.",
471
+ inputSchema: { step: z.number().int().min(1), url: z.string().describe("Path pattern with :name for the changing parts") },
472
+ },
473
+ wrap(async ({ step, url }) => text(await recorder.pattern({ step, url }))),
474
+ );
475
+
476
+ server.registerTool(
477
+ "path_check",
478
+ {
479
+ description:
480
+ "Walk the recorded path again in a fresh browser: every clicked element must still be found at its recorded position with the same text, and every page must be reached. Lists the screenshots to show the user. Required before path_save.",
481
+ inputSchema: {},
482
+ },
483
+ wrap(async () => text(await recorder.check())),
484
+ );
485
+
486
+ server.registerTool(
487
+ "path_save",
488
+ {
489
+ description:
490
+ "Upload the checked path to the dashboard, replacing the task's expected path. Only after the user approved it. success: keep (default) leaves the task's success condition; page = done on reaching the last page of the path; click = done on the last recorded click. Ask the user before changing it.",
491
+ inputSchema: { success: z.enum(["keep", "page", "click"]).optional() },
492
+ },
493
+ wrap(async ({ success }) => text(await recorder.save({ success }))),
494
+ );
495
+
496
+ // ── 가상 사용자 서브에이전트용 ──
497
+
498
+ server.registerTool(
499
+ "open_visit",
500
+ {
501
+ description:
502
+ "Start a visit as one virtual user from people.json. Returns a visit_id and what you see first. You ARE this person from now on. If it says the site is blocking us, stop and report that.",
503
+ inputSchema: { person: z.string().describe("The person's number from people.json, e.g. p3") },
504
+ },
505
+ wrap(async ({ person }) => {
506
+ const r = await hub.open(person);
507
+ return text(r.text);
508
+ }),
509
+ );
510
+
511
+ server.registerTool(
512
+ "act",
513
+ {
514
+ description:
515
+ "Do one thing on the screen, as the person. Numbers refer to the latest screen you were shown — only what is in view; scroll to see more. It happens after the pause a real person would take on that screen, then you get the new screen — or a survey card, or the end of the visit.",
516
+ inputSchema: {
517
+ visit_id: z.string(),
518
+ action: z.enum(["click", "type", "scroll", "scroll_up", "read", "back", "leave"]),
519
+ index: z.number().int().describe("Number of the thing to click or type into; -1 otherwise"),
520
+ text: z.string().optional().describe("What you type, for action=type, in the language of the site"),
521
+ why: z.string().describe("One short sentence in English: what you are doing and why, as this person"),
522
+ done: z.boolean().optional().describe("For leave: true if you got what you came for"),
523
+ times: z.number().int().min(1).max(5).optional().describe("For scroll, scroll_up or click: how many times in a row, quickly (scrolling down while skimming, clicking again because nothing happened). Default 1"),
524
+ },
525
+ },
526
+ wrap(async ({ visit_id, action, index, text: typed = "", why, done = false, times = 1 }) => text(await hub.act(visit_id, { action, index, text: typed, why, done, times }))),
527
+ );
528
+
529
+ server.registerTool(
530
+ "answer_card",
531
+ {
532
+ description:
533
+ "Respond to the survey card on screen, as the person. Decide `bothered` FIRST — most people close these without reading. Only if bothered, pick an option, write your own words, or still close.",
534
+ inputSchema: {
535
+ visit_id: z.string(),
536
+ bothered: z.boolean().describe("Would this person, right now, bother to answer at all?"),
537
+ bothered_why: z.string().describe("English, one sentence"),
538
+ action: z.enum(["choose", "other", "close"]),
539
+ choice_index: z.number().int().describe("Option number for choose; -1 otherwise"),
540
+ other_text: z.string().optional().describe("Your own words for other, in the language of the card, the way this person writes"),
541
+ why: z.string().describe("English, one sentence: why this answer"),
542
+ },
543
+ },
544
+ wrap(async ({ visit_id, bothered, bothered_why, action, choice_index, other_text = "", why }) =>
545
+ text(await hub.answer(visit_id, { bothered, botheredWhy: bothered_why, action, choiceIndex: choice_index, otherText: other_text, why })),
546
+ ),
547
+ );
548
+
549
+ // ── 절차 ──
550
+
551
+ /** 한 판에 만드는 사람 수 — 이보다 많거나 무제한이면 판을 나눠 이어 간다 */
552
+ const ROUND = 5;
553
+
554
+ /** research 의 인원 — 숫자면 그만큼(많으면 판을 나눠), "unlimited" 면 사용자가 멈출 때까지 */
555
+ function researchPlan(count) {
556
+ const c = String(count ?? "").trim().toLowerCase();
557
+ const unlimited = /^(unlimited|nonstop|infinite|forever|0|∞|무제한)$/.test(c);
558
+ const n = unlimited ? null : Math.max(1, parseInt(c, 10) || ROUND);
559
+ if (n !== null && n <= ROUND) return { total: n, line: String(n), rounds: `Cast all ${n} people in one round, run them (step 4), then report (step 5).` };
560
+ const until = unlimited ? "until the user stops you (they press Esc) — there is no limit" : `until ${n} people have visited (the last round may be smaller)`;
561
+ return {
562
+ total: n,
563
+ line: unlimited ? `unlimited — rounds of ${ROUND} until the user stops` : `${n} in total, in rounds of ${ROUND}`,
564
+ rounds: [
565
+ `Work in rounds of ${ROUND} people, ${until}.`,
566
+ `- **Round 1:** cast ${ROUND}, show the user the list, and tell them you will keep casting new people like this ${unlimited ? "until they press Esc" : `until ${n} have visited`}, and that every visit uses their Claude usage. Wait for the go-ahead, then run the round (step 4).`,
567
+ "- **Next rounds:** do not ask again. Call `list_people` first and cast people unlike the ones already there — different goals, arrival paths, devices and states of mind, not variations of the same person. Run the round, then tell the user one short line (round number, who went, visits so far) and go on.",
568
+ "- After each round, `results` tells you whether the run is finished. Until it says so, start the next round — do not write the report.",
569
+ "- Stop early only if the site blocks us, or visits keep failing the same way before they start.",
570
+ "- Keep your notes between rounds short. Do the full report (step 5) once, at the end — or when the user stops you and asks for it.",
571
+ ].join("\n"),
572
+ };
573
+ }
574
+
575
+ /**
576
+ * 지금 돌고 있는 research 의 인원. 긴 지시문의 "다음 판" 은 기다리는 사이 잊힌다(10/13 bluetags 에서 한 판 만에 끝냈다) —
577
+ * 그래서 판이 끝날 때마다 부르는 results 가 이어 갈지 끝낼지를 말한다
578
+ */
579
+ let run = null;
580
+
581
+ function runStatus() {
582
+ if (!run) return "";
583
+ const done = ws.recentRuns(5000).filter((r) => r.at && Date.parse(r.at) >= run.since).length;
584
+ if (run.total !== null && done >= run.total) {
585
+ run = null;
586
+ return `■ The run is complete — ${done} people visited. Now write the report (step 5).`;
587
+ }
588
+ const left = run.total === null ? "unlimited, until the user presses Esc" : `${run.total - done} of ${run.total} still to go`;
589
+ const next = run.total === null ? ROUND : Math.min(ROUND, run.total - done);
590
+ return [
591
+ `▶ The run is not finished — ${done} visited so far (${left}).`,
592
+ `Do not write the report and do not look into the code. Tell the user one short line about this round, then: call list_people, cast ${next} new people unlike the ones already there with save_people, start one usercase-visitor for each, and call results again when they finish.`,
593
+ ].join("\n");
594
+ }
595
+
596
+ server.registerPrompt(
597
+ "research",
598
+ {
599
+ description: "Research this site with Usercase virtual users",
600
+ argsSchema: { url: z.string().optional(), count: z.string().optional().describe('How many people (default 5), or "unlimited" to keep going until you stop it') },
601
+ },
602
+ ({ url, count }) => {
603
+ const plan = researchPlan(count);
604
+ run = { total: plan.total, since: Date.now() };
605
+ // 사람 만드는 규칙은 우리 AI 로 만들 때와 같은 원본(people.md)에서 가져온다 — 두 벌로 두지 않는다.
606
+ // 자유 탐방이라 원본이 말하는 "path" 는 "자기 목적으로 왔다" 뿐이다
607
+ const rules =
608
+ "(Here the \"path\" is simply: came to the site with something of their own to find out.)\n\n" +
609
+ fs
610
+ .readFileSync(path.join(__dirname, "..", "src", "prompts", "people.md"), "utf8")
611
+ .replace(/\r\n/g, "\n")
612
+ .replace(/^# Role:.*\n+/, "")
613
+ .replace(/^#/gm, "###");
614
+ const body = fs
615
+ .readFileSync(path.join(__dirname, "..", "agents", "research.md"), "utf8")
616
+ .replace("{{url}}", url || "(ask the user)")
617
+ .replace("{{count_line}}", plan.line)
618
+ .replace("{{rounds}}", plan.rounds)
619
+ .replace("{{people_rules}}", rules);
620
+ return { messages: [{ role: "user", content: { type: "text", text: body } }] };
621
+ },
622
+ );
623
+
624
+ server.registerPrompt(
625
+ "install",
626
+ { description: "Install the Usercase script on this site from its repository", argsSchema: { url: z.string().optional() } },
627
+ ({ url }) => {
628
+ const body = fs
629
+ .readFileSync(path.join(__dirname, "..", "agents", "install.md"), "utf8")
630
+ .replace(/\r\n/g, "\n")
631
+ .replace("{{url}}", url || "(the site in this repository)");
632
+ return { messages: [{ role: "user", content: { type: "text", text: body } }] };
633
+ },
634
+ );
635
+
636
+ server.registerPrompt(
637
+ "path",
638
+ { description: "Record the expected path of a Usercase research's tasks", argsSchema: { research: z.string().optional() } },
639
+ ({ research }) => {
640
+ const body = fs
641
+ .readFileSync(path.join(__dirname, "..", "agents", "path.md"), "utf8")
642
+ .replace(/\r\n/g, "\n")
643
+ .replace("{{research}}", research || "(ask the user — the dashboard shows it)");
644
+ return { messages: [{ role: "user", content: { type: "text", text: body } }] };
645
+ },
646
+ );
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
+
660
+ async function main() {
661
+ // 예전에 깐 방문자 에이전트가 있으면 이 버전 것으로 바꿔 둔다 — setup 은 처음 한 번만 불려서 옛 지시문이 남는다.
662
+ // 리서치를 한 번도 안 쓴 사람에게는 만들지 않는다
663
+ try {
664
+ if ((fs.existsSync(AGENT_DEST) || fs.existsSync(AGENT_OLD)) && installAgent() === "updated") process.stderr.write(`Updated the visitor agent → ${AGENT_DEST}\n`);
665
+ } catch {}
666
+ await server.connect(new StdioServerTransport());
667
+ process.stderr.write(`Usercase MCP ready — working folder ${ws.DIR}${hub.inject ? ` · local SDK injected (${hub.inject.project})` : ""}\n`);
668
+ }
669
+ const bye = async () => {
670
+ await hub.close().catch(() => {});
671
+ process.exit(0);
672
+ };
673
+ process.on("SIGINT", bye);
674
+ process.on("SIGTERM", bye);
675
+ process.stdin.on("close", bye);
676
+ main().catch((e) => {
677
+ process.stderr.write(`Failed to start: ${e.stack || e}\n`);
678
+ process.exit(1);
679
+ });