@thedesignagent/mcp 0.2.4 → 0.3.1
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 +19 -5
- package/dist/auth-capture.js +245 -110
- package/dist/auth-store.d.ts +1 -0
- package/dist/auth-store.js +7 -3
- package/dist/index.js +52 -34
- package/dist/screenshot.d.ts +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -34,11 +34,11 @@ Get an API key (it starts with `tda_`) at [thedesignagent.ai/dashboard/api-keys]
|
|
|
34
34
|
|
|
35
35
|
### Claude Code (recommended: the plugin)
|
|
36
36
|
|
|
37
|
-
The plugin includes this server, plus skills that teach the agent the brief → build → review loop and `/
|
|
37
|
+
The plugin includes this server, plus skills that teach the agent the brief → build → review loop and `/tda:brief` and `/tda:review` commands:
|
|
38
38
|
|
|
39
39
|
```
|
|
40
40
|
/plugin marketplace add CMBurnett/the-design-agent-plugin
|
|
41
|
-
/plugin install
|
|
41
|
+
/plugin install tda@thedesignagent
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
Or add the server on its own:
|
|
@@ -113,13 +113,27 @@ Screenshots are saved to `~/.thedesignagent/screenshots/` for 24 hours, so you c
|
|
|
113
113
|
|
|
114
114
|
### Pages behind login
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
When `Visual` lands on a login page it stops before reviewing (nothing is charged) and tells your agent how to save a session. Sessions are stored per site under `~/.thedesignagent/auth/` (readable only by you) and applied to every later screenshot of that site.
|
|
117
|
+
|
|
118
|
+
There are two ways to save one:
|
|
119
|
+
|
|
120
|
+
**A login recipe (automatic).** A small script in your repo, `.thedesignagent-login.mjs`, prints either a URL that logs in when opened (a magic link, a dev-only login route) or cookies as JSON. Pipe it into `login`:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
node .thedesignagent-login.mjs | npx -y --package=@thedesignagent/mcp thedesignagent-auth login http://localhost:3000/dashboard
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
It logs in headlessly, checks the page no longer redirects to login, and saves. The Claude Code plugin's `/tda:setup` writes a recipe for Supabase apps.
|
|
127
|
+
|
|
128
|
+
**Capture by hand.** Opens a browser window; log in and it saves and closes by itself once you leave the login page:
|
|
117
129
|
|
|
118
130
|
```
|
|
119
|
-
npx -y --package=@thedesignagent/mcp thedesignagent-auth capture http://localhost:3000
|
|
131
|
+
npx -y --package=@thedesignagent/mcp thedesignagent-auth capture http://localhost:3000/dashboard
|
|
120
132
|
```
|
|
121
133
|
|
|
122
|
-
|
|
134
|
+
It uses a dedicated browser profile, so later re-captures are often instant. Agents can run it in the background: it ends with `TDA_AUTH_SAVED <origin>` or `TDA_AUTH_FAILED <reason>`, and gives up after 10 minutes.
|
|
135
|
+
|
|
136
|
+
Check what's saved with `thedesignagent-auth status <url>`.
|
|
123
137
|
|
|
124
138
|
## Environment variables
|
|
125
139
|
|
package/dist/auth-capture.js
CHANGED
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* thedesignagent-auth: save a logged-in session so `Visual` can screenshot
|
|
4
|
+
* pages behind login.
|
|
5
|
+
*
|
|
6
|
+
* capture <app-url> Opens a browser window; log in. Saves and closes on its own
|
|
7
|
+
* once you leave the login page (or press Enter / close it).
|
|
8
|
+
* Safe to run from an agent in the background.
|
|
9
|
+
* login <app-url> Non-interactive. Reads a login recipe's output on stdin:
|
|
10
|
+
* either a URL that logs in when opened (a magic link, a
|
|
11
|
+
* dev-only login route) or an AuthSeed JSON ({ cookies, ... }).
|
|
12
|
+
* Logs in headlessly, verifies, saves.
|
|
13
|
+
* status <app-url> Says whether a session is saved and when it expires.
|
|
14
|
+
*
|
|
15
|
+
* Every run ends with one machine-readable line for agents:
|
|
16
|
+
* TDA_AUTH_SAVED <origin> | TDA_AUTH_FAILED <reason> | TDA_AUTH_STATUS ...
|
|
17
|
+
*/
|
|
2
18
|
import { findChromeExecutable, isLoginUrl } from './screenshot.js';
|
|
3
|
-
import { saveStoredAuth } from './auth-store.js';
|
|
19
|
+
import { authFilePath, loadStoredAuth, saveStoredAuth, BROWSER_PROFILE_DIR } from './auth-store.js';
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
4
21
|
import { createInterface } from 'node:readline';
|
|
22
|
+
const CAPTURE_TIMEOUT_MS = Number(process.env.TDA_CAPTURE_TIMEOUT_MS ?? 10 * 60 * 1000);
|
|
23
|
+
const POLL_MS = 1500;
|
|
5
24
|
function printUsage() {
|
|
6
|
-
console.error('Usage:
|
|
7
|
-
console.error('
|
|
25
|
+
console.error('Usage:');
|
|
26
|
+
console.error(' thedesignagent-auth capture <app-url> log in by hand in a browser window');
|
|
27
|
+
console.error(' thedesignagent-auth login <app-url> log in from a recipe (URL or AuthSeed JSON on stdin)');
|
|
28
|
+
console.error(' thedesignagent-auth status <app-url> show whether a session is saved');
|
|
29
|
+
console.error('Example: node .thedesignagent-login.mjs | thedesignagent-auth login http://localhost:3000');
|
|
30
|
+
}
|
|
31
|
+
function fail(reason, detail) {
|
|
32
|
+
console.error('');
|
|
33
|
+
console.error(`✗ ${reason}`);
|
|
34
|
+
for (const line of detail ?? [])
|
|
35
|
+
console.error(` ${line}`);
|
|
36
|
+
console.log(`TDA_AUTH_FAILED ${reason}`);
|
|
37
|
+
process.exit(1);
|
|
8
38
|
}
|
|
9
39
|
async function snapshotSession(browser, page, hostname) {
|
|
10
40
|
try {
|
|
@@ -19,144 +49,249 @@ async function snapshotSession(browser, page, hostname) {
|
|
|
19
49
|
httpOnly: c.httpOnly,
|
|
20
50
|
secure: c.secure,
|
|
21
51
|
sameSite: (c.sameSite === 'Strict' || c.sameSite === 'Lax' || c.sameSite === 'None') ? c.sameSite : undefined,
|
|
52
|
+
...(c.expires > 0 && { expires: c.expires }),
|
|
22
53
|
}));
|
|
54
|
+
// Storage is unreadable on error pages (opaque origin): keep the cookies anyway.
|
|
23
55
|
const storage = await page.evaluate(() => ({
|
|
24
56
|
localStorage: Object.fromEntries(Object.keys(localStorage).map(k => [k, localStorage.getItem(k) ?? ''])),
|
|
25
57
|
sessionStorage: Object.fromEntries(Object.keys(sessionStorage).map(k => [k, sessionStorage.getItem(k) ?? ''])),
|
|
26
|
-
}));
|
|
27
|
-
return {
|
|
28
|
-
cookies,
|
|
29
|
-
localStorage: storage.localStorage,
|
|
30
|
-
sessionStorage: storage.sessionStorage,
|
|
31
|
-
finalUrl: page.url(),
|
|
32
|
-
};
|
|
58
|
+
})).catch(() => ({ localStorage: {}, sessionStorage: {} }));
|
|
59
|
+
return { cookies, localStorage: storage.localStorage, sessionStorage: storage.sessionStorage, finalUrl: page.url() };
|
|
33
60
|
}
|
|
34
61
|
catch {
|
|
35
62
|
return null;
|
|
36
63
|
}
|
|
37
64
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// No TTY (e.g. backgrounded by an agent) — never resolves. Browser-close will trigger instead.
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
-
rl.once('line', () => { rl.close(); resolve('enter'); });
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
function waitForBrowserClose(browser) {
|
|
49
|
-
return new Promise(resolve => {
|
|
50
|
-
browser.on('disconnected', () => resolve('closed'));
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
// Post-trigger verification: if the browser is still alive after Enter was
|
|
54
|
-
// pressed, navigate back to the original URL and re-snapshot. Catches "user
|
|
55
|
-
// pressed Enter too early" — the partial session would otherwise be saved
|
|
56
|
-
// silently and fail on every subsequent visual call.
|
|
57
|
-
async function verifyAndSnapshot(browser, page, url, trigger, fallback) {
|
|
58
|
-
if (trigger !== 'enter' || !browser.connected)
|
|
59
|
-
return fallback;
|
|
65
|
+
// Navigate back to the app URL and re-snapshot, so a session is only saved
|
|
66
|
+
// once the app itself accepts it (catches "confirmed before login finished").
|
|
67
|
+
async function verifySnapshot(browser, page, url) {
|
|
60
68
|
try {
|
|
61
|
-
|
|
62
|
-
await
|
|
63
|
-
const snap = await snapshotSession(browser, page, url.hostname);
|
|
64
|
-
return snap ?? fallback;
|
|
69
|
+
await page.goto(url.href, { waitUntil: 'networkidle2', timeout: 20000 });
|
|
70
|
+
return await snapshotSession(browser, page, url.hostname);
|
|
65
71
|
}
|
|
66
72
|
catch {
|
|
67
|
-
return
|
|
73
|
+
return null;
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
let url;
|
|
77
|
-
try {
|
|
78
|
-
url = new URL(urlArg);
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
console.error(`Invalid URL: ${urlArg}`);
|
|
82
|
-
process.exit(1);
|
|
76
|
+
function saveSnapshot(url, snap, how) {
|
|
77
|
+
if (isLoginUrl(snap.finalUrl)) {
|
|
78
|
+
fail('session not accepted', [
|
|
79
|
+
`Opening ${url.href} still redirected to a login page: ${snap.finalUrl}`,
|
|
80
|
+
'Login may not have finished (a second factor or enrolment step still pending?).',
|
|
81
|
+
]);
|
|
83
82
|
}
|
|
83
|
+
const seed = {
|
|
84
|
+
cookies: snap.cookies,
|
|
85
|
+
...(Object.keys(snap.localStorage).length && { localStorage: snap.localStorage }),
|
|
86
|
+
...(Object.keys(snap.sessionStorage).length && { sessionStorage: snap.sessionStorage }),
|
|
87
|
+
};
|
|
88
|
+
const savedPath = saveStoredAuth(url.href, seed);
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(`✓ Logged in (${how}); ${snap.cookies.length} cookies, ${Object.keys(snap.localStorage).length} localStorage entries`);
|
|
91
|
+
console.log(`✓ Saved to ${savedPath}. Visual now uses it for every page on ${url.origin}.`);
|
|
92
|
+
console.log(`TDA_AUTH_SAVED ${url.origin}`);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
async function launch(opts) {
|
|
84
96
|
const chromePath = findChromeExecutable();
|
|
85
|
-
if (!chromePath)
|
|
86
|
-
|
|
87
|
-
process.exit(1);
|
|
88
|
-
}
|
|
97
|
+
if (!chromePath)
|
|
98
|
+
fail('no browser found', ['Install Google Chrome (or Edge, Brave, Chromium), or set CHROME_PATH.']);
|
|
89
99
|
const puppeteer = (await import('puppeteer-core')).default;
|
|
90
|
-
|
|
91
|
-
console.log(`Opening ${url.href} in a browser window…`);
|
|
92
|
-
const browser = await puppeteer.launch({
|
|
100
|
+
return puppeteer.launch({
|
|
93
101
|
executablePath: chromePath,
|
|
94
|
-
headless:
|
|
102
|
+
headless: opts.headless,
|
|
95
103
|
defaultViewport: { width: 1440, height: 900 },
|
|
104
|
+
// A dedicated profile keeps "remember me" and SSO sessions between
|
|
105
|
+
// captures, so re-capturing after expiry is often instant.
|
|
106
|
+
...(opts.persistent && { userDataDir: BROWSER_PROFILE_DIR }),
|
|
96
107
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
97
108
|
});
|
|
109
|
+
}
|
|
110
|
+
// ── capture ──────────────────────────────────────────────────────────────────
|
|
111
|
+
function waitForEnter() {
|
|
112
|
+
return new Promise(resolve => {
|
|
113
|
+
if (!process.stdin.isTTY)
|
|
114
|
+
return; // backgrounded by an agent: rely on auto-detect / close
|
|
115
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
116
|
+
rl.once('line', () => { rl.close(); resolve('enter'); });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function capture(url) {
|
|
120
|
+
const hadSavedSession = existsSync(authFilePath(url.href));
|
|
121
|
+
const browser = await launch({ headless: process.env.TDA_CAPTURE_HEADLESS === '1', persistent: true });
|
|
98
122
|
const [page] = await browser.pages();
|
|
99
|
-
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => { });
|
|
124
|
+
console.log('▌TheDesignAgent▐ login capture');
|
|
125
|
+
console.log(`→ A browser window opened at ${url.href}. Log in to your app there.`);
|
|
126
|
+
console.log('→ It saves and closes by itself once you reach a logged-in page.');
|
|
127
|
+
if (process.stdin.isTTY)
|
|
128
|
+
console.log(' (Or press Enter here, or close the window, when you are done.)');
|
|
129
|
+
console.log('');
|
|
130
|
+
// Logged in = we saw a login page on this origin and have now left it (the
|
|
131
|
+
// page is back on the app, off any login path) for two polls in a row.
|
|
132
|
+
// If the dedicated profile was already logged in and the app loads straight
|
|
133
|
+
// away, that counts too when a session was saved before (a re-capture).
|
|
134
|
+
let sawLogin = isLoginUrl(page.url());
|
|
135
|
+
let stableOffLogin = 0;
|
|
136
|
+
const detected = new Promise(resolve => {
|
|
137
|
+
const timer = setInterval(() => {
|
|
138
|
+
let current;
|
|
139
|
+
try {
|
|
140
|
+
current = new URL(page.url());
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const onApp = current.origin === url.origin;
|
|
146
|
+
if (onApp && isLoginUrl(current.href)) {
|
|
147
|
+
sawLogin = true;
|
|
148
|
+
stableOffLogin = 0;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (onApp && (sawLogin || hadSavedSession))
|
|
152
|
+
stableOffLogin++;
|
|
153
|
+
else
|
|
154
|
+
stableOffLogin = 0;
|
|
155
|
+
if (stableOffLogin >= 2) {
|
|
156
|
+
clearInterval(timer);
|
|
157
|
+
resolve('detected');
|
|
158
|
+
}
|
|
159
|
+
}, POLL_MS);
|
|
160
|
+
browser.on('disconnected', () => clearInterval(timer));
|
|
161
|
+
});
|
|
102
162
|
let lastSnapshot = null;
|
|
103
|
-
const
|
|
163
|
+
const snapTimer = setInterval(async () => {
|
|
104
164
|
const snap = await snapshotSession(browser, page, url.hostname);
|
|
105
165
|
if (snap)
|
|
106
166
|
lastSnapshot = snap;
|
|
107
|
-
},
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
console.log(interactiveHint);
|
|
114
|
-
console.log('');
|
|
115
|
-
const trigger = await Promise.race([waitForEnter(), waitForBrowserClose(browser)]);
|
|
116
|
-
clearInterval(pollInterval);
|
|
117
|
-
const verifiedSnapshot = await verifyAndSnapshot(browser, page, url, trigger, lastSnapshot);
|
|
118
|
-
if (trigger !== 'closed') {
|
|
167
|
+
}, POLL_MS);
|
|
168
|
+
const closed = new Promise(resolve => browser.on('disconnected', () => resolve('closed')));
|
|
169
|
+
const timedOut = new Promise(resolve => setTimeout(() => resolve('timeout'), CAPTURE_TIMEOUT_MS).unref());
|
|
170
|
+
const trigger = await Promise.race([detected, waitForEnter(), closed, timedOut]);
|
|
171
|
+
clearInterval(snapTimer);
|
|
172
|
+
if (trigger === 'timeout') {
|
|
119
173
|
try {
|
|
120
174
|
await browser.close();
|
|
121
175
|
}
|
|
122
|
-
catch {
|
|
176
|
+
catch { }
|
|
177
|
+
fail('timed out waiting for login', [`No login completed within ${Math.round(CAPTURE_TIMEOUT_MS / 60000)} minutes. Run it again when ready.`]);
|
|
123
178
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
179
|
+
const snap = trigger === 'closed' ? lastSnapshot : (await verifySnapshot(browser, page, url)) ?? lastSnapshot;
|
|
180
|
+
try {
|
|
181
|
+
await browser.close();
|
|
182
|
+
}
|
|
183
|
+
catch { }
|
|
184
|
+
if (!snap)
|
|
185
|
+
fail('no session captured', ['The window closed before a logged-in page loaded. Run it again.']);
|
|
186
|
+
saveSnapshot(url, snap, trigger === 'detected' ? 'detected automatically' : trigger === 'enter' ? 'Enter pressed' : 'window closed');
|
|
187
|
+
}
|
|
188
|
+
// ── login (from a recipe) ────────────────────────────────────────────────────
|
|
189
|
+
// Stream stdin: readFileSync(0) throws EAGAIN on a pipe whose writer hasn't
|
|
190
|
+
// finished yet (e.g. a recipe still waiting on a network call).
|
|
191
|
+
function readStdin() {
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
let data = '';
|
|
194
|
+
process.stdin.setEncoding('utf8');
|
|
195
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
196
|
+
process.stdin.on('end', () => resolve(data));
|
|
197
|
+
process.stdin.on('error', reject);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async function login(url) {
|
|
201
|
+
if (process.stdin.isTTY)
|
|
202
|
+
fail('nothing on stdin', ['Pipe a login recipe into this command, e.g. node .thedesignagent-login.mjs | thedesignagent-auth login ' + url.origin]);
|
|
203
|
+
const input = (await readStdin()).trim();
|
|
204
|
+
if (!input)
|
|
205
|
+
fail('empty input', ['The login recipe printed nothing.']);
|
|
206
|
+
let seed = null;
|
|
207
|
+
let loginUrl = null;
|
|
208
|
+
if (input.startsWith('{')) {
|
|
209
|
+
try {
|
|
210
|
+
seed = JSON.parse(input);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
fail('recipe output is not valid JSON');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
loginUrl = input.split('\n')[0].trim();
|
|
218
|
+
try {
|
|
219
|
+
new URL(loginUrl);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
fail('recipe output is neither a URL nor JSON');
|
|
223
|
+
}
|
|
129
224
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
225
|
+
const browser = await launch({ headless: true, persistent: false });
|
|
226
|
+
try {
|
|
227
|
+
const page = await browser.newPage();
|
|
228
|
+
if (seed?.cookies?.length) {
|
|
229
|
+
await page.browserContext().setCookie(...seed.cookies.map(c => ({ ...c, domain: c.domain ?? url.hostname, path: c.path ?? '/' })));
|
|
230
|
+
}
|
|
231
|
+
if (seed?.localStorage || seed?.sessionStorage) {
|
|
232
|
+
await page.evaluateOnNewDocument((ls, ss) => {
|
|
233
|
+
try {
|
|
234
|
+
for (const [k, v] of ls)
|
|
235
|
+
localStorage.setItem(k, v);
|
|
236
|
+
for (const [k, v] of ss)
|
|
237
|
+
sessionStorage.setItem(k, v);
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
}, Object.entries(seed.localStorage ?? {}), Object.entries(seed.sessionStorage ?? {}));
|
|
241
|
+
}
|
|
242
|
+
if (loginUrl) {
|
|
243
|
+
await page.goto(loginUrl, { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => { });
|
|
244
|
+
}
|
|
245
|
+
const snap = await verifySnapshot(browser, page, url);
|
|
246
|
+
await browser.close();
|
|
247
|
+
if (!snap)
|
|
248
|
+
fail('could not load the app', [`Is the dev server running at ${url.origin}?`]);
|
|
249
|
+
saveSnapshot(url, snap, loginUrl ? 'login URL' : 'recipe cookies');
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
try {
|
|
253
|
+
await browser.close();
|
|
254
|
+
}
|
|
255
|
+
catch { }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// ── status ───────────────────────────────────────────────────────────────────
|
|
259
|
+
function status(url) {
|
|
260
|
+
const seed = loadStoredAuth(url.href);
|
|
261
|
+
if (!seed) {
|
|
262
|
+
console.log(`No saved session for ${url.origin}.`);
|
|
263
|
+
console.log(`TDA_AUTH_STATUS none ${url.origin}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const expiries = (seed.cookies ?? []).map(c => c.expires).filter((e) => typeof e === 'number' && e > 0);
|
|
267
|
+
if (!expiries.length) {
|
|
268
|
+
console.log(`Session saved for ${url.origin} (session cookies, no expiry recorded).`);
|
|
269
|
+
console.log(`TDA_AUTH_STATUS saved ${url.origin}`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const soonest = new Date(Math.min(...expiries) * 1000);
|
|
273
|
+
const expired = soonest.getTime() < Date.now();
|
|
274
|
+
console.log(`Session saved for ${url.origin}; ${expired ? 'expired' : 'expires'} ${soonest.toISOString()}.`);
|
|
275
|
+
console.log(`TDA_AUTH_STATUS ${expired ? 'expired' : 'saved'} ${url.origin} ${soonest.toISOString()}`);
|
|
276
|
+
}
|
|
277
|
+
// ── main ─────────────────────────────────────────────────────────────────────
|
|
278
|
+
async function main() {
|
|
279
|
+
const [, , subcommand, urlArg] = process.argv;
|
|
280
|
+
if (!subcommand || !urlArg || !['capture', 'login', 'status'].includes(subcommand)) {
|
|
281
|
+
printUsage();
|
|
141
282
|
process.exit(1);
|
|
142
283
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
console.log(`▌TheDesignAgent▐ will now auto-apply this session for any URL on ${url.origin}.`);
|
|
156
|
-
console.log(`To re-capture (after expiry or logout), run this command again.`);
|
|
157
|
-
process.exit(0);
|
|
284
|
+
let url;
|
|
285
|
+
try {
|
|
286
|
+
url = new URL(urlArg);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
fail(`invalid URL: ${urlArg}`);
|
|
290
|
+
}
|
|
291
|
+
if (subcommand === 'status')
|
|
292
|
+
return status(url);
|
|
293
|
+
if (subcommand === 'login')
|
|
294
|
+
return login(url);
|
|
295
|
+
return capture(url);
|
|
158
296
|
}
|
|
159
|
-
main().catch(err =>
|
|
160
|
-
console.error('auth-capture failed:', err instanceof Error ? err.message : err);
|
|
161
|
-
process.exit(1);
|
|
162
|
-
});
|
|
297
|
+
main().catch(err => fail('auth command crashed', [err instanceof Error ? err.message : String(err)]));
|
package/dist/auth-store.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AuthSeed } from './screenshot.js';
|
|
2
2
|
export declare const AUTH_STORE_DIR: string;
|
|
3
|
+
export declare const BROWSER_PROFILE_DIR: string;
|
|
3
4
|
export declare function authFilePath(url: string): string;
|
|
4
5
|
export declare function loadStoredAuth(url: string): AuthSeed | null;
|
|
5
6
|
export declare function saveStoredAuth(url: string, seed: AuthSeed): string;
|
package/dist/auth-store.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
// Saved sessions are live login cookies: keep them readable by the owner only.
|
|
4
5
|
export const AUTH_STORE_DIR = join(homedir(), '.thedesignagent', 'auth');
|
|
6
|
+
export const BROWSER_PROFILE_DIR = join(homedir(), '.thedesignagent', 'browser-profile');
|
|
5
7
|
function safeFilename(origin) {
|
|
6
8
|
return origin.replace(/^https?:\/\//, '').replace(/[^a-z0-9.-]/gi, '_') + '.json';
|
|
7
9
|
}
|
|
@@ -20,8 +22,10 @@ export function loadStoredAuth(url) {
|
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
export function saveStoredAuth(url, seed) {
|
|
23
|
-
mkdirSync(AUTH_STORE_DIR, { recursive: true });
|
|
25
|
+
mkdirSync(AUTH_STORE_DIR, { recursive: true, mode: 0o700 });
|
|
26
|
+
chmodSync(AUTH_STORE_DIR, 0o700);
|
|
24
27
|
const path = authFilePath(url);
|
|
25
|
-
writeFileSync(path, JSON.stringify(seed, null, 2));
|
|
28
|
+
writeFileSync(path, JSON.stringify(seed, null, 2), { mode: 0o600 });
|
|
29
|
+
chmodSync(path, 0o600);
|
|
26
30
|
return path;
|
|
27
31
|
}
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
24
24
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
25
25
|
import Anthropic from '@anthropic-ai/sdk';
|
|
26
26
|
import { z } from 'zod';
|
|
27
|
+
import { existsSync } from 'node:fs';
|
|
27
28
|
import { dirname, join } from 'node:path';
|
|
28
29
|
import { captureScreenshot } from './screenshot.js';
|
|
29
30
|
import { loadStoredAuth } from './auth-store.js';
|
|
@@ -111,13 +112,24 @@ async function runFallback(task, artifact, codebaseContext) {
|
|
|
111
112
|
? `\n\n## Codebase Context\n${codebaseContext.slice(0, 3000)}`
|
|
112
113
|
: '';
|
|
113
114
|
const userMessage = `## Task\n${task}\n\n## Artifact to Evaluate\n${artifact}${contextSection}\n\nReturn ONLY valid JSON matching the schema in the system prompt.`;
|
|
115
|
+
// Opus 5.5 always thinks: thinking shares max_tokens, and the reply is not
|
|
116
|
+
// content[0]. `fallbacks` (not yet in the SDK types) re-runs a safety
|
|
117
|
+
// classifier decline on Anthropic's recommended model within the same call.
|
|
114
118
|
const response = await client.messages.create({
|
|
115
|
-
model: 'claude-opus-
|
|
116
|
-
max_tokens:
|
|
119
|
+
model: 'claude-opus-5-5',
|
|
120
|
+
max_tokens: 16000,
|
|
121
|
+
output_config: { effort: 'medium' },
|
|
117
122
|
system: FALLBACK_SYSTEM_PROMPT,
|
|
118
123
|
messages: [{ role: 'user', content: userMessage }],
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
fallbacks: 'default',
|
|
125
|
+
}, { headers: { 'anthropic-beta': 'server-side-fallback-2026-07-01' } });
|
|
126
|
+
if (response.stop_reason === 'refusal') {
|
|
127
|
+
throw new Error('Fallback Claude declined the request');
|
|
128
|
+
}
|
|
129
|
+
const text = response.content
|
|
130
|
+
.filter((b) => b.type === 'text')
|
|
131
|
+
.map((b) => b.text)
|
|
132
|
+
.join('');
|
|
121
133
|
const jsonStr = text.replace(/^```(?:json)?\n?/m, '').replace(/\n?```$/m, '').trim();
|
|
122
134
|
try {
|
|
123
135
|
const parsed = JSON.parse(jsonStr);
|
|
@@ -218,43 +230,43 @@ async function tryCaptureScreenshot(url, authSeed) {
|
|
|
218
230
|
return { screenshotError, authSeedSource: source };
|
|
219
231
|
}
|
|
220
232
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
233
|
+
// The login recipe a repo can provide (written by the plugin's setup flow).
|
|
234
|
+
// It prints either a URL that logs in when opened or an AuthSeed JSON.
|
|
235
|
+
const LOGIN_RECIPE = '.thedesignagent-login.mjs';
|
|
236
|
+
// Run the auth CLI that ships alongside this server, so its version always matches.
|
|
237
|
+
// Pass the page being reviewed, not just the origin: many apps 404 or
|
|
238
|
+
// redirect at the root, and the session is saved per origin either way.
|
|
239
|
+
function authCmd(sub, pageUrl) {
|
|
240
|
+
const authCapturePath = join(dirname(process.argv[1]), 'auth-capture.js');
|
|
241
|
+
return `node "${authCapturePath}" ${sub} ${pageUrl}`;
|
|
230
242
|
}
|
|
231
243
|
function buildAuthRequiredFooter(renderUrl, capture) {
|
|
232
244
|
const { screenshotPath, finalUrl, authSeedSource } = capture;
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
245
|
+
const origin = (() => { try {
|
|
246
|
+
return new URL(renderUrl).origin;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return renderUrl;
|
|
250
|
+
} })();
|
|
251
|
+
const stateLine = authSeedSource === 'stored'
|
|
252
|
+
? `A saved session for ${origin} existed but has expired.`
|
|
253
|
+
: `No saved session for ${origin} yet.`;
|
|
254
|
+
const hasRecipe = existsSync(join(process.cwd(), LOGIN_RECIPE));
|
|
237
255
|
const lines = [
|
|
238
256
|
`---`,
|
|
239
|
-
`🛑 ▌TheDesignAgent▐ —
|
|
257
|
+
`🛑 ▌TheDesignAgent▐ — page is behind login. No review ran and no credits were charged.`,
|
|
240
258
|
``,
|
|
241
|
-
|
|
242
|
-
``,
|
|
243
|
-
`${stateLine} The user must run this exact command in their own terminal:`,
|
|
244
|
-
``,
|
|
245
|
-
` ${suggestedAuthCmd(renderUrl)}`,
|
|
259
|
+
stateLine,
|
|
246
260
|
``,
|
|
247
261
|
`WHAT TO DO NEXT:`,
|
|
248
|
-
`1. Surface the command above to the user verbatim.`,
|
|
249
|
-
`2. Tell them: a Chrome window will open — log in normally, then close it (or press Enter in the terminal).`,
|
|
250
|
-
`3. Wait for them to confirm they've run it.`,
|
|
251
|
-
`4. Then retry this visual call with the same render_url. Auth will be auto-applied.`,
|
|
252
|
-
``,
|
|
253
|
-
`WHAT NOT TO DO:`,
|
|
254
|
-
`- Do not search the app's source for auth keys, JWT names, or localStorage tokens.`,
|
|
255
|
-
`- Do not attempt to construct auth_seed manually — it almost always fails (HttpOnly cookies are invisible to code search).`,
|
|
256
|
-
`- Do not run the capture command yourself in the background — it needs the user's interactive login.`,
|
|
257
262
|
];
|
|
263
|
+
if (hasRecipe) {
|
|
264
|
+
lines.push(`1. This repo has a login recipe. Run it (from the repo root):`, ` node ${LOGIN_RECIPE} | ${authCmd('login', renderUrl)}`, `2. When it prints TDA_AUTH_SAVED, retry this Visual call with the same render_url.`, `3. If it prints TDA_AUTH_FAILED, fall back to the manual capture below.`, ``, `Manual capture (fallback):`);
|
|
265
|
+
}
|
|
266
|
+
lines.push(`${hasRecipe ? '' : '1. '}Run this yourself, in the background (it opens a browser window for the user):`, ` ${authCmd('capture', renderUrl)}`, ` Tell the user: "A browser window opened at ${origin}. Log in there; it saves and closes by itself."`, ` Wait for the command to finish. It prints TDA_AUTH_SAVED when done (or TDA_AUTH_FAILED with a reason; it gives up after 10 minutes).`, `${hasRecipe ? '' : '2. '}Then retry this Visual call once with the same render_url. The session is applied automatically.`, ``, `WHAT NOT TO DO:`, `- Do not search the app's source for auth keys, JWT names, or localStorage tokens.`, `- Do not construct auth_seed by hand. HttpOnly cookies are invisible to code search, so it almost always fails.`);
|
|
267
|
+
if (!hasRecipe) {
|
|
268
|
+
lines.push(``, `To make this automatic next time, the plugin's setup flow can write a ${LOGIN_RECIPE} for this repo.`);
|
|
269
|
+
}
|
|
258
270
|
if (finalUrl) {
|
|
259
271
|
lines.push(``, `(Diagnostics: redirected to ${finalUrl})`);
|
|
260
272
|
}
|
|
@@ -418,6 +430,12 @@ async function handleVisualCall(args) {
|
|
|
418
430
|
log('info', 'pipeline_call', `tool=visual project_id=${args.project_id ?? 'none'} task_len=${args.task.length} render_url=${args.render_url ?? 'none'}`);
|
|
419
431
|
const capture = await tryCaptureScreenshot(args.render_url, args.auth_seed);
|
|
420
432
|
const { screenshot } = capture;
|
|
433
|
+
// Landed on a login page: stop before the pipeline. Evaluating the login
|
|
434
|
+
// form would cost credits and produce findings the agent must discard.
|
|
435
|
+
if (capture.authRequired && args.render_url) {
|
|
436
|
+
log('info', 'visual_auth_required', `render_url=${args.render_url} final_url=${capture.finalUrl ?? 'unknown'}`);
|
|
437
|
+
return { content: [{ type: 'text', text: buildAuthRequiredFooter(args.render_url, capture) }] };
|
|
438
|
+
}
|
|
421
439
|
try {
|
|
422
440
|
const response = await fetch(`${API_BASE}/visual`, {
|
|
423
441
|
method: 'POST',
|
|
@@ -516,7 +534,7 @@ const evalInputSchema = {
|
|
|
516
534
|
repo_hash: z.string().optional().describe('SHA256 of git remote origin URL (fallback if no .thedesignagent)'),
|
|
517
535
|
codebase_context: z.string().optional().describe('Optional surrounding code context'),
|
|
518
536
|
};
|
|
519
|
-
const server = new McpServer({ name: 'TheDesignAgent', version: '0.
|
|
537
|
+
const server = new McpServer({ name: 'TheDesignAgent', version: '0.3.1' });
|
|
520
538
|
server.registerTool('Discover', {
|
|
521
539
|
description: `Get a build brief calibrated to your project, persona, and the specific screen you're building.
|
|
522
540
|
|
|
@@ -567,7 +585,7 @@ Call for visual design review after building. TheDesignAgent scores across three
|
|
|
567
585
|
|
|
568
586
|
If render_url is provided, the MCP server takes a real screenshot via headless Chrome and the pipeline runs Vision-based analysis. Otherwise it falls back to text analysis of the artifact code.
|
|
569
587
|
|
|
570
|
-
AUTH-GATED PAGES: If the page is behind login, this tool
|
|
588
|
+
AUTH-GATED PAGES: If the page is behind login, this tool stops before evaluating (no credits charged) and returns the exact commands to save a session: a repo login recipe (.thedesignagent-login.mjs) when one exists, otherwise a browser capture you run in the background while the user logs in (it saves and closes by itself). Follow those instructions, then retry once. DO NOT investigate the app's auth scheme or populate auth_seed by grepping for tokens or localStorage keys; that wastes the user's time and usually doesn't work.
|
|
571
589
|
|
|
572
590
|
Screenshots are saved to ~/.thedesignagent/screenshots/ (24h TTL) so you can open them to see exactly what was evaluated.
|
|
573
591
|
|
package/dist/screenshot.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thedesignagent/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "MCP server for TheDesignAgent — UX and visual judgment layer for agent-generated UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"prepublishOnly": "npm run build"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@anthropic-ai/sdk": "^0.
|
|
38
|
+
"@anthropic-ai/sdk": "^0.92.0",
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
40
40
|
"puppeteer-core": "^23.11.1",
|
|
41
41
|
"zod": "^3.22.0"
|