@usercase/mcp 0.2.5 → 0.2.6-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
@@ -17,14 +17,18 @@ Open your repository in Claude Code and ask it to research your site. Claude rea
17
17
 
18
18
  ## Connect
19
19
 
20
- 1. In the Usercase dashboard, open **Settings → Integrations → API keys** and create a key. It starts with `eb_sk_` and is shown once.
21
- 2. In your repository folder, run:
20
+ 1. In your repository folder, run:
22
21
 
23
22
  ```sh
24
- claude mcp add usercase -e USERCASE_API_KEY=eb_sk_... -- npx -y @usercase/mcp@latest
23
+ claude mcp add usercase -- npx -y @usercase/mcp@latest
25
24
  ```
26
25
 
27
26
  This is saved to your Claude Code settings (`~/.claude.json`), not to your repository. `@latest` picks up new versions each time Claude Code starts.
27
+ 2. Run any Usercase command, such as `/mcp__usercase__install`. Your browser opens on the Usercase dashboard: sign in, check that the code matches the one in your terminal, and pick the project (or create one). The key is made on your computer and saved in `~/.usercase/credentials.json`; only its hash is sent to Usercase. It shows up in **Settings → Integrations → API keys**, where you can revoke it.
28
+
29
+ Each repository keeps its own connection, so different sites can go to different projects. To switch this repository to another project, run `/mcp__usercase__connect`.
30
+
31
+ Prefer a key? Create one under **Settings → Integrations → API keys** and add `-e USERCASE_API_KEY=eb_sk_...` before `--`. A key you set this way is used first.
28
32
 
29
33
  ## Install the script
30
34
 
@@ -34,7 +38,7 @@ The survey cards come from the Usercase script on your site. If it isn't there y
34
38
  /mcp__usercase__install
