ultralytics-mcp 0.1.2 → 0.1.4

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 CHANGED
@@ -2,24 +2,28 @@
2
2
 
3
3
  MCP server for the [Ultralytics Platform](https://platform.ultralytics.com).
4
4
 
5
+ > [!IMPORTANT]
5
6
  > Independent community project. Not affiliated with or endorsed by Ultralytics.
6
7
 
7
- Current milestone: read, monitor, predict, export, and initial project and
8
- dataset lifecycle tools are available. Additional resource-management tools
9
- land incrementally from here.
8
+ Current milestone: read, explore, monitor, predict, export, and extended
9
+ project and dataset lifecycle tools are available. Additional
10
+ resource-management tools land incrementally from here.
10
11
 
11
- ## Tools (20)
12
+ ## Tools (27)
12
13
 
13
14
  | Tool | Description |
14
15
  | --- | --- |
15
16
  | `projects_list` / `projects_get` | Browse projects |
16
17
  | `projects_create` / `projects_delete` | Create / soft-delete projects |
17
- | `datasets_list` / `datasets_get` / `datasets_create` / `datasets_delete` / `dataset_ingest` / `dataset_upload_file` | Browse / create / soft-delete datasets, start remote ingest jobs, and upload archive files |
18
+ | `explore_projects` / `explore_datasets` | Search public projects and datasets on Ultralytics Explore |
19
+ | `datasets_list` / `datasets_get` / `datasets_create` / `datasets_delete` / `dataset_images_list` / `dataset_ingest` / `dataset_upload_file` / `dataset_upload_folder` / `dataset_upload_video` | Browse / create / soft-delete datasets, inspect images, start remote ingest jobs, and upload archive files, folders, or videos |
18
20
  | `models_list` / `models_get` | Browse trained models and metrics |
19
- | `training_monitor` | Status, progress, and latest metrics |
21
+ | `training_monitor` | Status, progress, latest metrics, and optional recent metric history |
20
22
  | `model_predict` | Run inference on an image URL or base64 source |
21
23
  | `model_download` | Download a model weight file to a local path |
22
24
  | `gpu_availability` | Cloud GPU stock status |
25
+ | `dataset_export` | Get export link for latest or frozen dataset version |
26
+ | `dataset_version_create` | Create a frozen dataset version snapshot |
23
27
  | `exports_list` / `export_status` | List / check export jobs |
24
28
  | `export_create` | Create an export job — **requires `confirm_cost: true`** |
25
29
  | `training_start` | Start cloud training — **requires `confirm_cost: true`** |
@@ -1,7 +1,12 @@
1
1
  /** Read-only dataset tools. */
2
- import { readFile, stat } from "node:fs/promises";
3
- import { basename } from "node:path";
2
+ import { execFile as execFileCb } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
5
+ import { basename, join, relative, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { zipSync } from "fflate";
4
8
  import { resolveDataset } from "../resolve.js";
9
+ import { exploreSearch, validateExploreTasks } from "./explore.js";
5
10
  import { asRecord, listField, pyCount, pyField } from "./shared.js";
6
11
  const DATASET_TASKS = new Set([
7
12
  "detect",
@@ -13,6 +18,23 @@ const DATASET_TASKS = new Set([
13
18
  ]);
14
19
  const TARGET_SPLITS = new Set(["train", "val", "test"]);
15
20
  const MAX_UPLOAD_BYTES = 10 * 1024 * 1024 * 1024;
21
+ const IMAGE_SUFFIXES = new Set([
22
+ ".jpg",
23
+ ".jpeg",
24
+ ".png",
25
+ ".webp",
26
+ ".bmp",
27
+ ".tif",
28
+ ".tiff",
29
+ ]);
30
+ const VIDEO_SUFFIXES = new Set([
31
+ ".mp4",
32
+ ".webm",
33
+ ".mov",
34
+ ".mkv",
35
+ ".m4v",
36
+ ".avi",
37
+ ]);
16
38
  const UPLOAD_TYPES = [
17
39
  [".tar.gz", "application/gzip"],
18
40
  [".zip", "application/zip"],
@@ -20,6 +42,7 @@ const UPLOAD_TYPES = [
20
42
  [".tgz", "application/gzip"],
21
43
  [".ndjson", "application/x-ndjson"],
22
44
  ];
45
+ const execFile = promisify(execFileCb);
23
46
  function resourceId(item, fallback) {
24
47
  const value = item._id ?? item.id ?? item.projectId ?? item.datasetId;
25
48
  return String(value ?? fallback ?? "None");
@@ -30,6 +53,41 @@ function validateTargetSplit(targetSplit) {
30
53
  throw new Error(`Unsupported targetSplit '${targetSplit}'. Expected one of: ${allowed}.`);
31
54
  }
32
55
  }
56
+ function findToolOnPath(name) {
57
+ const paths = process.env.PATH?.split(":") ?? [];
58
+ for (const base of paths) {
59
+ const candidate = resolve(base, name);
60
+ if (existsSync(candidate)) {
61
+ return candidate;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ async function probeVideoDuration(videoPath, ffprobePath) {
67
+ const { stdout } = await execFile(ffprobePath, [
68
+ "-v",
69
+ "error",
70
+ "-show_entries",
71
+ "format=duration",
72
+ "-of",
73
+ "default=nokey=1:noprint_wrappers=1",
74
+ videoPath,
75
+ ]);
76
+ return Number.parseFloat(stdout.trim());
77
+ }
78
+ async function extractVideoFrames(options) {
79
+ await execFile(options.ffmpegPath, [
80
+ "-i",
81
+ options.videoPath,
82
+ "-vf",
83
+ `fps=${options.rate}`,
84
+ "-frames:v",
85
+ String(options.maxFrames),
86
+ "-q:v",
87
+ "2",
88
+ join(options.outputDir, "frame_%06d.jpg"),
89
+ ]);
90
+ }
33
91
  async function datasetUploadFileMeta(filePath) {
34
92
  if (!filePath.trim()) {
35
93
  throw new Error("`filePath` is required.");
@@ -56,6 +114,106 @@ async function datasetUploadFileMeta(filePath) {
56
114
  totalBytes: info.size,
57
115
  };
58
116
  }
117
+ function skipDatasetFolderPart(part) {
118
+ return part.startsWith(".") || part === "__MACOSX";
119
+ }
120
+ function hasSplitLikePath(path) {
121
+ return path.split("/").some((part) => TARGET_SPLITS.has(part.toLowerCase()));
122
+ }
123
+ async function datasetFolderImages(folderPath) {
124
+ if (!folderPath.trim()) {
125
+ throw new Error("`folderPath` is required.");
126
+ }
127
+ const resolvedFolder = resolve(folderPath);
128
+ const info = await stat(resolvedFolder).catch(() => null);
129
+ if (info === null) {
130
+ throw new Error(`Upload folder does not exist: ${resolvedFolder}`);
131
+ }
132
+ if (!info.isDirectory()) {
133
+ throw new Error(`Upload path is not a directory: ${resolvedFolder}`);
134
+ }
135
+ const files = [];
136
+ let totalBytes = 0;
137
+ let hasSplitDirs = false;
138
+ async function walk(currentPath) {
139
+ const entries = await readdir(currentPath, { withFileTypes: true });
140
+ for (const entry of entries) {
141
+ if (skipDatasetFolderPart(entry.name) || entry.name === ".DS_Store") {
142
+ continue;
143
+ }
144
+ const absolutePath = resolve(currentPath, entry.name);
145
+ const relativePath = relative(resolvedFolder, absolutePath).replaceAll("\\", "/");
146
+ if (relativePath
147
+ .split("/")
148
+ .some((part) => skipDatasetFolderPart(part) || part === ".DS_Store")) {
149
+ continue;
150
+ }
151
+ if (entry.isDirectory()) {
152
+ await walk(absolutePath);
153
+ continue;
154
+ }
155
+ if (!entry.isFile()) {
156
+ continue;
157
+ }
158
+ const lower = entry.name.toLowerCase();
159
+ const archiveSuffix = UPLOAD_TYPES.find(([candidate]) => lower.endsWith(candidate));
160
+ if (archiveSuffix) {
161
+ continue;
162
+ }
163
+ const imageSuffix = Array.from(IMAGE_SUFFIXES).find((candidate) => lower.endsWith(candidate));
164
+ if (!imageSuffix) {
165
+ continue;
166
+ }
167
+ const fileInfo = await stat(absolutePath);
168
+ totalBytes += fileInfo.size;
169
+ if (totalBytes >= MAX_UPLOAD_BYTES) {
170
+ throw new Error("Upload folder images must be smaller than 10 GB total.");
171
+ }
172
+ if (hasSplitLikePath(relativePath)) {
173
+ hasSplitDirs = true;
174
+ }
175
+ files.push({ absolutePath, relativePath, size: fileInfo.size });
176
+ }
177
+ }
178
+ await walk(resolvedFolder);
179
+ if (files.length === 0) {
180
+ throw new Error("No images found in folder.");
181
+ }
182
+ return { folderPath: resolvedFolder, files, hasSplitDirs };
183
+ }
184
+ async function buildDatasetFolderZip(files) {
185
+ const entries = {};
186
+ for (const file of files) {
187
+ entries[file.relativePath] = await readFile(file.absolutePath);
188
+ }
189
+ const zipBytes = zipSync(entries, { level: 6 });
190
+ if (zipBytes.byteLength >= MAX_UPLOAD_BYTES) {
191
+ throw new Error("Upload zip must be smaller than 10 GB.");
192
+ }
193
+ return zipBytes;
194
+ }
195
+ async function uploadDatasetContent(client, options) {
196
+ const signed = asRecord(await client.postJson("/upload/signed-url", {
197
+ assetType: "datasets",
198
+ assetId: options.datasetId,
199
+ filename: options.filename,
200
+ contentType: options.contentType,
201
+ totalBytes: options.totalBytes,
202
+ }));
203
+ const sessionId = String(signed.sessionId);
204
+ const uploadUrl = String(signed.uploadUrl ?? signed.url);
205
+ await client.uploadBytes(uploadUrl, options.content, options.contentType);
206
+ await client.postJson("/upload/complete", { sessionId });
207
+ const ingestPayload = {
208
+ datasetId: options.datasetId,
209
+ sessionId,
210
+ };
211
+ if (options.targetSplit !== undefined) {
212
+ ingestPayload.targetSplit = options.targetSplit;
213
+ }
214
+ const ingest = asRecord(await client.postJson("/datasets/ingest", ingestPayload));
215
+ return { sessionId, ingest };
216
+ }
59
217
  /** List datasets in the workspace, optionally filtered by username. */
60
218
  export async function datasetsList(client, username) {
61
219
  const data = await client.get("/datasets", username ? { username } : undefined);
@@ -70,6 +228,32 @@ export async function datasetsList(client, username) {
70
228
  }));
71
229
  return { summary: `${items.length} dataset(s).`, data: items };
72
230
  }
231
+ /** Search public datasets on Explore. */
232
+ export async function exploreDatasets(client, options) {
233
+ const data = await exploreSearch(client, "datasets", options.q, {
234
+ sort: options.sort,
235
+ offset: options.offset,
236
+ task: validateExploreTasks(options.task),
237
+ });
238
+ const items = listField(data, "datasets").map((dataset) => ({
239
+ id: dataset._id ?? null,
240
+ name: dataset.name ?? null,
241
+ slug: dataset.slug ?? null,
242
+ username: dataset.username ?? null,
243
+ task: dataset.task ?? null,
244
+ imageCount: dataset.imageCount ?? null,
245
+ classCount: dataset.classCount ?? null,
246
+ starCount: dataset.starCount ?? null,
247
+ }));
248
+ const hasMore = Boolean(data.hasMore);
249
+ return {
250
+ summary: `Search '${options.q.trim()}': ${items.length} dataset(s)${hasMore ? " (more available)" : ""}`,
251
+ data: {
252
+ datasets: items,
253
+ hasMore,
254
+ },
255
+ };
256
+ }
73
257
  /** Get one dataset by id, slug, username/slug, or dataset ul:// URI. */
74
258
  export async function datasetsGet(client, dataset) {
75
259
  const datasetId = await resolveDataset(client, dataset);
@@ -117,6 +301,72 @@ export async function datasetsCreate(client, options) {
117
301
  data: item,
118
302
  };
119
303
  }
304
+ /** List images in a dataset with optional filtering. */
305
+ export async function datasetImagesList(client, options) {
306
+ if (options.split !== undefined && !TARGET_SPLITS.has(options.split)) {
307
+ const allowed = Array.from(TARGET_SPLITS).sort().join(", ");
308
+ throw new Error(`Unsupported split '${options.split}'. Expected one of: ${allowed}.`);
309
+ }
310
+ if (options.limit !== undefined) {
311
+ if (options.limit <= 0) {
312
+ throw new Error("`limit` must be greater than 0.");
313
+ }
314
+ if (options.limit > 5000) {
315
+ throw new Error("`limit` must be at most 5000.");
316
+ }
317
+ }
318
+ if (options.offset !== undefined && options.offset < 0) {
319
+ throw new Error("`offset` must be greater than or equal to 0.");
320
+ }
321
+ const datasetId = await resolveDataset(client, options.dataset);
322
+ const params = {};
323
+ if (options.split !== undefined) {
324
+ params.split = options.split;
325
+ }
326
+ if (options.search !== undefined) {
327
+ params.search = options.search;
328
+ }
329
+ if (options.hasLabel !== undefined) {
330
+ params.hasLabel = options.hasLabel;
331
+ }
332
+ if (options.classIds && options.classIds.length > 0) {
333
+ params.classIds = options.classIds.join(",");
334
+ }
335
+ if (options.limit !== undefined) {
336
+ params.limit = options.limit;
337
+ }
338
+ if (options.offset !== undefined) {
339
+ params.offset = options.offset;
340
+ }
341
+ if (options.includeImageUrls !== undefined) {
342
+ params.includeImageUrls = options.includeImageUrls;
343
+ }
344
+ const data = await client.get(`/datasets/${datasetId}/images`, Object.keys(params).length > 0 ? params : undefined);
345
+ const record = asRecord(data);
346
+ const images = listField(data, "images").map((image) => ({
347
+ id: image._id ?? image.id ?? null,
348
+ name: image.name ?? null,
349
+ ext: image.ext ?? null,
350
+ split: image.split ?? null,
351
+ width: image.width ?? null,
352
+ height: image.height ?? null,
353
+ labelCount: image.labelCount ?? null,
354
+ bytes: image.bytes ?? null,
355
+ ...(image.imageUrl !== undefined ? { imageUrl: image.imageUrl } : {}),
356
+ ...(image.thumbnailUrl !== undefined
357
+ ? { thumbnailUrl: image.thumbnailUrl }
358
+ : {}),
359
+ }));
360
+ return {
361
+ summary: `${images.length} image(s) (total ${String(record.total ?? null)})`,
362
+ data: {
363
+ total: record.total ?? null,
364
+ hasMore: record.hasMore ?? null,
365
+ nextCursor: record.nextCursor ?? null,
366
+ images,
367
+ },
368
+ };
369
+ }
120
370
  /** Soft-delete a dataset by id, slug, username/slug, or dataset ul:// URI. */
121
371
  export async function datasetsDelete(client, dataset) {
122
372
  const datasetId = await resolveDataset(client, dataset);
@@ -154,22 +404,15 @@ export async function datasetUploadFile(client, options) {
154
404
  const meta = await datasetUploadFileMeta(options.filePath);
155
405
  const datasetId = await resolveDataset(client, options.dataset);
156
406
  const content = await readFile(options.filePath);
157
- const signed = asRecord(await client.postJson("/upload/signed-url", {
158
- assetType: "datasets",
159
- assetId: datasetId,
407
+ const upload = await uploadDatasetContent(client, {
408
+ datasetId,
160
409
  filename: meta.filename,
161
410
  contentType: meta.contentType,
162
411
  totalBytes: meta.totalBytes,
163
- }));
164
- const uploadUrl = String(signed.url ?? "");
165
- const sessionId = String(signed.sessionId ?? "");
166
- await client.uploadBytes(uploadUrl, content, meta.contentType);
167
- await client.postJson("/upload/complete", { sessionId });
168
- const ingestPayload = { datasetId, sessionId };
169
- if (options.targetSplit !== undefined) {
170
- ingestPayload.targetSplit = options.targetSplit;
171
- }
172
- const ingest = asRecord(await client.postJson("/datasets/ingest", ingestPayload));
412
+ content,
413
+ targetSplit: options.targetSplit,
414
+ });
415
+ const ingest = upload.ingest;
173
416
  const jobId = ingest.jobId ?? ingest.id ?? "None";
174
417
  return {
175
418
  summary: `Uploaded ${meta.filename} (${meta.totalBytes} bytes) and started dataset ingest job ${String(jobId)}.`,
@@ -177,8 +420,160 @@ export async function datasetUploadFile(client, options) {
177
420
  datasetId,
178
421
  filename: meta.filename,
179
422
  bytes: meta.totalBytes,
180
- sessionId,
423
+ sessionId: upload.sessionId,
181
424
  ingest,
182
425
  },
183
426
  };
184
427
  }
428
+ /** Upload a local image folder as zip, then start dataset ingest for the session. */
429
+ export async function datasetUploadFolder(client, options) {
430
+ validateTargetSplit(options.targetSplit);
431
+ const folder = await datasetFolderImages(options.folderPath);
432
+ if (options.targetSplit !== undefined && folder.hasSplitDirs) {
433
+ throw new Error("Folder has split directories (train/val/test); don't also pass targetSplit - it's ambiguous. Use one or the other.");
434
+ }
435
+ const datasetId = await resolveDataset(client, options.dataset);
436
+ const content = await buildDatasetFolderZip(folder.files);
437
+ const filename = `${basename(folder.folderPath)}.zip`;
438
+ const upload = await uploadDatasetContent(client, {
439
+ datasetId,
440
+ filename,
441
+ contentType: "application/zip",
442
+ totalBytes: content.byteLength,
443
+ content,
444
+ targetSplit: options.targetSplit,
445
+ });
446
+ const jobId = upload.ingest.jobId ?? upload.ingest.id ?? "None";
447
+ return {
448
+ summary: `Zipped ${folder.files.length} image(s) from ${folder.folderPath} and started ingest job ${String(jobId)} for dataset ${datasetId}.`,
449
+ data: {
450
+ datasetId,
451
+ imageCount: folder.files.length,
452
+ filename,
453
+ bytes: content.byteLength,
454
+ sessionId: upload.sessionId,
455
+ ingest: upload.ingest,
456
+ },
457
+ };
458
+ }
459
+ /** Upload local video by extracting JPEG frames, then start dataset ingest. */
460
+ export async function datasetUploadVideo(client, options) {
461
+ validateTargetSplit(options.targetSplit);
462
+ if (!options.videoPath.trim()) {
463
+ throw new Error("`videoPath` is required.");
464
+ }
465
+ const fps = options.fps ?? 1;
466
+ const maxFrames = options.maxFrames ?? 100;
467
+ if (fps <= 0) {
468
+ throw new Error("`fps` must be greater than 0.");
469
+ }
470
+ if (maxFrames <= 0) {
471
+ throw new Error("`maxFrames` must be greater than 0.");
472
+ }
473
+ const resolvedVideo = resolve(options.videoPath);
474
+ const info = await stat(resolvedVideo).catch(() => null);
475
+ if (info === null) {
476
+ throw new Error(`Upload video does not exist: ${resolvedVideo}`);
477
+ }
478
+ if (!info.isFile()) {
479
+ throw new Error(`Upload path is not a file: ${resolvedVideo}`);
480
+ }
481
+ const lower = basename(resolvedVideo).toLowerCase();
482
+ if (!Array.from(VIDEO_SUFFIXES).some((suffix) => lower.endsWith(suffix))) {
483
+ throw new Error(`Unsupported video file type. Expected one of: ${Array.from(VIDEO_SUFFIXES).sort().join(", ")}.`);
484
+ }
485
+ const findTool = options._findTool ?? findToolOnPath;
486
+ const ffmpegPath = findTool("ffmpeg");
487
+ const ffprobePath = findTool("ffprobe");
488
+ if (!ffmpegPath || !ffprobePath) {
489
+ throw new Error("ffmpeg/ffprobe not found on PATH. Install ffmpeg, or extract frames yourself (ffmpeg -i video.mp4 -vf fps=1 frames/%06d.jpg) and use dataset_upload_folder.");
490
+ }
491
+ let rate = fps;
492
+ let usedProbeFallback = false;
493
+ const probe = options._probeDuration ?? probeVideoDuration;
494
+ try {
495
+ const duration = await probe(resolvedVideo, ffprobePath);
496
+ if (duration > 0) {
497
+ rate = Math.min(fps, maxFrames / duration);
498
+ }
499
+ }
500
+ catch {
501
+ usedProbeFallback = true;
502
+ rate = fps;
503
+ }
504
+ const extract = options._extractFrames ?? extractVideoFrames;
505
+ const outputDir = await mkdtemp(join(process.cwd(), ".ultralytics-video-"));
506
+ try {
507
+ await extract({
508
+ videoPath: resolvedVideo,
509
+ outputDir,
510
+ ffmpegPath,
511
+ rate,
512
+ maxFrames,
513
+ });
514
+ const folder = await datasetFolderImages(outputDir);
515
+ const content = await buildDatasetFolderZip(folder.files);
516
+ const datasetId = await resolveDataset(client, options.dataset);
517
+ const filename = `${basename(resolvedVideo).replace(/\.[^.]+$/, "")}.zip`;
518
+ const upload = await uploadDatasetContent(client, {
519
+ datasetId,
520
+ filename,
521
+ contentType: "application/zip",
522
+ totalBytes: content.byteLength,
523
+ content,
524
+ targetSplit: options.targetSplit,
525
+ });
526
+ const jobId = upload.ingest.jobId ?? upload.ingest.id ?? "None";
527
+ return {
528
+ summary: `Extracted ${folder.files.length} frame(s) at ~${Number(rate.toFixed(4))} fps from ${resolvedVideo}; started ingest job ${String(jobId)} for dataset ${datasetId}.${usedProbeFallback ? " probe fallback" : ""}`,
529
+ data: {
530
+ datasetId,
531
+ frameCount: folder.files.length,
532
+ fps,
533
+ maxFrames,
534
+ filename,
535
+ bytes: content.byteLength,
536
+ sessionId: upload.sessionId,
537
+ ingest: upload.ingest,
538
+ },
539
+ };
540
+ }
541
+ finally {
542
+ await rm(outputDir, { recursive: true, force: true });
543
+ }
544
+ }
545
+ /** Get dataset export link for latest or one frozen version. */
546
+ export async function datasetExport(client, options) {
547
+ if (options.version !== undefined && options.version <= 0) {
548
+ throw new Error("`version` must be greater than 0.");
549
+ }
550
+ const datasetId = await resolveDataset(client, options.dataset);
551
+ const data = asRecord(await client.get(`/datasets/${datasetId}/export`, options.version !== undefined ? { v: options.version } : undefined));
552
+ const cached = typeof data.cached === "boolean"
553
+ ? String(data.cached)
554
+ : String(data.cached ?? null);
555
+ return {
556
+ summary: `Export link for ${options.dataset} ` +
557
+ `(version ${String(options.version ?? "latest")}, cached=${cached})`,
558
+ data: {
559
+ downloadUrl: data.downloadUrl ?? null,
560
+ cached: data.cached ?? null,
561
+ },
562
+ };
563
+ }
564
+ /** Create frozen dataset export version. */
565
+ export async function datasetVersionCreate(client, options) {
566
+ const datasetId = await resolveDataset(client, options.dataset);
567
+ const payload = {};
568
+ if (options.description !== undefined) {
569
+ payload.description = options.description;
570
+ }
571
+ const data = asRecord(await client.postJson(`/datasets/${datasetId}/export`, payload));
572
+ return {
573
+ summary: `Created dataset version ${String(data.version ?? null)}`,
574
+ data: {
575
+ version: data.version ?? null,
576
+ downloadUrl: data.downloadUrl ?? null,
577
+ },
578
+ };
579
+ }
@@ -0,0 +1,57 @@
1
+ import { asRecord } from "./shared.js";
2
+ const EXPLORE_SORTS = new Set([
3
+ "newest",
4
+ "stars",
5
+ "oldest",
6
+ "name-asc",
7
+ "name-desc",
8
+ "count-desc",
9
+ "count-asc",
10
+ ]);
11
+ const DATASET_TASKS = new Set([
12
+ "detect",
13
+ "segment",
14
+ "semantic",
15
+ "classify",
16
+ "pose",
17
+ "obb",
18
+ ]);
19
+ export function validateExploreQuery(q, sort = "newest", offset = 0) {
20
+ if (!q.trim()) {
21
+ throw new Error("q is required: a search query");
22
+ }
23
+ if (!EXPLORE_SORTS.has(sort)) {
24
+ const allowed = Array.from(EXPLORE_SORTS).sort().join(", ");
25
+ throw new Error(`Unsupported sort '${sort}'. Expected one of: ${allowed}.`);
26
+ }
27
+ if (offset < 0) {
28
+ throw new Error("`offset` must be greater than or equal to 0.");
29
+ }
30
+ }
31
+ export function validateExploreTasks(task) {
32
+ if (!task || task.length === 0) {
33
+ return undefined;
34
+ }
35
+ for (const item of task) {
36
+ if (!DATASET_TASKS.has(item)) {
37
+ const allowed = Array.from(DATASET_TASKS).sort().join(", ");
38
+ throw new Error(`Unsupported dataset task '${item}'. Expected one of: ${allowed}.`);
39
+ }
40
+ }
41
+ return task.join(",");
42
+ }
43
+ export async function exploreSearch(client, type, q, options = {}) {
44
+ const sort = options.sort ?? "newest";
45
+ const offset = options.offset ?? 0;
46
+ validateExploreQuery(q, sort, offset);
47
+ const params = {
48
+ type,
49
+ q: q.trim(),
50
+ sort,
51
+ offset,
52
+ };
53
+ if (options.task !== undefined) {
54
+ params.task = options.task;
55
+ }
56
+ return asRecord(await client.get("/explore/search", params));
57
+ }
@@ -7,34 +7,41 @@
7
7
  */
8
8
  import { z } from "zod";
9
9
  import { toMcpTextResult } from "../tool-result.js";
10
- import { datasetsCreate, datasetsDelete, datasetsGet, datasetsIngest, datasetsList, datasetUploadFile, } from "./datasets.js";
10
+ import { datasetExport, datasetImagesList, datasetsCreate, datasetsDelete, datasetsGet, datasetsIngest, datasetsList, datasetUploadFile, datasetUploadFolder, datasetUploadVideo, datasetVersionCreate, exploreDatasets, } from "./datasets.js";
11
11
  import { modelDownload } from "./downloads.js";
12
12
  import { exportCreate, exportStatus, exportsList } from "./exports.js";
13
13
  import { gpuAvailability } from "./gpu.js";
14
14
  import { modelsGet, modelsList } from "./models.js";
15
15
  import { modelPredict } from "./predict.js";
16
- import { projectsCreate, projectsDelete, projectsGet, projectsList, } from "./projects.js";
16
+ import { exploreProjects, projectsCreate, projectsDelete, projectsGet, projectsList, } from "./projects.js";
17
17
  import { trainingMonitor, trainingStart } from "./training.js";
18
- export { datasetsCreate, datasetsDelete, datasetsGet, datasetsIngest, datasetsList, datasetUploadFile, } from "./datasets.js";
18
+ export { datasetExport, datasetImagesList, datasetsCreate, datasetsDelete, datasetsGet, datasetsIngest, datasetsList, datasetUploadFile, datasetUploadFolder, datasetUploadVideo, datasetVersionCreate, exploreDatasets, } from "./datasets.js";
19
19
  export { modelDownload } from "./downloads.js";
20
20
  export { exportCreate, exportStatus, exportsList } from "./exports.js";
21
21
  export { gpuAvailability } from "./gpu.js";
22
22
  export { modelsGet, modelsList } from "./models.js";
23
23
  export { modelPredict } from "./predict.js";
24
- export { projectsCreate, projectsDelete, projectsGet, projectsList, } from "./projects.js";
24
+ export { exploreProjects, projectsCreate, projectsDelete, projectsGet, projectsList, } from "./projects.js";
25
25
  export { trainingMonitor, trainingStart } from "./training.js";
26
26
  /** Names of the read-only tools registered by `registerReadTools`. */
27
27
  export const READ_TOOL_NAMES = [
28
28
  "projects_list",
29
29
  "projects_get",
30
+ "explore_projects",
30
31
  "projects_create",
31
32
  "projects_delete",
32
33
  "datasets_list",
33
34
  "datasets_get",
35
+ "explore_datasets",
36
+ "dataset_images_list",
37
+ "dataset_export",
38
+ "dataset_version_create",
34
39
  "datasets_create",
35
40
  "datasets_delete",
36
41
  "dataset_ingest",
37
42
  "dataset_upload_file",
43
+ "dataset_upload_folder",
44
+ "dataset_upload_video",
38
45
  "models_list",
39
46
  "models_get",
40
47
  "gpu_availability",
@@ -49,6 +56,14 @@ export function registerReadTools(server, getClient) {
49
56
  description: "Get details for one project by id, slug, username/slug, or project ul:// URI.",
50
57
  inputSchema: { project: z.string() },
51
58
  }, async ({ project }) => toMcpTextResult(await projectsGet(getClient(), project)));
59
+ server.registerTool("explore_projects", {
60
+ description: "Search public projects on Ultralytics Explore.",
61
+ inputSchema: {
62
+ q: z.string(),
63
+ sort: z.string().optional(),
64
+ offset: z.number().int().optional(),
65
+ },
66
+ }, async ({ q, sort, offset }) => toMcpTextResult(await exploreProjects(getClient(), { q, sort, offset })));
52
67
  server.registerTool("projects_create", {
53
68
  description: "Create a project in your Ultralytics workspace.",
54
69
  inputSchema: {
@@ -69,6 +84,15 @@ export function registerReadTools(server, getClient) {
69
84
  description: "Get details for one dataset by id, slug, username/slug, or dataset ul:// URI.",
70
85
  inputSchema: { dataset: z.string() },
71
86
  }, async ({ dataset }) => toMcpTextResult(await datasetsGet(getClient(), dataset)));
87
+ server.registerTool("explore_datasets", {
88
+ description: "Search public datasets on Ultralytics Explore.",
89
+ inputSchema: {
90
+ q: z.string(),
91
+ sort: z.string().optional(),
92
+ offset: z.number().int().optional(),
93
+ task: z.array(z.string()).optional(),
94
+ },
95
+ }, async ({ q, sort, offset, task }) => toMcpTextResult(await exploreDatasets(getClient(), { q, sort, offset, task })));
72
96
  server.registerTool("datasets_create", {
73
97
  description: "Create a dataset in your Ultralytics workspace.",
74
98
  inputSchema: {
@@ -87,6 +111,42 @@ export function registerReadTools(server, getClient) {
87
111
  visibility,
88
112
  classNames,
89
113
  })));
114
+ server.registerTool("dataset_images_list", {
115
+ description: "List images in a dataset with optional filtering.",
116
+ inputSchema: {
117
+ dataset: z.string(),
118
+ split: z.string().optional(),
119
+ search: z.string().optional(),
120
+ hasLabel: z.boolean().optional(),
121
+ classIds: z.array(z.string()).optional(),
122
+ limit: z.number().optional(),
123
+ offset: z.number().optional(),
124
+ includeImageUrls: z.boolean().optional(),
125
+ },
126
+ }, async ({ dataset, split, search, hasLabel, classIds, limit, offset, includeImageUrls, }) => toMcpTextResult(await datasetImagesList(getClient(), {
127
+ dataset,
128
+ split,
129
+ search,
130
+ hasLabel,
131
+ classIds,
132
+ limit,
133
+ offset,
134
+ includeImageUrls,
135
+ })));
136
+ server.registerTool("dataset_export", {
137
+ description: "Get export link for latest or one frozen dataset version.",
138
+ inputSchema: {
139
+ dataset: z.string(),
140
+ version: z.number().optional(),
141
+ },
142
+ }, async ({ dataset, version }) => toMcpTextResult(await datasetExport(getClient(), { dataset, version })));
143
+ server.registerTool("dataset_version_create", {
144
+ description: "Create a frozen dataset version snapshot.",
145
+ inputSchema: {
146
+ dataset: z.string(),
147
+ description: z.string().optional(),
148
+ },
149
+ }, async ({ dataset, description }) => toMcpTextResult(await datasetVersionCreate(getClient(), { dataset, description })));
90
150
  server.registerTool("datasets_delete", {
91
151
  description: "Soft-delete a dataset by id, slug, username/slug, or dataset ul:// URI.",
92
152
  inputSchema: { dataset: z.string() },
@@ -111,6 +171,34 @@ export function registerReadTools(server, getClient) {
111
171
  filePath: file_path,
112
172
  targetSplit,
113
173
  })));
174
+ server.registerTool("dataset_upload_folder", {
175
+ description: "Upload a local image folder as a zip and start ingest for an existing dataset.",
176
+ inputSchema: {
177
+ dataset: z.string(),
178
+ folder_path: z.string(),
179
+ targetSplit: z.string().optional(),
180
+ },
181
+ }, async ({ dataset, folder_path, targetSplit }) => toMcpTextResult(await datasetUploadFolder(getClient(), {
182
+ dataset,
183
+ folderPath: folder_path,
184
+ targetSplit,
185
+ })));
186
+ server.registerTool("dataset_upload_video", {
187
+ description: "Upload a local video by extracting JPEG frames with ffmpeg, then start dataset ingest for an existing dataset.",
188
+ inputSchema: {
189
+ dataset: z.string(),
190
+ video_path: z.string(),
191
+ fps: z.number().optional(),
192
+ max_frames: z.number().int().optional(),
193
+ targetSplit: z.string().optional(),
194
+ },
195
+ }, async ({ dataset, video_path, fps, max_frames, targetSplit }) => toMcpTextResult(await datasetUploadVideo(getClient(), {
196
+ dataset,
197
+ videoPath: video_path,
198
+ fps,
199
+ maxFrames: max_frames,
200
+ targetSplit,
201
+ })));
114
202
  server.registerTool("models_list", {
115
203
  description: "List models in a project by project id, slug, username/slug, or project ul:// URI.",
116
204
  inputSchema: { project: z.string() },
@@ -134,8 +222,18 @@ export const ACTION_TOOL_NAMES = [
134
222
  export function registerActionTools(server, getClient) {
135
223
  server.registerTool("training_monitor", {
136
224
  description: "Report a model's training status and progress (works for private and public projects).",
137
- inputSchema: { model: z.string(), project: z.string().optional() },
138
- }, async ({ model, project }) => toMcpTextResult(await trainingMonitor(getClient(), model, project)));
225
+ inputSchema: {
226
+ model: z.string(),
227
+ project: z.string().optional(),
228
+ include_metrics: z.boolean().optional(),
229
+ include_history: z.boolean().optional(),
230
+ history_last_n: z.number().int().positive().optional(),
231
+ },
232
+ }, async ({ model, project, include_metrics, include_history, history_last_n, }) => toMcpTextResult(await trainingMonitor(getClient(), model, project, {
233
+ includeMetrics: include_metrics,
234
+ includeHistory: include_history,
235
+ historyLastN: history_last_n,
236
+ })));
139
237
  server.registerTool("model_predict", {
140
238
  description: "Run inference with a trained model on an image URL or base64 source (no local file paths).",
141
239
  inputSchema: {
@@ -1,5 +1,6 @@
1
1
  /** Read-only project tools. */
2
2
  import { resolveProject } from "../resolve.js";
3
+ import { exploreSearch } from "./explore.js";
3
4
  import { asRecord, listField, pyCount, pyField } from "./shared.js";
4
5
  function resourceId(item, fallback) {
5
6
  const value = item._id ?? item.id ?? item.projectId ?? item.datasetId;
@@ -18,6 +19,30 @@ export async function projectsList(client, username) {
18
19
  }));
19
20
  return { summary: `${items.length} project(s).`, data: items };
20
21
  }
22
+ /** Search public projects on Explore. */
23
+ export async function exploreProjects(client, options) {
24
+ const data = await exploreSearch(client, "projects", options.q, {
25
+ sort: options.sort,
26
+ offset: options.offset,
27
+ });
28
+ const items = listField(data, "projects").map((project) => ({
29
+ id: project._id ?? null,
30
+ name: project.name ?? null,
31
+ slug: project.slug ?? null,
32
+ username: project.username ?? null,
33
+ visibility: project.visibility ?? null,
34
+ modelCount: project.modelCount ?? null,
35
+ starCount: project.starCount ?? null,
36
+ }));
37
+ const hasMore = Boolean(data.hasMore);
38
+ return {
39
+ summary: `Search '${options.q.trim()}': ${items.length} project(s)${hasMore ? " (more available)" : ""}`,
40
+ data: {
41
+ projects: items,
42
+ hasMore,
43
+ },
44
+ };
45
+ }
21
46
  /** Get one project by id, slug, username/slug, or project ul:// URI. */
22
47
  export async function projectsGet(client, project) {
23
48
  const projectId = await resolveProject(client, project);
@@ -12,8 +12,15 @@ const KEY_METRICS = [
12
12
  function formatPercent(value) {
13
13
  return Number.isInteger(value) ? value.toFixed(1) : String(value);
14
14
  }
15
+ function validateHistoryLastN(historyLastN) {
16
+ if (!Number.isInteger(historyLastN) || historyLastN <= 0) {
17
+ throw new Error("`history_last_n` must be a positive integer.");
18
+ }
19
+ }
15
20
  /** Report model training status using private-safe model trainResults. */
16
- export async function trainingMonitor(client, model, project) {
21
+ export async function trainingMonitor(client, model, project, options = {}) {
22
+ const { includeMetrics = false, includeHistory = false, historyLastN = 20, } = options;
23
+ validateHistoryLastN(historyLastN);
17
24
  const modelId = await resolveModel(client, model, project);
18
25
  const data = await client.get(`/models/${modelId}`);
19
26
  const record = asRecord(data);
@@ -34,19 +41,38 @@ export async function trainingMonitor(client, model, project) {
34
41
  keyMetrics[key] = latestMetrics[key];
35
42
  }
36
43
  }
44
+ const metricsHistory = trainResults.slice(-historyLastN).map((entry) => {
45
+ const record = asRecord(entry);
46
+ return {
47
+ epoch: record.epoch ?? null,
48
+ metrics: asRecord(record.metrics),
49
+ };
50
+ });
37
51
  let progressPct = null;
38
52
  let progressText = null;
39
53
  let etaMs = null;
40
54
  let source = "model.trainResults";
55
+ let timing = null;
56
+ let instanceStatus = null;
41
57
  try {
42
58
  const trainingData = await client.get(`/models/${modelId}/training`);
43
- const job = asRecord(asRecord(trainingData).job);
59
+ const trainingRecord = asRecord(trainingData);
60
+ const job = asRecord(trainingRecord.job);
44
61
  const progress = asRecord(job.progress);
45
- const timing = asRecord(job.timing);
62
+ const timingRecord = asRecord(job.timing);
46
63
  progressPct = progress.percentage ?? null;
47
64
  progressText = progressPct === null ? null : String(progressPct);
48
- etaMs = timing.etaMs ?? null;
65
+ etaMs = timingRecord.etaMs ?? null;
49
66
  source = "models/{id}/training";
67
+ timing = {
68
+ etaMs: timingRecord.etaMs ?? null,
69
+ timePerEpochMs: timingRecord.timePerEpochMs ?? null,
70
+ elapsedMs: timingRecord.elapsedMs ?? null,
71
+ };
72
+ instanceStatus =
73
+ "instanceStatus" in trainingRecord
74
+ ? asRecord(trainingRecord.instanceStatus)
75
+ : null;
50
76
  }
51
77
  catch (error) {
52
78
  if (!(error instanceof UltralyticsApiError) ||
@@ -74,8 +100,15 @@ export async function trainingMonitor(client, model, project) {
74
100
  etaMs,
75
101
  bestEpoch: item.bestEpoch ?? null,
76
102
  bestFitness: item.bestFitness ?? null,
77
- latestMetrics: keyMetrics,
103
+ latestMetrics: includeMetrics ? latestMetrics : keyMetrics,
78
104
  progressSource: source,
105
+ ...(includeMetrics
106
+ ? {
107
+ timing,
108
+ instanceStatus,
109
+ }
110
+ : {}),
111
+ ...(includeHistory ? { metricsHistory } : {}),
79
112
  },
80
113
  };
81
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultralytics-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "MCP for Ultralytics Platform workflows, datasets, training, prediction, and model operations.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,7 @@
44
44
  "license": "MIT",
45
45
  "dependencies": {
46
46
  "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "fflate": "^0.8.3",
47
48
  "zod": "^4.4.3"
48
49
  },
49
50
  "devDependencies": {