@0xmaxma/claude-gateway 1.8.7 → 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.
- package/README.md +96 -0
- package/dist/agent/runner.d.ts +47 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +116 -17
- package/dist/agent/runner.js.map +1 -1
- package/dist/api/connectors-router.d.ts +50 -0
- package/dist/api/connectors-router.d.ts.map +1 -0
- package/dist/api/connectors-router.js +650 -0
- package/dist/api/connectors-router.js.map +1 -0
- package/dist/api/gateway-router.d.ts +4 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +26 -0
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/oauth-connectors-router.d.ts +43 -0
- package/dist/api/oauth-connectors-router.d.ts.map +1 -0
- package/dist/api/oauth-connectors-router.js +384 -0
- package/dist/api/oauth-connectors-router.js.map +1 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +101 -13
- package/dist/api/router.js.map +1 -1
- package/dist/apps/agent-manager.d.ts +6 -2
- package/dist/apps/agent-manager.d.ts.map +1 -1
- package/dist/apps/agent-manager.js +10 -18
- package/dist/apps/agent-manager.js.map +1 -1
- package/dist/config/config-write-lock.d.ts +45 -0
- package/dist/config/config-write-lock.d.ts.map +1 -0
- package/dist/config/config-write-lock.js +164 -0
- package/dist/config/config-write-lock.js.map +1 -0
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +29 -0
- package/dist/config/watcher.js.map +1 -1
- package/dist/connectors/custom-connectors-store.d.ts +54 -0
- package/dist/connectors/custom-connectors-store.d.ts.map +1 -0
- package/dist/connectors/custom-connectors-store.js +204 -0
- package/dist/connectors/custom-connectors-store.js.map +1 -0
- package/dist/connectors/custom.d.ts +69 -0
- package/dist/connectors/custom.d.ts.map +1 -0
- package/dist/connectors/custom.js +158 -0
- package/dist/connectors/custom.js.map +1 -0
- package/dist/connectors/mcp-oauth.d.ts +152 -0
- package/dist/connectors/mcp-oauth.d.ts.map +1 -0
- package/dist/connectors/mcp-oauth.js +522 -0
- package/dist/connectors/mcp-oauth.js.map +1 -0
- package/dist/connectors/oauth-refresh-sweep.d.ts +71 -0
- package/dist/connectors/oauth-refresh-sweep.d.ts.map +1 -0
- package/dist/connectors/oauth-refresh-sweep.js +337 -0
- package/dist/connectors/oauth-refresh-sweep.js.map +1 -0
- package/dist/connectors/pending-oauth-store.d.ts +39 -0
- package/dist/connectors/pending-oauth-store.d.ts.map +1 -0
- package/dist/connectors/pending-oauth-store.js +50 -0
- package/dist/connectors/pending-oauth-store.js.map +1 -0
- package/dist/connectors/resolve.d.ts +52 -0
- package/dist/connectors/resolve.d.ts.map +1 -0
- package/dist/connectors/resolve.js +176 -0
- package/dist/connectors/resolve.js.map +1 -0
- package/dist/connectors/token-env.d.ts +93 -0
- package/dist/connectors/token-env.d.ts.map +1 -0
- package/dist/connectors/token-env.js +323 -0
- package/dist/connectors/token-env.js.map +1 -0
- package/dist/connectors/types.d.ts +126 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +13 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/index.js +32 -1
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts +23 -0
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +78 -2
- package/dist/session/process.js.map +1 -1
- package/dist/types.d.ts +46 -1
- package/dist/types.d.ts.map +1 -1
- package/mcp/instructions.ts +14 -1
- package/mcp/server.ts +11 -5
- package/mcp/tools/image/module.ts +2 -70
- package/mcp/tools/shared/media.ts +76 -0
- package/mcp/tools/video/module.ts +676 -0
- package/package.json +1 -1
|
@@ -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
|
+
];
|