35
39
  ```
36
40
 
37
- Claude gets your project's script (from the API key), puts it once in your root layout or document head, shows you the diff, and opens your local site to check that it loads. It doesn't commit or deploy. Real visitors are recorded once you deploy the change.
41
+ Claude gets your project's script (from the connected project), puts it once in your root layout or document head, shows you the diff, and opens your local site to check that it loads. It doesn't commit or deploy. Real visitors are recorded once you deploy the change.
38
42
 
39
43
  ## Run a research
40
44
 
@@ -130,7 +134,7 @@ Pass with `claude mcp add ... -e NAME=value`.
130
134
 
131
135
  | Name | What it does |
132
136
  |---|---|
133
- | `USERCASE_API_KEY` | Your project API key. Without it, visits stay on your machine only. |
137
+ | `USERCASE_API_KEY` | A project API key, instead of connecting in the browser. Used before a saved connection. |
134
138
  | `USERCASE_AGENT_KEY` | Your firewall secret (see above). 16+ characters. |
135
139
  | `USERCASE_ALLOW_WRITE_PATHS` | Comma-separated path patterns where non-GET requests are allowed, for sites that load pages with `POST` after sign-in. Everything else stays blocked. |
136
140
  | `USERCASE_DIAGNOSTICS` | `off` stops sending diagnostics. On by default: when the tool cannot do something or misreads the screen (a button cut off or covered, a click that landed elsewhere, a change it did not notice), Usercase receives the kind of failure, the element's shape, the page path, the HTML around the element and a screenshot of that moment, to improve the tool. Only the Usercase team sees them; they are kept for 30 days. |
package/agents/install.md CHANGED
@@ -6,7 +6,7 @@ You are in the site's repository. Add the Usercase script so the site starts sen
6
6
 
7
7
  ## 1. Get the script
8
8
 
9
- Call `install_snippet`. It returns the project this repository is connected to and the exact script tag. If it says there is no API key or the key is wrong, stop and show the user its message — they create a key in the dashboard and add the MCP server again with it.
9
+ Call `install_snippet`. It returns the project this repository is connected to and the exact script tag. If it says it is not connected yet, show the user its message — a browser window opens where they pick the project — and call `install_snippet` again once they say they are done. If it says the key is wrong, stop and show the user its message.
10
10
 
11
11
  ## 2. Put it on every page
12
12
 
@@ -9,7 +9,7 @@ You are the coordinator. Virtual users will visit the site in a real browser, on
9
9
 
10
10
  ## 0. Setup (once)
11
11
 
12
- Call `setup` first. If it says it installed or updated the agent, ask the user to restart Claude Code and run this again — until then the visitors would use the old one, or none.
12
+ Call `setup` first. If it says it installed or updated the agent, ask the user to restart {{agent}} and run this again — until then the visitors would use the old one, or none.
13
13
 
14
14
  ## 1. Check the site
15
15
 
@@ -88,7 +88,8 @@ The goal is still the person's own reason for coming, in their words — never "
88
88
 
89
89
  ## 4. Send the visitors
90
90
 
91
- For each person, start a `{{visitor}}` sub-agent with a prompt like:
91
+ {{warmup}}
92
+ For each person, start a `{{visitor}}` sub-agent{{spawn}} with a prompt like:
92
93
 
93
94
  > Call open_visit with person "<id>" and live this visit as that person until you leave.
94
95
 
package/mcp/connect.js ADDED
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ /**
3
+ * 브라우저 연결 — 키를 복사해 붙여 넣지 않고 연결한다(10/28).
4
+ *
5
+ * MCP 가 키를 직접 만들고 그 sha256 만 서버에 맡긴다(POST /agent/connect). 브라우저로 대시보드 /connect/:id 를 열면
6
+ * 고객이 로그인해 프로젝트를 고르고, 대시보드가 그 해시로 키를 만든다. 키 원문은 이 컴퓨터를 떠나지 않는다.
7
+ * MCP 는 같은 키로 상태를 묻다가(GET /agent/connect/:id) 승인되면 ~/.usercase/credentials.json 에 둔다.
8
+ * 저장소 폴더마다 따로 둔다 — 사이트마다 저장소·프로젝트가 다르다. 저장소 A 에서 프로젝트를 바꿔도 B 는 그대로다.
9
+ * 처음 여는 저장소는 다른 저장소의 키를 빌리지 않고 연결부터 한다(엉뚱한 프로젝트로 올라가지 않게).
10
+ *
11
+ * 키를 고르는 순서: 직접 넣은 키(USERCASE_API_KEY) → 연결로 받은 키. 직접 넣은 키가 이번 세션에 거절되면 연결로 받은 키를 쓴다
12
+ * (MCP 는 사용자가 넣은 환경변수를 고칠 수 없다).
13
+ */
14
+ const fs = require("fs");
15
+ const os = require("os");
16
+ const path = require("path");
17
+ const crypto = require("crypto");
18
+ const { spawn } = require("child_process");
19
+
20
+ const KEY_ENV = "USERCASE_API_KEY";
21
+ const KEY_RE = /^eb_sk_[A-Za-z0-9]{32}$/;
22
+ // 사람의 홈 폴더 — 저장소의 .usercase/ 에 두면 git 에 들어갈 수 있다
23
+ const CRED_DIR = process.env.USERCASE_HOME || path.join(os.homedir(), ".usercase");
24
+ const CRED = path.join(CRED_DIR, "credentials.json");
25
+ // 이 MCP 를 띄운 폴더(에이전트가 연 저장소). Windows 는 대소문자를 가리지 않는다
26
+ const FOLDER = (() => {
27
+ const p = path.resolve(process.cwd());
28
+ return process.platform === "win32" ? p.toLowerCase() : p;
29
+ })();
30
+ const POLL_MS = 3000;
31
+ // 도구 한 번에 기다리는 시간. Codex 의 도구 시간 제한(기본 60초)보다 짧게 — 못 끝나면 다음 호출이 이어서 기다린다
32
+ const WAIT_MS = 30000;
33
+
34
+ let envRejected = false;
35
+ let savedRejected = false; // 연결로 받은 키가 이번 세션에 거절됐다(대시보드에서 지웠다)
36
+ let pending = null; // { id, code, url, key, expiresAt }
37
+
38
+ /** 플러그인이 값을 못 채우면 `${user_config.api_key}` 같은 글자가 그대로 온다 — 빈 값으로 본다 */
39
+ const envKey = () => {
40
+ const v = (process.env[KEY_ENV] || "").trim();
41
+ return v && !v.includes("${") ? v : "";
42
+ };
43
+
44
+ /** { v: 2, folders: { <폴더>: { key, projectId, title, savedAt } } } — 그 전(한 개만)이면 빈 것으로 본다 */
45
+ function readAll() {
46
+ try {
47
+ const c = JSON.parse(fs.readFileSync(CRED, "utf8"));
48
+ return c && c.v === 2 && c.folders && typeof c.folders === "object" ? c : { v: 2, folders: {} };
49
+ } catch {
50
+ return { v: 2, folders: {} };
51
+ }
52
+ }
53
+ function writeAll(c) {
54
+ fs.mkdirSync(CRED_DIR, { recursive: true });
55
+ fs.writeFileSync(CRED, JSON.stringify(c, null, 2), { mode: 0o600 });
56
+ }
57
+
58
+ function readSaved() {
59
+ const c = readAll().folders[FOLDER];
60
+ return c && KEY_RE.test(c.key) ? c : null;
61
+ }
62
+
63
+ function saveKey(key, project) {
64
+ const c = readAll();
65
+ c.folders[FOLDER] = { key, projectId: project.id, title: project.title, savedAt: new Date().toISOString() };
66
+ writeAll(c);
67
+ }
68
+
69
+ /** 이 폴더의 연결만 지운다 */
70
+ function dropSaved() {
71
+ const c = readAll();
72
+ if (!c.folders[FOLDER]) return;
73
+ delete c.folders[FOLDER];
74
+ writeAll(c);
75
+ }
76
+
77
+ /** 지금 쓸 키와 어디서 왔는지 */
78
+ function current() {
79
+ const env = envKey();
80
+ if (env && !envRejected) return { key: env, source: "env" };
81
+ const saved = readSaved();
82
+ if (saved) return { key: saved.key, source: "saved", title: saved.title };
83
+ // 직접 넣은 키가 거절됐고 연결로 받은 키도 없다 — 연결부터
84
+ return { key: "", source: "none", envRejected: !!env, savedRejected };
85
+ }
86
+
87
+ /** 연결로 받은 키를 버린다 — 다른 프로젝트로 다시 연결할 때 */
88
+ function forget() {
89
+ pending = null;
90
+ dropSaved();
91
+ }
92
+
93
+ /** 서버가 키를 거절했다(401). 연결로 받은 키면 지운다 — 다음 호출이 다시 연결한다 */
94
+ function rejected(source) {
95
+ if (source === "env") envRejected = true;
96
+ if (source === "saved") {
97
+ savedRejected = true;
98
+ dropSaved();
99
+ }
100
+ }
101
+
102
+ function newKey() {
103
+ const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
104
+ let out = "";
105
+ while (out.length < 32) for (const b of crypto.randomBytes(48)) if (b < 248 && out.length < 32) out += A[b % 62];
106
+ return `eb_sk_${out}`;
107
+ }
108
+
109
+ function openBrowser(url) {
110
+ try {
111
+ const [cmd, args] =
112
+ process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
113
+ spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true }).unref();
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
121
+
122
+ /**
123
+ * 연결한다 — 진행 중인 연결이 있으면 이어서 기다리고, 없으면 새로 시작해 브라우저를 연다.
124
+ * @param api (method, route, body, key) → json. report.js 의 fetch
125
+ * @param client 연결 화면에 보일 도구 이름(claude-code · codex …)
126
+ * @returns { status: "approved", title } | { status: "pending", url, code, opened } | { status: "denied" | "expired" }
127
+ */
128
+ async function connect(api, client) {
129
+ if (pending && Date.now() > pending.expiresAt) pending = null;
130
+ let opened = false;
131
+ if (!pending) {
132
+ const key = newKey();
133
+ // 앞 10글자는 대시보드 키 목록에 보이는 부분 — 원문은 보내지 않는다
134
+ const r = await api("POST", "/agent/connect", { keyHash: crypto.createHash("sha256").update(key).digest("hex"), keyPrefix: key.slice(0, 10), client, host: os.hostname() });
135
+ pending = { id: r.id, code: r.code, url: r.url, key, expiresAt: Date.now() + (r.expiresIn || 900) * 1000 };
136
+ opened = openBrowser(r.url);
137
+ // 처음엔 기다리지 않는다 — 브라우저 화면이 "터미널의 코드와 맞는지 보라" 고 하니 코드가 먼저 터미널에 떠야 한다(10/28 Codex 시험).
138
+ // 사용자가 승인한 뒤 다음 호출이 이어서 확인한다
139
+ return { status: "pending", url: pending.url, code: pending.code, opened };
140
+ }
141
+ const until = Date.now() + WAIT_MS;
142
+ while (Date.now() < until) {
143
+ const s = await api("GET", `/agent/connect/${pending.id}`, null, pending.key).catch(() => null);
144
+ if (s && s.status === "approved") {
145
+ saveKey(pending.key, s.project);
146
+ savedRejected = false;
147
+ envRejected = !!envKey(); // 직접 넣은 키보다 방금 받은 키를 쓴다
148
+ pending = null;
149
+ return { status: "approved", title: s.project.title };
150
+ }
151
+ if (s && (s.status === "denied" || s.status === "expired")) {
152
+ pending = null;
153
+ return { status: s.status };
154
+ }
155
+ await sleep(POLL_MS);
156
+ }
157
+ return { status: "pending", url: pending.url, code: pending.code, opened };
158
+ }
159
+
160
+ /** 연결 결과를 사람(과 에이전트)에게 할 말로 */
161
+ function describe(r) {
162
+ if (r.status === "approved") return `✓ Connected — visits go to the "${r.title}" project`;
163
+ if (r.status === "denied") return "Connecting was cancelled in the browser. Run the command again to connect";
164
+ if (r.status === "expired") return "The connect link expired. Run the command again to get a new one";
165
+ return [
166
+ `Not connected yet. Tell the user right away, before doing anything else: ${r.opened ? "a browser window opened" : "open this link"} to connect Usercase — ${r.url}`,
167
+ `The code on that page must be ${r.code}. They sign in, pick the project and press Connect.`,
168
+ "Then wait for the user to say they are done, and call this tool again — it picks up the same request.",
169
+ ].join("\n");
170
+ }
171
+
172
+ /** 연결을 기다리는 중인가 — 프로젝트 바꾸기를 다시 불러도 새로 시작하지 않고 이어서 기다리게 */
173
+ const isPending = () => !!pending && Date.now() < pending.expiresAt;
174
+
175
+ module.exports = { KEY_ENV, KEY_RE, CRED, FOLDER, current, rejected, forget, isPending, connect, describe };
package/mcp/report.js CHANGED
@@ -10,31 +10,43 @@ const fs = require("fs");
10
10
  const path = require("path");
11
11
  const crypto = require("crypto");
12
12
  const ws = require("./workspace.js");
13
+ const conn = require("./connect.js");
13
14
 
14
- const KEY_ENV = "USERCASE_API_KEY";
15
+ const KEY_ENV = conn.KEY_ENV;
15
16
  const URL_ENV = "USERCASE_API_URL";
16
17
  const DEFAULT_API = "https://app.usercase.io/koa";
17
- const KEY_RE = /^eb_sk_[A-Za-z0-9]{32}$/;
18
+ const KEY_RE = conn.KEY_RE;
18
19
  const OUTBOX = path.join(ws.DIR, "outbox.jsonl");
19
20
 
20
- const apiKey = () => (process.env[KEY_ENV] || "").trim();
21
+ /** 직접 넣은 키(USERCASE_API_KEY) 또는 브라우저 연결로 받은 키(connect.js) */
22
+ const apiKey = () => conn.current().key;
21
23
  // 플러그인이면 키는 플러그인 설정에 있다(server.js 의 PLUGIN 과 같은 표시)
22
24
  const PLUGIN = process.env.USERCASE_PLUGIN === "1";
23
- const NO_KEY = () =>
24
- PLUGIN
25
- ? "No API key. Create one in the dashboard (Settings → Integrations → API keys), then enter it with /plugin → Installed → usercase → Configure"
26
- : `No API key (${KEY_ENV}). Create one in the dashboard (Settings → Integrations → API keys), then add this MCP server again with -e ${KEY_ENV}=<key>`;
27
25
  const BAD_KEY = () =>
28
- `${PLUGIN ? "The API key" : KEY_ENV} does not look right (38 characters starting with eb_sk_). Copy it again from the dashboard${PLUGIN ? " and enter it with /plugin → Installed → usercase → Configure" : ""}`;
26
+ `${PLUGIN ? "The API key" : KEY_ENV} does not look right (38 characters starting with eb_sk_). Copy it again from the dashboard${PLUGIN ? " and enter it with /plugin → Installed → usercase → Configure" : ""}, or leave it empty to connect in the browser`;
27
+ // 직접 넣은 키는 MCP 가 고칠 수 없다 — 지워도 된다고만 알린다
28
+ const ENV_REVOKED = () =>
29
+ `The API key you set (${PLUGIN ? "plugin settings" : KEY_ENV}) was rejected — it may have been revoked. You can remove it there.`;
29
30
  const apiBase = () => (process.env[URL_ENV] || DEFAULT_API).replace(/\/$/, "");
30
31
 
31
32
  /** 이 MCP 세션 하나 = 실행 하나. 대시보드가 실행별로 묶는다 */
32
33
  const RUN_REF = `run_${new Date().toISOString().slice(0, 10)}_${crypto.randomBytes(6).toString("hex")}`;
33
34
 
34
- async function call(method, route, body, timeoutMs = 15000) {
35
+ // 어느 에이전트로 돌았나 — MCP 연결 때 클라이언트가 알려 준 이름(clientInfo.name). 대시보드는 claude-code 만 "Claude Code" 로 바꿔 보인다
36
+ let client = "";
37
+ const setClient = (name) => {
38
+ const n = String(name || "").trim();
39
+ client = /claude/i.test(n) ? "claude-code" : /codex/i.test(n) ? "codex" : n.slice(0, 40);
40
+ };
41
+ const toolName = () => client || (process.env.USERCASE_CLIENT === "codex" ? "codex" : "claude-code");
42
+
43
+ /** @param key 이 키로 부른다(연결 API). 없으면 지금 쓰는 키 */
44
+ async function call(method, route, body, timeoutMs = 15000, key) {
45
+ const cur = key === undefined ? conn.current() : null;
46
+ const k = key === undefined ? cur.key : key;
35
47
  const res = await fetch(`${apiBase()}${route}`, {
36
48
  method,
37
- headers: { Authorization: `Bearer ${apiKey()}`, "Content-Type": "application/json" },
49
+ headers: { ...(k ? { Authorization: `Bearer ${k}` } : {}), "Content-Type": "application/json" },
38
50
  body: body ? JSON.stringify(body) : undefined,
39
51
  signal: AbortSignal.timeout(timeoutMs),
40
52
  });
@@ -43,39 +55,69 @@ async function call(method, route, body, timeoutMs = 15000) {
43
55
  try {
44
56
  json = JSON.parse(text);
45
57
  } catch {}
46
- if (!res.ok) throw Object.assign(new Error((json && json.error) || `HTTP ${res.status}`), { status: res.status });
58
+ if (!res.ok) {
59
+ // 키가 거절됐다 — 연결로 받은 키면 지우고, 직접 넣은 키면 이번 세션엔 안 쓴다. 다음 호출이 브라우저 연결로 간다
60
+ if (res.status === 401 && cur && cur.source !== "none") {
61
+ conn.rejected(cur.source);
62
+ me = null;
63
+ }
64
+ throw Object.assign(new Error((json && json.error) || `HTTP ${res.status}`), { status: res.status });
65
+ }
47
66
  return json;
48
67
  }
49
68
 
50
- /** 키 상태 한 줄 — check_site 가 보여 준다. sdkProject 가 있으면 키의 프로젝트와 맞는지 본다 */
51
- async function describeKey(sdkProject) {
52
- const key = apiKey();
53
- if (!key) return `· No API key (${KEY_ENV}). Visits stay on this computer and are not sent to the dashboard. Create one in the dashboard: Settings → Integrations → API keys`;
54
- if (!KEY_RE.test(key)) return `⚠ ${KEY_ENV} does not look right (38 characters starting with eb_sk_). Copy it again from the dashboard`;
69
+ /** 연결 API — 키 없이(시작) 또는 새로 만든 키로(상태) 부른다 */
70
+ const connectApi = (method, route, body, key) => call(method, route, body, 15000, key || "");
71
+
72
+ /** 키가 없으면 브라우저로 연결한다. 연결 못 했으면 사람에게 할 말을 던진다 */
73
+ async function ensureKey() {
74
+ const cur = conn.current();
75
+ if (cur.source !== "none") {
76
+ if (!KEY_RE.test(cur.key)) throw new Error(BAD_KEY());
77
+ return null;
78
+ }
79
+ const r = await conn.connect(connectApi, toolName());
80
+ const why = cur.envRejected ? ENV_REVOKED() + "\n" : cur.savedRejected ? "The connection was revoked in the dashboard.\n" : "";
81
+ const msg = why + conn.describe(r);
82
+ if (r.status !== "approved") throw new Error(msg);
83
+ return msg;
84
+ }
85
+
86
+ /** 이 키의 프로젝트 — outbox 가 다른 프로젝트의 기록을 보내지 않게. 세션에 한 번 */
87
+ let me = null;
88
+ async function getMe() {
89
+ if (!me) me = await call("GET", "/agent/me");
90
+ return me;
91
+ }
92
+
93
+ /** 연결 상태 한 줄 — check_site 가 보여 준다. 연결이 없으면 브라우저 연결을 시작한다. sdkProject 가 있으면 키의 프로젝트와 맞는지 본다 */
94
+ async function describeKey(sdkProject, retried = false) {
95
+ // check_site 는 지금 상태를 본다 — 대시보드에서 키를 지웠을 수 있다
96
+ me = null;
97
+ let connected = null;
98
+ try {
99
+ connected = await ensureKey();
100
+ } catch (e) {
101
+ return `⚠ ${e.message}`;
102
+ }
55
103
  try {
56
- const me = await call("GET", "/agent/me");
57
- if (sdkProject && me.projectId !== sdkProject)
58
- return `⚠ The API key belongs to the "${me.title}" project, but the SDK on this site sends to another project (${sdkProject}). Visits and card answers would land in different projects. Use the key for the right project`;
59
- return `✓ API key — visits are sent to the "${me.title}" project`;
104
+ const m = await getMe();
105
+ if (sdkProject && m.projectId !== sdkProject)
106
+ return `⚠ Connected to the "${m.title}" project, but the SDK on this site sends to another project (${sdkProject}). Visits and card answers would land in different projects. Call the \`connect\` tool to pick the right project`;
107
+ return connected || `✓ Connected — visits are sent to the "${m.title}" project. To use another project, call the \`connect\` tool`;
60
108
  } catch (e) {
61
- if (e.status === 401) return `⚠ The API key is wrong or revoked. Create a new one in the dashboard`;
109
+ // 키가 거절됐다 — call 이 지웠으니 한 번만 다시(브라우저 연결로)
110
+ if (e.status === 401 && !retried) return describeKey(sdkProject, true);
111
+ if (e.status === 401) return `⚠ The connection was rejected. Run check_site again to connect in the browser`;
62
112
  return `⚠ Could not reach the Usercase server (${e.message}). Visits can still run; records will be sent later`;
63
113
  }
