@thedesignagent/mcp 0.2.5 → 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 CHANGED
@@ -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
- Capture a logged-in session once per app:
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
- A browser opens; log in, then press Enter (or close the browser). The session is saved under `~/.thedesignagent/auth/` and reused for that origin. When `Visual` hits a login redirect, it tells the agent to ask you to run this command.
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
 
@@ -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: thedesignagent-auth capture <url>');
7
- console.error('Example: thedesignagent-auth capture http://localhost:3000');
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
- function waitForEnter() {
39
- return new Promise(resolve => {
40
- if (!process.stdin.isTTY) {
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
- console.log('Verifying session…');
62
- await page.goto(url.href, { waitUntil: 'networkidle0', timeout: 15000 });
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 fallback;
73
+ return null;
68
74
  }
69
75
  }
70
- async function main() {
71
- const [, , subcommand, urlArg] = process.argv;
72
- if (subcommand !== 'capture' || !urlArg) {
73
- printUsage();
74
- process.exit(1);
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
- console.error('Chrome/Chromium not found. Install Google Chrome (or set CHROME_PATH).');
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
- console.log(`▌TheDesignAgent▐ auth capture`);
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: false,
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
- // Continuously snapshot the session as the user navigates, so we always have
101
- // a recent capture even if the browser is closed before they confirm.
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 pollInterval = setInterval(async () => {
163
+ const snapTimer = setInterval(async () => {
104
164
  const snap = await snapshotSession(browser, page, url.hostname);
105
165
  if (snap)
106
166
  lastSnapshot = snap;
107
- }, 2000);
108
- const interactiveHint = process.stdin.isTTY
109
- ? '→ When you\'re logged in, press Enter in your terminal OR close the browser window.'
110
- : '→ When you\'re logged in, close the browser window. (No TTY detected.)';
111
- console.log('');
112
- console.log('→ Log in to your app in the browser window that just opened.');
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 { /* already closing */ }
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
- if (!verifiedSnapshot) {
125
- console.error('');
126
- console.error('✗ No session captured — browser may have closed before login completed.');
127
- console.error(' Re-run the command and log in before closing the window.');
128
- process.exit(1);
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
- if (isLoginUrl(verifiedSnapshot.finalUrl)) {
131
- console.error('');
132
- console.error('✗ Session not saved — after capture, navigating to the target URL still');
133
- console.error(' redirected to a login page. This usually means:');
134
- console.error(' • Login was not fully completed (2FA still pending? form not submitted?)');
135
- console.error(' • You confirmed before reaching a logged-in page');
136
- console.error('');
137
- console.error(` Final URL after verification: ${verifiedSnapshot.finalUrl}`);
138
- console.error('');
139
- console.error(' Re-run the command. Make sure you reach a logged-in page in the');
140
- console.error(' browser before pressing Enter or closing the window.');
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
- const seed = {
144
- cookies: verifiedSnapshot.cookies,
145
- ...(Object.keys(verifiedSnapshot.localStorage).length && { localStorage: verifiedSnapshot.localStorage }),
146
- ...(Object.keys(verifiedSnapshot.sessionStorage).length && { sessionStorage: verifiedSnapshot.sessionStorage }),
147
- };
148
- const savedPath = saveStoredAuth(url.href, seed);
149
- console.log('');
150
- console.log(`✓ Trigger: ${trigger === 'closed' ? 'browser closed' : 'Enter pressed'}`);
151
- console.log(`✓ Captured ${verifiedSnapshot.cookies.length} cookies, ${Object.keys(verifiedSnapshot.localStorage).length} localStorage entries, ${Object.keys(verifiedSnapshot.sessionStorage).length} sessionStorage entries`);
152
- console.log(`✓ Final URL: ${verifiedSnapshot.finalUrl}`);
153
- console.log(`✓ Saved to: ${savedPath}`);
154
- console.log('');
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)]));
@@ -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;
@@ -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-4-7',
116
- max_tokens: 4000,
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
- const text = response.content[0].type === 'text' ? response.content[0].text : '';
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
- function suggestedAuthCmd(renderUrl) {
222
- try {
223
- const origin = new URL(renderUrl).origin;
224
- const authCapturePath = join(dirname(process.argv[1]), 'auth-capture.js');
225
- return `node ${authCapturePath} capture ${origin}`;
226
- }
227
- catch {
228
- return 'thedesignagent-auth capture <your-app-url>';
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 expired = authSeedSource === 'stored';
234
- const stateLine = expired
235
- ? `A saved session for this origin existed but appears expired.`
236
- : `No saved session for this origin yet.`;
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▐ — ACTION REQUIRED: page is behind authentication`,
257
+ `🛑 ▌TheDesignAgent▐ — page is behind login. No review ran and no credits were charged.`,
240
258
  ``,
241
- `The visual eval ran against a login page, not the target screen. The findings above describe the login form and should be DISCARDED.`,
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.2.5' });
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 will detect the redirect and return an auth-required response with a one-line command for the user to run (\`thedesignagent-auth capture <origin>\`). When you receive this response: STOP, surface that exact command to the user, and ask them to retry once they've run it. DO NOT investigate the app's auth scheme. DO NOT attempt to populate auth_seed by grepping for tokens or localStorage keys. The capture command handles all of that automatically; trying to construct auth_seed yourself wastes the user's time and usually doesn't work.
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
 
@@ -7,6 +7,7 @@ export interface AuthSeedCookie {
7
7
  httpOnly?: boolean;
8
8
  secure?: boolean;
9
9
  sameSite?: 'Strict' | 'Lax' | 'None';
10
+ expires?: number;
10
11
  }
11
12
  export interface AuthSeed {
12
13
  cookies?: AuthSeedCookie[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thedesignagent/mcp",
3
- "version": "0.2.5",
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.36.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"