@usercase/mcp 0.2.5-next.0 → 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
@@ -1,26 +1,26 @@
1
- # Install Usercase on this site
2
-
3
- Site: {{url}}
4
-
5
- You are in the site's repository. Add the Usercase script so the site starts sending visits to the user's Usercase project, then check that it loads.
6
-
7
- ## 1. Get the script
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.
10
-
11
- ## 2. Put it on every page
12
-
13
- Follow the placement rules `install_snippet` gives. Change as little as possible: one script, once, in the place every page goes through. Do not touch anything else.
14
-
15
- If the repository already has a Usercase script: with the same project id, say it is already installed and go to step 3; with a different id, stop and ask the user which project is right.
16
-
17
- Tell the user which file you changed and show the diff. Do not commit or deploy unless the user asks.
18
-
19
- ## 3. Check it loads
20
-
21
- If the site is running locally (or the user can start it), call `check_site` with the local address. Done when it says **"Usercase SDK is running"** with the same project. If it is not running, say so — do not keep changing files to make it pass; show the user what you see.
22
-
23
- ## 4. Tell the user what's next
24
-
25
- - Real visitors are recorded once this change is **deployed**. The dashboard's setup screen ticks itself off when the live site sends its first visit.
26
- - They don't have to wait for real visitors: offer to send a few virtual users now with the `research` prompt (`{{research_cmd}} <url> 3`).
1
+ # Install Usercase on this site
2
+
3
+ Site: {{url}}
4
+
5
+ You are in the site's repository. Add the Usercase script so the site starts sending visits to the user's Usercase project, then check that it loads.
6
+
7
+ ## 1. Get the script
8
+
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
+
11
+ ## 2. Put it on every page
12
+
13
+ Follow the placement rules `install_snippet` gives. Change as little as possible: one script, once, in the place every page goes through. Do not touch anything else.
14
+
15
+ If the repository already has a Usercase script: with the same project id, say it is already installed and go to step 3; with a different id, stop and ask the user which project is right.
16
+
17
+ Tell the user which file you changed and show the diff. Do not commit or deploy unless the user asks.
18
+
19
+ ## 3. Check it loads
20
+
21
+ If the site is running locally (or the user can start it), call `check_site` with the local address. Done when it says **"Usercase SDK is running"** with the same project. If it is not running, say so — do not keep changing files to make it pass; show the user what you see.
22
+
23
+ ## 4. Tell the user what's next
24
+
25
+ - Real visitors are recorded once this change is **deployed**. The dashboard's setup screen ticks itself off when the live site sends its first visit.
26
+ - They don't have to wait for real visitors: offer to send a few virtual users now with the `research` prompt (`{{research_cmd}} <url> 3`).
@@ -1,115 +1,116 @@
1
- # Research this site with Usercase virtual users
2
-
3
- Site: {{url}}
4
- How many people: {{count_line}}
5
-
6
- You are the coordinator. Virtual users will visit the site in a real browser, one sub-agent per person, and answer the Usercase survey cards they happen to meet. Their answers become findings in the Usercase dashboard.
7
-
8
- {{focus}}
9
-
10
- ## 0. Setup (once)
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.
13
-
14
- ## 1. Check the site
15
-
16
- Call `check_site` with the URL. If it reports a block (firewall, bot protection), stop and show the user the message — it says what to change. If the SDK is not running, tell the user no survey cards will appear without it, and offer to install it first (call `install_snippet` and follow it, then `check_site` again). Continue without it only if the user says so.
17
-
18
- It also shows the first screen — the menu and the text a visitor sees on arrival. Start your understanding of the site from that.
19
-
20
- ## 2. Understand the site (you may read the repository)
21
-
22
- The people you cast are only as good as your understanding of the site, so do this properly — a quick grep for a description is not enough. Look at:
23
- - **the first screen** from `check_site`: what the site says about itself and what the menu offers
24
- - **the list of pages**: the route or page directories (e.g. `pages/`, `app/`, a router file) — what sections exist
25
- - **the main copy**: headings and descriptions on the main pages, README, product docs if any
26
-
27
- Then write a one- or two-sentence English description of the site as a visitor would understand it: what it is, what people do there, who it is for.
28
-
29
- ### Feature map
30
-
31
- You can read the code, so do not leave parts of the site unvisited by accident. From the routes and the components, list what people can do here — each page and the things on it a visitor would use (reading a post, searching, filtering, comments, saving or following, a calendar, a profile, settings, sign-up, password reset, uploads …). For each, judge how much real use it gets: **common** (most visitors pass through it — the front page, the main list and detail pages), **regular** (a fair share use it), **occasional** (a few do — settings, password reset, an empty-state page). Skip what visitors cannot reach (admin, internal tools, sitemaps).
32
-
33
- Keep this map to yourself, like everything else from the code. It is for casting, not for the visitors.
34
-
35
- **What you learn from the code stays with you.** The visitors must not be told where buttons are, what the routes are, or what is broken. They only see the screen — a visitor who knows the code finds what real people cannot, and the research loses its value.
36
-
37
- ### Sign-in
38
-
39
- If important parts of the site are only reachable when signed in (a dashboard, "my" pages, a SaaS app), first read the site's **auth code** (next-auth, iron-session, express-session, Passport, Clerk, Auth0, Firebase, Supabase, or the sign-in route itself). Find out:
40
- - **how people sign in**: email and password, Google, other social buttons, magic link
41
- - **the sign-in cookie**: whether it has a lifetime (`maxAge`, `ttl`, `expires`) or lasts only until the browser closes
42
- - **how long a sign-in lasts**, in hours
43
- - anything that would stop a demo account: required email verification, invite-only sign-up
44
-
45
- Then decide:
46
- - A lasting cookie (most sites): open `login` with the default window. Google and other buttons work there.
47
- - A cookie that lasts only until the browser closes, but email and password sign-in exists: open `login` with `window: "playwright"`, and tell the user to sign in with email and password.
48
- - **A cookie that lasts only until the browser closes, and only Google or other social sign-in: signed-in visits are not possible on this site with this tool.** Tell the user this before casting anyone, in plain words: what you found in the code, and that every visitor will be a newcomer. Say what would make it possible (an email and password login for a demo account, or giving the sign-in cookie a lifetime). Then cast newcomers only.
49
-
50
- The code is a guide, not proof. Production settings can differ, and a hosted auth service decides some of this itself. `check_site` after each sign-in is what tells you it worked. If you could not tell from the code, open `login` with the default window.
51
-
52
- Ask the user whether they have **demo accounts** you can use, and **exactly how many** — ask for the number itself, not a range. More than one keeps members from all sharing one person's history; two to five is plenty. If you know how long a sign-in lasts, tell them, and pass it to `login` as `lifetimeHours`.
53
-
54
- For each account, one at a time: call `login` — a browser opens on their screen and **they** sign in; only the session is saved. When they have closed the window, run `check_site`. It says for each account whether the sign-in really worked. If one is not signed in, tell the user and call `login` again with that account name. If `check_site` says both windows were tried and it still did not work, stop asking: tell the user that signed-in visits are not possible on this site, and run newcomers only. **Never ask for or handle a password.**
55
-
56
- When the research is done, if there were no signed-in visitors, say so in the summary — the results show only what newcomers ran into.
57
-
58
- You only do this once. The sessions stay saved, and later runs reuse them. If `check_site` or a visit says an account's sign-in has expired, ask the user to sign in again for that account only.
59
-
60
- Make some people members (`member: true`) and keep some as newcomers — newcomers show what stops people before they sign up. Members are spread across the signed-in accounts automatically.
61
-
62
- ### Writing to the site
63
-
64
- If the site lets people sign up, post, comment, save or like, ask the user **once, before the first round**, whether visitors should really send those to the site. Say plainly what it means:
65
- - **Not sent** (the default): nothing visitors do changes the site's data. They are told when something was not sent, but those features are never really tried.
66
- - **Sent**: sign-ups, posts and saves really reach the site. It suits a test or staging site, or data that can be messed up. On a live site, real users will see what visitors post, and a local site may still be wired to the live database — only the user knows.
67
- If there are signed-in visitors, say this too: **they use the demo accounts as their own.** They may change the profile name or picture, edit settings, follow, save, comment and post as that account, and the next member visit on the same account sees those changes. That is why only demo accounts should be used. Signing out, deleting the account, changing the password, paying and connecting a wallet stay blocked either way.
68
-
69
- Call `set_writes` with their answer. Do not decide this yourself. If the site has nothing to write, skip the question.
70
-
71
- ## 3. Cast the people
72
-
73
- {{rounds}}
74
-
75
- Call `save_people` with the people of the round. Follow these rules:
76
-
77
- {{people_rules}}
78
-
79
- Base them on who really comes to this site (from what you read), not on a feature you suspect is broken. **Show the first list to the user and wait for their go-ahead** — the people are the research design.
80
-
81
- **Spread the people over the feature map.** Weight it the way real use is weighted — most people on the common pages, a good share on the regular ones — but give every feature on the map at least one or two people over the run, so nothing a real visitor would use is never tried. Put the feature in each person's `feature`. `save_people` and `list_people` show how many people each feature has so far; in later rounds, cast for the features that have few or none, still keeping the weighting.
82
-
83
- **Give each person a `landing` page that fits how they came.** Someone who searched for one announcement lands on that announcement; someone following a shared link lands on the page that was shared; a regular who bookmarked the calendar lands on the calendar; someone who typed the address lands on the front page (leave `landing` out). Use real paths — `check_site` lists the pages linked from the front page, and the routes are in the code. A goal and a landing that do not match (searched for one thing, landed on an unrelated page) wastes the visit — check the page really is about what they wanted (the list shows each link's text), and do not guess what a page holds from its address alone.
84
-
85
- The goal is still the person's own reason for coming, in their words — never "test the comments". The visitor is not told the feature or anything about the site.
86
-
87
- **A feature is where a visit starts or centres, not the whole visit.** Real people use a site broadly: they read the post they came for, then open the project behind it, glance at what else is new, try the search. Write goals the way people have them — a main reason that naturally leads on (someone checking one announcement also wants to know what else that project has been up to), not a single micro-task that ends the moment it is done ("share this post", "check Friday"). Give a fair share of people a second thing on their mind as well (in `goal` or `moment`). Otherwise every visit uses one feature and leaves, and the run never sees how people move between parts of the site.
88
-
89
- ## 4. Send the visitors
90
-
91
- For each person, start a `{{visitor}}` sub-agent with a prompt like:
92
-
93
- > Call open_visit with person "<id>" and live this visit as that person until you leave.
94
-
95
- Start the whole round at once (at most 5 run at the same time). Do not give them anything else — no hints about the site, no instructions about what to find or which card to answer.
96
-
97
- If any visitor reports the site is blocking us, stop starting new ones and tell the user.
98
-
99
- **Errors on the site are part of what the run records — keep going.** A page that loads blank or half-empty, an error message, a button that does nothing: that is what a real visitor would have met, and it goes in the report. Do not stop the run for it.
100
-
101
- Only when the whole site looks down — several visitors in a round saw nothing but a blank or error page from the start — run `check_site` before the next round. If it opens normally, carry on. If it does not, call `check_site` again, up to 5 times in all (a dev server often comes back within a minute or two); carry on as soon as it is back. Tell the user once, briefly, that the site went down and when it came back. Stop and ask the user only if it is still down after that — then say how many visits are recorded so far and that they can restart the site and tell you to continue.
102
-
103
- When the round's visitors have finished, call `results`. It tells you whether to start the next round or write the report — follow it.
104
-
105
- During the run, do not read the code to look into what visitors ran into, and do not offer fixes. Report what happened; the user decides what to look into.
106
-
107
- ## 5. Report
108
-
109
- When `results` says the run is complete, tell the user, from the records only:
110
- - where people came in, how far they got, and why they left (their own words)
111
- - which survey cards appeared and how people responded (none appearing is normal when the project has no live questions yet — Usercase drafts questions from these visits; do not present it as a fault)
112
- - any time the site itself was down or showed errors, and which visits it affected
113
- - what the safety guard blocked, if anything that might have changed the visit
114
-
115
- Do not invent findings beyond what the records show, and **do not guess causes** ("the data pipeline must be broken", "the filter is wrong") — report what the visitors saw and said. Findings are built by Usercase once enough answers pile up — point the user to the dashboard for those.
1
+ # Research this site with Usercase virtual users
2
+
3
+ Site: {{url}}
4
+ How many people: {{count_line}}
5
+
6
+ You are the coordinator. Virtual users will visit the site in a real browser, one sub-agent per person, and answer the Usercase survey cards they happen to meet. Their answers become findings in the Usercase dashboard.
7
+
8
+ {{focus}}
9
+
10
+ ## 0. Setup (once)
11
+
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
+
14
+ ## 1. Check the site
15
+
16
+ Call `check_site` with the URL. If it reports a block (firewall, bot protection), stop and show the user the message — it says what to change. If the SDK is not running, tell the user no survey cards will appear without it, and offer to install it first (call `install_snippet` and follow it, then `check_site` again). Continue without it only if the user says so.
17
+
18
+ It also shows the first screen — the menu and the text a visitor sees on arrival. Start your understanding of the site from that.
19
+
20
+ ## 2. Understand the site (you may read the repository)
21
+
22
+ The people you cast are only as good as your understanding of the site, so do this properly — a quick grep for a description is not enough. Look at:
23
+ - **the first screen** from `check_site`: what the site says about itself and what the menu offers
24
+ - **the list of pages**: the route or page directories (e.g. `pages/`, `app/`, a router file) — what sections exist
25
+ - **the main copy**: headings and descriptions on the main pages, README, product docs if any
26
+
27
+ Then write a one- or two-sentence English description of the site as a visitor would understand it: what it is, what people do there, who it is for.
28
+
29
+ ### Feature map
30
+
31
+ You can read the code, so do not leave parts of the site unvisited by accident. From the routes and the components, list what people can do here — each page and the things on it a visitor would use (reading a post, searching, filtering, comments, saving or following, a calendar, a profile, settings, sign-up, password reset, uploads …). For each, judge how much real use it gets: **common** (most visitors pass through it — the front page, the main list and detail pages), **regular** (a fair share use it), **occasional** (a few do — settings, password reset, an empty-state page). Skip what visitors cannot reach (admin, internal tools, sitemaps).
32
+
33
+ Keep this map to yourself, like everything else from the code. It is for casting, not for the visitors.
34
+
35
+ **What you learn from the code stays with you.** The visitors must not be told where buttons are, what the routes are, or what is broken. They only see the screen — a visitor who knows the code finds what real people cannot, and the research loses its value.
36
+
37
+ ### Sign-in
38
+
39
+ If important parts of the site are only reachable when signed in (a dashboard, "my" pages, a SaaS app), first read the site's **auth code** (next-auth, iron-session, express-session, Passport, Clerk, Auth0, Firebase, Supabase, or the sign-in route itself). Find out:
40
+ - **how people sign in**: email and password, Google, other social buttons, magic link
41
+ - **the sign-in cookie**: whether it has a lifetime (`maxAge`, `ttl`, `expires`) or lasts only until the browser closes
42
+ - **how long a sign-in lasts**, in hours
43
+ - anything that would stop a demo account: required email verification, invite-only sign-up
44
+
45
+ Then decide:
46
+ - A lasting cookie (most sites): open `login` with the default window. Google and other buttons work there.
47
+ - A cookie that lasts only until the browser closes, but email and password sign-in exists: open `login` with `window: "playwright"`, and tell the user to sign in with email and password.
48
+ - **A cookie that lasts only until the browser closes, and only Google or other social sign-in: signed-in visits are not possible on this site with this tool.** Tell the user this before casting anyone, in plain words: what you found in the code, and that every visitor will be a newcomer. Say what would make it possible (an email and password login for a demo account, or giving the sign-in cookie a lifetime). Then cast newcomers only.
49
+
50
+ The code is a guide, not proof. Production settings can differ, and a hosted auth service decides some of this itself. `check_site` after each sign-in is what tells you it worked. If you could not tell from the code, open `login` with the default window.
51
+
52
+ Ask the user whether they have **demo accounts** you can use, and **exactly how many** — ask for the number itself, not a range. More than one keeps members from all sharing one person's history; two to five is plenty. If you know how long a sign-in lasts, tell them, and pass it to `login` as `lifetimeHours`.
53
+
54
+ For each account, one at a time: call `login` — a browser opens on their screen and **they** sign in; only the session is saved. When they have closed the window, run `check_site`. It says for each account whether the sign-in really worked. If one is not signed in, tell the user and call `login` again with that account name. If `check_site` says both windows were tried and it still did not work, stop asking: tell the user that signed-in visits are not possible on this site, and run newcomers only. **Never ask for or handle a password.**
55
+
56
+ When the research is done, if there were no signed-in visitors, say so in the summary — the results show only what newcomers ran into.
57
+
58
+ You only do this once. The sessions stay saved, and later runs reuse them. If `check_site` or a visit says an account's sign-in has expired, ask the user to sign in again for that account only.
59
+
60
+ Make some people members (`member: true`) and keep some as newcomers — newcomers show what stops people before they sign up. Members are spread across the signed-in accounts automatically.
61
+
62
+ ### Writing to the site
63
+
64
+ If the site lets people sign up, post, comment, save or like, ask the user **once, before the first round**, whether visitors should really send those to the site. Say plainly what it means:
65
+ - **Not sent** (the default): nothing visitors do changes the site's data. They are told when something was not sent, but those features are never really tried.
66
+ - **Sent**: sign-ups, posts and saves really reach the site. It suits a test or staging site, or data that can be messed up. On a live site, real users will see what visitors post, and a local site may still be wired to the live database — only the user knows.
67
+ If there are signed-in visitors, say this too: **they use the demo accounts as their own.** They may change the profile name or picture, edit settings, follow, save, comment and post as that account, and the next member visit on the same account sees those changes. That is why only demo accounts should be used. Signing out, deleting the account, changing the password, paying and connecting a wallet stay blocked either way.
68
+
69
+ Call `set_writes` with their answer. Do not decide this yourself. If the site has nothing to write, skip the question.
70
+
71
+ ## 3. Cast the people
72
+
73
+ {{rounds}}
74
+
75
+ Call `save_people` with the people of the round. Follow these rules:
76
+
77
+ {{people_rules}}
78
+
79
+ Base them on who really comes to this site (from what you read), not on a feature you suspect is broken. **Show the first list to the user and wait for their go-ahead** — the people are the research design.
80
+
81
+ **Spread the people over the feature map.** Weight it the way real use is weighted — most people on the common pages, a good share on the regular ones — but give every feature on the map at least one or two people over the run, so nothing a real visitor would use is never tried. Put the feature in each person's `feature`. `save_people` and `list_people` show how many people each feature has so far; in later rounds, cast for the features that have few or none, still keeping the weighting.
82
+
83
+ **Give each person a `landing` page that fits how they came.** Someone who searched for one announcement lands on that announcement; someone following a shared link lands on the page that was shared; a regular who bookmarked the calendar lands on the calendar; someone who typed the address lands on the front page (leave `landing` out). Use real paths — `check_site` lists the pages linked from the front page, and the routes are in the code. A goal and a landing that do not match (searched for one thing, landed on an unrelated page) wastes the visit — check the page really is about what they wanted (the list shows each link's text), and do not guess what a page holds from its address alone.
84
+
85
+ The goal is still the person's own reason for coming, in their words — never "test the comments". The visitor is not told the feature or anything about the site.
86
+
87
+ **A feature is where a visit starts or centres, not the whole visit.** Real people use a site broadly: they read the post they came for, then open the project behind it, glance at what else is new, try the search. Write goals the way people have them — a main reason that naturally leads on (someone checking one announcement also wants to know what else that project has been up to), not a single micro-task that ends the moment it is done ("share this post", "check Friday"). Give a fair share of people a second thing on their mind as well (in `goal` or `moment`). Otherwise every visit uses one feature and leaves, and the run never sees how people move between parts of the site.
88
+
89
+ ## 4. Send the visitors
90
+
91
+ {{warmup}}
92
+ For each person, start a `{{visitor}}` sub-agent{{spawn}} with a prompt like:
93
+
94
+ > Call open_visit with person "<id>" and live this visit as that person until you leave.
95
+
96
+ Start the whole round at once (at most 5 run at the same time). Do not give them anything else — no hints about the site, no instructions about what to find or which card to answer.
97
+
98
+ If any visitor reports the site is blocking us, stop starting new ones and tell the user.
99
+
100
+ **Errors on the site are part of what the run records — keep going.** A page that loads blank or half-empty, an error message, a button that does nothing: that is what a real visitor would have met, and it goes in the report. Do not stop the run for it.
101
+
102
+ Only when the whole site looks down — several visitors in a round saw nothing but a blank or error page from the start — run `check_site` before the next round. If it opens normally, carry on. If it does not, call `check_site` again, up to 5 times in all (a dev server often comes back within a minute or two); carry on as soon as it is back. Tell the user once, briefly, that the site went down and when it came back. Stop and ask the user only if it is still down after that — then say how many visits are recorded so far and that they can restart the site and tell you to continue.
103
+
104
+ When the round's visitors have finished, call `results`. It tells you whether to start the next round or write the report — follow it.
105
+
106
+ During the run, do not read the code to look into what visitors ran into, and do not offer fixes. Report what happened; the user decides what to look into.
107
+
108
+ ## 5. Report
109
+
110
+ When `results` says the run is complete, tell the user, from the records only:
111
+ - where people came in, how far they got, and why they left (their own words)
112
+ - which survey cards appeared and how people responded (none appearing is normal when the project has no live questions yet — Usercase drafts questions from these visits; do not present it as a fault)
113
+ - any time the site itself was down or showed errors, and which visits it affected
114
+ - what the safety guard blocked, if anything that might have changed the visit
115
+
116
+ Do not invent findings beyond what the records show, and **do not guess causes** ("the data pipeline must be broken", "the filter is wrong") — report what the visitors saw and said. Findings are built by Usercase once enough answers pile up — point the user to the dashboard for those.
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,111 +743,138 @@ 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
 
670
- const doc = (name) => fs.readFileSync(path.join(__dirname, "..", "agents", name), "utf8").replace(/\r\n/g, "\n");
671
- /** 조건 쓰는 규칙은 question·steps 가 같이 쓴다 — 한 벌만 둔다 */
672
- const agentDoc = (name, vars = {}) => {
673
- let body = doc(name).replace("{{condition_rules}}", doc("condition.md").trim());
674
- for (const [k, v] of Object.entries(vars)) body = body.replace(`{{${k}}}`, v);
675
- return body;
676
- };
677
-
678
- /**
679
- * 명령 지시문. 따로 쓰면 MCP 프롬프트(/mcp__usercase__research), 플러그인이면 instructions 도구(/usercase:research 스킬이 부른다)로
680
- * 같은 글이 나간다
681
- */
682
- const COMMANDS = {
683
- research: {
684
- description: "Research this site with Usercase virtual users",
685
- argsSchema: {
686
- url: z.string().optional(),
687
- count: z.string().optional().describe('How many people (default 5), or "unlimited" to keep going until you stop it'),
688
- focus: z.string().optional().describe("Only people who come for this part of the site, e.g. calendar, or comments and sharing"),
689
- },
690
- body: ({ url, count, focus }) => {
691
- const plan = researchPlan(count);
692
- run = { total: plan.total, since: Date.now() };
693
- // 사람 만드는 규칙은 우리 AI 로 만들 때와 같은 원본(people.md)에서 가져온다 — 두 벌로 두지 않는다.
694
- // 자유 탐방이라 원본이 말하는 "path" 는 "자기 목적으로 왔다" 뿐이다
695
- const rules =
696
- "(Here the \"path\" is simply: came to the site with something of their own to find out.)\n\n" +
697
- fs
698
- .readFileSync(path.join(__dirname, "..", "src", "prompts", "people.md"), "utf8")
699
- .replace(/\r\n/g, "\n")
700
- .replace(/^# Role:.*\n+/, "")
701
- .replace(/^#/gm, "###");
702
- // focus 가 있으면 그 기능에 올 사람만 — 바뀌는 단계만 따로 적은 글을 앞에 붙인다. 방문자는 모른다
703
- const f = (focus || "").trim();
704
- const focusPart = f ? doc("focus.md").replaceAll("{{focus}}", f).trim() + "\n\n" : "";
705
- return doc("research.md")
706
- .replace("{{focus}}\n", focusPart)
707
- .replace("{{url}}", url || "(ask the user)")
708
- .replace("{{count_line}}", plan.line)
709
- .replace("{{rounds}}", plan.rounds)
710
- .replace("{{people_rules}}", rules)
711
- .replaceAll("{{visitor}}", VISITOR);
712
- },
713
- },
714
- install: {
715
- description: "Install the Usercase script on this site from its repository",
716
- argsSchema: { url: z.string().optional() },
717
- body: ({ url }) =>
718
- doc("install.md")
719
- .replace("{{url}}", url || "(the site in this repository)")
720
- .replace("{{research_cmd}}", cmd("research")),
721
- },
722
- path: {
723
- description: "Record the expected path of a Usercase research's tasks",
724
- // 대시보드 과제 화면의 명령은 과제 id 를 붙인다 — 그 과제만, 경로가 있어도 다시
725
- argsSchema: { research: z.string().optional(), task: z.string().optional() },
726
- body: ({ research, task }) =>
727
- doc("path.md")
728
- .replace("{{research}}", research || "(ask the user — the dashboard shows it)")
729
- .replace("{{task}}", task ? `Task: ${task} — record this task only, and record it again even if it already has a path.` : "Task: all tasks of the research"),
730
- },
731
- // 인자를 두지 않는다 — Claude Code 는 명령어 인자를 공백으로 나눠 문장을 못 싣는다. 무엇을 물을지는 대화로 묻는다
732
- question: {
733
- description: "Make a Usercase question for a precise moment on your site — Claude suggests the moment, question and answers",
734
- body: () => agentDoc("question.md"),
735
- },
736
- steps: {
737
- description: "Write the steps for the question open in your dashboard (New question → Several steps)",
738
- argsSchema: { session: z.string().optional().describe("The window id shown in the dashboard command") },
739
- body: ({ session }) => agentDoc("steps.md", { session: session || "(missing — ask the user to copy the command from the dashboard)" }),
740
- },
741
- };
742
-
743
- if (PLUGIN) {
744
- server.registerTool(
745
- "instructions",
746
- {
747
- 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.",
749
- inputSchema: {
750
- command: z.enum(Object.keys(COMMANDS)),
751
- ...COMMANDS.research.argsSchema,
752
- ...COMMANDS.path.argsSchema,
753
- ...COMMANDS.steps.argsSchema,
754
- },
755
- },
756
- wrap(async ({ command, ...args }) => text(COMMANDS[command].body(args))),
757
- );
758
- } else {
759
- for (const [name, c] of Object.entries(COMMANDS)) {
760
- const meta = c.argsSchema ? { description: c.description, argsSchema: c.argsSchema } : { description: c.description };
761
- server.registerPrompt(name, meta, (args = {}) => ({ messages: [{ role: "user", content: { type: "text", text: c.body(args) } }] }));
762
- }
763
- }
764
-
750
+ const doc = (name) => fs.readFileSync(path.join(__dirname, "..", "agents", name), "utf8").replace(/\r\n/g, "\n");
751
+ /** 조건 쓰는 규칙은 question·steps 가 같이 쓴다 — 한 벌만 둔다 */
752
+ const agentDoc = (name, vars = {}) => {
753
+ let body = doc(name).replace("{{condition_rules}}", doc("condition.md").trim());
754
+ for (const [k, v] of Object.entries(vars)) body = body.replace(`{{${k}}}`, v);
755
+ return body;
756
+ };
757
+
758
+ /**
759
+ * 명령 지시문. 따로 쓰면 MCP 프롬프트(/mcp__usercase__research), 플러그인이면 instructions 도구(/usercase:research 스킬이 부른다)로
760
+ * 같은 글이 나간다
761
+ */
762
+ const COMMANDS = {
763
+ research: {
764
+ description: "Research this site with Usercase virtual users",
765
+ argsSchema: {
766
+ url: z.string().optional(),
767
+ count: z.string().optional().describe('How many people (default 5), or "unlimited" to keep going until you stop it'),
768
+ focus: z.string().optional().describe("Only people who come for this part of the site, e.g. calendar, or comments and sharing"),
769
+ },
770
+ body: ({ url, count, focus }) => {
771
+ const plan = researchPlan(count);
772
+ run = { total: plan.total, since: Date.now() };
773
+ // 사람 만드는 규칙은 우리 AI 로 만들 때와 같은 원본(people.md)에서 가져온다 — 두 벌로 두지 않는다.
774
+ // 자유 탐방이라 원본이 말하는 "path" 는 "자기 목적으로 왔다" 뿐이다
775
+ const rules =
776
+ "(Here the \"path\" is simply: came to the site with something of their own to find out.)\n\n" +
777
+ fs
778
+ .readFileSync(path.join(__dirname, "..", "src", "prompts", "people.md"), "utf8")
779
+ .replace(/\r\n/g, "\n")
780
+ .replace(/^# Role:.*\n+/, "")
781
+ .replace(/^#/gm, "###");
782
+ // focus 가 있으면 그 기능에 올 사람만 — 바뀌는 단계만 따로 적은 글을 앞에 붙인다. 방문자는 모른다
783
+ const f = (focus || "").trim();
784
+ const focusPart = f ? doc("focus.md").replaceAll("{{focus}}", f).trim() + "\n\n" : "";
785
+ return doc("research.md")
786
+ .replace("{{focus}}\n", focusPart)
787
+ .replace("{{url}}", url || "(ask the user)")
788
+ .replace("{{count_line}}", plan.line)
789
+ .replace("{{rounds}}", plan.rounds)
790
+ .replace("{{people_rules}}", rules)
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
+ );
798
+ },
799
+ },
800
+ install: {
801
+ description: "Install the Usercase script on this site from its repository",
802
+ argsSchema: { url: z.string().optional() },
803
+ body: ({ url }) =>
804
+ doc("install.md")
805
+ .replace("{{url}}", url || "(the site in this repository)")
806
+ .replace("{{research_cmd}}", cmd("research")),
807
+ },
808
+ path: {
809
+ description: "Record the expected path of a Usercase research's tasks",
810
+ // 대시보드 과제 화면의 명령은 과제 id 를 붙인다 — 그 과제만, 경로가 있어도 다시
811
+ argsSchema: { research: z.string().optional(), task: z.string().optional() },
812
+ body: ({ research, task }) =>
813
+ doc("path.md")
814
+ .replace("{{research}}", research || "(ask the user — the dashboard shows it)")
815
+ .replace("{{task}}", task ? `Task: ${task} — record this task only, and record it again even if it already has a path.` : "Task: all tasks of the research"),
816
+ },
817
+ // 인자를 두지 않는다 — Claude Code 는 명령어 인자를 공백으로 나눠 문장을 못 싣는다. 무엇을 물을지는 대화로 묻는다
818
+ question: {
819
+ description: "Make a Usercase question for a precise moment on your site — your agent suggests the moment, question and answers",
820
+ body: () => agentDoc("question.md"),
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
+ },
836
+ steps: {
837
+ description: "Write the steps for the question open in your dashboard (New question → Several steps)",
838
+ argsSchema: { session: z.string().optional().describe("The window id shown in the dashboard command") },
839
+ body: ({ session }) => agentDoc("steps.md", { session: session || "(missing — ask the user to copy the command from the dashboard)" }),
840
+ },
841
+ };
842
+
843
+ if (PLUGIN || CODEX || OTHER) {
844
+ server.registerTool(
845
+ "instructions",
846
+ {
847
+ description:
848
+ "The step-by-step instructions for a Usercase command (research, install, path, question, steps, connect). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
849
+ inputSchema: {
850
+ command: z.enum(Object.keys(COMMANDS)),
851
+ ...COMMANDS.research.argsSchema,
852
+ ...COMMANDS.path.argsSchema,
853
+ ...COMMANDS.steps.argsSchema,
854
+ },
855
+ },
856
+ wrap(async ({ command, ...args }) => text(COMMANDS[command].body(args))),
857
+ );
858
+ } else {
859
+ for (const [name, c] of Object.entries(COMMANDS)) {
860
+ const meta = c.argsSchema ? { description: c.description, argsSchema: c.argsSchema } : { description: c.description };
861
+ server.registerPrompt(name, meta, (args = {}) => ({ messages: [{ role: "user", content: { type: "text", text: c.body(args) } }] }));
862
+ }
863
+ }
864
+
765
865
  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-next.0",
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",
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "scripts": {
28
- "check": "node --check mcp/server.js && node scripts/onboarding.js --check && node scripts/agents.js --check",
28
+ "check": "node --check mcp/server.js && node scripts/onboarding.js --check && node scripts/agents.js --check && node scripts/plugin.js --check",
29
29
  "docs": "node scripts/onboarding.js && node scripts/agents.js",
30
30
  "release:next": "node scripts/release.js next",
31
31
  "release:promote": "node scripts/release.js promote",