assets-cdn-mcp
by ChepelevIgor
README.md
# assets-cdn
> Content-hashed asset uploads for any S3-compatible CDN — with typed access, safe partial uploads, and deferred deletion.
[](https://github.com/chepelevigor/assets-cdn/actions/workflows/ci.yml)
[](./LICENSE)
[](package.json)
`assets-cdn` uploads your static assets (images, video, fonts, …) to any
S3-compatible bucket that backs a CDN — **AWS S3, Cloudflare R2, Tigris, MinIO** —
and gives every file a content hash in its name:
```
img/logo.png → img/logo.ab12cd34ef56.png
```
Because the URL changes whenever the bytes change, every asset can be served
with `Cache-Control: public, max-age=31536000, immutable` and cached forever —
no cache-busting query strings, no stale images after a deploy.
It also generates a typed `assets.ts` so your app references assets by their
**logical** name and always gets the current hashed URL:
```ts
import { asset } from "./assets";
asset("img/logo.png"); // → https://cdn.example.com/assets/img/logo.ab12cd34ef56.png
// ^^^^^^^^^^^^^^ autocompleted & type-checked; a typo won't compile
```
## Why
- **Immutable caching, done right.** Hashed names make `max-age` forever safe.
- **Safe partial uploads.** Uploading one source folder never deletes assets it
doesn't own; only new/changed files are pushed.
- **Race-safe manifest.** The `file-map.json` is published with compare-and-swap
(`If-Match`/`If-None-Match`), so two CI runners deploying at once merge instead
of clobbering each other — a 412 just triggers a re-read and retry.
- **Fast & resilient.** Uploads run with bounded concurrency and retry transient
errors with exponential backoff; `--verify` confirms every object landed.
- **Deferred deletion.** Retired versions are tagged `pending-delete` with a
grace window (default 7 days) so a rollback still has its old assets — then
purged by `cleanup-pending`.
- **Typed access.** `assets.ts` turns asset keys into a compile-time contract.
- **Responsive images.** Optionally transcode images to AVIF/WebP at multiple
widths on upload and get typed `srcset` helpers — see below.
- **Recoverable.** Lost your local map? `rebuild-from-cdn` reconstructs it from
what's already published.
## Install
```bash
npm install -g assets-cdn
# or run without installing:
npx assets-cdn --help
```
## Configuration
Copy `.env.example` to `.env` and fill in your bucket credentials:
```env
STATIC_BUCKET_NAME=my-bucket
STATIC_CDN_BASE=https://cdn.example.com
STATIC_AWS_ENDPOINT_URL_S3=https://s3.example.com # omit for AWS S3
STATIC_AWS_ACCESS_KEY_ID=your-access-key-id
STATIC_AWS_SECRET_ACCESS_KEY=your-secret-access-key
AWS_REGION=auto # "auto" for R2/Tigris
```
Commands that touch the CDN fail fast with a clear message if a required
variable is missing.
## Quick start
```bash
# 1. Preview what would change (no writes)
assets-cdn upload-partial --src ./assets --cdn imgs --plan
# 2. Upload new/changed files, publish the map, emit typed assets.ts
assets-cdn upload-partial \
--src ./assets \
--cdn imgs \
--out ./file-map.json \
--assets-ts ./src/assets.ts
# 3. Later, purge versions whose grace window has elapsed
assets-cdn cleanup-pending --cdn imgs
```
## Responsive images
With `--images`, source images (`jpg`/`png`/`webp`) are transcoded to modern
formats at multiple widths on upload, content-hashed, and recorded in the
manifest. The generated `assets.ts` then gives you typed `srcset` helpers:
```bash
assets-cdn upload-partial --src ./assets --cdn imgs \
--images --image-formats avif,webp --image-widths 640,1280,1920 \
--assets-ts ./src/assets.ts
```
```tsx
import { imageSrcSet, imageSources, IMAGES } from "./assets";
// A single format as srcset:
<img
srcSet={imageSrcSet("hero.jpg", "avif")}
width={IMAGES["hero.jpg"].width} // intrinsic size → no layout shift
height={IMAGES["hero.jpg"].height}
/>;
// Or a full <picture> with avif → webp fallback:
<picture>
{imageSources("hero.jpg").map((s) => (
<source key={s.type} type={s.type} srcSet={s.srcset} />
))}
<img src={/* fallback */ IMAGES["hero.jpg"].fallback} />
</picture>;
```
Image processing uses [`sharp`](https://sharp.pixelplumbing.com), an **optional
dependency** — it's only needed when you pass `--images`. Variants never upscale
past the source width, output is deterministic (metadata stripped, EXIF
orientation baked in), and each variant gets an immutable cache header.
> Scoped for v1: AVIF/WebP at configurable widths, original fallback, intrinsic
> dimensions. Not yet included: `<picture>` art-direction crops, LQIP/blur
> placeholders, and animated GIF → video.
## Vite plugin
Upload assets automatically as part of `vite build` — no separate CLI step:
```ts
// vite.config.ts
import { defineConfig } from "vite";
import assetsCdn from "assets-cdn/vite";
export default defineConfig({
plugins: [
assetsCdn({
src: "src/assets", // defaults to Vite's build.outDir
cdn: "imgs",
assetsTs: "src/assets.ts", // regenerate the typed manifest on each build
enabled: !!process.env.CI, // e.g. only upload in CI
}),
],
});
```
The plugin runs on `closeBundle` (build only), reuses the same safe partial
upload — content hashing, race-safe manifest, deferred deletion — and reads the
bucket from the standard `STATIC_*` environment variables. All CLI options
(`dryRun`, `concurrency`, `verify`, `pendingDays`, …) are accepted.
## Commands
| Command | Description |
| ------------------ | ---------------------------------------------------------------------------- |
| `upload-partial` | Upload new/changed assets, publish the file-map, mark retired versions |
| `cleanup-pending` | Permanently delete objects whose `pending-delete` window has passed |
| `rebuild-from-cdn` | Rebuild `file-map.json` (and `assets.ts`) from objects already on the CDN |
| `sync-fix-headers` | Repair `Content-Type` / `Cache-Control` headers on the CDN |
| `delete` | Delete CDN objects that no longer exist locally |
| `upload-json` | Upload a local `file-map.json` to the CDN |
| `download-json` | Download `file-map.json` from the CDN |
| `generate-assets` | Generate `assets.ts` from a local `file-map.json` (offline, no CDN needed) |
## Options
| Option | Description | Default |
| ------------------- | ------------------------------------------------------- | -------------------- |
| `--src <path>` | Source directory (or JSON path for `generate-assets`) | `assets-to-upload` |
| `--out <path>` | Output path for `file-map.json` | `file-map.json` |
| `--cdn <prefix>` | Key prefix on the CDN | `assets` |
| `--assets-ts <path>`| Also emit a typed `assets.ts` module at this path | — |
| `--pending-days <n>`| Grace period before `pending-delete` purge | `7` |
| `--concurrency <n>` | Max concurrent S3 requests | `16` |
| `--verify` | After upload, confirm objects exist with expected size | `false` |
| `--images` | Generate responsive image variants (needs `sharp`) | `false` |
| `--image-widths <l>`| Comma-separated widths for variants | `640,1280,1920` |
| `--image-formats <l>`| Comma-separated formats (`avif`,`webp`,`jpeg`,`png`) | `avif,webp` |
| `--plan` | Show the execution plan (diff only), make no changes | `false` |
| `--dry-run` | Simulate all actions without uploading/deleting | `false` |
| `--json` | Emit a machine-readable JSON result (for agents/CI) | `false` |
| `-v, --version` | Show version | — |
| `-h, --help` | Show help | — |
## For AI agents & CI
`assets-cdn` is built to be driven by machines, not just humans.
### Machine-readable output
Add `--json` to any command and it prints a single JSON object to stdout (all
human logs are suppressed) with a stable exit code — `0` on success, `1` on
error (the error is emitted as `{"error": "..."}` on stderr):
```bash
assets-cdn upload-partial --src ./assets --cdn imgs --plan --json
# {"command":"upload-partial","dryRun":false,"uploaded":["imgs/logo.ab12.png"],
# "pendingDelete":[],"kept":4,"out":"file-map.json","planned":true}
assets-cdn upload-partial --src ./assets --json | jq '.uploaded | length'
```
### MCP server (Claude Code, Claude Desktop, Cursor…)
The package ships an [MCP](https://modelcontextprotocol.io) server so AI agents
can call every command as a tool (`upload_partial`, `cleanup_pending`,
`rebuild_from_cdn`, `sync_fix_headers`, `delete_stale`, `upload_json`,
`download_json`, `generate_assets`). Register it:
```jsonc
// claude_desktop_config.json (or .mcp.json / .cursor/mcp.json)
{
"mcpServers": {
"assets-cdn": {
"command": "npx",
"args": ["-y", "assets-cdn-mcp"],
"env": {
"STATIC_BUCKET_NAME": "my-bucket",
"STATIC_CDN_BASE": "https://cdn.example.com",
"STATIC_AWS_ENDPOINT_URL_S3": "https://s3.example.com",
"STATIC_AWS_ACCESS_KEY_ID": "…",
"STATIC_AWS_SECRET_ACCESS_KEY": "…",
"AWS_REGION": "auto"
}
}
}
}
```
Then just ask: *"upload the assets in ./dist to the `imgs` prefix and show me
what changed"* — the agent calls `upload_partial` and reads the structured
result back.
## How partial upload works
```text
local files ──generate──▶ patch map ──┐
├─ diff vs. published file-map
CDN file-map ────────────────────────┘
│
┌─────────────────────────────────┴─────────────────────────────┐
│ new / changed → UPLOAD unchanged → KEEP │
│ superseded → mark pending-delete (delete-after = +N days) │
│ CDN-only → left untouched │
└───────────────────────────────────────────────────────────────┘
│
cleanup-pending ───┘ (deletes once delete-after passes)
```
The upload never removes an object just because it is absent locally — only the
*previous version of a file you re-uploaded* is retired, and even then only
after the grace window. That makes deploys safe to roll back.
The manifest itself is published with **compare-and-swap**: `assets-cdn` re-reads
the currently published `file-map.json`, merges this run's entries into it, and
writes conditionally on the object's ETag. If another deploy wrote in between, the
conditional write fails with `412` and the merge is retried — so parallel deploys
never lose each other's assets. (Requires an S3 provider with conditional writes:
AWS S3, Cloudflare R2, Tigris and MinIO all support them.)
## Development
```bash
pnpm install
pnpm test # fast unit/mock suites (vitest, no Docker)
pnpm test:integration # end-to-end against a real MinIO container (needs Docker)
pnpm typecheck # tsc --noEmit
pnpm build # bundle to dist/ via tsup
pnpm dev <cmd> # run the CLI from source (tsx)
```
- [`src/core.ts`](src/core.ts) — pure logic (hashing, name ↔ hash conversion,
diffing, header rules), no I/O.
- [`src/index.ts`](src/index.ts) — S3/IO orchestration; each command returns a
structured `CommandResult`.
- [`src/cli.ts`](src/cli.ts) — CLI entry.
- [`src/mcp.ts`](src/mcp.ts) — MCP server exposing the commands as agent tools.
Tests cover the pure logic ([`core.test.ts`](test/core.test.ts)), the S3 command
paths against a mocked S3 ([`commands.test.ts`](test/commands.test.ts)), the
`--json` contract ([`json.test.ts`](test/json.test.ts)), the MCP server over an
in-memory transport ([`mcp.test.ts`](test/mcp.test.ts)), and full end-to-end runs
against a real MinIO container
([`test/integration/minio.test.ts`](test/integration/minio.test.ts)) — including
the compare-and-swap manifest under genuine concurrent deploys.
## License
[MIT](./LICENSE) © Igor Chepelev
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues