eyeballs-cli 0.1.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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +144 -0
- package/dist/cli.js.map +1 -0
- package/dist/core.d.ts +67 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +301 -0
- package/dist/core.js.map +1 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +201 -0
- package/dist/mcp.js.map +1 -0
- package/package.json +51 -0
- package/src/cli.ts +172 -0
- package/src/core.ts +438 -0
- package/src/mcp.ts +243 -0
- package/src/types/pixelmatch.d.ts +20 -0
- package/tsconfig.json +19 -0
package/src/core.ts
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { chromium, type Browser, type Page } from 'playwright';
|
|
2
|
+
import pixelmatch from 'pixelmatch';
|
|
3
|
+
import { PNG, type PNGWithMetadata } from 'pngjs';
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
|
|
9
|
+
// --- Types ---
|
|
10
|
+
|
|
11
|
+
export interface Viewport {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Region {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CaptureResult {
|
|
24
|
+
buffer: Buffer;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
loadTimeMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DiffResult {
|
|
31
|
+
changed: boolean;
|
|
32
|
+
diffPercent: number;
|
|
33
|
+
threshold: number;
|
|
34
|
+
diffBuffer?: Buffer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Watch {
|
|
38
|
+
id: string;
|
|
39
|
+
url: string;
|
|
40
|
+
viewport: Viewport;
|
|
41
|
+
mode: 'pixel';
|
|
42
|
+
config: {
|
|
43
|
+
threshold: number;
|
|
44
|
+
region: Region | null;
|
|
45
|
+
};
|
|
46
|
+
baselinePath: string;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
lastCheckAt: string | null;
|
|
49
|
+
lastDiffPercent: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WatchesFile {
|
|
53
|
+
watches: Watch[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CheckResult {
|
|
57
|
+
changed: boolean;
|
|
58
|
+
diffPercent: number;
|
|
59
|
+
threshold: number;
|
|
60
|
+
message: string;
|
|
61
|
+
screenshotBuffer: Buffer;
|
|
62
|
+
diffBuffer?: Buffer;
|
|
63
|
+
watch: Watch;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Error types ---
|
|
67
|
+
|
|
68
|
+
export type EyeballsErrorCode =
|
|
69
|
+
| 'TIMEOUT'
|
|
70
|
+
| 'LOAD_FAILED'
|
|
71
|
+
| 'INVALID_URL'
|
|
72
|
+
| 'BROWSER_NOT_INSTALLED'
|
|
73
|
+
| 'BROWSER_VERSION_MISMATCH'
|
|
74
|
+
| 'BROWSER_LAUNCH_FAILED'
|
|
75
|
+
| 'STORAGE_FAILED'
|
|
76
|
+
| 'NOT_FOUND';
|
|
77
|
+
|
|
78
|
+
export class EyeballsError extends Error {
|
|
79
|
+
constructor(
|
|
80
|
+
public code: EyeballsErrorCode,
|
|
81
|
+
message: string,
|
|
82
|
+
) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = 'EyeballsError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Storage ---
|
|
89
|
+
|
|
90
|
+
const EYEBALLS_DIR = join(homedir(), '.eyeballs');
|
|
91
|
+
const SCREENSHOTS_DIR = join(EYEBALLS_DIR, 'screenshots');
|
|
92
|
+
const WATCHES_FILE = join(EYEBALLS_DIR, 'watches.json');
|
|
93
|
+
|
|
94
|
+
function ensureDirs(): void {
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(EYEBALLS_DIR, { recursive: true });
|
|
97
|
+
mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
98
|
+
} catch {
|
|
99
|
+
throw new EyeballsError('STORAGE_FAILED', 'Could not create ~/.eyeballs directory');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function loadWatches(): WatchesFile {
|
|
104
|
+
ensureDirs();
|
|
105
|
+
if (!existsSync(WATCHES_FILE)) {
|
|
106
|
+
return { watches: [] };
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const raw = readFileSync(WATCHES_FILE, 'utf-8');
|
|
110
|
+
return JSON.parse(raw) as WatchesFile;
|
|
111
|
+
} catch {
|
|
112
|
+
return { watches: [] };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function saveWatches(data: WatchesFile): void {
|
|
117
|
+
ensureDirs();
|
|
118
|
+
try {
|
|
119
|
+
writeFileSync(WATCHES_FILE, JSON.stringify(data, null, 2));
|
|
120
|
+
} catch {
|
|
121
|
+
throw new EyeballsError('STORAGE_FAILED', 'Could not write watches.json');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function saveScreenshot(id: string, suffix: string, buffer: Buffer): string {
|
|
126
|
+
ensureDirs();
|
|
127
|
+
const filename = `${id}-${suffix}.png`;
|
|
128
|
+
const filepath = join(SCREENSHOTS_DIR, filename);
|
|
129
|
+
try {
|
|
130
|
+
writeFileSync(filepath, buffer);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new EyeballsError('STORAGE_FAILED', 'Could not write screenshot');
|
|
133
|
+
}
|
|
134
|
+
return filepath;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Browser ---
|
|
138
|
+
|
|
139
|
+
let browser: Browser | null = null;
|
|
140
|
+
|
|
141
|
+
async function getBrowser(): Promise<Browser> {
|
|
142
|
+
if (browser && browser.isConnected()) {
|
|
143
|
+
return browser;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
browser = await chromium.launch();
|
|
147
|
+
return browser;
|
|
148
|
+
} catch (err: unknown) {
|
|
149
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
150
|
+
if (msg.includes('Executable doesn\'t exist') || msg.includes('browserType.launch')) {
|
|
151
|
+
if (msg.includes('Chromium') && msg.includes('revision')) {
|
|
152
|
+
throw new EyeballsError(
|
|
153
|
+
'BROWSER_VERSION_MISMATCH',
|
|
154
|
+
'Chromium version mismatch. Run: npx playwright install chromium',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
throw new EyeballsError(
|
|
158
|
+
'BROWSER_NOT_INSTALLED',
|
|
159
|
+
'Run: npx playwright install chromium',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
throw new EyeballsError('BROWSER_LAUNCH_FAILED', `Chromium cannot start: ${msg}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function shutdownBrowser(): Promise<void> {
|
|
167
|
+
if (browser) {
|
|
168
|
+
await browser.close().catch(() => {});
|
|
169
|
+
browser = null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Cleanup on exit
|
|
174
|
+
process.on('exit', () => {
|
|
175
|
+
browser?.close().catch(() => {});
|
|
176
|
+
});
|
|
177
|
+
process.on('SIGINT', async () => {
|
|
178
|
+
await shutdownBrowser();
|
|
179
|
+
process.exit(0);
|
|
180
|
+
});
|
|
181
|
+
process.on('SIGTERM', async () => {
|
|
182
|
+
await shutdownBrowser();
|
|
183
|
+
process.exit(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// --- URL validation ---
|
|
187
|
+
|
|
188
|
+
function validateUrl(url: string): void {
|
|
189
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
190
|
+
throw new EyeballsError('INVALID_URL', 'URL must start with http:// or https://');
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
new URL(url);
|
|
194
|
+
} catch {
|
|
195
|
+
throw new EyeballsError('INVALID_URL', `Invalid URL: ${url}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- Capture ---
|
|
200
|
+
|
|
201
|
+
export async function capture(
|
|
202
|
+
url: string,
|
|
203
|
+
viewport: Viewport = { width: 1280, height: 720 },
|
|
204
|
+
): Promise<CaptureResult> {
|
|
205
|
+
validateUrl(url);
|
|
206
|
+
|
|
207
|
+
const b = await getBrowser();
|
|
208
|
+
const context = await b.newContext({ viewport });
|
|
209
|
+
const page: Page = await context.newPage();
|
|
210
|
+
|
|
211
|
+
const start = Date.now();
|
|
212
|
+
try {
|
|
213
|
+
const response = await page.goto(url, {
|
|
214
|
+
waitUntil: 'networkidle',
|
|
215
|
+
timeout: 30000,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
if (response) {
|
|
219
|
+
const status = response.status();
|
|
220
|
+
if (status >= 400) {
|
|
221
|
+
throw new EyeballsError('LOAD_FAILED', `Page returned ${status}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Wait for paint to settle
|
|
226
|
+
await page.waitForTimeout(2000);
|
|
227
|
+
|
|
228
|
+
const buffer = await page.screenshot({ type: 'png' });
|
|
229
|
+
const loadTimeMs = Date.now() - start;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
buffer: Buffer.from(buffer),
|
|
233
|
+
width: viewport.width,
|
|
234
|
+
height: viewport.height,
|
|
235
|
+
loadTimeMs,
|
|
236
|
+
};
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err instanceof EyeballsError) throw err;
|
|
239
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
240
|
+
if (msg.includes('Timeout') || msg.includes('timeout')) {
|
|
241
|
+
throw new EyeballsError('TIMEOUT', 'Page failed to load within 30s');
|
|
242
|
+
}
|
|
243
|
+
throw new EyeballsError('LOAD_FAILED', `Failed to load page: ${msg}`);
|
|
244
|
+
} finally {
|
|
245
|
+
await page.close().catch(() => {});
|
|
246
|
+
await context.close().catch(() => {});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- Diff ---
|
|
251
|
+
|
|
252
|
+
export function diff(
|
|
253
|
+
baseline: Buffer,
|
|
254
|
+
current: Buffer,
|
|
255
|
+
threshold: number = 5,
|
|
256
|
+
region?: Region | null,
|
|
257
|
+
): DiffResult {
|
|
258
|
+
let img1: PNGWithMetadata | PNG = PNG.sync.read(baseline);
|
|
259
|
+
let img2: PNGWithMetadata | PNG = PNG.sync.read(current);
|
|
260
|
+
|
|
261
|
+
// Region crop if specified
|
|
262
|
+
if (region) {
|
|
263
|
+
img1 = cropPng(img1, region);
|
|
264
|
+
img2 = cropPng(img2, region);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Ensure same dimensions
|
|
268
|
+
if (img1.width !== img2.width || img1.height !== img2.height) {
|
|
269
|
+
return {
|
|
270
|
+
changed: true,
|
|
271
|
+
diffPercent: 100,
|
|
272
|
+
threshold,
|
|
273
|
+
diffBuffer: undefined,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const { width, height } = img1;
|
|
278
|
+
const diffPng = new PNG({ width, height });
|
|
279
|
+
|
|
280
|
+
const numDiffPixels = pixelmatch(
|
|
281
|
+
img1.data as unknown as Uint8Array,
|
|
282
|
+
img2.data as unknown as Uint8Array,
|
|
283
|
+
diffPng.data as unknown as Uint8Array,
|
|
284
|
+
width,
|
|
285
|
+
height,
|
|
286
|
+
{ threshold: 0.1 },
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const totalPixels = width * height;
|
|
290
|
+
const diffPercent = (numDiffPixels / totalPixels) * 100;
|
|
291
|
+
const changed = diffPercent > threshold;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
changed,
|
|
295
|
+
diffPercent: Math.round(diffPercent * 100) / 100,
|
|
296
|
+
threshold,
|
|
297
|
+
diffBuffer: changed ? Buffer.from(PNG.sync.write(diffPng)) : undefined,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function cropPng(png: PNG, region: Region): PNG {
|
|
302
|
+
const cropped = new PNG({ width: region.width, height: region.height });
|
|
303
|
+
for (let y = 0; y < region.height; y++) {
|
|
304
|
+
for (let x = 0; x < region.width; x++) {
|
|
305
|
+
const srcIdx = ((region.y + y) * png.width + (region.x + x)) << 2;
|
|
306
|
+
const dstIdx = (y * region.width + x) << 2;
|
|
307
|
+
cropped.data[dstIdx] = png.data[srcIdx];
|
|
308
|
+
cropped.data[dstIdx + 1] = png.data[srcIdx + 1];
|
|
309
|
+
cropped.data[dstIdx + 2] = png.data[srcIdx + 2];
|
|
310
|
+
cropped.data[dstIdx + 3] = png.data[srcIdx + 3];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return cropped;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- Check URL (baseline + diff) ---
|
|
317
|
+
|
|
318
|
+
export async function checkUrl(options: {
|
|
319
|
+
url: string;
|
|
320
|
+
viewport?: Viewport;
|
|
321
|
+
threshold?: number;
|
|
322
|
+
region?: Region | null;
|
|
323
|
+
resetBaseline?: boolean;
|
|
324
|
+
}): Promise<CheckResult> {
|
|
325
|
+
const { url, viewport = { width: 1280, height: 720 }, threshold = 5, region = null, resetBaseline = false } = options;
|
|
326
|
+
|
|
327
|
+
const data = loadWatches();
|
|
328
|
+
let watch = data.watches.find((w) => w.url === url);
|
|
329
|
+
|
|
330
|
+
// Capture current screenshot
|
|
331
|
+
const result = await capture(url, viewport);
|
|
332
|
+
|
|
333
|
+
// Reset baseline
|
|
334
|
+
if (watch && resetBaseline) {
|
|
335
|
+
const baselinePath = saveScreenshot(watch.id, 'baseline', result.buffer);
|
|
336
|
+
watch.baselinePath = baselinePath;
|
|
337
|
+
watch.viewport = viewport;
|
|
338
|
+
watch.config = { threshold, region };
|
|
339
|
+
watch.lastCheckAt = new Date().toISOString();
|
|
340
|
+
watch.lastDiffPercent = 0;
|
|
341
|
+
saveWatches(data);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
changed: false,
|
|
345
|
+
diffPercent: 0,
|
|
346
|
+
threshold,
|
|
347
|
+
message: 'Baseline updated',
|
|
348
|
+
screenshotBuffer: result.buffer,
|
|
349
|
+
watch,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// No baseline yet, store it
|
|
354
|
+
if (!watch) {
|
|
355
|
+
const id = randomUUID().slice(0, 8);
|
|
356
|
+
const baselinePath = saveScreenshot(id, 'baseline', result.buffer);
|
|
357
|
+
|
|
358
|
+
watch = {
|
|
359
|
+
id,
|
|
360
|
+
url,
|
|
361
|
+
viewport,
|
|
362
|
+
mode: 'pixel',
|
|
363
|
+
config: { threshold, region },
|
|
364
|
+
baselinePath,
|
|
365
|
+
createdAt: new Date().toISOString(),
|
|
366
|
+
lastCheckAt: new Date().toISOString(),
|
|
367
|
+
lastDiffPercent: 0,
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
data.watches.push(watch);
|
|
371
|
+
saveWatches(data);
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
changed: false,
|
|
375
|
+
diffPercent: 0,
|
|
376
|
+
threshold,
|
|
377
|
+
message: 'Baseline captured',
|
|
378
|
+
screenshotBuffer: result.buffer,
|
|
379
|
+
watch,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Diff against baseline
|
|
384
|
+
const baseline = readFileSync(watch.baselinePath);
|
|
385
|
+
const diffResult = diff(baseline, result.buffer, threshold, region);
|
|
386
|
+
|
|
387
|
+
// Save current screenshot
|
|
388
|
+
saveScreenshot(watch.id, 'latest', result.buffer);
|
|
389
|
+
if (diffResult.diffBuffer) {
|
|
390
|
+
saveScreenshot(watch.id, 'diff', diffResult.diffBuffer);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
watch.lastCheckAt = new Date().toISOString();
|
|
394
|
+
watch.lastDiffPercent = diffResult.diffPercent;
|
|
395
|
+
saveWatches(data);
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
changed: diffResult.changed,
|
|
399
|
+
diffPercent: diffResult.diffPercent,
|
|
400
|
+
threshold,
|
|
401
|
+
message: diffResult.changed
|
|
402
|
+
? `Changed: ${diffResult.diffPercent}% pixels differ (threshold: ${threshold}%)`
|
|
403
|
+
: `No change: ${diffResult.diffPercent}% pixels differ (threshold: ${threshold}%)`,
|
|
404
|
+
screenshotBuffer: result.buffer,
|
|
405
|
+
diffBuffer: diffResult.diffBuffer,
|
|
406
|
+
watch,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --- List / Remove watches ---
|
|
411
|
+
|
|
412
|
+
export function listWatches(): Watch[] {
|
|
413
|
+
return loadWatches().watches;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function removeWatch(id: string): void {
|
|
417
|
+
const data = loadWatches();
|
|
418
|
+
const idx = data.watches.findIndex((w) => w.id === id);
|
|
419
|
+
|
|
420
|
+
if (idx === -1) {
|
|
421
|
+
throw new EyeballsError('NOT_FOUND', `Watch ${id} not found`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
data.watches.splice(idx, 1);
|
|
425
|
+
saveWatches(data);
|
|
426
|
+
|
|
427
|
+
// Clean up screenshot files for this watch
|
|
428
|
+
try {
|
|
429
|
+
const files = readdirSync(SCREENSHOTS_DIR);
|
|
430
|
+
for (const file of files) {
|
|
431
|
+
if (file.startsWith(`${id}-`)) {
|
|
432
|
+
unlinkSync(join(SCREENSHOTS_DIR, file));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
// Best effort cleanup
|
|
437
|
+
}
|
|
438
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import {
|
|
10
|
+
capture,
|
|
11
|
+
checkUrl,
|
|
12
|
+
listWatches,
|
|
13
|
+
removeWatch,
|
|
14
|
+
EyeballsError,
|
|
15
|
+
type Viewport,
|
|
16
|
+
type Region,
|
|
17
|
+
} from './core.js';
|
|
18
|
+
|
|
19
|
+
const tools = [
|
|
20
|
+
{
|
|
21
|
+
name: 'screenshot',
|
|
22
|
+
description:
|
|
23
|
+
'Take a screenshot of a web page. Returns the image and metadata (width, height, load time).',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object' as const,
|
|
26
|
+
properties: {
|
|
27
|
+
url: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'URL to screenshot (must start with http:// or https://)',
|
|
30
|
+
},
|
|
31
|
+
viewport: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
description: 'Viewport size (default: 1280x720)',
|
|
34
|
+
properties: {
|
|
35
|
+
width: { type: 'number' },
|
|
36
|
+
height: { type: 'number' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ['url'],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'check_url',
|
|
45
|
+
description:
|
|
46
|
+
'Check a URL for visual changes. On first call, captures a baseline screenshot. On subsequent calls, compares against the baseline and reports the pixel diff percentage. Use reset_baseline to accept the current state as the new baseline.',
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object' as const,
|
|
49
|
+
properties: {
|
|
50
|
+
url: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'URL to check (must start with http:// or https://)',
|
|
53
|
+
},
|
|
54
|
+
viewport: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
description: 'Viewport size (default: 1280x720). Stored with baseline for consistent diffs.',
|
|
57
|
+
properties: {
|
|
58
|
+
width: { type: 'number' },
|
|
59
|
+
height: { type: 'number' },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
threshold: {
|
|
63
|
+
type: 'number',
|
|
64
|
+
description: 'Percentage of pixels that must differ to count as "changed" (default: 5)',
|
|
65
|
+
},
|
|
66
|
+
region: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
description: 'Crop region to compare (exclude headers, ads, etc.)',
|
|
69
|
+
properties: {
|
|
70
|
+
x: { type: 'number' },
|
|
71
|
+
y: { type: 'number' },
|
|
72
|
+
width: { type: 'number' },
|
|
73
|
+
height: { type: 'number' },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
reset_baseline: {
|
|
77
|
+
type: 'boolean',
|
|
78
|
+
description: 'Set to true to accept the current page as the new baseline',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
required: ['url'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'list_watches',
|
|
86
|
+
description: 'List all stored baselines (watched URLs) and their last check status.',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object' as const,
|
|
89
|
+
properties: {},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'remove_watch',
|
|
94
|
+
description: 'Remove a stored baseline and its screenshots.',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: 'object' as const,
|
|
97
|
+
properties: {
|
|
98
|
+
id: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
description: 'The watch ID to remove (from list_watches)',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['id'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
async function handleTool(
|
|
109
|
+
name: string,
|
|
110
|
+
args: Record<string, unknown>,
|
|
111
|
+
): Promise<Array<{ type: string; text?: string; data?: string; mimeType?: string }>> {
|
|
112
|
+
switch (name) {
|
|
113
|
+
case 'screenshot': {
|
|
114
|
+
const result = await capture(
|
|
115
|
+
args.url as string,
|
|
116
|
+
args.viewport as Viewport | undefined,
|
|
117
|
+
);
|
|
118
|
+
const base64 = result.buffer.toString('base64');
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
type: 'image',
|
|
122
|
+
data: base64,
|
|
123
|
+
mimeType: 'image/png',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
type: 'text',
|
|
127
|
+
text: JSON.stringify({
|
|
128
|
+
width: result.width,
|
|
129
|
+
height: result.height,
|
|
130
|
+
loadTimeMs: result.loadTimeMs,
|
|
131
|
+
base64Length: base64.length,
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'check_url': {
|
|
138
|
+
const result = await checkUrl({
|
|
139
|
+
url: args.url as string,
|
|
140
|
+
viewport: args.viewport as Viewport | undefined,
|
|
141
|
+
threshold: args.threshold as number | undefined,
|
|
142
|
+
region: args.region as Region | undefined,
|
|
143
|
+
resetBaseline: args.reset_baseline as boolean | undefined,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = [];
|
|
147
|
+
|
|
148
|
+
// Return the current screenshot
|
|
149
|
+
content.push({
|
|
150
|
+
type: 'image',
|
|
151
|
+
data: result.screenshotBuffer.toString('base64'),
|
|
152
|
+
mimeType: 'image/png',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// If there's a diff image, return it too
|
|
156
|
+
if (result.diffBuffer) {
|
|
157
|
+
content.push({
|
|
158
|
+
type: 'image',
|
|
159
|
+
data: result.diffBuffer.toString('base64'),
|
|
160
|
+
mimeType: 'image/png',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Text summary
|
|
165
|
+
content.push({
|
|
166
|
+
type: 'text',
|
|
167
|
+
text: JSON.stringify({
|
|
168
|
+
changed: result.changed,
|
|
169
|
+
diffPercent: result.diffPercent,
|
|
170
|
+
threshold: result.threshold,
|
|
171
|
+
message: result.message,
|
|
172
|
+
watchId: result.watch.id,
|
|
173
|
+
url: result.watch.url,
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return content;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case 'list_watches': {
|
|
181
|
+
const watches = listWatches();
|
|
182
|
+
if (watches.length === 0) {
|
|
183
|
+
return [{ type: 'text', text: 'No watches. Use check_url to start monitoring a URL.' }];
|
|
184
|
+
}
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
type: 'text',
|
|
188
|
+
text: JSON.stringify(
|
|
189
|
+
watches.map((w) => ({
|
|
190
|
+
id: w.id,
|
|
191
|
+
url: w.url,
|
|
192
|
+
viewport: w.viewport,
|
|
193
|
+
threshold: w.config.threshold,
|
|
194
|
+
region: w.config.region,
|
|
195
|
+
lastCheckAt: w.lastCheckAt,
|
|
196
|
+
lastDiffPercent: w.lastDiffPercent,
|
|
197
|
+
createdAt: w.createdAt,
|
|
198
|
+
})),
|
|
199
|
+
null,
|
|
200
|
+
2,
|
|
201
|
+
),
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case 'remove_watch': {
|
|
207
|
+
removeWatch(args.id as string);
|
|
208
|
+
return [{ type: 'text', text: `Watch ${args.id} removed.` }];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
default:
|
|
212
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const server = new Server(
|
|
217
|
+
{ name: 'eyeballs', version: '0.1.0' },
|
|
218
|
+
{ capabilities: { tools: {} } },
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
222
|
+
|
|
223
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
224
|
+
const { name, arguments: args } = request.params;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const content = await handleTool(name, (args ?? {}) as Record<string, unknown>);
|
|
228
|
+
return { content };
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const message =
|
|
231
|
+
error instanceof EyeballsError
|
|
232
|
+
? `${error.code}: ${error.message}`
|
|
233
|
+
: `Error: ${(error as Error).message}`;
|
|
234
|
+
return {
|
|
235
|
+
content: [{ type: 'text', text: message }],
|
|
236
|
+
isError: true,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const transport = new StdioServerTransport();
|
|
242
|
+
await server.connect(transport);
|
|
243
|
+
console.error('eyeballs MCP server running');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
declare module 'pixelmatch' {
|
|
2
|
+
interface PixelmatchOptions {
|
|
3
|
+
threshold?: number;
|
|
4
|
+
includeAA?: boolean;
|
|
5
|
+
alpha?: number;
|
|
6
|
+
aaColor?: [number, number, number];
|
|
7
|
+
diffColor?: [number, number, number];
|
|
8
|
+
diffColorAlt?: [number, number, number];
|
|
9
|
+
diffMask?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function pixelmatch(
|
|
13
|
+
img1: Buffer | Uint8Array | Uint8ClampedArray,
|
|
14
|
+
img2: Buffer | Uint8Array | Uint8ClampedArray,
|
|
15
|
+
output: Buffer | Uint8Array | Uint8ClampedArray | null,
|
|
16
|
+
width: number,
|
|
17
|
+
height: number,
|
|
18
|
+
options?: PixelmatchOptions
|
|
19
|
+
): number;
|
|
20
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src"],
|
|
18
|
+
"exclude": ["node_modules", "dist", "test"]
|
|
19
|
+
}
|