framewatch-mcp-server 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 +537 -0
- package/dist/constants.d.ts +172 -0
- package/dist/constants.js +168 -0
- package/dist/constants.js.map +1 -0
- package/dist/engine/browser.d.ts +56 -0
- package/dist/engine/browser.js +142 -0
- package/dist/engine/browser.js.map +1 -0
- package/dist/engine/differ.d.ts +88 -0
- package/dist/engine/differ.js +373 -0
- package/dist/engine/differ.js.map +1 -0
- package/dist/engine/interaction.d.ts +76 -0
- package/dist/engine/interaction.js +254 -0
- package/dist/engine/interaction.js.map +1 -0
- package/dist/engine/layers/console.d.ts +63 -0
- package/dist/engine/layers/console.js +118 -0
- package/dist/engine/layers/console.js.map +1 -0
- package/dist/engine/layers/dom.d.ts +53 -0
- package/dist/engine/layers/dom.js +282 -0
- package/dist/engine/layers/dom.js.map +1 -0
- package/dist/engine/layers/index.d.ts +95 -0
- package/dist/engine/layers/index.js +184 -0
- package/dist/engine/layers/index.js.map +1 -0
- package/dist/engine/layers/network.d.ts +62 -0
- package/dist/engine/layers/network.js +169 -0
- package/dist/engine/layers/network.js.map +1 -0
- package/dist/engine/layers/performance.d.ts +55 -0
- package/dist/engine/layers/performance.js +215 -0
- package/dist/engine/layers/performance.js.map +1 -0
- package/dist/engine/layers/probe.d.ts +50 -0
- package/dist/engine/layers/probe.js +39 -0
- package/dist/engine/layers/probe.js.map +1 -0
- package/dist/engine/layers/session.d.ts +46 -0
- package/dist/engine/layers/session.js +131 -0
- package/dist/engine/layers/session.js.map +1 -0
- package/dist/engine/recorder.d.ts +61 -0
- package/dist/engine/recorder.js +256 -0
- package/dist/engine/recorder.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/accessibility.d.ts +140 -0
- package/dist/tools/accessibility.js +357 -0
- package/dist/tools/accessibility.js.map +1 -0
- package/dist/tools/capture.d.ts +279 -0
- package/dist/tools/capture.js +275 -0
- package/dist/tools/capture.js.map +1 -0
- package/dist/tools/compare.d.ts +86 -0
- package/dist/tools/compare.js +247 -0
- package/dist/tools/compare.js.map +1 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.js +25 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/interact.d.ts +160 -0
- package/dist/tools/interact.js +203 -0
- package/dist/tools/interact.js.map +1 -0
- package/dist/tools/responsive.d.ts +89 -0
- package/dist/tools/responsive.js +197 -0
- package/dist/tools/responsive.js.map +1 -0
- package/dist/tools/screenshot.d.ts +76 -0
- package/dist/tools/screenshot.js +117 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/server.d.ts +89 -0
- package/dist/tools/server.js +201 -0
- package/dist/tools/server.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/bounded-log.d.ts +41 -0
- package/dist/utils/bounded-log.js +78 -0
- package/dist/utils/bounded-log.js.map +1 -0
- package/dist/utils/format.d.ts +56 -0
- package/dist/utils/format.js +130 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/image.d.ts +44 -0
- package/dist/utils/image.js +81 -0
- package/dist/utils/image.js.map +1 -0
- package/dist/utils/server-process.d.ts +84 -0
- package/dist/utils/server-process.js +251 -0
- package/dist/utils/server-process.js.map +1 -0
- package/package.json +74 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_INTERACT_WAIT_MS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS, } from "../constants.js";
|
|
3
|
+
import { getSessionPage, withSessionLock } from "../engine/browser.js";
|
|
4
|
+
import { buildDiffCards } from "../engine/differ.js";
|
|
5
|
+
import { applyContext, layersFor, summariseContext } from "../engine/layers/index.js";
|
|
6
|
+
import { INTERACT_ACTIONS, describeInteraction, executeInteraction, interactionFieldShape, needsTouch, refineInteraction, } from "../engine/interaction.js";
|
|
7
|
+
import { formatCardMeta } from "../utils/format.js";
|
|
8
|
+
export const INTERACT_TOOL_NAME = "framewatch_interact";
|
|
9
|
+
export const interactInputShape = {
|
|
10
|
+
action: z
|
|
11
|
+
.enum(INTERACT_ACTIONS)
|
|
12
|
+
.describe("What to do: click, tap, type, scroll, swipe, navigate, select or hover"),
|
|
13
|
+
...interactionFieldShape,
|
|
14
|
+
url: z
|
|
15
|
+
.string()
|
|
16
|
+
.url()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Open this URL first. Omit to act on the page left open by the previous call."),
|
|
19
|
+
wait_ms: z
|
|
20
|
+
.number()
|
|
21
|
+
.int()
|
|
22
|
+
.min(0)
|
|
23
|
+
.default(DEFAULT_INTERACT_WAIT_MS)
|
|
24
|
+
.describe("Wait time (ms) after the action before the 'after' screenshot, so animations can settle"),
|
|
25
|
+
timeout_ms: z
|
|
26
|
+
.number()
|
|
27
|
+
.int()
|
|
28
|
+
.min(1)
|
|
29
|
+
.default(SELECTOR_TIMEOUT_MS)
|
|
30
|
+
.describe("Max time (ms) to wait for the target element"),
|
|
31
|
+
viewport: z
|
|
32
|
+
.object({
|
|
33
|
+
width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH),
|
|
34
|
+
height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT),
|
|
35
|
+
})
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Resize the page to this before acting (defaults to leaving it as it is)"),
|
|
38
|
+
include_console: z
|
|
39
|
+
.boolean()
|
|
40
|
+
.default(true)
|
|
41
|
+
.describe("Report console logs, uncaught errors and unhandled rejections the action caused"),
|
|
42
|
+
include_network: z
|
|
43
|
+
.boolean()
|
|
44
|
+
.default(false)
|
|
45
|
+
.describe("Report network requests (method, url, status, duration) the action caused"),
|
|
46
|
+
include_dom: z
|
|
47
|
+
.boolean()
|
|
48
|
+
.default(false)
|
|
49
|
+
.describe("Report the DOM mutations the action caused — which elements were added, removed or restyled"),
|
|
50
|
+
include_performance: z
|
|
51
|
+
.boolean()
|
|
52
|
+
.default(false)
|
|
53
|
+
.describe("Report paint timing and layout shifts measured around the action"),
|
|
54
|
+
};
|
|
55
|
+
export const interactInputSchema = z.object(interactInputShape).superRefine(refineInteraction);
|
|
56
|
+
/**
|
|
57
|
+
* Perform one interaction on the current page and show what it did.
|
|
58
|
+
*
|
|
59
|
+
* Unlike every other FrameWatch tool this one is stateful on purpose: the page
|
|
60
|
+
* stays open between calls (see `getSessionPage`), which is what makes
|
|
61
|
+
* click → look → type → look iteration possible. Pass `url` to open or move
|
|
62
|
+
* the page; omit it to keep working on what is already there.
|
|
63
|
+
*
|
|
64
|
+
* The result is a two-frame diff card sequence — before, after, and the
|
|
65
|
+
* change region between them — reusing the same diff engine as
|
|
66
|
+
* `framewatch_capture` so the change bbox and crop mean the same thing in
|
|
67
|
+
* both tools.
|
|
68
|
+
*
|
|
69
|
+
* Calls are serialised through `withSessionLock`, which every tool that
|
|
70
|
+
* touches the session page shares: one page, one hand.
|
|
71
|
+
*/
|
|
72
|
+
export function performInteraction(rawInput) {
|
|
73
|
+
return withSessionLock(() => runInteraction(rawInput));
|
|
74
|
+
}
|
|
75
|
+
async function runInteraction(rawInput) {
|
|
76
|
+
const parsed = interactInputSchema.safeParse(rawInput);
|
|
77
|
+
if (!parsed.success) {
|
|
78
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
79
|
+
return errorResult(`Interaction failed: invalid input — ${issues}`);
|
|
80
|
+
}
|
|
81
|
+
const input = parsed.data;
|
|
82
|
+
const step = {
|
|
83
|
+
action: input.action,
|
|
84
|
+
...(input.selector !== undefined ? { selector: input.selector } : {}),
|
|
85
|
+
...(input.value !== undefined ? { value: input.value } : {}),
|
|
86
|
+
...(input.x !== undefined ? { x: input.x } : {}),
|
|
87
|
+
...(input.y !== undefined ? { y: input.y } : {}),
|
|
88
|
+
...(input.delta_x !== undefined ? { delta_x: input.delta_x } : {}),
|
|
89
|
+
...(input.delta_y !== undefined ? { delta_y: input.delta_y } : {}),
|
|
90
|
+
};
|
|
91
|
+
const flags = {
|
|
92
|
+
console: input.include_console,
|
|
93
|
+
network: input.include_network,
|
|
94
|
+
dom: input.include_dom,
|
|
95
|
+
performance: input.include_performance,
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
const { page, previousUrl } = await getSessionPage({
|
|
99
|
+
...(input.viewport ? { viewport: input.viewport } : {}),
|
|
100
|
+
hasTouch: needsTouch([step]),
|
|
101
|
+
});
|
|
102
|
+
// The layers belong to the page, not to this call (see SessionLayers), so
|
|
103
|
+
// they are installed once and emptied here — this call reports what this
|
|
104
|
+
// action caused, not what the last twenty did. Both happen before the
|
|
105
|
+
// navigation, so a page opened by `url` has its load watched too.
|
|
106
|
+
const layers = layersFor(page);
|
|
107
|
+
await layers.ensure(flags);
|
|
108
|
+
layers.clear();
|
|
109
|
+
// `previousUrl` means the session had to be reopened to enable touch; go
|
|
110
|
+
// back to where the user was unless they asked for somewhere else.
|
|
111
|
+
const target = input.url ?? previousUrl;
|
|
112
|
+
if (target !== undefined) {
|
|
113
|
+
await page.goto(target, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
114
|
+
}
|
|
115
|
+
if (page.url() === "about:blank") {
|
|
116
|
+
return errorResult("Interaction failed: no page is open yet — pass `url` to open one (e.g. http://localhost:3000).");
|
|
117
|
+
}
|
|
118
|
+
const before = await page.screenshot({ type: "png" });
|
|
119
|
+
const startedAt = Date.now();
|
|
120
|
+
await executeInteraction(page, step, { timeout_ms: input.timeout_ms });
|
|
121
|
+
if (input.wait_ms > 0) {
|
|
122
|
+
await page.waitForTimeout(input.wait_ms);
|
|
123
|
+
}
|
|
124
|
+
const after = await page.screenshot({ type: "png" });
|
|
125
|
+
const frames = [
|
|
126
|
+
{ buffer: before, timestamp_ms: 0, is_interaction: false },
|
|
127
|
+
{ buffer: after, timestamp_ms: Date.now() - startedAt, is_interaction: true },
|
|
128
|
+
];
|
|
129
|
+
// sensitivity 0 keeps both frames however small the change is — the point
|
|
130
|
+
// of this tool is to show what one action did, including "nothing".
|
|
131
|
+
const { cards } = await buildDiffCards(frames, { sensitivity: 0, max_frames: 2 });
|
|
132
|
+
if (cards.length < 2) {
|
|
133
|
+
return errorResult(`Interaction failed: ${describeInteraction(step)} ran, but the screenshots could not be compared.`);
|
|
134
|
+
}
|
|
135
|
+
// `startedAt` is the instant of the "before" frame, which puts the split
|
|
136
|
+
// exactly where it belongs: everything from opening the page lands on the
|
|
137
|
+
// before card (negative, i.e. "how we got here") and everything the action
|
|
138
|
+
// caused lands on the after card.
|
|
139
|
+
const context = layers.collect(startedAt, flags);
|
|
140
|
+
applyContext(cards, context);
|
|
141
|
+
const [beforeCard, afterCard] = cards;
|
|
142
|
+
const headline = [
|
|
143
|
+
summarise(step, page.url(), afterCard.change_region?.change_percent ?? 0, previousUrl !== undefined),
|
|
144
|
+
...(summariseContext(context, cards.length) ?? []),
|
|
145
|
+
].join("\n");
|
|
146
|
+
const content = [
|
|
147
|
+
{ type: "text", text: headline },
|
|
148
|
+
{ type: "image", data: beforeCard.full_frame, mimeType: "image/png" },
|
|
149
|
+
{ type: "text", text: `Before — ${formatCardMeta(beforeCard)}` },
|
|
150
|
+
{ type: "image", data: afterCard.full_frame, mimeType: "image/png" },
|
|
151
|
+
{ type: "text", text: `After — ${formatCardMeta(afterCard)}` },
|
|
152
|
+
];
|
|
153
|
+
const crop = afterCard.change_region?.crop;
|
|
154
|
+
if (crop) {
|
|
155
|
+
content.push({ type: "image", data: crop, mimeType: "image/png" });
|
|
156
|
+
}
|
|
157
|
+
return { content };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
return errorResult(describeInteractFailure(input, error));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** One line: what was done, where, and how much of the frame it moved. */
|
|
164
|
+
function summarise(step, url, changePercent, reopened) {
|
|
165
|
+
const note = reopened ? " (page reopened to enable touch — page state was reset)" : "";
|
|
166
|
+
const changed = changePercent > 0 ? `${changePercent.toFixed(1)}% of the frame changed` : "no visual change";
|
|
167
|
+
return `${describeInteraction(step)} on ${url}${note} — ${changed}`;
|
|
168
|
+
}
|
|
169
|
+
function errorResult(text) {
|
|
170
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Turn a failure into one actionable line. Interaction errors already name the
|
|
174
|
+
* step and the reason (see `executeInteraction`), so they are passed through
|
|
175
|
+
* with the page appended for context; everything else is a navigation or
|
|
176
|
+
* browser problem, worded as in the other tools.
|
|
177
|
+
*/
|
|
178
|
+
export function describeInteractFailure(input, error) {
|
|
179
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
180
|
+
const firstLine = message.split("\n")[0];
|
|
181
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
182
|
+
return ("Interaction failed: Playwright's Chromium browser is not installed. " +
|
|
183
|
+
`Run \`npx playwright install chromium\` and try again. (${firstLine})`);
|
|
184
|
+
}
|
|
185
|
+
if (/^page\.goto:/.test(message)) {
|
|
186
|
+
return `Interaction failed: could not open ${input.url ?? "the page"} — ${firstLine}`;
|
|
187
|
+
}
|
|
188
|
+
return input.url ? `${firstLine} (page: ${input.url})` : firstLine;
|
|
189
|
+
}
|
|
190
|
+
export function registerInteractTool(server) {
|
|
191
|
+
server.registerTool(INTERACT_TOOL_NAME, {
|
|
192
|
+
title: "Interact",
|
|
193
|
+
description: "Perform one interaction (click, tap, type, scroll, swipe, hover, select, navigate) on a page and return " +
|
|
194
|
+
"before/after screenshots plus a crop of what changed. The page stays open between calls, so you can " +
|
|
195
|
+
"click, look, type and look again without replaying the whole flow — pass `url` only to open or move it. " +
|
|
196
|
+
"Each call also reports the context its own action produced: console output and uncaught errors (on by " +
|
|
197
|
+
"default), plus network requests, DOM mutations and paint/layout-shift timing via `include_network`, " +
|
|
198
|
+
"`include_dom` and `include_performance` — that is how you find out why a click did nothing.",
|
|
199
|
+
inputSchema: interactInputShape,
|
|
200
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
201
|
+
}, async (args) => performInteraction(args));
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=interact.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interact.js","sourceRoot":"","sources":["../../src/tools/interact.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from \"zod\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n DEFAULT_INTERACT_WAIT_MS,\n MAX_VIEWPORT_HEIGHT,\n MAX_VIEWPORT_WIDTH,\n NAVIGATION_TIMEOUT_MS,\n SELECTOR_TIMEOUT_MS,\n} from \"../constants.js\";\nimport { getSessionPage, withSessionLock } from \"../engine/browser.js\";\nimport { buildDiffCards } from \"../engine/differ.js\";\nimport { applyContext, layersFor, summariseContext, type LayerFlags } from \"../engine/layers/index.js\";\nimport {\n INTERACT_ACTIONS,\n describeInteraction,\n executeInteraction,\n interactionFieldShape,\n needsTouch,\n refineInteraction,\n type Interaction,\n} from \"../engine/interaction.js\";\nimport type { RawFrame } from \"../types.js\";\nimport { formatCardMeta } from \"../utils/format.js\";\n\nexport const INTERACT_TOOL_NAME = \"framewatch_interact\";\n\nexport const interactInputShape = {\n action: z\n .enum(INTERACT_ACTIONS)\n .describe(\"What to do: click, tap, type, scroll, swipe, navigate, select or hover\"),\n ...interactionFieldShape,\n url: z\n .string()\n .url()\n .optional()\n .describe(\"Open this URL first. Omit to act on the page left open by the previous call.\"),\n wait_ms: z\n .number()\n .int()\n .min(0)\n .default(DEFAULT_INTERACT_WAIT_MS)\n .describe(\"Wait time (ms) after the action before the 'after' screenshot, so animations can settle\"),\n timeout_ms: z\n .number()\n .int()\n .min(1)\n .default(SELECTOR_TIMEOUT_MS)\n .describe(\"Max time (ms) to wait for the target element\"),\n viewport: z\n .object({\n width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH),\n height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT),\n })\n .optional()\n .describe(\"Resize the page to this before acting (defaults to leaving it as it is)\"),\n include_console: z\n .boolean()\n .default(true)\n .describe(\"Report console logs, uncaught errors and unhandled rejections the action caused\"),\n include_network: z\n .boolean()\n .default(false)\n .describe(\"Report network requests (method, url, status, duration) the action caused\"),\n include_dom: z\n .boolean()\n .default(false)\n .describe(\"Report the DOM mutations the action caused — which elements were added, removed or restyled\"),\n include_performance: z\n .boolean()\n .default(false)\n .describe(\"Report paint timing and layout shifts measured around the action\"),\n};\n\nexport const interactInputSchema = z.object(interactInputShape).superRefine(refineInteraction);\nexport type InteractInput = z.input<typeof interactInputSchema>;\n\n/**\n * Perform one interaction on the current page and show what it did.\n *\n * Unlike every other FrameWatch tool this one is stateful on purpose: the page\n * stays open between calls (see `getSessionPage`), which is what makes\n * click → look → type → look iteration possible. Pass `url` to open or move\n * the page; omit it to keep working on what is already there.\n *\n * The result is a two-frame diff card sequence — before, after, and the\n * change region between them — reusing the same diff engine as\n * `framewatch_capture` so the change bbox and crop mean the same thing in\n * both tools.\n *\n * Calls are serialised through `withSessionLock`, which every tool that\n * touches the session page shares: one page, one hand.\n */\nexport function performInteraction(rawInput: InteractInput): Promise<CallToolResult> {\n return withSessionLock(() => runInteraction(rawInput));\n}\n\nasync function runInteraction(rawInput: InteractInput): Promise<CallToolResult> {\n const parsed = interactInputSchema.safeParse(rawInput);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"input\"}: ${i.message}`).join(\"; \");\n return errorResult(`Interaction failed: invalid input — ${issues}`);\n }\n const input = parsed.data;\n const step: Interaction = {\n action: input.action,\n ...(input.selector !== undefined ? { selector: input.selector } : {}),\n ...(input.value !== undefined ? { value: input.value } : {}),\n ...(input.x !== undefined ? { x: input.x } : {}),\n ...(input.y !== undefined ? { y: input.y } : {}),\n ...(input.delta_x !== undefined ? { delta_x: input.delta_x } : {}),\n ...(input.delta_y !== undefined ? { delta_y: input.delta_y } : {}),\n };\n\n const flags: LayerFlags = {\n console: input.include_console,\n network: input.include_network,\n dom: input.include_dom,\n performance: input.include_performance,\n };\n\n try {\n const { page, previousUrl } = await getSessionPage({\n ...(input.viewport ? { viewport: input.viewport } : {}),\n hasTouch: needsTouch([step]),\n });\n\n // The layers belong to the page, not to this call (see SessionLayers), so\n // they are installed once and emptied here — this call reports what this\n // action caused, not what the last twenty did. Both happen before the\n // navigation, so a page opened by `url` has its load watched too.\n const layers = layersFor(page);\n await layers.ensure(flags);\n layers.clear();\n\n // `previousUrl` means the session had to be reopened to enable touch; go\n // back to where the user was unless they asked for somewhere else.\n const target = input.url ?? previousUrl;\n if (target !== undefined) {\n await page.goto(target, { waitUntil: \"load\", timeout: NAVIGATION_TIMEOUT_MS });\n }\n if (page.url() === \"about:blank\") {\n return errorResult(\n \"Interaction failed: no page is open yet — pass `url` to open one (e.g. http://localhost:3000).\",\n );\n }\n\n const before = await page.screenshot({ type: \"png\" });\n const startedAt = Date.now();\n await executeInteraction(page, step, { timeout_ms: input.timeout_ms });\n if (input.wait_ms > 0) {\n await page.waitForTimeout(input.wait_ms);\n }\n const after = await page.screenshot({ type: \"png\" });\n\n const frames: RawFrame[] = [\n { buffer: before, timestamp_ms: 0, is_interaction: false },\n { buffer: after, timestamp_ms: Date.now() - startedAt, is_interaction: true },\n ];\n // sensitivity 0 keeps both frames however small the change is — the point\n // of this tool is to show what one action did, including \"nothing\".\n const { cards } = await buildDiffCards(frames, { sensitivity: 0, max_frames: 2 });\n if (cards.length < 2) {\n return errorResult(`Interaction failed: ${describeInteraction(step)} ran, but the screenshots could not be compared.`);\n }\n\n // `startedAt` is the instant of the \"before\" frame, which puts the split\n // exactly where it belongs: everything from opening the page lands on the\n // before card (negative, i.e. \"how we got here\") and everything the action\n // caused lands on the after card.\n const context = layers.collect(startedAt, flags);\n applyContext(cards, context);\n\n const [beforeCard, afterCard] = cards;\n const headline = [\n summarise(step, page.url(), afterCard.change_region?.change_percent ?? 0, previousUrl !== undefined),\n ...(summariseContext(context, cards.length) ?? []),\n ].join(\"\\n\");\n\n const content: CallToolResult[\"content\"] = [\n { type: \"text\", text: headline },\n { type: \"image\", data: beforeCard.full_frame, mimeType: \"image/png\" },\n { type: \"text\", text: `Before — ${formatCardMeta(beforeCard)}` },\n { type: \"image\", data: afterCard.full_frame, mimeType: \"image/png\" },\n { type: \"text\", text: `After — ${formatCardMeta(afterCard)}` },\n ];\n const crop = afterCard.change_region?.crop;\n if (crop) {\n content.push({ type: \"image\", data: crop, mimeType: \"image/png\" });\n }\n return { content };\n } catch (error) {\n return errorResult(describeInteractFailure(input, error));\n }\n}\n\n/** One line: what was done, where, and how much of the frame it moved. */\nfunction summarise(step: Interaction, url: string, changePercent: number, reopened: boolean): string {\n const note = reopened ? \" (page reopened to enable touch — page state was reset)\" : \"\";\n const changed = changePercent > 0 ? `${changePercent.toFixed(1)}% of the frame changed` : \"no visual change\";\n return `${describeInteraction(step)} on ${url}${note} — ${changed}`;\n}\n\nfunction errorResult(text: string): CallToolResult {\n return { isError: true, content: [{ type: \"text\", text }] };\n}\n\n/**\n * Turn a failure into one actionable line. Interaction errors already name the\n * step and the reason (see `executeInteraction`), so they are passed through\n * with the page appended for context; everything else is a navigation or\n * browser problem, worded as in the other tools.\n */\nexport function describeInteractFailure(input: { url?: string }, error: unknown): string {\n const message = error instanceof Error ? error.message : String(error);\n const firstLine = message.split(\"\\n\")[0];\n\n if (/Executable doesn't exist|browserType\\.launch/i.test(message)) {\n return (\n \"Interaction failed: Playwright's Chromium browser is not installed. \" +\n `Run \\`npx playwright install chromium\\` and try again. (${firstLine})`\n );\n }\n if (/^page\\.goto:/.test(message)) {\n return `Interaction failed: could not open ${input.url ?? \"the page\"} — ${firstLine}`;\n }\n return input.url ? `${firstLine} (page: ${input.url})` : firstLine;\n}\n\nexport function registerInteractTool(server: McpServer): void {\n server.registerTool(\n INTERACT_TOOL_NAME,\n {\n title: \"Interact\",\n description:\n \"Perform one interaction (click, tap, type, scroll, swipe, hover, select, navigate) on a page and return \" +\n \"before/after screenshots plus a crop of what changed. The page stays open between calls, so you can \" +\n \"click, look, type and look again without replaying the whole flow — pass `url` only to open or move it. \" +\n \"Each call also reports the context its own action produced: console output and uncaught errors (on by \" +\n \"default), plus network requests, DOM mutations and paint/layout-shift timing via `include_network`, \" +\n \"`include_dom` and `include_performance` — that is how you find out why a click did nothing.\",\n inputSchema: interactInputShape,\n annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },\n },\n async (args) => performInteraction(args),\n );\n}\n"]}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
export declare const RESPONSIVE_TOOL_NAME = "framewatch_responsive";
|
|
5
|
+
export declare const responsiveInputShape: {
|
|
6
|
+
url: z.ZodString;
|
|
7
|
+
viewports: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
width: z.ZodNumber;
|
|
10
|
+
height: z.ZodNumber;
|
|
11
|
+
}, "strip", z.ZodTypeAny, {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
name: string;
|
|
15
|
+
}, {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
name: string;
|
|
19
|
+
}>, "many">>;
|
|
20
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
21
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
22
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
23
|
+
};
|
|
24
|
+
export declare const responsiveInputSchema: z.ZodObject<{
|
|
25
|
+
url: z.ZodString;
|
|
26
|
+
viewports: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
27
|
+
name: z.ZodString;
|
|
28
|
+
width: z.ZodNumber;
|
|
29
|
+
height: z.ZodNumber;
|
|
30
|
+
}, "strip", z.ZodTypeAny, {
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
name: string;
|
|
34
|
+
}, {
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
name: string;
|
|
38
|
+
}>, "many">>;
|
|
39
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
40
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
41
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
42
|
+
}, "strip", z.ZodTypeAny, {
|
|
43
|
+
url: string;
|
|
44
|
+
wait_ms: number;
|
|
45
|
+
wait_for_timeout_ms: number;
|
|
46
|
+
viewports: {
|
|
47
|
+
width: number;
|
|
48
|
+
height: number;
|
|
49
|
+
name: string;
|
|
50
|
+
}[];
|
|
51
|
+
wait_for?: string | undefined;
|
|
52
|
+
}, {
|
|
53
|
+
url: string;
|
|
54
|
+
wait_ms?: number | undefined;
|
|
55
|
+
wait_for?: string | undefined;
|
|
56
|
+
wait_for_timeout_ms?: number | undefined;
|
|
57
|
+
viewports?: {
|
|
58
|
+
width: number;
|
|
59
|
+
height: number;
|
|
60
|
+
name: string;
|
|
61
|
+
}[] | undefined;
|
|
62
|
+
}>;
|
|
63
|
+
export type ResponsiveInput = z.input<typeof responsiveInputSchema>;
|
|
64
|
+
/**
|
|
65
|
+
* Capture one page at several viewport sizes and return one screenshot per
|
|
66
|
+
* size, labelled, so a whole responsive range can be reviewed in a single
|
|
67
|
+
* look.
|
|
68
|
+
*
|
|
69
|
+
* Each viewport gets its own browser context rather than one page being
|
|
70
|
+
* resized, for two reasons: a page that has already laid itself out at 1440px
|
|
71
|
+
* often keeps state a genuine mobile visitor never had (a menu built by a
|
|
72
|
+
* matchMedia listener that ran once), and independent contexts can load
|
|
73
|
+
* concurrently — three 2s waits cost 2s, not 6s.
|
|
74
|
+
*
|
|
75
|
+
* A viewport that fails is reported next to the ones that worked instead of
|
|
76
|
+
* failing the call: "desktop is fine, mobile times out" is itself the finding.
|
|
77
|
+
* Only a run where every viewport failed comes back as an error.
|
|
78
|
+
*/
|
|
79
|
+
export declare function captureResponsive(rawInput: ResponsiveInput): Promise<CallToolResult>;
|
|
80
|
+
/**
|
|
81
|
+
* One actionable line for a viewport that failed. Mirrors `describeFailure` in
|
|
82
|
+
* screenshot.ts: match on the failing Playwright call, never on substrings of
|
|
83
|
+
* a user-supplied selector.
|
|
84
|
+
*/
|
|
85
|
+
export declare function describeViewportFailure(input: {
|
|
86
|
+
wait_for?: string;
|
|
87
|
+
wait_for_timeout_ms: number;
|
|
88
|
+
}, error: unknown): string;
|
|
89
|
+
export declare function registerResponsiveTool(server: McpServer): void;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_RESPONSIVE_VIEWPORTS, DEFAULT_RESPONSIVE_WAIT_MS, MAX_RESPONSIVE_VIEWPORTS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, NAVIGATION_TIMEOUT_MS, OVERFLOW_TOLERANCE_PX, SELECTOR_TIMEOUT_MS, } from "../constants.js";
|
|
3
|
+
import { withPage } from "../engine/browser.js";
|
|
4
|
+
import { getDimensions, resizeForOutput, toBase64 } from "../utils/image.js";
|
|
5
|
+
export const RESPONSIVE_TOOL_NAME = "framewatch_responsive";
|
|
6
|
+
const viewportSchema = z.object({
|
|
7
|
+
name: z.string().min(1).describe("Label for this size, e.g. 'mobile', 'tablet', 'desktop'"),
|
|
8
|
+
width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH),
|
|
9
|
+
height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT),
|
|
10
|
+
});
|
|
11
|
+
export const responsiveInputShape = {
|
|
12
|
+
url: z.string().url().describe("URL to capture, e.g. http://localhost:3000 (http, https and file URLs are accepted)"),
|
|
13
|
+
viewports: z
|
|
14
|
+
.array(viewportSchema)
|
|
15
|
+
.min(1)
|
|
16
|
+
.max(MAX_RESPONSIVE_VIEWPORTS)
|
|
17
|
+
.default([...DEFAULT_RESPONSIVE_VIEWPORTS])
|
|
18
|
+
.describe("Viewport sizes to capture (defaults to mobile 375x812, tablet 768x1024, desktop 1440x900)"),
|
|
19
|
+
wait_ms: z
|
|
20
|
+
.number()
|
|
21
|
+
.int()
|
|
22
|
+
.min(0)
|
|
23
|
+
.default(DEFAULT_RESPONSIVE_WAIT_MS)
|
|
24
|
+
.describe("Wait time (ms) after page load before each screenshot"),
|
|
25
|
+
wait_for: z.string().optional().describe("CSS selector to wait for (visible) before each screenshot"),
|
|
26
|
+
wait_for_timeout_ms: z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.min(1)
|
|
30
|
+
.default(SELECTOR_TIMEOUT_MS)
|
|
31
|
+
.describe("Max time (ms) to wait for `wait_for` to appear (must be > 0)"),
|
|
32
|
+
};
|
|
33
|
+
export const responsiveInputSchema = z.object(responsiveInputShape);
|
|
34
|
+
/**
|
|
35
|
+
* Capture one page at several viewport sizes and return one screenshot per
|
|
36
|
+
* size, labelled, so a whole responsive range can be reviewed in a single
|
|
37
|
+
* look.
|
|
38
|
+
*
|
|
39
|
+
* Each viewport gets its own browser context rather than one page being
|
|
40
|
+
* resized, for two reasons: a page that has already laid itself out at 1440px
|
|
41
|
+
* often keeps state a genuine mobile visitor never had (a menu built by a
|
|
42
|
+
* matchMedia listener that ran once), and independent contexts can load
|
|
43
|
+
* concurrently — three 2s waits cost 2s, not 6s.
|
|
44
|
+
*
|
|
45
|
+
* A viewport that fails is reported next to the ones that worked instead of
|
|
46
|
+
* failing the call: "desktop is fine, mobile times out" is itself the finding.
|
|
47
|
+
* Only a run where every viewport failed comes back as an error.
|
|
48
|
+
*/
|
|
49
|
+
export async function captureResponsive(rawInput) {
|
|
50
|
+
const parsed = responsiveInputSchema.safeParse(rawInput);
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
53
|
+
return errorResult(`Responsive capture failed: invalid input — ${issues}`);
|
|
54
|
+
}
|
|
55
|
+
const input = parsed.data;
|
|
56
|
+
const shots = await Promise.all(input.viewports.map((viewport) => captureOne(input, viewport)));
|
|
57
|
+
const captured = shots.filter((shot) => shot.png !== undefined);
|
|
58
|
+
if (captured.length === 0) {
|
|
59
|
+
const reasons = shots.map((shot) => `${shot.viewport.name}: ${shot.error ?? "no screenshot"}`).join("; ");
|
|
60
|
+
return errorResult(`Responsive capture of ${input.url} failed at every viewport — ${reasons}`);
|
|
61
|
+
}
|
|
62
|
+
const content = [
|
|
63
|
+
{
|
|
64
|
+
type: "text",
|
|
65
|
+
text: summarise(input.url, shots),
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
for (const shot of shots) {
|
|
69
|
+
if (!shot.png) {
|
|
70
|
+
content.push({ type: "text", text: `${label(shot.viewport)} — failed: ${shot.error ?? "no screenshot"}` });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const resized = await resizeForOutput(shot.png);
|
|
74
|
+
const { width, height } = await getDimensions(resized);
|
|
75
|
+
content.push({ type: "image", data: toBase64(resized), mimeType: "image/png" });
|
|
76
|
+
content.push({ type: "text", text: describeShot(shot, width, height) });
|
|
77
|
+
}
|
|
78
|
+
return { content };
|
|
79
|
+
}
|
|
80
|
+
/** Screenshot `url` at one viewport. Never throws — a failure is part of the result. */
|
|
81
|
+
async function captureOne(input, viewport) {
|
|
82
|
+
try {
|
|
83
|
+
const result = await withPage({ viewport: { width: viewport.width, height: viewport.height } }, async (page) => {
|
|
84
|
+
await page.goto(input.url, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
85
|
+
if (input.wait_for) {
|
|
86
|
+
await page.waitForSelector(input.wait_for, { state: "visible", timeout: input.wait_for_timeout_ms });
|
|
87
|
+
}
|
|
88
|
+
if (input.wait_ms > 0) {
|
|
89
|
+
await page.waitForTimeout(input.wait_ms);
|
|
90
|
+
}
|
|
91
|
+
const png = await page.screenshot({ type: "png" });
|
|
92
|
+
return { png, layout: await readLayout(page) };
|
|
93
|
+
});
|
|
94
|
+
return { viewport, ...result };
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
return { viewport, error: describeViewportFailure(input, error) };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The document's laid-out size against the viewport it was given. Horizontal
|
|
102
|
+
* overflow is the classic responsive bug and it is invisible in a screenshot —
|
|
103
|
+
* the content that sticks out is simply cropped away — so it is measured
|
|
104
|
+
* rather than left to the eye.
|
|
105
|
+
*
|
|
106
|
+
* Cosmetic: a page that dies before this is read still returns its screenshot.
|
|
107
|
+
*/
|
|
108
|
+
async function readLayout(page) {
|
|
109
|
+
try {
|
|
110
|
+
// `globalThis as any` rather than the DOM globals: this package is
|
|
111
|
+
// compiled with the Node lib only, and the page it lands in may be
|
|
112
|
+
// mid-teardown, so nothing here can be assumed to exist.
|
|
113
|
+
return await page.evaluate(() => {
|
|
114
|
+
const el = globalThis.document?.documentElement;
|
|
115
|
+
if (!el)
|
|
116
|
+
return undefined;
|
|
117
|
+
return {
|
|
118
|
+
scroll_width: Math.round(el.scrollWidth),
|
|
119
|
+
client_width: Math.round(el.clientWidth),
|
|
120
|
+
scroll_height: Math.round(el.scrollHeight),
|
|
121
|
+
client_height: Math.round(el.clientHeight),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function label(viewport) {
|
|
130
|
+
return `${viewport.name} ${viewport.width}x${viewport.height}`;
|
|
131
|
+
}
|
|
132
|
+
/** One line per viewport: what it is, how big the image is, and whether the content fits. */
|
|
133
|
+
function describeShot(shot, imageWidth, imageHeight) {
|
|
134
|
+
const parts = [label(shot.viewport), `image ${imageWidth}x${imageHeight}`];
|
|
135
|
+
const layout = shot.layout;
|
|
136
|
+
if (layout) {
|
|
137
|
+
if (layout.scroll_width > layout.client_width + OVERFLOW_TOLERANCE_PX) {
|
|
138
|
+
parts.push(`horizontal overflow: content is ${layout.scroll_width}px wide in a ${layout.client_width}px viewport ` +
|
|
139
|
+
`(+${layout.scroll_width - layout.client_width}px)`);
|
|
140
|
+
}
|
|
141
|
+
if (layout.scroll_height > layout.client_height) {
|
|
142
|
+
parts.push(`page scrolls to ${layout.scroll_height}px (${scrolls(layout)} screens)`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return parts.join(" — ");
|
|
146
|
+
}
|
|
147
|
+
function scrolls(layout) {
|
|
148
|
+
return (layout.scroll_height / Math.max(1, layout.client_height)).toFixed(1);
|
|
149
|
+
}
|
|
150
|
+
/** The opening line: what was captured, and an up-front warning about anything that overflowed. */
|
|
151
|
+
function summarise(url, shots) {
|
|
152
|
+
const captured = shots.filter((shot) => shot.png !== undefined);
|
|
153
|
+
const failed = shots.filter((shot) => shot.png === undefined);
|
|
154
|
+
const names = captured.map((shot) => label(shot.viewport)).join(", ");
|
|
155
|
+
const lines = [`Captured ${url} at ${captured.length} of ${shots.length} viewports: ${names}`];
|
|
156
|
+
if (failed.length > 0) {
|
|
157
|
+
lines.push(`Failed: ${failed.map((shot) => shot.viewport.name).join(", ")}`);
|
|
158
|
+
}
|
|
159
|
+
const overflowing = captured.filter((shot) => shot.layout !== undefined && shot.layout.scroll_width > shot.layout.client_width + OVERFLOW_TOLERANCE_PX);
|
|
160
|
+
if (overflowing.length > 0) {
|
|
161
|
+
lines.push(`Horizontal overflow at ${overflowing.map((shot) => shot.viewport.name).join(", ")} — ` +
|
|
162
|
+
"content is wider than the viewport, so something is sticking out past the right edge.");
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
function errorResult(text) {
|
|
167
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* One actionable line for a viewport that failed. Mirrors `describeFailure` in
|
|
171
|
+
* screenshot.ts: match on the failing Playwright call, never on substrings of
|
|
172
|
+
* a user-supplied selector.
|
|
173
|
+
*/
|
|
174
|
+
export function describeViewportFailure(input, error) {
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
+
const firstLine = message.split("\n")[0];
|
|
177
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
178
|
+
return "Playwright's Chromium browser is not installed. Run `npx playwright install chromium` and try again.";
|
|
179
|
+
}
|
|
180
|
+
if (input.wait_for && /^page\.waitForSelector:/.test(message)) {
|
|
181
|
+
return `selector "${input.wait_for}" did not become visible within ${input.wait_for_timeout_ms}ms`;
|
|
182
|
+
}
|
|
183
|
+
return firstLine;
|
|
184
|
+
}
|
|
185
|
+
export function registerResponsiveTool(server) {
|
|
186
|
+
server.registerTool(RESPONSIVE_TOOL_NAME, {
|
|
187
|
+
title: "Responsive",
|
|
188
|
+
description: "Screenshot the same page at several viewport sizes in one call (mobile, tablet and desktop by default) " +
|
|
189
|
+
"and return one labelled image per size. Each size loads in its own fresh browser context, so a mobile " +
|
|
190
|
+
"shot is what a phone would really get rather than a resized desktop layout. Content that is wider than " +
|
|
191
|
+
"its viewport is reported as horizontal overflow — the commonest responsive bug, and one a screenshot " +
|
|
192
|
+
"alone hides because the overflowing part is simply cropped off.",
|
|
193
|
+
inputSchema: responsiveInputShape,
|
|
194
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
195
|
+
}, async (args) => captureResponsive(args));
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=responsive.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"responsive.js","sourceRoot":"","sources":["../../src/tools/responsive.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from \"zod\";\nimport type { Page } from \"playwright\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n DEFAULT_RESPONSIVE_VIEWPORTS,\n DEFAULT_RESPONSIVE_WAIT_MS,\n MAX_RESPONSIVE_VIEWPORTS,\n MAX_VIEWPORT_HEIGHT,\n MAX_VIEWPORT_WIDTH,\n NAVIGATION_TIMEOUT_MS,\n OVERFLOW_TOLERANCE_PX,\n SELECTOR_TIMEOUT_MS,\n} from \"../constants.js\";\nimport { withPage } from \"../engine/browser.js\";\nimport { getDimensions, resizeForOutput, toBase64 } from \"../utils/image.js\";\n\nexport const RESPONSIVE_TOOL_NAME = \"framewatch_responsive\";\n\nconst viewportSchema = z.object({\n name: z.string().min(1).describe(\"Label for this size, e.g. 'mobile', 'tablet', 'desktop'\"),\n width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH),\n height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT),\n});\n\nexport const responsiveInputShape = {\n url: z.string().url().describe(\"URL to capture, e.g. http://localhost:3000 (http, https and file URLs are accepted)\"),\n viewports: z\n .array(viewportSchema)\n .min(1)\n .max(MAX_RESPONSIVE_VIEWPORTS)\n .default([...DEFAULT_RESPONSIVE_VIEWPORTS])\n .describe(\"Viewport sizes to capture (defaults to mobile 375x812, tablet 768x1024, desktop 1440x900)\"),\n wait_ms: z\n .number()\n .int()\n .min(0)\n .default(DEFAULT_RESPONSIVE_WAIT_MS)\n .describe(\"Wait time (ms) after page load before each screenshot\"),\n wait_for: z.string().optional().describe(\"CSS selector to wait for (visible) before each screenshot\"),\n wait_for_timeout_ms: z\n .number()\n .int()\n .min(1)\n .default(SELECTOR_TIMEOUT_MS)\n .describe(\"Max time (ms) to wait for `wait_for` to appear (must be > 0)\"),\n};\n\nexport const responsiveInputSchema = z.object(responsiveInputShape);\nexport type ResponsiveInput = z.input<typeof responsiveInputSchema>;\ntype ParsedViewport = z.output<typeof viewportSchema>;\n\n/** How one viewport turned out. Either it produced a shot, or it produced a reason. */\ninterface ViewportShot {\n viewport: ParsedViewport;\n png?: Buffer;\n /** Document width vs viewport width, for the overflow check. */\n layout?: LayoutInfo;\n error?: string;\n}\n\ninterface LayoutInfo {\n scroll_width: number;\n client_width: number;\n scroll_height: number;\n client_height: number;\n}\n\n/**\n * Capture one page at several viewport sizes and return one screenshot per\n * size, labelled, so a whole responsive range can be reviewed in a single\n * look.\n *\n * Each viewport gets its own browser context rather than one page being\n * resized, for two reasons: a page that has already laid itself out at 1440px\n * often keeps state a genuine mobile visitor never had (a menu built by a\n * matchMedia listener that ran once), and independent contexts can load\n * concurrently — three 2s waits cost 2s, not 6s.\n *\n * A viewport that fails is reported next to the ones that worked instead of\n * failing the call: \"desktop is fine, mobile times out\" is itself the finding.\n * Only a run where every viewport failed comes back as an error.\n */\nexport async function captureResponsive(rawInput: ResponsiveInput): Promise<CallToolResult> {\n const parsed = responsiveInputSchema.safeParse(rawInput);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"input\"}: ${i.message}`).join(\"; \");\n return errorResult(`Responsive capture failed: invalid input — ${issues}`);\n }\n const input = parsed.data;\n\n const shots = await Promise.all(input.viewports.map((viewport) => captureOne(input, viewport)));\n const captured = shots.filter((shot) => shot.png !== undefined);\n if (captured.length === 0) {\n const reasons = shots.map((shot) => `${shot.viewport.name}: ${shot.error ?? \"no screenshot\"}`).join(\"; \");\n return errorResult(`Responsive capture of ${input.url} failed at every viewport — ${reasons}`);\n }\n\n const content: CallToolResult[\"content\"] = [\n {\n type: \"text\",\n text: summarise(input.url, shots),\n },\n ];\n\n for (const shot of shots) {\n if (!shot.png) {\n content.push({ type: \"text\", text: `${label(shot.viewport)} — failed: ${shot.error ?? \"no screenshot\"}` });\n continue;\n }\n const resized = await resizeForOutput(shot.png);\n const { width, height } = await getDimensions(resized);\n content.push({ type: \"image\", data: toBase64(resized), mimeType: \"image/png\" });\n content.push({ type: \"text\", text: describeShot(shot, width, height) });\n }\n\n return { content };\n}\n\n/** Screenshot `url` at one viewport. Never throws — a failure is part of the result. */\nasync function captureOne(\n input: z.output<typeof responsiveInputSchema>,\n viewport: ParsedViewport,\n): Promise<ViewportShot> {\n try {\n const result = await withPage({ viewport: { width: viewport.width, height: viewport.height } }, async (page) => {\n await page.goto(input.url, { waitUntil: \"load\", timeout: NAVIGATION_TIMEOUT_MS });\n if (input.wait_for) {\n await page.waitForSelector(input.wait_for, { state: \"visible\", timeout: input.wait_for_timeout_ms });\n }\n if (input.wait_ms > 0) {\n await page.waitForTimeout(input.wait_ms);\n }\n const png = await page.screenshot({ type: \"png\" });\n return { png, layout: await readLayout(page) };\n });\n return { viewport, ...result };\n } catch (error) {\n return { viewport, error: describeViewportFailure(input, error) };\n }\n}\n\n/**\n * The document's laid-out size against the viewport it was given. Horizontal\n * overflow is the classic responsive bug and it is invisible in a screenshot —\n * the content that sticks out is simply cropped away — so it is measured\n * rather than left to the eye.\n *\n * Cosmetic: a page that dies before this is read still returns its screenshot.\n */\nasync function readLayout(page: Page): Promise<LayoutInfo | undefined> {\n try {\n // `globalThis as any` rather than the DOM globals: this package is\n // compiled with the Node lib only, and the page it lands in may be\n // mid-teardown, so nothing here can be assumed to exist.\n return await page.evaluate(() => {\n const el = (globalThis as any).document?.documentElement;\n if (!el) return undefined;\n return {\n scroll_width: Math.round(el.scrollWidth),\n client_width: Math.round(el.clientWidth),\n scroll_height: Math.round(el.scrollHeight),\n client_height: Math.round(el.clientHeight),\n };\n });\n } catch {\n return undefined;\n }\n}\n\nfunction label(viewport: ParsedViewport): string {\n return `${viewport.name} ${viewport.width}x${viewport.height}`;\n}\n\n/** One line per viewport: what it is, how big the image is, and whether the content fits. */\nfunction describeShot(shot: ViewportShot, imageWidth: number, imageHeight: number): string {\n const parts = [label(shot.viewport), `image ${imageWidth}x${imageHeight}`];\n\n const layout = shot.layout;\n if (layout) {\n if (layout.scroll_width > layout.client_width + OVERFLOW_TOLERANCE_PX) {\n parts.push(\n `horizontal overflow: content is ${layout.scroll_width}px wide in a ${layout.client_width}px viewport ` +\n `(+${layout.scroll_width - layout.client_width}px)`,\n );\n }\n if (layout.scroll_height > layout.client_height) {\n parts.push(`page scrolls to ${layout.scroll_height}px (${scrolls(layout)} screens)`);\n }\n }\n\n return parts.join(\" — \");\n}\n\nfunction scrolls(layout: LayoutInfo): string {\n return (layout.scroll_height / Math.max(1, layout.client_height)).toFixed(1);\n}\n\n/** The opening line: what was captured, and an up-front warning about anything that overflowed. */\nfunction summarise(url: string, shots: ViewportShot[]): string {\n const captured = shots.filter((shot) => shot.png !== undefined);\n const failed = shots.filter((shot) => shot.png === undefined);\n\n const names = captured.map((shot) => label(shot.viewport)).join(\", \");\n const lines = [`Captured ${url} at ${captured.length} of ${shots.length} viewports: ${names}`];\n\n if (failed.length > 0) {\n lines.push(`Failed: ${failed.map((shot) => shot.viewport.name).join(\", \")}`);\n }\n\n const overflowing = captured.filter(\n (shot) => shot.layout !== undefined && shot.layout.scroll_width > shot.layout.client_width + OVERFLOW_TOLERANCE_PX,\n );\n if (overflowing.length > 0) {\n lines.push(\n `Horizontal overflow at ${overflowing.map((shot) => shot.viewport.name).join(\", \")} — ` +\n \"content is wider than the viewport, so something is sticking out past the right edge.\",\n );\n }\n\n return lines.join(\"\\n\");\n}\n\nfunction errorResult(text: string): CallToolResult {\n return { isError: true, content: [{ type: \"text\", text }] };\n}\n\n/**\n * One actionable line for a viewport that failed. Mirrors `describeFailure` in\n * screenshot.ts: match on the failing Playwright call, never on substrings of\n * a user-supplied selector.\n */\nexport function describeViewportFailure(input: { wait_for?: string; wait_for_timeout_ms: number }, error: unknown): string {\n const message = error instanceof Error ? error.message : String(error);\n const firstLine = message.split(\"\\n\")[0];\n\n if (/Executable doesn't exist|browserType\\.launch/i.test(message)) {\n return \"Playwright's Chromium browser is not installed. Run `npx playwright install chromium` and try again.\";\n }\n if (input.wait_for && /^page\\.waitForSelector:/.test(message)) {\n return `selector \"${input.wait_for}\" did not become visible within ${input.wait_for_timeout_ms}ms`;\n }\n return firstLine;\n}\n\nexport function registerResponsiveTool(server: McpServer): void {\n server.registerTool(\n RESPONSIVE_TOOL_NAME,\n {\n title: \"Responsive\",\n description:\n \"Screenshot the same page at several viewport sizes in one call (mobile, tablet and desktop by default) \" +\n \"and return one labelled image per size. Each size loads in its own fresh browser context, so a mobile \" +\n \"shot is what a phone would really get rather than a resized desktop layout. Content that is wider than \" +\n \"its viewport is reported as horizontal overflow — the commonest responsive bug, and one a screenshot \" +\n \"alone hides because the overflowing part is simply cropped off.\",\n inputSchema: responsiveInputShape,\n annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n },\n async (args) => captureResponsive(args),\n );\n}\n"]}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
export declare const SCREENSHOT_TOOL_NAME = "framewatch_screenshot";
|
|
5
|
+
export declare const screenshotInputShape: {
|
|
6
|
+
url: z.ZodString;
|
|
7
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
8
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
9
|
+
width: z.ZodDefault<z.ZodNumber>;
|
|
10
|
+
height: z.ZodDefault<z.ZodNumber>;
|
|
11
|
+
}, "strip", z.ZodTypeAny, {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}, {
|
|
15
|
+
width?: number | undefined;
|
|
16
|
+
height?: number | undefined;
|
|
17
|
+
}>>;
|
|
18
|
+
selector: z.ZodOptional<z.ZodString>;
|
|
19
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
20
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
21
|
+
};
|
|
22
|
+
export declare const screenshotInputSchema: z.ZodObject<{
|
|
23
|
+
url: z.ZodString;
|
|
24
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
25
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
26
|
+
width: z.ZodDefault<z.ZodNumber>;
|
|
27
|
+
height: z.ZodDefault<z.ZodNumber>;
|
|
28
|
+
}, "strip", z.ZodTypeAny, {
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
}, {
|
|
32
|
+
width?: number | undefined;
|
|
33
|
+
height?: number | undefined;
|
|
34
|
+
}>>;
|
|
35
|
+
selector: z.ZodOptional<z.ZodString>;
|
|
36
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
37
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
38
|
+
}, "strip", z.ZodTypeAny, {
|
|
39
|
+
url: string;
|
|
40
|
+
wait_ms: number;
|
|
41
|
+
wait_for_timeout_ms: number;
|
|
42
|
+
viewport?: {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
} | undefined;
|
|
46
|
+
wait_for?: string | undefined;
|
|
47
|
+
selector?: string | undefined;
|
|
48
|
+
}, {
|
|
49
|
+
url: string;
|
|
50
|
+
viewport?: {
|
|
51
|
+
width?: number | undefined;
|
|
52
|
+
height?: number | undefined;
|
|
53
|
+
} | undefined;
|
|
54
|
+
wait_ms?: number | undefined;
|
|
55
|
+
wait_for?: string | undefined;
|
|
56
|
+
wait_for_timeout_ms?: number | undefined;
|
|
57
|
+
selector?: string | undefined;
|
|
58
|
+
}>;
|
|
59
|
+
export type ScreenshotInput = z.input<typeof screenshotInputSchema>;
|
|
60
|
+
type ParsedScreenshotInput = z.output<typeof screenshotInputSchema>;
|
|
61
|
+
/**
|
|
62
|
+
* Take a single screenshot of a page and return it as an MCP image content
|
|
63
|
+
* block (base64 PNG, resized to max OUTPUT_MAX_WIDTH wide) plus a one-line
|
|
64
|
+
* text summary. All failures — including invalid input — are reported as
|
|
65
|
+
* `isError` results rather than thrown so the MCP client sees a useful message.
|
|
66
|
+
*/
|
|
67
|
+
export declare function takeScreenshot(rawInput: ScreenshotInput): Promise<CallToolResult>;
|
|
68
|
+
/**
|
|
69
|
+
* Turn a Playwright/Node error into a one-line, actionable message. Matches on
|
|
70
|
+
* the failing Playwright call (the message prefix) rather than on substrings
|
|
71
|
+
* of user-supplied selectors, so a navigation failure is never blamed on an
|
|
72
|
+
* element.
|
|
73
|
+
*/
|
|
74
|
+
export declare function describeFailure(input: Pick<ParsedScreenshotInput, "url" | "wait_for" | "selector" | "wait_for_timeout_ms">, error: unknown): string;
|
|
75
|
+
export declare function registerScreenshotTool(server: McpServer): void;
|
|
76
|
+
export {};
|