@runpod/mcp-server 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/dist/{chunk-OM6RHPQC.mjs → chunk-ICUS4KGX.mjs} +708 -100
- package/dist/chunk-ICUS4KGX.mjs.map +1 -0
- package/dist/http.cjs +747 -108
- package/dist/http.cjs.map +1 -1
- package/dist/http.mjs +41 -10
- package/dist/http.mjs.map +1 -1
- package/dist/stdio.cjs +1337 -213
- package/dist/stdio.cjs.map +1 -1
- package/dist/stdio.mjs +726 -108
- package/dist/stdio.mjs.map +1 -1
- package/dist/tools.cjs +707 -99
- package/dist/tools.cjs.map +1 -1
- package/dist/tools.d.cts +13 -1
- package/dist/tools.d.ts +13 -1
- package/dist/tools.mjs +1 -1
- package/dist/wizard-FBLOZ6YM.mjs +901 -0
- package/dist/wizard-FBLOZ6YM.mjs.map +1 -0
- package/package.json +2 -2
- package/dist/chunk-OM6RHPQC.mjs.map +0 -1
- package/dist/wizard-37ZJSVB5.mjs +0 -402
- package/dist/wizard-37ZJSVB5.mjs.map +0 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
"
|
|
112
|
-
|
|
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 (
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
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
|
|
422
|
+
return null;
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
134
425
|
}
|
|
135
426
|
}
|
|
427
|
+
function runClaude(binary, args, secret) {
|
|
428
|
+
const isWindowsCmd = process.platform === "win32" && binary.toLowerCase().endsWith(".cmd");
|
|
429
|
+
const npmTarget = windowsNpmClaudeTarget(binary);
|
|
430
|
+
if (isWindowsCmd && npmTarget === null) {
|
|
431
|
+
return {
|
|
432
|
+
success: false,
|
|
433
|
+
refused: true,
|
|
434
|
+
message: `could not locate a trusted Claude Code npm entrypoint beside ${binary}. Run the shim yourself, substituting your key:
|
|
435
|
+
${describeCommandRedacted(binary, args)}`
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const command = npmTarget?.command ?? binary;
|
|
439
|
+
const commandArgs = [...npmTarget?.argsPrefix ?? [], ...args];
|
|
440
|
+
const result = (0, import_child_process.spawnSync)(command, commandArgs, {
|
|
441
|
+
stdio: "pipe",
|
|
442
|
+
encoding: "utf8",
|
|
443
|
+
env: sanitizedClaudeEnv()
|
|
444
|
+
});
|
|
445
|
+
if (result.error)
|
|
446
|
+
return {
|
|
447
|
+
success: false,
|
|
448
|
+
message: redactSecret(errMessage(result.error), secret)
|
|
449
|
+
};
|
|
450
|
+
const stdio = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
451
|
+
if (result.status === 0) return { success: true, output: stdio };
|
|
452
|
+
if (result.status === null) {
|
|
453
|
+
return { success: false, message: `killed by ${result.signal}` };
|
|
454
|
+
}
|
|
455
|
+
if (stdio.includes("already exists")) {
|
|
456
|
+
return {
|
|
457
|
+
success: true,
|
|
458
|
+
// Deliberately not "to replace the API key": this branch is also reached in
|
|
459
|
+
// hosted mode, where there is no key — and the pre-existing entry may be of the
|
|
460
|
+
// other kind entirely, which is exactly what the user needs to know.
|
|
461
|
+
message: `already configured, existing entry left unchanged \u2014 it was NOT updated to what you just chose (to replace it, run \`${describeCommand(binary, ["mcp", "remove", SERVER_NAME2, "--scope", "user"])}\` first, then re-run this wizard)`
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
success: false,
|
|
466
|
+
message: redactSecret(
|
|
467
|
+
stdio.trim() || `${path.basename(binary)} exited with code ${result.status}`,
|
|
468
|
+
secret
|
|
469
|
+
)
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function absoluteEnvDir(value, fallback) {
|
|
473
|
+
if (!value || !value.trim()) return fallback();
|
|
474
|
+
if (!path.isAbsolute(value) && !path.win32.isAbsolute(value))
|
|
475
|
+
return fallback();
|
|
476
|
+
return value;
|
|
477
|
+
}
|
|
478
|
+
function claudeUserConfigPath() {
|
|
479
|
+
const configured = process.env.CLAUDE_CONFIG_DIR;
|
|
480
|
+
return path.join(
|
|
481
|
+
configured ? path.resolve(configured) : os.homedir(),
|
|
482
|
+
".claude.json"
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
function readClaudeConfigState(configPath) {
|
|
486
|
+
const unknown = {
|
|
487
|
+
configPath,
|
|
488
|
+
userScope: void 0,
|
|
489
|
+
localScopeDirs: []
|
|
490
|
+
};
|
|
491
|
+
if (!path.isAbsolute(configPath)) return unknown;
|
|
492
|
+
let raw;
|
|
493
|
+
try {
|
|
494
|
+
if (!fs.existsSync(configPath)) {
|
|
495
|
+
return { configPath, userScope: false, localScopeDirs: [] };
|
|
496
|
+
}
|
|
497
|
+
raw = fs.readFileSync(configPath, "utf8");
|
|
498
|
+
} catch {
|
|
499
|
+
return unknown;
|
|
500
|
+
}
|
|
501
|
+
const errors = [];
|
|
502
|
+
const parsed = jsonc.parse(raw.replace(/^\uFEFF/, ""), errors, {
|
|
503
|
+
allowTrailingComma: true
|
|
504
|
+
});
|
|
505
|
+
if (errors.length > 0 || typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
506
|
+
return unknown;
|
|
507
|
+
}
|
|
508
|
+
const has = (servers) => typeof servers === "object" && servers !== null && Object.prototype.hasOwnProperty.call(servers, SERVER_NAME2);
|
|
509
|
+
const own = (key) => Object.prototype.hasOwnProperty.call(parsed, key) ? parsed[key] : void 0;
|
|
510
|
+
const projects = own("projects");
|
|
511
|
+
const localScopeDirs = typeof projects === "object" && projects !== null && !Array.isArray(projects) ? Object.entries(projects).filter(([, project]) => has(project?.mcpServers)).map(([dir]) => dir) : [];
|
|
512
|
+
return { configPath, userScope: has(own("mcpServers")), localScopeDirs };
|
|
513
|
+
}
|
|
514
|
+
function describeDirs(dirs) {
|
|
515
|
+
if (dirs.length === 1) return dirs[0];
|
|
516
|
+
return `${dirs.length} project directories (${dirs.slice(0, 3).join(", ")}${dirs.length > 3 ? ", \u2026" : ""})`;
|
|
517
|
+
}
|
|
518
|
+
function interpretRemoveResult(result, state, binary = "claude") {
|
|
519
|
+
if (result.refused) return result;
|
|
520
|
+
const alsoElsewhere = state.localScopeDirs.length > 0 ? ` A separate local-scope ${SERVER_NAME2} entry (which may carry its own API key) also exists in ${describeDirs(state.localScopeDirs)} \u2014 this wizard does not touch those. Remove with \`${describeCommand(binary, ["mcp", "remove", SERVER_NAME2, "--scope", "local"])}\` from each.` : "";
|
|
521
|
+
if (state.userScope === true) {
|
|
522
|
+
return {
|
|
523
|
+
success: false,
|
|
524
|
+
message: `the ${SERVER_NAME2} entry is STILL in Claude Code's user config (${state.configPath}), so your API key is still on disk. ${result.success ? "Most likely that file is not writable \u2014 the CLI reports success anyway." : `The removal command itself failed: ${result.message ?? "no output"}`}${alsoElsewhere}`
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
if (state.userScope === false) {
|
|
528
|
+
return {
|
|
529
|
+
success: true,
|
|
530
|
+
message: (result.success ? "removed from your user config" : "nothing to remove in your user config") + alsoElsewhere
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (result.success) {
|
|
534
|
+
return {
|
|
535
|
+
success: true,
|
|
536
|
+
message: `removal reported success, but ${state.configPath} could not be read to confirm it`
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return { success: false, message: result.message };
|
|
540
|
+
}
|
|
541
|
+
function interpretAddResult(result, state, binary) {
|
|
542
|
+
if (result.refused || !result.success) return result;
|
|
543
|
+
if (state.userScope === false) {
|
|
544
|
+
return {
|
|
545
|
+
success: false,
|
|
546
|
+
message: `Claude Code reported success but no ${SERVER_NAME2} entry is in its user config afterwards (${state.configPath}) \u2014 that file is most likely not writable. Nothing was configured.`
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
if (state.userScope === true && state.localScopeDirs.length > 0) {
|
|
550
|
+
return {
|
|
551
|
+
success: true,
|
|
552
|
+
message: `${result.message ? `${result.message}; ` : ""}note a local-scope ${SERVER_NAME2} entry also exists in ${describeDirs(state.localScopeDirs)} and takes precedence over the user config there, so Claude Code will keep using it in those projects \u2014 remove it with \`${describeCommand(binary, ["mcp", "remove", SERVER_NAME2, "--scope", "local"])}\` run from each`
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (state.userScope === void 0) {
|
|
556
|
+
return {
|
|
557
|
+
success: true,
|
|
558
|
+
message: `${result.message ? `${result.message}; ` : ""}could not read ${state.configPath} to confirm the entry landed`
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return { success: true, message: result.message };
|
|
562
|
+
}
|
|
563
|
+
function createClaudeCodeClient(resolveBinary, readState = () => readClaudeConfigState(claudeUserConfigPath())) {
|
|
564
|
+
return {
|
|
565
|
+
id: "claude-code",
|
|
566
|
+
name: "Claude Code",
|
|
567
|
+
detect: () => Promise.resolve(resolveBinary() !== null),
|
|
568
|
+
add: (mode) => {
|
|
569
|
+
const args = mode.kind === "local" ? [
|
|
570
|
+
"mcp",
|
|
571
|
+
"add",
|
|
572
|
+
SERVER_NAME2,
|
|
573
|
+
"--scope",
|
|
574
|
+
"user",
|
|
575
|
+
"-e",
|
|
576
|
+
`RUNPOD_API_KEY=${mode.apiKey}`,
|
|
577
|
+
"--",
|
|
578
|
+
"npx",
|
|
579
|
+
"-y",
|
|
580
|
+
NPM_PACKAGE
|
|
581
|
+
] : [
|
|
582
|
+
"mcp",
|
|
583
|
+
"add",
|
|
584
|
+
"--transport",
|
|
585
|
+
"http",
|
|
586
|
+
"--scope",
|
|
587
|
+
"user",
|
|
588
|
+
SERVER_NAME2,
|
|
589
|
+
mode.url
|
|
590
|
+
];
|
|
591
|
+
const binary = resolveBinary();
|
|
592
|
+
if (!binary) {
|
|
593
|
+
return Promise.resolve({
|
|
594
|
+
success: false,
|
|
595
|
+
message: `claude CLI not found in the standard install locations. If Claude Code is installed somewhere custom, register it yourself${mode.kind === "local" ? ", substituting your key" : ""}:
|
|
596
|
+
${describeCommandRedacted("claude", args)}`
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
const result = runClaude(
|
|
600
|
+
binary,
|
|
601
|
+
args,
|
|
602
|
+
mode.kind === "local" ? mode.apiKey : void 0
|
|
603
|
+
);
|
|
604
|
+
if (!result.success) return Promise.resolve(result);
|
|
605
|
+
return Promise.resolve(interpretAddResult(result, readState(), binary));
|
|
606
|
+
},
|
|
607
|
+
remove: () => {
|
|
608
|
+
const binary = resolveBinary();
|
|
609
|
+
if (!binary) {
|
|
610
|
+
return Promise.resolve({
|
|
611
|
+
success: false,
|
|
612
|
+
message: `claude CLI not found in the standard install locations. If Claude Code is installed somewhere custom, remove the entry yourself:
|
|
613
|
+
${describeCommand("claude", ["mcp", "remove", SERVER_NAME2, "--scope", "user"])}`
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
const result = runClaude(binary, [
|
|
617
|
+
"mcp",
|
|
618
|
+
"remove",
|
|
619
|
+
"--scope",
|
|
620
|
+
"user",
|
|
621
|
+
SERVER_NAME2
|
|
622
|
+
]);
|
|
623
|
+
if (result.refused) return Promise.resolve(result);
|
|
624
|
+
return Promise.resolve(
|
|
625
|
+
interpretRemoveResult(result, readState(), binary)
|
|
626
|
+
);
|
|
627
|
+
},
|
|
628
|
+
describeTarget: () => "Claude Code user config (via `claude mcp` CLI)"
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function appDataDir() {
|
|
632
|
+
return absoluteEnvDir(
|
|
633
|
+
process.env.APPDATA,
|
|
634
|
+
() => path.join(home, "AppData", "Roaming")
|
|
635
|
+
);
|
|
636
|
+
}
|
|
136
637
|
function claudeDesktopConfigPath() {
|
|
137
638
|
if (process.platform === "win32") {
|
|
138
|
-
return path.join(
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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(
|
|
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
|
|
985
|
+
return { id, count: gpuCount ?? 1 };
|
|
480
986
|
}
|
|
481
987
|
function mapPodCreateToV2(params) {
|
|
482
988
|
return compact({
|
|
@@ -603,6 +1109,42 @@ function podBodyFromTemplate(template) {
|
|
|
603
1109
|
}
|
|
604
1110
|
return out;
|
|
605
1111
|
}
|
|
1112
|
+
var SSH_PUBLIC_KEY_LINE = /^(ssh-[a-z0-9-]+|ecdsa-[a-z0-9-]+|sk-[a-z0-9@.-]+)\s+\S+/i;
|
|
1113
|
+
var PRIVATE_KEY_MARKER = /private[-\s]?key|PuTTY-User-Key-File/i;
|
|
1114
|
+
function normalizeSshPublicKey(input) {
|
|
1115
|
+
if (PRIVATE_KEY_MARKER.test(input))
|
|
1116
|
+
return {
|
|
1117
|
+
ok: false,
|
|
1118
|
+
error: 'sshPublicKey looks like a PRIVATE key. Pass the PUBLIC key instead \u2014 the contents of your ~/.ssh/<key>.pub file (e.g. "ssh-ed25519 AAAA\u2026").'
|
|
1119
|
+
};
|
|
1120
|
+
const lines = input.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1121
|
+
if (lines.length === 0)
|
|
1122
|
+
return {
|
|
1123
|
+
ok: false,
|
|
1124
|
+
error: "sshPublicKey is empty. Pass the contents of your ~/.ssh/<key>.pub file, or omit the parameter entirely to create the pod without SSH."
|
|
1125
|
+
};
|
|
1126
|
+
if (lines.some((line) => !SSH_PUBLIC_KEY_LINE.test(line)))
|
|
1127
|
+
return {
|
|
1128
|
+
ok: false,
|
|
1129
|
+
error: 'sshPublicKey is not an SSH public key. Expected the CONTENTS of your ~/.ssh/<key>.pub file \u2014 a line like "ssh-ed25519 AAAAC3Nza\u2026 you@host" (ssh-*, ecdsa-*, or sk-* followed by the base64 key) \u2014 not a file path, a SHA256 fingerprint, or a bare base64 blob. Newline-separate multiple keys.'
|
|
1130
|
+
};
|
|
1131
|
+
return { ok: true, key: lines.join("\n") };
|
|
1132
|
+
}
|
|
1133
|
+
function applySshPublicKey(body, sshPublicKey) {
|
|
1134
|
+
const ports = Array.isArray(body.ports) ? body.ports : [];
|
|
1135
|
+
const hasSshPort = ports.some((p2) => /^\s*22\/tcp\s*$/i.test(String(p2)));
|
|
1136
|
+
const env = { ...body.env ?? {} };
|
|
1137
|
+
const key = sshPublicKey.trim();
|
|
1138
|
+
if (!env.PUBLIC_KEY) env.PUBLIC_KEY = key;
|
|
1139
|
+
else if (!env.PUBLIC_KEY.includes(key))
|
|
1140
|
+
env.PUBLIC_KEY = `${env.PUBLIC_KEY}
|
|
1141
|
+
${key}`;
|
|
1142
|
+
return {
|
|
1143
|
+
...body,
|
|
1144
|
+
ports: hasSshPort ? ports : [...ports, "22/tcp"],
|
|
1145
|
+
env
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
606
1148
|
function mapTemplateUpdateToV2(params) {
|
|
607
1149
|
return {
|
|
608
1150
|
...containerConfigToV2(params),
|
|
@@ -646,7 +1188,7 @@ function restV1Base(env) {
|
|
|
646
1188
|
return env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
|
|
647
1189
|
}
|
|
648
1190
|
function restV2Base(env) {
|
|
649
|
-
return env.RUNPOD_REST_V2_API_URL ?? "https://
|
|
1191
|
+
return env.RUNPOD_REST_V2_API_URL ?? "https://api.runpod.io/v2";
|
|
650
1192
|
}
|
|
651
1193
|
function serverlessBase(env) {
|
|
652
1194
|
return env.RUNPOD_SERVERLESS_API_URL ?? "https://api.runpod.ai/v2";
|
|
@@ -868,32 +1410,203 @@ function resolveBackend(opts) {
|
|
|
868
1410
|
|
|
869
1411
|
// src/server.ts
|
|
870
1412
|
var SERVER_NAME = "Runpod API Server";
|
|
871
|
-
var SERVER_VERSION = true ? "3.
|
|
1413
|
+
var SERVER_VERSION = true ? "3.3.0" : "dev";
|
|
1414
|
+
var SERVER_INSTRUCTIONS = `These tools cover common Runpod operations, but they are curated projections: the underlying APIs expose more fields and parameters than the tools surface.
|
|
1415
|
+
|
|
1416
|
+
When you need data or capability beyond these tools, consult the full API contracts directly. The REST v2 API lives at https://api.runpod.io/v2 \u2014 the same host these tools call \u2014 and is machine-readable at https://api.runpod.io/v2/openapi.json (fetch and search it to enumerate every path, parameter, and response schema). REST v1 at https://rest.runpod.io/v1 is deprecated; prefer v2. The REST version these tools are actually configured for is reported in the serverInfo.version string of the initialize response, e.g. "[RUNPOD_REST_VERSION=v2]". The GraphQL API at https://api.runpod.io/graphql may expose data the REST API does not yet cover; its schema is documented at https://graphql-spec.runpod.io/, and introspection is disabled in production, so use that reference rather than __schema queries. Both APIs take Bearer auth with a Runpod API key: use the key this client is already configured with \u2014 do not search the environment or filesystem for a credential.`;
|
|
872
1417
|
function createServer(version = SERVER_VERSION) {
|
|
873
|
-
return new import_mcp.McpServer(
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1418
|
+
return new import_mcp.McpServer(
|
|
1419
|
+
{
|
|
1420
|
+
name: SERVER_NAME,
|
|
1421
|
+
version: `${version} [${restVersionSummary(process.env)}]`
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
// Only advertise capabilities the server implements. It registers
|
|
1425
|
+
// tools and no resources — advertising `resources` would invite
|
|
1426
|
+
// `resources/list` calls that answer -32601.
|
|
1427
|
+
capabilities: {
|
|
1428
|
+
tools: {}
|
|
1429
|
+
},
|
|
1430
|
+
instructions: SERVER_INSTRUCTIONS
|
|
879
1431
|
}
|
|
880
|
-
|
|
1432
|
+
);
|
|
881
1433
|
}
|
|
882
1434
|
|
|
883
1435
|
// src/tools/runtime.ts
|
|
884
1436
|
var import_node_fetch = __toESM(require("node-fetch"), 1);
|
|
885
1437
|
var import_node_crypto = require("crypto");
|
|
886
1438
|
|
|
1439
|
+
// src/_shared/rate-limit.ts
|
|
1440
|
+
var GENERIC_RATE_LIMIT_HINT = "rate limited \u2014 back off before retrying, and pace bulk operations";
|
|
1441
|
+
var MAX_PLAUSIBLE_WAIT_S = 7 * 86400;
|
|
1442
|
+
function remainingRequests(raw) {
|
|
1443
|
+
const value = raw?.trim();
|
|
1444
|
+
return value && /^\d+$/.test(value) ? Number(value) : null;
|
|
1445
|
+
}
|
|
1446
|
+
function waitSeconds(raw) {
|
|
1447
|
+
const value = raw?.trim();
|
|
1448
|
+
if (!value || !/^\d+$/.test(value)) return null;
|
|
1449
|
+
const seconds = Number(value);
|
|
1450
|
+
return seconds <= MAX_PLAUSIBLE_WAIT_S ? seconds : null;
|
|
1451
|
+
}
|
|
1452
|
+
var WINDOW_NAME_RE = /^(?:"([\x20-\x21\x23-\x5b\x5d-\x7e]{1,32})"|([A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]{0,31}))$/;
|
|
1453
|
+
function windowName(raw) {
|
|
1454
|
+
const match = WINDOW_NAME_RE.exec(raw.trim());
|
|
1455
|
+
return match ? match[1] ?? match[2] : null;
|
|
1456
|
+
}
|
|
1457
|
+
var QUOTED_VALUE_RE = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"$/;
|
|
1458
|
+
var DISPLAY_VALUE_RE = /^%"(?:[\x20-\x21\x23-\x24\x26-\x5b\x5d-\x7e]|\\|%[0-9a-f]{2})*"$/;
|
|
1459
|
+
function isWellFormedParam(param) {
|
|
1460
|
+
const eq = param.indexOf("=");
|
|
1461
|
+
const key = (eq === -1 ? param : param.slice(0, eq)).trim();
|
|
1462
|
+
if (key.includes('"')) return false;
|
|
1463
|
+
if (eq === -1) return true;
|
|
1464
|
+
const value = param.slice(eq + 1).trim();
|
|
1465
|
+
if (value.startsWith('"')) return QUOTED_VALUE_RE.test(value);
|
|
1466
|
+
if (value.startsWith('%"')) return DISPLAY_VALUE_RE.test(value);
|
|
1467
|
+
return !/[\s"]/.test(value);
|
|
1468
|
+
}
|
|
1469
|
+
function splitOutsideStrings(input, sep) {
|
|
1470
|
+
const parts = [];
|
|
1471
|
+
let current = "";
|
|
1472
|
+
let inString = false;
|
|
1473
|
+
let inDisplay = false;
|
|
1474
|
+
let escaped = false;
|
|
1475
|
+
let prev = "";
|
|
1476
|
+
for (const ch of input) {
|
|
1477
|
+
if (escaped) escaped = false;
|
|
1478
|
+
else if (inString && !inDisplay && ch === "\\") escaped = true;
|
|
1479
|
+
else if (ch === '"') {
|
|
1480
|
+
if (!inString) inDisplay = prev === "%";
|
|
1481
|
+
inString = !inString;
|
|
1482
|
+
} else if (ch === sep && !inString) {
|
|
1483
|
+
parts.push(current);
|
|
1484
|
+
current = "";
|
|
1485
|
+
prev = ch;
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
current += ch;
|
|
1489
|
+
prev = ch;
|
|
1490
|
+
}
|
|
1491
|
+
parts.push(current);
|
|
1492
|
+
return parts;
|
|
1493
|
+
}
|
|
1494
|
+
function readParams(params) {
|
|
1495
|
+
const values = /* @__PURE__ */ new Map();
|
|
1496
|
+
let unreadable = false;
|
|
1497
|
+
let valuelessAt = -1;
|
|
1498
|
+
for (const [index, param] of params.entries()) {
|
|
1499
|
+
const eq = param.indexOf("=");
|
|
1500
|
+
const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase();
|
|
1501
|
+
if (!key) continue;
|
|
1502
|
+
if (eq === -1) {
|
|
1503
|
+
if (key === "r") unreadable = true;
|
|
1504
|
+
if (key === "t") values.delete("t");
|
|
1505
|
+
valuelessAt = index;
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if ((key === "r" || key === "t") && (values.has(key) || valuelessAt !== -1 && index > valuelessAt)) {
|
|
1509
|
+
unreadable = true;
|
|
1510
|
+
}
|
|
1511
|
+
values.set(key, param.slice(eq + 1));
|
|
1512
|
+
}
|
|
1513
|
+
return { values, unreadable };
|
|
1514
|
+
}
|
|
1515
|
+
function parseExhaustedWindows(header) {
|
|
1516
|
+
if (!header) return { windows: [], dropped: false };
|
|
1517
|
+
const windows = [];
|
|
1518
|
+
let dropped = false;
|
|
1519
|
+
for (const item of splitOutsideStrings(header, ",")) {
|
|
1520
|
+
if (item.trim() === "") continue;
|
|
1521
|
+
const [rawName, ...params] = splitOutsideStrings(item, ";");
|
|
1522
|
+
if (!params.every(isWellFormedParam)) {
|
|
1523
|
+
dropped = true;
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
const { values, unreadable } = readParams(params);
|
|
1527
|
+
if (unreadable) {
|
|
1528
|
+
dropped = true;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
const remaining = remainingRequests(values.get("r"));
|
|
1532
|
+
if (remaining === null) {
|
|
1533
|
+
dropped = true;
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
if (remaining !== 0) continue;
|
|
1537
|
+
windows.push({
|
|
1538
|
+
window: windowName(rawName),
|
|
1539
|
+
resetS: waitSeconds(values.get("t"))
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
return { windows, dropped };
|
|
1543
|
+
}
|
|
1544
|
+
function humanWait(seconds) {
|
|
1545
|
+
if (seconds < 120) return `~${seconds}s`;
|
|
1546
|
+
if (seconds < 3600)
|
|
1547
|
+
return `~${Math.ceil(seconds / 60)} minutes (${seconds}s)`;
|
|
1548
|
+
if (seconds < 2 * 86400) {
|
|
1549
|
+
const hours = Math.ceil(seconds / 3600);
|
|
1550
|
+
return `~${hours} hour${hours === 1 ? "" : "s"} (${seconds}s)`;
|
|
1551
|
+
}
|
|
1552
|
+
return `~${Math.ceil(seconds / 86400)} days (${seconds}s)`;
|
|
1553
|
+
}
|
|
1554
|
+
function rateLimitHint(rateLimitHeader, retryAfterHeader) {
|
|
1555
|
+
const { windows, dropped } = parseExhaustedWindows(rateLimitHeader);
|
|
1556
|
+
windows.sort((a, b) => (b.resetS ?? -1) - (a.resetS ?? -1));
|
|
1557
|
+
const longestWindow = windows[0];
|
|
1558
|
+
const waitCandidates = [
|
|
1559
|
+
waitSeconds(retryAfterHeader),
|
|
1560
|
+
longestWindow?.resetS
|
|
1561
|
+
].filter((n) => typeof n === "number" && n > 0);
|
|
1562
|
+
const waitS = waitCandidates.length ? Math.max(...waitCandidates) : null;
|
|
1563
|
+
const retryAfterUnreadable = typeof retryAfterHeader === "string" && retryAfterHeader.trim() !== "" && waitSeconds(retryAfterHeader) === null;
|
|
1564
|
+
if (!longestWindow && waitS === null) {
|
|
1565
|
+
if (!retryAfterUnreadable) return GENERIC_RATE_LIMIT_HINT;
|
|
1566
|
+
}
|
|
1567
|
+
const namedClause = longestWindow?.window ? `the ${longestWindow.window} quota is exhausted` : "";
|
|
1568
|
+
if (waitS === null) {
|
|
1569
|
+
const cause = namedClause ? `${namedClause}, but the response gave no usable reset time` : "the response gave no usable reset time";
|
|
1570
|
+
return `rate limited \u2014 ${cause}; back off substantially before retrying, and pace bulk operations`;
|
|
1571
|
+
}
|
|
1572
|
+
const hedge = dropped || retryAfterUnreadable || windows.some((w) => w.resetS === null) ? "at least " : "";
|
|
1573
|
+
const wait = `wait ${hedge}${humanWait(waitS)} before retrying, and pace bulk operations`;
|
|
1574
|
+
return `rate limited \u2014 ${namedClause ? `${namedClause}; ` : ""}${wait}`;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
887
1577
|
// src/_shared/http.ts
|
|
1578
|
+
var EXPIRED_CREDENTIAL_HINT = "the Runpod API rejected the credential \u2014 the API key may be expired or revoked; re-authenticate or update RUNPOD_API_KEY";
|
|
888
1579
|
var HttpError = class extends Error {
|
|
889
|
-
constructor(prefix, status, body) {
|
|
890
|
-
const
|
|
891
|
-
super(`${prefix}: ${status} - ${
|
|
1580
|
+
constructor(prefix, status, body, hint) {
|
|
1581
|
+
const shownBody = body.length > 2048 ? `${body.slice(0, 2048)}\u2026 [truncated ${body.length - 2048} chars]` : body;
|
|
1582
|
+
super(`${prefix}: ${status} - ${shownBody}${hint ? ` (${hint})` : ""}`);
|
|
892
1583
|
this.name = "HttpError";
|
|
893
1584
|
this.status = status;
|
|
894
1585
|
this.body = body;
|
|
895
1586
|
}
|
|
896
1587
|
};
|
|
1588
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
1589
|
+
var MIN_REQUEST_TIMEOUT_MS = 1e3;
|
|
1590
|
+
var RequestTimeoutError = class extends Error {
|
|
1591
|
+
constructor(prefix, timeoutMs, method) {
|
|
1592
|
+
const write = method !== "GET" && method !== "HEAD";
|
|
1593
|
+
super(
|
|
1594
|
+
`${prefix}: no response after ${timeoutMs}ms for ${method} (the API may be overloaded, or still be working on it). ${write ? "This request may have SUCCEEDED upstream \u2014 do not retry blindly. Check with the matching list-/get- tool first and retry only if it did not take effect." : "This was a read and changed nothing, so retrying is safe."}`
|
|
1595
|
+
);
|
|
1596
|
+
this.name = "RequestTimeoutError";
|
|
1597
|
+
this.timeoutMs = timeoutMs;
|
|
1598
|
+
this.method = method;
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
async function withRequestTimeout(errorPrefix, timeoutMs, method, run) {
|
|
1602
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
1603
|
+
try {
|
|
1604
|
+
return await run(signal);
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
if (error instanceof HttpError || !signal.aborted) throw error;
|
|
1607
|
+
throw new RequestTimeoutError(errorPrefix, timeoutMs, method);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
897
1610
|
function isJsonContentType(contentType) {
|
|
898
1611
|
if (!contentType) return false;
|
|
899
1612
|
const ct = contentType.toLowerCase();
|
|
@@ -909,24 +1622,49 @@ function buildRequestHeaders(apiKey, tracking) {
|
|
|
909
1622
|
...tracking()
|
|
910
1623
|
};
|
|
911
1624
|
}
|
|
1625
|
+
function resolveCeiling(ceiling) {
|
|
1626
|
+
if (ceiling === void 0) return Infinity;
|
|
1627
|
+
if (typeof ceiling !== "function") return ceiling;
|
|
1628
|
+
return Math.max(ceiling(), MIN_REQUEST_TIMEOUT_MS);
|
|
1629
|
+
}
|
|
1630
|
+
function clampTimeout(requestedMs, ceiling, defaultMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
1631
|
+
return Math.min(requestedMs ?? defaultMs, resolveCeiling(ceiling));
|
|
1632
|
+
}
|
|
912
1633
|
function createHttpClient(deps) {
|
|
913
|
-
|
|
914
|
-
|
|
1634
|
+
const defaultTimeoutMs = deps.defaultTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
1635
|
+
return async function request(url, method = "GET", body, options) {
|
|
1636
|
+
const timeoutMs = clampTimeout(
|
|
1637
|
+
options?.timeoutMs,
|
|
1638
|
+
deps.maxTimeoutMs,
|
|
1639
|
+
defaultTimeoutMs
|
|
1640
|
+
);
|
|
1641
|
+
return withRequestTimeout(
|
|
1642
|
+
deps.errorPrefix,
|
|
1643
|
+
timeoutMs,
|
|
915
1644
|
method,
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
response.
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1645
|
+
async (signal) => {
|
|
1646
|
+
const init = {
|
|
1647
|
+
method,
|
|
1648
|
+
headers: buildRequestHeaders(deps.apiKey, deps.tracking),
|
|
1649
|
+
signal
|
|
1650
|
+
};
|
|
1651
|
+
if (body && methodSendsBody(method)) {
|
|
1652
|
+
init.body = JSON.stringify(body);
|
|
1653
|
+
}
|
|
1654
|
+
const response = await deps.fetch(url, init);
|
|
1655
|
+
if (!response.ok) {
|
|
1656
|
+
const hint = response.status === 429 ? rateLimitHint(
|
|
1657
|
+
response.headers.get("ratelimit"),
|
|
1658
|
+
response.headers.get("retry-after")
|
|
1659
|
+
) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0;
|
|
1660
|
+
const text = await response.text().catch(
|
|
1661
|
+
() => "<body unavailable: the connection closed while reading it>"
|
|
1662
|
+
);
|
|
1663
|
+
throw new HttpError(deps.errorPrefix, response.status, text, hint);
|
|
1664
|
+
}
|
|
1665
|
+
return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
|
|
1666
|
+
}
|
|
1667
|
+
);
|
|
930
1668
|
};
|
|
931
1669
|
}
|
|
932
1670
|
|
|
@@ -982,7 +1720,15 @@ async function readSseSnapshot(fetchImpl, url, headers, opts) {
|
|
|
982
1720
|
throw new HttpError(
|
|
983
1721
|
"Runpod API Error",
|
|
984
1722
|
res.status,
|
|
985
|
-
await res.text().catch(() => "")
|
|
1723
|
+
await res.text().catch(() => ""),
|
|
1724
|
+
// This throw bypasses createHttpClient, so the status hints are built
|
|
1725
|
+
// here too or these two tools alone return a bare 429/401. The 401
|
|
1726
|
+
// hint applies: this request always carries Authorization (see the
|
|
1727
|
+
// headers bound in runtime.ts streamSse).
|
|
1728
|
+
res.status === 429 ? rateLimitHint(
|
|
1729
|
+
res.headers.get("ratelimit"),
|
|
1730
|
+
res.headers.get("retry-after")
|
|
1731
|
+
) : res.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
|
|
986
1732
|
);
|
|
987
1733
|
}
|
|
988
1734
|
for await (const chunk of res.body ?? []) {
|
|
@@ -1045,7 +1791,7 @@ async function streamLogsReply(rt, tool, params) {
|
|
|
1045
1791
|
}
|
|
1046
1792
|
|
|
1047
1793
|
// src/tools/runtime.ts
|
|
1048
|
-
var MCP_SERVER_VERSION = true ? "3.
|
|
1794
|
+
var MCP_SERVER_VERSION = true ? "3.3.0" : "dev";
|
|
1049
1795
|
var SESSION_ID = (0, import_node_crypto.randomUUID)();
|
|
1050
1796
|
function trackingHeaders(server, ctx) {
|
|
1051
1797
|
const info = server.server.getClientVersion();
|
|
@@ -1060,6 +1806,11 @@ function trackingHeaders(server, ctx) {
|
|
|
1060
1806
|
sessionId: SESSION_ID
|
|
1061
1807
|
});
|
|
1062
1808
|
}
|
|
1809
|
+
var HTTP_TRANSPORT_BUDGET_MS = 5e4;
|
|
1810
|
+
function invocationBudget(totalMs) {
|
|
1811
|
+
const startedAt = Date.now();
|
|
1812
|
+
return () => totalMs - (Date.now() - startedAt);
|
|
1813
|
+
}
|
|
1063
1814
|
var defaultFetch = import_node_fetch.default;
|
|
1064
1815
|
async function withApiErrorLog(label, call) {
|
|
1065
1816
|
try {
|
|
@@ -1083,6 +1834,23 @@ var DESTRUCTIVE = {
|
|
|
1083
1834
|
openWorldHint: true
|
|
1084
1835
|
};
|
|
1085
1836
|
async function graphqlRequest(query, tracking, fetchImpl, url, options) {
|
|
1837
|
+
return withRequestTimeout(
|
|
1838
|
+
"Runpod GraphQL Error",
|
|
1839
|
+
clampTimeout(options?.timeoutMs, options?.maxTimeoutMs),
|
|
1840
|
+
// GraphQL is POST on the wire whatever it carries, but the retry advice keys
|
|
1841
|
+
// off the method, and telling a caller its list-gpu-types "may have
|
|
1842
|
+
// SUCCEEDED upstream — check with the matching list-/get- tool first" points
|
|
1843
|
+
// it back at the tool that just failed. The operation keyword is what
|
|
1844
|
+
// actually says whether anything could have been written.
|
|
1845
|
+
isGraphqlMutation(query) ? "POST" : "GET",
|
|
1846
|
+
(signal) => runGraphql(query, tracking, fetchImpl, url, signal, options)
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
function isGraphqlMutation(query) {
|
|
1850
|
+
const firstMeaningful = query.split("\n").map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#"));
|
|
1851
|
+
return /^mutation\b/.test(firstMeaningful ?? "");
|
|
1852
|
+
}
|
|
1853
|
+
async function runGraphql(query, tracking, fetchImpl, url, signal, options) {
|
|
1086
1854
|
const response = await fetchImpl(url, {
|
|
1087
1855
|
method: "POST",
|
|
1088
1856
|
headers: {
|
|
@@ -1093,8 +1861,39 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
|
|
|
1093
1861
|
body: JSON.stringify({
|
|
1094
1862
|
query,
|
|
1095
1863
|
...options?.variables ? { variables: options.variables } : {}
|
|
1096
|
-
})
|
|
1864
|
+
}),
|
|
1865
|
+
signal
|
|
1097
1866
|
});
|
|
1867
|
+
if (!response.ok) {
|
|
1868
|
+
const bodyText = await response.text();
|
|
1869
|
+
let gqlErrors;
|
|
1870
|
+
try {
|
|
1871
|
+
gqlErrors = JSON.parse(bodyText).errors;
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
if (Array.isArray(gqlErrors) && gqlErrors.length > 0 && response.status !== 401 && response.status !== 429) {
|
|
1875
|
+
throw new HttpError(
|
|
1876
|
+
"Runpod GraphQL Error",
|
|
1877
|
+
response.status,
|
|
1878
|
+
gqlErrors.map((e) => String(e?.message ?? JSON.stringify(e))).join(", ")
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
throw new HttpError(
|
|
1882
|
+
"Runpod GraphQL Error",
|
|
1883
|
+
response.status,
|
|
1884
|
+
bodyText,
|
|
1885
|
+
response.status === 429 ? rateLimitHint(
|
|
1886
|
+
response.headers.get("ratelimit"),
|
|
1887
|
+
response.headers.get("retry-after")
|
|
1888
|
+
) : (
|
|
1889
|
+
// Only when this request actually carried the caller's API key: the
|
|
1890
|
+
// public path sends no Authorization header, so a 401 from that
|
|
1891
|
+
// host (WAF, misconfigured RUNPOD_PUBLIC_GRAPHQL_URL) says nothing
|
|
1892
|
+
// about the credential — same rationale as observeUnauthorized.
|
|
1893
|
+
response.status === 401 && options?.apiKey ? EXPIRED_CREDENTIAL_HINT : void 0
|
|
1894
|
+
)
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1098
1897
|
const result = await response.json();
|
|
1099
1898
|
if (result.errors && result.errors.length > 0) {
|
|
1100
1899
|
throw new Error(
|
|
@@ -1119,39 +1918,53 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
1119
1918
|
}
|
|
1120
1919
|
const rawFetch = deps.fetch ?? defaultFetch;
|
|
1121
1920
|
const httpFetch = observeUnauthorized(rawFetch);
|
|
1921
|
+
const remainingHttpBudgetMs = invocationBudget(HTTP_TRANSPORT_BUDGET_MS);
|
|
1922
|
+
const timeouts = {
|
|
1923
|
+
defaultTimeoutMs: deps.defaultTimeoutMs,
|
|
1924
|
+
maxTimeoutMs: deps.maxTimeoutMs ?? (ctx.transport === "http" ? remainingHttpBudgetMs : void 0)
|
|
1925
|
+
};
|
|
1122
1926
|
const v1Client = createHttpClient({
|
|
1123
1927
|
apiKey: ctx.apiKey,
|
|
1124
1928
|
fetch: httpFetch,
|
|
1125
1929
|
tracking,
|
|
1126
|
-
errorPrefix: "Runpod API Error"
|
|
1930
|
+
errorPrefix: "Runpod API Error",
|
|
1931
|
+
...timeouts
|
|
1127
1932
|
});
|
|
1128
|
-
const runpodRequest = (endpoint, method = "GET", body) => withApiErrorLog(
|
|
1933
|
+
const runpodRequest = (endpoint, method = "GET", body, options) => withApiErrorLog(
|
|
1129
1934
|
"Error calling Runpod API:",
|
|
1130
|
-
() => v1Client(
|
|
1935
|
+
() => v1Client(
|
|
1936
|
+
`${restV1Base(process.env)}${endpoint}`,
|
|
1937
|
+
method,
|
|
1938
|
+
body,
|
|
1939
|
+
options
|
|
1940
|
+
)
|
|
1131
1941
|
);
|
|
1132
1942
|
const serverlessClient = createHttpClient({
|
|
1133
1943
|
apiKey: ctx.apiKey,
|
|
1134
1944
|
fetch: httpFetch,
|
|
1135
1945
|
tracking,
|
|
1136
|
-
errorPrefix: "Runpod Serverless API Error"
|
|
1946
|
+
errorPrefix: "Runpod Serverless API Error",
|
|
1947
|
+
...timeouts
|
|
1137
1948
|
});
|
|
1138
|
-
const serverlessRequest = (endpointId, path2, method = "GET", body) => withApiErrorLog(
|
|
1949
|
+
const serverlessRequest = (endpointId, path2, method = "GET", body, options) => withApiErrorLog(
|
|
1139
1950
|
"Error calling Runpod Serverless API:",
|
|
1140
1951
|
() => serverlessClient(
|
|
1141
1952
|
`${serverlessBase(process.env)}/${endpointId}${path2}`,
|
|
1142
1953
|
method,
|
|
1143
|
-
body
|
|
1954
|
+
body,
|
|
1955
|
+
options
|
|
1144
1956
|
)
|
|
1145
1957
|
);
|
|
1146
1958
|
const restClient = createHttpClient({
|
|
1147
1959
|
apiKey: ctx.apiKey,
|
|
1148
1960
|
fetch: httpFetch,
|
|
1149
1961
|
tracking,
|
|
1150
|
-
errorPrefix: "Runpod API Error"
|
|
1962
|
+
errorPrefix: "Runpod API Error",
|
|
1963
|
+
...timeouts
|
|
1151
1964
|
});
|
|
1152
|
-
const callRestUrl = (url, method = "GET", body) => withApiErrorLog(
|
|
1965
|
+
const callRestUrl = (url, method = "GET", body, options) => withApiErrorLog(
|
|
1153
1966
|
"Error calling Runpod API:",
|
|
1154
|
-
() => restClient(url, method, body)
|
|
1967
|
+
() => restClient(url, method, body, options)
|
|
1155
1968
|
);
|
|
1156
1969
|
const sseFetch = observeUnauthorized(
|
|
1157
1970
|
deps.sseFetch ?? import_node_fetch.default
|
|
@@ -1184,12 +1997,18 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
1184
1997
|
};
|
|
1185
1998
|
return {
|
|
1186
1999
|
jsonReply,
|
|
2000
|
+
// No GraphQL caller overrides a deadline, but both are still capped by the
|
|
2001
|
+
// shared budget: deploy-hub-repo spends one of each back to back.
|
|
1187
2002
|
graphql: (query) => graphqlRequest(
|
|
1188
2003
|
query,
|
|
1189
2004
|
tracking,
|
|
1190
2005
|
// rawFetch, not httpFetch: this call carries no credential (see above).
|
|
1191
2006
|
rawFetch,
|
|
1192
|
-
publicGraphqlBase(process.env)
|
|
2007
|
+
publicGraphqlBase(process.env),
|
|
2008
|
+
{
|
|
2009
|
+
timeoutMs: deps.defaultTimeoutMs,
|
|
2010
|
+
maxTimeoutMs: timeouts.maxTimeoutMs
|
|
2011
|
+
}
|
|
1193
2012
|
),
|
|
1194
2013
|
graphqlAuthed: (query, variables) => graphqlRequest(
|
|
1195
2014
|
query,
|
|
@@ -1197,7 +2016,12 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
1197
2016
|
httpFetch,
|
|
1198
2017
|
// NOT publicGraphqlBase: this call carries the caller's API key.
|
|
1199
2018
|
authedGraphqlBase(process.env),
|
|
1200
|
-
{
|
|
2019
|
+
{
|
|
2020
|
+
variables,
|
|
2021
|
+
apiKey: ctx.apiKey,
|
|
2022
|
+
timeoutMs: deps.defaultTimeoutMs,
|
|
2023
|
+
maxTimeoutMs: timeouts.maxTimeoutMs
|
|
2024
|
+
}
|
|
1201
2025
|
),
|
|
1202
2026
|
runpodRequest,
|
|
1203
2027
|
serverlessRequest,
|
|
@@ -1205,7 +2029,8 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
1205
2029
|
backendFor,
|
|
1206
2030
|
streamSse,
|
|
1207
2031
|
podAction,
|
|
1208
|
-
env: process.env
|
|
2032
|
+
env: process.env,
|
|
2033
|
+
transport: ctx.transport
|
|
1209
2034
|
};
|
|
1210
2035
|
}
|
|
1211
2036
|
|
|
@@ -1274,6 +2099,7 @@ function capListResult(result, options, extra) {
|
|
|
1274
2099
|
}
|
|
1275
2100
|
|
|
1276
2101
|
// src/tools/catalog.ts
|
|
2102
|
+
var GPU_PRODUCTS = /* @__PURE__ */ new Set(["POD", "CLUSTER", "SERVERLESS"]);
|
|
1277
2103
|
function registerCatalogTools(server, rt) {
|
|
1278
2104
|
const { jsonReply: jsonReply2, graphql, callRestUrl, backendFor } = rt;
|
|
1279
2105
|
server.tool(
|
|
@@ -1292,6 +2118,9 @@ function registerCatalogTools(server, rt) {
|
|
|
1292
2118
|
),
|
|
1293
2119
|
includeAvailability: import_zod3.z.boolean().optional().describe(
|
|
1294
2120
|
"Request realtime stock and annotate each GPU with an availability summary (HIGH/MEDIUM/LOW/NONE). Default true. Set false to skip the availability lookup \u2014 then out-of-stock GPUs cannot be filtered."
|
|
2121
|
+
),
|
|
2122
|
+
product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
|
|
2123
|
+
"Product context for the availability lookup \u2014 the same GPU can be scarce for Pods and plentiful for Serverless. Default POD. Use SERVERLESS when picking a GPU for an endpoint, CLUSTER for Instant Clusters. Ignored when includeAvailability is false."
|
|
1295
2124
|
)
|
|
1296
2125
|
},
|
|
1297
2126
|
{ title: "List GPU types", ...READ_ONLY },
|
|
@@ -1299,8 +2128,9 @@ function registerCatalogTools(server, rt) {
|
|
|
1299
2128
|
const backend = backendFor("gpus");
|
|
1300
2129
|
if (backend.version === "v2") {
|
|
1301
2130
|
const wantAvailability = params.includeAvailability !== false;
|
|
2131
|
+
const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
|
|
1302
2132
|
const raw = await callRestUrl(
|
|
1303
|
-
`${backend.base}${backend.list}${wantAvailability ?
|
|
2133
|
+
`${backend.base}${backend.list}${wantAvailability ? `?include=AVAILABILITY&product=${product}` : ""}`
|
|
1304
2134
|
);
|
|
1305
2135
|
let gpus = backend.unwrap(raw);
|
|
1306
2136
|
gpus = gpus.filter((g) => g.id !== "unknown");
|
|
@@ -1473,6 +2303,9 @@ function registerCatalogTools(server, rt) {
|
|
|
1473
2303
|
gpuTypeId: import_zod3.z.string().describe("ID of the GPU type to retrieve"),
|
|
1474
2304
|
includeAvailability: import_zod3.z.boolean().optional().describe(
|
|
1475
2305
|
"Include realtime per-datacenter availability (HIGH/MEDIUM/LOW/NONE). Default true."
|
|
2306
|
+
),
|
|
2307
|
+
product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
|
|
2308
|
+
"Product context for the availability lookup \u2014 stock differs by product. Default POD. Use SERVERLESS when picking a GPU for an endpoint, CLUSTER for Instant Clusters. Ignored when includeAvailability is false."
|
|
1476
2309
|
)
|
|
1477
2310
|
},
|
|
1478
2311
|
{ title: "Get GPU type", ...READ_ONLY },
|
|
@@ -1485,7 +2318,8 @@ function registerCatalogTools(server, rt) {
|
|
|
1485
2318
|
});
|
|
1486
2319
|
}
|
|
1487
2320
|
const path2 = backend.get(encodeURIComponent(params.gpuTypeId));
|
|
1488
|
-
const
|
|
2321
|
+
const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
|
|
2322
|
+
const query = params.includeAvailability === false ? "" : `?include=AVAILABILITY&product=${product}`;
|
|
1489
2323
|
const result = await callRestUrl(`${backend.base}${path2}${query}`);
|
|
1490
2324
|
return jsonReply2(result);
|
|
1491
2325
|
}
|
|
@@ -1511,6 +2345,178 @@ function registerCatalogTools(server, rt) {
|
|
|
1511
2345
|
return jsonReply2(result);
|
|
1512
2346
|
}
|
|
1513
2347
|
);
|
|
2348
|
+
server.tool(
|
|
2349
|
+
"get-capacity",
|
|
2350
|
+
"GPU capacity across host CUDA versions, as a matrix. Use this to choose an endpoint's allowedCudaVersions/minCudaVersion (or diagnose/widen a capacity-starved one) and to distinguish capacity problems from compatibility problems. Default mode is one call returning, per GPU type, overall stock plus AVAILABLE/UNAVAILABLE per host-reported CUDA version. Pass cudaVersions to deep-probe instead: one stock lookup per listed version, returning graded stock (High/Medium/Low/Out) and the lowest on-demand price per version; a probe that fails transiently reports a per-version error instead of failing the call. Nothing is hidden by default \u2014 set includeUnavailable:false to drop GPUs with no stock on any listed version. Credential-free public catalog data; works on both v1 and v2 APIs. Stock is live, so page cursors can shift between calls. Note: the matrix reflects host-reported versions, but endpoint allowedCudaVersions only accepts values from the platform enum \u2014 check create-endpoint/update-endpoint for the accepted list.",
|
|
2351
|
+
{
|
|
2352
|
+
...listPaginationParams,
|
|
2353
|
+
cudaVersions: import_zod3.z.array(import_zod3.z.string().regex(/^\d{1,2}\.\d{1,2}$/)).min(1).max(12).optional().describe(
|
|
2354
|
+
'Deep-probe these host CUDA versions (e.g. ["12.8", "13.0"], max 12): one stock lookup per version returns graded stock and price. Omit for the single-call AVAILABLE/UNAVAILABLE matrix across all versions the fleet currently reports.'
|
|
2355
|
+
),
|
|
2356
|
+
includeUnavailable: import_zod3.z.boolean().optional().describe(
|
|
2357
|
+
'Out-of-stock GPUs are included by default (explicit "Out" cells / all-UNAVAILABLE rows, sorted last). Set false to hide GPUs with no stock on any listed version.'
|
|
2358
|
+
),
|
|
2359
|
+
gpuTypeIds: import_zod3.z.array(import_zod3.z.string()).optional().describe(
|
|
2360
|
+
"Filter to GPU types matching any of these ids or display names (case-insensitive substring, e.g. ['NVIDIA GeForce RTX 4090'] or ['4090', 'H200'])."
|
|
2361
|
+
),
|
|
2362
|
+
gpuCount: import_zod3.z.number().int().min(1).max(8).optional().describe("GPUs per worker/pod to check stock for (default 1)."),
|
|
2363
|
+
secureCloudOnly: import_zod3.z.boolean().optional().describe("Restrict the stock lookup to Secure Cloud hosts.")
|
|
2364
|
+
},
|
|
2365
|
+
{ title: "Get GPU capacity by CUDA version", ...READ_ONLY },
|
|
2366
|
+
async (params) => {
|
|
2367
|
+
const gpuCount = Math.min(
|
|
2368
|
+
8,
|
|
2369
|
+
Math.max(1, Math.floor(Number(params.gpuCount)) || 1)
|
|
2370
|
+
);
|
|
2371
|
+
const secureArg = params.secureCloudOnly ? ", secureCloud: true" : "";
|
|
2372
|
+
const filterTerms = (params.gpuTypeIds ?? []).filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.toLowerCase());
|
|
2373
|
+
const matchesFilter = (gpu) => {
|
|
2374
|
+
if (filterTerms.length === 0) return true;
|
|
2375
|
+
const id = gpu.id.toLowerCase();
|
|
2376
|
+
const name = gpu.displayName.toLowerCase();
|
|
2377
|
+
return filterTerms.some(
|
|
2378
|
+
(term) => id.includes(term) || name.includes(term)
|
|
2379
|
+
);
|
|
2380
|
+
};
|
|
2381
|
+
const stockPriority = {
|
|
2382
|
+
High: 3,
|
|
2383
|
+
Medium: 2,
|
|
2384
|
+
Low: 1
|
|
2385
|
+
};
|
|
2386
|
+
if (!params.cudaVersions?.length) {
|
|
2387
|
+
const data = await graphql(`
|
|
2388
|
+
query {
|
|
2389
|
+
gpuTypes {
|
|
2390
|
+
id
|
|
2391
|
+
displayName
|
|
2392
|
+
memoryInGb
|
|
2393
|
+
secureCloud
|
|
2394
|
+
communityCloud
|
|
2395
|
+
lowestPrice(input: { gpuCount: ${gpuCount}${secureArg} }) {
|
|
2396
|
+
stockStatus
|
|
2397
|
+
uninterruptablePrice
|
|
2398
|
+
gpuTypeCudaVersions {
|
|
2399
|
+
cudaVersion
|
|
2400
|
+
availability
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
`);
|
|
2406
|
+
let rows2 = data.gpuTypes.filter((gpu) => gpu.id !== "unknown" && matchesFilter(gpu)).map((gpu) => {
|
|
2407
|
+
const cuda = {};
|
|
2408
|
+
for (const c of gpu.lowestPrice?.gpuTypeCudaVersions ?? []) {
|
|
2409
|
+
cuda[c.cudaVersion] = c.availability;
|
|
2410
|
+
}
|
|
2411
|
+
return {
|
|
2412
|
+
id: gpu.id,
|
|
2413
|
+
displayName: gpu.displayName,
|
|
2414
|
+
memoryGb: gpu.memoryInGb,
|
|
2415
|
+
secureCloud: gpu.secureCloud,
|
|
2416
|
+
communityCloud: gpu.communityCloud,
|
|
2417
|
+
stockStatus: gpu.lowestPrice?.stockStatus || "unavailable",
|
|
2418
|
+
pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null,
|
|
2419
|
+
cudaVersions: cuda
|
|
2420
|
+
};
|
|
2421
|
+
});
|
|
2422
|
+
const availableCount = (r) => Object.values(r.cudaVersions).filter((a) => a === "AVAILABLE").length;
|
|
2423
|
+
if (params.includeUnavailable === false)
|
|
2424
|
+
rows2 = rows2.filter((r) => availableCount(r) > 0);
|
|
2425
|
+
rows2.sort((a, b) => {
|
|
2426
|
+
const diff = availableCount(b) - availableCount(a);
|
|
2427
|
+
if (diff !== 0) return diff;
|
|
2428
|
+
const stockDiff = (stockPriority[b.stockStatus] || 0) - (stockPriority[a.stockStatus] || 0);
|
|
2429
|
+
if (stockDiff !== 0) return stockDiff;
|
|
2430
|
+
return b.memoryGb - a.memoryGb;
|
|
2431
|
+
});
|
|
2432
|
+
return capListResult(rows2, {
|
|
2433
|
+
limit: params.limit,
|
|
2434
|
+
cursor: params.cursor
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
const versions = [...new Set(params.cudaVersions)].slice(0, 12);
|
|
2438
|
+
const invalid = versions.filter((v) => !/^\d{1,2}\.\d{1,2}$/.test(v));
|
|
2439
|
+
if (invalid.length > 0) {
|
|
2440
|
+
return jsonReply2({
|
|
2441
|
+
error: `Invalid CUDA version format: ${invalid.join(", ")}. Use "major.minor" strings like "12.8".`,
|
|
2442
|
+
status: 400
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
const perVersion = await Promise.allSettled(
|
|
2446
|
+
versions.map(
|
|
2447
|
+
(v) => graphql(`
|
|
2448
|
+
query {
|
|
2449
|
+
gpuTypes {
|
|
2450
|
+
id
|
|
2451
|
+
displayName
|
|
2452
|
+
memoryInGb
|
|
2453
|
+
secureCloud
|
|
2454
|
+
communityCloud
|
|
2455
|
+
lowestPrice(input: { gpuCount: ${gpuCount}, allowedCudaVersions: ["${v}"]${secureArg} }) {
|
|
2456
|
+
stockStatus
|
|
2457
|
+
uninterruptablePrice
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
`)
|
|
2462
|
+
)
|
|
2463
|
+
);
|
|
2464
|
+
const probeErrors = {};
|
|
2465
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2466
|
+
versions.forEach((v, i) => {
|
|
2467
|
+
const settled = perVersion[i];
|
|
2468
|
+
if (settled.status === "rejected") {
|
|
2469
|
+
probeErrors[v] = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
for (const gpu of settled.value.gpuTypes) {
|
|
2473
|
+
if (gpu.id === "unknown" || !matchesFilter(gpu)) continue;
|
|
2474
|
+
let row = byId.get(gpu.id);
|
|
2475
|
+
if (!row) {
|
|
2476
|
+
row = {
|
|
2477
|
+
id: gpu.id,
|
|
2478
|
+
displayName: gpu.displayName,
|
|
2479
|
+
memoryGb: gpu.memoryInGb,
|
|
2480
|
+
secureCloud: gpu.secureCloud,
|
|
2481
|
+
communityCloud: gpu.communityCloud,
|
|
2482
|
+
cudaVersions: {}
|
|
2483
|
+
};
|
|
2484
|
+
byId.set(gpu.id, row);
|
|
2485
|
+
}
|
|
2486
|
+
const stock = gpu.lowestPrice?.stockStatus;
|
|
2487
|
+
row.cudaVersions[v] = !stock || stock === "Out" ? { stock: "Out", pricePerHr: null } : {
|
|
2488
|
+
stock,
|
|
2489
|
+
pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
});
|
|
2493
|
+
let rows = [...byId.values()];
|
|
2494
|
+
const inStockCount = (r) => Object.values(r.cudaVersions).filter((c) => c.stock !== "Out").length;
|
|
2495
|
+
if (params.includeUnavailable === false)
|
|
2496
|
+
rows = rows.filter((r) => inStockCount(r) > 0);
|
|
2497
|
+
rows.sort((a, b) => {
|
|
2498
|
+
const diff = inStockCount(b) - inStockCount(a);
|
|
2499
|
+
if (diff !== 0) return diff;
|
|
2500
|
+
const best = (r) => Math.max(
|
|
2501
|
+
0,
|
|
2502
|
+
...Object.values(r.cudaVersions).map(
|
|
2503
|
+
(c) => stockPriority[c.stock] || 0
|
|
2504
|
+
)
|
|
2505
|
+
);
|
|
2506
|
+
const bestDiff = best(b) - best(a);
|
|
2507
|
+
if (bestDiff !== 0) return bestDiff;
|
|
2508
|
+
return b.memoryGb - a.memoryGb;
|
|
2509
|
+
});
|
|
2510
|
+
return capListResult(
|
|
2511
|
+
rows,
|
|
2512
|
+
{ limit: params.limit, cursor: params.cursor },
|
|
2513
|
+
{
|
|
2514
|
+
probedCudaVersions: versions,
|
|
2515
|
+
...Object.keys(probeErrors).length > 0 ? { probeErrors } : {}
|
|
2516
|
+
}
|
|
2517
|
+
);
|
|
2518
|
+
}
|
|
2519
|
+
);
|
|
1514
2520
|
server.tool(
|
|
1515
2521
|
"get-data-center",
|
|
1516
2522
|
"Get details for a single data center by id. v2-only \u2014 returns a 501 notice on the v1 API (use list-data-centers there).",
|
|
@@ -2193,9 +3199,11 @@ function registerPodTools(server, rt) {
|
|
|
2193
3199
|
);
|
|
2194
3200
|
server.tool(
|
|
2195
3201
|
"create-pod",
|
|
2196
|
-
'Create a new GPU/CPU pod on Runpod. Pass gpuTypeIds for a GPU pod, or computeType:"CPU" for a CPU pod (CPU pods are served by the v1 API for now). Pass either imageName or templateId (templateId deploys from an existing template \u2014 its name, image, start command, ports, env, disk, volume, and registry credential are used as defaults, and any field you also pass explicitly replaces the template value for that field, e.g. passing env replaces the whole env set rather than merging; pass containerRegistryAuthId to override or supply a private-image credential, or an empty string to deploy with none). If the user specifies neither an image nor a template, recommend the "Runpod Pytorch 2.8.0" image (runpod/pytorch:1.0.2-cu1281-torch280-ubuntu2404) as the default \u2014 it has the most up-to-date CUDA and PyTorch versions.',
|
|
3202
|
+
'Create a new GPU/CPU pod on Runpod. Pass gpuTypeIds for a GPU pod, or computeType:"CPU" for a CPU pod (CPU pods are served by the v1 API for now). Pass either imageName or templateId (templateId deploys from an existing template \u2014 its name, image, start command, ports, env, disk, volume, and registry credential are used as defaults, and any field you also pass explicitly replaces the template value for that field, e.g. passing env replaces the whole env set rather than merging; pass containerRegistryAuthId to override or supply a private-image credential, or an empty string to deploy with none). For full SSH access (including SCP/SFTP/rsync), pass sshPublicKey \u2014 it sets the PUBLIC_KEY env var and exposes 22/tcp, which official Runpod images turn into a running sshd with the key authorized. If the user specifies neither an image nor a template, recommend the "Runpod Pytorch 2.8.0" image (runpod/pytorch:1.0.2-cu1281-torch280-ubuntu2404) as the default \u2014 it has the most up-to-date CUDA and PyTorch versions.',
|
|
2197
3203
|
{
|
|
2198
|
-
name: import_zod7.z.string().optional().describe(
|
|
3204
|
+
name: import_zod7.z.string().optional().describe(
|
|
3205
|
+
"Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
|
|
3206
|
+
),
|
|
2199
3207
|
imageName: import_zod7.z.string().optional().describe("Docker image to use. Optional when templateId is provided."),
|
|
2200
3208
|
templateId: import_zod7.z.string().optional().describe(
|
|
2201
3209
|
"Deploy from an existing template (see list-templates / get-template). The template supplies name, image, start command, ports, env, disk, volume, and registry credential as defaults; any field you also pass replaces the template value for that field (whole-field, not a merge). Override the registry credential with containerRegistryAuthId. Requires the v2 REST API. Still pass gpuTypeIds or computeType to choose the compute \u2014 a template does not pin the pod compute."
|
|
@@ -2205,12 +3213,18 @@ function registerPodTools(server, rt) {
|
|
|
2205
3213
|
),
|
|
2206
3214
|
cloudType: import_zod7.z.enum(["SECURE", "COMMUNITY"]).optional().describe("SECURE or COMMUNITY cloud"),
|
|
2207
3215
|
gpuTypeIds: import_zod7.z.array(import_zod7.z.string()).optional().describe("List of acceptable GPU types"),
|
|
2208
|
-
|
|
3216
|
+
// v2 GpuConfig.count is `integer, minimum: 1`. Without these bounds a 0,
|
|
3217
|
+
// a negative, or a fractional count reached the wire as-is and came back
|
|
3218
|
+
// as a raw 422; reject it here with a message that names the field.
|
|
3219
|
+
gpuCount: import_zod7.z.number().int().min(1).optional().describe("Number of GPUs (minimum 1)"),
|
|
2209
3220
|
containerDiskInGb: import_zod7.z.number().optional().describe("Container disk size in GB"),
|
|
2210
3221
|
volumeInGb: import_zod7.z.number().optional().describe("Volume size in GB"),
|
|
2211
3222
|
volumeMountPath: import_zod7.z.string().optional().describe("Path to mount the volume"),
|
|
2212
3223
|
ports: import_zod7.z.array(import_zod7.z.string()).optional().describe("Ports to expose (e.g., '8888/http', '22/tcp')"),
|
|
2213
3224
|
env: import_zod7.z.record(import_zod7.z.string()).optional().describe("Environment variables"),
|
|
3225
|
+
sshPublicKey: import_zod7.z.string().optional().describe(
|
|
3226
|
+
'SSH PUBLIC key(s) to authorize on the pod \u2014 the CONTENTS of your ~/.ssh/<key>.pub file, i.e. a literal "ssh-ed25519 AAAAC3Nza\u2026 you@host" line, NOT a path to the file, a fingerprint, or the private key; newline-separate multiple keys. Merges the key into the PUBLIC_KEY env var and ensures 22/tcp is exposed, enabling full SSH (SCP/SFTP/rsync) on any image that honors the PUBLIC_KEY convention \u2014 all runpod/* official images do. Extends template ports/env rather than replacing them.'
|
|
3227
|
+
),
|
|
2214
3228
|
containerRegistryAuthId: import_zod7.z.string().optional().describe(
|
|
2215
3229
|
"Container registry credential ID for pulling a private image (see list-container-registry-auths). When deploying from a templateId, this overrides the credential the template carries; pass an empty string to deploy with NO credential (clears a template\u2019s inherited registry)."
|
|
2216
3230
|
),
|
|
@@ -2219,6 +3233,15 @@ function registerPodTools(server, rt) {
|
|
|
2219
3233
|
{ title: "Create pod", ...WRITE },
|
|
2220
3234
|
async (params) => {
|
|
2221
3235
|
const backend = backendFor("pods");
|
|
3236
|
+
const { sshPublicKey, ...podParams } = params;
|
|
3237
|
+
let sshKey;
|
|
3238
|
+
if (sshPublicKey !== void 0) {
|
|
3239
|
+
const validated = normalizeSshPublicKey(sshPublicKey);
|
|
3240
|
+
if (!validated.ok)
|
|
3241
|
+
return jsonReply2({ error: validated.error, status: 400 });
|
|
3242
|
+
sshKey = validated.key;
|
|
3243
|
+
}
|
|
3244
|
+
const sshNote = "sshPublicKey applied: PUBLIC_KEY env set and 22/tcp exposed. Full SSH (incl. SCP/SFTP/rsync) also requires an image that installs PUBLIC_KEY into authorized_keys and starts sshd (all runpod/* official images do) and a machine with a public IP \u2014 read the pod\u2019s portMappings for port 22 once it is running.";
|
|
2222
3245
|
if (params.templateId && backend.version !== "v2") {
|
|
2223
3246
|
return jsonReply2({
|
|
2224
3247
|
error: "Deploying a pod from a templateId is only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2, or pass imageName instead.",
|
|
@@ -2254,6 +3277,12 @@ function registerPodTools(server, rt) {
|
|
|
2254
3277
|
status: 400
|
|
2255
3278
|
});
|
|
2256
3279
|
}
|
|
3280
|
+
if (!wantsCpu && !params.name && !params.templateId) {
|
|
3281
|
+
return jsonReply2({
|
|
3282
|
+
error: "Provide a name for the pod (deploying from a templateId inherits the template name).",
|
|
3283
|
+
status: 400
|
|
3284
|
+
});
|
|
3285
|
+
}
|
|
2257
3286
|
if (wantsCpu) {
|
|
2258
3287
|
if (params.templateId) {
|
|
2259
3288
|
return jsonReply2({
|
|
@@ -2262,13 +3291,15 @@ function registerPodTools(server, rt) {
|
|
|
2262
3291
|
});
|
|
2263
3292
|
}
|
|
2264
3293
|
try {
|
|
3294
|
+
const cpuBody = sshKey ? applySshPublicKey(podParams, sshKey) : podParams;
|
|
2265
3295
|
const result = await callRestUrl(
|
|
2266
3296
|
`${restV1Base(env)}/pods`,
|
|
2267
3297
|
"POST",
|
|
2268
|
-
|
|
3298
|
+
cpuBody
|
|
2269
3299
|
);
|
|
2270
3300
|
return jsonReply2({
|
|
2271
3301
|
...result,
|
|
3302
|
+
...sshKey ? { _ssh: sshNote } : {},
|
|
2272
3303
|
_servedBy: "v1",
|
|
2273
3304
|
_note: "CPU pod: created on the v1 API, because the v2 REST API does not support CPU pods yet."
|
|
2274
3305
|
});
|
|
@@ -2285,7 +3316,7 @@ function registerPodTools(server, rt) {
|
|
|
2285
3316
|
}
|
|
2286
3317
|
}
|
|
2287
3318
|
}
|
|
2288
|
-
let body = backend.mapCreate(
|
|
3319
|
+
let body = backend.mapCreate(podParams);
|
|
2289
3320
|
if (params.templateId) {
|
|
2290
3321
|
let template;
|
|
2291
3322
|
try {
|
|
@@ -2303,19 +3334,21 @@ function registerPodTools(server, rt) {
|
|
|
2303
3334
|
}
|
|
2304
3335
|
body = { ...podBodyFromTemplate(template), ...body };
|
|
2305
3336
|
}
|
|
3337
|
+
if (sshKey) body = applySshPublicKey(body, sshKey);
|
|
2306
3338
|
try {
|
|
2307
3339
|
const result = await callRestUrl(
|
|
2308
3340
|
`${backend.base}${backend.list}`,
|
|
2309
3341
|
"POST",
|
|
2310
3342
|
body
|
|
2311
3343
|
);
|
|
3344
|
+
const extras = {};
|
|
3345
|
+
if (sshKey) extras._ssh = sshNote;
|
|
2312
3346
|
if (backend.version === "v2" && (params.gpuTypeIds?.length ?? 0) > 1) {
|
|
2313
|
-
|
|
2314
|
-
...result,
|
|
2315
|
-
_warning: `v2 accepts one GPU type per pod; used "${params.gpuTypeIds[0]}" and ignored ${params.gpuTypeIds.length - 1} other(s): ${params.gpuTypeIds.slice(1).join(", ")}.`
|
|
2316
|
-
});
|
|
3347
|
+
extras._warning = `v2 accepts one GPU type per pod; used "${params.gpuTypeIds[0]}" and ignored ${params.gpuTypeIds.length - 1} other(s): ${params.gpuTypeIds.slice(1).join(", ")}.`;
|
|
2317
3348
|
}
|
|
2318
|
-
return jsonReply2(
|
|
3349
|
+
return jsonReply2(
|
|
3350
|
+
Object.keys(extras).length ? { ...result, ...extras } : result
|
|
3351
|
+
);
|
|
2319
3352
|
} catch (error) {
|
|
2320
3353
|
if (error instanceof HttpError) {
|
|
2321
3354
|
return jsonReply2({ error: error.message, status: error.status });
|
|
@@ -2776,8 +3809,108 @@ var import_zod9 = require("zod");
|
|
|
2776
3809
|
var DIAGNOSIS_TTL_MS = 15e3;
|
|
2777
3810
|
var MAX_DIAGNOSIS_ENTRIES = 500;
|
|
2778
3811
|
var diagnosisCache = /* @__PURE__ */ new Map();
|
|
3812
|
+
var HTTP_LONG_POLL_BUDGET_MS = 45e3;
|
|
3813
|
+
var STDIO_STREAM_BUDGET_MS = 5 * 60 * 1e3;
|
|
3814
|
+
var RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS = 9e4;
|
|
3815
|
+
var STREAM_UPSTREAM_DEFAULT_WAIT_MS = 1e4;
|
|
3816
|
+
var HTTP_STREAM_POLL_WAIT_MS = 1e3;
|
|
3817
|
+
var STREAM_JOB_POLL_INTERVAL_MS = 1e3;
|
|
3818
|
+
var MAX_CONSECUTIVE_STREAM_ERRORS = 5;
|
|
3819
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
3820
|
+
"COMPLETED",
|
|
3821
|
+
"FAILED",
|
|
3822
|
+
"CANCELLED",
|
|
3823
|
+
"TIMED_OUT"
|
|
3824
|
+
]);
|
|
3825
|
+
function isUnfinishedJobReply(result) {
|
|
3826
|
+
return result !== null && typeof result === "object" && !Array.isArray(result) && !("waitClamped" in result) && !TERMINAL_STATUSES.has(result.status ?? "");
|
|
3827
|
+
}
|
|
3828
|
+
function formatBudget(ms) {
|
|
3829
|
+
return ms >= 12e4 ? `${Math.round(ms / 6e4)} minutes` : `${Math.round(ms / 1e3)} seconds`;
|
|
3830
|
+
}
|
|
3831
|
+
var UPSTREAM_HOLD_SLACK_MS = 5e3;
|
|
3832
|
+
var MIN_STREAM_POLL_TIMEOUT_MS = 2e3;
|
|
3833
|
+
var QUEUED_DIAGNOSIS_TIMEOUT_MS = 5e3;
|
|
3834
|
+
function streamPollTimeoutMs(remainingMs, holdMs) {
|
|
3835
|
+
return Math.max(
|
|
3836
|
+
Math.min(remainingMs, holdMs + UPSTREAM_HOLD_SLACK_MS),
|
|
3837
|
+
MIN_STREAM_POLL_TIMEOUT_MS
|
|
3838
|
+
);
|
|
3839
|
+
}
|
|
3840
|
+
function sleep(ms) {
|
|
3841
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
3842
|
+
}
|
|
3843
|
+
function streamPollPlan(hosted) {
|
|
3844
|
+
return hosted ? {
|
|
3845
|
+
budgetMs: HTTP_LONG_POLL_BUDGET_MS,
|
|
3846
|
+
holdMs: HTTP_STREAM_POLL_WAIT_MS,
|
|
3847
|
+
query: `?wait=${HTTP_STREAM_POLL_WAIT_MS}`
|
|
3848
|
+
} : {
|
|
3849
|
+
budgetMs: STDIO_STREAM_BUDGET_MS,
|
|
3850
|
+
holdMs: STREAM_UPSTREAM_DEFAULT_WAIT_MS,
|
|
3851
|
+
query: ""
|
|
3852
|
+
};
|
|
3853
|
+
}
|
|
3854
|
+
var RESUME_ADVICE = "Call stream-job again to continue collecting output, get-job-status to check the job without streaming, or stream without a budget by calling the runtime API directly (GET https://api.runpod.ai/v2/{endpointId}/stream/{jobId} with a Bearer API key).";
|
|
3855
|
+
function budgetExhaustedNote(budgetMs, lastError) {
|
|
3856
|
+
return {
|
|
3857
|
+
pollingTimedOut: true,
|
|
3858
|
+
note: `Polling stopped after ${formatBudget(
|
|
3859
|
+
budgetMs
|
|
3860
|
+
)} with the job possibly still running. ${RESUME_ADVICE}`,
|
|
3861
|
+
// Surface the trailing error (if any) instead of discarding it — the last
|
|
3862
|
+
// polls may have been failing (e.g. job expired) even though earlier ones
|
|
3863
|
+
// succeeded. Cleared on success, so this is never a stale error from
|
|
3864
|
+
// minutes of healthy streaming ago.
|
|
3865
|
+
...lastError ? { lastError } : {}
|
|
3866
|
+
};
|
|
3867
|
+
}
|
|
3868
|
+
function errorCapNote(lastError) {
|
|
3869
|
+
return {
|
|
3870
|
+
error: `Polling aborted after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors: ${lastError}`,
|
|
3871
|
+
note: `Polling stopped after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors with the job possibly still running. ${RESUME_ADVICE}`
|
|
3872
|
+
};
|
|
3873
|
+
}
|
|
3874
|
+
async function collectJobStream(deps) {
|
|
3875
|
+
const { poll, budgetMs, holdMs } = deps;
|
|
3876
|
+
const startedAt = Date.now();
|
|
3877
|
+
const elapsed = () => Date.now() - startedAt;
|
|
3878
|
+
const chunks = [];
|
|
3879
|
+
let result = {};
|
|
3880
|
+
let consecutiveErrors = 0;
|
|
3881
|
+
let lastError;
|
|
3882
|
+
while (true) {
|
|
3883
|
+
try {
|
|
3884
|
+
const reply = await poll(
|
|
3885
|
+
streamPollTimeoutMs(budgetMs - elapsed(), holdMs)
|
|
3886
|
+
);
|
|
3887
|
+
consecutiveErrors = 0;
|
|
3888
|
+
lastError = void 0;
|
|
3889
|
+
if (Array.isArray(reply.stream)) chunks.push(...reply.stream);
|
|
3890
|
+
result = reply;
|
|
3891
|
+
if (TERMINAL_STATUSES.has(reply.status)) break;
|
|
3892
|
+
} catch (error) {
|
|
3893
|
+
consecutiveErrors++;
|
|
3894
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
3895
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_STREAM_ERRORS) {
|
|
3896
|
+
result = { ...result, ...errorCapNote(lastError) };
|
|
3897
|
+
break;
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
if (elapsed() > budgetMs) {
|
|
3901
|
+
result = { ...result, ...budgetExhaustedNote(budgetMs, lastError) };
|
|
3902
|
+
break;
|
|
3903
|
+
}
|
|
3904
|
+
await sleep(STREAM_JOB_POLL_INTERVAL_MS);
|
|
3905
|
+
}
|
|
3906
|
+
return { result, chunks };
|
|
3907
|
+
}
|
|
2779
3908
|
function registerJobTools(server, rt) {
|
|
2780
3909
|
const { jsonReply: jsonReply2, serverlessRequest, backendFor, callRestUrl } = rt;
|
|
3910
|
+
const hosted = rt.transport === "http";
|
|
3911
|
+
const streamBudget = formatBudget(
|
|
3912
|
+
hosted ? HTTP_LONG_POLL_BUDGET_MS : STDIO_STREAM_BUDGET_MS
|
|
3913
|
+
);
|
|
2781
3914
|
async function diagnoseQueuedJob(endpointId) {
|
|
2782
3915
|
const cached = diagnosisCache.get(endpointId);
|
|
2783
3916
|
if (cached && cached.expiresAt > Date.now()) {
|
|
@@ -2788,7 +3921,10 @@ function registerJobTools(server, rt) {
|
|
|
2788
3921
|
const backend = backendFor("workers");
|
|
2789
3922
|
if (backend.version !== "v2") return null;
|
|
2790
3923
|
const raw = await callRestUrl(
|
|
2791
|
-
`${backend.base}/serverless/${endpointId}/workers
|
|
3924
|
+
`${backend.base}/serverless/${endpointId}/workers`,
|
|
3925
|
+
"GET",
|
|
3926
|
+
void 0,
|
|
3927
|
+
{ timeoutMs: QUEUED_DIAGNOSIS_TIMEOUT_MS }
|
|
2792
3928
|
);
|
|
2793
3929
|
const summary = raw?.summary;
|
|
2794
3930
|
const workers = raw?.workers ?? [];
|
|
@@ -2879,14 +4015,14 @@ function registerJobTools(server, rt) {
|
|
|
2879
4015
|
);
|
|
2880
4016
|
server.tool(
|
|
2881
4017
|
"runsync-endpoint",
|
|
2882
|
-
|
|
4018
|
+
`Submit a synchronous job to a Serverless endpoint and wait for the result. Best for fast tasks: if the job outlives the wait, the response returns a job ID and a non-terminal status (IN_QUEUE or IN_PROGRESS) to poll with get-job-status. Max payload 20 MB; results expire after 1 minute. ${hosted ? `This server waits up to ${HTTP_LONG_POLL_BUDGET_MS} ms \u2014 it runs behind a 60-second gateway deadline, so that is both the default and the ceiling for the wait parameter. For longer jobs use run-endpoint + get-job-status, or POST https://api.runpod.ai/v2/{endpointId}/runsync?wait=300000 directly with a Bearer API key.` : "The wait parameter extends the server-side wait to up to 5 minutes (300000 ms); it defaults to 90000 (90 seconds)."}`,
|
|
2883
4019
|
{
|
|
2884
4020
|
endpointId: endpointIdSchema.describe(
|
|
2885
4021
|
"ID of the Serverless endpoint to run synchronously"
|
|
2886
4022
|
),
|
|
2887
4023
|
input: inputSchema,
|
|
2888
4024
|
wait: import_zod9.z.number().min(1e3).max(3e5).optional().describe(
|
|
2889
|
-
"How long in milliseconds the server should wait for a result before returning a job ID to poll (1000\u2013300000). Defaults to 90000 (90 seconds)."
|
|
4025
|
+
hosted ? `How long in milliseconds the server should wait for a result before returning a job ID to poll. Accepted range 1000\u2013300000, but this server clamps anything above ${HTTP_LONG_POLL_BUDGET_MS} down to ${HTTP_LONG_POLL_BUDGET_MS}, which is also the effective default.` : "How long in milliseconds the server should wait for a result before returning a job ID to poll (1000\u2013300000). Defaults to 90000 (90 seconds)."
|
|
2890
4026
|
),
|
|
2891
4027
|
webhook: webhookSchema,
|
|
2892
4028
|
policy: policySchema,
|
|
@@ -2895,13 +4031,36 @@ function registerJobTools(server, rt) {
|
|
|
2895
4031
|
{ title: "Run endpoint (sync)", ...WRITE },
|
|
2896
4032
|
async (params) => {
|
|
2897
4033
|
const { endpointId, wait, ...body } = params;
|
|
2898
|
-
const
|
|
4034
|
+
const effectiveWait = hosted ? Math.min(
|
|
4035
|
+
wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS,
|
|
4036
|
+
HTTP_LONG_POLL_BUDGET_MS
|
|
4037
|
+
) : wait;
|
|
4038
|
+
const waitQuery = effectiveWait === void 0 ? "" : `?wait=${effectiveWait}`;
|
|
4039
|
+
const path2 = `/runsync${waitQuery}`;
|
|
2899
4040
|
const result = await serverlessRequest(
|
|
2900
4041
|
endpointId,
|
|
2901
4042
|
path2,
|
|
2902
4043
|
"POST",
|
|
2903
|
-
body
|
|
4044
|
+
body,
|
|
4045
|
+
{
|
|
4046
|
+
// From the wait actually sent — on http the clamped 45s, not the
|
|
4047
|
+
// 300s the caller may have asked for.
|
|
4048
|
+
timeoutMs: (effectiveWait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS) + UPSTREAM_HOLD_SLACK_MS
|
|
4049
|
+
}
|
|
2904
4050
|
);
|
|
4051
|
+
const requestedWait = wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS;
|
|
4052
|
+
if (hosted && requestedWait > HTTP_LONG_POLL_BUDGET_MS && isUnfinishedJobReply(result)) {
|
|
4053
|
+
return jsonReply2({
|
|
4054
|
+
...result,
|
|
4055
|
+
waitClamped: {
|
|
4056
|
+
requestedMs: requestedWait,
|
|
4057
|
+
effectiveMs: effectiveWait,
|
|
4058
|
+
reason: `This server runs behind a 60-second gateway deadline, so a non-terminal status here means the job outlived ${formatBudget(
|
|
4059
|
+
HTTP_LONG_POLL_BUDGET_MS
|
|
4060
|
+
)}, not the requested wait \u2014 poll get-job-status rather than treating it as stuck.`
|
|
4061
|
+
}
|
|
4062
|
+
});
|
|
4063
|
+
}
|
|
2905
4064
|
return jsonReply2(result);
|
|
2906
4065
|
}
|
|
2907
4066
|
);
|
|
@@ -2935,7 +4094,7 @@ function registerJobTools(server, rt) {
|
|
|
2935
4094
|
);
|
|
2936
4095
|
server.tool(
|
|
2937
4096
|
"stream-job",
|
|
2938
|
-
|
|
4097
|
+
`Retrieve streaming output from a Serverless job. The worker must support streaming output. Polls /stream/{jobId} and collects chunks until the status is COMPLETED, FAILED, CANCELLED, or TIMED_OUT, for up to ${streamBudget}${hosted ? " (this server runs behind a 60-second gateway deadline)" : ""}. If the budget expires first, returns the chunks collected so far with pollingTimedOut: true \u2014 call stream-job again to resume where it left off, or get-job-status to check without streaming.${hosted ? " To collect with no budget, poll GET https://api.runpod.ai/v2/{endpointId}/stream/{jobId} yourself with a Bearer API key until the status is terminal \u2014 each request drains only the chunks buffered so far, so a single request is not the whole output." : ""}`,
|
|
2939
4098
|
{
|
|
2940
4099
|
endpointId: endpointIdSchema.describe(
|
|
2941
4100
|
"ID of the Serverless endpoint the job belongs to"
|
|
@@ -2944,51 +4103,16 @@ function registerJobTools(server, rt) {
|
|
|
2944
4103
|
},
|
|
2945
4104
|
{ title: "Stream job", ...READ_ONLY },
|
|
2946
4105
|
async (params) => {
|
|
2947
|
-
const
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
let finalResult = {};
|
|
2958
|
-
let consecutiveErrors = 0;
|
|
2959
|
-
let lastError;
|
|
2960
|
-
const startTime = Date.now();
|
|
2961
|
-
while (true) {
|
|
2962
|
-
try {
|
|
2963
|
-
const result = await serverlessRequest(
|
|
2964
|
-
params.endpointId,
|
|
2965
|
-
`/stream/${params.jobId}`
|
|
2966
|
-
);
|
|
2967
|
-
consecutiveErrors = 0;
|
|
2968
|
-
if (Array.isArray(result.stream)) {
|
|
2969
|
-
allChunks.push(...result.stream);
|
|
2970
|
-
}
|
|
2971
|
-
finalResult = result;
|
|
2972
|
-
if (TERMINAL_STATUSES.has(result.status)) {
|
|
2973
|
-
break;
|
|
2974
|
-
}
|
|
2975
|
-
} catch (error) {
|
|
2976
|
-
consecutiveErrors++;
|
|
2977
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
2978
|
-
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
2979
|
-
finalResult.error = `Polling aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive errors: ${lastError}`;
|
|
2980
|
-
break;
|
|
2981
|
-
}
|
|
2982
|
-
}
|
|
2983
|
-
if (Date.now() - startTime > MAX_POLL_TIME_MS) {
|
|
2984
|
-
finalResult.pollingTimedOut = true;
|
|
2985
|
-
finalResult.note = "Polling timed out after 5 minutes. Use get-job-status to check the job later.";
|
|
2986
|
-
if (lastError) finalResult.lastError = lastError;
|
|
2987
|
-
break;
|
|
2988
|
-
}
|
|
2989
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
2990
|
-
}
|
|
2991
|
-
return jsonReply2({ ...finalResult, stream: allChunks });
|
|
4106
|
+
const plan = streamPollPlan(hosted);
|
|
4107
|
+
const streamPath = `/stream/${params.jobId}${plan.query}`;
|
|
4108
|
+
const { result, chunks } = await collectJobStream({
|
|
4109
|
+
budgetMs: plan.budgetMs,
|
|
4110
|
+
holdMs: plan.holdMs,
|
|
4111
|
+
poll: (timeoutMs) => serverlessRequest(params.endpointId, streamPath, "GET", void 0, {
|
|
4112
|
+
timeoutMs
|
|
4113
|
+
})
|
|
4114
|
+
});
|
|
4115
|
+
return jsonReply2({ ...result, stream: chunks });
|
|
2992
4116
|
}
|
|
2993
4117
|
);
|
|
2994
4118
|
server.tool(
|