← ACE Carousel CreatorCONTENT HISTORY

Update to ACE Carousel Creator

Snapshot Nov 6, 2026 · 00:15 UTC · version 0.2.0

WHAT CHANGED

Package or technical metadata updated

Package or technical metadata updated

Observed in package metadata. These changes alone do not establish a new customer-facing feature.

Save ACE Carousel Creator and check future changes in Following.

Up to 3 plugins free · no card required.

All fields and original values.

These are saved file metadata. Open a changed file to read its text edits.

Package file

Before

Oct 24, 2026 · 13:48 UTC

Not present

After

Nov 6, 2026 · 00:15 UTC

{"sha256":"5f2025d9087cd8dc93ebe395791fda81379d9a438862e6e87a69b5faf4cc2b77","size":47}

Package file

Before

Oct 24, 2026 · 13:48 UTC

Not present

After

Nov 6, 2026 · 00:15 UTC

{"sha256":"fd48d4fdbb81718d2dfbffa6314dee7c56d042aa5dd96d34d45ee3b38ba91e49","size":315}

Package file

Before

Oct 24, 2026 · 13:48 UTC

Not present

After

Nov 6, 2026 · 00:15 UTC

{"sha256":"29942ea87db4340c9a272091ada1b037d8b8b02a2e76dce75509cb8a1e418059","size":2313}

Package file

Before

Oct 24, 2026 · 13:48 UTC

Not present

After

Nov 6, 2026 · 00:15 UTC

{"sha256":"2014b20842e56188734f68b1490b3cbb5a8dd5365993de5a9935fdc353ff30fc","size":1112}

Compare saved observations

Download comparison JSON

src/server.mjs

− removed · + added

--- before
+++ after
@@ -4,8 +4,13 @@
 import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
 import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
 import { z } from 'zod';
