@runpod/mcp-server 3.0.0 → 3.2.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/dist/tools.cjs CHANGED
@@ -38,11 +38,150 @@ module.exports = __toCommonJS(tools_exports);
38
38
  var import_node_fetch = __toESM(require("node-fetch"), 1);
39
39
  var import_node_crypto = require("crypto");
40
40
 
41
+ // src/_shared/rate-limit.ts
42
+ var GENERIC_RATE_LIMIT_HINT = "rate limited \u2014 back off before retrying, and pace bulk operations";
43
+ var MAX_PLAUSIBLE_WAIT_S = 7 * 86400;
44
+ function remainingRequests(raw) {
45
+ const value = raw?.trim();
46
+ return value && /^\d+$/.test(value) ? Number(value) : null;
47
+ }
48
+ function waitSeconds(raw) {
49
+ const value = raw?.trim();
50
+ if (!value || !/^\d+$/.test(value)) return null;
51
+ const seconds = Number(value);
52
+ return seconds <= MAX_PLAUSIBLE_WAIT_S ? seconds : null;
53
+ }
54
+ var WINDOW_NAME_RE = /^(?:"([\x20-\x21\x23-\x5b\x5d-\x7e]{1,32})"|([A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]{0,31}))$/;
55
+ function windowName(raw) {
56
+ const match = WINDOW_NAME_RE.exec(raw.trim());
57
+ return match ? match[1] ?? match[2] : null;
58
+ }
59
+ var QUOTED_VALUE_RE = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"$/;
60
+ var DISPLAY_VALUE_RE = /^%"(?:[\x20-\x21\x23-\x24\x26-\x5b\x5d-\x7e]|\\|%[0-9a-f]{2})*"$/;
61
+ function isWellFormedParam(param) {
62
+ const eq = param.indexOf("=");
63
+ const key = (eq === -1 ? param : param.slice(0, eq)).trim();
64
+ if (key.includes('"')) return false;
65
+ if (eq === -1) return true;
66
+ const value = param.slice(eq + 1).trim();
67
+ if (value.startsWith('"')) return QUOTED_VALUE_RE.test(value);
68
+ if (value.startsWith('%"')) return DISPLAY_VALUE_RE.test(value);
69
+ return !/[\s"]/.test(value);
70
+ }
71
+ function splitOutsideStrings(input, sep) {
72
+ const parts = [];
73
+ let current = "";
74
+ let inString = false;
75
+ let inDisplay = false;
76
+ let escaped = false;
77
+ let prev = "";
78
+ for (const ch of input) {
79
+ if (escaped) escaped = false;
80
+ else if (inString && !inDisplay && ch === "\\") escaped = true;
81
+ else if (ch === '"') {
82
+ if (!inString) inDisplay = prev === "%";
83
+ inString = !inString;
84
+ } else if (ch === sep && !inString) {
85
+ parts.push(current);
86
+ current = "";
87
+ prev = ch;
88
+ continue;
89
+ }
90
+ current += ch;
91
+ prev = ch;
92
+ }
93
+ parts.push(current);
94
+ return parts;
95
+ }
96
+ function readParams(params) {
97
+ const values = /* @__PURE__ */ new Map();
98
+ let unreadable = false;
99
+ let valuelessAt = -1;
100
+ for (const [index, param] of params.entries()) {
101
+ const eq = param.indexOf("=");
102
+ const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase();
103
+ if (!key) continue;
104
+ if (eq === -1) {
105
+ if (key === "r") unreadable = true;
106
+ if (key === "t") values.delete("t");
107
+ valuelessAt = index;
108
+ continue;
109
+ }
110
+ if ((key === "r" || key === "t") && (values.has(key) || valuelessAt !== -1 && index > valuelessAt)) {
111
+ unreadable = true;
112
+ }
113
+ values.set(key, param.slice(eq + 1));
114
+ }
115
+ return { values, unreadable };
116
+ }
117
+ function parseExhaustedWindows(header) {
118
+ if (!header) return { windows: [], dropped: false };
119
+ const windows = [];
120
+ let dropped = false;
121
+ for (const item of splitOutsideStrings(header, ",")) {
122
+ if (item.trim() === "") continue;
123
+ const [rawName, ...params] = splitOutsideStrings(item, ";");
124
+ if (!params.every(isWellFormedParam)) {
125
+ dropped = true;
126
+ continue;
127
+ }
128
+ const { values, unreadable } = readParams(params);
129
+ if (unreadable) {
130
+ dropped = true;
131
+ continue;
132
+ }
133
+ const remaining = remainingRequests(values.get("r"));
134
+ if (remaining === null) {
135
+ dropped = true;
136
+ continue;
137
+ }
138
+ if (remaining !== 0) continue;
139
+ windows.push({
140
+ window: windowName(rawName),
141
+ resetS: waitSeconds(values.get("t"))
142
+ });
143
+ }
144
+ return { windows, dropped };
145
+ }
146
+ function humanWait(seconds) {
147
+ if (seconds < 120) return `~${seconds}s`;
148
+ if (seconds < 3600)
149
+ return `~${Math.ceil(seconds / 60)} minutes (${seconds}s)`;
150
+ if (seconds < 2 * 86400) {
151
+ const hours = Math.ceil(seconds / 3600);
152
+ return `~${hours} hour${hours === 1 ? "" : "s"} (${seconds}s)`;
153
+ }
154
+ return `~${Math.ceil(seconds / 86400)} days (${seconds}s)`;
155
+ }
156
+ function rateLimitHint(rateLimitHeader, retryAfterHeader) {
157
+ const { windows, dropped } = parseExhaustedWindows(rateLimitHeader);
158
+ windows.sort((a, b) => (b.resetS ?? -1) - (a.resetS ?? -1));
159
+ const longestWindow = windows[0];
160
+ const waitCandidates = [
161
+ waitSeconds(retryAfterHeader),
162
+ longestWindow?.resetS
163
+ ].filter((n) => typeof n === "number" && n > 0);
164
+ const waitS = waitCandidates.length ? Math.max(...waitCandidates) : null;
165
+ const retryAfterUnreadable = typeof retryAfterHeader === "string" && retryAfterHeader.trim() !== "" && waitSeconds(retryAfterHeader) === null;
166
+ if (!longestWindow && waitS === null) {
167
+ if (!retryAfterUnreadable) return GENERIC_RATE_LIMIT_HINT;
168
+ }
169
+ const namedClause = longestWindow?.window ? `the ${longestWindow.window} quota is exhausted` : "";
170
+ if (waitS === null) {
171
+ const cause = namedClause ? `${namedClause}, but the response gave no usable reset time` : "the response gave no usable reset time";
172
+ return `rate limited \u2014 ${cause}; back off substantially before retrying, and pace bulk operations`;
173
+ }
174
+ const hedge = dropped || retryAfterUnreadable || windows.some((w) => w.resetS === null) ? "at least " : "";
175
+ const wait = `wait ${hedge}${humanWait(waitS)} before retrying, and pace bulk operations`;
176
+ return `rate limited \u2014 ${namedClause ? `${namedClause}; ` : ""}${wait}`;
177
+ }
178
+
41
179
  // src/_shared/http.ts
180
+ var EXPIRED_CREDENTIAL_HINT = "the Runpod API rejected the credential \u2014 the API key may be expired or revoked; re-authenticate or update RUNPOD_API_KEY";
42
181
  var HttpError = class extends Error {
43
- constructor(prefix, status, body) {
44
- const hint = status === 401 ? " (the Runpod API rejected the credential \u2014 the API key may be expired or revoked; re-authenticate or update RUNPOD_API_KEY)" : "";
45
- super(`${prefix}: ${status} - ${body}${hint}`);
182
+ constructor(prefix, status, body, hint) {
183
+ const shownBody = body.length > 2048 ? `${body.slice(0, 2048)}\u2026 [truncated ${body.length - 2048} chars]` : body;
184
+ super(`${prefix}: ${status} - ${shownBody}${hint ? ` (${hint})` : ""}`);
46
185
  this.name = "HttpError";
47
186
  this.status = status;
48
187
  this.body = body;
@@ -77,7 +216,11 @@ function createHttpClient(deps) {
77
216
  throw new HttpError(
78
217
  deps.errorPrefix,
79
218
  response.status,
80
- await response.text()
219
+ await response.text(),
220
+ response.status === 429 ? rateLimitHint(
221
+ response.headers.get("ratelimit"),
222
+ response.headers.get("retry-after")
223
+ ) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
81
224
  );
82
225
  }
83
226
  return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
@@ -136,7 +279,15 @@ async function readSseSnapshot(fetchImpl, url, headers, opts) {
136
279
  throw new HttpError(
137
280
  "Runpod API Error",
138
281
  res.status,
139
- await res.text().catch(() => "")
282
+ await res.text().catch(() => ""),
283
+ // This throw bypasses createHttpClient, so the status hints are built
284
+ // here too or these two tools alone return a bare 429/401. The 401
285
+ // hint applies: this request always carries Authorization (see the
286
+ // headers bound in runtime.ts streamSse).
287
+ res.status === 429 ? rateLimitHint(
288
+ res.headers.get("ratelimit"),
289
+ res.headers.get("retry-after")
290
+ ) : res.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
140
291
  );
141
292
  }
142
293
  for await (const chunk of res.body ?? []) {
@@ -255,15 +406,27 @@ function mapPodUpdateToV2(params) {
255
406
  ...compact({ name: params.name })
256
407
  };
257
408
  }
258
- function nestedOrUndefined(obj) {
259
- const c = compact(obj);
260
- return Object.keys(c).length ? c : void 0;
409
+ var DEFAULT_ENDPOINT_TYPE = "QUEUE";
410
+ var DEFAULT_SCALER_VALUE = 4;
411
+ function defaultScalerType(endpointType) {
412
+ return endpointType === "LOAD_BALANCER" ? "REQUEST_COUNT" : "QUEUE_DELAY";
413
+ }
414
+ function endpointScaling(scalerType, value) {
415
+ return scalerType === "REQUEST_COUNT" ? { type: "REQUEST_COUNT", requestCount: value } : { type: "QUEUE_DELAY", queueDelay: value };
261
416
  }
262
417
  function endpointGpuConfig(pools, count) {
263
418
  if (!pools?.length) return void 0;
264
419
  return compact({ pools, count });
265
420
  }
266
- function mapEndpointToV2(params) {
421
+ function endpointWorkers(params) {
422
+ const workers = compact({
423
+ min: params.workersMin,
424
+ max: params.workersMax,
425
+ idleTimeout: params.idleTimeout
426
+ });
427
+ return Object.keys(workers).length ? workers : void 0;
428
+ }
429
+ function endpointCommonToV2(params) {
267
430
  return compact({
268
431
  name: params.name,
269
432
  image: params.imageName,
@@ -273,23 +436,33 @@ function mapEndpointToV2(params) {
273
436
  env: params.env,
274
437
  registry: params.containerRegistryAuthId,
275
438
  gpu: endpointGpuConfig(params.gpuPoolIds, params.gpuCount),
276
- workers: nestedOrUndefined({
277
- min: params.workersMin,
278
- max: params.workersMax
279
- }),
280
- scaling: nestedOrUndefined({
281
- type: params.scalerType,
282
- value: params.scalerValue,
283
- idleTimeout: params.idleTimeout
284
- }),
439
+ workers: endpointWorkers(params),
285
440
  dataCenterIds: params.dataCenterIds?.length ? params.dataCenterIds : void 0,
286
441
  networkVolumes: params.networkVolumeIds?.length ? params.networkVolumeIds : void 0,
287
442
  timeout: params.executionTimeoutMs,
288
443
  flashboot: params.flashboot
289
444
  });
290
445
  }
291
- var mapEndpointCreateToV2 = mapEndpointToV2;
292
- var mapEndpointUpdateToV2 = mapEndpointToV2;
446
+ function mapEndpointCreateToV2(params) {
447
+ const endpointType = params.endpointType ?? DEFAULT_ENDPOINT_TYPE;
448
+ return compact({
449
+ ...endpointCommonToV2(params),
450
+ type: endpointType,
451
+ scaling: endpointScaling(
452
+ params.scalerType ?? defaultScalerType(endpointType),
453
+ params.scalerValue ?? DEFAULT_SCALER_VALUE
454
+ )
455
+ });
456
+ }
457
+ function mapEndpointUpdateToV2(params) {
458
+ return compact({
459
+ ...endpointCommonToV2(params),
460
+ scaling: params.scalerType ? endpointScaling(
461
+ params.scalerType,
462
+ params.scalerValue ?? DEFAULT_SCALER_VALUE
463
+ ) : void 0
464
+ });
465
+ }
293
466
  function mapNetworkVolumeCreateToV2(params) {
294
467
  return compact({
295
468
  name: params.name,
@@ -373,7 +546,7 @@ function restV1Base(env) {
373
546
  return env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
374
547
  }
375
548
  function restV2Base(env) {
376
- return env.RUNPOD_REST_V2_API_URL ?? "https://v2-rest.runpod.io/v2";
549
+ return env.RUNPOD_REST_V2_API_URL ?? "https://api.runpod.io/v2";
377
550
  }
378
551
  function serverlessBase(env) {
379
552
  return env.RUNPOD_SERVERLESS_API_URL ?? "https://api.runpod.ai/v2";
@@ -536,7 +709,7 @@ function resolveBackend(opts) {
536
709
  }
537
710
 
538
711
  // src/tools/runtime.ts
539
- var MCP_SERVER_VERSION = true ? "3.0.0" : "dev";
712
+ var MCP_SERVER_VERSION = true ? "3.2.0" : "dev";
540
713
  var SESSION_ID = (0, import_node_crypto.randomUUID)();
541
714
  function trackingHeaders(server, ctx) {
542
715
  const info = server.server.getClientVersion();
@@ -586,6 +759,36 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
586
759
  ...options?.variables ? { variables: options.variables } : {}
587
760
  })
588
761
  });
762
+ if (!response.ok) {
763
+ const bodyText = await response.text();
764
+ let gqlErrors;
765
+ try {
766
+ gqlErrors = JSON.parse(bodyText).errors;
767
+ } catch {
768
+ }
769
+ if (Array.isArray(gqlErrors) && gqlErrors.length > 0 && response.status !== 401 && response.status !== 429) {
770
+ throw new HttpError(
771
+ "Runpod GraphQL Error",
772
+ response.status,
773
+ gqlErrors.map((e) => String(e?.message ?? JSON.stringify(e))).join(", ")
774
+ );
775
+ }
776
+ throw new HttpError(
777
+ "Runpod GraphQL Error",
778
+ response.status,
779
+ bodyText,
780
+ response.status === 429 ? rateLimitHint(
781
+ response.headers.get("ratelimit"),
782
+ response.headers.get("retry-after")
783
+ ) : (
784
+ // Only when this request actually carried the caller's API key: the
785
+ // public path sends no Authorization header, so a 401 from that
786
+ // host (WAF, misconfigured RUNPOD_PUBLIC_GRAPHQL_URL) says nothing
787
+ // about the credential — same rationale as observeUnauthorized.
788
+ response.status === 401 && options?.apiKey ? EXPIRED_CREDENTIAL_HINT : void 0
789
+ )
790
+ );
791
+ }
589
792
  const result = await response.json();
590
793
  if (result.errors && result.errors.length > 0) {
591
794
  throw new Error(
@@ -1002,6 +1205,178 @@ function registerCatalogTools(server, rt) {
1002
1205
  return jsonReply2(result);
1003
1206
  }
1004
1207
  );
1208
+ server.tool(
1209
+ "get-capacity",
1210
+ "GPU capacity across host CUDA versions, as a matrix. Use this to choose an endpoint's allowedCudaVersions/minCudaVersion (or diagnose/widen a capacity-starved one) and to distinguish capacity problems from compatibility problems. Default mode is one call returning, per GPU type, overall stock plus AVAILABLE/UNAVAILABLE per host-reported CUDA version. Pass cudaVersions to deep-probe instead: one stock lookup per listed version, returning graded stock (High/Medium/Low/Out) and the lowest on-demand price per version; a probe that fails transiently reports a per-version error instead of failing the call. Nothing is hidden by default \u2014 set includeUnavailable:false to drop GPUs with no stock on any listed version. Credential-free public catalog data; works on both v1 and v2 APIs. Stock is live, so page cursors can shift between calls. Note: the matrix reflects host-reported versions, but endpoint allowedCudaVersions only accepts values from the platform enum \u2014 check create-endpoint/update-endpoint for the accepted list.",
1211
+ {
1212
+ ...listPaginationParams,
1213
+ cudaVersions: import_zod3.z.array(import_zod3.z.string().regex(/^\d{1,2}\.\d{1,2}$/)).min(1).max(12).optional().describe(
1214
+ 'Deep-probe these host CUDA versions (e.g. ["12.8", "13.0"], max 12): one stock lookup per version returns graded stock and price. Omit for the single-call AVAILABLE/UNAVAILABLE matrix across all versions the fleet currently reports.'
1215
+ ),
1216
+ includeUnavailable: import_zod3.z.boolean().optional().describe(
1217
+ 'Out-of-stock GPUs are included by default (explicit "Out" cells / all-UNAVAILABLE rows, sorted last). Set false to hide GPUs with no stock on any listed version.'
1218
+ ),
1219
+ gpuTypeIds: import_zod3.z.array(import_zod3.z.string()).optional().describe(
1220
+ "Filter to GPU types matching any of these ids or display names (case-insensitive substring, e.g. ['NVIDIA GeForce RTX 4090'] or ['4090', 'H200'])."
1221
+ ),
1222
+ gpuCount: import_zod3.z.number().int().min(1).max(8).optional().describe("GPUs per worker/pod to check stock for (default 1)."),
1223
+ secureCloudOnly: import_zod3.z.boolean().optional().describe("Restrict the stock lookup to Secure Cloud hosts.")
1224
+ },
1225
+ { title: "Get GPU capacity by CUDA version", ...READ_ONLY },
1226
+ async (params) => {
1227
+ const gpuCount = Math.min(
1228
+ 8,
1229
+ Math.max(1, Math.floor(Number(params.gpuCount)) || 1)
1230
+ );
1231
+ const secureArg = params.secureCloudOnly ? ", secureCloud: true" : "";
1232
+ const filterTerms = (params.gpuTypeIds ?? []).filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.toLowerCase());
1233
+ const matchesFilter = (gpu) => {
1234
+ if (filterTerms.length === 0) return true;
1235
+ const id = gpu.id.toLowerCase();
1236
+ const name = gpu.displayName.toLowerCase();
1237
+ return filterTerms.some(
1238
+ (term) => id.includes(term) || name.includes(term)
1239
+ );
1240
+ };
1241
+ const stockPriority = {
1242
+ High: 3,
1243
+ Medium: 2,
1244
+ Low: 1
1245
+ };
1246
+ if (!params.cudaVersions?.length) {
1247
+ const data = await graphql(`
1248
+ query {
1249
+ gpuTypes {
1250
+ id
1251
+ displayName
1252
+ memoryInGb
1253
+ secureCloud
1254
+ communityCloud
1255
+ lowestPrice(input: { gpuCount: ${gpuCount}${secureArg} }) {
1256
+ stockStatus
1257
+ uninterruptablePrice
1258
+ gpuTypeCudaVersions {
1259
+ cudaVersion
1260
+ availability
1261
+ }
1262
+ }
1263
+ }
1264
+ }
1265
+ `);
1266
+ let rows2 = data.gpuTypes.filter((gpu) => gpu.id !== "unknown" && matchesFilter(gpu)).map((gpu) => {
1267
+ const cuda = {};
1268
+ for (const c of gpu.lowestPrice?.gpuTypeCudaVersions ?? []) {
1269
+ cuda[c.cudaVersion] = c.availability;
1270
+ }
1271
+ return {
1272
+ id: gpu.id,
1273
+ displayName: gpu.displayName,
1274
+ memoryGb: gpu.memoryInGb,
1275
+ secureCloud: gpu.secureCloud,
1276
+ communityCloud: gpu.communityCloud,
1277
+ stockStatus: gpu.lowestPrice?.stockStatus || "unavailable",
1278
+ pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null,
1279
+ cudaVersions: cuda
1280
+ };
1281
+ });
1282
+ const availableCount = (r) => Object.values(r.cudaVersions).filter((a) => a === "AVAILABLE").length;
1283
+ if (params.includeUnavailable === false)
1284
+ rows2 = rows2.filter((r) => availableCount(r) > 0);
1285
+ rows2.sort((a, b) => {
1286
+ const diff = availableCount(b) - availableCount(a);
1287
+ if (diff !== 0) return diff;
1288
+ const stockDiff = (stockPriority[b.stockStatus] || 0) - (stockPriority[a.stockStatus] || 0);
1289
+ if (stockDiff !== 0) return stockDiff;
1290
+ return b.memoryGb - a.memoryGb;
1291
+ });
1292
+ return capListResult(rows2, {
1293
+ limit: params.limit,
1294
+ cursor: params.cursor
1295
+ });
1296
+ }
1297
+ const versions = [...new Set(params.cudaVersions)].slice(0, 12);
1298
+ const invalid = versions.filter((v) => !/^\d{1,2}\.\d{1,2}$/.test(v));
1299
+ if (invalid.length > 0) {
1300
+ return jsonReply2({
1301
+ error: `Invalid CUDA version format: ${invalid.join(", ")}. Use "major.minor" strings like "12.8".`,
1302
+ status: 400
1303
+ });
1304
+ }
1305
+ const perVersion = await Promise.allSettled(
1306
+ versions.map(
1307
+ (v) => graphql(`
1308
+ query {
1309
+ gpuTypes {
1310
+ id
1311
+ displayName
1312
+ memoryInGb
1313
+ secureCloud
1314
+ communityCloud
1315
+ lowestPrice(input: { gpuCount: ${gpuCount}, allowedCudaVersions: ["${v}"]${secureArg} }) {
1316
+ stockStatus
1317
+ uninterruptablePrice
1318
+ }
1319
+ }
1320
+ }
1321
+ `)
1322
+ )
1323
+ );
1324
+ const probeErrors = {};
1325
+ const byId = /* @__PURE__ */ new Map();
1326
+ versions.forEach((v, i) => {
1327
+ const settled = perVersion[i];
1328
+ if (settled.status === "rejected") {
1329
+ probeErrors[v] = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
1330
+ return;
1331
+ }
1332
+ for (const gpu of settled.value.gpuTypes) {
1333
+ if (gpu.id === "unknown" || !matchesFilter(gpu)) continue;
1334
+ let row = byId.get(gpu.id);
1335
+ if (!row) {
1336
+ row = {
1337
+ id: gpu.id,
1338
+ displayName: gpu.displayName,
1339
+ memoryGb: gpu.memoryInGb,
1340
+ secureCloud: gpu.secureCloud,
1341
+ communityCloud: gpu.communityCloud,
1342
+ cudaVersions: {}
1343
+ };
1344
+ byId.set(gpu.id, row);
1345
+ }
1346
+ const stock = gpu.lowestPrice?.stockStatus;
1347
+ row.cudaVersions[v] = !stock || stock === "Out" ? { stock: "Out", pricePerHr: null } : {
1348
+ stock,
1349
+ pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null
1350
+ };
1351
+ }
1352
+ });
1353
+ let rows = [...byId.values()];
1354
+ const inStockCount = (r) => Object.values(r.cudaVersions).filter((c) => c.stock !== "Out").length;
1355
+ if (params.includeUnavailable === false)
1356
+ rows = rows.filter((r) => inStockCount(r) > 0);
1357
+ rows.sort((a, b) => {
1358
+ const diff = inStockCount(b) - inStockCount(a);
1359
+ if (diff !== 0) return diff;
1360
+ const best = (r) => Math.max(
1361
+ 0,
1362
+ ...Object.values(r.cudaVersions).map(
1363
+ (c) => stockPriority[c.stock] || 0
1364
+ )
1365
+ );
1366
+ const bestDiff = best(b) - best(a);
1367
+ if (bestDiff !== 0) return bestDiff;
1368
+ return b.memoryGb - a.memoryGb;
1369
+ });
1370
+ return capListResult(
1371
+ rows,
1372
+ { limit: params.limit, cursor: params.cursor },
1373
+ {
1374
+ probedCudaVersions: versions,
1375
+ ...Object.keys(probeErrors).length > 0 ? { probeErrors } : {}
1376
+ }
1377
+ );
1378
+ }
1379
+ );
1005
1380
  server.tool(
1006
1381
  "get-data-center",
1007
1382
  "Get details for a single data center by id. v2-only \u2014 returns a 501 notice on the v1 API (use list-data-centers there).",
@@ -1686,7 +2061,9 @@ function registerPodTools(server, rt) {
1686
2061
  "create-pod",
1687
2062
  'Create a new GPU/CPU pod on Runpod. Pass gpuTypeIds for a GPU pod, or computeType:"CPU" for a CPU pod (CPU pods are served by the v1 API for now). Pass either imageName or templateId (templateId deploys from an existing template \u2014 its name, image, start command, ports, env, disk, volume, and registry credential are used as defaults, and any field you also pass explicitly replaces the template value for that field, e.g. passing env replaces the whole env set rather than merging; pass containerRegistryAuthId to override or supply a private-image credential, or an empty string to deploy with none). If the user specifies neither an image nor a template, recommend the "Runpod Pytorch 2.8.0" image (runpod/pytorch:1.0.2-cu1281-torch280-ubuntu2404) as the default \u2014 it has the most up-to-date CUDA and PyTorch versions.',
1688
2063
  {
1689
- name: import_zod7.z.string().optional().describe("Name for the pod"),
2064
+ name: import_zod7.z.string().optional().describe(
2065
+ "Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
2066
+ ),
1690
2067
  imageName: import_zod7.z.string().optional().describe("Docker image to use. Optional when templateId is provided."),
1691
2068
  templateId: import_zod7.z.string().optional().describe(
1692
2069
  "Deploy from an existing template (see list-templates / get-template). The template supplies name, image, start command, ports, env, disk, volume, and registry credential as defaults; any field you also pass replaces the template value for that field (whole-field, not a merge). Override the registry credential with containerRegistryAuthId. Requires the v2 REST API. Still pass gpuTypeIds or computeType to choose the compute \u2014 a template does not pin the pod compute."
@@ -1745,6 +2122,12 @@ function registerPodTools(server, rt) {
1745
2122
  status: 400
1746
2123
  });
1747
2124
  }
2125
+ if (!wantsCpu && !params.name && !params.templateId) {
2126
+ return jsonReply2({
2127
+ error: "Provide a name for the pod (deploying from a templateId inherits the template name).",
2128
+ status: 400
2129
+ });
2130
+ }
1748
2131
  if (wantsCpu) {
1749
2132
  if (params.templateId) {
1750
2133
  return jsonReply2({
@@ -1922,11 +2305,22 @@ function registerPodTools(server, rt) {
1922
2305
 
1923
2306
  // src/tools/endpoints.ts
1924
2307
  var import_zod8 = require("zod");
2308
+ var MIN_QUEUE_DELAY = 0.5;
2309
+ var MIN_REQUEST_COUNT = 1;
2310
+ var MIN_IDLE_TIMEOUT = 1;
2311
+ var MAX_IDLE_TIMEOUT = 3600;
2312
+ function scalerValueError(scalerType, scalerValue) {
2313
+ if (scalerType !== "REQUEST_COUNT" || scalerValue === void 0) return;
2314
+ if (!Number.isInteger(scalerValue) || scalerValue < MIN_REQUEST_COUNT) {
2315
+ return `REQUEST_COUNT scales on whole in-flight requests per worker, so scalerValue must be an integer >= ${MIN_REQUEST_COUNT} (got ${scalerValue}). For a fractional target use scalerType QUEUE_DELAY, which takes seconds >= ${MIN_QUEUE_DELAY}.`;
2316
+ }
2317
+ return;
2318
+ }
1925
2319
  function registerEndpointTools(server, rt) {
1926
2320
  const { jsonReply: jsonReply2, callRestUrl, backendFor } = rt;
1927
2321
  server.tool(
1928
2322
  "list-endpoints",
1929
- "List your Serverless endpoints, optionally expanding template and worker details (v1 only). Paginated via limit/cursor.",
2323
+ "List your Serverless endpoints, optionally expanding template and worker details (v1 only). Paginated via limit/cursor. On v2 each endpoint carries its request routing in `type` and the URLs to call it in `requestUrls`.",
1930
2324
  {
1931
2325
  ...listPaginationParams,
1932
2326
  includeTemplate: import_zod8.z.boolean().optional().describe("Include template information (v1 only)"),
@@ -1961,7 +2355,7 @@ function registerEndpointTools(server, rt) {
1961
2355
  );
1962
2356
  server.tool(
1963
2357
  "get-endpoint",
1964
- "Get one Serverless endpoint by id, optionally expanding template and worker details (v1 only).",
2358
+ "Get one Serverless endpoint by id, optionally expanding template and worker details (v1 only). On v2 the reply carries `type` (queue-based vs load-balancing routing) and `requestUrls` \u2014 the run/runsync/status/... URLs for a queue endpoint, or the base + health URLs for a load-balancing one. Read those rather than constructing endpoint URLs by hand.",
1965
2359
  {
1966
2360
  endpointId: import_zod8.z.string().describe("ID of the endpoint to retrieve"),
1967
2361
  includeTemplate: import_zod8.z.boolean().optional().describe("Include template information (v1 only)"),
@@ -1993,11 +2387,14 @@ function registerEndpointTools(server, rt) {
1993
2387
  );
1994
2388
  server.tool(
1995
2389
  "create-endpoint",
1996
- "Create a Serverless endpoint. On v2 (default), pass an inline config: imageName + gpuPoolIds (GPU pool names from list-gpu-types \u2014 the `pool` field, e.g. AMPERE_80/ADA_24) plus optional workers/scaling/disk/env. On v1, pass a templateId instead. Worker min/max set autoscaling bounds (min 0 = scale to zero).",
2390
+ "Create a Serverless endpoint. On v2 (default), pass an inline config: imageName + gpuPoolIds (GPU pool names from list-gpu-types \u2014 the `pool` field, e.g. AMPERE_80/ADA_24) plus optional workers/scaling/disk/env. On v1, pass a templateId instead. Worker min/max set autoscaling bounds (min 0 = scale to zero). Use endpointType to pick queue-based (default) or load-balancing request routing; the response `requestUrls` carries the URLs to call the endpoint with.",
1997
2391
  {
1998
2392
  name: import_zod8.z.string().optional().describe("Name for the endpoint"),
1999
2393
  // --- v2 inline-config fields ---
2000
2394
  imageName: import_zod8.z.string().optional().describe("Docker image (v2). Required on v2 instead of a templateId."),
2395
+ endpointType: import_zod8.z.enum(["QUEUE", "LOAD_BALANCER"]).optional().describe(
2396
+ "How requests reach the workers (v2). QUEUE (default) = submit jobs through the managed queue (run/runsync/status/...). LOAD_BALANCER = send HTTP requests straight to worker-defined paths, and forces scalerType REQUEST_COUNT. Fixed at creation \u2014 update-endpoint cannot change it."
2397
+ ),
2001
2398
  gpuPoolIds: import_zod8.z.array(import_zod8.z.string()).optional().describe(
2002
2399
  'GPU pool names (v2, required). The `pool` field from list-gpu-types, e.g. ["AMPERE_80"]. NOT GPU type ids.'
2003
2400
  ),
@@ -2017,9 +2414,15 @@ function registerEndpointTools(server, rt) {
2017
2414
  // --- shared autoscaling fields ---
2018
2415
  workersMin: import_zod8.z.number().optional().describe("Minimum number of workers"),
2019
2416
  workersMax: import_zod8.z.number().optional().describe("Maximum number of workers"),
2020
- scalerType: import_zod8.z.enum(["QUEUE_DELAY", "REQUEST_COUNT"]).optional().describe("Autoscaler type"),
2021
- scalerValue: import_zod8.z.number().optional().describe("Autoscaler target value"),
2022
- idleTimeout: import_zod8.z.number().optional().describe("Idle timeout in seconds before scaling a worker down"),
2417
+ scalerType: import_zod8.z.enum(["QUEUE_DELAY", "REQUEST_COUNT"]).optional().describe(
2418
+ "Autoscaler signal. QUEUE_DELAY scales on how long requests wait in the queue (queue endpoints only); REQUEST_COUNT scales on in-flight requests per worker. Defaults to QUEUE_DELAY for QUEUE endpoints and REQUEST_COUNT for LOAD_BALANCER ones."
2419
+ ),
2420
+ scalerValue: import_zod8.z.number().min(MIN_QUEUE_DELAY).optional().describe(
2421
+ "Autoscaler target for the chosen scalerType \u2014 seconds of queue delay (min 0.5) or in-flight requests per worker (integer, min 1). Defaults to 4."
2422
+ ),
2423
+ idleTimeout: import_zod8.z.number().int().min(MIN_IDLE_TIMEOUT).max(MAX_IDLE_TIMEOUT).optional().describe(
2424
+ "Idle timeout in seconds before scaling a worker down (1-3600). Does not apply to QUEUE endpoints scaling on REQUEST_COUNT."
2425
+ ),
2023
2426
  dataCenterIds: import_zod8.z.array(import_zod8.z.string()).optional().describe("List of preferred data centers")
2024
2427
  },
2025
2428
  { title: "Create endpoint", ...WRITE },
@@ -2044,6 +2447,19 @@ function registerEndpointTools(server, rt) {
2044
2447
  status: 400
2045
2448
  });
2046
2449
  }
2450
+ if (params.endpointType === "LOAD_BALANCER" && params.scalerType === "QUEUE_DELAY") {
2451
+ return jsonReply2({
2452
+ error: "LOAD_BALANCER endpoints have no request queue, so they cannot scale on QUEUE_DELAY. Use scalerType REQUEST_COUNT (the default for this endpoint type), or create a QUEUE endpoint instead.",
2453
+ status: 400
2454
+ });
2455
+ }
2456
+ const scalerError = scalerValueError(
2457
+ params.scalerType ?? (params.endpointType === "LOAD_BALANCER" ? "REQUEST_COUNT" : void 0),
2458
+ params.scalerValue
2459
+ );
2460
+ if (scalerError) {
2461
+ return jsonReply2({ error: scalerError, status: 400 });
2462
+ }
2047
2463
  const body = backend.mapCreate(params);
2048
2464
  const result2 = await callRestUrl(
2049
2465
  `${backend.base}${backend.list}`,
@@ -2081,15 +2497,21 @@ function registerEndpointTools(server, rt) {
2081
2497
  );
2082
2498
  server.tool(
2083
2499
  "update-endpoint",
2084
- "Update a Serverless endpoint's config. On v2 you can change image/disk/env/ports/registry/workers/scaling/networkVolumes/timeout/flashboot; on v1, scaling fields (worker min/max, idle timeout, scaler type/value, name). Only provided fields change.",
2500
+ "Update a Serverless endpoint's config. On v2 you can change image/disk/env/ports/registry/workers/scaling/networkVolumes/timeout/flashboot; on v1, scaling fields (worker min/max, idle timeout, scaler type/value, name). Only provided fields change. An endpoint's request routing (queue vs load balancer) is fixed at creation and cannot be changed here \u2014 recreate the endpoint instead.",
2085
2501
  {
2086
2502
  endpointId: import_zod8.z.string().describe("ID of the endpoint to update"),
2087
2503
  name: import_zod8.z.string().optional().describe("New name for the endpoint"),
2088
2504
  workersMin: import_zod8.z.number().optional().describe("New minimum number of workers"),
2089
2505
  workersMax: import_zod8.z.number().optional().describe("New maximum number of workers"),
2090
- idleTimeout: import_zod8.z.number().optional().describe("New idle timeout in seconds"),
2091
- scalerType: import_zod8.z.enum(["QUEUE_DELAY", "REQUEST_COUNT"]).optional().describe("Scaler type"),
2092
- scalerValue: import_zod8.z.number().optional().describe("Scaler value"),
2506
+ idleTimeout: import_zod8.z.number().int().min(MIN_IDLE_TIMEOUT).max(MAX_IDLE_TIMEOUT).optional().describe(
2507
+ "New idle timeout in seconds (1-3600). Does not apply to queue endpoints scaling on REQUEST_COUNT."
2508
+ ),
2509
+ scalerType: import_zod8.z.enum(["QUEUE_DELAY", "REQUEST_COUNT"]).optional().describe(
2510
+ "New autoscaler signal. Switchable on queue endpoints; load-balancing endpoints only accept REQUEST_COUNT."
2511
+ ),
2512
+ scalerValue: import_zod8.z.number().min(MIN_QUEUE_DELAY).optional().describe(
2513
+ "New autoscaler target \u2014 seconds of queue delay (min 0.5) or in-flight requests per worker (integer, min 1). Applies to the endpoint's current scalerType unless you also pass a new one."
2514
+ ),
2093
2515
  // --- v2-only inline-config fields ---
2094
2516
  imageName: import_zod8.z.string().optional().describe("New Docker image (v2)"),
2095
2517
  gpuPoolIds: import_zod8.z.array(import_zod8.z.string()).optional().describe('New GPU pool names (v2), e.g. ["AMPERE_80"]'),
@@ -2107,13 +2529,28 @@ function registerEndpointTools(server, rt) {
2107
2529
  async (params) => {
2108
2530
  const { endpointId, ...updateParams } = params;
2109
2531
  const backend = backendFor("endpoints");
2110
- const body = backend.mapUpdate(updateParams);
2111
- const result = await callRestUrl(
2112
- `${backend.base}${backend.get(endpointId)}`,
2113
- "PATCH",
2114
- body
2532
+ const url = `${backend.base}${backend.get(endpointId)}`;
2533
+ if (backend.version !== "v2") {
2534
+ const body2 = backend.mapUpdate(updateParams);
2535
+ return jsonReply2(await callRestUrl(url, "PATCH", body2));
2536
+ }
2537
+ let scalerType = updateParams.scalerType;
2538
+ if (scalerType === void 0 && updateParams.scalerValue !== void 0) {
2539
+ const current = await callRestUrl(url);
2540
+ scalerType = current?.scaling?.type === "REQUEST_COUNT" ? "REQUEST_COUNT" : "QUEUE_DELAY";
2541
+ }
2542
+ const scalerError = scalerValueError(
2543
+ scalerType,
2544
+ updateParams.scalerValue
2115
2545
  );
2116
- return jsonReply2(result);
2546
+ if (scalerError) {
2547
+ return jsonReply2({ error: scalerError, status: 400 });
2548
+ }
2549
+ const body = backend.mapUpdate({
2550
+ ...updateParams,
2551
+ scalerType
2552
+ });
2553
+ return jsonReply2(await callRestUrl(url, "PATCH", body));
2117
2554
  }
2118
2555
  );
2119
2556
  server.tool(
@@ -2161,7 +2598,7 @@ function registerEndpointTools(server, rt) {
2161
2598
  );
2162
2599
  server.tool(
2163
2600
  "list-endpoint-workers",
2164
- "List the workers backing a Serverless endpoint, with their status and an aggregate summary. v2-only \u2014 returns a 501 notice on the v1 API. Paginated via limit/cursor.",
2601
+ "List the workers backing a Serverless endpoint, with their status and an aggregate summary. v2-only \u2014 returns a 501 notice on the v1 API. An UNHEALTHY worker usually means the container is crash-looping (repeated starts that exit before the handler runs) \u2014 jobs on the endpoint will sit IN_QUEUE even though this is a worker failure, not a capacity shortage; inspect it with stream-worker-logs. Zero workers while jobs queue means the endpoint is waiting for GPU capacity. Paginated via limit/cursor.",
2165
2602
  {
2166
2603
  ...listPaginationParams,
2167
2604
  endpointId: import_zod8.z.string().describe("ID of the Serverless endpoint whose workers to list")
@@ -2187,7 +2624,7 @@ function registerEndpointTools(server, rt) {
2187
2624
  );
2188
2625
  server.tool(
2189
2626
  "stream-worker-logs",
2190
- "Fetch a bounded snapshot of a serverless worker's live logs (container and/or system) via Server-Sent Events. v2-only \u2014 returns a 501 notice on the v1 API. Get the workerId from list-endpoint-workers. Reads for up to maxWaitMs (default 5s) and returns the collected log lines; use `tail` to backfill recent lines first. Large output is truncated (see the `truncated` flag).",
2627
+ "Fetch a bounded snapshot of a serverless worker's live logs (container and/or system) via Server-Sent Events. v2-only \u2014 returns a 501 notice on the v1 API. Get the workerId from list-endpoint-workers. Use this to diagnose UNHEALTHY workers: repeated `start container` system lines with no container output is the crash-loop signature (the container exits before the handler runs). Reads for up to maxWaitMs (default 5s) and returns the collected log lines; use `tail` to backfill recent lines first. Large output is truncated (see the `truncated` flag).",
2191
2628
  {
2192
2629
  endpointId: import_zod8.z.string().describe("ID of the Serverless endpoint the worker belongs to"),
2193
2630
  workerId: import_zod8.z.string().describe(
@@ -2210,8 +2647,67 @@ function registerEndpointTools(server, rt) {
2210
2647
 
2211
2648
  // src/tools/jobs.ts
2212
2649
  var import_zod9 = require("zod");
2650
+ var DIAGNOSIS_TTL_MS = 15e3;
2651
+ var MAX_DIAGNOSIS_ENTRIES = 500;
2652
+ var diagnosisCache = /* @__PURE__ */ new Map();
2213
2653
  function registerJobTools(server, rt) {
2214
- const { jsonReply: jsonReply2, serverlessRequest } = rt;
2654
+ const { jsonReply: jsonReply2, serverlessRequest, backendFor, callRestUrl } = rt;
2655
+ async function diagnoseQueuedJob(endpointId) {
2656
+ const cached = diagnosisCache.get(endpointId);
2657
+ if (cached && cached.expiresAt > Date.now()) {
2658
+ return cached.value;
2659
+ }
2660
+ diagnosisCache.delete(endpointId);
2661
+ try {
2662
+ const backend = backendFor("workers");
2663
+ if (backend.version !== "v2") return null;
2664
+ const raw = await callRestUrl(
2665
+ `${backend.base}/serverless/${endpointId}/workers`
2666
+ );
2667
+ const summary = raw?.summary;
2668
+ const workers = raw?.workers ?? [];
2669
+ if (!summary) return null;
2670
+ const remember = (value) => {
2671
+ if (diagnosisCache.size >= MAX_DIAGNOSIS_ENTRIES) {
2672
+ const oldest = diagnosisCache.keys().next().value;
2673
+ if (oldest !== void 0) diagnosisCache.delete(oldest);
2674
+ }
2675
+ diagnosisCache.set(endpointId, {
2676
+ value,
2677
+ expiresAt: Date.now() + DIAGNOSIS_TTL_MS
2678
+ });
2679
+ return value;
2680
+ };
2681
+ if ((summary.unhealthy ?? 0) > 0) {
2682
+ const badIds = workers.filter((w) => w.status === "UNHEALTHY").map((w) => w.id).join(", ");
2683
+ return remember({
2684
+ workerHealth: summary,
2685
+ hint: `${summary.unhealthy} of ${summary.total} worker(s) on this endpoint are UNHEALTHY \u2014 the container is likely crash-looping (repeated starts that exit before the handler runs). The job can stay IN_QUEUE indefinitely; this is a worker failure, NOT a capacity shortage. Inspect the logs with stream-worker-logs${badIds ? ` (workerId: ${badIds})` : ""}.`
2686
+ });
2687
+ }
2688
+ if ((summary.total ?? 0) === 0) {
2689
+ return remember({
2690
+ workerHealth: summary,
2691
+ hint: "No workers are scheduled for this endpoint yet \u2014 it may simply be scaling up from zero (normal for the first seconds of a cold start), or it is waiting for GPU capacity / its GPU-CUDA constraints exclude the currently-available hosts. If this persists, check availability with list-gpu-types or widen the gpuIds/CUDA settings."
2692
+ });
2693
+ }
2694
+ if ((summary.throttled ?? 0) > 0 && (summary.running ?? 0) === 0) {
2695
+ return remember({
2696
+ workerHealth: summary,
2697
+ hint: "Workers exist but are throttled \u2014 the hosts are at capacity right now; the job should start when capacity frees up."
2698
+ });
2699
+ }
2700
+ if ((summary.initializing ?? 0) > 0) {
2701
+ return remember({
2702
+ workerHealth: summary,
2703
+ hint: "A worker is initializing \u2014 likely a cold start (image pull / model load); the job should start soon."
2704
+ });
2705
+ }
2706
+ return remember({ workerHealth: summary, hint: "" });
2707
+ } catch {
2708
+ return null;
2709
+ }
2710
+ }
2215
2711
  const endpointIdSchema = import_zod9.z.string().regex(/^[a-zA-Z0-9_-]+$/, "Invalid endpoint ID format");
2216
2712
  const jobIdSchema = import_zod9.z.string().regex(/^[a-zA-Z0-9_-]+$/, "Invalid job ID format");
2217
2713
  const inputSchema = import_zod9.z.record(import_zod9.z.unknown()).describe(
@@ -2285,7 +2781,7 @@ function registerJobTools(server, rt) {
2285
2781
  );
2286
2782
  server.tool(
2287
2783
  "get-job-status",
2288
- "Check the status of an asynchronous Serverless job. Returns the current status and output when complete. Job statuses: IN_QUEUE, IN_PROGRESS, COMPLETED, FAILED, CANCELLED, TIMED_OUT.",
2784
+ "Check the status of an asynchronous Serverless job. Returns the current status and output when complete. Job statuses: IN_QUEUE, IN_PROGRESS, COMPLETED, FAILED, CANCELLED, TIMED_OUT. IMPORTANT: a job stuck IN_QUEUE does not necessarily mean a capacity shortage \u2014 a worker may have spun up and crash-looped (the platform marks it UNHEALTHY while the job stays queued). When the job is IN_QUEUE this tool attaches a workerHealth summary and a hint; to dig deeper, call list-endpoint-workers (look for UNHEALTHY) and stream-worker-logs.",
2289
2785
  {
2290
2786
  endpointId: endpointIdSchema.describe(
2291
2787
  "ID of the Serverless endpoint the job belongs to"
@@ -2298,6 +2794,16 @@ function registerJobTools(server, rt) {
2298
2794
  params.endpointId,
2299
2795
  `/status/${params.jobId}`
2300
2796
  );
2797
+ if (result && typeof result === "object" && result.status === "IN_QUEUE") {
2798
+ const diagnosis = await diagnoseQueuedJob(params.endpointId);
2799
+ if (diagnosis) {
2800
+ return jsonReply2({
2801
+ ...result,
2802
+ workerHealth: diagnosis.workerHealth,
2803
+ ...diagnosis.hint ? { hint: diagnosis.hint } : {}
2804
+ });
2805
+ }
2806
+ }
2301
2807
  return jsonReply2(result);
2302
2808
  }
2303
2809
  );
@@ -2399,7 +2905,7 @@ function registerJobTools(server, rt) {
2399
2905
  );
2400
2906
  server.tool(
2401
2907
  "endpoint-health",
2402
- "Get the health and operational status of a Serverless endpoint, including worker counts and job statistics.",
2908
+ "Get the health and operational status of a Serverless endpoint, including worker counts and job statistics. Use this when jobs are stuck IN_QUEUE: unhealthy workers mean a crash-looping container (a worker failure \u2014 inspect with stream-worker-logs), while zero workers means the endpoint is waiting for GPU capacity.",
2403
2909
  {
2404
2910
  endpointId: endpointIdSchema.describe(
2405
2911
  "ID of the Serverless endpoint to check health for"