@runpod/mcp-server 3.1.0 → 3.3.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/stdio.mjs CHANGED
@@ -34,7 +34,7 @@ function containerConfigToV2(p) {
34
34
  function gpuConfigToV2(gpuTypeIds, gpuCount) {
35
35
  const id = gpuTypeIds?.[0];
36
36
  if (id === void 0) return void 0;
37
- return compact({ id, count: gpuCount });
37
+ return { id, count: gpuCount ?? 1 };
38
38
  }
39
39
  function mapPodCreateToV2(params) {
40
40
  return compact({
@@ -161,6 +161,42 @@ function podBodyFromTemplate(template) {
161
161
  }
162
162
  return out;
163
163
  }
164
+ var SSH_PUBLIC_KEY_LINE = /^(ssh-[a-z0-9-]+|ecdsa-[a-z0-9-]+|sk-[a-z0-9@.-]+)\s+\S+/i;
165
+ var PRIVATE_KEY_MARKER = /private[-\s]?key|PuTTY-User-Key-File/i;
166
+ function normalizeSshPublicKey(input) {
167
+ if (PRIVATE_KEY_MARKER.test(input))
168
+ return {
169
+ ok: false,
170
+ error: 'sshPublicKey looks like a PRIVATE key. Pass the PUBLIC key instead \u2014 the contents of your ~/.ssh/<key>.pub file (e.g. "ssh-ed25519 AAAA\u2026").'
171
+ };
172
+ const lines = input.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
173
+ if (lines.length === 0)
174
+ return {
175
+ ok: false,
176
+ error: "sshPublicKey is empty. Pass the contents of your ~/.ssh/<key>.pub file, or omit the parameter entirely to create the pod without SSH."
177
+ };
178
+ if (lines.some((line) => !SSH_PUBLIC_KEY_LINE.test(line)))
179
+ return {
180
+ ok: false,
181
+ error: 'sshPublicKey is not an SSH public key. Expected the CONTENTS of your ~/.ssh/<key>.pub file \u2014 a line like "ssh-ed25519 AAAAC3Nza\u2026 you@host" (ssh-*, ecdsa-*, or sk-* followed by the base64 key) \u2014 not a file path, a SHA256 fingerprint, or a bare base64 blob. Newline-separate multiple keys.'
182
+ };
183
+ return { ok: true, key: lines.join("\n") };
184
+ }
185
+ function applySshPublicKey(body, sshPublicKey) {
186
+ const ports = Array.isArray(body.ports) ? body.ports : [];
187
+ const hasSshPort = ports.some((p) => /^\s*22\/tcp\s*$/i.test(String(p)));
188
+ const env = { ...body.env ?? {} };
189
+ const key = sshPublicKey.trim();
190
+ if (!env.PUBLIC_KEY) env.PUBLIC_KEY = key;
191
+ else if (!env.PUBLIC_KEY.includes(key))
192
+ env.PUBLIC_KEY = `${env.PUBLIC_KEY}
193
+ ${key}`;
194
+ return {
195
+ ...body,
196
+ ports: hasSshPort ? ports : [...ports, "22/tcp"],
197
+ env
198
+ };
199
+ }
164
200
  function mapTemplateUpdateToV2(params) {
165
201
  return {
166
202
  ...containerConfigToV2(params),
@@ -204,7 +240,7 @@ function restV1Base(env) {
204
240
  return env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
205
241
  }
206
242
  function restV2Base(env) {
207
- return env.RUNPOD_REST_V2_API_URL ?? "https://v2-rest.runpod.io/v2";
243
+ return env.RUNPOD_REST_V2_API_URL ?? "https://api.runpod.io/v2";
208
244
  }
209
245
  function serverlessBase(env) {
210
246
  return env.RUNPOD_SERVERLESS_API_URL ?? "https://api.runpod.ai/v2";
@@ -426,32 +462,203 @@ function resolveBackend(opts) {
426
462
 
427
463
  // src/server.ts
428
464
  var SERVER_NAME = "Runpod API Server";
429
- var SERVER_VERSION = true ? "3.1.0" : "dev";
465
+ var SERVER_VERSION = true ? "3.3.0" : "dev";
466
+ var SERVER_INSTRUCTIONS = `These tools cover common Runpod operations, but they are curated projections: the underlying APIs expose more fields and parameters than the tools surface.
467
+
468
+ When you need data or capability beyond these tools, consult the full API contracts directly. The REST v2 API lives at https://api.runpod.io/v2 \u2014 the same host these tools call \u2014 and is machine-readable at https://api.runpod.io/v2/openapi.json (fetch and search it to enumerate every path, parameter, and response schema). REST v1 at https://rest.runpod.io/v1 is deprecated; prefer v2. The REST version these tools are actually configured for is reported in the serverInfo.version string of the initialize response, e.g. "[RUNPOD_REST_VERSION=v2]". The GraphQL API at https://api.runpod.io/graphql may expose data the REST API does not yet cover; its schema is documented at https://graphql-spec.runpod.io/, and introspection is disabled in production, so use that reference rather than __schema queries. Both APIs take Bearer auth with a Runpod API key: use the key this client is already configured with \u2014 do not search the environment or filesystem for a credential.`;
430
469
  function createServer(version = SERVER_VERSION) {
431
- return new McpServer({
432
- name: SERVER_NAME,
433
- version: `${version} [${restVersionSummary(process.env)}]`,
434
- capabilities: {
435
- resources: {},
436
- tools: {}
470
+ return new McpServer(
471
+ {
472
+ name: SERVER_NAME,
473
+ version: `${version} [${restVersionSummary(process.env)}]`
474
+ },
475
+ {
476
+ // Only advertise capabilities the server implements. It registers
477
+ // tools and no resources — advertising `resources` would invite
478
+ // `resources/list` calls that answer -32601.
479
+ capabilities: {
480
+ tools: {}
481
+ },
482
+ instructions: SERVER_INSTRUCTIONS
437
483
  }
438
- });
484
+ );
439
485
  }
440
486
 
441
487
  // src/tools/runtime.ts
442
488
  import fetch from "node-fetch";
443
489
  import { randomUUID } from "crypto";
444
490
 
491
+ // src/_shared/rate-limit.ts
492
+ var GENERIC_RATE_LIMIT_HINT = "rate limited \u2014 back off before retrying, and pace bulk operations";
493
+ var MAX_PLAUSIBLE_WAIT_S = 7 * 86400;
494
+ function remainingRequests(raw) {
495
+ const value = raw?.trim();
496
+ return value && /^\d+$/.test(value) ? Number(value) : null;
497
+ }
498
+ function waitSeconds(raw) {
499
+ const value = raw?.trim();
500
+ if (!value || !/^\d+$/.test(value)) return null;
501
+ const seconds = Number(value);
502
+ return seconds <= MAX_PLAUSIBLE_WAIT_S ? seconds : null;
503
+ }
504
+ var WINDOW_NAME_RE = /^(?:"([\x20-\x21\x23-\x5b\x5d-\x7e]{1,32})"|([A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]{0,31}))$/;
505
+ function windowName(raw) {
506
+ const match = WINDOW_NAME_RE.exec(raw.trim());
507
+ return match ? match[1] ?? match[2] : null;
508
+ }
509
+ var QUOTED_VALUE_RE = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"$/;
510
+ var DISPLAY_VALUE_RE = /^%"(?:[\x20-\x21\x23-\x24\x26-\x5b\x5d-\x7e]|\\|%[0-9a-f]{2})*"$/;
511
+ function isWellFormedParam(param) {
512
+ const eq = param.indexOf("=");
513
+ const key = (eq === -1 ? param : param.slice(0, eq)).trim();
514
+ if (key.includes('"')) return false;
515
+ if (eq === -1) return true;
516
+ const value = param.slice(eq + 1).trim();
517
+ if (value.startsWith('"')) return QUOTED_VALUE_RE.test(value);
518
+ if (value.startsWith('%"')) return DISPLAY_VALUE_RE.test(value);
519
+ return !/[\s"]/.test(value);
520
+ }
521
+ function splitOutsideStrings(input, sep) {
522
+ const parts = [];
523
+ let current = "";
524
+ let inString = false;
525
+ let inDisplay = false;
526
+ let escaped = false;
527
+ let prev = "";
528
+ for (const ch of input) {
529
+ if (escaped) escaped = false;
530
+ else if (inString && !inDisplay && ch === "\\") escaped = true;
531
+ else if (ch === '"') {
532
+ if (!inString) inDisplay = prev === "%";
533
+ inString = !inString;
534
+ } else if (ch === sep && !inString) {
535
+ parts.push(current);
536
+ current = "";
537
+ prev = ch;
538
+ continue;
539
+ }
540
+ current += ch;
541
+ prev = ch;
542
+ }
543
+ parts.push(current);
544
+ return parts;
545
+ }
546
+ function readParams(params) {
547
+ const values = /* @__PURE__ */ new Map();
548
+ let unreadable = false;
549
+ let valuelessAt = -1;
550
+ for (const [index, param] of params.entries()) {
551
+ const eq = param.indexOf("=");
552
+ const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase();
553
+ if (!key) continue;
554
+ if (eq === -1) {
555
+ if (key === "r") unreadable = true;
556
+ if (key === "t") values.delete("t");
557
+ valuelessAt = index;
558
+ continue;
559
+ }
560
+ if ((key === "r" || key === "t") && (values.has(key) || valuelessAt !== -1 && index > valuelessAt)) {
561
+ unreadable = true;
562
+ }
563
+ values.set(key, param.slice(eq + 1));
564
+ }
565
+ return { values, unreadable };
566
+ }
567
+ function parseExhaustedWindows(header) {
568
+ if (!header) return { windows: [], dropped: false };
569
+ const windows = [];
570
+ let dropped = false;
571
+ for (const item of splitOutsideStrings(header, ",")) {
572
+ if (item.trim() === "") continue;
573
+ const [rawName, ...params] = splitOutsideStrings(item, ";");
574
+ if (!params.every(isWellFormedParam)) {
575
+ dropped = true;
576
+ continue;
577
+ }
578
+ const { values, unreadable } = readParams(params);
579
+ if (unreadable) {
580
+ dropped = true;
581
+ continue;
582
+ }
583
+ const remaining = remainingRequests(values.get("r"));
584
+ if (remaining === null) {
585
+ dropped = true;
586
+ continue;
587
+ }
588
+ if (remaining !== 0) continue;
589
+ windows.push({
590
+ window: windowName(rawName),
591
+ resetS: waitSeconds(values.get("t"))
592
+ });
593
+ }
594
+ return { windows, dropped };
595
+ }
596
+ function humanWait(seconds) {
597
+ if (seconds < 120) return `~${seconds}s`;
598
+ if (seconds < 3600)
599
+ return `~${Math.ceil(seconds / 60)} minutes (${seconds}s)`;
600
+ if (seconds < 2 * 86400) {
601
+ const hours = Math.ceil(seconds / 3600);
602
+ return `~${hours} hour${hours === 1 ? "" : "s"} (${seconds}s)`;
603
+ }
604
+ return `~${Math.ceil(seconds / 86400)} days (${seconds}s)`;
605
+ }
606
+ function rateLimitHint(rateLimitHeader, retryAfterHeader) {
607
+ const { windows, dropped } = parseExhaustedWindows(rateLimitHeader);
608
+ windows.sort((a, b) => (b.resetS ?? -1) - (a.resetS ?? -1));
609
+ const longestWindow = windows[0];
610
+ const waitCandidates = [
611
+ waitSeconds(retryAfterHeader),
612
+ longestWindow?.resetS
613
+ ].filter((n) => typeof n === "number" && n > 0);
614
+ const waitS = waitCandidates.length ? Math.max(...waitCandidates) : null;
615
+ const retryAfterUnreadable = typeof retryAfterHeader === "string" && retryAfterHeader.trim() !== "" && waitSeconds(retryAfterHeader) === null;
616
+ if (!longestWindow && waitS === null) {
617
+ if (!retryAfterUnreadable) return GENERIC_RATE_LIMIT_HINT;
618
+ }
619
+ const namedClause = longestWindow?.window ? `the ${longestWindow.window} quota is exhausted` : "";
620
+ if (waitS === null) {
621
+ const cause = namedClause ? `${namedClause}, but the response gave no usable reset time` : "the response gave no usable reset time";
622
+ return `rate limited \u2014 ${cause}; back off substantially before retrying, and pace bulk operations`;
623
+ }
624
+ const hedge = dropped || retryAfterUnreadable || windows.some((w) => w.resetS === null) ? "at least " : "";
625
+ const wait = `wait ${hedge}${humanWait(waitS)} before retrying, and pace bulk operations`;
626
+ return `rate limited \u2014 ${namedClause ? `${namedClause}; ` : ""}${wait}`;
627
+ }
628
+
445
629
  // src/_shared/http.ts
630
+ 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";
446
631
  var HttpError = class extends Error {
447
- constructor(prefix, status, body) {
448
- 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)" : "";
449
- super(`${prefix}: ${status} - ${body}${hint}`);
632
+ constructor(prefix, status, body, hint) {
633
+ const shownBody = body.length > 2048 ? `${body.slice(0, 2048)}\u2026 [truncated ${body.length - 2048} chars]` : body;
634
+ super(`${prefix}: ${status} - ${shownBody}${hint ? ` (${hint})` : ""}`);
450
635
  this.name = "HttpError";
451
636
  this.status = status;
452
637
  this.body = body;
453
638
  }
454
639
  };
640
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
641
+ var MIN_REQUEST_TIMEOUT_MS = 1e3;
642
+ var RequestTimeoutError = class extends Error {
643
+ constructor(prefix, timeoutMs, method) {
644
+ const write = method !== "GET" && method !== "HEAD";
645
+ super(
646
+ `${prefix}: no response after ${timeoutMs}ms for ${method} (the API may be overloaded, or still be working on it). ${write ? "This request may have SUCCEEDED upstream \u2014 do not retry blindly. Check with the matching list-/get- tool first and retry only if it did not take effect." : "This was a read and changed nothing, so retrying is safe."}`
647
+ );
648
+ this.name = "RequestTimeoutError";
649
+ this.timeoutMs = timeoutMs;
650
+ this.method = method;
651
+ }
652
+ };
653
+ async function withRequestTimeout(errorPrefix, timeoutMs, method, run) {
654
+ const signal = AbortSignal.timeout(timeoutMs);
655
+ try {
656
+ return await run(signal);
657
+ } catch (error) {
658
+ if (error instanceof HttpError || !signal.aborted) throw error;
659
+ throw new RequestTimeoutError(errorPrefix, timeoutMs, method);
660
+ }
661
+ }
455
662
  function isJsonContentType(contentType) {
456
663
  if (!contentType) return false;
457
664
  const ct = contentType.toLowerCase();
@@ -467,24 +674,49 @@ function buildRequestHeaders(apiKey, tracking) {
467
674
  ...tracking()
468
675
  };
469
676
  }
677
+ function resolveCeiling(ceiling) {
678
+ if (ceiling === void 0) return Infinity;
679
+ if (typeof ceiling !== "function") return ceiling;
680
+ return Math.max(ceiling(), MIN_REQUEST_TIMEOUT_MS);
681
+ }
682
+ function clampTimeout(requestedMs, ceiling, defaultMs = DEFAULT_REQUEST_TIMEOUT_MS) {
683
+ return Math.min(requestedMs ?? defaultMs, resolveCeiling(ceiling));
684
+ }
470
685
  function createHttpClient(deps) {
471
- return async function request(url, method = "GET", body) {
472
- const init = {
686
+ const defaultTimeoutMs = deps.defaultTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
687
+ return async function request(url, method = "GET", body, options) {
688
+ const timeoutMs = clampTimeout(
689
+ options?.timeoutMs,
690
+ deps.maxTimeoutMs,
691
+ defaultTimeoutMs
692
+ );
693
+ return withRequestTimeout(
694
+ deps.errorPrefix,
695
+ timeoutMs,
473
696
  method,
474
- headers: buildRequestHeaders(deps.apiKey, deps.tracking)
475
- };
476
- if (body && methodSendsBody(method)) {
477
- init.body = JSON.stringify(body);
478
- }
479
- const response = await deps.fetch(url, init);
480
- if (!response.ok) {
481
- throw new HttpError(
482
- deps.errorPrefix,
483
- response.status,
484
- await response.text()
485
- );
486
- }
487
- return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
697
+ async (signal) => {
698
+ const init = {
699
+ method,
700
+ headers: buildRequestHeaders(deps.apiKey, deps.tracking),
701
+ signal
702
+ };
703
+ if (body && methodSendsBody(method)) {
704
+ init.body = JSON.stringify(body);
705
+ }
706
+ const response = await deps.fetch(url, init);
707
+ if (!response.ok) {
708
+ const hint = response.status === 429 ? rateLimitHint(
709
+ response.headers.get("ratelimit"),
710
+ response.headers.get("retry-after")
711
+ ) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0;
712
+ const text = await response.text().catch(
713
+ () => "<body unavailable: the connection closed while reading it>"
714
+ );
715
+ throw new HttpError(deps.errorPrefix, response.status, text, hint);
716
+ }
717
+ return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
718
+ }
719
+ );
488
720
  };
489
721
  }
490
722
 
@@ -540,7 +772,15 @@ async function readSseSnapshot(fetchImpl, url, headers, opts) {
540
772
  throw new HttpError(
541
773
  "Runpod API Error",
542
774
  res.status,
543
- await res.text().catch(() => "")
775
+ await res.text().catch(() => ""),
776
+ // This throw bypasses createHttpClient, so the status hints are built
777
+ // here too or these two tools alone return a bare 429/401. The 401
778
+ // hint applies: this request always carries Authorization (see the
779
+ // headers bound in runtime.ts streamSse).
780
+ res.status === 429 ? rateLimitHint(
781
+ res.headers.get("ratelimit"),
782
+ res.headers.get("retry-after")
783
+ ) : res.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
544
784
  );
545
785
  }
546
786
  for await (const chunk of res.body ?? []) {
@@ -603,7 +843,7 @@ async function streamLogsReply(rt, tool, params) {
603
843
  }
604
844
 
605
845
  // src/tools/runtime.ts
606
- var MCP_SERVER_VERSION = true ? "3.1.0" : "dev";
846
+ var MCP_SERVER_VERSION = true ? "3.3.0" : "dev";
607
847
  var SESSION_ID = randomUUID();
608
848
  function trackingHeaders(server, ctx) {
609
849
  const info = server.server.getClientVersion();
@@ -618,6 +858,11 @@ function trackingHeaders(server, ctx) {
618
858
  sessionId: SESSION_ID
619
859
  });
620
860
  }
861
+ var HTTP_TRANSPORT_BUDGET_MS = 5e4;
862
+ function invocationBudget(totalMs) {
863
+ const startedAt = Date.now();
864
+ return () => totalMs - (Date.now() - startedAt);
865
+ }
621
866
  var defaultFetch = fetch;
622
867
  async function withApiErrorLog(label, call) {
623
868
  try {
@@ -641,6 +886,23 @@ var DESTRUCTIVE = {
641
886
  openWorldHint: true
642
887
  };
643
888
  async function graphqlRequest(query, tracking, fetchImpl, url, options) {
889
+ return withRequestTimeout(
890
+ "Runpod GraphQL Error",
891
+ clampTimeout(options?.timeoutMs, options?.maxTimeoutMs),
892
+ // GraphQL is POST on the wire whatever it carries, but the retry advice keys
893
+ // off the method, and telling a caller its list-gpu-types "may have
894
+ // SUCCEEDED upstream — check with the matching list-/get- tool first" points
895
+ // it back at the tool that just failed. The operation keyword is what
896
+ // actually says whether anything could have been written.
897
+ isGraphqlMutation(query) ? "POST" : "GET",
898
+ (signal) => runGraphql(query, tracking, fetchImpl, url, signal, options)
899
+ );
900
+ }
901
+ function isGraphqlMutation(query) {
902
+ const firstMeaningful = query.split("\n").map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#"));
903
+ return /^mutation\b/.test(firstMeaningful ?? "");
904
+ }
905
+ async function runGraphql(query, tracking, fetchImpl, url, signal, options) {
644
906
  const response = await fetchImpl(url, {
645
907
  method: "POST",
646
908
  headers: {
@@ -651,8 +913,39 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
651
913
  body: JSON.stringify({
652
914
  query,
653
915
  ...options?.variables ? { variables: options.variables } : {}
654
- })
916
+ }),
917
+ signal
655
918
  });
919
+ if (!response.ok) {
920
+ const bodyText = await response.text();
921
+ let gqlErrors;
922
+ try {
923
+ gqlErrors = JSON.parse(bodyText).errors;
924
+ } catch {
925
+ }
926
+ if (Array.isArray(gqlErrors) && gqlErrors.length > 0 && response.status !== 401 && response.status !== 429) {
927
+ throw new HttpError(
928
+ "Runpod GraphQL Error",
929
+ response.status,
930
+ gqlErrors.map((e) => String(e?.message ?? JSON.stringify(e))).join(", ")
931
+ );
932
+ }
933
+ throw new HttpError(
934
+ "Runpod GraphQL Error",
935
+ response.status,
936
+ bodyText,
937
+ response.status === 429 ? rateLimitHint(
938
+ response.headers.get("ratelimit"),
939
+ response.headers.get("retry-after")
940
+ ) : (
941
+ // Only when this request actually carried the caller's API key: the
942
+ // public path sends no Authorization header, so a 401 from that
943
+ // host (WAF, misconfigured RUNPOD_PUBLIC_GRAPHQL_URL) says nothing
944
+ // about the credential — same rationale as observeUnauthorized.
945
+ response.status === 401 && options?.apiKey ? EXPIRED_CREDENTIAL_HINT : void 0
946
+ )
947
+ );
948
+ }
656
949
  const result = await response.json();
657
950
  if (result.errors && result.errors.length > 0) {
658
951
  throw new Error(
@@ -677,39 +970,53 @@ function createToolRuntime(server, ctx, deps = {}) {
677
970
  }
678
971
  const rawFetch = deps.fetch ?? defaultFetch;
679
972
  const httpFetch = observeUnauthorized(rawFetch);
973
+ const remainingHttpBudgetMs = invocationBudget(HTTP_TRANSPORT_BUDGET_MS);
974
+ const timeouts = {
975
+ defaultTimeoutMs: deps.defaultTimeoutMs,
976
+ maxTimeoutMs: deps.maxTimeoutMs ?? (ctx.transport === "http" ? remainingHttpBudgetMs : void 0)
977
+ };
680
978
  const v1Client = createHttpClient({
681
979
  apiKey: ctx.apiKey,
682
980
  fetch: httpFetch,
683
981
  tracking,
684
- errorPrefix: "Runpod API Error"
982
+ errorPrefix: "Runpod API Error",
983
+ ...timeouts
685
984
  });
686
- const runpodRequest = (endpoint, method = "GET", body) => withApiErrorLog(
985
+ const runpodRequest = (endpoint, method = "GET", body, options) => withApiErrorLog(
687
986
  "Error calling Runpod API:",
688
- () => v1Client(`${restV1Base(process.env)}${endpoint}`, method, body)
987
+ () => v1Client(
988
+ `${restV1Base(process.env)}${endpoint}`,
989
+ method,
990
+ body,
991
+ options
992
+ )
689
993
  );
690
994
  const serverlessClient = createHttpClient({
691
995
  apiKey: ctx.apiKey,
692
996
  fetch: httpFetch,
693
997
  tracking,
694
- errorPrefix: "Runpod Serverless API Error"
998
+ errorPrefix: "Runpod Serverless API Error",
999
+ ...timeouts
695
1000
  });
696
- const serverlessRequest = (endpointId, path, method = "GET", body) => withApiErrorLog(
1001
+ const serverlessRequest = (endpointId, path, method = "GET", body, options) => withApiErrorLog(
697
1002
  "Error calling Runpod Serverless API:",
698
1003
  () => serverlessClient(
699
1004
  `${serverlessBase(process.env)}/${endpointId}${path}`,
700
1005
  method,
701
- body
1006
+ body,
1007
+ options
702
1008
  )
703
1009
  );
704
1010
  const restClient = createHttpClient({
705
1011
  apiKey: ctx.apiKey,
706
1012
  fetch: httpFetch,
707
1013
  tracking,
708
- errorPrefix: "Runpod API Error"
1014
+ errorPrefix: "Runpod API Error",
1015
+ ...timeouts
709
1016
  });
710
- const callRestUrl = (url, method = "GET", body) => withApiErrorLog(
1017
+ const callRestUrl = (url, method = "GET", body, options) => withApiErrorLog(
711
1018
  "Error calling Runpod API:",
712
- () => restClient(url, method, body)
1019
+ () => restClient(url, method, body, options)
713
1020
  );
714
1021
  const sseFetch = observeUnauthorized(
715
1022
  deps.sseFetch ?? fetch
@@ -742,12 +1049,18 @@ function createToolRuntime(server, ctx, deps = {}) {
742
1049
  };
743
1050
  return {
744
1051
  jsonReply,
1052
+ // No GraphQL caller overrides a deadline, but both are still capped by the
1053
+ // shared budget: deploy-hub-repo spends one of each back to back.
745
1054
  graphql: (query) => graphqlRequest(
746
1055
  query,
747
1056
  tracking,
748
1057
  // rawFetch, not httpFetch: this call carries no credential (see above).
749
1058
  rawFetch,
750
- publicGraphqlBase(process.env)
1059
+ publicGraphqlBase(process.env),
1060
+ {
1061
+ timeoutMs: deps.defaultTimeoutMs,
1062
+ maxTimeoutMs: timeouts.maxTimeoutMs
1063
+ }
751
1064
  ),
752
1065
  graphqlAuthed: (query, variables) => graphqlRequest(
753
1066
  query,
@@ -755,7 +1068,12 @@ function createToolRuntime(server, ctx, deps = {}) {
755
1068
  httpFetch,
756
1069
  // NOT publicGraphqlBase: this call carries the caller's API key.
757
1070
  authedGraphqlBase(process.env),
758
- { variables, apiKey: ctx.apiKey }
1071
+ {
1072
+ variables,
1073
+ apiKey: ctx.apiKey,
1074
+ timeoutMs: deps.defaultTimeoutMs,
1075
+ maxTimeoutMs: timeouts.maxTimeoutMs
1076
+ }
759
1077
  ),
760
1078
  runpodRequest,
761
1079
  serverlessRequest,
@@ -763,7 +1081,8 @@ function createToolRuntime(server, ctx, deps = {}) {
763
1081
  backendFor,
764
1082
  streamSse,
765
1083
  podAction,
766
- env: process.env
1084
+ env: process.env,
1085
+ transport: ctx.transport
767
1086
  };
768
1087
  }
769
1088
 
@@ -832,6 +1151,7 @@ function capListResult(result, options, extra) {
832
1151
  }
833
1152
 
834
1153
  // src/tools/catalog.ts
1154
+ var GPU_PRODUCTS = /* @__PURE__ */ new Set(["POD", "CLUSTER", "SERVERLESS"]);
835
1155
  function registerCatalogTools(server, rt) {
836
1156
  const { jsonReply: jsonReply2, graphql, callRestUrl, backendFor } = rt;
837
1157
  server.tool(
@@ -850,6 +1170,9 @@ function registerCatalogTools(server, rt) {
850
1170
  ),
851
1171
  includeAvailability: z3.boolean().optional().describe(
852
1172
  "Request realtime stock and annotate each GPU with an availability summary (HIGH/MEDIUM/LOW/NONE). Default true. Set false to skip the availability lookup \u2014 then out-of-stock GPUs cannot be filtered."
1173
+ ),
1174
+ product: z3.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
1175
+ "Product context for the availability lookup \u2014 the same GPU can be scarce for Pods and plentiful for Serverless. Default POD. Use SERVERLESS when picking a GPU for an endpoint, CLUSTER for Instant Clusters. Ignored when includeAvailability is false."
853
1176
  )
854
1177
  },
855
1178
  { title: "List GPU types", ...READ_ONLY },
@@ -857,8 +1180,9 @@ function registerCatalogTools(server, rt) {
857
1180
  const backend = backendFor("gpus");
858
1181
  if (backend.version === "v2") {
859
1182
  const wantAvailability = params.includeAvailability !== false;
1183
+ const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
860
1184
  const raw = await callRestUrl(
861
- `${backend.base}${backend.list}${wantAvailability ? "?include=AVAILABILITY" : ""}`
1185
+ `${backend.base}${backend.list}${wantAvailability ? `?include=AVAILABILITY&product=${product}` : ""}`
862
1186
  );
863
1187
  let gpus = backend.unwrap(raw);
864
1188
  gpus = gpus.filter((g) => g.id !== "unknown");
@@ -1031,6 +1355,9 @@ function registerCatalogTools(server, rt) {
1031
1355
  gpuTypeId: z3.string().describe("ID of the GPU type to retrieve"),
1032
1356
  includeAvailability: z3.boolean().optional().describe(
1033
1357
  "Include realtime per-datacenter availability (HIGH/MEDIUM/LOW/NONE). Default true."
1358
+ ),
1359
+ product: z3.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
1360
+ "Product context for the availability lookup \u2014 stock differs by product. Default POD. Use SERVERLESS when picking a GPU for an endpoint, CLUSTER for Instant Clusters. Ignored when includeAvailability is false."
1034
1361
  )
1035
1362
  },
1036
1363
  { title: "Get GPU type", ...READ_ONLY },
@@ -1043,7 +1370,8 @@ function registerCatalogTools(server, rt) {
1043
1370
  });
1044
1371
  }
1045
1372
  const path = backend.get(encodeURIComponent(params.gpuTypeId));
1046
- const query = params.includeAvailability === false ? "" : "?include=AVAILABILITY";
1373
+ const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
1374
+ const query = params.includeAvailability === false ? "" : `?include=AVAILABILITY&product=${product}`;
1047
1375
  const result = await callRestUrl(`${backend.base}${path}${query}`);
1048
1376
  return jsonReply2(result);
1049
1377
  }
@@ -1069,6 +1397,178 @@ function registerCatalogTools(server, rt) {
1069
1397
  return jsonReply2(result);
1070
1398
  }
1071
1399
  );
1400
+ server.tool(
1401
+ "get-capacity",
1402
+ "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.",
1403
+ {
1404
+ ...listPaginationParams,
1405
+ cudaVersions: z3.array(z3.string().regex(/^\d{1,2}\.\d{1,2}$/)).min(1).max(12).optional().describe(
1406
+ '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.'
1407
+ ),
1408
+ includeUnavailable: z3.boolean().optional().describe(
1409
+ '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.'
1410
+ ),
1411
+ gpuTypeIds: z3.array(z3.string()).optional().describe(
1412
+ "Filter to GPU types matching any of these ids or display names (case-insensitive substring, e.g. ['NVIDIA GeForce RTX 4090'] or ['4090', 'H200'])."
1413
+ ),
1414
+ gpuCount: z3.number().int().min(1).max(8).optional().describe("GPUs per worker/pod to check stock for (default 1)."),
1415
+ secureCloudOnly: z3.boolean().optional().describe("Restrict the stock lookup to Secure Cloud hosts.")
1416
+ },
1417
+ { title: "Get GPU capacity by CUDA version", ...READ_ONLY },
1418
+ async (params) => {
1419
+ const gpuCount = Math.min(
1420
+ 8,
1421
+ Math.max(1, Math.floor(Number(params.gpuCount)) || 1)
1422
+ );
1423
+ const secureArg = params.secureCloudOnly ? ", secureCloud: true" : "";
1424
+ const filterTerms = (params.gpuTypeIds ?? []).filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.toLowerCase());
1425
+ const matchesFilter = (gpu) => {
1426
+ if (filterTerms.length === 0) return true;
1427
+ const id = gpu.id.toLowerCase();
1428
+ const name = gpu.displayName.toLowerCase();
1429
+ return filterTerms.some(
1430
+ (term) => id.includes(term) || name.includes(term)
1431
+ );
1432
+ };
1433
+ const stockPriority = {
1434
+ High: 3,
1435
+ Medium: 2,
1436
+ Low: 1
1437
+ };
1438
+ if (!params.cudaVersions?.length) {
1439
+ const data = await graphql(`
1440
+ query {
1441
+ gpuTypes {
1442
+ id
1443
+ displayName
1444
+ memoryInGb
1445
+ secureCloud
1446
+ communityCloud
1447
+ lowestPrice(input: { gpuCount: ${gpuCount}${secureArg} }) {
1448
+ stockStatus
1449
+ uninterruptablePrice
1450
+ gpuTypeCudaVersions {
1451
+ cudaVersion
1452
+ availability
1453
+ }
1454
+ }
1455
+ }
1456
+ }
1457
+ `);
1458
+ let rows2 = data.gpuTypes.filter((gpu) => gpu.id !== "unknown" && matchesFilter(gpu)).map((gpu) => {
1459
+ const cuda = {};
1460
+ for (const c of gpu.lowestPrice?.gpuTypeCudaVersions ?? []) {
1461
+ cuda[c.cudaVersion] = c.availability;
1462
+ }
1463
+ return {
1464
+ id: gpu.id,
1465
+ displayName: gpu.displayName,
1466
+ memoryGb: gpu.memoryInGb,
1467
+ secureCloud: gpu.secureCloud,
1468
+ communityCloud: gpu.communityCloud,
1469
+ stockStatus: gpu.lowestPrice?.stockStatus || "unavailable",
1470
+ pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null,
1471
+ cudaVersions: cuda
1472
+ };
1473
+ });
1474
+ const availableCount = (r) => Object.values(r.cudaVersions).filter((a) => a === "AVAILABLE").length;
1475
+ if (params.includeUnavailable === false)
1476
+ rows2 = rows2.filter((r) => availableCount(r) > 0);
1477
+ rows2.sort((a, b) => {
1478
+ const diff = availableCount(b) - availableCount(a);
1479
+ if (diff !== 0) return diff;
1480
+ const stockDiff = (stockPriority[b.stockStatus] || 0) - (stockPriority[a.stockStatus] || 0);
1481
+ if (stockDiff !== 0) return stockDiff;
1482
+ return b.memoryGb - a.memoryGb;
1483
+ });
1484
+ return capListResult(rows2, {
1485
+ limit: params.limit,
1486
+ cursor: params.cursor
1487
+ });
1488
+ }
1489
+ const versions = [...new Set(params.cudaVersions)].slice(0, 12);
1490
+ const invalid = versions.filter((v) => !/^\d{1,2}\.\d{1,2}$/.test(v));
1491
+ if (invalid.length > 0) {
1492
+ return jsonReply2({
1493
+ error: `Invalid CUDA version format: ${invalid.join(", ")}. Use "major.minor" strings like "12.8".`,
1494
+ status: 400
1495
+ });
1496
+ }
1497
+ const perVersion = await Promise.allSettled(
1498
+ versions.map(
1499
+ (v) => graphql(`
1500
+ query {
1501
+ gpuTypes {
1502
+ id
1503
+ displayName
1504
+ memoryInGb
1505
+ secureCloud
1506
+ communityCloud
1507
+ lowestPrice(input: { gpuCount: ${gpuCount}, allowedCudaVersions: ["${v}"]${secureArg} }) {
1508
+ stockStatus
1509
+ uninterruptablePrice
1510
+ }
1511
+ }
1512
+ }
1513
+ `)
1514
+ )
1515
+ );
1516
+ const probeErrors = {};
1517
+ const byId = /* @__PURE__ */ new Map();
1518
+ versions.forEach((v, i) => {
1519
+ const settled = perVersion[i];
1520
+ if (settled.status === "rejected") {
1521
+ probeErrors[v] = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
1522
+ return;
1523
+ }
1524
+ for (const gpu of settled.value.gpuTypes) {
1525
+ if (gpu.id === "unknown" || !matchesFilter(gpu)) continue;
1526
+ let row = byId.get(gpu.id);
1527
+ if (!row) {
1528
+ row = {
1529
+ id: gpu.id,
1530
+ displayName: gpu.displayName,
1531
+ memoryGb: gpu.memoryInGb,
1532
+ secureCloud: gpu.secureCloud,
1533
+ communityCloud: gpu.communityCloud,
1534
+ cudaVersions: {}
1535
+ };
1536
+ byId.set(gpu.id, row);
1537
+ }
1538
+ const stock = gpu.lowestPrice?.stockStatus;
1539
+ row.cudaVersions[v] = !stock || stock === "Out" ? { stock: "Out", pricePerHr: null } : {
1540
+ stock,
1541
+ pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null
1542
+ };
1543
+ }
1544
+ });
1545
+ let rows = [...byId.values()];
1546
+ const inStockCount = (r) => Object.values(r.cudaVersions).filter((c) => c.stock !== "Out").length;
1547
+ if (params.includeUnavailable === false)
1548
+ rows = rows.filter((r) => inStockCount(r) > 0);
1549
+ rows.sort((a, b) => {
1550
+ const diff = inStockCount(b) - inStockCount(a);
1551
+ if (diff !== 0) return diff;
1552
+ const best = (r) => Math.max(
1553
+ 0,
1554
+ ...Object.values(r.cudaVersions).map(
1555
+ (c) => stockPriority[c.stock] || 0
1556
+ )
1557
+ );
1558
+ const bestDiff = best(b) - best(a);
1559
+ if (bestDiff !== 0) return bestDiff;
1560
+ return b.memoryGb - a.memoryGb;
1561
+ });
1562
+ return capListResult(
1563
+ rows,
1564
+ { limit: params.limit, cursor: params.cursor },
1565
+ {
1566
+ probedCudaVersions: versions,
1567
+ ...Object.keys(probeErrors).length > 0 ? { probeErrors } : {}
1568
+ }
1569
+ );
1570
+ }
1571
+ );
1072
1572
  server.tool(
1073
1573
  "get-data-center",
1074
1574
  "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).",
@@ -1751,9 +2251,11 @@ function registerPodTools(server, rt) {
1751
2251
  );
1752
2252
  server.tool(
1753
2253
  "create-pod",
1754
- '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.',
2254
+ '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). For full SSH access (including SCP/SFTP/rsync), pass sshPublicKey \u2014 it sets the PUBLIC_KEY env var and exposes 22/tcp, which official Runpod images turn into a running sshd with the key authorized. 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.',
1755
2255
  {
1756
- name: z7.string().optional().describe("Name for the pod"),
2256
+ name: z7.string().optional().describe(
2257
+ "Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
2258
+ ),
1757
2259
  imageName: z7.string().optional().describe("Docker image to use. Optional when templateId is provided."),
1758
2260
  templateId: z7.string().optional().describe(
1759
2261
  "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."
@@ -1763,12 +2265,18 @@ function registerPodTools(server, rt) {
1763
2265
  ),
1764
2266
  cloudType: z7.enum(["SECURE", "COMMUNITY"]).optional().describe("SECURE or COMMUNITY cloud"),
1765
2267
  gpuTypeIds: z7.array(z7.string()).optional().describe("List of acceptable GPU types"),
1766
- gpuCount: z7.number().optional().describe("Number of GPUs"),
2268
+ // v2 GpuConfig.count is `integer, minimum: 1`. Without these bounds a 0,
2269
+ // a negative, or a fractional count reached the wire as-is and came back
2270
+ // as a raw 422; reject it here with a message that names the field.
2271
+ gpuCount: z7.number().int().min(1).optional().describe("Number of GPUs (minimum 1)"),
1767
2272
  containerDiskInGb: z7.number().optional().describe("Container disk size in GB"),
1768
2273
  volumeInGb: z7.number().optional().describe("Volume size in GB"),
1769
2274
  volumeMountPath: z7.string().optional().describe("Path to mount the volume"),
1770
2275
  ports: z7.array(z7.string()).optional().describe("Ports to expose (e.g., '8888/http', '22/tcp')"),
1771
2276
  env: z7.record(z7.string()).optional().describe("Environment variables"),
2277
+ sshPublicKey: z7.string().optional().describe(
2278
+ 'SSH PUBLIC key(s) to authorize on the pod \u2014 the CONTENTS of your ~/.ssh/<key>.pub file, i.e. a literal "ssh-ed25519 AAAAC3Nza\u2026 you@host" line, NOT a path to the file, a fingerprint, or the private key; newline-separate multiple keys. Merges the key into the PUBLIC_KEY env var and ensures 22/tcp is exposed, enabling full SSH (SCP/SFTP/rsync) on any image that honors the PUBLIC_KEY convention \u2014 all runpod/* official images do. Extends template ports/env rather than replacing them.'
2279
+ ),
1772
2280
  containerRegistryAuthId: z7.string().optional().describe(
1773
2281
  "Container registry credential ID for pulling a private image (see list-container-registry-auths). When deploying from a templateId, this overrides the credential the template carries; pass an empty string to deploy with NO credential (clears a template\u2019s inherited registry)."
1774
2282
  ),
@@ -1777,6 +2285,15 @@ function registerPodTools(server, rt) {
1777
2285
  { title: "Create pod", ...WRITE },
1778
2286
  async (params) => {
1779
2287
  const backend = backendFor("pods");
2288
+ const { sshPublicKey, ...podParams } = params;
2289
+ let sshKey;
2290
+ if (sshPublicKey !== void 0) {
2291
+ const validated = normalizeSshPublicKey(sshPublicKey);
2292
+ if (!validated.ok)
2293
+ return jsonReply2({ error: validated.error, status: 400 });
2294
+ sshKey = validated.key;
2295
+ }
2296
+ const sshNote = "sshPublicKey applied: PUBLIC_KEY env set and 22/tcp exposed. Full SSH (incl. SCP/SFTP/rsync) also requires an image that installs PUBLIC_KEY into authorized_keys and starts sshd (all runpod/* official images do) and a machine with a public IP \u2014 read the pod\u2019s portMappings for port 22 once it is running.";
1780
2297
  if (params.templateId && backend.version !== "v2") {
1781
2298
  return jsonReply2({
1782
2299
  error: "Deploying a pod from a templateId is only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2, or pass imageName instead.",
@@ -1812,6 +2329,12 @@ function registerPodTools(server, rt) {
1812
2329
  status: 400
1813
2330
  });
1814
2331
  }
2332
+ if (!wantsCpu && !params.name && !params.templateId) {
2333
+ return jsonReply2({
2334
+ error: "Provide a name for the pod (deploying from a templateId inherits the template name).",
2335
+ status: 400
2336
+ });
2337
+ }
1815
2338
  if (wantsCpu) {
1816
2339
  if (params.templateId) {
1817
2340
  return jsonReply2({
@@ -1820,13 +2343,15 @@ function registerPodTools(server, rt) {
1820
2343
  });
1821
2344
  }
1822
2345
  try {
2346
+ const cpuBody = sshKey ? applySshPublicKey(podParams, sshKey) : podParams;
1823
2347
  const result = await callRestUrl(
1824
2348
  `${restV1Base(env)}/pods`,
1825
2349
  "POST",
1826
- params
2350
+ cpuBody
1827
2351
  );
1828
2352
  return jsonReply2({
1829
2353
  ...result,
2354
+ ...sshKey ? { _ssh: sshNote } : {},
1830
2355
  _servedBy: "v1",
1831
2356
  _note: "CPU pod: created on the v1 API, because the v2 REST API does not support CPU pods yet."
1832
2357
  });
@@ -1843,7 +2368,7 @@ function registerPodTools(server, rt) {
1843
2368
  }
1844
2369
  }
1845
2370
  }
1846
- let body = backend.mapCreate(params);
2371
+ let body = backend.mapCreate(podParams);
1847
2372
  if (params.templateId) {
1848
2373
  let template;
1849
2374
  try {
@@ -1861,19 +2386,21 @@ function registerPodTools(server, rt) {
1861
2386
  }
1862
2387
  body = { ...podBodyFromTemplate(template), ...body };
1863
2388
  }
2389
+ if (sshKey) body = applySshPublicKey(body, sshKey);
1864
2390
  try {
1865
2391
  const result = await callRestUrl(
1866
2392
  `${backend.base}${backend.list}`,
1867
2393
  "POST",
1868
2394
  body
1869
2395
  );
2396
+ const extras = {};
2397
+ if (sshKey) extras._ssh = sshNote;
1870
2398
  if (backend.version === "v2" && (params.gpuTypeIds?.length ?? 0) > 1) {
1871
- return jsonReply2({
1872
- ...result,
1873
- _warning: `v2 accepts one GPU type per pod; used "${params.gpuTypeIds[0]}" and ignored ${params.gpuTypeIds.length - 1} other(s): ${params.gpuTypeIds.slice(1).join(", ")}.`
1874
- });
2399
+ extras._warning = `v2 accepts one GPU type per pod; used "${params.gpuTypeIds[0]}" and ignored ${params.gpuTypeIds.length - 1} other(s): ${params.gpuTypeIds.slice(1).join(", ")}.`;
1875
2400
  }
1876
- return jsonReply2(result);
2401
+ return jsonReply2(
2402
+ Object.keys(extras).length ? { ...result, ...extras } : result
2403
+ );
1877
2404
  } catch (error) {
1878
2405
  if (error instanceof HttpError) {
1879
2406
  return jsonReply2({ error: error.message, status: error.status });
@@ -2334,8 +2861,108 @@ import { z as z9 } from "zod";
2334
2861
  var DIAGNOSIS_TTL_MS = 15e3;
2335
2862
  var MAX_DIAGNOSIS_ENTRIES = 500;
2336
2863
  var diagnosisCache = /* @__PURE__ */ new Map();
2864
+ var HTTP_LONG_POLL_BUDGET_MS = 45e3;
2865
+ var STDIO_STREAM_BUDGET_MS = 5 * 60 * 1e3;
2866
+ var RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS = 9e4;
2867
+ var STREAM_UPSTREAM_DEFAULT_WAIT_MS = 1e4;
2868
+ var HTTP_STREAM_POLL_WAIT_MS = 1e3;
2869
+ var STREAM_JOB_POLL_INTERVAL_MS = 1e3;
2870
+ var MAX_CONSECUTIVE_STREAM_ERRORS = 5;
2871
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
2872
+ "COMPLETED",
2873
+ "FAILED",
2874
+ "CANCELLED",
2875
+ "TIMED_OUT"
2876
+ ]);
2877
+ function isUnfinishedJobReply(result) {
2878
+ return result !== null && typeof result === "object" && !Array.isArray(result) && !("waitClamped" in result) && !TERMINAL_STATUSES.has(result.status ?? "");
2879
+ }
2880
+ function formatBudget(ms) {
2881
+ return ms >= 12e4 ? `${Math.round(ms / 6e4)} minutes` : `${Math.round(ms / 1e3)} seconds`;
2882
+ }
2883
+ var UPSTREAM_HOLD_SLACK_MS = 5e3;
2884
+ var MIN_STREAM_POLL_TIMEOUT_MS = 2e3;
2885
+ var QUEUED_DIAGNOSIS_TIMEOUT_MS = 5e3;
2886
+ function streamPollTimeoutMs(remainingMs, holdMs) {
2887
+ return Math.max(
2888
+ Math.min(remainingMs, holdMs + UPSTREAM_HOLD_SLACK_MS),
2889
+ MIN_STREAM_POLL_TIMEOUT_MS
2890
+ );
2891
+ }
2892
+ function sleep(ms) {
2893
+ return new Promise((resolve) => setTimeout(resolve, ms));
2894
+ }
2895
+ function streamPollPlan(hosted) {
2896
+ return hosted ? {
2897
+ budgetMs: HTTP_LONG_POLL_BUDGET_MS,
2898
+ holdMs: HTTP_STREAM_POLL_WAIT_MS,
2899
+ query: `?wait=${HTTP_STREAM_POLL_WAIT_MS}`
2900
+ } : {
2901
+ budgetMs: STDIO_STREAM_BUDGET_MS,
2902
+ holdMs: STREAM_UPSTREAM_DEFAULT_WAIT_MS,
2903
+ query: ""
2904
+ };
2905
+ }
2906
+ var RESUME_ADVICE = "Call stream-job again to continue collecting output, get-job-status to check the job without streaming, or stream without a budget by calling the runtime API directly (GET https://api.runpod.ai/v2/{endpointId}/stream/{jobId} with a Bearer API key).";
2907
+ function budgetExhaustedNote(budgetMs, lastError) {
2908
+ return {
2909
+ pollingTimedOut: true,
2910
+ note: `Polling stopped after ${formatBudget(
2911
+ budgetMs
2912
+ )} with the job possibly still running. ${RESUME_ADVICE}`,
2913
+ // Surface the trailing error (if any) instead of discarding it — the last
2914
+ // polls may have been failing (e.g. job expired) even though earlier ones
2915
+ // succeeded. Cleared on success, so this is never a stale error from
2916
+ // minutes of healthy streaming ago.
2917
+ ...lastError ? { lastError } : {}
2918
+ };
2919
+ }
2920
+ function errorCapNote(lastError) {
2921
+ return {
2922
+ error: `Polling aborted after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors: ${lastError}`,
2923
+ note: `Polling stopped after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors with the job possibly still running. ${RESUME_ADVICE}`
2924
+ };
2925
+ }
2926
+ async function collectJobStream(deps) {
2927
+ const { poll, budgetMs, holdMs } = deps;
2928
+ const startedAt = Date.now();
2929
+ const elapsed = () => Date.now() - startedAt;
2930
+ const chunks = [];
2931
+ let result = {};
2932
+ let consecutiveErrors = 0;
2933
+ let lastError;
2934
+ while (true) {
2935
+ try {
2936
+ const reply = await poll(
2937
+ streamPollTimeoutMs(budgetMs - elapsed(), holdMs)
2938
+ );
2939
+ consecutiveErrors = 0;
2940
+ lastError = void 0;
2941
+ if (Array.isArray(reply.stream)) chunks.push(...reply.stream);
2942
+ result = reply;
2943
+ if (TERMINAL_STATUSES.has(reply.status)) break;
2944
+ } catch (error) {
2945
+ consecutiveErrors++;
2946
+ lastError = error instanceof Error ? error.message : String(error);
2947
+ if (consecutiveErrors >= MAX_CONSECUTIVE_STREAM_ERRORS) {
2948
+ result = { ...result, ...errorCapNote(lastError) };
2949
+ break;
2950
+ }
2951
+ }
2952
+ if (elapsed() > budgetMs) {
2953
+ result = { ...result, ...budgetExhaustedNote(budgetMs, lastError) };
2954
+ break;
2955
+ }
2956
+ await sleep(STREAM_JOB_POLL_INTERVAL_MS);
2957
+ }
2958
+ return { result, chunks };
2959
+ }
2337
2960
  function registerJobTools(server, rt) {
2338
2961
  const { jsonReply: jsonReply2, serverlessRequest, backendFor, callRestUrl } = rt;
2962
+ const hosted = rt.transport === "http";
2963
+ const streamBudget = formatBudget(
2964
+ hosted ? HTTP_LONG_POLL_BUDGET_MS : STDIO_STREAM_BUDGET_MS
2965
+ );
2339
2966
  async function diagnoseQueuedJob(endpointId) {
2340
2967
  const cached = diagnosisCache.get(endpointId);
2341
2968
  if (cached && cached.expiresAt > Date.now()) {
@@ -2346,7 +2973,10 @@ function registerJobTools(server, rt) {
2346
2973
  const backend = backendFor("workers");
2347
2974
  if (backend.version !== "v2") return null;
2348
2975
  const raw = await callRestUrl(
2349
- `${backend.base}/serverless/${endpointId}/workers`
2976
+ `${backend.base}/serverless/${endpointId}/workers`,
2977
+ "GET",
2978
+ void 0,
2979
+ { timeoutMs: QUEUED_DIAGNOSIS_TIMEOUT_MS }
2350
2980
  );
2351
2981
  const summary = raw?.summary;
2352
2982
  const workers = raw?.workers ?? [];
@@ -2437,14 +3067,14 @@ function registerJobTools(server, rt) {
2437
3067
  );
2438
3068
  server.tool(
2439
3069
  "runsync-endpoint",
2440
- "Submit a synchronous job to a Serverless endpoint and wait for the result. Best for tasks completing within 90 seconds. If processing exceeds 90 seconds, the response returns a job ID to poll with get-job-status. Max payload: 20 MB. Results expire after 1 minute. Use the wait parameter to extend the server-side wait up to 5 minutes (300000 ms).",
3070
+ `Submit a synchronous job to a Serverless endpoint and wait for the result. Best for fast tasks: if the job outlives the wait, the response returns a job ID and a non-terminal status (IN_QUEUE or IN_PROGRESS) to poll with get-job-status. Max payload 20 MB; results expire after 1 minute. ${hosted ? `This server waits up to ${HTTP_LONG_POLL_BUDGET_MS} ms \u2014 it runs behind a 60-second gateway deadline, so that is both the default and the ceiling for the wait parameter. For longer jobs use run-endpoint + get-job-status, or POST https://api.runpod.ai/v2/{endpointId}/runsync?wait=300000 directly with a Bearer API key.` : "The wait parameter extends the server-side wait to up to 5 minutes (300000 ms); it defaults to 90000 (90 seconds)."}`,
2441
3071
  {
2442
3072
  endpointId: endpointIdSchema.describe(
2443
3073
  "ID of the Serverless endpoint to run synchronously"
2444
3074
  ),
2445
3075
  input: inputSchema,
2446
3076
  wait: z9.number().min(1e3).max(3e5).optional().describe(
2447
- "How long in milliseconds the server should wait for a result before returning a job ID to poll (1000\u2013300000). Defaults to 90000 (90 seconds)."
3077
+ hosted ? `How long in milliseconds the server should wait for a result before returning a job ID to poll. Accepted range 1000\u2013300000, but this server clamps anything above ${HTTP_LONG_POLL_BUDGET_MS} down to ${HTTP_LONG_POLL_BUDGET_MS}, which is also the effective default.` : "How long in milliseconds the server should wait for a result before returning a job ID to poll (1000\u2013300000). Defaults to 90000 (90 seconds)."
2448
3078
  ),
2449
3079
  webhook: webhookSchema,
2450
3080
  policy: policySchema,
@@ -2453,13 +3083,36 @@ function registerJobTools(server, rt) {
2453
3083
  { title: "Run endpoint (sync)", ...WRITE },
2454
3084
  async (params) => {
2455
3085
  const { endpointId, wait, ...body } = params;
2456
- const path = wait ? `/runsync?wait=${wait}` : "/runsync";
3086
+ const effectiveWait = hosted ? Math.min(
3087
+ wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS,
3088
+ HTTP_LONG_POLL_BUDGET_MS
3089
+ ) : wait;
3090
+ const waitQuery = effectiveWait === void 0 ? "" : `?wait=${effectiveWait}`;
3091
+ const path = `/runsync${waitQuery}`;
2457
3092
  const result = await serverlessRequest(
2458
3093
  endpointId,
2459
3094
  path,
2460
3095
  "POST",
2461
- body
3096
+ body,
3097
+ {
3098
+ // From the wait actually sent — on http the clamped 45s, not the
3099
+ // 300s the caller may have asked for.
3100
+ timeoutMs: (effectiveWait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS) + UPSTREAM_HOLD_SLACK_MS
3101
+ }
2462
3102
  );
3103
+ const requestedWait = wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS;
3104
+ if (hosted && requestedWait > HTTP_LONG_POLL_BUDGET_MS && isUnfinishedJobReply(result)) {
3105
+ return jsonReply2({
3106
+ ...result,
3107
+ waitClamped: {
3108
+ requestedMs: requestedWait,
3109
+ effectiveMs: effectiveWait,
3110
+ reason: `This server runs behind a 60-second gateway deadline, so a non-terminal status here means the job outlived ${formatBudget(
3111
+ HTTP_LONG_POLL_BUDGET_MS
3112
+ )}, not the requested wait \u2014 poll get-job-status rather than treating it as stuck.`
3113
+ }
3114
+ });
3115
+ }
2463
3116
  return jsonReply2(result);
2464
3117
  }
2465
3118
  );
@@ -2493,7 +3146,7 @@ function registerJobTools(server, rt) {
2493
3146
  );
2494
3147
  server.tool(
2495
3148
  "stream-job",
2496
- "Retrieve all streaming output from a Serverless job by polling until the job reaches a terminal state. The worker must support streaming output. Polls /stream/{jobId} repeatedly and collects every chunk until status is COMPLETED, FAILED, CANCELLED, or TIMED_OUT.",
3149
+ `Retrieve streaming output from a Serverless job. The worker must support streaming output. Polls /stream/{jobId} and collects chunks until the status is COMPLETED, FAILED, CANCELLED, or TIMED_OUT, for up to ${streamBudget}${hosted ? " (this server runs behind a 60-second gateway deadline)" : ""}. If the budget expires first, returns the chunks collected so far with pollingTimedOut: true \u2014 call stream-job again to resume where it left off, or get-job-status to check without streaming.${hosted ? " To collect with no budget, poll GET https://api.runpod.ai/v2/{endpointId}/stream/{jobId} yourself with a Bearer API key until the status is terminal \u2014 each request drains only the chunks buffered so far, so a single request is not the whole output." : ""}`,
2497
3150
  {
2498
3151
  endpointId: endpointIdSchema.describe(
2499
3152
  "ID of the Serverless endpoint the job belongs to"
@@ -2502,51 +3155,16 @@ function registerJobTools(server, rt) {
2502
3155
  },
2503
3156
  { title: "Stream job", ...READ_ONLY },
2504
3157
  async (params) => {
2505
- const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
2506
- "COMPLETED",
2507
- "FAILED",
2508
- "CANCELLED",
2509
- "TIMED_OUT"
2510
- ]);
2511
- const MAX_POLL_TIME_MS = 5 * 60 * 1e3;
2512
- const POLL_INTERVAL_MS = 1e3;
2513
- const MAX_CONSECUTIVE_ERRORS = 5;
2514
- const allChunks = [];
2515
- let finalResult = {};
2516
- let consecutiveErrors = 0;
2517
- let lastError;
2518
- const startTime = Date.now();
2519
- while (true) {
2520
- try {
2521
- const result = await serverlessRequest(
2522
- params.endpointId,
2523
- `/stream/${params.jobId}`
2524
- );
2525
- consecutiveErrors = 0;
2526
- if (Array.isArray(result.stream)) {
2527
- allChunks.push(...result.stream);
2528
- }
2529
- finalResult = result;
2530
- if (TERMINAL_STATUSES.has(result.status)) {
2531
- break;
2532
- }
2533
- } catch (error) {
2534
- consecutiveErrors++;
2535
- lastError = error instanceof Error ? error.message : String(error);
2536
- if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
2537
- finalResult.error = `Polling aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive errors: ${lastError}`;
2538
- break;
2539
- }
2540
- }
2541
- if (Date.now() - startTime > MAX_POLL_TIME_MS) {
2542
- finalResult.pollingTimedOut = true;
2543
- finalResult.note = "Polling timed out after 5 minutes. Use get-job-status to check the job later.";
2544
- if (lastError) finalResult.lastError = lastError;
2545
- break;
2546
- }
2547
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
2548
- }
2549
- return jsonReply2({ ...finalResult, stream: allChunks });
3158
+ const plan = streamPollPlan(hosted);
3159
+ const streamPath = `/stream/${params.jobId}${plan.query}`;
3160
+ const { result, chunks } = await collectJobStream({
3161
+ budgetMs: plan.budgetMs,
3162
+ holdMs: plan.holdMs,
3163
+ poll: (timeoutMs) => serverlessRequest(params.endpointId, streamPath, "GET", void 0, {
3164
+ timeoutMs
3165
+ })
3166
+ });
3167
+ return jsonReply2({ ...result, stream: chunks });
2550
3168
  }
2551
3169
  );
2552
3170
  server.tool(
@@ -3109,7 +3727,7 @@ async function main(apiKey) {
3109
3727
  await server.connect(transport);
3110
3728
  }
3111
3729
  if (isWizardMode) {
3112
- import("./wizard-37ZJSVB5.mjs").then(({ runWizard }) => runWizard(subcommand)).catch((error) => {
3730
+ import("./wizard-FBLOZ6YM.mjs").then(({ runWizard }) => runWizard(subcommand)).catch((error) => {
3113
3731
  console.error(error instanceof Error ? error.message : String(error));
3114
3732
  process.exit(1);
3115
3733
  });