hoverify-mcp 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +269 -2
- package/dist/bridge.js +602 -0
- package/dist/browsers.js +58 -0
- package/dist/index.js +59 -0
- package/dist/key.js +48 -0
- package/dist/protocol.js +13 -0
- package/dist/tools.js +441 -0
- package/package.json +51 -5
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { Bridge, VERSION } from "./bridge.js";
|
|
6
|
+
import { DEFAULT_PORT, RELEASE_AFTER } from "./protocol.js";
|
|
7
|
+
import { registerTools } from "./tools.js";
|
|
8
|
+
// stdout carries the MCP protocol, so every log line goes to stderr
|
|
9
|
+
const log = (line) => process.stderr.write(`[hoverify-mcp] ${line}\n`);
|
|
10
|
+
// shown in Hoverify's "allow this agent?" prompt
|
|
11
|
+
const AGENT_NAMES = {
|
|
12
|
+
"claude-code": "Claude Code",
|
|
13
|
+
"claude-ai": "Claude Desktop",
|
|
14
|
+
"cursor-vscode": "Cursor",
|
|
15
|
+
"Visual Studio Code": "VS Code",
|
|
16
|
+
};
|
|
17
|
+
function configuredBrowser() {
|
|
18
|
+
const at = process.argv.findIndex(arg => arg === "--browser" || arg.startsWith("--browser="));
|
|
19
|
+
if (at < 0)
|
|
20
|
+
return (process.env.HOVERIFY_BROWSER || "").trim();
|
|
21
|
+
const arg = process.argv[at];
|
|
22
|
+
return (arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : process.argv[at + 1] || "").trim();
|
|
23
|
+
}
|
|
24
|
+
const server = new McpServer({ name: "hoverify", version: VERSION });
|
|
25
|
+
const bridge = new Bridge({
|
|
26
|
+
port: Number(process.env.HOVERIFY_MCP_PORT) || DEFAULT_PORT,
|
|
27
|
+
name: `${process.pid}`,
|
|
28
|
+
releaseAfter: Number(process.env.HOVERIFY_MCP_IDLE_MS) || RELEASE_AFTER,
|
|
29
|
+
agent: () => {
|
|
30
|
+
const client = server.server.getClientVersion();
|
|
31
|
+
return client ? AGENT_NAMES[client.name] || client.title || client.name : "";
|
|
32
|
+
},
|
|
33
|
+
configured: configuredBrowser(),
|
|
34
|
+
log,
|
|
35
|
+
});
|
|
36
|
+
server.server.oninitialized = async () => {
|
|
37
|
+
bridge.agentChanged();
|
|
38
|
+
if (!server.server.getClientCapabilities()?.roots)
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
const { roots } = await server.server.listRoots();
|
|
42
|
+
const first = roots.find(root => root.uri.startsWith("file:"));
|
|
43
|
+
if (first)
|
|
44
|
+
bridge.setProject(fileURLToPath(first.uri));
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
log(`couldn't read the agent's project folder: ${error.message}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
registerTools(server, bridge);
|
|
51
|
+
bridge.start();
|
|
52
|
+
await server.connect(new StdioServerTransport());
|
|
53
|
+
const shutdown = () => {
|
|
54
|
+
bridge.close();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
};
|
|
57
|
+
process.stdin.on("close", shutdown);
|
|
58
|
+
process.on("SIGTERM", shutdown);
|
|
59
|
+
process.on("SIGINT", shutdown);
|
package/dist/key.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
const KEY_FORMAT = /^[0-9a-f]{64}$/;
|
|
6
|
+
export const keyPath = () => join(process.env.HOVERIFY_HOME || join(homedir(), ".hoverify"), "agent.key");
|
|
7
|
+
// read on every handshake, so deleting the file starts over at the next connection
|
|
8
|
+
export function loadKey() {
|
|
9
|
+
const path = keyPath();
|
|
10
|
+
try {
|
|
11
|
+
return checked(readFileSync(path, "utf8"), path);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code !== "ENOENT")
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
// sessions often start together; linking a finished draft means none of them reads a half-written key
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
19
|
+
const draft = `${path}.${process.pid}`;
|
|
20
|
+
writeFileSync(draft, randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
21
|
+
try {
|
|
22
|
+
linkSync(draft, path);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code !== "EEXIST")
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
unlinkSync(draft);
|
|
30
|
+
}
|
|
31
|
+
return checked(readFileSync(path, "utf8"), path);
|
|
32
|
+
}
|
|
33
|
+
function checked(text, path) {
|
|
34
|
+
const key = text.trim();
|
|
35
|
+
if (!KEY_FORMAT.test(key))
|
|
36
|
+
throw new Error(`${path} isn't a Hoverify key. Delete it and restart the agent; Hoverify will ask you to allow it again.`);
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
39
|
+
export const keyId = (key) => createHash("sha256").update(Buffer.from(key, "hex")).digest("hex").slice(0, 16);
|
|
40
|
+
export const nonce = () => randomBytes(16).toString("hex");
|
|
41
|
+
export const prove = (key, ...parts) => createHmac("sha256", Buffer.from(key, "hex")).update(parts.join("|")).digest("hex");
|
|
42
|
+
export function proves(given, key, ...parts) {
|
|
43
|
+
if (typeof given !== "string")
|
|
44
|
+
return false;
|
|
45
|
+
const a = Buffer.from(given);
|
|
46
|
+
const b = Buffer.from(prove(key, ...parts));
|
|
47
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
48
|
+
}
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// hello, challenge and auth keep their shape in every version: an older hub checks a newer session's key with them before handing over the port
|
|
2
|
+
export const PROTOCOL = 3;
|
|
3
|
+
export const DEFAULT_PORT = 47862;
|
|
4
|
+
export const PING_INTERVAL = 20_000;
|
|
5
|
+
export const RELEASE_AFTER = 5 * 60_000;
|
|
6
|
+
export const NOT_CONNECTED = "Hoverify isn't connected. Open the Hoverify popup in Chrome or Firefox and switch on Connect to agent, then try again.";
|
|
7
|
+
export const APPROVAL_NEEDED = "Hoverify is asking whether to let this agent connect. Click the Hoverify icon in the browser and choose Allow, then try again.";
|
|
8
|
+
export const PEER_REJECTED = "Another agent session holds the connection to Hoverify and uses a different key file. That happens when agents run with different home folders. Set HOVERIFY_HOME to the same folder for every agent, then restart them.";
|
|
9
|
+
export const PEER_VERSION = "Another agent session holds the connection to Hoverify and runs a different version of hoverify-mcp. Restart your agent sessions so they all run the same version.";
|
|
10
|
+
export const EXTENSION_VERSION = "Hoverify and hoverify-mcp are different versions and can't connect. Update the Hoverify extension, and restart the agent so it runs the latest hoverify-mcp.";
|
|
11
|
+
export const BROWSER_MOVED = "This call ran in a different browser, because the one this session was using isn't connected anymore. Tab ids from earlier calls don't apply here.";
|
|
12
|
+
export const BROWSER_CLAIMED = "This call ran in the browser the user keeps this project in, which isn't the one this session was using. Tab ids from earlier calls don't apply here.";
|
|
13
|
+
export const BRIDGE_RESET = "bridge_reset";
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, extname, join } from "node:path";
|
|
5
|
+
import { BRIDGE_RESET } from "./protocol.js";
|
|
6
|
+
// the extension's catalog; the user's own devices are checked there
|
|
7
|
+
const DEVICE_IDS = ["iphone_duo", "iphone_18_pro_max", "iphone_18_pro", "iphone_17_pro_max", "iphone_17_pro", "iphone_17", "iphone_air", "iphone_17e", "iphone_16", "iphone_16_plus", "iphone_se_3", "galaxy_s26_ultra", "galaxy_s26", "galaxy_a17_5g", "galaxy_a07", "pixel_11", "pixel_11_pro_xl", "redmi_note_14_4g", "moto_g_power_2025", "galaxy_z_fold7", "galaxy_z_flip8", "pixel_10_pro_fold", "ipad_pro_13_m5", "ipad_pro_11_m5", "ipad_air_13_m4", "ipad_a16", "ipad_mini_a17_pro", "galaxy_tab_s11", "ipad", "tailwind_sm", "tailwind_md", "tailwind_lg", "tailwind_xl", "tailwind_2xl"];
|
|
8
|
+
const tabId = z.number().int().optional().describe("Tab to use. Defaults to the tab the user is viewing. Tabs you opened with open_url can be used too.");
|
|
9
|
+
const format = z.enum(["markdown", "json"]).optional().describe("markdown (default) is a ranked fix list written for you; json is the raw result.");
|
|
10
|
+
const browser = z.string().optional().describe("Only when Hoverify is on in more than one browser: which one to use, by the name or id get_status lists. The session stays in it afterwards. Leave it out to keep using this session's browser.");
|
|
11
|
+
const assetTypes = z.array(z.enum(["image", "svg", "video", "lottie", "pdf", "favicon"])).optional().describe("Only these kinds of asset");
|
|
12
|
+
const DESIGN_SIZES = [
|
|
13
|
+
"auto", "web_open_graph", "instagram_post", "instagram_portrait", "instagram_story", "facebook_news_feed", "facebook_story", "facebook_cover_photo",
|
|
14
|
+
"x_one_image", "x_two_images", "x_cover_photo", "linkedin_feed", "linkedin_story", "linkedin_cover_business", "linkedin_cover_personal",
|
|
15
|
+
"youtube_thumbnail", "bluesky_post", "bluesky_cover", "bluesky_cover_mobile", "product_hunt_gallery", "product_hunt_thumbnail", "dribbble_shot",
|
|
16
|
+
];
|
|
17
|
+
const DESIGN_BACKGROUNDS = [
|
|
18
|
+
"auto",
|
|
19
|
+
"linear_navy_red_gold", "linear_purple_red_orange", "linear_deep_teal", "linear_night_purple_pink", "linear_pink_peach", "linear_plum_cream",
|
|
20
|
+
"linear_soft_pink", "linear_cream_navy", "linear_sage_slate", "linear_blue_red", "linear_espresso", "linear_lavender_night",
|
|
21
|
+
"cosmic_rose_dawn", "cosmic_pink_band", "cosmic_magenta_arc", "cosmic_pink_glow", "cosmic_dim_violet", "cosmic_purple_corner",
|
|
22
|
+
"cosmic_violet", "cosmic_cyan_beam", "cosmic_blue_orb", "cosmic_teal_glow",
|
|
23
|
+
"radiant_white_red", "radiant_indigo_rose", "radiant_blue_orange", "radiant_lavender_maroon", "radiant_coral_pink", "radiant_pink_peach",
|
|
24
|
+
"radiant_white_orange", "radiant_silver_magenta", "radiant_dusk_magenta", "radiant_white_pink",
|
|
25
|
+
"desktop_sunset_waves", "desktop_blue_hills", "desktop_green_hills", "desktop_teal_lines", "desktop_red_blue_waves", "desktop_neon_ribbons",
|
|
26
|
+
"desktop_violet_peaks", "desktop_orange_petals", "desktop_purple_lines", "desktop_magenta_peaks",
|
|
27
|
+
"earth_coast_dusk", "earth_coast_clouds", "earth_lone_tree", "earth_turquoise_shore", "earth_desert_rocks", "earth_rocks_night",
|
|
28
|
+
"earth_island_blue", "earth_pink_sunset", "earth_ice_cave", "earth_night_forest", "earth_orange_haze", "earth_misty_forest", "earth_sand_dunes",
|
|
29
|
+
"abstract_dark_scales", "abstract_pink_orange_flow", "abstract_blue_waves", "abstract_sandstone", "abstract_blue_pink_blur",
|
|
30
|
+
"abstract_green_leaves", "abstract_green_swirls", "abstract_cream_silk", "abstract_peach_dune",
|
|
31
|
+
];
|
|
32
|
+
const DESIGN_PALETTES = ["site", "shot", "indigo", "ocean", "emerald", "sunset", "rose", "daylight"];
|
|
33
|
+
const DESIGN_SHADERS = ["silk", "glow", "pastel", "marble", "stripes", "liquid", "rays", "corner-rays", "soft-rays", "panels"];
|
|
34
|
+
const DESIGN_PATTERNS = ["checks", "stripes", "edge"];
|
|
35
|
+
const DESIGN_LAYOUTS = [
|
|
36
|
+
"centered", "roomy", "hero_bleed", "corner_peek", "tilted", "diagonal", "isometric", "close_up", "flat_lay", "side_wall", "tumble", "iso_close_up", "steep_corner",
|
|
37
|
+
"pair", "pair_staggered", "pair_cascade", "pair_fan", "pair_tilted", "pair_iso", "trio", "trio_staggered", "trio_cascade", "trio_fan", "trio_tilted", "trio_iso", "devices",
|
|
38
|
+
];
|
|
39
|
+
const DESIGN_DEVICES = [
|
|
40
|
+
"iphone_duo_closed", "iphone_duo_open", "iphone_18_pro_max", "iphone_18_pro", "iphone_17_pro_max", "iphone_17_pro", "iphone_17", "iphone_air",
|
|
41
|
+
"iphone_17e", "iphone_16", "iphone_16_plus", "iphone_se_3", "galaxy_s26_ultra", "galaxy_s26", "galaxy_a17_5g", "galaxy_a07", "pixel_11",
|
|
42
|
+
"pixel_11_pro_xl", "redmi_note_14_4g", "moto_g_power_2025", "galaxy_z_fold7_closed", "galaxy_z_fold7_open", "galaxy_z_flip8",
|
|
43
|
+
"pixel_10_pro_fold_closed", "pixel_10_pro_fold_open", "ipad_pro_13_m5", "ipad_pro_11_m5", "ipad_air_13_m4", "ipad_a16", "ipad_mini_a17_pro", "galaxy_tab_s11",
|
|
44
|
+
];
|
|
45
|
+
const step = z.enum(["none", "small", "medium", "large"]);
|
|
46
|
+
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
|
47
|
+
const hexAlpha = z.string().regex(/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/).describe("#rrggbb, or #rrggbbaa with alpha");
|
|
48
|
+
const WEIGHTS = ["thin", "light", "regular", "medium", "semibold", "bold", "extrabold", "black"];
|
|
49
|
+
const percent = z.number().min(0).max(100);
|
|
50
|
+
const rect = z.object({ x: z.number(), y: z.number(), width: z.number().positive(), height: z.number().positive() });
|
|
51
|
+
const ANCHORS = ["top_left", "top", "top_right", "left", "center", "right", "bottom_left", "bottom", "bottom_right"];
|
|
52
|
+
const pct = z.number().min(-50).max(150);
|
|
53
|
+
const artPct = z.number().min(-100).max(200);
|
|
54
|
+
const annotations = z.array(z.object({
|
|
55
|
+
type: z.enum(["box", "circle", "arrow", "label", "text", "number", "blur", "spotlight", "details", "underline", "highlight", "magnifier", "image", "shape"]).describe("Pointing at the page: box, circle and arrow point something out; label is a callout with a pointer; number is a numbered step; underline, highlight and magnifier mark text or zoom into a small element; blur hides it; spotlight dims everything else; details shows its size, spacing, font and colours. Designing the canvas (with at): text, image (a logo or picture from path or url) and shape (a rect, pill, ellipse or line for colour blocks, badges and dividers). Without at, text is a caption above the shot."),
|
|
56
|
+
selector: z.string().optional().describe("CSS selector of the element on the captured page"),
|
|
57
|
+
rect: rect.optional().describe("Instead of a selector: CSS pixels in the screenshot; when a capture comes in several images, from the top of the first"),
|
|
58
|
+
side: z.enum(["left", "right", "above", "below"]).optional().describe("Label and arrow: which side of the element they go, instead of the side with the most room"),
|
|
59
|
+
at: z.object({
|
|
60
|
+
anchor: z.enum(ANCHORS).optional().describe("One of nine canvas points; several at one anchor stack in order and shrink together to fit beside the shots"),
|
|
61
|
+
margin: step.optional().describe("With anchor: space from the edge. Default medium."),
|
|
62
|
+
x: pct.optional().describe("Free placement: percent across the canvas (0 left, 100 right; beyond bleeds off)"),
|
|
63
|
+
y: pct.optional().describe("Free placement: percent down the canvas"),
|
|
64
|
+
origin: z.enum(ANCHORS).optional().describe("With x and y: which point of the element sits there. Default top_left; center centres it."),
|
|
65
|
+
}).optional().describe("Place text, an image or a shape on the canvas: an anchor (with margin), or x and y for exactly where you want it. Canvas elements draw above the shots, in the order listed."),
|
|
66
|
+
to: z.object({ x: pct, y: pct }).optional().describe("A line shape: where it ends, in canvas percents; it starts at at.x, at.y"),
|
|
67
|
+
text: z.string().max(300).optional().describe("The words, for label and text; text on the canvas can break lines with \\n"),
|
|
68
|
+
role: z.enum(["eyebrow", "headline", "subtitle", "body", "caption"]).optional().describe("Canvas text: a starting size, weight and spacing scaled to the canvas. Default body."),
|
|
69
|
+
font: z.string().optional().describe("Canvas text: a Google font family, Inter, Noto Sans, Nunito or Source Code Pro, or site_heading / site_body for the page's own fonts"),
|
|
70
|
+
font_size: z.number().min(4).max(1000).optional().describe("Canvas text: size in px at the canvas's own size (a 2400×1350 canvas is 2400 px wide), over the role's"),
|
|
71
|
+
weight: z.enum(WEIGHTS).optional(),
|
|
72
|
+
line_height: z.number().min(0.7).max(3).optional().describe("Canvas text: a multiple of the font size"),
|
|
73
|
+
letter_spacing: z.number().min(-100).max(200).optional().describe("Canvas text: px, negative to tighten"),
|
|
74
|
+
width: z.union([z.enum(["narrow", "half", "wide"]), z.number().min(0.1).max(200)]).optional().describe("Canvas text: where it wraps (narrow, half, wide, or a percent of the canvas width). Image: its width as a percent of the canvas. Shape: its width as a percent of the canvas."),
|
|
75
|
+
height: z.number().min(0.1).max(200).optional().describe("Shape: its height as a percent of the canvas height"),
|
|
76
|
+
align: z.enum(["left", "center", "right"]).optional().describe("Canvas text: defaults to the anchor's side"),
|
|
77
|
+
background: hex.optional().describe("Canvas text: a solid badge behind the words, sized to them"),
|
|
78
|
+
radius: z.number().min(0).max(2000).optional().describe("Badge or rect corner radius in px; a badge defaults to a pill"),
|
|
79
|
+
shape: z.enum(["rect", "pill", "ellipse", "line"]).optional(),
|
|
80
|
+
fill: hex.optional().describe("Shape fill"),
|
|
81
|
+
stroke: hex.optional().describe("Shape outline, or a line's colour"),
|
|
82
|
+
stroke_width: z.number().min(0).max(200).optional().describe("Shape outline or line width in px"),
|
|
83
|
+
rotation: z.number().min(-180).max(180).optional().describe("Canvas element: degrees"),
|
|
84
|
+
opacity: z.number().min(0).max(100).optional().describe("Canvas element: percent"),
|
|
85
|
+
path: z.string().optional().describe("Image: a PNG, JPEG, WebP, GIF or SVG file on this machine, up to 5 MB"),
|
|
86
|
+
url: z.string().optional().describe("Image: an http or https address of a picture, fetched without the user's cookies, up to 5 MB"),
|
|
87
|
+
color: z.union([hex, z.literal("auto")]).optional().describe("#rrggbb; defaults to the user's annotation colour. Canvas text defaults to auto: white or near-black, whichever reads best on what's behind it."),
|
|
88
|
+
size: z.enum(["s", "m", "l"]).optional().describe("s, m (default) or l: stroke, text and marker size; for an image with at and no width, its height"),
|
|
89
|
+
follow_shot: z.boolean().optional().describe("design_screenshot on a tilted shot: true draws it into the shot so it turns with the page; false keeps it flat on top. Pointing marks default to true, text to false; canvas elements always stay flat."),
|
|
90
|
+
})).max(40);
|
|
91
|
+
const art = z.array(z.object({
|
|
92
|
+
type: z.enum(["glow", "gradient", "pattern", "shape", "text", "image", "grain"]).describe("glow: a soft light, centred on at. gradient: colors across its box, linear, radial or conic. pattern: a grid, dots, lines, cross, rings or checks. shape: rect, pill, ellipse, ring, line, polygon or star. text: display type behind the shots, filled or outlined. image: a picture or photo from path or url. grain: film noise. gradient, pattern, grain and image fill the whole canvas unless given at and a size."),
|
|
93
|
+
at: z.object({
|
|
94
|
+
x: artPct.optional().describe("Percent across the canvas, -100 to 200; beyond 0 to 100 bleeds off"),
|
|
95
|
+
y: artPct.optional().describe("Percent down the canvas, -100 to 200"),
|
|
96
|
+
origin: z.enum(ANCHORS).optional().describe("Which point of the layer sits at x, y. Default top_left; a glow's is center."),
|
|
97
|
+
}).optional(),
|
|
98
|
+
width: z.number().min(0.1).max(400).optional().describe("Percent of the canvas width. Text: where it wraps; leave it out for no wrapping."),
|
|
99
|
+
height: z.number().min(0.1).max(400).optional().describe("Percent of the canvas height. Left out, it's as many pixels as the width (a circle, a square), or a picture's own shape."),
|
|
100
|
+
to: z.object({ x: artPct, y: artPct }).optional().describe("A line: where it ends, in canvas percents"),
|
|
101
|
+
color: hexAlpha.optional().describe("The layer's colour: a glow's light, a pattern's marks, a shape's or text's fill"),
|
|
102
|
+
colors: z.array(hexAlpha).min(2).max(8).optional().describe("A gradient instead of one colour, for a gradient layer, or to fill a shape, text or pattern"),
|
|
103
|
+
gradient: z.enum(["linear", "radial", "conic"]).optional().describe("With colors. Default linear."),
|
|
104
|
+
angle: z.number().min(-360).max(360).optional().describe("Linear gradient: degrees, 0 up, 90 right, 180 (default) down. Conic: where it starts. Pattern: turns its lines."),
|
|
105
|
+
center: z.object({ x: percent, y: percent }).optional().describe("Radial or conic gradient, or rings: the middle, in percents of the layer's own box"),
|
|
106
|
+
stops: z.array(percent).max(8).optional().describe("With colors: where each colour sits, percents along the gradient"),
|
|
107
|
+
stroke: hexAlpha.optional().describe("Shape or text outline; a ring's or line's colour"),
|
|
108
|
+
stroke_width: z.number().min(0).max(500).optional().describe("px"),
|
|
109
|
+
radius: z.number().min(0).max(5000).optional().describe("Rect or image corner radius in px"),
|
|
110
|
+
shape: z.enum(["rect", "pill", "ellipse", "ring", "line", "polygon", "star"]).optional(),
|
|
111
|
+
sides: z.number().int().min(3).max(24).optional().describe("Polygon sides or star points"),
|
|
112
|
+
pattern: z.enum(["grid", "dots", "lines", "cross", "rings", "checks", "scatter"]).optional().describe("scatter strews dots of varied size and brightness, for stars, dust and sparkle"),
|
|
113
|
+
spacing: z.number().min(2).max(2000).optional().describe("Pattern: px between marks, on average for scatter. Default 48."),
|
|
114
|
+
size: z.number().min(0.5).max(200).optional().describe("Pattern: line width or dot size in px, the largest dot for scatter (default 1.5). Grain: px per grain (default 1.5)."),
|
|
115
|
+
text: z.string().max(300).optional().describe("Text: the words; \\n breaks lines"),
|
|
116
|
+
font: z.string().optional().describe("Text: a Google font family, a bundled one, or site_heading / site_body"),
|
|
117
|
+
weight: z.enum(WEIGHTS).optional().describe("Text: default bold"),
|
|
118
|
+
font_size: z.number().min(4).max(2000).optional().describe("Text: px at the canvas's size. Default 96."),
|
|
119
|
+
line_height: z.number().min(0.5).max(3).optional(),
|
|
120
|
+
letter_spacing: z.number().optional().describe("Text: px, negative to tighten"),
|
|
121
|
+
align: z.enum(["left", "center", "right"]).optional(),
|
|
122
|
+
fit: z.enum(["cover", "contain"]).optional().describe("Image: fill its box (default) or fit inside it"),
|
|
123
|
+
seed: z.number().int().min(0).max(10000).optional().describe("Grain or scatter: a different random pattern"),
|
|
124
|
+
path: z.string().optional().describe("Image: a PNG, JPEG, WebP, GIF or SVG file on this machine, up to 5 MB"),
|
|
125
|
+
url: z.string().optional().describe("Image: an http or https address, fetched without the user's cookies, up to 5 MB"),
|
|
126
|
+
rotation: z.number().min(-360).max(360).optional().describe("Degrees, about the layer's centre"),
|
|
127
|
+
opacity: z.number().min(0).max(100).optional().describe("Percent. Grain defaults to 10."),
|
|
128
|
+
blend: z.enum(["normal", "multiply", "screen", "overlay", "soft_light", "hard_light", "lighten", "darken", "color_dodge", "color_burn", "difference", "exclusion"]).optional().describe("How it mixes with what's under it: screen and color_dodge light up, multiply darkens, overlay and soft_light add contrast. Grain defaults to overlay."),
|
|
129
|
+
blur: z.number().min(0).max(1000).optional().describe("px of softening"),
|
|
130
|
+
fade: z.enum(["none", "top", "bottom", "left", "right", "edges", "center"]).optional().describe("Fade the layer out toward a side, toward its edges (a vignette), or out of its centre"),
|
|
131
|
+
})).max(40);
|
|
132
|
+
const placement = {
|
|
133
|
+
scale: z.number().min(50).max(300).optional().describe("Size in percent; 100 fits"),
|
|
134
|
+
x: z.number().min(0).max(100).optional().describe("Where it sits across, 0 to 100; 50 centres it"),
|
|
135
|
+
y: z.number().min(0).max(100).optional().describe("Where it sits down, 0 to 100"),
|
|
136
|
+
tilt_x: z.number().min(-60).max(60).optional().describe("3D: positive leans the top back"),
|
|
137
|
+
tilt_y: z.number().min(-60).max(60).optional().describe("3D: negative faces it left, positive right"),
|
|
138
|
+
turn: z.number().min(-180).max(180).optional().describe("Turn in the canvas plane, degrees"),
|
|
139
|
+
};
|
|
140
|
+
const TOOLS = [
|
|
141
|
+
{
|
|
142
|
+
name: "get_status",
|
|
143
|
+
description: "Check that Hoverify is connected and see which tab is active: URL, title and viewport. With Hoverify on in more than one browser, it also lists them and says which one this session uses.",
|
|
144
|
+
schema: {},
|
|
145
|
+
timeout: 15_000,
|
|
146
|
+
idempotent: true,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "open_url",
|
|
150
|
+
description: "Open a URL in the browser for Hoverify to work on, for example a local dev server after a change. Returns the tab id to pass to other tools. Only http, https and file URLs. Close the tab with close_tab when you're done.",
|
|
151
|
+
schema: { url: z.string().describe("The URL to open") },
|
|
152
|
+
timeout: 60_000,
|
|
153
|
+
idempotent: false,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "reload_tab",
|
|
157
|
+
description: "Reload a tab and wait for it to finish loading, for example after changing code so the next audit sees the change.",
|
|
158
|
+
schema: { tab_id: tabId },
|
|
159
|
+
timeout: 60_000,
|
|
160
|
+
idempotent: true,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "close_tab",
|
|
164
|
+
description: "Close a tab you opened with open_url once you're done with it. Leave out tab_id to close every tab you opened. The user's own tabs can't be closed.",
|
|
165
|
+
schema: { tab_id: z.number().int().optional().describe("Tab to close. Leave it out to close every tab you opened.") },
|
|
166
|
+
timeout: 15_000,
|
|
167
|
+
idempotent: false,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "audit_seo",
|
|
171
|
+
description: "Run Hoverify's SEO check on a page: meta tags, headings, images, links and structured data. Returns the issues with CSS selectors and fixes. Link status and PageSpeed Insights are opt-in.",
|
|
172
|
+
schema: {
|
|
173
|
+
tab_id: tabId,
|
|
174
|
+
format,
|
|
175
|
+
check_links: z.boolean().optional().describe("Also request every link on the page and list the broken ones and the redirects. Slower on pages with many links."),
|
|
176
|
+
page_speed: z.boolean().optional().describe("Also run Google PageSpeed Insights for mobile and desktop: Lighthouse scores, Core Web Vitals and the slowest audits. Google's servers load the URL, so it works on public pages only and reflects what's deployed. Takes up to a minute or two."),
|
|
177
|
+
},
|
|
178
|
+
timeout: 300_000,
|
|
179
|
+
idempotent: true,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: "audit_accessibility",
|
|
183
|
+
description: "Run Hoverify's accessibility audit (axe-core plus Hoverify's own checks for forms, keyboard order, links and images) on a page. Returns failing rules with selectors, HTML snippets, contrast fixes and WCAG references.",
|
|
184
|
+
schema: { tab_id: tabId, format },
|
|
185
|
+
timeout: 150_000,
|
|
186
|
+
idempotent: true,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "audit_geo",
|
|
190
|
+
description: "Score how well AI search engines and agents can read and cite a page (Hoverify's GEO check). Returns the score and the issues ranked by how much each fix should raise it.",
|
|
191
|
+
schema: {
|
|
192
|
+
tab_id: tabId,
|
|
193
|
+
format,
|
|
194
|
+
ai: z.boolean().optional().describe("Also run the AI content review. Slower, and uses the model key the user set in Hoverify."),
|
|
195
|
+
},
|
|
196
|
+
timeout: 300_000,
|
|
197
|
+
idempotent: true,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "get_site_stack",
|
|
201
|
+
description: "Detect the page's tech stack (frameworks, libraries, analytics), hosting, DNS and SSL. Useful context before changing code.",
|
|
202
|
+
schema: { tab_id: tabId, format },
|
|
203
|
+
timeout: 90_000,
|
|
204
|
+
idempotent: true,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "inspect_element",
|
|
208
|
+
description: "Get an element's cleaned HTML, its authored CSS rules with their stylesheet URLs, custom properties, box metrics, accessible name and ancestors.",
|
|
209
|
+
schema: { selector: z.string().describe("CSS selector for the element"), tab_id: tabId, format },
|
|
210
|
+
timeout: 45_000,
|
|
211
|
+
idempotent: true,
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "get_selected_element",
|
|
215
|
+
description: "Get the element the user pinned or is hovering in the Hoverify inspector, in the same shape as inspect_element. Use this when the user says \"this element\" or \"the one I selected\".",
|
|
216
|
+
schema: { format },
|
|
217
|
+
timeout: 45_000,
|
|
218
|
+
idempotent: true,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: "get_fonts",
|
|
222
|
+
description: "List the fonts a page renders: each family with its weights, sizes and the elements that use it, where it loads from (Google Fonts, a web font file or the system), and declared fonts that didn't load and fell back.",
|
|
223
|
+
schema: { tab_id: tabId, format },
|
|
224
|
+
timeout: 60_000,
|
|
225
|
+
idempotent: true,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "get_colors",
|
|
229
|
+
description: "List a page's colours: CSS variables that hold colours, the colours actually used for text, backgrounds and borders with element counts, and every colour declared in its stylesheets. Use it to match a site's palette.",
|
|
230
|
+
schema: { tab_id: tabId, format },
|
|
231
|
+
timeout: 90_000,
|
|
232
|
+
idempotent: true,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: "list_assets",
|
|
236
|
+
description: "List a page's images, inline SVGs, videos, Lottie animations, PDFs and favicon with ids, file sizes, dimensions and URLs. Pass the ids to download_assets to save the files.",
|
|
237
|
+
schema: { tab_id: tabId, types: assetTypes, format },
|
|
238
|
+
timeout: 90_000,
|
|
239
|
+
idempotent: true,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: "download_assets",
|
|
243
|
+
description: "Save assets from the page to local files and return their paths: pass ids from list_assets, or types (for example [\"svg\"]) to save every asset of those kinds. Only assets found on the page can be saved, up to 40 MB per call.",
|
|
244
|
+
schema: {
|
|
245
|
+
tab_id: tabId,
|
|
246
|
+
ids: z.array(z.string()).optional().describe("Asset ids from list_assets"),
|
|
247
|
+
types: assetTypes,
|
|
248
|
+
},
|
|
249
|
+
timeout: 180_000,
|
|
250
|
+
idempotent: true,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: "capture",
|
|
254
|
+
description: "Take a screenshot of the visible page, the whole scrollable page, one element or a rectangle of the page. Returns a preview image, the page context (URL, viewport, element) and the path of the full-resolution image. An element or area bigger than the screen, like a full page, is scrolled through and stitched, then the page is put back; it takes longer, and a tall result comes back as preview slices from top to bottom. The user's privacy settings apply: sensitive text found on the page (emails, phone and card numbers, keys, filled-in fields) is pixelated and cookie banners and chat widgets are hidden, and the result says what was hidden. Pass annotations to mark the screenshot up (boxes, arrows, labels, numbered steps, highlights, blurs and more, pointing at elements by CSS selector), drawn on the image itself with nothing around it; for a finished, styled image use design_screenshot instead.",
|
|
255
|
+
schema: {
|
|
256
|
+
mode: z.enum(["viewport", "full_page", "element", "area"]).optional().describe("viewport for what's on screen, full_page for the whole page, element for one whole element, area for a rectangle of the page. Leave it out when passing capture_id."),
|
|
257
|
+
selector: z.string().optional().describe("CSS selector, required for element mode"),
|
|
258
|
+
area: z.object({
|
|
259
|
+
x: z.number(),
|
|
260
|
+
y: z.number(),
|
|
261
|
+
width: z.number().positive(),
|
|
262
|
+
height: z.number().positive(),
|
|
263
|
+
}).optional().describe("Required for area mode: CSS pixels from the top-left of the page, the same coordinates as a full_page capture. inspect_element reports viewport positions, so add the page's scroll. For one element, use element mode instead."),
|
|
264
|
+
tab_id: tabId,
|
|
265
|
+
raw: z.boolean().optional().describe("Skip the user's privacy settings for this capture, so hidden text and clutter show as on the page. Only when those exact pixels are what you need to check, like a form field you just changed or a cookie banner you're fixing."),
|
|
266
|
+
annotations: annotations.optional().describe("Up to 40, placed inside the screenshot where there's room around each element unless you give a side; text, images and shapes can go anywhere with at. A page that comes in several images gets each annotation in the image holding its element, with numbered steps counting on across them. The result gives a capture_id to annotate the same capture again."),
|
|
267
|
+
capture_id: z.string().optional().describe("Annotate an earlier annotated capture again instead of capturing again: the new annotations list replaces the old one. New selectors are looked up only if the captured tab is still on that page."),
|
|
268
|
+
},
|
|
269
|
+
timeout: 180_000,
|
|
270
|
+
idempotent: true,
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: "design_screenshot",
|
|
274
|
+
description: "Make a finished, shareable image of a page, an element or the full page in Hoverify's screenshot editor, or with mode none a design with no screenshot: a background, padding, rounded corners, a shadow, a browser or device frame, tilt, and annotations pointing at elements by CSS selector. Use it for changelogs, PRs, docs and social posts. Start with only the source and maybe a size, look at the preview, then restyle by calling it again with the returned capture_id and changed choices; a restyle doesn't capture again. Unset choices take the editor's default look (medium padding, medium radius, spread shadow, a browser frame on viewport and full-page captures, the radiant_pink_peach gradient). The user's privacy settings apply: sensitive text found on the page is pixelated and cookie banners and chat widgets are hidden, and the result says what was hidden. Returns a preview, the choices used, the capture_id and the path of the full-resolution image.",
|
|
275
|
+
schema: {
|
|
276
|
+
mode: z.enum(["viewport", "full_page", "element", "area", "none"]).optional().describe("What to capture, as in capture; none for a design with no screenshot, only text, pictures, shapes and art, which needs a size. Leave it out when passing capture_id."),
|
|
277
|
+
selector: z.string().optional().describe("CSS selector of the element to capture, for element mode"),
|
|
278
|
+
area: rect.optional().describe("For area mode: CSS pixels from the top-left of the page"),
|
|
279
|
+
tab_id: tabId,
|
|
280
|
+
capture_id: z.string().optional().describe("Restyle an earlier design_screenshot capture instead of capturing again (kept in memory only until Hoverify restarts: your last 5 calls that captured, and any open in the screenshot editor)"),
|
|
281
|
+
size: z.enum(DESIGN_SIZES).optional().describe("auto (default) hugs the shot plus padding; the others are social and marketing sizes, for example web_open_graph (1200×630, a link preview on any site), x_one_image (2400×1350) or instagram_post (1080×1080)"),
|
|
282
|
+
background: z.union([
|
|
283
|
+
z.enum(DESIGN_BACKGROUNDS),
|
|
284
|
+
z.object({ color: hex }),
|
|
285
|
+
z.object({ gradient: z.array(hex).min(2).max(6), angle: z.number().min(0).max(360).optional().describe("Degrees; default 135") }),
|
|
286
|
+
z.object({
|
|
287
|
+
shader: z.enum(DESIGN_SHADERS),
|
|
288
|
+
palette: z.enum(DESIGN_PALETTES).optional(),
|
|
289
|
+
colors: z.array(hex).min(1).max(3).optional().describe("Instead of a palette: your own 1 to 3 colours, which the look builds its tones from"),
|
|
290
|
+
dark: z.boolean().optional().describe("With colors: false for the light version of the look. Default true."),
|
|
291
|
+
settings: z.strictObject({
|
|
292
|
+
distortion: percent.optional(),
|
|
293
|
+
swirl: percent.optional(),
|
|
294
|
+
grain: percent.optional(),
|
|
295
|
+
pattern: z.enum(DESIGN_PATTERNS).optional(),
|
|
296
|
+
softness: percent.optional(),
|
|
297
|
+
light: z.strictObject({ x: percent, y: percent }).optional(),
|
|
298
|
+
density: percent.optional(),
|
|
299
|
+
intensity: percent.optional(),
|
|
300
|
+
bloom: percent.optional(),
|
|
301
|
+
angle: z.number().min(-100).max(100).optional(),
|
|
302
|
+
length: percent.optional(),
|
|
303
|
+
blur: percent.optional(),
|
|
304
|
+
}).optional().describe("The shader's own controls as the editor's slider percents; leave out any that should keep the look's tuned value. silk, glow, pastel: distortion, swirl, grain. marble, stripes, liquid: pattern (checks | stripes | edge), distortion, swirl, softness. rays, corner-rays, soft-rays: light { x, y } (where the light sits, 0 to 100 from the canvas's top-left; 0 and 100 are just past the edge), density, intensity, bloom. panels: density, angle (-100 to 100), length, blur. A setting the chosen shader doesn't have is refused."),
|
|
305
|
+
}),
|
|
306
|
+
]).optional().describe("A gradient (linear_*, cosmic_* dark, radiant_* light) or image (desktop_*, earth_* photos, abstract_*) id; auto for a gradient from the shot's own colours; { color: \"#rrggbb\" }; or a WebGL shader { shader, palette } or { shader, colors }: silk, glow and pastel are soft gradients, marble, stripes and liquid are marbled bands, rays, corner-rays and soft-rays light the shot from behind, panels is a lit stage; palette \"site\" uses the captured site's brand colours. Default radiant_pink_peach."),
|
|
307
|
+
effects: z.object({
|
|
308
|
+
noise: percent.optional().describe("Film noise over the background; the editor's default is 35"),
|
|
309
|
+
blur: z.number().min(0).max(18).optional().describe("Softens a gradient or image background, px; default 4"),
|
|
310
|
+
zoom: percent.optional().describe("Scales a gradient or image background up, percent; default 2"),
|
|
311
|
+
}).optional().describe("The background's own noise, blur and zoom. Art is drawn above the blur and zoom, and under the noise."),
|
|
312
|
+
padding: step.optional().describe("Space around the shot. Default medium."),
|
|
313
|
+
radius: step.optional().describe("The shot's corner radius. Default medium."),
|
|
314
|
+
shadow: z.enum(["none", "spread", "hug"]).optional().describe("spread (default) is a wide soft lift, hug a tight one"),
|
|
315
|
+
frame: z.enum(["auto", "none", "light", "dark"]).optional().describe("A browser window around the shot with the page's address. auto (default) frames viewport and full-page captures only."),
|
|
316
|
+
device: z.object({
|
|
317
|
+
model: z.enum(DESIGN_DEVICES),
|
|
318
|
+
finish: z.string().optional().describe("A colour the model comes in; defaults to its first"),
|
|
319
|
+
}).optional().describe("Put the shot in a phone or tablet frame instead of a browser frame. The device's screen must be within 30% of the shot's shape, so a wide desktop viewport usually doesn't fit: capture an element or an area of about the screen's shape (a tablet is about 1.4:1 turned sideways, a phone about 1:2.2 upright). A device that doesn't fit is skipped, and the result says what shape it needs."),
|
|
320
|
+
layout: z.enum(DESIGN_LAYOUTS).optional().describe("How the shot sits on the canvas: centered (default) and roomy; hero_bleed and corner_peek scale it up and let it run off the edge; tilted turns it gently; diagonal rolls it; isometric, flat_lay, side_wall, tumble, iso_close_up and steep_corner are bold 3D angles; close_up crops into it. A layout sets the shot's own scale, so padding then only pads an auto canvas. With shots, a layout for that many: pair (default for 2), pair_staggered, pair_cascade, pair_fan, pair_tilted and pair_iso; trio (default for 3), trio_staggered, trio_cascade, trio_fan, trio_tilted and trio_iso; devices puts 3 in a desktop, tablet and phone, so give it a tablet-shaped and a phone-shaped shot."),
|
|
321
|
+
mirror: z.boolean().optional().describe("Face the layout the other way round"),
|
|
322
|
+
tilt: z.enum(["none", "left", "right", "back", "left_back", "right_back"]).optional().describe("Turn the shot in 3D. Default none. With a layout it replaces only the layout's tip and turn."),
|
|
323
|
+
branding: z.enum(["none", "left", "center", "right"]).optional().describe("A small \"made with Hoverify\" badge. Default none; only add it if the user asks."),
|
|
324
|
+
annotations: annotations.optional().describe("Up to 40: marks pointing at the main shot, placed where there's room unless you give a side, and text, images and shapes you lay out on the canvas. On a restyle, new selectors are looked up only if the captured tab is still on that page."),
|
|
325
|
+
art: art.optional().describe("Up to 40 layers drawn over the background and under the shots, in order: glows, gradients, patterns, shapes, display type, photos and grain, each with its own opacity, blend, blur, fade and rotation. This is where the background's look is made. Places and sizes are canvas percents, lengths canvas px. The result's shot_box says where the shot landed."),
|
|
326
|
+
shot: z.object(placement).optional().describe("Place the main shot yourself, over the layout: scale, position and 3D angle"),
|
|
327
|
+
shots: z.array(z.object({
|
|
328
|
+
...placement,
|
|
329
|
+
capture_id: z.string().optional().describe("A capture_id from an earlier capture or design_screenshot"),
|
|
330
|
+
mode: z.enum(["viewport", "full_page", "element", "area"]).optional().describe("Or capture it now, as the main shot is"),
|
|
331
|
+
selector: z.string().optional(),
|
|
332
|
+
area: rect.optional(),
|
|
333
|
+
tab_id: tabId,
|
|
334
|
+
})).max(2).optional().describe("Up to 2 more shots beside the main one, laid out by a multi-shot layout: each a capture_id or a source to capture now, from any tab you can reach. Their sensitive text is pixelated by the user's setting. On a restyle, leave it out to keep the shots the design has; pass [] for the main shot alone."),
|
|
335
|
+
format: z.enum(["png", "jpeg", "webp"]).optional().describe("Default png"),
|
|
336
|
+
scale: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Pixel scale. Defaults to the capture's own in auto size, 1 for preset sizes."),
|
|
337
|
+
open_in_editor: z.boolean().optional().describe("Leave the design open in Hoverify's screenshot editor so the user can keep editing it. A later call with its capture_id updates that tab in place, as one step the user can undo. Close it with close_tab."),
|
|
338
|
+
},
|
|
339
|
+
timeout: 180_000,
|
|
340
|
+
idempotent: false,
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
name: "capture_devices",
|
|
344
|
+
description: "Screenshot a page on phones, tablets and desktop sizes through Hoverify's Responsive view. Each device loads the page fresh at its own screen size with its own browser's user agent, and the image shows the page in the device with its status and browser bars (Safari on iOS, Chrome on Android), as the Responsive view draws it. Up to 6 devices a call, captured one after another. Returns a preview per device and the full-resolution images; a viewport shot also returns a capture_id that design_screenshot can restyle. The user's privacy settings apply.",
|
|
345
|
+
schema: {
|
|
346
|
+
url: z.string().optional().describe("An http or https page to load on each device. Leave it out to use the page in tab_id, or in the tab the user is viewing."),
|
|
347
|
+
tab_id: tabId,
|
|
348
|
+
devices: z.array(z.string()).min(1).max(6).optional().describe(`Device ids, by default iphone_17_pro, ipad_pro_11_m5 and tailwind_xl. Built in: ${DEVICE_IDS.join(", ")}. A device the user added to Hoverify works by its id too.`),
|
|
349
|
+
mode: z.enum(["viewport", "full_page"]).optional().describe("viewport (default): what each screen shows, in the device. full_page: the whole page at the device's width, with no device around it."),
|
|
350
|
+
frame: z.boolean().optional().describe("Draw the device around the screen. Default true."),
|
|
351
|
+
os_chrome: z.boolean().optional().describe("Draw the status bar and browser bars, which also leave the page less height, as on the device. Default true."),
|
|
352
|
+
landscape: z.boolean().optional().describe("Turn the devices that turn. Default false."),
|
|
353
|
+
unfolded: z.boolean().optional().describe("Open the foldables. Default false."),
|
|
354
|
+
},
|
|
355
|
+
timeout: 300_000,
|
|
356
|
+
idempotent: true,
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: "get_design",
|
|
360
|
+
description: "Read a design the user has open in Hoverify's screenshot editor (from design_screenshot with open_in_editor), as they have it now. Returns a preview and the full-resolution image, its choices in design_screenshot's terms (size, background, padding, shot placement and the rest), which of your choices the user changed, every annotation with whose it is and where it sits, and which of yours the user deleted. Use it to see the user's edits before a restyle, then pass the choices they made to keep them. The user's privacy settings apply to the image.",
|
|
361
|
+
schema: { capture_id: z.string().describe("The capture_id of a design open in the screenshot editor") },
|
|
362
|
+
timeout: 90_000,
|
|
363
|
+
idempotent: true,
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
// one folder level is allowed for grouping; every segment is sanitised so nothing escapes the hoverify folder
|
|
367
|
+
function savePath(dir, name) {
|
|
368
|
+
const parts = name.split("/").map(part => part.replace(/[^\w.()-]/g, "_")).filter(part => part && part !== "." && part !== "..");
|
|
369
|
+
return join(dir, ...(parts.length ? parts.slice(-2) : ["file"]));
|
|
370
|
+
}
|
|
371
|
+
function saveFiles(output) {
|
|
372
|
+
if (!output.files?.length)
|
|
373
|
+
return output.content;
|
|
374
|
+
const dir = join(tmpdir(), "hoverify");
|
|
375
|
+
const saved = output.files.map(file => {
|
|
376
|
+
const path = savePath(dir, file.name);
|
|
377
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
378
|
+
writeFileSync(path, Buffer.from(file.data, "base64"));
|
|
379
|
+
return path;
|
|
380
|
+
});
|
|
381
|
+
const label = output.files_label || "Full-resolution image";
|
|
382
|
+
const list = saved.length === 1 ? ` ${saved[0]}` : `\n${saved.map(path => `- ${path}`).join("\n")}`;
|
|
383
|
+
return [...output.content, { type: "text", text: `${label}:${list}` }];
|
|
384
|
+
}
|
|
385
|
+
const PICTURE_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml" };
|
|
386
|
+
const MAX_PICTURE = 5 * 1024 * 1024;
|
|
387
|
+
// the browser can't read the agent's files, so a picture's path goes over as its bytes
|
|
388
|
+
function withPictures(args) {
|
|
389
|
+
const out = { ...args };
|
|
390
|
+
for (const key of ["annotations", "art"])
|
|
391
|
+
if (Array.isArray(args[key]))
|
|
392
|
+
out[key] = args[key].map(read);
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
function read(a) {
|
|
396
|
+
if (!a || typeof a !== "object" || typeof a.path !== "string")
|
|
397
|
+
return a;
|
|
398
|
+
const { path, ...rest } = a;
|
|
399
|
+
const type = PICTURE_TYPES[extname(path).toLowerCase()];
|
|
400
|
+
if (!type)
|
|
401
|
+
throw new Error(`${path} isn't a PNG, JPEG, WebP, GIF or SVG file.`);
|
|
402
|
+
let size = 0;
|
|
403
|
+
try {
|
|
404
|
+
const stat = statSync(path);
|
|
405
|
+
if (!stat.isFile())
|
|
406
|
+
throw new Error();
|
|
407
|
+
size = stat.size;
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
throw new Error(`${path} can't be read.`);
|
|
411
|
+
}
|
|
412
|
+
if (size > MAX_PICTURE)
|
|
413
|
+
throw new Error(`${path} is over 5 MB.`);
|
|
414
|
+
return { ...rest, data: `data:${type};base64,${readFileSync(path).toString("base64")}` };
|
|
415
|
+
}
|
|
416
|
+
export function registerTools(server, bridge) {
|
|
417
|
+
for (const spec of TOOLS) {
|
|
418
|
+
server.registerTool(spec.name, { description: spec.description, inputSchema: { ...spec.schema, browser } }, async (args) => {
|
|
419
|
+
try {
|
|
420
|
+
let output;
|
|
421
|
+
args = withPictures(args);
|
|
422
|
+
try {
|
|
423
|
+
output = await bridge.call(spec.name, args, spec.timeout);
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
if (error.message !== BRIDGE_RESET || !spec.idempotent)
|
|
427
|
+
throw error;
|
|
428
|
+
output = await bridge.call(spec.name, args, spec.timeout);
|
|
429
|
+
}
|
|
430
|
+
return { content: saveFiles(output) };
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
const message = error.message === BRIDGE_RESET
|
|
434
|
+
? "The connection to Hoverify dropped during the call. Try again."
|
|
435
|
+
: error.message;
|
|
436
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
export const TOOL_NAMES = TOOLS.map(tool => tool.name);
|