@focusgts/eds-mcp-server
Advanced tools
@@ -10,3 +10,3 @@ /** | ||
| */ | ||
| import type { DaClientOptions, DaSourceEntry, DaSourceContent, DaVersion, DaOperationResponse, DaDocument, DaExportResult, DaPushResult } from './types.js'; | ||
| import type { DaClientOptions, DaSourceEntry, DaSourceContent, DaVersion, DaOperationResponse, DaDocument, DaExportResult, DaPushResult, DaPushPreview, DaUndo } from './types.js'; | ||
| /** Friendly message shown when a DA operation is attempted without a token. */ | ||
@@ -73,3 +73,18 @@ export declare const NEEDS_DA_TOKEN_MESSAGE = "No DA token configured. Set EDS_DA_TOKEN to a Document Authoring API token to use the DA content tools \u2014 see the README."; | ||
| concurrency?: number; | ||
| withUndo?: boolean; | ||
| }): Promise<DaPushResult>; | ||
| /** | ||
| * Preview a push without writing: classify each document as create / update / | ||
| * unchanged (with line-change counts for updates). Read-only, so always safe. | ||
| */ | ||
| previewPush(documents: DaDocument[]): Promise<DaPushPreview>; | ||
| /** | ||
| * Undo a push: re-write the prior content of updated docs and delete the docs | ||
| * the push created. Takes the `undo` object returned by a `withUndo` push. | ||
| */ | ||
| rollback(undo: DaUndo): Promise<DaPushResult>; | ||
| /** Get a document's source, or null if it does not exist (404). */ | ||
| private getSourceOrNull; | ||
| /** The site-relative document path a write would target (with .html applied). */ | ||
| private docPath; | ||
| /** Run `fn` over `items` with at most `concurrency` in flight at once. */ | ||
@@ -76,0 +91,0 @@ private mapWithConcurrency; |
+102
-1
@@ -17,2 +17,20 @@ /** | ||
| } | ||
| /** Count added/removed lines between two versions (multiset line difference). */ | ||
| function lineChanges(oldContent, newContent) { | ||
| const tally = (text) => { | ||
| const m = new Map(); | ||
| for (const line of text.split('\n')) | ||
| m.set(line, (m.get(line) ?? 0) + 1); | ||
| return m; | ||
| }; | ||
| const oldT = tally(oldContent); | ||
| const newT = tally(newContent); | ||
| let added = 0; | ||
| let removed = 0; | ||
| for (const [line, n] of newT) | ||
| added += Math.max(0, n - (oldT.get(line) ?? 0)); | ||
| for (const [line, n] of oldT) | ||
| removed += Math.max(0, n - (newT.get(line) ?? 0)); | ||
| return { added, removed }; | ||
| } | ||
| export class DaClient { | ||
@@ -255,6 +273,29 @@ token; | ||
| const failed = []; | ||
| const restore = []; | ||
| const remove = []; | ||
| await this.mapWithConcurrency(documents, async (doc) => { | ||
| try { | ||
| // Read prior state first (needed for the undo entry and to skip no-op | ||
| // writes), but only RECORD the undo entry AFTER the write succeeds. | ||
| // Recording before the write would let a failed write leave a phantom | ||
| // undo entry — e.g. a `remove` for a doc that was never created, which | ||
| // rollback would then delete: the safety feature causing data loss. | ||
| let prior = null; | ||
| if (options.withUndo) { | ||
| prior = await this.getSourceOrNull(doc.path); | ||
| // Already in the desired state: don't write a spurious version, and | ||
| // there's nothing to undo. Mirrors previewPush's `unchanged`. | ||
| if (prior && prior.content === doc.content) { | ||
| succeeded.push(doc.path); | ||
| return; | ||
| } | ||
| } | ||
| await this.putSource(doc.path, doc.content, doc.contentType); | ||
| succeeded.push(doc.path); | ||
| if (options.withUndo) { | ||
| if (prior) | ||
| restore.push(prior); | ||
| else | ||
| remove.push(this.docPath(doc.path)); | ||
| } | ||
| } | ||
@@ -265,4 +306,64 @@ catch (error) { | ||
| }, concurrency); | ||
| return { succeeded, failed }; | ||
| const result = { succeeded, failed }; | ||
| if (options.withUndo) | ||
| result.undo = { restore, remove }; | ||
| return result; | ||
| } | ||
| /** | ||
| * Preview a push without writing: classify each document as create / update / | ||
| * unchanged (with line-change counts for updates). Read-only, so always safe. | ||
| */ | ||
| async previewPush(documents) { | ||
| const plan = []; | ||
| await this.mapWithConcurrency(documents, async (doc) => { | ||
| const prior = await this.getSourceOrNull(doc.path); | ||
| if (prior === null) { | ||
| plan.push({ path: this.docPath(doc.path), action: 'create' }); | ||
| } | ||
| else if (prior.content === doc.content) { | ||
| plan.push({ path: prior.path, action: 'unchanged' }); | ||
| } | ||
| else { | ||
| plan.push({ path: prior.path, action: 'update', changes: lineChanges(prior.content, doc.content) }); | ||
| } | ||
| }, 6); | ||
| const summary = { create: 0, update: 0, unchanged: 0 }; | ||
| for (const e of plan) | ||
| summary[e.action] += 1; | ||
| return { plan, summary }; | ||
| } | ||
| /** | ||
| * Undo a push: re-write the prior content of updated docs and delete the docs | ||
| * the push created. Takes the `undo` object returned by a `withUndo` push. | ||
| */ | ||
| async rollback(undo) { | ||
| const restored = await this.pushDocuments(undo.restore); | ||
| const removed = []; | ||
| const failed = [...restored.failed]; | ||
| await this.mapWithConcurrency(undo.remove, async (path) => { | ||
| try { | ||
| await this.deleteSource(path); | ||
| removed.push(this.docPath(path)); | ||
| } | ||
| catch (error) { | ||
| failed.push({ path, error: error instanceof Error ? error.message : String(error) }); | ||
| } | ||
| }, 6); | ||
| return { succeeded: [...restored.succeeded, ...removed], failed }; | ||
| } | ||
| /** Get a document's source, or null if it does not exist (404). */ | ||
| async getSourceOrNull(path) { | ||
| try { | ||
| return await this.getSource(path); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof EdsApiError && error.status === 404) | ||
| return null; | ||
| throw error; | ||
| } | ||
| } | ||
| /** The site-relative document path a write would target (with .html applied). */ | ||
| docPath(path) { | ||
| return `/${this.normalizeDocPath(path)}`; | ||
| } | ||
| /** Run `fn` over `items` with at most `concurrency` in flight at once. */ | ||
@@ -269,0 +370,0 @@ async mapWithConcurrency(items, fn, concurrency) { |
@@ -94,2 +94,38 @@ /** | ||
| }>; | ||
| /** | ||
| * Present when the push was made with `withUndo`. The reverse operation: | ||
| * `restore` re-writes the prior content of updated docs, `remove` deletes | ||
| * the docs this push newly created. Pass it to `eds_da_rollback` to undo. | ||
| */ | ||
| undo?: DaUndo; | ||
| } | ||
| /** One document's status in a dry-run push preview. */ | ||
| export interface DaPushPlanEntry { | ||
| /** Site-relative document path. */ | ||
| path: string; | ||
| /** What the push would do to it. */ | ||
| action: 'create' | 'update' | 'unchanged'; | ||
| /** For updates: line-level change counts (added/removed). */ | ||
| changes?: { | ||
| added: number; | ||
| removed: number; | ||
| }; | ||
| } | ||
| /** Result of a dry-run push preview (no writes performed). */ | ||
| export interface DaPushPreview { | ||
| /** Per-document plan. */ | ||
| plan: DaPushPlanEntry[]; | ||
| /** Roll-up counts. */ | ||
| summary: { | ||
| create: number; | ||
| update: number; | ||
| unchanged: number; | ||
| }; | ||
| } | ||
| /** The reverse of a push, used to undo it. */ | ||
| export interface DaUndo { | ||
| /** Prior content to re-write (undoes updates). */ | ||
| restore: DaDocument[]; | ||
| /** Paths the push created, to delete (undoes creates). */ | ||
| remove: string[]; | ||
| } |
@@ -75,2 +75,4 @@ /** | ||
| }>; | ||
| dryRun?: boolean; | ||
| withUndo?: boolean; | ||
| }): Promise<{ | ||
@@ -82,2 +84,17 @@ content: { | ||
| }>; | ||
| export declare function handleDaRollback(client: DaClient, args: { | ||
| undo: { | ||
| restore: Array<{ | ||
| path: string; | ||
| content: string; | ||
| contentType?: string; | ||
| }>; | ||
| remove: string[]; | ||
| }; | ||
| }): Promise<{ | ||
| content: { | ||
| type: "text"; | ||
| text: string; | ||
| }[]; | ||
| }>; | ||
| export declare function handleDaGetVersions(client: DaClient, args: { | ||
@@ -84,0 +101,0 @@ path: string; |
@@ -126,3 +126,19 @@ /** | ||
| try { | ||
| const result = await client.pushDocuments(args.documents); | ||
| // Dry-run: preview what would change, write nothing. | ||
| if (args.dryRun) { | ||
| const preview = await client.previewPush(args.documents); | ||
| const s = preview.summary; | ||
| const lines = [ | ||
| `Dry run — nothing was written. ${s.create} create, ${s.update} update, ${s.unchanged} unchanged.`, | ||
| '', | ||
| ]; | ||
| for (const e of preview.plan) { | ||
| const detail = e.action === 'update' && e.changes | ||
| ? ` (+${e.changes.added}/-${e.changes.removed} lines)` | ||
| : ''; | ||
| lines.push(` ${e.action.toUpperCase().padEnd(9)} ${e.path}${detail}`); | ||
| } | ||
| return textResult(lines.join('\n')); | ||
| } | ||
| const result = await client.pushDocuments(args.documents, { withUndo: args.withUndo }); | ||
| const lines = [ | ||
@@ -141,2 +157,5 @@ `Pushed ${result.succeeded.length} document${result.succeeded.length === 1 ? '' : 's'}; ${result.failed.length} failed.`, | ||
| } | ||
| if (result.undo) { | ||
| lines.push('', 'To undo this push, call eds_da_rollback with this exact object:', JSON.stringify({ undo: result.undo })); | ||
| } | ||
| return textResult(lines.join('\n')); | ||
@@ -148,2 +167,19 @@ } | ||
| } | ||
| export async function handleDaRollback(client, args) { | ||
| try { | ||
| const result = await client.rollback(args.undo); | ||
| const lines = [ | ||
| `Rolled back: ${result.succeeded.length} restored/removed; ${result.failed.length} failed.`, | ||
| ]; | ||
| if (result.failed.length > 0) { | ||
| lines.push('', 'Failed:'); | ||
| for (const f of result.failed) | ||
| lines.push(` ✗ ${f.path} — ${f.error}`); | ||
| } | ||
| return textResult(lines.join('\n')); | ||
| } | ||
| catch (error) { | ||
| return errorResult(error); | ||
| } | ||
| } | ||
| export async function handleDaGetVersions(client, args) { | ||
@@ -150,0 +186,0 @@ try { |
| /** | ||
| * MCP server factory for the EDS MCP server. | ||
| * | ||
| * Creates a {@link McpServer} instance with all 30 tools registered. | ||
| * Creates a {@link McpServer} instance with all 31 tools registered. | ||
| * Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's | ||
@@ -6,0 +6,0 @@ * first-party MCP servers. |
+24
-2
| /** | ||
| * MCP server factory for the EDS MCP server. | ||
| * | ||
| * Creates a {@link McpServer} instance with all 30 tools registered. | ||
| * Creates a {@link McpServer} instance with all 31 tools registered. | ||
| * Tool naming follows the `eds_{verb}_{noun}` convention used by Adobe's | ||
@@ -270,3 +270,3 @@ * first-party MCP servers. | ||
| }, async (args) => daHandlers.handleDaExport(daClient, args)); | ||
| server.tool('eds_da_push', 'Bulk-push many edited Document Authoring documents back in one call. The "push" half of the bulk workflow — write a whole batch of {path, content} at once. Returns per-document succeeded/failed. Requires EDS_DA_TOKEN.', { | ||
| server.tool('eds_da_push', 'Bulk-push many edited Document Authoring documents back in one call. Set dryRun to PREVIEW what would change (create/update/unchanged) without writing anything — safest to run this first. Set withUndo to make the write reversible (returns an undo object for eds_da_rollback). Requires EDS_DA_TOKEN.', { | ||
| documents: z | ||
@@ -281,4 +281,26 @@ .array(z.object({ | ||
| .describe('The documents to write back'), | ||
| dryRun: z | ||
| .boolean() | ||
| .optional() | ||
| .describe('Preview the changes without writing anything (recommended first pass)'), | ||
| withUndo: z | ||
| .boolean() | ||
| .optional() | ||
| .describe('Capture prior state so the push can be reverted with eds_da_rollback'), | ||
| }, async (args) => daHandlers.handleDaPush(daClient, args)); | ||
| server.tool('eds_da_rollback', 'Undo a previous eds_da_push. Pass the exact `undo` object returned by a push that used withUndo — it restores overwritten documents and deletes newly-created ones. Requires EDS_DA_TOKEN.', { | ||
| undo: z | ||
| .object({ | ||
| restore: z | ||
| .array(z.object({ | ||
| path: daSourcePath.describe('Prior document path'), | ||
| content: z.string(), | ||
| contentType: z.string().optional(), | ||
| })) | ||
| .describe('Prior document contents to re-write'), | ||
| remove: z.array(daSourcePath).describe('Paths the push created, to delete'), | ||
| }) | ||
| .describe('The undo object returned by a withUndo push'), | ||
| }, async (args) => daHandlers.handleDaRollback(daClient, args)); | ||
| return server; | ||
| } |
+1
-1
| { | ||
| "name": "@focusgts/eds-mcp-server", | ||
| "version": "0.6.0", | ||
| "version": "0.7.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"> | ||
| **30 tools. No extra dependencies beyond the MCP SDK. Works with any EDS site.** | ||
| **31 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/>30 tools"] | ||
| A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>31 tools"] | ||
| B --> C["Admin API<br/>admin.hlx.page"] | ||
@@ -80,3 +80,3 @@ B --> D["Content API<br/>*.aem.live"] | ||
| ## 🛠️ The 30 tools | ||
| ## 🛠️ The 31 tools | ||
@@ -144,5 +144,6 @@ ### Edge Delivery Services — publish, content, analytics | ||
| **Bulk ("clone")** | ||
| **Bulk ("clone") + safe writes** | ||
| - `eds_da_export` | ||
| - `eds_da_push` | ||
| - `eds_da_rollback` | ||
@@ -154,2 +155,4 @@ </td></tr> | ||
| > | ||
| > **Safe by default.** `eds_da_push` takes `dryRun: true` to **preview** exactly what a bulk edit would do (create / update / unchanged, with line-diff counts) without writing a thing, and `withUndo: true` to make the write **reversible** — it returns an `undo` object you hand to `eds_da_rollback` to restore prior content and remove any docs the push created. Preview before writing, undo after: the difference between an impressive demo and something you'd point at a production site. | ||
| > | ||
| > `EDS_DA_TOKEN` is an Adobe IMS access token for Document Authoring — grab it from an authenticated [da.live](https://da.live) session (the IMS `access_token`). Document paths assume `.html` when no extension is given (`index` → `index.html`). | ||
@@ -156,0 +159,0 @@ |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
193202
5.31%4240
5.68%271
1.12%