distribea-mcp 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +23 -0
  2. package/index.mjs +283 -12
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -58,4 +58,27 @@ A [Distribea](https://distribea.com) subscription is required.
58
58
  - Your project files never leave your machine except the specific images you
59
59
  ask to edit.
60
60
 
61
+ ## Privacy Policy
62
+
63
+ This local connector contains **no API keys and no secrets**. To generate an
64
+ image it sends to the hosted Distribea engine only what is needed for the
65
+ request:
66
+
67
+ - **What is sent:** your image brief (the subject/prompt you ask for), a short
68
+ text excerpt of the page being worked on (used to infer the site's style and
69
+ context), a hashed project identifier, and any image file you explicitly ask
70
+ to edit. **Your other project files stay on your machine** — they are never
71
+ read or uploaded.
72
+ - **What is stored, tied to your account:** your locked visual style, recurring
73
+ characters/products/avatars, the images generated for you (hosted on
74
+ Distribea's CDN), and your credit/billing usage.
75
+ - **Third parties:** images are produced through Distribea's hosted AI
76
+ providers solely to fulfil your request. Your data is **never sold**.
77
+ - **Authentication:** calls are authenticated with your personal key
78
+ (`dmcp_…`), issued and revocable at
79
+ <https://distribea.com/account/mcp>.
80
+ - **Retention & contact:** data is kept for the life of your account; full
81
+ policy, retention details and contact at
82
+ <https://distribea.com/legal/privacy-policy>.
83
+
61
84
  © Distribea, <https://distribea.com>
package/index.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
18
  import { createHash } from "node:crypto";
19
- import { readFileSync, readdirSync, statSync } from "node:fs";
19
+ import { readdirSync, readFileSync, statSync } from "node:fs";
20
20
  import { mkdir, readFile, writeFile } from "node:fs/promises";
21
21
  import { createRequire } from "node:module";
22
22
  import {
@@ -229,7 +229,75 @@ const SKIP_DIRS = new Set([
229
229
  const PLACEHOLDER_SRC_RE =
230
230
  /placehold\.co|via\.placeholder\.com|placekitten|picsum\.photos|dummyimage\.com|loremflickr\.com|placeimg\.com|fakeimg\.pl|images\.unsplash\.com|source\.unsplash\.com|images\.pexels\.com|cdn\.pixabay\.com|placeholder/i;
231
231
  const IMG_TAG_RE = /<(?:img|Image)\b[\s\S]*?>/g;
232
- const HEADING_RE = /<h[1-3][^>]*>([\s\S]*?)<\/h[1-3]>/g;
232
+ const HEADING_SCAN_RE = /<h[1-4][^>]*>([\s\S]*?)<\/h[1-4]>/gi;
233
+ const NEXT_HEADING_RE = /<h[1-4][^>]*>/i;
234
+ // Frontières de bloc : on ne relie JAMAIS une image au titre d'une autre section.
235
+ const BLOCK_BOUNDARY_RE =
236
+ /<\/?(?:section|article|header|footer|main|nav|aside)\b[^>]*>/gi;
237
+
238
+ // Le « bloc » qui contient l'image : entre la frontière de section juste avant
239
+ // et juste après. Page mal codée (aucune balise de section) → fenêtre bornée
240
+ // pour ne pas aller chercher un titre à l'autre bout du fichier.
241
+ function sectionBoundsForImg(content, imgStart, imgEnd) {
242
+ let lo = 0;
243
+ let hi = content.length;
244
+ for (const b of content.matchAll(BLOCK_BOUNDARY_RE)) {
245
+ if (b.index < imgStart) {
246
+ lo = b.index + b[0].length;
247
+ } else if (b.index >= imgEnd) {
248
+ hi = b.index;
249
+ break;
250
+ }
251
+ }
252
+ if (lo === 0 && hi === content.length) {
253
+ lo = Math.max(0, imgStart - 1200);
254
+ hi = Math.min(content.length, imgEnd + 1200);
255
+ }
256
+ return { lo, hi };
257
+ }
258
+
259
+ // Titre + description du MÊME bloc que l'image : on prend le titre le plus
260
+ // proche, qu'il soit AU-DESSUS ou EN DESSOUS (cartes image-en-haut/titre-dessous,
261
+ // blocs 2 colonnes…), sans déborder sur la carte voisine. Aucun titre trouvé →
262
+ // on retombe sur le texte juste avant l'image.
263
+ function headingAndContextForImg(content, imgStart, imgEnd) {
264
+ const { lo, hi } = sectionBoundsForImg(content, imgStart, imgEnd);
265
+ const scope = content.slice(lo, hi);
266
+ const relStart = imgStart - lo;
267
+ const relEnd = imgEnd - lo;
268
+ let best = null;
269
+ let bestDist = Number.POSITIVE_INFINITY;
270
+ for (const h of scope.matchAll(HEADING_SCAN_RE)) {
271
+ const hStart = h.index;
272
+ const hEnd = h.index + h[0].length;
273
+ let dist = 0;
274
+ if (hEnd <= relStart) {
275
+ dist = relStart - hEnd;
276
+ } else if (hStart >= relEnd) {
277
+ dist = hStart - relEnd;
278
+ }
279
+ if (dist < bestDist) {
280
+ bestDist = dist;
281
+ best = { text: stripMarkup(h[1]), hEnd };
282
+ }
283
+ }
284
+ if (!best) {
285
+ return {
286
+ heading: "",
287
+ context: stripMarkup(
288
+ content.slice(Math.max(0, imgStart - 300), imgStart)
289
+ ).slice(-250),
290
+ };
291
+ }
292
+ // Description = le texte qui suit CE titre, borné au titre suivant (pour ne
293
+ // pas avaler la carte d'après).
294
+ const afterHead = scope.slice(best.hEnd);
295
+ const nextHead = afterHead.search(NEXT_HEADING_RE);
296
+ const desc = stripMarkup(
297
+ nextHead >= 0 ? afterHead.slice(0, nextHead) : afterHead
298
+ ).slice(0, 280);
299
+ return { heading: best.text, context: desc };
300
+ }
233
301
 
234
302
  function walkFiles(dir, out = []) {
235
303
  for (const name of readdirSync(dir)) {
@@ -309,15 +377,18 @@ function scanFileForSlots(file, content) {
309
377
  h = Number(dimM[2]);
310
378
  }
311
379
  }
312
- const before = content.slice(Math.max(0, m.index - 700), m.index);
313
- const headM = [...before.matchAll(HEADING_RE)].pop();
380
+ const { heading, context } = headingAndContextForImg(
381
+ content,
382
+ m.index,
383
+ m.index + tag.length
384
+ );
314
385
  slots.push({
315
386
  file,
316
387
  tag,
317
388
  src: srcM[2],
318
389
  alt: altM?.[2] ?? "",
319
- heading: headM ? stripMarkup(headM[1]) : "",
320
- context: stripMarkup(before).slice(-250),
390
+ heading,
391
+ context,
321
392
  // Le prénom de l'auteur d'un avis est presque toujours SOUS sa photo.
322
393
  after: stripMarkup(
323
394
  content.slice(m.index + tag.length, m.index + tag.length + 500)
@@ -820,6 +891,130 @@ async function runGenerateImage(args, progress) {
820
891
  .join("\n");
821
892
  }
822
893
 
894
+ // --- blog cover ---------------------------------------------------------------
895
+ const ARTICLE_UA =
896
+ "Mozilla/5.0 (compatible; DistribeaImages/1.0; +https://distribea.com)";
897
+
898
+ // Résout le contenu de l'article : fichier local > URL publique > texte collé.
899
+ async function resolveArticle(projectDir, args) {
900
+ const file = args.article_file ?? args.article_path;
901
+ if (file) {
902
+ const p = resolveIn(projectDir, String(file));
903
+ let raw;
904
+ try {
905
+ raw = await readFile(p, "utf8");
906
+ } catch (e) {
907
+ throw new Error(`Article introuvable : ${p} (${e.message})`);
908
+ }
909
+ return { title: "", text: stripMarkup(raw).slice(0, 8000) };
910
+ }
911
+ if (args.article_url) {
912
+ const u = String(args.article_url).trim();
913
+ if (!/^https?:\/\//i.test(u)) {
914
+ throw new Error("article_url doit commencer par http:// ou https://");
915
+ }
916
+ let res;
917
+ try {
918
+ res = await fetch(u, {
919
+ headers: { "user-agent": ARTICLE_UA },
920
+ signal: AbortSignal.timeout(20_000),
921
+ redirect: "follow",
922
+ });
923
+ } catch (e) {
924
+ throw new Error(`Lecture de l'article impossible (${u}) : ${e.message}`);
925
+ }
926
+ if (!res.ok) {
927
+ throw new Error(
928
+ `Lecture de l'article impossible (HTTP ${res.status}) — ${u}`
929
+ );
930
+ }
931
+ const html = await res.text();
932
+ const title = oneLine(
933
+ html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""
934
+ );
935
+ const stripped = html
936
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
937
+ .replace(/<style[\s\S]*?<\/style>/gi, " ");
938
+ return { title, text: stripMarkup(stripped).slice(0, 8000) };
939
+ }
940
+ const t = String(args.article_text ?? args.article ?? "").trim();
941
+ return { title: "", text: t.slice(0, 8000) };
942
+ }
943
+
944
+ async function runBlogCover(args, progress) {
945
+ const projectDir = resolveIn(
946
+ process.cwd(),
947
+ args.project_dir ?? process.cwd()
948
+ );
949
+ const resolved = await resolveArticle(projectDir, args);
950
+ const title = oneLine(args.title ?? "") || resolved.title || "";
951
+ const text = resolved.text;
952
+ if (!(title || text)) {
953
+ throw new Error(
954
+ "Donne l'article : article_text (texte collé), article_url (lien public) ou article_file (fichier local)."
955
+ );
956
+ }
957
+ const extra = Math.max(
958
+ 0,
959
+ Math.min(5, Math.round(Number(args.illustrations ?? 0)))
960
+ );
961
+ const count = 1 + extra;
962
+ // Cover de blog : 16:9 ("wide") par défaut, standard. portrait/square au choix.
963
+ const orientation = ["wide", "landscape", "portrait", "square"].includes(
964
+ args.orientation
965
+ )
966
+ ? args.orientation
967
+ : "wide";
968
+ const saveDir = args.save_dir
969
+ ? resolveIn(projectDir, args.save_dir)
970
+ : join(projectDir, "public", "images");
971
+
972
+ progress?.(
973
+ `🎨 Cover de blog (${count} image${count > 1 ? "s" : ""}, ≈ ${IMAGE_CREDITS_HINT * count} crédits, 30-60 s)…`,
974
+ 0,
975
+ 1
976
+ );
977
+
978
+ const out = await engine("blog_cover", projectDir, {
979
+ title,
980
+ text,
981
+ count,
982
+ orientation,
983
+ character: args.character,
984
+ product: args.product,
985
+ pages_excerpt: pagesExcerpt(projectDir),
986
+ client_ref: "blog_cover",
987
+ });
988
+
989
+ const slugBase = slugify(title || text.slice(0, 60) || "article");
990
+ const images = Array.isArray(out.images) ? out.images : [];
991
+ const saved = await mapPool(images, 4, async (im, i) => {
992
+ const fileName =
993
+ im.role === "cover"
994
+ ? `blog-${slugBase}-cover.webp`
995
+ : `blog-${slugBase}-${i}.webp`;
996
+ const outPath = join(saveDir, fileName);
997
+ await saveUrl(im.cdn_url, outPath);
998
+ return { ...im, fileName, outPath };
999
+ });
1000
+
1001
+ const lines = [
1002
+ out.style_inferred ? STYLE_INFERRED_NOTE : "",
1003
+ `Cover de blog générée ✔ (${out.credits} crédits)`,
1004
+ ];
1005
+ for (const im of saved) {
1006
+ lines.push(
1007
+ "",
1008
+ im.role === "cover" ? "🖼️ COVER" : "🖼️ illustration",
1009
+ `file: ${im.outPath}`,
1010
+ `size: ${im.width}×${im.height} — ${Math.round(im.bytes / 1024)} KB (WebP)`,
1011
+ `alt: ${im.alt}`,
1012
+ `<img src="/images/${im.fileName}" alt="${String(im.alt).replace(/"/g, "&quot;")}" width="${im.width}" height="${im.height}" loading="${im.role === "cover" ? "eager" : "lazy"}" />`
1013
+ );
1014
+ }
1015
+ return lines.filter(Boolean).join("\n");
1016
+ }
1017
+
823
1018
  const OUT_FORMAT_BY_EXT = {
824
1019
  ".png": "png",
825
1020
  ".jpg": "jpeg",
@@ -1449,13 +1644,16 @@ function scanRebrandCandidates(projectDir) {
1449
1644
  continue;
1450
1645
  }
1451
1646
  const altM = tag.match(/\balt\s*=\s*\{?\s*(["'])([\s\S]*?)\1/);
1452
- const before = content.slice(Math.max(0, m.index - 700), m.index);
1453
- const headM = [...before.matchAll(HEADING_RE)].pop();
1647
+ const { heading } = headingAndContextForImg(
1648
+ content,
1649
+ m.index,
1650
+ m.index + tag.length
1651
+ );
1454
1652
  const entry = seen.get(resolved) ?? {
1455
1653
  path: resolved,
1456
1654
  usedIn: new Set(),
1457
1655
  alt: altM?.[2] ?? "",
1458
- heading: headM ? stripMarkup(headM[1]) : "",
1656
+ heading,
1459
1657
  };
1460
1658
  entry.usedIn.add(relative(projectDir, file));
1461
1659
  seen.set(resolved, entry);
@@ -1499,9 +1697,13 @@ async function runRebrandImages(args, progress) {
1499
1697
  c.bytes = st.size;
1500
1698
  candidates.push(c);
1501
1699
  }
1700
+ const eligibleBeyondCap = Math.max(0, candidates.length - max);
1502
1701
  candidates.splice(max);
1503
1702
 
1504
1703
  const notes = [
1704
+ eligibleBeyondCap
1705
+ ? `+${eligibleBeyondCap} autre(s) image(s) au-delà de la limite (max_images=${max}) — monte max_images (jusqu'à 20) ou relance pour continuer.`
1706
+ : "",
1505
1707
  placeholders
1506
1708
  ? `${placeholders} placeholder(s)/stock détecté(s) aussi → make_images (sans rebrand) s'en charge.`
1507
1709
  : "",
@@ -1673,7 +1875,7 @@ const TOOLS = [
1673
1875
  title: "Generate one on-brand website image",
1674
1876
  annotations: {
1675
1877
  readOnlyHint: false,
1676
- destructiveHint: false,
1878
+ destructiveHint: true,
1677
1879
  openWorldHint: true,
1678
1880
  },
1679
1881
  description:
@@ -1720,6 +1922,70 @@ const TOOLS = [
1720
1922
  required: ["subject"],
1721
1923
  },
1722
1924
  },
1925
+ {
1926
+ name: "blog_cover",
1927
+ title: "Cover image (and optional illustrations) for a blog article",
1928
+ annotations: {
1929
+ readOnlyHint: false,
1930
+ destructiveHint: true,
1931
+ openWorldHint: true,
1932
+ },
1933
+ description:
1934
+ "AUTONOMOUS TRIGGER — call this when an EDITORIAL ARTICLE still LACKS a cover/hero image (a brand-new post, or one left with a placeholder/stock image), even if the user never mentions an image. GUARDRAIL against noise: fire it ONCE per article, only when the cover is missing; if the article already has a real cover, leave it — do NOT regenerate; a pure text/typo/SEO edit is NOT a trigger; never re-run a successful call or 'improve' a cover on your own — only on the user's explicit request (each call bills the subscriber). Recognise the article by MEANING, not by tags: any standalone piece written to be READ — a headline + a body of prose on one topic (blog post, news, guide, tutorial, case study, magazine/journal piece) — in ANY language, on ANY stack (markdown/MDX, a headless CMS like WordPress/Sanity/Contentful, or a custom DB-driven route). Folders/routes (/blog, /posts, /articles, /news, /journal, /actualités, /magazine, /guides), markdown/MDX frontmatter, or an <article> element are only NON-EXHAUSTIVE hints — none is required: if it simply reads like an article, it qualifies (a marketing/landing/product page does NOT — that keeps make_images). You already hold the article you are working on → pass its own title + text as article_text (or article_url for a public link, or article_file for a local md/mdx/html/txt file). It generates the COVER, driven by the ARTICLE'S OWN specific subject (never a generic trade photo), kept visually coherent with the site's locked style (palette, light, medium), adapted to the site's country (driving side, architecture, currency), with NO recurring face/logo/product stamped on by default (every article gets a DIFFERENT scene — no 'same baker's face on every post'). Default = the cover ONLY at 16:9; set illustrations:N to also get N in-article images. Delivers an optimised WebP + ALT, ready to host. To DELIBERATELY feature a locked character/product, pass character/product. If the site has no locked style it infers one from the project; if it still lacks the trade/brand it tells you what to provide.",
1935
+ inputSchema: {
1936
+ type: "object",
1937
+ properties: {
1938
+ article_text: {
1939
+ type: "string",
1940
+ description: "The article's text (title + body), pasted directly",
1941
+ },
1942
+ article_url: {
1943
+ type: "string",
1944
+ description: "Public URL of the article — its text is read for you",
1945
+ },
1946
+ article_file: {
1947
+ type: "string",
1948
+ description:
1949
+ "Local file to read (md/mdx/html/txt…), relative to the project",
1950
+ },
1951
+ title: {
1952
+ type: "string",
1953
+ description:
1954
+ "Optional article title (overrides the one read from the source)",
1955
+ },
1956
+ illustrations: {
1957
+ type: "number",
1958
+ description:
1959
+ "Extra in-article images beyond the cover (default 0, max 5)",
1960
+ },
1961
+ orientation: {
1962
+ type: "string",
1963
+ enum: ["wide", "portrait", "square"],
1964
+ description:
1965
+ "Cover shape — default 'wide' (16:9, the standard blog-cover ratio). Use portrait/square only if the layout needs it.",
1966
+ },
1967
+ character: {
1968
+ type: "string",
1969
+ description:
1970
+ "Optional: name/role of a locked character to deliberately feature",
1971
+ },
1972
+ product: {
1973
+ type: "string",
1974
+ description:
1975
+ "Optional: name of a locked product to deliberately feature",
1976
+ },
1977
+ save_dir: {
1978
+ type: "string",
1979
+ description:
1980
+ "Directory to save the WebP into (default <project>/public/images)",
1981
+ },
1982
+ project_dir: {
1983
+ type: "string",
1984
+ description: "Project path (default: current directory)",
1985
+ },
1986
+ },
1987
+ },
1988
+ },
1723
1989
  {
1724
1990
  name: "edit_image",
1725
1991
  title: "Retouch an image: edit, redo, cutout, upscale, extend",
@@ -1769,7 +2035,7 @@ const TOOLS = [
1769
2035
  openWorldHint: true,
1770
2036
  },
1771
2037
  description:
1772
- "The site's art direction in one tool — action: 'setup' (lock the style from a short brief and/or the user's existing site_url; run FIRST on a new project, or let make_images infer it), 'refine' (plain-language feedback — « plus chaleureux » — becomes PERMANENT for every future image), 'lock_image' (« j'adore CELLE-LÀ, fais les autres pareil » — the approved image becomes the permanent style reference). Action inferred if omitted: image_path→lock_image, feedback→refine, otherwise setup. Free (optional moodboard billed as 1 image).",
2038
+ "The site's art direction in one tool — action: 'setup' (lock the style from a short brief and/or the user's existing site_url; run FIRST on a new project, or let make_images infer it), 'refine' (plain-language feedback — « plus chaleureux », OR a CORRECTION of a wrong/off-topic depiction such as « un VTC est un chauffeur privé, jamais un taxi » or « montre toujours le pro au travail, pas un client » the rule is RECORDED PERMANENTLY in the site's scene rules and obeyed by every future image, blog_cover included, so the same mistake is never reproduced), 'lock_image' (« j'adore CELLE-LÀ, fais les autres pareil » — the approved image becomes the permanent style reference). Action inferred if omitted: image_path→lock_image, feedback→refine, otherwise setup. Free (optional moodboard billed as 1 image).",
1773
2039
  inputSchema: {
1774
2040
  type: "object",
1775
2041
  properties: {
@@ -1863,7 +2129,7 @@ const TOOLS = [
1863
2129
  "Lock a recurring character, product or place (identical everywhere)",
1864
2130
  annotations: {
1865
2131
  readOnlyHint: false,
1866
- destructiveHint: false,
2132
+ destructiveHint: true,
1867
2133
  openWorldHint: true,
1868
2134
  },
1869
2135
  description:
@@ -1957,6 +2223,7 @@ const TOOLS = [
1957
2223
  const TOOL_RUNNERS = {
1958
2224
  make_images: runMakeImages,
1959
2225
  generate_image: runGenerateImage,
2226
+ blog_cover: runBlogCover,
1960
2227
  edit_image: runEditImage,
1961
2228
  site_style: runSiteStyle,
1962
2229
  brand_pack: runBrandPack,
@@ -1964,6 +2231,8 @@ const TOOL_RUNNERS = {
1964
2231
  finish_images: runFinishImages,
1965
2232
  pack_status: runPackStatus,
1966
2233
  // Anciens noms — gardés en coulisse (compat tests / vieux clients).
2234
+ blog_image: (a, p) => runBlogCover(a, p),
2235
+ blog: (a, p) => runBlogCover(a, p),
1967
2236
  setup_style: (a, p) => runSiteStyle({ ...a, action: "setup" }, p),
1968
2237
  refine_style: (a, p) => runSiteStyle({ ...a, action: "refine" }, p),
1969
2238
  lock_style_image: (a, p) => runSiteStyle({ ...a, action: "lock_image" }, p),
@@ -2054,8 +2323,10 @@ THE EASY PATH (prefer it): write the page with <img src="https://placehold.co/12
2054
2323
  Also:
2055
2324
  - User provides a person's photo ("c'est lui le professeur") → create_reference (kind character, photo_path) FIRST, so make_images/generate_image reuse that exact face in every scene. A product that must stay identical in every shot → create_reference (kind product). Never paste the raw photo into the page when a styled scene would serve better.
2056
2325
  - User gives style feedback ("plus chaleureux") → site_style (action refine): the change becomes permanent. User approves ONE image and wants the rest the SAME ("j'adore celle-là, fais les autres pareil") → site_style (action lock_image) with that file.
2326
+ - User points out the image got the SUBJECT/trade/role WRONG or off-topic (e.g. "a VTC is a private chauffeur, not a taxi", "show the baker working, not a customer", wrong product) → site_style (action refine) with that correction: it is recorded as a permanent scene rule and obeyed by EVERY future image (blog_cover included), so the same off-topic is never reproduced. Do this the moment a correction is given — never just regenerate and hope.
2057
2327
  - EXISTING site whose real images look cheap/stock/off-brand → make_images with rebrand:true: the first call lists every replaceable image for free, then apply:true rebrands them ALL in place (code untouched, originals kept as *.original).
2058
2328
  - REVIEW / TESTIMONIAL sections: just put a placeholder <img> next to each review and run make_images — the avatars come out as ultra-real casual smartphone selfies (UGC look: car, bedroom, living room…), NOT brand photos. Same reviewer name = same face everywhere on the site; a face is NEVER reused on another site. Never ship a review section without these avatars.
2329
+ - An EDITORIAL ARTICLE that still LACKS a cover/hero image → use blog_cover (NOT make_images, NOT placeholder stock). GUARDRAIL against noise: ONE cover per article, only when it is missing — if the article already has a real cover, leave it (never regenerate); a pure text/typo/SEO edit is NOT a trigger; never re-run on your own initiative (each call bills). Recognise it by MEANING, not by tags: any standalone piece written to be READ — a headline plus a body of prose on one topic (blog post, news, guide, tutorial, case study, magazine/journal piece) — in ANY language and on ANY stack (markdown/MDX, a headless CMS like WordPress/Sanity/Contentful, or a custom DB-driven route). Structural cues are only NON-EXHAUSTIVE hints, none of them required: folders/routes such as /blog /posts /articles /news /journal /actualités /magazine /guides, markdown/MDX frontmatter, or an <article> element — but if it simply READS like an article, it qualifies even with none of these. (It is the article body, not a marketing/landing/product page — those keep make_images.) You are the one writing/editing it, so you ALREADY have its text: pass its own title + body as article_text (or article_url / article_file). blog_cover reads the article, illustrates its SPECIFIC subject (not a generic trade photo), matches the site's locked style, adapts to the site's country (driving side, architecture…), and returns a 16:9 WebP + ALT. Default = the cover only; pass illustrations:N for in-article images. No style locked yet? it infers one from the project; if it still lacks the trade/brand it tells you what to provide — give it, don't guess.
2059
2330
  - One-off image → generate_image. Retouch/redo/cutout/upscale/widen an existing one → edit_image with the right action.
2060
2331
  - Finish a page properly: brand_pack (logo + favicon + og:image in ONE call) then finish_images (ALT auto-fixed + WebP optimisation, free).
2061
2332
  COST DISCIPLINE — NEVER iterate on your own. ONE make_images call dresses a page: the job is then DONE. Do NOT regenerate, redo, retouch or "improve" an image on your own initiative, do NOT call tools in a loop, do NOT re-run a call that succeeded — every generation bills the subscriber's credits and burns their tokens. Retouch or regenerate ONLY when the USER explicitly asks for it.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "distribea-mcp",
3
- "version": "1.2.0",
4
- "description": "Distribea MCP, on-brand website imagery (style-locked, recurring characters, UGC review avatars) generated by the hosted Distribea engine. Requires a Distribea subscription key.",
3
+ "version": "1.3.1",
4
+ "description": "Distribea MCP, on-brand website imagery (style-locked, recurring characters, UGC review avatars, blog covers) generated by the hosted Distribea engine. Requires a Distribea subscription key.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "distribea-mcp": "index.mjs"