mcp-web-validator 1.0.0 → 1.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/dist/index.js CHANGED
@@ -1,682 +1,457 @@
1
1
  #!/usr/bin/env node
2
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import * as path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
- import * as fs from "fs/promises";
6
- import * as path from "path";
7
- import { validateHtmlContent, validateCssContent } from "./w3c-validator.js";
8
- import { auditSeoMetadata, validateSchemaMarkup, checkBrokenLinks } from "./seo-auditor.js";
6
+ import { z } from "zod";
9
7
  import { captureScreenshots } from "./screenshot.js";
10
- // Initialize MCP Server
11
- const server = new Server({
12
- name: "mcp-web-validator",
13
- version: "1.0.0",
14
- }, {
15
- capabilities: {
16
- tools: {},
17
- },
8
+ import { auditSeoMetadata, auditSeoMetadataDetailed, checkBrokenLinks, validateSchemaMarkup, validateSchemaMarkupDetailed, } from "./seo-auditor.js";
9
+ import { createValidationReport, validationReportChecks, } from "./report.js";
10
+ import { fetchPublicText, getErrorMessage, readTextFile } from "./network.js";
11
+ import { cssValidationContent, failureContent, htmlValidationContent, linkCheckContent, reportContent, schemaValidationContent, screenshotCaptureContent, seoAuditContent, } from "./presentation.js";
12
+ import { PACKAGE_VERSION } from "./version.js";
13
+ import { MAX_CSS_VALIDATION_BYTES, validateCssContent, validateHtmlContent, } from "./w3c-validator.js";
14
+ export const SERVER_VERSION = PACKAGE_VERSION;
15
+ const HTML_MAX_BYTES = 2_000_000;
16
+ const CSS_MAX_BYTES = MAX_CSS_VALIDATION_BYTES;
17
+ const PATH_MAX_LENGTH = 4_096;
18
+ const MAX_VIEWPORTS = 8;
19
+ const filePathSchema = z.string().trim().min(1).max(PATH_MAX_LENGTH);
20
+ const htmlContentSchema = z.string().min(1).max(HTML_MAX_BYTES);
21
+ const publicUrlSchema = z
22
+ .string()
23
+ .url()
24
+ .refine((value) => {
25
+ const protocol = new URL(value).protocol;
26
+ return protocol === "http:" || protocol === "https:";
27
+ }, "URL must use HTTP or HTTPS.");
28
+ const w3cMessageSchema = z.object({
29
+ type: z.string(),
30
+ message: z.string(),
31
+ lastLine: z.number().int().optional(),
32
+ lastColumn: z.number().int().optional(),
33
+ firstLine: z.number().int().optional(),
34
+ firstColumn: z.number().int().optional(),
35
+ extract: z.string().optional(),
18
36
  });
19
- // Register Tool Definitions
20
- server.setRequestHandler(ListToolsRequestSchema, async () => {
37
+ const cssMessageSchema = z.object({
38
+ line: z.number().int(),
39
+ type: z.string(),
40
+ message: z.string(),
41
+ context: z.string().optional(),
42
+ });
43
+ const seoIssueSchema = z.object({
44
+ severity: z.enum(["error", "warning", "info"]),
45
+ category: z.enum(["SEO", "Schema", "BrokenLinks", "Accessibility"]),
46
+ message: z.string(),
47
+ element: z.string().optional(),
48
+ });
49
+ const linkStatusSchema = z.object({
50
+ url: z.string(),
51
+ status: z.union([z.number().int(), z.enum(["blocked", "failed"])]),
52
+ ok: z.boolean(),
53
+ message: z.string().optional(),
54
+ });
55
+ const screenshotSchema = z.object({
56
+ viewportName: z.string(),
57
+ width: z.number().int(),
58
+ height: z.number().int(),
59
+ outputPath: z.string(),
60
+ });
61
+ const reportSummarySchema = z.object({
62
+ overallScore: z.number().int().min(0).max(100).nullable(),
63
+ htmlScore: z.number().int().min(0).max(100).nullable(),
64
+ cssScore: z.number().int().min(0).max(100).nullable(),
65
+ seoScore: z.number().int().min(0).max(100).nullable(),
66
+ linkScore: z.number().int().min(0).max(100).nullable(),
67
+ htmlErrors: z.number().int().nonnegative(),
68
+ htmlWarnings: z.number().int().nonnegative(),
69
+ cssErrors: z.number().int().nonnegative(),
70
+ seoErrors: z.number().int().nonnegative(),
71
+ seoWarnings: z.number().int().nonnegative(),
72
+ schemaErrors: z.number().int().nonnegative(),
73
+ linksChecked: z.number().int().nonnegative(),
74
+ brokenLinks: z.number().int().nonnegative(),
75
+ });
76
+ const externalReadOnlyAnnotations = {
77
+ // These calls contact external recipients but do not modify external state.
78
+ readOnlyHint: true,
79
+ destructiveHint: false,
80
+ idempotentHint: true,
81
+ openWorldHint: true,
82
+ };
83
+ const localReadOnlyAnnotations = {
84
+ readOnlyHint: true,
85
+ destructiveHint: false,
86
+ idempotentHint: true,
87
+ openWorldHint: false,
88
+ };
89
+ function result(structuredContent, content, isError = false) {
21
90
  return {
22
- tools: [
91
+ structuredContent: structuredContent,
92
+ content: [
23
93
  {
24
- name: "html_validate_local",
25
- description: "Validates a local HTML file against the official W3C Nu HTML Checker API. Catches syntax, tags, and compliance errors.",
26
- inputSchema: {
27
- type: "object",
28
- properties: {
29
- filePath: {
30
- type: "string",
31
- description: "Absolute or relative path to the local HTML file.",
32
- },
33
- },
34
- required: ["filePath"],
35
- },
36
- outputSchema: {
37
- type: "object",
38
- description: "Validation result containing W3C HTML parser validation messages.",
39
- properties: {
40
- errors: {
41
- type: "array",
42
- description: "List of HTML validation error and warning objects returned from W3C.",
43
- items: {
44
- type: "object",
45
- properties: {
46
- type: { "type": "string", "description": "The category of message: 'error', 'info', or 'non-document-error'." },
47
- message: { "type": "string", "description": "The specific validation or parsing error message." },
48
- extract: { "type": "string", "description": "The HTML snippet around the validation location." },
49
- lastLine: { "type": "number", "description": "Line number where the issue occurred." },
50
- lastColumn: { "type": "number", "description": "Column number where the issue occurred." }
51
- },
52
- required: ["type", "message"]
53
- }
54
- }
55
- },
56
- required: ["errors"]
57
- },
58
- annotations: {
59
- readOnlyHint: true,
60
- openWorldHint: false
61
- }
62
- },
63
- {
64
- name: "html_validate_url",
65
- description: "Validates the markup of a live public URL using the W3C HTML validation engine.",
66
- inputSchema: {
67
- type: "object",
68
- properties: {
69
- url: {
70
- type: "string",
71
- description: "The live URL to validate (must start with http:// or https://).",
72
- },
73
- },
74
- required: ["url"],
75
- },
76
- outputSchema: {
77
- type: "object",
78
- description: "Validation result containing W3C HTML parser validation messages.",
79
- properties: {
80
- errors: {
81
- type: "array",
82
- description: "List of HTML validation error and warning objects returned from W3C.",
83
- items: {
84
- type: "object",
85
- properties: {
86
- type: { "type": "string", "description": "The category of message: 'error', 'info', or 'non-document-error'." },
87
- message: { "type": "string", "description": "The specific validation or parsing error message." },
88
- extract: { "type": "string", "description": "The HTML snippet around the validation location." },
89
- lastLine: { "type": "number", "description": "Line number where the issue occurred." },
90
- lastColumn: { "type": "number", "description": "Column number where the issue occurred." }
91
- },
92
- required: ["type", "message"]
93
- }
94
- }
95
- },
96
- required: ["errors"]
97
- },
98
- annotations: {
99
- readOnlyHint: true,
100
- openWorldHint: false
101
- }
102
- },
103
- {
104
- name: "css_validate_local",
105
- description: "Validates a local CSS file against the W3C Jigsaw CSS Validator API. Finds styling syntax errors.",
106
- inputSchema: {
107
- type: "object",
108
- properties: {
109
- filePath: {
110
- type: "string",
111
- description: "Absolute or relative path to the local CSS file.",
112
- },
113
- },
114
- required: ["filePath"],
115
- },
116
- outputSchema: {
117
- type: "object",
118
- description: "CSS validation results.",
119
- properties: {
120
- errors: {
121
- type: "array",
122
- description: "List of CSS validation errors from W3C Jigsaw API.",
123
- items: {
124
- type: "object",
125
- properties: {
126
- line: { "type": "number", "description": "Line number of the CSS error." },
127
- context: { "type": "string", "description": "The CSS selector or context where the error occurred." },
128
- message: { "type": "string", "description": "The specific CSS validation warning or error message." }
129
- },
130
- required: ["line", "message"]
131
- }
132
- }
133
- },
134
- required: ["errors"]
135
- },
136
- annotations: {
137
- readOnlyHint: true,
138
- openWorldHint: false
139
- }
140
- },
141
- {
142
- name: "seo_audit_metadata",
143
- description: "Runs a fast offline audit of HTML metadata, heading structure, viewport responsive tags, image alt tags, and Open Graph cards.",
144
- inputSchema: {
145
- type: "object",
146
- properties: {
147
- htmlContent: {
148
- type: "string",
149
- description: "The raw HTML string content to analyze.",
150
- },
151
- },
152
- required: ["htmlContent"],
153
- },
154
- outputSchema: {
155
- type: "object",
156
- description: "Audited Technical SEO results.",
157
- properties: {
158
- issues: {
159
- type: "array",
160
- description: "List of audited Technical SEO issues, warnings, and error indicators.",
161
- items: {
162
- type: "object",
163
- properties: {
164
- category: { "type": "string", "description": "SEO audit category (e.g. metadata, structure, accessibility)." },
165
- severity: { "type": "string", "description": "Severity of the issue: 'error' or 'warning'." },
166
- message: { "type": "string", "description": "Detailed explanation of the SEO issue." },
167
- element: { "type": "string", "description": "Relevant HTML snippet or element if applicable." }
168
- },
169
- required: ["category", "severity", "message"]
170
- }
171
- }
172
- },
173
- required: ["issues"]
174
- },
175
- annotations: {
176
- readOnlyHint: true,
177
- openWorldHint: false
178
- }
94
+ type: "text",
95
+ text: content,
179
96
  },
180
- {
181
- name: "links_check_broken",
182
- description: "Extracts all links (a href tags) in the HTML content and tests their HTTP status codes to detect broken internal or external URLs.",
183
- inputSchema: {
184
- type: "object",
185
- properties: {
186
- htmlContent: {
187
- type: "string",
188
- description: "The raw HTML string content to inspect.",
189
- },
190
- baseUrl: {
191
- type: "string",
192
- description: "Optional base URL to resolve relative paths (e.g., https://example.com).",
193
- },
194
- },
195
- required: ["htmlContent"],
196
- },
197
- outputSchema: {
198
- type: "object",
199
- description: "Reachability report of page links.",
200
- properties: {
201
- links: {
202
- type: "array",
203
- description: "Status details of all checked links on the page.",
204
- items: {
205
- type: "object",
206
- properties: {
207
- url: { "type": "string", "description": "The destination URL that was tested." },
208
- status: { "type": "number", "description": "HTTP status code response (e.g., 200, 404)." },
209
- ok: { "type": "boolean", "description": "Whether the link is reachable and returned a successful status code." },
210
- message: { "type": "string", "description": "Reachable status details or error description." }
211
- },
212
- required: ["url", "status", "ok"]
213
- }
214
- }
215
- },
216
- required: ["links"]
217
- },
218
- annotations: {
219
- readOnlyHint: true,
220
- openWorldHint: false
221
- }
222
- },
223
- {
224
- name: "schema_validate_markup",
225
- description: "Finds and validates the JSON-LD schema blocks within the HTML, catching syntax issues.",
226
- inputSchema: {
227
- type: "object",
228
- properties: {
229
- htmlContent: {
230
- type: "string",
231
- description: "The raw HTML string containing <script type=\"application/ld+json\"> blocks.",
232
- },
233
- },
234
- required: ["htmlContent"],
235
- },
236
- outputSchema: {
237
- type: "object",
238
- description: "Parsed JSON-LD validation report.",
239
- properties: {
240
- issues: {
241
- type: "array",
242
- description: "List of parsed JSON-LD validation results.",
243
- items: {
244
- type: "object",
245
- properties: {
246
- category: { "type": "string", "description": "Always 'Schema Markup'." },
247
- severity: { "type": "string", "description": "Severity level: 'error' or 'warning'." },
248
- message: { "type": "string", "description": "Validation message explaining JSON parsing errors or schema problems." }
249
- },
250
- required: ["category", "severity", "message"]
251
- }
252
- }
253
- },
254
- required: ["issues"]
255
- },
256
- annotations: {
257
- readOnlyHint: true,
258
- openWorldHint: false
259
- }
260
- },
261
- {
262
- name: "report_generate_validation",
263
- description: "Runs all validation checks (HTML, CSS, SEO, Schema, Links) on local files and aggregates them into a beautifully formatted Markdown report with summary tables.",
264
- inputSchema: {
265
- type: "object",
266
- properties: {
267
- htmlFilePath: {
268
- type: "string",
269
- description: "Absolute or relative path to the local HTML file to validate.",
270
- },
271
- cssFilePath: {
272
- type: "string",
273
- description: "Optional absolute or relative path to the local CSS file to validate.",
274
- },
275
- baseUrl: {
276
- type: "string",
277
- description: "Optional base URL to resolve relative link paths.",
278
- },
279
- },
280
- required: ["htmlFilePath"],
281
- },
282
- outputSchema: {
283
- type: "object",
284
- description: "Aggregated Markdown validation and SEO report details.",
285
- properties: {
286
- content: {
287
- type: "array",
288
- items: {
289
- type: "object",
290
- properties: {
291
- type: { "type": "string", "enum": ["text"] },
292
- text: { "type": "string", "description": "Formatted Markdown report." }
293
- },
294
- required: ["type", "text"]
295
- }
296
- }
297
- },
298
- required: ["content"]
299
- },
300
- annotations: {
301
- readOnlyHint: true,
302
- openWorldHint: false
303
- }
304
- },
305
- {
306
- name: "screenshot_capture",
307
- description: "Renders a local HTML file or remote URL using Puppeteer and captures screenshots at different viewport sizes (desktop, tablet, mobile).",
308
- inputSchema: {
309
- type: "object",
310
- properties: {
311
- targetPath: {
312
- type: "string",
313
- description: "Path to the local HTML file or remote URL (e.g. http:// or https://) to screenshot.",
314
- },
315
- outputDir: {
316
- type: "string",
317
- description: "Optional absolute or relative directory path where screenshots will be saved. Defaults to '.mcp-validator/screenshots'.",
318
- },
319
- viewports: {
320
- type: "array",
321
- description: "Optional list of custom viewports to capture. Each viewport object must have name, width, and height.",
322
- items: {
323
- type: "object",
324
- properties: {
325
- name: { type: "string", description: "Name of the viewport (e.g., mobile-portrait)." },
326
- width: { type: "number", description: "Width in pixels." },
327
- height: { type: "number", description: "Height in pixels." }
328
- },
329
- required: ["name", "width", "height"]
330
- }
331
- }
332
- },
333
- required: ["targetPath"]
334
- },
335
- outputSchema: {
336
- type: "object",
337
- description: "Status report of screenshot file paths and coordinates generated.",
338
- properties: {
339
- content: {
340
- type: "array",
341
- items: {
342
- type: "object",
343
- properties: {
344
- type: { "type": "string", "enum": ["text"] },
345
- text: { "type": "string", "description": "Markdown list of screenshots created and their directories." }
346
- },
347
- required: ["type", "text"]
348
- }
349
- }
350
- },
351
- required: ["content"]
352
- },
353
- annotations: {
354
- readOnlyHint: false,
355
- openWorldHint: true
356
- }
357
- }
358
97
  ],
98
+ ...(isError ? { isError: true } : {}),
359
99
  };
360
- });
361
- // Handle Tool Calls
362
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
363
- const { name, arguments: args } = request.params;
364
- try {
365
- switch (name) {
366
- case "html_validate_local": {
367
- const filePath = String(args?.filePath);
368
- const resolvedPath = path.resolve(filePath);
369
- try {
370
- const content = await fs.readFile(resolvedPath, "utf-8");
371
- const errors = await validateHtmlContent(content);
372
- return {
373
- content: [
374
- {
375
- type: "text",
376
- text: JSON.stringify(errors, null, 2),
377
- },
378
- ],
379
- };
380
- }
381
- catch (e) {
382
- return {
383
- isError: true,
384
- content: [
385
- {
386
- type: "text",
387
- text: `Error reading file at "${resolvedPath}": ${e.message}`,
388
- },
389
- ],
390
- };
391
- }
392
- }
393
- case "html_validate_url": {
394
- const url = String(args?.url);
395
- try {
396
- const response = await fetch(url);
397
- if (!response.ok) {
398
- return {
399
- isError: true,
400
- content: [
401
- {
402
- type: "text",
403
- text: `Failed to fetch URL ${url}. Status code: ${response.status}`,
404
- },
405
- ],
406
- };
407
- }
408
- const content = await response.text();
409
- const errors = await validateHtmlContent(content);
410
- return {
411
- content: [
412
- {
413
- type: "text",
414
- text: JSON.stringify(errors, null, 2),
415
- },
416
- ],
417
- };
418
- }
419
- catch (e) {
420
- return {
421
- isError: true,
422
- content: [
423
- {
424
- type: "text",
425
- text: `Error fetching URL "${url}": ${e.message}`,
426
- },
427
- ],
428
- };
429
- }
430
- }
431
- case "css_validate_local": {
432
- const filePath = String(args?.filePath);
433
- const resolvedPath = path.resolve(filePath);
434
- try {
435
- const content = await fs.readFile(resolvedPath, "utf-8");
436
- const errors = await validateCssContent(content);
437
- return {
438
- content: [
439
- {
440
- type: "text",
441
- text: JSON.stringify(errors, null, 2),
442
- },
443
- ],
444
- };
445
- }
446
- catch (e) {
447
- return {
448
- isError: true,
449
- content: [
450
- {
451
- type: "text",
452
- text: `Error reading CSS file at "${resolvedPath}": ${e.message}`,
453
- },
454
- ],
455
- };
456
- }
457
- }
458
- case "seo_audit_metadata": {
459
- const htmlContent = String(args?.htmlContent);
460
- const issues = auditSeoMetadata(htmlContent);
461
- return {
462
- content: [
463
- {
464
- type: "text",
465
- text: JSON.stringify(issues, null, 2),
466
- },
467
- ],
468
- };
469
- }
470
- case "links_check_broken": {
471
- const htmlContent = String(args?.htmlContent);
472
- const baseUrl = args?.baseUrl ? String(args.baseUrl) : undefined;
473
- const linkStatuses = await checkBrokenLinks(htmlContent, baseUrl);
474
- return {
475
- content: [
476
- {
477
- type: "text",
478
- text: JSON.stringify(linkStatuses, null, 2),
479
- },
480
- ],
481
- };
482
- }
483
- case "schema_validate_markup": {
484
- const htmlContent = String(args?.htmlContent);
485
- const issues = validateSchemaMarkup(htmlContent);
486
- return {
487
- content: [
488
- {
489
- type: "text",
490
- text: JSON.stringify(issues, null, 2),
491
- },
492
- ],
493
- };
494
- }
495
- case "report_generate_validation": {
496
- const htmlFilePath = String(args?.htmlFilePath);
497
- const cssFilePath = args?.cssFilePath ? String(args.cssFilePath) : undefined;
498
- const baseUrl = args?.baseUrl ? String(args.baseUrl) : undefined;
499
- const resolvedHtmlPath = path.resolve(htmlFilePath);
500
- const htmlContent = await fs.readFile(resolvedHtmlPath, "utf-8");
501
- // Runs checks
502
- const htmlErrors = await validateHtmlContent(htmlContent);
503
- const seoIssues = auditSeoMetadata(htmlContent);
504
- const schemaIssues = validateSchemaMarkup(htmlContent);
505
- const linkStatuses = await checkBrokenLinks(htmlContent, baseUrl);
506
- let cssErrors = [];
507
- if (cssFilePath) {
508
- try {
509
- const resolvedCssPath = path.resolve(cssFilePath);
510
- const cssContent = await fs.readFile(resolvedCssPath, "utf-8");
511
- cssErrors = await validateCssContent(cssContent);
512
- }
513
- catch (e) {
514
- cssErrors = [{ line: 0, type: "error", message: `Failed to read CSS: ${e.message}` }];
515
- }
516
- }
517
- // Generate Report Markdown
518
- const htmlErrCount = htmlErrors.filter(e => e.type === "error").length;
519
- const htmlWarnCount = htmlErrors.filter(e => e.type !== "error").length;
520
- const cssErrCount = cssErrors.length;
521
- const seoErrCount = seoIssues.filter(i => i.severity === "error").length;
522
- const seoWarnCount = seoIssues.filter(i => i.severity !== "error").length;
523
- const schemaErrCount = schemaIssues.filter(i => i.severity === "error").length;
524
- const brokenLinkCount = linkStatuses.filter(l => !l.ok).length;
525
- // Calculate Scores (PageSpeed style: Base 100)
526
- let htmlScore = 100 - (htmlErrCount * 15) - (htmlWarnCount * 2);
527
- let cssScore = cssFilePath ? (100 - (cssErrCount * 20)) : 100;
528
- let seoScore = 100 - (seoErrCount * 15) - (seoWarnCount * 4) - (schemaErrCount * 15);
529
- let linkScore = linkStatuses.length > 0 ? (100 - (brokenLinkCount * 25)) : 100;
530
- // Clamp to [0, 100]
531
- htmlScore = Math.max(0, Math.min(100, htmlScore));
532
- cssScore = Math.max(0, Math.min(100, cssScore));
533
- seoScore = Math.max(0, Math.min(100, seoScore));
534
- linkScore = Math.max(0, Math.min(100, linkScore));
535
- const getCircle = (score) => {
536
- if (score >= 90)
537
- return "🟢";
538
- if (score >= 50)
539
- return "🟠";
540
- return "🔴";
541
- };
542
- const totalAuditCount = cssFilePath ? 4 : 3;
543
- const totalScoreSum = htmlScore + seoScore + linkScore + (cssFilePath ? cssScore : 0);
544
- const overallScore = Math.round(totalScoreSum / totalAuditCount);
545
- const report = [
546
- `# 📋 Web Validation & SEO Audit Report — ${getCircle(overallScore)} **${overallScore}**/100`,
547
- `*Generated for: \`${path.basename(htmlFilePath)}\`*`,
548
- ``,
549
- `## ⚡ Page Health Scores (PageSpeed Inspired)`,
550
- ``,
551
- `| Score Card | Status | Score |`,
552
- `| :--- | :---: | :---: |`,
553
- `| **W3C HTML Validation** | ${getCircle(htmlScore)} ${htmlScore >= 90 ? "Excellent" : (htmlScore >= 50 ? "Needs Work" : "Poor")} | **${htmlScore}** / 100 |`,
554
- `| **W3C CSS Validation** | ${cssFilePath ? `${getCircle(cssScore)} ${cssScore >= 90 ? "Excellent" : (cssScore >= 50 ? "Needs Work" : "Poor")}` : "ℹ️ Not Audited"} | ${cssFilePath ? `**${cssScore}** / 100` : "N/A"} |`,
555
- `| **SEO & Accessibility** | ${getCircle(seoScore)} ${seoScore >= 90 ? "Optimized" : (seoScore >= 50 ? "Warnings" : "Poor")} | **${seoScore}** / 100 |`,
556
- `| **Links Integrity** | ${linkStatuses.length > 0 ? `${getCircle(linkScore)} ${linkScore >= 90 ? "All Good" : "Broken Links"}` : "ℹ️ No Links"} | ${linkStatuses.length > 0 ? `**${linkScore}** / 100` : "N/A"} |`,
557
- ``,
558
- `---`,
559
- ``,
560
- `## 📊 Audit Details Overview`,
561
- `| Audit Category | Status | Details |`,
562
- `| :--- | :---: | :--- |`,
563
- `| **W3C HTML Validation** | ${htmlErrCount > 0 ? "❌ Failed" : "✅ Passed"} | ${htmlErrCount} Errors, ${htmlWarnCount} Warnings |`,
564
- `| **W3C CSS Validation** | ${cssFilePath ? (cssErrCount > 0 ? "❌ Failed" : "✅ Passed") : "ℹ️ Not Audited"} | ${cssErrCount} Errors |`,
565
- `| **Technical SEO & Accessibility** | ${seoErrCount > 0 ? "❌ Critical Issues" : (seoWarnCount > 0 ? "⚠️ Warnings" : "✅ Optimized")} | ${seoErrCount} Errors, ${seoWarnCount} Warnings |`,
566
- `| **JSON-LD Schema Verification** | ${schemaErrCount > 0 ? "❌ Invalid" : "✅ Valid"} | ${schemaErrCount} Syntax Errors |`,
567
- `| **Broken Link Check** | ${brokenLinkCount > 0 ? "❌ Broken Links Found" : "✅ All Links OK"} | ${brokenLinkCount} Dead Links, ${linkStatuses.length} Total Links Checked |`,
568
- `---`,
569
- ``,
570
- `## 🔴 HTML Syntax & Compliance Issues (${htmlErrors.length})`,
571
- ];
572
- if (htmlErrors.length === 0) {
573
- report.push("*No HTML syntax or markup validation errors found! Excellent job.*");
574
- }
575
- else {
576
- report.push("| Line | Col | Severity | Message | Extract |");
577
- report.push("| :---: | :---: | :--- | :--- | :--- |");
578
- for (const err of htmlErrors) {
579
- const extract = err.extract ? `\`${err.extract.replace(/\n/g, " ").trim()}\`` : "N/A";
580
- report.push(`| ${err.lastLine || "N/A"} | ${err.lastColumn || "N/A"} | ${err.type === "error" ? "🔴 Error" : "⚠️ Warning"} | ${err.message} | ${extract} |`);
581
- }
582
- }
583
- if (cssFilePath) {
584
- report.push(``, `---`, ``, `## 🎨 CSS Styling Issues (${cssErrors.length})`);
585
- if (cssErrors.length === 0) {
586
- report.push("*No CSS syntax errors found! Stylesheet is fully compliant.*");
587
- }
588
- else {
589
- report.push("| Line | Context | Message |");
590
- report.push("| :---: | :--- | :--- |");
591
- for (const err of cssErrors) {
592
- report.push(`| ${err.line} | \`${err.context || "N/A"}\` | ${err.message} |`);
593
- }
594
- }
595
- }
596
- report.push(``, `---`, ``, `## 🔍 Technical SEO & Accessibility Issues (${seoIssues.length + schemaIssues.length})`);
597
- const allSeo = [...seoIssues, ...schemaIssues];
598
- if (allSeo.length === 0) {
599
- report.push("*No technical SEO or schema issues found! Page is search-engine ready.*");
600
- }
601
- else {
602
- report.push("| Category | Severity | Message | Element Snippet |");
603
- report.push("| :--- | :--- | :--- | :--- |");
604
- for (const issue of allSeo) {
605
- const severityLabel = issue.severity === "error" ? "🔴 Error" : (issue.severity === "warning" ? "⚠️ Warning" : "ℹ️ Info");
606
- const snippet = issue.element ? `\`${issue.element.trim()}\`` : "N/A";
607
- report.push(`| ${issue.category} | ${severityLabel} | ${issue.message} | ${snippet} |`);
608
- }
609
- }
610
- report.push(``, `---`, ``, `## 🔗 Link Health Check (${linkStatuses.length} links checked)`);
611
- if (linkStatuses.length === 0) {
612
- report.push("*No hyperlinks found in the document.*");
613
- }
614
- else {
615
- report.push("| Link URL | Status Code | Health | Details |");
616
- report.push("| :--- | :---: | :---: | :--- |");
617
- for (const link of linkStatuses) {
618
- report.push(`| [${link.url}](${link.url}) | ${link.status} | ${link.ok ? "✅ Healthy" : "❌ Broken"} | ${link.message || "Accessible"} |`);
619
- }
620
- }
621
- return {
622
- content: [
623
- {
624
- type: "text",
625
- text: report.join("\n"),
626
- },
627
- ],
628
- };
629
- }
630
- case "screenshot_capture": {
631
- const targetPath = String(args?.targetPath);
632
- const outputDir = args?.outputDir ? String(args.outputDir) : ".mcp-validator/screenshots";
633
- const viewports = args?.viewports;
634
- const resolvedOutputDir = path.resolve(outputDir);
635
- const results = await captureScreenshots(targetPath, resolvedOutputDir, viewports);
636
- const responseText = [
637
- `# 📸 Viewport Screenshot Generation Complete`,
638
- `Captured **${results.length}** viewport rendering(s):`,
639
- ``,
640
- `| Viewport | Dimensions | Output Path |`,
641
- `| :--- | :---: | :--- |`,
642
- ...results.map(r => `| **${r.viewportName}** | ${r.width}x${r.height} px | [${path.basename(r.outputPath)}](file:///${r.outputPath.replace(/\\/g, "/")}) |`),
643
- ``,
644
- `> [!NOTE]`,
645
- `> Screenshots have been saved successfully to [${outputDir}](file:///${resolvedOutputDir.replace(/\\/g, "/")})`
646
- ].join("\n");
647
- return {
648
- content: [
649
- {
650
- type: "text",
651
- text: responseText,
652
- },
653
- ],
654
- };
655
- }
656
- default:
657
- throw new Error(`Tool "${name}" not found.`);
100
+ }
101
+ function failedReport(filePath, error) {
102
+ return {
103
+ report: `Validation report could not be generated: ${error}`,
104
+ summary: {
105
+ overallScore: null,
106
+ htmlScore: null,
107
+ cssScore: null,
108
+ seoScore: null,
109
+ linkScore: null,
110
+ htmlErrors: 0,
111
+ htmlWarnings: 0,
112
+ cssErrors: 0,
113
+ seoErrors: 0,
114
+ seoWarnings: 0,
115
+ schemaErrors: 0,
116
+ linksChecked: 0,
117
+ brokenLinks: 0,
118
+ },
119
+ htmlMessages: [],
120
+ cssMessages: [],
121
+ seoIssues: [],
122
+ schemaIssues: [],
123
+ links: [],
124
+ failedChecks: ["input"],
125
+ errors: [`${path.basename(filePath || "document")}: ${error}`],
126
+ };
127
+ }
128
+ const validationReportDependencies = {
129
+ readTextFile,
130
+ validateHtmlContent,
131
+ validateCssContent,
132
+ auditSeoMetadata,
133
+ validateSchemaMarkup,
134
+ checkBrokenLinks,
135
+ };
136
+ /** Runs independent validation checks without discarding successful results when another check fails. */
137
+ export async function generateValidationReport({ htmlFilePath, cssFilePath, baseUrl }, dependencies = validationReportDependencies) {
138
+ const html = await dependencies.readTextFile(htmlFilePath, HTML_MAX_BYTES);
139
+ const failedChecks = [];
140
+ const errors = [];
141
+ const recordFailure = (check, label, cause) => {
142
+ if (!failedChecks.includes(check)) {
143
+ failedChecks.push(check);
144
+ }
145
+ errors.push(`${label} was unavailable: ${getErrorMessage(cause)}`);
146
+ };
147
+ let css;
148
+ if (cssFilePath) {
149
+ try {
150
+ css = await dependencies.readTextFile(cssFilePath, CSS_MAX_BYTES);
151
+ }
152
+ catch (cause) {
153
+ recordFailure("css", "CSS validation", cause);
658
154
  }
659
155
  }
660
- catch (error) {
661
- return {
662
- isError: true,
663
- content: [
664
- {
665
- type: "text",
666
- text: `Server error executing tool "${name}": ${error.message}`,
156
+ const [htmlResult, cssResult, seoResult, schemaResult, linksResult] = await Promise.allSettled([
157
+ dependencies.validateHtmlContent(html),
158
+ css === undefined ? Promise.resolve([]) : dependencies.validateCssContent(css),
159
+ Promise.resolve().then(() => dependencies.auditSeoMetadata(html)),
160
+ Promise.resolve().then(() => dependencies.validateSchemaMarkup(html)),
161
+ dependencies.checkBrokenLinks(html, baseUrl, 25),
162
+ ]);
163
+ if (htmlResult.status === "rejected")
164
+ recordFailure("html", "HTML validation", htmlResult.reason);
165
+ if (cssResult.status === "rejected")
166
+ recordFailure("css", "CSS validation", cssResult.reason);
167
+ if (seoResult.status === "rejected")
168
+ recordFailure("seo", "SEO analysis", seoResult.reason);
169
+ if (schemaResult.status === "rejected")
170
+ recordFailure("schema", "JSON-LD analysis", schemaResult.reason);
171
+ if (linksResult.status === "rejected")
172
+ recordFailure("links", "Link checking", linksResult.reason);
173
+ return createValidationReport({
174
+ htmlFilePath,
175
+ cssAudited: css !== undefined,
176
+ htmlMessages: htmlResult.status === "fulfilled" ? htmlResult.value : [],
177
+ cssMessages: cssResult.status === "fulfilled" ? cssResult.value : [],
178
+ seoIssues: seoResult.status === "fulfilled" ? seoResult.value.slice(0, 200) : [],
179
+ schemaIssues: schemaResult.status === "fulfilled" ? schemaResult.value.slice(0, 200) : [],
180
+ links: linksResult.status === "fulfilled" ? linksResult.value : [],
181
+ failedChecks,
182
+ errors,
183
+ });
184
+ }
185
+ export function createServer() {
186
+ const server = new McpServer({ name: "mcp-web-validator", version: SERVER_VERSION }, {
187
+ instructions: "Validate only files, markup, and public URLs the user owns or is authorized to inspect. HTML and CSS validation send supplied content to W3C-operated validators. Link checks contact public links without following redirects. Screenshot capture executes page content in a sandboxed local browser and writes PNG files.",
188
+ });
189
+ server.registerTool("html.local", {
190
+ title: "Validate local HTML",
191
+ description: "Reads a bounded local HTML file and sends its markup to the W3C Nu HTML Checker. Use only files the user is authorized to share.",
192
+ inputSchema: {
193
+ filePath: filePathSchema.describe("Absolute or workspace-relative path to an HTML file."),
194
+ },
195
+ outputSchema: {
196
+ errors: z.array(w3cMessageSchema),
197
+ error: z.string().optional(),
198
+ },
199
+ annotations: externalReadOnlyAnnotations,
200
+ }, async ({ filePath }) => {
201
+ try {
202
+ const html = await readTextFile(filePath, HTML_MAX_BYTES);
203
+ const errors = await validateHtmlContent(html);
204
+ return result({ errors }, htmlValidationContent(errors));
205
+ }
206
+ catch (cause) {
207
+ const error = getErrorMessage(cause);
208
+ return result({ errors: [], error }, failureContent("HTML validation", error, "Confirm the file exists and is readable, then retry. If the W3C service is unavailable, try again later."), true);
209
+ }
210
+ });
211
+ server.registerTool("html.url", {
212
+ title: "Validate a public URL",
213
+ description: "Fetches a bounded public HTTP(S) page, then sends its markup to the W3C Nu HTML Checker. Private, reserved, credentialed, and nonstandard-port destinations are rejected.",
214
+ inputSchema: {
215
+ url: publicUrlSchema.describe("Public HTTP(S) URL on port 80 or 443."),
216
+ },
217
+ outputSchema: {
218
+ errors: z.array(w3cMessageSchema),
219
+ fetchedUrl: z.string().optional(),
220
+ error: z.string().optional(),
221
+ },
222
+ annotations: externalReadOnlyAnnotations,
223
+ }, async ({ url }) => {
224
+ try {
225
+ const fetched = await fetchPublicText(url, {
226
+ maxBytes: HTML_MAX_BYTES,
227
+ timeoutMs: 15_000,
228
+ maxRedirects: 3,
229
+ headers: {
230
+ accept: "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.5",
231
+ "user-agent": `DigestSEO-Web-Validator/${SERVER_VERSION} (+https://digestseo.com/validator-mcp/)`,
667
232
  },
668
- ],
669
- };
670
- }
671
- });
672
- // Run server using stdio transport
673
- async function run() {
674
- const transport = new StdioServerTransport();
675
- await server.connect(transport);
676
- console.error("mcp-web-validator server successfully started on stdio");
233
+ });
234
+ if (fetched.status < 200 || fetched.status >= 300) {
235
+ throw new Error(`Target URL returned HTTP ${fetched.status}.`);
236
+ }
237
+ const errors = await validateHtmlContent(fetched.text);
238
+ return result({ errors, fetchedUrl: fetched.url }, htmlValidationContent(errors, fetched.url));
239
+ }
240
+ catch (cause) {
241
+ const error = getErrorMessage(cause);
242
+ return result({ errors: [], error }, failureContent("URL validation", error, "Confirm the URL is public, uses HTTP or HTTPS on a standard port, and returns HTML, then retry."), true);
243
+ }
244
+ });
245
+ server.registerTool("css.local", {
246
+ title: "Validate local CSS",
247
+ description: "Reads a bounded local CSS file and sends it to the W3C Jigsaw CSS Validator. Use only files the user is authorized to share.",
248
+ inputSchema: {
249
+ filePath: filePathSchema.describe("Absolute or workspace-relative path to a CSS file."),
250
+ },
251
+ outputSchema: {
252
+ errors: z.array(cssMessageSchema),
253
+ error: z.string().optional(),
254
+ },
255
+ annotations: externalReadOnlyAnnotations,
256
+ }, async ({ filePath }) => {
257
+ try {
258
+ const css = await readTextFile(filePath, CSS_MAX_BYTES);
259
+ const errors = await validateCssContent(css);
260
+ return result({ errors }, cssValidationContent(errors));
261
+ }
262
+ catch (cause) {
263
+ const error = getErrorMessage(cause);
264
+ return result({ errors: [], error }, failureContent("CSS validation", error, "Confirm the file exists, is readable, and is within the size limit, then retry."), true);
265
+ }
266
+ });
267
+ server.registerTool("seo.metadata", {
268
+ title: "Audit SEO metadata",
269
+ description: "Analyzes supplied HTML locally for metadata, heading structure, viewport configuration, image alternatives, and Open Graph fields.",
270
+ inputSchema: {
271
+ htmlContent: htmlContentSchema.describe("Raw HTML markup to inspect locally."),
272
+ },
273
+ outputSchema: {
274
+ issues: z.array(seoIssueSchema),
275
+ totalIssues: z.number().int().nonnegative(),
276
+ truncated: z.boolean(),
277
+ error: z.string().optional(),
278
+ },
279
+ annotations: localReadOnlyAnnotations,
280
+ }, async ({ htmlContent }) => {
281
+ try {
282
+ const { issues, totalIssues, truncated } = auditSeoMetadataDetailed(htmlContent);
283
+ return result({ issues, totalIssues, truncated }, seoAuditContent(issues, totalIssues, truncated));
284
+ }
285
+ catch (cause) {
286
+ const error = getErrorMessage(cause);
287
+ return result({ issues: [], totalIssues: 0, truncated: false, error }, failureContent("SEO audit", error, "Confirm the supplied HTML is complete and within the size limit, then retry."), true);
288
+ }
289
+ });
290
+ server.registerTool("links.broken", {
291
+ title: "Check public links",
292
+ description: "Resolves and checks up to 25 public HTTP(S) links in supplied HTML. Redirects are reported but not followed, and response bodies are discarded.",
293
+ inputSchema: {
294
+ htmlContent: htmlContentSchema.describe("Raw HTML markup containing links to check."),
295
+ baseUrl: publicUrlSchema
296
+ .optional()
297
+ .describe("Optional public HTTP(S) base URL used to resolve relative links."),
298
+ maxLinks: z
299
+ .number()
300
+ .int()
301
+ .min(1)
302
+ .max(25)
303
+ .default(25)
304
+ .describe("Maximum number of public HTTP(S) links to check, from 1 to 25."),
305
+ },
306
+ outputSchema: {
307
+ links: z.array(linkStatusSchema),
308
+ error: z.string().optional(),
309
+ },
310
+ annotations: externalReadOnlyAnnotations,
311
+ }, async ({ htmlContent, baseUrl, maxLinks }) => {
312
+ try {
313
+ const links = await checkBrokenLinks(htmlContent, baseUrl, maxLinks);
314
+ return result({ links }, linkCheckContent(links, baseUrl));
315
+ }
316
+ catch (cause) {
317
+ const error = getErrorMessage(cause);
318
+ return result({ links: [], error }, failureContent("Link check", error, "Confirm the base URL is public and the supplied HTML is within the size limit, then retry."), true);
319
+ }
320
+ });
321
+ server.registerTool("schema.markup", {
322
+ title: "Validate JSON-LD syntax",
323
+ description: "Parses JSON-LD blocks in supplied HTML locally and reports empty blocks or JSON syntax errors. It does not validate vocabulary semantics.",
324
+ inputSchema: {
325
+ htmlContent: htmlContentSchema.describe("Raw HTML containing JSON-LD script blocks."),
326
+ },
327
+ outputSchema: {
328
+ issues: z.array(seoIssueSchema),
329
+ totalIssues: z.number().int().nonnegative(),
330
+ truncated: z.boolean(),
331
+ error: z.string().optional(),
332
+ },
333
+ annotations: localReadOnlyAnnotations,
334
+ }, async ({ htmlContent }) => {
335
+ try {
336
+ const { issues, totalIssues, truncated } = validateSchemaMarkupDetailed(htmlContent);
337
+ return result({ issues, totalIssues, truncated }, schemaValidationContent(issues, totalIssues, truncated, htmlContent));
338
+ }
339
+ catch (cause) {
340
+ const error = getErrorMessage(cause);
341
+ return result({ issues: [], totalIssues: 0, truncated: false, error }, failureContent("JSON-LD syntax", error, "Confirm the supplied HTML is complete and within the size limit, then retry."), true);
342
+ }
343
+ });
344
+ server.registerTool("report.validation", {
345
+ title: "Generate a validation report",
346
+ description: "Combines W3C HTML/CSS validation, local SEO/accessibility checks, JSON-LD syntax checks, and a bounded public-link check into a Markdown and structured report.",
347
+ inputSchema: {
348
+ htmlFilePath: filePathSchema.describe("Absolute or workspace-relative HTML file path."),
349
+ cssFilePath: filePathSchema
350
+ .optional()
351
+ .describe("Optional absolute or workspace-relative CSS file path."),
352
+ baseUrl: publicUrlSchema
353
+ .optional()
354
+ .describe("Optional public HTTP(S) base URL used to resolve relative links."),
355
+ },
356
+ outputSchema: {
357
+ report: z.string(),
358
+ summary: reportSummarySchema,
359
+ htmlMessages: z.array(w3cMessageSchema),
360
+ cssMessages: z.array(cssMessageSchema),
361
+ seoIssues: z.array(seoIssueSchema),
362
+ schemaIssues: z.array(seoIssueSchema),
363
+ links: z.array(linkStatusSchema),
364
+ failedChecks: z.array(z.enum(validationReportChecks)),
365
+ errors: z.array(z.string()).optional(),
366
+ },
367
+ annotations: externalReadOnlyAnnotations,
368
+ }, async ({ htmlFilePath, cssFilePath, baseUrl }) => {
369
+ try {
370
+ const reportData = await generateValidationReport({
371
+ htmlFilePath,
372
+ cssFilePath,
373
+ baseUrl,
374
+ });
375
+ return result(reportData, reportContent(reportData));
376
+ }
377
+ catch (cause) {
378
+ const error = getErrorMessage(cause);
379
+ const reportData = failedReport(htmlFilePath, error);
380
+ return result(reportData, failureContent("Validation report", error, "Confirm the input paths are readable and any base URL is public, then regenerate the report."), true);
381
+ }
382
+ });
383
+ server.registerTool("screenshot.capture", {
384
+ title: "Capture responsive screenshots",
385
+ description: "Renders a local HTML file or HTTP(S) URL in a sandboxed local Chromium browser and writes PNG screenshots to the requested directory. Existing matching files may be replaced.",
386
+ inputSchema: {
387
+ targetPath: z
388
+ .string()
389
+ .trim()
390
+ .min(1)
391
+ .max(PATH_MAX_LENGTH)
392
+ .describe("Local HTML path or HTTP(S) URL to render."),
393
+ outputDir: filePathSchema
394
+ .optional()
395
+ .default(".mcp-validator/screenshots")
396
+ .describe("Directory where PNG screenshots will be written."),
397
+ viewports: z
398
+ .array(z.object({
399
+ name: z
400
+ .string()
401
+ .regex(/^[A-Za-z0-9_-]{1,50}$/)
402
+ .describe("Safe filename label for this screenshot viewport."),
403
+ width: z
404
+ .number()
405
+ .int()
406
+ .min(200)
407
+ .max(3_840)
408
+ .describe("Viewport width in CSS pixels, from 200 to 3840."),
409
+ height: z
410
+ .number()
411
+ .int()
412
+ .min(200)
413
+ .max(4_320)
414
+ .describe("Viewport height in CSS pixels, from 200 to 4320."),
415
+ }))
416
+ .min(1)
417
+ .max(MAX_VIEWPORTS)
418
+ .optional()
419
+ .describe("Optional viewport definitions; defaults to desktop, tablet, and mobile sizes."),
420
+ },
421
+ outputSchema: {
422
+ screenshots: z.array(screenshotSchema),
423
+ outputDirectory: z.string(),
424
+ error: z.string().optional(),
425
+ },
426
+ annotations: {
427
+ readOnlyHint: false,
428
+ destructiveHint: true,
429
+ idempotentHint: true,
430
+ openWorldHint: true,
431
+ },
432
+ }, async ({ targetPath, outputDir, viewports }) => {
433
+ const outputDirectory = path.resolve(outputDir);
434
+ try {
435
+ const screenshots = await captureScreenshots(targetPath, outputDirectory, viewports);
436
+ return result({ screenshots, outputDirectory }, screenshotCaptureContent(screenshots.length, outputDirectory));
437
+ }
438
+ catch (cause) {
439
+ const error = getErrorMessage(cause);
440
+ return result({ screenshots: [], outputDirectory, error }, failureContent("Screenshot capture", error, "Confirm the target is reachable or readable and the output directory is writable, then retry."), true);
441
+ }
442
+ });
443
+ return server;
444
+ }
445
+ export async function run() {
446
+ const server = createServer();
447
+ await server.connect(new StdioServerTransport());
448
+ console.error(`mcp-web-validator ${SERVER_VERSION} started on stdio`);
449
+ }
450
+ const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : undefined;
451
+ if (invokedPath === import.meta.url) {
452
+ run().catch((cause) => {
453
+ console.error("Fatal error starting mcp-web-validator:", getErrorMessage(cause));
454
+ process.exitCode = 1;
455
+ });
677
456
  }
678
- run().catch((error) => {
679
- console.error("Fatal error starting mcp-web-validator server:", error);
680
- process.exit(1);
681
- });
682
457
  //# sourceMappingURL=index.js.map