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.
- package/README.md +23 -0
- package/index.mjs +283 -12
- 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 {
|
|
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
|
|
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
|
|
313
|
-
|
|
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
|
|
320
|
-
context
|
|
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, """)}" 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
|
|
1453
|
-
|
|
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
|
|
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:
|
|
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 » —
|
|
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:
|
|
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.
|
|
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"
|