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.8.0
to
0.9.0
+70
dist/fix/metadata.d.ts
/**
* Metadata fix (ADR-011) — add or correct a page's `<head>` metadata by editing
* its DA source, so the EDS pipeline emits the right `<meta>` tags.
*
* EDS reads per-page metadata from a **Metadata block** in the authored document.
* Grounded against Adobe's own pipeline (`helix-html-pipeline`
* `src/steps/extract-metadata.js`): it does `select('div.metadata', document)`,
* reads each row as `[name, value] = row.children`, and for the image field pulls
* the `src` from an `<img>` inside the value cell. So the DA source shape is:
*
* <div class="metadata">
* <div><div>Title</div><div>value</div></div>
* <div><div>Description</div><div>value</div></div>
* <div><div>Image</div><div><img src="…"></div></div>
* </div>
*
* The writer edits that block with a depth-aware scanner (no DOM dependency),
* and is careful never to corrupt the document:
* - it **preserves untouched rows verbatim** (so an existing Image/`<img>` row or
* an authored link in a value is never flattened away when another field is
* changed);
* - it matches the metadata block even when it carries variant classes
* (`class="metadata foo"`), any attribute order, or single quotes — so it never
* appends a duplicate block that the pipeline would ignore;
* - metadata keys round-trip reversibly (`Image Alt` ⇄ `image-alt`), so repeated
* fixes are idempotent and never accumulate duplicate rows.
*/
/** Metadata fields a fix can set. Keys are matched case-insensitively. */
export interface MetadataFields {
title?: string;
description?: string;
image?: string;
'image-alt'?: string;
[key: string]: string | undefined;
}
/** One field the fix changed. */
export interface MetadataChange {
field: string;
from: string | null;
to: string;
}
/** Result of applying metadata to a page's DA source. */
export interface ApplyMetadataResult {
html: string;
changes: MetadataChange[];
}
/**
* Find a `<div>` whose `class` attribute contains `className` as a
* whitespace-delimited token — in any attribute position, either quote style,
* and tolerating variant classes (`class="metadata foo"`). Depth-aware.
*/
export declare function findDivBlock(html: string, className: string): {
start: number;
end: number;
inner: string;
} | null;
/** Parse a metadata block's rows into a key → value map (test/inspection helper). */
export declare function parseMetadataRows(inner: string): Map<string, string>;
/** Build a fresh `<div class="metadata">` block from a key → value map. */
export declare function buildMetadataBlock(fields: Map<string, string>): string;
/**
* Apply metadata `fields` to a page's DA source HTML.
*
* Merges into an existing metadata block — rebuilding only the rows that change,
* preserving every other row **verbatim** (including `<img>` and authored markup)
* — or inserts a new block before `</main>` (or `</body>`). Returns the new HTML
* and the fields that actually changed; an empty change list means the page was
* already correct and `html` is returned unchanged.
*/
export declare function applyMetadata(html: string, fields: MetadataFields): ApplyMetadataResult;
/**
* Metadata fix (ADR-011) — add or correct a page's `<head>` metadata by editing
* its DA source, so the EDS pipeline emits the right `<meta>` tags.
*
* EDS reads per-page metadata from a **Metadata block** in the authored document.
* Grounded against Adobe's own pipeline (`helix-html-pipeline`
* `src/steps/extract-metadata.js`): it does `select('div.metadata', document)`,
* reads each row as `[name, value] = row.children`, and for the image field pulls
* the `src` from an `<img>` inside the value cell. So the DA source shape is:
*
* <div class="metadata">
* <div><div>Title</div><div>value</div></div>
* <div><div>Description</div><div>value</div></div>
* <div><div>Image</div><div><img src="…"></div></div>
* </div>
*
* The writer edits that block with a depth-aware scanner (no DOM dependency),
* and is careful never to corrupt the document:
* - it **preserves untouched rows verbatim** (so an existing Image/`<img>` row or
* an authored link in a value is never flattened away when another field is
* changed);
* - it matches the metadata block even when it carries variant classes
* (`class="metadata foo"`), any attribute order, or single quotes — so it never
* appends a duplicate block that the pipeline would ignore;
* - metadata keys round-trip reversibly (`Image Alt` ⇄ `image-alt`), so repeated
* fixes are idempotent and never accumulate duplicate rows.
*/
const LABELS = {
title: 'Title',
description: 'Description',
image: 'Image',
'image-alt': 'Image Alt',
url: 'URL',
};
/** Canonical key ⇄ display label. Reversible: `Image Alt` ⇄ `image-alt`. */
function normalizeKey(raw) {
return raw.trim().toLowerCase().replace(/\s+/g, '-');
}
function labelFor(key) {
return LABELS[key] ?? key.replace(/(^|-)([a-z])/g, (_, s, c) => (s === '-' ? ' ' : '') + c.toUpperCase());
}
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** Decode entities in a single left-to-right pass (so `&amp;lt;` → `&lt;`, not `<`). */
function decodeEntities(s) {
const map = { amp: '&', lt: '<', gt: '>', quot: '"', '#x27': "'", '#39': "'" };
return s.replace(/&(amp|lt|gt|quot|#x27|#39);/g, (_, e) => map[e]);
}
function stripTags(s) {
return decodeEntities(s.replace(/<[^>]*>/g, '')).trim();
}
/**
* The comparable value of a metadata value cell: an image's `src` when the cell
* holds an `<img>` (matching the pipeline), otherwise its plain text.
*/
function cellValue(inner) {
const img = /<img\b[^>]*\bsrc\s*=\s*("([^"]*)"|'([^']*)')/i.exec(inner);
if (img)
return img[2] ?? img[3] ?? '';
return stripTags(inner);
}
/** From `innerStart`, find the matching `</div>` (depth-aware, ignores `<div/>`). */
function scanDivClose(html, innerStart) {
const tag = /<div\b[^>]*>|<\/div\s*>/gi;
tag.lastIndex = innerStart;
let depth = 1;
let m;
while ((m = tag.exec(html)) !== null) {
if (m[0][1] === '/') {
depth--;
if (depth === 0)
return { innerEnd: m.index, end: tag.lastIndex };
}
else if (!m[0].endsWith('/>')) {
depth++;
}
}
return null;
}
/**
* Find a `<div>` whose `class` attribute contains `className` as a
* whitespace-delimited token — in any attribute position, either quote style,
* and tolerating variant classes (`class="metadata foo"`). Depth-aware.
*/
export function findDivBlock(html, className) {
const divRe = /<div\b([^>]*)>/gi;
let m;
while ((m = divRe.exec(html)) !== null) {
if (m[0].endsWith('/>'))
continue;
const classAttr = /\bclass\s*=\s*("([^"]*)"|'([^']*)')/i.exec(m[1]);
if (!classAttr)
continue;
const classes = (classAttr[2] ?? classAttr[3] ?? '').split(/\s+/);
if (!classes.includes(className))
continue;
const innerStart = m.index + m[0].length;
const close = scanDivClose(html, innerStart);
if (!close)
return null;
return { start: m.index, end: close.end, inner: html.slice(innerStart, close.innerEnd) };
}
return null;
}
/** Each top-level `<div>` block's outer + inner HTML (depth-aware). */
function topLevelDivBlocks(html) {
const out = [];
let i = 0;
while (i < html.length) {
const open = /<div\b[^>]*>/i.exec(html.slice(i));
if (!open)
break;
const openAbs = i + open.index;
if (open[0].endsWith('/>')) {
i = openAbs + open[0].length;
continue;
}
const innerStart = openAbs + open[0].length;
const close = scanDivClose(html, innerStart);
if (!close)
break;
out.push({ outer: html.slice(openAbs, close.end), inner: html.slice(innerStart, close.innerEnd) });
i = close.end;
}
return out;
}
function parseRows(blockInner) {
const rows = [];
for (const row of topLevelDivBlocks(blockInner)) {
const cells = topLevelDivBlocks(row.inner);
if (cells.length >= 2) {
rows.push({ key: normalizeKey(stripTags(cells[0].inner)), value: cellValue(cells[1].inner), outer: row.outer });
}
else {
rows.push({ key: '', value: '', outer: row.outer }); // malformed — preserve verbatim
}
}
return rows;
}
/** Parse a metadata block's rows into a key → value map (test/inspection helper). */
export function parseMetadataRows(inner) {
const map = new Map();
for (const r of parseRows(inner))
if (r.key)
map.set(r.key, r.value);
return map;
}
function valueCell(key, value) {
// The pipeline resolves the image field from an <img> in the cell.
if (key === 'image')
return `<img src="${escapeHtml(value)}">`;
return `<p>${escapeHtml(value)}</p>`;
}
function buildRow(key, value, indent = ' ') {
return (`${indent}<div>\n` +
`${indent} <div><p>${escapeHtml(labelFor(key))}</p></div>\n` +
`${indent} <div>${valueCell(key, value)}</div>\n` +
`${indent}</div>`);
}
function assembleBlock(rowHtml) {
return `<div class="metadata">\n${rowHtml.join('\n')}\n </div>`;
}
/** Build a fresh `<div class="metadata">` block from a key → value map. */
export function buildMetadataBlock(fields) {
const rows = [...fields.entries()].filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => buildRow(k, v));
return assembleBlock(rows);
}
/**
* Apply metadata `fields` to a page's DA source HTML.
*
* Merges into an existing metadata block — rebuilding only the rows that change,
* preserving every other row **verbatim** (including `<img>` and authored markup)
* — or inserts a new block before `</main>` (or `</body>`). Returns the new HTML
* and the fields that actually changed; an empty change list means the page was
* already correct and `html` is returned unchanged.
*/
export function applyMetadata(html, fields) {
const block = findDivBlock(html, 'metadata');
const rows = block ? parseRows(block.inner) : [];
const byKey = new Map(rows.filter((r) => r.key).map((r) => [r.key, r]));
const changes = [];
const newValueByKey = new Map();
for (const [rawKey, value] of Object.entries(fields)) {
if (value === undefined)
continue;
const key = normalizeKey(rawKey);
const from = byKey.get(key)?.value ?? null;
if (from !== value) {
changes.push({ field: key, from, to: value });
newValueByKey.set(key, value);
}
}
if (changes.length === 0)
return { html, changes: [] };
// Assemble: rebuild only changed rows; keep every other row byte-for-byte.
const assembled = [];
const used = new Set();
for (const r of rows) {
if (r.key && newValueByKey.has(r.key)) {
assembled.push(buildRow(r.key, newValueByKey.get(r.key)));
}
else {
assembled.push(r.outer.trim());
}
if (r.key)
used.add(r.key);
}
for (const [key, value] of newValueByKey) {
if (!used.has(key))
assembled.push(buildRow(key, value));
}
const newBlock = assembleBlock(assembled);
if (block) {
return { html: html.slice(0, block.start) + newBlock + html.slice(block.end), changes };
}
const mainClose = html.search(/<\/main>/i);
if (mainClose >= 0)
return { html: `${html.slice(0, mainClose)}${newBlock}\n${html.slice(mainClose)}`, changes };
const bodyClose = html.search(/<\/body>/i);
if (bodyClose >= 0)
return { html: `${html.slice(0, bodyClose)}${newBlock}\n${html.slice(bodyClose)}`, changes };
return { html: `${html}\n${newBlock}`, changes };
}
/**
* MCP tool handlers for the safe-fix layer (ADR-011).
*
* `eds_fix_metadata` repairs a page's `<head>` metadata by editing its DA source
* through the ADR-009 safe-writes path (preview + undo), and can optionally
* preview+publish so the change goes live. The agent supplies the content; this
* handler writes it correctly and reversibly.
*/
import type { EdsClient } from '../eds-admin/client.js';
import type { DaClient } from '../da-admin/client.js';
import { type MetadataFields } from '../fix/metadata.js';
export declare function handleFixMetadata(daClient: DaClient, edsClient: EdsClient, args: {
path: string;
metadata: MetadataFields;
dryRun?: boolean;
withUndo?: boolean;
publish?: boolean;
}): Promise<{
content: {
type: "text";
text: string;
}[];
}>;
/**
* MCP tool handlers for the safe-fix layer (ADR-011).
*
* `eds_fix_metadata` repairs a page's `<head>` metadata by editing its DA source
* through the ADR-009 safe-writes path (preview + undo), and can optionally
* preview+publish so the change goes live. The agent supplies the content; this
* handler writes it correctly and reversibly.
*/
import { formatError } from '../utils/errors.js';
import { applyMetadata } from '../fix/metadata.js';
function textResult(text) {
return { content: [{ type: 'text', text }] };
}
function errorResult(error) {
return {
content: [{ type: 'text', text: `Error: ${formatError(error)}` }],
isError: true,
};
}
export async function handleFixMetadata(daClient, edsClient, args) {
try {
const source = await daClient.getSource(args.path);
const { html, changes } = applyMetadata(source.content, args.metadata);
if (changes.length === 0) {
return textResult(`No metadata changes needed for ${source.path} — already correct.`);
}
// Dry run: show the before/after, write nothing.
if (args.dryRun) {
const lines = [
`Dry run — nothing written. ${changes.length} metadata field(s) would change on ${source.path}:`,
'',
];
for (const c of changes) {
lines.push(` ${c.field}: ${c.from === null ? '(none)' : `"${c.from}"`} → "${c.to}"`);
}
return textResult(lines.join('\n'));
}
// Write through the safe-writes path.
const result = await daClient.pushDocuments([{ path: source.path, content: html, contentType: source.contentType }], { withUndo: args.withUndo });
if (result.failed.length > 0) {
return errorResult(new Error(`Failed to write ${source.path}: ${result.failed[0].error}`));
}
const lines = [`Updated metadata on ${source.path}: ${changes.map((c) => c.field).join(', ')}.`];
// Optionally make it live — a DA write alone is not visible until republished.
if (args.publish) {
try {
await edsClient.previewAndPublish(args.path);
lines.push('Previewed + published — the change is live.');
}
catch (e) {
lines.push(`(Written to DA, but publish failed: ${formatError(e)} — run eds_preview_and_publish manually.)`);
}
}
else {
lines.push('(Written to DA. Pass publish:true, or run eds_preview_and_publish, to make it live.)');
}
if (result.undo) {
lines.push('', 'To undo this change, call eds_da_rollback with:', JSON.stringify({ undo: result.undo }));
}
return textResult(lines.join('\n'));
}
catch (error) {
return errorResult(error);
}
}
+2
-0

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

