@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.cjs CHANGED
@@ -45,32 +45,138 @@ function hostedServerConfig(strategy, serverProperty, url) {
45
45
  }
46
46
  return serverProperty === "servers" ? { type: "http", url } : { url };
47
47
  }
48
- function upsertJsonServer(configPath, serverProperty, value) {
48
+ function describeDialect(dialect) {
49
+ return dialect === "json" ? "JSON" : "JSONC";
50
+ }
51
+ function parseFailure(content, dialect) {
52
+ if (dialect === "json") {
53
+ try {
54
+ JSON.parse(content);
55
+ return void 0;
56
+ } catch (error) {
57
+ return errMessage(error);
58
+ }
59
+ }
60
+ const errors = [];
61
+ jsonc.parse(content, errors, { allowTrailingComma: true });
62
+ return errors.length > 0 ? `JSONC parse error at offset ${errors[0].offset}` : void 0;
63
+ }
64
+ function parseAsClient(content, dialect) {
65
+ if (dialect !== "json") {
66
+ return jsonc.parse(content, [], { allowTrailingComma: true });
67
+ }
68
+ try {
69
+ return JSON.parse(content);
70
+ } catch {
71
+ return void 0;
72
+ }
73
+ }
74
+ function readBackEntry(content, serverProperty, dialect) {
75
+ const parsed = parseAsClient(content, dialect);
76
+ if (typeof parsed !== "object" || parsed === null) return void 0;
77
+ const servers = parsed[serverProperty];
78
+ if (typeof servers !== "object" || servers === null) return void 0;
79
+ return servers[SERVER_NAME2];
80
+ }
81
+ function reliesOnLeniency(content) {
82
+ if (content.trim() === "") return false;
83
+ try {
84
+ JSON.parse(content);
85
+ return false;
86
+ } catch {
87
+ return parseFailure(content, "jsonc") === void 0;
88
+ }
89
+ }
90
+ function upsertJsonServer(configPath, serverProperty, value, dialect) {
91
+ if (!path.isAbsolute(configPath)) {
92
+ return {
93
+ success: false,
94
+ message: `refusing to write "${configPath}": not an absolute path, so it would resolve against the current directory. This usually means an environment variable (APPDATA, XDG_CONFIG_HOME) is set to an empty or relative value.`
95
+ };
96
+ }
49
97
  try {
50
98
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
51
99
  const content = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
52
100
  const edits = jsonc.modify(content, [serverProperty, SERVER_NAME2], value, {
53
101
  formattingOptions: { tabSize: 2, insertSpaces: true }
54
102
  });
55
- fs.writeFileSync(configPath, jsonc.applyEdits(content, edits), "utf8");
103
+ const updated = jsonc.applyEdits(content, edits).replace(/^\uFEFF/, "");
104
+ const failure = parseFailure(updated, dialect);
105
+ if (failure) {
106
+ const wasAlreadyBroken = content.trim() !== "" && parseFailure(content, dialect) !== void 0;
107
+ return {
108
+ success: false,
109
+ message: wasAlreadyBroken ? `${configPath} could not be parsed as ${describeDialect(dialect)} before this change either (${failure}), so it was left untouched \u2014 this client would not load the entry. Fix the file (or move it aside) and re-run.` : `writing to ${configPath} would leave it unparseable (${failure}), so it was left untouched.`
110
+ };
111
+ }
112
+ const landed = readBackEntry(updated, serverProperty, dialect);
113
+ if (JSON.stringify(landed) !== JSON.stringify(value)) {
114
+ return {
115
+ success: false,
116
+ message: `${configPath} was left untouched: the entry would have been written where this client does not read it, so it would have had no effect. This usually means "${serverProperty}" appears more than once in the file \u2014 the last one wins, so merge them into a single block and re-run.`
117
+ };
118
+ }
119
+ fs.writeFileSync(configPath, updated, { encoding: "utf8", mode: 384 });
120
+ if (dialect === "unverified" && reliesOnLeniency(updated)) {
121
+ return {
122
+ success: true,
123
+ message: "written, but this config uses comments or trailing commas and whether this client accepts them is unverified \u2014 if the Runpod server does not appear, remove them"
124
+ };
125
+ }
56
126
  return { success: true };
57
127
  } catch (error) {
58
128
  return { success: false, message: errMessage(error) };
59
129
  }
60
130
  }
61
- function removeJsonServer(configPath, serverProperty) {
131
+ function removeJsonServer(configPath, serverProperty, dialect) {
132
+ if (!path.isAbsolute(configPath)) {
133
+ return {
134
+ success: false,
135
+ message: `refusing to touch "${configPath}": not an absolute path. Check APPDATA / XDG_CONFIG_HOME.`
136
+ };
137
+ }
62
138
  try {
63
139
  if (!fs.existsSync(configPath)) {
64
140
  return { success: true, message: "nothing to remove" };
65
141
  }
66
142
  const content = fs.readFileSync(configPath, "utf8");
67
- const edits = jsonc.modify(
68
- content,
69
- [serverProperty, SERVER_NAME2],
70
- void 0,
71
- { formattingOptions: { tabSize: 2, insertSpaces: true } }
72
- );
73
- fs.writeFileSync(configPath, jsonc.applyEdits(content, edits), "utf8");
143
+ if (content.trim() === "") {
144
+ return { success: true, message: "nothing to remove" };
145
+ }
146
+ const existingFailure = parseFailure(content, dialect);
147
+ if (existingFailure !== void 0) {
148
+ return {
149
+ success: false,
150
+ message: `could not safely remove the Runpod entry from ${configPath}: the existing config is not valid ${describeDialect(dialect)} (${existingFailure}). The original file was left unchanged; repair it or delete the entry by hand.`
151
+ };
152
+ }
153
+ let edits;
154
+ try {
155
+ edits = jsonc.modify(content, [serverProperty, SERVER_NAME2], void 0, {
156
+ formattingOptions: { tabSize: 2, insertSpaces: true }
157
+ });
158
+ } catch (error) {
159
+ if (/empty document/i.test(errMessage(error))) {
160
+ return { success: true, message: "nothing to remove" };
161
+ }
162
+ throw error;
163
+ }
164
+ if (edits.length === 0) {
165
+ return readBackEntry(content, serverProperty, dialect) === void 0 ? { success: true, message: "nothing to remove" } : {
166
+ success: false,
167
+ message: `the Runpod entry is still present in ${configPath} and could not be removed automatically \u2014 "${serverProperty}" appears more than once, so the edit targeted a block this client does not read. Delete the entry by hand.`
168
+ };
169
+ }
170
+ const updated = jsonc.applyEdits(content, edits);
171
+ const brokeIt = parseFailure(updated, dialect) !== void 0;
172
+ const stillThere = readBackEntry(updated, serverProperty, dialect) !== void 0;
173
+ if (brokeIt || stillThere) {
174
+ return {
175
+ success: false,
176
+ message: `could not remove the Runpod entry from ${configPath} without ${brokeIt ? "making the config unparseable" : "leaving the entry present"}. The original file was left unchanged so its comments and formatting are preserved; delete the Runpod entry by hand.`
177
+ };
178
+ }
179
+ fs.writeFileSync(configPath, updated, "utf8");
74
180
  return { success: true };
75
181
  } catch (error) {
76
182
  return { success: false, message: errMessage(error) };
@@ -98,56 +204,461 @@ function jsonClient(opts) {
98
204
  add: (mode) => {
99
205
  const value = mode.kind === "local" ? localServerConfig(mode.apiKey, serverProperty) : hostedServerConfig(hostedStrategy, serverProperty, mode.url);
100
206
  return Promise.resolve(
101
- upsertJsonServer(opts.configPath(), serverProperty, value)
207
+ upsertJsonServer(opts.configPath(), serverProperty, value, opts.dialect)
102
208
  );
103
209
  },
104
- remove: () => Promise.resolve(removeJsonServer(opts.configPath(), serverProperty)),
210
+ dialect: opts.dialect,
211
+ remove: () => Promise.resolve(
212
+ removeJsonServer(opts.configPath(), serverProperty, opts.dialect)
213
+ ),
105
214
  describeTarget: () => opts.configPath()
106
215
  };
107
216
  }
108
- function findClaudeBinary() {
109
- const candidates = [
110
- path.join(os.homedir(), ".claude", "local", "claude"),
111
- "/usr/local/bin/claude",
112
- "/opt/homebrew/bin/claude"
217
+ function claudeCandidates(platform, homedir2, appdata) {
218
+ if (platform === "win32") {
219
+ const win = path.win32;
220
+ const defaultAppdata = win.join(homedir2, "AppData", "Roaming");
221
+ return [
222
+ {
223
+ path: win.join(homedir2, ".local", "bin", "claude.exe"),
224
+ trustedRoot: homedir2
225
+ },
226
+ // npm -g installs a .cmd shim. runClaude bypasses it and resolves the real
227
+ // native or JavaScript target from the installed package manifest.
228
+ //
229
+ // absoluteEnvDir, not `?? `: an empty or relative APPDATA yielded the relative
230
+ // `npm\claude.cmd`. Candidates are probed before any lookup, so a relative
231
+ // candidate would resolve against the current directory and receive the live
232
+ // API key.
233
+ {
234
+ path: win.join(
235
+ absoluteEnvDir(appdata, () => defaultAppdata),
236
+ "npm",
237
+ "claude.cmd"
238
+ ),
239
+ trustedRoot: defaultAppdata
240
+ }
241
+ ];
242
+ }
243
+ return [
244
+ // Where the current native installer puts it (verified on a real install:
245
+ // ~/.local/bin/claude -> versions/2.1.220). Same reasoning as the Windows
246
+ // .local\bin\claude.exe entry — it also covers an install whose PATH entry is
247
+ // missing, which the lookup can never find. Without it, POSIX detection depended
248
+ // entirely on PATH.
249
+ {
250
+ path: path.posix.join(homedir2, ".local", "bin", "claude"),
251
+ trustedRoot: homedir2
252
+ },
253
+ {
254
+ path: path.posix.join(homedir2, ".claude", "local", "claude"),
255
+ trustedRoot: homedir2
256
+ },
257
+ { path: "/usr/local/bin/claude", trustedRoot: "/usr/local" },
258
+ { path: "/opt/homebrew/bin/claude", trustedRoot: "/opt/homebrew" }
113
259
  ];
260
+ }
261
+ function pathIsInside(candidate, directory, pathApi) {
262
+ const relative = pathApi.relative(
263
+ pathApi.resolve(directory),
264
+ pathApi.resolve(candidate)
265
+ );
266
+ return relative === "" || !relative.startsWith(`..${pathApi.sep}`) && relative !== ".." && !pathApi.isAbsolute(relative);
267
+ }
268
+ function isNodeModulesBin(candidate) {
269
+ return /(^|[\\/])node_modules[\\/]\.bin([\\/]|$)/i.test(candidate);
270
+ }
271
+ function pickClaudeBinary(stdout, platform, cwd) {
272
+ if (platform === "win32") return null;
273
+ const hits = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
274
+ return hits.find(
275
+ (hit) => path.posix.isAbsolute(hit) && !isNodeModulesBin(hit) && (cwd === void 0 || !pathIsInside(hit, cwd, path.posix))
276
+ ) ?? null;
277
+ }
278
+ function projectBoundary(cwd, home2) {
279
+ let current = cwd;
280
+ let boundary = null;
281
+ while (true) {
282
+ if (current === home2) return boundary;
283
+ if (PROJECT_BOUNDARY_MARKERS.some(
284
+ (marker) => exists(path.join(current, marker))
285
+ )) {
286
+ boundary = current;
287
+ }
288
+ const parent = path.dirname(current);
289
+ if (parent === current) return boundary;
290
+ current = parent;
291
+ }
292
+ }
293
+ function trustedClaudeBinary(binary, cwd, home2, requiredRoot) {
294
+ try {
295
+ const canonicalBinary = fs.realpathSync.native(binary);
296
+ const canonicalCwd = fs.realpathSync.native(cwd);
297
+ let canonicalHome;
298
+ if (home2 !== void 0) {
299
+ try {
300
+ canonicalHome = fs.realpathSync.native(home2);
301
+ } catch {
302
+ canonicalHome = void 0;
303
+ }
304
+ }
305
+ const projectRoot = projectBoundary(canonicalCwd, canonicalHome);
306
+ const canonicalRequiredRoot = requiredRoot === void 0 ? void 0 : fs.realpathSync.native(requiredRoot);
307
+ if (isNodeModulesBin(canonicalBinary) || canonicalRequiredRoot !== void 0 && !pathIsInside(canonicalBinary, canonicalRequiredRoot, path) || projectRoot !== null && pathIsInside(canonicalBinary, projectRoot, path)) {
308
+ return null;
309
+ }
310
+ return canonicalBinary;
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+ function findClaudeBinary(options = {}) {
316
+ const platform = options.platform ?? process.platform;
317
+ let homedir2 = options.homedir;
318
+ if (homedir2 === void 0) {
319
+ try {
320
+ homedir2 = os.userInfo().homedir;
321
+ } catch {
322
+ homedir2 = null;
323
+ }
324
+ }
325
+ const appdata = options.appdata ?? process.env.APPDATA;
326
+ const cwd = options.cwd ?? process.cwd();
327
+ const candidates = homedir2 == null ? [] : claudeCandidates(platform, homedir2, appdata);
114
328
  for (const candidate of candidates) {
115
- if (exists(candidate)) return candidate;
329
+ if (!path.isAbsolute(candidate.path) && !path.win32.isAbsolute(candidate.path))
330
+ continue;
331
+ if (exists(candidate.path)) {
332
+ const trusted = trustedClaudeBinary(
333
+ candidate.path,
334
+ cwd,
335
+ homedir2 ?? void 0,
336
+ candidate.trustedRoot
337
+ );
338
+ if (trusted !== null) return trusted;
339
+ }
116
340
  }
117
341
  try {
118
- (0, import_child_process.execSync)("command -v claude", { stdio: "pipe" });
119
- return "claude";
342
+ if (platform === "win32") return null;
343
+ const out = (0, import_child_process.execSync)("command -v claude", {
344
+ stdio: ["ignore", "pipe", "ignore"],
345
+ encoding: "utf8"
346
+ });
347
+ const binary = pickClaudeBinary(out, platform);
348
+ return binary === null ? null : trustedClaudeBinary(binary, cwd, homedir2 ?? void 0);
120
349
  } catch {
121
350
  return null;
122
351
  }
123
352
  }
124
- function runClaude(binary, args) {
353
+ function describeCommand(binary, args) {
354
+ const quote = (s) => /\s/.test(s) || CMD_META.test(s) ? `"${s}"` : s;
355
+ return [binary, ...args].map(quote).join(" ");
356
+ }
357
+ function describeCommandRedacted(binary, args) {
358
+ return describeCommand(
359
+ binary,
360
+ args.map(
361
+ (arg) => arg.startsWith("RUNPOD_API_KEY=") ? `RUNPOD_API_KEY=${KEY_PLACEHOLDER}` : arg
362
+ )
363
+ );
364
+ }
365
+ function redactSecret(text, secret) {
366
+ if (!secret) return text;
367
+ return text.split(secret).join(KEY_PLACEHOLDER);
368
+ }
369
+ function sanitizedClaudeEnv() {
370
+ const env = { ...process.env };
371
+ for (const key of Object.keys(env)) {
372
+ if (["path", "comspec", "pathext", "node_options", "node_path"].includes(
373
+ key.toLowerCase()
374
+ )) {
375
+ delete env[key];
376
+ }
377
+ }
378
+ if (process.platform === "win32") {
379
+ env.Path = path.dirname(process.execPath);
380
+ } else {
381
+ env.PATH = [
382
+ path.dirname(process.execPath),
383
+ "/usr/local/bin",
384
+ "/opt/homebrew/bin",
385
+ "/usr/bin",
386
+ "/bin"
387
+ ].join(path.delimiter);
388
+ }
389
+ return env;
390
+ }
391
+ function windowsNpmClaudeTarget(binary) {
392
+ if (process.platform !== "win32" || !binary.toLowerCase().endsWith(".cmd")) {
393
+ return null;
394
+ }
125
395
  try {
126
- (0, import_child_process.execFileSync)(binary, args, { stdio: "pipe" });
127
- return { success: true };
128
- } catch (error) {
129
- const message = errMessage(error);
130
- if (message.includes("already exists")) {
131
- return { success: true, message: "already configured" };
396
+ const npmRoot = fs.realpathSync.native(path.dirname(binary));
397
+ const packageRoot = fs.realpathSync.native(
398
+ path.join(npmRoot, "node_modules", "@anthropic-ai", "claude-code")
399
+ );
400
+ if (!pathIsInside(packageRoot, npmRoot, path)) return null;
401
+ const manifestPath = fs.realpathSync.native(
402
+ path.join(packageRoot, "package.json")
403
+ );
404
+ if (!pathIsInside(manifestPath, packageRoot, path)) return null;
405
+ const manifest = JSON.parse(
406
+ fs.readFileSync(manifestPath, "utf8")
407
+ );
408
+ if (manifest === null || typeof manifest !== "object") return null;
409
+ const bin = manifest.bin;
410
+ const target = typeof bin === "string" ? bin : bin !== null && typeof bin === "object" ? bin.claude : void 0;
411
+ if (typeof target !== "string" || target.trim() === "") return null;
412
+ const entrypoint = fs.realpathSync.native(
413
+ path.resolve(packageRoot, target)
414
+ );
415
+ if (!pathIsInside(entrypoint, packageRoot, path)) return null;
416
+ if (/\.(?:exe|com)$/i.test(entrypoint)) {
417
+ return { command: entrypoint, argsPrefix: [] };
418
+ }
419
+ if (/\.(?:c?js|mjs)$/i.test(entrypoint)) {
420
+ return { command: process.execPath, argsPrefix: [entrypoint] };
132
421
  }
133
- return { success: false, message };
422
+ return null;
423
+ } catch {
424
+ return null;
134
425
  }
135
426
  }
427
+ function runClaude(binary, args, secret) {
428
+ const isWindowsCmd = process.platform === "win32" && binary.toLowerCase().endsWith(".cmd");
429
+ const npmTarget = windowsNpmClaudeTarget(binary);
430
+ if (isWindowsCmd && npmTarget === null) {
431
+ return {
432
+ success: false,
433
+ refused: true,
434
+ message: `could not locate a trusted Claude Code npm entrypoint beside ${binary}. Run the shim yourself, substituting your key:
435
+ ${describeCommandRedacted(binary, args)}`
436
+ };
437
+ }
438
+ const command = npmTarget?.command ?? binary;
439
+ const commandArgs = [...npmTarget?.argsPrefix ?? [], ...args];
440
+ const result = (0, import_child_process.spawnSync)(command, commandArgs, {
441
+ stdio: "pipe",
442
+ encoding: "utf8",
443
+ env: sanitizedClaudeEnv()
444
+ });
445
+ if (result.error)
446
+ return {
447
+ success: false,
448
+ message: redactSecret(errMessage(result.error), secret)
449
+ };
450
+ const stdio = `${result.stdout ?? ""}${result.stderr ?? ""}`;
451
+ if (result.status === 0) return { success: true, output: stdio };
452
+ if (result.status === null) {
453
+ return { success: false, message: `killed by ${result.signal}` };
454
+ }
455
+ if (stdio.includes("already exists")) {
456
+ return {
457
+ success: true,
458
+ // Deliberately not "to replace the API key": this branch is also reached in
459
+ // hosted mode, where there is no key — and the pre-existing entry may be of the
460
+ // other kind entirely, which is exactly what the user needs to know.
461
+ message: `already configured, existing entry left unchanged \u2014 it was NOT updated to what you just chose (to replace it, run \`${describeCommand(binary, ["mcp", "remove", SERVER_NAME2, "--scope", "user"])}\` first, then re-run this wizard)`
462
+ };
463
+ }
464
+ return {
465
+ success: false,
466
+ message: redactSecret(
467
+ stdio.trim() || `${path.basename(binary)} exited with code ${result.status}`,
468
+ secret
469
+ )
470
+ };
471
+ }
472
+ function absoluteEnvDir(value, fallback) {
473
+ if (!value || !value.trim()) return fallback();
474
+ if (!path.isAbsolute(value) && !path.win32.isAbsolute(value))
475
+ return fallback();
476
+ return value;
477
+ }
478
+ function claudeUserConfigPath() {
479
+ const configured = process.env.CLAUDE_CONFIG_DIR;
480
+ return path.join(
481
+ configured ? path.resolve(configured) : os.homedir(),
482
+ ".claude.json"
483
+ );
484
+ }
485
+ function readClaudeConfigState(configPath) {
486
+ const unknown = {
487
+ configPath,
488
+ userScope: void 0,
489
+ localScopeDirs: []
490
+ };
491
+ if (!path.isAbsolute(configPath)) return unknown;
492
+ let raw;
493
+ try {
494
+ if (!fs.existsSync(configPath)) {
495
+ return { configPath, userScope: false, localScopeDirs: [] };
496
+ }
497
+ raw = fs.readFileSync(configPath, "utf8");
498
+ } catch {
499
+ return unknown;
500
+ }
501
+ const errors = [];
502
+ const parsed = jsonc.parse(raw.replace(/^\uFEFF/, ""), errors, {
503
+ allowTrailingComma: true
504
+ });
505
+ if (errors.length > 0 || typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
506
+ return unknown;
507
+ }
508
+ const has = (servers) => typeof servers === "object" && servers !== null && Object.prototype.hasOwnProperty.call(servers, SERVER_NAME2);
509
+ const own = (key) => Object.prototype.hasOwnProperty.call(parsed, key) ? parsed[key] : void 0;
510
+ const projects = own("projects");
511
+ const localScopeDirs = typeof projects === "object" && projects !== null && !Array.isArray(projects) ? Object.entries(projects).filter(([, project]) => has(project?.mcpServers)).map(([dir]) => dir) : [];
512
+ return { configPath, userScope: has(own("mcpServers")), localScopeDirs };
513
+ }
514
+ function describeDirs(dirs) {
515
+ if (dirs.length === 1) return dirs[0];
516
+ return `${dirs.length} project directories (${dirs.slice(0, 3).join(", ")}${dirs.length > 3 ? ", \u2026" : ""})`;
517
+ }
518
+ function interpretRemoveResult(result, state, binary = "claude") {
519
+ if (result.refused) return result;
520
+ const alsoElsewhere = state.localScopeDirs.length > 0 ? ` A separate local-scope ${SERVER_NAME2} entry (which may carry its own API key) also exists in ${describeDirs(state.localScopeDirs)} \u2014 this wizard does not touch those. Remove with \`${describeCommand(binary, ["mcp", "remove", SERVER_NAME2, "--scope", "local"])}\` from each.` : "";
521
+ if (state.userScope === true) {
522
+ return {
523
+ success: false,
524
+ message: `the ${SERVER_NAME2} entry is STILL in Claude Code's user config (${state.configPath}), so your API key is still on disk. ${result.success ? "Most likely that file is not writable \u2014 the CLI reports success anyway." : `The removal command itself failed: ${result.message ?? "no output"}`}${alsoElsewhere}`
525
+ };
526
+ }
527
+ if (state.userScope === false) {
528
+ return {
529
+ success: true,
530
+ message: (result.success ? "removed from your user config" : "nothing to remove in your user config") + alsoElsewhere
531
+ };
532
+ }
533
+ if (result.success) {
534
+ return {
535
+ success: true,
536
+ message: `removal reported success, but ${state.configPath} could not be read to confirm it`
537
+ };
538
+ }
539
+ return { success: false, message: result.message };
540
+ }
541
+ function interpretAddResult(result, state, binary) {
542
+ if (result.refused || !result.success) return result;
543
+ if (state.userScope === false) {
544
+ return {
545
+ success: false,
546
+ message: `Claude Code reported success but no ${SERVER_NAME2} entry is in its user config afterwards (${state.configPath}) \u2014 that file is most likely not writable. Nothing was configured.`
547
+ };
548
+ }
549
+ if (state.userScope === true && state.localScopeDirs.length > 0) {
550
+ return {
551
+ success: true,
552
+ message: `${result.message ? `${result.message}; ` : ""}note a local-scope ${SERVER_NAME2} entry also exists in ${describeDirs(state.localScopeDirs)} and takes precedence over the user config there, so Claude Code will keep using it in those projects \u2014 remove it with \`${describeCommand(binary, ["mcp", "remove", SERVER_NAME2, "--scope", "local"])}\` run from each`
553
+ };
554
+ }
555
+ if (state.userScope === void 0) {
556
+ return {
557
+ success: true,
558
+ message: `${result.message ? `${result.message}; ` : ""}could not read ${state.configPath} to confirm the entry landed`
559
+ };
560
+ }
561
+ return { success: true, message: result.message };
562
+ }
563
+ function createClaudeCodeClient(resolveBinary, readState = () => readClaudeConfigState(claudeUserConfigPath())) {
564
+ return {
565
+ id: "claude-code",
566
+ name: "Claude Code",
567
+ detect: () => Promise.resolve(resolveBinary() !== null),
568
+ add: (mode) => {
569
+ const args = mode.kind === "local" ? [
570
+ "mcp",
571
+ "add",
572
+ SERVER_NAME2,
573
+ "--scope",
574
+ "user",
575
+ "-e",
576
+ `RUNPOD_API_KEY=${mode.apiKey}`,
577
+ "--",
578
+ "npx",
579
+ "-y",
580
+ NPM_PACKAGE
581
+ ] : [
582
+ "mcp",
583
+ "add",
584
+ "--transport",
585
+ "http",
586
+ "--scope",
587
+ "user",
588
+ SERVER_NAME2,
589
+ mode.url
590
+ ];
591
+ const binary = resolveBinary();
592
+ if (!binary) {
593
+ return Promise.resolve({
594
+ success: false,
595
+ message: `claude CLI not found in the standard install locations. If Claude Code is installed somewhere custom, register it yourself${mode.kind === "local" ? ", substituting your key" : ""}:
596
+ ${describeCommandRedacted("claude", args)}`
597
+ });
598
+ }
599
+ const result = runClaude(
600
+ binary,
601
+ args,
602
+ mode.kind === "local" ? mode.apiKey : void 0
603
+ );
604
+ if (!result.success) return Promise.resolve(result);
605
+ return Promise.resolve(interpretAddResult(result, readState(), binary));
606
+ },
607
+ remove: () => {
608
+ const binary = resolveBinary();
609
+ if (!binary) {
610
+ return Promise.resolve({
611
+ success: false,
612
+ message: `claude CLI not found in the standard install locations. If Claude Code is installed somewhere custom, remove the entry yourself:
613
+ ${describeCommand("claude", ["mcp", "remove", SERVER_NAME2, "--scope", "user"])}`
614
+ });
615
+ }
616
+ const result = runClaude(binary, [
617
+ "mcp",
618
+ "remove",
619
+ "--scope",
620
+ "user",
621
+ SERVER_NAME2
622
+ ]);
623
+ if (result.refused) return Promise.resolve(result);
624
+ return Promise.resolve(
625
+ interpretRemoveResult(result, readState(), binary)
626
+ );
627
+ },
628
+ describeTarget: () => "Claude Code user config (via `claude mcp` CLI)"
629
+ };
630
+ }
631
+ function appDataDir() {
632
+ return absoluteEnvDir(
633
+ process.env.APPDATA,
634
+ () => path.join(home, "AppData", "Roaming")
635
+ );
636
+ }
136
637
  function claudeDesktopConfigPath() {
137
638
  if (process.platform === "win32") {
138
- return path.join(appData, "Claude", "claude_desktop_config.json");
639
+ return path.join(appDataDir(), "Claude", "claude_desktop_config.json");
640
+ }
641
+ if (process.platform === "darwin") {
642
+ return path.join(
643
+ home,
644
+ "Library",
645
+ "Application Support",
646
+ "Claude",
647
+ "claude_desktop_config.json"
648
+ );
139
649
  }
140
650
  return path.join(
141
- home,
142
- "Library",
143
- "Application Support",
651
+ absoluteEnvDir(
652
+ process.env.XDG_CONFIG_HOME,
653
+ () => path.join(home, ".config")
654
+ ),
144
655
  "Claude",
145
656
  "claude_desktop_config.json"
146
657
  );
147
658
  }
148
659
  function vsCodeConfigPath() {
149
660
  if (process.platform === "win32") {
150
- return path.join(appData, "Code", "User", "mcp.json");
661
+ return path.join(appDataDir(), "Code", "User", "mcp.json");
151
662
  }
152
663
  if (process.platform === "darwin") {
153
664
  return path.join(
@@ -159,9 +670,17 @@ function vsCodeConfigPath() {
159
670
  "mcp.json"
160
671
  );
161
672
  }
162
- return path.join(home, ".config", "Code", "User", "mcp.json");
673
+ return path.join(
674
+ absoluteEnvDir(
675
+ process.env.XDG_CONFIG_HOME,
676
+ () => path.join(home, ".config")
677
+ ),
678
+ "Code",
679
+ "User",
680
+ "mcp.json"
681
+ );
163
682
  }
164
- var fs, os, path, import_child_process, jsonc, SERVER_NAME2, NPM_PACKAGE, HOSTED_URL, claudeCodeClient, home, appData, CLIENTS;
683
+ var fs, os, path, import_child_process, jsonc, SERVER_NAME2, NPM_PACKAGE, HOSTED_URL, PROJECT_BOUNDARY_MARKERS, CMD_META, KEY_PLACEHOLDER, claudeCodeClient, home, CLIENTS;
165
684
  var init_clients = __esm({
166
685
  "src/install/clients.ts"() {
167
686
  "use strict";
@@ -173,73 +692,37 @@ var init_clients = __esm({
173
692
  SERVER_NAME2 = "runpod";
174
693
  NPM_PACKAGE = "@runpod/mcp-server@latest";
175
694
  HOSTED_URL = process.env.RUNPOD_MCP_URL ?? "https://mcp.getrunpod.io/";
176
- claudeCodeClient = {
177
- id: "claude-code",
178
- name: "Claude Code",
179
- detect: () => Promise.resolve(findClaudeBinary() !== null),
180
- add: (mode) => {
181
- const binary = findClaudeBinary();
182
- if (!binary) {
183
- return Promise.resolve({
184
- success: false,
185
- message: "claude CLI not found"
186
- });
187
- }
188
- const args = mode.kind === "local" ? [
189
- "mcp",
190
- "add",
191
- SERVER_NAME2,
192
- "--scope",
193
- "user",
194
- "-e",
195
- `RUNPOD_API_KEY=${mode.apiKey}`,
196
- "--",
197
- "npx",
198
- "-y",
199
- NPM_PACKAGE
200
- ] : [
201
- "mcp",
202
- "add",
203
- "--transport",
204
- "http",
205
- "--scope",
206
- "user",
207
- SERVER_NAME2,
208
- mode.url
209
- ];
210
- return Promise.resolve(runClaude(binary, args));
211
- },
212
- remove: () => {
213
- const binary = findClaudeBinary();
214
- if (!binary) {
215
- return Promise.resolve({
216
- success: false,
217
- message: "claude CLI not found"
218
- });
219
- }
220
- try {
221
- (0, import_child_process.execFileSync)(binary, ["mcp", "remove", "--scope", "user", SERVER_NAME2], {
222
- stdio: "pipe"
223
- });
224
- return Promise.resolve({ success: true });
225
- } catch (error) {
226
- return Promise.resolve({ success: true, message: errMessage(error) });
227
- }
228
- },
229
- describeTarget: () => "Claude Code user config (via `claude mcp` CLI)"
230
- };
695
+ PROJECT_BOUNDARY_MARKERS = [
696
+ ".git",
697
+ "package.json",
698
+ "pnpm-workspace.yaml",
699
+ "pnpm-lock.yaml",
700
+ "package-lock.json",
701
+ "yarn.lock",
702
+ "bun.lock",
703
+ "bun.lockb"
704
+ ];
705
+ CMD_META = /[()\][%!^"`<>&|;, *?\t\n\v\r]/;
706
+ KEY_PLACEHOLDER = "YOUR_RUNPOD_API_KEY";
707
+ claudeCodeClient = createClaudeCodeClient(findClaudeBinary);
231
708
  home = os.homedir();
232
- appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
233
709
  CLIENTS = [
234
710
  claudeCodeClient,
235
711
  jsonClient({
236
712
  id: "cursor",
237
713
  name: "Cursor",
714
+ // A VS Code fork, and its MCP reader keeps VS Code's tolerance — comments and
715
+ // trailing commas both parse. Read out of the shipped bundle, not assumed from
716
+ // "Electron app"; see ConfigDialect.
717
+ dialect: "jsonc",
238
718
  configPath: () => path.join(home, ".cursor", "mcp.json")
239
719
  }),
240
720
  jsonClient({
241
721
  id: "windsurf",
242
722
  name: "Windsurf",
723
+ // Not installed on any machine this was checked on, so its parser was never read.
724
+ // Lenient-and-disclosed rather than a guess in either direction.
725
+ dialect: "unverified",
243
726
  // Windsurf is stdio-only, so it reaches the hosted server via mcp-remote.
244
727
  hostedStrategy: "mcp-remote",
245
728
  configPath: () => path.join(home, ".codeium", "windsurf", "mcp_config.json")
@@ -247,6 +730,9 @@ var init_clients = __esm({
247
730
  jsonClient({
248
731
  id: "claude-desktop",
249
732
  name: "Claude Desktop",
733
+ // Genuinely strict: its reader is a bare `JSON.parse(readFileSync(...))` with no
734
+ // preprocessing and no retry, so a comment or trailing comma is fatal.
735
+ dialect: "json",
250
736
  // Claude Desktop config files are stdio-only; use the mcp-remote bridge.
251
737
  hostedStrategy: "mcp-remote",
252
738
  configPath: claudeDesktopConfigPath,
@@ -257,6 +743,9 @@ var init_clients = __esm({
257
743
  id: "vscode",
258
744
  name: "Visual Studio Code",
259
745
  serverProperty: "servers",
746
+ // The one client that genuinely reads JSONC — comments and trailing commas in
747
+ // mcp.json are supported and common.
748
+ dialect: "jsonc",
260
749
  configPath: vsCodeConfigPath,
261
750
  detectPaths: () => [path.dirname(vsCodeConfigPath())]
262
751
  })
@@ -264,6 +753,33 @@ var init_clients = __esm({
264
753
  }
265
754
  });
266
755
 
756
+ // src/install/verify-key.ts
757
+ async function verifyApiKey(apiKey, timeoutMs = VERIFY_API_KEY_TIMEOUT_MS) {
758
+ try {
759
+ const base = process.env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
760
+ const response = await fetch(`${base}/pods`, {
761
+ headers: { Authorization: `Bearer ${apiKey}` },
762
+ // Without this the wizard sits at "Verifying…" forever against a host
763
+ // that accepts the connection and goes quiet, with no way out but ^C.
764
+ // An abort lands in the catch below and is reported as "check failed",
765
+ // which is exactly what it is.
766
+ signal: AbortSignal.timeout(timeoutMs)
767
+ });
768
+ if (response.ok) return true;
769
+ if (response.status === 401 || response.status === 403) return false;
770
+ return null;
771
+ } catch {
772
+ return null;
773
+ }
774
+ }
775
+ var VERIFY_API_KEY_TIMEOUT_MS;
776
+ var init_verify_key = __esm({
777
+ "src/install/verify-key.ts"() {
778
+ "use strict";
779
+ VERIFY_API_KEY_TIMEOUT_MS = 1e4;
780
+ }
781
+ });
782
+
267
783
  // src/install/wizard.ts
268
784
  var wizard_exports = {};
269
785
  __export(wizard_exports, {
@@ -281,25 +797,12 @@ function openBrowser(url) {
281
797
  } catch {
282
798
  }
283
799
  }
284
- async function verifyApiKey(apiKey) {
285
- try {
286
- const base = process.env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
287
- const response = await fetch(`${base}/pods`, {
288
- headers: { Authorization: `Bearer ${apiKey}` }
289
- });
290
- if (response.ok) return true;
291
- if (response.status === 401 || response.status === 403) return false;
292
- return null;
293
- } catch {
294
- return null;
295
- }
296
- }
297
800
  function bail(message = "Cancelled.") {
298
801
  p.cancel(message);
299
802
  process.exit(0);
300
803
  }
301
804
  async function promptForApiKey() {
302
- const fromEnv = process.env.RUNPOD_API_KEY;
805
+ const fromEnv = process.env.RUNPOD_API_KEY?.trim();
303
806
  if (fromEnv) {
304
807
  const useEnv = await p.confirm({
305
808
  message: "Use the RUNPOD_API_KEY from your environment?"
@@ -394,7 +897,9 @@ async function runAdd() {
394
897
  const result = await client.add(mode);
395
898
  if (result.success) {
396
899
  spinner2.stop(`${client.name} configured.`);
397
- results.push(`\u2713 ${client.name} \u2014 ${client.describeTarget()}`);
900
+ results.push(
901
+ `\u2713 ${client.name} \u2014 ${result.message ? `${result.message}; ` : ""}${client.describeTarget()}`
902
+ );
398
903
  } else {
399
904
  spinner2.stop(`${client.name} failed.`);
400
905
  results.push(`\u2717 ${client.name} \u2014 ${result.message ?? "unknown error"}`);
@@ -438,6 +943,7 @@ var init_wizard = __esm({
438
943
  import_child_process2 = require("child_process");
439
944
  p = __toESM(require("@clack/prompts"), 1);
440
945
  init_clients();
946
+ init_verify_key();
441
947
  API_KEYS_URL = "https://www.runpod.io/console/user/settings";
442
948
  }
443
949
  });
@@ -476,7 +982,7 @@ function containerConfigToV2(p2) {
476
982
  function gpuConfigToV2(gpuTypeIds, gpuCount) {
477
983
  const id = gpuTypeIds?.[0];
478
984
  if (id === void 0) return void 0;
479
- return compact({ id, count: gpuCount });
985
+ return { id, count: gpuCount ?? 1 };
480
986
  }
481
987
  function mapPodCreateToV2(params) {
482
988
  return compact({
@@ -603,6 +1109,42 @@ function podBodyFromTemplate(template) {
603
1109
  }
604
1110
  return out;
605
1111
  }
1112
+ var SSH_PUBLIC_KEY_LINE = /^(ssh-[a-z0-9-]+|ecdsa-[a-z0-9-]+|sk-[a-z0-9@.-]+)\s+\S+/i;
1113
+ var PRIVATE_KEY_MARKER = /private[-\s]?key|PuTTY-User-Key-File/i;
1114
+ function normalizeSshPublicKey(input) {
1115
+ if (PRIVATE_KEY_MARKER.test(input))
1116
+ return {
1117
+ ok: false,
1118
+ 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").'
1119
+ };
1120
+ const lines = input.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
1121
+ if (lines.length === 0)
1122
+ return {
1123
+ ok: false,
1124
+ error: "sshPublicKey is empty. Pass the contents of your ~/.ssh/<key>.pub file, or omit the parameter entirely to create the pod without SSH."
1125
+ };
1126
+ if (lines.some((line) => !SSH_PUBLIC_KEY_LINE.test(line)))
1127
+ return {
1128
+ ok: false,
1129
+ 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.'
1130
+ };
1131
+ return { ok: true, key: lines.join("\n") };
1132
+ }
1133
+ function applySshPublicKey(body, sshPublicKey) {
1134
+ const ports = Array.isArray(body.ports) ? body.ports : [];
1135
+ const hasSshPort = ports.some((p2) => /^\s*22\/tcp\s*$/i.test(String(p2)));
1136
+ const env = { ...body.env ?? {} };
1137
+ const key = sshPublicKey.trim();
1138
+ if (!env.PUBLIC_KEY) env.PUBLIC_KEY = key;
1139
+ else if (!env.PUBLIC_KEY.includes(key))
1140
+ env.PUBLIC_KEY = `${env.PUBLIC_KEY}
1141
+ ${key}`;
1142
+ return {
1143
+ ...body,
1144
+ ports: hasSshPort ? ports : [...ports, "22/tcp"],
1145
+ env
1146
+ };
1147
+ }
606
1148
  function mapTemplateUpdateToV2(params) {
607
1149
  return {
608
1150
  ...containerConfigToV2(params),
@@ -646,7 +1188,7 @@ function restV1Base(env) {
646
1188
  return env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
647
1189
  }
648
1190
  function restV2Base(env) {
649
- return env.RUNPOD_REST_V2_API_URL ?? "https://v2-rest.runpod.io/v2";
1191
+ return env.RUNPOD_REST_V2_API_URL ?? "https://api.runpod.io/v2";
650
1192
  }
651
1193
  function serverlessBase(env) {
652
1194
  return env.RUNPOD_SERVERLESS_API_URL ?? "https://api.runpod.ai/v2";
@@ -868,32 +1410,203 @@ function resolveBackend(opts) {
868
1410
 
869
1411
  // src/server.ts
870
1412
  var SERVER_NAME = "Runpod API Server";
871
- var SERVER_VERSION = true ? "3.1.0" : "dev";
1413
+ var SERVER_VERSION = true ? "3.3.0" : "dev";
1414
+ 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.
1415
+
1416
+ 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.`;
872
1417
  function createServer(version = SERVER_VERSION) {
873
- return new import_mcp.McpServer({
874
- name: SERVER_NAME,
875
- version: `${version} [${restVersionSummary(process.env)}]`,
876
- capabilities: {
877
- resources: {},
878
- tools: {}
1418
+ return new import_mcp.McpServer(
1419
+ {
1420
+ name: SERVER_NAME,
1421
+ version: `${version} [${restVersionSummary(process.env)}]`
1422
+ },
1423
+ {
1424
+ // Only advertise capabilities the server implements. It registers
1425
+ // tools and no resources — advertising `resources` would invite
1426
+ // `resources/list` calls that answer -32601.
1427
+ capabilities: {
1428
+ tools: {}
1429
+ },
1430
+ instructions: SERVER_INSTRUCTIONS
879
1431
  }
880
- });
1432
+ );
881
1433
  }
882
1434
 
883
1435
  // src/tools/runtime.ts
884
1436
  var import_node_fetch = __toESM(require("node-fetch"), 1);
885
1437
  var import_node_crypto = require("crypto");
886
1438
 
1439
+ // src/_shared/rate-limit.ts
1440
+ var GENERIC_RATE_LIMIT_HINT = "rate limited \u2014 back off before retrying, and pace bulk operations";
1441
+ var MAX_PLAUSIBLE_WAIT_S = 7 * 86400;
1442
+ function remainingRequests(raw) {
1443
+ const value = raw?.trim();
1444
+ return value && /^\d+$/.test(value) ? Number(value) : null;
1445
+ }
1446
+ function waitSeconds(raw) {
1447
+ const value = raw?.trim();
1448
+ if (!value || !/^\d+$/.test(value)) return null;
1449
+ const seconds = Number(value);
1450
+ return seconds <= MAX_PLAUSIBLE_WAIT_S ? seconds : null;
1451
+ }
1452
+ var WINDOW_NAME_RE = /^(?:"([\x20-\x21\x23-\x5b\x5d-\x7e]{1,32})"|([A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]{0,31}))$/;
1453
+ function windowName(raw) {
1454
+ const match = WINDOW_NAME_RE.exec(raw.trim());
1455
+ return match ? match[1] ?? match[2] : null;
1456
+ }
1457
+ var QUOTED_VALUE_RE = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"$/;
1458
+ var DISPLAY_VALUE_RE = /^%"(?:[\x20-\x21\x23-\x24\x26-\x5b\x5d-\x7e]|\\|%[0-9a-f]{2})*"$/;
1459
+ function isWellFormedParam(param) {
1460
+ const eq = param.indexOf("=");
1461
+ const key = (eq === -1 ? param : param.slice(0, eq)).trim();
1462
+ if (key.includes('"')) return false;
1463
+ if (eq === -1) return true;
1464
+ const value = param.slice(eq + 1).trim();
1465
+ if (value.startsWith('"')) return QUOTED_VALUE_RE.test(value);
1466
+ if (value.startsWith('%"')) return DISPLAY_VALUE_RE.test(value);
1467
+ return !/[\s"]/.test(value);
1468
+ }
1469
+ function splitOutsideStrings(input, sep) {
1470
+ const parts = [];
1471
+ let current = "";
1472
+ let inString = false;
1473
+ let inDisplay = false;
1474
+ let escaped = false;
1475
+ let prev = "";
1476
+ for (const ch of input) {
1477
+ if (escaped) escaped = false;
1478
+ else if (inString && !inDisplay && ch === "\\") escaped = true;
1479
+ else if (ch === '"') {
1480
+ if (!inString) inDisplay = prev === "%";
1481
+ inString = !inString;
1482
+ } else if (ch === sep && !inString) {
1483
+ parts.push(current);
1484
+ current = "";
1485
+ prev = ch;
1486
+ continue;
1487
+ }
1488
+ current += ch;
1489
+ prev = ch;
1490
+ }
1491
+ parts.push(current);
1492
+ return parts;
1493
+ }
1494
+ function readParams(params) {
1495
+ const values = /* @__PURE__ */ new Map();
1496
+ let unreadable = false;
1497
+ let valuelessAt = -1;
1498
+ for (const [index, param] of params.entries()) {
1499
+ const eq = param.indexOf("=");
1500
+ const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase();
1501
+ if (!key) continue;
1502
+ if (eq === -1) {
1503
+ if (key === "r") unreadable = true;
1504
+ if (key === "t") values.delete("t");
1505
+ valuelessAt = index;
1506
+ continue;
1507
+ }
1508
+ if ((key === "r" || key === "t") && (values.has(key) || valuelessAt !== -1 && index > valuelessAt)) {
1509
+ unreadable = true;
1510
+ }
1511
+ values.set(key, param.slice(eq + 1));
1512
+ }
1513
+ return { values, unreadable };
1514
+ }
1515
+ function parseExhaustedWindows(header) {
1516
+ if (!header) return { windows: [], dropped: false };
1517
+ const windows = [];
1518
+ let dropped = false;
1519
+ for (const item of splitOutsideStrings(header, ",")) {
1520
+ if (item.trim() === "") continue;
1521
+ const [rawName, ...params] = splitOutsideStrings(item, ";");
1522
+ if (!params.every(isWellFormedParam)) {
1523
+ dropped = true;
1524
+ continue;
1525
+ }
1526
+ const { values, unreadable } = readParams(params);
1527
+ if (unreadable) {
1528
+ dropped = true;
1529
+ continue;
1530
+ }
1531
+ const remaining = remainingRequests(values.get("r"));
1532
+ if (remaining === null) {
1533
+ dropped = true;
1534
+ continue;
1535
+ }
1536
+ if (remaining !== 0) continue;
1537
+ windows.push({
1538
+ window: windowName(rawName),
1539
+ resetS: waitSeconds(values.get("t"))
1540
+ });
1541
+ }
1542
+ return { windows, dropped };
1543
+ }
1544
+ function humanWait(seconds) {
1545
+ if (seconds < 120) return `~${seconds}s`;
1546
+ if (seconds < 3600)
1547
+ return `~${Math.ceil(seconds / 60)} minutes (${seconds}s)`;
1548
+ if (seconds < 2 * 86400) {
1549
+ const hours = Math.ceil(seconds / 3600);
1550
+ return `~${hours} hour${hours === 1 ? "" : "s"} (${seconds}s)`;
1551
+ }
1552
+ return `~${Math.ceil(seconds / 86400)} days (${seconds}s)`;
1553
+ }
1554
+ function rateLimitHint(rateLimitHeader, retryAfterHeader) {
1555
+ const { windows, dropped } = parseExhaustedWindows(rateLimitHeader);
1556
+ windows.sort((a, b) => (b.resetS ?? -1) - (a.resetS ?? -1));
1557
+ const longestWindow = windows[0];
1558
+ const waitCandidates = [
1559
+ waitSeconds(retryAfterHeader),
1560
+ longestWindow?.resetS
1561
+ ].filter((n) => typeof n === "number" && n > 0);
1562
+ const waitS = waitCandidates.length ? Math.max(...waitCandidates) : null;
1563
+ const retryAfterUnreadable = typeof retryAfterHeader === "string" && retryAfterHeader.trim() !== "" && waitSeconds(retryAfterHeader) === null;
1564
+ if (!longestWindow && waitS === null) {
1565
+ if (!retryAfterUnreadable) return GENERIC_RATE_LIMIT_HINT;
1566
+ }
1567
+ const namedClause = longestWindow?.window ? `the ${longestWindow.window} quota is exhausted` : "";
1568
+ if (waitS === null) {
1569
+ const cause = namedClause ? `${namedClause}, but the response gave no usable reset time` : "the response gave no usable reset time";
1570
+ return `rate limited \u2014 ${cause}; back off substantially before retrying, and pace bulk operations`;
1571
+ }
1572
+ const hedge = dropped || retryAfterUnreadable || windows.some((w) => w.resetS === null) ? "at least " : "";
1573
+ const wait = `wait ${hedge}${humanWait(waitS)} before retrying, and pace bulk operations`;
1574
+ return `rate limited \u2014 ${namedClause ? `${namedClause}; ` : ""}${wait}`;
1575
+ }
1576
+
887
1577
  // src/_shared/http.ts
1578
+ 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";
888
1579
  var HttpError = class extends Error {
889
- constructor(prefix, status, body) {
890
- 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)" : "";
891
- super(`${prefix}: ${status} - ${body}${hint}`);
1580
+ constructor(prefix, status, body, hint) {
1581
+ const shownBody = body.length > 2048 ? `${body.slice(0, 2048)}\u2026 [truncated ${body.length - 2048} chars]` : body;
1582
+ super(`${prefix}: ${status} - ${shownBody}${hint ? ` (${hint})` : ""}`);
892
1583
  this.name = "HttpError";
893
1584
  this.status = status;
894
1585
  this.body = body;
895
1586
  }
896
1587
  };
1588
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
1589
+ var MIN_REQUEST_TIMEOUT_MS = 1e3;
1590
+ var RequestTimeoutError = class extends Error {
1591
+ constructor(prefix, timeoutMs, method) {
1592
+ const write = method !== "GET" && method !== "HEAD";
1593
+ super(
1594
+ `${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."}`
1595
+ );
1596
+ this.name = "RequestTimeoutError";
1597
+ this.timeoutMs = timeoutMs;
1598
+ this.method = method;
1599
+ }
1600
+ };
1601
+ async function withRequestTimeout(errorPrefix, timeoutMs, method, run) {
1602
+ const signal = AbortSignal.timeout(timeoutMs);
1603
+ try {
1604
+ return await run(signal);
1605
+ } catch (error) {
1606
+ if (error instanceof HttpError || !signal.aborted) throw error;
1607
+ throw new RequestTimeoutError(errorPrefix, timeoutMs, method);
1608
+ }
1609
+ }
897
1610
  function isJsonContentType(contentType) {
898
1611
  if (!contentType) return false;
899
1612
  const ct = contentType.toLowerCase();
@@ -909,24 +1622,49 @@ function buildRequestHeaders(apiKey, tracking) {
909
1622
  ...tracking()
910
1623
  };
911
1624
  }
1625
+ function resolveCeiling(ceiling) {
1626
+ if (ceiling === void 0) return Infinity;
1627
+ if (typeof ceiling !== "function") return ceiling;
1628
+ return Math.max(ceiling(), MIN_REQUEST_TIMEOUT_MS);
1629
+ }
1630
+ function clampTimeout(requestedMs, ceiling, defaultMs = DEFAULT_REQUEST_TIMEOUT_MS) {
1631
+ return Math.min(requestedMs ?? defaultMs, resolveCeiling(ceiling));
1632
+ }
912
1633
  function createHttpClient(deps) {
913
- return async function request(url, method = "GET", body) {
914
- const init = {
1634
+ const defaultTimeoutMs = deps.defaultTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1635
+ return async function request(url, method = "GET", body, options) {
1636
+ const timeoutMs = clampTimeout(
1637
+ options?.timeoutMs,
1638
+ deps.maxTimeoutMs,
1639
+ defaultTimeoutMs
1640
+ );
1641
+ return withRequestTimeout(
1642
+ deps.errorPrefix,
1643
+ timeoutMs,
915
1644
  method,
916
- headers: buildRequestHeaders(deps.apiKey, deps.tracking)
917
- };
918
- if (body && methodSendsBody(method)) {
919
- init.body = JSON.stringify(body);
920
- }
921
- const response = await deps.fetch(url, init);
922
- if (!response.ok) {
923
- throw new HttpError(
924
- deps.errorPrefix,
925
- response.status,
926
- await response.text()
927
- );
928
- }
929
- return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
1645
+ async (signal) => {
1646
+ const init = {
1647
+ method,
1648
+ headers: buildRequestHeaders(deps.apiKey, deps.tracking),
1649
+ signal
1650
+ };
1651
+ if (body && methodSendsBody(method)) {
1652
+ init.body = JSON.stringify(body);
1653
+ }
1654
+ const response = await deps.fetch(url, init);
1655
+ if (!response.ok) {
1656
+ const hint = response.status === 429 ? rateLimitHint(
1657
+ response.headers.get("ratelimit"),
1658
+ response.headers.get("retry-after")
1659
+ ) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0;
1660
+ const text = await response.text().catch(
1661
+ () => "<body unavailable: the connection closed while reading it>"
1662
+ );
1663
+ throw new HttpError(deps.errorPrefix, response.status, text, hint);
1664
+ }
1665
+ return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
1666
+ }
1667
+ );
930
1668
  };
931
1669
  }
932
1670
 
@@ -982,7 +1720,15 @@ async function readSseSnapshot(fetchImpl, url, headers, opts) {
982
1720
  throw new HttpError(
983
1721
  "Runpod API Error",
984
1722
  res.status,
985
- await res.text().catch(() => "")
1723
+ await res.text().catch(() => ""),
1724
+ // This throw bypasses createHttpClient, so the status hints are built
1725
+ // here too or these two tools alone return a bare 429/401. The 401
1726
+ // hint applies: this request always carries Authorization (see the
1727
+ // headers bound in runtime.ts streamSse).
1728
+ res.status === 429 ? rateLimitHint(
1729
+ res.headers.get("ratelimit"),
1730
+ res.headers.get("retry-after")
1731
+ ) : res.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
986
1732
  );
987
1733
  }
988
1734
  for await (const chunk of res.body ?? []) {
@@ -1045,7 +1791,7 @@ async function streamLogsReply(rt, tool, params) {
1045
1791
  }
1046
1792
 
1047
1793
  // src/tools/runtime.ts
1048
- var MCP_SERVER_VERSION = true ? "3.1.0" : "dev";
1794
+ var MCP_SERVER_VERSION = true ? "3.3.0" : "dev";
1049
1795
  var SESSION_ID = (0, import_node_crypto.randomUUID)();
1050
1796
  function trackingHeaders(server, ctx) {
1051
1797
  const info = server.server.getClientVersion();
@@ -1060,6 +1806,11 @@ function trackingHeaders(server, ctx) {
1060
1806
  sessionId: SESSION_ID
1061
1807
  });
1062
1808
  }
1809
+ var HTTP_TRANSPORT_BUDGET_MS = 5e4;
1810
+ function invocationBudget(totalMs) {
1811
+ const startedAt = Date.now();
1812
+ return () => totalMs - (Date.now() - startedAt);
1813
+ }
1063
1814
  var defaultFetch = import_node_fetch.default;
1064
1815
  async function withApiErrorLog(label, call) {
1065
1816
  try {
@@ -1083,6 +1834,23 @@ var DESTRUCTIVE = {
1083
1834
  openWorldHint: true
1084
1835
  };
1085
1836
  async function graphqlRequest(query, tracking, fetchImpl, url, options) {
1837
+ return withRequestTimeout(
1838
+ "Runpod GraphQL Error",
1839
+ clampTimeout(options?.timeoutMs, options?.maxTimeoutMs),
1840
+ // GraphQL is POST on the wire whatever it carries, but the retry advice keys
1841
+ // off the method, and telling a caller its list-gpu-types "may have
1842
+ // SUCCEEDED upstream — check with the matching list-/get- tool first" points
1843
+ // it back at the tool that just failed. The operation keyword is what
1844
+ // actually says whether anything could have been written.
1845
+ isGraphqlMutation(query) ? "POST" : "GET",
1846
+ (signal) => runGraphql(query, tracking, fetchImpl, url, signal, options)
1847
+ );
1848
+ }
1849
+ function isGraphqlMutation(query) {
1850
+ const firstMeaningful = query.split("\n").map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#"));
1851
+ return /^mutation\b/.test(firstMeaningful ?? "");
1852
+ }
1853
+ async function runGraphql(query, tracking, fetchImpl, url, signal, options) {
1086
1854
  const response = await fetchImpl(url, {
1087
1855
  method: "POST",
1088
1856
  headers: {
@@ -1093,8 +1861,39 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
1093
1861
  body: JSON.stringify({
1094
1862
  query,
1095
1863
  ...options?.variables ? { variables: options.variables } : {}
1096
- })
1864
+ }),
1865
+ signal
1097
1866
  });
1867
+ if (!response.ok) {
1868
+ const bodyText = await response.text();
1869
+ let gqlErrors;
1870
+ try {
1871
+ gqlErrors = JSON.parse(bodyText).errors;
1872
+ } catch {
1873
+ }
1874
+ if (Array.isArray(gqlErrors) && gqlErrors.length > 0 && response.status !== 401 && response.status !== 429) {
1875
+ throw new HttpError(
1876
+ "Runpod GraphQL Error",
1877
+ response.status,
1878
+ gqlErrors.map((e) => String(e?.message ?? JSON.stringify(e))).join(", ")
1879
+ );
1880
+ }
1881
+ throw new HttpError(
1882
+ "Runpod GraphQL Error",
1883
+ response.status,
1884
+ bodyText,
1885
+ response.status === 429 ? rateLimitHint(
1886
+ response.headers.get("ratelimit"),
1887
+ response.headers.get("retry-after")
1888
+ ) : (
1889
+ // Only when this request actually carried the caller's API key: the
1890
+ // public path sends no Authorization header, so a 401 from that
1891
+ // host (WAF, misconfigured RUNPOD_PUBLIC_GRAPHQL_URL) says nothing
1892
+ // about the credential — same rationale as observeUnauthorized.
1893
+ response.status === 401 && options?.apiKey ? EXPIRED_CREDENTIAL_HINT : void 0
1894
+ )
1895
+ );
1896
+ }
1098
1897
  const result = await response.json();
1099
1898
  if (result.errors && result.errors.length > 0) {
1100
1899
  throw new Error(
@@ -1119,39 +1918,53 @@ function createToolRuntime(server, ctx, deps = {}) {
1119
1918
  }
1120
1919
  const rawFetch = deps.fetch ?? defaultFetch;
1121
1920
  const httpFetch = observeUnauthorized(rawFetch);
1921
+ const remainingHttpBudgetMs = invocationBudget(HTTP_TRANSPORT_BUDGET_MS);
1922
+ const timeouts = {
1923
+ defaultTimeoutMs: deps.defaultTimeoutMs,
1924
+ maxTimeoutMs: deps.maxTimeoutMs ?? (ctx.transport === "http" ? remainingHttpBudgetMs : void 0)
1925
+ };
1122
1926
  const v1Client = createHttpClient({
1123
1927
  apiKey: ctx.apiKey,
1124
1928
  fetch: httpFetch,
1125
1929
  tracking,
1126
- errorPrefix: "Runpod API Error"
1930
+ errorPrefix: "Runpod API Error",
1931
+ ...timeouts
1127
1932
  });
1128
- const runpodRequest = (endpoint, method = "GET", body) => withApiErrorLog(
1933
+ const runpodRequest = (endpoint, method = "GET", body, options) => withApiErrorLog(
1129
1934
  "Error calling Runpod API:",
1130
- () => v1Client(`${restV1Base(process.env)}${endpoint}`, method, body)
1935
+ () => v1Client(
1936
+ `${restV1Base(process.env)}${endpoint}`,
1937
+ method,
1938
+ body,
1939
+ options
1940
+ )
1131
1941
  );
1132
1942
  const serverlessClient = createHttpClient({
1133
1943
  apiKey: ctx.apiKey,
1134
1944
  fetch: httpFetch,
1135
1945
  tracking,
1136
- errorPrefix: "Runpod Serverless API Error"
1946
+ errorPrefix: "Runpod Serverless API Error",
1947
+ ...timeouts
1137
1948
  });
1138
- const serverlessRequest = (endpointId, path2, method = "GET", body) => withApiErrorLog(
1949
+ const serverlessRequest = (endpointId, path2, method = "GET", body, options) => withApiErrorLog(
1139
1950
  "Error calling Runpod Serverless API:",
1140
1951
  () => serverlessClient(
1141
1952
  `${serverlessBase(process.env)}/${endpointId}${path2}`,
1142
1953
  method,
1143
- body
1954
+ body,
1955
+ options
1144
1956
  )
1145
1957
  );
1146
1958
  const restClient = createHttpClient({
1147
1959
  apiKey: ctx.apiKey,
1148
1960
  fetch: httpFetch,
1149
1961
  tracking,
1150
- errorPrefix: "Runpod API Error"
1962
+ errorPrefix: "Runpod API Error",
1963
+ ...timeouts
1151
1964
  });
1152
- const callRestUrl = (url, method = "GET", body) => withApiErrorLog(
1965
+ const callRestUrl = (url, method = "GET", body, options) => withApiErrorLog(
1153
1966
  "Error calling Runpod API:",
1154
- () => restClient(url, method, body)
1967
+ () => restClient(url, method, body, options)
1155
1968
  );
1156
1969
  const sseFetch = observeUnauthorized(
1157
1970
  deps.sseFetch ?? import_node_fetch.default
@@ -1184,12 +1997,18 @@ function createToolRuntime(server, ctx, deps = {}) {
1184
1997
  };
1185
1998
  return {
1186
1999
  jsonReply,
2000
+ // No GraphQL caller overrides a deadline, but both are still capped by the
2001
+ // shared budget: deploy-hub-repo spends one of each back to back.
1187
2002
  graphql: (query) => graphqlRequest(
1188
2003
  query,
1189
2004
  tracking,
1190
2005
  // rawFetch, not httpFetch: this call carries no credential (see above).
1191
2006
  rawFetch,
1192
- publicGraphqlBase(process.env)
2007
+ publicGraphqlBase(process.env),
2008
+ {
2009
+ timeoutMs: deps.defaultTimeoutMs,
2010
+ maxTimeoutMs: timeouts.maxTimeoutMs
2011
+ }
1193
2012
  ),
1194
2013
  graphqlAuthed: (query, variables) => graphqlRequest(
1195
2014
  query,
@@ -1197,7 +2016,12 @@ function createToolRuntime(server, ctx, deps = {}) {
1197
2016
  httpFetch,
1198
2017
  // NOT publicGraphqlBase: this call carries the caller's API key.
1199
2018
  authedGraphqlBase(process.env),
1200
- { variables, apiKey: ctx.apiKey }
2019
+ {
2020
+ variables,
2021
+ apiKey: ctx.apiKey,
2022
+ timeoutMs: deps.defaultTimeoutMs,
2023
+ maxTimeoutMs: timeouts.maxTimeoutMs
2024
+ }
1201
2025
  ),
1202
2026
  runpodRequest,
1203
2027
  serverlessRequest,
@@ -1205,7 +2029,8 @@ function createToolRuntime(server, ctx, deps = {}) {
1205
2029
  backendFor,
1206
2030
  streamSse,
1207
2031
  podAction,
1208
- env: process.env
2032
+ env: process.env,
2033
+ transport: ctx.transport
1209
2034
  };
1210
2035
  }
1211
2036
 
@@ -1274,6 +2099,7 @@ function capListResult(result, options, extra) {
1274
2099
  }
1275
2100
 
1276
2101
  // src/tools/catalog.ts
2102
+ var GPU_PRODUCTS = /* @__PURE__ */ new Set(["POD", "CLUSTER", "SERVERLESS"]);
1277
2103
  function registerCatalogTools(server, rt) {
1278
2104
  const { jsonReply: jsonReply2, graphql, callRestUrl, backendFor } = rt;
1279
2105
  server.tool(
@@ -1292,6 +2118,9 @@ function registerCatalogTools(server, rt) {
1292
2118
  ),
1293
2119
  includeAvailability: import_zod3.z.boolean().optional().describe(
1294
2120
  "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."
2121
+ ),
2122
+ product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
2123
+ "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."
1295
2124
  )
1296
2125
  },
1297
2126
  { title: "List GPU types", ...READ_ONLY },
@@ -1299,8 +2128,9 @@ function registerCatalogTools(server, rt) {
1299
2128
  const backend = backendFor("gpus");
1300
2129
  if (backend.version === "v2") {
1301
2130
  const wantAvailability = params.includeAvailability !== false;
2131
+ const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
1302
2132
  const raw = await callRestUrl(
1303
- `${backend.base}${backend.list}${wantAvailability ? "?include=AVAILABILITY" : ""}`
2133
+ `${backend.base}${backend.list}${wantAvailability ? `?include=AVAILABILITY&product=${product}` : ""}`
1304
2134
  );
1305
2135
  let gpus = backend.unwrap(raw);
1306
2136
  gpus = gpus.filter((g) => g.id !== "unknown");
@@ -1473,6 +2303,9 @@ function registerCatalogTools(server, rt) {
1473
2303
  gpuTypeId: import_zod3.z.string().describe("ID of the GPU type to retrieve"),
1474
2304
  includeAvailability: import_zod3.z.boolean().optional().describe(
1475
2305
  "Include realtime per-datacenter availability (HIGH/MEDIUM/LOW/NONE). Default true."
2306
+ ),
2307
+ product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
2308
+ "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."
1476
2309
  )
1477
2310
  },
1478
2311
  { title: "Get GPU type", ...READ_ONLY },
@@ -1485,7 +2318,8 @@ function registerCatalogTools(server, rt) {
1485
2318
  });
1486
2319
  }
1487
2320
  const path2 = backend.get(encodeURIComponent(params.gpuTypeId));
1488
- const query = params.includeAvailability === false ? "" : "?include=AVAILABILITY";
2321
+ const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
2322
+ const query = params.includeAvailability === false ? "" : `?include=AVAILABILITY&product=${product}`;
1489
2323
  const result = await callRestUrl(`${backend.base}${path2}${query}`);
1490
2324
  return jsonReply2(result);
1491
2325
  }
@@ -1511,6 +2345,178 @@ function registerCatalogTools(server, rt) {
1511
2345
  return jsonReply2(result);
1512
2346
  }
1513
2347
  );
2348
+ server.tool(
2349
+ "get-capacity",
2350
+ "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.",
2351
+ {
2352
+ ...listPaginationParams,
2353
+ cudaVersions: import_zod3.z.array(import_zod3.z.string().regex(/^\d{1,2}\.\d{1,2}$/)).min(1).max(12).optional().describe(
2354
+ '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.'
2355
+ ),
2356
+ includeUnavailable: import_zod3.z.boolean().optional().describe(
2357
+ '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.'
2358
+ ),
2359
+ gpuTypeIds: import_zod3.z.array(import_zod3.z.string()).optional().describe(
2360
+ "Filter to GPU types matching any of these ids or display names (case-insensitive substring, e.g. ['NVIDIA GeForce RTX 4090'] or ['4090', 'H200'])."
2361
+ ),
2362
+ gpuCount: import_zod3.z.number().int().min(1).max(8).optional().describe("GPUs per worker/pod to check stock for (default 1)."),
2363
+ secureCloudOnly: import_zod3.z.boolean().optional().describe("Restrict the stock lookup to Secure Cloud hosts.")
2364
+ },
2365
+ { title: "Get GPU capacity by CUDA version", ...READ_ONLY },
2366
+ async (params) => {
2367
+ const gpuCount = Math.min(
2368
+ 8,
2369
+ Math.max(1, Math.floor(Number(params.gpuCount)) || 1)
2370
+ );
2371
+ const secureArg = params.secureCloudOnly ? ", secureCloud: true" : "";
2372
+ const filterTerms = (params.gpuTypeIds ?? []).filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.toLowerCase());
2373
+ const matchesFilter = (gpu) => {
2374
+ if (filterTerms.length === 0) return true;
2375
+ const id = gpu.id.toLowerCase();
2376
+ const name = gpu.displayName.toLowerCase();
2377
+ return filterTerms.some(
2378
+ (term) => id.includes(term) || name.includes(term)
2379
+ );
2380
+ };
2381
+ const stockPriority = {
2382
+ High: 3,
2383
+ Medium: 2,
2384
+ Low: 1
2385
+ };
2386
+ if (!params.cudaVersions?.length) {
2387
+ const data = await graphql(`
2388
+ query {
2389
+ gpuTypes {
2390
+ id
2391
+ displayName
2392
+ memoryInGb
2393
+ secureCloud
2394
+ communityCloud
2395
+ lowestPrice(input: { gpuCount: ${gpuCount}${secureArg} }) {
2396
+ stockStatus
2397
+ uninterruptablePrice
2398
+ gpuTypeCudaVersions {
2399
+ cudaVersion
2400
+ availability
2401
+ }
2402
+ }
2403
+ }
2404
+ }
2405
+ `);
2406
+ let rows2 = data.gpuTypes.filter((gpu) => gpu.id !== "unknown" && matchesFilter(gpu)).map((gpu) => {
2407
+ const cuda = {};
2408
+ for (const c of gpu.lowestPrice?.gpuTypeCudaVersions ?? []) {
2409
+ cuda[c.cudaVersion] = c.availability;
2410
+ }
2411
+ return {
2412
+ id: gpu.id,
2413
+ displayName: gpu.displayName,
2414
+ memoryGb: gpu.memoryInGb,
2415
+ secureCloud: gpu.secureCloud,
2416
+ communityCloud: gpu.communityCloud,
2417
+ stockStatus: gpu.lowestPrice?.stockStatus || "unavailable",
2418
+ pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null,
2419
+ cudaVersions: cuda
2420
+ };
2421
+ });
2422
+ const availableCount = (r) => Object.values(r.cudaVersions).filter((a) => a === "AVAILABLE").length;
2423
+ if (params.includeUnavailable === false)
2424
+ rows2 = rows2.filter((r) => availableCount(r) > 0);
2425
+ rows2.sort((a, b) => {
2426
+ const diff = availableCount(b) - availableCount(a);
2427
+ if (diff !== 0) return diff;
2428
+ const stockDiff = (stockPriority[b.stockStatus] || 0) - (stockPriority[a.stockStatus] || 0);
2429
+ if (stockDiff !== 0) return stockDiff;
2430
+ return b.memoryGb - a.memoryGb;
2431
+ });
2432
+ return capListResult(rows2, {
2433
+ limit: params.limit,
2434
+ cursor: params.cursor
2435
+ });
2436
+ }
2437
+ const versions = [...new Set(params.cudaVersions)].slice(0, 12);
2438
+ const invalid = versions.filter((v) => !/^\d{1,2}\.\d{1,2}$/.test(v));
2439
+ if (invalid.length > 0) {
2440
+ return jsonReply2({
2441
+ error: `Invalid CUDA version format: ${invalid.join(", ")}. Use "major.minor" strings like "12.8".`,
2442
+ status: 400
2443
+ });
2444
+ }
2445
+ const perVersion = await Promise.allSettled(
2446
+ versions.map(
2447
+ (v) => graphql(`
2448
+ query {
2449
+ gpuTypes {
2450
+ id
2451
+ displayName
2452
+ memoryInGb
2453
+ secureCloud
2454
+ communityCloud
2455
+ lowestPrice(input: { gpuCount: ${gpuCount}, allowedCudaVersions: ["${v}"]${secureArg} }) {
2456
+ stockStatus
2457
+ uninterruptablePrice
2458
+ }
2459
+ }
2460
+ }
2461
+ `)
2462
+ )
2463
+ );
2464
+ const probeErrors = {};
2465
+ const byId = /* @__PURE__ */ new Map();
2466
+ versions.forEach((v, i) => {
2467
+ const settled = perVersion[i];
2468
+ if (settled.status === "rejected") {
2469
+ probeErrors[v] = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
2470
+ return;
2471
+ }
2472
+ for (const gpu of settled.value.gpuTypes) {
2473
+ if (gpu.id === "unknown" || !matchesFilter(gpu)) continue;
2474
+ let row = byId.get(gpu.id);
2475
+ if (!row) {
2476
+ row = {
2477
+ id: gpu.id,
2478
+ displayName: gpu.displayName,
2479
+ memoryGb: gpu.memoryInGb,
2480
+ secureCloud: gpu.secureCloud,
2481
+ communityCloud: gpu.communityCloud,
2482
+ cudaVersions: {}
2483
+ };
2484
+ byId.set(gpu.id, row);
2485
+ }
2486
+ const stock = gpu.lowestPrice?.stockStatus;
2487
+ row.cudaVersions[v] = !stock || stock === "Out" ? { stock: "Out", pricePerHr: null } : {
2488
+ stock,
2489
+ pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null
2490
+ };
2491
+ }
2492
+ });
2493
+ let rows = [...byId.values()];
2494
+ const inStockCount = (r) => Object.values(r.cudaVersions).filter((c) => c.stock !== "Out").length;
2495
+ if (params.includeUnavailable === false)
2496
+ rows = rows.filter((r) => inStockCount(r) > 0);
2497
+ rows.sort((a, b) => {
2498
+ const diff = inStockCount(b) - inStockCount(a);
2499
+ if (diff !== 0) return diff;
2500
+ const best = (r) => Math.max(
2501
+ 0,
2502
+ ...Object.values(r.cudaVersions).map(
2503
+ (c) => stockPriority[c.stock] || 0
2504
+ )
2505
+ );
2506
+ const bestDiff = best(b) - best(a);
2507
+ if (bestDiff !== 0) return bestDiff;
2508
+ return b.memoryGb - a.memoryGb;
2509
+ });
2510
+ return capListResult(
2511
+ rows,
2512
+ { limit: params.limit, cursor: params.cursor },
2513
+ {
2514
+ probedCudaVersions: versions,
2515
+ ...Object.keys(probeErrors).length > 0 ? { probeErrors } : {}
2516
+ }
2517
+ );
2518
+ }
2519
+ );
1514
2520
  server.tool(
1515
2521
  "get-data-center",
1516
2522
  "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).",
@@ -2193,9 +3199,11 @@ function registerPodTools(server, rt) {
2193
3199
  );
2194
3200
  server.tool(
2195
3201
  "create-pod",
2196
- '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.',
3202
+ '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.',
2197
3203
  {
2198
- name: import_zod7.z.string().optional().describe("Name for the pod"),
3204
+ name: import_zod7.z.string().optional().describe(
3205
+ "Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
3206
+ ),
2199
3207
  imageName: import_zod7.z.string().optional().describe("Docker image to use. Optional when templateId is provided."),
2200
3208
  templateId: import_zod7.z.string().optional().describe(
2201
3209
  "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."
@@ -2205,12 +3213,18 @@ function registerPodTools(server, rt) {
2205
3213
  ),
2206
3214
  cloudType: import_zod7.z.enum(["SECURE", "COMMUNITY"]).optional().describe("SECURE or COMMUNITY cloud"),
2207
3215
  gpuTypeIds: import_zod7.z.array(import_zod7.z.string()).optional().describe("List of acceptable GPU types"),
2208
- gpuCount: import_zod7.z.number().optional().describe("Number of GPUs"),
3216
+ // v2 GpuConfig.count is `integer, minimum: 1`. Without these bounds a 0,
3217
+ // a negative, or a fractional count reached the wire as-is and came back
3218
+ // as a raw 422; reject it here with a message that names the field.
3219
+ gpuCount: import_zod7.z.number().int().min(1).optional().describe("Number of GPUs (minimum 1)"),
2209
3220
  containerDiskInGb: import_zod7.z.number().optional().describe("Container disk size in GB"),
2210
3221
  volumeInGb: import_zod7.z.number().optional().describe("Volume size in GB"),
2211
3222
  volumeMountPath: import_zod7.z.string().optional().describe("Path to mount the volume"),
2212
3223
  ports: import_zod7.z.array(import_zod7.z.string()).optional().describe("Ports to expose (e.g., '8888/http', '22/tcp')"),
2213
3224
  env: import_zod7.z.record(import_zod7.z.string()).optional().describe("Environment variables"),
3225
+ sshPublicKey: import_zod7.z.string().optional().describe(
3226
+ '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.'
3227
+ ),
2214
3228
  containerRegistryAuthId: import_zod7.z.string().optional().describe(
2215
3229
  "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)."
2216
3230
  ),
@@ -2219,6 +3233,15 @@ function registerPodTools(server, rt) {
2219
3233
  { title: "Create pod", ...WRITE },
2220
3234
  async (params) => {
2221
3235
  const backend = backendFor("pods");
3236
+ const { sshPublicKey, ...podParams } = params;
3237
+ let sshKey;
3238
+ if (sshPublicKey !== void 0) {
3239
+ const validated = normalizeSshPublicKey(sshPublicKey);
3240
+ if (!validated.ok)
3241
+ return jsonReply2({ error: validated.error, status: 400 });
3242
+ sshKey = validated.key;
3243
+ }
3244
+ 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.";
2222
3245
  if (params.templateId && backend.version !== "v2") {
2223
3246
  return jsonReply2({
2224
3247
  error: "Deploying a pod from a templateId is only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2, or pass imageName instead.",
@@ -2254,6 +3277,12 @@ function registerPodTools(server, rt) {
2254
3277
  status: 400
2255
3278
  });
2256
3279
  }
3280
+ if (!wantsCpu && !params.name && !params.templateId) {
3281
+ return jsonReply2({
3282
+ error: "Provide a name for the pod (deploying from a templateId inherits the template name).",
3283
+ status: 400
3284
+ });
3285
+ }
2257
3286
  if (wantsCpu) {
2258
3287
  if (params.templateId) {
2259
3288
  return jsonReply2({
@@ -2262,13 +3291,15 @@ function registerPodTools(server, rt) {
2262
3291
  });
2263
3292
  }
2264
3293
  try {
3294
+ const cpuBody = sshKey ? applySshPublicKey(podParams, sshKey) : podParams;
2265
3295
  const result = await callRestUrl(
2266
3296
  `${restV1Base(env)}/pods`,
2267
3297
  "POST",
2268
- params
3298
+ cpuBody
2269
3299
  );
2270
3300
  return jsonReply2({
2271
3301
  ...result,
3302
+ ...sshKey ? { _ssh: sshNote } : {},
2272
3303
  _servedBy: "v1",
2273
3304
  _note: "CPU pod: created on the v1 API, because the v2 REST API does not support CPU pods yet."
2274
3305
  });
@@ -2285,7 +3316,7 @@ function registerPodTools(server, rt) {
2285
3316
  }
2286
3317
  }
2287
3318
  }
2288
- let body = backend.mapCreate(params);
3319
+ let body = backend.mapCreate(podParams);
2289
3320
  if (params.templateId) {
2290
3321
  let template;
2291
3322
  try {
@@ -2303,19 +3334,21 @@ function registerPodTools(server, rt) {
2303
3334
  }
2304
3335
  body = { ...podBodyFromTemplate(template), ...body };
2305
3336
  }
3337
+ if (sshKey) body = applySshPublicKey(body, sshKey);
2306
3338
  try {
2307
3339
  const result = await callRestUrl(
2308
3340
  `${backend.base}${backend.list}`,
2309
3341
  "POST",
2310
3342
  body
2311
3343
  );
3344
+ const extras = {};
3345
+ if (sshKey) extras._ssh = sshNote;
2312
3346
  if (backend.version === "v2" && (params.gpuTypeIds?.length ?? 0) > 1) {
2313
- return jsonReply2({
2314
- ...result,
2315
- _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(", ")}.`
2316
- });
3347
+ 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(", ")}.`;
2317
3348
  }
2318
- return jsonReply2(result);
3349
+ return jsonReply2(
3350
+ Object.keys(extras).length ? { ...result, ...extras } : result
3351
+ );
2319
3352
  } catch (error) {
2320
3353
  if (error instanceof HttpError) {
2321
3354
  return jsonReply2({ error: error.message, status: error.status });
@@ -2776,8 +3809,108 @@ var import_zod9 = require("zod");
2776
3809
  var DIAGNOSIS_TTL_MS = 15e3;
2777
3810
  var MAX_DIAGNOSIS_ENTRIES = 500;
2778
3811
  var diagnosisCache = /* @__PURE__ */ new Map();
3812
+ var HTTP_LONG_POLL_BUDGET_MS = 45e3;
3813
+ var STDIO_STREAM_BUDGET_MS = 5 * 60 * 1e3;
3814
+ var RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS = 9e4;
3815
+ var STREAM_UPSTREAM_DEFAULT_WAIT_MS = 1e4;
3816
+ var HTTP_STREAM_POLL_WAIT_MS = 1e3;
3817
+ var STREAM_JOB_POLL_INTERVAL_MS = 1e3;
3818
+ var MAX_CONSECUTIVE_STREAM_ERRORS = 5;
3819
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
3820
+ "COMPLETED",
3821
+ "FAILED",
3822
+ "CANCELLED",
3823
+ "TIMED_OUT"
3824
+ ]);
3825
+ function isUnfinishedJobReply(result) {
3826
+ return result !== null && typeof result === "object" && !Array.isArray(result) && !("waitClamped" in result) && !TERMINAL_STATUSES.has(result.status ?? "");
3827
+ }
3828
+ function formatBudget(ms) {
3829
+ return ms >= 12e4 ? `${Math.round(ms / 6e4)} minutes` : `${Math.round(ms / 1e3)} seconds`;
3830
+ }
3831
+ var UPSTREAM_HOLD_SLACK_MS = 5e3;
3832
+ var MIN_STREAM_POLL_TIMEOUT_MS = 2e3;
3833
+ var QUEUED_DIAGNOSIS_TIMEOUT_MS = 5e3;
3834
+ function streamPollTimeoutMs(remainingMs, holdMs) {
3835
+ return Math.max(
3836
+ Math.min(remainingMs, holdMs + UPSTREAM_HOLD_SLACK_MS),
3837
+ MIN_STREAM_POLL_TIMEOUT_MS
3838
+ );
3839
+ }
3840
+ function sleep(ms) {
3841
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
3842
+ }
3843
+ function streamPollPlan(hosted) {
3844
+ return hosted ? {
3845
+ budgetMs: HTTP_LONG_POLL_BUDGET_MS,
3846
+ holdMs: HTTP_STREAM_POLL_WAIT_MS,
3847
+ query: `?wait=${HTTP_STREAM_POLL_WAIT_MS}`
3848
+ } : {
3849
+ budgetMs: STDIO_STREAM_BUDGET_MS,
3850
+ holdMs: STREAM_UPSTREAM_DEFAULT_WAIT_MS,
3851
+ query: ""
3852
+ };
3853
+ }
3854
+ 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).";
3855
+ function budgetExhaustedNote(budgetMs, lastError) {
3856
+ return {
3857
+ pollingTimedOut: true,
3858
+ note: `Polling stopped after ${formatBudget(
3859
+ budgetMs
3860
+ )} with the job possibly still running. ${RESUME_ADVICE}`,
3861
+ // Surface the trailing error (if any) instead of discarding it — the last
3862
+ // polls may have been failing (e.g. job expired) even though earlier ones
3863
+ // succeeded. Cleared on success, so this is never a stale error from
3864
+ // minutes of healthy streaming ago.
3865
+ ...lastError ? { lastError } : {}
3866
+ };
3867
+ }
3868
+ function errorCapNote(lastError) {
3869
+ return {
3870
+ error: `Polling aborted after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors: ${lastError}`,
3871
+ note: `Polling stopped after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors with the job possibly still running. ${RESUME_ADVICE}`
3872
+ };
3873
+ }
3874
+ async function collectJobStream(deps) {
3875
+ const { poll, budgetMs, holdMs } = deps;
3876
+ const startedAt = Date.now();
3877
+ const elapsed = () => Date.now() - startedAt;
3878
+ const chunks = [];
3879
+ let result = {};
3880
+ let consecutiveErrors = 0;
3881
+ let lastError;
3882
+ while (true) {
3883
+ try {
3884
+ const reply = await poll(
3885
+ streamPollTimeoutMs(budgetMs - elapsed(), holdMs)
3886
+ );
3887
+ consecutiveErrors = 0;
3888
+ lastError = void 0;
3889
+ if (Array.isArray(reply.stream)) chunks.push(...reply.stream);
3890
+ result = reply;
3891
+ if (TERMINAL_STATUSES.has(reply.status)) break;
3892
+ } catch (error) {
3893
+ consecutiveErrors++;
3894
+ lastError = error instanceof Error ? error.message : String(error);
3895
+ if (consecutiveErrors >= MAX_CONSECUTIVE_STREAM_ERRORS) {
3896
+ result = { ...result, ...errorCapNote(lastError) };
3897
+ break;
3898
+ }
3899
+ }
3900
+ if (elapsed() > budgetMs) {
3901
+ result = { ...result, ...budgetExhaustedNote(budgetMs, lastError) };
3902
+ break;
3903
+ }
3904
+ await sleep(STREAM_JOB_POLL_INTERVAL_MS);
3905
+ }
3906
+ return { result, chunks };
3907
+ }
2779
3908
  function registerJobTools(server, rt) {
2780
3909
  const { jsonReply: jsonReply2, serverlessRequest, backendFor, callRestUrl } = rt;
3910
+ const hosted = rt.transport === "http";
3911
+ const streamBudget = formatBudget(
3912
+ hosted ? HTTP_LONG_POLL_BUDGET_MS : STDIO_STREAM_BUDGET_MS
3913
+ );
2781
3914
  async function diagnoseQueuedJob(endpointId) {
2782
3915
  const cached = diagnosisCache.get(endpointId);
2783
3916
  if (cached && cached.expiresAt > Date.now()) {
@@ -2788,7 +3921,10 @@ function registerJobTools(server, rt) {
2788
3921
  const backend = backendFor("workers");
2789
3922
  if (backend.version !== "v2") return null;
2790
3923
  const raw = await callRestUrl(
2791
- `${backend.base}/serverless/${endpointId}/workers`
3924
+ `${backend.base}/serverless/${endpointId}/workers`,
3925
+ "GET",
3926
+ void 0,
3927
+ { timeoutMs: QUEUED_DIAGNOSIS_TIMEOUT_MS }
2792
3928
  );
2793
3929
  const summary = raw?.summary;
2794
3930
  const workers = raw?.workers ?? [];
@@ -2879,14 +4015,14 @@ function registerJobTools(server, rt) {
2879
4015
  );
2880
4016
  server.tool(
2881
4017
  "runsync-endpoint",
2882
- "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).",
4018
+ `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)."}`,
2883
4019
  {
2884
4020
  endpointId: endpointIdSchema.describe(
2885
4021
  "ID of the Serverless endpoint to run synchronously"
2886
4022
  ),
2887
4023
  input: inputSchema,
2888
4024
  wait: import_zod9.z.number().min(1e3).max(3e5).optional().describe(
2889
- "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)."
4025
+ 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)."
2890
4026
  ),
2891
4027
  webhook: webhookSchema,
2892
4028
  policy: policySchema,
@@ -2895,13 +4031,36 @@ function registerJobTools(server, rt) {
2895
4031
  { title: "Run endpoint (sync)", ...WRITE },
2896
4032
  async (params) => {
2897
4033
  const { endpointId, wait, ...body } = params;
2898
- const path2 = wait ? `/runsync?wait=${wait}` : "/runsync";
4034
+ const effectiveWait = hosted ? Math.min(
4035
+ wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS,
4036
+ HTTP_LONG_POLL_BUDGET_MS
4037
+ ) : wait;
4038
+ const waitQuery = effectiveWait === void 0 ? "" : `?wait=${effectiveWait}`;
4039
+ const path2 = `/runsync${waitQuery}`;
2899
4040
  const result = await serverlessRequest(
2900
4041
  endpointId,
2901
4042
  path2,
2902
4043
  "POST",
2903
- body
4044
+ body,
4045
+ {
4046
+ // From the wait actually sent — on http the clamped 45s, not the
4047
+ // 300s the caller may have asked for.
4048
+ timeoutMs: (effectiveWait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS) + UPSTREAM_HOLD_SLACK_MS
4049
+ }
2904
4050
  );
4051
+ const requestedWait = wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS;
4052
+ if (hosted && requestedWait > HTTP_LONG_POLL_BUDGET_MS && isUnfinishedJobReply(result)) {
4053
+ return jsonReply2({
4054
+ ...result,
4055
+ waitClamped: {
4056
+ requestedMs: requestedWait,
4057
+ effectiveMs: effectiveWait,
4058
+ reason: `This server runs behind a 60-second gateway deadline, so a non-terminal status here means the job outlived ${formatBudget(
4059
+ HTTP_LONG_POLL_BUDGET_MS
4060
+ )}, not the requested wait \u2014 poll get-job-status rather than treating it as stuck.`
4061
+ }
4062
+ });
4063
+ }
2905
4064
  return jsonReply2(result);
2906
4065
  }
2907
4066
  );
@@ -2935,7 +4094,7 @@ function registerJobTools(server, rt) {
2935
4094
  );
2936
4095
  server.tool(
2937
4096
  "stream-job",
2938
- "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.",
4097
+ `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." : ""}`,
2939
4098
  {
2940
4099
  endpointId: endpointIdSchema.describe(
2941
4100
  "ID of the Serverless endpoint the job belongs to"
@@ -2944,51 +4103,16 @@ function registerJobTools(server, rt) {
2944
4103
  },
2945
4104
  { title: "Stream job", ...READ_ONLY },
2946
4105
  async (params) => {
2947
- const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
2948
- "COMPLETED",
2949
- "FAILED",
2950
- "CANCELLED",
2951
- "TIMED_OUT"
2952
- ]);
2953
- const MAX_POLL_TIME_MS = 5 * 60 * 1e3;
2954
- const POLL_INTERVAL_MS = 1e3;
2955
- const MAX_CONSECUTIVE_ERRORS = 5;
2956
- const allChunks = [];
2957
- let finalResult = {};
2958
- let consecutiveErrors = 0;
2959
- let lastError;
2960
- const startTime = Date.now();
2961
- while (true) {
2962
- try {
2963
- const result = await serverlessRequest(
2964
- params.endpointId,
2965
- `/stream/${params.jobId}`
2966
- );
2967
- consecutiveErrors = 0;
2968
- if (Array.isArray(result.stream)) {
2969
- allChunks.push(...result.stream);
2970
- }
2971
- finalResult = result;
2972
- if (TERMINAL_STATUSES.has(result.status)) {
2973
- break;
2974
- }
2975
- } catch (error) {
2976
- consecutiveErrors++;
2977
- lastError = error instanceof Error ? error.message : String(error);
2978
- if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
2979
- finalResult.error = `Polling aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive errors: ${lastError}`;
2980
- break;
2981
- }
2982
- }
2983
- if (Date.now() - startTime > MAX_POLL_TIME_MS) {
2984
- finalResult.pollingTimedOut = true;
2985
- finalResult.note = "Polling timed out after 5 minutes. Use get-job-status to check the job later.";
2986
- if (lastError) finalResult.lastError = lastError;
2987
- break;
2988
- }
2989
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
2990
- }
2991
- return jsonReply2({ ...finalResult, stream: allChunks });
4106
+ const plan = streamPollPlan(hosted);
4107
+ const streamPath = `/stream/${params.jobId}${plan.query}`;
4108
+ const { result, chunks } = await collectJobStream({
4109
+ budgetMs: plan.budgetMs,
4110
+ holdMs: plan.holdMs,
4111
+ poll: (timeoutMs) => serverlessRequest(params.endpointId, streamPath, "GET", void 0, {
4112
+ timeoutMs
4113
+ })
4114
+ });
4115
+ return jsonReply2({ ...result, stream: chunks });
2992
4116
  }
2993
4117
  );
2994
4118
  server.tool(