@runpod/mcp-server 1.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.d.cts CHANGED
@@ -1,7 +1,28 @@
1
+ import { ToolContext } from './tools.cjs';
2
+ export { registerTools } from './tools.cjs';
1
3
  import { IncomingMessage, ServerResponse } from 'node:http';
2
- export { ToolContext, registerTools } from './tools.cjs';
3
4
  import '@modelcontextprotocol/sdk/server/mcp.js';
4
5
 
6
+ type CredentialVerdict = {
7
+ status: 'valid';
8
+ } | {
9
+ status: 'invalid';
10
+ reason: string;
11
+ } | {
12
+ status: 'unknown';
13
+ };
14
+ type CredentialChecker = (token: string) => Promise<CredentialVerdict>;
15
+ interface CredentialCheckerHandle {
16
+ verify: CredentialChecker;
17
+ invalidate: (token: string) => void;
18
+ }
19
+
20
+ declare const defaultCredentialChecker: CredentialCheckerHandle;
21
+ declare function bodyIsCheckable(body: unknown): boolean;
22
+ declare function buildToolContext(req: IncomingMessage, bearerToken: string, opts?: {
23
+ serverVersion?: string;
24
+ invalidateCredential?: (token: string) => void;
25
+ }): ToolContext;
5
26
  /**
6
27
  * Handle an incoming HTTP request as a streamable MCP session.
7
28
  *
@@ -10,9 +31,21 @@ import '@modelcontextprotocol/sdk/server/mcp.js';
10
31
  * Runpod API key (set manually by the caller) or a token obtained through the
11
32
  * OAuth "Sign in with Runpod" flow. The server holds no credential of its own
12
33
  * and never shares one across users.
34
+ *
35
+ * The token is pre-flight verified (cached) before the MCP request is
36
+ * handled: a dead credential — e.g. an OAuth-minted key revoked mid-session —
37
+ * must surface as an HTTP 401 + WWW-Authenticate so
38
+ * OAuth-capable clients re-run their auth flow automatically. Without this,
39
+ * the upstream 401 is wrapped into a 200 JSON-RPC tool error and the client
40
+ * never re-authenticates. Set MCP_SKIP_CREDENTIAL_CHECK=true to disable.
41
+ *
42
+ * The gate also self-disables when the REST hosts are overridden without a
43
+ * matching RUNPOD_AUTHED_GRAPHQL_URL — see credentialCheckMayBeWrongEnvironment.
13
44
  */
14
45
  declare function handleMcpRequest(req: IncomingMessage, res: ServerResponse, opts?: {
15
46
  serverVersion?: string;
47
+ verifyCredential?: CredentialChecker;
48
+ invalidateCredential?: (token: string) => void;
16
49
  }): Promise<void>;
17
50
 
18
- export { handleMcpRequest };
51
+ export { ToolContext, bodyIsCheckable, buildToolContext, defaultCredentialChecker, handleMcpRequest };
package/dist/http.d.ts CHANGED
@@ -1,7 +1,28 @@
1
+ import { ToolContext } from './tools.js';
2
+ export { registerTools } from './tools.js';
1
3
  import { IncomingMessage, ServerResponse } from 'node:http';
2
- export { ToolContext, registerTools } from './tools.js';
3
4
  import '@modelcontextprotocol/sdk/server/mcp.js';
4
5
 
6
+ type CredentialVerdict = {
7
+ status: 'valid';
8
+ } | {
9
+ status: 'invalid';
10
+ reason: string;
11
+ } | {
12
+ status: 'unknown';
13
+ };
14
+ type CredentialChecker = (token: string) => Promise<CredentialVerdict>;
15
+ interface CredentialCheckerHandle {
16
+ verify: CredentialChecker;
17
+ invalidate: (token: string) => void;
18
+ }
19
+
20
+ declare const defaultCredentialChecker: CredentialCheckerHandle;
21
+ declare function bodyIsCheckable(body: unknown): boolean;
22
+ declare function buildToolContext(req: IncomingMessage, bearerToken: string, opts?: {
23
+ serverVersion?: string;
24
+ invalidateCredential?: (token: string) => void;
25
+ }): ToolContext;
5
26
  /**
6
27
  * Handle an incoming HTTP request as a streamable MCP session.
7
28
  *
@@ -10,9 +31,21 @@ import '@modelcontextprotocol/sdk/server/mcp.js';
10
31
  * Runpod API key (set manually by the caller) or a token obtained through the
11
32
  * OAuth "Sign in with Runpod" flow. The server holds no credential of its own
12
33
  * and never shares one across users.
34
+ *
35
+ * The token is pre-flight verified (cached) before the MCP request is
36
+ * handled: a dead credential — e.g. an OAuth-minted key revoked mid-session —
37
+ * must surface as an HTTP 401 + WWW-Authenticate so
38
+ * OAuth-capable clients re-run their auth flow automatically. Without this,
39
+ * the upstream 401 is wrapped into a 200 JSON-RPC tool error and the client
40
+ * never re-authenticates. Set MCP_SKIP_CREDENTIAL_CHECK=true to disable.
41
+ *
42
+ * The gate also self-disables when the REST hosts are overridden without a
43
+ * matching RUNPOD_AUTHED_GRAPHQL_URL — see credentialCheckMayBeWrongEnvironment.
13
44
  */
14
45
  declare function handleMcpRequest(req: IncomingMessage, res: ServerResponse, opts?: {
15
46
  serverVersion?: string;
47
+ verifyCredential?: CredentialChecker;
48
+ invalidateCredential?: (token: string) => void;
16
49
  }): Promise<void>;
17
50
 
18
- export { handleMcpRequest };
51
+ export { ToolContext, bodyIsCheckable, buildToolContext, defaultCredentialChecker, handleMcpRequest };
package/dist/http.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  import {
2
- registerTools
3
- } from "./chunk-D4AEYJ2O.mjs";
2
+ authedGraphqlBase,
3
+ registerTools,
4
+ restV1Base,
5
+ restV2Base,
6
+ restVersionSummary,
7
+ serverlessBase
8
+ } from "./chunk-GTBCNGHE.mjs";
4
9
 
5
10
  // src/http.ts
6
11
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -8,11 +13,11 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
8
13
  // src/server.ts
9
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
15
  var SERVER_NAME = "Runpod API Server";
