@@ -571,6 +571,17 @@ function formatMetatags(url: string, parsed: ParsedHead): string {
571571 lines . push ( `${ h . lang } : ${ h . href } ` ) ;
572572 }
573573 lines . push ( "" ) ;
574+ } else {
575+ // Warn if URL suggests locale-specific content but no hreflang
576+ const localePattern =
577+ / \/ (?: e n | e s | f r | d e | j a | k o | p t | z h | r u | i t | n l | s v | d a | f i | n b | p l | t r | a r | h i | t h | v i | i d | m s | u k | c s | r o | h u | e l | h e | b g | h r | s k | s l | s r | l t | l v | e t | c a | g l | e u | c y ) (?: [ - _ ] [ a - z ] { 2 , 4 } ) ? (?: \/ | $ ) / i;
578+ const checkUrl = parsed . canonical ?? url ;
579+ if ( localePattern . test ( checkUrl ) ) {
580+ lines . push (
581+ "WARN: URL appears locale-specific but no hreflang tags found — search engines may not discover alternate language versions" ,
582+ ) ;
583+ lines . push ( "" ) ;
584+ }
574585 }
575586
576587 // Summary of missing tags relevant to search and social
@@ -798,15 +809,19 @@ export function registerPageTool(server: McpServer): void {
798809 "page" ,
799810 "Analyze what's on a page — meta tags, Open Graph, Twitter Card, structured data (JSON-LD), internal links, and redirect chains. Also includes a WCAG contrast checker for accessibility fixes." ,
800811 {
801- url : z
802- . string ( )
803- . url ( )
812+ url : z . string ( ) . url ( ) . optional ( ) . describe ( "Single URL to analyze. Use this OR urls, not both." ) ,
813+ urls : z
814+ . array ( z . string ( ) . url ( ) )
815+ . min ( 2 )
816+ . max ( 10 )
804817 . optional ( )
805- . describe ( "Page URL to analyze. Returns meta tags, structured data, and optionally internal links." ) ,
818+ . describe (
819+ "Multiple URLs (2-10) for batch analysis. Returns a summary table of meta tags, structured data, and link health per page." ,
820+ ) ,
806821 check_links : z
807822 . boolean ( )
808823 . optional ( )
809- . describe ( "Also check all internal links for broken links and redirect chains. Default: false." ) ,
824+ . describe ( "Check internal links for broken links and redirect chains. Default: true. Set false to skip ." ) ,
810825 user_agent : z . string ( ) . optional ( ) . describe ( "Custom User-Agent for page fetch. Default: Googlebot-compatible." ) ,
811826 foreground : z
812827 . string ( )
@@ -818,7 +833,129 @@ export function registerPageTool(server: McpServer): void {
818833 . optional ( )
819834 . describe ( "Whether text is large (≥18pt or ≥14pt bold). Lowers AA threshold to 3:1." ) ,
820835 } ,
821- async ( { url, check_links, user_agent, foreground, background, large_text } ) => {
836+ async ( { url, urls, check_links, user_agent, foreground, background, large_text } ) => {
837+ // --- Batch mode ---
838+ if ( urls ) {
839+ const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" ;
840+ const results : Array < {
841+ url : string ;
842+ title : string ;
843+ description : string ;
844+ canonical : string ;
845+ jsonLd : string ;
846+ links : string ;
847+ issues : string [ ] ;
848+ error ?: string ;
849+ } > = [ ] ;
850+
851+ for ( const pageUrl of urls ) {
852+ try {
853+ const { chain, response : res } = await followRedirects ( pageUrl , ua ) ;
854+ if ( ! res . ok ) {
855+ results . push ( {
856+ url : pageUrl ,
857+ title : "-" ,
858+ description : "-" ,
859+ canonical : "-" ,
860+ jsonLd : "-" ,
861+ links : "-" ,
862+ issues : [ `HTTP ${ res . status } ` ] ,
863+ } ) ;
864+ continue ;
865+ }
866+ const html = await res . text ( ) ;
867+ const parsed = parseHead ( html ) ;
868+ const finalUrl = chain . length > 1 ? chain [ chain . length - 1 ] . url : pageUrl ;
869+
870+ const issues : string [ ] = [ ] ;
871+ if ( ! parsed . title ) issues . push ( "no title" ) ;
872+ if ( ! getMeta ( parsed . meta , "description" ) ) issues . push ( "no description" ) ;
873+ else if ( ( getMeta ( parsed . meta , "description" ) ?? "" ) . length > 155 ) issues . push ( "description >155 chars" ) ;
874+ if ( ! parsed . canonical ) issues . push ( "no canonical" ) ;
875+ if ( parsed . jsonLd . length === 0 ) issues . push ( "no JSON-LD" ) ;
876+ if ( chain . length > 2 ) issues . push ( `${ chain . length - 1 } redirects` ) ;
877+
878+ // Quick link check
879+ let linkSummary = "-" ;
880+ if ( check_links !== false ) {
881+ const origin = new URL ( finalUrl ) . origin ;
882+ const links = extractInternalLinks ( html , origin ) ;
883+ if ( links . length > 0 ) {
884+ const checked = await Promise . all ( links . slice ( 0 , 20 ) . map ( ( href ) => checkLink ( href ) ) ) ;
885+ const broken = checked . filter ( ( r ) => r . error || ( r . status && r . status >= 400 ) ) . length ;
886+ const redirected = checked . filter (
887+ ( r ) => ! r . error && r . redirectChain . length > 1 && r . status && r . status < 400 ,
888+ ) . length ;
889+ const parts : string [ ] = [ `${ links . length } found` ] ;
890+ if ( broken > 0 ) parts . push ( `${ broken } broken` ) ;
891+ if ( redirected > 0 ) parts . push ( `${ redirected } redirected` ) ;
892+ if ( broken === 0 && redirected === 0 ) parts . push ( "all OK" ) ;
893+ linkSummary = parts . join ( ", " ) ;
894+ if ( broken > 0 ) issues . push ( `${ broken } broken links` ) ;
895+ if ( redirected > 0 ) issues . push ( `${ redirected } redirect chains` ) ;
896+ } else {
897+ linkSummary = "0 (SPA?)" ;
898+ }
899+ }
900+
901+ const jsonLdTypes = parsed . jsonLd
902+ . map ( ( block ) => {
903+ if ( block && typeof block === "object" && "@type" in block )
904+ return String ( ( block as Record < string , unknown > ) [ "@type" ] ) ;
905+ return null ;
906+ } )
907+ . filter ( Boolean ) ;
908+
909+ results . push ( {
910+ url : finalUrl ,
911+ title : parsed . title
912+ ? parsed . title . length > 40
913+ ? `${ parsed . title . slice ( 0 , 40 ) } ...`
914+ : parsed . title
915+ : "(missing)" ,
916+ description : getMeta ( parsed . meta , "description" ) ? "yes" : "no" ,
917+ canonical : parsed . canonical ? "yes" : "no" ,
918+ jsonLd : jsonLdTypes . length > 0 ? jsonLdTypes . join ( ", " ) : "none" ,
919+ links : linkSummary ,
920+ issues,
921+ } ) ;
922+ } catch ( err ) {
923+ results . push ( {
924+ url : pageUrl ,
925+ title : "-" ,
926+ description : "-" ,
927+ canonical : "-" ,
928+ jsonLd : "-" ,
929+ links : "-" ,
930+ issues : [ err instanceof Error ? err . message : String ( err ) ] ,
931+ } ) ;
932+ }
933+ }
934+
935+ const lines : string [ ] = [ `=== Batch Page Analysis (${ results . length } URLs) ===` , "" ] ;
936+
937+ for ( const r of results ) {
938+ const u = new URL ( r . url ) ;
939+ const allSameHost = results . every ( ( x ) => new URL ( x . url ) . hostname === u . hostname ) ;
940+ const label = allSameHost ? u . pathname : `${ u . hostname } ${ u . pathname } ` ;
941+ lines . push ( label ) ;
942+ lines . push ( ` Title: ${ r . title } ` ) ;
943+ lines . push ( ` Description: ${ r . description } Canonical: ${ r . canonical } JSON-LD: ${ r . jsonLd } ` ) ;
944+ if ( check_links !== false ) lines . push ( ` Links: ${ r . links } ` ) ;
945+ if ( r . issues . length > 0 ) {
946+ lines . push ( ` Issues: ${ r . issues . join ( ", " ) } ` ) ;
947+ } else {
948+ lines . push ( " Issues: none" ) ;
949+ }
950+ lines . push ( "" ) ;
951+ }
952+
953+ const issueCount = results . reduce ( ( sum , r ) => sum + r . issues . length , 0 ) ;
954+ lines . push ( `Total: ${ results . length } pages, ${ issueCount } issues` ) ;
955+
956+ return { content : [ { type : "text" , text : lines . join ( "\n" ) } ] } ;
957+ }
958+
822959 // --- Contrast-only mode (no URL) ---
823960 if ( foreground && background && ! url ) {
824961 const fg = parseHex ( foreground ) ;
@@ -971,8 +1108,34 @@ export function registerPageTool(server: McpServer): void {
9711108 }
9721109 }
9731110
974- // --- Link check (appended after metatags output) ---
975- if ( check_links ) {
1111+ // Contrast check (before links, after structured data)
1112+ if ( foreground && background ) {
1113+ const fg = parseHex ( foreground ) ;
1114+ const bg = parseHex ( background ) ;
1115+ if ( fg && bg ) {
1116+ const fgLum = relativeLuminance ( ...fg ) ;
1117+ const bgLum = relativeLuminance ( ...bg ) ;
1118+ const ratio = contrastRatio ( fgLum , bgLum ) ;
1119+ const isLarge = large_text ?? false ;
1120+ const level = wcagLevel ( ratio , isLarge ) ;
1121+ const aaThreshold = isLarge ? 3 : 4.5 ;
1122+ const aaaThreshold = isLarge ? 4.5 : 7 ;
1123+ output . push (
1124+ "" ,
1125+ "--- Contrast Check ---" ,
1126+ "" ,
1127+ `Foreground: ${ toHex ( ...fg ) } Background: ${ toHex ( ...bg ) } ` ,
1128+ `Ratio: ${ ratio . toFixed ( 2 ) } :1 AA: ${ ratio >= aaThreshold ? "PASS" : "FAIL" } AAA: ${ ratio >= aaaThreshold ? "PASS" : "FAIL" } Result: ${ level } ` ,
1129+ ) ;
1130+ if ( ratio < aaThreshold ) {
1131+ const suggested = findNearestPassing ( fg , bg , aaThreshold ) ;
1132+ output . push ( `Nearest AA-passing foreground: ${ suggested } ` ) ;
1133+ }
1134+ }
1135+ }
1136+
1137+ // --- Link check (last section) ---
1138+ if ( check_links !== false ) {
9761139 const origin = new URL ( url ) . origin ;
9771140 const links = extractInternalLinks ( html , origin ) ;
9781141
@@ -1001,32 +1164,6 @@ export function registerPageTool(server: McpServer): void {
10011164 }
10021165 }
10031166
1004- // Append contrast check if colors were also provided
1005- if ( foreground && background ) {
1006- const fg = parseHex ( foreground ) ;
1007- const bg = parseHex ( background ) ;
1008- if ( fg && bg ) {
1009- const fgLum = relativeLuminance ( ...fg ) ;
1010- const bgLum = relativeLuminance ( ...bg ) ;
1011- const ratio = contrastRatio ( fgLum , bgLum ) ;
1012- const isLarge = large_text ?? false ;
1013- const level = wcagLevel ( ratio , isLarge ) ;
1014- const aaThreshold = isLarge ? 3 : 4.5 ;
1015- const aaaThreshold = isLarge ? 4.5 : 7 ;
1016- output . push (
1017- "" ,
1018- "--- Contrast Check ---" ,
1019- "" ,
1020- `Foreground: ${ toHex ( ...fg ) } Background: ${ toHex ( ...bg ) } ` ,
1021- `Ratio: ${ ratio . toFixed ( 2 ) } :1 AA: ${ ratio >= aaThreshold ? "PASS" : "FAIL" } AAA: ${ ratio >= aaaThreshold ? "PASS" : "FAIL" } Result: ${ level } ` ,
1022- ) ;
1023- if ( ratio < aaThreshold ) {
1024- const suggested = findNearestPassing ( fg , bg , aaThreshold ) ;
1025- output . push ( `Nearest AA-passing foreground: ${ suggested } ` ) ;
1026- }
1027- }
1028- }
1029-
10301167 return {
10311168 content : [ { type : "text" , text : output . join ( "\n" ) } ] ,
10321169 } ;
0 commit comments