@0xmaxma/claude-gateway 1.8.8 → 1.8.9

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.
@@ -19,7 +19,16 @@ const IMAGE_INSTRUCTION = [
19
19
  '• NEVER tell the user to install an app or set up an MCP server for images.',
20
20
  ].join('\n');
21
21
 
22
- export function buildChannelInstructions(imageEnabled: boolean): string {
22
+ const VIDEO_INSTRUCTION = [
23
+ 'VIDEO GENERATION IS BUILT IN — you can create short video clips yourself; no app install or API-key setup is needed.',
24
+ '• To make ANY video you MUST use the generate_video tool. When the user asks to create / generate / make / animate a video or clip, call generate_video with action="list" to see the models, then action="generate".',
25
+ '• Video generation legitimately takes MINUTES. A "running" status (including the "still generating, call again with action=status" note) is normal, not stuck — keep polling with action="status" and the SAME task_id until it resolves to done/failed. Do NOT start a second generate for the same request while one is still running (you would pay for two clips).',
26
+ '• ONE clip, sent ONCE, then STOP: as soon as a generate SUCCEEDS, deliver that single mp4 with your reply tool exactly ONE time (files: ["/abs/path.mp4"]) and briefly mention the model. Do NOT re-generate or resend.',
27
+ '• IMAGE-TO-VIDEO: to animate an existing image, pass it in "image". If the user points at an earlier image ("animate image 2"), call action="list_refs" FIRST and use that item\'s "ref" — never count images from your own memory.',
28
+ '• If generation fails or no video model is available, tell the user PLAINLY — do NOT invent app-install / MCP-setup steps, and do NOT pretend a video was created.',
29
+ ].join('\n');
30
+
31
+ export function buildChannelInstructions(imageEnabled: boolean, videoEnabled = false): string {
23
32
  const lines = [
24
33
  'The sender reads Telegram, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.',
25
34
  '',
@@ -38,5 +47,9 @@ export function buildChannelInstructions(imageEnabled: boolean): string {
38
47
  lines.push('', IMAGE_INSTRUCTION);
39
48
  }
40
49
 
50
+ if (videoEnabled) {
51
+ lines.push('', VIDEO_INSTRUCTION);
52
+ }
53
+
41
54
  return lines.join('\n');
42
55
  }
package/mcp/server.ts CHANGED
@@ -21,6 +21,7 @@ import { SkillsModule } from './tools/skills/module';
21
21
  import { AgentModule } from './tools/agent/module';
22
22
  import { BrowserModule } from './tools/browser/module';
23
23
  import { ImageModule } from './tools/image/module';
24
+ import { VideoModule } from './tools/video/module';
24
25
  import { ShareFileModule } from './tools/share-file/module';
25
26
  import { AppsModule } from './tools/apps/module';
26
27
  import { ApiModule } from './tools/api/module';
@@ -46,6 +47,7 @@ const modules: AnyModule[] = [
46
47
  new AgentModule(),
47
48
  new BrowserModule(),
48
49
  new ImageModule(),
50
+ new VideoModule(),
49
51
  new ShareFileModule(),
50
52
  new AppsModule(),
51
53
  new ApiModule(),
@@ -85,6 +87,7 @@ for (const mod of modules) {
85
87
  const shutdownController = new AbortController();
86
88
 
87
89
  const imageEnabled = visibleTools.some((t) => t.name === 'generate_image');
90
+ const videoEnabled = visibleTools.some((t) => t.name === 'generate_video');
88
91
 
89
92
  const mcp = new Server(
90
93
  { name: 'gateway', version: '1.0.0' },
@@ -96,7 +99,7 @@ const mcp = new Server(
96
99
  'claude/channel/permission': {},
97
100
  },
98
101
  },
99
- instructions: buildChannelInstructions(imageEnabled),
102
+ instructions: buildChannelInstructions(imageEnabled, videoEnabled),
100
103
  },
101
104
  );
102
105
 
@@ -136,10 +139,13 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
136
139
  await mcp.connect(new StdioServerTransport());
137
140
 
138
141
  // Graceful shutdown — used by stdin-close, SIGINT, and SIGTERM paths.
139
- // Awaits any in-flight image cancel (E3) before exiting so the provider
140
- // actually stops generating when the user presses Stop, rather than the
142
+ // Awaits any in-flight cancel (E3) on the media modules before exiting so the
143
+ // provider actually stops generating when the user presses Stop, rather than the
141
144
  // process dying mid-request and the cancel call never reaching the server.
142
- const imageModuleRef = modules.find((m) => m.id === 'image') as { drainCancel?: () => Promise<void> } | undefined;
145
+ const drainableModules = modules.filter(
146
+ (m): m is AnyModule & { drainCancel: () => Promise<void> } =>
147
+ typeof (m as { drainCancel?: unknown }).drainCancel === 'function',
148
+ );
143
149
  let shuttingDown = false;
144
150
  function shutdown(): void {
145
151
  if (shuttingDown) return;
@@ -156,7 +162,7 @@ function shutdown(): void {
156
162
  // Give the event loop one tick so the poll loop's sleep() onAbort listener
157
163
  // fires and cancelledResult() sets activeCancelPromise before we try to drain it.
158
164
  setImmediate(async () => {
159
- try { await imageModuleRef?.drainCancel?.(); } catch { /* non-fatal */ }
165
+ try { await Promise.all(drainableModules.map((m) => m.drainCancel())); } catch { /* non-fatal */ }
160
166
  clearTimeout(forceExit);
161
167
  process.exit(0);
162
168
  });
@@ -14,6 +14,7 @@ import {
14
14
  type ShareRef,
15
15
  type ShareItem,
16
16
  } from '../shared/share-client';
17
+ import { sleep, sanitize, readCapped, baseUrlIsSecure } from '../shared/media';
17
18
 
18
19
  /**
19
20
  * Image-generation tool module (#184, Track B).
@@ -661,7 +662,7 @@ export class ImageModule implements ToolModule {
661
662
  await assertSafeImageUrl(item);
662
663
  const res = await fetch(item, { signal: AbortSignal.timeout(30_000), redirect: 'error' });
663
664
  if (!res.ok) throw new Error(`download image failed: HTTP ${res.status}`);
664
- buf = await readCapped(res, DOWNLOAD_MAX_BYTES);
665
+ buf = await readCapped(res, DOWNLOAD_MAX_BYTES, 'image');
665
666
  } else {
666
667
  // Base64 bytes (openai / gemini / stability / hf) — tolerate a data: URI
667
668
  // wrapper as well as raw base64.
@@ -791,25 +792,6 @@ export class ImageModule implements ToolModule {
791
792
  }
792
793
  }
793
794
 
794
- // Resolves early (without rejecting) on abort — the poll loop re-checks
795
- // signal.aborted itself right after, so this only needs to shorten the wait.
796
- // The abort listener is removed when the timer fires normally: sleep() is called
797
- // once per poll iteration against the SAME long-lived signal (up to ~75 times for
798
- // the default 150s/2s budget), so leaving { once: true } listeners around on the
799
- // non-abort path would pile them onto that one signal and trip Node's
800
- // MaxListenersExceededWarning.
801
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
802
- return new Promise((r) => {
803
- const onAbort = () => { clearTimeout(t); r(); };
804
- const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); r(); }, ms);
805
- signal?.addEventListener('abort', onAbort, { once: true });
806
- });
807
- }
808
-
809
- function sanitize(s: string): string {
810
- return s.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 48) || 'default';
811
- }
812
-
813
795
  // SSRF guard for provider image URLs. Require https, then resolve the host and
814
796
  // reject if ANY resolved address is private / loopback / link-local / metadata —
815
797
  // so a compromised provider response can't make the gateway fetch internal or
@@ -873,29 +855,6 @@ function isBlockedAddress(ip: string): boolean {
873
855
  return true; // not a valid IP → block
874
856
  }
875
857
 
876
- // Read a response body into a Buffer with a hard byte ceiling: reject early on a
877
- // too-large Content-Length, and stream-count actual bytes so a chunked response
878
- // without Content-Length can't blow past the cap (OOM guard).
879
- async function readCapped(res: Response, cap: number): Promise<Buffer> {
880
- const declared = Number(res.headers.get('content-length'));
881
- if (Number.isFinite(declared) && declared > cap) {
882
- throw new Error(`download image too large: ${declared} bytes (max ${cap})`);
883
- }
884
- if (!res.body) {
885
- const ab = await res.arrayBuffer();
886
- if (ab.byteLength > cap) throw new Error(`download image too large (max ${cap} bytes)`);
887
- return Buffer.from(ab);
888
- }
889
- const chunks: Buffer[] = [];
890
- let total = 0;
891
- for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
892
- total += chunk.length;
893
- if (total > cap) throw new Error(`download image exceeded ${cap} bytes`);
894
- chunks.push(Buffer.from(chunk));
895
- }
896
- return Buffer.concat(chunks);
897
- }
898
-
899
858
  function defaultCodeForStatus(status: number): string {
900
859
  switch (status) {
901
860
  case 400: return 'invalid_model';
@@ -923,33 +882,6 @@ function detectImageExt(buf: Buffer): string | null {
923
882
  return null;
924
883
  }
925
884
 
926
- // https is required for a PUBLIC image endpoint (the Bearer proxy_secret is sent on
927
- // every call); http is tolerated only for a local/internal host — a trusted hop such
928
- // as host.docker.internal in dev, where cleartext never leaves the machine/network.
929
- function baseUrlIsSecure(raw: string): boolean {
930
- if (!raw) return false;
931
- let u: URL;
932
- try {
933
- u = new URL(raw);
934
- } catch {
935
- return false;
936
- }
937
- if (u.protocol === 'https:') return true;
938
- if (u.protocol !== 'http:') return false;
939
- const h = u.hostname.toLowerCase();
940
- return (
941
- h === 'localhost' ||
942
- h === 'host.docker.internal' ||
943
- h.endsWith('.internal') ||
944
- h.endsWith('.local') ||
945
- /^127\./.test(h) ||
946
- h === '::1' ||
947
- /^10\./.test(h) ||
948
- /^192\.168\./.test(h) ||
949
- /^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)
950
- );
951
- }
952
-
953
885
  const imageToolDefs: McpToolDefinition[] = [
954
886
  {
955
887
  name: 'generate_image',
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Media-download/poll helpers shared by the image and video MCP tool modules.
3
+ * Self-contained on purpose, like share-client.ts: mcp/** ships as source
4
+ * without src/**, so this module must not import from src/ (see
5
+ * tests/unit/mcp-no-src-imports.test.ts).
6
+ */
7
+
8
+ // Resolves early (without rejecting) on abort — a poll loop re-checks
9
+ // signal.aborted itself right after, so this only needs to shorten the wait.
10
+ // The abort listener is removed when the timer fires normally: sleep() is called
11
+ // once per poll iteration against the SAME long-lived signal (up to dozens of
12
+ // times for a multi-minute poll budget), so leaving { once: true } listeners
13
+ // around on the non-abort path would pile them onto that one signal and trip
14
+ // Node's MaxListenersExceededWarning.
15
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
16
+ return new Promise((r) => {
17
+ const onAbort = () => { clearTimeout(t); r(); };
18
+ const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); r(); }, ms);
19
+ signal?.addEventListener('abort', onAbort, { once: true });
20
+ });
21
+ }
22
+
23
+ export function sanitize(s: string): string {
24
+ return s.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 48) || 'default';
25
+ }
26
+
27
+ // Read a response body into a Buffer with a hard byte ceiling: reject early on a
28
+ // too-large Content-Length, and stream-count actual bytes so a chunked response
29
+ // without Content-Length can't blow past the cap (OOM guard). `label` (e.g.
30
+ // "image"/"video") only shapes the error message.
31
+ export async function readCapped(res: Response, cap: number, label = 'file'): Promise<Buffer> {
32
+ const declared = Number(res.headers.get('content-length'));
33
+ if (Number.isFinite(declared) && declared > cap) {
34
+ throw new Error(`download ${label} too large: ${declared} bytes (max ${cap})`);
35
+ }
36
+ if (!res.body) {
37
+ const ab = await res.arrayBuffer();
38
+ if (ab.byteLength > cap) throw new Error(`download ${label} too large (max ${cap} bytes)`);
39
+ return Buffer.from(ab);
40
+ }
41
+ const chunks: Buffer[] = [];
42
+ let total = 0;
43
+ for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
44
+ total += chunk.length;
45
+ if (total > cap) throw new Error(`download ${label} exceeded ${cap} bytes`);
46
+ chunks.push(Buffer.from(chunk));
47
+ }
48
+ return Buffer.concat(chunks);
49
+ }
50
+
51
+ // https is required for a PUBLIC endpoint (a Bearer proxy_secret is sent on
52
+ // every call); http is tolerated only for a local/internal host — a trusted hop
53
+ // such as host.docker.internal in dev, where cleartext never leaves the network.
54
+ export function baseUrlIsSecure(raw: string): boolean {
55
+ if (!raw) return false;
56
+ let u: URL;
57
+ try {
58
+ u = new URL(raw);
59
+ } catch {
60
+ return false;
61
+ }
62
+ if (u.protocol === 'https:') return true;
63
+ if (u.protocol !== 'http:') return false;
64
+ const h = u.hostname.toLowerCase();
65
+ return (
66
+ h === 'localhost' ||
67
+ h === 'host.docker.internal' ||
68
+ h.endsWith('.internal') ||
69
+ h.endsWith('.local') ||
70
+ /^127\./.test(h) ||
71
+ h === '::1' ||
72
+ /^10\./.test(h) ||
73
+ /^192\.168\./.test(h) ||
74
+ /^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)
75
+ );
76
+ }
@@ -0,0 +1,676 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
5
+ import {
6
+ ShareClientError,
7
+ createShares,
8
+ listSessionImages,
9
+ revokeSharesBestEffort,
10
+ shareBridgeEnabled,
11
+ type ShareRef,
12
+ type ShareItem,
13
+ } from '../shared/share-client';
14
+ import { sleep, sanitize, readCapped, baseUrlIsSecure } from '../shared/media';
15
+
16
+ /**
17
+ * Video-generation tool module — the moving-picture parallel of the image module
18
+ * (tools/image/module.ts), sharing the same env-configured endpoint and M2M
19
+ * `Authorization: Bearer <proxy_secret>` seam. A single `generate_video` tool with
20
+ * generate | status | list | list_refs actions, results written into the session
21
+ * media dir (like generate_image) so the existing reply tools deliver them.
22
+ *
23
+ * Flow (api contract, mirrors E1/E2 of the image path):
24
+ * generate → POST /v1/videos/generations (202 { task_id })
25
+ * → poll GET /v1/videos/jobs/:id → on done the job carries a `video_url`
26
+ * (a stable, M2M-authed api path /v1/videos/files/:id.mp4). We download that
27
+ * clip WITH the proxy secret — it is our own trusted api host, NOT an untrusted
28
+ * provider URL — and write the mp4 into GATEWAY_SESSION_MEDIA_DIR.
29
+ *
30
+ * Key differences from the image tool, all driven by the medium:
31
+ * - one clip per request (no `n`), so no batch delivery;
32
+ * - the result is always a large binary the api streams back over an authed
33
+ * path — never base64-in-JSON — so `deliver()` re-fetches by task_id with the
34
+ * Bearer secret instead of running the image path's public-URL SSRF screen;
35
+ * - a longer default poll budget (video generation legitimately runs minutes).
36
+ * - `image` is a single optional SOURCE FRAME for image-to-video (share-bridge
37
+ * minted to a public URL the provider fetches), not a batch of edit refs, and
38
+ * there is no `continue_from` resume concept.
39
+ */
40
+
41
+ const DEFAULT_POLL_INTERVAL_MS = 3000;
42
+ const DEFAULT_POLL_TIMEOUT_MS = 300_000; // 5 min — video runs longer than an image
43
+ const REQUEST_TIMEOUT_MS = 30_000;
44
+ // A finished clip is a few MB, but bound the download so a hung/oversized upstream
45
+ // can't OOM the tool. The api streams it off the microservice's disk.
46
+ const DOWNLOAD_MAX_BYTES = 200 * 1024 * 1024; // 200 MB
47
+ const DOWNLOAD_TIMEOUT_MS = 120_000;
48
+
49
+ /** Human-readable guidance per api error code (mirrors the video api taxonomy). */
50
+ const ERROR_HINTS: Record<string, string> = {
51
+ invalid_model: 'The model id is not recognised. Call generate_video with action="list" to see valid video models.',
52
+ model_not_video: 'That model is not a video model. Use action="list" to pick a video-capable model.',
53
+ missing_prompt: 'A non-empty prompt is required to generate a video.',
54
+ unsupported_duration: 'The requested duration is out of range for this model. Try a shorter clip.',
55
+ unauthorized: 'The gateway is not authorised to call the video service (check the proxy secret).',
56
+ insufficient_credit: 'Not enough daily credit to generate this video on the managed pool. Try later.',
57
+ not_pool_eligible: 'That model is not pool-eligible for video generation. Pick a pool-eligible video model (action="list").',
58
+ no_credential: 'No provider key is available for video generation.',
59
+ rate_limited: 'Video generation is rate-limited right now. Wait a moment and try again.',
60
+ no_supply: 'No managed provider key is available for this provider right now. Try again later.',
61
+ provider_error: 'The video provider returned an error. Try again or adjust the prompt.',
62
+ provider_timeout: 'The video provider timed out. Try again.',
63
+ content_policy: 'The prompt was rejected by the provider content policy. Rephrase and try again.',
64
+ job_not_found: 'That video job was not found (it may have expired or belongs to another user).',
65
+ result_expired: 'The generated video expired before it was fetched (credit was already spent). Generate it again.',
66
+ };
67
+
68
+ type JobResponse = {
69
+ task_id?: string;
70
+ status?: 'queued' | 'running' | 'done' | 'failed';
71
+ // What actually generated (or is generating) the clip — echoed on every poll so
72
+ // a status re-poll in a LATER turn (no longer holding the original "model" arg)
73
+ // can still recover them instead of falling back to "unknown".
74
+ provider?: string;
75
+ model?: string;
76
+ provider_task_id?: string;
77
+ byok?: boolean;
78
+ cost?: number;
79
+ // Stable, M2M-authed api path (/v1/videos/files/:id.mp4) to the finished clip.
80
+ // Present only on a done job.
81
+ video_url?: string;
82
+ error?: { code?: string; message?: string };
83
+ };
84
+
85
+ export class VideoModule implements ToolModule {
86
+ id = 'video';
87
+ toolVisibility: ToolVisibility = 'all-configured';
88
+
89
+ isEnabled(): boolean {
90
+ // Enabled when the api endpoint is configured (same resolution as the image
91
+ // tool — video shares the getpod api) and not explicitly turned off.
92
+ if (!this.baseUrl() || process.env.VIDEO_DISABLED === 'true') return false;
93
+ // The Bearer proxy_secret rides every call — refuse a cleartext http URL to a
94
+ // PUBLIC host (that would leak the secret). http to a local/internal host is a
95
+ // trusted hop (e.g. host.docker.internal in dev) and stays allowed.
96
+ if (!baseUrlIsSecure(this.baseUrl())) {
97
+ if (!this.warnedInsecureUrl) {
98
+ this.warnedInsecureUrl = true;
99
+ console.error(
100
+ `[video] 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).`
101
+ );
102
+ }
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+
108
+ private warnedInsecureUrl = false;
109
+
110
+ getTools(): McpToolDefinition[] {
111
+ return videoToolDefs;
112
+ }
113
+
114
+ async handleTool(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<McpToolResult> {
115
+ if (name !== 'generate_video') {
116
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
117
+ }
118
+ const action = typeof args.action === 'string' ? args.action : 'generate';
119
+ switch (action) {
120
+ case 'generate':
121
+ // Only "generate" gets the signal — it's the one action with a multi-second
122
+ // poll loop worth reacting to a Stop mid-flight. status/list/list_refs are
123
+ // single bounded requests already.
124
+ return this.handleGenerate(args, signal);
125
+ case 'status':
126
+ return this.handleStatus(args);
127
+ case 'list':
128
+ return this.handleList();
129
+ case 'list_refs':
130
+ return this.handleListRefs();
131
+ default:
132
+ return {
133
+ content: [{ type: 'text', text: `generate_video: unknown action "${action}" (expected generate | status | list | list_refs)` }],
134
+ isError: true,
135
+ };
136
+ }
137
+ }
138
+
139
+ // ── config ────────────────────────────────────────────────────────────────
140
+ // Identical resolution to the image tool: video targets the same getpod api.
141
+
142
+ private baseUrl(): string {
143
+ const raw =
144
+ process.env.VIDEO_BASE_URL ||
145
+ process.env.IMAGE_BASE_URL ||
146
+ process.env.ANTHROPIC_BASE_URL ||
147
+ this.settingsEnv('ANTHROPIC_BASE_URL');
148
+ return raw.replace(/\/+$/, '');
149
+ }
150
+
151
+ private authToken(): string {
152
+ return (
153
+ process.env.VIDEO_API_KEY ||
154
+ process.env.IMAGE_API_KEY ||
155
+ process.env.ANTHROPIC_AUTH_TOKEN ||
156
+ this.settingsEnv('ANTHROPIC_AUTH_TOKEN') ||
157
+ this.settingsEnv('CLAUDE_CODE_OAUTH_TOKEN')
158
+ );
159
+ }
160
+
161
+ // Parsed `env` block of the CLI config, read once per instance.
162
+ private settingsEnvCache?: Record<string, unknown> | null;
163
+
164
+ private settingsEnv(key: string): string {
165
+ if (this.settingsEnvCache === undefined) {
166
+ try {
167
+ const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
168
+ this.settingsEnvCache =
169
+ JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'))?.env ?? null;
170
+ } catch {
171
+ this.settingsEnvCache = null;
172
+ }
173
+ }
174
+ const v = this.settingsEnvCache?.[key];
175
+ return typeof v === 'string' ? v : '';
176
+ }
177
+
178
+ private headers(): Record<string, string> {
179
+ const h: Record<string, string> = { 'Content-Type': 'application/json' };
180
+ const token = this.authToken();
181
+ if (token) h['Authorization'] = `Bearer ${token}`;
182
+ const agentId = process.env.GATEWAY_AGENT_ID;
183
+ const sessionId = process.env.GATEWAY_SESSION_ID;
184
+ if (agentId) h['X-Agent-Id'] = agentId;
185
+ if (sessionId) h['X-Session-Id'] = sessionId;
186
+ return h;
187
+ }
188
+
189
+ // ── actions ───────────────────────────────────────────────────────────────
190
+
191
+ private async handleList(): Promise<McpToolResult> {
192
+ const url = `${this.baseUrl()}/v1/models?kind=video`;
193
+ let res: Response;
194
+ try {
195
+ res = await fetch(url, { method: 'GET', headers: this.headers(), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
196
+ } catch (err) {
197
+ return this.unavailable(err);
198
+ }
199
+ const body = await res.text().catch(() => '');
200
+ if (!res.ok) return this.mapHttpError(res.status, body);
201
+ return { content: [{ type: 'text', text: body || '[]' }] };
202
+ }
203
+
204
+ /**
205
+ * action="list_refs" — the same ground-truth catalog of this session's images
206
+ * the image tool exposes. For video it resolves a SOURCE FRAME for
207
+ * image-to-video ("animate image 2"). Read-only, gated on the share bridge.
208
+ */
209
+ private async handleListRefs(): Promise<McpToolResult> {
210
+ if (!shareBridgeEnabled()) {
211
+ return {
212
+ content: [{ type: 'text', text: 'generate_video: list_refs is unavailable (share bridge is not configured).' }],
213
+ isError: true,
214
+ };
215
+ }
216
+ let items;
217
+ try {
218
+ items = await listSessionImages();
219
+ } catch (err) {
220
+ if (err instanceof ShareClientError) {
221
+ return { content: [{ type: 'text', text: `generate_video: ${err.code}: ${err.message}` }], isError: true };
222
+ }
223
+ return {
224
+ content: [{ type: 'text', text: `generate_video: share service unavailable: ${(err as Error).message}` }],
225
+ isError: true,
226
+ };
227
+ }
228
+ return {
229
+ content: [{
230
+ type: 'text',
231
+ text: JSON.stringify({
232
+ images: items,
233
+ note: 'Ground-truth catalog of every image in this session, numbered in order of first appearance ("image 1" = index 1). '
234
+ + 'To animate one as the source frame, pass its "ref" value in the "image" argument of action="generate". '
235
+ + 'Do NOT count images from conversation memory. '
236
+ + 'When the user names an index ("Image 3", "the third image") or attached an image this turn, trust that exactly; '
237
+ + 'when they refer by content ("the dog picture"), match against each item\'s "desc". '
238
+ + 'If the reference is ambiguous or the index does not exist, ask the user instead of guessing. '
239
+ + 'Items with available:false can no longer be used.',
240
+ }),
241
+ }],
242
+ };
243
+ }
244
+
245
+ private async handleGenerate(args: Record<string, unknown>, signal?: AbortSignal): Promise<McpToolResult> {
246
+ const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
247
+ const model = typeof args.model === 'string' ? args.model.trim() : '';
248
+ if (!prompt) {
249
+ return { content: [{ type: 'text', text: `${ERROR_HINTS.missing_prompt}` }], isError: true };
250
+ }
251
+ if (!model) {
252
+ return { content: [{ type: 'text', text: 'generate_video: "model" is required (use action="list" to see options).' }], isError: true };
253
+ }
254
+
255
+ // Build request body — forward only defined optional fields.
256
+ const reqBody: Record<string, unknown> = { model, prompt };
257
+ for (const k of ['resolution', 'aspect_ratio'] as const) {
258
+ if (typeof args[k] === 'string' && (args[k] as string).length) reqBody[k] = args[k];
259
+ }
260
+ if (typeof args.duration === 'number' && args.duration > 0) {
261
+ reqBody.duration = Math.floor(args.duration);
262
+ }
263
+
264
+ // Optional source frame for image-to-video. With the share bridge on, a local
265
+ // media path or artifact:<id> is minted to a short-lived public URL the
266
+ // provider fetches; with the bridge off, it's an exact legacy pass-through.
267
+ const rawImage = typeof args.image === 'string' && args.image.length ? args.image.trim() : undefined;
268
+ let mintedShareIds: string[] = [];
269
+ if (rawImage) {
270
+ if (shareBridgeEnabled()) {
271
+ const normalized = await this.normalizeRef(rawImage);
272
+ if ('error' in normalized) return normalized.error;
273
+ mintedShareIds = normalized.mintedShareIds;
274
+ reqBody.image = normalized.url;
275
+ } else {
276
+ reqBody.image = rawImage;
277
+ }
278
+ }
279
+
280
+ // Submit. On an immediate submit failure, best-effort revoke any share URL
281
+ // minted for this call — the TTL still bounds exposure otherwise.
282
+ let res: Response;
283
+ try {
284
+ res = await fetch(`${this.baseUrl()}/v1/videos/generations`, {
285
+ method: 'POST',
286
+ headers: this.headers(),
287
+ body: JSON.stringify(reqBody),
288
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
289
+ });
290
+ } catch (err) {
291
+ await revokeSharesBestEffort(mintedShareIds);
292
+ return this.unavailable(err);
293
+ }
294
+ const submitText = await res.text().catch(() => '');
295
+ if (!res.ok) {
296
+ await revokeSharesBestEffort(mintedShareIds);
297
+ return this.mapHttpError(res.status, submitText);
298
+ }
299
+
300
+ let submit: JobResponse;
301
+ try {
302
+ submit = JSON.parse(submitText) as JobResponse;
303
+ } catch {
304
+ await revokeSharesBestEffort(mintedShareIds);
305
+ return { content: [{ type: 'text', text: 'generate_video: invalid JSON from video service on submit' }], isError: true };
306
+ }
307
+ const taskId = submit.task_id;
308
+ if (!taskId) {
309
+ await revokeSharesBestEffort(mintedShareIds);
310
+ return { content: [{ type: 'text', text: 'generate_video: video service did not return a task_id' }], isError: true };
311
+ }
312
+
313
+ // Poll until done/failed, budget exceeded, or the caller cancels (Stop
314
+ // mid-generation). Checked at the top of every iteration AND inside sleep().
315
+ const deadline = Date.now() + this.pollTimeoutMs();
316
+ let last: JobResponse = submit;
317
+ while (Date.now() < deadline) {
318
+ if (signal?.aborted) return this.cancelledResult(taskId);
319
+ await sleep(DEFAULT_POLL_INTERVAL_MS, signal);
320
+ if (signal?.aborted) return this.cancelledResult(taskId);
321
+ const polled = await this.fetchJob(taskId, signal);
322
+ if (polled.__transportError) {
323
+ if (signal?.aborted) return this.cancelledResult(taskId);
324
+ continue;
325
+ }
326
+ if (polled.httpError) return this.mapHttpError(polled.httpError.status, polled.httpError.body);
327
+ last = polled.job!;
328
+ if (last.status === 'done') return await this.deliver(last, taskId, model);
329
+ if (last.status === 'failed') return this.mapJobError(last);
330
+ }
331
+
332
+ // Still running after the local poll budget — hand the task_id back so the
333
+ // agent can poll with action="status" (the api keeps the buffered result).
334
+ return {
335
+ content: [{
336
+ type: 'text',
337
+ text: JSON.stringify({
338
+ status: last.status ?? 'running',
339
+ task_id: taskId,
340
+ byok: last.byok ?? submit.byok ?? false,
341
+ cost: last.cost ?? submit.cost ?? 0,
342
+ note: 'Video is still generating. Call generate_video again with action="status" and this task_id to fetch the result.',
343
+ }),
344
+ }],
345
+ };
346
+ }
347
+
348
+ private async handleStatus(args: Record<string, unknown>): Promise<McpToolResult> {
349
+ const taskId = typeof args.task_id === 'string' ? args.task_id.trim() : '';
350
+ if (!taskId) {
351
+ return { content: [{ type: 'text', text: 'generate_video: action="status" requires "task_id"' }], isError: true };
352
+ }
353
+ const polled = await this.fetchJob(taskId);
354
+ if (polled.__transportError) return this.unavailable(polled.__transportError);
355
+ if (polled.httpError) return this.mapHttpError(polled.httpError.status, polled.httpError.body);
356
+ const job = polled.job!;
357
+ if (job.status === 'done') return await this.deliver(job, taskId);
358
+ if (job.status === 'failed') return this.mapJobError(job);
359
+ return {
360
+ content: [{
361
+ type: 'text',
362
+ text: JSON.stringify({ status: job.status ?? 'running', task_id: taskId, byok: job.byok ?? false, cost: job.cost ?? 0 }),
363
+ }],
364
+ };
365
+ }
366
+
367
+ // ── helpers ─────────────────────────────────────────────────────────────
368
+
369
+ /**
370
+ * Normalize a single source-frame reference, preserving legacy pass-through when
371
+ * the share bridge is off (caller checks that before invoking this):
372
+ * https URL → validate syntax, pass through
373
+ * http URL → reject (requires HTTPS)
374
+ * artifact:<id> → resolve+mint via gateway share API
375
+ * local path → validate+mint via gateway share API
376
+ */
377
+ private async normalizeRef(
378
+ raw: string,
379
+ ): Promise<{ url: string; mintedShareIds: string[] } | { error: McpToolResult }> {
380
+ const fail = (text: string): { error: McpToolResult } => ({
381
+ error: { content: [{ type: 'text', text }], isError: true },
382
+ });
383
+ const ref = raw.trim();
384
+ if (/^https:\/\//i.test(ref)) {
385
+ try {
386
+ new URL(ref);
387
+ } catch {
388
+ return fail('generate_video: reference URL is malformed.');
389
+ }
390
+ return { url: ref, mintedShareIds: [] };
391
+ }
392
+ if (/^http:\/\//i.test(ref)) {
393
+ return fail('generate_video: http:// reference URLs are not allowed — use https.');
394
+ }
395
+ let shareRef: ShareRef;
396
+ if (ref.startsWith('artifact:')) {
397
+ const id = ref.slice('artifact:'.length).trim();
398
+ if (!id) return fail('generate_video: empty artifact reference.');
399
+ shareRef = { artifact_id: id };
400
+ } else {
401
+ shareRef = { path: ref };
402
+ }
403
+ let minted: ShareItem[];
404
+ try {
405
+ minted = await createShares([shareRef], { purpose: 'codex_ref' });
406
+ } catch (err) {
407
+ if (err instanceof ShareClientError) {
408
+ if (err.code === 'share_ref_not_found' || err.code === 'image_ref_not_found') {
409
+ return fail(`generate_video: ${err.code}: the referenced image/artifact does not exist in this session.`);
410
+ }
411
+ return fail(`generate_video: ${err.code}: ${err.message}`);
412
+ }
413
+ return fail(`generate_video: share service unavailable: ${(err as Error).message}`);
414
+ }
415
+ const item = minted[0];
416
+ if (!item) return fail('generate_video: share service returned no share for the source frame.');
417
+ // The provider fetches this over HTTPS, so it needs an absolute URL — which the
418
+ // share API only fills when gateway.publicUrl is configured.
419
+ if (!item.url) {
420
+ await revokeSharesBestEffort([item.share_id]);
421
+ return fail('generate_video: source-frame sharing requires gateway.publicUrl to be configured.');
422
+ }
423
+ return { url: item.url, mintedShareIds: [item.share_id] };
424
+ }
425
+
426
+ private pollTimeoutMs(): number {
427
+ const raw = Number(process.env.VIDEO_POLL_TIMEOUT_MS);
428
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_POLL_TIMEOUT_MS;
429
+ }
430
+
431
+ /** Best-effort E3 cancel — fires and never throws. */
432
+ private async cancelJob(taskId: string): Promise<void> {
433
+ try {
434
+ await fetch(`${this.baseUrl()}/v1/videos/jobs/${encodeURIComponent(taskId)}/cancel`, {
435
+ method: 'POST',
436
+ headers: this.headers(),
437
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
438
+ });
439
+ } catch {
440
+ // non-fatal — the local tool call already reports itself cancelled either way
441
+ }
442
+ }
443
+
444
+ // Tracks the in-flight cancel call so drainCancel() can await it before the
445
+ // server process exits (see the image module for the full stdin-close rationale).
446
+ private activeCancelPromise: Promise<void> | null = null;
447
+
448
+ /** Wait for any in-flight E3 cancel to complete. Called by the shutdown handler. */
449
+ async drainCancel(): Promise<void> {
450
+ if (this.activeCancelPromise) await this.activeCancelPromise;
451
+ }
452
+
453
+ /** Tool result for a Stop-triggered cancellation, firing the E3 cancel first. */
454
+ private cancelledResult(taskId: string): McpToolResult {
455
+ this.activeCancelPromise = this.cancelJob(taskId).finally(() => {
456
+ this.activeCancelPromise = null;
457
+ });
458
+ return {
459
+ content: [{
460
+ type: 'text',
461
+ text: `generate_video: cancelled (task_id ${taskId}).`,
462
+ }],
463
+ isError: true,
464
+ };
465
+ }
466
+
467
+ /** Fetch a job (E2), classifying transport vs HTTP errors so the poller can retry transient ones. */
468
+ private async fetchJob(taskId: string, signal?: AbortSignal): Promise<{ job?: JobResponse; httpError?: { status: number; body: string }; __transportError?: unknown }> {
469
+ let res: Response;
470
+ try {
471
+ const fetchSignal = signal
472
+ ? AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal])
473
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
474
+ res = await fetch(`${this.baseUrl()}/v1/videos/jobs/${encodeURIComponent(taskId)}`, {
475
+ method: 'GET',
476
+ headers: this.headers(),
477
+ signal: fetchSignal,
478
+ });
479
+ } catch (err) {
480
+ return { __transportError: err };
481
+ }
482
+ const text = await res.text().catch(() => '');
483
+ if (!res.ok) return { httpError: { status: res.status, body: text } };
484
+ try {
485
+ return { job: JSON.parse(text) as JobResponse };
486
+ } catch {
487
+ return { httpError: { status: res.status, body: 'invalid JSON from video service' } };
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Download the finished clip into the session media dir and return its path.
493
+ *
494
+ * Unlike the image path, the clip is not base64 in the job JSON — it is streamed
495
+ * by our OWN api at a stable, M2M-authed path we reconstruct from the task id
496
+ * (/v1/videos/files/:id.mp4). We fetch it WITH the proxy secret. Because that
497
+ * host is the same trusted api we just submitted to (not a provider-controlled
498
+ * URL), the image path's public-address SSRF screen does NOT apply — it would in
499
+ * fact block our own internal api host. The backstop against garbage is the mp4
500
+ * magic-byte check below.
501
+ */
502
+ // `model` is only known on the generate path (same-turn poll); the
503
+ // action="status" path re-enters in a later turn without it.
504
+ private async deliver(job: JobResponse, taskId: string, model?: string): Promise<McpToolResult> {
505
+ // Reconstruct the file URL from the task id rather than trusting job.video_url
506
+ // verbatim (defence in depth — it's our own fixed path shape either way).
507
+ const fileUrl = `${this.baseUrl()}/v1/videos/files/${encodeURIComponent(taskId)}.mp4`;
508
+ const mediaDir = this.resolveMediaDir();
509
+ let filePath: string;
510
+ try {
511
+ let res: Response;
512
+ try {
513
+ res = await fetch(fileUrl, { method: 'GET', headers: this.headers(), signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), redirect: 'error' });
514
+ } catch (err) {
515
+ // A done job whose buffered clip is gone (TTL) surfaces here or as a 404.
516
+ return { content: [{ type: 'text', text: `generate_video: failed to fetch the finished clip: ${(err as Error).message}` }], isError: true };
517
+ }
518
+ if (res.status === 404) {
519
+ const code = job.error?.code ?? 'result_expired';
520
+ const msg = job.error?.message ?? ERROR_HINTS[code] ?? 'The generated video is no longer available.';
521
+ return { content: [{ type: 'text', text: `${code}: ${msg}` }], isError: true };
522
+ }
523
+ if (!res.ok) {
524
+ const body = await res.text().catch(() => '');
525
+ return this.mapHttpError(res.status, body);
526
+ }
527
+ const buf = await readCapped(res, DOWNLOAD_MAX_BYTES, 'video');
528
+ if (!isMp4(buf)) {
529
+ // Not a recognized mp4 — reject instead of saving garbage (e.g. an api
530
+ // error page that slipped through with a 200).
531
+ return { content: [{ type: 'text', text: 'generate_video: the video service returned data that is not a recognized mp4' }], isError: true };
532
+ }
533
+ fs.mkdirSync(mediaDir, { recursive: true });
534
+ const filename = `video_${sanitize(process.env.GATEWAY_SESSION_ID ?? 'default')}_${Date.now()}.mp4`;
535
+ filePath = path.join(mediaDir, filename);
536
+ fs.writeFileSync(filePath, buf);
537
+ } catch (err) {
538
+ return { content: [{ type: 'text', text: `generate_video: failed to save video: ${(err as Error).message}` }], isError: true };
539
+ }
540
+
541
+ return {
542
+ content: [{
543
+ type: 'text',
544
+ text: JSON.stringify({
545
+ status: 'done',
546
+ task_id: taskId,
547
+ ...(model ? { model } : {}),
548
+ byok: job.byok ?? false,
549
+ cost: job.cost ?? 0,
550
+ files: [filePath],
551
+ note: 'Video saved. Deliver it to the user with your channel delivery tool — api_reply/reply (files: [...]). Do NOT open/Read the file to inspect it first; attach it and answer briefly.'
552
+ + (model ? ` Mention which model made it (${model}).` : ''),
553
+ }),
554
+ }],
555
+ };
556
+ }
557
+
558
+ /**
559
+ * Where to write result clips. Prefer the per-session media dir the gateway
560
+ * provisions (GATEWAY_SESSION_MEDIA_DIR); otherwise derive the agent media root
561
+ * from the workspace (…/agents/<id>/media); last resort /tmp.
562
+ */
563
+ private resolveMediaDir(): string {
564
+ const sessionMediaDir = process.env.GATEWAY_SESSION_MEDIA_DIR;
565
+ if (sessionMediaDir) return sessionMediaDir;
566
+ const workspace = process.env.GATEWAY_WORKSPACE_DIR;
567
+ if (workspace) {
568
+ const sid = sanitize(process.env.GATEWAY_SESSION_ID ?? 'default');
569
+ return path.resolve(workspace, '..', 'media', `session-${sid}`);
570
+ }
571
+ return '/tmp';
572
+ }
573
+
574
+ private unavailable(err: unknown): McpToolResult {
575
+ return {
576
+ content: [{ type: 'text', text: `generate_video: video service unavailable: ${(err as Error).message}` }],
577
+ isError: true,
578
+ };
579
+ }
580
+
581
+ private mapHttpError(status: number, body: string): McpToolResult {
582
+ let code = '';
583
+ let message = '';
584
+ try {
585
+ const parsed = JSON.parse(body) as { error?: { code?: string; message?: string } };
586
+ code = parsed.error?.code ?? '';
587
+ message = parsed.error?.message ?? '';
588
+ } catch {
589
+ /* non-JSON error body */
590
+ }
591
+ if (!code) code = defaultCodeForStatus(status);
592
+ const hint = ERROR_HINTS[code];
593
+ const text = [`${code}${message ? `: ${message}` : ''}`, hint && hint !== message ? hint : '']
594
+ .filter(Boolean)
595
+ .join(' — ');
596
+ return { content: [{ type: 'text', text: text || `video service error (HTTP ${status})` }], isError: true };
597
+ }
598
+
599
+ private mapJobError(job: JobResponse): McpToolResult {
600
+ const code = job.error?.code ?? 'provider_error';
601
+ const message = job.error?.message ?? '';
602
+ const hint = ERROR_HINTS[code];
603
+ const text = [`${code}${message ? `: ${message}` : ''}`, hint && hint !== message ? hint : '']
604
+ .filter(Boolean)
605
+ .join(' — ');
606
+ return { content: [{ type: 'text', text: text || `video generation failed (${code})` }], isError: true };
607
+ }
608
+ }
609
+
610
+ function defaultCodeForStatus(status: number): string {
611
+ switch (status) {
612
+ case 400: return 'invalid_model';
613
+ case 401: return 'unauthorized';
614
+ case 402: return 'insufficient_credit';
615
+ case 403: return 'no_credential';
616
+ case 404: return 'job_not_found';
617
+ case 429: return 'rate_limited';
618
+ case 503: return 'no_supply';
619
+ default: return 'provider_error';
620
+ }
621
+ }
622
+
623
+ // True when the buffer is an ISO-BMFF/mp4 container: the first box's type (bytes
624
+ // 4..8) is "ftyp". Covers the H.264/AAC mp4 the grok-video path produces.
625
+ function isMp4(buf: Buffer): boolean {
626
+ return buf.length >= 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70;
627
+ }
628
+
629
+ // https is required for a PUBLIC api endpoint (the Bearer proxy_secret is sent on
630
+ // every call); http is tolerated only for a local/internal host — a trusted hop
631
+ // such as host.docker.internal in dev, where cleartext never leaves the network.
632
+
633
+ const videoToolDefs: McpToolDefinition[] = [
634
+ {
635
+ name: 'generate_video',
636
+ description:
637
+ 'Use this WHENEVER the user asks to create, generate, make, or animate a VIDEO or clip — it is built in, no app install needed. ' +
638
+ 'Generate a short video from a text prompt (optionally animating a source image) via the configured video generation service. ' +
639
+ 'action="generate" submits the request and returns the saved mp4 file path once ready — then deliver it with your channel reply ' +
640
+ 'tool (files: [...]). AFTER A SUCCESSFUL GENERATE: do NOT open/Read the produced file to inspect it and do NOT re-analyze it — ' +
641
+ 'the generation already succeeded; attach it with your reply tool and answer in one or two short sentences. ' +
642
+ 'action="status" polls a previously returned task_id. ' +
643
+ 'PATIENCE: video generation legitimately takes minutes — a "running" status (including the "still generating, call again with ' +
644
+ 'action=status" note you get back when the local poll budget runs out) is normal, not stuck. Keep calling action="status" with ' +
645
+ 'the SAME task_id until it resolves to done/failed. Do NOT submit a new action="generate" call for the same request while an ' +
646
+ 'earlier task_id is still running — the earlier job may finish moments later and you will have generated and charged for the clip ' +
647
+ 'twice while delivering only one. ' +
648
+ 'action="list" returns every available video model with its parameters (durations, resolutions, cost). Call it FIRST when choosing ' +
649
+ 'a model. ' +
650
+ 'SOURCE IMAGE (image-to-video): to animate an existing image, pass its media path in "image" (a media path, an "artifact:<id>" ref, ' +
651
+ 'or an https URL). When the user points at an image from earlier in the chat ("animate image 2", "the first picture"), call ' +
652
+ 'action="list_refs" FIRST and pass the chosen item\'s "ref" — never count images from your own memory of the conversation. ' +
653
+ 'Omit "image" for pure text-to-video (the service generates its own source frame from the prompt).',
654
+ inputSchema: {
655
+ type: 'object',
656
+ properties: {
657
+ action: {
658
+ type: 'string',
659
+ enum: ['generate', 'status', 'list', 'list_refs'],
660
+ description: 'generate (default) | status | list | list_refs (numbered catalog of this session\'s images, for resolving "animate the second image" style references)',
661
+ },
662
+ model: {
663
+ type: 'string',
664
+ description: 'Model id "provider/model" (required for generate). Use action="list" to discover valid ids.',
665
+ },
666
+ prompt: { type: 'string', description: 'Text prompt describing the video (required for generate).' },
667
+ duration: { type: 'integer', description: 'Optional clip length in seconds (provider default if omitted).' },
668
+ resolution: { type: 'string', description: 'Optional resolution, e.g. "480p" or "720p" (must be supported by the model).' },
669
+ aspect_ratio: { type: 'string', description: 'Optional aspect ratio, e.g. "9:16" or "16:9".' },
670
+ image: { type: 'string', description: 'Optional source frame for image-to-video: a media path (e.g. "media/xxx.png"), an "artifact:<id>" ref, or an https URL. Local/artifact refs are converted to short-lived URLs automatically. Omit for text-to-video.' },
671
+ task_id: { type: 'string', description: 'Job id to poll (required for action="status").' },
672
+ },
673
+ required: [],
674
+ },
675
+ },
676
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.8.8",
3
+ "version": "1.8.9",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",