@visionengine/video-recognize 1.0.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-zh.md ADDED
@@ -0,0 +1,104 @@
1
+ # VE 视频理解 MCP
2
+
3
+ 通过 `ve-backend` 代理实现视频理解的异步 MCP 服务。
4
+
5
+ ## 环境变量
6
+
7
+ - `API_URL`:后端代理地址,默认 `https://api.visionengine-tech.com/api/v1/video`
8
+ - `API_KEY`:VisionEngine 平台用户 API Key(用于 submit/query 和 remote 上传,必填)
9
+ - `MODEL`:平台模型 ID,默认 `@preset/vec-1-0-video-recognize`
10
+ - `WORKDIR`:本地工作目录根路径
11
+ - `FILE_MODE`:本地文件处理模式,`local` 或 `remote`,默认 `remote`
12
+ - `REMOTION_WORK_DIR`:`local` 模式下共享挂载根目录,默认 `/vec`
13
+ - `BASE_URL`:后端对外基础地址,用于拼接 `/save` 和 `/shared` 链接,默认 `https://api.visionengine-tech.com`
14
+ - 远程模式上传目录为代码内置:`public/videos`
15
+
16
+ ## 工具
17
+
18
+ - `submit`
19
+ - `query`
20
+
21
+ ### `submit`
22
+
23
+ 用于提交异步视频理解任务,提交成功后会返回 `taskId`,供后续查询使用。
24
+
25
+ 支持的 `taskType`:
26
+
27
+ - `understand`
28
+ - `cut_effect_points`
29
+ - `emotion_analysis`
30
+ - `script_generate`
31
+ - `style_analyze`
32
+
33
+ 输入统一为一个参数:
34
+
35
+ - `video`:既可以是公网可访问视频 URL,也可以是本地文件路径
36
+
37
+ 当 `video` 为本地文件路径时:
38
+
39
+ - `FILE_MODE=local`:校验文件位于 `REMOTION_WORK_DIR` 下后,MCP 会传递相对于 `REMOTION_WORK_DIR` 的路径,由后端在内部解析为本地文件输入
40
+ - `FILE_MODE=remote`(默认):先调用后端 `/save` 上传文件,再将返回路径拼接为 `/shared/...?...download=true` 下载链接
41
+
42
+ 第一版统一使用 `stream=false`,`submit` 仅负责返回稳定的任务提交结果,最终识别结果请通过 `query` 获取。
43
+
44
+ 支持可选分析范围参数:
45
+
46
+ - `analysisRange.type`:`time` 或 `frame`
47
+ - `analysisRange.startSec` / `analysisRange.endSec`:按秒指定分析区间
48
+ - `analysisRange.startFrame` / `analysisRange.endFrame`:按帧指定分析区间
49
+
50
+ 约束规则:
51
+
52
+ - `type=time` 时只能传 `startSec` / `endSec`
53
+ - `type=frame` 时只能传 `startFrame` / `endFrame`
54
+ - 至少提供一个边界
55
+ - 支持单边省略,例如 `{ type: "time", startSec: 30 }`
56
+
57
+ 提交示例:
58
+
59
+ ```json
60
+ {
61
+ "video": "https://example.com/demo.mp4",
62
+ "analysisRange": {
63
+ "type": "time",
64
+ "startSec": 5,
65
+ "endSec": 20
66
+ },
67
+ "taskType": "understand",
68
+ "responseFormat": "json_object"
69
+ }
70
+ ```
71
+
72
+ ### `query`
73
+
74
+ 用于根据 `taskId` 查询任务状态。
75
+
76
+ - 如果任务仍在执行中,会返回当前状态并提示稍后再次查询
77
+ - 如果任务成功或部分成功,会自动继续请求 `/task/{taskId}/result` 并一并返回最终结构化结果
78
+ - 如果任务失败或已取消,会返回状态、消息和错误信息
79
+
80
+ 典型调用流程:
81
+
82
+ 1. 调用 `submit`
83
+ 2. 等待片刻
84
+ 3. 使用返回的 `taskId` 调用 `query`
85
+ 4. 若仍未完成,继续调用 `query` 直到任务结束
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "ve-video-recognize": {
91
+ "type": "local",
92
+ "command": "npx",
93
+ "args": ["-y", "@visionengine/video-recognize@latest"],
94
+ "transport": "stdio",
95
+ "env": {
96
+ "API_KEY": "你的API密钥",
97
+ "WORKDIR": "./",
98
+ "FILE_MODE": "remote",
99
+ "REMOTION_WORK_DIR": "/vec"
100
+ }
101
+ }
102
+ }
103
+ }
104
+ ```
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # VE Video Recognize MCP
2
+
3
+ Async MCP server for video understanding via `ve-backend` proxy.
4
+
5
+ ## Environment
6
+
7
+ - `API_URL`: backend proxy url, default `https://api.visionengine-tech.com/api/v1/video`
8
+ - `API_KEY`: user API key from VisionEngine backend (required for submit/query and remote upload)
9
+ - `MODEL`: platform model id, default `@preset/vec-1-0-video-recognize`
10
+ - `WORKDIR`: local workspace root
11
+ - `FILE_MODE`: local file handling mode, `local` or `remote`, default `remote`
12
+ - `REMOTION_WORK_DIR`: shared mount root used in `local` mode, default `/vec`
13
+ - `BASE_URL`: backend public base url used for `/save` and `/shared` links, default `https://api.visionengine-tech.com`
14
+ - Remote upload path is built-in in code: `public/videos`
15
+
16
+ ## Tools
17
+
18
+ - `submit`
19
+ - `query`
20
+
21
+ ### `submit`
22
+
23
+ Submit an async video understanding task and receive a `taskId` for later polling.
24
+
25
+ Supported task types:
26
+
27
+ - `understand`
28
+ - `cut_effect_points`
29
+ - `emotion_analysis`
30
+ - `script_generate`
31
+ - `style_analyze`
32
+
33
+ Input uses a single parameter:
34
+
35
+ - `video`: can be either a public video URL or a local file path
36
+
37
+ When `video` is a local file path:
38
+
39
+ - `FILE_MODE=local`: after validating the file is under `REMOTION_WORK_DIR`, MCP sends a path relative to `REMOTION_WORK_DIR`, and backend resolves it as local file input internally
40
+ - `FILE_MODE=remote` (default): upload local file to backend `/save`, then convert returned path to `/shared/...?...download=true` URL
41
+
42
+ First version always submits with `stream=false` and returns a stable task-oriented payload. Use `query` to retrieve final results.
43
+
44
+ Supported optional analysis range parameter:
45
+
46
+ - `analysisRange.type`: `time` or `frame`
47
+ - `analysisRange.startSec` / `analysisRange.endSec`: select a time range in seconds
48
+ - `analysisRange.startFrame` / `analysisRange.endFrame`: select a frame range
49
+
50
+ Rules:
51
+
52
+ - `type=time` only allows `startSec` / `endSec`
53
+ - `type=frame` only allows `startFrame` / `endFrame`
54
+ - at least one boundary is required
55
+ - single-sided ranges are supported, for example `{ type: "time", startSec: 30 }`
56
+
57
+ Example submit parameters:
58
+
59
+ ```json
60
+ {
61
+ "video": "https://example.com/demo.mp4",
62
+ "analysisRange": {
63
+ "type": "time",
64
+ "startSec": 5,
65
+ "endSec": 20
66
+ },
67
+ "taskType": "understand",
68
+ "responseFormat": "json_object"
69
+ }
70
+ ```
71
+
72
+ ### `query`
73
+
74
+ Query a submitted task by `taskId`.
75
+
76
+ - If the task is still running, the tool returns the current status and asks the caller to try again later.
77
+ - If the task succeeds or partially succeeds, the tool automatically fetches `/task/{taskId}/result` and returns the final structured result.
78
+ - If the task failed or was canceled, the tool returns the status and backend message/error.
79
+
80
+ Typical flow:
81
+
82
+ 1. Call `submit`
83
+ 2. Wait a short time
84
+ 3. Call `query` with the returned `taskId`
85
+ 4. Repeat `query` until the task finishes
86
+
87
+ ## Example MCP config
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "ve-video-recognize": {
93
+ "command": "npx",
94
+ "args": ["-y", "@visionengine/video-recognize@latest"],
95
+ "transport": "stdio",
96
+ "env": {
97
+ "API_KEY": "<YOUR_API_KEY>",
98
+ "WORKDIR": "./",
99
+ "FILE_MODE": "remote",
100
+ "REMOTION_WORK_DIR": "/vec"
101
+ }
102
+ }
103
+ }
104
+ }
105
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { server } from "./server.js";
3
+ server.start({
4
+ transportType: "stdio",
5
+ });
package/dist/server.js ADDED
@@ -0,0 +1,320 @@
1
+ import { FastMCP } from "fastmcp";
2
+ import { z } from "zod";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ const server = new FastMCP({
6
+ name: "VE Video Recognize",
7
+ version: "1.0.0",
8
+ });
9
+ const API_URL = process.env.API_URL || "https://api.visionengine-tech.com/api/v1/video";
10
+ const API_KEY = process.env.API_KEY || "";
11
+ const MODEL = process.env.MODEL || "@preset/vec-1-0-video-recognize";
12
+ const WORKDIR = process.env.WORKDIR || "./";
13
+ const FILE_MODE = (process.env.FILE_MODE || "remote").toLowerCase();
14
+ const REMOTION_WORK_DIR = process.env.REMOTION_WORK_DIR || "/vec";
15
+ const BASE_URL = process.env.BASE_URL || "https://api.visionengine-tech.com";
16
+ const REMOTE_UPLOAD_PATH = "public/videos";
17
+ function getWorkDir() {
18
+ return path.isAbsolute(WORKDIR) ? WORKDIR : path.resolve(process.cwd(), WORKDIR);
19
+ }
20
+ function resolveInputPath(inputPath) {
21
+ return path.isAbsolute(inputPath) ? inputPath : path.resolve(getWorkDir(), inputPath);
22
+ }
23
+ function isHttpUrl(value) {
24
+ return /^https?:\/\//i.test(value);
25
+ }
26
+ function toPosixPath(value) {
27
+ return value.replace(/\\/g, "/");
28
+ }
29
+ function normalizeMode(value) {
30
+ return value === "local" ? "local" : "remote";
31
+ }
32
+ function getBaseUrl() {
33
+ return BASE_URL.replace(/\/+$/, "");
34
+ }
35
+ function buildSharedDownloadUrl(relativePath) {
36
+ const normalizedRelativePath = toPosixPath(relativePath).replace(/^\/+/, "");
37
+ const encodedRelativePath = normalizedRelativePath
38
+ .split("/")
39
+ .filter(Boolean)
40
+ .map((segment) => encodeURIComponent(segment))
41
+ .join("/");
42
+ return `${getBaseUrl()}/shared/${encodedRelativePath}?download=true`;
43
+ }
44
+ function resolveRemotionWorkDir() {
45
+ return path.isAbsolute(REMOTION_WORK_DIR)
46
+ ? path.resolve(REMOTION_WORK_DIR)
47
+ : path.resolve(getWorkDir(), REMOTION_WORK_DIR);
48
+ }
49
+ function toSharedRelativePathFromLocal(fullPath) {
50
+ const mountRoot = resolveRemotionWorkDir();
51
+ const normalizedMountRoot = path.resolve(mountRoot);
52
+ const normalizedFullPath = path.resolve(fullPath);
53
+ const relativePath = path.relative(normalizedMountRoot, normalizedFullPath);
54
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
55
+ throw new Error(`File path is outside REMOTION_WORK_DIR: ${fullPath}`);
56
+ }
57
+ return toPosixPath(relativePath).replace(/^\/+/, "");
58
+ }
59
+ async function uploadFileToBackend(fullPath) {
60
+ if (!API_KEY) {
61
+ throw new Error("API_KEY environment variable is required");
62
+ }
63
+ const fileName = path.basename(fullPath);
64
+ const fileBuffer = await fs.promises.readFile(fullPath);
65
+ const formData = new FormData();
66
+ formData.set("file", new Blob([fileBuffer]), fileName);
67
+ formData.set("file_name", fileName);
68
+ formData.set("path", REMOTE_UPLOAD_PATH);
69
+ const response = await fetch(`${getBaseUrl()}/save`, {
70
+ method: "POST",
71
+ headers: {
72
+ Authorization: `Bearer ${API_KEY}`,
73
+ },
74
+ body: formData,
75
+ });
76
+ if (!response.ok) {
77
+ throw new Error(`Backend save API error (${response.status}): ${await response.text()}`);
78
+ }
79
+ const result = (await response.json());
80
+ if (!result.success || !result.file?.path) {
81
+ throw new Error("Backend save API response is missing file.path");
82
+ }
83
+ return buildSharedDownloadUrl(result.file.path);
84
+ }
85
+ async function normalizeVideoInput(video) {
86
+ if (isHttpUrl(video)) {
87
+ return video;
88
+ }
89
+ const fullPath = resolveInputPath(video);
90
+ if (!fs.existsSync(fullPath)) {
91
+ throw new Error(`Video file not found: ${fullPath}`);
92
+ }
93
+ const mode = normalizeMode(FILE_MODE);
94
+ if (mode === "local") {
95
+ toSharedRelativePathFromLocal(fullPath);
96
+ return path.relative(resolveRemotionWorkDir(), fullPath).replace(/\\/g, "/") || path.basename(fullPath);
97
+ }
98
+ return await uploadFileToBackend(fullPath);
99
+ }
100
+ async function requestJson(url, init) {
101
+ if (!API_KEY) {
102
+ throw new Error("API_KEY environment variable is required");
103
+ }
104
+ const response = await fetch(url, {
105
+ method: init.method,
106
+ headers: {
107
+ Authorization: `Bearer ${API_KEY}`,
108
+ "Content-Type": "application/json",
109
+ },
110
+ body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
111
+ });
112
+ if (!response.ok) {
113
+ throw new Error(`Backend API error (${response.status}): ${await response.text()}`);
114
+ }
115
+ return (await response.json());
116
+ }
117
+ function normalizeAnalysisRange(analysisRange) {
118
+ if (!analysisRange) {
119
+ return undefined;
120
+ }
121
+ if (analysisRange.type === "time") {
122
+ if (analysisRange.startFrame !== undefined || analysisRange.endFrame !== undefined) {
123
+ throw new Error("analysisRange of type=time does not allow startFrame/endFrame");
124
+ }
125
+ if (analysisRange.startSec === undefined && analysisRange.endSec === undefined) {
126
+ throw new Error("analysisRange of type=time requires at least one of startSec or endSec");
127
+ }
128
+ if (analysisRange.startSec !== undefined &&
129
+ analysisRange.endSec !== undefined &&
130
+ analysisRange.endSec <= analysisRange.startSec) {
131
+ throw new Error("analysisRange.endSec must be greater than analysisRange.startSec");
132
+ }
133
+ return {
134
+ type: "time",
135
+ start_sec: analysisRange.startSec,
136
+ end_sec: analysisRange.endSec,
137
+ };
138
+ }
139
+ if (analysisRange.startSec !== undefined || analysisRange.endSec !== undefined) {
140
+ throw new Error("analysisRange of type=frame does not allow startSec/endSec");
141
+ }
142
+ if (analysisRange.startFrame === undefined && analysisRange.endFrame === undefined) {
143
+ throw new Error("analysisRange of type=frame requires at least one of startFrame or endFrame");
144
+ }
145
+ if (analysisRange.startFrame !== undefined &&
146
+ analysisRange.endFrame !== undefined &&
147
+ analysisRange.endFrame <= analysisRange.startFrame) {
148
+ throw new Error("analysisRange.endFrame must be greater than analysisRange.startFrame");
149
+ }
150
+ return {
151
+ type: "frame",
152
+ start_frame: analysisRange.startFrame,
153
+ end_frame: analysisRange.endFrame,
154
+ };
155
+ }
156
+ function buildSubmitPayload(args) {
157
+ const segmentConfig = {};
158
+ if (args.forceSegment !== undefined)
159
+ segmentConfig.force_segment = args.forceSegment;
160
+ if (args.segmentDuration !== undefined)
161
+ segmentConfig.segment_duration = args.segmentDuration;
162
+ if (args.overlap !== undefined)
163
+ segmentConfig.overlap = args.overlap;
164
+ if (args.maxSegments !== undefined)
165
+ segmentConfig.max_segments = args.maxSegments;
166
+ const normalizedResponseFormat = args.responseFormat ?? "json_object";
167
+ const normalizedAnalysisRange = normalizeAnalysisRange(args.analysisRange);
168
+ return {
169
+ video: args.video,
170
+ analysis_range: normalizedAnalysisRange,
171
+ task_type: args.taskType,
172
+ prompt_mode: args.promptMode ?? "template",
173
+ user_prompt: args.userPrompt,
174
+ stream: args.stream ?? false,
175
+ response_format: normalizedResponseFormat === "json_object" ? { type: "json_object" } : "text",
176
+ model_id: args.modelId || MODEL,
177
+ segment_config: Object.keys(segmentConfig).length > 0 ? segmentConfig : undefined,
178
+ };
179
+ }
180
+ function extractStatus(payload) {
181
+ return payload.task_status ?? payload.status ?? "UNKNOWN";
182
+ }
183
+ async function submitVideoRecognizeTask(args) {
184
+ const normalizedVideo = await normalizeVideoInput(args.video);
185
+ const payload = buildSubmitPayload({
186
+ video: normalizedVideo,
187
+ analysisRange: args.analysisRange,
188
+ taskType: args.taskType,
189
+ promptMode: args.promptMode,
190
+ userPrompt: args.userPrompt,
191
+ responseFormat: args.responseFormat,
192
+ stream: false,
193
+ forceSegment: args.forceSegment,
194
+ segmentDuration: args.segmentDuration,
195
+ overlap: args.overlap,
196
+ maxSegments: args.maxSegments,
197
+ modelId: args.modelId,
198
+ });
199
+ const submitResult = await requestJson(`${API_URL}/analyze`, {
200
+ method: "POST",
201
+ body: payload,
202
+ });
203
+ return {
204
+ success: submitResult.success ?? true,
205
+ taskId: submitResult.task_id ?? null,
206
+ status: extractStatus(submitResult),
207
+ taskType: submitResult.task_type ?? args.taskType,
208
+ video: normalizedVideo,
209
+ analysisRange: payload.analysis_range ?? null,
210
+ message: submitResult.message || "Video analysis task submitted successfully.",
211
+ nextAction: "Use the query tool with taskId to get task status or final result.",
212
+ raw: submitResult,
213
+ };
214
+ }
215
+ async function queryTask(taskId) {
216
+ return requestJson(`${API_URL}/task/${encodeURIComponent(taskId)}`, { method: "GET" });
217
+ }
218
+ async function getTaskResult(taskId) {
219
+ return requestJson(`${API_URL}/task/${encodeURIComponent(taskId)}/result`, { method: "GET" });
220
+ }
221
+ async function buildQueryResult(taskId) {
222
+ const statusResult = await queryTask(taskId);
223
+ const normalizedStatus = String(extractStatus(statusResult)).toUpperCase();
224
+ if (normalizedStatus === "SUCCEEDED" || normalizedStatus === "PARTIAL_SUCCESS") {
225
+ const resultPayload = await getTaskResult(taskId);
226
+ return {
227
+ success: resultPayload.success ?? statusResult.success ?? true,
228
+ taskId: resultPayload.task_id ?? statusResult.task_id ?? taskId,
229
+ status: extractStatus(resultPayload) !== "UNKNOWN" ? extractStatus(resultPayload) : extractStatus(statusResult),
230
+ taskType: resultPayload.task_type ?? statusResult.task_type ?? null,
231
+ createdAt: statusResult.created_at ?? null,
232
+ updatedAt: statusResult.updated_at ?? null,
233
+ result: resultPayload.result ?? null,
234
+ segmentResults: resultPayload.segment_results ?? null,
235
+ usage: resultPayload.usage ?? null,
236
+ error: resultPayload.error ?? null,
237
+ message: resultPayload.message || "Video analysis completed successfully.",
238
+ rawStatus: statusResult,
239
+ rawResult: resultPayload,
240
+ };
241
+ }
242
+ return {
243
+ success: statusResult.success ?? true,
244
+ taskId: statusResult.task_id ?? taskId,
245
+ taskStatus: extractStatus(statusResult),
246
+ taskType: statusResult.task_type ?? null,
247
+ createdAt: statusResult.created_at ?? null,
248
+ updatedAt: statusResult.updated_at ?? null,
249
+ error: statusResult.error ?? null,
250
+ message: normalizedStatus === "FAILED"
251
+ ? statusResult.message || "Video analysis task failed. Please check the input video and backend logs before retrying."
252
+ : normalizedStatus === "CANCELED" || normalizedStatus === "CANCELLED"
253
+ ? statusResult.message || "Video analysis task was canceled."
254
+ : statusResult.message || "Task is still running. Please query again later.",
255
+ rawStatus: statusResult,
256
+ };
257
+ }
258
+ server.addTool({
259
+ annotations: {
260
+ openWorldHint: true,
261
+ readOnlyHint: false,
262
+ title: "Submit Video Recognition Task",
263
+ },
264
+ description: "Submit a video understanding task to the backend proxy. Use a single video parameter: it may be a public URL or a local path. For local files, FILE_MODE=local sends the path relative to REMOTION_WORK_DIR and lets backend resolve it; FILE_MODE=remote uploads the file to /save first, then submits the returned shared URL. Also supports analysisRange so the backend only analyzes the selected time/frame range of the original video. First version always uses stream=false and supports task types: understand, cut_effect_points, emotion_analysis, script_generate, style_analyze.",
265
+ execute: async (args) => {
266
+ const result = await submitVideoRecognizeTask({
267
+ video: args.video,
268
+ analysisRange: args.analysisRange,
269
+ taskType: args.taskType,
270
+ promptMode: args.promptMode,
271
+ userPrompt: args.userPrompt,
272
+ responseFormat: args.responseFormat,
273
+ stream: args.stream,
274
+ forceSegment: args.forceSegment,
275
+ segmentDuration: args.segmentDuration,
276
+ overlap: args.overlap,
277
+ maxSegments: args.maxSegments,
278
+ modelId: args.modelId,
279
+ });
280
+ return JSON.stringify(result, null, 2);
281
+ },
282
+ name: "submit",
283
+ parameters: z.object({
284
+ video: z.string().describe("Single video input. Can be a public http(s) URL or a local file path. In FILE_MODE=local, local path should be under REMOTION_WORK_DIR and may be relative to WORKDIR; MCP will convert it to a path relative to REMOTION_WORK_DIR for backend resolution. In FILE_MODE=remote, local files are uploaded via /save before analysis."),
285
+ analysisRange: z.object({
286
+ type: z.enum(["time", "frame"]).describe("Select analysis range by time or frame."),
287
+ startSec: z.number().min(0).optional().describe("Start time in seconds for type=time."),
288
+ endSec: z.number().positive().optional().describe("End time in seconds for type=time."),
289
+ startFrame: z.number().int().min(0).optional().describe("Start frame index for type=frame."),
290
+ endFrame: z.number().int().positive().optional().describe("End frame index for type=frame."),
291
+ }).optional().describe("Optional hard analysis range. Supports selecting only a specific time segment or frame interval of the original video."),
292
+ taskType: z.enum(["understand", "cut_effect_points", "emotion_analysis", "script_generate", "style_analyze"]).describe("Video understanding task type."),
293
+ promptMode: z.enum(["template", "auto"]).optional().describe("Prompt mode for backend analysis. Default template."),
294
+ userPrompt: z.string().optional().describe("Optional custom prompt appended or used by the backend for the selected task."),
295
+ responseFormat: z.enum(["text", "json_object"]).optional().describe("Desired backend response format. json_object is recommended for structured outputs."),
296
+ stream: z.boolean().optional().describe("Reserved for compatibility. First version still forces non-streaming backend requests."),
297
+ forceSegment: z.boolean().optional().describe("Whether to force backend video segmentation."),
298
+ segmentDuration: z.number().positive().optional().describe("Backend segment duration in seconds."),
299
+ overlap: z.number().min(0).optional().describe("Backend segment overlap in seconds."),
300
+ maxSegments: z.number().int().positive().optional().describe("Maximum number of backend segments."),
301
+ modelId: z.string().optional().describe("Platform model ID override. Defaults to MODEL env."),
302
+ }),
303
+ });
304
+ server.addTool({
305
+ annotations: {
306
+ openWorldHint: true,
307
+ readOnlyHint: true,
308
+ title: "Query Video Recognition Task",
309
+ },
310
+ description: "Query a previously submitted video understanding task by taskId. If the task is finished, this tool also fetches and returns the final result payload.",
311
+ execute: async (args) => {
312
+ const result = await buildQueryResult(args.taskId);
313
+ return JSON.stringify(result, null, 2);
314
+ },
315
+ name: "query",
316
+ parameters: z.object({
317
+ taskId: z.string().describe("Task ID returned by the submit tool."),
318
+ }),
319
+ });
320
+ export { server };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@visionengine/video-recognize",
3
+ "version": "1.0.0",
4
+ "description": "VisionEngine Video Recognize MCP Server - Async video understanding via backend proxy",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "ve-video-recognize": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "README-zh.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "clear": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "video-recognize",
25
+ "video-understand",
26
+ "visionengine"
27
+ ],
28
+ "author": "team@visionengine-tech.com",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/crazyyanchao/ve-mcp.git",
33
+ "directory": "packages/video-recognize"
34
+ },
35
+ "homepage": "https://visionengine-tech.com/mcp",
36
+ "dependencies": {
37
+ "fastmcp": "^3.26.8",
38
+ "zod": "^4.1.12"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^24.10.1",
42
+ "typescript": "^5.8.3",
43
+ "vitest": "^3.1.3"
44
+ },
45
+ "engines": {
46
+ "node": ">=18.0.0"
47
+ }
48
+ }