@runpod/mcp-server 3.2.0 → 3.4.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;
425
+ }
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
+ };
134
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
+ );
135
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
  });
@@ -473,10 +979,15 @@ function containerConfigToV2(p2) {
473
979
  mounts: persistentMount(p2.volumeInGb, p2.volumeMountPath)
474
980
  });
475
981
  }
476
- function gpuConfigToV2(gpuTypeIds, gpuCount) {
982
+ function gpuConfigToV2(gpuTypeIds, gpuCount, allowedCudaVersions, minCudaVersion2) {
477
983
  const id = gpuTypeIds?.[0];
478
984
  if (id === void 0) return void 0;
479
- return compact({ id, count: gpuCount });
985
+ return compact({
986
+ id,
987
+ count: gpuCount ?? 1,
988
+ allowedCudaVersions,
989
+ minCudaVersion: minCudaVersion2
990
+ });
480
991
  }
481
992
  function mapPodCreateToV2(params) {
482
993
  return compact({
@@ -497,7 +1008,12 @@ function mapPodCreateToV2(params) {
497
1008
  // singular on v2 (`gpu.id`): gpuConfigToV2 takes the first id; the
498
1009
  // create-pod handler warns when more than one gpuTypeId was supplied.
499
1010
  dataCenterIds: params.dataCenterIds?.length ? params.dataCenterIds : void 0,
500
- gpu: gpuConfigToV2(params.gpuTypeIds, params.gpuCount)
1011
+ gpu: gpuConfigToV2(
1012
+ params.gpuTypeIds,
1013
+ params.gpuCount,
1014
+ params.allowedCudaVersions,
1015
+ params.minCudaVersion
1016
+ )
501
1017
  });
502
1018
  }
503
1019
  function mapPodUpdateToV2(params) {
@@ -514,9 +1030,15 @@ function defaultScalerType(endpointType) {
514
1030
  function endpointScaling(scalerType, value) {
515
1031
  return scalerType === "REQUEST_COUNT" ? { type: "REQUEST_COUNT", requestCount: value } : { type: "QUEUE_DELAY", queueDelay: value };
516
1032
  }
517
- function endpointGpuConfig(pools, count) {
518
- if (!pools?.length) return void 0;
519
- return compact({ pools, count });
1033
+ function endpointGpuConfig(params, mode) {
1034
+ const gpu = compact({
1035
+ pools: params.gpuPoolIds?.length ? params.gpuPoolIds : void 0,
1036
+ count: params.gpuCount,
1037
+ allowedCudaVersions: params.allowedCudaVersions,
1038
+ minCudaVersion: params.minCudaVersion
1039
+ });
1040
+ if (mode === "create" && !("pools" in gpu)) return void 0;
1041
+ return Object.keys(gpu).length ? gpu : void 0;
520
1042
  }
521
1043
  function endpointWorkers(params) {
522
1044
  const workers = compact({
@@ -526,7 +1048,7 @@ function endpointWorkers(params) {
526
1048
  });
527
1049
  return Object.keys(workers).length ? workers : void 0;
528
1050
  }
529
- function endpointCommonToV2(params) {
1051
+ function endpointCommonToV2(params, mode) {
530
1052
  return compact({
531
1053
  name: params.name,
532
1054
  image: params.imageName,
@@ -535,7 +1057,7 @@ function endpointCommonToV2(params) {
535
1057
  ports: params.ports,
536
1058
  env: params.env,
537
1059
  registry: params.containerRegistryAuthId,
538
- gpu: endpointGpuConfig(params.gpuPoolIds, params.gpuCount),
1060
+ gpu: endpointGpuConfig(params, mode),
539
1061
  workers: endpointWorkers(params),
540
1062
  dataCenterIds: params.dataCenterIds?.length ? params.dataCenterIds : void 0,
541
1063
  networkVolumes: params.networkVolumeIds?.length ? params.networkVolumeIds : void 0,
@@ -546,7 +1068,7 @@ function endpointCommonToV2(params) {
546
1068
  function mapEndpointCreateToV2(params) {
547
1069
  const endpointType = params.endpointType ?? DEFAULT_ENDPOINT_TYPE;
548
1070
  return compact({
549
- ...endpointCommonToV2(params),
1071
+ ...endpointCommonToV2(params, "create"),
550
1072
  type: endpointType,
551
1073
  scaling: endpointScaling(
552
1074
  params.scalerType ?? defaultScalerType(endpointType),
@@ -556,13 +1078,28 @@ function mapEndpointCreateToV2(params) {
556
1078
  }
557
1079
  function mapEndpointUpdateToV2(params) {
558
1080
  return compact({
559
- ...endpointCommonToV2(params),
1081
+ ...endpointCommonToV2(params, "update"),
560
1082
  scaling: params.scalerType ? endpointScaling(
561
1083
  params.scalerType,
562
1084
  params.scalerValue ?? DEFAULT_SCALER_VALUE
563
1085
  ) : void 0
564
1086
  });
565
1087
  }
1088
+ var CUDA_MAJOR_MINOR = /^\d+\.\d+$/;
1089
+ function cudaConstraintError(params, opts = {}) {
1090
+ const { allowedCudaVersions, minCudaVersion: minCudaVersion2 } = params;
1091
+ if (allowedCudaVersions?.length && minCudaVersion2) {
1092
+ return "allowedCudaVersions and minCudaVersion are mutually exclusive \u2014 pass either an exact set or an open-ended floor, not both.";
1093
+ }
1094
+ if (minCudaVersion2 !== void 0 && !CUDA_MAJOR_MINOR.test(minCudaVersion2) && !(opts.allowClear && minCudaVersion2 === "")) {
1095
+ return `minCudaVersion must be major.minor (e.g. "12.0", not "12")${opts.allowClear ? "; an empty string clears the floor" : ""}.`;
1096
+ }
1097
+ const bad = allowedCudaVersions?.find((v) => !CUDA_MAJOR_MINOR.test(v));
1098
+ if (bad !== void 0) {
1099
+ return `allowedCudaVersions entries must be major.minor (e.g. "12.8"); got "${bad}". Discover valid values via get-capacity or list-gpu-types.`;
1100
+ }
1101
+ return void 0;
1102
+ }
566
1103
  function mapNetworkVolumeCreateToV2(params) {
567
1104
  return compact({
568
1105
  name: params.name,
@@ -603,6 +1140,42 @@ function podBodyFromTemplate(template) {
603
1140
  }
604
1141
  return out;
605
1142
  }
1143
+ var SSH_PUBLIC_KEY_LINE = /^(ssh-[a-z0-9-]+|ecdsa-[a-z0-9-]+|sk-[a-z0-9@.-]+)\s+\S+/i;
1144
+ var PRIVATE_KEY_MARKER = /private[-\s]?key|PuTTY-User-Key-File/i;
1145
+ function normalizeSshPublicKey(input) {
1146
+ if (PRIVATE_KEY_MARKER.test(input))
1147
+ return {
1148
+ ok: false,
1149
+ 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").'
1150
+ };
1151
+ const lines = input.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
1152
+ if (lines.length === 0)
1153
+ return {
1154
+ ok: false,
1155
+ error: "sshPublicKey is empty. Pass the contents of your ~/.ssh/<key>.pub file, or omit the parameter entirely to create the pod without SSH."
1156
+ };
1157
+ if (lines.some((line) => !SSH_PUBLIC_KEY_LINE.test(line)))
1158
+ return {
1159
+ ok: false,
1160
+ 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.'
1161
+ };
1162
+ return { ok: true, key: lines.join("\n") };
1163
+ }
1164
+ function applySshPublicKey(body, sshPublicKey) {
1165
+ const ports = Array.isArray(body.ports) ? body.ports : [];
1166
+ const hasSshPort = ports.some((p2) => /^\s*22\/tcp\s*$/i.test(String(p2)));
1167
+ const env = { ...body.env ?? {} };
1168
+ const key = sshPublicKey.trim();
1169
+ if (!env.PUBLIC_KEY) env.PUBLIC_KEY = key;
1170
+ else if (!env.PUBLIC_KEY.includes(key))
1171
+ env.PUBLIC_KEY = `${env.PUBLIC_KEY}
1172
+ ${key}`;
1173
+ return {
1174
+ ...body,
1175
+ ports: hasSshPort ? ports : [...ports, "22/tcp"],
1176
+ env
1177
+ };
1178
+ }
606
1179
  function mapTemplateUpdateToV2(params) {
607
1180
  return {
608
1181
  ...containerConfigToV2(params),
@@ -868,7 +1441,7 @@ function resolveBackend(opts) {
868
1441
 
869
1442
  // src/server.ts
870
1443
  var SERVER_NAME = "Runpod API Server";
871
- var SERVER_VERSION = true ? "3.2.0" : "dev";
1444
+ var SERVER_VERSION = true ? "3.4.0" : "dev";
872
1445
  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.
873
1446
 
874
1447
  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.`;
@@ -1043,6 +1616,28 @@ var HttpError = class extends Error {
1043
1616
  this.body = body;
1044
1617
  }
1045
1618
  };
1619
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
1620
+ var MIN_REQUEST_TIMEOUT_MS = 1e3;
1621
+ var RequestTimeoutError = class extends Error {
1622
+ constructor(prefix, timeoutMs, method) {
1623
+ const write = method !== "GET" && method !== "HEAD";
1624
+ super(
1625
+ `${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."}`
1626
+ );
1627
+ this.name = "RequestTimeoutError";
1628
+ this.timeoutMs = timeoutMs;
1629
+ this.method = method;
1630
+ }
1631
+ };
1632
+ async function withRequestTimeout(errorPrefix, timeoutMs, method, run) {
1633
+ const signal = AbortSignal.timeout(timeoutMs);
1634
+ try {
1635
+ return await run(signal);
1636
+ } catch (error) {
1637
+ if (error instanceof HttpError || !signal.aborted) throw error;
1638
+ throw new RequestTimeoutError(errorPrefix, timeoutMs, method);
1639
+ }
1640
+ }
1046
1641
  function isJsonContentType(contentType) {
1047
1642
  if (!contentType) return false;
1048
1643
  const ct = contentType.toLowerCase();
@@ -1058,28 +1653,49 @@ function buildRequestHeaders(apiKey, tracking) {
1058
1653
  ...tracking()
1059
1654
  };
1060
1655
  }
1656
+ function resolveCeiling(ceiling) {
1657
+ if (ceiling === void 0) return Infinity;
1658
+ if (typeof ceiling !== "function") return ceiling;
1659
+ return Math.max(ceiling(), MIN_REQUEST_TIMEOUT_MS);
1660
+ }
1661
+ function clampTimeout(requestedMs, ceiling, defaultMs = DEFAULT_REQUEST_TIMEOUT_MS) {
1662
+ return Math.min(requestedMs ?? defaultMs, resolveCeiling(ceiling));
1663
+ }
1061
1664
  function createHttpClient(deps) {
1062
- return async function request(url, method = "GET", body) {
1063
- const init = {
1665
+ const defaultTimeoutMs = deps.defaultTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1666
+ return async function request(url, method = "GET", body, options) {
1667
+ const timeoutMs = clampTimeout(
1668
+ options?.timeoutMs,
1669
+ deps.maxTimeoutMs,
1670
+ defaultTimeoutMs
1671
+ );
1672
+ return withRequestTimeout(
1673
+ deps.errorPrefix,
1674
+ timeoutMs,
1064
1675
  method,
1065
- headers: buildRequestHeaders(deps.apiKey, deps.tracking)
1066
- };
1067
- if (body && methodSendsBody(method)) {
1068
- init.body = JSON.stringify(body);
1069
- }
1070
- const response = await deps.fetch(url, init);
1071
- if (!response.ok) {
1072
- throw new HttpError(
1073
- deps.errorPrefix,
1074
- response.status,
1075
- await response.text(),
1076
- response.status === 429 ? rateLimitHint(
1077
- response.headers.get("ratelimit"),
1078
- response.headers.get("retry-after")
1079
- ) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
1080
- );
1081
- }
1082
- return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
1676
+ async (signal) => {
1677
+ const init = {
1678
+ method,
1679
+ headers: buildRequestHeaders(deps.apiKey, deps.tracking),
1680
+ signal
1681
+ };
1682
+ if (body && methodSendsBody(method)) {
1683
+ init.body = JSON.stringify(body);
1684
+ }
1685
+ const response = await deps.fetch(url, init);
1686
+ if (!response.ok) {
1687
+ const hint = response.status === 429 ? rateLimitHint(
1688
+ response.headers.get("ratelimit"),
1689
+ response.headers.get("retry-after")
1690
+ ) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0;
1691
+ const text = await response.text().catch(
1692
+ () => "<body unavailable: the connection closed while reading it>"
1693
+ );
1694
+ throw new HttpError(deps.errorPrefix, response.status, text, hint);
1695
+ }
1696
+ return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
1697
+ }
1698
+ );
1083
1699
  };
1084
1700
  }
1085
1701
 
@@ -1206,7 +1822,7 @@ async function streamLogsReply(rt, tool, params) {
1206
1822
  }
1207
1823
 
1208
1824
  // src/tools/runtime.ts
1209
- var MCP_SERVER_VERSION = true ? "3.2.0" : "dev";
1825
+ var MCP_SERVER_VERSION = true ? "3.4.0" : "dev";
1210
1826
  var SESSION_ID = (0, import_node_crypto.randomUUID)();
1211
1827
  function trackingHeaders(server, ctx) {
1212
1828
  const info = server.server.getClientVersion();
@@ -1221,6 +1837,11 @@ function trackingHeaders(server, ctx) {
1221
1837
  sessionId: SESSION_ID
1222
1838
  });
1223
1839
  }
1840
+ var HTTP_TRANSPORT_BUDGET_MS = 5e4;
1841
+ function invocationBudget(totalMs) {
1842
+ const startedAt = Date.now();
1843
+ return () => totalMs - (Date.now() - startedAt);
1844
+ }
1224
1845
  var defaultFetch = import_node_fetch.default;
1225
1846
  async function withApiErrorLog(label, call) {
1226
1847
  try {
@@ -1244,6 +1865,23 @@ var DESTRUCTIVE = {
1244
1865
  openWorldHint: true
1245
1866
  };
1246
1867
  async function graphqlRequest(query, tracking, fetchImpl, url, options) {
1868
+ return withRequestTimeout(
1869
+ "Runpod GraphQL Error",
1870
+ clampTimeout(options?.timeoutMs, options?.maxTimeoutMs),
1871
+ // GraphQL is POST on the wire whatever it carries, but the retry advice keys
1872
+ // off the method, and telling a caller its list-gpu-types "may have
1873
+ // SUCCEEDED upstream — check with the matching list-/get- tool first" points
1874
+ // it back at the tool that just failed. The operation keyword is what
1875
+ // actually says whether anything could have been written.
1876
+ isGraphqlMutation(query) ? "POST" : "GET",
1877
+ (signal) => runGraphql(query, tracking, fetchImpl, url, signal, options)
1878
+ );
1879
+ }
1880
+ function isGraphqlMutation(query) {
1881
+ const firstMeaningful = query.split("\n").map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#"));
1882
+ return /^mutation\b/.test(firstMeaningful ?? "");
1883
+ }
1884
+ async function runGraphql(query, tracking, fetchImpl, url, signal, options) {
1247
1885
  const response = await fetchImpl(url, {
1248
1886
  method: "POST",
1249
1887
  headers: {
@@ -1254,7 +1892,8 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
1254
1892
  body: JSON.stringify({
1255
1893
  query,
1256
1894
  ...options?.variables ? { variables: options.variables } : {}
1257
- })
1895
+ }),
1896
+ signal
1258
1897
  });
1259
1898
  if (!response.ok) {
1260
1899
  const bodyText = await response.text();
@@ -1310,39 +1949,53 @@ function createToolRuntime(server, ctx, deps = {}) {
1310
1949
  }
1311
1950
  const rawFetch = deps.fetch ?? defaultFetch;
1312
1951
  const httpFetch = observeUnauthorized(rawFetch);
1952
+ const remainingHttpBudgetMs = invocationBudget(HTTP_TRANSPORT_BUDGET_MS);
1953
+ const timeouts = {
1954
+ defaultTimeoutMs: deps.defaultTimeoutMs,
1955
+ maxTimeoutMs: deps.maxTimeoutMs ?? (ctx.transport === "http" ? remainingHttpBudgetMs : void 0)
1956
+ };
1313
1957
  const v1Client = createHttpClient({
1314
1958
  apiKey: ctx.apiKey,
1315
1959
  fetch: httpFetch,
1316
1960
  tracking,
1317
- errorPrefix: "Runpod API Error"
1961
+ errorPrefix: "Runpod API Error",
1962
+ ...timeouts
1318
1963
  });
1319
- const runpodRequest = (endpoint, method = "GET", body) => withApiErrorLog(
1964
+ const runpodRequest = (endpoint, method = "GET", body, options) => withApiErrorLog(
1320
1965
  "Error calling Runpod API:",
1321
- () => v1Client(`${restV1Base(process.env)}${endpoint}`, method, body)
1966
+ () => v1Client(
1967
+ `${restV1Base(process.env)}${endpoint}`,
1968
+ method,
1969
+ body,
1970
+ options
1971
+ )
1322
1972
  );
1323
1973
  const serverlessClient = createHttpClient({
1324
1974
  apiKey: ctx.apiKey,
1325
1975
  fetch: httpFetch,
1326
1976
  tracking,
1327
- errorPrefix: "Runpod Serverless API Error"
1977
+ errorPrefix: "Runpod Serverless API Error",
1978
+ ...timeouts
1328
1979
  });
1329
- const serverlessRequest = (endpointId, path2, method = "GET", body) => withApiErrorLog(
1980
+ const serverlessRequest = (endpointId, path2, method = "GET", body, options) => withApiErrorLog(
1330
1981
  "Error calling Runpod Serverless API:",
1331
1982
  () => serverlessClient(
1332
1983
  `${serverlessBase(process.env)}/${endpointId}${path2}`,
1333
1984
  method,
1334
- body
1985
+ body,
1986
+ options
1335
1987
  )
1336
1988
  );
1337
1989
  const restClient = createHttpClient({
1338
1990
  apiKey: ctx.apiKey,
1339
1991
  fetch: httpFetch,
1340
1992
  tracking,
1341
- errorPrefix: "Runpod API Error"
1993
+ errorPrefix: "Runpod API Error",
1994
+ ...timeouts
1342
1995
  });
1343
- const callRestUrl = (url, method = "GET", body) => withApiErrorLog(
1996
+ const callRestUrl = (url, method = "GET", body, options) => withApiErrorLog(
1344
1997
  "Error calling Runpod API:",
1345
- () => restClient(url, method, body)
1998
+ () => restClient(url, method, body, options)
1346
1999
  );
1347
2000
  const sseFetch = observeUnauthorized(
1348
2001
  deps.sseFetch ?? import_node_fetch.default
@@ -1375,12 +2028,18 @@ function createToolRuntime(server, ctx, deps = {}) {
1375
2028
  };
1376
2029
  return {
1377
2030
  jsonReply,
2031
+ // No GraphQL caller overrides a deadline, but both are still capped by the
2032
+ // shared budget: deploy-hub-repo spends one of each back to back.
1378
2033
  graphql: (query) => graphqlRequest(
1379
2034
  query,
1380
2035
  tracking,
1381
2036
  // rawFetch, not httpFetch: this call carries no credential (see above).
1382
2037
  rawFetch,
1383
- publicGraphqlBase(process.env)
2038
+ publicGraphqlBase(process.env),
2039
+ {
2040
+ timeoutMs: deps.defaultTimeoutMs,
2041
+ maxTimeoutMs: timeouts.maxTimeoutMs
2042
+ }
1384
2043
  ),
1385
2044
  graphqlAuthed: (query, variables) => graphqlRequest(
1386
2045
  query,
@@ -1388,7 +2047,12 @@ function createToolRuntime(server, ctx, deps = {}) {
1388
2047
  httpFetch,
1389
2048
  // NOT publicGraphqlBase: this call carries the caller's API key.
1390
2049
  authedGraphqlBase(process.env),
1391
- { variables, apiKey: ctx.apiKey }
2050
+ {
2051
+ variables,
2052
+ apiKey: ctx.apiKey,
2053
+ timeoutMs: deps.defaultTimeoutMs,
2054
+ maxTimeoutMs: timeouts.maxTimeoutMs
2055
+ }
1392
2056
  ),
1393
2057
  runpodRequest,
1394
2058
  serverlessRequest,
@@ -1396,7 +2060,8 @@ function createToolRuntime(server, ctx, deps = {}) {
1396
2060
  backendFor,
1397
2061
  streamSse,
1398
2062
  podAction,
1399
- env: process.env
2063
+ env: process.env,
2064
+ transport: ctx.transport
1400
2065
  };
1401
2066
  }
1402
2067
 
@@ -1465,6 +2130,7 @@ function capListResult(result, options, extra) {
1465
2130
  }
1466
2131
 
1467
2132
  // src/tools/catalog.ts
2133
+ var GPU_PRODUCTS = /* @__PURE__ */ new Set(["POD", "CLUSTER", "SERVERLESS"]);
1468
2134
  function registerCatalogTools(server, rt) {
1469
2135
  const { jsonReply: jsonReply2, graphql, callRestUrl, backendFor } = rt;
1470
2136
  server.tool(
@@ -1483,15 +2149,50 @@ function registerCatalogTools(server, rt) {
1483
2149
  ),
1484
2150
  includeAvailability: import_zod3.z.boolean().optional().describe(
1485
2151
  "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."
2152
+ ),
2153
+ product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
2154
+ "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."
2155
+ ),
2156
+ minCudaVersion: import_zod3.z.string().optional().describe(
2157
+ 'Scope the availability lookup to hosts at or above this CUDA version (v2 only). Integer major or major.minor \u2014 "12" means any 12.x release; unlike the create-pod/create-endpoint body fields, a bare major is accepted here because the filter only widens a read. Requires includeAvailability (the default).'
1486
2158
  )
1487
2159
  },
1488
2160
  { title: "List GPU types", ...READ_ONLY },
1489
2161
  async (params) => {
1490
2162
  const backend = backendFor("gpus");
2163
+ if (params.minCudaVersion !== void 0 && backend.version !== "v2") {
2164
+ return jsonReply2({
2165
+ error: "The minCudaVersion filter is only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2, or use get-capacity for per-CUDA-version availability.",
2166
+ status: 501
2167
+ });
2168
+ }
1491
2169
  if (backend.version === "v2") {
1492
2170
  const wantAvailability = params.includeAvailability !== false;
2171
+ const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
2172
+ if (params.minCudaVersion !== void 0) {
2173
+ if (!wantAvailability) {
2174
+ return jsonReply2({
2175
+ error: "minCudaVersion filters the availability lookup, so it requires includeAvailability (the default). Drop includeAvailability:false or the filter.",
2176
+ status: 400
2177
+ });
2178
+ }
2179
+ if (!/^\d+(\.\d+)?$/.test(params.minCudaVersion)) {
2180
+ return jsonReply2({
2181
+ error: 'minCudaVersion must be an integer major ("12") or major.minor ("12.1").',
2182
+ status: 400
2183
+ });
2184
+ }
2185
+ }
2186
+ const query = new URLSearchParams();
2187
+ if (wantAvailability) {
2188
+ query.set("include", "AVAILABILITY");
2189
+ query.set("product", product);
2190
+ if (params.minCudaVersion !== void 0) {
2191
+ query.set("minCudaVersion", params.minCudaVersion);
2192
+ }
2193
+ }
1493
2194
  const raw = await callRestUrl(
1494
- `${backend.base}${backend.list}${wantAvailability ? "?include=AVAILABILITY" : ""}`
2195
+ `${backend.base}${backend.list}${query.size ? `?${query}` : ""}`
1495
2196
  );
1496
2197
  let gpus = backend.unwrap(raw);
1497
2198
  gpus = gpus.filter((g) => g.id !== "unknown");
@@ -1664,6 +2365,9 @@ function registerCatalogTools(server, rt) {
1664
2365
  gpuTypeId: import_zod3.z.string().describe("ID of the GPU type to retrieve"),
1665
2366
  includeAvailability: import_zod3.z.boolean().optional().describe(
1666
2367
  "Include realtime per-datacenter availability (HIGH/MEDIUM/LOW/NONE). Default true."
2368
+ ),
2369
+ product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
2370
+ "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."
1667
2371
  )
1668
2372
  },
1669
2373
  { title: "Get GPU type", ...READ_ONLY },
@@ -1676,7 +2380,8 @@ function registerCatalogTools(server, rt) {
1676
2380
  });
1677
2381
  }
1678
2382
  const path2 = backend.get(encodeURIComponent(params.gpuTypeId));
1679
- const query = params.includeAvailability === false ? "" : "?include=AVAILABILITY";
2383
+ const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
2384
+ const query = params.includeAvailability === false ? "" : `?include=AVAILABILITY&product=${product}`;
1680
2385
  const result = await callRestUrl(`${backend.base}${path2}${query}`);
1681
2386
  return jsonReply2(result);
1682
2387
  }
@@ -2556,7 +3261,7 @@ function registerPodTools(server, rt) {
2556
3261
  );
2557
3262
  server.tool(
2558
3263
  "create-pod",
2559
- '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.',
3264
+ '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.',
2560
3265
  {
2561
3266
  name: import_zod7.z.string().optional().describe(
2562
3267
  "Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
@@ -2570,20 +3275,48 @@ function registerPodTools(server, rt) {
2570
3275
  ),
2571
3276
  cloudType: import_zod7.z.enum(["SECURE", "COMMUNITY"]).optional().describe("SECURE or COMMUNITY cloud"),
2572
3277
  gpuTypeIds: import_zod7.z.array(import_zod7.z.string()).optional().describe("List of acceptable GPU types"),
2573
- gpuCount: import_zod7.z.number().optional().describe("Number of GPUs"),
3278
+ // v2 GpuConfig.count is `integer, minimum: 1`. Without these bounds a 0,
3279
+ // a negative, or a fractional count reached the wire as-is and came back
3280
+ // as a raw 422; reject it here with a message that names the field.
3281
+ gpuCount: import_zod7.z.number().int().min(1).optional().describe("Number of GPUs (minimum 1)"),
2574
3282
  containerDiskInGb: import_zod7.z.number().optional().describe("Container disk size in GB"),
2575
3283
  volumeInGb: import_zod7.z.number().optional().describe("Volume size in GB"),
2576
3284
  volumeMountPath: import_zod7.z.string().optional().describe("Path to mount the volume"),
2577
3285
  ports: import_zod7.z.array(import_zod7.z.string()).optional().describe("Ports to expose (e.g., '8888/http', '22/tcp')"),
2578
3286
  env: import_zod7.z.record(import_zod7.z.string()).optional().describe("Environment variables"),
3287
+ sshPublicKey: import_zod7.z.string().optional().describe(
3288
+ '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.'
3289
+ ),
2579
3290
  containerRegistryAuthId: import_zod7.z.string().optional().describe(
2580
3291
  "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)."
2581
3292
  ),
2582
- dataCenterIds: import_zod7.z.array(import_zod7.z.string()).optional().describe("List of data centers")
3293
+ dataCenterIds: import_zod7.z.array(import_zod7.z.string()).optional().describe("List of data centers"),
3294
+ allowedCudaVersions: import_zod7.z.array(import_zod7.z.string()).optional().describe(
3295
+ 'Acceptable host CUDA versions as major.minor, e.g. ["12.8","12.6"] (v2, GPU pods only). Exact match \u2014 discover valid values per GPU type with get-capacity. Mutually exclusive with minCudaVersion.'
3296
+ ),
3297
+ minCudaVersion: import_zod7.z.string().optional().describe(
3298
+ 'Lowest acceptable host CUDA version as major.minor, e.g. "12.4" (v2, GPU pods only). A bare major like "12" is rejected. Open-ended floor; mutually exclusive with a non-empty allowedCudaVersions.'
3299
+ )
2583
3300
  },
2584
3301
  { title: "Create pod", ...WRITE },
2585
3302
  async (params) => {
2586
3303
  const backend = backendFor("pods");
3304
+ const { sshPublicKey, ...podParams } = params;
3305
+ let sshKey;
3306
+ if (sshPublicKey !== void 0) {
3307
+ const validated = normalizeSshPublicKey(sshPublicKey);
3308
+ if (!validated.ok)
3309
+ return jsonReply2({ error: validated.error, status: 400 });
3310
+ sshKey = validated.key;
3311
+ }
3312
+ 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.";
3313
+ const hasCudaParams = params.allowedCudaVersions !== void 0 || params.minCudaVersion !== void 0;
3314
+ if (hasCudaParams && backend.version !== "v2") {
3315
+ return jsonReply2({
3316
+ error: "allowedCudaVersions/minCudaVersion are only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2.",
3317
+ status: 501
3318
+ });
3319
+ }
2587
3320
  if (params.templateId && backend.version !== "v2") {
2588
3321
  return jsonReply2({
2589
3322
  error: "Deploying a pod from a templateId is only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2, or pass imageName instead.",
@@ -2619,6 +3352,16 @@ function registerPodTools(server, rt) {
2619
3352
  status: 400
2620
3353
  });
2621
3354
  }
3355
+ if (hasCudaParams && !wantsGpu) {
3356
+ return jsonReply2({
3357
+ error: "allowedCudaVersions/minCudaVersion apply to GPU pods only \u2014 pass gpuTypeIds (see list-gpu-types).",
3358
+ status: 400
3359
+ });
3360
+ }
3361
+ if (hasCudaParams) {
3362
+ const cudaError = cudaConstraintError(params);
3363
+ if (cudaError) return jsonReply2({ error: cudaError, status: 400 });
3364
+ }
2622
3365
  if (!wantsCpu && !params.name && !params.templateId) {
2623
3366
  return jsonReply2({
2624
3367
  error: "Provide a name for the pod (deploying from a templateId inherits the template name).",
@@ -2633,13 +3376,15 @@ function registerPodTools(server, rt) {
2633
3376
  });
2634
3377
  }
2635
3378
  try {
3379
+ const cpuBody = sshKey ? applySshPublicKey(podParams, sshKey) : podParams;
2636
3380
  const result = await callRestUrl(
2637
3381
  `${restV1Base(env)}/pods`,
2638
3382
  "POST",
2639
- params
3383
+ cpuBody
2640
3384
  );
2641
3385
  return jsonReply2({
2642
3386
  ...result,
3387
+ ...sshKey ? { _ssh: sshNote } : {},
2643
3388
  _servedBy: "v1",
2644
3389
  _note: "CPU pod: created on the v1 API, because the v2 REST API does not support CPU pods yet."
2645
3390
  });
@@ -2656,7 +3401,7 @@ function registerPodTools(server, rt) {
2656
3401
  }
2657
3402
  }
2658
3403
  }
2659
- let body = backend.mapCreate(params);
3404
+ let body = backend.mapCreate(podParams);
2660
3405
  if (params.templateId) {
2661
3406
  let template;
2662
3407
  try {
@@ -2674,19 +3419,21 @@ function registerPodTools(server, rt) {
2674
3419
  }
2675
3420
  body = { ...podBodyFromTemplate(template), ...body };
2676
3421
  }
3422
+ if (sshKey) body = applySshPublicKey(body, sshKey);
2677
3423
  try {
2678
3424
  const result = await callRestUrl(
2679
3425
  `${backend.base}${backend.list}`,
2680
3426
  "POST",
2681
3427
  body
2682
3428
  );
3429
+ const extras = {};
3430
+ if (sshKey) extras._ssh = sshNote;
2683
3431
  if (backend.version === "v2" && (params.gpuTypeIds?.length ?? 0) > 1) {
2684
- return jsonReply2({
2685
- ...result,
2686
- _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(", ")}.`
2687
- });
3432
+ 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(", ")}.`;
2688
3433
  }
2689
- return jsonReply2(result);
3434
+ return jsonReply2(
3435
+ Object.keys(extras).length ? { ...result, ...extras } : result
3436
+ );
2690
3437
  } catch (error) {
2691
3438
  if (error instanceof HttpError) {
2692
3439
  return jsonReply2({ error: error.message, status: error.status });
@@ -2804,6 +3551,9 @@ function registerPodTools(server, rt) {
2804
3551
  var import_zod8 = require("zod");
2805
3552
  var MIN_QUEUE_DELAY = 0.5;
2806
3553
  var MIN_REQUEST_COUNT = 1;
3554
+ function isRequestCountScaler(type) {
3555
+ return type === "REQUEST_COUNT";
3556
+ }
2807
3557
  var MIN_IDLE_TIMEOUT = 1;
2808
3558
  var MAX_IDLE_TIMEOUT = 3600;
2809
3559
  function scalerValueError(scalerType, scalerValue) {
@@ -2896,6 +3646,12 @@ function registerEndpointTools(server, rt) {
2896
3646
  'GPU pool names (v2, required). The `pool` field from list-gpu-types, e.g. ["AMPERE_80"]. NOT GPU type ids.'
2897
3647
  ),
2898
3648
  gpuCount: import_zod8.z.number().optional().describe("GPUs per worker (v2)"),
3649
+ allowedCudaVersions: import_zod8.z.array(import_zod8.z.string()).optional().describe(
3650
+ 'Acceptable host CUDA versions for worker placement as major.minor, e.g. ["12.8"] (v2). Exact match \u2014 see get-capacity for which versions have stock. Mutually exclusive with minCudaVersion.'
3651
+ ),
3652
+ minCudaVersion: import_zod8.z.string().optional().describe(
3653
+ 'Lowest acceptable host CUDA version as major.minor, e.g. "12.4" (v2). A bare major like "12" is rejected. Mutually exclusive with a non-empty allowedCudaVersions.'
3654
+ ),
2899
3655
  args: import_zod8.z.string().optional().describe("Container start command/args (v2)"),
2900
3656
  containerDiskInGb: import_zod8.z.number().optional().describe("Container disk size in GB (v2)"),
2901
3657
  ports: import_zod8.z.array(import_zod8.z.string()).optional().describe("Ports to expose (v2), e.g. ['8000/http']"),
@@ -2925,6 +3681,13 @@ function registerEndpointTools(server, rt) {
2925
3681
  { title: "Create endpoint", ...WRITE },
2926
3682
  async (params) => {
2927
3683
  const backend = backendFor("endpoints");
3684
+ const hasCudaParams = params.allowedCudaVersions !== void 0 || params.minCudaVersion !== void 0;
3685
+ if (hasCudaParams && backend.version !== "v2") {
3686
+ return jsonReply2({
3687
+ error: "allowedCudaVersions/minCudaVersion are only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2.",
3688
+ status: 501
3689
+ });
3690
+ }
2928
3691
  if (backend.version === "v2") {
2929
3692
  if (!params.name) {
2930
3693
  return jsonReply2({
@@ -2957,6 +3720,10 @@ function registerEndpointTools(server, rt) {
2957
3720
  if (scalerError) {
2958
3721
  return jsonReply2({ error: scalerError, status: 400 });
2959
3722
  }
3723
+ if (hasCudaParams) {
3724
+ const cudaError = cudaConstraintError(params);
3725
+ if (cudaError) return jsonReply2({ error: cudaError, status: 400 });
3726
+ }
2960
3727
  const body = backend.mapCreate(params);
2961
3728
  const result2 = await callRestUrl(
2962
3729
  `${backend.base}${backend.list}`,
@@ -2994,7 +3761,7 @@ function registerEndpointTools(server, rt) {
2994
3761
  );
2995
3762
  server.tool(
2996
3763
  "update-endpoint",
2997
- "Update a Serverless endpoint's config. On v2 you can change image/disk/env/ports/registry/workers/scaling/networkVolumes/timeout/flashboot; on v1, scaling fields (worker min/max, idle timeout, scaler type/value, name). Only provided fields change. An endpoint's request routing (queue vs load balancer) is fixed at creation and cannot be changed here \u2014 recreate the endpoint instead.",
3764
+ "Update a Serverless endpoint's config. On v2 you can change image/disk/env/ports/registry/workers/scaling/networkVolumes/timeout/flashboot, plus the GPU selection (gpuPoolIds/gpuCount) and its CUDA constraints (allowedCudaVersions/minCudaVersion) \u2014 CUDA-only or count-only changes do not require resending gpuPoolIds; on v1, scaling fields (worker min/max, idle timeout, scaler type/value, name). Only provided fields change. An endpoint's request routing (queue vs load balancer) is fixed at creation and cannot be changed here \u2014 recreate the endpoint instead. Note: passing gpuPoolIds replaces the GPU selection wholesale, which clears any GPU-type exclusions set elsewhere (console or set-endpoint-gpus) \u2014 the reply carries a _warning when that happens.",
2998
3765
  {
2999
3766
  endpointId: import_zod8.z.string().describe("ID of the endpoint to update"),
3000
3767
  name: import_zod8.z.string().optional().describe("New name for the endpoint"),
@@ -3013,6 +3780,12 @@ function registerEndpointTools(server, rt) {
3013
3780
  imageName: import_zod8.z.string().optional().describe("New Docker image (v2)"),
3014
3781
  gpuPoolIds: import_zod8.z.array(import_zod8.z.string()).optional().describe('New GPU pool names (v2), e.g. ["AMPERE_80"]'),
3015
3782
  gpuCount: import_zod8.z.number().optional().describe("New GPUs per worker (v2)"),
3783
+ allowedCudaVersions: import_zod8.z.array(import_zod8.z.string()).optional().describe(
3784
+ "New acceptable host CUDA versions as major.minor (v2). Changing only this does not require resending gpuPoolIds. An explicit [] clears the constraint. Mutually exclusive with minCudaVersion."
3785
+ ),
3786
+ minCudaVersion: import_zod8.z.string().optional().describe(
3787
+ 'New lowest acceptable host CUDA version as major.minor (v2). An empty string clears the floor; a bare major like "12" is rejected. Mutually exclusive with a non-empty allowedCudaVersions.'
3788
+ ),
3016
3789
  args: import_zod8.z.string().optional().describe("New container args (v2)"),
3017
3790
  containerDiskInGb: import_zod8.z.number().optional().describe("New container disk size in GB (v2)"),
3018
3791
  ports: import_zod8.z.array(import_zod8.z.string()).optional().describe("New ports (v2)"),
@@ -3027,14 +3800,41 @@ function registerEndpointTools(server, rt) {
3027
3800
  const { endpointId, ...updateParams } = params;
3028
3801
  const backend = backendFor("endpoints");
3029
3802
  const url = `${backend.base}${backend.get(endpointId)}`;
3803
+ const hasCudaParams = updateParams.allowedCudaVersions !== void 0 || updateParams.minCudaVersion !== void 0;
3030
3804
  if (backend.version !== "v2") {
3805
+ if (hasCudaParams) {
3806
+ return jsonReply2({
3807
+ error: "allowedCudaVersions/minCudaVersion are only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2.",
3808
+ status: 501
3809
+ });
3810
+ }
3031
3811
  const body2 = backend.mapUpdate(updateParams);
3032
3812
  return jsonReply2(await callRestUrl(url, "PATCH", body2));
3033
3813
  }
3814
+ if (hasCudaParams) {
3815
+ const cudaError = cudaConstraintError(updateParams, {
3816
+ allowClear: true
3817
+ });
3818
+ if (cudaError) return jsonReply2({ error: cudaError, status: 400 });
3819
+ }
3820
+ if (updateParams.gpuPoolIds !== void 0 && !updateParams.gpuPoolIds.length) {
3821
+ return jsonReply2({
3822
+ error: "gpuPoolIds cannot be empty \u2014 omit it to keep the current GPU selection.",
3823
+ status: 400
3824
+ });
3825
+ }
3034
3826
  let scalerType = updateParams.scalerType;
3035
- if (scalerType === void 0 && updateParams.scalerValue !== void 0) {
3827
+ const needScalerRead = scalerType === void 0 && updateParams.scalerValue !== void 0;
3828
+ const needExclusionRead = (updateParams.gpuPoolIds?.length ?? 0) > 0;
3829
+ let clearedExclusions = [];
3830
+ if (needScalerRead || needExclusionRead) {
3036
3831
  const current = await callRestUrl(url);
3037
- scalerType = current?.scaling?.type === "REQUEST_COUNT" ? "REQUEST_COUNT" : "QUEUE_DELAY";
3832
+ if (needScalerRead) {
3833
+ scalerType = isRequestCountScaler(current?.scaling?.type) ? "REQUEST_COUNT" : "QUEUE_DELAY";
3834
+ }
3835
+ if (needExclusionRead && current?.gpu?.excludedTypes?.length) {
3836
+ clearedExclusions = current.gpu.excludedTypes;
3837
+ }
3038
3838
  }
3039
3839
  const scalerError = scalerValueError(
3040
3840
  scalerType,
@@ -3047,7 +3847,14 @@ function registerEndpointTools(server, rt) {
3047
3847
  ...updateParams,
3048
3848
  scalerType
3049
3849
  });
3050
- return jsonReply2(await callRestUrl(url, "PATCH", body));
3850
+ const result = await callRestUrl(url, "PATCH", body);
3851
+ if (clearedExclusions.length) {
3852
+ return jsonReply2({
3853
+ ...result,
3854
+ _warning: `Supplying gpuPoolIds replaced the GPU selection wholesale, clearing ${clearedExclusions.length} GPU-type exclusion(s) previously set on this endpoint: ${clearedExclusions.join(", ")}. Re-apply them with set-endpoint-gpus if still wanted.`
3855
+ });
3856
+ }
3857
+ return jsonReply2(result);
3051
3858
  }
3052
3859
  );
3053
3860
  server.tool(
@@ -3147,8 +3954,108 @@ var import_zod9 = require("zod");
3147
3954
  var DIAGNOSIS_TTL_MS = 15e3;
3148
3955
  var MAX_DIAGNOSIS_ENTRIES = 500;
3149
3956
  var diagnosisCache = /* @__PURE__ */ new Map();
3957
+ var HTTP_LONG_POLL_BUDGET_MS = 45e3;
3958
+ var STDIO_STREAM_BUDGET_MS = 5 * 60 * 1e3;
3959
+ var RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS = 9e4;
3960
+ var STREAM_UPSTREAM_DEFAULT_WAIT_MS = 1e4;
3961
+ var HTTP_STREAM_POLL_WAIT_MS = 1e3;
3962
+ var STREAM_JOB_POLL_INTERVAL_MS = 1e3;
3963
+ var MAX_CONSECUTIVE_STREAM_ERRORS = 5;
3964
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
3965
+ "COMPLETED",
3966
+ "FAILED",
3967
+ "CANCELLED",
3968
+ "TIMED_OUT"
3969
+ ]);
3970
+ function isUnfinishedJobReply(result) {
3971
+ return result !== null && typeof result === "object" && !Array.isArray(result) && !("waitClamped" in result) && !TERMINAL_STATUSES.has(result.status ?? "");
3972
+ }
3973
+ function formatBudget(ms) {
3974
+ return ms >= 12e4 ? `${Math.round(ms / 6e4)} minutes` : `${Math.round(ms / 1e3)} seconds`;
3975
+ }
3976
+ var UPSTREAM_HOLD_SLACK_MS = 5e3;
3977
+ var MIN_STREAM_POLL_TIMEOUT_MS = 2e3;
3978
+ var QUEUED_DIAGNOSIS_TIMEOUT_MS = 5e3;
3979
+ function streamPollTimeoutMs(remainingMs, holdMs) {
3980
+ return Math.max(
3981
+ Math.min(remainingMs, holdMs + UPSTREAM_HOLD_SLACK_MS),
3982
+ MIN_STREAM_POLL_TIMEOUT_MS
3983
+ );
3984
+ }
3985
+ function sleep(ms) {
3986
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
3987
+ }
3988
+ function streamPollPlan(hosted) {
3989
+ return hosted ? {
3990
+ budgetMs: HTTP_LONG_POLL_BUDGET_MS,
3991
+ holdMs: HTTP_STREAM_POLL_WAIT_MS,
3992
+ query: `?wait=${HTTP_STREAM_POLL_WAIT_MS}`
3993
+ } : {
3994
+ budgetMs: STDIO_STREAM_BUDGET_MS,
3995
+ holdMs: STREAM_UPSTREAM_DEFAULT_WAIT_MS,
3996
+ query: ""
3997
+ };
3998
+ }
3999
+ 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).";
4000
+ function budgetExhaustedNote(budgetMs, lastError) {
4001
+ return {
4002
+ pollingTimedOut: true,
4003
+ note: `Polling stopped after ${formatBudget(
4004
+ budgetMs
4005
+ )} with the job possibly still running. ${RESUME_ADVICE}`,
4006
+ // Surface the trailing error (if any) instead of discarding it — the last
4007
+ // polls may have been failing (e.g. job expired) even though earlier ones
4008
+ // succeeded. Cleared on success, so this is never a stale error from
4009
+ // minutes of healthy streaming ago.
4010
+ ...lastError ? { lastError } : {}
4011
+ };
4012
+ }
4013
+ function errorCapNote(lastError) {
4014
+ return {
4015
+ error: `Polling aborted after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors: ${lastError}`,
4016
+ note: `Polling stopped after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors with the job possibly still running. ${RESUME_ADVICE}`
4017
+ };
4018
+ }
4019
+ async function collectJobStream(deps) {
4020
+ const { poll, budgetMs, holdMs } = deps;
4021
+ const startedAt = Date.now();
4022
+ const elapsed = () => Date.now() - startedAt;
4023
+ const chunks = [];
4024
+ let result = {};
4025
+ let consecutiveErrors = 0;
4026
+ let lastError;
4027
+ while (true) {
4028
+ try {
4029
+ const reply = await poll(
4030
+ streamPollTimeoutMs(budgetMs - elapsed(), holdMs)
4031
+ );
4032
+ consecutiveErrors = 0;
4033
+ lastError = void 0;
4034
+ if (Array.isArray(reply.stream)) chunks.push(...reply.stream);
4035
+ result = reply;
4036
+ if (TERMINAL_STATUSES.has(reply.status)) break;
4037
+ } catch (error) {
4038
+ consecutiveErrors++;
4039
+ lastError = error instanceof Error ? error.message : String(error);
4040
+ if (consecutiveErrors >= MAX_CONSECUTIVE_STREAM_ERRORS) {
4041
+ result = { ...result, ...errorCapNote(lastError) };
4042
+ break;
4043
+ }
4044
+ }
4045
+ if (elapsed() > budgetMs) {
4046
+ result = { ...result, ...budgetExhaustedNote(budgetMs, lastError) };
4047
+ break;
4048
+ }
4049
+ await sleep(STREAM_JOB_POLL_INTERVAL_MS);
4050
+ }
4051
+ return { result, chunks };
4052
+ }
3150
4053
  function registerJobTools(server, rt) {
3151
4054
  const { jsonReply: jsonReply2, serverlessRequest, backendFor, callRestUrl } = rt;
4055
+ const hosted = rt.transport === "http";
4056
+ const streamBudget = formatBudget(
4057
+ hosted ? HTTP_LONG_POLL_BUDGET_MS : STDIO_STREAM_BUDGET_MS
4058
+ );
3152
4059
  async function diagnoseQueuedJob(endpointId) {
3153
4060
  const cached = diagnosisCache.get(endpointId);
3154
4061
  if (cached && cached.expiresAt > Date.now()) {
@@ -3159,7 +4066,10 @@ function registerJobTools(server, rt) {
3159
4066
  const backend = backendFor("workers");
3160
4067
  if (backend.version !== "v2") return null;
3161
4068
  const raw = await callRestUrl(
3162
- `${backend.base}/serverless/${endpointId}/workers`
4069
+ `${backend.base}/serverless/${endpointId}/workers`,
4070
+ "GET",
4071
+ void 0,
4072
+ { timeoutMs: QUEUED_DIAGNOSIS_TIMEOUT_MS }
3163
4073
  );
3164
4074
  const summary = raw?.summary;
3165
4075
  const workers = raw?.workers ?? [];
@@ -3250,14 +4160,14 @@ function registerJobTools(server, rt) {
3250
4160
  );
3251
4161
  server.tool(
3252
4162
  "runsync-endpoint",
3253
- "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).",
4163
+ `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)."}`,
3254
4164
  {
3255
4165
  endpointId: endpointIdSchema.describe(
3256
4166
  "ID of the Serverless endpoint to run synchronously"
3257
4167
  ),
3258
4168
  input: inputSchema,
3259
4169
  wait: import_zod9.z.number().min(1e3).max(3e5).optional().describe(
3260
- "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)."
4170
+ 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)."
3261
4171
  ),
3262
4172
  webhook: webhookSchema,
3263
4173
  policy: policySchema,
@@ -3266,13 +4176,36 @@ function registerJobTools(server, rt) {
3266
4176
  { title: "Run endpoint (sync)", ...WRITE },
3267
4177
  async (params) => {
3268
4178
  const { endpointId, wait, ...body } = params;
3269
- const path2 = wait ? `/runsync?wait=${wait}` : "/runsync";
4179
+ const effectiveWait = hosted ? Math.min(
4180
+ wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS,
4181
+ HTTP_LONG_POLL_BUDGET_MS
4182
+ ) : wait;
4183
+ const waitQuery = effectiveWait === void 0 ? "" : `?wait=${effectiveWait}`;
4184
+ const path2 = `/runsync${waitQuery}`;
3270
4185
  const result = await serverlessRequest(
3271
4186
  endpointId,
3272
4187
  path2,
3273
4188
  "POST",
3274
- body
4189
+ body,
4190
+ {
4191
+ // From the wait actually sent — on http the clamped 45s, not the
4192
+ // 300s the caller may have asked for.
4193
+ timeoutMs: (effectiveWait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS) + UPSTREAM_HOLD_SLACK_MS
4194
+ }
3275
4195
  );
4196
+ const requestedWait = wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS;
4197
+ if (hosted && requestedWait > HTTP_LONG_POLL_BUDGET_MS && isUnfinishedJobReply(result)) {
4198
+ return jsonReply2({
4199
+ ...result,
4200
+ waitClamped: {
4201
+ requestedMs: requestedWait,
4202
+ effectiveMs: effectiveWait,
4203
+ reason: `This server runs behind a 60-second gateway deadline, so a non-terminal status here means the job outlived ${formatBudget(
4204
+ HTTP_LONG_POLL_BUDGET_MS
4205
+ )}, not the requested wait \u2014 poll get-job-status rather than treating it as stuck.`
4206
+ }
4207
+ });
4208
+ }
3276
4209
  return jsonReply2(result);
3277
4210
  }
3278
4211
  );
@@ -3306,7 +4239,7 @@ function registerJobTools(server, rt) {
3306
4239
  );
3307
4240
  server.tool(
3308
4241
  "stream-job",
3309
- "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.",
4242
+ `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." : ""}`,
3310
4243
  {
3311
4244
  endpointId: endpointIdSchema.describe(
3312
4245
  "ID of the Serverless endpoint the job belongs to"
@@ -3315,51 +4248,16 @@ function registerJobTools(server, rt) {
3315
4248
  },
3316
4249
  { title: "Stream job", ...READ_ONLY },
3317
4250
  async (params) => {
3318
- const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
3319
- "COMPLETED",
3320
- "FAILED",
3321
- "CANCELLED",
3322
- "TIMED_OUT"
3323
- ]);
3324
- const MAX_POLL_TIME_MS = 5 * 60 * 1e3;
3325
- const POLL_INTERVAL_MS = 1e3;
3326
- const MAX_CONSECUTIVE_ERRORS = 5;
3327
- const allChunks = [];
3328
- let finalResult = {};
3329
- let consecutiveErrors = 0;
3330
- let lastError;
3331
- const startTime = Date.now();
3332
- while (true) {
3333
- try {
3334
- const result = await serverlessRequest(
3335
- params.endpointId,
3336
- `/stream/${params.jobId}`
3337
- );
3338
- consecutiveErrors = 0;
3339
- if (Array.isArray(result.stream)) {
3340
- allChunks.push(...result.stream);
3341
- }
3342
- finalResult = result;
3343
- if (TERMINAL_STATUSES.has(result.status)) {
3344
- break;
3345
- }
3346
- } catch (error) {
3347
- consecutiveErrors++;
3348
- lastError = error instanceof Error ? error.message : String(error);
3349
- if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
3350
- finalResult.error = `Polling aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive errors: ${lastError}`;
3351
- break;
3352
- }
3353
- }
3354
- if (Date.now() - startTime > MAX_POLL_TIME_MS) {
3355
- finalResult.pollingTimedOut = true;
3356
- finalResult.note = "Polling timed out after 5 minutes. Use get-job-status to check the job later.";
3357
- if (lastError) finalResult.lastError = lastError;
3358
- break;
3359
- }
3360
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
3361
- }
3362
- return jsonReply2({ ...finalResult, stream: allChunks });
4251
+ const plan = streamPollPlan(hosted);
4252
+ const streamPath = `/stream/${params.jobId}${plan.query}`;
4253
+ const { result, chunks } = await collectJobStream({
4254
+ budgetMs: plan.budgetMs,
4255
+ holdMs: plan.holdMs,
4256
+ poll: (timeoutMs) => serverlessRequest(params.endpointId, streamPath, "GET", void 0, {
4257
+ timeoutMs
4258
+ })
4259
+ });
4260
+ return jsonReply2({ ...result, stream: chunks });
3363
4261
  }
3364
4262
  );
3365
4263
  server.tool(