-import { buildCarousel, imageLimit } from './archive.mjs';
-import { DocumentError, getSpec, parseLayout, referencedAssets, validateDocument } from './document.mjs';
+import { buildCarousel, imageLimit, photoFormat } from './archive.mjs';
+import { DocumentError, parseLayout, referencedAssets, validateDocument } from './document.mjs';
+
+import { getSpecResponse, designGuide, packageVersion, capabilities } from './spec.mjs';
+import { categories } from './catalog.mjs';
+import { diagnoseDocument } from './diagnostics.mjs';
+import { nativeRenderer } from './renderer.mjs';
 
 const fileInput = z.object({ download_url: z.string().url(), file_id: z.string().min(1), mime_type: z.string().optional(), file_name: z.string().optional(), asset_name: z.string().min(1).max(128).optional() }).strict();
 const fileName = name => (name.normalize('NFC').replace(/[\u0000-\u001f\u007f/\\<>:"|?*]/g, '-').replace(/\.carousel$/i, '').trim().slice(0, 64) || 'carousel') + '.carousel';
@@ -18,18 +23,71 @@
   prune() {
     for (const [id, entry] of this.files) if (entry.expires <= this.now()) this.files.delete(id);
   }
-  add(name, bytes) {
+  add(name, bytes) { return this.addMany([{ name, bytes, mimeType: 'application/octet-stream' }])[0]; }
+  addMany(items) {
     this.prune();
     const total = [...this.files.values()].reduce((n, f) => n + f.bytes.byteLength, 0);
-    if (total + bytes.byteLength > this.capacity || this.files.size >= 200) throw new DocumentError('Temporary storage is full. Wait for old download links to expire.');
-    const id = randomBytes(24).toString('hex');
-    const entry = { name: fileName(name), bytes, expires: this.now() + this.ttl };
-    this.files.set(id, entry);
-    return { id, ...entry };
+    if (total + items.reduce((n, item) => n + item.bytes.byteLength, 0) > this.capacity || this.files.size + items.length > 200) throw new DocumentError('Temporary storage is full. Wait for old download links to expire.');
+    const prepared = items.map(({ name, bytes, mimeType }) => {
+      if (!['image/png', 'application/octet-stream'].includes(mimeType)) throw new DocumentError('Unsupported artifact type');
+      const safeName = mimeType === 'image/png' ? fileName(name.replace(/\.png$/i, '')).replace(/\.carousel$/, '.png') : fileName(name);
+      return { id: randomBytes(24).toString('hex'), name: safeName, bytes, mimeType, expires: this.now() + this.ttl };
+    });
+    for (const { id, ...entry } of prepared) this.files.set(id, entry);
+    return prepared;
   }
   get(id) { this.prune(); return this.files.get(id); }
 }
 
+class UploadError extends DocumentError {
+  constructor(message, status) { super(message); this.status = status; }
+}
+
+// One-time links for photographs that exist only on the user's computer. Bytes are only packaged into projects, never served back.
+export class UploadStore {
+  constructor({ ttl = 3_600_000, capacity = 256 * 1024 * 1024, limit = imageLimit, now = Date.now } = {}) {
+    this.ttl = ttl; this.capacity = capacity; this.limit = limit; this.now = now; this.slots = new Map();
+  }
+  prune() {
+    for (const [id, slot] of this.slots) if (slot.expires <= this.now()) this.slots.delete(id);
+  }
+  reserve(names) {
+    this.prune();
+    if (new Set(names).size !== names.length) throw new DocumentError('asset_names: each photograph needs a distinct asset_name; duplicate names');
+    if (this.slots.size + names.length > 200) throw new DocumentError('Temporary storage is full. Wait for old upload links to expire.');
+    const expires = this.now() + this.ttl;
+    return names.map(name => {
+      const id = randomBytes(24).toString('hex');
+      this.slots.set(id, { name, expires, bytes: null });
+      return { id, name, expires };
+    });
+  }
+  async receive(id, bytes) {
+    const slot = this.get(id);
+    if (!slot) throw new UploadError('Upload link expired or not found; call prepare_photo_upload again', 404);
+    if (slot.bytes) throw new UploadError('This upload link was already used; call prepare_photo_upload for a new one', 409);
+    if (bytes.byteLength > this.limit) throw new UploadError(`Photograph exceeds ${this.limit} bytes`, 413);
+    const stored = [...this.slots.values()].reduce((n, entry) => n + (entry.bytes?.byteLength ?? 0), 0);
+    if (stored + bytes.byteLength > this.capacity) throw new UploadError('Temporary storage is full. Try again later.', 507);
+    const format = await photoFormat(bytes);
+    if (!format) throw new UploadError('Send one still photograph per link: JPEG, PNG, WebP or AVIF', 415);
+    // Another request may have filled or expired the slot while the header was read.
+    if (slot.bytes || this.get(id) !== slot) throw new UploadError('This upload link was already used; call prepare_photo_upload for a new one', 409);
+    slot.bytes = bytes;
+    return { upload_id: id, asset_name: slot.name, format, bytes: bytes.byteLength };
+  }
+  get(id) { this.prune(); return this.slots.get(id); }
+}
+
+const readUpload = (request, limit) => new Promise((resolve, reject) => {
+  const tooLarge = () => new UploadError(`Photograph exceeds ${limit} bytes`, 413);
+  if (Number(request.headers['content-length']) > limit) { reject(tooLarge()); return; }
+  const chunks = []; let length = 0;
+  request.on('data', chunk => { length += chunk.byteLength; if (length <= limit) chunks.push(chunk); });
+  request.on('end', () => length > limit ? reject(tooLarge()) : resolve(Buffer.concat(chunks)));
+  request.on('error', reject);
+});
+
 export async function downloadImage(file, fetcher = fetch) {
   const url = new URL(file.download_url);
   const allowed = url.hostname === 'chatgpt.com' || url.hostname === 'oaiusercontent.com' || url.hostname.endsWith('.oaiusercontent.com');
@@ -54,53 +112,116 @@
   return Buffer.concat(chunks);
 }
 
-export function createMCPServer({ publicURL, store = new ArtifactStore(), download = downloadImage } = {}) {
+export function createMCPServer({ publicURL, store = new ArtifactStore(), uploads = new UploadStore(), download = downloadImage, renderer = null } = {}) {
   const url = new URL(publicURL);
   if (url.username || url.password || url.search || url.hash || !['https:', 'http:'].includes(url.protocol) || (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('CAROUSEL_PUBLIC_URL must be an HTTPS origin, or localhost for development');
   const baseURL = url.href.replace(/\/$/, '');
-  const server = new McpServer({ name: 'carousel-creator', version: '0.1.0' }, {
-    instructions: 'Create editable projects for ACE: Aesthetic Carousel Editor. First read get_carousel_spec. Write the copy and native layout yourself, then call create_carousel to validate and package a downloadable .carousel file. Use only real catalog names and supplied images. Preserve explicit line breaks; text never auto-wraps. Return the download link and tell the user to import it from Projects or Templates in ACE. Preserve the language requested by the user for carousel content. Schema checks do not confirm visual appearance.'
+  const versions = { ...capabilities, native_renderer: Boolean(renderer), renderer: renderer?.metadata ?? null, preview_formats: renderer ? ['image/png'] : [], photo_uploads: true };
+  const artifactURL = entry => `${baseURL}/downloads/${entry.id}/${encodeURIComponent(entry.name)}`;
+  const buildInput = async (yaml, asset_files = [], photo_uploads = []) => {
+    const draft = validateDocument(parseLayout(yaml), { allowUnresolvedAssets: true });
+    const images = new Map(), used = referencedAssets(draft);
+    const uploaded = photo_uploads.map(id => {
+      const slot = uploads.get(id);
+      if (!slot) throw new DocumentError(`photo_uploads: upload ${id} expired or was never prepared; call prepare_photo_upload again`);
+      if (!slot.bytes) throw new DocumentError(`photo_uploads: no file was sent for ${slot.name}; PUT the photograph to its upload_url first`);
+      return [slot.name, slot.bytes];
+    });
+    const names = [...asset_files.map((file, index) => file.asset_name ?? `photo_${index + 1}`), ...uploaded.map(([name]) => name)];
+    const duplicate = names.find((name, index) => names.indexOf(name) !== index);
+    if (duplicate !== undefined) throw new DocumentError(`asset_files/photo_uploads: duplicate asset_name ${duplicate}`);
+    for (const [name, bytes] of uploaded) if (used.has(name)) images.set(name, bytes);
+    for (const [index, file] of asset_files.entries()) if (used.has(names[index])) images.set(names[index], await download(file));
+    return buildCarousel(yaml, images);
+  };
+  const uploadInput = z.array(z.string().regex(/^[a-f0-9]{48}$/)).max(20).optional();
+  const renderPreview = async (project, width) => {
+    const result = await renderer.render(project.bytes, { width, slideCount: project.document.slides });
+    const entries = store.addMany([
+      { name: 'preview.png', bytes: result.sheet, mimeType: 'image/png' },
+      ...result.slides.map(slide => ({ name: slide.file, bytes: slide.bytes, mimeType: 'image/png' }))
+    ]);
+    return {
+      preview: { status: 'rendered', engine: result.engine, sheet_url: artifactURL(entries[0]), expires_at: new Date(entries[0].expires).toISOString(), warnings: result.warnings,
+        slides: result.slides.map(({ bytes, file, ...slide }, index) => ({ ...slide, url: artifactURL(entries[index + 1]) })) },
+      content: [
+        { type: 'image', mimeType: 'image/png', data: Buffer.from(result.sheet).toString('base64') },
+        ...entries.map(entry => ({ type: 'resource_link', name: entry.name, uri: artifactURL(entry), mimeType: 'image/png', description: 'Native ACE preview' }))
+      ]
+    };
+  };
+  const server = new McpServer({ name: 'carousel-creator', version: packageVersion }, {
+    instructions: 'Create editable ACE projects. Read get_carousel_spec overview, then rules and relevant catalogs/examples. When writing or revising copy, also read section: writing. Compose before create_carousel. Use real catalog IDs and supplied photographs. For photographs on the user’s computer, when you can run shell commands, call prepare_photo_upload, PUT each file to its upload_url, and pass the upload IDs as photo_uploads. Photographs attached only in a chat without file references cannot reach this server: leave intentional empty photo slots (source: null) and say that the photos are added in ACE. Preserve language and line breaks. Review warnings; do not flatten content to suppress them. When native_renderer is true, create_carousel automatically returns a native contact sheet as image content and per-slide PNG links. Display that image in the chat alongside the editable file. Inspect every slide and use render_carousel to iterate; rendering alone is not a visual review. Report preview failures instead of claiming it was checked. Import via ACE → Projects or Templates → Import.\n\n' + designGuide
   });
   server.registerTool('get_carousel_spec', {
     title: 'Carousel Format and Resources',
-    description: 'Read the native carousel format before creating a layout. rules contains layout rules and recipes; catalogs lists exact built-in stickers, frames and fonts. Supply query to search a keyword or resource name.',
-    inputSchema: { section: z.enum(['rules', 'catalogs', 'full']).default('rules'), query: z.string().max(200).default('') },
+    description: 'Read overview for design decisions and capability versions, writing before composing or revising copy (grounded angles, voice, storytelling and editorial review), rules for the native contract, catalogs for complete resources/geometry, or examples for diverse editable compositions. Catalog category avoids ambiguous substring matches; query supports exact IDs and category synonyms. Follow next_offset for more matches. Examples and schemas are not visual previews or ready-to-use copy.',
+    inputSchema: { section: z.enum(['overview', 'writing', 'rules', 'catalogs', 'examples', 'full']).default('rules'), query: z.string().max(200).default(''), category: z.enum(categories).optional(), offset: z.number().int().nonnegative().default(0), limit: z.number().int().min(1).max(50).default(12) },
     annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
-  }, async ({ section, query }) => ({ content: [{ type: 'text', text: getSpec(section, query) }] }));
+  }, async ({ section, query, ...options }) => {
+    try {
+      const result = getSpecResponse(section, query, options);
+      const { text, ...metadata } = result;
+      return { content: [{ type: 'text', text: section === 'overview' ? text + '\n\nConnected server capabilities:\n' + JSON.stringify(versions) : text }], structuredContent: { ...metadata, capabilities: versions } };
+    } catch (error) { return failure(error); }
+  });
   server.registerTool('validate_carousel', {
-    title: 'Validate Carousel', description: 'Check a completed native YAML layout without creating a download. Missing extension tags are inferred. Image-bearing layouts are checked during create_carousel after real image assets are supplied.',
-    inputSchema: { yaml: z.string().max(4 * 1024 * 1024) },
+    title: 'Validate Carousel', description: 'Validate native YAML and return actionable, non-blocking design/portability warnings. Set allow_unresolved_assets for a draft with real asset keys; the result explicitly lists unresolved references. Creation still requires real image bytes. No native visual or text measurement check is performed.',
+    inputSchema: { yaml: z.string().max(4 * 1024 * 1024), allow_unresolved_assets: z.boolean().default(false) },
     annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
-  }, async ({ yaml }) => {
+  }, async ({ yaml, allow_unresolved_assets }) => {
     try {
-      const document = validateDocument(parseLayout(yaml));
-      return { content: [{ type: 'text', text: JSON.stringify({ valid: true, slides: document.slides, layers: document.layers.length, requires: document.requires, visual_check: 'not performed' }) }] };
+      const document = validateDocument(parseLayout(yaml), { allowUnresolvedAssets: allow_unresolved_assets });
+      const unresolved_assets = [...referencedAssets(document)].filter(name => !Object.hasOwn(document.assets ?? {}, name));
+      const result = { valid: true, slides: document.slides, layers: document.layers.length, requires: document.requires, asset_references_resolved: unresolved_assets.length === 0, unresolved_assets, asset_bytes_checked: false, ...diagnoseDocument(document), visual_check: 'not performed', versions };
+      return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
     } catch (error) { return failure(error); }
   });
   server.registerTool('create_carousel', {
     title: 'Create Carousel File',
-    description: 'Package complete editable YAML into a downloadable .carousel project for ACE: Aesthetic Carousel Editor. Export the file and import it into ACE to continue editing. You must compose content and layout first using get_carousel_spec. For photographs, omit draft assets and use source keys matching asset_files[].asset_name (default photo_1, photo_2...). Only referenced images are downloaded and included; metadata is stripped and hashes generated automatically. Empty slots use source: null. This version supports still images, not video. Download links expire after one hour.',
-    inputSchema: { yaml: z.string().max(4 * 1024 * 1024), filename: z.string().min(1).max(100).default('carousel'), asset_files: z.array(fileInput).max(20).optional() },
+    description: 'Package complete editable YAML into a downloadable ACE .carousel project. When native_renderer is available, automatically return a native contact-sheet image for display in chat and PNG links for every slide. Compose content and layout first using get_carousel_spec. For photographs, omit draft assets and use source keys matching asset_files[].asset_name (default photo_1, photo_2...) or the asset_name of each upload listed in photo_uploads. Only referenced images are included; metadata is stripped and hashes generated automatically. Empty slots use source: null. Supports still images, not video. Preview failure is reported without losing the editable file. Links expire after one hour.',
+    inputSchema: { yaml: z.string().max(4 * 1024 * 1024), filename: z.string().min(1).max(100).default('carousel'), asset_files: z.array(fileInput).max(20).optional(), photo_uploads: uploadInput, preview: z.boolean().default(true), preview_width: z.number().int().min(360).max(1080).default(720) },
     annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
     _meta: { 'openai/fileParams': ['asset_files'] }
-  }, async ({ yaml, filename, asset_files = [] }) => {
+  }, async ({ yaml, filename, asset_files = [], photo_uploads = [], preview, preview_width }) => {
     try {
-      const draft = validateDocument(parseLayout(yaml), { allowUnresolvedAssets: true });
-      // Reject invalid layouts and determine usage before downloading any photographs.
-      const images = new Map();
-      const names = new Set();
-      const used = referencedAssets(draft);
-      for (const [index, file] of asset_files.entries()) {
-        const name = file.asset_name ?? `photo_${index + 1}`;
-        if (names.has(name)) throw new DocumentError(`asset_files: duplicate asset_name ${name}`);
-        names.add(name);
-        if (used.has(name)) images.set(name, await download(file));
-      }
-      const result = await buildCarousel(yaml, images);
+      const result = await buildInput(yaml, asset_files, photo_uploads);
       const entry = store.add(filename, result.bytes);
-      const downloadURL = `${baseURL}/downloads/${entry.id}/${encodeURIComponent(entry.name)}`;
-      const summary = { filename: entry.name, download_url: downloadURL, expires_at: new Date(entry.expires).toISOString(), slides: result.document.slides, layers: result.document.layers.length, bytes: result.bytes.byteLength, visual_check: 'not performed', import: 'ACE: Aesthetic Carousel Editor → Projects (or Templates) → Import → choose the downloaded .carousel file' };
-      return { content: [{ type: 'text', text: JSON.stringify(summary) }, { type: 'resource_link', name: entry.name, uri: downloadURL, mimeType: 'application/octet-stream', description: 'Editable ACE project; download and import into ACE: Aesthetic Carousel Editor' }], structuredContent: summary };
+      const downloadURL = artifactURL(entry);
+      let rendered = { preview: { status: preview ? 'unavailable' : 'skipped', reason: preview ? 'Native renderer is not configured on this server' : 'Preview was disabled by the caller' }, content: [] };
+      if (preview && renderer) {
+        try { rendered = await renderPreview(result, preview_width); }
+        catch (error) { rendered = { preview: { status: 'failed', reason: error.message }, content: [] }; }
+      }
+      const summary = { filename: entry.name, download_url: downloadURL, expires_at: new Date(entry.expires).toISOString(), slides: result.document.slides, layers: result.document.layers.length, bytes: result.bytes.byteLength, ...result.diagnostics, versions, preview: rendered.preview, visual_check: rendered.preview.status === 'rendered' ? 'native preview rendered; visual review pending' : 'not performed', import: 'ACE: Aesthetic Carousel Editor → Projects (or Templates) → Import → choose the downloaded .carousel file' };
+      return { content: [{ type: 'text', text: JSON.stringify(summary) }, { type: 'resource_link', name: entry.name, uri: downloadURL, mimeType: 'application/octet-stream', description: 'Editable ACE project; download and import into ACE: Aesthetic Carousel Editor' }, ...rendered.content], structuredContent: summary };
+    } catch (error) { return failure(error); }
+  });
+  server.registerTool('render_carousel', {
+    title: 'Render Native Carousel Preview',
+    description: 'Render every slide with ACE SlideRasterizer, including native fonts, frames, grids, blend modes, masks and multi-composition. Returns a contact sheet as image content for display in chat and individual PNG URLs. Requires capabilities.native_renderer=true and real supplied assets. Use for visual iteration before final delivery; this does not certify visual quality.',
+    inputSchema: { yaml: z.string().max(4 * 1024 * 1024), asset_files: z.array(fileInput).max(20).optional(), photo_uploads: uploadInput, width: z.number().int().min(360).max(1080).default(720) },
+    annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
+    _meta: { 'openai/fileParams': ['asset_files'] }
+  }, async ({ yaml, asset_files, photo_uploads, width }) => {
+    try {
+      if (!renderer) throw new DocumentError('Native rendering is unavailable on this server. Configure CAROUSEL_RENDERER_CONFIG on macOS.');
+      const project = await buildInput(yaml, asset_files, photo_uploads);
+      const rendered = await renderPreview(project, width);
+      const summary = { slides: project.document.slides, ...project.diagnostics, versions, preview: rendered.preview, visual_check: 'native preview rendered; visual review pending' };
+      return { content: [{ type: 'text', text: JSON.stringify(summary) }, ...rendered.content], structuredContent: summary };
+    } catch (error) { return failure(error); }
+  });
+  server.registerTool('prepare_photo_upload', {
+    title: 'Prepare Photo Upload',
+    description: 'Reserve one-time HTTPS upload links for photographs stored on the user’s computer, for clients that can run shell commands. Send each file with an HTTP PUT, for example curl -sS --fail-with-body -T photo.jpg "<upload_url>", then pass the upload IDs as photo_uploads to create_carousel or render_carousel and use each asset_name as an image layer source. Each link takes one still JPEG, PNG, WebP or AVIF photograph up to max_bytes; convert HEIC to JPEG first. Uploaded photographs are only packaged into projects, never served back, and expire after one hour.',
+    inputSchema: { asset_names: z.array(z.string().min(1).max(128)).min(1).max(20) },
+    annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
+  }, async ({ asset_names }) => {
+    try {
+      const slots = uploads.reserve(asset_names);
+      const result = { uploads: slots.map(slot => ({ asset_name: slot.name, upload_id: slot.id, upload_url: `${baseURL}/uploads/${slot.id}` })), method: 'PUT', max_bytes: uploads.limit, expires_at: new Date(slots[0].expires).toISOString() };
+      return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
     } catch (error) { return failure(error); }
   });
   return server;
@@ -108,23 +229,35 @@
 
 export function httpServer(configuration) {
   const store = configuration.store ?? new ArtifactStore();
+  const uploads = configuration.uploads ?? new UploadStore();
+  const renderer = configuration.renderer ?? null;
   return createServer(async (request, response) => {
     const path = new URL(request.url, 'http://localhost').pathname;
     if (path === '/health' && request.method === 'GET') {
       response.writeHead(200, { 'Content-Type': 'application/json' }); response.end('{"ok":true}'); return;
     }
+    const upload = /^\/uploads\/([a-f0-9]{48})$/.exec(path);
+    if (upload) {
+      if (!['PUT', 'POST'].includes(request.method)) { response.writeHead(405, { Allow: 'PUT, POST' }); response.end(); return; }
+      let status = 200, body;
+      try { body = await uploads.receive(upload[1], await readUpload(request, uploads.limit)); }
+      catch (error) { status = error.status ?? 400; body = { error: error.message }; }
+      // An oversized body is left unread, so the connection cannot be reused.
+      response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...(status === 413 ? { Connection: 'close' } : {}) });
+      response.end(JSON.stringify(body)); return;
+    }
     const match = /^\/downloads\/([a-f0-9]{48})\/([^/]+)$/.exec(path);
     if (match && ['GET', 'HEAD'].includes(request.method)) {
       const entry = store.get(match[1]);
       let name;
       try { name = decodeURIComponent(match[2]); } catch { /* invalid URI is a missing file */ }
       if (!entry || name !== entry.name) { response.writeHead(404); response.end('Download expired or not found'); return; }
-      response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': entry.bytes.byteLength, 'Content-Disposition': `attachment; filename="carousel.carousel"; filename*=UTF-8''${encodeURIComponent(entry.name)}`, 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' });
+      response.writeHead(200, { 'Content-Type': entry.mimeType, 'Content-Length': entry.bytes.byteLength, 'Content-Disposition': `${entry.mimeType === 'image/png' ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(entry.name)}`, 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' });
       response.end(request.method === 'HEAD' ? undefined : entry.bytes); return;
     }
     if (path !== '/mcp' || request.method !== 'POST') { response.writeHead(path === '/mcp' ? 405 : 404); response.end(); return; }
     const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
-    const server = createMCPServer({ publicURL: configuration.publicURL, store });
+    const server = createMCPServer({ publicURL: configuration.publicURL, store, uploads, renderer, download: configuration.download });
     response.on('close', () => { void transport.close(); void server.close(); });
     try { await server.connect(transport); await transport.handleRequest(request, response); }
     catch { if (!response.headersSent) response.writeHead(500); response.end(); }
@@ -135,8 +268,9 @@
   const port = Number(process.env.CAROUSEL_PORT ?? 8787);
   const publicURL = process.env.CAROUSEL_PUBLIC_URL ?? `http://127.0.0.1:${port}`;
   // Validate configuration at startup, instead of failing during the first tool call.
-  const check = createMCPServer({ publicURL }); await check.close();
-  const app = httpServer({ publicURL });
+  const renderer = nativeRenderer();
+  const check = createMCPServer({ publicURL, renderer }); await check.close();
+  const app = httpServer({ publicURL, renderer });
   app.listen(port, process.env.CAROUSEL_HOST ?? '127.0.0.1', () => console.log(`Carousel Creator MCP: ${publicURL}/mcp`));
   for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => app.close(() => process.exit(0)));
 }
Full technical diff · 55 changed fields

added /files/.dockerignore

BEFORE
Field was absent
AFTER
{
  "sha256": "5f2025d9087cd8dc93ebe395791fda81379d9a438862e6e87a69b5faf4cc2b77",
  "size": 47
}

added /files/Dockerfile

BEFORE
Field was absent
AFTER
{
  "sha256": "fd48d4fdbb81718d2dfbffa6314dee7c56d042aa5dd96d34d45ee3b38ba91e49",
  "size": 315
}

added /files/LICENSE

BEFORE
Field was absent
AFTER
{
  "sha256": "29942ea87db4340c9a272091ada1b037d8b8b02a2e76dce75509cb8a1e418059",
  "size": 2313
}

added /files/README.md

BEFORE
Field was absent
AFTER
{
  "sha256": "2014b20842e56188734f68b1490b3cbb5a8dd5365993de5a9935fdc353ff30fc",
  "size": 1112
}

added /files/data~1catalog.json

BEFORE
Field was absent
AFTER
{
  "sha256": "93dcbf982d26c83ffef1134e7dd95115ce7337cd741ff963f27310becb4fff88",
  "size": 425901
}

added /files/data~1examples.json

BEFORE
Field was absent
AFTER
{
  "sha256": "9300369fb5b6ed73beb1db3352df1c6fb08e7c891589f6cb4e834beac9ecf124",
  "size": 3675
}

added /files/examples~1contact-gallery.yaml

BEFORE
Field was absent
AFTER
{
  "sha256": "31383e1bfa7f27aaf6b8d5fd6c450ec039e4be05390398b27341c2d82670733f",
  "size": 1626
}

added /files/examples~1effects-study.yaml

BEFORE
Field was absent
AFTER
{
  "sha256": "709a9449fa409ef272e58f07764eb2661961888a41329284a9b9aaf4fec1796c",
  "size": 1210
}

added /files/examples~1free-collage.yaml

BEFORE
Field was absent
AFTER
{
  "sha256": "82e9ea3801309187184a41c6a0490d4af1fa792abca303f462ae9d1a0a9b48a0",
  "size": 1139
}

added /files/examples~1photo-story.yaml

BEFORE
Field was absent
AFTER
{
  "sha256": "10b1f45fe3eab32b55740bfb3526831a23374ae41120ff05fead7a8761fd9d47",
  "size": 1060
}

added /files/examples~1smart-stamp.yaml

BEFORE
Field was absent
AFTER
{
  "sha256": "b039ec2ed3e17f9c1757f4380f7abe5ce366bb71d06153e087053cf0f21ae019",
  "size": 914
}

added /files/examples~1type-led.yaml

BEFORE
Field was absent
AFTER
{
  "sha256": "4220649308cf1b1b3418409b1d880b516df55e3e81dd87a9a44b3226b63d55da",
  "size": 1200
}

added /files/native~1RendererApp.swift

BEFORE
Field was absent
AFTER
{
  "sha256": "90f7699ffa9b44cb1263dbfebab1f0c1062ebf934cb97a4e718cc577fc66539c",
  "size": 4434
}

changed /files/package-lock.json/sha256

BEFORE
"ad8658df058ed6e12856e442e18591e49ee0dae7af722e8482940b43fa6d0432"
AFTER
"1ff211f79a56954cb9b076c2eeedd85b56a361584f8205f11b84764f4f4fdf75"

changed /files/package.json/sha256

BEFORE
"2d43a17f4b9852e8cea13f32c903536b8fc8132607045a72d1f3c710c8de3efc"
AFTER
"ea3ade412901851f814ecda42c8a32e00e72052bba153bc76f5ff515a30be224"

changed /files/package.json/size

BEFORE
503
AFTER
908

changed /files/plugin.json/sha256

BEFORE
"9171fcb956c5e86ca839303912e290b0db31660efcd7017698fb540d21bd9ba5"
AFTER
"8cdbe9970f20ed8120bd20a37b5e1c0604b48aa49e099bd00920e372734e2566"

changed /files/plugin.json/size

BEFORE
11337
AFTER
10022

changed /files/scripts~1build-carousel.mjs/sha256

BEFORE
"0ec532932ce99c8e02f0f2b57730efd590f17b5c822c18ccb35aaaf43f0dab3f"
AFTER
"8496a1368be3dbf2ef9245f449ac7a5158dec37184fa30c638ed127e663e65b2"

changed /files/scripts~1build-carousel.mjs/size

BEFORE
1161
AFTER
1184

added /files/scripts~1check-server.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "42674a0f1e3797f36247f0093dae0cba1d4b8c407690bd929902d881605767ff",
  "size": 4394
}

added /files/scripts~1get-spec.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "771b4044e7bb24fa563b169e8c1880b41709c76c35c5778279004b47f85861d9",
  "size": 311
}

changed /files/scripts~1package-plugin.mjs/sha256

BEFORE
"698df0d5de5c1a076b100f47f0e00705467a85b77d31b9fae15e5bc36ebb7fa8"
AFTER
"7df454a476fa80d28753c85541082b193a6b0aa1156bbadc3b80e7a3783ce7a3"

changed /files/scripts~1package-plugin.mjs/size

BEFORE
5594
AFTER
6810

added /files/scripts~1render-carousel.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "ae7b739231767f5e17dd7957c179bbdc617fe9d4718437967733df67ad031537",
  "size": 1450
}

added /files/scripts~1setup-native-renderer.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "f18984ec4a2fa0587d737db082e4cfa33ea09adda828b4cce915bc61b812d8a6",
  "size": 4180
}

added /files/scripts~1sync-catalog.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "315dccd2343306cef52dedbd87798d495f5b0c5ad9e66bdeb9e56bc4e692fae8",
  "size": 626
}

changed /files/skills~1create-carousel~1SKILL.md/sha256

BEFORE
"9151fb427fee5e69f407c0f1e2ee2f33d77d2b0cc99f4f6409730ad62cd6de25"
AFTER
"0d4c5ec38abdeeae533cdd8985685be4c0f532a0637f95767d2b999e605fa9e7"

changed /files/skills~1create-carousel~1SKILL.md/size

BEFORE
3939
AFTER
10609

added /files/skills~1create-carousel~1references~1DESIGN_GUIDE.md

BEFORE
Field was absent
AFTER
{
  "sha256": "2ffd410e74f7028499999a3373315b076c5b1cea0930664fe14726810fc1b618",
  "size": 4266
}

changed /files/skills~1create-carousel~1references~1DSL_LAYOUT_SPEC.md/sha256

BEFORE
"1278b5f6f81afe42e29433479c5c102e3f75ae16eb93e23dd0eb2a70a15125d1"
AFTER
"3394ff0ae25f338c282c55fe53dc580ae2c596b62f09da77f24452a5724e6cb0"

changed /files/skills~1create-carousel~1references~1DSL_LAYOUT_SPEC.md/size

BEFORE
212874
AFTER
218069

added /files/skills~1create-carousel~1references~1WRITING_GUIDE.md

BEFORE
Field was absent
AFTER
{
  "sha256": "fcf68ef35f4e3a858d3d6236e4f3dd182fd52b018c24cd663d257ed1b4a2f477",
  "size": 5902
}

changed /files/src~1archive.mjs/sha256

BEFORE
"a7c8b4ed23371010e755a3898f926f6bed4d2dd5d257d4a7c98a3041a048b46d"
AFTER
"f082bea90b8ff5e2655cccd7044bff2248d551e8989c734ee24ce4a8b14b64e9"

changed /files/src~1archive.mjs/size

BEFORE
2908
AFTER
3498

added /files/src~1catalog.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "41e0108752aff449b825d15a46808ccdbc43649ba4998143118dca4b3e6ca962",
  "size": 6068
}

