@0xmaxma/claude-gateway 1.3.32 → 1.4.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.
- package/dist/agent/runner.d.ts +22 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +76 -3
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +36 -3
- package/dist/api/router.js.map +1 -1
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +30 -0
- package/dist/session/process.js.map +1 -1
- package/dist/session/store.d.ts +3 -2
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +1 -1
- package/dist/session/store.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/mcp/instructions.ts +39 -0
- package/mcp/server.ts +6 -11
- package/mcp/tools/discord/module.ts +19 -1
- package/mcp/tools/image/module.ts +592 -0
- package/package.json +1 -1
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as dns from 'node:dns';
|
|
4
|
+
import * as net from 'node:net';
|
|
5
|
+
import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Image-generation tool module (#184, Track B).
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the browser module shape: an env-configured image-service endpoint reached
|
|
11
|
+
* with `Authorization: Bearer <proxy_secret>` (the same M2M secret the LLM proxy
|
|
12
|
+
* path already uses — contract §0/D16), a single `generate_image` tool with
|
|
13
|
+
* generate | status | list actions (D18), and results written into the session
|
|
14
|
+
* media dir (like browser_screenshot) so the existing reply tools deliver them.
|
|
15
|
+
*
|
|
16
|
+
* Flow (contract E1/E2): generate → POST /v1/images/generations (202 { task_id })
|
|
17
|
+
* → poll GET /v1/images/jobs/:id → on done, write each b64 image into
|
|
18
|
+
* GATEWAY_SESSION_MEDIA_DIR and return the absolute path(s).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
22
|
+
const DEFAULT_POLL_TIMEOUT_MS = 150_000;
|
|
23
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
24
|
+
// deliver() hardening: cap per-image download (a provider URL could stream GBs →
|
|
25
|
+
// OOM) and cap how many images we write per job (unbounded sequential downloads
|
|
26
|
+
// would hang the tool ~N×30s).
|
|
27
|
+
const DOWNLOAD_MAX_BYTES = 25 * 1024 * 1024; // 25 MB per image
|
|
28
|
+
const MAX_DELIVER_IMAGES = 10;
|
|
29
|
+
|
|
30
|
+
/** Human-readable guidance per api error code (contract §6 taxonomy). */
|
|
31
|
+
const ERROR_HINTS: Record<string, string> = {
|
|
32
|
+
invalid_model: 'The model id is not recognised. Call generate_image with action="list" to see valid image models.',
|
|
33
|
+
model_not_image: 'That model is not an image model. Use action="list" to pick an image-capable model.',
|
|
34
|
+
missing_prompt: 'A non-empty prompt is required to generate an image.',
|
|
35
|
+
unsupported_quality: 'The requested quality is not supported by this model. Check supported_qualities from action="list".',
|
|
36
|
+
image_ref_unsupported: 'This model cannot take a reference image (supports_image_ref is false). Either retry WITHOUT the "image" param — look at the reference image yourself and describe it in the prompt (text-to-image) — or switch to a model whose supports_image_ref is true (see action="list").',
|
|
37
|
+
unauthorized: 'The gateway is not authorised to call the image service (check the proxy secret).',
|
|
38
|
+
insufficient_credit: 'Not enough daily credit to generate this image on the managed pool. Connect your own provider key (BYOK) or try later.',
|
|
39
|
+
no_credential: 'No provider key is available: connect your own key (BYOK) or pick a pool-eligible model.',
|
|
40
|
+
rate_limited: 'Image generation is rate-limited right now. Wait a moment and try again.',
|
|
41
|
+
no_supply: 'No managed provider key is available for this provider right now. Try a different model or use BYOK.',
|
|
42
|
+
provider_error: 'The image provider returned an error. Try again or adjust the prompt.',
|
|
43
|
+
provider_timeout: 'The image provider timed out. Try again.',
|
|
44
|
+
content_policy: 'The prompt was rejected by the provider content policy. Rephrase and try again.',
|
|
45
|
+
job_not_found: 'That image job was not found (it may have expired or belongs to another user).',
|
|
46
|
+
result_expired: 'The generated image expired before it was fetched (credit was already spent). Generate it again.',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type JobResponse = {
|
|
50
|
+
task_id?: string;
|
|
51
|
+
status?: 'queued' | 'running' | 'done' | 'failed';
|
|
52
|
+
byok?: boolean;
|
|
53
|
+
cost?: number;
|
|
54
|
+
images?: string[];
|
|
55
|
+
error?: { code?: string; message?: string };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export class ImageModule implements ToolModule {
|
|
59
|
+
id = 'image';
|
|
60
|
+
toolVisibility: ToolVisibility = 'all-configured';
|
|
61
|
+
|
|
62
|
+
isEnabled(): boolean {
|
|
63
|
+
// Enabled when the image service endpoint is configured (env-driven, like browser).
|
|
64
|
+
if (!this.baseUrl() || process.env.IMAGE_DISABLED === 'true') return false;
|
|
65
|
+
// The Bearer proxy_secret rides every call — refuse a cleartext http URL to a
|
|
66
|
+
// PUBLIC host (that would leak the secret). http to a local/internal host is a
|
|
67
|
+
// trusted hop (e.g. host.docker.internal in dev) and stays allowed.
|
|
68
|
+
if (!baseUrlIsSecure(this.baseUrl())) {
|
|
69
|
+
if (!this.warnedInsecureUrl) {
|
|
70
|
+
this.warnedInsecureUrl = true;
|
|
71
|
+
console.error(
|
|
72
|
+
`[image] ANTHROPIC_BASE_URL is http to a non-local host — refusing to send the proxy secret in cleartext. Use https (or a local/internal host).`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private warnedInsecureUrl = false;
|
|
81
|
+
|
|
82
|
+
getTools(): McpToolDefinition[] {
|
|
83
|
+
return imageToolDefs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
|
87
|
+
if (name !== 'generate_image') {
|
|
88
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
89
|
+
}
|
|
90
|
+
const action = typeof args.action === 'string' ? args.action : 'generate';
|
|
91
|
+
switch (action) {
|
|
92
|
+
case 'generate':
|
|
93
|
+
return this.handleGenerate(args);
|
|
94
|
+
case 'status':
|
|
95
|
+
return this.handleStatus(args);
|
|
96
|
+
case 'list':
|
|
97
|
+
return this.handleList();
|
|
98
|
+
default:
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: 'text', text: `generate_image: unknown action "${action}" (expected generate | status | list)` }],
|
|
101
|
+
isError: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── config ────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
private baseUrl(): string {
|
|
109
|
+
// Image generation can target any provider — not necessarily the same host as
|
|
110
|
+
// the LLM. IMAGE_BASE_URL overrides so an operator can point image at a separate
|
|
111
|
+
// endpoint; it falls back to ANTHROPIC_BASE_URL when they share one provider that
|
|
112
|
+
// fronts /v1/images/{generations,jobs} alongside /v1/messages.
|
|
113
|
+
const raw = process.env.IMAGE_BASE_URL || process.env.ANTHROPIC_BASE_URL || '';
|
|
114
|
+
return raw.replace(/\/+$/, '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private authToken(): string {
|
|
118
|
+
// Image API key overrides so a separate image endpoint can carry its own secret;
|
|
119
|
+
// falls back to ANTHROPIC_AUTH_TOKEN (the M2M proxy secret) when they share one.
|
|
120
|
+
return process.env.IMAGE_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private headers(): Record<string, string> {
|
|
124
|
+
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
125
|
+
const token = this.authToken();
|
|
126
|
+
if (token) h['Authorization'] = `Bearer ${token}`;
|
|
127
|
+
// Optional identity context for api-side logging/trace (contract §0).
|
|
128
|
+
const agentId = process.env.GATEWAY_AGENT_ID;
|
|
129
|
+
const sessionId = process.env.GATEWAY_SESSION_ID;
|
|
130
|
+
if (agentId) h['X-Agent-Id'] = agentId;
|
|
131
|
+
if (sessionId) h['X-Session-Id'] = sessionId;
|
|
132
|
+
return h;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── actions ───────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
private async handleList(): Promise<McpToolResult> {
|
|
138
|
+
const url = `${this.baseUrl()}/v1/models?kind=image`;
|
|
139
|
+
let res: Response;
|
|
140
|
+
try {
|
|
141
|
+
res = await fetch(url, { method: 'GET', headers: this.headers(), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return this.unavailable(err);
|
|
144
|
+
}
|
|
145
|
+
const body = await res.text().catch(() => '');
|
|
146
|
+
if (!res.ok) return this.mapHttpError(res.status, body);
|
|
147
|
+
return { content: [{ type: 'text', text: body || '[]' }] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async handleGenerate(args: Record<string, unknown>): Promise<McpToolResult> {
|
|
151
|
+
const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
|
|
152
|
+
const model = typeof args.model === 'string' ? args.model.trim() : '';
|
|
153
|
+
if (!prompt) {
|
|
154
|
+
return { content: [{ type: 'text', text: `${ERROR_HINTS.missing_prompt}` }], isError: true };
|
|
155
|
+
}
|
|
156
|
+
if (!model) {
|
|
157
|
+
return { content: [{ type: 'text', text: 'generate_image: "model" is required (use action="list" to see options).' }], isError: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Build request body — forward only defined optional fields (contract E1).
|
|
161
|
+
const reqBody: Record<string, unknown> = { model, prompt };
|
|
162
|
+
for (const k of ['quality', 'size', 'aspect_ratio', 'style'] as const) {
|
|
163
|
+
if (typeof args[k] === 'string' && (args[k] as string).length) reqBody[k] = args[k];
|
|
164
|
+
}
|
|
165
|
+
if (typeof args.n === 'number' && args.n > 0) reqBody.n = args.n;
|
|
166
|
+
if (typeof args.image === 'string' && args.image.length) reqBody.image = args.image;
|
|
167
|
+
if (Array.isArray(args.images) && args.images.every((x) => typeof x === 'string') && args.images.length) {
|
|
168
|
+
reqBody.images = args.images;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Submit (E1)
|
|
172
|
+
let res: Response;
|
|
173
|
+
try {
|
|
174
|
+
res = await fetch(`${this.baseUrl()}/v1/images/generations`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: this.headers(),
|
|
177
|
+
body: JSON.stringify(reqBody),
|
|
178
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return this.unavailable(err);
|
|
182
|
+
}
|
|
183
|
+
const submitText = await res.text().catch(() => '');
|
|
184
|
+
if (!res.ok) return this.mapHttpError(res.status, submitText);
|
|
185
|
+
|
|
186
|
+
let submit: JobResponse;
|
|
187
|
+
try {
|
|
188
|
+
submit = JSON.parse(submitText) as JobResponse;
|
|
189
|
+
} catch {
|
|
190
|
+
return { content: [{ type: 'text', text: 'generate_image: invalid JSON from image service on submit' }], isError: true };
|
|
191
|
+
}
|
|
192
|
+
const taskId = submit.task_id;
|
|
193
|
+
if (!taskId) {
|
|
194
|
+
return { content: [{ type: 'text', text: 'generate_image: image service did not return a task_id' }], isError: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Poll (E2) until done/failed or budget exceeded.
|
|
198
|
+
const deadline = Date.now() + this.pollTimeoutMs();
|
|
199
|
+
let last: JobResponse = submit;
|
|
200
|
+
while (Date.now() < deadline) {
|
|
201
|
+
await sleep(DEFAULT_POLL_INTERVAL_MS);
|
|
202
|
+
const polled = await this.fetchJob(taskId);
|
|
203
|
+
if (polled.__transportError) {
|
|
204
|
+
// transient transport error — keep polling until deadline
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (polled.httpError) return this.mapHttpError(polled.httpError.status, polled.httpError.body);
|
|
208
|
+
last = polled.job!;
|
|
209
|
+
if (last.status === 'done') return await this.deliver(last, taskId);
|
|
210
|
+
if (last.status === 'failed') return this.mapJobError(last);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Still running after the local poll budget — hand the task_id back so the
|
|
214
|
+
// agent can poll with action="status" (the api keeps the buffered result).
|
|
215
|
+
return {
|
|
216
|
+
content: [{
|
|
217
|
+
type: 'text',
|
|
218
|
+
text: JSON.stringify({
|
|
219
|
+
status: last.status ?? 'running',
|
|
220
|
+
task_id: taskId,
|
|
221
|
+
byok: last.byok ?? submit.byok ?? false,
|
|
222
|
+
cost: last.cost ?? submit.cost ?? 0,
|
|
223
|
+
note: 'Image is still generating. Call generate_image again with action="status" and this task_id to fetch the result.',
|
|
224
|
+
}),
|
|
225
|
+
}],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async handleStatus(args: Record<string, unknown>): Promise<McpToolResult> {
|
|
230
|
+
const taskId = typeof args.task_id === 'string' ? args.task_id.trim() : '';
|
|
231
|
+
if (!taskId) {
|
|
232
|
+
return { content: [{ type: 'text', text: 'generate_image: action="status" requires "task_id"' }], isError: true };
|
|
233
|
+
}
|
|
234
|
+
const polled = await this.fetchJob(taskId);
|
|
235
|
+
if (polled.__transportError) return this.unavailable(polled.__transportError);
|
|
236
|
+
if (polled.httpError) return this.mapHttpError(polled.httpError.status, polled.httpError.body);
|
|
237
|
+
const job = polled.job!;
|
|
238
|
+
if (job.status === 'done') return await this.deliver(job, taskId);
|
|
239
|
+
if (job.status === 'failed') return this.mapJobError(job);
|
|
240
|
+
return {
|
|
241
|
+
content: [{
|
|
242
|
+
type: 'text',
|
|
243
|
+
text: JSON.stringify({ status: job.status ?? 'running', task_id: taskId, byok: job.byok ?? false, cost: job.cost ?? 0 }),
|
|
244
|
+
}],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── helpers ─────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
private pollTimeoutMs(): number {
|
|
251
|
+
const raw = Number(process.env.IMAGE_POLL_TIMEOUT_MS);
|
|
252
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_POLL_TIMEOUT_MS;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Fetch a job (E2), classifying transport vs HTTP errors so the poller can retry transient ones. */
|
|
256
|
+
private async fetchJob(taskId: string): Promise<{ job?: JobResponse; httpError?: { status: number; body: string }; __transportError?: unknown }> {
|
|
257
|
+
let res: Response;
|
|
258
|
+
try {
|
|
259
|
+
res = await fetch(`${this.baseUrl()}/v1/images/jobs/${encodeURIComponent(taskId)}`, {
|
|
260
|
+
method: 'GET',
|
|
261
|
+
headers: this.headers(),
|
|
262
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
263
|
+
});
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return { __transportError: err };
|
|
266
|
+
}
|
|
267
|
+
const text = await res.text().catch(() => '');
|
|
268
|
+
if (!res.ok) return { httpError: { status: res.status, body: text } };
|
|
269
|
+
try {
|
|
270
|
+
return { job: JSON.parse(text) as JobResponse };
|
|
271
|
+
} catch {
|
|
272
|
+
return { httpError: { status: res.status, body: 'invalid JSON from image service' } };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Write done-job images into the session media dir and return their paths. Each
|
|
277
|
+
* item is either base64 bytes (sync providers like openai/gemini) OR an https URL
|
|
278
|
+
* (async providers like nanobanana/bfl/fal, which return a hosted file) — download
|
|
279
|
+
* URLs, decode base64. */
|
|
280
|
+
private async deliver(job: JobResponse, taskId: string): Promise<McpToolResult> {
|
|
281
|
+
const allImages = Array.isArray(job.images) ? job.images.filter((s) => typeof s === 'string' && s.length) : [];
|
|
282
|
+
// Cap how many we process — downloads are sequential (~30s each), so an
|
|
283
|
+
// over-long list would hang the tool call far past the poll budget.
|
|
284
|
+
const images = allImages.slice(0, MAX_DELIVER_IMAGES);
|
|
285
|
+
const droppedImages = allImages.length - images.length;
|
|
286
|
+
if (!images.length) {
|
|
287
|
+
// done but empty buffer → result_expired (credit already spent, D20)
|
|
288
|
+
const code = job.error?.code ?? 'result_expired';
|
|
289
|
+
const msg = job.error?.message ?? ERROR_HINTS[code] ?? 'The generated image is no longer available.';
|
|
290
|
+
return { content: [{ type: 'text', text: `${code}: ${msg}` }], isError: true };
|
|
291
|
+
}
|
|
292
|
+
const mediaDir = this.resolveMediaDir();
|
|
293
|
+
const files: string[] = [];
|
|
294
|
+
try {
|
|
295
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
296
|
+
for (let idx = 0; idx < images.length; idx++) {
|
|
297
|
+
const item = images[idx]!;
|
|
298
|
+
let buf: Buffer;
|
|
299
|
+
if (/^https?:\/\//i.test(item)) {
|
|
300
|
+
// Async providers (nanobanana / bfl / fal / runway) return a hosted image
|
|
301
|
+
// URL — download the bytes. SSRF guard first: https-only + block any host
|
|
302
|
+
// that resolves to a private/loopback/link-local/metadata address, and
|
|
303
|
+
// redirect:'error' so a later hop can't bounce to an internal target.
|
|
304
|
+
await assertSafeImageUrl(item);
|
|
305
|
+
const res = await fetch(item, { signal: AbortSignal.timeout(30_000), redirect: 'error' });
|
|
306
|
+
if (!res.ok) throw new Error(`download image failed: HTTP ${res.status}`);
|
|
307
|
+
buf = await readCapped(res, DOWNLOAD_MAX_BYTES);
|
|
308
|
+
} else {
|
|
309
|
+
// Base64 bytes (openai / gemini / stability / hf) — tolerate a data: URI
|
|
310
|
+
// wrapper as well as raw base64.
|
|
311
|
+
const m = /^data:[^;,]*;base64,(.*)$/is.exec(item);
|
|
312
|
+
buf = Buffer.from(m ? m[1]! : item, 'base64');
|
|
313
|
+
if (buf.length > DOWNLOAD_MAX_BYTES) {
|
|
314
|
+
throw new Error(`inline image too large: ${buf.length} bytes (max ${DOWNLOAD_MAX_BYTES})`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const ext = detectImageExt(buf);
|
|
318
|
+
if (!ext) {
|
|
319
|
+
// Not a recognized image — reject instead of saving garbage. This is what
|
|
320
|
+
// the old code did: base64-decode a URL string into ~60 bytes and save it
|
|
321
|
+
// as a .png the web then failed to render.
|
|
322
|
+
throw new Error('provider returned data that is not a recognized image');
|
|
323
|
+
}
|
|
324
|
+
const filename = `image_${sanitize(process.env.GATEWAY_SESSION_ID ?? 'default')}_${Date.now()}_${idx}.${ext}`;
|
|
325
|
+
const filePath = path.join(mediaDir, filename);
|
|
326
|
+
fs.writeFileSync(filePath, buf);
|
|
327
|
+
files.push(filePath);
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
return { content: [{ type: 'text', text: `generate_image: failed to save image: ${(err as Error).message}` }], isError: true };
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
content: [{
|
|
334
|
+
type: 'text',
|
|
335
|
+
text: JSON.stringify({
|
|
336
|
+
status: 'done',
|
|
337
|
+
task_id: taskId,
|
|
338
|
+
byok: job.byok ?? false,
|
|
339
|
+
cost: job.cost ?? 0,
|
|
340
|
+
files,
|
|
341
|
+
...(droppedImages > 0 ? { dropped_images: droppedImages } : {}),
|
|
342
|
+
note: 'Image saved. Deliver it to the user with your channel reply tool (files: [...]) — e.g. api_reply, reply.'
|
|
343
|
+
+ (droppedImages > 0 ? ` (${droppedImages} extra image(s) beyond the cap were not saved)` : ''),
|
|
344
|
+
}),
|
|
345
|
+
}],
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Where to write result images. Prefer the per-session media dir the gateway
|
|
351
|
+
* already provisions (GATEWAY_SESSION_MEDIA_DIR); otherwise derive the agent
|
|
352
|
+
* media root from the workspace (…/agents/<id>/media) so the file is reachable
|
|
353
|
+
* by the reply/attachment routes; last resort /tmp (path still returned).
|
|
354
|
+
*/
|
|
355
|
+
private resolveMediaDir(): string {
|
|
356
|
+
const sessionMediaDir = process.env.GATEWAY_SESSION_MEDIA_DIR;
|
|
357
|
+
if (sessionMediaDir) return sessionMediaDir;
|
|
358
|
+
const workspace = process.env.GATEWAY_WORKSPACE_DIR;
|
|
359
|
+
if (workspace) {
|
|
360
|
+
const sid = sanitize(process.env.GATEWAY_SESSION_ID ?? 'default');
|
|
361
|
+
return path.resolve(workspace, '..', 'media', `session-${sid}`);
|
|
362
|
+
}
|
|
363
|
+
return '/tmp';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private unavailable(err: unknown): McpToolResult {
|
|
367
|
+
return {
|
|
368
|
+
content: [{ type: 'text', text: `generate_image: image service unavailable: ${(err as Error).message}` }],
|
|
369
|
+
isError: true,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private mapHttpError(status: number, body: string): McpToolResult {
|
|
374
|
+
let code = '';
|
|
375
|
+
let message = '';
|
|
376
|
+
try {
|
|
377
|
+
const parsed = JSON.parse(body) as { error?: { code?: string; message?: string } };
|
|
378
|
+
code = parsed.error?.code ?? '';
|
|
379
|
+
message = parsed.error?.message ?? '';
|
|
380
|
+
} catch {
|
|
381
|
+
/* non-JSON error body */
|
|
382
|
+
}
|
|
383
|
+
if (!code) code = defaultCodeForStatus(status);
|
|
384
|
+
const hint = ERROR_HINTS[code];
|
|
385
|
+
const text = [`${code}${message ? `: ${message}` : ''}`, hint && hint !== message ? hint : '']
|
|
386
|
+
.filter(Boolean)
|
|
387
|
+
.join(' — ');
|
|
388
|
+
return { content: [{ type: 'text', text: text || `image service error (HTTP ${status})` }], isError: true };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private mapJobError(job: JobResponse): McpToolResult {
|
|
392
|
+
const code = job.error?.code ?? 'provider_error';
|
|
393
|
+
const message = job.error?.message ?? '';
|
|
394
|
+
const hint = ERROR_HINTS[code];
|
|
395
|
+
const text = [`${code}${message ? `: ${message}` : ''}`, hint && hint !== message ? hint : '']
|
|
396
|
+
.filter(Boolean)
|
|
397
|
+
.join(' — ');
|
|
398
|
+
return { content: [{ type: 'text', text: text || `image generation failed (${code})` }], isError: true };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function sleep(ms: number): Promise<void> {
|
|
403
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function sanitize(s: string): string {
|
|
407
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 48) || 'default';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// SSRF guard for provider image URLs. Require https, then resolve the host and
|
|
411
|
+
// reject if ANY resolved address is private / loopback / link-local / metadata —
|
|
412
|
+
// so a compromised provider response can't make the gateway fetch internal or
|
|
413
|
+
// cloud-metadata endpoints. Best-effort screen (a DNS rebind between this lookup
|
|
414
|
+
// and the fetch is still bounded by redirect:'error' at the call site).
|
|
415
|
+
async function assertSafeImageUrl(raw: string): Promise<void> {
|
|
416
|
+
let u: URL;
|
|
417
|
+
try {
|
|
418
|
+
u = new URL(raw);
|
|
419
|
+
} catch {
|
|
420
|
+
throw new Error('image url is malformed');
|
|
421
|
+
}
|
|
422
|
+
if (u.protocol !== 'https:') throw new Error('refusing to download image over a non-https url');
|
|
423
|
+
let addrs: { address: string }[];
|
|
424
|
+
try {
|
|
425
|
+
addrs = await dns.promises.lookup(u.hostname, { all: true });
|
|
426
|
+
} catch {
|
|
427
|
+
throw new Error('image url host could not be resolved');
|
|
428
|
+
}
|
|
429
|
+
if (!addrs.length || addrs.some((a) => isBlockedAddress(a.address))) {
|
|
430
|
+
throw new Error('refusing to download image from a non-public address');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// True for private / loopback / link-local / metadata / reserved IPs (v4 + v6),
|
|
435
|
+
// or anything that isn't a valid IP literal (fail closed).
|
|
436
|
+
function isBlockedAddress(ip: string): boolean {
|
|
437
|
+
const kind = net.isIP(ip);
|
|
438
|
+
if (kind === 4) {
|
|
439
|
+
const p = ip.split('.').map(Number);
|
|
440
|
+
if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
|
|
441
|
+
const [a, b] = p as [number, number, number, number];
|
|
442
|
+
if (a === 0 || a === 127) return true; // unspecified / loopback
|
|
443
|
+
if (a === 10) return true; // 10/8
|
|
444
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16/12
|
|
445
|
+
if (a === 192 && b === 168) return true; // 192.168/16
|
|
446
|
+
if (a === 169 && b === 254) return true; // link-local + 169.254.169.254 metadata
|
|
447
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10
|
|
448
|
+
if (a >= 224) return true; // multicast / reserved
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
if (kind === 6) {
|
|
452
|
+
const lo = ip.toLowerCase();
|
|
453
|
+
if (lo === '::1' || lo === '::') return true; // loopback / unspecified
|
|
454
|
+
if (lo.startsWith('fe80')) return true; // link-local
|
|
455
|
+
if (lo.startsWith('fc') || lo.startsWith('fd')) return true; // unique-local fc00::/7
|
|
456
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lo); // IPv4-mapped (dotted)
|
|
457
|
+
if (mapped) return isBlockedAddress(mapped[1]!);
|
|
458
|
+
// IPv4-mapped in hex form, e.g. ::ffff:7f00:1 == 127.0.0.1 — decode both
|
|
459
|
+
// 16-bit groups to dotted octets so it can't slip past the dotted check above.
|
|
460
|
+
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lo);
|
|
461
|
+
if (mappedHex) {
|
|
462
|
+
const hi = parseInt(mappedHex[1]!, 16);
|
|
463
|
+
const low = parseInt(mappedHex[2]!, 16);
|
|
464
|
+
return isBlockedAddress(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`);
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
return true; // not a valid IP → block
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Read a response body into a Buffer with a hard byte ceiling: reject early on a
|
|
472
|
+
// too-large Content-Length, and stream-count actual bytes so a chunked response
|
|
473
|
+
// without Content-Length can't blow past the cap (OOM guard).
|
|
474
|
+
async function readCapped(res: Response, cap: number): Promise<Buffer> {
|
|
475
|
+
const declared = Number(res.headers.get('content-length'));
|
|
476
|
+
if (Number.isFinite(declared) && declared > cap) {
|
|
477
|
+
throw new Error(`download image too large: ${declared} bytes (max ${cap})`);
|
|
478
|
+
}
|
|
479
|
+
if (!res.body) {
|
|
480
|
+
const ab = await res.arrayBuffer();
|
|
481
|
+
if (ab.byteLength > cap) throw new Error(`download image too large (max ${cap} bytes)`);
|
|
482
|
+
return Buffer.from(ab);
|
|
483
|
+
}
|
|
484
|
+
const chunks: Buffer[] = [];
|
|
485
|
+
let total = 0;
|
|
486
|
+
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
|
487
|
+
total += chunk.length;
|
|
488
|
+
if (total > cap) throw new Error(`download image exceeded ${cap} bytes`);
|
|
489
|
+
chunks.push(Buffer.from(chunk));
|
|
490
|
+
}
|
|
491
|
+
return Buffer.concat(chunks);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function defaultCodeForStatus(status: number): string {
|
|
495
|
+
switch (status) {
|
|
496
|
+
case 400: return 'invalid_model';
|
|
497
|
+
case 401: return 'unauthorized';
|
|
498
|
+
case 402: return 'insufficient_credit';
|
|
499
|
+
case 403: return 'no_credential';
|
|
500
|
+
case 404: return 'job_not_found';
|
|
501
|
+
case 429: return 'rate_limited';
|
|
502
|
+
case 503: return 'no_supply';
|
|
503
|
+
default: return 'provider_error';
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Detect image extension from magic bytes; default png. */
|
|
508
|
+
// Returns the extension for a recognized image (by magic bytes), or null when the
|
|
509
|
+
// buffer is NOT a known image — callers must reject that instead of saving garbage
|
|
510
|
+
// (a provider returning a URL parsed as base64, or a download that yielded an HTML
|
|
511
|
+
// error page, both land here).
|
|
512
|
+
function detectImageExt(buf: Buffer): string | null {
|
|
513
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpg';
|
|
514
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'png';
|
|
515
|
+
if (buf.length >= 6 && buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'gif';
|
|
516
|
+
if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
|
|
517
|
+
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return 'webp';
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// https is required for a PUBLIC image endpoint (the Bearer proxy_secret is sent on
|
|
522
|
+
// every call); http is tolerated only for a local/internal host — a trusted hop such
|
|
523
|
+
// as host.docker.internal in dev, where cleartext never leaves the machine/network.
|
|
524
|
+
function baseUrlIsSecure(raw: string): boolean {
|
|
525
|
+
if (!raw) return false;
|
|
526
|
+
let u: URL;
|
|
527
|
+
try {
|
|
528
|
+
u = new URL(raw);
|
|
529
|
+
} catch {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
if (u.protocol === 'https:') return true;
|
|
533
|
+
if (u.protocol !== 'http:') return false;
|
|
534
|
+
const h = u.hostname.toLowerCase();
|
|
535
|
+
return (
|
|
536
|
+
h === 'localhost' ||
|
|
537
|
+
h === 'host.docker.internal' ||
|
|
538
|
+
h.endsWith('.internal') ||
|
|
539
|
+
h.endsWith('.local') ||
|
|
540
|
+
/^127\./.test(h) ||
|
|
541
|
+
h === '::1' ||
|
|
542
|
+
/^10\./.test(h) ||
|
|
543
|
+
/^192\.168\./.test(h) ||
|
|
544
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const imageToolDefs: McpToolDefinition[] = [
|
|
549
|
+
{
|
|
550
|
+
name: 'generate_image',
|
|
551
|
+
description:
|
|
552
|
+
'Use this WHENEVER the user asks to create, draw, make, or edit an image — it is built in, no app install needed. ' +
|
|
553
|
+
'Generate images from a text prompt (optionally guided by a reference image) via the configured image generation service. ' +
|
|
554
|
+
'action="generate" submits the request and returns the saved image file path(s) once ready — ' +
|
|
555
|
+
'then deliver them with your channel reply tool (files: [...]). ' +
|
|
556
|
+
'action="status" polls a previously returned task_id. ' +
|
|
557
|
+
'action="list" returns every available image model with its supported_qualities, supported_sizes, cost, and ' +
|
|
558
|
+
'the capability flags supports_image_ref (image-to-image / edit) and supports_style_ref. Call it FIRST when ' +
|
|
559
|
+
'choosing a model or when you need to know what a provider can do — you are NOT limited to the composer ' +
|
|
560
|
+
'options; you may set any parameter the chosen model actually supports. ' +
|
|
561
|
+
'REFERENCE IMAGE: when the turn includes an image the user wants to transform or edit, prefer a model whose ' +
|
|
562
|
+
'supports_image_ref is true and pass that image\'s media path in "image" (real image-to-image). If no ' +
|
|
563
|
+
'img2img-capable model is available — or the model you picked has supports_image_ref=false — do NOT pass ' +
|
|
564
|
+
'"image": instead look at the reference image yourself, describe what matters in the "prompt", and generate ' +
|
|
565
|
+
'text-to-image. Never send "image" to a model that does not support it. ' +
|
|
566
|
+
'When the user selected options in the composer (an <image-params .../> tag in the turn), honor those values.',
|
|
567
|
+
inputSchema: {
|
|
568
|
+
type: 'object',
|
|
569
|
+
properties: {
|
|
570
|
+
action: {
|
|
571
|
+
type: 'string',
|
|
572
|
+
enum: ['generate', 'status', 'list'],
|
|
573
|
+
description: 'generate (default) | status | list',
|
|
574
|
+
},
|
|
575
|
+
model: {
|
|
576
|
+
type: 'string',
|
|
577
|
+
description: 'Model id "provider/model" (required for generate). Use action="list" to discover valid ids.',
|
|
578
|
+
},
|
|
579
|
+
prompt: { type: 'string', description: 'Text prompt (required for generate).' },
|
|
580
|
+
quality: { type: 'string', description: 'Optional quality (must be in the model supported_qualities).' },
|
|
581
|
+
size: { type: 'string', description: 'Optional size, e.g. "1024x1024".' },
|
|
582
|
+
aspect_ratio: { type: 'string', description: 'Optional aspect ratio, e.g. "1:1" (converted to size if the provider needs it).' },
|
|
583
|
+
n: { type: 'integer', description: 'Optional number of images (default 1).' },
|
|
584
|
+
image: { type: 'string', description: 'Optional reference-image media path for image-to-image/edit (e.g. "media/xxx.png"). ONLY pass this to a model whose supports_image_ref is true (check action="list"); for any other model, describe the reference image in the prompt instead of sending it here.' },
|
|
585
|
+
images: { type: 'array', items: { type: 'string' }, description: 'Optional multiple reference-image media paths (same supports_image_ref rule as "image").' },
|
|
586
|
+
style: { type: 'string', description: 'Optional native style parameter (e.g. "vivid") — only for models whose supports_style_ref is true.' },
|
|
587
|
+
task_id: { type: 'string', description: 'Job id to poll (required for action="status").' },
|
|
588
|
+
},
|
|
589
|
+
required: [],
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
];
|