@focusgts/eds-mcp-server
Advanced tools
@@ -24,1 +24,14 @@ /** | ||
| }>; | ||
| export declare function handleBulkFixMetadata(daClient: DaClient, edsClient: EdsClient, args: { | ||
| pages: Array<{ | ||
| path: string; | ||
| metadata: MetadataFields; | ||
| }>; | ||
| dryRun?: boolean; | ||
| publish?: boolean; | ||
| }): Promise<{ | ||
| content: { | ||
| type: "text"; | ||
| text: string; | ||
| }[]; | ||
| }>; |
+131
-0
@@ -20,2 +20,14 @@ /** | ||
| } | ||
| /** 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())); | ||
| } | ||
| export async function handleFixMetadata(daClient, edsClient, args) { | ||
@@ -67,1 +79,120 @@ try { | ||
| } | ||
| export async function handleBulkFixMetadata(daClient, edsClient, args) { | ||
| try { | ||
| // Dedupe input by DA-normalized path (case-sensitive; strip a leading "/" | ||
| // and a trailing ".html"), MERGING metadata so two entries for the same page | ||
| // combine rather than racing each other on write (which would produce a | ||
| // nondeterministic result and a broken undo). | ||
| const deduped = new Map(); | ||
| for (const p of args.pages) { | ||
| 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()]; | ||
| // 1. Read + transform each page (bounded concurrency). Collect only the | ||
| // pages that actually change; record read failures without aborting. | ||
| 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: e instanceof Error ? e.message : String(e) }); | ||
| } | ||
| }, 6); | ||
| const unchanged = pages.length - plans.length - readFailed.length; | ||
| // 2. Dry run: full plan, no writes. | ||
| if (args.dryRun) { | ||
| const lines = [ | ||
| `Dry run — nothing written. ${plans.length} page(s) would change, ${unchanged} already correct, ${readFailed.length} unreadable.`, | ||
| ]; | ||
| if (plans.length > 0) { | ||
| lines.push('', 'Would change:'); | ||
| for (const pl of plans) | ||
| lines.push(` ${pl.sourcePath}: ${pl.fields.join(', ')}`); | ||
| } | ||
| 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')); | ||
| } | ||
| if (plans.length === 0) { | ||
| // Lead with the failure when nothing could be read (e.g. bad token) so it | ||
| // doesn't read as a success. | ||
| if (readFailed.length > 0) { | ||
| const lines = [ | ||
| `Nothing written — ${readFailed.length} page(s) could not be read; ${unchanged} 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 — ${unchanged} page(s) already correct.`); | ||
| } | ||
| // 3. Push ALL changed pages in ONE batch → a single aggregated undo. | ||
| const result = await daClient.pushDocuments(plans.map((pl) => ({ path: pl.sourcePath, content: pl.html, contentType: pl.contentType })), { withUndo: true }); | ||
| const lines = [ | ||
| `Fixed ${result.succeeded.length} page(s) in one batch; ${result.failed.length} write failure(s); ${unchanged} already correct; ${readFailed.length} unreadable.`, | ||
| ]; | ||
| // 4. Optionally publish the pages that were written. | ||
| if (args.publish && result.succeeded.length > 0) { | ||
| const written = new Set(result.succeeded); | ||
| const toPublish = plans.filter((pl) => written.has(pl.sourcePath)).map((pl) => pl.path); | ||
| 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} page(s) 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}`); | ||
| } | ||
| // Return the aggregated undo — but never inline a giant blob. A large batch's | ||
| // undo carries every page's full prior HTML; past a size cap, inlining it is | ||
| // unusable, so advise smaller batches (DA versioning is the per-page fallback). | ||
| if (result.undo && (result.undo.restore.length > 0 || result.undo.remove.length > 0)) { | ||
| const undoJson = JSON.stringify({ undo: result.undo }); | ||
| const UNDO_INLINE_CAP = 200_000; | ||
| if (undoJson.length <= UNDO_INLINE_CAP) { | ||
| lines.push('', 'To undo this ENTIRE batch in one call, use eds_da_rollback with:', undoJson); | ||
| } | ||
| else { | ||
| lines.push('', `(This batch's undo is ${Math.round(undoJson.length / 1024)} KB — too large to return inline. For a single returnable undo, run smaller batches; DA also versions every page, so any page can be reverted from its version history.)`); | ||
| } | ||
| } | ||
| return textResult(lines.join('\n')); | ||
| } | ||
| catch (error) { | ||
| return errorResult(error); | ||
| } | ||
| } |
| /** | ||
| * MCP server factory for the EDS MCP server. | ||
| * | ||
| * Creates a {@link McpServer} instance with all 34 tools registered. | ||
| * Creates a {@link McpServer} instance with all 35 tools registered. | ||
| * Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's | ||
@@ -6,0 +6,0 @@ * first-party MCP servers. |
+26
-1
| /** | ||
| * MCP server factory for the EDS MCP server. | ||
| * | ||
| * Creates a {@link McpServer} instance with all 34 tools registered. | ||
| * Creates a {@link McpServer} instance with all 35 tools registered. | ||
| * Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's | ||
@@ -376,3 +376,28 @@ * first-party MCP servers. | ||
| }); | ||
| server.tool('eds_bulk_fix_metadata', 'Fix SEO/social metadata across MANY pages in one reversible operation. Takes a list of { path, metadata } (the agent supplies each page\'s values after auditing). Writes all changed pages in a single batch and returns ONE undo object that reverts the entire batch via eds_da_rollback. dryRun previews the whole plan; publish:true previews+publishes the batch live. Requires EDS_DA_TOKEN. Pair with eds_audit_site to fix a site\'s findings at once.', { | ||
| pages: z | ||
| .array(z.object({ | ||
| path: edsPath.describe('Site-relative page path'), | ||
| metadata: z | ||
| .object({ | ||
| title: z.string().optional(), | ||
| description: z.string().optional(), | ||
| image: z.string().optional(), | ||
| imageAlt: z.string().optional(), | ||
| }) | ||
| .describe('Metadata fields to set on this page (only provided ones change)'), | ||
| })) | ||
| .min(1) | ||
| .max(500) | ||
| .describe('The pages to fix, each with its own metadata values'), | ||
| dryRun: z.boolean().optional().describe('Preview the whole plan without writing (recommended first pass)'), | ||
| publish: z.boolean().optional().describe('Preview + publish the changed pages so the batch goes live'), | ||
| }, async (args) => { | ||
| const pages = args.pages.map((p) => { | ||
| const { imageAlt, ...rest } = p.metadata; | ||
| return { path: p.path, metadata: { ...rest, ...(imageAlt !== undefined ? { 'image-alt': imageAlt } : {}) } }; | ||
| }); | ||
| return fixHandlers.handleBulkFixMetadata(daClient, client, { pages, dryRun: args.dryRun, publish: args.publish }); | ||
| }); | ||
| return server; | ||
| } |
+1
-1
| { | ||
| "name": "@focusgts/eds-mcp-server", | ||
| "version": "0.9.0", | ||
| "version": "0.10.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", |
+7
-4
@@ -13,3 +13,3 @@ <div align="center"> | ||
| **34 tools. No extra dependencies beyond the MCP SDK. Works with any EDS site.** | ||
| **35 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/>34 tools"] | ||
| A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>35 tools"] | ||
| B --> C["Admin API<br/>admin.hlx.page"] | ||
@@ -80,3 +80,3 @@ B --> D["Content API<br/>*.aem.live"] | ||
| ## 🛠️ The 34 tools | ||
| ## 🛠️ The 35 tools | ||
@@ -168,4 +168,7 @@ ### Edge Delivery Services — publish, content, analytics | ||
| - `eds_fix_metadata` | ||
| - `eds_bulk_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.** | ||
| > **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_bulk_fix_metadata`** does it across a **whole site in one reversible operation** — pass a list of `{ path, metadata }`, and it writes every changed page in a single batch that returns **one** undo reverting all of it. The full loop: **`eds_audit_site` → fix the batch → publish → re-audit to zero** — with a single undo if anything looks off. | ||
@@ -172,0 +175,0 @@ --- |
258444
3.56%5740
3.05%287
1.06%