forgemesh-imagegen 2.1.2 → 2.2.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 +2 -0
  2. package/index.js +80 -23
  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,50 +34,52 @@ const TOOLS = [
30
34
  {
31
35
  name: "generate_image",
32
36
  annotations: GENERATE_ANNOTATIONS,
33
- description: "Generate a standard AI image from a text prompt. Returns a PNG image URL. 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: "Generate an AI image with background removed. Returns a transparent PNG URL. 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: "Generate a premium AI image upscaled 4x HD. Returns a high-resolution 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: "Generate a top-tier AI image with background removal and 4x HD upscale. 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",
56
60
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
57
- description: "List the 58 identity-preserving transform presets for transform_image (action figure, pet royalty, CEO portrait, cartoon villain, fantasy poster, pet astronaut, ...). Returns id, title, tagline and whether each expects a person or a pet photo. Costs $0.005 USDC on Base mainnet.",
61
+ description: "Photo transform presets: list the 58 identity-preserving image-to-image styles for transform_image (action figure, pet royalty, CEO portrait, cartoon villain, fantasy poster, pet astronaut, ...). Returns id, title, tagline and whether each expects a person or a pet photo. Costs $0.005 USDC on Base mainnet.",
58
62
  inputSchema: { type: "object", properties: {}, required: [] },
59
63
  },
60
64
  {
61
65
  name: "transform_image",
62
66
  annotations: GENERATE_ANNOTATIONS,
63
- description: "Transform a photo with a named preset while keeping the subject's identity (FLUX Kontext Max image-to-image). 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,61 @@ 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 detail = reason ? (typeof reason === "string" ? reason : JSON.stringify(reason)) : res.text.slice(0, 500);
159
+ const retryAfter = res.headers.get("retry-after");
160
+ return new Error(`HTTP ${res.status} (charged:${charged})${retryAfter ? ` retry-after:${retryAfter}s` : ""}: ${detail}`);
161
+ }
162
+
163
+ // Poll a job's free status_url until it succeeds or fails (up to ~90s).
164
+ async function pollJob(job) {
165
+ let result = job;
166
+ for (let i = 0; i < 30 && !["succeeded", "failed"].includes(result.status); i++) {
167
+ await sleep(3000);
168
+ const poll = await fetchBounded(job.status_url);
169
+ if (poll.ok) result = { ...job, ...parseJson(poll.text, poll.status) };
170
+ }
171
+ return result;
172
+ }
173
+
174
+ // Shape an async job for the tool result, with the service's retry/manual-review guidance on failure.
175
+ function jobResult(job, result) {
176
+ const out = {
177
+ job_id: result.job_id, status: result.status, tier: result.tier || job.tier, preset: job.preset,
178
+ image_url: result.image_url || null, expires_at: result.expires_at || null,
179
+ error: result.error || null, status_url: job.status_url,
180
+ };
181
+ if (result.retry_credit) out.retry_credit = result.retry_credit;
182
+ if (result.status === "failed") {
183
+ out.note = result.retry_credit
184
+ ? "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)."
185
+ : "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).";
186
+ } else if (!["succeeded"].includes(result.status)) {
187
+ out.note = "Job not finished yet; poll status_url (free) until status is succeeded or failed.";
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function parseRetryCredit(args, account) {
193
+ if (args.retry_credit === undefined) return {};
194
+ 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");
195
+ return { retry_credit: args.retry_credit, wallet: account.address };
196
+ }
197
+
144
198
  function requirePrompt(args) {
145
199
  if (!args || typeof args.prompt !== "string" || !args.prompt.trim()) throw new Error("prompt is required and must be a non-empty string");
146
200
  if (args.prompt.length > MAX_PROMPT_CHARS) throw new Error(`prompt must be at most ${MAX_PROMPT_CHARS} characters`);
@@ -181,28 +235,31 @@ async function main() {
181
235
  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
236
  const taspect = args.aspect || "match_input_image";
183
237
  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
- }
238
+ const job = await callPaid(ctx, endpoint.path, { image_url: args.image_url, preset: args.preset, aspect: taspect, ...parseRetryCredit(args, ctx.account) });
239
+ const result = args.wait !== false && typeof job.status_url === "string" ? await pollJob(job) : job;
193
240
  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) }],
241
+ content: [{ type: "text", text: JSON.stringify(jobResult(job, result), null, 2) }],
195
242
  isError: result.status === "failed",
196
243
  };
197
244
  }
198
245
 
199
246
  const { prompt, aspect } = requirePrompt(args);
247
+ if (ASYNC_TOOLS.has(name)) {
248
+ const job = await callPaid(ctx, endpoint.path, { prompt, aspect, ...parseRetryCredit(args, ctx.account) });
249
+ const result = args.wait !== false && typeof job.status_url === "string" ? await pollJob(job) : job;
250
+ return {
251
+ content: [{ type: "text", text: JSON.stringify(jobResult(job, result), null, 2) }],
252
+ isError: result.status === "failed",
253
+ };
254
+ }
200
255
  const data = await callPaid(ctx, endpoint.path, { prompt, aspect });
201
256
  return {
202
257
  content: [{
203
258
  type: "text",
204
259
  text: JSON.stringify({
205
260
  image_url: data.image_url,
261
+ expires_at: data.expires_at || null,
262
+ model: data.model,
206
263
  prompt: data.prompt,
207
264
  aspect: data.aspect,
208
265
  tier: name.replace("generate_", "") || "base",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgemesh-imagegen",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
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",