forgemesh-imagegen 2.1.3 → 2.2.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 +2 -0
  2. package/index.js +88 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,6 +23,8 @@ Part of the [ForgeMesh](https://github.com/forgemeshlabs/forgemesh) ecosystem
23
23
  The four `generate_*` tools accept `prompt` (required) and `aspect` (optional: `1:1`, `16:9`, `9:16`, `4:3`).
24
24
  `transform_image` takes a public `image_url`, a `preset` id from `list_presets`, an optional `aspect` (`match_input_image` default, or `1:1`, `16:9`, `9:16`, `4:3`, `3:4`) and `wait` (default true: polls the free status URL until the PNG is ready, usually 15-30s). Submit only images you have rights to; no minors; the provider safety filter applies.
25
25
 
26
+ `generate_hd`, `generate_pro` and `transform_image` run as async jobs: the tool polls the free status URL (default `wait` true, up to ~90s) and returns `image_url` plus `expires_at` (images are stored 7 days). If a job fails on the service's side (infrastructure or model host), the result carries a `retry_credit`; call the same tool again with `retry_credit` set to that value for one free retry (single use, 72 hours, same wallet, no new payment). Safety rejections and unclear failures after payment get no automatic retry and are logged for manual review (hello@forgemesh.io, quote the job id). Synchronous `generate_image` / `generate_clean` renders not finished within 85 seconds answer 504 and are not charged.
27
+
26
28
  ---
27
29
 
28
30
  ## Install
package/index.js CHANGED
@@ -16,12 +16,16 @@ const REQUEST_TIMEOUT_MS = 60_000;
16
16
  const MAX_RESPONSE_BYTES = 1_000_000;
17
17
  const MAX_PROMPT_CHARS = 2_000;
18
18
 
19
+ const UUID_RE_SRC = "^[0-9a-fA-F-]{36}$";
19
20
  const GENERATE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
20
- const promptSchema = (subject) => ({
21
+ const RETRY_CREDIT_DESC = "Optional retry credit: the retry_credit value (a job id) from a failed job's status. Redeems one free retry of the same route (single use, 72 hours, bound to the paying wallet, no new payment). Only issued for failures on the service's side (infrastructure or model host).";
22
+ const WAIT_DESC = "Poll the job until it finishes and return the final image_url (default true, up to ~90s). Set false to get the job_id and status_url immediately.";
23
+ const promptSchema = (subject, job) => ({
21
24
  type: "object",
22
25
  properties: {
23
26
  prompt: { type: "string", maxLength: MAX_PROMPT_CHARS, description: subject },
24
27
  aspect: { type: "string", enum: VALID_ASPECTS, description: "Aspect ratio — 1:1 (default), 16:9, 9:16, 4:3" },
28
+ ...(job ? { wait: { type: "boolean", description: WAIT_DESC }, retry_credit: { type: "string", pattern: UUID_RE_SRC, description: RETRY_CREDIT_DESC } } : {}),
25
29
  },
26
30
  required: ["prompt"],
27
31
  });
@@ -30,26 +34,26 @@ const TOOLS = [
30
34
  {
31
35
  name: "generate_image",
32
36
  annotations: GENERATE_ANNOTATIONS,
33
- description: "Image generation: text prompt to image (text-to-image) with FLUX Schnell. Pass prompt and optional aspect (1:1, 16:9, 9:16 or 4:3). Returns the image URL with prompt, aspect and model. Use it for illustrations, thumbnails, mockups and social images. Costs $0.05 USDC on Base mainnet, paid automatically.",
37
+ description: "Image generation: text prompt to image (text-to-image) with FLUX Schnell. Pass prompt and optional aspect (1:1, 16:9, 9:16 or 4:3). Returns JSON in one synchronous call with image_url (stored 7 days on the service), expires_at, prompt, aspect, model and generated_at. Prompt is required. Use it for illustrations, thumbnails, mockups, concept art and social images. A render not finished within 85 seconds is a 504 and is not charged. Costs $0.05 USDC on Base mainnet, paid automatically.",
34
38
  inputSchema: promptSchema("Text description of the image to generate"),
35
39
  },
36
40
  {
37
41
  name: "generate_clean",
38
42
  annotations: GENERATE_ANNOTATIONS,
39
- description: "Image generation with transparent background: text prompt to image with FLUX Schnell, then automatic background removal. Returns the URL of a transparent PNG cutout. Use it for product cutouts, stickers and logo marks. Costs $0.35 USDC on Base mainnet.",
43
+ description: "Image generation with transparent background: text prompt to image with FLUX Schnell, then automatic background removal. Returns image_url (transparent PNG cutout, stored 7 days) with expires_at, prompt, aspect, tier, model and generated_at. Prompt is required. Use it for product cutouts, stickers and logo marks. Costs $0.35 USDC on Base mainnet.",
40
44
  inputSchema: promptSchema("Text description of the subject (background will be removed)"),
41
45
  },
42
46
  {
43
47
  name: "generate_hd",
44
48
  annotations: GENERATE_ANNOTATIONS,
45
- description: "High-resolution image generation: text prompt to image with FLUX 1.1 Pro plus a 4x Real-ESRGAN upscale, run as an async job. Returns the job (job_id, status, status_url); poll status_url until it succeeds to read image_url. Costs $0.10 USDC on Base mainnet.",
46
- inputSchema: promptSchema("Text description of the image to generate"),
49
+ description: "High-resolution image generation: text prompt to image with FLUX 1.1 Pro plus a 4x Real-ESRGAN upscale, run as an async job. The paid call returns an async job (job_id, status, status_url); this tool polls the free status_url until it succeeds and returns image_url (stored 7 days) with expires_at (wait=false returns the job immediately). If the job fails on the service's side (infrastructure or model host), the result carries a retry_credit: pass it as retry_credit to redo the same request once for free (72 hours, single use, same wallet). Failures without a retry_credit (e.g. a safety rejection or an unclear failure after payment) get no automatic retry and are logged for manual review (hello@forgemesh.io, quote the job id). Costs $0.10 USDC on Base mainnet.",
50
+ inputSchema: promptSchema("Text description of the image to generate", true),
47
51
  },
48
52
  {
49
53
  name: "generate_pro",
50
54
  annotations: GENERATE_ANNOTATIONS,
51
- description: "Premium image generation: text prompt to image with FLUX Kontext Max, background removal and a 4x upscale, run as an async job. Returns the job (job_id, status, status_url); poll status_url until it succeeds to read image_url. Costs $0.75 USDC on Base mainnet.",
52
- inputSchema: promptSchema("Text description of the subject (background removed, then upscaled)"),
55
+ description: "Premium image generation: text prompt to image with FLUX Kontext Max, background removal and a 4x upscale, run as an async job. The paid call returns an async job (job_id, status, status_url); this tool polls the free status_url until it succeeds and returns image_url (transparent, high-resolution, stored 7 days) with expires_at (wait=false returns the job immediately). If the job fails on the service's side (infrastructure or model host), the result carries a retry_credit: pass it as retry_credit to redo the same request once for free (72 hours, single use, same wallet). Failures without a retry_credit (e.g. a safety rejection or an unclear failure after payment) get no automatic retry and are logged for manual review (hello@forgemesh.io, quote the job id). Costs $0.75 USDC on Base mainnet.",
56
+ inputSchema: promptSchema("Text description of the subject (background removed, then upscaled)", true),
53
57
  },
54
58
  {
55
59
  name: "list_presets",
@@ -60,20 +64,22 @@ const TOOLS = [
60
64
  {
61
65
  name: "transform_image",
62
66
  annotations: GENERATE_ANNOTATIONS,
63
- description: "Photo transform (image-to-image): restyle a photo with a named preset while keeping the subject's identity, using FLUX Kontext Max. Pass a public https image URL and a preset id from list_presets. Returns a job with a free status_url to poll until the PNG is ready (usually 15-30s). Costs $0.75 USDC on Base mainnet. Submit only images you have rights to; no minors; provider safety filter applies.",
67
+ description: "Photo transform (image-to-image): restyle a photo with a named preset while keeping the subject's identity, using FLUX Kontext Max. Pass a public https image URL and a preset id from list_presets. An unreachable or non-image URL (or an unknown preset or aspect) is refused before payment and is not charged. Returns a job with a free status_url, polled until the PNG is ready (usually 15-30s), then image_url (stored 7 days) with expires_at. If the render fails on the service's side (infrastructure or model host), the result carries a retry_credit: pass it as retry_credit to redo the same request once for free (72 hours, single use, same wallet). An image the safety filter declines after payment, or any other unclear failure, earns no automatic retry; it is logged for manual review (hello@forgemesh.io, quote the job id). Costs $0.75 USDC on Base mainnet. Submit only images you have rights to; no minors; provider safety filter applies.",
64
68
  inputSchema: {
65
69
  type: "object",
66
70
  properties: {
67
71
  image_url: { type: "string", format: "uri", maxLength: 2048, description: "Public https URL of the source photo (JPEG, PNG or WebP)" },
68
72
  preset: { type: "string", pattern: "^[a-z0-9-]{1,64}$", description: "Preset id from list_presets, e.g. action-figure, pet-royal, ceo-portrait" },
69
73
  aspect: { type: "string", enum: TRANSFORM_ASPECTS, description: "Output aspect (default match_input_image)" },
70
- wait: { type: "boolean", description: "Poll the job until it finishes and return the final image_url (default true, up to ~90s)" },
74
+ wait: { type: "boolean", description: WAIT_DESC },
75
+ retry_credit: { type: "string", pattern: UUID_RE_SRC, description: RETRY_CREDIT_DESC },
71
76
  },
72
77
  required: ["image_url", "preset"],
73
78
  },
74
79
  },
75
80
  ];
76
81
 
82
+ const ASYNC_TOOLS = new Set(["generate_hd", "generate_pro"]);
77
83
  const TOOL_ENDPOINTS = {
78
84
  generate_image: { path: "/generate", price: "$0.05" },
79
85
  generate_clean: { path: "/generate/clean", price: "$0.35" },
@@ -125,7 +131,7 @@ function parseJson(text, status) {
125
131
  async function callPaid(ctx, path, params) {
126
132
  const { httpClient } = ctx;
127
133
  const url = new URL(BASE_URL + path);
128
- if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
134
+ if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null) url.searchParams.set(k, v); });
129
135
 
130
136
  const res = await fetchBounded(url);
131
137
  if (res.status === 402) {
@@ -134,13 +140,67 @@ async function callPaid(ctx, path, params) {
134
140
  const paymentRequired = httpClient.getPaymentRequiredResponse((name) => res.headers.get(name), body);
135
141
  const paymentPayload = await httpClient.createPaymentPayload(paymentRequired);
136
142
  const paid = await fetchBounded(url, { headers: httpClient.encodePaymentSignatureHeader(paymentPayload) });
137
- if (!paid.ok) throw new Error(`Payment failed — HTTP ${paid.status}: ${paid.text.slice(0, 200)}`);
143
+ if (!paid.ok) throw refusalError(paid, true);
138
144
  return parseJson(paid.text, paid.status);
139
145
  }
140
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.text.slice(0, 200)}`);
146
+ if (!res.ok) throw refusalError(res, false);
141
147
  return parseJson(res.text, res.status);
142
148
  }
143
149
 
150
+ // Surface the service's own refusal: HTTP status, charged flag, reason text, Retry-After.
151
+ function refusalError(res, paidAttempt) {
152
+ let body; try { body = JSON.parse(res.text); } catch { body = undefined; }
153
+ let charged;
154
+ if (body && typeof body === "object" && typeof body.charged === "boolean") charged = body.charged;
155
+ else if (!paidAttempt) charged = false;
156
+ else charged = res.headers.get("payment-response") || res.headers.get("x-payment-response") ? true : "unknown (no settlement receipt)";
157
+ const reason = body && typeof body === "object" ? (body.error || body.reason || body.message || body.invalidReason) : undefined;
158
+ const retryAfter = res.headers.get("retry-after");
159
+ // Fixed message (status, charged, optional short error code); the service body travels only as capped data.
160
+ const code = typeof reason === "string" && /^[a-z0-9_.:-]{1,64}$/i.test(reason) ? `: ${reason}` : "";
161
+ const err = new Error(`HTTP ${res.status} (charged:${charged})${retryAfter ? ` retry-after:${retryAfter}s` : ""}${code}`);
162
+ err.status = res.status;
163
+ err.charged = charged;
164
+ const raw = body && typeof body === "object" ? JSON.stringify(body) : res.text;
165
+ err.upstream = body && typeof body === "object" && raw.length <= 1500 ? body : raw.slice(0, body && typeof body === "object" ? 1500 : 500);
166
+ return err;
167
+ }
168
+
169
+ // Poll a job's free status_url until it succeeds or fails (up to ~90s).
170
+ async function pollJob(job) {
171
+ let result = job;
172
+ for (let i = 0; i < 30 && !["succeeded", "failed"].includes(result.status); i++) {
173
+ await sleep(3000);
174
+ const poll = await fetchBounded(job.status_url);
175
+ if (poll.ok) result = { ...job, ...parseJson(poll.text, poll.status) };
176
+ }
177
+ return result;
178
+ }
179
+
180
+ // Shape an async job for the tool result, with the service's retry/manual-review guidance on failure.
181
+ function jobResult(job, result) {
182
+ const out = {
183
+ job_id: result.job_id, status: result.status, tier: result.tier || job.tier, preset: job.preset,
184
+ image_url: result.image_url || null, expires_at: result.expires_at || null,
185
+ error: result.error || null, status_url: job.status_url,
186
+ };
187
+ if (result.retry_credit) out.retry_credit = result.retry_credit;
188
+ if (result.status === "failed") {
189
+ out.note = result.retry_credit
190
+ ? "Failed on the service's side. Call this tool again with the same arguments plus retry_credit set to the value above for one free retry (single use, 72 hours, same wallet, no new payment)."
191
+ : "Failed with no retry_credit: no automatic retry. Safety rejections and unclear failures after payment are logged for manual review (hello@forgemesh.io, quote the job_id).";
192
+ } else if (!["succeeded"].includes(result.status)) {
193
+ out.note = "Job not finished yet; poll status_url (free) until status is succeeded or failed.";
194
+ }
195
+ return out;
196
+ }
197
+
198
+ function parseRetryCredit(args, account) {
199
+ if (args.retry_credit === undefined) return {};
200
+ if (typeof args.retry_credit !== "string" || !/^[0-9a-fA-F-]{36}$/.test(args.retry_credit)) throw new Error("retry_credit must be the job id (uuid) from a failed job's retry_credit");
201
+ return { retry_credit: args.retry_credit, wallet: account.address };
202
+ }
203
+
144
204
  function requirePrompt(args) {
145
205
  if (!args || typeof args.prompt !== "string" || !args.prompt.trim()) throw new Error("prompt is required and must be a non-empty string");
146
206
  if (args.prompt.length > MAX_PROMPT_CHARS) throw new Error(`prompt must be at most ${MAX_PROMPT_CHARS} characters`);
@@ -181,28 +241,31 @@ async function main() {
181
241
  if (typeof args.preset !== "string" || !/^[a-z0-9-]{1,64}$/.test(args.preset)) throw new Error("preset must be a preset id from list_presets");
182
242
  const taspect = args.aspect || "match_input_image";
183
243
  if (!TRANSFORM_ASPECTS.includes(taspect)) throw new Error(`Invalid aspect '${taspect}'. Valid: ${TRANSFORM_ASPECTS.join(", ")}`);
184
- const job = await callPaid(ctx, endpoint.path, { image_url: args.image_url, preset: args.preset, aspect: taspect });
185
- let result = job;
186
- if (args.wait !== false && typeof job.status_url === "string") {
187
- for (let i = 0; i < 30 && !["succeeded", "failed"].includes(result.status); i++) {
188
- await sleep(3000);
189
- const poll = await fetchBounded(job.status_url);
190
- if (poll.ok) result = { ...job, ...parseJson(poll.text, poll.status) };
191
- }
192
- }
244
+ const job = await callPaid(ctx, endpoint.path, { image_url: args.image_url, preset: args.preset, aspect: taspect, ...parseRetryCredit(args, ctx.account) });
245
+ const result = args.wait !== false && typeof job.status_url === "string" ? await pollJob(job) : job;
193
246
  return {
194
- content: [{ type: "text", text: JSON.stringify({ job_id: result.job_id, status: result.status, preset: job.preset, image_url: result.image_url || null, error: result.error || null, status_url: job.status_url }, null, 2) }],
247
+ content: [{ type: "text", text: JSON.stringify(jobResult(job, result), null, 2) }],
195
248
  isError: result.status === "failed",
196
249
  };
197
250
  }
198
251
 
199
252
  const { prompt, aspect } = requirePrompt(args);
253
+ if (ASYNC_TOOLS.has(name)) {
254
+ const job = await callPaid(ctx, endpoint.path, { prompt, aspect, ...parseRetryCredit(args, ctx.account) });
255
+ const result = args.wait !== false && typeof job.status_url === "string" ? await pollJob(job) : job;
256
+ return {
257
+ content: [{ type: "text", text: JSON.stringify(jobResult(job, result), null, 2) }],
258
+ isError: result.status === "failed",
259
+ };
260
+ }
200
261
  const data = await callPaid(ctx, endpoint.path, { prompt, aspect });
201
262
  return {
202
263
  content: [{
203
264
  type: "text",
204
265
  text: JSON.stringify({
205
266
  image_url: data.image_url,
267
+ expires_at: data.expires_at || null,
268
+ model: data.model,
206
269
  prompt: data.prompt,
207
270
  aspect: data.aspect,
208
271
  tier: name.replace("generate_", "") || "base",
@@ -211,6 +274,9 @@ async function main() {
211
274
  }],
212
275
  };
213
276
  } catch (e) {
277
+ if (e && e.status !== undefined) {
278
+ return { content: [{ type: "text", text: JSON.stringify({ error: e.message, status: e.status, charged: e.charged, upstream: e.upstream, untrusted: true }) }], isError: true };
279
+ }
214
280
  return { content: [{ type: "text", text: "Error: " + e.message }], isError: true };
215
281
  }
216
282
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgemesh-imagegen",
3
- "version": "2.1.3",
3
+ "version": "2.2.1",
4
4
  "mcpName": "io.github.forgemeshlabs/imagegen-mcp",
5
5
  "description": "MCP server for AI image generation — generate, clean (bg removal), HD (4x upscale), Pro. Pay per image in USDC via x402 on Base. No API key needed.",
6
6
  "main": "index.js",