New:Socket for Asana Is Now Available.Learn more
Get Started

@focusgts/eds-mcp-server

Package Overview
Dependencies
Maintainers
1
Versions
21
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@focusgts/eds-mcp-server - npm Package Compare versions

Comparing version
0.7.0
to
0.8.0
+11
dist/audit/checks/accessibility.d.ts
/**
* Per-page accessibility checks (ADR-010).
*
* Regex-based analysis of a page's rendered HTML — no DOM, no dependencies.
* Detection logic (patterns, ratio thresholds) is ported verbatim from the
* eds-score scorer; only the output shape differs. A check returns `null` when
* the page passes.
*/
import type { AuditFinding } from '../types.js';
/** Run every per-page accessibility check and return the findings. */
export declare function accessibilityFindings(html: string): AuditFinding[];
/**
* Per-page accessibility checks (ADR-010).
*
* Regex-based analysis of a page's rendered HTML — no DOM, no dependencies.
* Detection logic (patterns, ratio thresholds) is ported verbatim from the
* eds-score scorer; only the output shape differs. A check returns `null` when
* the page passes.
*/
function checkImageAltText(html) {
const images = html.match(/<img\s[^>]*>/gi) ?? [];
if (images.length === 0)
return null;
let missing = 0;
for (const img of images) {
// Only a TRULY absent alt attribute is a WCAG failure (screen readers then
// announce the file name). `alt=""` is the spec-correct decorative marker —
// valid, not a violation — and EDS emits it whenever an author leaves alt
// blank, so treating it as "missing" would false-positive across EDS sites.
const hasAltAttr = /\balt\s*=/i.test(img);
if (!hasAltAttr) {
const decorative = /role=["']presentation["']/i.test(img) || /aria-hidden=["']true["']/i.test(img);
if (!decorative)
missing++;
}
}
if (missing === 0)
return null;
const ratio = missing / images.length;
return {
dimension: 'accessibility',
// A majority of images with no alt attribute is a real barrier; a few is a warning.
severity: ratio > 0.5 ? 'critical' : 'warning',
title: 'Images missing an alt attribute',
detail: `${missing} of ${images.length} images have no alt attribute at all.`,
suggestion: 'Add alt text (or alt="" for genuinely decorative images).',
};
}
function checkHeadingHierarchy(html) {
const re = /<h([1-6])[\s>]/gi;
const levels = [];
let m;
while ((m = re.exec(html)) !== null)
levels.push(parseInt(m[1], 10));
if (levels.length === 0) {
return {
dimension: 'accessibility',
severity: 'warning',
title: 'No headings on the page',
detail: 'The page has no heading elements to structure its content.',
suggestion: 'Add a heading outline (one <h1>, then <h2>/<h3>) for screen-reader navigation.',
};
}
let skips = 0;
for (let i = 1; i < levels.length; i++) {
if (levels[i] > levels[i - 1] + 1)
skips++;
}
if (skips === 0)
return null;
return {
dimension: 'accessibility',
severity: skips > 2 ? 'warning' : 'info',
title: 'Heading levels skip',
detail: `Found ${skips} heading-level skip(s) across ${levels.length} headings (e.g. h1 → h3).`,
suggestion: 'Do not skip heading levels; step down one at a time.',
};
}
function checkLinkText(html) {
const re = /<a\s[^>]*>([\s\S]*?)<\/a>/gi;
const bad = new Set(['click here', 'read more', 'learn more', 'here', 'more', 'link']);
let total = 0;
let generic = 0;
let m;
while ((m = re.exec(html)) !== null) {
total++;
const text = m[1].replace(/<[^>]*>/g, '').trim().toLowerCase();
if (text && bad.has(text))
generic++;
}
if (total === 0 || generic === 0)
return null;
return {
dimension: 'accessibility',
severity: 'info',
title: 'Non-descriptive link text',
detail: `${generic} of ${total} links use generic text (e.g. "click here", "read more").`,
suggestion: 'Use link text that describes the destination out of context.',
};
}
function checkAriaLandmarks(html) {
const hasMain = /<main[\s>]/i.test(html) || /role=["']main["']/i.test(html);
// <header>/banner counts as the top landmark: EDS loads <nav> into the header
// client-side, so requiring a literal <nav> in the served HTML would
// false-positive on every EDS site.
const hasNav = /<nav[\s>]/i.test(html) ||
/role=["']navigation["']/i.test(html) ||
/<header[\s>]/i.test(html) ||
/role=["']banner["']/i.test(html);
const hasFooter = /<footer[\s>]/i.test(html) || /role=["']contentinfo["']/i.test(html);
const found = [hasMain, hasNav, hasFooter].filter(Boolean).length;
if (found === 3)
return null;
const missing = [];
if (!hasMain)
missing.push('main');
if (!hasNav)
missing.push('header/nav');
if (!hasFooter)
missing.push('footer');
return {
dimension: 'accessibility',
severity: found === 0 ? 'warning' : 'info',
title: 'Missing landmark regions',
detail: `Missing landmark region(s): ${missing.join(', ')}.`,
suggestion: 'Use semantic <main>, <nav> and <footer> for screen-reader navigation.',
};
}
// NOTE: there is deliberately no `<html lang>` check. Edge Delivery Services
// applies `document.documentElement.lang` client-side (from metadata) — every
// EDS page, including Adobe's own www.aem.live, serves bare `<html>` at the
// origin. Auditing the served HTML for lang would false-positive a "critical"
// on 100% of pages of every EDS site, which is wrong and misleading.
function checkFormLabels(html) {
const re = /<(?:input|select|textarea)\s[^>]*>/gi;
const controls = [];
let m;
while ((m = re.exec(html)) !== null) {
const tag = m[0];
const type = tag.match(/type=["']([^"']*)["']/i)?.[1]?.toLowerCase() ?? 'text';
if (['hidden', 'submit', 'button', 'image'].includes(type))
continue;
controls.push(tag);
}
if (controls.length === 0)
return null;
let unlabeled = 0;
for (const control of controls) {
const hasAriaLabel = /aria-label=["'][^"']+["']/i.test(control);
const hasAriaLabelledBy = /aria-labelledby=["'][^"']+["']/i.test(control);
const hasTitle = /title=["'][^"']+["']/i.test(control);
const id = control.match(/\bid=["']([^"']*)["']/i)?.[1];
let hasAssociatedLabel = false;
if (id) {
// Escape regex metacharacters — ids like "a[0]" would otherwise mis-match
// or throw (and throwing would drop the whole page to a fetch "failure").
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
hasAssociatedLabel = new RegExp(`<label\\s[^>]*for=["']${escaped}["']`, 'i').test(html);
}
if (!hasAriaLabel && !hasAriaLabelledBy && !hasTitle && !hasAssociatedLabel)
unlabeled++;
}
if (unlabeled === 0)
return null;
const ratio = unlabeled / controls.length;
return {
dimension: 'accessibility',
severity: ratio > 0.5 ? 'critical' : 'warning',
title: 'Form inputs missing labels',
detail: `${unlabeled} of ${controls.length} form inputs have no associated label.`,
suggestion: 'Associate each input with a <label for>, aria-label, or aria-labelledby.',
};
}
/** Run every per-page accessibility check and return the findings. */
export function accessibilityFindings(html) {
return [
checkImageAltText(html),
checkHeadingHierarchy(html),
checkLinkText(html),
checkAriaLandmarks(html),
checkFormLabels(html),
].filter((f) => f !== null);
}
/**
* Per-page SEO checks (ADR-010).
*
* Regex-based analysis of a page's rendered HTML — no DOM, no dependencies.
* The detection logic (patterns, length thresholds) is ported verbatim from the
* eds-score scorer; only the output shape differs (findings, not scores). A
* check returns `null` when the page passes.
*/
import type { AuditFinding } from '../types.js';
/** Run every per-page SEO check and return the findings (passing checks omitted). */
export declare function seoFindings(html: string): AuditFinding[];
/**
* Per-page SEO checks (ADR-010).
*
* Regex-based analysis of a page's rendered HTML — no DOM, no dependencies.
* The detection logic (patterns, length thresholds) is ported verbatim from the
* eds-score scorer; only the output shape differs (findings, not scores). A
* check returns `null` when the page passes.
*/
function matchTitle(html) {
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
return m?.[1]?.trim() ?? '';
}
function checkTitle(html) {
const title = matchTitle(html);
if (!title) {
return {
dimension: 'seo',
severity: 'critical',
title: 'Missing title tag',
detail: 'No <title> tag found on the page.',
suggestion: 'Add a descriptive <title> of 30–60 characters.',
};
}
const len = title.length;
if (len >= 30 && len <= 60)
return null;
return {
dimension: 'seo',
severity: 'warning',
title: 'Title length is outside the ideal range',
detail: `Title "${title}" is ${len} characters (ideal 30–60).`,
suggestion: 'Aim for a 30–60 character title so it renders fully in search results.',
};
}
function checkMetaDescription(html) {
// Match the closing quote to the opening one (backreference) so a description
// containing an apostrophe isn't truncated at the first ' — a very common
// false positive with `content="what's new …"`.
const m = html.match(/<meta\s+name=["']description["']\s+content=(["'])([\s\S]*?)\1[^>]*>/i) ??
html.match(/<meta\s+content=(["'])([\s\S]*?)\1\s+name=["']description["'][^>]*>/i);
const description = m?.[2]?.trim() ?? '';
if (!description) {
return {
dimension: 'seo',
severity: 'critical',
title: 'Missing meta description',
detail: 'No <meta name="description"> found on the page.',
suggestion: 'Add a 120–160 character meta description summarizing the page.',
};
}
const len = description.length;
if (len >= 120 && len <= 160)
return null;
return {
dimension: 'seo',
severity: 'warning',
title: 'Meta description length is outside the ideal range',
detail: `Meta description is ${len} characters (ideal 120–160).`,
suggestion: 'Aim for 120–160 characters so it renders fully in search results.',
};
}
function checkH1(html) {
const count = (html.match(/<h1[\s>]/gi) ?? []).length;
if (count === 1)
return null;
if (count === 0) {
return {
dimension: 'seo',
severity: 'critical',
title: 'No H1 heading',
detail: 'The page has no <h1> heading.',
suggestion: 'Add exactly one <h1> that describes the page.',
};
}
return {
dimension: 'seo',
severity: 'warning',
title: 'Multiple H1 headings',
detail: `Found ${count} <h1> headings — a page should have exactly one.`,
suggestion: 'Keep a single <h1>; demote the rest to <h2>/<h3>.',
};
}
function checkRobots(html) {
const m = html.match(/<meta\s+name=["']robots["']\s+content=["']([^"']*)["'][^>]*>/i) ??
html.match(/<meta\s+content=["']([^"']*)["']\s+name=["']robots["'][^>]*>/i);
const content = m?.[1]?.toLowerCase() ?? '';
// Only `noindex` (or `none`, which implies noindex) blocks indexing.
// `nofollow` controls link-following, NOT indexing — do not report it as
// "blocked from search indexing" (a factually wrong, embarrassing claim).
if (content.includes('noindex') || content.includes('none')) {
return {
dimension: 'seo',
severity: 'critical',
title: 'Page is blocked from search indexing',
detail: `A robots meta directive is blocking indexing: "${content}".`,
suggestion: 'Remove noindex/none if this page should appear in search results.',
};
}
return null;
}
function checkCanonical(html) {
if (/<link\s+[^>]*rel=["']canonical["'][^>]*>/i.test(html))
return null;
return {
dimension: 'seo',
severity: 'warning',
title: 'No canonical URL',
detail: 'The page does not declare a canonical URL.',
suggestion: 'Add <link rel="canonical"> to prevent duplicate-content issues.',
};
}
function checkJsonLd(html) {
if (/<script\s+type=["']application\/ld\+json["'][^>]*>/i.test(html))
return null;
return {
dimension: 'seo',
severity: 'info',
title: 'No structured data (JSON-LD)',
detail: 'The page has no Schema.org JSON-LD markup.',
suggestion: 'Add JSON-LD structured data to enable rich search results.',
};
}
function checkOgTags(html) {
const hasTitle = /<meta\s+[^>]*property=["']og:title["'][^>]*>/i.test(html);
const hasDesc = /<meta\s+[^>]*property=["']og:description["'][^>]*>/i.test(html);
const hasImage = /<meta\s+[^>]*property=["']og:image["'][^>]*>/i.test(html);
const found = [hasTitle, hasDesc, hasImage].filter(Boolean).length;
if (found === 3)
return null;
const missing = [];
if (!hasTitle)
missing.push('og:title');
if (!hasDesc)
missing.push('og:description');
if (!hasImage)
missing.push('og:image');
return {
dimension: 'seo',
// No OG tags at all is a warning; a partial set is a minor gap.
severity: found === 0 ? 'warning' : 'info',
title: found === 0 ? 'No Open Graph tags' : 'Incomplete Open Graph tags',
detail: `Missing Open Graph tags: ${missing.join(', ')}.`,
suggestion: 'Add og:title, og:description and og:image for rich social sharing.',
};
}
/** Run every per-page SEO check and return the findings (passing checks omitted). */
export function seoFindings(html) {
return [
checkTitle(html),
checkMetaDescription(html),
checkH1(html),
checkRobots(html),
checkCanonical(html),
checkJsonLd(html),
checkOgTags(html),
].filter((f) => f !== null);
}
/**
* Content-audit engine (ADR-010).
*
* `auditPage` runs the per-page SEO + accessibility checks over a single page's
* HTML. `auditSite` sweeps the site: per-page checks across the page index
* (bounded concurrency) plus site-level checks (freshness, sitemap coverage,
* and — when a domain is supplied — RUM performance and 404s).
*
* All data comes from the EdsClient (server-side, owner/repo/ref addressed);
* there is no Google PageSpeed dependency — performance uses Adobe's own RUM.
*/
import type { EdsClient } from '../eds-admin/client.js';
import { type AuditDimension, type AuditFinding, type AuditReport } from './types.js';
/** Run every per-page check over one page's HTML. */
export declare function auditPage(html: string, path?: string): AuditFinding[];
export interface AuditSiteOptions {
/** Only audit pages under this path prefix (e.g. "/blog/"). */
pathPrefix?: string;
/** Max pages to fetch HTML for (per-page checks). Default 50. */
maxPages?: number;
/** Which dimensions to run. Default: all. */
dimensions?: AuditDimension[];
/** Live domain for RUM (performance, links). Omit to skip RUM checks. */
domain?: string;
/** RUM window in days. Default 7. */
days?: number;
}
/** Audit a whole site (or a subtree). */
export declare function auditSite(client: EdsClient, options?: AuditSiteOptions): Promise<AuditReport>;
/** Audit a single page from its HTML. */
export declare function auditSinglePage(html: string, path: string): AuditReport;
/**
* Content-audit engine (ADR-010).
*
* `auditPage` runs the per-page SEO + accessibility checks over a single page's
* HTML. `auditSite` sweeps the site: per-page checks across the page index
* (bounded concurrency) plus site-level checks (freshness, sitemap coverage,
* and — when a domain is supplied — RUM performance and 404s).
*
* All data comes from the EdsClient (server-side, owner/repo/ref addressed);
* there is no Google PageSpeed dependency — performance uses Adobe's own RUM.
*/
import { ALL_DIMENSIONS, } from './types.js';
import { seoFindings } from './checks/seo.js';
import { accessibilityFindings } from './checks/accessibility.js';
const SEVERITY_ORDER = {
critical: 0,
warning: 1,
info: 2,
};
/** Sort findings critical-first, stable within a severity. */
function sortFindings(findings) {
return [...findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
}
/** Run every per-page check over one page's HTML. */
export function auditPage(html, path) {
const findings = [...seoFindings(html), ...accessibilityFindings(html)];
if (path)
for (const f of findings)
f.page = path;
return sortFindings(findings);
}
/** Run `fn` over `items` with at most `concurrency` in flight at once. */
async function mapWithConcurrency(items, fn, concurrency) {
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
await fn(items[i]);
}
};
const size = Math.max(1, Math.min(concurrency, items.length));
await Promise.all(Array.from({ length: size }, () => worker()));
}
// ---------------------------------------------------------------------------
// Site-level checks
// ---------------------------------------------------------------------------
const YEAR_MS = 365 * 24 * 60 * 60 * 1000;
function preview(paths, n = 10) {
const shown = paths.slice(0, n).join(', ');
return paths.length > n ? `${shown}, …` : shown;
}
/** Normalize a path for cross-source comparison: decode + drop a trailing slash. */
function normPath(p) {
let s = p;
try {
s = decodeURIComponent(p);
}
catch {
/* malformed encoding — compare as-is */
}
return s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s;
}
/** Pages not updated in over a year (query-index `lastModified`, unix seconds). */
function freshnessFindings(entries, now) {
const stale = entries.filter((e) => typeof e.lastModified === 'number' && now - e.lastModified * 1000 > YEAR_MS);
if (stale.length === 0)
return [];
return [
{
dimension: 'freshness',
severity: 'warning',
title: `${stale.length} page(s) not updated in over a year`,
detail: `Stale pages: ${preview(stale.map((e) => e.path))}`,
suggestion: 'Review and refresh outdated content, or confirm it is still accurate.',
},
];
}
/** Sitemap presence + coverage of the page index. */
function sitemapFindings(sitemap, entries) {
if (sitemap.length === 0) {
return [
{
dimension: 'sitemap',
severity: 'warning',
title: 'No sitemap entries',
detail: 'sitemap.xml returned no URLs.',
suggestion: 'Publish a sitemap.xml so search engines can discover every page.',
},
];
}
const sitemapPaths = new Set(sitemap.map((s) => {
try {
return normPath(new URL(s.loc).pathname);
}
catch {
return normPath(s.loc);
}
}));
const missing = entries.map((e) => e.path).filter((p) => !sitemapPaths.has(normPath(p)));
if (missing.length === 0)
return [];
return [
{
dimension: 'sitemap',
severity: 'info',
title: `${missing.length} indexed page(s) missing from the sitemap`,
detail: `Not in sitemap: ${preview(missing)}`,
suggestion: 'Ensure the sitemap includes every published page.',
},
];
}
/** Core Web Vitals from RUM — flag pages exceeding the "good" thresholds. */
function performanceFindings(cwv) {
const findings = [];
const worst = (rows, fmt, by) => [...rows].sort((a, b) => by(b) - by(a)).slice(0, 5).map(fmt).join(', ');
const slowLcp = cwv.filter((c) => c.lcp > 2500);
if (slowLcp.length > 0) {
findings.push({
dimension: 'performance',
severity: 'warning',
title: `${slowLcp.length} page(s) with slow LCP (>2.5s)`,
detail: `Worst: ${worst(slowLcp, (c) => `${c.url} ${Math.round(c.lcp)}ms`, (c) => c.lcp)}`,
suggestion: 'Optimize the largest content element — images, fonts, render-blocking resources.',
});
}
const shiftyCls = cwv.filter((c) => c.cls > 0.1);
if (shiftyCls.length > 0) {
findings.push({
dimension: 'performance',
severity: 'warning',
title: `${shiftyCls.length} page(s) with layout shift (CLS >0.1)`,
detail: `Worst: ${worst(shiftyCls, (c) => `${c.url} ${c.cls.toFixed(2)}`, (c) => c.cls)}`,
suggestion: 'Set explicit width/height on media and reserve space for late-loading content.',
});
}
const laggyInp = cwv.filter((c) => c.inp > 200);
if (laggyInp.length > 0) {
findings.push({
dimension: 'performance',
severity: 'warning',
title: `${laggyInp.length} page(s) with slow interaction (INP >200ms)`,
detail: `Worst: ${worst(laggyInp, (c) => `${c.url} ${Math.round(c.inp)}ms`, (c) => c.inp)}`,
suggestion: 'Reduce long tasks and third-party JavaScript on the main thread.',
});
}
return findings;
}
/** 404s from RUM — the top broken URLs by traffic. */
function link404Findings(entries) {
if (entries.length === 0)
return [];
const top = [...entries].sort((a, b) => b.views - a.views).slice(0, 10);
return [
{
dimension: 'links',
severity: 'warning',
title: `${entries.length} URL(s) returning 404`,
detail: `Top 404s: ${top.map((e) => `${e.url} (${e.views} views)`).join(', ')}`,
suggestion: 'Add redirects for these URLs, or fix the links pointing to them.',
},
];
}
function summarize(scope, target, findings, skipped, truncated, pagesAudited) {
const sorted = sortFindings(findings);
return {
scope,
target,
findings: sorted,
summary: {
critical: sorted.filter((f) => f.severity === 'critical').length,
warning: sorted.filter((f) => f.severity === 'warning').length,
info: sorted.filter((f) => f.severity === 'info').length,
total: sorted.length,
...(pagesAudited !== undefined ? { pagesAudited } : {}),
},
skipped,
truncated,
};
}
/** Audit a whole site (or a subtree). */
export async function auditSite(client, options = {}) {
const maxPages = options.maxPages ?? 50;
const dims = new Set(options.dimensions ?? ALL_DIMENSIONS);
const days = options.days ?? 7;
const findings = [];
const skipped = [];
// 1. Page index (query-index) — paginate so site-level checks (freshness,
// sitemap coverage) see every page, not just the first response.
const HARD_CAP = 5000;
const allEntries = [];
let offset = 0;
let indexTotal;
for (;;) {
const page = await client.listPages(500, offset);
indexTotal = typeof page.total === 'number' ? page.total : allEntries.length + page.data.length;
allEntries.push(...page.data);
offset += page.data.length;
if (page.data.length === 0 || allEntries.length >= indexTotal || allEntries.length >= HARD_CAP)
break;
}
if (indexTotal > allEntries.length) {
skipped.push(`index truncated (scanned first ${allEntries.length} of ${indexTotal} pages — freshness/sitemap coverage reflect only those)`);
}
let entries = allEntries;
if (options.pathPrefix) {
entries = entries.filter((e) => e.path.startsWith(options.pathPrefix));
}
// 2. Per-page SEO + accessibility (bounded concurrency, capped by maxPages).
const wantsPageChecks = dims.has('seo') || dims.has('accessibility');
const toAudit = entries.slice(0, maxPages);
const truncated = entries.length > maxPages && wantsPageChecks;
if (wantsPageChecks) {
await mapWithConcurrency(toAudit, async (entry) => {
try {
const { html } = await client.getRenderedPage(entry.path);
if (dims.has('seo')) {
for (const f of seoFindings(html))
findings.push({ ...f, page: entry.path });
}
if (dims.has('accessibility')) {
for (const f of accessibilityFindings(html))
findings.push({ ...f, page: entry.path });
}
}
catch (error) {
findings.push({
dimension: 'links',
severity: 'info',
page: entry.path,
title: 'Page could not be fetched',
detail: error instanceof Error ? error.message : String(error),
suggestion: 'Confirm the page is published and reachable.',
});
}
}, 6);
}
const now = Date.now();
// 3. Freshness (query-index lastModified) — no extra fetch.
if (dims.has('freshness'))
findings.push(...freshnessFindings(entries, now));
// 4. Sitemap coverage.
if (dims.has('sitemap')) {
try {
const sitemap = await client.getSitemap();
findings.push(...sitemapFindings(sitemap, entries));
}
catch (error) {
skipped.push(`sitemap (${error instanceof Error ? error.message : 'unavailable'})`);
}
}
// 5. Performance (RUM) — needs a domain.
if (dims.has('performance')) {
if (options.domain) {
try {
findings.push(...performanceFindings(await client.getCwv(options.domain, days)));
}
catch (error) {
skipped.push(`performance (${error instanceof Error ? error.message : 'RUM unavailable'})`);
}
}
else {
skipped.push('performance (pass a domain + set EDS_DOMAIN_KEY for RUM data)');
}
}
// 6. 404s (RUM) — needs a domain.
if (dims.has('links')) {
if (options.domain) {
try {
findings.push(...link404Findings(await client.get404s(options.domain, days)));
}
catch (error) {
skipped.push(`links/404s (${error instanceof Error ? error.message : 'RUM unavailable'})`);
}
}
else {
skipped.push('links/404s (pass a domain + set EDS_DOMAIN_KEY for RUM data)');
}
}
const target = options.pathPrefix ? options.pathPrefix : '(whole site)';
return summarize('site', target, findings, skipped, truncated, wantsPageChecks ? toAudit.length : 0);
}
/** Audit a single page from its HTML. */
export function auditSinglePage(html, path) {
return summarize('page', path, auditPage(html, path), [], false);
}
/**
* Types for the content-audit layer (ADR-010).
*
* The audit produces a flat, prioritized list of {@link AuditFinding}s — the
* agent-native "tell me what's wrong" surface. Unlike the eds-score web tool,
* there is no numeric grade: an agent wants actionable issues, not a letter.
*/
/** The quality dimension a finding belongs to. */
export type AuditDimension = 'seo' | 'accessibility' | 'performance' | 'freshness' | 'links' | 'sitemap';
/** Severity of a finding, most to least urgent. */
export type AuditSeverity = 'critical' | 'warning' | 'info';
/** Every dimension the audit can cover. */
export declare const ALL_DIMENSIONS: AuditDimension[];
/** One thing worth fixing, found by a check. */
export interface AuditFinding {
/** Which quality dimension this belongs to. */
dimension: AuditDimension;
/** How urgent it is. */
severity: AuditSeverity;
/** Site-relative page path, when the finding is page-specific. */
page?: string;
/** Short label for the issue. */
title: string;
/** What was actually found. */
detail: string;
/** What to do about it. */
suggestion?: string;
}
/** The result of an audit — a prioritized findings list plus roll-up counts. */
export interface AuditReport {
/** Whether this was a single page or a whole-site sweep. */
scope: 'page' | 'site';
/** What was audited (a path, or the site root/prefix). */
target: string;
/** Findings, sorted critical-first. */
findings: AuditFinding[];
/** Roll-up counts. */
summary: {
critical: number;
warning: number;
info: number;
total: number;
/** Number of pages inspected (site scope only). */
pagesAudited?: number;
};
/** Dimensions that could not run (e.g. RUM without a domain key), never silent. */
skipped: string[];
/** True when the sweep hit the page cap and some pages were not audited. */
truncated: boolean;
}
/**
* Types for the content-audit layer (ADR-010).
*
* The audit produces a flat, prioritized list of {@link AuditFinding}s — the
* agent-native "tell me what's wrong" surface. Unlike the eds-score web tool,
* there is no numeric grade: an agent wants actionable issues, not a letter.
*/
/** Every dimension the audit can cover. */
export const ALL_DIMENSIONS = [
'seo',
'accessibility',
'performance',
'freshness',
'links',
'sitemap',
];
/**
* MCP tool handlers for the content audit (ADR-010).
*
* `eds_audit_page` audits one page; `eds_audit_site` sweeps the site. Both
* return a prioritized, human-readable findings report.
*/
import type { EdsClient } from '../eds-admin/client.js';
import type { AuditDimension } from '../audit/types.js';
export declare function handleAuditPage(client: EdsClient, args: {
path: string;
}): Promise<{
content: {
type: "text";
text: string;
}[];
}>;
export declare function handleAuditSite(client: EdsClient, args: {
pathPrefix?: string;
maxPages?: number;
dimensions?: AuditDimension[];
domain?: string;
days?: number;
}): Promise<{
content: {
type: "text";
text: string;
}[];
}>;
/**
* MCP tool handlers for the content audit (ADR-010).
*
* `eds_audit_page` audits one page; `eds_audit_site` sweeps the site. Both
* return a prioritized, human-readable findings report.
*/
import { formatError } from '../utils/errors.js';
import { auditSite, auditSinglePage } from '../audit/engine.js';
function textResult(text) {
return { content: [{ type: 'text', text }] };
}
function errorResult(error) {
return {
content: [{ type: 'text', text: `Error: ${formatError(error)}` }],
isError: true,
};
}
const SEVERITY_LABEL = {
critical: 'CRITICAL',
warning: 'WARNING',
info: 'INFO',
};
/** Cap the number of findings rendered so a huge site can't blow the response. */
const MAX_RENDERED = 200;
function formatReport(report) {
const { summary } = report;
const lines = [];
const scopeLabel = report.scope === 'page' ? `page ${report.target}` : `site ${report.target}`;
lines.push(`Audit of ${scopeLabel} — ${summary.critical} critical, ${summary.warning} warning, ${summary.info} info (${summary.total} finding${summary.total === 1 ? '' : 's'}).`);
if (summary.pagesAudited !== undefined && report.scope === 'site') {
lines.push(`Pages inspected: ${summary.pagesAudited}${report.truncated ? ' (hit the page cap — raise maxPages or narrow pathPrefix for full coverage)' : ''}.`);
}
if (report.skipped.length > 0) {
lines.push(`Skipped: ${report.skipped.join('; ')}.`);
}
if (summary.total === 0) {
lines.push('', 'No issues found. ✓');
return lines.join('\n');
}
const rendered = report.findings.slice(0, MAX_RENDERED);
let lastSeverity = null;
for (const f of rendered) {
if (f.severity !== lastSeverity) {
lines.push('', SEVERITY_LABEL[f.severity]);
lastSeverity = f.severity;
}
const where = f.page ? ` — ${f.page}` : '';
lines.push(` [${f.dimension}] ${f.title}${where}`);
lines.push(` ${f.detail}`);
if (f.suggestion)
lines.push(` → ${f.suggestion}`);
}
if (report.findings.length > MAX_RENDERED) {
lines.push('', `(${report.findings.length - MAX_RENDERED} more finding(s) not shown — narrow the audit with pathPrefix or dimensions.)`);
}
return lines.join('\n');
}
export async function handleAuditPage(client, args) {
try {
const { html } = await client.getRenderedPage(args.path);
return textResult(formatReport(auditSinglePage(html, args.path)));
}
catch (error) {
return errorResult(error);
}
}
export async function handleAuditSite(client, args) {
try {
const options = {
pathPrefix: args.pathPrefix,
maxPages: args.maxPages,
dimensions: args.dimensions,
domain: args.domain,
days: args.days,
};
return textResult(formatReport(await auditSite(client, options)));
}
catch (error) {
return errorResult(error);
}
}
+9
-0

@@ -103,2 +103,11 @@ /**

/**
* Fetch the FULL rendered page HTML (with `<head>`), not the `.plain.html`
* body fragment. This is what SEO/metadata analysis needs — title, meta
* description, canonical, Open Graph, JSON-LD and `<html lang>` all live in
* the head, which `.plain.html` omits.
*
* GET https://{ref}--{repo}--{owner}.aem.live/{path}
*/
getRenderedPage(path: string): Promise<EdsPageContent>;
/**
* List pages from the query index.

@@ -105,0 +114,0 @@ *

@@ -365,2 +365,21 @@ /**

/**
* Fetch the FULL rendered page HTML (with `<head>`), not the `.plain.html`
* body fragment. This is what SEO/metadata analysis needs — title, meta
* description, canonical, Open Graph, JSON-LD and `<html lang>` all live in
* the head, which `.plain.html` omits.
*
* GET https://{ref}--{repo}--{owner}.aem.live/{path}
*/
async getRenderedPage(path) {
const normalized = this.normalizePath(path)
.replace(/\.plain\.html$/, '')
.replace(/\.html$/, '');
const url = `${this.contentOrigin}/${normalized}`;
const html = await this.request(url, { method: 'GET' });
return {
path: `/${normalized}`,
html: typeof html === 'string' ? html : String(html),
};
}
/**
* List pages from the query index.

@@ -367,0 +386,0 @@ *

+1
-1
/**
* MCP server factory for the EDS MCP server.
*
* Creates a {@link McpServer} instance with all 31 tools registered.
* Creates a {@link McpServer} instance with all 33 tools registered.
* Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's

@@ -6,0 +6,0 @@ * first-party MCP servers.

/**
* MCP server factory for the EDS MCP server.
*
* Creates a {@link McpServer} instance with all 31 tools registered.
* Creates a {@link McpServer} instance with all 33 tools registered.
* Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's

@@ -18,2 +18,4 @@ * first-party MCP servers.

import * as daHandlers from './da-handlers.js';
import * as auditHandlers from './audit-handlers.js';
import { ALL_DIMENSIONS } from '../audit/types.js';
const require = createRequire(import.meta.url);

@@ -304,3 +306,37 @@ const { version } = require('../../package.json');

}, async (args) => daHandlers.handleDaRollback(daClient, args));
// -------------------------------------------------------------------------
// Content audit (ADR-010) — find what's wrong, prioritized and actionable
// -------------------------------------------------------------------------
server.tool('eds_audit_page', 'Audit a single page for SEO and accessibility issues (missing title/description, no H1, images without alt text, missing landmarks, etc.). Returns a prioritized list of findings with suggested fixes. Read-only.', {
path: edsPath.describe('Site-relative page path to audit (e.g. /blog/post)'),
}, async (args) => auditHandlers.handleAuditPage(client, args));
server.tool('eds_audit_site', 'Sweep the whole site (or a subtree) and return a prioritized list of content-quality issues across SEO, accessibility, freshness, sitemap coverage, and — when a domain is supplied — performance (Core Web Vitals) and 404s from real-user data. Read-only; safe to run anytime. Pair with the eds_da_* write tools to fix what it finds.', {
pathPrefix: z
.string()
.optional()
.describe('Only audit pages under this path prefix (e.g. "/blog/"). Omit for the whole site.'),
maxPages: z
.number()
.int()
.positive()
.max(1000)
.optional()
.describe('Max pages to fetch for per-page checks (default 50).'),
dimensions: z
.array(z.enum(ALL_DIMENSIONS))
.optional()
.describe(`Which dimensions to run (default all): ${ALL_DIMENSIONS.join(', ')}.`),
domain: z
.string()
.optional()
.describe('Live domain (e.g. www.example.com) for RUM-based performance and 404 checks. Requires EDS_DOMAIN_KEY. Omit to skip those.'),
days: z
.number()
.int()
.positive()
.max(365)
.optional()
.describe('RUM look-back window in days (default 7).'),
}, async (args) => auditHandlers.handleAuditSite(client, args));
return server;
}
{
"name": "@focusgts/eds-mcp-server",
"version": "0.7.0",
"version": "0.8.0",
"mcpName": "io.github.focusgts/eds-mcp-server",

@@ -5,0 +5,0 @@ "description": "MCP server for Adobe Edge Delivery Services — preview, publish, metrics, and content operations",

@@ -13,3 +13,3 @@ <div align="center">

**31 tools. No extra dependencies beyond the MCP SDK. Works with any EDS site.**
**33 tools. No extra dependencies beyond the MCP SDK. Works with any EDS site.**
The first MCP server purpose-built for Edge Delivery Services.

@@ -44,3 +44,3 @@

flowchart LR
A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>31 tools"]
A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>33 tools"]
B --> C["Admin API<br/>admin.hlx.page"]

@@ -80,3 +80,3 @@ B --> D["Content API<br/>*.aem.live"]

## 🛠️ The 31 tools
## 🛠️ The 33 tools

@@ -158,2 +158,9 @@ ### Edge Delivery Services — publish, content, analytics

### Content audit — find what's wrong, before you fix it
- `eds_audit_page`
- `eds_audit_site`
> **It tells you what's wrong.** `eds_audit_site` sweeps the whole site (or a subtree) and returns a **prioritized** list of issues across **SEO** (missing titles/descriptions, no H1, blocked from indexing), **accessibility** (images without alt text, missing landmarks, unlabeled form inputs), **freshness** (pages not updated in over a year), **sitemap coverage**, and — with a `domain` — **performance** (Core Web Vitals) and **404s** from Adobe's own real-user data. `eds_audit_page` does the same for one page. Read-only and safe to run anytime. Pair it with the `eds_da_*` write tools (dry-run + undo) to fix what it finds.
---

@@ -160,0 +167,0 @@