added /files/src~1diagnostics.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "a620e5631de644ab4a5586127c4612a94695152202b26145336f1d8cf70cacd5",
  "size": 7803
}

changed /files/src~1document.mjs/sha256

BEFORE
"a38e271507e7b55fb606ad619d907a6836ecdce8dafd502b8f042d155d3a6f7c"
AFTER
"97a4868aafefe954717d9058d5d910639076613b8e157f80f2f47c42db8c46ec"

changed /files/src~1document.mjs/size

BEFORE
13586
AFTER
12413

added /files/src~1errors.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "d1f236c2bff72432b73e3aa27ad675ee1038c80ccf8b457fbb4f717898061660",
  "size": 44
}

added /files/src~1recipes.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "660b575c97fc2ee70c0668c482d8f949185cf748acc7caee62b739fc44bbdcd1",
  "size": 4103
}

added /files/src~1renderer.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "44b8f9cad2feabe0b99cce1e8a6a6e830b8788ad8112b58c0444eedf720720da",
  "size": 7706
}

changed /files/src~1server.mjs/sha256

BEFORE
"711db8dfd4f596dcf673d6edd728f96ebc6177fb05284310b40748ffb3777679"
AFTER
"67a96be7fcf04f603c900ce5151b73943c671f7cdbe65e82ef1be6d494008ca4"

