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.5.0
to
0.6.0
+32
-2
dist/da-admin/client.d.ts

@@ -10,3 +10,3 @@ /**

*/
import type { DaClientOptions, DaSourceEntry, DaSourceContent, DaVersion, DaOperationResponse } from './types.js';
import type { DaClientOptions, DaSourceEntry, DaSourceContent, DaVersion, DaOperationResponse, DaDocument, DaExportResult, DaPushResult } from './types.js';
/** Friendly message shown when a DA operation is attempted without a token. */

@@ -26,4 +26,13 @@ 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.";

get hasToken(): boolean;
/** List sources/directories under a path — GET /list/{org}/{repo}[/{path}]. */
/**
* List sources/directories under a path — GET /list/{org}/{repo}[/{path}].
*
* DA paginates the listing (S3's 1000-key default) via the
* `da-continuation-token` header, in and out. We follow it to completion so
* large folders aren't silently truncated (which would make an export report
* "complete" while missing files).
*/
listSources(path?: string): Promise<DaSourceEntry[]>;
/** True when a listing entry is a file (has a file extension); DA folders have none. */
private hasExtension;
/** Remove the leading /{org}/{repo} from a DA-returned path. */

@@ -48,2 +57,23 @@ private stripSitePrefix;

/**
* Export a whole DA subtree in one call: recursively list every document
* under `path` and fetch its source concurrently. This is the agent-native
* "clone" read — one call instead of the agent orchestrating N list+get
* calls. Bounded by `maxFiles` (flags `truncated`) and resilient to
* individual fetch failures (reported in `failed`, never dropped silently).
*/
exportTree(path: string, options?: {
maxFiles?: number;
concurrency?: number;
}): Promise<DaExportResult>;
/**
* Push many documents back to DA in one call, concurrently. The "push" half
* of the bulk model. Returns per-document succeeded/failed (partial failures
* never abort the whole batch).
*/
pushDocuments(documents: DaDocument[], options?: {
concurrency?: number;
}): Promise<DaPushResult>;
/** Run `fn` over `items` with at most `concurrency` in flight at once. */
private mapWithConcurrency;
/**
* Normalize a folder/collection path: drop empty segments (leading, trailing,

@@ -50,0 +80,0 @@ * and internal `//`), reject traversal, and percent-encode each segment.

@@ -41,21 +41,47 @@ /**

// -------------------------------------------------------------------------
/** List sources/directories under a path — GET /list/{org}/{repo}[/{path}]. */
/**
* List sources/directories under a path — GET /list/{org}/{repo}[/{path}].
*
* DA paginates the listing (S3's 1000-key default) via the
* `da-continuation-token` header, in and out. We follow it to completion so
* large folders aren't silently truncated (which would make an export report
* "complete" while missing files).
*/
async listSources(path) {
const suffix = path ? `/${this.normalizePath(path)}` : '';
const res = await this.request(`/list/${this.org}/${this.repo}${suffix}`, {
method: 'GET',
});
const body = (await res.json().catch(() => null));
// The API may return a bare array or a { sources: [...] } wrapper.
let entries = [];
if (Array.isArray(body)) {
entries = body;
const endpoint = `/list/${this.org}/${this.repo}${suffix}`;
const all = [];
let token = null;
// Safety cap so a misbehaving token loop can't run forever (~50k entries).
for (let page = 0; page < 50; page++) {
const res = await this.request(endpoint, {
method: 'GET',
headers: token ? { 'da-continuation-token': token } : undefined,
});
const body = (await res.json().catch(() => null));
// The API may return a bare array or a { sources: [...] } wrapper.
let entries = [];
if (Array.isArray(body)) {
entries = body;
}
else if (body && typeof body === 'object' && Array.isArray(body.sources)) {
entries = body.sources;
}
// DA list paths include the /{org}/{repo} prefix; strip it so a listed
// path is site-relative and can be passed back to get_source/put_source.
for (const e of entries)
all.push({ ...e, path: this.stripSitePrefix(e.path) });
token = res.headers.get('da-continuation-token') || null;
if (!token)
break;
}
else if (body && typeof body === 'object' && Array.isArray(body.sources)) {
entries = body.sources;
}
// DA list paths include the /{org}/{repo} prefix; strip it so a listed path
// is site-relative and can be passed straight back to get_source/put_source.
return entries.map((e) => ({ ...e, path: this.stripSitePrefix(e.path) }));
return all;
}
/** True when a listing entry is a file (has a file extension); DA folders have none. */
hasExtension(entry) {
if (typeof entry.ext === 'string' && entry.ext.length > 0)
return true;
const last = (entry.path ?? '').split('/').pop() ?? '';
return last.lastIndexOf('.') > 0;
}
/** Remove the leading /{org}/{repo} from a DA-returned path. */

@@ -139,2 +165,117 @@ stripSitePrefix(path) {

// -------------------------------------------------------------------------
// Bulk content operations (the agent-native "clone" model, ADR-008)
// -------------------------------------------------------------------------
/**
* Export a whole DA subtree in one call: recursively list every document
* under `path` and fetch its source concurrently. This is the agent-native
* "clone" read — one call instead of the agent orchestrating N list+get
* calls. Bounded by `maxFiles` (flags `truncated`) and resilient to
* individual fetch failures (reported in `failed`, never dropped silently).
*/
async exportTree(path, options = {}) {
const maxFiles = options.maxFiles ?? 100;
const concurrency = options.concurrency ?? 6;
// Only descend into folders under the requested root, so a stray or
// self-referential listing entry can't walk us out of the subtree.
const rootNorm = path.replace(/^\/+|\/+$/g, '');
const rootPrefix = rootNorm === '' ? '/' : `/${rootNorm}/`;
const inTree = (p) => rootNorm === '' || p === `/${rootNorm}` || p.startsWith(rootPrefix);
const dirKey = (p) => p.replace(/^\/+|\/+$/g, '');
// Breadth-first discovery of file paths (folders end with '/'). A `visited`
// set defends against cycles/duplicates; folder listings are guarded so one
// unreadable folder is recorded and skipped, not fatal to the whole export.
const files = [];
const failed = [];
const visited = new Set();
const queue = [path];
let skippedFile = false;
while (queue.length > 0 && files.length < maxFiles) {
const dir = queue.shift();
const key = dirKey(dir);
if (visited.has(key))
continue;
visited.add(key);
let entries;
try {
entries = await this.listSources(dir);
}
catch (error) {
failed.push({ path: dir, error: error instanceof Error ? error.message : String(error) });
continue;
}
for (const entry of entries) {
if (!entry.path)
continue;
// DA marks folders by the ABSENCE of a file extension (verified against
// adobe/da-admin formatList: CommonPrefixes → { path, name } with no
// `ext`; folder paths have no trailing slash). A trailing-slash check
// would never match a real folder and silently flatten the export.
const isFolder = !this.hasExtension(entry);
if (isFolder) {
if (!visited.has(dirKey(entry.path)) && inTree(entry.path)) {
queue.push(entry.path);
}
}
else if (!inTree(entry.path)) {
// A file outside the requested subtree (shouldn't happen, but the
// containment invariant applies to files too, not just folders).
continue;
}
else if (files.length < maxFiles) {
files.push(entry.path);
}
else {
skippedFile = true;
}
}
}
// Truncated only when we genuinely stopped short: a file was skipped, or we
// hit the cap with folders still unexplored.
const truncated = skippedFile || (files.length >= maxFiles && queue.length > 0);
const documents = [];
await this.mapWithConcurrency(files, async (filePath) => {
try {
const src = await this.getSource(filePath);
documents.push({ path: src.path, content: src.content, contentType: src.contentType });
}
catch (error) {
failed.push({ path: filePath, error: error instanceof Error ? error.message : String(error) });
}
}, concurrency);
return { documents, fileCount: files.length, truncated, failed };
}
/**
* Push many documents back to DA in one call, concurrently. The "push" half
* of the bulk model. Returns per-document succeeded/failed (partial failures
* never abort the whole batch).
*/
async pushDocuments(documents, options = {}) {
const concurrency = options.concurrency ?? 6;
const succeeded = [];
const failed = [];
await this.mapWithConcurrency(documents, async (doc) => {
try {
await this.putSource(doc.path, doc.content, doc.contentType);
succeeded.push(doc.path);
}
catch (error) {
failed.push({ path: doc.path, error: error instanceof Error ? error.message : String(error) });
}
}, concurrency);
return { succeeded, failed };
}
/** Run `fn` over `items` with at most `concurrency` in flight at once. */
async mapWithConcurrency(items, fn, concurrency) {
let next = 0;
const worker = async () => {
while (next < items.length) {
const index = next;
next += 1;
await fn(items[index]);
}
};
const size = Math.max(1, Math.min(concurrency, items.length));
await Promise.all(Array.from({ length: size }, () => worker()));
}
// -------------------------------------------------------------------------
// Helpers

@@ -141,0 +282,0 @@ // -------------------------------------------------------------------------

@@ -62,1 +62,34 @@ /**

}
/** A single authored document (path + its source content). */
export interface DaDocument {
/** Site-relative document path (e.g. /blog/post.html). */
path: string;
/** Raw source content. */
content: string;
/** MIME type, when known. */
contentType?: string;
}
/** Result of a bulk export of a DA subtree. */
export interface DaExportResult {
/** Every document fetched successfully under the exported path. */
documents: DaDocument[];
/** Number of files attempted (bounded by `maxFiles`), not necessarily all that exist. */
fileCount: number;
/** True when the subtree exceeded the `maxFiles` cap — some files omitted. */
truncated: boolean;
/** Files discovered but not fetchable (e.g. deleted mid-export), not dropped silently. */
failed: Array<{
path: string;
error: string;
}>;
}
/** Result of a bulk push of many documents. */
export interface DaPushResult {
/** Paths written successfully. */
succeeded: string[];
/** Paths that failed, with the error message. */
failed: Array<{
path: string;
error: string;
}>;
}

@@ -17,3 +17,6 @@ #!/usr/bin/env node

* EDS_DOMAIN_KEY — OpTel domain key for CWV / 404 / experiment queries
* EDS_DA_TOKEN — Document Authoring IMS token (enables the eds_da_* tools)
* EDS_DA_ORG — DA org (default: EDS_OWNER)
* EDS_DA_REPO — DA repo/site (default: EDS_REPO)
*/
export {};

@@ -17,2 +17,5 @@ #!/usr/bin/env node

* EDS_DOMAIN_KEY — OpTel domain key for CWV / 404 / experiment queries
* EDS_DA_TOKEN — Document Authoring IMS token (enables the eds_da_* tools)
* EDS_DA_ORG — DA org (default: EDS_OWNER)
* EDS_DA_REPO — DA repo/site (default: EDS_REPO)
*/

@@ -109,2 +112,3 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

' EDS_DOMAIN_KEY OpTel domain key for analytics queries',
' EDS_DA_TOKEN Document Authoring token (enables the eds_da_* tools)',
'',

@@ -111,0 +115,0 @@ ].join('\n'));

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

}>;
export declare function handleDaExport(client: DaClient, args: {
path: string;
maxFiles?: number;
}): Promise<{
content: {
type: "text";
text: string;
}[];
}>;
export declare function handleDaPush(client: DaClient, args: {
documents: Array<{
path: string;
content: string;
contentType?: string;
}>;
}): Promise<{
content: {
type: "text";
text: string;
}[];
}>;
export declare function handleDaGetVersions(client: DaClient, args: {

@@ -62,0 +83,0 @@ path: string;

@@ -25,5 +25,7 @@ /**

for (const e of entries) {
// Paths are site-relative and already carry a file extension (files) or a
// trailing slash (folders), so they read cleanly as-is.
lines.push(` ${e.path ?? e.name ?? '(unnamed)'}`);
const p = e.path ?? e.name ?? '(unnamed)';
// DA folders have no extension; mark them with a trailing slash so the
// listing distinguishes folders from files at a glance.
const isFolder = !e.ext && !/\.[^/]+$/.test(p);
lines.push(` ${p}${isFolder && !p.endsWith('/') ? '/' : ''}`);
}

@@ -81,2 +83,66 @@ return textResult(lines.join('\n'));

}
export async function handleDaExport(client, args) {
try {
const result = await client.exportTree(args.path, { maxFiles: args.maxFiles });
const root = `/${args.path.replace(/^\/+/, '')}`;
if (result.documents.length === 0 && result.failed.length === 0 && !result.truncated) {
return textResult(`No documents found under ${root}.`);
}
const header = [
`Exported ${result.documents.length} document${result.documents.length === 1 ? '' : 's'} from ${root}.`,
];
if (result.truncated) {
header.push(`(May be incomplete — hit the maxFiles cap with folders left. Narrow the path or raise maxFiles.)`);
}
if (result.failed.length > 0) {
header.push(`(${result.failed.length} item(s) could not be read: ${result.failed.map((f) => f.path).join(', ')})`);
}
// Bound the response size: inline document content up to a byte budget, then
// list the remaining paths so the agent knows they exist (fetch individually).
const MAX_OUTPUT_CHARS = 800_000;
const blocks = [];
const omitted = [];
let used = header.join('\n').length;
for (const d of result.documents) {
const block = `=== ${d.path} ===\n${d.content}`;
if (blocks.length === 0 || used + block.length <= MAX_OUTPUT_CHARS) {
blocks.push(block);
used += block.length + 1;
}
else {
omitted.push(d.path);
}
}
if (omitted.length > 0) {
const shown = omitted.slice(0, 30).join(', ');
header.push(`(${omitted.length} document(s) omitted from this response to stay within size limits — fetch individually with eds_da_get_source: ${shown}${omitted.length > 30 ? ', …' : ''})`);
}
return textResult([...header, '', ...blocks].join('\n'));
}
catch (error) {
return errorResult(error);
}
}
export async function handleDaPush(client, args) {
try {
const result = await client.pushDocuments(args.documents);
const lines = [
`Pushed ${result.succeeded.length} document${result.succeeded.length === 1 ? '' : 's'}; ${result.failed.length} failed.`,
];
if (result.succeeded.length > 0) {
lines.push('', 'Succeeded:');
for (const p of result.succeeded)
lines.push(` ✓ ${p}`);
}
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) {

@@ -83,0 +149,0 @@ try {

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

@@ -248,3 +248,36 @@ * first-party MCP servers.

}, async (args) => daHandlers.handleDaGetVersions(daClient, args));
server.tool('eds_da_export', 'Bulk-export a whole Document Authoring subtree in one call: recursively fetch every document under a path and return all their sources together. The efficient "clone" read for operating on many pages at once (vs. one get per page). Requires EDS_DA_TOKEN.', {
path: z
.string()
.refine((v) => !v.split('/').some((s) => {
let decoded;
try {
decoded = decodeURIComponent(s);
}
catch {
decoded = s;
}
return decoded === '..' || decoded === '.';
}), { message: 'Path must not contain traversal segments (.. or .)' })
.describe('DA folder path to export (e.g., "blog"); use "" for the whole site'),
maxFiles: z
.number()
.int()
.min(1)
.max(1000)
.optional()
.describe('Maximum documents to fetch (default 100); result flags truncation if exceeded'),
}, 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.', {
documents: z
.array(z.object({
path: daSourcePath.describe('DA document path to write'),
content: z.string().describe('The full source content (typically HTML)'),
contentType: z.string().optional().describe('MIME type (default: text/html)'),
}))
.min(1)
.max(1000)
.describe('The documents to write back'),
}, async (args) => daHandlers.handleDaPush(daClient, args));
return server;
}
{
"name": "@focusgts/eds-mcp-server",
"version": "0.5.0",
"version": "0.6.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">

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

@@ -34,2 +34,3 @@

> *"Find every page about pricing and list the ones missing a description."*
> *"Export the whole `/blog` folder, fix every heading, and push it back."*

@@ -44,9 +45,11 @@ That's it — no local AEM, no scripts, no glue code.

flowchart LR
A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>28 tools"]
A["AI agent<br/>(Claude Code · Cursor · Copilot)"] -- MCP / stdio --> B["eds-mcp-server<br/>30 tools"]
B --> C["Admin API<br/>admin.hlx.page"]
B --> D["Content API<br/>*.aem.live"]
B --> E["RUM / OpTel<br/>Core Web Vitals"]
B --> G["Document Authoring<br/>admin.da.live"]
C --> F["Your EDS site"]
D --> F
E --> F
G --> F
```

@@ -78,4 +81,6 @@

## 🛠️ The 28 tools
## 🛠️ The 30 tools
### Edge Delivery Services — publish, content, analytics
<table>

@@ -118,5 +123,33 @@ <tr><td valign="top" width="33%">

**Document Authoring (DA)** — direct access to the authored source, not the rendered output (requires `EDS_DA_TOKEN`):
`eds_da_list_sources` · `eds_da_get_source` · `eds_da_put_source` · `eds_da_delete_source` · `eds_da_copy_source` · `eds_da_move_source` · `eds_da_get_versions`
### Document Authoring (DA) — the authored *source*, not the rendered output
Nine tools reach a site's Document Authoring source directly (`admin.da.live`), the source of truth behind an EDS site. Requires `EDS_DA_TOKEN`.
<table>
<tr><td valign="top" width="33%">
**Read**
- `eds_da_list_sources`
- `eds_da_get_source`
- `eds_da_get_versions`
</td><td valign="top" width="33%">
**Write**
- `eds_da_put_source`
- `eds_da_delete_source`
- `eds_da_copy_source`
- `eds_da_move_source`
</td><td valign="top" width="33%">
**Bulk ("clone")**
- `eds_da_export`
- `eds_da_push`
</td></tr>
</table>
> **`eds_da_export` / `eds_da_push`** bring the efficiency of `aem content clone` to agents: export a whole DA subtree in **one** call, operate on it, and push the batch back in **one** call — no local checkout, no `aem-cli`. Same model, network-native.
>
> `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`).

@@ -179,3 +212,3 @@

| `EDS_DOMAIN_KEY` | No | OpTel domain key for analytics queries (CWV, 404s, experiments) |
| `EDS_DA_TOKEN` | No | Document Authoring API token — enables the `eds_da_*` source tools |
| `EDS_DA_TOKEN` | No | Document Authoring IMS access token — enables the `eds_da_*` source & bulk tools |
| `EDS_DA_ORG` | No | DA org (defaults to `EDS_OWNER`) |

@@ -182,0 +215,0 @@ | `EDS_DA_REPO` | No | DA repo/site (defaults to `EDS_REPO`) |