ref?: string;
/** How long to wait for the browser callback, in ms. Defaults to 120000. */
timeoutMs?: number;
}

@@ -20,0 +22,0 @@ /** Result of a successful login. */

+4
-3

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

const ref = options.ref ?? 'main';
const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
const state = generateNonce();

@@ -200,10 +201,10 @@ return new Promise((resolve, reject) => {

openBrowser(loginUrl);
process.stderr.write(`\nWaiting for sign-in to complete (timeout ${LOGIN_TIMEOUT_MS / 1000}s)...\n` +
process.stderr.write(`\nWaiting for sign-in to complete (timeout ${timeoutMs / 1000}s)...\n` +
'Use Google Chrome or Firefox to sign in (Safari blocks the local callback). ' +
"If it doesn't complete, set EDS_API_KEY instead — see the README.\n");
timeout = setTimeout(() => {
settle(() => reject(new Error(`Login timed out after ${LOGIN_TIMEOUT_MS / 1000}s — no callback was received. ` +
settle(() => reject(new Error(`Login timed out after ${timeoutMs / 1000}s — no callback was received. ` +
'Safari is not supported (it blocks the local callback) — use Google Chrome or Firefox. ' +
'Alternatively, set EDS_API_KEY instead of signing in — see the README.')));
}, LOGIN_TIMEOUT_MS);
}, timeoutMs);
timeout.unref();

@@ -210,0 +211,0 @@ });

/**
* MCP server factory for the EDS MCP server.
*
* Creates a {@link McpServer} instance with all 33 tools registered.
* Creates a {@link McpServer} instance with all 34 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 33 tools registered.
* Creates a {@link McpServer} instance with all 34 tools registered.
* Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's

@@ -19,2 +19,3 @@ * first-party MCP servers.

import * as auditHandlers from './audit-handlers.js';
import * as fixHandlers from './fix-handlers.js';
import { ALL_DIMENSIONS } from '../audit/types.js';

@@ -340,3 +341,39 @@ const require = createRequire(import.meta.url);

}, async (args) => auditHandlers.handleAuditSite(client, args));
// -------------------------------------------------------------------------
// Safe fixes (ADR-011) — repair audit findings through the safe-writes layer
// -------------------------------------------------------------------------
server.tool('eds_fix_metadata', 'Fix a page\'s SEO/social metadata (title, description, Open Graph image) by editing its Document Authoring source. Adds or updates the page\'s Metadata block idempotently, through the safe-writes path (dry-run + undo). The AGENT supplies the values (e.g. write a good meta description); this tool writes them correctly and reversibly. Requires EDS_DA_TOKEN. Set publish:true to preview+publish so the change goes live.', {
path: edsPath.describe('Site-relative page path to fix (e.g. /blog/post)'),
metadata: z
.object({
title: z.string().optional().describe('Page title (aim for 30–60 characters)'),
description: z.string().optional().describe('Meta description (aim for 120–160 characters)'),
image: z.string().optional().describe('Open Graph / social share image URL'),
imageAlt: z.string().optional().describe('Alt text for the social image'),
})
.describe('Metadata fields to set (only the ones provided are changed)'),
dryRun: z
.boolean()
.optional()
.describe('Preview the before/after without writing (recommended first pass)'),
withUndo: z
.boolean()
.optional()
.describe('Make the write reversible — returns an undo object for eds_da_rollback'),
publish: z
.boolean()
.optional()
.describe('Preview + publish the page after writing so the change goes live'),
}, async (args) => {
const { imageAlt, ...rest } = args.metadata;
const metadata = { ...rest, ...(imageAlt !== undefined ? { 'image-alt': imageAlt } : {}) };
return fixHandlers.handleFixMetadata(daClient, client, {
path: args.path,
metadata,
dryRun: args.dryRun,
withUndo: args.withUndo,
publish: args.publish,
});
});
return server;
}
{
"name": "@focusgts/eds-mcp-server",
"version": "0.8.0",
"version": "0.9.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">

**33 tools. No extra dependencies beyond the MCP SDK. Works with any EDS site.**
**34 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/>33 tools"]
A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>34 tools"]
B --> C["Admin API<br/>admin.hlx.page"]

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

## 🛠️ The 33 tools
## 🛠️ The 34 tools

@@ -163,4 +163,10 @@ ### Edge Delivery Services — publish, content, analytics

> **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.
> **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.
### Safe fixes — repair what the audit finds
- `eds_fix_metadata`
> **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. The full loop: **audit → fix → publish → re-audit to zero.**
---

@@ -167,0 +173,0 @@