ultralytics-mcp 0.1.3 → 0.1.5
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 +227 -17
- package/dist/server.js +10 -1
- package/dist/tools/datasets.js +310 -16
- package/dist/tools/explore.js +57 -0
- package/dist/tools/index.js +66 -7
- package/dist/tools/projects.js +25 -0
- package/dist/tools/training.js +38 -5
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,30 +1,240 @@
|
|
|
1
1
|
# Ultralytics Platform MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/ultralytics-mcp)
|
|
4
|
+
[](https://github.com/amanharshx/ultralytics-mcp/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
4
6
|
|
|
7
|
+
MCP server for [Ultralytics Platform](https://platform.ultralytics.com)
|
|
8
|
+
workflows: projects, datasets, models, training, prediction, exports, and
|
|
9
|
+
dataset uploads.
|
|
10
|
+
|
|
11
|
+
> [!IMPORTANT]
|
|
5
12
|
> Independent community project. Not affiliated with or endorsed by Ultralytics.
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Table of Contents
|
|
17
|
+
|
|
18
|
+
- [Requirements](#requirements)
|
|
19
|
+
- [Get API Key](#get-api-key)
|
|
20
|
+
- [Environment Variables](#environment-variables)
|
|
21
|
+
- [Installation](#installation)
|
|
22
|
+
- [Claude Code](#claude-code)
|
|
23
|
+
- [Codex](#codex)
|
|
24
|
+
- [Verify Setup](#verify-setup)
|
|
25
|
+
- [What You Can Do](#what-you-can-do)
|
|
26
|
+
- [Tools](#tools-27)
|
|
27
|
+
- [Safety](#safety)
|
|
28
|
+
- [Troubleshooting](#troubleshooting)
|
|
29
|
+
- [Development](#development)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
- Node.js `>=20`
|
|
36
|
+
- Ultralytics Platform API key
|
|
37
|
+
- `ffmpeg` and `ffprobe` on `PATH` to upload a dataset from a local video file
|
|
38
|
+
- Claude Code, Codex, or another MCP client that can launch stdio servers
|
|
39
|
+
|
|
40
|
+
## Get API Key
|
|
41
|
+
|
|
42
|
+
1. Sign in at [Ultralytics Platform](https://platform.ultralytics.com).
|
|
43
|
+
2. Open `Settings -> API Keys`.
|
|
44
|
+
3. Create or copy an API key.
|
|
45
|
+
|
|
46
|
+
## Environment Variables
|
|
47
|
+
|
|
48
|
+
| Variable | Required | Description |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `ULTRALYTICS_API_KEY` | ✅ | Ultralytics API key. Expected format: `ul_` followed by 40 hex characters |
|
|
51
|
+
| `ULTRALYTICS_API_BASE` | ❌ | Advanced: override API base URL. Default: `https://platform.ultralytics.com/api` |
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
### Standard Config
|
|
56
|
+
|
|
57
|
+
Works in many MCP clients that accept JSON stdio server definitions.
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"ultralytics": {
|
|
63
|
+
"command": "npx",
|
|
64
|
+
"args": ["-y", "ultralytics-mcp"],
|
|
65
|
+
"env": {
|
|
66
|
+
"ULTRALYTICS_API_KEY": "ul_your_api_key_here"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Claude Code
|
|
74
|
+
|
|
75
|
+
Add server with Claude Code CLI:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
claude mcp add ultralytics --env ULTRALYTICS_API_KEY=ul_your_api_key_here -- npx -y ultralytics-mcp
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Or add a project-scoped server in repo-root `.mcp.json`:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"mcpServers": {
|
|
86
|
+
"ultralytics": {
|
|
87
|
+
"command": "npx",
|
|
88
|
+
"args": ["-y", "ultralytics-mcp"],
|
|
89
|
+
"env": {
|
|
90
|
+
"ULTRALYTICS_API_KEY": "ul_your_api_key_here"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Codex
|
|
98
|
+
|
|
99
|
+
Add server with Codex CLI:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
codex mcp add ultralytics --env ULTRALYTICS_API_KEY=ul_your_api_key_here -- npx -y ultralytics-mcp
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Or add it directly to `~/.codex/config.toml`:
|
|
106
|
+
|
|
107
|
+
```toml
|
|
108
|
+
[mcp_servers.ultralytics]
|
|
109
|
+
command = "npx"
|
|
110
|
+
args = ["-y", "ultralytics-mcp"]
|
|
111
|
+
|
|
112
|
+
[mcp_servers.ultralytics.env]
|
|
113
|
+
ULTRALYTICS_API_KEY = "ul_your_api_key_here"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Verify Setup
|
|
117
|
+
|
|
118
|
+
### Claude Code
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
claude mcp list
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
You should see `ultralytics` in configured MCP servers.
|
|
125
|
+
|
|
126
|
+
### Codex
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
codex mcp list
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
You should see `ultralytics` in configured MCP servers.
|
|
133
|
+
|
|
134
|
+
## What You Can Do
|
|
10
135
|
|
|
11
|
-
|
|
136
|
+
- Browse projects, datasets, models, exports, GPU availability
|
|
137
|
+
- Resolve project and dataset refs by id, slug, `username/slug`, or `ul://`
|
|
138
|
+
- Search public projects and datasets on Ultralytics Explore
|
|
139
|
+
- Start dataset ingest jobs and upload archive files, folders, or videos
|
|
140
|
+
- Monitor training progress and inspect latest metrics or recent metric history
|
|
141
|
+
- Run model prediction from image URL or base64 input
|
|
142
|
+
- Download model weights to local path
|
|
143
|
+
- Create exports and training jobs with explicit cost confirmation
|
|
144
|
+
|
|
145
|
+
## Tools (27)
|
|
146
|
+
|
|
147
|
+
### Projects (5 tools)
|
|
12
148
|
|
|
13
149
|
| Tool | Description |
|
|
14
150
|
| --- | --- |
|
|
15
|
-
| `projects_list`
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
|
151
|
+
| `projects_list` | List projects in your Ultralytics workspace |
|
|
152
|
+
| `projects_get` | Get one project by id, slug, `username/slug`, or `ul://` |
|
|
153
|
+
| `projects_create` | Create a new project |
|
|
154
|
+
| `projects_delete` | Soft-delete a project |
|
|
155
|
+
| `explore_projects` | Search public projects on Ultralytics Explore |
|
|
156
|
+
|
|
157
|
+
### Datasets (12 tools)
|
|
158
|
+
|
|
159
|
+
| Tool | Description |
|
|
160
|
+
| --- | --- |
|
|
161
|
+
| `datasets_list` | List datasets in your Ultralytics workspace |
|
|
162
|
+
| `datasets_get` | Get one dataset by id, slug, `username/slug`, or `ul://` |
|
|
163
|
+
| `datasets_create` | Create a new dataset |
|
|
164
|
+
| `datasets_delete` | Soft-delete a dataset |
|
|
165
|
+
| `dataset_images_list` | List images in a dataset with optional filters |
|
|
166
|
+
| `dataset_ingest` | Start a remote ingest job from a source URL |
|
|
167
|
+
| `dataset_upload_file` | Upload a local archive file for dataset ingest |
|
|
168
|
+
| `dataset_upload_folder` | Upload a local image folder for dataset ingest |
|
|
169
|
+
| `dataset_upload_video` | Upload a local video by extracting frames for dataset ingest |
|
|
170
|
+
| `dataset_export` | Get an export link for latest or frozen dataset version |
|
|
24
171
|
| `dataset_version_create` | Create a frozen dataset version snapshot |
|
|
25
|
-
| `
|
|
26
|
-
|
|
27
|
-
|
|
172
|
+
| `explore_datasets` | Search public datasets on Ultralytics Explore |
|
|
173
|
+
|
|
174
|
+
### Models (4 tools)
|
|
175
|
+
|
|
176
|
+
| Tool | Description |
|
|
177
|
+
| --- | --- |
|
|
178
|
+
| `models_list` | List trained models and summary metrics |
|
|
179
|
+
| `models_get` | Get one model and its details |
|
|
180
|
+
| `model_predict` | Run inference from an image URL or base64 input |
|
|
181
|
+
| `model_download` | Download a model weight file to a local path |
|
|
182
|
+
|
|
183
|
+
### Training (2 tools)
|
|
184
|
+
|
|
185
|
+
| Tool | Description |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `training_monitor` | Inspect status, progress, latest metrics, and optional history |
|
|
188
|
+
| `training_start` | Start a cloud training job with explicit cost confirmation |
|
|
189
|
+
|
|
190
|
+
### Exports (3 tools)
|
|
191
|
+
|
|
192
|
+
| Tool | Description |
|
|
193
|
+
| --- | --- |
|
|
194
|
+
| `exports_list` | List export jobs |
|
|
195
|
+
| `export_status` | Check export job status |
|
|
196
|
+
| `export_create` | Create an export job with explicit cost confirmation |
|
|
197
|
+
|
|
198
|
+
### Infrastructure (1 tool)
|
|
199
|
+
|
|
200
|
+
| Tool | Description |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
| `gpu_availability` | Check cloud GPU availability |
|
|
203
|
+
|
|
204
|
+
## Safety
|
|
205
|
+
|
|
206
|
+
- `export_create` requires `confirm_cost: true`
|
|
207
|
+
- `training_start` requires `confirm_cost: true`
|
|
208
|
+
- Ambiguous project or dataset refs fail instead of guessing
|
|
209
|
+
- Signed upload and download URLs do not forward `Authorization`
|
|
210
|
+
|
|
211
|
+
## Troubleshooting
|
|
212
|
+
|
|
213
|
+
### Invalid API key
|
|
214
|
+
|
|
215
|
+
`ULTRALYTICS_API_KEY` must start with `ul_` and contain exactly 40 hex
|
|
216
|
+
characters after prefix.
|
|
217
|
+
|
|
218
|
+
### Server not loading in Claude Code
|
|
219
|
+
|
|
220
|
+
- run `claude mcp list`
|
|
221
|
+
- verify `npx` and Node.js are installed
|
|
222
|
+
- verify `ULTRALYTICS_API_KEY` was passed with `--env` when adding server
|
|
223
|
+
- if needed, inspect server config with `claude mcp get ultralytics`
|
|
224
|
+
|
|
225
|
+
### Server not loading in Codex
|
|
226
|
+
|
|
227
|
+
- run `codex mcp list`
|
|
228
|
+
- verify `npx` and Node.js are installed
|
|
229
|
+
- verify `ULTRALYTICS_API_KEY` value in `~/.codex/config.toml` or `codex mcp add` command
|
|
230
|
+
|
|
231
|
+
### Manual server smoke test
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
ULTRALYTICS_API_KEY=ul_your_api_key_here npx -y ultralytics-mcp
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
If command exits immediately with config error, fix environment first.
|
|
28
238
|
|
|
29
239
|
## Development
|
|
30
240
|
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
3
|
import { UltralyticsClient } from "./client.js";
|
|
3
4
|
import { registerTools } from "./tools/index.js";
|
|
5
|
+
function readPackageVersion() {
|
|
6
|
+
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
7
|
+
return packageJson.version;
|
|
8
|
+
}
|
|
9
|
+
export const SERVER_VERSION = readPackageVersion();
|
|
4
10
|
/** Create the MCP server with all tools registered.
|
|
5
11
|
*
|
|
6
12
|
* The client is created lazily on first tool invocation (so it reads the API key
|
|
@@ -8,7 +14,10 @@ import { registerTools } from "./tools/index.js";
|
|
|
8
14
|
* `clientFactory` can be injected for tests.
|
|
9
15
|
*/
|
|
10
16
|
export function createServer(clientFactory = () => new UltralyticsClient()) {
|
|
11
|
-
const server = new McpServer({
|
|
17
|
+
const server = new McpServer({
|
|
18
|
+
name: "ultralytics",
|
|
19
|
+
version: SERVER_VERSION,
|
|
20
|
+
});
|
|
12
21
|
let client;
|
|
13
22
|
const getClient = () => {
|
|
14
23
|
if (!client) {
|
package/dist/tools/datasets.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/** Read-only dataset tools. */
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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);
|
|
@@ -220,22 +404,15 @@ export async function datasetUploadFile(client, options) {
|
|
|
220
404
|
const meta = await datasetUploadFileMeta(options.filePath);
|
|
221
405
|
const datasetId = await resolveDataset(client, options.dataset);
|
|
222
406
|
const content = await readFile(options.filePath);
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
assetId: datasetId,
|
|
407
|
+
const upload = await uploadDatasetContent(client, {
|
|
408
|
+
datasetId,
|
|
226
409
|
filename: meta.filename,
|
|
227
410
|
contentType: meta.contentType,
|
|
228
411
|
totalBytes: meta.totalBytes,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
await client.postJson("/upload/complete", { sessionId });
|
|
234
|
-
const ingestPayload = { datasetId, sessionId };
|
|
235
|
-
if (options.targetSplit !== undefined) {
|
|
236
|
-
ingestPayload.targetSplit = options.targetSplit;
|
|
237
|
-
}
|
|
238
|
-
const ingest = asRecord(await client.postJson("/datasets/ingest", ingestPayload));
|
|
412
|
+
content,
|
|
413
|
+
targetSplit: options.targetSplit,
|
|
414
|
+
});
|
|
415
|
+
const ingest = upload.ingest;
|
|
239
416
|
const jobId = ingest.jobId ?? ingest.id ?? "None";
|
|
240
417
|
return {
|
|
241
418
|
summary: `Uploaded ${meta.filename} (${meta.totalBytes} bytes) and started dataset ingest job ${String(jobId)}.`,
|
|
@@ -243,11 +420,128 @@ export async function datasetUploadFile(client, options) {
|
|
|
243
420
|
datasetId,
|
|
244
421
|
filename: meta.filename,
|
|
245
422
|
bytes: meta.totalBytes,
|
|
246
|
-
sessionId,
|
|
423
|
+
sessionId: upload.sessionId,
|
|
247
424
|
ingest,
|
|
248
425
|
},
|
|
249
426
|
};
|
|
250
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
|
+
}
|
|
251
545
|
/** Get dataset export link for latest or one frozen version. */
|
|
252
546
|
export async function datasetExport(client, options) {
|
|
253
547
|
if (options.version !== undefined && options.version <= 0) {
|
|
@@ -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
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -7,37 +7,41 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { toMcpTextResult } from "../tool-result.js";
|
|
10
|
-
import { datasetExport, datasetImagesList, datasetsCreate, datasetsDelete, datasetsGet, datasetsIngest, datasetsList, datasetUploadFile, datasetVersionCreate, } 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 { datasetExport, datasetImagesList, datasetsCreate, datasetsDelete, datasetsGet, datasetsIngest, datasetsList, datasetUploadFile, datasetVersionCreate, } 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",
|
|
34
|
-
"
|
|
35
|
+
"explore_datasets",
|
|
35
36
|
"dataset_images_list",
|
|
36
37
|
"dataset_export",
|
|
37
38
|
"dataset_version_create",
|
|
39
|
+
"datasets_create",
|
|
38
40
|
"datasets_delete",
|
|
39
41
|
"dataset_ingest",
|
|
40
42
|
"dataset_upload_file",
|
|
43
|
+
"dataset_upload_folder",
|
|
44
|
+
"dataset_upload_video",
|
|
41
45
|
"models_list",
|
|
42
46
|
"models_get",
|
|
43
47
|
"gpu_availability",
|
|
@@ -52,6 +56,14 @@ export function registerReadTools(server, getClient) {
|
|
|
52
56
|
description: "Get details for one project by id, slug, username/slug, or project ul:// URI.",
|
|
53
57
|
inputSchema: { project: z.string() },
|
|
54
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 })));
|
|
55
67
|
server.registerTool("projects_create", {
|
|
56
68
|
description: "Create a project in your Ultralytics workspace.",
|
|
57
69
|
inputSchema: {
|
|
@@ -72,6 +84,15 @@ export function registerReadTools(server, getClient) {
|
|
|
72
84
|
description: "Get details for one dataset by id, slug, username/slug, or dataset ul:// URI.",
|
|
73
85
|
inputSchema: { dataset: z.string() },
|
|
74
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 })));
|
|
75
96
|
server.registerTool("datasets_create", {
|
|
76
97
|
description: "Create a dataset in your Ultralytics workspace.",
|
|
77
98
|
inputSchema: {
|
|
@@ -150,6 +171,34 @@ export function registerReadTools(server, getClient) {
|
|
|
150
171
|
filePath: file_path,
|
|
151
172
|
targetSplit,
|
|
152
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
|
+
})));
|
|
153
202
|
server.registerTool("models_list", {
|
|
154
203
|
description: "List models in a project by project id, slug, username/slug, or project ul:// URI.",
|
|
155
204
|
inputSchema: { project: z.string() },
|
|
@@ -173,8 +222,18 @@ export const ACTION_TOOL_NAMES = [
|
|
|
173
222
|
export function registerActionTools(server, getClient) {
|
|
174
223
|
server.registerTool("training_monitor", {
|
|
175
224
|
description: "Report a model's training status and progress (works for private and public projects).",
|
|
176
|
-
inputSchema: {
|
|
177
|
-
|
|
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
|
+
})));
|
|
178
237
|
server.registerTool("model_predict", {
|
|
179
238
|
description: "Run inference with a trained model on an image URL or base64 source (no local file paths).",
|
|
180
239
|
inputSchema: {
|
package/dist/tools/projects.js
CHANGED
|
@@ -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);
|
package/dist/tools/training.js
CHANGED
|
@@ -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
|
|
59
|
+
const trainingRecord = asRecord(trainingData);
|
|
60
|
+
const job = asRecord(trainingRecord.job);
|
|
44
61
|
const progress = asRecord(job.progress);
|
|
45
|
-
const
|
|
62
|
+
const timingRecord = asRecord(job.timing);
|
|
46
63
|
progressPct = progress.percentage ?? null;
|
|
47
64
|
progressText = progressPct === null ? null : String(progressPct);
|
|
48
|
-
etaMs =
|
|
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.
|
|
3
|
+
"version": "0.1.5",
|
|
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": {
|