@runpod/mcp-server 3.2.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] };
421
+ }
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
+ };
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: [] };
132
496
  }
133
- return { success: false, message };
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
+ };
134
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
  });
@@ -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),
@@ -868,7 +1410,7 @@ function resolveBackend(opts) {
868
1410
 
869
1411
  // src/server.ts
870
1412
  var SERVER_NAME = "Runpod API Server";
871
- var SERVER_VERSION = true ? "3.2.0" : "dev";
1413
+ var SERVER_VERSION = true ? "3.3.0" : "dev";
872
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.
873
1415
 
874
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.`;
@@ -1043,6 +1585,28 @@ var HttpError = class extends Error {
1043
1585
  this.body = body;
1044
1586
  }
1045
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
+ }
1046
1610
  function isJsonContentType(contentType) {
1047
1611
  if (!contentType) return false;
1048
1612
  const ct = contentType.toLowerCase();
@@ -1058,28 +1622,49 @@ function buildRequestHeaders(apiKey, tracking) {
1058
1622
  ...tracking()
1059
1623
  };
1060
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
+ }
1061
1633
  function createHttpClient(deps) {
1062
- return async function request(url, method = "GET", body) {
1063
- 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,
1064
1644
  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 };
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
+ );
1083
1668
  };
1084
1669
  }
1085
1670
 
@@ -1206,7 +1791,7 @@ async function streamLogsReply(rt, tool, params) {
1206
1791
  }
1207
1792
 
1208
1793
  // src/tools/runtime.ts
1209
- var MCP_SERVER_VERSION = true ? "3.2.0" : "dev";
1794
+ var MCP_SERVER_VERSION = true ? "3.3.0" : "dev";
1210
1795
  var SESSION_ID = (0, import_node_crypto.randomUUID)();
1211
1796
  function trackingHeaders(server, ctx) {
1212
1797
  const info = server.server.getClientVersion();
@@ -1221,6 +1806,11 @@ function trackingHeaders(server, ctx) {
1221
1806
  sessionId: SESSION_ID
1222
1807
  });
1223
1808
  }
1809
+ var HTTP_TRANSPORT_BUDGET_MS = 5e4;
1810
+ function invocationBudget(totalMs) {
1811
+ const startedAt = Date.now();
1812
+ return () => totalMs - (Date.now() - startedAt);
1813
+ }
1224
1814
  var defaultFetch = import_node_fetch.default;
1225
1815
  async function withApiErrorLog(label, call) {
1226
1816
  try {
@@ -1244,6 +1834,23 @@ var DESTRUCTIVE = {
1244
1834
  openWorldHint: true
1245
1835
  };
1246
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) {
1247
1854
  const response = await fetchImpl(url, {
1248
1855
  method: "POST",
1249
1856
  headers: {
@@ -1254,7 +1861,8 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
1254
1861
  body: JSON.stringify({
1255
1862
  query,
1256
1863
  ...options?.variables ? { variables: options.variables } : {}
1257
- })
1864
+ }),
1865
+ signal
1258
1866
  });
1259
1867
  if (!response.ok) {
1260
1868
  const bodyText = await response.text();
@@ -1310,39 +1918,53 @@ function createToolRuntime(server, ctx, deps = {}) {
1310
1918
  }
1311
1919
  const rawFetch = deps.fetch ?? defaultFetch;
1312
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
+ };
1313
1926
  const v1Client = createHttpClient({
1314
1927
  apiKey: ctx.apiKey,
1315
1928
  fetch: httpFetch,
1316
1929
  tracking,
1317
- errorPrefix: "Runpod API Error"
1930
+ errorPrefix: "Runpod API Error",
1931
+ ...timeouts
1318
1932
  });
1319
- const runpodRequest = (endpoint, method = "GET", body) => withApiErrorLog(
1933
+ const runpodRequest = (endpoint, method = "GET", body, options) => withApiErrorLog(
1320
1934
  "Error calling Runpod API:",
1321
- () => v1Client(`${restV1Base(process.env)}${endpoint}`, method, body)
1935
+ () => v1Client(
1936
+ `${restV1Base(process.env)}${endpoint}`,
1937
+ method,
1938
+ body,
1939
+ options
1940
+ )
1322
1941
  );
1323
1942
  const serverlessClient = createHttpClient({
1324
1943
  apiKey: ctx.apiKey,
1325
1944
  fetch: httpFetch,
1326
1945
  tracking,
1327
- errorPrefix: "Runpod Serverless API Error"
1946
+ errorPrefix: "Runpod Serverless API Error",
1947
+ ...timeouts
1328
1948
  });
1329
- const serverlessRequest = (endpointId, path2, method = "GET", body) => withApiErrorLog(
1949
+ const serverlessRequest = (endpointId, path2, method = "GET", body, options) => withApiErrorLog(
1330
1950
  "Error calling Runpod Serverless API:",
1331
1951
  () => serverlessClient(
1332
1952
  `${serverlessBase(process.env)}/${endpointId}${path2}`,
1333
1953
  method,
1334
- body
1954
+ body,
1955
+ options
1335
1956
  )
1336
1957
  );
1337
1958
  const restClient = createHttpClient({
1338
1959
  apiKey: ctx.apiKey,
1339
1960
  fetch: httpFetch,
1340
1961
  tracking,
1341
- errorPrefix: "Runpod API Error"
1962
+ errorPrefix: "Runpod API Error",
1963
+ ...timeouts
1342
1964
  });
1343
- const callRestUrl = (url, method = "GET", body) => withApiErrorLog(
1965
+ const callRestUrl = (url, method = "GET", body, options) => withApiErrorLog(
1344
1966
  "Error calling Runpod API:",
1345
- () => restClient(url, method, body)
1967
+ () => restClient(url, method, body, options)
1346
1968
  );
1347
1969
  const sseFetch = observeUnauthorized(
1348
1970
  deps.sseFetch ?? import_node_fetch.default
@@ -1375,12 +1997,18 @@ function createToolRuntime(server, ctx, deps = {}) {
1375
1997
  };
1376
1998
  return {
1377
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.
1378
2002
  graphql: (query) => graphqlRequest(
1379
2003
  query,
1380
2004
  tracking,
1381
2005
  // rawFetch, not httpFetch: this call carries no credential (see above).
1382
2006
  rawFetch,
1383
- publicGraphqlBase(process.env)
2007
+ publicGraphqlBase(process.env),
2008
+ {
2009
+ timeoutMs: deps.defaultTimeoutMs,
2010
+ maxTimeoutMs: timeouts.maxTimeoutMs
2011
+ }
1384
2012
  ),
1385
2013
  graphqlAuthed: (query, variables) => graphqlRequest(
1386
2014
  query,
@@ -1388,7 +2016,12 @@ function createToolRuntime(server, ctx, deps = {}) {
1388
2016
  httpFetch,
1389
2017
  // NOT publicGraphqlBase: this call carries the caller's API key.
1390
2018
  authedGraphqlBase(process.env),
1391
- { variables, apiKey: ctx.apiKey }
2019
+ {
2020
+ variables,
2021
+ apiKey: ctx.apiKey,
2022
+ timeoutMs: deps.defaultTimeoutMs,
2023
+ maxTimeoutMs: timeouts.maxTimeoutMs
2024
+ }
1392
2025
  ),
1393
2026
  runpodRequest,
1394
2027
  serverlessRequest,
@@ -1396,7 +2029,8 @@ function createToolRuntime(server, ctx, deps = {}) {
1396
2029
  backendFor,
1397
2030
  streamSse,
1398
2031
  podAction,
1399
- env: process.env
2032
+ env: process.env,
2033
+ transport: ctx.transport
1400
2034
  };
1401
2035
  }
1402
2036
 
@@ -1465,6 +2099,7 @@ function capListResult(result, options, extra) {
1465
2099
  }
1466
2100
 
1467
2101
  // src/tools/catalog.ts
2102
+ var GPU_PRODUCTS = /* @__PURE__ */ new Set(["POD", "CLUSTER", "SERVERLESS"]);
1468
2103
  function registerCatalogTools(server, rt) {
1469
2104
  const { jsonReply: jsonReply2, graphql, callRestUrl, backendFor } = rt;
1470
2105
  server.tool(
@@ -1483,6 +2118,9 @@ function registerCatalogTools(server, rt) {
1483
2118
  ),
1484
2119
  includeAvailability: import_zod3.z.boolean().optional().describe(
1485
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."
1486
2124
  )
1487
2125
  },
1488
2126
  { title: "List GPU types", ...READ_ONLY },
@@ -1490,8 +2128,9 @@ function registerCatalogTools(server, rt) {
1490
2128
  const backend = backendFor("gpus");
1491
2129
  if (backend.version === "v2") {
1492
2130
  const wantAvailability = params.includeAvailability !== false;
2131
+ const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
1493
2132
  const raw = await callRestUrl(
1494
- `${backend.base}${backend.list}${wantAvailability ? "?include=AVAILABILITY" : ""}`
2133
+ `${backend.base}${backend.list}${wantAvailability ? `?include=AVAILABILITY&product=${product}` : ""}`
1495
2134
  );
1496
2135
  let gpus = backend.unwrap(raw);
1497
2136
  gpus = gpus.filter((g) => g.id !== "unknown");
@@ -1664,6 +2303,9 @@ function registerCatalogTools(server, rt) {
1664
2303
  gpuTypeId: import_zod3.z.string().describe("ID of the GPU type to retrieve"),
1665
2304
  includeAvailability: import_zod3.z.boolean().optional().describe(
1666
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."
1667
2309
  )
1668
2310
  },
1669
2311
  { title: "Get GPU type", ...READ_ONLY },
@@ -1676,7 +2318,8 @@ function registerCatalogTools(server, rt) {
1676
2318
  });
1677
2319
  }
1678
2320
  const path2 = backend.get(encodeURIComponent(params.gpuTypeId));
1679
- 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}`;
1680
2323
  const result = await callRestUrl(`${backend.base}${path2}${query}`);
1681
2324
  return jsonReply2(result);
1682
2325
  }
