@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.
@@ -2,16 +2,177 @@
2
2
  import fetch from "node-fetch";
3
3
  import { randomUUID } from "crypto";
4
4
 
5
+ // src/_shared/rate-limit.ts
6
+ var GENERIC_RATE_LIMIT_HINT = "rate limited \u2014 back off before retrying, and pace bulk operations";
7
+ var MAX_PLAUSIBLE_WAIT_S = 7 * 86400;
8
+ function remainingRequests(raw) {
9
+ const value = raw?.trim();
10
+ return value && /^\d+$/.test(value) ? Number(value) : null;
11
+ }
12
+ function waitSeconds(raw) {
13
+ const value = raw?.trim();
14
+ if (!value || !/^\d+$/.test(value)) return null;
15
+ const seconds = Number(value);
16
+ return seconds <= MAX_PLAUSIBLE_WAIT_S ? seconds : null;
17
+ }
18
+ var WINDOW_NAME_RE = /^(?:"([\x20-\x21\x23-\x5b\x5d-\x7e]{1,32})"|([A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]{0,31}))$/;
19
+ function windowName(raw) {
20
+ const match = WINDOW_NAME_RE.exec(raw.trim());
21
+ return match ? match[1] ?? match[2] : null;
22
+ }
23
+ var QUOTED_VALUE_RE = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"$/;
24
+ var DISPLAY_VALUE_RE = /^%"(?:[\x20-\x21\x23-\x24\x26-\x5b\x5d-\x7e]|\\|%[0-9a-f]{2})*"$/;
25
+ function isWellFormedParam(param) {
26
+ const eq = param.indexOf("=");
27
+ const key = (eq === -1 ? param : param.slice(0, eq)).trim();
28
+ if (key.includes('"')) return false;
29
+ if (eq === -1) return true;
30
+ const value = param.slice(eq + 1).trim();
31
+ if (value.startsWith('"')) return QUOTED_VALUE_RE.test(value);
32
+ if (value.startsWith('%"')) return DISPLAY_VALUE_RE.test(value);
33
+ return !/[\s"]/.test(value);
34
+ }
35
+ function splitOutsideStrings(input, sep) {
36
+ const parts = [];
37
+ let current = "";
38
+ let inString = false;
39
+ let inDisplay = false;
40
+ let escaped = false;
41
+ let prev = "";
42
+ for (const ch of input) {
43
+ if (escaped) escaped = false;
44
+ else if (inString && !inDisplay && ch === "\\") escaped = true;
45
+ else if (ch === '"') {
46
+ if (!inString) inDisplay = prev === "%";
47
+ inString = !inString;
48
+ } else if (ch === sep && !inString) {
49
+ parts.push(current);
50
+ current = "";
51
+ prev = ch;
52
+ continue;
53
+ }
54
+ current += ch;
55
+ prev = ch;
56
+ }
57
+ parts.push(current);
58
+ return parts;
59
+ }
60
+ function readParams(params) {
61
+ const values = /* @__PURE__ */ new Map();
62
+ let unreadable = false;
63
+ let valuelessAt = -1;
64
+ for (const [index, param] of params.entries()) {
65
+ const eq = param.indexOf("=");
66
+ const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase();
67
+ if (!key) continue;
68
+ if (eq === -1) {
69
+ if (key === "r") unreadable = true;
70
+ if (key === "t") values.delete("t");
71
+ valuelessAt = index;
72
+ continue;
73
+ }
74
+ if ((key === "r" || key === "t") && (values.has(key) || valuelessAt !== -1 && index > valuelessAt)) {
75
+ unreadable = true;
76
+ }
77
+ values.set(key, param.slice(eq + 1));
78
+ }
79
+ return { values, unreadable };
80
+ }
81
+ function parseExhaustedWindows(header) {
82
+ if (!header) return { windows: [], dropped: false };
83
+ const windows = [];
84
+ let dropped = false;
85
+ for (const item of splitOutsideStrings(header, ",")) {
86
+ if (item.trim() === "") continue;
87
+ const [rawName, ...params] = splitOutsideStrings(item, ";");
88
+ if (!params.every(isWellFormedParam)) {
89
+ dropped = true;
90
+ continue;
91
+ }
92
+ const { values, unreadable } = readParams(params);
93
+ if (unreadable) {
94
+ dropped = true;
95
+ continue;
96
+ }
97
+ const remaining = remainingRequests(values.get("r"));
98
+ if (remaining === null) {
99
+ dropped = true;
100
+ continue;
101
+ }
102
+ if (remaining !== 0) continue;
103
+ windows.push({
104
+ window: windowName(rawName),
105
+ resetS: waitSeconds(values.get("t"))
106
+ });
107
+ }
108
+ return { windows, dropped };
109
+ }
110
+ function humanWait(seconds) {
111
+ if (seconds < 120) return `~${seconds}s`;
112
+ if (seconds < 3600)
113
+ return `~${Math.ceil(seconds / 60)} minutes (${seconds}s)`;
114
+ if (seconds < 2 * 86400) {
115
+ const hours = Math.ceil(seconds / 3600);
116
+ return `~${hours} hour${hours === 1 ? "" : "s"} (${seconds}s)`;
117
+ }
118
+ return `~${Math.ceil(seconds / 86400)} days (${seconds}s)`;
119
+ }
120
+ function rateLimitHint(rateLimitHeader, retryAfterHeader) {
121
+ const { windows, dropped } = parseExhaustedWindows(rateLimitHeader);
122
+ windows.sort((a, b) => (b.resetS ?? -1) - (a.resetS ?? -1));
123
+ const longestWindow = windows[0];
124
+ const waitCandidates = [
125
+ waitSeconds(retryAfterHeader),
126
+ longestWindow?.resetS
127
+ ].filter((n) => typeof n === "number" && n > 0);
128
+ const waitS = waitCandidates.length ? Math.max(...waitCandidates) : null;
129
+ const retryAfterUnreadable = typeof retryAfterHeader === "string" && retryAfterHeader.trim() !== "" && waitSeconds(retryAfterHeader) === null;
130
+ if (!longestWindow && waitS === null) {
131
+ if (!retryAfterUnreadable) return GENERIC_RATE_LIMIT_HINT;
132
+ }
133
+ const namedClause = longestWindow?.window ? `the ${longestWindow.window} quota is exhausted` : "";
134
+ if (waitS === null) {
135
+ const cause = namedClause ? `${namedClause}, but the response gave no usable reset time` : "the response gave no usable reset time";
136
+ return `rate limited \u2014 ${cause}; back off substantially before retrying, and pace bulk operations`;
137
+ }
138
+ const hedge = dropped || retryAfterUnreadable || windows.some((w) => w.resetS === null) ? "at least " : "";
139
+ const wait = `wait ${hedge}${humanWait(waitS)} before retrying, and pace bulk operations`;
140
+ return `rate limited \u2014 ${namedClause ? `${namedClause}; ` : ""}${wait}`;
141
+ }
142
+
5
143
  // src/_shared/http.ts
144
+ 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";
6
145
  var HttpError = class extends Error {
7
- constructor(prefix, status, body) {
8
- 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)" : "";
9
- super(`${prefix}: ${status} - ${body}${hint}`);
146
+ constructor(prefix, status, body, hint) {
147
+ const shownBody = body.length > 2048 ? `${body.slice(0, 2048)}\u2026 [truncated ${body.length - 2048} chars]` : body;
148
+ super(`${prefix}: ${status} - ${shownBody}${hint ? ` (${hint})` : ""}`);
10
149
  this.name = "HttpError";
11
150
  this.status = status;
12
151
  this.body = body;
13
152
  }
14
153
  };
154
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
155
+ var MIN_REQUEST_TIMEOUT_MS = 1e3;
156
+ var RequestTimeoutError = class extends Error {
157
+ constructor(prefix, timeoutMs, method) {
158
+ const write = method !== "GET" && method !== "HEAD";
159
+ super(
160
+ `${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."}`
161
+ );
162
+ this.name = "RequestTimeoutError";
163
+ this.timeoutMs = timeoutMs;
164
+ this.method = method;
165
+ }
166
+ };
167
+ async function withRequestTimeout(errorPrefix, timeoutMs, method, run) {
168
+ const signal = AbortSignal.timeout(timeoutMs);
169
+ try {
170
+ return await run(signal);
171
+ } catch (error) {
172
+ if (error instanceof HttpError || !signal.aborted) throw error;
173
+ throw new RequestTimeoutError(errorPrefix, timeoutMs, method);
174
+ }
175
+ }
15
176
  function isJsonContentType(contentType) {
16
177
  if (!contentType) return false;
17
178
  const ct = contentType.toLowerCase();
@@ -27,24 +188,49 @@ function buildRequestHeaders(apiKey, tracking) {
27
188
  ...tracking()
28
189
  };
29
190
  }
191
+ function resolveCeiling(ceiling) {
192
+ if (ceiling === void 0) return Infinity;
193
+ if (typeof ceiling !== "function") return ceiling;
194
+ return Math.max(ceiling(), MIN_REQUEST_TIMEOUT_MS);
195
+ }
196
+ function clampTimeout(requestedMs, ceiling, defaultMs = DEFAULT_REQUEST_TIMEOUT_MS) {
197
+ return Math.min(requestedMs ?? defaultMs, resolveCeiling(ceiling));
198
+ }
30
199
  function createHttpClient(deps) {
31
- return async function request(url, method = "GET", body) {
32
- const init = {
200
+ const defaultTimeoutMs = deps.defaultTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
201
+ return async function request(url, method = "GET", body, options) {
202
+ const timeoutMs = clampTimeout(
203
+ options?.timeoutMs,
204
+ deps.maxTimeoutMs,
205
+ defaultTimeoutMs
206
+ );
207
+ return withRequestTimeout(
208
+ deps.errorPrefix,
209
+ timeoutMs,
33
210
  method,
34
- headers: buildRequestHeaders(deps.apiKey, deps.tracking)
35
- };
36
- if (body && methodSendsBody(method)) {
37
- init.body = JSON.stringify(body);
38
- }
39
- const response = await deps.fetch(url, init);
40
- if (!response.ok) {
41
- throw new HttpError(
42
- deps.errorPrefix,
43
- response.status,
44
- await response.text()
45
- );
46
- }
47
- return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
211
+ async (signal) => {
212
+ const init = {
213
+ method,
214
+ headers: buildRequestHeaders(deps.apiKey, deps.tracking),
215
+ signal
216
+ };
217
+ if (body && methodSendsBody(method)) {
218
+ init.body = JSON.stringify(body);
219
+ }
220
+ const response = await deps.fetch(url, init);
221
+ if (!response.ok) {
222
+ const hint = response.status === 429 ? rateLimitHint(
223
+ response.headers.get("ratelimit"),
224
+ response.headers.get("retry-after")
225
+ ) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0;
226
+ const text = await response.text().catch(
227
+ () => "<body unavailable: the connection closed while reading it>"
228
+ );
229
+ throw new HttpError(deps.errorPrefix, response.status, text, hint);
230
+ }
231
+ return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
232
+ }
233
+ );
48
234
  };
49
235
  }
50
236
 
@@ -100,7 +286,15 @@ async function readSseSnapshot(fetchImpl, url, headers, opts) {
100
286
  throw new HttpError(
101
287
  "Runpod API Error",
102
288
  res.status,
103
- await res.text().catch(() => "")
289
+ await res.text().catch(() => ""),
290
+ // This throw bypasses createHttpClient, so the status hints are built
291
+ // here too or these two tools alone return a bare 429/401. The 401
292
+ // hint applies: this request always carries Authorization (see the
293
+ // headers bound in runtime.ts streamSse).
294
+ res.status === 429 ? rateLimitHint(
295
+ res.headers.get("ratelimit"),
296
+ res.headers.get("retry-after")
297
+ ) : res.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
104
298
  );
105
299
  }
106
300
  for await (const chunk of res.body ?? []) {
@@ -189,7 +383,7 @@ function containerConfigToV2(p) {
189
383
  function gpuConfigToV2(gpuTypeIds, gpuCount) {
190
384
  const id = gpuTypeIds?.[0];
191
385
  if (id === void 0) return void 0;
192
- return compact({ id, count: gpuCount });
386
+ return { id, count: gpuCount ?? 1 };
193
387
  }
194
388
  function mapPodCreateToV2(params) {
195
389
  return compact({
@@ -316,6 +510,42 @@ function podBodyFromTemplate(template) {
316
510
  }
317
511
  return out;
318
512
  }
513
+ var SSH_PUBLIC_KEY_LINE = /^(ssh-[a-z0-9-]+|ecdsa-[a-z0-9-]+|sk-[a-z0-9@.-]+)\s+\S+/i;
514
+ var PRIVATE_KEY_MARKER = /private[-\s]?key|PuTTY-User-Key-File/i;
515
+ function normalizeSshPublicKey(input) {
516
+ if (PRIVATE_KEY_MARKER.test(input))
517
+ return {
518
+ ok: false,
519
+ 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").'
520
+ };
521
+ const lines = input.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
522
+ if (lines.length === 0)
523
+ return {
524
+ ok: false,
525
+ error: "sshPublicKey is empty. Pass the contents of your ~/.ssh/<key>.pub file, or omit the parameter entirely to create the pod without SSH."
526
+ };
527
+ if (lines.some((line) => !SSH_PUBLIC_KEY_LINE.test(line)))
528
+ return {
529
+ ok: false,
530
+ 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.'
531
+ };
532
+ return { ok: true, key: lines.join("\n") };
533
+ }
534
+ function applySshPublicKey(body, sshPublicKey) {
535
+ const ports = Array.isArray(body.ports) ? body.ports : [];
536
+ const hasSshPort = ports.some((p) => /^\s*22\/tcp\s*$/i.test(String(p)));
537
+ const env = { ...body.env ?? {} };
538
+ const key = sshPublicKey.trim();
539
+ if (!env.PUBLIC_KEY) env.PUBLIC_KEY = key;
540
+ else if (!env.PUBLIC_KEY.includes(key))
541
+ env.PUBLIC_KEY = `${env.PUBLIC_KEY}
542
+ ${key}`;
543
+ return {
544
+ ...body,
545
+ ports: hasSshPort ? ports : [...ports, "22/tcp"],
546
+ env
547
+ };
548
+ }
319
549
  function mapTemplateUpdateToV2(params) {
320
550
  return {
321
551
  ...containerConfigToV2(params),
@@ -359,7 +589,7 @@ function restV1Base(env) {
359
589
  return env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
360
590
  }
361
591
  function restV2Base(env) {
362
- return env.RUNPOD_REST_V2_API_URL ?? "https://v2-rest.runpod.io/v2";
592
+ return env.RUNPOD_REST_V2_API_URL ?? "https://api.runpod.io/v2";
363
593
  }
364
594
  function serverlessBase(env) {
365
595
  return env.RUNPOD_SERVERLESS_API_URL ?? "https://api.runpod.ai/v2";
@@ -530,7 +760,7 @@ function resolveBackend(opts) {
530
760
  }
531
761
 
532
762
  // src/tools/runtime.ts
533
- var MCP_SERVER_VERSION = true ? "3.1.0" : "dev";
763
+ var MCP_SERVER_VERSION = true ? "3.3.0" : "dev";
534
764
  var SESSION_ID = randomUUID();
535
765
  function trackingHeaders(server, ctx) {
536
766
  const info = server.server.getClientVersion();
@@ -545,6 +775,11 @@ function trackingHeaders(server, ctx) {
545
775
  sessionId: SESSION_ID
546
776
  });
547
777
  }
778
+ var HTTP_TRANSPORT_BUDGET_MS = 5e4;
779
+ function invocationBudget(totalMs) {
780
+ const startedAt = Date.now();
781
+ return () => totalMs - (Date.now() - startedAt);
782
+ }
548
783
  var defaultFetch = fetch;
549
784
  async function withApiErrorLog(label, call) {
550
785
  try {
@@ -568,6 +803,23 @@ var DESTRUCTIVE = {
568
803
  openWorldHint: true
569
804
  };
570
805
  async function graphqlRequest(query, tracking, fetchImpl, url, options) {
806
+ return withRequestTimeout(
807
+ "Runpod GraphQL Error",
808
+ clampTimeout(options?.timeoutMs, options?.maxTimeoutMs),
809
+ // GraphQL is POST on the wire whatever it carries, but the retry advice keys
810
+ // off the method, and telling a caller its list-gpu-types "may have
811
+ // SUCCEEDED upstream — check with the matching list-/get- tool first" points
812
+ // it back at the tool that just failed. The operation keyword is what
813
+ // actually says whether anything could have been written.
814
+ isGraphqlMutation(query) ? "POST" : "GET",
815
+ (signal) => runGraphql(query, tracking, fetchImpl, url, signal, options)
816
+ );
817
+ }
818
+ function isGraphqlMutation(query) {
819
+ const firstMeaningful = query.split("\n").map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#"));
820
+ return /^mutation\b/.test(firstMeaningful ?? "");
821
+ }
822
+ async function runGraphql(query, tracking, fetchImpl, url, signal, options) {
571
823
  const response = await fetchImpl(url, {
572
824
  method: "POST",
573
825
  headers: {
@@ -578,8 +830,39 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
578
830
  body: JSON.stringify({
579
831
  query,
580
832
  ...options?.variables ? { variables: options.variables } : {}
581
- })
833
+ }),
834
+ signal
582
835
  });
836
+ if (!response.ok) {
837
+ const bodyText = await response.text();
838
+ let gqlErrors;
839
+ try {
840
+ gqlErrors = JSON.parse(bodyText).errors;
841
+ } catch {
842
+ }
843
+ if (Array.isArray(gqlErrors) && gqlErrors.length > 0 && response.status !== 401 && response.status !== 429) {
844
+ throw new HttpError(
845
+ "Runpod GraphQL Error",
846
+ response.status,
847
+ gqlErrors.map((e) => String(e?.message ?? JSON.stringify(e))).join(", ")
848
+ );
849
+ }
850
+ throw new HttpError(
851
+ "Runpod GraphQL Error",
852
+ response.status,
853
+ bodyText,
854
+ response.status === 429 ? rateLimitHint(
855
+ response.headers.get("ratelimit"),
856
+ response.headers.get("retry-after")
857
+ ) : (
858
+ // Only when this request actually carried the caller's API key: the
859
+ // public path sends no Authorization header, so a 401 from that
860
+ // host (WAF, misconfigured RUNPOD_PUBLIC_GRAPHQL_URL) says nothing
861
+ // about the credential — same rationale as observeUnauthorized.
862
+ response.status === 401 && options?.apiKey ? EXPIRED_CREDENTIAL_HINT : void 0
863
+ )
864
+ );
865
+ }
583
866
  const result = await response.json();
584
867
  if (result.errors && result.errors.length > 0) {
585
868
  throw new Error(
@@ -604,39 +887,53 @@ function createToolRuntime(server, ctx, deps = {}) {
604
887
  }
605
888
  const rawFetch = deps.fetch ?? defaultFetch;
606
889
  const httpFetch = observeUnauthorized(rawFetch);
890
+ const remainingHttpBudgetMs = invocationBudget(HTTP_TRANSPORT_BUDGET_MS);
891
+ const timeouts = {
892
+ defaultTimeoutMs: deps.defaultTimeoutMs,
893
+ maxTimeoutMs: deps.maxTimeoutMs ?? (ctx.transport === "http" ? remainingHttpBudgetMs : void 0)
894
+ };
607
895
  const v1Client = createHttpClient({
608
896
  apiKey: ctx.apiKey,
609
897
  fetch: httpFetch,
610
898
  tracking,
611
- errorPrefix: "Runpod API Error"
899
+ errorPrefix: "Runpod API Error",
900
+ ...timeouts
612
901
  });
613
- const runpodRequest = (endpoint, method = "GET", body) => withApiErrorLog(
902
+ const runpodRequest = (endpoint, method = "GET", body, options) => withApiErrorLog(
614
903
  "Error calling Runpod API:",
615
- () => v1Client(`${restV1Base(process.env)}${endpoint}`, method, body)
904
+ () => v1Client(
905
+ `${restV1Base(process.env)}${endpoint}`,
906
+ method,
907
+ body,
908
+ options
909
+ )
616
910
  );
617
911
  const serverlessClient = createHttpClient({
618
912
  apiKey: ctx.apiKey,
619
913
  fetch: httpFetch,
620
914
  tracking,
621
- errorPrefix: "Runpod Serverless API Error"
915
+ errorPrefix: "Runpod Serverless API Error",
916
+ ...timeouts
622
917
  });
623
- const serverlessRequest = (endpointId, path, method = "GET", body) => withApiErrorLog(
918
+ const serverlessRequest = (endpointId, path, method = "GET", body, options) => withApiErrorLog(
624
919
  "Error calling Runpod Serverless API:",
625
920
  () => serverlessClient(
626
921
  `${serverlessBase(process.env)}/${endpointId}${path}`,
627
922
  method,
628
- body
923
+ body,
924
+ options
629
925
  )
630
926
  );
631
927
  const restClient = createHttpClient({
632
928
  apiKey: ctx.apiKey,
633
929
  fetch: httpFetch,
634
930
  tracking,
635
- errorPrefix: "Runpod API Error"
931
+ errorPrefix: "Runpod API Error",
932
+ ...timeouts
636
933
  });
637
- const callRestUrl = (url, method = "GET", body) => withApiErrorLog(
934
+ const callRestUrl = (url, method = "GET", body, options) => withApiErrorLog(
638
935
  "Error calling Runpod API:",
639
- () => restClient(url, method, body)
936
+ () => restClient(url, method, body, options)
640
937
  );
641
938
  const sseFetch = observeUnauthorized(
642
939
  deps.sseFetch ?? fetch
@@ -669,12 +966,18 @@ function createToolRuntime(server, ctx, deps = {}) {
669
966
  };
670
967
  return {
671
968
  jsonReply,
969
+ // No GraphQL caller overrides a deadline, but both are still capped by the
970
+ // shared budget: deploy-hub-repo spends one of each back to back.
672
971
  graphql: (query) => graphqlRequest(
673
972
  query,
674
973
  tracking,
675
974
  // rawFetch, not httpFetch: this call carries no credential (see above).
676
975
  rawFetch,
677
- publicGraphqlBase(process.env)
976
+ publicGraphqlBase(process.env),
977
+ {
978
+ timeoutMs: deps.defaultTimeoutMs,
979
+ maxTimeoutMs: timeouts.maxTimeoutMs
980
+ }
678
981
  ),
679
982
  graphqlAuthed: (query, variables) => graphqlRequest(
680
983
  query,
@@ -682,7 +985,12 @@ function createToolRuntime(server, ctx, deps = {}) {
682
985
  httpFetch,
683
986
  // NOT publicGraphqlBase: this call carries the caller's API key.
684
987
  authedGraphqlBase(process.env),
685
- { variables, apiKey: ctx.apiKey }
988
+ {
989
+ variables,
990
+ apiKey: ctx.apiKey,
991
+ timeoutMs: deps.defaultTimeoutMs,
992
+ maxTimeoutMs: timeouts.maxTimeoutMs
993
+ }
686
994
  ),
687
995
  runpodRequest,
688
996
  serverlessRequest,
@@ -690,7 +998,8 @@ function createToolRuntime(server, ctx, deps = {}) {
690
998
  backendFor,
691
999
  streamSse,
692
1000
  podAction,
693
- env: process.env
1001
+ env: process.env,
1002
+ transport: ctx.transport
694
1003
  };
695
1004
  }
696
1005
 
@@ -759,6 +1068,7 @@ function capListResult(result, options, extra) {
759
1068
  }
760
1069
 
761
1070
  // src/tools/catalog.ts
1071
+ var GPU_PRODUCTS = /* @__PURE__ */ new Set(["POD", "CLUSTER", "SERVERLESS"]);
762
1072
  function registerCatalogTools(server, rt) {
763
1073
  const { jsonReply: jsonReply2, graphql, callRestUrl, backendFor } = rt;
764
1074
  server.tool(
@@ -777,6 +1087,9 @@ function registerCatalogTools(server, rt) {
777
1087
  ),
778
1088
  includeAvailability: z3.boolean().optional().describe(
779
1089
  "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."
1090
+ ),
1091
+ product: z3.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
1092
+ "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."
780
1093
  )
781
1094
  },
782
1095
  { title: "List GPU types", ...READ_ONLY },
@@ -784,8 +1097,9 @@ function registerCatalogTools(server, rt) {
784
1097
  const backend = backendFor("gpus");
785
1098
  if (backend.version === "v2") {
786
1099
  const wantAvailability = params.includeAvailability !== false;
1100
+ const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
787
1101
  const raw = await callRestUrl(
788
- `${backend.base}${backend.list}${wantAvailability ? "?include=AVAILABILITY" : ""}`
1102
+ `${backend.base}${backend.list}${wantAvailability ? `?include=AVAILABILITY&product=${product}` : ""}`
789
1103
  );
790
1104
  let gpus = backend.unwrap(raw);
791
1105
  gpus = gpus.filter((g) => g.id !== "unknown");
@@ -958,6 +1272,9 @@ function registerCatalogTools(server, rt) {
958
1272
  gpuTypeId: z3.string().describe("ID of the GPU type to retrieve"),
959
1273
  includeAvailability: z3.boolean().optional().describe(
960
1274
  "Include realtime per-datacenter availability (HIGH/MEDIUM/LOW/NONE). Default true."
1275
+ ),
1276
+ product: z3.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
1277
+ "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."
961
1278
  )
962
1279
  },
963
1280
  { title: "Get GPU type", ...READ_ONLY },
@@ -970,7 +1287,8 @@ function registerCatalogTools(server, rt) {
970
1287
  });
971
1288
  }
972
1289
  const path = backend.get(encodeURIComponent(params.gpuTypeId));
973
- const query = params.includeAvailability === false ? "" : "?include=AVAILABILITY";
1290
+ const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
1291
+ const query = params.includeAvailability === false ? "" : `?include=AVAILABILITY&product=${product}`;
974
1292
  const result = await callRestUrl(`${backend.base}${path}${query}`);
975
1293
  return jsonReply2(result);
976
1294
  }
@@ -996,6 +1314,178 @@ function registerCatalogTools(server, rt) {
996
1314
  return jsonReply2(result);
997
1315
  }
998
1316
  );
1317
+ server.tool(
1318
+ "get-capacity",
1319
+ "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.",
1320
+ {
1321
+ ...listPaginationParams,
1322
+ cudaVersions: z3.array(z3.string().regex(/^\d{1,2}\.\d{1,2}$/)).min(1).max(12).optional().describe(
1323
+ '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.'
1324
+ ),
1325
+ includeUnavailable: z3.boolean().optional().describe(
1326
+ '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.'
1327
+ ),
1328
+ gpuTypeIds: z3.array(z3.string()).optional().describe(
1329
+ "Filter to GPU types matching any of these ids or display names (case-insensitive substring, e.g. ['NVIDIA GeForce RTX 4090'] or ['4090', 'H200'])."
1330
+ ),
1331
+ gpuCount: z3.number().int().min(1).max(8).optional().describe("GPUs per worker/pod to check stock for (default 1)."),
1332
+ secureCloudOnly: z3.boolean().optional().describe("Restrict the stock lookup to Secure Cloud hosts.")
1333
+ },
1334
+ { title: "Get GPU capacity by CUDA version", ...READ_ONLY },
1335
+ async (params) => {
1336
+ const gpuCount = Math.min(
1337
+ 8,
1338
+ Math.max(1, Math.floor(Number(params.gpuCount)) || 1)
1339
+ );
1340
+ const secureArg = params.secureCloudOnly ? ", secureCloud: true" : "";
1341
+ const filterTerms = (params.gpuTypeIds ?? []).filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.toLowerCase());
1342
+ const matchesFilter = (gpu) => {
1343
+ if (filterTerms.length === 0) return true;
1344
+ const id = gpu.id.toLowerCase();
1345
+ const name = gpu.displayName.toLowerCase();
1346
+ return filterTerms.some(
1347
+ (term) => id.includes(term) || name.includes(term)
1348
+ );
1349
+ };
1350
+ const stockPriority = {
1351
+ High: 3,
1352
+ Medium: 2,
1353
+ Low: 1
1354
+ };
1355
+ if (!params.cudaVersions?.length) {
1356
+ const data = await graphql(`
1357
+ query {
1358
+ gpuTypes {
1359
+ id
1360
+ displayName
1361
+ memoryInGb
1362
+ secureCloud
1363
+ communityCloud
1364
+ lowestPrice(input: { gpuCount: ${gpuCount}${secureArg} }) {
1365
+ stockStatus
1366
+ uninterruptablePrice
1367
+ gpuTypeCudaVersions {
1368
+ cudaVersion
1369
+ availability
1370
+ }
1371
+ }
1372
+ }
1373
+ }
1374
+ `);
1375
+ let rows2 = data.gpuTypes.filter((gpu) => gpu.id !== "unknown" && matchesFilter(gpu)).map((gpu) => {
1376
+ const cuda = {};
1377
+ for (const c of gpu.lowestPrice?.gpuTypeCudaVersions ?? []) {
1378
+ cuda[c.cudaVersion] = c.availability;
1379
+ }
1380
+ return {
1381
+ id: gpu.id,
1382
+ displayName: gpu.displayName,
1383
+ memoryGb: gpu.memoryInGb,
1384
+ secureCloud: gpu.secureCloud,
1385
+ communityCloud: gpu.communityCloud,
1386
+ stockStatus: gpu.lowestPrice?.stockStatus || "unavailable",
1387
+ pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null,
1388
+ cudaVersions: cuda
1389
+ };
1390
+ });
1391
+ const availableCount = (r) => Object.values(r.cudaVersions).filter((a) => a === "AVAILABLE").length;
1392
+ if (params.includeUnavailable === false)
1393
+ rows2 = rows2.filter((r) => availableCount(r) > 0);
1394
+ rows2.sort((a, b) => {
1395
+ const diff = availableCount(b) - availableCount(a);
1396
+ if (diff !== 0) return diff;
1397
+ const stockDiff = (stockPriority[b.stockStatus] || 0) - (stockPriority[a.stockStatus] || 0);
1398
+ if (stockDiff !== 0) return stockDiff;
1399
+ return b.memoryGb - a.memoryGb;
1400
+ });
1401
+ return capListResult(rows2, {
1402
+ limit: params.limit,
1403
+ cursor: params.cursor
1404
+ });
1405
+ }
1406
+ const versions = [...new Set(params.cudaVersions)].slice(0, 12);
1407
+ const invalid = versions.filter((v) => !/^\d{1,2}\.\d{1,2}$/.test(v));
1408
+ if (invalid.length > 0) {
1409
+ return jsonReply2({
1410
+ error: `Invalid CUDA version format: ${invalid.join(", ")}. Use "major.minor" strings like "12.8".`,
1411
+ status: 400
1412
+ });
1413
+ }
1414
+ const perVersion = await Promise.allSettled(
1415
+ versions.map(
1416
+ (v) => graphql(`
1417
+ query {
1418
+ gpuTypes {
1419
+ id
1420
+ displayName
1421
+ memoryInGb
1422
+ secureCloud
1423
+ communityCloud
1424
+ lowestPrice(input: { gpuCount: ${gpuCount}, allowedCudaVersions: ["${v}"]${secureArg} }) {
1425
+ stockStatus
1426
+ uninterruptablePrice
1427
+ }
1428
+ }
1429
+ }
1430
+ `)
1431
+ )
1432
+ );
1433
+ const probeErrors = {};
1434
+ const byId = /* @__PURE__ */ new Map();
1435
+ versions.forEach((v, i) => {
1436
+ const settled = perVersion[i];
1437
+ if (settled.status === "rejected") {
1438
+ probeErrors[v] = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
1439
+ return;
1440
+ }
1441
+ for (const gpu of settled.value.gpuTypes) {
1442
+ if (gpu.id === "unknown" || !matchesFilter(gpu)) continue;
1443
+ let row = byId.get(gpu.id);
1444
+ if (!row) {
1445
+ row = {
1446
+ id: gpu.id,
1447
+ displayName: gpu.displayName,
1448
+ memoryGb: gpu.memoryInGb,
1449
+ secureCloud: gpu.secureCloud,
1450
+ communityCloud: gpu.communityCloud,
1451
+ cudaVersions: {}
1452
+ };
1453
+ byId.set(gpu.id, row);
1454
+ }
1455
+ const stock = gpu.lowestPrice?.stockStatus;
1456
+ row.cudaVersions[v] = !stock || stock === "Out" ? { stock: "Out", pricePerHr: null } : {
1457
+ stock,
1458
+ pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null
1459
+ };
1460
+ }
1461
+ });
1462
+ let rows = [...byId.values()];
1463
+ const inStockCount = (r) => Object.values(r.cudaVersions).filter((c) => c.stock !== "Out").length;
1464
+ if (params.includeUnavailable === false)
1465
+ rows = rows.filter((r) => inStockCount(r) > 0);
1466
+ rows.sort((a, b) => {
1467
+ const diff = inStockCount(b) - inStockCount(a);
1468
+ if (diff !== 0) return diff;
1469
+ const best = (r) => Math.max(
1470
+ 0,
1471
+ ...Object.values(r.cudaVersions).map(
1472
+ (c) => stockPriority[c.stock] || 0
1473
+ )
1474
+ );
1475
+ const bestDiff = best(b) - best(a);
1476
+ if (bestDiff !== 0) return bestDiff;
1477
+ return b.memoryGb - a.memoryGb;
1478
+ });
1479
+ return capListResult(
1480
+ rows,
1481
+ { limit: params.limit, cursor: params.cursor },
1482
+ {
1483
+ probedCudaVersions: versions,
1484
+ ...Object.keys(probeErrors).length > 0 ? { probeErrors } : {}
1485
+ }
1486
+ );
1487
+ }
1488
+ );
999
1489
  server.tool(
1000
1490
  "get-data-center",
1001
1491
  "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).",
@@ -1678,9 +2168,11 @@ function registerPodTools(server, rt) {
1678
2168
  );
1679
2169
  server.tool(
1680
2170
  "create-pod",
1681
- '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.',
2171
+ '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.',
1682
2172
  {
1683
- name: z7.string().optional().describe("Name for the pod"),
2173
+ name: z7.string().optional().describe(
2174
+ "Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
2175
+ ),
1684
2176
  imageName: z7.string().optional().describe("Docker image to use. Optional when templateId is provided."),
1685
2177
  templateId: z7.string().optional().describe(
1686
2178
  "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."
@@ -1690,12 +2182,18 @@ function registerPodTools(server, rt) {
1690
2182
  ),
1691
2183
  cloudType: z7.enum(["SECURE", "COMMUNITY"]).optional().describe("SECURE or COMMUNITY cloud"),
1692
2184
  gpuTypeIds: z7.array(z7.string()).optional().describe("List of acceptable GPU types"),
1693
- gpuCount: z7.number().optional().describe("Number of GPUs"),
2185
+ // v2 GpuConfig.count is `integer, minimum: 1`. Without these bounds a 0,
2186
+ // a negative, or a fractional count reached the wire as-is and came back
2187
+ // as a raw 422; reject it here with a message that names the field.
2188
+ gpuCount: z7.number().int().min(1).optional().describe("Number of GPUs (minimum 1)"),
1694
2189
  containerDiskInGb: z7.number().optional().describe("Container disk size in GB"),
1695
2190
  volumeInGb: z7.number().optional().describe("Volume size in GB"),
1696
2191
  volumeMountPath: z7.string().optional().describe("Path to mount the volume"),
1697
2192
  ports: z7.array(z7.string()).optional().describe("Ports to expose (e.g., '8888/http', '22/tcp')"),
1698
2193
  env: z7.record(z7.string()).optional().describe("Environment variables"),
2194
+ sshPublicKey: z7.string().optional().describe(
2195
+ '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.'
2196
+ ),
1699
2197
  containerRegistryAuthId: z7.string().optional().describe(
1700
2198
  "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)."
1701
2199
  ),
@@ -1704,6 +2202,15 @@ function registerPodTools(server, rt) {
1704
2202
  { title: "Create pod", ...WRITE },
1705
2203
  async (params) => {
1706
2204
  const backend = backendFor("pods");
2205
+ const { sshPublicKey, ...podParams } = params;
2206
+ let sshKey;
2207
+ if (sshPublicKey !== void 0) {
2208
+ const validated = normalizeSshPublicKey(sshPublicKey);
2209
+ if (!validated.ok)
2210
+ return jsonReply2({ error: validated.error, status: 400 });
2211
+ sshKey = validated.key;
2212
+ }
2213
+ 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.";
1707
2214
  if (params.templateId && backend.version !== "v2") {
1708
2215
  return jsonReply2({
1709
2216
  error: "Deploying a pod from a templateId is only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2, or pass imageName instead.",
@@ -1739,6 +2246,12 @@ function registerPodTools(server, rt) {
1739
2246
  status: 400
1740
2247
  });
1741
2248
  }
2249
+ if (!wantsCpu && !params.name && !params.templateId) {
2250
+ return jsonReply2({
2251
+ error: "Provide a name for the pod (deploying from a templateId inherits the template name).",
2252
+ status: 400
2253
+ });
2254
+ }
1742
2255
  if (wantsCpu) {
1743
2256
  if (params.templateId) {
1744
2257
  return jsonReply2({
@@ -1747,13 +2260,15 @@ function registerPodTools(server, rt) {
1747
2260
  });
1748
2261
  }
1749
2262
  try {
2263
+ const cpuBody = sshKey ? applySshPublicKey(podParams, sshKey) : podParams;
1750
2264
  const result = await callRestUrl(
1751
2265
  `${restV1Base(env)}/pods`,
1752
2266
  "POST",
1753
- params
2267
+ cpuBody
1754
2268
  );
1755
2269
  return jsonReply2({
1756
2270
  ...result,
2271
+ ...sshKey ? { _ssh: sshNote } : {},
1757
2272
  _servedBy: "v1",
1758
2273
  _note: "CPU pod: created on the v1 API, because the v2 REST API does not support CPU pods yet."
1759
2274
  });
@@ -1770,7 +2285,7 @@ function registerPodTools(server, rt) {
1770
2285
  }
1771
2286
  }
1772
2287
  }
1773
- let body = backend.mapCreate(params);
2288
+ let body = backend.mapCreate(podParams);
1774
2289
  if (params.templateId) {
1775
2290
  let template;
1776
2291
  try {
@@ -1788,19 +2303,21 @@ function registerPodTools(server, rt) {
1788
2303
  }
1789
2304
  body = { ...podBodyFromTemplate(template), ...body };
1790
2305
  }
2306
+ if (sshKey) body = applySshPublicKey(body, sshKey);
1791
2307
  try {
1792
2308
  const result = await callRestUrl(
1793
2309
  `${backend.base}${backend.list}`,
1794
2310
  "POST",
1795
2311
  body
1796
2312
  );
2313
+ const extras = {};
2314
+ if (sshKey) extras._ssh = sshNote;
1797
2315
  if (backend.version === "v2" && (params.gpuTypeIds?.length ?? 0) > 1) {
1798
- return jsonReply2({
1799
- ...result,
1800
- _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(", ")}.`
1801
- });
2316
+ 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(", ")}.`;
1802
2317
  }
1803
- return jsonReply2(result);
2318
+ return jsonReply2(
2319
+ Object.keys(extras).length ? { ...result, ...extras } : result
2320
+ );
1804
2321
  } catch (error) {
1805
2322
  if (error instanceof HttpError) {
1806
2323
  return jsonReply2({ error: error.message, status: error.status });
@@ -2261,8 +2778,108 @@ import { z as z9 } from "zod";
2261
2778
  var DIAGNOSIS_TTL_MS = 15e3;
2262
2779
  var MAX_DIAGNOSIS_ENTRIES = 500;
2263
2780
  var diagnosisCache = /* @__PURE__ */ new Map();
2781
+ var HTTP_LONG_POLL_BUDGET_MS = 45e3;
2782
+ var STDIO_STREAM_BUDGET_MS = 5 * 60 * 1e3;
2783
+ var RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS = 9e4;
2784
+ var STREAM_UPSTREAM_DEFAULT_WAIT_MS = 1e4;
2785
+ var HTTP_STREAM_POLL_WAIT_MS = 1e3;
2786
+ var STREAM_JOB_POLL_INTERVAL_MS = 1e3;
2787
+ var MAX_CONSECUTIVE_STREAM_ERRORS = 5;
2788
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
2789
+ "COMPLETED",
2790
+ "FAILED",
2791
+ "CANCELLED",
2792
+ "TIMED_OUT"
2793
+ ]);
2794
+ function isUnfinishedJobReply(result) {
2795
+ return result !== null && typeof result === "object" && !Array.isArray(result) && !("waitClamped" in result) && !TERMINAL_STATUSES.has(result.status ?? "");
2796
+ }
2797
+ function formatBudget(ms) {
2798
+ return ms >= 12e4 ? `${Math.round(ms / 6e4)} minutes` : `${Math.round(ms / 1e3)} seconds`;
2799
+ }
2800
+ var UPSTREAM_HOLD_SLACK_MS = 5e3;
2801
+ var MIN_STREAM_POLL_TIMEOUT_MS = 2e3;
2802
+ var QUEUED_DIAGNOSIS_TIMEOUT_MS = 5e3;
2803
+ function streamPollTimeoutMs(remainingMs, holdMs) {
2804
+ return Math.max(
2805
+ Math.min(remainingMs, holdMs + UPSTREAM_HOLD_SLACK_MS),
2806
+ MIN_STREAM_POLL_TIMEOUT_MS
2807
+ );
2808
+ }
2809
+ function sleep(ms) {
2810
+ return new Promise((resolve) => setTimeout(resolve, ms));
2811
+ }
2812
+ function streamPollPlan(hosted) {
2813
+ return hosted ? {
2814
+ budgetMs: HTTP_LONG_POLL_BUDGET_MS,
2815
+ holdMs: HTTP_STREAM_POLL_WAIT_MS,
2816
+ query: `?wait=${HTTP_STREAM_POLL_WAIT_MS}`
2817
+ } : {
2818
+ budgetMs: STDIO_STREAM_BUDGET_MS,
2819
+ holdMs: STREAM_UPSTREAM_DEFAULT_WAIT_MS,
2820
+ query: ""
2821
+ };
2822
+ }
2823
+ 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).";
2824
+ function budgetExhaustedNote(budgetMs, lastError) {
2825
+ return {
2826
+ pollingTimedOut: true,
2827
+ note: `Polling stopped after ${formatBudget(
2828
+ budgetMs
2829
+ )} with the job possibly still running. ${RESUME_ADVICE}`,
2830
+ // Surface the trailing error (if any) instead of discarding it — the last
2831
+ // polls may have been failing (e.g. job expired) even though earlier ones
2832
+ // succeeded. Cleared on success, so this is never a stale error from
2833
+ // minutes of healthy streaming ago.
2834
+ ...lastError ? { lastError } : {}
2835
+ };
2836
+ }
2837
+ function errorCapNote(lastError) {
2838
+ return {
2839
+ error: `Polling aborted after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors: ${lastError}`,
2840
+ note: `Polling stopped after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors with the job possibly still running. ${RESUME_ADVICE}`
2841
+ };
2842
+ }
2843
+ async function collectJobStream(deps) {
2844
+ const { poll, budgetMs, holdMs } = deps;
2845
+ const startedAt = Date.now();
2846
+ const elapsed = () => Date.now() - startedAt;
2847
+ const chunks = [];
2848
+ let result = {};
2849
+ let consecutiveErrors = 0;
2850
+ let lastError;
2851
+ while (true) {
2852
+ try {
2853
+ const reply = await poll(
2854
+ streamPollTimeoutMs(budgetMs - elapsed(), holdMs)
2855
+ );
2856
+ consecutiveErrors = 0;
2857
+ lastError = void 0;
2858
+ if (Array.isArray(reply.stream)) chunks.push(...reply.stream);
2859
+ result = reply;
2860
+ if (TERMINAL_STATUSES.has(reply.status)) break;
2861
+ } catch (error) {
2862
+ consecutiveErrors++;
2863
+ lastError = error instanceof Error ? error.message : String(error);
2864
+ if (consecutiveErrors >= MAX_CONSECUTIVE_STREAM_ERRORS) {
2865
+ result = { ...result, ...errorCapNote(lastError) };
2866
+ break;
2867
+ }
2868
+ }
2869
+ if (elapsed() > budgetMs) {
2870
+ result = { ...result, ...budgetExhaustedNote(budgetMs, lastError) };
2871
+ break;
2872
+ }
2873
+ await sleep(STREAM_JOB_POLL_INTERVAL_MS);
2874
+ }
2875
+ return { result, chunks };
2876
+ }
2264
2877
  function registerJobTools(server, rt) {
2265
2878
  const { jsonReply: jsonReply2, serverlessRequest, backendFor, callRestUrl } = rt;
2879
+ const hosted = rt.transport === "http";
2880
+ const streamBudget = formatBudget(
2881
+ hosted ? HTTP_LONG_POLL_BUDGET_MS : STDIO_STREAM_BUDGET_MS
2882
+ );
2266
2883
  async function diagnoseQueuedJob(endpointId) {
2267
2884
  const cached = diagnosisCache.get(endpointId);
2268
2885
  if (cached && cached.expiresAt > Date.now()) {
@@ -2273,7 +2890,10 @@ function registerJobTools(server, rt) {
2273
2890
  const backend = backendFor("workers");
2274
2891
  if (backend.version !== "v2") return null;
2275
2892
  const raw = await callRestUrl(
2276
- `${backend.base}/serverless/${endpointId}/workers`
2893
+ `${backend.base}/serverless/${endpointId}/workers`,
2894
+ "GET",
2895
+ void 0,
2896
+ { timeoutMs: QUEUED_DIAGNOSIS_TIMEOUT_MS }
2277
2897
  );
2278
2898
  const summary = raw?.summary;
2279
2899
  const workers = raw?.workers ?? [];
@@ -2364,14 +2984,14 @@ function registerJobTools(server, rt) {
2364
2984
  );
2365
2985
  server.tool(
2366
2986
  "runsync-endpoint",
2367
- "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).",
2987
+ `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)."}`,
2368
2988
  {
2369
2989
  endpointId: endpointIdSchema.describe(
2370
2990
  "ID of the Serverless endpoint to run synchronously"
2371
2991
  ),
2372
2992
  input: inputSchema,
2373
2993
  wait: z9.number().min(1e3).max(3e5).optional().describe(
2374
- "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)."
2994
+ 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)."
2375
2995
  ),
2376
2996
  webhook: webhookSchema,
2377
2997
  policy: policySchema,
@@ -2380,13 +3000,36 @@ function registerJobTools(server, rt) {
2380
3000
  { title: "Run endpoint (sync)", ...WRITE },
2381
3001
  async (params) => {
2382
3002
  const { endpointId, wait, ...body } = params;
2383
- const path = wait ? `/runsync?wait=${wait}` : "/runsync";
3003
+ const effectiveWait = hosted ? Math.min(
3004
+ wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS,
3005
+ HTTP_LONG_POLL_BUDGET_MS
3006
+ ) : wait;
3007
+ const waitQuery = effectiveWait === void 0 ? "" : `?wait=${effectiveWait}`;
3008
+ const path = `/runsync${waitQuery}`;
2384
3009
  const result = await serverlessRequest(
2385
3010
  endpointId,
2386
3011
  path,
2387
3012
  "POST",
2388
- body
3013
+ body,
3014
+ {
3015
+ // From the wait actually sent — on http the clamped 45s, not the
3016
+ // 300s the caller may have asked for.
3017
+ timeoutMs: (effectiveWait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS) + UPSTREAM_HOLD_SLACK_MS
3018
+ }
2389
3019
  );
3020
+ const requestedWait = wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS;
3021
+ if (hosted && requestedWait > HTTP_LONG_POLL_BUDGET_MS && isUnfinishedJobReply(result)) {
3022
+ return jsonReply2({
3023
+ ...result,
3024
+ waitClamped: {
3025
+ requestedMs: requestedWait,
3026
+ effectiveMs: effectiveWait,
3027
+ reason: `This server runs behind a 60-second gateway deadline, so a non-terminal status here means the job outlived ${formatBudget(
3028
+ HTTP_LONG_POLL_BUDGET_MS
3029
+ )}, not the requested wait \u2014 poll get-job-status rather than treating it as stuck.`
3030
+ }
3031
+ });
3032
+ }
2390
3033
  return jsonReply2(result);
2391
3034
  }
2392
3035
  );
@@ -2420,7 +3063,7 @@ function registerJobTools(server, rt) {
2420
3063
  );
2421
3064
  server.tool(
2422
3065
  "stream-job",
2423
- "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.",
3066
+ `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." : ""}`,
2424
3067
  {
2425
3068
  endpointId: endpointIdSchema.describe(
2426
3069
  "ID of the Serverless endpoint the job belongs to"
@@ -2429,51 +3072,16 @@ function registerJobTools(server, rt) {
2429
3072
  },
2430
3073
  { title: "Stream job", ...READ_ONLY },
2431
3074
  async (params) => {
2432
- const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
2433
- "COMPLETED",
2434
- "FAILED",
2435
- "CANCELLED",
2436
- "TIMED_OUT"
2437
- ]);
2438
- const MAX_POLL_TIME_MS = 5 * 60 * 1e3;
2439
- const POLL_INTERVAL_MS = 1e3;
2440
- const MAX_CONSECUTIVE_ERRORS = 5;
2441
- const allChunks = [];
2442
- let finalResult = {};
2443
- let consecutiveErrors = 0;
2444
- let lastError;
2445
- const startTime = Date.now();
2446
- while (true) {
2447
- try {
2448
- const result = await serverlessRequest(
2449
- params.endpointId,
2450
- `/stream/${params.jobId}`
2451
- );
2452
- consecutiveErrors = 0;
2453
- if (Array.isArray(result.stream)) {
2454
- allChunks.push(...result.stream);
2455
- }
2456
- finalResult = result;
2457
- if (TERMINAL_STATUSES.has(result.status)) {
2458
- break;
2459
- }
2460
- } catch (error) {
2461
- consecutiveErrors++;
2462
- lastError = error instanceof Error ? error.message : String(error);
2463
- if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
2464
- finalResult.error = `Polling aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive errors: ${lastError}`;
2465
- break;
2466
- }
2467
- }
2468
- if (Date.now() - startTime > MAX_POLL_TIME_MS) {
2469
- finalResult.pollingTimedOut = true;
2470
- finalResult.note = "Polling timed out after 5 minutes. Use get-job-status to check the job later.";
2471
- if (lastError) finalResult.lastError = lastError;
2472
- break;
2473
- }
2474
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
2475
- }
2476
- return jsonReply2({ ...finalResult, stream: allChunks });
3075
+ const plan = streamPollPlan(hosted);
3076
+ const streamPath = `/stream/${params.jobId}${plan.query}`;
3077
+ const { result, chunks } = await collectJobStream({
3078
+ budgetMs: plan.budgetMs,
3079
+ holdMs: plan.holdMs,
3080
+ poll: (timeoutMs) => serverlessRequest(params.endpointId, streamPath, "GET", void 0, {
3081
+ timeoutMs
3082
+ })
3083
+ });
3084
+ return jsonReply2({ ...result, stream: chunks });
2477
3085
  }
2478
3086
  );
2479
3087
  server.tool(
@@ -3020,4 +3628,4 @@ export {
3020
3628
  restVersionSummary,
3021
3629
  registerTools
3022
3630
  };
3023
- //# sourceMappingURL=chunk-OM6RHPQC.mjs.map
3631
+ //# sourceMappingURL=chunk-ICUS4KGX.mjs.map