distribea-mcp 1.2.0 → 1.3.0

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 +201 -4
  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 {
@@ -820,6 +820,130 @@ async function runGenerateImage(args, progress) {
820
820
  .join("\n");
821
821
  }
822
822
 
823
+ // --- blog cover ---------------------------------------------------------------
824
+ const ARTICLE_UA =
825
+ "Mozilla/5.0 (compatible; DistribeaImages/1.0; +https://distribea.com)";
826
+
827
+ // Résout le contenu de l'article : fichier local > URL publique > texte collé.
828
+ async function resolveArticle(projectDir, args) {
829
+ const file = args.article_file ?? args.article_path;
830
+ if (file) {
831
+ const p = resolveIn(projectDir, String(file));
832
+ let raw;
833
+ try {
834
+ raw = await readFile(p, "utf8");
835
+ } catch (e) {
836
+ throw new Error(`Article introuvable : ${p} (${e.message})`);
837
+ }
838
+ return { title: "", text: stripMarkup(raw).slice(0, 8000) };
839
+ }
840
+ if (args.article_url) {
841
+ const u = String(args.article_url).trim();
842
+ if (!/^https?:\/\//i.test(u)) {
843
+ throw new Error("article_url doit commencer par http:// ou https://");
844
+ }
845
+ let res;
846
+ try {
847
+ res = await fetch(u, {
848
+ headers: { "user-agent": ARTICLE_UA },
849
+ signal: AbortSignal.timeout(20_000),
850
+ redirect: "follow",
851
+ });
852
+ } catch (e) {
853
+ throw new Error(`Lecture de l'article impossible (${u}) : ${e.message}`);
854
+ }
855
+ if (!res.ok) {
856
+ throw new Error(
857
+ `Lecture de l'article impossible (HTTP ${res.status}) — ${u}`
858
+ );
859
+ }
860
+ const html = await res.text();
861
+ const title = oneLine(
862
+ html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""
863
+ );
864
+ const stripped = html
865
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
866
+ .replace(/<style[\s\S]*?<\/style>/gi, " ");
867
+ return { title, text: stripMarkup(stripped).slice(0, 8000) };
868
+ }
869
+ const t = String(args.article_text ?? args.article ?? "").trim();
870
+ return { title: "", text: t.slice(0, 8000) };
871
+ }
872
+
873
+ async function runBlogCover(args, progress) {
874
+ const projectDir = resolveIn(
875
+ process.cwd(),
876
+ args.project_dir ?? process.cwd()
877
+ );
878
+ const resolved = await resolveArticle(projectDir, args);
879
+ const title = oneLine(args.title ?? "") || resolved.title || "";
880
+ const text = resolved.text;
881
+ if (!(title || text)) {
882
+ throw new Error(
883
+ "Donne l'article : article_text (texte collé), article_url (lien public) ou article_file (fichier local)."
884
+ );
885
+ }
886
+ const extra = Math.max(
887
+ 0,
888
+ Math.min(5, Math.round(Number(args.illustrations ?? 0)))
889
+ );
890
+ const count = 1 + extra;
891
+ // Cover de blog : 16:9 ("wide") par défaut, standard. portrait/square au choix.
892
+ const orientation = ["wide", "landscape", "portrait", "square"].includes(
893
+ args.orientation
894
+ )
895
+ ? args.orientation
896
+ : "wide";
897
+ const saveDir = args.save_dir
898
+ ? resolveIn(projectDir, args.save_dir)
899
+ : join(projectDir, "public", "images");
900
+
901
+ progress?.(
902
+ `🎨 Cover de blog (${count} image${count > 1 ? "s" : ""}, ≈ ${IMAGE_CREDITS_HINT * count} crédits, 30-60 s)…`,
903
+ 0,
904
+ 1
905
+ );
906
+
907
+ const out = await engine("blog_cover", projectDir, {
908
+ title,
909
+ text,
910
+ count,
911
+ orientation,
912
+ character: args.character,
913
+ product: args.product,
914
+ pages_excerpt: pagesExcerpt(projectDir),
915
+ client_ref: "blog_cover",
916
+ });
917
+
918
+ const slugBase = slugify(title || text.slice(0, 60) || "article");
919
+ const images = Array.isArray(out.images) ? out.images : [];
920
+ const saved = await mapPool(images, 4, async (im, i) => {
921
+ const fileName =
922
+ im.role === "cover"
923
+ ? `blog-${slugBase}-cover.webp`
924
+ : `blog-${slugBase}-${i}.webp`;
925
+ const outPath = join(saveDir, fileName);
926
+ await saveUrl(im.cdn_url, outPath);
927
+ return { ...im, fileName, outPath };
928
+ });
929
+
930
+ const lines = [
931
+ out.style_inferred ? STYLE_INFERRED_NOTE : "",
932
+ `Cover de blog générée ✔ (${out.credits} crédits)`,
933
+ ];
934
+ for (const im of saved) {
935
+ lines.push(
936
+ "",
937
+ im.role === "cover" ? "🖼️ COVER" : "🖼️ illustration",
938
+ `file: ${im.outPath}`,
939
+ `size: ${im.width}×${im.height} — ${Math.round(im.bytes / 1024)} KB (WebP)`,
940
+ `alt: ${im.alt}`,
941
+ `<img src="/images/${im.fileName}" alt="${String(im.alt).replace(/"/g, "&quot;")}" width="${im.width}" height="${im.height}" loading="${im.role === "cover" ? "eager" : "lazy"}" />`
942
+ );
943
+ }
944
+ return lines.filter(Boolean).join("\n");
945
+ }
946
+
823
947
  const OUT_FORMAT_BY_EXT = {
824
948
  ".png": "png",
825
949
  ".jpg": "jpeg",
@@ -1499,9 +1623,13 @@ async function runRebrandImages(args, progress) {
1499
1623
  c.bytes = st.size;
1500
1624
  candidates.push(c);
1501
1625
  }
1626
+ const eligibleBeyondCap = Math.max(0, candidates.length - max);
1502
1627
  candidates.splice(max);
1503
1628
 
1504
1629
  const notes = [
1630
+ eligibleBeyondCap
1631
+ ? `+${eligibleBeyondCap} autre(s) image(s) au-delà de la limite (max_images=${max}) — monte max_images (jusqu'à 20) ou relance pour continuer.`
1632
+ : "",
1505
1633
  placeholders
1506
1634
  ? `${placeholders} placeholder(s)/stock détecté(s) aussi → make_images (sans rebrand) s'en charge.`
1507
1635
  : "",
@@ -1673,7 +1801,7 @@ const TOOLS = [
1673
1801
  title: "Generate one on-brand website image",
1674
1802
  annotations: {
1675
1803
  readOnlyHint: false,
1676
- destructiveHint: false,
1804
+ destructiveHint: true,
1677
1805
  openWorldHint: true,
1678
1806
  },
1679
1807
  description:
@@ -1720,6 +1848,70 @@ const TOOLS = [
1720
1848
  required: ["subject"],
1721
1849
  },
1722
1850
  },
1851
+ {
1852
+ name: "blog_cover",
1853
+ title: "Cover image (and optional illustrations) for a blog article",
1854
+ annotations: {
1855
+ readOnlyHint: false,
1856
+ destructiveHint: true,
1857
+ openWorldHint: true,
1858
+ },
1859
+ description:
1860
+ "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.",
1861
+ inputSchema: {
1862
+ type: "object",
1863
+ properties: {
1864
+ article_text: {
1865
+ type: "string",
1866
+ description: "The article's text (title + body), pasted directly",
1867
+ },
1868
+ article_url: {
1869
+ type: "string",
1870
+ description: "Public URL of the article — its text is read for you",
1871
+ },
1872
+ article_file: {
1873
+ type: "string",
1874
+ description:
1875
+ "Local file to read (md/mdx/html/txt…), relative to the project",
1876
+ },
1877
+ title: {
1878
+ type: "string",
1879
+ description:
1880
+ "Optional article title (overrides the one read from the source)",
1881
+ },
1882
+ illustrations: {
1883
+ type: "number",
1884
+ description:
1885
+ "Extra in-article images beyond the cover (default 0, max 5)",
1886
+ },
1887
+ orientation: {
1888
+ type: "string",
1889
+ enum: ["wide", "portrait", "square"],
1890
+ description:
1891
+ "Cover shape — default 'wide' (16:9, the standard blog-cover ratio). Use portrait/square only if the layout needs it.",
1892
+ },
1893
+ character: {
1894
+ type: "string",
1895
+ description:
1896
+ "Optional: name/role of a locked character to deliberately feature",
1897
+ },
1898
+ product: {
1899
+ type: "string",
1900
+ description:
1901
+ "Optional: name of a locked product to deliberately feature",
1902
+ },
1903
+ save_dir: {
1904
+ type: "string",
1905
+ description:
1906
+ "Directory to save the WebP into (default <project>/public/images)",
1907
+ },
1908
+ project_dir: {
1909
+ type: "string",
1910
+ description: "Project path (default: current directory)",
1911
+ },
1912
+ },
1913
+ },
1914
+ },
1723
1915
  {
1724
1916
  name: "edit_image",
1725
1917
  title: "Retouch an image: edit, redo, cutout, upscale, extend",
@@ -1769,7 +1961,7 @@ const TOOLS = [
1769
1961
  openWorldHint: true,
1770
1962
  },
1771
1963
  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).",
1964
+ "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
1965
  inputSchema: {
1774
1966
  type: "object",
1775
1967
  properties: {
@@ -1863,7 +2055,7 @@ const TOOLS = [
1863
2055
  "Lock a recurring character, product or place (identical everywhere)",
1864
2056
  annotations: {
1865
2057
  readOnlyHint: false,
1866
- destructiveHint: false,
2058
+ destructiveHint: true,
1867
2059
  openWorldHint: true,
1868
2060
  },
1869
2061
  description:
@@ -1957,6 +2149,7 @@ const TOOLS = [
1957
2149
  const TOOL_RUNNERS = {
1958
2150
  make_images: runMakeImages,
1959
2151
  generate_image: runGenerateImage,
2152
+ blog_cover: runBlogCover,
1960
2153
  edit_image: runEditImage,
1961
2154
  site_style: runSiteStyle,
1962
2155
  brand_pack: runBrandPack,
@@ -1964,6 +2157,8 @@ const TOOL_RUNNERS = {
1964
2157
  finish_images: runFinishImages,
1965
2158
  pack_status: runPackStatus,
1966
2159
  // Anciens noms — gardés en coulisse (compat tests / vieux clients).
2160
+ blog_image: (a, p) => runBlogCover(a, p),
2161
+ blog: (a, p) => runBlogCover(a, p),
1967
2162
  setup_style: (a, p) => runSiteStyle({ ...a, action: "setup" }, p),
1968
2163
  refine_style: (a, p) => runSiteStyle({ ...a, action: "refine" }, p),
1969
2164
  lock_style_image: (a, p) => runSiteStyle({ ...a, action: "lock_image" }, p),
@@ -2054,8 +2249,10 @@ THE EASY PATH (prefer it): write the page with <img src="https://placehold.co/12
2054
2249
  Also:
2055
2250
  - 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
2251
  - 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.
2252
+ - 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
2253
  - 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
2254
  - 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.
2255
+ - 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
2256
  - One-off image → generate_image. Retouch/redo/cutout/upscale/widen an existing one → edit_image with the right action.
2060
2257
  - Finish a page properly: brand_pack (logo + favicon + og:image in ONE call) then finish_images (ALT auto-fixed + WebP optimisation, free).
2061
2258
  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.0",
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"