@@ -2556,7 +3199,7 @@ function registerPodTools(server, rt) {
2556
3199
  );
2557
3200
  server.tool(
2558
3201
  "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.',
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.',
2560
3203
  {
2561
3204
  name: import_zod7.z.string().optional().describe(
2562
3205
  "Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
@@ -2570,12 +3213,18 @@ function registerPodTools(server, rt) {
2570
3213
  ),
2571
3214
  cloudType: import_zod7.z.enum(["SECURE", "COMMUNITY"]).optional().describe("SECURE or COMMUNITY cloud"),
2572
3215
  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"),
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)"),
2574
3220
  containerDiskInGb: import_zod7.z.number().optional().describe("Container disk size in GB"),
2575
3221
  volumeInGb: import_zod7.z.number().optional().describe("Volume size in GB"),
2576
3222
  volumeMountPath: import_zod7.z.string().optional().describe("Path to mount the volume"),
2577
3223
  ports: import_zod7.z.array(import_zod7.z.string()).optional().describe("Ports to expose (e.g., '8888/http', '22/tcp')"),
2578
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
+ ),
2579
3228
  containerRegistryAuthId: import_zod7.z.string().optional().describe(
2580
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)."
2581
3230
  ),
@@ -2584,6 +3233,15 @@ function registerPodTools(server, rt) {
2584
3233
  { title: "Create pod", ...WRITE },
2585
3234
  async (params) => {
2586
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.";
2587
3245
  if (params.templateId && backend.version !== "v2") {
2588
3246
  return jsonReply2({
2589
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.",
@@ -2633,13 +3291,15 @@ function registerPodTools(server, rt) {
2633
3291
  });
2634
3292
  }
2635
3293
  try {
3294
+ const cpuBody = sshKey ? applySshPublicKey(podParams, sshKey) : podParams;
2636
3295
  const result = await callRestUrl(
2637
3296
  `${restV1Base(env)}/pods`,
2638
3297
  "POST",
2639
- params
3298
+ cpuBody
2640
3299
  );
2641
3300
  return jsonReply2({
2642
3301
  ...result,
3302
+ ...sshKey ? { _ssh: sshNote } : {},
2643
3303
  _servedBy: "v1",
2644
3304
  _note: "CPU pod: created on the v1 API, because the v2 REST API does not support CPU pods yet."
2645
3305
  });
@@ -2656,7 +3316,7 @@ function registerPodTools(server, rt) {
2656
3316
  }
2657
3317
  }
2658
3318
  }
2659
- let body = backend.mapCreate(params);
3319
+ let body = backend.mapCreate(podParams);
2660
3320
  if (params.templateId) {
2661
3321
  let template;
2662
3322
  try {
@@ -2674,19 +3334,21 @@ function registerPodTools(server, rt) {
2674
3334
  }
2675
3335
  body = { ...podBodyFromTemplate(template), ...body };
2676
3336
  }
3337
+ if (sshKey) body = applySshPublicKey(body, sshKey);
2677
3338
  try {
2678
3339
  const result = await callRestUrl(
2679
3340
  `${backend.base}${backend.list}`,
2680
3341
  "POST",
2681
3342
  body
2682
3343
  );
3344
+ const extras = {};
3345
+ if (sshKey) extras._ssh = sshNote;
2683
3346
  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
- });
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(", ")}.`;
2688
3348
  }
2689
- return jsonReply2(result);
3349
+ return jsonReply2(
3350
+ Object.keys(extras).length ? { ...result, ...extras } : result
3351
+ );
2690
3352
  } catch (error) {
2691
3353
  if (error instanceof HttpError) {
2692
3354
  return jsonReply2({ error: error.message, status: error.status });
@@ -3147,8 +3809,108 @@ var import_zod9 = require("zod");
3147
3809
  var DIAGNOSIS_TTL_MS = 15e3;
3148
3810
  var MAX_DIAGNOSIS_ENTRIES = 500;
3149
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
+ }
3150
3908
  function registerJobTools(server, rt) {
3151
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
+ );
3152
3914
  async function diagnoseQueuedJob(endpointId) {
3153
3915
  const cached = diagnosisCache.get(endpointId);
3154
3916
  if (cached && cached.expiresAt > Date.now()) {
@@ -3159,7 +3921,10 @@ function registerJobTools(server, rt) {
3159
3921
  const backend = backendFor("workers");
3160
3922
  if (backend.version !== "v2") return null;
3161
3923
  const raw = await callRestUrl(
3162
- `${backend.base}/serverless/${endpointId}/workers`
3924
+ `${backend.base}/serverless/${endpointId}/workers`,
3925
+ "GET",
3926
+ void 0,
3927
+ { timeoutMs: QUEUED_DIAGNOSIS_TIMEOUT_MS }
3163
3928
  );
3164
3929
  const summary = raw?.summary;
3165
3930
  const workers = raw?.workers ?? [];
@@ -3250,14 +4015,14 @@ function registerJobTools(server, rt) {
3250
4015
  );
3251
4016
  server.tool(
3252
4017
  "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).",
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)."}`,
3254
4019
  {
3255
4020
  endpointId: endpointIdSchema.describe(
3256
4021
  "ID of the Serverless endpoint to run synchronously"
3257
4022
  ),
3258
4023
  input: inputSchema,
3259
4024
  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)."
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)."
3261
4026
  ),
3262
4027
  webhook: webhookSchema,
3263
4028
  policy: policySchema,
@@ -3266,13 +4031,36 @@ function registerJobTools(server, rt) {
3266
4031
  { title: "Run endpoint (sync)", ...WRITE },
3267
4032
  async (params) => {
3268
4033
  const { endpointId, wait, ...body } = params;
3269
- 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}`;
3270
4040
  const result = await serverlessRequest(
3271
4041
  endpointId,
3272
4042
  path2,
3273
4043
  "POST",
3274
- 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
+ }
3275
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
+ }
3276
4064
  return jsonReply2(result);
3277
4065
  }
3278
4066
  );
@@ -3306,7 +4094,7 @@ function registerJobTools(server, rt) {
3306
4094
  );
3307
4095
  server.tool(
3308
4096
  "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.",
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." : ""}`,
3310
4098
  {
3311
4099
  endpointId: endpointIdSchema.describe(
3312
4100
  "ID of the Serverless endpoint the job belongs to"
@@ -3315,51 +4103,16 @@ function registerJobTools(server, rt) {
3315
4103
  },
3316
4104
  { title: "Stream job", ...READ_ONLY },
3317
4105
  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 });
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 });
3363
4116
  }
3364
4117
  );
3365
4118
  server.tool(