@0xmaxma/claude-gateway 1.5.7 → 1.6.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/README.md +22 -0
- package/dist/agent/runner.d.ts +31 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +224 -16
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/apps-router.d.ts.map +1 -1
- package/dist/api/apps-router.js +7 -2
- package/dist/api/apps-router.js.map +1 -1
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +27 -0
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/line-webhook-router.d.ts.map +1 -1
- package/dist/api/line-webhook-router.js +68 -0
- package/dist/api/line-webhook-router.js.map +1 -1
- package/dist/api/router.d.ts +2 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +60 -4
- package/dist/api/router.js.map +1 -1
- package/dist/api/share-router.d.ts +11 -0
- package/dist/api/share-router.d.ts.map +1 -0
- package/dist/api/share-router.js +402 -0
- package/dist/api/share-router.js.map +1 -0
- package/dist/apps/installer.d.ts +55 -0
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +136 -1
- package/dist/apps/installer.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +9 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/public-url.d.ts +11 -0
- package/dist/config/public-url.d.ts.map +1 -0
- package/dist/config/public-url.js +41 -0
- package/dist/config/public-url.js.map +1 -0
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +1 -0
- package/dist/config/watcher.js.map +1 -1
- package/dist/history/db.d.ts +14 -0
- package/dist/history/db.d.ts.map +1 -1
- package/dist/history/db.js +48 -4
- package/dist/history/db.js.map +1 -1
- package/dist/history/types.d.ts +5 -0
- package/dist/history/types.d.ts.map +1 -1
- package/dist/session/process.d.ts +3 -0
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +27 -1
- package/dist/session/process.js.map +1 -1
- package/dist/session/store.d.ts +2 -1
- package/dist/session/store.d.ts.map +1 -1
- package/dist/session/store.js +10 -2
- package/dist/session/store.js.map +1 -1
- package/dist/share/session-image-catalog.d.ts +22 -0
- package/dist/share/session-image-catalog.d.ts.map +1 -0
- package/dist/share/session-image-catalog.js +151 -0
- package/dist/share/session-image-catalog.js.map +1 -0
- package/dist/share/share-store.d.ts +137 -0
- package/dist/share/share-store.d.ts.map +1 -0
- package/dist/share/share-store.js +340 -0
- package/dist/share/share-store.js.map +1 -0
- package/dist/types.d.ts +19 -7
- package/dist/types.d.ts.map +1 -1
- package/mcp/instructions.ts +1 -0
- package/mcp/server.ts +2 -0
- package/mcp/tools/agent/handlers.ts +55 -4
- package/mcp/tools/image/module.ts +240 -26
- package/mcp/tools/line/module.ts +186 -0
- package/mcp/tools/share-image/module.ts +120 -0
- package/mcp/tools/shared/share-client.ts +197 -0
- package/package.json +3 -1
|
@@ -4,6 +4,16 @@ import * as path from 'node:path';
|
|
|
4
4
|
import * as dns from 'node:dns';
|
|
5
5
|
import * as net from 'node:net';
|
|
6
6
|
import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
|
|
7
|
+
import {
|
|
8
|
+
ShareClientError,
|
|
9
|
+
createShares,
|
|
10
|
+
listSessionImages,
|
|
11
|
+
registerArtifacts,
|
|
12
|
+
revokeSharesBestEffort,
|
|
13
|
+
shareBridgeEnabled,
|
|
14
|
+
type ShareRef,
|
|
15
|
+
type ShareItem,
|
|
16
|
+
} from '../shared/share-client';
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
19
|
* Image-generation tool module (#184, Track B).
|
|
@@ -61,12 +71,9 @@ export class ImageModule implements ToolModule {
|
|
|
61
71
|
toolVisibility: ToolVisibility = 'all-configured';
|
|
62
72
|
|
|
63
73
|
isEnabled(): boolean {
|
|
64
|
-
// Enabled when
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
// disables the tool cleanly, instead of advertising it and then failing every call with a
|
|
68
|
-
// 401 on an empty Bearer.
|
|
69
|
-
if (!this.baseUrl() || !this.authToken() || process.env.IMAGE_DISABLED === 'true') return false;
|
|
74
|
+
// Enabled when the image endpoint is configured in ~/.claude/settings.json (see
|
|
75
|
+
// baseUrl()) and not explicitly turned off.
|
|
76
|
+
if (!this.baseUrl() || process.env.IMAGE_DISABLED === 'true') return false;
|
|
70
77
|
// The Bearer proxy_secret rides every call — refuse a cleartext http URL to a
|
|
71
78
|
// PUBLIC host (that would leak the secret). http to a local/internal host is a
|
|
72
79
|
// trusted hop (e.g. host.docker.internal in dev) and stays allowed.
|
|
@@ -100,9 +107,11 @@ export class ImageModule implements ToolModule {
|
|
|
100
107
|
return this.handleStatus(args);
|
|
101
108
|
case 'list':
|
|
102
109
|
return this.handleList();
|
|
110
|
+
case 'list_refs':
|
|
111
|
+
return this.handleListRefs();
|
|
103
112
|
default:
|
|
104
113
|
return {
|
|
105
|
-
content: [{ type: 'text', text: `generate_image: unknown action "${action}" (expected generate | status | list)` }],
|
|
114
|
+
content: [{ type: 'text', text: `generate_image: unknown action "${action}" (expected generate | status | list | list_refs)` }],
|
|
106
115
|
isError: true,
|
|
107
116
|
};
|
|
108
117
|
}
|
|
@@ -187,6 +196,53 @@ export class ImageModule implements ToolModule {
|
|
|
187
196
|
return { content: [{ type: 'text', text: body || '[]' }] };
|
|
188
197
|
}
|
|
189
198
|
|
|
199
|
+
/**
|
|
200
|
+
* action="list_refs" (#72) — return the deterministic, gateway-computed catalog
|
|
201
|
+
* of every image in this session so the agent never has to count images from its
|
|
202
|
+
* own transcript (which breaks after compaction/resume). Read-only.
|
|
203
|
+
*
|
|
204
|
+
* Gated on the share bridge exactly like reference minting: with the bridge off
|
|
205
|
+
* there is no catalog endpoint to read, so the action stays inert (legacy mode
|
|
206
|
+
* gains no new behavior).
|
|
207
|
+
*/
|
|
208
|
+
private async handleListRefs(): Promise<McpToolResult> {
|
|
209
|
+
if (!shareBridgeEnabled()) {
|
|
210
|
+
return {
|
|
211
|
+
content: [{ type: 'text', text: 'generate_image: list_refs is unavailable (image share bridge is not configured).' }],
|
|
212
|
+
isError: true,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
let items;
|
|
216
|
+
try {
|
|
217
|
+
items = await listSessionImages();
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (err instanceof ShareClientError) {
|
|
220
|
+
return { content: [{ type: 'text', text: `generate_image: ${err.code}: ${err.message}` }], isError: true };
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
content: [{ type: 'text', text: `generate_image: image share service unavailable: ${(err as Error).message}` }],
|
|
224
|
+
isError: true,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
content: [{
|
|
229
|
+
type: 'text',
|
|
230
|
+
text: JSON.stringify({
|
|
231
|
+
images: items,
|
|
232
|
+
note: 'Ground-truth catalog of every image in this session, numbered in order of first appearance ("image 1" = index 1). '
|
|
233
|
+
+ 'To use one as a reference, pass its "ref" value in the "image"/"images" argument of action="generate". '
|
|
234
|
+
+ 'Do NOT count images from conversation memory. '
|
|
235
|
+
+ 'Resolution precedence: (1) when the user names an index ("Image 3", "the third image") or attached an image this turn, '
|
|
236
|
+
+ 'trust that EXACTLY — never let "desc" override an explicit reference; '
|
|
237
|
+
+ '(2) when the user refers by content ("the dog picture"), match against each item\'s "desc" '
|
|
238
|
+
+ '(the generation prompt, or the user text that came with an upload); '
|
|
239
|
+
+ '(3) if the reference is ambiguous or the index does not exist, ask the user instead of guessing. '
|
|
240
|
+
+ 'Items with available:false can no longer be used.',
|
|
241
|
+
}),
|
|
242
|
+
}],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
190
246
|
private async handleGenerate(args: Record<string, unknown>): Promise<McpToolResult> {
|
|
191
247
|
const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
|
|
192
248
|
const model = typeof args.model === 'string' ? args.model.trim() : '';
|
|
@@ -202,13 +258,39 @@ export class ImageModule implements ToolModule {
|
|
|
202
258
|
for (const k of ['quality', 'size', 'aspect_ratio', 'style'] as const) {
|
|
203
259
|
if (typeof args[k] === 'string' && (args[k] as string).length) reqBody[k] = args[k];
|
|
204
260
|
}
|
|
205
|
-
if (typeof args.n === 'number' && args.n > 0)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
261
|
+
if (typeof args.n === 'number' && args.n > 0) {
|
|
262
|
+
// Clamp the requested image count — an unbounded n is forwarded straight to
|
|
263
|
+
// the provider (cost / abuse). MAX_DELIVER_IMAGES is the most we can deliver
|
|
264
|
+
// back anyway, so generating more is wasted spend.
|
|
265
|
+
reqBody.n = Math.min(Math.floor(args.n), MAX_DELIVER_IMAGES);
|
|
209
266
|
}
|
|
210
267
|
|
|
211
|
-
//
|
|
268
|
+
// Reference images (#70): when the share bridge is enabled, local paths and
|
|
269
|
+
// artifact:<id> refs are auto-converted to short-lived public share URLs via
|
|
270
|
+
// the authenticated gateway API (plan §7). With the bridge off, behavior is
|
|
271
|
+
// EXACTLY the legacy pass-through (regression guarantee).
|
|
272
|
+
const rawImage = typeof args.image === 'string' && args.image.length ? args.image : undefined;
|
|
273
|
+
const rawImages =
|
|
274
|
+
Array.isArray(args.images) && args.images.every((x) => typeof x === 'string') && args.images.length
|
|
275
|
+
? (args.images as string[])
|
|
276
|
+
: undefined;
|
|
277
|
+
let mintedShareIds: string[] = [];
|
|
278
|
+
if (shareBridgeEnabled() && (rawImage || rawImages)) {
|
|
279
|
+
if (rawImage && rawImages) {
|
|
280
|
+
return { content: [{ type: 'text', text: 'generate_image: pass either "image" or "images", not both.' }], isError: true };
|
|
281
|
+
}
|
|
282
|
+
const normalized = await this.normalizeRefs(rawImage ? [rawImage] : rawImages!);
|
|
283
|
+
if ('error' in normalized) return normalized.error;
|
|
284
|
+
mintedShareIds = normalized.mintedShareIds;
|
|
285
|
+
if (rawImage) reqBody.image = normalized.urls[0];
|
|
286
|
+
else reqBody.images = normalized.urls;
|
|
287
|
+
} else {
|
|
288
|
+
if (rawImage) reqBody.image = rawImage;
|
|
289
|
+
if (rawImages) reqBody.images = rawImages;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Submit (E1). On an immediate submit failure, best-effort revoke any share
|
|
293
|
+
// URLs minted for this call (§18) — the TTL still bounds exposure otherwise.
|
|
212
294
|
let res: Response;
|
|
213
295
|
try {
|
|
214
296
|
res = await fetch(`${this.baseUrl()}/v1/images/generations`, {
|
|
@@ -218,19 +300,25 @@ export class ImageModule implements ToolModule {
|
|
|
218
300
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
219
301
|
});
|
|
220
302
|
} catch (err) {
|
|
303
|
+
await revokeSharesBestEffort(mintedShareIds);
|
|
221
304
|
return this.unavailable(err);
|
|
222
305
|
}
|
|
223
306
|
const submitText = await res.text().catch(() => '');
|
|
224
|
-
if (!res.ok)
|
|
307
|
+
if (!res.ok) {
|
|
308
|
+
await revokeSharesBestEffort(mintedShareIds);
|
|
309
|
+
return this.mapHttpError(res.status, submitText);
|
|
310
|
+
}
|
|
225
311
|
|
|
226
312
|
let submit: JobResponse;
|
|
227
313
|
try {
|
|
228
314
|
submit = JSON.parse(submitText) as JobResponse;
|
|
229
315
|
} catch {
|
|
316
|
+
await revokeSharesBestEffort(mintedShareIds);
|
|
230
317
|
return { content: [{ type: 'text', text: 'generate_image: invalid JSON from image service on submit' }], isError: true };
|
|
231
318
|
}
|
|
232
319
|
const taskId = submit.task_id;
|
|
233
320
|
if (!taskId) {
|
|
321
|
+
await revokeSharesBestEffort(mintedShareIds);
|
|
234
322
|
return { content: [{ type: 'text', text: 'generate_image: image service did not return a task_id' }], isError: true };
|
|
235
323
|
}
|
|
236
324
|
|
|
@@ -246,7 +334,7 @@ export class ImageModule implements ToolModule {
|
|
|
246
334
|
}
|
|
247
335
|
if (polled.httpError) return this.mapHttpError(polled.httpError.status, polled.httpError.body);
|
|
248
336
|
last = polled.job!;
|
|
249
|
-
if (last.status === 'done') return await this.deliver(last, taskId);
|
|
337
|
+
if (last.status === 'done') return await this.deliver(last, taskId, model, prompt);
|
|
250
338
|
if (last.status === 'failed') return this.mapJobError(last);
|
|
251
339
|
}
|
|
252
340
|
|
|
@@ -287,6 +375,82 @@ export class ImageModule implements ToolModule {
|
|
|
287
375
|
|
|
288
376
|
// ── helpers ─────────────────────────────────────────────────────────────
|
|
289
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Normalize reference inputs (#70, plan §7), preserving order:
|
|
380
|
+
* https URL → validate syntax, pass through
|
|
381
|
+
* http URL → reject (phase 1 requires HTTPS)
|
|
382
|
+
* artifact:<id> → resolve+mint via gateway share API
|
|
383
|
+
* local media path → validate+mint via gateway share API
|
|
384
|
+
* Rejects duplicates and over-count BEFORE any minting/billing.
|
|
385
|
+
*/
|
|
386
|
+
private async normalizeRefs(
|
|
387
|
+
refs: string[],
|
|
388
|
+
): Promise<{ urls: string[]; mintedShareIds: string[] } | { error: McpToolResult }> {
|
|
389
|
+
const fail = (text: string): { error: McpToolResult } => ({
|
|
390
|
+
error: { content: [{ type: 'text', text }], isError: true },
|
|
391
|
+
});
|
|
392
|
+
const maxRefs = (() => {
|
|
393
|
+
const n = Number(process.env.IMAGE_SHARE_MAX_REFS);
|
|
394
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 5;
|
|
395
|
+
})();
|
|
396
|
+
if (refs.length > maxRefs) {
|
|
397
|
+
return fail(`generate_image: too many reference images (max ${maxRefs}).`);
|
|
398
|
+
}
|
|
399
|
+
if (new Set(refs).size !== refs.length) {
|
|
400
|
+
return fail('generate_image: duplicate reference images are not allowed.');
|
|
401
|
+
}
|
|
402
|
+
type Entry = { kind: 'url'; url: string } | { kind: 'local'; ref: ShareRef };
|
|
403
|
+
const entries: Entry[] = [];
|
|
404
|
+
for (const raw of refs) {
|
|
405
|
+
const ref = raw.trim();
|
|
406
|
+
if (/^https:\/\//i.test(ref)) {
|
|
407
|
+
try {
|
|
408
|
+
new URL(ref);
|
|
409
|
+
} catch {
|
|
410
|
+
return fail(`generate_image: reference URL is malformed.`);
|
|
411
|
+
}
|
|
412
|
+
entries.push({ kind: 'url', url: ref });
|
|
413
|
+
} else if (/^http:\/\//i.test(ref)) {
|
|
414
|
+
return fail('generate_image: http:// reference URLs are not allowed — use https.');
|
|
415
|
+
} else if (ref.startsWith('artifact:')) {
|
|
416
|
+
const id = ref.slice('artifact:'.length).trim();
|
|
417
|
+
if (!id) return fail('generate_image: empty artifact reference.');
|
|
418
|
+
entries.push({ kind: 'local', ref: { artifact_id: id } });
|
|
419
|
+
} else {
|
|
420
|
+
entries.push({ kind: 'local', ref: { path: ref } });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const localRefs = entries.filter((e): e is Extract<Entry, { kind: 'local' }> => e.kind === 'local');
|
|
424
|
+
let minted: ShareItem[] = [];
|
|
425
|
+
if (localRefs.length) {
|
|
426
|
+
try {
|
|
427
|
+
minted = await createShares(localRefs.map((e) => e.ref), { purpose: 'codex_ref' });
|
|
428
|
+
} catch (err) {
|
|
429
|
+
if (err instanceof ShareClientError) {
|
|
430
|
+
if (err.code === 'image_ref_not_found') {
|
|
431
|
+
return fail('generate_image: image_ref_not_found: the referenced image/artifact does not exist in this session.');
|
|
432
|
+
}
|
|
433
|
+
return fail(`generate_image: ${err.code}: ${err.message}`);
|
|
434
|
+
}
|
|
435
|
+
return fail(`generate_image: image share service unavailable: ${(err as Error).message}`);
|
|
436
|
+
}
|
|
437
|
+
if (minted.length !== localRefs.length) {
|
|
438
|
+
await revokeSharesBestEffort(minted.map((m) => m.share_id));
|
|
439
|
+
return fail('generate_image: image share service returned a mismatched share count.');
|
|
440
|
+
}
|
|
441
|
+
// Provider fetches these refs over HTTPS, so each needs an absolute URL —
|
|
442
|
+
// which the share API only fills when gateway.publicUrl is configured.
|
|
443
|
+
if (minted.some((m) => !m.url)) {
|
|
444
|
+
await revokeSharesBestEffort(minted.map((m) => m.share_id));
|
|
445
|
+
return fail('generate_image: image reference sharing requires gateway.publicUrl to be configured.');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Merge back preserving the caller's order.
|
|
449
|
+
let next = 0;
|
|
450
|
+
const urls = entries.map((e) => (e.kind === 'url' ? e.url : minted[next++]!.url!));
|
|
451
|
+
return { urls, mintedShareIds: minted.map((m) => m.share_id) };
|
|
452
|
+
}
|
|
453
|
+
|
|
290
454
|
private pollTimeoutMs(): number {
|
|
291
455
|
const raw = Number(process.env.IMAGE_POLL_TIMEOUT_MS);
|
|
292
456
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_POLL_TIMEOUT_MS;
|
|
@@ -317,7 +481,10 @@ export class ImageModule implements ToolModule {
|
|
|
317
481
|
* item is either base64 bytes (sync providers like openai/gemini) OR an https URL
|
|
318
482
|
* (async providers like nanobanana/bfl/fal, which return a hosted file) — download
|
|
319
483
|
* URLs, decode base64. */
|
|
320
|
-
|
|
484
|
+
// `prompt` is only known on the generate path (same-turn poll); the
|
|
485
|
+
// action="status" path re-enters in a later turn without it, so those
|
|
486
|
+
// artifacts register promptless.
|
|
487
|
+
private async deliver(job: JobResponse, taskId: string, model?: string, prompt?: string): Promise<McpToolResult> {
|
|
321
488
|
const allImages = Array.isArray(job.images) ? job.images.filter((s) => typeof s === 'string' && s.length) : [];
|
|
322
489
|
// Cap how many we process — downloads are sequential (~30s each), so an
|
|
323
490
|
// over-long list would hang the tool call far past the poll budget.
|
|
@@ -338,9 +505,18 @@ export class ImageModule implements ToolModule {
|
|
|
338
505
|
let buf: Buffer;
|
|
339
506
|
if (/^https?:\/\//i.test(item)) {
|
|
340
507
|
// Async providers (nanobanana / bfl / fal / runway) return a hosted image
|
|
341
|
-
// URL — download the bytes. SSRF guard
|
|
342
|
-
//
|
|
343
|
-
// redirect:'error' so a
|
|
508
|
+
// URL — download the bytes. SSRF guard: https-only + reject any host that
|
|
509
|
+
// resolves to a private/loopback/link-local/metadata address, and
|
|
510
|
+
// redirect:'error' so a 3xx hop can't bounce to an internal target.
|
|
511
|
+
//
|
|
512
|
+
// Residual risk (accepted): this is a best-effort screen, NOT a full
|
|
513
|
+
// rebinding defence. assertSafeImageUrl resolves DNS once; fetch()
|
|
514
|
+
// resolves again (Node/undici does not pin the vetted IP), so a
|
|
515
|
+
// low-TTL record could answer public here and 127.0.0.1 at fetch time.
|
|
516
|
+
// redirect:'error' does NOT close this — it only blocks HTTP redirects,
|
|
517
|
+
// not DNS rebinding. The backstop is downstream: the response body must
|
|
518
|
+
// pass detectImageExt() below, so an internal non-image endpoint yields
|
|
519
|
+
// unrecognized bytes and is rejected before anything is written to disk.
|
|
344
520
|
await assertSafeImageUrl(item);
|
|
345
521
|
const res = await fetch(item, { signal: AbortSignal.timeout(30_000), redirect: 'error' });
|
|
346
522
|
if (!res.ok) throw new Error(`download image failed: HTTP ${res.status}`);
|
|
@@ -369,6 +545,28 @@ export class ImageModule implements ToolModule {
|
|
|
369
545
|
} catch (err) {
|
|
370
546
|
return { content: [{ type: 'text', text: `generate_image: failed to save image: ${(err as Error).message}` }], isError: true };
|
|
371
547
|
}
|
|
548
|
+
|
|
549
|
+
// Register the written files as private artifacts (#70, plan §8) so a later
|
|
550
|
+
// turn can reference them as artifact:<id> for editing. Best-effort: when
|
|
551
|
+
// the bridge is off or registration fails, the response simply carries no
|
|
552
|
+
// artifacts — file delivery is unaffected.
|
|
553
|
+
let artifacts: Array<Record<string, unknown>> | undefined;
|
|
554
|
+
if (shareBridgeEnabled() && files.length) {
|
|
555
|
+
const slash = (model ?? '').indexOf('/');
|
|
556
|
+
const provider = slash > 0 ? (model as string).slice(0, slash) : 'unknown';
|
|
557
|
+
const modelName = slash > 0 ? (model as string).slice(slash + 1) : (model || 'unknown');
|
|
558
|
+
const registered = await registerArtifacts(files, { provider, model: modelName, taskId, prompt });
|
|
559
|
+
if (registered) {
|
|
560
|
+
artifacts = registered.map((item, index) => ({
|
|
561
|
+
artifact_id: item.artifact_id,
|
|
562
|
+
artifact_ref: item.artifact_ref,
|
|
563
|
+
index,
|
|
564
|
+
path: files[index],
|
|
565
|
+
provider,
|
|
566
|
+
model: modelName,
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
372
570
|
return {
|
|
373
571
|
content: [{
|
|
374
572
|
type: 'text',
|
|
@@ -378,8 +576,10 @@ export class ImageModule implements ToolModule {
|
|
|
378
576
|
byok: job.byok ?? false,
|
|
379
577
|
cost: job.cost ?? 0,
|
|
380
578
|
files,
|
|
579
|
+
...(artifacts ? { artifacts } : {}),
|
|
381
580
|
...(droppedImages > 0 ? { dropped_images: droppedImages } : {}),
|
|
382
|
-
note: 'Image saved. Deliver it to the user with your channel
|
|
581
|
+
note: 'Image saved. Deliver it to the user with your channel delivery tool — api_reply/reply (files: [...]), or line_image on LINE. Do NOT open/Read the file to inspect it first; attach it and answer briefly.'
|
|
582
|
+
+ (artifacts ? ' To edit this image later, reference it via its artifact_ref (e.g. image: "artifact:...").' : '')
|
|
383
583
|
+ (droppedImages > 0 ? ` (${droppedImages} extra image(s) beyond the cap were not saved)` : ''),
|
|
384
584
|
}),
|
|
385
585
|
}],
|
|
@@ -450,8 +650,10 @@ function sanitize(s: string): string {
|
|
|
450
650
|
// SSRF guard for provider image URLs. Require https, then resolve the host and
|
|
451
651
|
// reject if ANY resolved address is private / loopback / link-local / metadata —
|
|
452
652
|
// so a compromised provider response can't make the gateway fetch internal or
|
|
453
|
-
// cloud-metadata endpoints. Best-effort screen
|
|
454
|
-
// and the fetch is
|
|
653
|
+
// cloud-metadata endpoints. Best-effort screen only: a DNS rebind between this
|
|
654
|
+
// lookup and the fetch is NOT prevented here (Node does not let us pin the vetted
|
|
655
|
+
// IP without a custom undici dispatcher). The real backstop is the caller's
|
|
656
|
+
// post-download detectImageExt() check — non-image bytes are rejected before use.
|
|
455
657
|
async function assertSafeImageUrl(raw: string): Promise<void> {
|
|
456
658
|
let u: URL;
|
|
457
659
|
try {
|
|
@@ -592,7 +794,10 @@ const imageToolDefs: McpToolDefinition[] = [
|
|
|
592
794
|
'Use this WHENEVER the user asks to create, draw, make, or edit an image — it is built in, no app install needed. ' +
|
|
593
795
|
'Generate images from a text prompt (optionally guided by a reference image) via the configured image generation service. ' +
|
|
594
796
|
'action="generate" submits the request and returns the saved image file path(s) once ready — ' +
|
|
595
|
-
'then deliver them with your channel reply tool (files: [...]). ' +
|
|
797
|
+
'then deliver them with your channel reply tool (files: [...]). AFTER A SUCCESSFUL GENERATE: do NOT ' +
|
|
798
|
+
'open/Read the produced file to inspect it and do NOT re-analyze it — the generation already succeeded; ' +
|
|
799
|
+
'attach it with your reply tool and answer in one or two short sentences. Every extra step risks pushing ' +
|
|
800
|
+
'the request past its timeout. ' +
|
|
596
801
|
'action="status" polls a previously returned task_id. ' +
|
|
597
802
|
'action="list" returns every available image model with its supported_qualities, supported_sizes, cost, and ' +
|
|
598
803
|
'the capability flags supports_image_ref (image-to-image / edit) and supports_style_ref. Call it FIRST when ' +
|
|
@@ -603,14 +808,23 @@ const imageToolDefs: McpToolDefinition[] = [
|
|
|
603
808
|
'img2img-capable model is available — or the model you picked has supports_image_ref=false — do NOT pass ' +
|
|
604
809
|
'"image": instead look at the reference image yourself, describe what matters in the "prompt", and generate ' +
|
|
605
810
|
'text-to-image. Never send "image" to a model that does not support it. ' +
|
|
811
|
+
'EARLIER IMAGES IN THE CHAT: when the user points at an image from earlier in this conversation ' +
|
|
812
|
+
'("image number 2", "the first image", "the picture you just made", "that logo from before"), call ' +
|
|
813
|
+
'action="list_refs" FIRST. It returns the ground-truth catalog of this session\'s images numbered by ' +
|
|
814
|
+
'first appearance; pass the chosen item\'s "ref" into "image"/"images". NEVER work out which earlier ' +
|
|
815
|
+
'image the user means by counting from your own memory of the conversation — the numbering there is ' +
|
|
816
|
+
'not reliable, and not remembering any images is NOT evidence there are none (your context may have ' +
|
|
817
|
+
'been reset while the chat history still has them): never answer "no images attached" to such a ' +
|
|
818
|
+
'request before list_refs has returned an empty catalog. If the catalog makes the request ambiguous ' +
|
|
819
|
+
'(or the requested index does not exist), ask the user which image they mean instead of guessing. ' +
|
|
606
820
|
'When the user selected options in the composer (an <image-params .../> tag in the turn), honor those values.',
|
|
607
821
|
inputSchema: {
|
|
608
822
|
type: 'object',
|
|
609
823
|
properties: {
|
|
610
824
|
action: {
|
|
611
825
|
type: 'string',
|
|
612
|
-
enum: ['generate', 'status', 'list'],
|
|
613
|
-
description: 'generate (default) | status | list',
|
|
826
|
+
enum: ['generate', 'status', 'list', 'list_refs'],
|
|
827
|
+
description: 'generate (default) | status | list | list_refs (numbered catalog of this session\'s images, for resolving "the second image" style references)',
|
|
614
828
|
},
|
|
615
829
|
model: {
|
|
616
830
|
type: 'string',
|
|
@@ -621,8 +835,8 @@ const imageToolDefs: McpToolDefinition[] = [
|
|
|
621
835
|
size: { type: 'string', description: 'Optional size, e.g. "1024x1024".' },
|
|
622
836
|
aspect_ratio: { type: 'string', description: 'Optional aspect ratio, e.g. "1:1" (converted to size if the provider needs it).' },
|
|
623
837
|
n: { type: 'integer', description: 'Optional number of images (default 1).' },
|
|
624
|
-
image: { type: 'string', description: 'Optional reference
|
|
625
|
-
images: { type: 'array', items: { type: 'string' }, description: 'Optional multiple reference
|
|
838
|
+
image: { type: 'string', description: 'Optional reference image for image-to-image/edit: a media path (e.g. "media/xxx.png"), an "artifact:<id>" ref from a previous generate_image result, or an https URL. Local/artifact refs are converted to short-lived URLs automatically. 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. Do not pass both "image" and "images".' },
|
|
839
|
+
images: { type: 'array', items: { type: 'string' }, description: 'Optional multiple reference images (max 5; media paths, artifact:<id> refs, or https URLs — same supports_image_ref rule as "image"). Order is preserved.' },
|
|
626
840
|
style: { type: 'string', description: 'Optional native style parameter (e.g. "vivid") — only for models whose supports_style_ref is true.' },
|
|
627
841
|
task_id: { type: 'string', description: 'Job id to poll (required for action="status").' },
|
|
628
842
|
},
|
package/mcp/tools/line/module.ts
CHANGED
|
@@ -10,9 +10,34 @@
|
|
|
10
10
|
* is passed, and fall back to push if it's absent or expired/used (the single-use
|
|
11
11
|
* token has no guaranteed lifetime — ~1 min — so push remains the safety net).
|
|
12
12
|
*/
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
13
15
|
import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
|
|
14
16
|
import { messagingApi } from '@line/bot-sdk';
|
|
15
17
|
import { chunkText, planLineSend, LINE_TEXT_LIMIT } from './pure';
|
|
18
|
+
import { createShares, ShareClientError, shareBridgeEnabled } from '../shared/share-client';
|
|
19
|
+
|
|
20
|
+
/** Default lifetime of a share URL handed to LINE (1 hour). */
|
|
21
|
+
const DEFAULT_MEDIA_URL_TTL_MS = 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert a media path (absolute under the agent media root, or already relative
|
|
25
|
+
* like "media/<chat>/<file>.png") to the "media/…"-relative form the gateway's
|
|
26
|
+
* signed-URL route expects. Returns null if it cannot be resolved to the media root.
|
|
27
|
+
*/
|
|
28
|
+
function toRelativeMediaPath(p: string): string | null {
|
|
29
|
+
if (!p) return null;
|
|
30
|
+
const normalized = p.replace(/\\/g, '/');
|
|
31
|
+
if (!path.isAbsolute(normalized)) {
|
|
32
|
+
return normalized.startsWith('media/') ? normalized : `media/${normalized.replace(/^\/+/, '')}`;
|
|
33
|
+
}
|
|
34
|
+
const workspace = process.env.GATEWAY_WORKSPACE_DIR;
|
|
35
|
+
if (!workspace) return null;
|
|
36
|
+
const mediaRoot = path.resolve(workspace, '..', 'media');
|
|
37
|
+
const rel = path.relative(mediaRoot, path.resolve(normalized));
|
|
38
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
39
|
+
return `media/${rel.split(path.sep).join('/')}`;
|
|
40
|
+
}
|
|
16
41
|
|
|
17
42
|
export class LineModule implements ToolModule {
|
|
18
43
|
id = 'line';
|
|
@@ -54,11 +79,43 @@ export class LineModule implements ToolModule {
|
|
|
54
79
|
required: ['chat_id', 'text'],
|
|
55
80
|
},
|
|
56
81
|
},
|
|
82
|
+
{
|
|
83
|
+
name: 'line_image',
|
|
84
|
+
description:
|
|
85
|
+
'Send an image to the current LINE user. Pass chat_id (the LINE userId from the ' +
|
|
86
|
+
'<channel> tag) and image (a media path saved by the gateway, e.g. a generate_image ' +
|
|
87
|
+
'result path or a "media/…" path). LINE can only fetch public HTTPS URLs, so the tool ' +
|
|
88
|
+
'mints a signed, short-lived public URL for the file and sends it as an image message. ' +
|
|
89
|
+
'Pass reply_token from the <channel> tag when present to send for FREE (falls back to push).',
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
chat_id: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: 'LINE userId to send to (the chat_id from the channel turn).',
|
|
96
|
+
},
|
|
97
|
+
image: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: 'Media path of the image (absolute path under the agent media dir, or a "media/…" relative path).',
|
|
100
|
+
},
|
|
101
|
+
preview: {
|
|
102
|
+
type: 'string',
|
|
103
|
+
description: 'Optional separate preview-image media path. Defaults to the same image.',
|
|
104
|
+
},
|
|
105
|
+
reply_token: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
description: 'Optional single-use reply token from the <channel> tag (sends free; falls back to push).',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
required: ['chat_id', 'image'],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
57
113
|
];
|
|
58
114
|
}
|
|
59
115
|
|
|
60
116
|
async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
|
|
61
117
|
if (name === 'line_reply') return this.handleReply(args);
|
|
118
|
+
if (name === 'line_image') return this.handleImage(args);
|
|
62
119
|
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
63
120
|
}
|
|
64
121
|
|
|
@@ -124,4 +181,133 @@ export class LineModule implements ToolModule {
|
|
|
124
181
|
};
|
|
125
182
|
}
|
|
126
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Send an image to LINE. LINE's servers fetch the image over public HTTPS (the
|
|
187
|
+
* normal media route is API-key-gated), so we mint a short-lived share token via
|
|
188
|
+
* the gateway share bridge — the SAME `/shared/:token` primitive the image tools
|
|
189
|
+
* use — and hand LINE the resulting public URL. The host comes from `.public-base`
|
|
190
|
+
* (derived by the gateway from the inbound webhook); the token is host-agnostic,
|
|
191
|
+
* so we simply prepend that base to `/shared/<token>`.
|
|
192
|
+
*/
|
|
193
|
+
private async handleImage(args: Record<string, unknown>): Promise<McpToolResult> {
|
|
194
|
+
const chatId = typeof args.chat_id === 'string' ? args.chat_id : '';
|
|
195
|
+
const image = typeof args.image === 'string' ? args.image : '';
|
|
196
|
+
const preview = typeof args.preview === 'string' && args.preview ? args.preview : image;
|
|
197
|
+
const replyToken = typeof args.reply_token === 'string' ? args.reply_token : '';
|
|
198
|
+
const token = process.env.LINE_CHANNEL_ACCESS_TOKEN ?? '';
|
|
199
|
+
|
|
200
|
+
if (!chatId) {
|
|
201
|
+
return { content: [{ type: 'text', text: 'line_image: missing chat_id' }], isError: true };
|
|
202
|
+
}
|
|
203
|
+
if (!image) {
|
|
204
|
+
return { content: [{ type: 'text', text: 'line_image: image path is required' }], isError: true };
|
|
205
|
+
}
|
|
206
|
+
// Fail fast on a missing LINE token BEFORE any file IO or share mint, so a
|
|
207
|
+
// misconfigured pod never mints a throwaway share it can't deliver.
|
|
208
|
+
if (!token) {
|
|
209
|
+
return { content: [{ type: 'text', text: 'line_image: missing LINE_CHANNEL_ACCESS_TOKEN' }], isError: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Public base URL is derived by the gateway from the inbound LINE webhook and
|
|
213
|
+
// written to `<workspace>/../.public-base` (no public-base-URL env var). Read it
|
|
214
|
+
// here at call-time; if it isn't there yet the pod hasn't seen a webhook, so
|
|
215
|
+
// ask the user to send another message rather than pushing a broken URL.
|
|
216
|
+
const workspace = process.env.GATEWAY_WORKSPACE_DIR;
|
|
217
|
+
if (!workspace) {
|
|
218
|
+
return {
|
|
219
|
+
content: [{ type: 'text', text: 'line_image: GATEWAY_WORKSPACE_DIR not set' }],
|
|
220
|
+
isError: true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
let publicBase = '';
|
|
224
|
+
try {
|
|
225
|
+
publicBase = fs
|
|
226
|
+
.readFileSync(path.resolve(workspace, '..', '.public-base'), 'utf8')
|
|
227
|
+
.trim()
|
|
228
|
+
.replace(/\/+$/, '');
|
|
229
|
+
} catch {
|
|
230
|
+
publicBase = '';
|
|
231
|
+
}
|
|
232
|
+
if (!publicBase) {
|
|
233
|
+
return {
|
|
234
|
+
content: [{ type: 'text', text: 'line_image: public base not resolved yet (send a message again)' }],
|
|
235
|
+
isError: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
// The share bridge (the same authenticated local gateway API the image tools
|
|
239
|
+
// use) needs the session identity injected into this subprocess. Missing it =
|
|
240
|
+
// image delivery isn't wired up on this pod.
|
|
241
|
+
if (!shareBridgeEnabled()) {
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: 'text', text: 'line_image: image delivery not configured (share bridge unavailable)' }],
|
|
244
|
+
isError: true,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const relImage = toRelativeMediaPath(image);
|
|
249
|
+
const relPreview = toRelativeMediaPath(preview);
|
|
250
|
+
if (!relImage || !relPreview) {
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: 'text', text: `line_image: could not resolve media path to the agent media root: ${image}` }],
|
|
253
|
+
isError: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const ttlMs = Number(process.env.GATEWAY_MEDIA_URL_TTL_MS) > 0
|
|
258
|
+
? Number(process.env.GATEWAY_MEDIA_URL_TTL_MS)
|
|
259
|
+
: DEFAULT_MEDIA_URL_TTL_MS;
|
|
260
|
+
const ttlSeconds = Math.max(10, Math.ceil(ttlMs / 1000));
|
|
261
|
+
|
|
262
|
+
// Mint one share per ref (order preserved). Identical refs dedupe to the same
|
|
263
|
+
// token within the store's idempotency window — fine, LINE gets a valid URL.
|
|
264
|
+
let originalContentUrl: string;
|
|
265
|
+
let previewImageUrl: string;
|
|
266
|
+
try {
|
|
267
|
+
const items = await createShares(
|
|
268
|
+
[{ path: relImage }, { path: relPreview }],
|
|
269
|
+
{ purpose: 'line', ttlSeconds },
|
|
270
|
+
);
|
|
271
|
+
if (!items[0]?.token || !items[1]?.token) {
|
|
272
|
+
throw new ShareClientError('share_failed', 'share response missing token', 500);
|
|
273
|
+
}
|
|
274
|
+
originalContentUrl = `${publicBase}/shared/${items[0].token}`;
|
|
275
|
+
previewImageUrl = `${publicBase}/shared/${items[1].token}`;
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const msg = err instanceof ShareClientError ? `${err.code}: ${err.message}` : String(err);
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: 'text', text: `line_image: failed to mint share URL — ${msg}` }],
|
|
280
|
+
isError: true,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const message = {
|
|
285
|
+
type: 'image' as const,
|
|
286
|
+
originalContentUrl,
|
|
287
|
+
previewImageUrl,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const client = new messagingApi.MessagingApiClient({ channelAccessToken: token });
|
|
291
|
+
|
|
292
|
+
let via = 'push';
|
|
293
|
+
try {
|
|
294
|
+
if (replyToken) {
|
|
295
|
+
try {
|
|
296
|
+
await client.replyMessage({ replyToken, messages: [message] });
|
|
297
|
+
via = 'reply';
|
|
298
|
+
} catch {
|
|
299
|
+
await client.pushMessage({ to: chatId, messages: [message] });
|
|
300
|
+
via = 'push (reply fallback)';
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
await client.pushMessage({ to: chatId, messages: [message] });
|
|
304
|
+
}
|
|
305
|
+
return { content: [{ type: 'text', text: `Sent image to LINE (${via}).` }] };
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return {
|
|
308
|
+
content: [{ type: 'text', text: `line_image failed: ${(err as Error).message}` }],
|
|
309
|
+
isError: true,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
127
313
|
}
|