framewatch-mcp-server 0.1.0 → 0.2.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/README.md +895 -15
- package/dist/constants.d.ts +274 -0
- package/dist/constants.js +279 -0
- package/dist/constants.js.map +1 -1
- package/dist/engine/browser.d.ts +20 -4
- package/dist/engine/browser.js +26 -9
- package/dist/engine/browser.js.map +1 -1
- package/dist/engine/clicks.d.ts +221 -0
- package/dist/engine/clicks.js +801 -0
- package/dist/engine/clicks.js.map +1 -0
- package/dist/engine/forms.d.ts +137 -0
- package/dist/engine/forms.js +474 -0
- package/dist/engine/forms.js.map +1 -0
- package/dist/engine/hmr.d.ts +41 -0
- package/dist/engine/hmr.js +91 -0
- package/dist/engine/hmr.js.map +1 -0
- package/dist/engine/inspect.d.ts +31 -0
- package/dist/engine/inspect.js +383 -0
- package/dist/engine/inspect.js.map +1 -0
- package/dist/engine/interaction.d.ts +12 -7
- package/dist/engine/interaction.js +110 -18
- package/dist/engine/interaction.js.map +1 -1
- package/dist/engine/links.d.ts +134 -0
- package/dist/engine/links.js +384 -0
- package/dist/engine/links.js.map +1 -0
- package/dist/engine/mocks.d.ts +53 -0
- package/dist/engine/mocks.js +148 -0
- package/dist/engine/mocks.js.map +1 -0
- package/dist/engine/rtl.d.ts +129 -0
- package/dist/engine/rtl.js +540 -0
- package/dist/engine/rtl.js.map +1 -0
- package/dist/engine/seo.d.ts +189 -0
- package/dist/engine/seo.js +398 -0
- package/dist/engine/seo.js.map +1 -0
- package/dist/engine/snapshot.d.ts +29 -0
- package/dist/engine/snapshot.js +10 -0
- package/dist/engine/snapshot.js.map +1 -0
- package/dist/engine/vue.d.ts +54 -0
- package/dist/engine/vue.js +419 -0
- package/dist/engine/vue.js.map +1 -0
- package/dist/index.js +45 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/accessibility.d.ts +4 -0
- package/dist/tools/accessibility.js +9 -2
- package/dist/tools/accessibility.js.map +1 -1
- package/dist/tools/api-mock.d.ts +405 -0
- package/dist/tools/api-mock.js +186 -0
- package/dist/tools/api-mock.js.map +1 -0
- package/dist/tools/capture.d.ts +90 -26
- package/dist/tools/capture.js +109 -58
- package/dist/tools/capture.js.map +1 -1
- package/dist/tools/compare.d.ts +4 -0
- package/dist/tools/compare.js +16 -5
- package/dist/tools/compare.js.map +1 -1
- package/dist/tools/dead-clicks.d.ts +128 -0
- package/dist/tools/dead-clicks.js +570 -0
- package/dist/tools/dead-clicks.js.map +1 -0
- package/dist/tools/form-test.d.ts +112 -0
- package/dist/tools/form-test.js +477 -0
- package/dist/tools/form-test.js.map +1 -0
- package/dist/tools/index.d.ts +17 -1
- package/dist/tools/index.js +45 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inspect.d.ts +78 -0
- package/dist/tools/inspect.js +136 -0
- package/dist/tools/inspect.js.map +1 -0
- package/dist/tools/interact.d.ts +37 -18
- package/dist/tools/interact.js +113 -13
- package/dist/tools/interact.js.map +1 -1
- package/dist/tools/links.d.ts +129 -0
- package/dist/tools/links.js +640 -0
- package/dist/tools/links.js.map +1 -0
- package/dist/tools/responsive.d.ts +10 -6
- package/dist/tools/responsive.js +21 -4
- package/dist/tools/responsive.js.map +1 -1
- package/dist/tools/rtl.d.ts +241 -0
- package/dist/tools/rtl.js +410 -0
- package/dist/tools/rtl.js.map +1 -0
- package/dist/tools/save-auth.d.ts +263 -0
- package/dist/tools/save-auth.js +253 -0
- package/dist/tools/save-auth.js.map +1 -0
- package/dist/tools/screenshot.d.ts +4 -0
- package/dist/tools/screenshot.js +15 -4
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/seo.d.ts +113 -0
- package/dist/tools/seo.js +281 -0
- package/dist/tools/seo.js.map +1 -0
- package/dist/tools/snapshot.d.ts +122 -0
- package/dist/tools/snapshot.js +183 -0
- package/dist/tools/snapshot.js.map +1 -0
- package/dist/tools/wait-for.d.ts +107 -0
- package/dist/tools/wait-for.js +167 -0
- package/dist/tools/wait-for.js.map +1 -0
- package/dist/utils/arabic-text.d.ts +14 -0
- package/dist/utils/arabic-text.js +193 -0
- package/dist/utils/arabic-text.js.map +1 -0
- package/dist/utils/budget.d.ts +41 -0
- package/dist/utils/budget.js +182 -0
- package/dist/utils/budget.js.map +1 -0
- package/dist/utils/format.d.ts +11 -1
- package/dist/utils/format.js +27 -4
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/highlight.d.ts +69 -0
- package/dist/utils/highlight.js +181 -0
- package/dist/utils/highlight.js.map +1 -0
- package/dist/utils/link-rules.d.ts +100 -0
- package/dist/utils/link-rules.js +284 -0
- package/dist/utils/link-rules.js.map +1 -0
- package/dist/utils/mock-rules.d.ts +144 -0
- package/dist/utils/mock-rules.js +224 -0
- package/dist/utils/mock-rules.js.map +1 -0
- package/dist/utils/rtl-rules.d.ts +142 -0
- package/dist/utils/rtl-rules.js +296 -0
- package/dist/utils/rtl-rules.js.map +1 -0
- package/dist/utils/seo-rules.d.ts +129 -0
- package/dist/utils/seo-rules.js +726 -0
- package/dist/utils/seo-rules.js.map +1 -0
- package/dist/utils/snapshot-rules.d.ts +33 -0
- package/dist/utils/snapshot-rules.js +111 -0
- package/dist/utils/snapshot-rules.js.map +1 -0
- package/dist/utils/storage-state.d.ts +76 -0
- package/dist/utils/storage-state.js +195 -0
- package/dist/utils/storage-state.js.map +1 -0
- package/dist/utils/style-rules.d.ts +107 -0
- package/dist/utils/style-rules.js +223 -0
- package/dist/utils/style-rules.js.map +1 -0
- package/dist/utils/test-data.d.ts +75 -0
- package/dist/utils/test-data.js +294 -0
- package/dist/utils/test-data.js.map +1 -0
- package/dist/utils/vue-rules.d.ts +72 -0
- package/dist/utils/vue-rules.js +108 -0
- package/dist/utils/vue-rules.js.map +1 -0
- package/package.json +6 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"screenshot.js","sourceRoot":"","sources":["../../src/tools/screenshot.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 { DEFAULT_SCREENSHOT_WAIT_MS, DEFAULT_VIEWPORT, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS } from \"../constants.js\";\nimport { withPage } from \"../engine/browser.js\";\nimport { getDimensions, resizeForOutput, toBase64 } from \"../utils/image.js\";\n\nexport const SCREENSHOT_TOOL_NAME = \"framewatch_screenshot\";\n\nexport const screenshotInputShape = {\n url: z\n .string()\n .url()\n .describe(\"URL to screenshot, e.g. http://localhost:3000 (http, https and file URLs are accepted)\"),\n wait_ms: z\n .number()\n .int()\n .min(0)\n .default(DEFAULT_SCREENSHOT_WAIT_MS)\n .describe(\"Wait time (ms) after page load before screenshot\"),\n viewport: z\n .object({\n width: z.number().int().min(1).default(DEFAULT_VIEWPORT.width),\n height: z.number().int().min(1).default(DEFAULT_VIEWPORT.height),\n })\n .optional()\n .describe(\"Viewport size (defaults to 1280x720)\"),\n selector: z.string().optional().describe(\"CSS selector to screenshot a specific element instead of the viewport\"),\n wait_for: z.string().optional().describe(\"CSS selector to wait for (visible) before taking the 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` / `selector` to appear (must be > 0)\"),\n};\n\nexport const screenshotInputSchema = z.object(screenshotInputShape);\nexport type ScreenshotInput = z.input<typeof screenshotInputSchema>;\ntype ParsedScreenshotInput = z.output<typeof screenshotInputSchema>;\n\n/**\n * Take a single screenshot of a page and return it as an MCP image content\n * block (base64 PNG, resized to max OUTPUT_MAX_WIDTH wide) plus a one-line\n * text summary. All failures — including invalid input — are reported as\n * `isError` results rather than thrown so the MCP client sees a useful message.\n */\nexport async function takeScreenshot(rawInput: ScreenshotInput): Promise<CallToolResult> {\n const parsed = screenshotInputSchema.safeParse(rawInput);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"input\"}: ${i.message}`).join(\"; \");\n return errorResult(`Screenshot failed: invalid input — ${issues}`);\n }\n const input = parsed.data;\n const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };\n\n try {\n const shot = await withPage({ viewport }, async (page) => {\n const response = await page.goto(input.url, { waitUntil: \"load\", timeout: NAVIGATION_TIMEOUT_MS });\n\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\n const png = input.selector\n ? await page.locator(input.selector).first().screenshot({ type: \"png\", timeout: input.wait_for_timeout_ms })\n : await page.screenshot({ type: \"png\" });\n\n return { png, title: await page.title(), finalUrl: page.url(), status: response?.status() ?? null };\n });\n\n const resized = await resizeForOutput(shot.png);\n const { width, height } = await getDimensions(resized);\n\n const summaryParts = [\n `Screenshot of ${shot.finalUrl}`,\n shot.status !== null && shot.status >= 400 ? `HTTP ${shot.status}` : null,\n shot.title ? `\"${shot.title}\"` : null,\n `${width}x${height}`,\n `viewport ${viewport.width}x${viewport.height}`,\n input.selector ? `element ${input.selector}` : null,\n ].filter((p): p is string => p !== null);\n\n return {\n content: [\n { type: \"image\", data: toBase64(resized), mimeType: \"image/png\" },\n { type: \"text\", text: summaryParts.join(\" — \") },\n ],\n };\n } catch (error) {\n return errorResult(describeFailure(input, error));\n }\n}\n\nfunction errorResult(text: string): CallToolResult {\n return { isError: true, content: [{ type: \"text\", text }] };\n}\n\n/**\n * Turn a Playwright/Node error into a one-line, actionable message. Matches on\n * the failing Playwright call (the message prefix) rather than on substrings\n * of user-supplied selectors, so a navigation failure is never blamed on an\n * element.\n */\nexport function describeFailure(\n input: Pick<ParsedScreenshotInput, \"url\" | \"wait_for\" | \"selector\" | \"wait_for_timeout_ms\">,\n error: unknown,\n): string {\n const message = error instanceof Error ? error.message : String(error);\n const firstLine = message.split(\"\\n\")[0];\n const prefix = `Screenshot of ${input.url} failed:`;\n\n if (/Executable doesn't exist|browserType\\.launch/i.test(message)) {\n return (\n `${prefix} Playwright's Chromium browser is not installed. ` +\n `Run \\`npx playwright install chromium\\` and try again. (${firstLine})`\n );\n }\n if (input.wait_for && /^page\\.waitForSelector:/.test(message)) {\n return `${prefix} selector \"${input.wait_for}\" did not become visible within ${input.wait_for_timeout_ms}ms.`;\n }\n if (input.selector && /^locator\\.screenshot:/.test(message)) {\n return `${prefix} element \"${input.selector}\" not found or not visible within ${input.wait_for_timeout_ms}ms. ${firstLine}`;\n }\n return `${prefix} ${firstLine}`;\n}\n\nexport function registerScreenshotTool(server: McpServer): void {\n server.registerTool(\n SCREENSHOT_TOOL_NAME,\n {\n title: \"Screenshot\",\n description:\n \"Take a single screenshot of a web page (or one element on it) and return it as a PNG image. \" +\n \"Good for checking the current visual state of a running app.\",\n inputSchema: screenshotInputShape,\n annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n },\n async (args) => takeScreenshot(args),\n );\n}\n"]}
|
|
1
|
+
{"version":3,"file":"screenshot.js","sourceRoot":"","sources":["../../src/tools/screenshot.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 { DEFAULT_SCREENSHOT_WAIT_MS, DEFAULT_VIEWPORT, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS } from \"../constants.js\";\nimport { withPage } from \"../engine/browser.js\";\nimport { getDimensions, resizeForOutput, toBase64 } from \"../utils/image.js\";\nimport { loginFormVisible, resolveStorageState, storageStateField, withAuthNote } from \"../utils/storage-state.js\";\n\nexport const SCREENSHOT_TOOL_NAME = \"framewatch_screenshot\";\n\nexport const screenshotInputShape = {\n url: z\n .string()\n .url()\n .describe(\"URL to screenshot, e.g. http://localhost:3000 (http, https and file URLs are accepted)\"),\n wait_ms: z\n .number()\n .int()\n .min(0)\n .default(DEFAULT_SCREENSHOT_WAIT_MS)\n .describe(\"Wait time (ms) after page load before screenshot\"),\n viewport: z\n .object({\n width: z.number().int().min(1).default(DEFAULT_VIEWPORT.width),\n height: z.number().int().min(1).default(DEFAULT_VIEWPORT.height),\n })\n .optional()\n .describe(\"Viewport size (defaults to 1280x720)\"),\n selector: z.string().optional().describe(\"CSS selector to screenshot a specific element instead of the viewport\"),\n wait_for: z.string().optional().describe(\"CSS selector to wait for (visible) before taking the 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` / `selector` to appear (must be > 0)\"),\n storage_state: storageStateField,\n};\n\nexport const screenshotInputSchema = z.object(screenshotInputShape);\nexport type ScreenshotInput = z.input<typeof screenshotInputSchema>;\ntype ParsedScreenshotInput = z.output<typeof screenshotInputSchema>;\n\n/**\n * Take a single screenshot of a page and return it as an MCP image content\n * block (base64 PNG, resized to max OUTPUT_MAX_WIDTH wide) plus a one-line\n * text summary. All failures — including invalid input — are reported as\n * `isError` results rather than thrown so the MCP client sees a useful message.\n */\nexport async function takeScreenshot(rawInput: ScreenshotInput): Promise<CallToolResult> {\n const parsed = screenshotInputSchema.safeParse(rawInput);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"input\"}: ${i.message}`).join(\"; \");\n return errorResult(`Screenshot failed: invalid input — ${issues}`);\n }\n const input = parsed.data;\n const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };\n\n try {\n const auth = await resolveStorageState(input.storage_state);\n const contextOptions = auth ? { storageState: auth.state } : {};\n const shot = await withPage({ viewport, contextOptions }, async (page) => {\n const response = await page.goto(input.url, { waitUntil: \"load\", timeout: NAVIGATION_TIMEOUT_MS });\n\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\n const png = input.selector\n ? await page.locator(input.selector).first().screenshot({ type: \"png\", timeout: input.wait_for_timeout_ms })\n : await page.screenshot({ type: \"png\" });\n\n return {\n png,\n title: await page.title(),\n finalUrl: page.url(),\n status: response?.status() ?? null,\n // A login form on the page after restoring a session is the expiry signal.\n loginVisible: auth ? await loginFormVisible(page) : false,\n };\n });\n\n const resized = await resizeForOutput(shot.png);\n const { width, height } = await getDimensions(resized);\n\n const summaryParts = [\n `Screenshot of ${shot.finalUrl}`,\n shot.status !== null && shot.status >= 400 ? `HTTP ${shot.status}` : null,\n shot.title ? `\"${shot.title}\"` : null,\n `${width}x${height}`,\n `viewport ${viewport.width}x${viewport.height}`,\n input.selector ? `element ${input.selector}` : null,\n ].filter((p): p is string => p !== null);\n\n return withAuthNote(\n {\n content: [\n { type: \"image\", data: toBase64(resized), mimeType: \"image/png\" },\n { type: \"text\", text: summaryParts.join(\" — \") },\n ],\n },\n auth,\n shot.loginVisible,\n );\n } catch (error) {\n return errorResult(describeFailure(input, error));\n }\n}\n\nfunction errorResult(text: string): CallToolResult {\n return { isError: true, content: [{ type: \"text\", text }] };\n}\n\n/**\n * Turn a Playwright/Node error into a one-line, actionable message. Matches on\n * the failing Playwright call (the message prefix) rather than on substrings\n * of user-supplied selectors, so a navigation failure is never blamed on an\n * element.\n */\nexport function describeFailure(\n input: Pick<ParsedScreenshotInput, \"url\" | \"wait_for\" | \"selector\" | \"wait_for_timeout_ms\">,\n error: unknown,\n): string {\n const message = error instanceof Error ? error.message : String(error);\n const firstLine = message.split(\"\\n\")[0];\n const prefix = `Screenshot of ${input.url} failed:`;\n\n if (/Executable doesn't exist|browserType\\.launch/i.test(message)) {\n return (\n `${prefix} Playwright's Chromium browser is not installed. ` +\n `Run \\`npx playwright install chromium\\` and try again. (${firstLine})`\n );\n }\n if (input.wait_for && /^page\\.waitForSelector:/.test(message)) {\n return `${prefix} selector \"${input.wait_for}\" did not become visible within ${input.wait_for_timeout_ms}ms.`;\n }\n if (input.selector && /^locator\\.screenshot:/.test(message)) {\n return `${prefix} element \"${input.selector}\" not found or not visible within ${input.wait_for_timeout_ms}ms. ${firstLine}`;\n }\n return `${prefix} ${firstLine}`;\n}\n\nexport function registerScreenshotTool(server: McpServer): void {\n server.registerTool(\n SCREENSHOT_TOOL_NAME,\n {\n title: \"Screenshot\",\n description:\n \"Take a single screenshot of a web page (or one element on it) and return it as a PNG image. \" +\n \"Good for checking the current visual state of a running app.\",\n inputSchema: screenshotInputShape,\n annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n },\n async (args) => takeScreenshot(args),\n );\n}\n"]}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
import { type PageSeo } from "../engine/seo.js";
|
|
5
|
+
import { type SeoReport } from "../utils/seo-rules.js";
|
|
6
|
+
export declare const SEO_TOOL_NAME = "framewatch_seo";
|
|
7
|
+
export declare const seoInputShape: {
|
|
8
|
+
url: z.ZodString;
|
|
9
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
10
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
11
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
check_robots: z.ZodDefault<z.ZodBoolean>;
|
|
13
|
+
robots_user_agent: z.ZodDefault<z.ZodString>;
|
|
14
|
+
check_og_image: z.ZodDefault<z.ZodBoolean>;
|
|
15
|
+
include_performance: z.ZodDefault<z.ZodBoolean>;
|
|
16
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
17
|
+
width: z.ZodDefault<z.ZodNumber>;
|
|
18
|
+
height: z.ZodDefault<z.ZodNumber>;
|
|
19
|
+
}, "strip", z.ZodTypeAny, {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}, {
|
|
23
|
+
width?: number | undefined;
|
|
24
|
+
height?: number | undefined;
|
|
25
|
+
}>>;
|
|
26
|
+
storage_state: z.ZodOptional<z.ZodString>;
|
|
27
|
+
};
|
|
28
|
+
export declare const seoInputSchema: z.ZodObject<{
|
|
29
|
+
url: z.ZodString;
|
|
30
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
31
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
32
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
33
|
+
check_robots: z.ZodDefault<z.ZodBoolean>;
|
|
34
|
+
robots_user_agent: z.ZodDefault<z.ZodString>;
|
|
35
|
+
check_og_image: z.ZodDefault<z.ZodBoolean>;
|
|
36
|
+
include_performance: z.ZodDefault<z.ZodBoolean>;
|
|
37
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
38
|
+
width: z.ZodDefault<z.ZodNumber>;
|
|
39
|
+
height: z.ZodDefault<z.ZodNumber>;
|
|
40
|
+
}, "strip", z.ZodTypeAny, {
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
}, {
|
|
44
|
+
width?: number | undefined;
|
|
45
|
+
height?: number | undefined;
|
|
46
|
+
}>>;
|
|
47
|
+
storage_state: z.ZodOptional<z.ZodString>;
|
|
48
|
+
}, "strip", z.ZodTypeAny, {
|
|
49
|
+
url: string;
|
|
50
|
+
wait_ms: number;
|
|
51
|
+
wait_for_timeout_ms: number;
|
|
52
|
+
include_performance: boolean;
|
|
53
|
+
check_robots: boolean;
|
|
54
|
+
robots_user_agent: string;
|
|
55
|
+
check_og_image: boolean;
|
|
56
|
+
viewport?: {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
} | undefined;
|
|
60
|
+
wait_for?: string | undefined;
|
|
61
|
+
storage_state?: string | undefined;
|
|
62
|
+
}, {
|
|
63
|
+
url: string;
|
|
64
|
+
viewport?: {
|
|
65
|
+
width?: number | undefined;
|
|
66
|
+
height?: number | undefined;
|
|
67
|
+
} | undefined;
|
|
68
|
+
wait_ms?: number | undefined;
|
|
69
|
+
wait_for?: string | undefined;
|
|
70
|
+
wait_for_timeout_ms?: number | undefined;
|
|
71
|
+
storage_state?: string | undefined;
|
|
72
|
+
include_performance?: boolean | undefined;
|
|
73
|
+
check_robots?: boolean | undefined;
|
|
74
|
+
robots_user_agent?: string | undefined;
|
|
75
|
+
check_og_image?: boolean | undefined;
|
|
76
|
+
}>;
|
|
77
|
+
export type SeoInput = z.input<typeof seoInputSchema>;
|
|
78
|
+
/**
|
|
79
|
+
* Audit what a search engine — and a link preview — would make of a page.
|
|
80
|
+
*
|
|
81
|
+
* Everything is read from the *rendered* document rather than the HTML source.
|
|
82
|
+
* A single-page app ships an empty `<div id="app">` and fills in the title,
|
|
83
|
+
* the description and the structured data afterwards, so reading the source
|
|
84
|
+
* would report every client-rendered page as having no SEO at all, which is
|
|
85
|
+
* both wrong and the opposite of useful.
|
|
86
|
+
*
|
|
87
|
+
* Three things are checked that are not on the page and cannot be seen from
|
|
88
|
+
* it: the response headers (an `X-Robots-Tag: noindex` is invisible in the
|
|
89
|
+
* DOM), robots.txt (a page can be perfect and simply not crawlable), and the
|
|
90
|
+
* share image itself, which is fetched and measured — an `og:image` pointing
|
|
91
|
+
* at a 404 looks exactly like a working one until somebody shares the link.
|
|
92
|
+
*/
|
|
93
|
+
export declare function auditSeo(rawInput: SeoInput): Promise<CallToolResult>;
|
|
94
|
+
/**
|
|
95
|
+
* The report as text: a verdict line, the problems up front, then every check
|
|
96
|
+
* in the order it was run.
|
|
97
|
+
*
|
|
98
|
+
* Passing checks are printed rather than counted. A report of four lines with
|
|
99
|
+
* nothing else in it is otherwise indistinguishable from an audit that only
|
|
100
|
+
* looked at four things.
|
|
101
|
+
*/
|
|
102
|
+
export declare function formatSeoReport(page: PageSeo, report: SeoReport): string;
|
|
103
|
+
/**
|
|
104
|
+
* One actionable line for an audit that could not run. Mirrors
|
|
105
|
+
* `describeFailure` in screenshot.ts: match on the failing Playwright call,
|
|
106
|
+
* never on substrings of a user-supplied selector.
|
|
107
|
+
*/
|
|
108
|
+
export declare function describeSeoFailure(input: {
|
|
109
|
+
url: string;
|
|
110
|
+
wait_for?: string;
|
|
111
|
+
wait_for_timeout_ms: number;
|
|
112
|
+
}, error: unknown): string;
|
|
113
|
+
export declare function registerSeoTool(server: McpServer): void;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_SEO_WAIT_MS, DEFAULT_VIEWPORT, MAX_SEO_HEADINGS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS, } from "../constants.js";
|
|
3
|
+
import { withPage } from "../engine/browser.js";
|
|
4
|
+
import { PERF_INIT_SCRIPT, extractSeo, fetchRobots, probeImage, readPerformance, } from "../engine/seo.js";
|
|
5
|
+
import { resizeForOutput, toBase64 } from "../utils/image.js";
|
|
6
|
+
import { SEO_AREAS, judgeSeo, metaValues, } from "../utils/seo-rules.js";
|
|
7
|
+
import { resolveStorageState, storageStateField, withAuthNote } from "../utils/storage-state.js";
|
|
8
|
+
export const SEO_TOOL_NAME = "framewatch_seo";
|
|
9
|
+
export const seoInputShape = {
|
|
10
|
+
url: z.string().url().describe("URL to audit, e.g. http://localhost:3000 (http, https and file URLs are accepted)"),
|
|
11
|
+
wait_ms: z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.min(0)
|
|
15
|
+
.default(DEFAULT_SEO_WAIT_MS)
|
|
16
|
+
.describe("Wait time (ms) after page load before reading the page, so a client-rendered app can finish"),
|
|
17
|
+
wait_for: z.string().optional().describe("CSS selector to wait for (visible) before reading the page"),
|
|
18
|
+
wait_for_timeout_ms: z
|
|
19
|
+
.number()
|
|
20
|
+
.int()
|
|
21
|
+
.min(1)
|
|
22
|
+
.default(SELECTOR_TIMEOUT_MS)
|
|
23
|
+
.describe("Max time (ms) to wait for `wait_for` to appear (must be > 0)"),
|
|
24
|
+
check_robots: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.default(true)
|
|
27
|
+
.describe("Fetch /robots.txt and work out whether this page's own path is crawlable"),
|
|
28
|
+
robots_user_agent: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.default("Googlebot")
|
|
32
|
+
.describe("Which crawler to answer the robots.txt question for. A named group beats the `*` group, as it does for the real thing."),
|
|
33
|
+
check_og_image: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.default(true)
|
|
36
|
+
.describe("Fetch the og:image and measure it — a share image that 404s or is too small is invisible from the page itself"),
|
|
37
|
+
include_performance: z
|
|
38
|
+
.boolean()
|
|
39
|
+
.default(false)
|
|
40
|
+
.describe("Also measure this load: LCP, CLS, TTFB, page weight and DOM size. Lab numbers from one headless load, " +
|
|
41
|
+
"useful for finding the slow element, not a prediction of field data."),
|
|
42
|
+
viewport: z
|
|
43
|
+
.object({
|
|
44
|
+
width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH).default(DEFAULT_VIEWPORT.width),
|
|
45
|
+
height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT).default(DEFAULT_VIEWPORT.height),
|
|
46
|
+
})
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Viewport size (defaults to 1280x720)"),
|
|
49
|
+
storage_state: storageStateField,
|
|
50
|
+
};
|
|
51
|
+
export const seoInputSchema = z.object(seoInputShape);
|
|
52
|
+
/**
|
|
53
|
+
* Audit what a search engine — and a link preview — would make of a page.
|
|
54
|
+
*
|
|
55
|
+
* Everything is read from the *rendered* document rather than the HTML source.
|
|
56
|
+
* A single-page app ships an empty `<div id="app">` and fills in the title,
|
|
57
|
+
* the description and the structured data afterwards, so reading the source
|
|
58
|
+
* would report every client-rendered page as having no SEO at all, which is
|
|
59
|
+
* both wrong and the opposite of useful.
|
|
60
|
+
*
|
|
61
|
+
* Three things are checked that are not on the page and cannot be seen from
|
|
62
|
+
* it: the response headers (an `X-Robots-Tag: noindex` is invisible in the
|
|
63
|
+
* DOM), robots.txt (a page can be perfect and simply not crawlable), and the
|
|
64
|
+
* share image itself, which is fetched and measured — an `og:image` pointing
|
|
65
|
+
* at a 404 looks exactly like a working one until somebody shares the link.
|
|
66
|
+
*/
|
|
67
|
+
export async function auditSeo(rawInput) {
|
|
68
|
+
const parsed = seoInputSchema.safeParse(rawInput);
|
|
69
|
+
if (!parsed.success) {
|
|
70
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
71
|
+
return errorResult(`SEO audit failed: invalid input — ${issues}`);
|
|
72
|
+
}
|
|
73
|
+
const input = parsed.data;
|
|
74
|
+
const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };
|
|
75
|
+
let contextOptions = {};
|
|
76
|
+
let auth = null;
|
|
77
|
+
try {
|
|
78
|
+
auth = await resolveStorageState(input.storage_state);
|
|
79
|
+
if (auth)
|
|
80
|
+
contextOptions = { storageState: auth.state };
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return errorResult(`SEO audit of ${input.url} failed: ${firstLine(error)}`);
|
|
84
|
+
}
|
|
85
|
+
let gathered;
|
|
86
|
+
try {
|
|
87
|
+
gathered = await withPage({ viewport, contextOptions }, (page, context) => gather(input, page, context));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return errorResult(describeSeoFailure(input, error));
|
|
91
|
+
}
|
|
92
|
+
const report = judgeSeo({
|
|
93
|
+
requested_url: input.url,
|
|
94
|
+
page: gathered.page,
|
|
95
|
+
robots_user_agent: input.robots_user_agent,
|
|
96
|
+
...(gathered.response ? { response: gathered.response } : {}),
|
|
97
|
+
...(gathered.robots ? { robots: gathered.robots } : {}),
|
|
98
|
+
...(gathered.og_image ? { og_image: gathered.og_image } : {}),
|
|
99
|
+
...(gathered.performance ? { performance: gathered.performance } : {}),
|
|
100
|
+
});
|
|
101
|
+
return withAuthNote(await render(gathered, report), auth);
|
|
102
|
+
}
|
|
103
|
+
/** Open the page and collect everything the audit needs from it. */
|
|
104
|
+
async function gather(input, page, context) {
|
|
105
|
+
// The observers have to exist before the navigation: LCP, CLS and long
|
|
106
|
+
// tasks are only observable as they happen.
|
|
107
|
+
if (input.include_performance)
|
|
108
|
+
await page.addInitScript(PERF_INIT_SCRIPT);
|
|
109
|
+
const response = await page.goto(input.url, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
110
|
+
if (input.wait_for) {
|
|
111
|
+
await page.waitForSelector(input.wait_for, { state: "visible", timeout: input.wait_for_timeout_ms });
|
|
112
|
+
}
|
|
113
|
+
if (input.wait_ms > 0) {
|
|
114
|
+
await page.waitForTimeout(input.wait_ms);
|
|
115
|
+
}
|
|
116
|
+
const seo = await extractSeo(page);
|
|
117
|
+
const gathered = { page: seo };
|
|
118
|
+
if (response) {
|
|
119
|
+
gathered.response = { status: response.status(), headers: response.headers() };
|
|
120
|
+
}
|
|
121
|
+
if (input.include_performance) {
|
|
122
|
+
const metrics = await readPerformance(page);
|
|
123
|
+
if (metrics)
|
|
124
|
+
gathered.performance = metrics;
|
|
125
|
+
}
|
|
126
|
+
if (input.check_robots) {
|
|
127
|
+
gathered.robots = await fetchRobots(context, seo.url);
|
|
128
|
+
}
|
|
129
|
+
if (input.check_og_image) {
|
|
130
|
+
const target = resolveUrl(metaValues(seo, "og:image")[0], seo.url);
|
|
131
|
+
if (target)
|
|
132
|
+
gathered.og_image = await probeImage(context, target);
|
|
133
|
+
}
|
|
134
|
+
return gathered;
|
|
135
|
+
}
|
|
136
|
+
/* ── Rendering ────────────────────────────────────────────────────────── */
|
|
137
|
+
const AREA_TITLES = {
|
|
138
|
+
indexing: "Indexing",
|
|
139
|
+
content: "Title & description",
|
|
140
|
+
headings: "Headings",
|
|
141
|
+
social: "Social (Open Graph)",
|
|
142
|
+
images: "Images",
|
|
143
|
+
structured_data: "Structured data",
|
|
144
|
+
performance: "Performance (lab)",
|
|
145
|
+
};
|
|
146
|
+
const MARKS = {
|
|
147
|
+
problem: "✗",
|
|
148
|
+
warning: "!",
|
|
149
|
+
pass: "✓",
|
|
150
|
+
info: "·",
|
|
151
|
+
};
|
|
152
|
+
async function render(gathered, report) {
|
|
153
|
+
const content = [{ type: "text", text: formatSeoReport(gathered.page, report) }];
|
|
154
|
+
// The share image is worth looking at, not only measuring: whether the logo
|
|
155
|
+
// is cropped or the text unreadable at card size is not a thing any header
|
|
156
|
+
// can answer.
|
|
157
|
+
const probe = gathered.og_image;
|
|
158
|
+
if (probe?.data) {
|
|
159
|
+
try {
|
|
160
|
+
const resized = await resizeForOutput(probe.data);
|
|
161
|
+
content.push({ type: "image", data: toBase64(resized), mimeType: "image/png" });
|
|
162
|
+
content.push({
|
|
163
|
+
type: "text",
|
|
164
|
+
text: `og:image — ${probe.url} (${probe.width}x${probe.height}${probe.content_type ? `, ${probe.content_type}` : ""}), as it will appear when the page is shared.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Measured already; failing to re-encode it is not worth failing the audit for.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { content };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The report as text: a verdict line, the problems up front, then every check
|
|
175
|
+
* in the order it was run.
|
|
176
|
+
*
|
|
177
|
+
* Passing checks are printed rather than counted. A report of four lines with
|
|
178
|
+
* nothing else in it is otherwise indistinguishable from an audit that only
|
|
179
|
+
* looked at four things.
|
|
180
|
+
*/
|
|
181
|
+
export function formatSeoReport(page, report) {
|
|
182
|
+
const lines = [];
|
|
183
|
+
lines.push(`SEO audit of ${page.url} — ${count(report.problems, "problem")}, ${count(report.warnings, "warning")}, ` +
|
|
184
|
+
`${report.passes} ${report.passes === 1 ? "check" : "checks"} passed.`);
|
|
185
|
+
const problems = report.findings.filter((finding) => finding.level === "problem");
|
|
186
|
+
if (problems.length > 0) {
|
|
187
|
+
lines.push(`Problems: ${problems.map((finding) => `${finding.label} (${headline(finding)})`).join("; ")}`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
lines.push("Nothing here would keep this page out of an index or break its share card.");
|
|
191
|
+
}
|
|
192
|
+
for (const area of SEO_AREAS) {
|
|
193
|
+
const findings = report.findings.filter((finding) => finding.area === area);
|
|
194
|
+
if (findings.length === 0)
|
|
195
|
+
continue;
|
|
196
|
+
lines.push("");
|
|
197
|
+
lines.push(AREA_TITLES[area]);
|
|
198
|
+
for (const finding of findings) {
|
|
199
|
+
lines.push(` ${MARKS[finding.level]} ${finding.label} — ${finding.detail}`);
|
|
200
|
+
if (finding.fix)
|
|
201
|
+
lines.push(` → ${finding.fix}`);
|
|
202
|
+
}
|
|
203
|
+
if (area === "headings" && page.headings.length > 0) {
|
|
204
|
+
lines.push(" Outline:");
|
|
205
|
+
for (const heading of page.headings) {
|
|
206
|
+
const indent = " ".repeat(Math.max(0, heading.level - 1));
|
|
207
|
+
const text = heading.empty ? "(empty)" : heading.text;
|
|
208
|
+
lines.push(` ${indent}h${heading.level} ${text}${heading.hidden ? " (hidden)" : ""}`);
|
|
209
|
+
}
|
|
210
|
+
if (page.heading_total > page.headings.length) {
|
|
211
|
+
lines.push(` … and ${page.heading_total - page.headings.length} more headings (the first ${MAX_SEO_HEADINGS} are listed)`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
/** The short form of a finding, for the problems line at the top. */
|
|
218
|
+
function headline(finding) {
|
|
219
|
+
const [first] = finding.detail.split(" — ");
|
|
220
|
+
return first.length > 90 ? `${first.slice(0, 90)}…` : first;
|
|
221
|
+
}
|
|
222
|
+
function count(n, noun) {
|
|
223
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
224
|
+
}
|
|
225
|
+
/* ── Helpers ──────────────────────────────────────────────────────────── */
|
|
226
|
+
/** Resolve a possibly relative URL against the page. Returns undefined for anything unusable. */
|
|
227
|
+
function resolveUrl(href, base) {
|
|
228
|
+
if (!href)
|
|
229
|
+
return undefined;
|
|
230
|
+
try {
|
|
231
|
+
const resolved = new URL(href, base);
|
|
232
|
+
// A data: URI is already the image; there is nothing to fetch and nothing
|
|
233
|
+
// a network could fail to load.
|
|
234
|
+
return resolved.protocol === "http:" || resolved.protocol === "https:" ? resolved.href : undefined;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function firstLine(error) {
|
|
241
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
242
|
+
}
|
|
243
|
+
function errorResult(text) {
|
|
244
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* One actionable line for an audit that could not run. Mirrors
|
|
248
|
+
* `describeFailure` in screenshot.ts: match on the failing Playwright call,
|
|
249
|
+
* never on substrings of a user-supplied selector.
|
|
250
|
+
*/
|
|
251
|
+
export function describeSeoFailure(input, error) {
|
|
252
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
253
|
+
const line = message.split("\n")[0];
|
|
254
|
+
const prefix = `SEO audit of ${input.url} failed:`;
|
|
255
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
256
|
+
return (`${prefix} Playwright's Chromium browser is not installed. ` +
|
|
257
|
+
`Run \`npx playwright install chromium\` and try again. (${line})`);
|
|
258
|
+
}
|
|
259
|
+
if (input.wait_for && /^page\.waitForSelector:/.test(message)) {
|
|
260
|
+
return `${prefix} selector "${input.wait_for}" did not become visible within ${input.wait_for_timeout_ms}ms.`;
|
|
261
|
+
}
|
|
262
|
+
if (/^page\.goto:/.test(message)) {
|
|
263
|
+
return `${prefix} the page could not be opened — ${line}`;
|
|
264
|
+
}
|
|
265
|
+
return `${prefix} ${line}`;
|
|
266
|
+
}
|
|
267
|
+
export function registerSeoTool(server) {
|
|
268
|
+
server.registerTool(SEO_TOOL_NAME, {
|
|
269
|
+
title: "SEO",
|
|
270
|
+
description: "Audit what a search engine and a link preview would make of a page: title and meta description (with " +
|
|
271
|
+
"lengths), canonical, robots directives and robots.txt, Open Graph and Twitter card tags, the heading " +
|
|
272
|
+
"outline, images with no alt text, and JSON-LD structured data checked against what Google's rich " +
|
|
273
|
+
"results require. Everything is read from the rendered DOM, so a client-rendered app is measured " +
|
|
274
|
+
"properly rather than reported as empty. The og:image is fetched and measured — a share image that " +
|
|
275
|
+
"404s or is too small is invisible from the page itself — and comes back as an image to look at. " +
|
|
276
|
+
"Set `include_performance: true` to add LCP, CLS, TTFB, page weight and DOM size from the same load.",
|
|
277
|
+
inputSchema: seoInputShape,
|
|
278
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
279
|
+
}, async (args) => auditSeo(args));
|
|
280
|
+
}
|
|
281
|
+
//# sourceMappingURL=seo.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seo.js","sourceRoot":"","sources":["../../src/tools/seo.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,EACL,mBAAmB,EACnB,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACnB,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,EACL,gBAAgB,EAChB,UAAU,EACV,WAAW,EACX,UAAU,EACV,eAAe,GAKhB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAC9D,OAAO,EACL,SAAS,EACT,QAAQ,EACR,UAAU,GAMX,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEjG,MAAM,CAAC,MAAM,aAAa,GAAG,gBAAgB,CAAC;AAE9C,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,mFAAmF,CAAC;IACnH,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,mBAAmB,CAAC;SAC5B,QAAQ,CAAC,6FAA6F,CAAC;IAC1G,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,4DAA4D,CAAC;IACtG,mBAAmB,EAAE,CAAC;SACnB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,mBAAmB,CAAC;SAC5B,QAAQ,CAAC,8DAA8D,CAAC;IAC3E,YAAY,EAAE,CAAC;SACZ,OAAO,EAAE;SACT,OAAO,CAAC,IAAI,CAAC;SACb,QAAQ,CAAC,0EAA0E,CAAC;IACvF,iBAAiB,EAAE,CAAC;SACjB,MAAM,EAAE;SACR,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,WAAW,CAAC;SACpB,QAAQ,CAAC,wHAAwH,CAAC;IACrI,cAAc,EAAE,CAAC;SACd,OAAO,EAAE;SACT,OAAO,CAAC,IAAI,CAAC;SACb,QAAQ,CAAC,+GAA+G,CAAC;IAC5H,mBAAmB,EAAE,CAAC;SACnB,OAAO,EAAE;SACT,OAAO,CAAC,KAAK,CAAC;SACd,QAAQ,CACP,wGAAwG;QACtG,sEAAsE,CACzE;IACH,QAAQ,EAAE,CAAC;SACR,MAAM,CAAC;QACN,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,OAAO,CAAC,gBAAgB,CAAC,KAAK,CAAC;QACtF,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,mBAAmB,CAAC,CAAC,OAAO,CAAC,gBAAgB,CAAC,MAAM,CAAC;KAC1F,CAAC;SACD,QAAQ,EAAE;SACV,QAAQ,CAAC,sCAAsC,CAAC;IACnD,aAAa,EAAE,iBAAiB;CACjC,CAAC;AAEF,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;AAatD;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,QAAkB;IAC/C,MAAM,MAAM,GAAG,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IAClD,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,OAAO,KAAK,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzG,OAAO,WAAW,CAAC,qCAAqC,MAAM,EAAE,CAAC,CAAC;IACpE,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC;IAC1B,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,IAAI,EAAE,GAAG,gBAAgB,EAAE,CAAC;IAE3D,IAAI,cAAc,GAAG,EAAE,CAAC;IACxB,IAAI,IAAI,GAAG,IAAI,CAAC;IAChB,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,mBAAmB,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;QACtD,IAAI,IAAI;YAAE,cAAc,GAAG,EAAE,YAAY,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;IAC1D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,WAAW,CAAC,gBAAgB,KAAK,CAAC,GAAG,YAAY,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC9E,CAAC;IAED,IAAI,QAAkB,CAAC;IACvB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,QAAQ,CAAC,EAAE,QAAQ,EAAE,cAAc,EAAE,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAC3G,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,WAAW,CAAC,kBAAkB,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;IACvD,CAAC;IAED,MAAM,MAAM,GAAG,QAAQ,CAAC;QACtB,aAAa,EAAE,KAAK,CAAC,GAAG;QACxB,IAAI,EAAE,QAAQ,CAAC,IAAI;QACnB,iBAAiB,EAAE,KAAK,CAAC,iBAAiB;QAC1C,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvD,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,GAAG,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvE,CAAC,CAAC;IAEH,OAAO,YAAY,CAAC,MAAM,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC;AAC5D,CAAC;AAED,oEAAoE;AACpE,KAAK,UAAU,MAAM,CAAC,KAAqB,EAAE,IAAU,EAAE,OAAuB;IAC9E,uEAAuE;IACvE,4CAA4C;IAC5C,IAAI,KAAK,CAAC,mBAAmB;QAAE,MAAM,IAAI,CAAC,aAAa,CAAC,gBAAgB,CAAC,CAAC;IAE1E,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,EAAE,CAAC,CAAC;IACnG,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;QACnB,MAAM,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,CAAC,mBAAmB,EAAE,CAAC,CAAC;IACvG,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC3C,CAAC;IAED,MAAM,GAAG,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC;IACnC,MAAM,QAAQ,GAAa,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;IAEzC,IAAI,QAAQ,EAAE,CAAC;QACb,QAAQ,CAAC,QAAQ,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,EAAE,CAAC;IACjF,CAAC;IACD,IAAI,KAAK,CAAC,mBAAmB,EAAE,CAAC;QAC9B,MAAM,OAAO,GAAG,MAAM,eAAe,CAAC,IAAI,CAAC,CAAC;QAC5C,IAAI,OAAO;YAAE,QAAQ,CAAC,WAAW,GAAG,OAAO,CAAC;IAC9C,CAAC;IACD,IAAI,KAAK,CAAC,YAAY,EAAE,CAAC;QACvB,QAAQ,CAAC,MAAM,GAAG,MAAM,WAAW,CAAC,OAAO,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IACxD,CAAC;IACD,IAAI,KAAK,CAAC,cAAc,EAAE,CAAC;QACzB,MAAM,MAAM,GAAG,UAAU,CAAC,UAAU,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QACnE,IAAI,MAAM;YAAE,QAAQ,CAAC,QAAQ,GAAG,MAAM,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACpE,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,6EAA6E;AAE7E,MAAM,WAAW,GAAsC;IACrD,QAAQ,EAAE,UAAU;IACpB,OAAO,EAAE,qBAAqB;IAC9B,QAAQ,EAAE,UAAU;IACpB,MAAM,EAAE,qBAAqB;IAC7B,MAAM,EAAE,QAAQ;IAChB,eAAe,EAAE,iBAAiB;IAClC,WAAW,EAAE,mBAAmB;CACjC,CAAC;AAEF,MAAM,KAAK,GAAuC;IAChD,OAAO,EAAE,GAAG;IACZ,OAAO,EAAE,GAAG;IACZ,IAAI,EAAE,GAAG;IACT,IAAI,EAAE,GAAG;CACV,CAAC;AAEF,KAAK,UAAU,MAAM,CAAC,QAAkB,EAAE,MAAiB;IACzD,MAAM,OAAO,GAA8B,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;IAE5G,4EAA4E;IAC5E,2EAA2E;IAC3E,cAAc;IACd,MAAM,KAAK,GAAG,QAAQ,CAAC,QAAQ,CAAC;IAChC,IAAI,KAAK,EAAE,IAAI,EAAE,CAAC;QAChB,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAClD,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAC;YAChF,OAAO,CAAC,IAAI,CAAC;gBACX,IAAI,EAAE,MAAM;gBACZ,IAAI,EAAE,cAAc,KAAK,CAAC,GAAG,KAAK,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,+CAA+C;aACnK,CAAC,CAAC;QACL,CAAC;QAAC,MAAM,CAAC;YACP,gFAAgF;QAClF,CAAC;IACH,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,CAAC;AACrB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,IAAa,EAAE,MAAiB;IAC9D,MAAM,KAAK,GAAa,EAAE,CAAC;IAE3B,KAAK,CAAC,IAAI,CACR,gBAAgB,IAAI,CAAC,GAAG,MAAM,KAAK,CAAC,MAAM,CAAC,QAAQ,EAAE,SAAS,CAAC,KAAK,KAAK,CAAC,MAAM,CAAC,QAAQ,EAAE,SAAS,CAAC,IAAI;QACvG,GAAG,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,UAAU,CACzE,CAAC;IAEF,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC;IAClF,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,KAAK,CAAC,IAAI,CAAC,aAAa,QAAQ,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,GAAG,OAAO,CAAC,KAAK,KAAK,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC7G,CAAC;SAAM,CAAC;QACN,KAAK,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAC;IAC3F,CAAC;IAED,KAAK,MAAM,IAAI,IAAI,SAAS,EAAE,CAAC;QAC7B,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;QAC5E,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACpC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC;QAC9B,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,KAAK,CAAC,IAAI,CAAC,KAAK,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC,KAAK,MAAM,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;YAC7E,IAAI,OAAO,CAAC,GAAG;gBAAE,KAAK,CAAC,IAAI,CAAC,WAAW,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;QACxD,CAAC;QACD,IAAI,IAAI,KAAK,UAAU,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpD,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;YACzB,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACpC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC;gBAC3D,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC;gBACtD,KAAK,CAAC,IAAI,CAAC,OAAO,MAAM,IAAI,OAAO,CAAC,KAAK,IAAI,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAC3F,CAAC;YACD,IAAI,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;gBAC9C,KAAK,CAAC,IAAI,CAAC,aAAa,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,6BAA6B,gBAAgB,cAAc,CAAC,CAAC;YAChI,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED,qEAAqE;AACrE,SAAS,QAAQ,CAAC,OAAmB;IACnC,MAAM,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC5C,OAAO,KAAK,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC;AAC9D,CAAC;AAED,SAAS,KAAK,CAAC,CAAS,EAAE,IAAY;IACpC,OAAO,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;AAC7C,CAAC;AAED,6EAA6E;AAE7E,iGAAiG;AACjG,SAAS,UAAU,CAAC,IAAwB,EAAE,IAAY;IACxD,IAAI,CAAC,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5B,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACrC,0EAA0E;QAC1E,gCAAgC;QAChC,OAAO,QAAQ,CAAC,QAAQ,KAAK,OAAO,IAAI,QAAQ,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;IACrG,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,KAAc;IAC/B,OAAO,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AACjF,CAAC;AAED,SAAS,WAAW,CAAC,IAAY;IAC/B,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;AAC9D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAChC,KAAsE,EACtE,KAAc;IAEd,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACvE,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IACpC,MAAM,MAAM,GAAG,gBAAgB,KAAK,CAAC,GAAG,UAAU,CAAC;IAEnD,IAAI,+CAA+C,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QAClE,OAAO,CACL,GAAG,MAAM,mDAAmD;YAC5D,2DAA2D,IAAI,GAAG,CACnE,CAAC;IACJ,CAAC;IACD,IAAI,KAAK,CAAC,QAAQ,IAAI,yBAAyB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QAC9D,OAAO,GAAG,MAAM,cAAc,KAAK,CAAC,QAAQ,mCAAmC,KAAK,CAAC,mBAAmB,KAAK,CAAC;IAChH,CAAC;IACD,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QACjC,OAAO,GAAG,MAAM,mCAAmC,IAAI,EAAE,CAAC;IAC5D,CAAC;IACD,OAAO,GAAG,MAAM,IAAI,IAAI,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,MAAiB;IAC/C,MAAM,CAAC,YAAY,CACjB,aAAa,EACb;QACE,KAAK,EAAE,KAAK;QACZ,WAAW,EACT,uGAAuG;YACvG,uGAAuG;YACvG,mGAAmG;YACnG,kGAAkG;YAClG,oGAAoG;YACpG,kGAAkG;YAClG,qGAAqG;QACvG,WAAW,EAAE,aAAa;QAC1B,WAAW,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,cAAc,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE;KACvG,EACD,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAC/B,CAAC;AACJ,CAAC","sourcesContent":["import { z } from \"zod\";\nimport type { BrowserContext, Page } from \"playwright\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n DEFAULT_SEO_WAIT_MS,\n DEFAULT_VIEWPORT,\n MAX_SEO_HEADINGS,\n MAX_VIEWPORT_HEIGHT,\n MAX_VIEWPORT_WIDTH,\n NAVIGATION_TIMEOUT_MS,\n SELECTOR_TIMEOUT_MS,\n} from \"../constants.js\";\nimport { withPage } from \"../engine/browser.js\";\nimport {\n PERF_INIT_SCRIPT,\n extractSeo,\n fetchRobots,\n probeImage,\n readPerformance,\n type ImageProbe,\n type PageSeo,\n type PerfMetrics,\n type RobotsFetch,\n} from \"../engine/seo.js\";\nimport { resizeForOutput, toBase64 } from \"../utils/image.js\";\nimport {\n SEO_AREAS,\n judgeSeo,\n metaValues,\n type SeoArea,\n type SeoFinding,\n type SeoLevel,\n type SeoReport,\n type SeoResponseInfo,\n} from \"../utils/seo-rules.js\";\nimport { resolveStorageState, storageStateField, withAuthNote } from \"../utils/storage-state.js\";\n\nexport const SEO_TOOL_NAME = \"framewatch_seo\";\n\nexport const seoInputShape = {\n url: z.string().url().describe(\"URL to audit, e.g. http://localhost:3000 (http, https and file URLs are accepted)\"),\n wait_ms: z\n .number()\n .int()\n .min(0)\n .default(DEFAULT_SEO_WAIT_MS)\n .describe(\"Wait time (ms) after page load before reading the page, so a client-rendered app can finish\"),\n wait_for: z.string().optional().describe(\"CSS selector to wait for (visible) before reading the page\"),\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 check_robots: z\n .boolean()\n .default(true)\n .describe(\"Fetch /robots.txt and work out whether this page's own path is crawlable\"),\n robots_user_agent: z\n .string()\n .min(1)\n .default(\"Googlebot\")\n .describe(\"Which crawler to answer the robots.txt question for. A named group beats the `*` group, as it does for the real thing.\"),\n check_og_image: z\n .boolean()\n .default(true)\n .describe(\"Fetch the og:image and measure it — a share image that 404s or is too small is invisible from the page itself\"),\n include_performance: z\n .boolean()\n .default(false)\n .describe(\n \"Also measure this load: LCP, CLS, TTFB, page weight and DOM size. Lab numbers from one headless load, \" +\n \"useful for finding the slow element, not a prediction of field data.\",\n ),\n viewport: z\n .object({\n width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH).default(DEFAULT_VIEWPORT.width),\n height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT).default(DEFAULT_VIEWPORT.height),\n })\n .optional()\n .describe(\"Viewport size (defaults to 1280x720)\"),\n storage_state: storageStateField,\n};\n\nexport const seoInputSchema = z.object(seoInputShape);\nexport type SeoInput = z.input<typeof seoInputSchema>;\ntype ParsedSeoInput = z.output<typeof seoInputSchema>;\n\n/** Everything one audit gathered, before anything judges it. */\ninterface Gathered {\n page: PageSeo;\n response?: SeoResponseInfo;\n robots?: RobotsFetch;\n og_image?: ImageProbe;\n performance?: PerfMetrics;\n}\n\n/**\n * Audit what a search engine — and a link preview — would make of a page.\n *\n * Everything is read from the *rendered* document rather than the HTML source.\n * A single-page app ships an empty `<div id=\"app\">` and fills in the title,\n * the description and the structured data afterwards, so reading the source\n * would report every client-rendered page as having no SEO at all, which is\n * both wrong and the opposite of useful.\n *\n * Three things are checked that are not on the page and cannot be seen from\n * it: the response headers (an `X-Robots-Tag: noindex` is invisible in the\n * DOM), robots.txt (a page can be perfect and simply not crawlable), and the\n * share image itself, which is fetched and measured — an `og:image` pointing\n * at a 404 looks exactly like a working one until somebody shares the link.\n */\nexport async function auditSeo(rawInput: SeoInput): Promise<CallToolResult> {\n const parsed = seoInputSchema.safeParse(rawInput);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"input\"}: ${i.message}`).join(\"; \");\n return errorResult(`SEO audit failed: invalid input — ${issues}`);\n }\n const input = parsed.data;\n const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };\n\n let contextOptions = {};\n let auth = null;\n try {\n auth = await resolveStorageState(input.storage_state);\n if (auth) contextOptions = { storageState: auth.state };\n } catch (error) {\n return errorResult(`SEO audit of ${input.url} failed: ${firstLine(error)}`);\n }\n\n let gathered: Gathered;\n try {\n gathered = await withPage({ viewport, contextOptions }, (page, context) => gather(input, page, context));\n } catch (error) {\n return errorResult(describeSeoFailure(input, error));\n }\n\n const report = judgeSeo({\n requested_url: input.url,\n page: gathered.page,\n robots_user_agent: input.robots_user_agent,\n ...(gathered.response ? { response: gathered.response } : {}),\n ...(gathered.robots ? { robots: gathered.robots } : {}),\n ...(gathered.og_image ? { og_image: gathered.og_image } : {}),\n ...(gathered.performance ? { performance: gathered.performance } : {}),\n });\n\n return withAuthNote(await render(gathered, report), auth);\n}\n\n/** Open the page and collect everything the audit needs from it. */\nasync function gather(input: ParsedSeoInput, page: Page, context: BrowserContext): Promise<Gathered> {\n // The observers have to exist before the navigation: LCP, CLS and long\n // tasks are only observable as they happen.\n if (input.include_performance) await page.addInitScript(PERF_INIT_SCRIPT);\n\n const response = 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\n const seo = await extractSeo(page);\n const gathered: Gathered = { page: seo };\n\n if (response) {\n gathered.response = { status: response.status(), headers: response.headers() };\n }\n if (input.include_performance) {\n const metrics = await readPerformance(page);\n if (metrics) gathered.performance = metrics;\n }\n if (input.check_robots) {\n gathered.robots = await fetchRobots(context, seo.url);\n }\n if (input.check_og_image) {\n const target = resolveUrl(metaValues(seo, \"og:image\")[0], seo.url);\n if (target) gathered.og_image = await probeImage(context, target);\n }\n\n return gathered;\n}\n\n/* ── Rendering ────────────────────────────────────────────────────────── */\n\nconst AREA_TITLES: Readonly<Record<SeoArea, string>> = {\n indexing: \"Indexing\",\n content: \"Title & description\",\n headings: \"Headings\",\n social: \"Social (Open Graph)\",\n images: \"Images\",\n structured_data: \"Structured data\",\n performance: \"Performance (lab)\",\n};\n\nconst MARKS: Readonly<Record<SeoLevel, string>> = {\n problem: \"✗\",\n warning: \"!\",\n pass: \"✓\",\n info: \"·\",\n};\n\nasync function render(gathered: Gathered, report: SeoReport): Promise<CallToolResult> {\n const content: CallToolResult[\"content\"] = [{ type: \"text\", text: formatSeoReport(gathered.page, report) }];\n\n // The share image is worth looking at, not only measuring: whether the logo\n // is cropped or the text unreadable at card size is not a thing any header\n // can answer.\n const probe = gathered.og_image;\n if (probe?.data) {\n try {\n const resized = await resizeForOutput(probe.data);\n content.push({ type: \"image\", data: toBase64(resized), mimeType: \"image/png\" });\n content.push({\n type: \"text\",\n text: `og:image — ${probe.url} (${probe.width}x${probe.height}${probe.content_type ? `, ${probe.content_type}` : \"\"}), as it will appear when the page is shared.`,\n });\n } catch {\n // Measured already; failing to re-encode it is not worth failing the audit for.\n }\n }\n\n return { content };\n}\n\n/**\n * The report as text: a verdict line, the problems up front, then every check\n * in the order it was run.\n *\n * Passing checks are printed rather than counted. A report of four lines with\n * nothing else in it is otherwise indistinguishable from an audit that only\n * looked at four things.\n */\nexport function formatSeoReport(page: PageSeo, report: SeoReport): string {\n const lines: string[] = [];\n\n lines.push(\n `SEO audit of ${page.url} — ${count(report.problems, \"problem\")}, ${count(report.warnings, \"warning\")}, ` +\n `${report.passes} ${report.passes === 1 ? \"check\" : \"checks\"} passed.`,\n );\n\n const problems = report.findings.filter((finding) => finding.level === \"problem\");\n if (problems.length > 0) {\n lines.push(`Problems: ${problems.map((finding) => `${finding.label} (${headline(finding)})`).join(\"; \")}`);\n } else {\n lines.push(\"Nothing here would keep this page out of an index or break its share card.\");\n }\n\n for (const area of SEO_AREAS) {\n const findings = report.findings.filter((finding) => finding.area === area);\n if (findings.length === 0) continue;\n lines.push(\"\");\n lines.push(AREA_TITLES[area]);\n for (const finding of findings) {\n lines.push(` ${MARKS[finding.level]} ${finding.label} — ${finding.detail}`);\n if (finding.fix) lines.push(` → ${finding.fix}`);\n }\n if (area === \"headings\" && page.headings.length > 0) {\n lines.push(\" Outline:\");\n for (const heading of page.headings) {\n const indent = \" \".repeat(Math.max(0, heading.level - 1));\n const text = heading.empty ? \"(empty)\" : heading.text;\n lines.push(` ${indent}h${heading.level} ${text}${heading.hidden ? \" (hidden)\" : \"\"}`);\n }\n if (page.heading_total > page.headings.length) {\n lines.push(` … and ${page.heading_total - page.headings.length} more headings (the first ${MAX_SEO_HEADINGS} are listed)`);\n }\n }\n }\n\n return lines.join(\"\\n\");\n}\n\n/** The short form of a finding, for the problems line at the top. */\nfunction headline(finding: SeoFinding): string {\n const [first] = finding.detail.split(\" — \");\n return first.length > 90 ? `${first.slice(0, 90)}…` : first;\n}\n\nfunction count(n: number, noun: string): string {\n return `${n} ${noun}${n === 1 ? \"\" : \"s\"}`;\n}\n\n/* ── Helpers ──────────────────────────────────────────────────────────── */\n\n/** Resolve a possibly relative URL against the page. Returns undefined for anything unusable. */\nfunction resolveUrl(href: string | undefined, base: string): string | undefined {\n if (!href) return undefined;\n try {\n const resolved = new URL(href, base);\n // A data: URI is already the image; there is nothing to fetch and nothing\n // a network could fail to load.\n return resolved.protocol === \"http:\" || resolved.protocol === \"https:\" ? resolved.href : undefined;\n } catch {\n return undefined;\n }\n}\n\nfunction firstLine(error: unknown): string {\n return (error instanceof Error ? error.message : String(error)).split(\"\\n\")[0];\n}\n\nfunction errorResult(text: string): CallToolResult {\n return { isError: true, content: [{ type: \"text\", text }] };\n}\n\n/**\n * One actionable line for an audit that could not run. Mirrors\n * `describeFailure` in screenshot.ts: match on the failing Playwright call,\n * never on substrings of a user-supplied selector.\n */\nexport function describeSeoFailure(\n input: { url: string; wait_for?: string; wait_for_timeout_ms: number },\n error: unknown,\n): string {\n const message = error instanceof Error ? error.message : String(error);\n const line = message.split(\"\\n\")[0];\n const prefix = `SEO audit of ${input.url} failed:`;\n\n if (/Executable doesn't exist|browserType\\.launch/i.test(message)) {\n return (\n `${prefix} Playwright's Chromium browser is not installed. ` +\n `Run \\`npx playwright install chromium\\` and try again. (${line})`\n );\n }\n if (input.wait_for && /^page\\.waitForSelector:/.test(message)) {\n return `${prefix} selector \"${input.wait_for}\" did not become visible within ${input.wait_for_timeout_ms}ms.`;\n }\n if (/^page\\.goto:/.test(message)) {\n return `${prefix} the page could not be opened — ${line}`;\n }\n return `${prefix} ${line}`;\n}\n\nexport function registerSeoTool(server: McpServer): void {\n server.registerTool(\n SEO_TOOL_NAME,\n {\n title: \"SEO\",\n description:\n \"Audit what a search engine and a link preview would make of a page: title and meta description (with \" +\n \"lengths), canonical, robots directives and robots.txt, Open Graph and Twitter card tags, the heading \" +\n \"outline, images with no alt text, and JSON-LD structured data checked against what Google's rich \" +\n \"results require. Everything is read from the rendered DOM, so a client-rendered app is measured \" +\n \"properly rather than reported as empty. The og:image is fetched and measured — a share image that \" +\n \"404s or is too small is invisible from the page itself — and comes back as an image to look at. \" +\n \"Set `include_performance: true` to add LCP, CLS, TTFB, page weight and DOM size from the same load.\",\n inputSchema: seoInputShape,\n annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n },\n async (args) => auditSeo(args),\n );\n}\n"]}
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
import { getSessionPage } from "../engine/browser.js";
|
|
5
|
+
import { type PageSnapshot } from "../engine/snapshot.js";
|
|
6
|
+
import { type VueInfo } from "../utils/vue-rules.js";
|
|
7
|
+
import { type ResolvedAuth } from "../utils/storage-state.js";
|
|
8
|
+
import type { Viewport } from "../types.js";
|
|
9
|
+
export declare const SNAPSHOT_TOOL_NAME = "framewatch_snapshot";
|
|
10
|
+
export declare const snapshotInputShape: {
|
|
11
|
+
url: z.ZodOptional<z.ZodString>;
|
|
12
|
+
selector: z.ZodOptional<z.ZodString>;
|
|
13
|
+
mode: z.ZodDefault<z.ZodEnum<["full", "interactive"]>>;
|
|
14
|
+
max_chars: z.ZodDefault<z.ZodNumber>;
|
|
15
|
+
include_screenshot: z.ZodDefault<z.ZodBoolean>;
|
|
16
|
+
include_components: z.ZodDefault<z.ZodBoolean>;
|
|
17
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
18
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
19
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
20
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
21
|
+
width: z.ZodNumber;
|
|
22
|
+
height: z.ZodNumber;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}, {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}>>;
|
|
30
|
+
storage_state: z.ZodOptional<z.ZodString>;
|
|
31
|
+
};
|
|
32
|
+
export declare const snapshotInputSchema: z.ZodObject<{
|
|
33
|
+
url: z.ZodOptional<z.ZodString>;
|
|
34
|
+
selector: z.ZodOptional<z.ZodString>;
|
|
35
|
+
mode: z.ZodDefault<z.ZodEnum<["full", "interactive"]>>;
|
|
36
|
+
max_chars: z.ZodDefault<z.ZodNumber>;
|
|
37
|
+
include_screenshot: z.ZodDefault<z.ZodBoolean>;
|
|
38
|
+
include_components: z.ZodDefault<z.ZodBoolean>;
|
|
39
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
40
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
41
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
42
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
43
|
+
width: z.ZodNumber;
|
|
44
|
+
height: z.ZodNumber;
|
|
45
|
+
}, "strip", z.ZodTypeAny, {
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
}, {
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
}>>;
|
|
52
|
+
storage_state: z.ZodOptional<z.ZodString>;
|
|
53
|
+
}, "strip", z.ZodTypeAny, {
|
|
54
|
+
wait_ms: number;
|
|
55
|
+
wait_for_timeout_ms: number;
|
|
56
|
+
mode: "full" | "interactive";
|
|
57
|
+
max_chars: number;
|
|
58
|
+
include_screenshot: boolean;
|
|
59
|
+
include_components: boolean;
|
|
60
|
+
url?: string | undefined;
|
|
61
|
+
viewport?: {
|
|
62
|
+
width: number;
|
|
63
|
+
height: number;
|
|
64
|
+
} | undefined;
|
|
65
|
+
wait_for?: string | undefined;
|
|
66
|
+
storage_state?: string | undefined;
|
|
67
|
+
selector?: string | undefined;
|
|
68
|
+
}, {
|
|
69
|
+
url?: string | undefined;
|
|
70
|
+
viewport?: {
|
|
71
|
+
width: number;
|
|
72
|
+
height: number;
|
|
73
|
+
} | undefined;
|
|
74
|
+
wait_ms?: number | undefined;
|
|
75
|
+
wait_for?: string | undefined;
|
|
76
|
+
wait_for_timeout_ms?: number | undefined;
|
|
77
|
+
storage_state?: string | undefined;
|
|
78
|
+
selector?: string | undefined;
|
|
79
|
+
mode?: "full" | "interactive" | undefined;
|
|
80
|
+
max_chars?: number | undefined;
|
|
81
|
+
include_screenshot?: boolean | undefined;
|
|
82
|
+
include_components?: boolean | undefined;
|
|
83
|
+
}>;
|
|
84
|
+
export type SnapshotInput = z.input<typeof snapshotInputSchema>;
|
|
85
|
+
/**
|
|
86
|
+
* Read the page as a tree of named elements, on the interact session's page so
|
|
87
|
+
* the refs it hands out are the ones the next `framewatch_interact` or
|
|
88
|
+
* `framewatch_inspect` can use.
|
|
89
|
+
*/
|
|
90
|
+
export declare function snapshotPage(rawInput: SnapshotInput): Promise<CallToolResult>;
|
|
91
|
+
export declare function describeCounts(snapshot: Pick<PageSnapshot, "elements" | "interactive">): string;
|
|
92
|
+
/** Input the session-page tools share: where to open, how long to wait, what size. */
|
|
93
|
+
export interface SessionPageInput {
|
|
94
|
+
url?: string;
|
|
95
|
+
wait_ms?: number;
|
|
96
|
+
wait_for?: string;
|
|
97
|
+
wait_for_timeout_ms?: number;
|
|
98
|
+
viewport?: Viewport;
|
|
99
|
+
storage_state?: string;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The session page, opened at `url` when one is given. Shared by snapshot and
|
|
103
|
+
* inspect, which both read the page interact drives; `toolName` is only for
|
|
104
|
+
* the error wording.
|
|
105
|
+
*/
|
|
106
|
+
export interface OpenedSessionPage {
|
|
107
|
+
page: Awaited<ReturnType<typeof getSessionPage>>["page"];
|
|
108
|
+
vue: VueInfo | null;
|
|
109
|
+
/** The saved auth in force for this call, if any. */
|
|
110
|
+
auth: ResolvedAuth | null;
|
|
111
|
+
/** Whether the page showed a login form after opening — only checked when `url` was given. */
|
|
112
|
+
login_visible: boolean;
|
|
113
|
+
}
|
|
114
|
+
export declare function openSessionPage(input: SessionPageInput, toolName: string): Promise<OpenedSessionPage | {
|
|
115
|
+
error: string;
|
|
116
|
+
}>;
|
|
117
|
+
export declare function describeSessionFailure(toolName: string, input: {
|
|
118
|
+
url?: string;
|
|
119
|
+
wait_for?: string;
|
|
120
|
+
wait_for_timeout_ms?: number;
|
|
121
|
+
}, error: unknown): string;
|
|
122
|
+
export declare function registerSnapshotTool(server: McpServer): void;
|