distribea-mcp 1.1.2 → 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 +217 -15
  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",
@@ -969,18 +1093,21 @@ async function runCreateReference(args) {
969
1093
  ? await fileToDataUri(resolveIn(projectDir, args.photo_path))
970
1094
  : undefined;
971
1095
 
972
- if (args.kind === "product") {
1096
+ if (args.kind === "product" || args.kind === "place") {
973
1097
  const out = await engine("create_product", projectDir, {
974
1098
  name: args.name,
975
1099
  description: args.description,
976
1100
  photo,
977
1101
  pages_excerpt: excerpt,
1102
+ kind: args.kind,
978
1103
  });
979
1104
  return [
980
- "Product locked ✔",
1105
+ `${args.kind === "place" ? "Place" : "Product"} locked ✔`,
981
1106
  `name: ${out.name}`,
982
1107
  `look: ${out.description}`,
983
- `Cet objet restera IDENTIQUE dans toutes les images qui le citent (param product: "${out.name}", ou automatiquement quand son nom apparaît près d'un placeholder).`,
1108
+ args.kind === "place"
1109
+ ? `Ce LIEU sera reconstruit À L'IDENTIQUE dans toutes les images qui le citent (param product: "${out.name}", ou automatiquement quand son nom apparaît près d'un placeholder).`
1110
+ : `Cet objet restera IDENTIQUE dans toutes les images qui le citent (param product: "${out.name}", ou automatiquement quand son nom apparaît près d'un placeholder).`,
984
1111
  ].join("\n");
985
1112
  }
986
1113
  const out = await engine("create_character", projectDir, {
@@ -1496,9 +1623,13 @@ async function runRebrandImages(args, progress) {
1496
1623
  c.bytes = st.size;
1497
1624
  candidates.push(c);
1498
1625
  }
1626
+ const eligibleBeyondCap = Math.max(0, candidates.length - max);
1499
1627
  candidates.splice(max);
1500
1628
 
1501
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
+ : "",
1502
1633
  placeholders
1503
1634
  ? `${placeholders} placeholder(s)/stock détecté(s) aussi → make_images (sans rebrand) s'en charge.`
1504
1635
  : "",
@@ -1670,7 +1801,7 @@ const TOOLS = [
1670
1801
  title: "Generate one on-brand website image",
1671
1802
  annotations: {
1672
1803
  readOnlyHint: false,
1673
- destructiveHint: false,
1804
+ destructiveHint: true,
1674
1805
  openWorldHint: true,
1675
1806
  },
1676
1807
  description:
@@ -1717,6 +1848,70 @@ const TOOLS = [
1717
1848
  required: ["subject"],
1718
1849
  },
1719
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
+ },
1720
1915
  {
1721
1916
  name: "edit_image",
1722
1917
  title: "Retouch an image: edit, redo, cutout, upscale, extend",
@@ -1766,7 +1961,7 @@ const TOOLS = [
1766
1961
  openWorldHint: true,
1767
1962
  },
1768
1963
  description:
1769
- "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).",
1770
1965
  inputSchema: {
1771
1966
  type: "object",
1772
1967
  properties: {
@@ -1856,35 +2051,37 @@ const TOOLS = [
1856
2051
  },
1857
2052
  {
1858
2053
  name: "create_reference",
1859
- title: "Lock a recurring character or product (identical everywhere)",
2054
+ title:
2055
+ "Lock a recurring character, product or place (identical everywhere)",
1860
2056
  annotations: {
1861
2057
  readOnlyHint: false,
1862
- destructiveHint: false,
2058
+ destructiveHint: true,
1863
2059
  openWorldHint: true,
1864
2060
  },
1865
2061
  description:
1866
- "Lock a RECURRING reference reused identically across every image — kind 'character' (default): the same FACE everywhere, auto-cast or locked from a real photo via photo_path (« c'est lui le professeur » → call this FIRST so every scene reuses that exact face); kind 'product' (e-commerce): the exact same object in every shot. make_images and generate_image use them automatically when their name appears near a slot.",
2062
+ "Lock a RECURRING reference reused identically across every image — kind 'character' (default): the same FACE everywhere, auto-cast or locked from a real photo via photo_path (« c'est lui le professeur » → call this FIRST so every scene reuses that exact face); kind 'product' (e-commerce): the exact same object in every shot; kind 'place': the brand's real location (shop, workshop…) rebuilt identically in every scene (« voilà ma boutique » → photo_path). make_images and generate_image use them automatically when their name appears near a slot.",
1867
2063
  inputSchema: {
1868
2064
  type: "object",
1869
2065
  properties: {
1870
2066
  kind: {
1871
2067
  type: "string",
1872
- enum: ["character", "product"],
1873
- description: "Default 'character'",
2068
+ enum: ["character", "product", "place"],
2069
+ description:
2070
+ "Default 'character'. 'place' = the brand's real location rebuilt identically",
1874
2071
  },
1875
2072
  name: {
1876
2073
  type: "string",
1877
2074
  description:
1878
- "Character role (e.g. 'la pâtissière') or product name as written on the site",
2075
+ "Character role (e.g. 'la pâtissière'), product name as written on the site, or place name (e.g. 'la boulangerie')",
1879
2076
  },
1880
2077
  description: {
1881
2078
  type: "string",
1882
- description: "product: optional physical description",
2079
+ description: "product/place: optional physical description",
1883
2080
  },
1884
2081
  photo_path: {
1885
2082
  type: "string",
1886
2083
  description:
1887
- "Optional absolute path to a real photo to lock (face or product)",
2084
+ "Optional absolute path to a real photo to lock (face, product or place)",
1888
2085
  },
1889
2086
  project_dir: {
1890
2087
  type: "string",
@@ -1952,6 +2149,7 @@ const TOOLS = [
1952
2149
  const TOOL_RUNNERS = {
1953
2150
  make_images: runMakeImages,
1954
2151
  generate_image: runGenerateImage,
2152
+ blog_cover: runBlogCover,
1955
2153
  edit_image: runEditImage,
1956
2154
  site_style: runSiteStyle,
1957
2155
  brand_pack: runBrandPack,
@@ -1959,6 +2157,8 @@ const TOOL_RUNNERS = {
1959
2157
  finish_images: runFinishImages,
1960
2158
  pack_status: runPackStatus,
1961
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),
1962
2162
  setup_style: (a, p) => runSiteStyle({ ...a, action: "setup" }, p),
1963
2163
  refine_style: (a, p) => runSiteStyle({ ...a, action: "refine" }, p),
1964
2164
  lock_style_image: (a, p) => runSiteStyle({ ...a, action: "lock_image" }, p),
@@ -2049,8 +2249,10 @@ THE EASY PATH (prefer it): write the page with <img src="https://placehold.co/12
2049
2249
  Also:
2050
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.
2051
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.
2052
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).
2053
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.
2054
2256
  - One-off image → generate_image. Retouch/redo/cutout/upscale/widen an existing one → edit_image with the right action.
2055
2257
  - Finish a page properly: brand_pack (logo + favicon + og:image in ONE call) then finish_images (ALT auto-fixed + WebP optimisation, free).
2056
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.
@@ -2077,7 +2279,7 @@ async function handle(msg) {
2077
2279
  serverInfo: {
2078
2280
  name: "distribea-mcp",
2079
2281
  title: "Distribea MCP",
2080
- version: "1.1.1",
2282
+ version: "1.2.0",
2081
2283
  },
2082
2284
  instructions: INSTRUCTIONS,
2083
2285
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "distribea-mcp",
3
- "version": "1.1.2",
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"