New:Socket for Asana Is Now Available.Learn more
Sign In

@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.11.0
to
0.12.0
+16
dist/audit/report.d.ts
/**
* Site-health report (ADR-014) — render an {@link AuditReport} as a
* self-contained, theme-aware HTML document (inline CSS + inline SVG gauges, no
* external assets, no dependencies). The value of the audit, made visible and
* shareable.
*
* The health score is derived transparently from the findings (not a fabricated
* metric) and the report says so; dimensions that could not run are shown as
* "not run", never as a misleading score.
*/
import { type AuditReport } from './types.js';
/** Render an audit report as a self-contained HTML document. */
export declare function generateReport(report: AuditReport, meta: {
site: string;
generatedAt: string;
}): string;
/**
* Site-health report (ADR-014) — render an {@link AuditReport} as a
* self-contained, theme-aware HTML document (inline CSS + inline SVG gauges, no
* external assets, no dependencies). The value of the audit, made visible and
* shareable.
*
* The health score is derived transparently from the findings (not a fabricated
* metric) and the report says so; dimensions that could not run are shown as
* "not run", never as a misleading score.
*/
import { ALL_DIMENSIONS } from './types.js';
const DIMENSION_LABELS = {
seo: 'SEO',
accessibility: 'Accessibility',
performance: 'Performance',
freshness: 'Freshness',
links: 'Links & 404s',
sitemap: 'Sitemap',
};
/** Plain-English, one-line explanation of each dimension — shown on hover. */
const DIMENSION_TIPS = {
seo: 'How easily Google can find and understand your pages — titles, descriptions, headings and page structure.',
accessibility: 'How usable your site is for people with disabilities — image alt text, form labels, headings and landmarks.',
performance: 'How fast your pages load for real visitors (Google’s Core Web Vitals), measured from Adobe’s real-user data.',
freshness: 'How recently your pages were updated — flags stale content that hasn’t been touched in a long time.',
links: 'Broken links and “page not found” (404) errors your real visitors are actually hitting.',
sitemap: 'Whether your sitemap correctly lists your pages so search engines can discover all of them.',
};
const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
function grade(score) {
if (score >= 95)
return 'A+';
if (score >= 85)
return 'A';
if (score >= 70)
return 'B';
if (score >= 55)
return 'C';
if (score >= 40)
return 'D';
return 'F';
}
/** Health score for a dimension, derived from its findings, normalized by pages. */
function scoreDimension(findings, pages) {
const weighted = findings.reduce((s, f) => s + (f.severity === 'critical' ? 3 : f.severity === 'warning' ? 1 : 0.25), 0);
const perPage = weighted / Math.max(1, pages);
return Math.max(0, Math.min(100, Math.round(100 - perPage * 10)));
}
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function scoreClass(score) {
return score >= 85 ? 'good' : score >= 55 ? 'fair' : 'poor';
}
/** Inline SVG ring gauge. Colors/type come from CSS classes so it stays theme-aware. */
function gauge(opts) {
const c = opts.size / 2;
const r = c - opts.sw / 2 - 1;
const circ = 2 * Math.PI * r;
const off = circ * (1 - Math.max(0, Math.min(100, opts.score)) / 100);
const cls = scoreClass(opts.score);
return (`<svg class="gauge" width="${opts.size}" height="${opts.size}" viewBox="0 0 ${opts.size} ${opts.size}" aria-hidden="true">` +
`<circle class="track" cx="${c}" cy="${c}" r="${r.toFixed(1)}" stroke-width="${opts.sw}" fill="none"/>` +
`<circle class="arc ${cls}" cx="${c}" cy="${c}" r="${r.toFixed(1)}" stroke-width="${opts.sw}" fill="none" stroke-linecap="round" stroke-dasharray="${circ.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}" transform="rotate(-90 ${c} ${c})"/>` +
`<text class="gtext ${cls}" x="${c}" y="${c}" text-anchor="middle" dominant-baseline="central" style="font-size:${opts.centerSize}px">${opts.center}</text>` +
`</svg>`);
}
function groupFindings(findings) {
const map = new Map();
for (const f of findings) {
const key = `${f.dimension}|${f.title}`;
let g = map.get(key);
if (!g) {
g = { dimension: f.dimension, severity: f.severity, title: f.title, suggestion: f.suggestion, pages: [], fixable: false };
map.set(key, g);
}
if (f.fix)
g.fixable = true;
if (f.page && !g.pages.includes(f.page))
g.pages.push(f.page);
}
return [...map.values()].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || b.pages.length - a.pages.length);
}
const STYLE = `
:root{
--bg:#eef1f7;--panel:#ffffff;--ink:#0e1730;--muted:#5c688a;--faint:#8a95b4;--line:#e4e8f1;--track:#e7ebf4;
--accent:#5b54e6;--good:#12a150;--fair:#d18700;--poor:#e0402f;
--shadow:0 1px 2px rgba(16,24,48,.04),0 8px 24px rgba(16,24,48,.06);
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
@media(prefers-color-scheme:dark){:root:not([data-theme="light"]){
--bg:#080c18;--panel:#111a30;--ink:#eef2fb;--muted:#98a4c6;--faint:#6f7ca3;--line:#212d4c;--track:#1c2743;
--accent:#9a8dff;--good:#38c47f;--fair:#eab53f;--poor:#f26a5a;
--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.35);
}}
:root[data-theme="dark"]{
--bg:#080c18;--panel:#111a30;--ink:#eef2fb;--muted:#98a4c6;--faint:#6f7ca3;--line:#212d4c;--track:#1c2743;
--accent:#9a8dff;--good:#38c47f;--fair:#eab53f;--poor:#f26a5a;
--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
.wrap{max-width:900px;margin:0 auto;padding:40px 20px 72px}
.gauge .track{stroke:var(--track)}
.gauge .arc.good{stroke:var(--good)}.gauge .arc.fair{stroke:var(--fair)}.gauge .arc.poor{stroke:var(--poor)}
.gauge .gtext{font-family:var(--mono);font-weight:700;font-variant-numeric:tabular-nums}
.gtext.good{fill:var(--good)}.gtext.fair{fill:var(--fair)}.gtext.poor{fill:var(--poor)}
.hero{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:20px;box-shadow:var(--shadow);
padding:30px 32px;display:flex;gap:28px;align-items:center;flex-wrap:wrap;overflow:hidden}
.hero::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:linear-gradient(90deg,var(--accent),transparent 70%)}
.hero-main{flex:1;min-width:230px}
.brand{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:700}
.hero h1{margin:8px 0 4px;font-size:28px;font-weight:800;letter-spacing:-.01em;text-wrap:balance;word-break:break-word}
.meta{color:var(--muted);font-size:13px}
.pills{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
.pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--muted);
background:var(--bg);border:1px solid var(--line);border-radius:999px;padding:4px 11px}
.pill::before{content:"";width:8px;height:8px;border-radius:50%;background:currentColor}
.pill.crit{color:var(--poor)}.pill.warn{color:var(--fair)}.pill.info{color:var(--accent)}
.hero-gauge{display:flex;flex-direction:column;align-items:center;gap:8px}
.hero-gauge .cap{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
.section-title{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);font-weight:700;margin:34px 2px 14px}
.gauges{display:grid;grid-template-columns:repeat(var(--cols,3),minmax(0,1fr));gap:14px}
@media(max-width:560px){.gauges{grid-template-columns:repeat(2,minmax(0,1fr))}}
.ga{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);
padding:18px 12px 16px;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center;
cursor:help;outline:none;transition:border-color .14s,box-shadow .14s}
.ga:hover,.ga:focus-visible{border-color:var(--accent)}
.ga:focus-visible{box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 30%,transparent)}
.ga .lab{font-size:13px;font-weight:600}
.ga .sub{font-size:11.5px;color:var(--muted)}
.ga.skip{justify-content:center;min-height:150px;color:var(--faint)}
.ga.skip .lab{color:var(--muted);font-size:14px}
.ga.skip .nr{font-size:11.5px;font-weight:600;color:var(--faint);text-transform:uppercase;letter-spacing:.06em}
/* Hover / focus explainer bubble */
.ga[data-tip]::after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 9px);
transform:translateX(-50%) translateY(4px);width:230px;max-width:78vw;
background:var(--ink);color:var(--panel);font-size:12px;line-height:1.45;font-weight:500;text-align:left;
padding:10px 12px;border-radius:10px;box-shadow:0 8px 24px rgba(16,24,48,.22);
opacity:0;pointer-events:none;transition:opacity .14s,transform .14s;z-index:10}
.ga[data-tip]::before{content:"";position:absolute;left:50%;bottom:calc(100% + 3px);transform:translateX(-50%);
border:6px solid transparent;border-top-color:var(--ink);opacity:0;transition:opacity .14s;z-index:10}
.ga[data-tip]:hover::after,.ga[data-tip]:focus-visible::after{opacity:1;transform:translateX(-50%) translateY(0)}
.ga[data-tip]:hover::before,.ga[data-tip]:focus-visible::before{opacity:1}
@media(hover:none){.ga{cursor:default}}
.issue{position:relative;background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--line);
border-radius:12px;box-shadow:var(--shadow);padding:14px 16px 14px 18px;margin-bottom:10px}
.issue.critical{border-left-color:var(--poor)}
.issue.warning{border-left-color:var(--fair)}
.issue.info{border-left-color:var(--accent)}
.itop{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.sev{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:2px 8px;border-radius:6px;color:#fff}
.issue.critical .sev{background:var(--poor)}.issue.warning .sev{background:var(--fair)}.issue.info .sev{background:var(--accent)}
.chip{font-size:11px;font-weight:600;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:2px 9px}
.fixable{font-size:10.5px;font-weight:700;letter-spacing:.02em;color:var(--good);
background:color-mix(in srgb,var(--good) 12%,transparent);border:1px solid color-mix(in srgb,var(--good) 34%,transparent);
border-radius:999px;padding:2px 9px;white-space:nowrap}
.fixlead{font-size:13.5px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:12px;
box-shadow:var(--shadow);padding:12px 15px;margin-bottom:14px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.fixlead code,footer code{font-family:var(--mono)}
.ititle{font-weight:600}
.count{margin-left:auto;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.fix{margin-top:7px;font-size:13.5px;color:var(--muted)}
.fix b{color:var(--ink);font-weight:600}
.pages{margin-top:8px;font-size:11.5px;color:var(--faint);font-family:var(--mono);word-break:break-word}
.clean{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);
padding:26px;text-align:center;color:var(--good);font-weight:700;font-size:17px}
.note{font-size:12px;color:var(--faint);margin-top:8px}
footer{margin-top:34px;padding-top:18px;border-top:1px solid var(--line);color:var(--faint);font-size:12px;text-align:center;line-height:1.7}
footer a{color:var(--accent);text-decoration:none}
footer code{font-family:var(--mono);font-size:11.5px;background:var(--bg);border:1px solid var(--line);border-radius:5px;padding:1px 5px}
`.trim();
/** Render an audit report as a self-contained HTML document. */
export function generateReport(report, meta) {
const pages = report.summary.pagesAudited ?? 1;
const skippedDims = new Set(ALL_DIMENSIONS.filter((d) => report.skipped.some((s) => s.startsWith(d))));
const perDim = ALL_DIMENSIONS.map((dim) => {
const findings = report.findings.filter((f) => f.dimension === dim);
if (skippedDims.has(dim))
return { dim, skipped: true, score: 0, findings };
return { dim, skipped: false, score: scoreDimension(findings, pages), findings };
});
const scored = perDim.filter((d) => !d.skipped);
const overall = scored.length ? Math.round(scored.reduce((s, d) => s + d.score, 0) / scored.length) : 0;
const gaugesHtml = perDim
.map((d) => {
const label = esc(DIMENSION_LABELS[d.dim]);
const tip = esc(DIMENSION_TIPS[d.dim]);
if (d.skipped) {
return (`<div class="ga skip" tabindex="0" data-tip="${tip}" aria-label="${label}: ${tip}">` +
`<div class="lab">${label}</div>` +
`<div class="nr">Not run yet</div>` +
`<div class="sub">This one reads your real-visitor data — add your site’s live domain to switch it on.</div></div>`);
}
const n = d.findings.length;
return (`<div class="ga" tabindex="0" data-tip="${tip}" aria-label="${label}: ${tip}">` +
`${gauge({ size: 92, sw: 8, score: d.score, center: String(d.score), centerSize: 26 })}` +
`<div class="lab">${label}</div>` +
`<div class="sub">Grade ${grade(d.score)} · ${n} issue${n === 1 ? '' : 's'}</div></div>`);
})
.join('');
// Choose a column count so the cards always split into even rows — never a
// lone orphan on its own line. Favor 3-wide, but drop to 2 for 4 cards (2×2).
const nCards = perDim.length;
const cols = nCards <= 3 ? nCards : nCards === 4 ? 2 : 3;
const groups = groupFindings(report.findings);
const fixableGroups = groups.filter((g) => g.fixable).length;
const issuesHtml = groups.length
? groups
.slice(0, 100)
.map((g) => {
const pageList = g.pages.length
? `<div class="pages">${g.pages.length} page${g.pages.length === 1 ? '' : 's'}: ${esc(g.pages.slice(0, 25).join(' · '))}${g.pages.length > 25 ? ' · …' : ''}</div>`
: '';
const fix = g.suggestion ? `<div class="fix"><b>Fix</b> — ${esc(g.suggestion)}</div>` : '';
const count = g.pages.length ? `<span class="count">${g.pages.length} page${g.pages.length === 1 ? '' : 's'}</span>` : '';
const fixable = g.fixable
? `<span class="fixable" title="This server can fix this for you — ask your AI agent to apply it (previewed first, one-click undo).">✦ Fixable</span>`
: '';
return (`<div class="issue ${g.severity}"><div class="itop">` +
`<span class="sev">${g.severity}</span>` +
`<span class="chip">${esc(DIMENSION_LABELS[g.dimension])}</span>` +
`<span class="ititle">${esc(g.title)}</span>${fixable}${count}</div>${fix}${pageList}</div>`);
})
.join('')
: `<div class="clean">No issues found. ✓</div>`;
const s = report.summary;
const skippedLabels = ALL_DIMENSIONS.filter((d) => skippedDims.has(d)).map((d) => DIMENSION_LABELS[d]);
const skippedNote = skippedLabels.length
? `<div class="note">Not measured yet: ${esc(skippedLabels.join(' · '))} — these read your site’s real-visitor data. Add your live domain to switch them on.</div>`
: '';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Site Health — ${esc(meta.site)}</title>
<style>${STYLE}</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<div class="hero-main">
<div class="brand">EDS Site Health</div>
<h1>${esc(meta.site)}</h1>
<div class="meta">Audited ${esc(meta.generatedAt)} · ${pages} page${pages === 1 ? '' : 's'} inspected</div>
<div class="pills">
<span class="pill crit">${s.critical} critical</span>
<span class="pill warn">${s.warning} warning</span>
<span class="pill info">${s.info} info</span>
</div>
${skippedNote}
</div>
<div class="hero-gauge">
${gauge({ size: 132, sw: 11, score: overall, center: grade(overall), centerSize: 40 })}
<div class="cap">${overall}/100 overall</div>
</div>
</header>
<div class="section-title">By dimension</div>
<section class="gauges" style="--cols:${cols}">${gaugesHtml}</section>
<div class="section-title">Prioritized issues${groups.length > 100 ? ` — showing 100 of ${groups.length}` : ''}</div>
${fixableGroups > 0 ? `<div class="fixlead"><span class="fixable">✦ Fixable</span> ${fixableGroups} of ${groups.length} issue type${groups.length === 1 ? '' : 's'} can be fixed in place — ask your AI agent to apply them with <code>eds_fix_audit</code> (previewed first, one-click undo).</div>` : ''}
${issuesHtml}
<footer>
Generated by <a href="https://www.npmjs.com/package/@focusgts/eds-mcp-server">@focusgts/eds-mcp-server</a><br>
Health score derived from findings${fixableGroups > 0 ? ` · issues marked <span class="fixable">✦ Fixable</span> can be repaired with the <code>eds_fix_*</code> tools — safely, with undo` : ''}.
</footer>
</div>
</body>
</html>`;
}
+6
-0

@@ -32,2 +32,3 @@ /**

// A majority of images with no alt attribute is a real barrier; a few is a warning.
code: 'a11y-missing-alt',
severity: ratio > 0.5 ? 'critical' : 'warning',

@@ -48,2 +49,3 @@ title: 'Images missing an alt attribute',

dimension: 'accessibility',
code: 'a11y-no-headings',
severity: 'warning',

@@ -64,2 +66,3 @@ title: 'No headings on the page',

dimension: 'accessibility',
code: 'a11y-heading-skip',
severity: skips > 2 ? 'warning' : 'info',

@@ -87,2 +90,3 @@ title: 'Heading levels skip',

dimension: 'accessibility',
code: 'a11y-vague-link-text',
severity: 'info',

@@ -116,2 +120,3 @@ title: 'Non-descriptive link text',

dimension: 'accessibility',
code: 'a11y-missing-landmarks',
severity: found === 0 ? 'warning' : 'info',

@@ -162,2 +167,3 @@ title: 'Missing landmark regions',

dimension: 'accessibility',
code: 'a11y-unlabeled-inputs',
severity: ratio > 0.5 ? 'critical' : 'warning',

@@ -164,0 +170,0 @@ title: 'Form inputs missing labels',

@@ -18,2 +18,3 @@ /**

dimension: 'seo',
code: 'seo-missing-title',
severity: 'critical',

@@ -23,2 +24,3 @@ title: 'Missing title tag',

suggestion: 'Add a descriptive <title> of 30–60 characters.',
fix: { tool: 'eds_fix_metadata', field: 'title' },
};

@@ -31,2 +33,3 @@ }

dimension: 'seo',
code: 'seo-title-length',
severity: 'warning',

@@ -36,2 +39,3 @@ title: 'Title length is outside the ideal range',

suggestion: 'Aim for a 30–60 character title so it renders fully in search results.',
fix: { tool: 'eds_fix_metadata', field: 'title' },
};

@@ -49,2 +53,3 @@ }

dimension: 'seo',
code: 'seo-missing-description',
severity: 'critical',

@@ -54,2 +59,3 @@ title: 'Missing meta description',

suggestion: 'Add a 120–160 character meta description summarizing the page.',
fix: { tool: 'eds_fix_metadata', field: 'description' },
};

@@ -62,2 +68,3 @@ }

dimension: 'seo',
code: 'seo-description-length',
severity: 'warning',

@@ -67,2 +74,3 @@ title: 'Meta description length is outside the ideal range',

suggestion: 'Aim for 120–160 characters so it renders fully in search results.',
fix: { tool: 'eds_fix_metadata', field: 'description' },
};

@@ -77,2 +85,3 @@ }

dimension: 'seo',
code: 'seo-missing-h1',
severity: 'critical',

@@ -86,2 +95,3 @@ title: 'No H1 heading',

dimension: 'seo',
code: 'seo-multiple-h1',
severity: 'warning',

@@ -103,2 +113,3 @@ title: 'Multiple H1 headings',

dimension: 'seo',
code: 'seo-noindex',
severity: 'critical',

@@ -117,2 +128,3 @@ title: 'Page is blocked from search indexing',

dimension: 'seo',
code: 'seo-missing-canonical',
severity: 'warning',

@@ -129,2 +141,3 @@ title: 'No canonical URL',

dimension: 'seo',
code: 'seo-missing-jsonld',
severity: 'info',

@@ -152,2 +165,3 @@ title: 'No structured data (JSON-LD)',

dimension: 'seo',
code: found === 0 ? 'seo-missing-og' : 'seo-incomplete-og',
// No OG tags at all is a warning; a partial set is a minor gap.

@@ -154,0 +168,0 @@ severity: found === 0 ? 'warning' : 'info',

@@ -71,2 +71,3 @@ /**

dimension: 'freshness',
code: 'freshness-stale',
severity: 'warning',

@@ -85,2 +86,3 @@ title: `${stale.length} page(s) not updated in over a year`,

dimension: 'sitemap',
code: 'sitemap-empty',
severity: 'warning',

@@ -107,2 +109,3 @@ title: 'No sitemap entries',

dimension: 'sitemap',
code: 'sitemap-missing-pages',
severity: 'info',

@@ -123,2 +126,3 @@ title: `${missing.length} indexed page(s) missing from the sitemap`,

dimension: 'performance',
code: 'perf-slow-lcp',
severity: 'warning',

@@ -134,2 +138,3 @@ title: `${slowLcp.length} page(s) with slow LCP (>2.5s)`,

dimension: 'performance',
code: 'perf-high-cls',
severity: 'warning',

@@ -145,2 +150,3 @@ title: `${shiftyCls.length} page(s) with layout shift (CLS >0.1)`,

dimension: 'performance',
code: 'perf-slow-inp',
severity: 'warning',

@@ -162,2 +168,3 @@ title: `${laggyInp.length} page(s) with slow interaction (INP >200ms)`,

dimension: 'links',
code: 'links-broken-404',
severity: 'warning',

@@ -167,2 +174,3 @@ title: `${entries.length} URL(s) returning 404`,

suggestion: 'Add redirects for these URLs, or fix the links pointing to them.',
fix: { tool: 'eds_fix_redirect', field: 'redirect' },
},

@@ -236,2 +244,3 @@ ];

dimension: 'links',
code: 'page-unfetchable',
severity: 'info',

@@ -238,0 +247,0 @@ page: entry.path,

@@ -12,2 +12,23 @@ /**

export type AuditSeverity = 'critical' | 'warning' | 'info';
/**
* Stable identifier for a *kind* of issue (ADR-015). Lets tooling route a
* finding to its fix without re-deriving from the human title. Kebab-case,
* `dimension-issue`. These are a quasi-public contract — agents key off them —
* so name them once and keep them stable.
*/
export type AuditCode = 'seo-missing-title' | 'seo-title-length' | 'seo-missing-description' | 'seo-description-length' | 'seo-missing-h1' | 'seo-multiple-h1' | 'seo-noindex' | 'seo-missing-canonical' | 'seo-missing-jsonld' | 'seo-missing-og' | 'seo-incomplete-og' | 'a11y-missing-alt' | 'a11y-no-headings' | 'a11y-heading-skip' | 'a11y-vague-link-text' | 'a11y-missing-landmarks' | 'a11y-unlabeled-inputs' | 'freshness-stale' | 'sitemap-empty' | 'sitemap-missing-pages' | 'perf-slow-lcp' | 'perf-high-cls' | 'perf-slow-inp' | 'links-broken-404' | 'page-unfetchable';
/** What a fix writes. */
export type FixField = 'title' | 'description' | 'ogImage' | 'redirect';
/**
* How to repair a finding (ADR-015). Present only on findings our shipped safe
* writers can actually fix; `eds_fix_audit` routes on it. The target page is the
* finding's own `page`. Every current fixable field needs an agent-supplied
* value — the tool never fabricates copy.
*/
export interface FindingFix {
/** The tool that repairs this finding. */
tool: 'eds_fix_metadata' | 'eds_fix_redirect';
/** Which value it writes. */
field: FixField;
}
/** Every dimension the audit can cover. */

@@ -19,2 +40,4 @@ export declare const ALL_DIMENSIONS: AuditDimension[];

dimension: AuditDimension;
/** Stable machine identifier for the kind of issue (ADR-015). */
code: AuditCode;
/** How urgent it is. */

@@ -30,2 +53,4 @@ severity: AuditSeverity;

suggestion?: string;
/** How to repair it, when a shipped safe writer can (ADR-015). */
fix?: FindingFix;
}

@@ -32,0 +57,0 @@ /** The result of an audit — a prioritized findings list plus roll-up counts. */

@@ -17,2 +17,14 @@ /**

}>;
export declare function handleAuditReport(client: EdsClient, site: string, args: {
pathPrefix?: string;
maxPages?: number;
dimensions?: AuditDimension[];
domain?: string;
days?: number;
}): Promise<{
content: {
type: "text";
text: string;
}[];
}>;
export declare function handleAuditSite(client: EdsClient, args: {

@@ -19,0 +31,0 @@ pathPrefix?: string;

@@ -9,2 +9,3 @@ /**

import { auditSite, auditSinglePage } from '../audit/engine.js';
import { generateReport } from '../audit/report.js';
function textResult(text) {

@@ -68,2 +69,19 @@ return { content: [{ type: 'text', text }] };

}
export async function handleAuditReport(client, site, args) {
try {
const options = {
pathPrefix: args.pathPrefix,
maxPages: args.maxPages,
dimensions: args.dimensions,
domain: args.domain,
days: args.days,
};
const report = await auditSite(client, options);
const html = generateReport(report, { site, generatedAt: new Date().toISOString().slice(0, 10) });
return textResult(html);
}
catch (error) {
return errorResult(error);
}
}
export async function handleAuditSite(client, args) {

@@ -70,0 +88,0 @@ try {

@@ -48,1 +48,23 @@ /**

}>;
/**
* Apply the fixes an audit surfaced, across mixed types, in ONE reversible
* operation (ADR-015). Takes agent-supplied metadata fixes and/or redirect rules
* — the values the agent wrote from the findings (the tool never invents copy) —
* and pushes every changed document (metadata pages + the redirects sheet) in a
* single `withUndo` push, so ONE `eds_da_rollback` reverses the entire batch.
* Pure orchestration over the ADR-011/013 transforms; no new write logic.
*/
export declare function handleFixAudit(daClient: DaClient, edsClient: EdsClient, args: {
metadata?: Array<{
path: string;
metadata: MetadataFields;
}>;
redirects?: RedirectRule[];
dryRun?: boolean;
publish?: boolean;
}): Promise<{
content: {
type: "text";
text: string;
}[];
}>;

@@ -262,1 +262,158 @@ /**

}
const errMsg = (e) => (e instanceof Error ? e.message : String(e));
/**
* Apply the fixes an audit surfaced, across mixed types, in ONE reversible
* operation (ADR-015). Takes agent-supplied metadata fixes and/or redirect rules
* — the values the agent wrote from the findings (the tool never invents copy) —
* and pushes every changed document (metadata pages + the redirects sheet) in a
* single `withUndo` push, so ONE `eds_da_rollback` reverses the entire batch.
* Pure orchestration over the ADR-011/013 transforms; no new write logic.
*/
export async function handleFixAudit(daClient, edsClient, args) {
try {
const metaInput = args.metadata ?? [];
const redirectInput = args.redirects ?? [];
if (metaInput.length === 0 && redirectInput.length === 0) {
return errorResult(new Error('Nothing to fix — supply `metadata` fixes and/or `redirects`. These are the audit findings that carry a `fix`; the agent provides the values (this tool never invents copy).'));
}
// 1. Metadata — dedupe+merge by DA-normalized path (same discipline as
// bulk-fix, so two entries for one page can't race on write), then
// read+transform each. Read failures are recorded, never abort the batch.
const deduped = new Map();
for (const p of metaInput) {
const key = p.path.replace(/^\/+/, '').replace(/\.html$/i, '');
const existing = deduped.get(key);
if (existing)
existing.metadata = { ...existing.metadata, ...p.metadata };
else
deduped.set(key, { path: p.path, metadata: { ...p.metadata } });
}
const pages = [...deduped.values()];
const plans = [];
const readFailed = [];
await mapWithConcurrency(pages, async (p) => {
try {
const source = await daClient.getSource(p.path);
const { html, changes } = applyMetadata(source.content, p.metadata);
if (changes.length > 0) {
plans.push({ path: p.path, sourcePath: source.path, contentType: source.contentType, html, fields: changes.map((c) => c.field) });
}
}
catch (e) {
readFailed.push({ path: p.path, error: errMsg(e) });
}
}, 6);
const metaUnchanged = pages.length - plans.length - readFailed.length;
// 2. Redirects — read the existing sheet and apply the rules.
let redirectDoc = null;
if (redirectInput.length > 0) {
const existing = await getSourceOrNull(daClient, '/redirects.json');
let applied;
try {
applied = applyRedirects(existing?.content ?? null, redirectInput);
}
catch (e) {
return errorResult(e); // unrecognizable / multi-sheet existing doc
}
if (applied.changes.length > 0) {
redirectDoc = { content: applied.content, count: applied.changes.length, existed: !!existing };
}
}
// 3. Dry run — the full combined plan, no writes.
if (args.dryRun) {
const lines = [
`Dry run — nothing written. Would change ${plans.length} page(s) and ${redirectDoc ? redirectDoc.count : 0} redirect rule(s); ${metaUnchanged} page(s) already correct; ${readFailed.length} unreadable.`,
];
if (plans.length > 0) {
lines.push('', 'Metadata:');
for (const pl of plans)
lines.push(` ${pl.sourcePath}: ${pl.fields.join(', ')}`);
}
if (redirectDoc) {
lines.push('', `Redirects (${redirectDoc.existed ? 'update' : 'create'} /redirects.json): ${redirectDoc.count} rule(s).`);
}
if (readFailed.length > 0) {
lines.push('', 'Could not read:');
for (const f of readFailed)
lines.push(` ✗ ${f.path} — ${f.error}`);
}
return textResult(lines.join('\n'));
}
// Nothing actually changes.
if (plans.length === 0 && !redirectDoc) {
if (readFailed.length > 0) {
const lines = [`Nothing written — ${readFailed.length} page(s) could not be read; ${metaUnchanged} already correct.`, '', 'Could not read:'];
for (const f of readFailed)
lines.push(` ✗ ${f.path} — ${f.error}`);
return textResult(lines.join('\n'));
}
return textResult('No changes needed — everything the audit flagged is already correct.');
}
// 4. Push EVERYTHING in one batch → a single unified undo across mixed types.
const docs = plans.map((pl) => ({ path: pl.sourcePath, content: pl.html, contentType: pl.contentType }));
if (redirectDoc)
docs.push({ path: '/redirects.json', content: redirectDoc.content, contentType: 'application/json' });
const result = await daClient.pushDocuments(docs, { withUndo: true });
// Report what ACTUALLY got written, not what was planned — a partial write
// failure (e.g. one page 403s) must never read as success.
const written = new Set(result.succeeded);
const pagesFixed = plans.filter((pl) => written.has(pl.sourcePath)).length;
const redirectsFixed = redirectDoc && written.has('/redirects.json') ? redirectDoc.count : 0;
const lines = [
`Fixed ${pagesFixed} page(s)${redirectsFixed ? ` and ${redirectsFixed} redirect rule(s)` : ''} in one reversible batch; ${result.failed.length} write failure(s); ${metaUnchanged} page(s) already correct; ${readFailed.length} unreadable.`,
];
// 5. Optionally publish everything that was written.
if (args.publish && result.succeeded.length > 0) {
const toPublish = plans.filter((pl) => written.has(pl.sourcePath)).map((pl) => pl.path);
if (redirectDoc && written.has('/redirects.json'))
toPublish.push('/redirects.json');
let published = 0;
const pubFailed = [];
await mapWithConcurrency(toPublish, async (path) => {
try {
await edsClient.previewAndPublish(path);
published++;
}
catch {
pubFailed.push(path);
}
}, 6);
lines.push(`Published ${published}/${toPublish.length} live${pubFailed.length ? ` (${pubFailed.length} publish failure(s))` : ''}.`);
}
else if (result.succeeded.length > 0) {
lines.push('(Written to DA. Pass publish:true to make the batch live.)');
}
if (readFailed.length > 0) {
lines.push('', 'Could not read:');
for (const f of readFailed)
lines.push(` ✗ ${f.path} — ${f.error}`);
}
if (result.failed.length > 0) {
lines.push('', 'Write failures:');
for (const f of result.failed)
lines.push(` ✗ ${f.path} — ${f.error}`);
}
// One aggregated undo for the whole batch (metadata + redirects), capped so a
// huge batch's undo isn't inlined unusably (DA versions each doc as a fallback).
if (result.undo && (result.undo.restore.length > 0 || result.undo.remove.length > 0)) {
const undoJson = JSON.stringify({ undo: result.undo });
if (undoJson.length <= 200_000) {
lines.push('', 'To undo this ENTIRE batch in one call, use eds_da_rollback with:', undoJson);
// eds_da_rollback restores the DA *source* but does not republish. If this
// batch went live, the old (now-wrong) version keeps serving until the
// reverted docs are republished — critical for a redirect, which hides a
// live page. Say so, honestly.
if (args.publish) {
lines.push('', 'Note: this batch was published. eds_da_rollback restores the source but does NOT republish — after rolling back, republish the affected paths (including /redirects.json) so the revert goes live.');
}
}
else {
lines.push('', `(This batch's undo is ${Math.round(undoJson.length / 1024)} KB — too large to return inline. Run smaller batches; DA also versions every doc for per-doc revert.)`);
}
}
return textResult(lines.join('\n'));
}
catch (error) {
return errorResult(error);
}
}
+1
-1
/**
* MCP server factory for the EDS MCP server.
*
* Creates a {@link McpServer} instance with all 36 tools registered.
* Creates a {@link McpServer} instance with all 38 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 36 tools registered.
* Creates a {@link McpServer} instance with all 38 tools registered.
* Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's

@@ -340,2 +340,15 @@ * first-party MCP servers.

}, async (args) => auditHandlers.handleAuditSite(client, args));
server.tool('eds_audit_report', 'Run a site audit and return a beautiful, self-contained HTML site-health report — per-dimension health scores, a prioritized issue list, and the suggested fixes — ready to save, host, or share. Same options as eds_audit_site. Read-only.', {
pathPrefix: z.string().optional().describe('Only audit pages under this path prefix. 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 include (default all): ${ALL_DIMENSIONS.join(', ')}.`),
domain: z.string().optional().describe('Live domain for RUM-based performance and 404 checks (needs EDS_DOMAIN_KEY). Also used as the report title.'),
days: z.number().int().positive().max(365).optional().describe('RUM look-back window in days (default 7).'),
}, async (args) => {
const site = args.domain ?? `${options.owner}/${options.repo}`;
return auditHandlers.handleAuditReport(client, site, args);
});
// -------------------------------------------------------------------------

@@ -418,3 +431,41 @@ // Safe fixes (ADR-011) — repair audit findings through the safe-writes layer

}));
server.tool('eds_fix_audit', "Fix what the audit found — across metadata AND redirects — in ONE reversible operation. After eds_audit_site/eds_audit_report, findings that carry a `fix` are repairable here: pass `metadata` fixes (per page) and/or `redirects` ({ source, destination }) with the values YOU wrote from the findings — this tool never invents copy. Everything is pushed in a single batch, so ONE eds_da_rollback undoes the whole thing. dryRun previews the combined plan; publish:true makes it live. Requires EDS_DA_TOKEN. This is the 'Fix it' button in agent form: audit → you supply values → one safe, reversible apply.", {
metadata: z
.array(z.object({
path: edsPath.describe('Site-relative page path to fix'),
metadata: z
.object({
title: z.string().optional(),
description: z.string().optional(),
image: z.string().optional(),
imageAlt: z.string().optional(),
})
.describe('Metadata values to set on this page (only provided ones change)'),
}))
.max(500)
.optional()
.describe('Per-page metadata fixes for SEO title/description/OG-image findings'),
redirects: z
.array(z.object({
source: z.string().min(1).describe('The 404 path that should redirect (relative, e.g. /old-page)'),
destination: z.string().min(1).describe('Where it should go — a relative path or a full URL'),
}))
.max(500)
.optional()
.describe('Redirect rules for broken-link (404) findings'),
dryRun: z.boolean().optional().describe('Preview the combined plan without writing (recommended first pass)'),
publish: z.boolean().optional().describe('Preview + publish everything written so the fixes go live'),
}, async (args) => {
const metadata = args.metadata?.map((p) => {
const { imageAlt, ...rest } = p.metadata;
return { path: p.path, metadata: { ...rest, ...(imageAlt !== undefined ? { 'image-alt': imageAlt } : {}) } };
});
return fixHandlers.handleFixAudit(daClient, client, {
metadata,
redirects: args.redirects,
dryRun: args.dryRun,
publish: args.publish,
});
});
return server;
}
{
"name": "@focusgts/eds-mcp-server",
"version": "0.11.0",
"version": "0.12.0",
"mcpName": "io.github.focusgts/eds-mcp-server",
"description": "MCP server for Adobe Edge Delivery Services — preview, publish, metrics, and content operations",
"description": "The MCP server for Adobe Edge Delivery Services — read, audit, fix, publish and undo your site from any AI agent. 38 tools, safe by default.",
"license": "Apache-2.0",

@@ -24,3 +24,7 @@ "author": "FocusGTS <dfox@focusgts.com> (https://focusgts.com)",

"ai",
"content-management"
"content-management",
"seo",
"accessibility",
"site-audit",
"document-authoring"
],

@@ -27,0 +31,0 @@ "type": "module",

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

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

@@ -60,3 +60,3 @@

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

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

## 🛠️ The 36 tools
## 🛠️ The 38 tools

@@ -178,4 +178,7 @@ ### Edge Delivery Services — publish, content, analytics

- `eds_audit_site`
- `eds_audit_report`
> **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.
>
> **`eds_audit_report`** turns that audit into a **beautiful, shareable HTML report** — per-dimension health scores, a prioritized issue list, and each suggested fix — self-contained (no external assets), ready to open, host, or send to a stakeholder.

@@ -187,2 +190,3 @@ ### Safe fixes — repair what the audit finds

- `eds_fix_redirect`
- `eds_fix_audit`

@@ -194,2 +198,4 @@ > **It fixes what it finds — reversibly.** `eds_fix_metadata` repairs a page's title, meta description and Open Graph image by editing its Document Authoring source, routed through the same **dry-run + undo** path as the write tools. The agent supplies the content (e.g. writes a fitting description); the tool writes it *correctly and idempotently* (merges into the page's Metadata block, never duplicates it). Pass `publish: true` to preview + publish so the change goes live.

> **`eds_fix_redirect`** closes the 404 loop: `eds_audit_site` surfaces the broken links from real-user data, and this adds the **301 redirect rules** (to the site's `redirects` sheet) that fix them — one rule or many, idempotent, dry-run + undo. So the audit now has a fix for *every* major finding.
>
> **`eds_fix_audit`** is the "fix it" button in agent form: after an audit, apply its fixable findings — metadata **and** redirects together — in **one reversible batch**. Findings the report marks **✦ Fixable** carry a machine-readable fix; you supply the values (the tool never invents copy), and every change is pushed at once so a **single** `eds_da_rollback` undoes all of it. `dryRun` previews the whole plan; `publish: true` makes it live.

@@ -196,0 +202,0 @@ ---