changed /files/src~1server.mjs/size

BEFORE
10916
AFTER
23198

added /files/src~1spec.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "2564a2e59d6abbc363d6622741e0c47e0958013bde2472295b8b4586d2858fcf",
  "size": 4491
}

changed /files/tests~1carousel.test.mjs/sha256

BEFORE
"d006e3346166652fa2104ebe75ec621f545255cd58924f86f4f922065dcac398"
AFTER
"3d2bef07c979ebb12149442ecc81704d5ead4822ec58bea2d43a64c8b15db026"

changed /files/tests~1carousel.test.mjs/size

BEFORE
8150
AFTER
8364

added /files/tests~1diagnostics.test.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "b3baa3352b4a57b38e13e674ec4a96f6a7d8547b251f2a1f6474327773a46cd8",
  "size": 5280
}

added /files/tests~1discovery.test.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "d6b7f4e6447e9d398b1b88feead7644d6f29be721a9175d8a29fcdb40e9cec75",
  "size": 8147
}

changed /files/tests~1http.integration.mjs/sha256

BEFORE
"f77721afd3db1659dda0874702326c703b627185729fb2f462c1ec3dd94aa166"
AFTER
"d2f14621194cf28139f2e04dd77df988d7423f6c0b2b070e9e231796c383d7ae"

