ultralytics-mcp 0.1.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/LICENSE +21 -0
- package/README.md +15 -0
- package/dist/cli.js +6 -0
- package/dist/client.js +179 -0
- package/dist/config.js +29 -0
- package/dist/errors.js +25 -0
- package/dist/resolve.js +141 -0
- package/dist/server.js +21 -0
- package/dist/tool-result.js +10 -0
- package/dist/tools/datasets.js +30 -0
- package/dist/tools/downloads.js +107 -0
- package/dist/tools/exports.js +93 -0
- package/dist/tools/gpu.js +13 -0
- package/dist/tools/index.js +185 -0
- package/dist/tools/models.js +32 -0
- package/dist/tools/predict.js +28 -0
- package/dist/tools/projects.js +29 -0
- package/dist/tools/shared.js +24 -0
- package/dist/tools/training.js +129 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aman Harsh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Ultralytics Platform MCP
|
|
2
|
+
|
|
3
|
+
MCP server for the Ultralytics Platform REST API.
|
|
4
|
+
|
|
5
|
+
Current milestone: protocol scaffold with CLI entrypoint and protocol-level
|
|
6
|
+
tests. Tooling and API features land incrementally from here.
|
|
7
|
+
|
|
8
|
+
## Development
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install
|
|
12
|
+
npm run check
|
|
13
|
+
npm test
|
|
14
|
+
npm run build
|
|
15
|
+
```
|
package/dist/cli.js
ADDED
package/dist/client.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** HTTP client wrapper for the Ultralytics Platform REST API.
|
|
2
|
+
*
|
|
3
|
+
* Mirrors the Python `UltralyticsClient` safety behaviors exactly:
|
|
4
|
+
* - Bearer auth + Accept: application/json on API calls.
|
|
5
|
+
* - GET retries 429 (idempotent); POST defaults to NO retry (no duplicate
|
|
6
|
+
* state-changing/cost calls).
|
|
7
|
+
* - `Retry-After` numeric header wins over exponential backoff.
|
|
8
|
+
* - Non-2xx responses normalize into `UltralyticsApiError`.
|
|
9
|
+
* - `downloadBytes` fetches signed URLs WITHOUT forwarding `Authorization`.
|
|
10
|
+
*
|
|
11
|
+
* `fetchImpl` / `downloadFetchImpl` are injectable for tests.
|
|
12
|
+
*/
|
|
13
|
+
import { getApiBase, getApiKey } from "./config.js";
|
|
14
|
+
import { UltralyticsApiError } from "./errors.js";
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
export class UltralyticsClient {
|
|
17
|
+
baseUrl;
|
|
18
|
+
apiKey;
|
|
19
|
+
timeoutMs;
|
|
20
|
+
maxRetries;
|
|
21
|
+
fetchImpl;
|
|
22
|
+
downloadFetchImpl;
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.baseUrl = (options.baseUrl ?? getApiBase()).replace(/\/+$/, "");
|
|
25
|
+
this.apiKey = options.apiKey ?? getApiKey();
|
|
26
|
+
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
27
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
28
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
29
|
+
this.downloadFetchImpl = options.downloadFetchImpl ?? fetch;
|
|
30
|
+
}
|
|
31
|
+
// -- public verbs --------------------------------------------------------
|
|
32
|
+
/** GET requests are idempotent and retry 429 responses. */
|
|
33
|
+
async get(path, params) {
|
|
34
|
+
return this.request("GET", path, { params, retryOn429: true });
|
|
35
|
+
}
|
|
36
|
+
/** POST JSON. Defaults to no retry to avoid duplicate state-changing calls. */
|
|
37
|
+
async postJson(path, payload, options = {}) {
|
|
38
|
+
return this.request("POST", path, {
|
|
39
|
+
jsonBody: payload,
|
|
40
|
+
retryOn429: options.retryOn429 ?? false,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** POST multipart/form-data. Defaults to no retry. */
|
|
44
|
+
async postMultipart(path, content, options = {}) {
|
|
45
|
+
const form = new FormData();
|
|
46
|
+
if (content.data) {
|
|
47
|
+
for (const [key, value] of Object.entries(content.data)) {
|
|
48
|
+
form.append(key, String(value));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (content.files) {
|
|
52
|
+
for (const [key, file] of Object.entries(content.files)) {
|
|
53
|
+
if (file.filename) {
|
|
54
|
+
form.append(key, file.blob, file.filename);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
form.append(key, file.blob);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return this.request("POST", path, {
|
|
62
|
+
formBody: form,
|
|
63
|
+
retryOn429: options.retryOn429 ?? false,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Download bytes from a signed URL WITHOUT forwarding API credentials. */
|
|
67
|
+
async downloadBytes(url) {
|
|
68
|
+
let attempt = 0;
|
|
69
|
+
while (true) {
|
|
70
|
+
const response = await this.fetchWithTimeout(this.downloadFetchImpl, url, {
|
|
71
|
+
method: "GET",
|
|
72
|
+
headers: { Accept: "*/*" }, // deliberately no Authorization
|
|
73
|
+
});
|
|
74
|
+
if (response.status === 429 && attempt < this.maxRetries) {
|
|
75
|
+
attempt += 1;
|
|
76
|
+
await sleep(this.retryAfterMs(response, attempt));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (response.ok) {
|
|
80
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
81
|
+
}
|
|
82
|
+
await this.handle(response, url); // throws UltralyticsApiError
|
|
83
|
+
throw new Error("unreachable");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// -- internals -----------------------------------------------------------
|
|
87
|
+
buildUrl(path, params) {
|
|
88
|
+
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
89
|
+
const url = new URL(this.baseUrl + suffix);
|
|
90
|
+
if (params) {
|
|
91
|
+
for (const [key, value] of Object.entries(params)) {
|
|
92
|
+
if (value !== undefined && value !== null) {
|
|
93
|
+
url.searchParams.set(key, String(value));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return url.toString();
|
|
98
|
+
}
|
|
99
|
+
async request(method, path, spec) {
|
|
100
|
+
const url = this.buildUrl(path, spec.params);
|
|
101
|
+
const headers = {
|
|
102
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
103
|
+
Accept: "application/json",
|
|
104
|
+
};
|
|
105
|
+
let body;
|
|
106
|
+
if (spec.jsonBody !== undefined) {
|
|
107
|
+
headers["Content-Type"] = "application/json";
|
|
108
|
+
body = JSON.stringify(spec.jsonBody);
|
|
109
|
+
}
|
|
110
|
+
else if (spec.formBody !== undefined) {
|
|
111
|
+
// Let fetch set multipart Content-Type with boundary.
|
|
112
|
+
body = spec.formBody;
|
|
113
|
+
}
|
|
114
|
+
let attempt = 0;
|
|
115
|
+
while (true) {
|
|
116
|
+
const response = await this.fetchWithTimeout(this.fetchImpl, url, {
|
|
117
|
+
method,
|
|
118
|
+
headers,
|
|
119
|
+
body,
|
|
120
|
+
});
|
|
121
|
+
if (response.status === 429 &&
|
|
122
|
+
spec.retryOn429 &&
|
|
123
|
+
attempt < this.maxRetries) {
|
|
124
|
+
attempt += 1;
|
|
125
|
+
await sleep(this.retryAfterMs(response, attempt));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
return this.handle(response, url);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async fetchWithTimeout(impl, url, init) {
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
134
|
+
try {
|
|
135
|
+
return await impl(url, { ...init, signal: controller.signal });
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
retryAfterMs(response, attempt) {
|
|
142
|
+
const raw = response.headers.get("Retry-After");
|
|
143
|
+
if (raw) {
|
|
144
|
+
const seconds = Number(raw);
|
|
145
|
+
if (!Number.isNaN(seconds)) {
|
|
146
|
+
return seconds * 1000;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return 2 ** attempt * 1000;
|
|
150
|
+
}
|
|
151
|
+
async handle(response, url) {
|
|
152
|
+
const text = await response.text();
|
|
153
|
+
if (response.ok) {
|
|
154
|
+
if (!text) {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(text);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return { raw: text };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
let message = response.statusText || "request failed";
|
|
165
|
+
if (text) {
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(text);
|
|
168
|
+
if (parsed && typeof parsed === "object") {
|
|
169
|
+
const obj = parsed;
|
|
170
|
+
message = obj.error || obj.message || message;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
message = text.slice(0, 300);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
throw new UltralyticsApiError(response.status, message, url);
|
|
178
|
+
}
|
|
179
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Configuration and environment handling for the Ultralytics MCP server. */
|
|
2
|
+
const DEFAULT_API_BASE = "https://platform.ultralytics.com/api";
|
|
3
|
+
const API_KEY_ENV = "ULTRALYTICS_API_KEY";
|
|
4
|
+
const API_BASE_ENV = "ULTRALYTICS_API_BASE";
|
|
5
|
+
/** Documented key format: 'ul_' followed by exactly 40 hex characters. */
|
|
6
|
+
const API_KEY_RE = /^ul_[0-9a-fA-F]{40}$/;
|
|
7
|
+
/** Raised when required configuration is missing or invalid. */
|
|
8
|
+
export class ConfigError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "ConfigError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Return the API base URL, without a trailing slash. */
|
|
15
|
+
export function getApiBase(env = process.env) {
|
|
16
|
+
return (env[API_BASE_ENV] ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
17
|
+
}
|
|
18
|
+
/** Return a validated Ultralytics API key from the environment. */
|
|
19
|
+
export function getApiKey(env = process.env) {
|
|
20
|
+
const key = (env[API_KEY_ENV] ?? "").trim();
|
|
21
|
+
if (!key) {
|
|
22
|
+
throw new ConfigError(`Missing ${API_KEY_ENV}. Generate a key at ` +
|
|
23
|
+
"https://platform.ultralytics.com (Settings > API Keys) and set it.");
|
|
24
|
+
}
|
|
25
|
+
if (!API_KEY_RE.test(key)) {
|
|
26
|
+
throw new ConfigError(`${API_KEY_ENV} looks malformed: expected 'ul_' followed by 40 hex characters.`);
|
|
27
|
+
}
|
|
28
|
+
return key;
|
|
29
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Normalized error type for non-success Ultralytics API responses. */
|
|
2
|
+
const STATUS_HINTS = {
|
|
3
|
+
400: "invalid request",
|
|
4
|
+
401: "authentication failed - check your ULTRALYTICS_API_KEY",
|
|
5
|
+
403: "insufficient permissions for this resource",
|
|
6
|
+
404: "resource not found",
|
|
7
|
+
409: "conflict",
|
|
8
|
+
429: "rate limit exceeded",
|
|
9
|
+
500: "server error",
|
|
10
|
+
};
|
|
11
|
+
/** A non-success response from the Ultralytics API. */
|
|
12
|
+
export class UltralyticsApiError extends Error {
|
|
13
|
+
statusCode;
|
|
14
|
+
apiMessage;
|
|
15
|
+
url;
|
|
16
|
+
constructor(statusCode, message, url) {
|
|
17
|
+
const hint = STATUS_HINTS[statusCode];
|
|
18
|
+
const suffix = hint ? ` (${hint})` : "";
|
|
19
|
+
super(`HTTP ${statusCode}${suffix}: ${message} [${url}]`);
|
|
20
|
+
this.name = "UltralyticsApiError";
|
|
21
|
+
this.statusCode = statusCode;
|
|
22
|
+
this.apiMessage = message;
|
|
23
|
+
this.url = url;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** Resolve friendly Platform references to opaque resource IDs.
|
|
2
|
+
*
|
|
3
|
+
* Mirrors the Python `resolve` module exactly, including the resource-aware
|
|
4
|
+
* `ul://` URI shapes and the hard rule that ambiguous/missing references fail
|
|
5
|
+
* loudly (never silently pick the first match).
|
|
6
|
+
*/
|
|
7
|
+
const ID_RE = /^[0-9a-fA-F]{24}$/;
|
|
8
|
+
const UL_PREFIX = "ul://";
|
|
9
|
+
/** Raised when a reference cannot resolve to exactly one resource. */
|
|
10
|
+
export class ResolutionError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ResolutionError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Return true when `ref` is a 24-hex Platform object id. */
|
|
17
|
+
export function looksLikeId(ref) {
|
|
18
|
+
return ID_RE.test(ref.trim());
|
|
19
|
+
}
|
|
20
|
+
/** Split a reference into `{ isUlUri, parts }`. */
|
|
21
|
+
export function parseRef(ref) {
|
|
22
|
+
const cleaned = ref.trim();
|
|
23
|
+
const isUlUri = cleaned.startsWith(UL_PREFIX);
|
|
24
|
+
const body = isUlUri ? cleaned.slice(UL_PREFIX.length) : cleaned;
|
|
25
|
+
return { isUlUri, parts: body.split("/").filter((part) => part.length > 0) };
|
|
26
|
+
}
|
|
27
|
+
function simpleUsernameSlug(parts, kind, ref) {
|
|
28
|
+
if (parts.length === 1) {
|
|
29
|
+
return [null, parts[0]];
|
|
30
|
+
}
|
|
31
|
+
if (parts.length === 2) {
|
|
32
|
+
return [parts[0], parts[1]];
|
|
33
|
+
}
|
|
34
|
+
throw new ResolutionError(`Cannot parse ${kind} reference '${ref}'. Use 'slug', 'username/slug', ` +
|
|
35
|
+
"a ul:// URI, or a 24-character id.");
|
|
36
|
+
}
|
|
37
|
+
function listField(data, field) {
|
|
38
|
+
if (data && typeof data === "object") {
|
|
39
|
+
const value = data[field];
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
function select(matches, kind, ref) {
|
|
47
|
+
if (matches.length === 0) {
|
|
48
|
+
throw new ResolutionError(`No ${kind} found matching '${ref}'. Check the slug/username, or pass the ` +
|
|
49
|
+
`24-character ${kind} id directly.`);
|
|
50
|
+
}
|
|
51
|
+
if (matches.length > 1) {
|
|
52
|
+
const options = matches
|
|
53
|
+
.slice(0, 8)
|
|
54
|
+
.map((item) => `${item.username ?? "?"}/${item.slug ?? "?"} (id=${item._id})`)
|
|
55
|
+
.join(", ");
|
|
56
|
+
throw new ResolutionError(`Ambiguous ${kind} reference '${ref}' matched ${matches.length} resources: ` +
|
|
57
|
+
`${options}. Pass the explicit id or a fully qualified reference.`);
|
|
58
|
+
}
|
|
59
|
+
return matches[0];
|
|
60
|
+
}
|
|
61
|
+
/** Resolve a project id, slug, username/slug, or project ul:// URI. */
|
|
62
|
+
export async function resolveProject(client, ref) {
|
|
63
|
+
if (looksLikeId(ref)) {
|
|
64
|
+
return ref;
|
|
65
|
+
}
|
|
66
|
+
const { isUlUri, parts } = parseRef(ref);
|
|
67
|
+
let username;
|
|
68
|
+
let slug;
|
|
69
|
+
if (isUlUri) {
|
|
70
|
+
if (parts.length === 3 && parts[1] === "datasets") {
|
|
71
|
+
throw new ResolutionError(`'${ref}' is a dataset URI, not a project.`);
|
|
72
|
+
}
|
|
73
|
+
if (parts.length === 3) {
|
|
74
|
+
throw new ResolutionError(`'${ref}' is a model URI; use 'ul://${parts[0]}/${parts[1]}' for the project.`);
|
|
75
|
+
}
|
|
76
|
+
if (parts.length !== 2) {
|
|
77
|
+
throw new ResolutionError(`Unsupported project ul:// URI '${ref}'. Expected ul://username/project.`);
|
|
78
|
+
}
|
|
79
|
+
[username, slug] = [parts[0], parts[1]];
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
[username, slug] = simpleUsernameSlug(parts, "project", ref);
|
|
83
|
+
}
|
|
84
|
+
const data = await client.get("/projects", username ? { username } : undefined);
|
|
85
|
+
const matches = listField(data, "projects").filter((project) => project.slug === slug &&
|
|
86
|
+
(username === null || project.username === username));
|
|
87
|
+
return select(matches, "project", ref)._id;
|
|
88
|
+
}
|
|
89
|
+
/** Resolve a dataset id, slug, username/slug, or dataset ul:// URI. */
|
|
90
|
+
export async function resolveDataset(client, ref) {
|
|
91
|
+
if (looksLikeId(ref)) {
|
|
92
|
+
return ref;
|
|
93
|
+
}
|
|
94
|
+
const { isUlUri, parts } = parseRef(ref);
|
|
95
|
+
let username;
|
|
96
|
+
let slug;
|
|
97
|
+
if (isUlUri) {
|
|
98
|
+
if (!(parts.length === 3 && parts[1] === "datasets")) {
|
|
99
|
+
throw new ResolutionError(`Unsupported dataset ul:// URI '${ref}'. Expected ul://username/datasets/slug.`);
|
|
100
|
+
}
|
|
101
|
+
[username, slug] = [parts[0], parts[2]];
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
[username, slug] = simpleUsernameSlug(parts, "dataset", ref);
|
|
105
|
+
}
|
|
106
|
+
const params = { slug };
|
|
107
|
+
if (username) {
|
|
108
|
+
params.username = username;
|
|
109
|
+
}
|
|
110
|
+
const data = await client.get("/datasets", params);
|
|
111
|
+
const matches = listField(data, "datasets").filter((dataset) => dataset.slug === slug &&
|
|
112
|
+
(username === null || dataset.username === username));
|
|
113
|
+
return select(matches, "dataset", ref)._id;
|
|
114
|
+
}
|
|
115
|
+
/** Resolve a model id, slug plus project, or model ul:// URI. */
|
|
116
|
+
export async function resolveModel(client, ref, projectRef) {
|
|
117
|
+
if (looksLikeId(ref)) {
|
|
118
|
+
return ref;
|
|
119
|
+
}
|
|
120
|
+
const { isUlUri, parts } = parseRef(ref);
|
|
121
|
+
let slug;
|
|
122
|
+
let resolvedProjectRef = projectRef;
|
|
123
|
+
if (isUlUri) {
|
|
124
|
+
if (!(parts.length === 3 && parts[1] !== "datasets")) {
|
|
125
|
+
throw new ResolutionError(`Unsupported model ul:// URI '${ref}'. Expected ul://username/project/model.`);
|
|
126
|
+
}
|
|
127
|
+
const [username, projectSlug, modelSlug] = parts;
|
|
128
|
+
slug = modelSlug;
|
|
129
|
+
resolvedProjectRef = `${username}/${projectSlug}`;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
if (resolvedProjectRef === undefined) {
|
|
133
|
+
throw new ResolutionError(`Model reference '${ref}' is a slug; a project id/slug is required to resolve it.`);
|
|
134
|
+
}
|
|
135
|
+
[, slug] = simpleUsernameSlug(parts, "model", ref);
|
|
136
|
+
}
|
|
137
|
+
const projectId = await resolveProject(client, resolvedProjectRef);
|
|
138
|
+
const data = await client.get("/models", { projectId });
|
|
139
|
+
const matches = listField(data, "models").filter((model) => model.slug === slug);
|
|
140
|
+
return select(matches, "model", ref)._id;
|
|
141
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { UltralyticsClient } from "./client.js";
|
|
3
|
+
import { registerTools } from "./tools/index.js";
|
|
4
|
+
/** Create the MCP server with all tools registered.
|
|
5
|
+
*
|
|
6
|
+
* The client is created lazily on first tool invocation (so it reads the API key
|
|
7
|
+
* only when a tool actually runs, not at registration/listing time). A custom
|
|
8
|
+
* `clientFactory` can be injected for tests.
|
|
9
|
+
*/
|
|
10
|
+
export function createServer(clientFactory = () => new UltralyticsClient()) {
|
|
11
|
+
const server = new McpServer({ name: "ultralytics", version: "0.2.0" });
|
|
12
|
+
let client;
|
|
13
|
+
const getClient = () => {
|
|
14
|
+
if (!client) {
|
|
15
|
+
client = clientFactory();
|
|
16
|
+
}
|
|
17
|
+
return client;
|
|
18
|
+
};
|
|
19
|
+
registerTools(server, getClient);
|
|
20
|
+
return server;
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Read-only dataset tools. */
|
|
2
|
+
import { resolveDataset } from "../resolve.js";
|
|
3
|
+
import { asRecord, listField, pyCount, pyField } from "./shared.js";
|
|
4
|
+
/** List datasets in the workspace, optionally filtered by username. */
|
|
5
|
+
export async function datasetsList(client, username) {
|
|
6
|
+
const data = await client.get("/datasets", username ? { username } : undefined);
|
|
7
|
+
const items = listField(data, "datasets").map((dataset) => ({
|
|
8
|
+
id: dataset._id ?? null,
|
|
9
|
+
name: dataset.name ?? null,
|
|
10
|
+
slug: dataset.slug ?? null,
|
|
11
|
+
task: dataset.task ?? null,
|
|
12
|
+
imageCount: dataset.imageCount ?? null,
|
|
13
|
+
classCount: dataset.classCount ?? null,
|
|
14
|
+
visibility: dataset.visibility ?? null,
|
|
15
|
+
}));
|
|
16
|
+
return { summary: `${items.length} dataset(s).`, data: items };
|
|
17
|
+
}
|
|
18
|
+
/** Get one dataset by id, slug, username/slug, or dataset ul:// URI. */
|
|
19
|
+
export async function datasetsGet(client, dataset) {
|
|
20
|
+
const datasetId = await resolveDataset(client, dataset);
|
|
21
|
+
const data = await client.get(`/datasets/${datasetId}`);
|
|
22
|
+
const record = asRecord(data);
|
|
23
|
+
const item = "dataset" in record ? record.dataset : data;
|
|
24
|
+
const fields = asRecord(item);
|
|
25
|
+
return {
|
|
26
|
+
summary: `Dataset '${pyField(fields.name)}' [${pyField(fields.task)}], ` +
|
|
27
|
+
`${pyCount(fields, "imageCount")} images, ${pyCount(fields, "classCount")} classes.`,
|
|
28
|
+
data: item,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Model weight download tool. Writes to an explicit local path; the signed-URL
|
|
2
|
+
* fetch never forwards API credentials (handled by client.downloadBytes).
|
|
3
|
+
*/
|
|
4
|
+
import { stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { resolveModel } from "../resolve.js";
|
|
8
|
+
import { asRecord } from "./shared.js";
|
|
9
|
+
function fileName(info) {
|
|
10
|
+
const value = info.name ?? info.filename ?? info.fileName;
|
|
11
|
+
return value ? String(value) : null;
|
|
12
|
+
}
|
|
13
|
+
function fileUrl(info) {
|
|
14
|
+
const value = info.url ?? info.downloadUrl ?? info.download_url ?? info.signedUrl;
|
|
15
|
+
return value ? String(value) : null;
|
|
16
|
+
}
|
|
17
|
+
function modelFiles(data) {
|
|
18
|
+
const record = asRecord(data);
|
|
19
|
+
const files = record.files ?? record.modelFiles ?? record.models;
|
|
20
|
+
if (!Array.isArray(files)) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
return files.filter((item) => item && typeof item === "object");
|
|
24
|
+
}
|
|
25
|
+
function selectModelFile(files, filename) {
|
|
26
|
+
if (files.length === 0) {
|
|
27
|
+
throw new Error("No downloadable model files returned by the API.");
|
|
28
|
+
}
|
|
29
|
+
if (filename) {
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
if (fileName(file) === filename) {
|
|
32
|
+
return file;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`No model file named '${filename}' was returned by the API.`);
|
|
36
|
+
}
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
if (fileName(file) === "best.pt") {
|
|
39
|
+
return file;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return files[0];
|
|
43
|
+
}
|
|
44
|
+
function expandHome(input) {
|
|
45
|
+
if (input === "~") {
|
|
46
|
+
return homedir();
|
|
47
|
+
}
|
|
48
|
+
if (input.startsWith("~/")) {
|
|
49
|
+
return join(homedir(), input.slice(2));
|
|
50
|
+
}
|
|
51
|
+
return input;
|
|
52
|
+
}
|
|
53
|
+
async function statSafe(target) {
|
|
54
|
+
try {
|
|
55
|
+
const info = await stat(target);
|
|
56
|
+
return { exists: true, isDir: info.isDirectory() };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { exists: false, isDir: false };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function downloadTarget(outputPath, overwrite) {
|
|
63
|
+
if (!outputPath?.trim()) {
|
|
64
|
+
throw new Error("`output_path` is required.");
|
|
65
|
+
}
|
|
66
|
+
const target = resolve(expandHome(outputPath));
|
|
67
|
+
const parent = dirname(target);
|
|
68
|
+
const parentInfo = await statSafe(parent);
|
|
69
|
+
if (!parentInfo.exists) {
|
|
70
|
+
throw new Error(`Output directory does not exist: ${parent}`);
|
|
71
|
+
}
|
|
72
|
+
if (!parentInfo.isDir) {
|
|
73
|
+
throw new Error(`Output parent is not a directory: ${parent}`);
|
|
74
|
+
}
|
|
75
|
+
const targetInfo = await statSafe(target);
|
|
76
|
+
if (targetInfo.exists && targetInfo.isDir) {
|
|
77
|
+
throw new Error(`Output path is a directory: ${target}`);
|
|
78
|
+
}
|
|
79
|
+
if (targetInfo.exists && !overwrite) {
|
|
80
|
+
throw new Error(`Output path exists: ${target}. Pass overwrite=true to replace it.`);
|
|
81
|
+
}
|
|
82
|
+
return target;
|
|
83
|
+
}
|
|
84
|
+
/** Download one model weight file to an explicit local path. */
|
|
85
|
+
export async function modelDownload(client, model, options) {
|
|
86
|
+
const { outputPath, project, filename, overwrite = false } = options;
|
|
87
|
+
const target = await downloadTarget(outputPath, overwrite);
|
|
88
|
+
const modelId = await resolveModel(client, model, project);
|
|
89
|
+
const data = await client.get(`/models/${modelId}/files`);
|
|
90
|
+
const fileInfo = selectModelFile(modelFiles(data), filename);
|
|
91
|
+
const selectedName = fileName(fileInfo) ?? filename ?? "model file";
|
|
92
|
+
const signedUrl = fileUrl(fileInfo);
|
|
93
|
+
if (signedUrl === null) {
|
|
94
|
+
throw new Error(`Model file '${selectedName}' did not include a download URL.`);
|
|
95
|
+
}
|
|
96
|
+
const content = await client.downloadBytes(signedUrl);
|
|
97
|
+
await writeFile(target, content);
|
|
98
|
+
return {
|
|
99
|
+
summary: `Downloaded ${selectedName} to ${target} (${content.length} bytes).`,
|
|
100
|
+
data: {
|
|
101
|
+
modelId,
|
|
102
|
+
filename: selectedName,
|
|
103
|
+
path: target,
|
|
104
|
+
bytes: content.length,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Export tools. `export_create` is state-changing and guarded by confirm_cost. */
|
|
2
|
+
import { looksLikeId, resolveModel } from "../resolve.js";
|
|
3
|
+
import { asRecord, listField, pyField } from "./shared.js";
|
|
4
|
+
const EXPORT_FORMATS = new Set([
|
|
5
|
+
"onnx",
|
|
6
|
+
"torchscript",
|
|
7
|
+
"openvino",
|
|
8
|
+
"engine",
|
|
9
|
+
"coreml",
|
|
10
|
+
"tflite",
|
|
11
|
+
"saved_model",
|
|
12
|
+
"pb",
|
|
13
|
+
"paddle",
|
|
14
|
+
"ncnn",
|
|
15
|
+
"edgetpu",
|
|
16
|
+
"tfjs",
|
|
17
|
+
"mnn",
|
|
18
|
+
"rknn",
|
|
19
|
+
"qnn",
|
|
20
|
+
"imx",
|
|
21
|
+
"axelera",
|
|
22
|
+
"executorch",
|
|
23
|
+
"deepx",
|
|
24
|
+
]);
|
|
25
|
+
/** List exports for a model. */
|
|
26
|
+
export async function exportsList(client, model, project) {
|
|
27
|
+
const modelId = await resolveModel(client, model, project);
|
|
28
|
+
const data = await client.get("/exports", { modelId });
|
|
29
|
+
const items = listField(data, "exports").map((entry) => ({
|
|
30
|
+
id: entry._id ?? null,
|
|
31
|
+
format: entry.format ?? null,
|
|
32
|
+
status: entry.status ?? null,
|
|
33
|
+
}));
|
|
34
|
+
return { summary: `${items.length} export(s) for model.`, data: items };
|
|
35
|
+
}
|
|
36
|
+
/** Get status for one export job. */
|
|
37
|
+
export async function exportStatus(client, exportId) {
|
|
38
|
+
if (!looksLikeId(exportId)) {
|
|
39
|
+
throw new Error("`export_id` must be a 24-character export id.");
|
|
40
|
+
}
|
|
41
|
+
const data = await client.get(`/exports/${exportId}`);
|
|
42
|
+
const record = asRecord(data);
|
|
43
|
+
const item = "export" in record ? record.export : data;
|
|
44
|
+
const fields = asRecord(item);
|
|
45
|
+
const idText = "_id" in fields ? pyField(fields._id) : exportId;
|
|
46
|
+
return {
|
|
47
|
+
summary: `Export ${idText} status=${pyField(fields.status)} format=${pyField(fields.format)}.`,
|
|
48
|
+
data: item,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Create a model export job. This is state-changing and may cost credits. */
|
|
52
|
+
export async function exportCreate(client, model, format, options = {}) {
|
|
53
|
+
const { project, gpuType, imgsz, half, dynamic, confirmCost = false, } = options;
|
|
54
|
+
if (!confirmCost) {
|
|
55
|
+
throw new Error("Set confirm_cost=true to create an export job.");
|
|
56
|
+
}
|
|
57
|
+
const exportFormat = format.trim().toLowerCase();
|
|
58
|
+
if (!EXPORT_FORMATS.has(exportFormat)) {
|
|
59
|
+
throw new Error(`Unsupported export format '${format}'.`);
|
|
60
|
+
}
|
|
61
|
+
if (exportFormat === "engine" && !gpuType) {
|
|
62
|
+
throw new Error("`gpu_type` is required for TensorRT engine exports.");
|
|
63
|
+
}
|
|
64
|
+
const modelId = await resolveModel(client, model, project);
|
|
65
|
+
const payload = { modelId, format: exportFormat };
|
|
66
|
+
if (gpuType) {
|
|
67
|
+
payload.gpuType = gpuType;
|
|
68
|
+
}
|
|
69
|
+
const args = {};
|
|
70
|
+
if (imgsz !== undefined) {
|
|
71
|
+
if (imgsz <= 0) {
|
|
72
|
+
throw new Error("`imgsz` must be greater than 0.");
|
|
73
|
+
}
|
|
74
|
+
args.imgsz = imgsz;
|
|
75
|
+
}
|
|
76
|
+
if (half !== undefined) {
|
|
77
|
+
args.half = half;
|
|
78
|
+
}
|
|
79
|
+
if (dynamic !== undefined) {
|
|
80
|
+
args.dynamic = dynamic;
|
|
81
|
+
}
|
|
82
|
+
if (Object.keys(args).length > 0) {
|
|
83
|
+
payload.args = args;
|
|
84
|
+
}
|
|
85
|
+
const data = await client.postJson("/exports", payload);
|
|
86
|
+
const record = asRecord(data);
|
|
87
|
+
const item = "export" in record ? record.export : data;
|
|
88
|
+
const fields = asRecord(item);
|
|
89
|
+
return {
|
|
90
|
+
summary: `Created export ${pyField(fields._id)} status=${pyField(fields.status)} format=${pyField(fields.format)}.`,
|
|
91
|
+
data: item,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Cloud GPU availability tool. */
|
|
2
|
+
import { asRecord } from "./shared.js";
|
|
3
|
+
/** Get current cloud GPU stock status by GPU type. */
|
|
4
|
+
export async function gpuAvailability(client) {
|
|
5
|
+
const data = await client.get("/training/gpu-availability");
|
|
6
|
+
const available = Object.entries(asRecord(data))
|
|
7
|
+
.filter(([, stock]) => stock === "High" || stock === "Medium")
|
|
8
|
+
.map(([name]) => name);
|
|
9
|
+
return {
|
|
10
|
+
summary: `${available.length} GPU type(s) with High/Medium stock.`,
|
|
11
|
+
data,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** Tool registration for the MCP server.
|
|
2
|
+
*
|
|
3
|
+
* Logic functions live in the sibling modules and are re-exported for tests and
|
|
4
|
+
* the parity fixture runner. `registerReadTools` wires them onto an `McpServer`
|
|
5
|
+
* with Zod input schemas. User-facing tool names stay snake_case for parity with
|
|
6
|
+
* the Python package.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { toMcpTextResult } from "../tool-result.js";
|
|
10
|
+
import { datasetsGet, datasetsList } from "./datasets.js";
|
|
11
|
+
import { modelDownload } from "./downloads.js";
|
|
12
|
+
import { exportCreate, exportStatus, exportsList } from "./exports.js";
|
|
13
|
+
import { gpuAvailability } from "./gpu.js";
|
|
14
|
+
import { modelsGet, modelsList } from "./models.js";
|
|
15
|
+
import { modelPredict } from "./predict.js";
|
|
16
|
+
import { projectsGet, projectsList } from "./projects.js";
|
|
17
|
+
import { trainingMonitor, trainingStart } from "./training.js";
|
|
18
|
+
export { datasetsGet, datasetsList } from "./datasets.js";
|
|
19
|
+
export { modelDownload } from "./downloads.js";
|
|
20
|
+
export { exportCreate, exportStatus, exportsList } from "./exports.js";
|
|
21
|
+
export { gpuAvailability } from "./gpu.js";
|
|
22
|
+
export { modelsGet, modelsList } from "./models.js";
|
|
23
|
+
export { modelPredict } from "./predict.js";
|
|
24
|
+
export { projectsGet, projectsList } from "./projects.js";
|
|
25
|
+
export { trainingMonitor, trainingStart } from "./training.js";
|
|
26
|
+
/** Names of the read-only tools registered by `registerReadTools`. */
|
|
27
|
+
export const READ_TOOL_NAMES = [
|
|
28
|
+
"projects_list",
|
|
29
|
+
"projects_get",
|
|
30
|
+
"datasets_list",
|
|
31
|
+
"datasets_get",
|
|
32
|
+
"models_list",
|
|
33
|
+
"models_get",
|
|
34
|
+
"gpu_availability",
|
|
35
|
+
];
|
|
36
|
+
/** Register the read-only tools onto a server, using a lazy client provider. */
|
|
37
|
+
export function registerReadTools(server, getClient) {
|
|
38
|
+
server.registerTool("projects_list", {
|
|
39
|
+
description: "List computer-vision projects in your Ultralytics workspace.",
|
|
40
|
+
inputSchema: { username: z.string().optional() },
|
|
41
|
+
}, async ({ username }) => toMcpTextResult(await projectsList(getClient(), username)));
|
|
42
|
+
server.registerTool("projects_get", {
|
|
43
|
+
description: "Get details for one project by id, slug, username/slug, or project ul:// URI.",
|
|
44
|
+
inputSchema: { project: z.string() },
|
|
45
|
+
}, async ({ project }) => toMcpTextResult(await projectsGet(getClient(), project)));
|
|
46
|
+
server.registerTool("datasets_list", {
|
|
47
|
+
description: "List datasets in your Ultralytics workspace.",
|
|
48
|
+
inputSchema: { username: z.string().optional() },
|
|
49
|
+
}, async ({ username }) => toMcpTextResult(await datasetsList(getClient(), username)));
|
|
50
|
+
server.registerTool("datasets_get", {
|
|
51
|
+
description: "Get details for one dataset by id, slug, username/slug, or dataset ul:// URI.",
|
|
52
|
+
inputSchema: { dataset: z.string() },
|
|
53
|
+
}, async ({ dataset }) => toMcpTextResult(await datasetsGet(getClient(), dataset)));
|
|
54
|
+
server.registerTool("models_list", {
|
|
55
|
+
description: "List models in a project by project id, slug, username/slug, or project ul:// URI.",
|
|
56
|
+
inputSchema: { project: z.string() },
|
|
57
|
+
}, async ({ project }) => toMcpTextResult(await modelsList(getClient(), project)));
|
|
58
|
+
server.registerTool("models_get", {
|
|
59
|
+
description: "Get one model by id, or by slug plus project.",
|
|
60
|
+
inputSchema: { model: z.string(), project: z.string().optional() },
|
|
61
|
+
}, async ({ model, project }) => toMcpTextResult(await modelsGet(getClient(), model, project)));
|
|
62
|
+
server.registerTool("gpu_availability", {
|
|
63
|
+
description: "Get current cloud-GPU stock status by GPU type.",
|
|
64
|
+
inputSchema: {},
|
|
65
|
+
}, async () => toMcpTextResult(await gpuAvailability(getClient())));
|
|
66
|
+
}
|
|
67
|
+
/** Names of the monitor/predict/download tools. */
|
|
68
|
+
export const ACTION_TOOL_NAMES = [
|
|
69
|
+
"training_monitor",
|
|
70
|
+
"model_predict",
|
|
71
|
+
"model_download",
|
|
72
|
+
];
|
|
73
|
+
/** Register training monitor, predict, and download tools. */
|
|
74
|
+
export function registerActionTools(server, getClient) {
|
|
75
|
+
server.registerTool("training_monitor", {
|
|
76
|
+
description: "Report a model's training status and progress (works for private and public projects).",
|
|
77
|
+
inputSchema: { model: z.string(), project: z.string().optional() },
|
|
78
|
+
}, async ({ model, project }) => toMcpTextResult(await trainingMonitor(getClient(), model, project)));
|
|
79
|
+
server.registerTool("model_predict", {
|
|
80
|
+
description: "Run inference with a trained model on an image URL or base64 source (no local file paths).",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
model: z.string(),
|
|
83
|
+
source: z.string(),
|
|
84
|
+
project: z.string().optional(),
|
|
85
|
+
conf: z.number().optional(),
|
|
86
|
+
iou: z.number().optional(),
|
|
87
|
+
imgsz: z.number().optional(),
|
|
88
|
+
},
|
|
89
|
+
}, async ({ model, source, project, conf, iou, imgsz }) => toMcpTextResult(await modelPredict(getClient(), model, {
|
|
90
|
+
source,
|
|
91
|
+
project,
|
|
92
|
+
conf,
|
|
93
|
+
iou,
|
|
94
|
+
imgsz,
|
|
95
|
+
})));
|
|
96
|
+
server.registerTool("model_download", {
|
|
97
|
+
description: "Download one trained model weight file to an explicit local path.",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
model: z.string(),
|
|
100
|
+
output_path: z.string(),
|
|
101
|
+
project: z.string().optional(),
|
|
102
|
+
filename: z.string().optional(),
|
|
103
|
+
overwrite: z.boolean().optional(),
|
|
104
|
+
},
|
|
105
|
+
}, async ({ model, output_path, project, filename, overwrite }) => toMcpTextResult(await modelDownload(getClient(), model, {
|
|
106
|
+
outputPath: output_path,
|
|
107
|
+
project,
|
|
108
|
+
filename,
|
|
109
|
+
overwrite,
|
|
110
|
+
})));
|
|
111
|
+
}
|
|
112
|
+
/** Names of the guarded write tools (exports + training start). */
|
|
113
|
+
export const WRITE_TOOL_NAMES = [
|
|
114
|
+
"exports_list",
|
|
115
|
+
"export_status",
|
|
116
|
+
"export_create",
|
|
117
|
+
"training_start",
|
|
118
|
+
];
|
|
119
|
+
/** Register export and training-start tools. The cost-incurring ones are guarded. */
|
|
120
|
+
export function registerWriteTools(server, getClient) {
|
|
121
|
+
server.registerTool("exports_list", {
|
|
122
|
+
description: "List export jobs for a model.",
|
|
123
|
+
inputSchema: { model: z.string(), project: z.string().optional() },
|
|
124
|
+
}, async ({ model, project }) => toMcpTextResult(await exportsList(getClient(), model, project)));
|
|
125
|
+
server.registerTool("export_status", {
|
|
126
|
+
description: "Get status for one export job by 24-character export id.",
|
|
127
|
+
inputSchema: { export_id: z.string() },
|
|
128
|
+
}, async ({ export_id }) => toMcpTextResult(await exportStatus(getClient(), export_id)));
|
|
129
|
+
server.registerTool("export_create", {
|
|
130
|
+
description: "Create a model export job (state-changing, may cost credits). Requires confirm_cost=true.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
model: z.string(),
|
|
133
|
+
format: z.string(),
|
|
134
|
+
project: z.string().optional(),
|
|
135
|
+
gpu_type: z.string().optional(),
|
|
136
|
+
imgsz: z.number().optional(),
|
|
137
|
+
half: z.boolean().optional(),
|
|
138
|
+
dynamic: z.boolean().optional(),
|
|
139
|
+
confirm_cost: z.boolean().optional(),
|
|
140
|
+
},
|
|
141
|
+
}, async ({ model, format, project, gpu_type, imgsz, half, dynamic, confirm_cost, }) => toMcpTextResult(await exportCreate(getClient(), model, format, {
|
|
142
|
+
project,
|
|
143
|
+
gpuType: gpu_type,
|
|
144
|
+
imgsz,
|
|
145
|
+
half,
|
|
146
|
+
dynamic,
|
|
147
|
+
confirmCost: confirm_cost,
|
|
148
|
+
})));
|
|
149
|
+
server.registerTool("training_start", {
|
|
150
|
+
description: "Start a cloud training job (state-changing, may cost credits). Requires confirm_cost=true.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
model: z.string(),
|
|
153
|
+
project: z.string(),
|
|
154
|
+
dataset: z.string(),
|
|
155
|
+
gpu_type: z.string(),
|
|
156
|
+
epochs: z.number().optional(),
|
|
157
|
+
imgsz: z.number().optional(),
|
|
158
|
+
batch: z.number().optional(),
|
|
159
|
+
name: z.string().optional(),
|
|
160
|
+
confirm_cost: z.boolean().optional(),
|
|
161
|
+
},
|
|
162
|
+
}, async ({ model, project, dataset, gpu_type, epochs, imgsz, batch, name, confirm_cost, }) => toMcpTextResult(await trainingStart(getClient(), {
|
|
163
|
+
model,
|
|
164
|
+
project,
|
|
165
|
+
dataset,
|
|
166
|
+
gpuType: gpu_type,
|
|
167
|
+
epochs,
|
|
168
|
+
imgsz,
|
|
169
|
+
batch,
|
|
170
|
+
name,
|
|
171
|
+
confirmCost: confirm_cost,
|
|
172
|
+
})));
|
|
173
|
+
}
|
|
174
|
+
/** All tool names registered so far. */
|
|
175
|
+
export const TOOL_NAMES = [
|
|
176
|
+
...READ_TOOL_NAMES,
|
|
177
|
+
...ACTION_TOOL_NAMES,
|
|
178
|
+
...WRITE_TOOL_NAMES,
|
|
179
|
+
];
|
|
180
|
+
/** Register all available tools onto a server. */
|
|
181
|
+
export function registerTools(server, getClient) {
|
|
182
|
+
registerReadTools(server, getClient);
|
|
183
|
+
registerActionTools(server, getClient);
|
|
184
|
+
registerWriteTools(server, getClient);
|
|
185
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Read-only model tools. */
|
|
2
|
+
import { resolveModel, resolveProject } from "../resolve.js";
|
|
3
|
+
import { asRecord, listField, pyField } from "./shared.js";
|
|
4
|
+
/** List models in a project. */
|
|
5
|
+
export async function modelsList(client, project) {
|
|
6
|
+
const projectId = await resolveProject(client, project);
|
|
7
|
+
const data = await client.get("/models", { projectId });
|
|
8
|
+
const items = listField(data, "models").map((model) => ({
|
|
9
|
+
id: model._id ?? null,
|
|
10
|
+
name: model.name ?? null,
|
|
11
|
+
slug: model.slug ?? null,
|
|
12
|
+
status: model.status ?? null,
|
|
13
|
+
task: model.task ?? null,
|
|
14
|
+
epochs: model.epochs ?? null,
|
|
15
|
+
bestFitness: model.bestFitness ?? null,
|
|
16
|
+
}));
|
|
17
|
+
return { summary: `${items.length} model(s) in project.`, data: items };
|
|
18
|
+
}
|
|
19
|
+
/** Get one model by id, or by slug within a project. */
|
|
20
|
+
export async function modelsGet(client, model, project) {
|
|
21
|
+
const modelId = await resolveModel(client, model, project);
|
|
22
|
+
const data = await client.get(`/models/${modelId}`);
|
|
23
|
+
const record = asRecord(data);
|
|
24
|
+
const item = "model" in record ? record.model : data;
|
|
25
|
+
const fields = asRecord(item);
|
|
26
|
+
const info = asRecord(fields.modelInfo);
|
|
27
|
+
return {
|
|
28
|
+
summary: `Model '${pyField(fields.name)}' [${pyField(fields.task)}] status=${pyField(fields.status)}, ` +
|
|
29
|
+
`epochs=${pyField(fields.epochs)}, params=${pyField(info.parameters)}.`,
|
|
30
|
+
data: item,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Inference tool. Accepts only an image URL or base64 source (no local paths). */
|
|
2
|
+
import { resolveModel } from "../resolve.js";
|
|
3
|
+
import { asRecord, listField } from "./shared.js";
|
|
4
|
+
/** Run inference from an image URL or base64 source. Local paths are not accepted. */
|
|
5
|
+
export async function modelPredict(client, model, options) {
|
|
6
|
+
const { source, project, conf = 0.25, iou = 0.7, imgsz = 640 } = options;
|
|
7
|
+
if (!source?.trim()) {
|
|
8
|
+
throw new Error("`source` is required: an image URL or base64-encoded image.");
|
|
9
|
+
}
|
|
10
|
+
const modelId = await resolveModel(client, model, project);
|
|
11
|
+
const result = await client.postMultipart(`/models/${modelId}/predict`, {
|
|
12
|
+
data: {
|
|
13
|
+
source,
|
|
14
|
+
conf: String(conf),
|
|
15
|
+
iou: String(iou),
|
|
16
|
+
imgsz: String(imgsz),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const images = listField(result, "images");
|
|
20
|
+
const detectionCount = images.reduce((total, image) => total +
|
|
21
|
+
(Array.isArray(asRecord(image).results)
|
|
22
|
+
? asRecord(image).results.length
|
|
23
|
+
: 0), 0);
|
|
24
|
+
return {
|
|
25
|
+
summary: `${images.length} image(s), ${detectionCount} detection(s).`,
|
|
26
|
+
data: result,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Read-only project tools. */
|
|
2
|
+
import { resolveProject } from "../resolve.js";
|
|
3
|
+
import { asRecord, listField, pyCount, pyField } from "./shared.js";
|
|
4
|
+
/** List projects in the workspace, optionally filtered by username. */
|
|
5
|
+
export async function projectsList(client, username) {
|
|
6
|
+
const data = await client.get("/projects", username ? { username } : undefined);
|
|
7
|
+
const items = listField(data, "projects").map((project) => ({
|
|
8
|
+
id: project._id ?? null,
|
|
9
|
+
name: project.name ?? null,
|
|
10
|
+
slug: project.slug ?? null,
|
|
11
|
+
username: project.username ?? null,
|
|
12
|
+
visibility: project.visibility ?? null,
|
|
13
|
+
modelCount: project.modelCount ?? null,
|
|
14
|
+
}));
|
|
15
|
+
return { summary: `${items.length} project(s).`, data: items };
|
|
16
|
+
}
|
|
17
|
+
/** Get one project by id, slug, username/slug, or project ul:// URI. */
|
|
18
|
+
export async function projectsGet(client, project) {
|
|
19
|
+
const projectId = await resolveProject(client, project);
|
|
20
|
+
const data = await client.get(`/projects/${projectId}`);
|
|
21
|
+
const record = asRecord(data);
|
|
22
|
+
const item = "project" in record ? record.project : data;
|
|
23
|
+
const fields = asRecord(item);
|
|
24
|
+
return {
|
|
25
|
+
summary: `Project '${pyField(fields.name)}' (${pyField(fields.visibility)}), ` +
|
|
26
|
+
`${pyCount(fields, "modelCount")} model(s).`,
|
|
27
|
+
data: item,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Shared helpers for tool logic. */
|
|
2
|
+
/** Coerce unknown JSON into a record for safe field access. */
|
|
3
|
+
export function asRecord(data) {
|
|
4
|
+
return data && typeof data === "object"
|
|
5
|
+
? data
|
|
6
|
+
: {};
|
|
7
|
+
}
|
|
8
|
+
/** Return `data[field]` as an array of records, or []. */
|
|
9
|
+
export function listField(data, field) {
|
|
10
|
+
const value = asRecord(data)[field];
|
|
11
|
+
return Array.isArray(value) ? value : [];
|
|
12
|
+
}
|
|
13
|
+
/** Render a summary field like Python's `dict.get(key)`: missing -> "None". */
|
|
14
|
+
export function pyField(value) {
|
|
15
|
+
return value === undefined || value === null ? "None" : String(value);
|
|
16
|
+
}
|
|
17
|
+
/** Render a count like Python's `dict.get(key, "?")`: absent -> "?", present-null -> "None". */
|
|
18
|
+
export function pyCount(fields, key) {
|
|
19
|
+
if (!(key in fields)) {
|
|
20
|
+
return "?";
|
|
21
|
+
}
|
|
22
|
+
const value = fields[key];
|
|
23
|
+
return value === undefined || value === null ? "None" : String(value);
|
|
24
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Training monitor tool (private-safe: derives progress from model trainResults). */
|
|
2
|
+
import { UltralyticsApiError } from "../errors.js";
|
|
3
|
+
import { resolveDataset, resolveModel, resolveProject } from "../resolve.js";
|
|
4
|
+
import { asRecord, pyField } from "./shared.js";
|
|
5
|
+
const KEY_METRICS = [
|
|
6
|
+
"metrics/mAP50(B)",
|
|
7
|
+
"metrics/mAP50-95(B)",
|
|
8
|
+
"metrics/mAP50(M)",
|
|
9
|
+
"metrics/mAP50-95(M)",
|
|
10
|
+
];
|
|
11
|
+
/** Format a percentage like Python's `str(round(x, 1))` (whole numbers keep `.0`). */
|
|
12
|
+
function formatPercent(value) {
|
|
13
|
+
return Number.isInteger(value) ? value.toFixed(1) : String(value);
|
|
14
|
+
}
|
|
15
|
+
/** Report model training status using private-safe model trainResults. */
|
|
16
|
+
export async function trainingMonitor(client, model, project) {
|
|
17
|
+
const modelId = await resolveModel(client, model, project);
|
|
18
|
+
const data = await client.get(`/models/${modelId}`);
|
|
19
|
+
const record = asRecord(data);
|
|
20
|
+
const item = asRecord("model" in record ? record.model : data);
|
|
21
|
+
const status = item.status ?? null;
|
|
22
|
+
const totalEpochs = item.epochs;
|
|
23
|
+
const hasTotal = typeof totalEpochs === "number" && totalEpochs > 0;
|
|
24
|
+
const trainResults = Array.isArray(item.trainResults)
|
|
25
|
+
? item.trainResults
|
|
26
|
+
: [];
|
|
27
|
+
const epochsDone = trainResults.length;
|
|
28
|
+
const latestMetrics = epochsDone > 0
|
|
29
|
+
? asRecord(asRecord(trainResults[epochsDone - 1]).metrics)
|
|
30
|
+
: {};
|
|
31
|
+
const keyMetrics = {};
|
|
32
|
+
for (const key of KEY_METRICS) {
|
|
33
|
+
if (key in latestMetrics) {
|
|
34
|
+
keyMetrics[key] = latestMetrics[key];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let progressPct = null;
|
|
38
|
+
let progressText = null;
|
|
39
|
+
let etaMs = null;
|
|
40
|
+
let source = "model.trainResults";
|
|
41
|
+
try {
|
|
42
|
+
const trainingData = await client.get(`/models/${modelId}/training`);
|
|
43
|
+
const job = asRecord(asRecord(trainingData).job);
|
|
44
|
+
const progress = asRecord(job.progress);
|
|
45
|
+
const timing = asRecord(job.timing);
|
|
46
|
+
progressPct = progress.percentage ?? null;
|
|
47
|
+
progressText = progressPct === null ? null : String(progressPct);
|
|
48
|
+
etaMs = timing.etaMs ?? null;
|
|
49
|
+
source = "models/{id}/training";
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (!(error instanceof UltralyticsApiError) ||
|
|
53
|
+
![401, 403, 404].includes(error.statusCode)) {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (hasTotal) {
|
|
57
|
+
progressPct =
|
|
58
|
+
Math.round(((100 * epochsDone) / totalEpochs) * 10) / 10;
|
|
59
|
+
progressText = formatPercent(progressPct);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const totalDisplay = hasTotal ? totalEpochs : "?";
|
|
63
|
+
const summary = `Training status=${status}; epoch ${epochsDone}/${totalDisplay}` +
|
|
64
|
+
(progressPct !== null ? `; ~${progressText}%` : "") +
|
|
65
|
+
(etaMs ? `; ETA ${Math.round(etaMs / 60000)}min` : "");
|
|
66
|
+
return {
|
|
67
|
+
summary,
|
|
68
|
+
data: {
|
|
69
|
+
modelId,
|
|
70
|
+
status,
|
|
71
|
+
epochsDone,
|
|
72
|
+
totalEpochs: hasTotal ? totalEpochs : null,
|
|
73
|
+
progressPercentage: progressPct,
|
|
74
|
+
etaMs,
|
|
75
|
+
bestEpoch: item.bestEpoch ?? null,
|
|
76
|
+
bestFitness: item.bestFitness ?? null,
|
|
77
|
+
latestMetrics: keyMetrics,
|
|
78
|
+
progressSource: source,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Start cloud training. This is state-changing and may cost credits. */
|
|
83
|
+
export async function trainingStart(client, options) {
|
|
84
|
+
const { model, project, dataset, gpuType, epochs, imgsz, batch, name, confirmCost = false, } = options;
|
|
85
|
+
if (!confirmCost) {
|
|
86
|
+
throw new Error("Set confirm_cost=true to start a cloud training job.");
|
|
87
|
+
}
|
|
88
|
+
if (!gpuType?.trim()) {
|
|
89
|
+
throw new Error("`gpu_type` is required.");
|
|
90
|
+
}
|
|
91
|
+
const modelId = await resolveModel(client, model, project);
|
|
92
|
+
const projectId = await resolveProject(client, project);
|
|
93
|
+
const datasetId = await resolveDataset(client, dataset);
|
|
94
|
+
const trainArgs = { data: datasetId };
|
|
95
|
+
if (epochs !== undefined) {
|
|
96
|
+
if (epochs <= 0) {
|
|
97
|
+
throw new Error("`epochs` must be greater than 0.");
|
|
98
|
+
}
|
|
99
|
+
trainArgs.epochs = epochs;
|
|
100
|
+
}
|
|
101
|
+
if (imgsz !== undefined) {
|
|
102
|
+
if (imgsz <= 0) {
|
|
103
|
+
throw new Error("`imgsz` must be greater than 0.");
|
|
104
|
+
}
|
|
105
|
+
trainArgs.imgsz = imgsz;
|
|
106
|
+
}
|
|
107
|
+
if (batch !== undefined) {
|
|
108
|
+
if (batch <= 0) {
|
|
109
|
+
throw new Error("`batch` must be greater than 0.");
|
|
110
|
+
}
|
|
111
|
+
trainArgs.batch = batch;
|
|
112
|
+
}
|
|
113
|
+
if (name) {
|
|
114
|
+
trainArgs.name = name;
|
|
115
|
+
}
|
|
116
|
+
const data = await client.postJson("/training/start", {
|
|
117
|
+
modelId,
|
|
118
|
+
projectId,
|
|
119
|
+
gpuType,
|
|
120
|
+
trainArgs,
|
|
121
|
+
});
|
|
122
|
+
const record = asRecord(data);
|
|
123
|
+
const item = "job" in record ? record.job : data;
|
|
124
|
+
const fields = asRecord(item);
|
|
125
|
+
return {
|
|
126
|
+
summary: `Started training job ${pyField(fields._id)} status=${pyField(fields.status)}.`,
|
|
127
|
+
data: item,
|
|
128
|
+
};
|
|
129
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ultralytics-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript MCP server for the Ultralytics Platform REST API.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ultralytics-mcp": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"package.json"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/amanharshx/ultralytics-mcp.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/amanharshx/ultralytics-mcp#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/amanharshx/ultralytics-mcp/issues"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.json",
|
|
30
|
+
"check": "biome check .",
|
|
31
|
+
"format": "biome check --write .",
|
|
32
|
+
"prepack": "npm run build",
|
|
33
|
+
"prepublishOnly": "npm run build",
|
|
34
|
+
"test": "vitest run"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"mcp",
|
|
38
|
+
"ultralytics",
|
|
39
|
+
"yolo",
|
|
40
|
+
"model-context-protocol"
|
|
41
|
+
],
|
|
42
|
+
"author": "Aman Harsh",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
46
|
+
"zod": "^4.4.3"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@biomejs/biome": "^2.5.1",
|
|
50
|
+
"@types/node": "^24.4.0",
|
|
51
|
+
"typescript": "^5.9.2",
|
|
52
|
+
"vitest": "^4.1.9"
|
|
53
|
+
}
|
|
54
|
+
}
|