Skip to main content
Glama
README.md
# revspot-mcp

Revspot's image-generation MCP server. Exposes a DTC-ads tool surface
(`generate_image`, `show_marketing_studio`, …) over MCP, so any MCP client —
Claude included — can generate ad creatives against your product library.

## Run it

    npm install
    cp .env.example .env.local     # OPENAI_API_KEY at minimum
    npm run dev

`POST /api/mcp` — JSON-RPC, MCP over HTTP. Thirteen tools:

    generate_image            generate_image_batch      jobs_wait
    job_status                show_marketing_studio     show_generations
    show_generation_by_ids    show_marketing_studio_generations
    edit_image                media_upload              media_confirm
    models_explore            balance

## Layout

    app/api/mcp/route.ts        the MCP endpoint — JSON-RPC, tool dispatch,
                                widget resources
    app/api/media/[id]          PUT target for media_upload; the id is the
                                capability, write-once
    app/assets, app/outputs     serve product-pack images and generated files
    lib/core/dtc-ads/           the surface: prompt assembly, ad-format
                                catalogue, tool schemas, handlers, widgets
    lib/core/image-generation/  provider + the canvas table
    lib/core/                   product-pack store, run store, storage

## The prompt layer — `lib/core/dtc-ads/`

This is the part that decides what a creative looks like. Four files carry it:

    assets/system-base.txt        system prompt, no brand kit
    assets/system-brandkit.txt    system prompt when a brand kit is supplied
    assets/formats-base.json      ad-format definitions (layout per template)
    assets/formats-brandkit.json  the same, brand-kit variant
    assets/catalogue.json         the 42 ad formats: id, name, ordering

`assembly.ts` composes these into the `enhanced_prompt` payload the image model
receives. Key ordering, whitespace and which blocks appear are all significant —
the payload is consumed as a whole, so a change anywhere shifts output.

Two things worth knowing before editing:

- **`style_id` has no default and is the dominant creative driver.** Its
  parameter description in `tools.ts` is load-bearing: wording there changes
  which template agents pick. Treat tool descriptions as behaviour, not docs.
- **The brand-kit path switches system prompt.** Supplying a kit selects
  `system-brandkit.txt` and folds the kit into the payload. Passing a bare
  `brand_kit_id` with nothing to resolve is refused rather than silently
  degraded — otherwise the model is told to hold a brand it was never shown.

## Canvas table — read before touching aspect ratios

`lib/core/image-generation/openai.ts`

The provider enforces two rules, quoted from its own rejections: both sides
divisible by 16, and within a per-resolution pixel budget. The sizes in
`OBSERVED` satisfy both, and **no formula reproduces them** — 3:4 at 2k is
1744×2336, not 2× the 1k 880×1168. The relationship is bespoke per ratio.

So there are two outcomes: a canvas backed by a measurement, or `auto`. Do not
compute one — an earlier computed table produced sizes the provider rejects and
broke 17 of 45 aspect/resolution combinations.

`COMPUTED` holds 4:5 only, the documented exception: it is the Meta feed
standard, and each size is the largest exact-0.8 canvas satisfying both rules.
Marked `observed: false` so a run record says the number rests on arithmetic.
Before it existed, asking for 4:5 silently returned a 1254×1254 square.

## Widgets

Three MCP-Apps panels — generation gallery, ad-format picker, media upload —
served as `ui://revspot/<name>.<version>.html` from `lib/core/dtc-ads/widgets.ts`.

Hosts cache widget HTML against its URI, so markup changes are invisible to any
client that already opened a panel. `widgets:version` enforces that: change the
markup without bumping `WIDGET_VERSION` and it fails with both hashes. Bump the
constant, commit the regenerated lockfile, and clients refetch.

## Before you deploy

    npm run predeploy

    widgets:version   resource versions consistent
    widgets:parse     every widget script parses
    widgets:paint     widgets render against a strict host
    tsc --noEmit      types

`widgets:paint` needs `REVSPOT_BACKEND_URL` pointing at a running instance.

## Storage

`REVSPOT_DATA_DIR` is the root — a mounted volume in production, any writable
path locally.

    .prizm-data/store.json           generation runs
    data/prizm-product-packs.json    product packs
    assets/product-packs/<id>/       pack images
    outputs/                         generated images

Those two `prizm`-prefixed names, and the legacy `/prizm-assets` and
`/prizm-outputs` URL prefixes accepted in `servedUrlToFilePath`, are held over
from an earlier name. They are not dead: records already on disk reference them,
so renaming orphans every pack and run written before the change. Rename only
alongside a migration. New writes already use `assets/` and `outputs/`.

## Known gaps

1. Tools not implemented that MCP clients may reach for: `media_import_url`,
   `media_upload_widget`, `show_medias`, and the transform set — `upscale_image`,
   `reframe`, `outpaint_image`, `remove_background`. Without the last group,
   "make it bigger" or "make it vertical" becomes a fresh generation with
   different copy rather than a transform of the existing image.
2. No brand-kit store. An inline `brand_kit` object works; a bare id is refused.
3. No cost preflight, so spend cannot be quoted before running.
4. Run history has no `created_at` and no date filter, so "what did I make
   today" cannot be scoped server-side.
5. Jobs occasionally accept and sit in `queued` without starting. Re-submitting
   clears it and a 15-minute watchdog reaps them; root cause not diagnosed.
6. Concurrency cap is 8 — larger batches need a queue that waits for slots.

## Things that will bite you

- **Product packs do not transfer between instances.** Rebuild them with the
  same images. A pack with the right name and the wrong images renders a
  different product and looks like a model failure. Verify image by image, not
  by title or count.
- **Never reconstruct a record from a read that may have missed.** A
  `{...(await get(id))!}` spread once persisted a row with no id and no
  createdAt; the listing sorted on `createdAt.localeCompare` and threw. Because
  the concurrency check calls that listing at the top of every generate, one bad
  row took generation down completely, and restarting could not clear it.