11
- var SERVER_VERSION = true ? "1.4.0" : "dev";
16
+ var SERVER_VERSION = true ? "3.0.0" : "dev";
12
17
  function createServer(version = SERVER_VERSION) {
13
18
  return new McpServer({
14
19
  name: SERVER_NAME,
15
- version,
20
+ version: `${version} [${restVersionSummary(process.env)}]`,
16
21
  capabilities: {
17
22
  resources: {},
18
23
  tools: {}
@@ -20,6 +25,107 @@ function createServer(version = SERVER_VERSION) {
20
25
  });
21
26
  }
22
27
 
28
+ // src/_shared/credential-check.ts
29
+ import { createHash } from "crypto";
30
+ var VALID_TTL_MS = 6e4;
31
+ var INVALID_TTL_MS = 3e4;
32
+ var REQUEST_TIMEOUT_MS = 4e3;
33
+ var MAX_CACHE_ENTRIES = 1e3;
34
+ function hashToken(token) {
35
+ return createHash("sha256").update(token).digest("hex");
36
+ }
37
+ function createCredentialChecker(deps) {
38
+ const now = deps.now ?? (() => performance.now());
39
+ const validTtl = deps.validTtlMs ?? VALID_TTL_MS;
40
+ const invalidTtl = deps.invalidTtlMs ?? INVALID_TTL_MS;
41
+ const timeoutMs = deps.timeoutMs ?? REQUEST_TIMEOUT_MS;
42
+ const maxEntries = Math.max(1, deps.maxEntries ?? MAX_CACHE_ENTRIES);
43
+ const entries = /* @__PURE__ */ new Map();
44
+ async function fetchVerdict(token) {
45
+ try {
46
+ const response = await deps.fetch(deps.url(), {
47
+ method: "POST",
48
+ headers: {
49
+ "Content-Type": "application/json",
50
+ Authorization: `Bearer ${token}`
51
+ },
52
+ body: JSON.stringify({ query: "query { myself { id } }" }),
53
+ signal: AbortSignal.timeout(timeoutMs)
54
+ });
55
+ if (response.status === 401) {
56
+ return {
57
+ status: "invalid",
58
+ reason: "The Runpod API rejected the credential (it may have been revoked)."
59
+ };
60
+ }
61
+ if (!response.ok) return { status: "unknown" };
62
+ const result = await response.json();
63
+ if (result?.data?.myself?.id) return { status: "valid" };
64
+ if (Array.isArray(result?.errors) && result.errors.length > 0) {
65
+ return { status: "unknown" };
66
+ }
67
+ if (result?.data && result.data.myself === null) {
68
+ return {
69
+ status: "invalid",
70
+ reason: "The credential does not resolve to a Runpod account."
71
+ };
72
+ }
73
+ return { status: "unknown" };
74
+ } catch {
75
+ return { status: "unknown" };
76
+ }
77
+ }
78
+ function evictIfFull() {
79
+ while (entries.size >= maxEntries) {
80
+ let victim;
81
+ for (const [k, e] of entries) {
82
+ if (!e.promise) {
83
+ victim = k;
84
+ break;
85
+ }
86
+ }
87
+ if (victim === void 0) return;
88
+ entries.delete(victim);
89
+ }
90
+ }
91
+ async function verify(token) {
92
+ const key = hashToken(token);
93
+ const existing = entries.get(key);
94
+ if (existing?.verdict) {
95
+ if (existing.expiresAt > now()) {
96
+ entries.delete(key);
97
+ entries.set(key, existing);
98
+ return existing.verdict;
99
+ }
100
+ entries.delete(key);
101
+ } else if (existing?.promise) {
102
+ return existing.promise;
103
+ }
104
+ const entry = { expiresAt: 0 };
105
+ entry.promise = fetchVerdict(token).then((verdict) => {
106
+ if (entries.get(key) === entry) {
107
+ if (verdict.status !== "unknown") {
108
+ entry.verdict = verdict;
109
+ entry.expiresAt = now() + (verdict.status === "valid" ? validTtl : invalidTtl);
110
+ entry.promise = void 0;
111
+ } else {
112
+ entries.delete(key);
113
+ }
114
+ }
115
+ return verdict;
116
+ });
117
+ evictIfFull();
118
+ entries.set(key, entry);
119
+ return entry.promise;
120
+ }
121
+ return {
122
+ verify,
123
+ invalidate: (token) => {
124
+ entries.delete(hashToken(token));
125
+ }
126
+ };
127
+ }
128
+
23
129
  // src/http.ts
24
130
  function extractBearerToken(req) {
25
131
  const authHeader = req.headers.authorization;
@@ -36,16 +142,111 @@ function getBaseUrl(req) {
36
142
  }
37
143
  return `${proto}://${host}`;
38
144
  }
39
- function writeUnauthorized(req, res, error) {
40
- const headers = {
41
- "Content-Type": "application/json",
42
- "WWW-Authenticate": `Bearer realm="mcp", resource_metadata="${getBaseUrl(
145
+ function writeUnauthorized(req, res, error, invalidToken = false) {
146
+ const challenge = [
147
+ 'Bearer realm="mcp"',
148
+ ...invalidToken ? [
149
+ 'error="invalid_token"',
150
+ 'error_description="The credential was rejected; re-authenticate."'
151
+ ] : [],
152
+ `resource_metadata="${getBaseUrl(
43
153
  req
44
154
  )}/.well-known/oauth-protected-resource"`
45
- };
46
- res.writeHead(401, headers);
155
+ ].join(", ");
156
+ res.writeHead(401, {
157
+ "Content-Type": "application/json",
158
+ "WWW-Authenticate": challenge
159
+ });
47
160
  res.end(JSON.stringify({ error }));
48
161
  }