64
114
  }
65
115
 
66
116
  /** 키의 프로젝트와 그 설치 스크립트(서버가 대시보드 설치 화면과 같은 글을 준다) — install_snippet 이 쓴다 */
67
117
  async function getInstall() {
68
- const key = apiKey();
69
- if (!key) throw new Error(NO_KEY());
70
- if (!KEY_RE.test(key)) throw new Error(BAD_KEY());
71
- try {
72
- const me = await call("GET", "/agent/me");
73
- if (!me.snippet) throw new Error("The Usercase server did not return a script. It may be an older server version");
74
- return me;
75
- } catch (e) {
76
- if (e.status === 401) throw new Error("The API key is wrong or revoked. Create a new one in the dashboard");
77
- throw e;
78
- }
118
+ const m = await keyed("GET", "/agent/me");
119
+ if (!m.snippet) throw new Error("The Usercase server did not return a script. It may be an older server version");
120
+ return m;
79
121
  }
80
122
 
81
123
  function readOutbox() {
@@ -107,7 +149,7 @@ function payload(row, person, startedAt) {
107
149
  return {
108
150
  id: crypto.randomUUID(),
109
151
  run: RUN_REF,
110
- tool: "claude-code",
152
+ tool: toolName(),
111
153
  site: row.site,
112
154
  person: person.id,
113
155
  personUid: person.uid,
@@ -156,23 +198,34 @@ async function sendScreens(item, screens) {
156
198
  */
157
199
  async function sendVisit(row, person, startedAt, screens = []) {
158
200
  if (!KEY_RE.test(apiKey())) return null;
159
- const queue = [...readOutbox(), await sendScreens(payload(row, person, startedAt), screens)];
201
+ // 기록마다 어느 프로젝트로 가야 했는지 붙여 둔다 — 다시 연결하며 다른 프로젝트를 골라도 엉뚱한 곳에 들어가지 않게
202
+ const project = await getMe().then((m) => m.projectId).catch(() => null);
203
+ const fresh = { ...(await sendScreens(payload(row, person, startedAt), screens)), _project: project };
204
+ const queue = [...readOutbox(), fresh];
160
205
  const left = [];
161
206
  let sent = 0;
207
+ let other = 0;
162
208
  let lastErr = null;
163
209
  for (const item of queue) {
210
+ if (item._project && project && item._project !== project) {
211
+ other++;
212
+ left.push(item);
213
+ continue;
214
+ }
215
+ const { _project, ...body } = item;
164
216
  try {
165
- await call("POST", "/agent/visits", item);
217
+ await call("POST", "/agent/visits", body);
166
218
  sent++;
167
219
  } catch (e) {
168
220
  lastErr = e;
169
- // 키가 틀렸으면 다시 보내도 소용없다 — 그래도 버리지 않고 남겨 둔다(키를 고치면 다음에 간다)
170
- left.push(item);
221
+ // 키가 틀렸으면 다시 보내도 소용없다 — 그래도 버리지 않고 남겨 둔다(다시 연결하면 다음에 간다)
222
+ left.push({ ...item, _project: _project || project });
171
223
  }
172
224
  }
173
225
  writeOutbox(left);
174
- if (!left.length) return `· Sent to the dashboard${sent > 1 ? ` (${sent} including earlier ones)` : ""}`;
175
- return `⚠ Could not upload to the dashboard (${lastErr?.status === 401 ? "API key rejected" : lastErr?.message}) — ${left.length} kept in ${OUTBOX}, will retry next time`;
226
+ const otherLine = other ? ` · ${other} from another project kept in ${OUTBOX} until you connect that project again` : "";
227
+ if (left.length === other) return `· Sent to the dashboard${sent > 1 ? ` (${sent} including earlier ones)` : ""}${otherLine}`;
228
+ return `⚠ Could not upload to the dashboard (${lastErr?.status === 401 ? "connection rejected — run check_site to connect again" : lastErr?.message}) — ${left.length - other} kept in ${OUTBOX}, will retry next time${otherLine}`;
176
229
  }
177
230
 
178
231
  /**
@@ -193,16 +246,30 @@ async function sendDiagnostics(events, device, shots = []) {
193
246
  }
194
247
  }
195
248
 
196
- /** 키가 있어야 하는 호출 — 없거나 틀렸으면 사용자에게 할 말로 바꿔 던진다 */
197
- async function keyed(method, route, body, timeoutMs) {
198
- const key = apiKey();
199
- if (!key) throw new Error(NO_KEY());
200
- if (!KEY_RE.test(key)) throw new Error(BAD_KEY());
249
+ /** 다른 프로젝트로 다시 연결한다 — 연결로 받은 키는 버리고 브라우저를 연다. 직접 넣은 키는 MCP 가 바꿀 수 없다 */
250
+ async function reconnect() {
251
+ const cur = conn.current();
252
+ if (cur.source === "env") return `You set an API key (${PLUGIN ? "plugin settings" : KEY_ENV}), and it is used first. Remove it there to connect in the browser, or set the other project's key instead.`;
253
+ // 지금 연결은 새 연결이 승인될 때까지 둔다 — 브라우저에서 취소해도 원래대로 쓴다. 다시 부르면 진행 중인 연결을 이어서 기다린다
254
+ const before = cur.source === "saved" ? `Connected now: "${cur.title}" (this folder only).\n` : "";
255
+ try {
256
+ const r = await conn.connect(connectApi, toolName());
257
+ if (r.status === "approved") me = null;
258
+ return before + conn.describe(r) + (r.status === "approved" ? " — for this folder only" : "");
259
+ } catch (e) {
260
+ return before + e.message;
261
+ }
262
+ }
263
+
264
+ /** 키가 있어야 하는 호출 — 없으면 브라우저로 연결하고, 거절되면 한 번 다시 연결한다. 못 하면 사용자에게 할 말로 던진다 */
265
+ async function keyed(method, route, body, timeoutMs, retried = false) {
266
+ await ensureKey();
201
267
  try {
202
268
  return await call(method, route, body, timeoutMs);
203
269
  } catch (e) {
204
- if (e.status === 401) throw new Error("The API key is wrong or revoked. Create a new one in the dashboard");
205
- if (e.status === 404) throw new Error(`${e.message}. Check the id — it must belong to the project of this API key`);
270
+ if (e.status === 401 && !retried) return keyed(method, route, body, timeoutMs, true);
271
+ if (e.status === 401) throw new Error("The connection was rejected. Call this tool again to connect in the browser");
272
+ if (e.status === 404) throw new Error(`${e.message}. Check the id — it must belong to the connected project`);
206
273
  throw e;
207
274
  }
208
275
  }
@@ -227,7 +294,9 @@ module.exports = {
227
294
  KEY_ENV,
228
295
  URL_ENV,
229
296
  RUN_REF,
297
+ setClient,
230
298
  describeKey,
299
+ reconnect,
231
300
  getInstall,
232
301
  sendVisit,
233
302
  sendDiagnostics,
package/mcp/server.js CHANGED
@@ -32,8 +32,17 @@ const server = new McpServer({ name: "usercase", version: require("../package.js
32
32
  // 플러그인에서는 방문자 에이전트가 플러그인에 들어 있어 이름이 usercase:usercase-visitor 이고(도구도 mcp__plugin_usercase_usercase__*),
33
33
  // 명령은 /usercase:research 같은 스킬이다 — 스킬은 MCP 프롬프트를 못 부르니 instructions 도구로 같은 글을 받는다
34
34
  const PLUGIN = process.env.USERCASE_PLUGIN === "1";
35
- const VISITOR = PLUGIN ? "usercase:usercase-visitor" : "usercase-visitor";
36
- const cmd = (name) => (PLUGIN ? `/usercase:${name}` : `/mcp__usercase__${name}`);
35
+ // Codex 로 떴는지(시험 중). Codex 는 MCP 프롬프트를 명령으로 안 보여 줘서 플러그인처럼 스킬($usercase-research)이 instructions 도구를 부르고,
36
+ // 방문자는 ~/.codex/agents/usercase_visitor.toml 의 사용자 에이전트다
37
+ const CODEX = process.env.USERCASE_CLIENT === "codex";
38
+ // 그 밖의 MCP 클라이언트(Cursor, VS Code …) — 대시보드의 MCP 설정 칸이 USERCASE_CLIENT=other 를 준다. 프롬프트를 명령으로 안 보여 주는 곳이 있어 instructions 도구를 둔다
39
+ const OTHER = process.env.USERCASE_CLIENT === "other";
40
+ const VISITOR = PLUGIN ? "usercase:usercase-visitor" : CODEX ? "usercase_visitor" : "usercase-visitor";
41
+ // 플러그인 스킬은 /usercase:이름. 직접 붙인 MCP 의 프롬프트는 목록에 /usercase:이름 (MCP) 로 보이지만 그렇게 치면 안 돌고 /mcp__usercase__이름 으로만 돈다(10/28 확인).
42
+ // Codex 플러그인 스킬은 $usercase:이름
43
+ const cmd = (name) => (CODEX ? `$usercase:${name}` : PLUGIN ? `/usercase:${name}` : `/mcp__usercase__${name}`);
44
+ // Codex 의 spawn_agent 는 기본으로 부모 대화 전체(읽은 코드·다른 사람들)를 물려준다 — 방문자는 빈 상태로 시작해야 한다(10/28)
45
+ const SPAWN = CODEX ? " (with `fork_turns` set to `none`, so it starts without this conversation)" : "";
37
46
 
38
47
  const text = (t) => ({ content: [{ type: "text", text: String(t) }] });
39
48
  /** 방문 화면 + 그 화면의 이름 없는 아이콘 그림(있으면). 방문자는 그림을 보고 모양으로 안다 */
@@ -81,7 +90,7 @@ server.registerTool(
81
90
  "install_snippet",
82
91
  {
83
92
  description:
84
- "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.",
93
+ "Get the Usercase script for the connected project, with where to put it per framework. If nothing is connected yet, it opens the browser to connect and says what to tell the user. 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.",
85
94
  inputSchema: {},
86
95
  },
87
96
  wrap(async () => {
@@ -199,6 +208,8 @@ server.registerTool(
199
208
  ...ids.incoming.map((p) => `- ${p.id} · ${personLine(p)}`),
200
209
  `${doc.people.length} in total (${doc.people.filter((x) => !x.usedAt).length} not visited yet)`,
201
210
  coverageLine(doc.people),
211
+ // 절차서에만 쓰면 건너뛴다(10/28 둘째 판) — 방문자를 띄우기 직전인 여기서 한 번 더
212
+ ...(CODEX && warmed.size < 2 ? [`\nBefore you start any visitor: ${WARMUP_STEP}`] : []),
202
213
  ].join("\n"),
203
214
  );
204
215
  }),
@@ -290,6 +301,43 @@ function removeOldAgent() {
290
301
  return removed;
291
302
  }
292
303
 
304
+ // Codex 는 플러그인에 에이전트를 넣을 수 없다(10/28 확인) — setup 이 ~/.codex/agents/ 에 깐다. 지시문은 Claude 것과 같다
305
+ const CODEX_AGENT_DEST = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "agents", "usercase_visitor.toml");
306
+ function codexAgentToml() {
307
+ const body = fs
308
+ .readFileSync(AGENT_SRC, "utf8")
309
+ .replace(/\r\n/g, "\n")
310
+ .replace(/^---\n[\s\S]*?\n---\n+/, "")
311
+ .replace(/^<!--[\s\S]*?-->\n+/m, "")
312
+ .trim();
313
+ if (body.includes("'''")) throw new Error("usercase-visitor.md contains ''' and cannot go into a TOML literal string");
314
+ return [
315
+ `name = "usercase_visitor"`,
316
+ `description = "One Usercase virtual user visiting a website in a real browser. Give it only the person's number (e.g. p3) from .usercase/people.json. It sees nothing but the screen."`,
317
+ `sandbox_mode = "read-only"`,
318
+ "",
319
+ "developer_instructions = '''",
320
+ body,
321
+ "",
322
+ "You may only use the usercase tools open_visit, act and answer_card. Never use any other tool.",
323
+ "'''",
324
+ "",
325
+ "[features]",
326
+ "shell_tool = false",
327
+ "",
328
+ ].join("\n");
329
+ }
330
+ /** 이 MCP 를 띄울 때 깔았나("installed")·바꿨나("updated") — 그럼 이 Codex 세션은 아직 그 에이전트를 모른다 */
331
+ let codexAgentFresh = null;
332
+ function installCodexAgent() {
333
+ const toml = codexAgentToml();
334
+ const had = fs.existsSync(CODEX_AGENT_DEST);
335
+ if (had && fs.readFileSync(CODEX_AGENT_DEST, "utf8") === toml) return "same";
336
+ fs.mkdirSync(path.dirname(CODEX_AGENT_DEST), { recursive: true });
337
+ fs.writeFileSync(CODEX_AGENT_DEST, toml);
338
+ return had ? "updated" : "installed";
339
+ }
340
+
293
341
  /** 방문자 에이전트를 ~/.claude/agents/ 에 둔다. "installed" | "updated" | "same" */
294
342
  function installAgent() {
295
343
  const moved = removeOldAgent();
@@ -300,15 +348,34 @@ function installAgent() {
300
348
  return had || moved ? "updated" : "installed";
301
349
  }
302
350
 
351
+ server.registerTool(
352
+ "connect",
353
+ {
354
+ description:
355
+ "Connect Usercase to a project in the browser, or switch to another project. Opens the Usercase dashboard where the user signs in and picks the project. Other tools connect by themselves when nothing is connected — use this only when the user wants another project, or check_site says the SDK sends to a different project.",
356
+ inputSchema: {},
357
+ },
358
+ wrap(async () => text(await report.reconnect())),
359
+ );
360
+
303
361
  server.registerTool(
304
362
  "setup",
305
363
  {
306
364
  description:
307
- "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.",
365
+ "Install the visitor sub-agent definition (Claude Code: ~/.claude/agents/, Codex: ~/.codex/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.",
308
366
  inputSchema: {},
309
367
  },
310
368
  wrap(async () => {
311
369
  if (PLUGIN) return text(`Nothing to install — the visitor agent comes with the Usercase plugin as ${VISITOR}.`);
370
+ if (CODEX) {
371
+ // 이 세션을 띄울 때 깔았거나 바꿨으면 이 세션의 Codex 는 아직 모른다 — 일반 하위 에이전트로 대신 띄우면 코드를 본다
372
+ const c = codexAgentFresh || installCodexAgent();
373
+ return text(
374
+ c === "same"
375
+ ? `Already up to date — ${CODEX_AGENT_DEST}`
376
+ : `${c === "updated" ? "Updated" : "Installed"} → ${CODEX_AGENT_DEST}\nThis agent can only use the three Usercase tools (open_visit, act, answer_card) and cannot read files or run commands.\nCodex reads agents only when a session starts, so ${VISITOR} is not available in this session. Stop here and ask the user to start a new Codex session (quit and run codex again) and run the command again. Never start visitors as a general sub-agent instead — it would see the code.`,
377
+ );
378
+ }
312
379
  const r = installAgent();
313
380
  return text(
314
381
  r === "same"
@@ -569,6 +636,15 @@ server.registerTool(
569
636
 
570
637
  // ── 가상 사용자 서브에이전트용 ──
571
638
 
639
+ // Codex 는 도구마다 처음 부를 때 허용을 묻는다. 방문 도중에 물으면 그동안 첫 화면에 가만히 있던 사람이 된다(10/28 첫 행동 130초).
640
+ // 그래서 진행자가 방문 전에 visit_id "warmup" 으로 한 번씩 불러 허용을 미리 받는다 — 아무 일도 하지 않는다
641
+ const WARMUP = "warmup";
642
+ const WARMUP_TEXT = "Permission check only — nothing happened.";
643
+ const WARMUP_STEP =
644
+ 'call `act` and `answer_card` yourself, once each, with `visit_id` "warmup" and any other arguments (they do nothing). Codex then asks the user to allow these tools now — otherwise it asks in the middle of a visit, and the visitor stands still on the page until the user answers. Tell the user to allow them for good.';
645
+ const warmed = new Set();
646
+ const warm = (tool) => (warmed.add(tool), text(WARMUP_TEXT));
647
+
572
648
  server.registerTool(
573
649
  "open_visit",
574
650
  {
@@ -597,7 +673,9 @@ server.registerTool(
597
673
  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"),
598
674
  },
599
675
  },
600
- wrap(async ({ visit_id, action, index, text: typed = "", why, done = false, times = 1 }) => screen(await hub.act(visit_id, { action, index, text: typed, why, done, times }), visit_id)),
676
+ wrap(async ({ visit_id, action, index, text: typed = "", why, done = false, times = 1 }) =>
677
+ visit_id === WARMUP ? warm("act") : screen(await hub.act(visit_id, { action, index, text: typed, why, done, times }), visit_id),
678
+ ),
601
679
  );
602
680
 
603
681
  server.registerTool(
@@ -616,7 +694,9 @@ server.registerTool(
616
694
  },
617
695
  },
618
696
  wrap(async ({ visit_id, bothered, bothered_why, action, choice_index, other_text = "", why }) =>
619
- screen(await hub.answer(visit_id, { bothered, botheredWhy: bothered_why, action, choiceIndex: choice_index, otherText: other_text, why }), visit_id),
697
+ visit_id === WARMUP
698
+ ? warm("answer_card")
699
+ : screen(await hub.answer(visit_id, { bothered, botheredWhy: bothered_why, action, choiceIndex: choice_index, otherText: other_text, why }), visit_id),
620
700
  ),
621
701
  );
622
702
 
@@ -637,7 +717,7 @@ function researchPlan(count) {
637
717
  line: unlimited ? `unlimited — rounds of ${ROUND} until the user stops` : `${n} in total, in rounds of ${ROUND}`,
638
718
  rounds: [
639
719
  `Work in rounds of ${ROUND} people, ${until}.`,
640
- `- **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).`,
720
+ `- **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 ${CODEX ? "Codex" : "Claude"} usage. Wait for the go-ahead, then run the round (step 4).`,
641
721
  "- **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.",
642
722
  "- After each round, `results` tells you whether the run is finished. Until it says so, start the next round — do not write the report.",
643
723
  "- Stop early only if the site blocks us, or visits keep failing the same way before they start.",
@@ -663,7 +743,7 @@ function runStatus() {
663
743
  const next = run.total === null ? ROUND : Math.min(ROUND, run.total - done);
664
744
  return [
665
745
  `▶ The run is not finished — ${done} visited so far (${left}).`,
666
- `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 ${VISITOR} for each, and call results again when they finish.`,
746
+ `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 ${VISITOR}${SPAWN} for each, and call results again when they finish.`,
667
747
  ].join("\n");
668
748
  }
669
749
 
@@ -708,7 +788,13 @@ const COMMANDS = {
708
788
  .replace("{{count_line}}", plan.line)
709
789
  .replace("{{rounds}}", plan.rounds)
710
790
  .replace("{{people_rules}}", rules)
711
- .replaceAll("{{visitor}}", VISITOR);
791
+ .replaceAll("{{visitor}}", VISITOR)
792
+ .replace("{{spawn}}", SPAWN)
793
+ .replace("{{agent}}", CODEX ? "Codex" : "Claude Code")
794
+ .replace(
795
+ "{{warmup}}\n",
796
+ CODEX ? `**Required before the first round:** ${WARMUP_STEP}\n\n` : "",
797
+ );
712
798
  },
713
799
  },
714
800
  install: {
@@ -730,9 +816,23 @@ const COMMANDS = {
730
816
  },
731
817
  // 인자를 두지 않는다 — Claude Code 는 명령어 인자를 공백으로 나눠 문장을 못 싣는다. 무엇을 물을지는 대화로 묻는다
732
818
  question: {
733
- description: "Make a Usercase question for a precise moment on your site — Claude suggests the moment, question and answers",
819
+ description: "Make a Usercase question for a precise moment on your site — your agent suggests the moment, question and answers",
734
820
  body: () => agentDoc("question.md"),
735
821
  },
822
+ // 프로젝트 바꾸기 — 연결은 저장소 폴더마다라 이 저장소만 바뀐다
823
+ connect: {
824
+ description: "Connect this repository to a Usercase project, or switch it to another one",
825
+ body: () =>
826
+ [
827
+ "# Connect this repository to a Usercase project",
828
+ "",
829
+ "Call the `connect` tool. It opens the Usercase dashboard in the browser, where the user signs in and picks the project (or creates one). Tell the user what it returns.",
830
+ "",
831
+ "If it says it is not connected yet, the browser is waiting for the user: wait until they say they are done, then call `connect` again — it carries on with the same request.",
832
+ "",
833
+ "Only this repository's folder changes. Other repositories keep their own project. Until the new project is approved, the current connection stays.",
834
+ ].join("\n"),
835
+ },
736
836
  steps: {
737
837
  description: "Write the steps for the question open in your dashboard (New question → Several steps)",
738
838
  argsSchema: { session: z.string().optional().describe("The window id shown in the dashboard command") },
@@ -740,12 +840,12 @@ const COMMANDS = {
740
840
  },
741
841
  };
742
842
 
743
- if (PLUGIN) {
843
+ if (PLUGIN || CODEX || OTHER) {
744
844
  server.registerTool(
745
845
  "instructions",
746
846
  {
747
847
  description:
748
- "The step-by-step instructions for a Usercase command (research, install, path, question, steps). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
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.",
749
849
  inputSchema: {
750
850
  command: z.enum(Object.keys(COMMANDS)),
751
851
  ...COMMANDS.research.argsSchema,
@@ -766,8 +866,15 @@ async function main() {
766
866
  // 예전에 깐 방문자 에이전트가 있으면 이 버전 것으로 바꿔 둔다 — setup 은 처음 한 번만 불려서 옛 지시문이 남는다.
767
867
  // 리서치를 한 번도 안 쓴 사람에게는 만들지 않는다
768
868
  try {
769
- if (!PLUGIN && (fs.existsSync(AGENT_DEST) || fs.existsSync(AGENT_OLD)) && installAgent() === "updated") process.stderr.write(`Updated the visitor agent → ${AGENT_DEST}\n`);
869
+ if (!PLUGIN && !CODEX && (fs.existsSync(AGENT_DEST) || fs.existsSync(AGENT_OLD)) && installAgent() === "updated") process.stderr.write(`Updated the visitor agent → ${AGENT_DEST}\n`);
870
+ // Codex 는 세션을 시작할 때만 에이전트를 읽는다(10/28 확인 — 세션 도중 깐 것은 unknown agent_type). 그래서 없어도 띄울 때 바로 깐다
871
+ if (CODEX) {
872
+ const c = installCodexAgent();
873
+ if (c !== "same") codexAgentFresh = c;
874
+ if (c !== "same") process.stderr.write(`${c === "updated" ? "Updated" : "Installed"} the visitor agent → ${CODEX_AGENT_DEST}\n`);
875
+ }
770
876
  } catch {}
877
+ server.server.oninitialized = () => report.setClient((server.server.getClientVersion() || {}).name);
771
878
  await server.connect(new StdioServerTransport());
772
879
  process.stderr.write(`Usercase MCP ready — working folder ${ws.DIR}${hub.inject ? ` · local SDK injected (${hub.inject.project})` : ""}\n`);
773
880
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.5",
3
+ "version": "0.2.6-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",