changed /files/tests~1http.integration.mjs/size

BEFORE
2053
AFTER
2995

added /files/tests~1native.integration.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "37bc67dd03fa1931a1a52c9ee0860a3b9e056619d0f8a735ee029ac3ae33412d",
  "size": 7909
}

added /files/tests~1package.test.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "c7d100facf765bd64763a2825e65a2a2f662f94fc5ded114f524aabf039b11c9",
  "size": 2933
}

added /files/tests~1preview.test.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "a3b1c7317dc71d4d065156a89014b131b997e7c0c06203b8b4deac0d24fe9c89",
  "size": 5386
}

added /files/tests~1uploads.test.mjs

BEFORE
Field was absent
AFTER
{
  "sha256": "3e7dc647b2c2268e0c4cd71e119af9531079aced921011a081d1d5a7448d323b",
  "size": 6352
}
Full snapshot data
{
  "files": {
    ".app.json": {
      "sha256": "5e21add253e73f295f8dd9638efdad6133db89cea1afc6eb8bef86fcd9dc9d29",
      "size": 108
    },
    ".dockerignore": {
      "sha256": "5f2025d9087cd8dc93ebe395791fda81379d9a438862e6e87a69b5faf4cc2b77",
      "size": 47
    },
    "Dockerfile": {
      "sha256": "fd48d4fdbb81718d2dfbffa6314dee7c56d042aa5dd96d34d45ee3b38ba91e49",
      "size": 315
    },
    "LICENSE": {
      "sha256": "29942ea87db4340c9a272091ada1b037d8b8b02a2e76dce75509cb8a1e418059",
      "size": 2313
    },
    "README.md": {
      "sha256": "2014b20842e56188734f68b1490b3cbb5a8dd5365993de5a9935fdc353ff30fc",
      "size": 1112
    },
    "assets/icon.png": {
      "sha256": "6dfad6349d9c99ac11613f4abbe19fe6e608f89357e0c2ff87abf7079072cb83",
      "size": 31011
    },
    "data/catalog.json": {
      "sha256": "93dcbf982d26c83ffef1134e7dd95115ce7337cd741ff963f27310becb4fff88",
      "size": 425901
    },
    "data/examples.json": {
      "sha256": "9300369fb5b6ed73beb1db3352df1c6fb08e7c891589f6cb4e834beac9ecf124",
      "size": 3675
    },
    "examples/contact-gallery.yaml": {
      "sha256": "31383e1bfa7f27aaf6b8d5fd6c450ec039e4be05390398b27341c2d82670733f",
      "size": 1626
    },
    "examples/creator-demo-preview.png": {
      "sha256": "72f9bdd64e2f4bbbeee007d645a75ea38e90713f99079c5fbafe74f8c3ea306c",
      "size": 69512
    },
    "examples/creator-demo.carousel": {
      "sha256": "4a16b05ee1c671d4e57d58136058838c15b542456f55d0e25df92941f9a64465",
      "size": 5679
    },
    "examples/creator-demo.yaml": {
      "sha256": "ac375143bd9958012deed3c38cb84c212a585c40a96c051f5503df17b74f642a",
      "size": 3908
    },
    "examples/effects-study.yaml": {
      "sha256": "709a9449fa409ef272e58f07764eb2661961888a41329284a9b9aaf4fec1796c",
      "size": 1210
    },
    "examples/free-collage.yaml": {
      "sha256": "82e9ea3801309187184a41c6a0490d4af1fa792abca303f462ae9d1a0a9b48a0",
      "size": 1139
    },
    "examples/photo-story.yaml": {
      "sha256": "10b1f45fe3eab32b55740bfb3526831a23374ae41120ff05fead7a8761fd9d47",
      "size": 1060
    },
    "examples/smart-stamp.yaml": {
      "sha256": "b039ec2ed3e17f9c1757f4380f7abe5ce366bb71d06153e087053cf0f21ae019",
      "size": 914
    },
    "examples/type-led.yaml": {
      "sha256": "4220649308cf1b1b3418409b1d880b516df55e3e81dd87a9a44b3226b63d55da",
      "size": 1200
    },
    "mcp.json": {
      "sha256": "828f2d5c8663ded2b9b701aa0fd0f0848bd1fbd865c1e8a170bb1b13148e7ccb",
      "size": 210
    },
    "native/RendererApp.swift": {
      "sha256": "90f7699ffa9b44cb1263dbfebab1f0c1062ebf934cb97a4e718cc577fc66539c",
      "size": 4434
    },
    "package-lock.json": {
      "sha256": "1ff211f79a56954cb9b076c2eeedd85b56a361584f8205f11b84764f4f4fdf75",
      "size": 61504
    },
    "package.json": {
      "sha256": "ea3ade412901851f814ecda42c8a32e00e72052bba153bc76f5ff515a30be224",
      "size": 908
    },
    "plugin.json": {
      "sha256": "8cdbe9970f20ed8120bd20a37b5e1c0604b48aa49e099bd00920e372734e2566",
      "size": 10022
    },
    "scripts/build-carousel.mjs": {
      "sha256": "8496a1368be3dbf2ef9245f449ac7a5158dec37184fa30c638ed127e663e65b2",
      "size": 1184
    },
    "scripts/check-server.mjs": {
      "sha256": "42674a0f1e3797f36247f0093dae0cba1d4b8c407690bd929902d881605767ff",
      "size": 4394
    },
    "scripts/get-spec.mjs": {
      "sha256": "771b4044e7bb24fa563b169e8c1880b41709c76c35c5778279004b47f85861d9",
      "size": 311
    },
    "scripts/package-plugin.mjs": {
      "sha256": "7df454a476fa80d28753c85541082b193a6b0aa1156bbadc3b80e7a3783ce7a3",
      "size": 6810
    },
    "scripts/render-carousel.mjs": {
      "sha256": "ae7b739231767f5e17dd7957c179bbdc617fe9d4718437967733df67ad031537",
      "size": 1450
    },
    "scripts/setup-native-renderer.mjs": {
      "sha256": "f18984ec4a2fa0587d737db082e4cfa33ea09adda828b4cce915bc61b812d8a6",
      "size": 4180
    },
    "scripts/sync-catalog.mjs": {
      "sha256": "315dccd2343306cef52dedbd87798d495f5b0c5ad9e66bdeb9e56bc4e692fae8",
      "size": 626
    },
    "skills/create-carousel/SKILL.md": {
      "sha256": "0d4c5ec38abdeeae533cdd8985685be4c0f532a0637f95767d2b999e605fa9e7",
      "size": 10609
    },
    "skills/create-carousel/references/DESIGN_GUIDE.md": {
      "sha256": "2ffd410e74f7028499999a3373315b076c5b1cea0930664fe14726810fc1b618",
      "size": 4266
    },
    "skills/create-carousel/references/DSL_LAYOUT_SPEC.md": {
      "sha256": "3394ff0ae25f338c282c55fe53dc580ae2c596b62f09da77f24452a5724e6cb0",
      "size": 218069
    },
    "skills/create-carousel/references/WRITING_GUIDE.md": {
      "sha256": "fcf68ef35f4e3a858d3d6236e4f3dd182fd52b018c24cd663d257ed1b4a2f477",
      "size": 5902
    },
    "src/archive.mjs": {
      "sha256": "f082bea90b8ff5e2655cccd7044bff2248d551e8989c734ee24ce4a8b14b64e9",
      "size": 3498
    },
    "src/catalog.mjs": {
      "sha256": "41e0108752aff449b825d15a46808ccdbc43649ba4998143118dca4b3e6ca962",
      "size": 6068
    },
    "src/diagnostics.mjs": {
      "sha256": "a620e5631de644ab4a5586127c4612a94695152202b26145336f1d8cf70cacd5",
      "size": 7803
    },
    "src/document.mjs": {
      "sha256": "97a4868aafefe954717d9058d5d910639076613b8e157f80f2f47c42db8c46ec",
      "size": 12413
    },
    "src/errors.mjs": {
      "sha256": "d1f236c2bff72432b73e3aa27ad675ee1038c80ccf8b457fbb4f717898061660",
      "size": 44
    },
    "src/recipes.mjs": {
      "sha256": "660b575c97fc2ee70c0668c482d8f949185cf748acc7caee62b739fc44bbdcd1",
      "size": 4103
    },
    "src/renderer.mjs": {
      "sha256": "44b8f9cad2feabe0b99cce1e8a6a6e830b8788ad8112b58c0444eedf720720da",
      "size": 7706
    },
    "src/server.mjs": {
      "sha256": "67a96be7fcf04f603c900ce5151b73943c671f7cdbe65e82ef1be6d494008ca4",
      "size": 23198
    },
    "src/spec.mjs": {
      "sha256": "2564a2e59d6abbc363d6622741e0c47e0958013bde2472295b8b4586d2858fcf",
      "size": 4491
    },
    "tests/carousel.test.mjs": {
      "sha256": "3d2bef07c979ebb12149442ecc81704d5ead4822ec58bea2d43a64c8b15db026",
      "size": 8364
    },
    "tests/diagnostics.test.mjs": {
      "sha256": "b3baa3352b4a57b38e13e674ec4a96f6a7d8547b251f2a1f6474327773a46cd8",
      "size": 5280
    },
    "tests/discovery.test.mjs": {
      "sha256": "d6b7f4e6447e9d398b1b88feead7644d6f29be721a9175d8a29fcdb40e9cec75",
      "size": 8147
    },
    "tests/http.integration.mjs": {
      "sha256": "d2f14621194cf28139f2e04dd77df988d7423f6c0b2b070e9e231796c383d7ae",
      "size": 2995
    },
    "tests/native.integration.mjs": {
      "sha256": "37bc67dd03fa1931a1a52c9ee0860a3b9e056619d0f8a735ee029ac3ae33412d",
      "size": 7909
    },
    "tests/package.test.mjs": {
      "sha256": "c7d100facf765bd64763a2825e65a2a2f662f94fc5ded114f524aabf039b11c9",
      "size": 2933
    },
    "tests/preview.test.mjs": {
      "sha256": "a3b1c7317dc71d4d065156a89014b131b997e7c0c06203b8b4deac0d24fe9c89",
      "size": 5386
    },
    "tests/uploads.test.mjs": {
      "sha256": "3e7dc647b2c2268e0c4cd71e119af9531079aced921011a081d1d5a7448d323b",
      "size": 6352
    }
  }
}

SHA-256 of public snapshot: 64959cf6937ca474b78ee128969c5afdcb3064e41f5dc2d8e4ef072e2c240fab