← Files ACE Carousel CreatorARCHIVED FILE

src/server.mjs

10.7 KB · Nov 3, 2026 · 19:19 UTC

↓ Download file

import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';
import { pathToFileURL } from 'node:url';
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';

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';
const failure = error => ({ isError: true, content: [{ type: 'text', text: error instanceof DocumentError ? error.message : `Could not create the file: ${error.message}` }] });

export class ArtifactStore {
  constructor({ ttl = 3_600_000, capacity = 256 * 1024 * 1024, now = Date.now } = {}) {
    this.ttl = ttl; this.capacity = capacity; this.now = now; this.files = new Map();
  }
  prune() {
    for (const [id, entry] of this.files) if (entry.expires <= this.now()) this.files.delete(id);
  }
  add(name, bytes) {
    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 };
  }
  get(id) { this.prune(); return this.files.get(id); }
}

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');
  if (url.protocol !== 'https:' || !allowed || url.username || url.password || (url.port && url.port !== '443')) throw new DocumentError('Image download URL must be an HTTPS file URL supplied by ChatGPT');
  // Do not follow redirects from allowed public hosts into arbitrary/private networks.
  const response = await fetcher(url, { redirect: 'error', signal: AbortSignal.timeout(30_000) });
  if (!response.ok) throw new DocumentError(`Image download failed with HTTP ${response.status}; attach the file again if its link expired`);
  if (Number(response.headers.get('content-length')) > imageLimit) throw new DocumentError('Attached image exceeds 20 MiB');
  const reader = response.body.getReader();
  const chunks = []; let length = 0;
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      length += value.byteLength;
      if (length > imageLimit) throw new DocumentError('Attached image exceeds 20 MiB');
      chunks.push(value);
    }
  } catch (error) {
    await reader.cancel(); throw error;
  } finally { reader.releaseLock(); }
  return Buffer.concat(chunks);
}

export function createMCPServer({ publicURL, store = new ArtifactStore(), download = downloadImage } = {}) {
  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.'
  });
  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('') },
    annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
  }, async ({ section, query }) => ({ content: [{ type: 'text', text: getSpec(section, query) }] }));
  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) },
    annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
  }, async ({ yaml }) => {
    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' }) }] };
    } 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() },
    annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
    _meta: { 'openai/fileParams': ['asset_files'] }
  }, async ({ yaml, filename, asset_files = [] }) => {
    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 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 };
    } catch (error) { return failure(error); }
  });
  return server;
}

export function httpServer(configuration) {
  const store = configuration.store ?? new ArtifactStore();
  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 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.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 });
    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(); }
  });
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  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 });
  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)));
}

SHA-256: 711db8dfd4f596dcf673d6edd728f96ebc6177fb05284310b40748ffb3777679