162
+ function authGraphqlUrl(env) {
163
+ return authedGraphqlBase(env);
164
+ }
165
+ var defaultCredentialChecker = createCredentialChecker({
166
+ fetch: (url, init) => fetch(url, init),
167
+ url: () => authGraphqlUrl(process.env)
168
+ });
169
+ var sameHost = (a, b) => normalizeUrl(a) === normalizeUrl(b);
170
+ function normalizeUrl(raw) {
171
+ try {
172
+ const u = new URL(raw);
173
+ return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, "")}`;
174
+ } catch {
175
+ return raw.trim().toLowerCase().replace(/\/+$/, "");
176
+ }
177
+ }
178
+ function credentialCheckMayBeWrongEnvironment(env = process.env) {
179
+ const envWithoutEmpties = Object.fromEntries(
180
+ Object.entries(env).filter(([, v]) => v !== "")
181
+ );
182
+ const restMoved = !sameHost(restV1Base(envWithoutEmpties), restV1Base({})) || !sameHost(restV2Base(envWithoutEmpties), restV2Base({})) || !sameHost(serverlessBase(envWithoutEmpties), serverlessBase({}));
183
+ const graphqlMoved = !sameHost(authGraphqlUrl(env), authGraphqlUrl({}));
184
+ return restMoved !== graphqlMoved;
185
+ }
186
+ function looksLikeMcpMessage(value) {
187
+ if (typeof value !== "object" || value === null) return false;
188
+ const msg = value;
189
+ return msg.jsonrpc === "2.0" && typeof msg.method === "string";
190
+ }
191
+ function bodyIsCheckable(body) {
192
+ const messages = Array.isArray(body) ? body : [body];
193
+ return messages.some(looksLikeMcpMessage);
194
+ }
195
+ function isJsonContentType(req) {
196
+ const ct = req.headers["content-type"];
197
+ if (!ct) return false;
198
+ return ct.toLowerCase().includes("application/json");
199
+ }
200
+ var MAX_BODY_BYTES = 4 * 1024 * 1024;
201
+ var BODY_TOO_LARGE = Symbol("body_too_large");
202
+ async function readJsonBody(req) {
203
+ const existing = req.body;
204
+ if (existing !== void 0) return existing;
205
+ const declared = Number(req.headers["content-length"]);
206
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
207
+ return BODY_TOO_LARGE;
208
+ }
209
+ try {
210
+ const chunks = [];
211
+ let total = 0;
212
+ for await (const chunk of req) {
213
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
214
+ total += buf.length;
215
+ if (total > MAX_BODY_BYTES) return BODY_TOO_LARGE;
216
+ chunks.push(buf);
217
+ }
218
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
219
+ return raw ? JSON.parse(raw) : void 0;
220
+ } catch {
221
+ return void 0;
222
+ }
223
+ }
224
+ function earlyCredentialCheckSkip(req) {
225
+ if (process.env.MCP_SKIP_CREDENTIAL_CHECK === "true") return "skip_env_var";
226
+ if (req.method !== "POST") return "skip_not_post";
227
+ if (!isJsonContentType(req)) return "skip_not_json";
228
+ if (credentialCheckMayBeWrongEnvironment()) return "skip_env_mismatch";
229
+ return null;
230
+ }
231
+ function buildToolContext(req, bearerToken, opts = {}) {
232
+ const drop = opts.invalidateCredential ?? defaultCredentialChecker.invalidate;
233
+ return {
234
+ apiKey: bearerToken,
235
+ transport: "http",
236
+ // A tools/call is a separate request from `initialize` in stateless HTTP, so
237
+ // the per-request server never sees the MCP `clientInfo`; fall back to the
238
+ // inbound User-Agent so caller-tracking still attributes the client.
239
+ clientUserAgent: req.headers["user-agent"],
240
+ serverVersion: opts.serverVersion,
241
+ // A tool call that 401s proves a cached "valid" wrong before its TTL. Drop it
242
+ // so the next request re-checks and can emit the 401 that triggers re-auth.
243
+ onUnauthorized: () => drop(bearerToken)
244
+ };
245
+ }
246
+ function logCredentialCheck(outcome) {
247
+ if (outcome === "pass" && process.env.MCP_VERBOSE_LOGS !== "true") return;
248
+ console.log("credential_check", { outcome });
249
+ }
49
250
  async function handleMcpRequest(req, res, opts = {}) {
50
251
  const bearerToken = extractBearerToken(req);
51
252
  console.log("mcp_request", {
@@ -61,13 +262,39 @@ async function handleMcpRequest(req, res, opts = {}) {
61
262
  );
62
263
  return;
63
264
  }
265
+ let body = req.body;
266
+ const early = earlyCredentialCheckSkip(req);
267
+ if (early) {
268
+ logCredentialCheck(early);
269
+ } else {
270
+ const read = await readJsonBody(req);
271
+ if (read === BODY_TOO_LARGE) {
272
+ res.writeHead(413, { "Content-Type": "application/json" });
273
+ res.end(JSON.stringify({ error: "Request body too large." }));
274
+ return;
275
+ }
276
+ body = read;
277
+ if (!bodyIsCheckable(body)) {
278
+ logCredentialCheck("skip_not_mcp_body");
279
+ } else {
280
+ const verify = opts.verifyCredential ?? defaultCredentialChecker.verify;
281
+ const verdict = await verify(bearerToken);
282
+ logCredentialCheck(
283
+ verdict.status === "invalid" ? "reject" : verdict.status === "valid" ? "pass" : "fail_open"
284
+ );
285
+ if (verdict.status === "invalid") {
286
+ writeUnauthorized(
287
+ req,
288
+ res,
289
+ `${verdict.reason} Re-authenticate to continue.`,
290
+ true
291
+ );
292
+ return;
293
+ }
294
+ }
295
+ }
64
296
  const server = createServer(opts.serverVersion);
65
- registerTools(server, {
66
- apiKey: bearerToken,
67
- transport: "http",
68
- clientUserAgent: req.headers["user-agent"],
69
- serverVersion: opts.serverVersion
70
- });
297
+ registerTools(server, buildToolContext(req, bearerToken, opts));
71
298
  const transport = new StreamableHTTPServerTransport({
72
299
  sessionIdGenerator: void 0
73
300
  // stateless — no session persistence
@@ -79,9 +306,12 @@ async function handleMcpRequest(req, res, opts = {}) {
79
306
  });
80
307
  });
81
308
  await server.connect(transport);
82
- await transport.handleRequest(req, res, req.body);
309
+ await transport.handleRequest(req, res, body);
83
310
  }
84
311
  export {
312
+ bodyIsCheckable,
313
+ buildToolContext,
314
+ defaultCredentialChecker,
85
315
  handleMcpRequest,
86
316
  registerTools
87
317
  };
package/dist/http.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/http.ts","../src/server.ts"],"sourcesContent":["import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';\nimport { createServer } from './server.js';\nimport { registerTools } from './tools.js';\nimport { IncomingMessage, ServerResponse } from 'node:http';\n\n/**\n * Extract the bearer token from an HTTP request's Authorization header.\n * Returns null if the header is missing or malformed.\n */\nfunction extractBearerToken(req: IncomingMessage): string | null {\n const authHeader = req.headers.authorization;\n if (!authHeader) return null;\n const parts = authHeader.split(' ');\n if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') return null;\n return parts[1];\n}\n\nfunction getBaseUrl(req: IncomingMessage): string {\n const proto =\n (req.headers['x-forwarded-proto'] as string | undefined)?.split(',')[0] ??\n 'https';\n const host = req.headers.host;\n if (!host) {\n throw new Error('Missing Host header');\n }\n\n return `${proto}://${host}`;\n}\n\nfunction writeUnauthorized(\n req: IncomingMessage,\n res: ServerResponse,\n error: string\n): void {\n // Always advertise the protected-resource metadata so OAuth-capable clients\n // (e.g. Claude) know to start the \"Sign in with Runpod\" flow. Callers who\n // bring their own API key as the bearer token simply never hit this path.\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n 'WWW-Authenticate': `Bearer realm=\"mcp\", resource_metadata=\"${getBaseUrl(\n req\n )}/.well-known/oauth-protected-resource\"`,\n };\n\n res.writeHead(401, headers);\n res.end(JSON.stringify({ error }));\n}\n\n/**\n * Handle an incoming HTTP request as a streamable MCP session.\n *\n * Each request gets its own McpServer + transport. The caller's Bearer token\n * is forwarded directly to the Runpod API as the credential — it may be a\n * Runpod API key (set manually by the caller) or a token obtained through the\n * OAuth \"Sign in with Runpod\" flow. The server holds no credential of its own\n * and never shares one across users.\n */\nexport async function handleMcpRequest(\n req: IncomingMessage,\n res: ServerResponse,\n opts: { serverVersion?: string } = {}\n): Promise<void> {\n const bearerToken = extractBearerToken(req);\n console.log('mcp_request', {\n method: req.method,\n url: req.url,\n hasBearerToken: !!bearerToken,\n });\n if (!bearerToken) {\n writeUnauthorized(\n req,\n res,\n 'Missing or invalid Authorization header. Provide a Bearer token.'\n );\n return;\n }\n\n const server = createServer(opts.serverVersion);\n // In stateless HTTP, a tools/call is a separate request from `initialize`, so\n // the per-request server never sees the MCP `clientInfo`. Fall back to the\n // inbound HTTP User-Agent so caller-tracking still attributes the client.\n registerTools(server, {\n apiKey: bearerToken,\n transport: 'http',\n clientUserAgent: req.headers['user-agent'],\n serverVersion: opts.serverVersion,\n });\n\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined, // stateless — no session persistence\n });\n\n // This per-request server/transport pair is single-use; dispose it when the\n // response closes so listeners don't accumulate on a warm serverless instance.\n res.on('close', () => {\n transport.close().catch(() => {});\n server.close().catch(() => {});\n });\n\n await server.connect(transport);\n // Pass the already-parsed body when the host provides one (e.g. Vercel's\n // VercelRequest exposes `req.body`); the SDK reads the raw stream otherwise.\n await transport.handleRequest(req, res, (req as { body?: unknown }).body);\n}\n\nexport { registerTools } from './tools.js';\nexport type { ToolContext } from './tools.js';\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n\n// `__PACKAGE_VERSION__` is replaced at build time by tsup's `define` with the\n// current `version` from package.json. Falls back to `'dev'` when the\n// substitution doesn't happen (e.g. running via tsx).\ndeclare const __PACKAGE_VERSION__: string;\n\nexport const SERVER_NAME = 'Runpod API Server';\nexport const SERVER_VERSION =\n typeof __PACKAGE_VERSION__ !== 'undefined' ? __PACKAGE_VERSION__ : 'dev';\n\n// `version` defaults to the build-time value (correct for the npm/stdio build).\n// The hosted entrypoint passes a runtime value read from package.json, since\n// tsup's define doesn't run when Vercel compiles api/index.ts.\nexport function createServer(version: string = SERVER_VERSION): McpServer {\n return new McpServer({\n name: SERVER_NAME,\n version,\n capabilities: {\n resources: {},\n tools: {},\n },\n });\n}\n"],"mappings":";;;;;AAAA,SAAS,qCAAqC;;;ACA9C,SAAS,iBAAiB;AAOnB,IAAM,cAAc;AACpB,IAAM,iBACX,OAA6C,UAAsB;AAK9D,SAAS,aAAa,UAAkB,gBAA2B;AACxE,SAAO,IAAI,UAAU;AAAA,IACnB,MAAM;AAAA,IACN;AAAA,IACA,cAAc;AAAA,MACZ,WAAW,CAAC;AAAA,MACZ,OAAO,CAAC;AAAA,IACV;AAAA,EACF,CAAC;AACH;;;ADdA,SAAS,mBAAmB,KAAqC;AAC/D,QAAM,aAAa,IAAI,QAAQ;AAC/B,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,QAAQ,WAAW,MAAM,GAAG;AAClC,MAAI,MAAM,WAAW,KAAK,MAAM,CAAC,EAAE,YAAY,MAAM,SAAU,QAAO;AACtE,SAAO,MAAM,CAAC;AAChB;AAEA,SAAS,WAAW,KAA8B;AAChD,QAAM,QACH,IAAI,QAAQ,mBAAmB,GAA0B,MAAM,GAAG,EAAE,CAAC,KACtE;AACF,QAAM,OAAO,IAAI,QAAQ;AACzB,MAAI,CAAC,MAAM;AACT,UAAM,IAAI,MAAM,qBAAqB;AAAA,EACvC;AAEA,SAAO,GAAG,KAAK,MAAM,IAAI;AAC3B;AAEA,SAAS,kBACP,KACA,KACA,OACM;AAIN,QAAM,UAAkC;AAAA,IACtC,gBAAgB;AAAA,IAChB,oBAAoB,0CAA0C;AAAA,MAC5D;AAAA,IACF,CAAC;AAAA,EACH;AAEA,MAAI,UAAU,KAAK,OAAO;AAC1B,MAAI,IAAI,KAAK,UAAU,EAAE,MAAM,CAAC,CAAC;AACnC;AAWA,eAAsB,iBACpB,KACA,KACA,OAAmC,CAAC,GACrB;AACf,QAAM,cAAc,mBAAmB,GAAG;AAC1C,UAAQ,IAAI,eAAe;AAAA,IACzB,QAAQ,IAAI;AAAA,IACZ,KAAK,IAAI;AAAA,IACT,gBAAgB,CAAC,CAAC;AAAA,EACpB,CAAC;AACD,MAAI,CAAC,aAAa;AAChB;AAAA,MACE;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA;AAAA,EACF;AAEA,QAAM,SAAS,aAAa,KAAK,aAAa;AAI9C,gBAAc,QAAQ;AAAA,IACpB,QAAQ;AAAA,IACR,WAAW;AAAA,IACX,iBAAiB,IAAI,QAAQ,YAAY;AAAA,IACzC,eAAe,KAAK;AAAA,EACtB,CAAC;AAED,QAAM,YAAY,IAAI,8BAA8B;AAAA,IAClD,oBAAoB;AAAA;AAAA,EACtB,CAAC;AAID,MAAI,GAAG,SAAS,MAAM;AACpB,cAAU,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAChC,WAAO,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EAC/B,CAAC;AAED,QAAM,OAAO,QAAQ,SAAS;AAG9B,QAAM,UAAU,cAAc,KAAK,KAAM,IAA2B,IAAI;AAC1E;","names":[]}
1
+ {"version":3,"sources":["../src/http.ts","../src/server.ts","../src/_shared/credential-check.ts"],"sourcesContent":["import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';\nimport { createServer } from './server.js';\nimport { registerTools, type ToolContext } from './tools.js';\nimport {\n createCredentialChecker,\n type CredentialChecker,\n type CredentialCheckerHandle,\n} from './_shared/credential-check.js';\nimport {\n restV1Base,\n restV2Base,\n serverlessBase,\n authedGraphqlBase,\n type Env,\n} from './_shared/backend.js';\nimport { IncomingMessage, ServerResponse } from 'node:http';\n\n/**\n * Extract the bearer token from an HTTP request's Authorization header.\n * Returns null if the header is missing or malformed.\n */\nfunction extractBearerToken(req: IncomingMessage): string | null {\n const authHeader = req.headers.authorization;\n if (!authHeader) return null;\n const parts = authHeader.split(' ');\n if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') return null;\n return parts[1];\n}\n\nfunction getBaseUrl(req: IncomingMessage): string {\n const proto =\n (req.headers['x-forwarded-proto'] as string | undefined)?.split(',')[0] ??\n 'https';\n const host = req.headers.host;\n if (!host) {\n throw new Error('Missing Host header');\n }\n\n return `${proto}://${host}`;\n}\n\nfunction writeUnauthorized(\n req: IncomingMessage,\n res: ServerResponse,\n error: string,\n // A credential was supplied and rejected, as opposed to none being sent. RFC\n // 6750 §3.1 puts error=\"invalid_token\" on that case and requires it be omitted\n // otherwise, so a client can tell \"never authenticated\" from \"re-authenticate\".\n invalidToken = false\n): void {\n // Always advertise the protected-resource metadata so OAuth-capable clients\n // (e.g. Claude) know to start the \"Sign in with Runpod\" flow.\n const challenge = [\n 'Bearer realm=\"mcp\"',\n ...(invalidToken\n ? [\n 'error=\"invalid_token\"',\n 'error_description=\"The credential was rejected; re-authenticate.\"',\n ]\n : []),\n `resource_metadata=\"${getBaseUrl(\n req\n )}/.well-known/oauth-protected-resource\"`,\n ].join(', ');\n\n res.writeHead(401, {\n 'Content-Type': 'application/json',\n 'WWW-Authenticate': challenge,\n });\n res.end(JSON.stringify({ error }));\n}\n\n// The GraphQL host the pre-flight authenticates against. This call carries the\n// CALLER'S key, so it uses authedGraphqlBase (RUNPOD_AUTHED_GRAPHQL_URL) — the\n// variable backend.ts reserves for key-bearing GraphQL — NOT the freely\n// repointable OAuth flash-auth var (RUNPOD_GRAPHQL_URL): repointing that one\n// must never redirect every caller's key to an arbitrary host. One resolver so\n// the checker and the environment guard below cannot disagree about defaults.\nfunction authGraphqlUrl(\n env: NodeJS.ProcessEnv | Record<string, string | undefined>\n): string {\n return authedGraphqlBase(env as Env);\n}\n\n// Shared across requests so the verdict cache helps on a warm instance. Uses the\n// global fetch (Node >= 18) — this runs before any MCP/tool wiring exists.\nexport const defaultCredentialChecker: CredentialCheckerHandle =\n createCredentialChecker({\n fetch: (url, init) => fetch(url, init),\n url: () => authGraphqlUrl(process.env),\n });\n\nconst sameHost = (a: string, b: string) => normalizeUrl(a) === normalizeUrl(b);\n\n// Lowercased origin + path with any trailing slash removed, so cosmetic\n// differences in an env var do not read as a different environment.\nfunction normalizeUrl(raw: string): string {\n try {\n const u = new URL(raw);\n return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\\/+$/, '')}`;\n } catch {\n return raw.trim().toLowerCase().replace(/\\/+$/, '');\n }\n}\n\n// The pre-flight checks the token against RUNPOD_AUTHED_GRAPHQL_URL; the tools\n// authenticate against the REST/Serverless hosts. Those are independent, so a\n// deployment pointing REST at a dev environment while GraphQL stays on prod\n// would validate a dev key against prod and reject EVERY request — though every\n// tool would have worked. Skipping on that ambiguity is the safer error: a wrong\n// 401 rejects everything, a missed one only restores the prior behaviour.\n//\n// UNVERIFIED: nobody has confirmed what the hosted deployment actually sets.\n// There is no .env in the repo, no `env` block in vercel.json, and CI sets no\n// RUNPOD_* vars — the values live only in the Vercel dashboard. If production\n// points any REST host somewhere non-default without also setting\n// RUNPOD_AUTHED_GRAPHQL_URL, this guard is silently disabling the gate in production\n// right now. Run `vercel env ls production` to settle it.\nfunction credentialCheckMayBeWrongEnvironment(env = process.env): boolean {\n // Normalised, not byte-exact: a trailing slash, host casing or an empty string\n // is not a different environment. Empty is treated as unset, matching how the\n // base URLs resolve.\n const envWithoutEmpties = Object.fromEntries(\n Object.entries(env).filter(([, v]) => v !== '')\n ) as Record<string, string | undefined>;\n const restMoved =\n !sameHost(restV1Base(envWithoutEmpties), restV1Base({})) ||\n !sameHost(restV2Base(envWithoutEmpties), restV2Base({})) ||\n !sameHost(serverlessBase(envWithoutEmpties), serverlessBase({}));\n const graphqlMoved = !sameHost(authGraphqlUrl(env), authGraphqlUrl({}));\n // Skip whenever REST and the auth-GraphQL host DISAGREE about whether they are\n // on the default (prod) environment — in EITHER direction. Validating a key\n // against an auth backend from a different environment than the tools use\n // rejects every request while every tool would have worked, and OAuth clients\n // answer that 401 by re-authenticating in a loop. Only when both sides agree\n // (both default, or both moved) is the pre-flight validating against the same\n // environment the tools will call. An earlier version only caught REST-moved-\n // but-GraphQL-default, so moving only RUNPOD_GRAPHQL_URL (its documented dev\n // override) still 401'd every good credential.\n return restMoved !== graphqlMoved;\n}\n\n// Requires `method`, so a JSON-RPC *response* ({jsonrpc, id, result}) does not\n// qualify — a client posting one invokes no tool, so there is no credential to\n// check. Erring toward \"skip\" only loses a 401 we did not need.\nfunction looksLikeMcpMessage(value: unknown): boolean {\n if (typeof value !== 'object' || value === null) return false;\n const msg = value as { jsonrpc?: unknown; method?: unknown };\n return msg.jsonrpc === '2.0' && typeof msg.method === 'string';\n}\n\n// On a host that pre-parses bodies, `undefined` means the platform declined to\n// parse this content type (Vercel returns undefined for everything outside\n// json/text/urlencoded/octet-stream), so the body is unverifiable and must not be\n// treated as MCP — otherwise a caller reopens the credential oracle by changing\n// one header. On a host that never parses (plain node:http), there is nothing to\n// inspect without consuming the stream the SDK needs, so the gate is skipped.\n// Either way `undefined` is NOT checkable; only an actual MCP body is.\n//\n// `some`, not `every`: a JSON-RPC batch may legitimately mix a response element\n// (no `method`) with a request that invokes a tool. `every` let one response\n// element mark the whole batch un-checkable while the SDK still ran the tool\n// call — a one-element opt-out of the pre-flight. If any element invokes a\n// method, the batch must be checked. `some` on an empty array is false, so an\n// empty batch (which invokes nothing) is correctly not checkable.\nexport function bodyIsCheckable(body: unknown): boolean {\n const messages = Array.isArray(body) ? body : [body];\n return messages.some(looksLikeMcpMessage);\n}\n\n// The gate must be at least as permissive as the SDK's own content-type check,\n// or any type the SDK accepts but the gate rejects is a bypass window: the gate\n// skips, then the SDK parses and runs the tool with an unverified credential.\n// The SDK uses `ct.includes('application/json')` (streamableHttp.js), so\n// `application/json-patch+json` and `application/jsonx` reach a tool — an exact\n// match here missed them. Mirror the SDK's substring test (lowercased, so the\n// gate is a superset and stays safe even if the SDK never normalises case). A\n// type the SDK 415s that we happen to check only costs one needless probe.\nfunction isJsonContentType(req: IncomingMessage): boolean {\n const ct = req.headers['content-type'];\n if (!ct) return false;\n return ct.toLowerCase().includes('application/json');\n}\n\n// Match the SDK's own body limit (MAXIMUM_MESSAGE_SIZE, '4mb'). A body the SDK\n// would reject as too large is one we must not buffer either.\nconst MAX_BODY_BYTES = 4 * 1024 * 1024;\n// readJsonBody's distinct \"over the cap\" result, kept separate from undefined\n// (absent/unparseable) so the caller answers 413 rather than falling through to\n// the skip path and handing a partial stream back to the SDK.\nconst BODY_TOO_LARGE = Symbol('body_too_large');\n\n// Buffer and parse a JSON body when the host did not already. Without this the\n// gate cannot run on a plain node:http host — the published ./http export and\n// scripts/serve-http.local.ts — because there is nothing to inspect, which would\n// silently disable the pre-flight for those consumers. The parsed value is handed\n// to transport.handleRequest afterwards, so the SDK never needs the stream this\n// consumed. Returns undefined when the body is absent or not JSON (the caller\n// then skips), or BODY_TOO_LARGE past the cap (the caller answers 413).\n//\n// Capped, and only ever called after the cheap skip checks pass, so a non-JSON,\n// kill-switched or oversized request never buffers: on the un-pre-parsed\n// node:http path this replaces the SDK's own getRawBody, which enforced the same\n// 4mb limit — without the cap an unauthenticated caller could stream unbounded\n// bytes into memory, and reading before the content-type check let a slow\n// non-JSON upload hold the request slot open.\nasync function readJsonBody(\n req: IncomingMessage\n): Promise<unknown | typeof BODY_TOO_LARGE> {\n const existing = (req as { body?: unknown }).body;\n if (existing !== undefined) return existing;\n const declared = Number(req.headers['content-length']);\n if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {\n return BODY_TOO_LARGE;\n }\n try {\n const chunks: Buffer[] = [];\n let total = 0;\n for await (const chunk of req) {\n const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);\n total += buf.length;\n // Covers a chunked upload or a lying Content-Length: stop the moment the\n // bytes on the wire exceed the cap, before growing the buffer further.\n if (total > MAX_BODY_BYTES) return BODY_TOO_LARGE;\n chunks.push(buf);\n }\n const raw = Buffer.concat(chunks).toString('utf8').trim();\n return raw ? JSON.parse(raw) : undefined;\n } catch {\n return undefined;\n }\n}\n\ntype SkipReason =\n | 'skip_env_var'\n | 'skip_env_mismatch'\n | 'skip_not_post'\n | 'skip_not_json'\n | 'skip_not_mcp_body';\n\n// Body-independent skip reasons, decided before the body is read (see\n// readJsonBody). Only well-formed MCP POSTs are ever checked. This does NOT close\n// the credential oracle — a caller who speaks the protocol still learns\n// 401-vs-otherwise from a well-formed tools/list — but it stops an\n// unauthenticated scanner probing stolen keys through us with arbitrary requests,\n// and avoids an upstream query per junk request. Closing it properly needs rate\n// limiting, which this does not add. GET and DELETE make no authenticated\n// upstream call, so a dead credential breaks nothing there. The cheap header\n// checks precede the env-mismatch scan (which parses several URLs) so the common\n// non-POST path stays cheap, and the kill switch comes first so it also prevents\n// the body read.\nfunction earlyCredentialCheckSkip(req: IncomingMessage): SkipReason | null {\n if (process.env.MCP_SKIP_CREDENTIAL_CHECK === 'true') return 'skip_env_var';\n if (req.method !== 'POST') return 'skip_not_post';\n if (!isJsonContentType(req)) return 'skip_not_json';\n if (credentialCheckMayBeWrongEnvironment()) return 'skip_env_mismatch';\n return null;\n}\n\n// The ToolContext the hosted path hands to registerTools. Exported so the\n// onUnauthorized wiring is reachable from a test.\nexport function buildToolContext(\n req: IncomingMessage,\n bearerToken: string,\n opts: {\n serverVersion?: string;\n invalidateCredential?: (token: string) => void;\n } = {}\n): ToolContext {\n const drop = opts.invalidateCredential ?? defaultCredentialChecker.invalidate;\n return {\n apiKey: bearerToken,\n transport: 'http',\n // A tools/call is a separate request from `initialize` in stateless HTTP, so\n // the per-request server never sees the MCP `clientInfo`; fall back to the\n // inbound User-Agent so caller-tracking still attributes the client.\n clientUserAgent: req.headers['user-agent'],\n serverVersion: opts.serverVersion,\n // A tool call that 401s proves a cached \"valid\" wrong before its TTL. Drop it\n // so the next request re-checks and can emit the 401 that triggers re-auth.\n onUnauthorized: () => drop(bearerToken),\n };\n}\n\nfunction logCredentialCheck(\n outcome: SkipReason | 'pass' | 'reject' | 'fail_open'\n): void {\n // 'pass' is the bulk of traffic and is pure log spend; everything else —\n // rejections, fail-open guesses, every skip reason — always logs, because those\n // are the only way to notice the gate misbehaving or silently not running.\n if (outcome === 'pass' && process.env.MCP_VERBOSE_LOGS !== 'true') return;\n console.log('credential_check', { outcome });\n}\n\n/**\n * Handle an incoming HTTP request as a streamable MCP session.\n *\n * Each request gets its own McpServer + transport. The caller's Bearer token\n * is forwarded directly to the Runpod API as the credential — it may be a\n * Runpod API key (set manually by the caller) or a token obtained through the\n * OAuth \"Sign in with Runpod\" flow. The server holds no credential of its own\n * and never shares one across users.\n *\n * The token is pre-flight verified (cached) before the MCP request is\n * handled: a dead credential — e.g. an OAuth-minted key revoked mid-session —\n * must surface as an HTTP 401 + WWW-Authenticate so\n * OAuth-capable clients re-run their auth flow automatically. Without this,\n * the upstream 401 is wrapped into a 200 JSON-RPC tool error and the client\n * never re-authenticates. Set MCP_SKIP_CREDENTIAL_CHECK=true to disable.\n *\n * The gate also self-disables when the REST hosts are overridden without a\n * matching RUNPOD_AUTHED_GRAPHQL_URL — see credentialCheckMayBeWrongEnvironment.\n */\nexport async function handleMcpRequest(\n req: IncomingMessage,\n res: ServerResponse,\n opts: {\n serverVersion?: string;\n // Test seams — production uses the shared default checker.\n verifyCredential?: CredentialChecker;\n invalidateCredential?: (token: string) => void;\n } = {}\n): Promise<void> {\n const bearerToken = extractBearerToken(req);\n console.log('mcp_request', {\n method: req.method,\n url: req.url,\n hasBearerToken: !!bearerToken,\n });\n if (!bearerToken) {\n writeUnauthorized(\n req,\n res,\n 'Missing or invalid Authorization header. Provide a Bearer token.'\n );\n return;\n }\n\n // The body is read only when the pre-flight might actually run. The cheap,\n // body-independent skips are decided first (earlyCredentialCheckSkip) so a\n // non-JSON, kill-switched or wrong-environment request never buffers a body —\n // an earlier version read every body up front, which removed the SDK's 4mb cap\n // and let a slow or oversized non-JSON upload hold the request slot open. For a\n // skipped request `body` stays whatever the host pre-parsed (undefined on plain\n // node:http, where the SDK then reads the stream itself).\n let body: unknown = (req as { body?: unknown }).body;\n const early = earlyCredentialCheckSkip(req);\n if (early) {\n logCredentialCheck(early);\n } else {\n const read = await readJsonBody(req);\n if (read === BODY_TOO_LARGE) {\n res.writeHead(413, { 'Content-Type': 'application/json' });\n res.end(JSON.stringify({ error: 'Request body too large.' }));\n return;\n }\n body = read;\n if (!bodyIsCheckable(body)) {\n // Absent, unparseable, or not an MCP message — nothing to check, and\n // checking it anyway would reopen the credential oracle.\n logCredentialCheck('skip_not_mcp_body');\n } else {\n const verify = opts.verifyCredential ?? defaultCredentialChecker.verify;\n const verdict = await verify(bearerToken);\n // 'unknown' is logged distinctly from 'pass': both let the request through,\n // but one is an answer and the other a guess made because the auth backend\n // could not be reached. Silently merging them hides an outage upstream.\n logCredentialCheck(\n verdict.status === 'invalid'\n ? 'reject'\n : verdict.status === 'valid'\n ? 'pass'\n : 'fail_open'\n );\n if (verdict.status === 'invalid') {\n writeUnauthorized(\n req,\n res,\n `${verdict.reason} Re-authenticate to continue.`,\n true\n );\n return;\n }\n }\n }\n\n const server = createServer(opts.serverVersion);\n registerTools(server, buildToolContext(req, bearerToken, opts));\n\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined, // stateless — no session persistence\n });\n\n // This per-request server/transport pair is single-use; dispose it when the\n // response closes so listeners don't accumulate on a warm serverless instance.\n res.on('close', () => {\n transport.close().catch(() => {});\n server.close().catch(() => {});\n });\n\n await server.connect(transport);\n // Pass the already-parsed body when the host provides one (e.g. Vercel's\n // VercelRequest exposes `req.body`); the SDK reads the raw stream otherwise.\n // Pass the body we already resolved: readJsonBody may have consumed the stream.\n await transport.handleRequest(req, res, body);\n}\n\nexport { registerTools } from './tools.js';\nexport type { ToolContext } from './tools.js';\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { restVersionSummary, type Env } from './_shared/backend.js';\n\n// `__PACKAGE_VERSION__` is replaced at build time by tsup's `define` with the\n// current `version` from package.json. Falls back to `'dev'` when the\n// substitution doesn't happen (e.g. running via tsx).\ndeclare const __PACKAGE_VERSION__: string;\n\nexport const SERVER_NAME = 'Runpod API Server';\nexport const SERVER_VERSION =\n typeof __PACKAGE_VERSION__ !== 'undefined' ? __PACKAGE_VERSION__ : 'dev';\n\n// `version` defaults to the build-time value (correct for the npm/stdio build).\n// The hosted entrypoint passes a runtime value read from package.json, since\n// tsup's define doesn't run when Vercel compiles api/index.ts.\n//\n// The reported version also carries the configured REST version, e.g.\n// `2.0.0 [RUNPOD_REST_VERSION=v2]`, so a plain `initialize` handshake shows at a\n// glance whether the deployment is running v1 or v2 without env inspection.\nexport function createServer(version: string = SERVER_VERSION): McpServer {\n return new McpServer({\n name: SERVER_NAME,\n version: `${version} [${restVersionSummary(process.env as Env)}]`,\n capabilities: {\n resources: {},\n tools: {},\n },\n });\n}\n","// ============== HOSTED CREDENTIAL PRE-FLIGHT ==============\n// The hosted HTTP server forwards the caller's Bearer token straight to the\n// Runpod API. When that credential dies mid-session (the OAuth-minted key\n// is revoked), every tool call fails upstream with a 401 — but the\n// MCP SDK wraps thrown tool errors into a 200 JSON-RPC tool result, so the\n// client never sees an HTTP 401 and never re-runs its OAuth flow. The user is\n// stuck with bare \"Unauthorized\" tool errors until they manually reconnect.\n//\n// This module verifies the credential BEFORE the MCP request is handled, so\n// a dead credential turns into a proper HTTP 401 + WWW-Authenticate response\n// (src/http.ts writeUnauthorized) — the signal OAuth-capable MCP clients use\n// to re-authenticate automatically.\n//\n// Verification is one authenticated `myself { id }` GraphQL query. Observed live:\n// - never-valid key → HTTP 401\n// - anonymous (no ident) → HTTP 200 with `myself: null`\n// - valid key → HTTP 200 with `myself.id`\n//\n// NOT yet observed: what a once-valid, since-REVOKED key returns. The assumption\n// is HTTP 401, same as a key that never existed — but if it instead answers\n// `{data: null, errors: [...]}` that lands in the indeterminate branch below and\n// the gate fails open, silently never firing for the case it exists for. Probe a\n// genuinely revoked key to settle it.\n// Verdicts are cached in-memory by token hash (never the raw token) so a warm\n// instance adds no per-request latency: valid verdicts live for a few minutes,\n// invalid ones briefly (a just-reauthorized user shouldn't wait long). Network\n// failures and 5xx responses FAIL OPEN — the request proceeds and the tools\n// surface the real upstream error — so an auth-backend blip can't take down\n// the whole server.\n\nimport { createHash } from 'node:crypto';\n\n// `unknown` is the fail-open case: the request proceeds, but it is a GUESS and is\n// never cached — caching one lets a revoked credential skip the 401 for a full\n// TTL, which is the behaviour this module exists to remove.\nexport type CredentialVerdict =\n | { status: 'valid' }\n | { status: 'invalid'; reason: string }\n | { status: 'unknown' };\n\nexport type CredentialChecker = (token: string) => Promise<CredentialVerdict>;\n\n// A checker plus the hook needed to correct a stale verdict (see invalidate).\nexport interface CredentialCheckerHandle {\n verify: CredentialChecker;\n // Drop a cached verdict. Called when a tool's own upstream call 401s, which\n // proves the cached \"valid\" wrong sooner than its TTL would.\n invalidate: (token: string) => void;\n}\n\n// Named AuthProbeFetch/AuthProbeResponse, not FetchLike: _shared/http.ts already\n// owns that name for a stricter, incompatible shape, and backend.ts documents the\n// same convention for its own ProbeFetch.\ninterface AuthProbeResponse {\n ok: boolean;\n status: number;\n json(): Promise<unknown>;\n}\n\ntype AuthProbeFetch = (\n url: string,\n init: {\n method: string;\n headers: Record<string, string>;\n body: string;\n signal?: AbortSignal;\n }\n) => Promise<AuthProbeResponse>;\n\n// A valid verdict is short-lived on purpose. It bounds how long a key revoked\n// mid-session keeps passing the gate and failing upstream instead — the exact\n// experience this module exists to remove — for the cost of one cheap query a\n// minute per active token.\nconst VALID_TTL_MS = 60_000;\nconst INVALID_TTL_MS = 30_000;\n// The pre-flight blocks the MCP request, so a slow auth backend must not be\n// able to hold it open. Without this the request hangs until the platform's own\n// limit (Vercel maxDuration 60s) turns it into a 504 — failing OPEN covers an\n// erroring backend but not a slow one. Matches PROBE_TIMEOUT_MS in backend.ts.\nconst REQUEST_TIMEOUT_MS = 4000;\n// Cap so a stream of one-off tokens cannot grow the map without bound: entries\n// expire lazily (only on a repeat lookup of the same token), and an unauthed\n// caller can mint arbitrarily many distinct tokens.\nconst MAX_CACHE_ENTRIES = 1000;\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex');\n}\n\nexport function createCredentialChecker(deps: {\n fetch: AuthProbeFetch;\n // Resolved per call so an env change takes effect without a module reload\n // (matches how the tool runtime resolves its base URLs).\n url: () => string;\n now?: () => number;\n validTtlMs?: number;\n invalidTtlMs?: number;\n timeoutMs?: number;\n maxEntries?: number;\n}): CredentialCheckerHandle {\n // Monotonic by default, not Date.now: TTLs are elapsed-time bounds, and a\n // wall-clock step backwards (NTP/VM correction) against an absolute Date.now\n // stamp would silently extend a cached verdict well past its TTL — turning the\n // documented ~60s revocation bound into minutes. Tests inject their own `now`.\n const now = deps.now ?? (() => performance.now());\n const validTtl = deps.validTtlMs ?? VALID_TTL_MS;\n const invalidTtl = deps.invalidTtlMs ?? INVALID_TTL_MS;\n const timeoutMs = deps.timeoutMs ?? REQUEST_TIMEOUT_MS;\n // Clamped so the two call sites below need no degenerate-case guards.\n const maxEntries = Math.max(1, deps.maxEntries ?? MAX_CACHE_ENTRIES);\n // ONE entry per token: either a check in flight or a settled verdict. A\n // settling check writes its result only if the entry it started with is still\n // the one in the map, and invalidate() removes the entry — so a check already\n // running when a credential is rejected cannot resurrect its stale verdict.\n //\n // Insertion-ordered, so this doubles as the LRU: a hit re-inserts to move the\n // key to the back (see verify), and eviction takes the front.\n interface Entry {\n // Set while a check is running; cleared once it settles.\n promise?: Promise<CredentialVerdict>;\n // Set once a DEFINITIVE answer arrives. An indeterminate fail-open is never\n // stored — it is a guess, and caching a guess for a full TTL lets a dead\n // credential skip the 401 for that window, the bug this module exists to fix.\n verdict?: CredentialVerdict;\n expiresAt: number;\n }\n const entries = new Map<string, Entry>();\n\n async function fetchVerdict(token: string): Promise<CredentialVerdict> {\n try {\n const response = await deps.fetch(deps.url(), {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify({ query: 'query { myself { id } }' }),\n signal: AbortSignal.timeout(timeoutMs),\n });\n\n if (response.status === 401) {\n return {\n status: 'invalid',\n reason:\n 'The Runpod API rejected the credential (it may have been revoked).',\n };\n }\n // 403 is NOT a dead credential. This host sits behind Cloudflare, so a\n // WAF/bot block answers 403 with no relation to the token, and a\n // permission-scoped key can 403 while still working for other calls.\n // Treating it as dead sent a 401 back, the client re-ran OAuth, and that\n // MINTS A NEW API KEY on every attempt — a loop that never recovers and\n // fills the user's dashboard with keys. Fail open and let the tool\n // surface the real error.\n if (!response.ok) return { status: 'unknown' };\n\n const result = (await response.json()) as {\n data?: { myself?: { id?: string } | null };\n errors?: unknown[];\n };\n if (result?.data?.myself?.id) return { status: 'valid' };\n // A GraphQL response that carries errors is NOT authoritative about the\n // credential. Per the GraphQL spec a resolver failure on the nullable\n // `myself` field answers HTTP 200 with `myself: null` AND an `errors`\n // array — that null reflects a server-side fault, not a dead token. The\n // shape is identical for every caller during a backend blip, so treating\n // it as `invalid` would 401 EVERY valid credential at once and drive every\n // OAuth client into the re-auth key-minting loop the 403 branch above\n // exists to prevent — and it fails closed, which is worse than 403's\n // fail-open. So fail open on any errors; a genuine anonymous token returns\n // a clean `myself: null` with no errors (verified live).\n if (Array.isArray(result?.errors) && result.errors.length > 0) {\n return { status: 'unknown' };\n }\n if (result?.data && result.data.myself === null) {\n // The backend treated the request as anonymous — the token carries no\n // identity, so every downstream call would 401.\n return {\n status: 'invalid',\n reason: 'The credential does not resolve to a Runpod account.',\n };\n }\n // Unrecognized shape (including a bare {data: null}) — fail open.\n return { status: 'unknown' };\n } catch {\n // Network error, or the timeout above aborting — fail open.\n return { status: 'unknown' };\n }\n }\n\n function evictIfFull(): void {\n // Never evict an entry whose check is still in flight. Doing so disowns a\n // registered check — its settle handler then fails the `entries.get(key) ===\n // entry` identity test and drops its verdict — which breaks the one-in-flight-\n // check-per-token invariant that makes last-writer-wins safe. Without this a\n // flood of distinct tokens (each cached as a definitive `invalid`, so they\n // hold the map full) could evict a legitimate user's in-flight check and let\n // a since-revoked key cache as `valid` for a full TTL, and duplicate the\n // upstream call. Only settled entries are evicted, oldest first (insertion\n // order = LRU). If every entry is in flight we allow a brief, bounded\n // overshoot: in-flight count is capped by request concurrency and each check\n // self-clears within the request timeout.\n while (entries.size >= maxEntries) {\n let victim: string | undefined;\n for (const [k, e] of entries) {\n if (!e.promise) {\n victim = k;\n break;\n }\n }\n if (victim === undefined) return;\n entries.delete(victim);\n }\n }\n\n async function verify(token: string): Promise<CredentialVerdict> {\n const key = hashToken(token);\n const existing = entries.get(key);\n\n if (existing?.verdict) {\n if (existing.expiresAt > now()) {\n // Refresh recency so a hot token is not evicted ahead of a cold one.\n entries.delete(key);\n entries.set(key, existing);\n return existing.verdict;\n }\n entries.delete(key);\n } else if (existing?.promise) {\n // Join the check already running for this token rather than duplicating it.\n return existing.promise;\n }\n\n const entry: Entry = { expiresAt: 0 };\n entry.promise = fetchVerdict(token).then((verdict) => {\n // Only RECORD the result if this entry is still the one registered. A\n // different entry means invalidate() removed ours, or a newer check\n // replaced it, so this answer must not be stored — and must not delete the\n // newer entry either. The caller that started this check is still served\n // its own result; only the shared state is protected.\n if (entries.get(key) === entry) {\n if (verdict.status !== 'unknown') {\n entry.verdict = verdict;\n entry.expiresAt =\n now() + (verdict.status === 'valid' ? validTtl : invalidTtl);\n entry.promise = undefined;\n } else {\n entries.delete(key);\n }\n }\n return verdict;\n });\n\n evictIfFull();\n entries.set(key, entry);\n return entry.promise;\n }\n\n return {\n verify,\n invalidate: (token: string) => {\n // Removing the entry is the whole mechanism: a check already in flight can\n // no longer match it on settle, so it cannot resurrect the verdict this\n // call just rejected. Nothing to prune afterwards, so nothing can leak.\n entries.delete(hashToken(token));\n },\n };\n}\n"],"mappings":";;;;;;;;;;AAAA,SAAS,qCAAqC;;;ACA9C,SAAS,iBAAiB;AAQnB,IAAM,cAAc;AACpB,IAAM,iBACX,OAA6C,UAAsB;AAS9D,SAAS,aAAa,UAAkB,gBAA2B;AACxE,SAAO,IAAI,UAAU;AAAA,IACnB,MAAM;AAAA,IACN,SAAS,GAAG,OAAO,KAAK,mBAAmB,QAAQ,GAAU,CAAC;AAAA,IAC9D,cAAc;AAAA,MACZ,WAAW,CAAC;AAAA,MACZ,OAAO,CAAC;AAAA,IACV;AAAA,EACF,CAAC;AACH;;;ACEA,SAAS,kBAAkB;AA2C3B,IAAM,eAAe;AACrB,IAAM,iBAAiB;AAKvB,IAAM,qBAAqB;AAI3B,IAAM,oBAAoB;AAE1B,SAAS,UAAU,OAAuB;AACxC,SAAO,WAAW,QAAQ,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK;AACxD;AAEO,SAAS,wBAAwB,MAUZ;AAK1B,QAAM,MAAM,KAAK,QAAQ,MAAM,YAAY,IAAI;AAC/C,QAAM,WAAW,KAAK,cAAc;AACpC,QAAM,aAAa,KAAK,gBAAgB;AACxC,QAAM,YAAY,KAAK,aAAa;AAEpC,QAAM,aAAa,KAAK,IAAI,GAAG,KAAK,cAAc,iBAAiB;AAiBnE,QAAM,UAAU,oBAAI,IAAmB;AAEvC,iBAAe,aAAa,OAA2C;AACrE,QAAI;AACF,YAAM,WAAW,MAAM,KAAK,MAAM,KAAK,IAAI,GAAG;AAAA,QAC5C,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,gBAAgB;AAAA,UAChB,eAAe,UAAU,KAAK;AAAA,QAChC;AAAA,QACA,MAAM,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC;AAAA,QACzD,QAAQ,YAAY,QAAQ,SAAS;AAAA,MACvC,CAAC;AAED,UAAI,SAAS,WAAW,KAAK;AAC3B,eAAO;AAAA,UACL,QAAQ;AAAA,UACR,QACE;AAAA,QACJ;AAAA,MACF;AAQA,UAAI,CAAC,SAAS,GAAI,QAAO,EAAE,QAAQ,UAAU;AAE7C,YAAM,SAAU,MAAM,SAAS,KAAK;AAIpC,UAAI,QAAQ,MAAM,QAAQ,GAAI,QAAO,EAAE,QAAQ,QAAQ;AAWvD,UAAI,MAAM,QAAQ,QAAQ,MAAM,KAAK,OAAO,OAAO,SAAS,GAAG;AAC7D,eAAO,EAAE,QAAQ,UAAU;AAAA,MAC7B;AACA,UAAI,QAAQ,QAAQ,OAAO,KAAK,WAAW,MAAM;AAG/C,eAAO;AAAA,UACL,QAAQ;AAAA,UACR,QAAQ;AAAA,QACV;AAAA,MACF;AAEA,aAAO,EAAE,QAAQ,UAAU;AAAA,IAC7B,QAAQ;AAEN,aAAO,EAAE,QAAQ,UAAU;AAAA,IAC7B;AAAA,EACF;AAEA,WAAS,cAAoB;AAY3B,WAAO,QAAQ,QAAQ,YAAY;AACjC,UAAI;AACJ,iBAAW,CAAC,GAAG,CAAC,KAAK,SAAS;AAC5B,YAAI,CAAC,EAAE,SAAS;AACd,mBAAS;AACT;AAAA,QACF;AAAA,MACF;AACA,UAAI,WAAW,OAAW;AAC1B,cAAQ,OAAO,MAAM;AAAA,IACvB;AAAA,EACF;AAEA,iBAAe,OAAO,OAA2C;AAC/D,UAAM,MAAM,UAAU,KAAK;AAC3B,UAAM,WAAW,QAAQ,IAAI,GAAG;AAEhC,QAAI,UAAU,SAAS;AACrB,UAAI,SAAS,YAAY,IAAI,GAAG;AAE9B,gBAAQ,OAAO,GAAG;AAClB,gBAAQ,IAAI,KAAK,QAAQ;AACzB,eAAO,SAAS;AAAA,MAClB;AACA,cAAQ,OAAO,GAAG;AAAA,IACpB,WAAW,UAAU,SAAS;AAE5B,aAAO,SAAS;AAAA,IAClB;AAEA,UAAM,QAAe,EAAE,WAAW,EAAE;AACpC,UAAM,UAAU,aAAa,KAAK,EAAE,KAAK,CAAC,YAAY;AAMpD,UAAI,QAAQ,IAAI,GAAG,MAAM,OAAO;AAC9B,YAAI,QAAQ,WAAW,WAAW;AAChC,gBAAM,UAAU;AAChB,gBAAM,YACJ,IAAI,KAAK,QAAQ,WAAW,UAAU,WAAW;AACnD,gBAAM,UAAU;AAAA,QAClB,OAAO;AACL,kBAAQ,OAAO,GAAG;AAAA,QACpB;AAAA,MACF;AACA,aAAO;AAAA,IACT,CAAC;AAED,gBAAY;AACZ,YAAQ,IAAI,KAAK,KAAK;AACtB,WAAO,MAAM;AAAA,EACf;AAEA,SAAO;AAAA,IACL;AAAA,IACA,YAAY,CAAC,UAAkB;AAI7B,cAAQ,OAAO,UAAU,KAAK,CAAC;AAAA,IACjC;AAAA,EACF;AACF;;;AFrPA,SAAS,mBAAmB,KAAqC;AAC/D,QAAM,aAAa,IAAI,QAAQ;AAC/B,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,QAAQ,WAAW,MAAM,GAAG;AAClC,MAAI,MAAM,WAAW,KAAK,MAAM,CAAC,EAAE,YAAY,MAAM,SAAU,QAAO;AACtE,SAAO,MAAM,CAAC;AAChB;AAEA,SAAS,WAAW,KAA8B;AAChD,QAAM,QACH,IAAI,QAAQ,mBAAmB,GAA0B,MAAM,GAAG,EAAE,CAAC,KACtE;AACF,QAAM,OAAO,IAAI,QAAQ;AACzB,MAAI,CAAC,MAAM;AACT,UAAM,IAAI,MAAM,qBAAqB;AAAA,EACvC;AAEA,SAAO,GAAG,KAAK,MAAM,IAAI;AAC3B;AAEA,SAAS,kBACP,KACA,KACA,OAIA,eAAe,OACT;AAGN,QAAM,YAAY;AAAA,IAChB;AAAA,IACA,GAAI,eACA;AAAA,MACE;AAAA,MACA;AAAA,IACF,IACA,CAAC;AAAA,IACL,sBAAsB;AAAA,MACpB;AAAA,IACF,CAAC;AAAA,EACH,EAAE,KAAK,IAAI;AAEX,MAAI,UAAU,KAAK;AAAA,IACjB,gBAAgB;AAAA,IAChB,oBAAoB;AAAA,EACtB,CAAC;AACD,MAAI,IAAI,KAAK,UAAU,EAAE,MAAM,CAAC,CAAC;AACnC;AAQA,SAAS,eACP,KACQ;AACR,SAAO,kBAAkB,GAAU;AACrC;AAIO,IAAM,2BACX,wBAAwB;AAAA,EACtB,OAAO,CAAC,KAAK,SAAS,MAAM,KAAK,IAAI;AAAA,EACrC,KAAK,MAAM,eAAe,QAAQ,GAAG;AACvC,CAAC;AAEH,IAAM,WAAW,CAAC,GAAW,MAAc,aAAa,CAAC,MAAM,aAAa,CAAC;AAI7E,SAAS,aAAa,KAAqB;AACzC,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,WAAO,GAAG,EAAE,QAAQ,KAAK,EAAE,KAAK,YAAY,CAAC,GAAG,EAAE,SAAS,QAAQ,QAAQ,EAAE,CAAC;AAAA,EAChF,QAAQ;AACN,WAAO,IAAI,KAAK,EAAE,YAAY,EAAE,QAAQ,QAAQ,EAAE;AAAA,EACpD;AACF;AAeA,SAAS,qCAAqC,MAAM,QAAQ,KAAc;AAIxE,QAAM,oBAAoB,OAAO;AAAA,IAC/B,OAAO,QAAQ,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC,MAAM,MAAM,EAAE;AAAA,EAChD;AACA,QAAM,YACJ,CAAC,SAAS,WAAW,iBAAiB,GAAG,WAAW,CAAC,CAAC,CAAC,KACvD,CAAC,SAAS,WAAW,iBAAiB,GAAG,WAAW,CAAC,CAAC,CAAC,KACvD,CAAC,SAAS,eAAe,iBAAiB,GAAG,eAAe,CAAC,CAAC,CAAC;AACjE,QAAM,eAAe,CAAC,SAAS,eAAe,GAAG,GAAG,eAAe,CAAC,CAAC,CAAC;AAUtE,SAAO,cAAc;AACvB;AAKA,SAAS,oBAAoB,OAAyB;AACpD,MAAI,OAAO,UAAU,YAAY,UAAU,KAAM,QAAO;AACxD,QAAM,MAAM;AACZ,SAAO,IAAI,YAAY,SAAS,OAAO,IAAI,WAAW;AACxD;AAgBO,SAAS,gBAAgB,MAAwB;AACtD,QAAM,WAAW,MAAM,QAAQ,IAAI,IAAI,OAAO,CAAC,IAAI;AACnD,SAAO,SAAS,KAAK,mBAAmB;AAC1C;AAUA,SAAS,kBAAkB,KAA+B;AACxD,QAAM,KAAK,IAAI,QAAQ,cAAc;AACrC,MAAI,CAAC,GAAI,QAAO;AAChB,SAAO,GAAG,YAAY,EAAE,SAAS,kBAAkB;AACrD;AAIA,IAAM,iBAAiB,IAAI,OAAO;AAIlC,IAAM,iBAAiB,OAAO,gBAAgB;AAgB9C,eAAe,aACb,KAC0C;AAC1C,QAAM,WAAY,IAA2B;AAC7C,MAAI,aAAa,OAAW,QAAO;AACnC,QAAM,WAAW,OAAO,IAAI,QAAQ,gBAAgB,CAAC;AACrD,MAAI,OAAO,SAAS,QAAQ,KAAK,WAAW,gBAAgB;AAC1D,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,SAAmB,CAAC;AAC1B,QAAI,QAAQ;AACZ,qBAAiB,SAAS,KAAK;AAC7B,YAAM,MAAM,OAAO,SAAS,KAAK,IAAI,QAAQ,OAAO,KAAK,KAAK;AAC9D,eAAS,IAAI;AAGb,UAAI,QAAQ,eAAgB,QAAO;AACnC,aAAO,KAAK,GAAG;AAAA,IACjB;AACA,UAAM,MAAM,OAAO,OAAO,MAAM,EAAE,SAAS,MAAM,EAAE,KAAK;AACxD,WAAO,MAAM,KAAK,MAAM,GAAG,IAAI;AAAA,EACjC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAoBA,SAAS,yBAAyB,KAAyC;AACzE,MAAI,QAAQ,IAAI,8BAA8B,OAAQ,QAAO;AAC7D,MAAI,IAAI,WAAW,OAAQ,QAAO;AAClC,MAAI,CAAC,kBAAkB,GAAG,EAAG,QAAO;AACpC,MAAI,qCAAqC,EAAG,QAAO;AACnD,SAAO;AACT;AAIO,SAAS,iBACd,KACA,aACA,OAGI,CAAC,GACQ;AACb,QAAM,OAAO,KAAK,wBAAwB,yBAAyB;AACnE,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,WAAW;AAAA;AAAA;AAAA;AAAA,IAIX,iBAAiB,IAAI,QAAQ,YAAY;AAAA,IACzC,eAAe,KAAK;AAAA;AAAA;AAAA,IAGpB,gBAAgB,MAAM,KAAK,WAAW;AAAA,EACxC;AACF;AAEA,SAAS,mBACP,SACM;AAIN,MAAI,YAAY,UAAU,QAAQ,IAAI,qBAAqB,OAAQ;AACnE,UAAQ,IAAI,oBAAoB,EAAE,QAAQ,CAAC;AAC7C;AAqBA,eAAsB,iBACpB,KACA,KACA,OAKI,CAAC,GACU;AACf,QAAM,cAAc,mBAAmB,GAAG;AAC1C,UAAQ,IAAI,eAAe;AAAA,IACzB,QAAQ,IAAI;AAAA,IACZ,KAAK,IAAI;AAAA,IACT,gBAAgB,CAAC,CAAC;AAAA,EACpB,CAAC;AACD,MAAI,CAAC,aAAa;AAChB;AAAA,MACE;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA;AAAA,EACF;AASA,MAAI,OAAiB,IAA2B;AAChD,QAAM,QAAQ,yBAAyB,GAAG;AAC1C,MAAI,OAAO;AACT,uBAAmB,KAAK;AAAA,EAC1B,OAAO;AACL,UAAM,OAAO,MAAM,aAAa,GAAG;AACnC,QAAI,SAAS,gBAAgB;AAC3B,UAAI,UAAU,KAAK,EAAE,gBAAgB,mBAAmB,CAAC;AACzD,UAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D;AAAA,IACF;AACA,WAAO;AACP,QAAI,CAAC,gBAAgB,IAAI,GAAG;AAG1B,yBAAmB,mBAAmB;AAAA,IACxC,OAAO;AACL,YAAM,SAAS,KAAK,oBAAoB,yBAAyB;AACjE,YAAM,UAAU,MAAM,OAAO,WAAW;AAIxC;AAAA,QACE,QAAQ,WAAW,YACf,WACA,QAAQ,WAAW,UACjB,SACA;AAAA,MACR;AACA,UAAI,QAAQ,WAAW,WAAW;AAChC;AAAA,UACE;AAAA,UACA;AAAA,UACA,GAAG,QAAQ,MAAM;AAAA,UACjB;AAAA,QACF;AACA;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,QAAM,SAAS,aAAa,KAAK,aAAa;AAC9C,gBAAc,QAAQ,iBAAiB,KAAK,aAAa,IAAI,CAAC;AAE9D,QAAM,YAAY,IAAI,8BAA8B;AAAA,IAClD,oBAAoB;AAAA;AAAA,EACtB,CAAC;AAID,MAAI,GAAG,SAAS,MAAM;AACpB,cAAU,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAChC,WAAO,MAAM,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EAC/B,CAAC;AAED,QAAM,OAAO,QAAQ,SAAS;AAI9B,QAAM,UAAU,cAAc,KAAK,KAAK,IAAI;AAC9C;","names":[]}