From 773a4ceccab267b8346faf0e302f07ce108847b6 Mon Sep 17 00:00:00 2001 From: Luke Piette Date: Thu, 30 Jul 2026 18:48:32 -0400 Subject: [PATCH 1/3] Add get-capacity: GPU capacity matrix across host CUDA versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One credential-free catalog call answers 'which GPUs have stock on which host CUDA versions' — previously an undocumented per-version fan-out over gpuTypes.lowestPrice that agents had to reverse-engineer. Default mode returns the full AVAILABLE/UNAVAILABLE matrix via gpuTypeCudaVersions; passing cudaVersions deep-probes those versions for graded stock (High/Medium/Low) and per-version pricing. Values inlined into the GraphQL query (the public path takes no variables) are runtime-validated against injection since direct handler calls bypass zod. Co-Authored-By: Claude Fable 5 --- .changeset/get-capacity-tool.md | 5 + src/tools/catalog.ts | 230 +++++++++++++++++++++++++++++++ tests/handlers.test.ts | 183 ++++++++++++++++++++++++ tests/spec-parity.test.ts | 2 + tests/tools-registration.test.ts | 1 + 5 files changed, 421 insertions(+) create mode 100644 .changeset/get-capacity-tool.md diff --git a/.changeset/get-capacity-tool.md b/.changeset/get-capacity-tool.md new file mode 100644 index 0000000..66e8289 --- /dev/null +++ b/.changeset/get-capacity-tool.md @@ -0,0 +1,5 @@ +--- +'@runpod/mcp-server': minor +--- + +Add `get-capacity`: GPU capacity across host CUDA versions as one matrix call. Agents picking an endpoint's `allowedCudaVersions`/`minCudaVersion` (or diagnosing a capacity-starved endpoint) previously had to reverse-engineer per-version stock by calling the GraphQL `gpuTypes.lowestPrice` query once per CUDA version — an undocumented idiom nobody discovers organically. The default mode returns, per GPU type, overall stock plus AVAILABLE/UNAVAILABLE per host-reported CUDA version in a single credential-free catalog query; passing `cudaVersions` deep-probes those versions instead, returning graded stock (High/Medium/Low) and the lowest on-demand price per version. Works on both v1 and v2 APIs (the v2 REST catalog has no CUDA dimension, so both versions use the public GraphQL catalog). diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index c462236..1aa272c 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -348,6 +348,236 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { } ); + // Get capacity (GPU × host-CUDA availability). Public GraphQL on both API + // versions — the v2 REST catalog has no CUDA dimension, so unlike the other + // catalog tools this one never branches on backendFor. + server.tool( + 'get-capacity', + "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) and the lowest on-demand price per version — in probe mode GPUs with no stock on any probed version are omitted. Credential-free public catalog data; works on both v1 and v2 APIs. Note: the matrix reflects host-reported versions, but endpoint allowedCudaVersions only accepts values from the platform enum — check create-endpoint/update-endpoint for the accepted list.", + { + ...listPaginationParams, + cudaVersions: z + .array(z.string().regex(/^\d{1,2}\.\d{1,2}$/)) + .max(12) + .optional() + .describe( + '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.' + ), + gpuTypeIds: z + .array(z.string()) + .optional() + .describe( + "Filter to GPU types matching any of these ids or display names (case-insensitive substring, e.g. ['NVIDIA GeForce RTX 4090'] or ['4090', 'H200'])." + ), + gpuCount: z + .number() + .int() + .min(1) + .max(8) + .optional() + .describe('GPUs per worker/pod to check stock for (default 1).'), + secureCloudOnly: z + .boolean() + .optional() + .describe('Restrict the stock lookup to Secure Cloud hosts.'), + }, + { title: 'Get GPU capacity by CUDA version', ...READ_ONLY }, + async (params) => { + // The public GraphQL path takes no variables, so arguments are inlined. + // Every inlined value is re-validated here at runtime (not just in zod) + // because direct handler calls can bypass schema validation: gpuCount is + // coerced to an int in [1,8], cudaVersions is regex-checked below, and + // secureCloud is a literal. + const gpuCount = Math.min( + 8, + Math.max(1, Math.floor(Number(params.gpuCount)) || 1) + ); + const secureArg = params.secureCloudOnly ? ', secureCloud: true' : ''; + + interface CapacityGpu { + id: string; + displayName: string; + memoryInGb: number; + secureCloud: boolean; + communityCloud: boolean; + lowestPrice?: { + stockStatus: string | null; + uninterruptablePrice: number | null; + gpuTypeCudaVersions?: Array<{ + cudaVersion: string; + availability: string; + }> | null; + } | null; + } + interface CapacityResponse { + gpuTypes: CapacityGpu[]; + } + + const matchesFilter = (gpu: CapacityGpu) => { + if (!params.gpuTypeIds?.length) return true; + const id = gpu.id.toLowerCase(); + const name = gpu.displayName.toLowerCase(); + return params.gpuTypeIds.some((t) => { + const term = t.toLowerCase(); + return id.includes(term) || name.includes(term); + }); + }; + + const stockPriority: Record = { + High: 3, + Medium: 2, + Low: 1, + }; + + // Matrix mode: one query, per-version availability from the fleet. + if (!params.cudaVersions?.length) { + const data = await graphql(` + query { + gpuTypes { + id + displayName + memoryInGb + secureCloud + communityCloud + lowestPrice(input: { gpuCount: ${gpuCount}${secureArg} }) { + stockStatus + uninterruptablePrice + gpuTypeCudaVersions { + cudaVersion + availability + } + } + } + } + `); + const rows = data.gpuTypes + .filter((gpu) => gpu.id !== 'unknown' && matchesFilter(gpu)) + .map((gpu) => { + const cuda: Record = {}; + for (const c of gpu.lowestPrice?.gpuTypeCudaVersions ?? []) { + cuda[c.cudaVersion] = c.availability; + } + return { + id: gpu.id, + displayName: gpu.displayName, + memoryGb: gpu.memoryInGb, + secureCloud: gpu.secureCloud, + communityCloud: gpu.communityCloud, + stockStatus: gpu.lowestPrice?.stockStatus || 'unavailable', + pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null, + cudaVersions: cuda, + }; + }); + const availableCount = (r: (typeof rows)[number]) => + Object.values(r.cudaVersions).filter((a) => a === 'AVAILABLE').length; + rows.sort((a, b) => { + const diff = availableCount(b) - availableCount(a); + if (diff !== 0) return diff; + return ( + (stockPriority[b.stockStatus] || 0) - + (stockPriority[a.stockStatus] || 0) + ); + }); + return capListResult(rows, { + limit: params.limit, + cursor: params.cursor, + }); + } + + // Probe mode: one stock lookup per requested version, merged per GPU. + // Zod caps at 12, but the cap is re-enforced here because direct handler + // calls (tests, other transports) can bypass schema validation. + const versions = [...new Set(params.cudaVersions)].slice(0, 12); + const invalid = versions.filter((v) => !/^\d{1,2}\.\d{1,2}$/.test(v)); + if (invalid.length > 0) { + return jsonReply({ + error: `Invalid CUDA version format: ${invalid.join(', ')}. Use "major.minor" strings like "12.8".`, + status: 400, + }); + } + const perVersion = await Promise.all( + versions.map((v) => + graphql(` + query { + gpuTypes { + id + displayName + memoryInGb + secureCloud + communityCloud + lowestPrice(input: { gpuCount: ${gpuCount}, allowedCudaVersions: ["${v}"]${secureArg} }) { + stockStatus + uninterruptablePrice + } + } + } + `) + ) + ); + + const byId = new Map< + string, + { + id: string; + displayName: string; + memoryGb: number; + secureCloud: boolean; + communityCloud: boolean; + cudaVersions: Record< + string, + { stock: string; pricePerHr: number | null } + >; + } + >(); + versions.forEach((v, i) => { + for (const gpu of perVersion[i].gpuTypes) { + if (gpu.id === 'unknown' || !matchesFilter(gpu)) continue; + const stock = gpu.lowestPrice?.stockStatus; + // No stockStatus for this version means no matching hosts — omit the + // cell so rows only carry versions with actual capacity. + if (!stock || stock === 'Out') continue; + let row = byId.get(gpu.id); + if (!row) { + row = { + id: gpu.id, + displayName: gpu.displayName, + memoryGb: gpu.memoryInGb, + secureCloud: gpu.secureCloud, + communityCloud: gpu.communityCloud, + cudaVersions: {}, + }; + byId.set(gpu.id, row); + } + row.cudaVersions[v] = { + stock, + pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null, + }; + } + }); + + const rows = [...byId.values()]; + rows.sort((a, b) => { + const diff = + Object.keys(b.cudaVersions).length - + Object.keys(a.cudaVersions).length; + if (diff !== 0) return diff; + const best = (r: (typeof rows)[number]) => + Math.max( + 0, + ...Object.values(r.cudaVersions).map( + (c) => stockPriority[c.stock] || 0 + ) + ); + return best(b) - best(a); + }); + return capListResult( + rows, + { limit: params.limit, cursor: params.cursor }, + { probedCudaVersions: versions } + ); + } + ); + // Get Data Center by id (v2-only — GET /v2/catalog/datacenters/{id}) server.tool( 'get-data-center', diff --git a/tests/handlers.test.ts b/tests/handlers.test.ts index d932924..f4c2637 100644 --- a/tests/handlers.test.ts +++ b/tests/handlers.test.ts @@ -2081,6 +2081,189 @@ describe('v1 catalog GraphQL uses the injected fetch (offline seam)', () => { }); }); +// get-capacity: GPU × host-CUDA availability. Both modes go through the +// public GraphQL catalog regardless of REST version, so these goldens pin the +// outbound query shape (matrix vs per-version probe) and the row mapping. +describe('get-capacity — GPU × CUDA availability', () => { + const matrixGpu = (over: Record) => ({ + id: 'NVIDIA GeForce RTX 4090', + displayName: 'RTX 4090', + memoryInGb: 24, + secureCloud: true, + communityCloud: true, + lowestPrice: { + stockStatus: 'Low', + uninterruptablePrice: 0.34, + gpuTypeCudaVersions: [ + { cudaVersion: '12.8', availability: 'AVAILABLE' }, + { cudaVersion: '13.0', availability: 'AVAILABLE' }, + ], + }, + ...over, + }); + + it('matrix mode → ONE public GraphQL call requesting gpuTypeCudaVersions; maps rows, sorts most-available first', async () => { + const { handlers, outbound } = harness({ + jsonBody: { + data: { + gpuTypes: [ + matrixGpu({ + id: 'NVIDIA H100 80GB HBM3', + displayName: 'H100 SXM', + lowestPrice: { + stockStatus: 'High', + uninterruptablePrice: 2.69, + gpuTypeCudaVersions: [ + { cudaVersion: '12.8', availability: 'UNAVAILABLE' }, + { cudaVersion: '13.0', availability: 'AVAILABLE' }, + ], + }, + }), + matrixGpu({}), + // The catalog's NONE-stock placeholder must never leak into rows. + matrixGpu({ id: 'unknown' }), + ], + }, + }, + }); + const out = await handlers.get('get-capacity')!({}); + assert.equal(outbound.length, 1); + assert.equal(outbound[0].url, 'https://api.runpod.io/graphql'); + const body = JSON.parse(outbound[0].body!) as { query: string }; + assert.ok(body.query.includes('gpuTypeCudaVersions')); + assert.ok(body.query.includes('gpuCount: 1')); + assert.ok(!body.query.includes('allowedCudaVersions')); + const parsed = parseText(out); + const items = parsed.items as Array>; + // unknown sentinel dropped; 4090 (2 AVAILABLE) sorts above H100 (1). + assert.equal(items.length, 2); + assert.equal(items[0].id, 'NVIDIA GeForce RTX 4090'); + assert.deepEqual(items[0].cudaVersions, { + '12.8': 'AVAILABLE', + '13.0': 'AVAILABLE', + }); + assert.equal(items[1].stockStatus, 'High'); + }); + + it('matrix mode gpuTypeIds filters by case-insensitive id/displayName substring', async () => { + const { handlers } = harness({ + jsonBody: { + data: { + gpuTypes: [ + matrixGpu({}), + matrixGpu({ id: 'NVIDIA H200', displayName: 'H200 SXM' }), + ], + }, + }, + }); + const out = await handlers.get('get-capacity')!({ + gpuTypeIds: ['h200'], + }); + const items = parseText(out).items as Array>; + assert.equal(items.length, 1); + assert.equal(items[0].id, 'NVIDIA H200'); + }); + + it('probe mode → one call per version with allowedCudaVersions inlined; merges per-GPU cells and omits no-stock GPUs', async () => { + const probeGpus = (stock128: string | null, stock130: string | null) => [ + { + data: { + gpuTypes: [ + { + id: 'NVIDIA GeForce RTX 4090', + displayName: 'RTX 4090', + memoryInGb: 24, + secureCloud: true, + communityCloud: true, + lowestPrice: stock128 + ? { stockStatus: stock128, uninterruptablePrice: 0.34 } + : null, + }, + { + id: 'NVIDIA L4', + displayName: 'L4', + memoryInGb: 24, + secureCloud: true, + communityCloud: false, + lowestPrice: null, + }, + ], + }, + }, + { + data: { + gpuTypes: [ + { + id: 'NVIDIA GeForce RTX 4090', + displayName: 'RTX 4090', + memoryInGb: 24, + secureCloud: true, + communityCloud: true, + lowestPrice: stock130 + ? { stockStatus: stock130, uninterruptablePrice: 0.34 } + : null, + }, + { + id: 'NVIDIA L4', + displayName: 'L4', + memoryInGb: 24, + secureCloud: true, + communityCloud: false, + lowestPrice: null, + }, + ], + }, + }, + ]; + const { handlers, outbound } = harness({ + jsonBodies: probeGpus('Low', 'Medium'), + }); + const out = await handlers.get('get-capacity')!({ + cudaVersions: ['12.8', '13.0'], + }); + assert.equal(outbound.length, 2); + const q1 = (JSON.parse(outbound[0].body!) as { query: string }).query; + const q2 = (JSON.parse(outbound[1].body!) as { query: string }).query; + assert.ok(q1.includes('allowedCudaVersions: ["12.8"]')); + assert.ok(q2.includes('allowedCudaVersions: ["13.0"]')); + const parsed = parseText(out); + assert.deepEqual(parsed.probedCudaVersions, ['12.8', '13.0']); + const items = parsed.items as Array>; + // L4 had no stock on either probed version → omitted entirely. + assert.equal(items.length, 1); + assert.deepEqual(items[0].cudaVersions, { + '12.8': { stock: 'Low', pricePerHr: 0.34 }, + '13.0': { stock: 'Medium', pricePerHr: 0.34 }, + }); + }); + + it('probe mode secureCloudOnly inlines secureCloud: true into the query', async () => { + const { handlers, outbound } = harness({ + jsonBodies: [{ data: { gpuTypes: [] } }], + }); + await handlers.get('get-capacity')!({ + cudaVersions: ['12.8'], + secureCloudOnly: true, + }); + const q = (JSON.parse(outbound[0].body!) as { query: string }).query; + assert.ok(q.includes('secureCloud: true')); + }); + + it('probe mode rejects malformed version strings with a 400 reply (handler-level guard, zod bypassed)', async () => { + const { handlers, outbound } = harness({}); + // Direct handler calls skip schema validation — the injection guard must + // hold on its own. This string would otherwise break out of the inlined + // GraphQL argument. + const out = await handlers.get('get-capacity')!({ + cudaVersions: ['12.8"] }) { id } }'], + }); + assert.equal(outbound.length, 0); + const parsed = parseText(out); + assert.equal(parsed.status, 400); + assert.ok(String(parsed.error).includes('Invalid CUDA version')); + }); +}); + // get-job-status queued-job diagnosis: a job stuck IN_QUEUE is ambiguous — // crash-looping (UNHEALTHY) workers and a capacity shortage look identical // from the job status alone. When the status is IN_QUEUE the tool attaches diff --git a/tests/spec-parity.test.ts b/tests/spec-parity.test.ts index 88c265f..4a6d94b 100644 --- a/tests/spec-parity.test.ts +++ b/tests/spec-parity.test.ts @@ -168,6 +168,8 @@ const ALLOWLIST_PENDING_REST: Record = { 'GPU SKU pinning is only expressible via the GraphQL saveEndpoint gpuIds string; no REST equivalent (revisit if gpuPoolIds gains SKU exclusion)', 'list-public-endpoints': 'Public Endpoints catalog is served by the public GraphQL endpoint; no v2 REST home yet (revisit when Public Endpoints get REST)', + 'get-capacity': + 'Per-CUDA-version capacity is only expressible via the public GraphQL gpuTypes.lowestPrice query (gpuTypeCudaVersions / allowedCudaVersions); the v2 REST catalog has no CUDA dimension (revisit when it does)', }; const ALLOWLIST_UNMAPPED_TOOLS: Record = { diff --git a/tests/tools-registration.test.ts b/tests/tools-registration.test.ts index beba297..702f194 100644 --- a/tests/tools-registration.test.ts +++ b/tests/tools-registration.test.ts @@ -69,6 +69,7 @@ const EXPECTED_TOOLS = [ 'get-gpu-type', 'get-cpu-type', 'get-data-center', + 'get-capacity', // hub 'list-hub-repos', 'deploy-hub-repo', From 74a807cae10d5981f640bd3c228f9423fa723509 Mon Sep 17 00:00:00 2001 From: Luke Piette Date: Thu, 30 Jul 2026 19:01:16 -0400 Subject: [PATCH 2/3] Apply stress-test review fixes to get-capacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Probe mode: Promise.allSettled — a transient failure on one version reports a per-version probeErrors entry instead of discarding the other results (matches get-job-status's best-effort fan-out stance) - Nothing hidden by default: probe mode now emits explicit Out cells and keeps no-stock GPUs (includeUnavailable:false to hide), matching the list-gpu-types catalog philosophy - Blank/non-string gpuTypeIds entries ignored instead of matching all - memoryGb tertiary sort tie-break (matches list-gpu-types); .min(1) on cudaVersions; goldens for gpuCount coercion, dedupe, probe failure, includeUnavailable, matrix secureCloudOnly - Doc drift: RUNPOD_PUBLIC_GRAPHQL_URL consumer list and architecture overview now include get-capacity (and the other GraphQL consumers) Co-Authored-By: Claude Fable 5 --- .changeset/get-capacity-tool.md | 2 +- CLAUDE.md | 4 +- docs/context.md | 2 +- pnpm-workspace.yaml | 2 + src/tools/catalog.ts | 91 +++++++++++++++++------- tests/handlers.test.ts | 120 +++++++++++++++++++++++++++++++- 6 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/.changeset/get-capacity-tool.md b/.changeset/get-capacity-tool.md index 66e8289..353628c 100644 --- a/.changeset/get-capacity-tool.md +++ b/.changeset/get-capacity-tool.md @@ -2,4 +2,4 @@ '@runpod/mcp-server': minor --- -Add `get-capacity`: GPU capacity across host CUDA versions as one matrix call. Agents picking an endpoint's `allowedCudaVersions`/`minCudaVersion` (or diagnosing a capacity-starved endpoint) previously had to reverse-engineer per-version stock by calling the GraphQL `gpuTypes.lowestPrice` query once per CUDA version — an undocumented idiom nobody discovers organically. The default mode returns, per GPU type, overall stock plus AVAILABLE/UNAVAILABLE per host-reported CUDA version in a single credential-free catalog query; passing `cudaVersions` deep-probes those versions instead, returning graded stock (High/Medium/Low) and the lowest on-demand price per version. Works on both v1 and v2 APIs (the v2 REST catalog has no CUDA dimension, so both versions use the public GraphQL catalog). +Add `get-capacity`: GPU capacity across host CUDA versions as one matrix call. Agents picking an endpoint's `allowedCudaVersions`/`minCudaVersion` (or diagnosing a capacity-starved endpoint) previously had to reverse-engineer per-version stock by calling the GraphQL `gpuTypes.lowestPrice` query once per CUDA version — an undocumented idiom nobody discovers organically. The default mode returns, per GPU type, overall stock plus AVAILABLE/UNAVAILABLE per host-reported CUDA version in a single credential-free catalog query; passing `cudaVersions` deep-probes those versions instead, returning graded stock (High/Medium/Low/Out) and the lowest on-demand price per version. Nothing is hidden by default — out-of-stock versions appear as explicit `Out` cells (set `includeUnavailable: false` to drop no-stock GPUs), and a transiently failing probe reports a per-version `probeErrors` entry instead of failing the whole call. Works on both v1 and v2 APIs (the v2 REST catalog has no CUDA dimension, so both versions use the public GraphQL catalog). diff --git a/CLAUDE.md b/CLAUDE.md index 48f5f70..3e2a0a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Prefer using paragraphs to bullet points unless directly asked. When using bulle ## Architecture -The server communicates with two separate Runpod API backends. The REST API at `https://rest.runpod.io/v1` handles all authenticated CRUD operations for Pods, endpoints, templates, network volumes, and container registry auths. It requires a `RUNPOD_API_KEY` environment variable. The GraphQL API at `https://api.runpod.io/graphql` is reached two ways. The public, unauthenticated path serves read-only discovery queries — GPU types, data centers, the Hub catalog, Public Endpoints. The authenticated path (`graphqlAuthed`, API key as a Bearer token) serves the handful of write operations that have no REST equivalent: `deploy-hub-repo` and `set-endpoint-gpus`, both of which call the `saveEndpoint` mutation. The two resolve their host from separate env vars on purpose — see `RUNPOD_PUBLIC_GRAPHQL_URL` and `RUNPOD_AUTHED_GRAPHQL_URL` below. +The server communicates with two separate Runpod API backends. The REST API at `https://rest.runpod.io/v1` handles all authenticated CRUD operations for Pods, endpoints, templates, network volumes, and container registry auths. It requires a `RUNPOD_API_KEY` environment variable. The GraphQL API at `https://api.runpod.io/graphql` is reached two ways. The public, unauthenticated path serves read-only discovery queries — GPU types, GPU capacity by CUDA version, data centers, the Hub catalog, Public Endpoints. The authenticated path (`graphqlAuthed`, API key as a Bearer token) serves the handful of write operations that have no REST equivalent: `deploy-hub-repo` and `set-endpoint-gpus`, both of which call the `saveEndpoint` mutation. The two resolve their host from separate env vars on purpose — see `RUNPOD_PUBLIC_GRAPHQL_URL` and `RUNPOD_AUTHED_GRAPHQL_URL` below. The source is split by responsibility: @@ -39,7 +39,7 @@ The hosted HTTP path (`api/index.ts` + `src/http.ts`) reads these, all optional - `RUNPOD_GRAPHQL_URL`: flash auth backend for the OAuth flow (default `https://api.runpod.io/graphql`). Also the host the hosted credential pre-flight verifies against, so unlike the guest flash-auth mutations it now receives the caller's bearer token — point it only at a host you trust with that. - `CONSOLE_BASE_URL`: console that hosts the sign-in handoff page (default `https://console.runpod.io`). - `RUNPOD_REST_API_URL` / `RUNPOD_SERVERLESS_API_URL`: override the REST and Serverless API hosts (e.g. for a dev API key). -- `RUNPOD_PUBLIC_GRAPHQL_URL`: override the public discovery GraphQL host used by `list-gpu-types`/`list-data-centers` (default `https://api.runpod.io/graphql`). Never carries a credential — safe to point at a stub. +- `RUNPOD_PUBLIC_GRAPHQL_URL`: override the public discovery GraphQL host used by `list-gpu-types`, `list-data-centers`, `get-capacity`, `list-hub-repos`, and `list-public-endpoints` (default `https://api.runpod.io/graphql`). Never carries a credential — safe to point at a stub, though note `get-capacity` has no REST fallback on either API version. - `RUNPOD_AUTHED_GRAPHQL_URL`: override the GraphQL host for **authenticated** operations with no REST equivalent — `deploy-hub-repo` and `set-endpoint-gpus` (default `https://api.runpod.io/graphql`). These send the caller's API key as a Bearer token, so only point this at a host you trust with it; on the hosted server that key is a per-user OAuth-minted one. - `RUNPOD_API_KEY_NAME`: name for the minted key (default `runpod-mcp`; set to `""` to omit for a backend without the `apiKeyName` argument). - `MCP_VERBOSE_LOGS`: set to `true` to log OAuth request ids (live auth codes) for debugging. diff --git a/docs/context.md b/docs/context.md index c29199d..4655af1 100644 --- a/docs/context.md +++ b/docs/context.md @@ -34,7 +34,7 @@ All optional, with production-safe defaults: - `RUNPOD_GRAPHQL_URL`: flash auth backend for the OAuth flow (default `https://api.runpod.io/graphql`). Also the host the hosted credential pre-flight verifies against, so unlike the guest flash-auth mutations it now receives the caller's bearer token — point it only at a host you trust with that. - `CONSOLE_BASE_URL`: console hosting the sign-in handoff page (default `https://console.runpod.io`). - `RUNPOD_REST_API_URL` / `RUNPOD_SERVERLESS_API_URL`: override the REST and Serverless API hosts. -- `RUNPOD_PUBLIC_GRAPHQL_URL`: override the public discovery GraphQL host used by `list-gpu-types`/`list-data-centers`. Never carries a credential. +- `RUNPOD_PUBLIC_GRAPHQL_URL`: override the public discovery GraphQL host used by `list-gpu-types`, `list-data-centers`, `get-capacity`, `list-hub-repos`, and `list-public-endpoints`. Never carries a credential. - `RUNPOD_AUTHED_GRAPHQL_URL`: override the GraphQL host for authenticated, no-REST-equivalent operations (`deploy-hub-repo`, `set-endpoint-gpus`). Sends the caller's API key — point it only at a trusted host. - `RUNPOD_API_KEY_NAME`: name for the minted key (default `runpod-mcp`; `""` to omit). - `MCP_VERBOSE_LOGS`: `true` to log OAuth request ids (live auth codes) for debugging. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index 1aa272c..4c5a38e 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -353,16 +353,23 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { // catalog tools this one never branches on backendFor. server.tool( 'get-capacity', - "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) and the lowest on-demand price per version — in probe mode GPUs with no stock on any probed version are omitted. Credential-free public catalog data; works on both v1 and v2 APIs. Note: the matrix reflects host-reported versions, but endpoint allowedCudaVersions only accepts values from the platform enum — check create-endpoint/update-endpoint for the accepted list.", + "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 — 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 — check create-endpoint/update-endpoint for the accepted list.", { ...listPaginationParams, cudaVersions: z .array(z.string().regex(/^\d{1,2}\.\d{1,2}$/)) + .min(1) .max(12) .optional() .describe( '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.' ), + includeUnavailable: z + .boolean() + .optional() + .describe( + '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.' + ), gpuTypeIds: z .array(z.string()) .optional() @@ -413,14 +420,19 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { gpuTypes: CapacityGpu[]; } + // Blank/non-string filter entries are ignored (an all-blank list means + // "no filter", same as omitting it) — a zod-bypassed [""] must not + // silently match the whole catalog as if it were a real term. + const filterTerms = (params.gpuTypeIds ?? []) + .filter((t) => typeof t === 'string' && t.trim().length > 0) + .map((t) => t.toLowerCase()); const matchesFilter = (gpu: CapacityGpu) => { - if (!params.gpuTypeIds?.length) return true; + if (filterTerms.length === 0) return true; const id = gpu.id.toLowerCase(); const name = gpu.displayName.toLowerCase(); - return params.gpuTypeIds.some((t) => { - const term = t.toLowerCase(); - return id.includes(term) || name.includes(term); - }); + return filterTerms.some( + (term) => id.includes(term) || name.includes(term) + ); }; const stockPriority: Record = { @@ -450,7 +462,7 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { } } `); - const rows = data.gpuTypes + let rows = data.gpuTypes .filter((gpu) => gpu.id !== 'unknown' && matchesFilter(gpu)) .map((gpu) => { const cuda: Record = {}; @@ -470,13 +482,16 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { }); const availableCount = (r: (typeof rows)[number]) => Object.values(r.cudaVersions).filter((a) => a === 'AVAILABLE').length; + if (params.includeUnavailable === false) + rows = rows.filter((r) => availableCount(r) > 0); rows.sort((a, b) => { const diff = availableCount(b) - availableCount(a); if (diff !== 0) return diff; - return ( + const stockDiff = (stockPriority[b.stockStatus] || 0) - - (stockPriority[a.stockStatus] || 0) - ); + (stockPriority[a.stockStatus] || 0); + if (stockDiff !== 0) return stockDiff; + return b.memoryGb - a.memoryGb; }); return capListResult(rows, { limit: params.limit, @@ -495,7 +510,11 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { status: 400, }); } - const perVersion = await Promise.all( + // allSettled, not all: a transient failure (429/5xx) on one probe must + // not discard the other versions' results — the same best-effort stance + // as get-job-status's worker fan-out. Failed versions are reported in a + // probeErrors sibling field instead of failing the call. + const perVersion = await Promise.allSettled( versions.map((v) => graphql(` query { @@ -515,6 +534,7 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { ) ); + const probeErrors: Record = {}; const byId = new Map< string, { @@ -530,12 +550,16 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { } >(); versions.forEach((v, i) => { - for (const gpu of perVersion[i].gpuTypes) { + const settled = perVersion[i]; + if (settled.status === 'rejected') { + probeErrors[v] = + settled.reason instanceof Error + ? settled.reason.message + : String(settled.reason); + return; + } + for (const gpu of settled.value.gpuTypes) { if (gpu.id === 'unknown' || !matchesFilter(gpu)) continue; - const stock = gpu.lowestPrice?.stockStatus; - // No stockStatus for this version means no matching hosts — omit the - // cell so rows only carry versions with actual capacity. - if (!stock || stock === 'Out') continue; let row = byId.get(gpu.id); if (!row) { row = { @@ -548,18 +572,28 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { }; byId.set(gpu.id, row); } - row.cudaVersions[v] = { - stock, - pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null, - }; + const stock = gpu.lowestPrice?.stockStatus; + // No stockStatus means no hosts match this version at all — an + // explicit "Out" cell, so starvation is visible rather than an + // absence the agent has to infer (a capacity-diagnosis tool must + // not hide the empty cells it exists to reveal). + row.cudaVersions[v] = + !stock || stock === 'Out' + ? { stock: 'Out', pricePerHr: null } + : { + stock, + pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null, + }; } }); - const rows = [...byId.values()]; + let rows = [...byId.values()]; + const inStockCount = (r: (typeof rows)[number]) => + Object.values(r.cudaVersions).filter((c) => c.stock !== 'Out').length; + if (params.includeUnavailable === false) + rows = rows.filter((r) => inStockCount(r) > 0); rows.sort((a, b) => { - const diff = - Object.keys(b.cudaVersions).length - - Object.keys(a.cudaVersions).length; + const diff = inStockCount(b) - inStockCount(a); if (diff !== 0) return diff; const best = (r: (typeof rows)[number]) => Math.max( @@ -568,12 +602,17 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { (c) => stockPriority[c.stock] || 0 ) ); - return best(b) - best(a); + const bestDiff = best(b) - best(a); + if (bestDiff !== 0) return bestDiff; + return b.memoryGb - a.memoryGb; }); return capListResult( rows, { limit: params.limit, cursor: params.cursor }, - { probedCudaVersions: versions } + { + probedCudaVersions: versions, + ...(Object.keys(probeErrors).length > 0 ? { probeErrors } : {}), + } ); } ); diff --git a/tests/handlers.test.ts b/tests/handlers.test.ts index f4c2637..ee3342f 100644 --- a/tests/handlers.test.ts +++ b/tests/handlers.test.ts @@ -2229,12 +2229,128 @@ describe('get-capacity — GPU × CUDA availability', () => { const parsed = parseText(out); assert.deepEqual(parsed.probedCudaVersions, ['12.8', '13.0']); const items = parsed.items as Array>; - // L4 had no stock on either probed version → omitted entirely. - assert.equal(items.length, 1); + // Nothing hidden by default: L4 (no stock on either version) is included + // with explicit Out cells and sorts last. + assert.equal(items.length, 2); + assert.equal(items[0].id, 'NVIDIA GeForce RTX 4090'); assert.deepEqual(items[0].cudaVersions, { '12.8': { stock: 'Low', pricePerHr: 0.34 }, '13.0': { stock: 'Medium', pricePerHr: 0.34 }, }); + assert.deepEqual(items[1].cudaVersions, { + '12.8': { stock: 'Out', pricePerHr: null }, + '13.0': { stock: 'Out', pricePerHr: null }, + }); + }); + + it('probe mode includeUnavailable:false hides GPUs with no stock on any probed version', async () => { + const gpus = (stock: string | null) => ({ + data: { + gpuTypes: [ + { + id: 'NVIDIA GeForce RTX 4090', + displayName: 'RTX 4090', + memoryInGb: 24, + secureCloud: true, + communityCloud: true, + lowestPrice: stock + ? { stockStatus: stock, uninterruptablePrice: 0.34 } + : null, + }, + { + id: 'NVIDIA L4', + displayName: 'L4', + memoryInGb: 24, + secureCloud: true, + communityCloud: false, + lowestPrice: { stockStatus: 'Out', uninterruptablePrice: null }, + }, + ], + }, + }); + const { handlers } = harness({ jsonBodies: [gpus('Low')] }); + const out = await handlers.get('get-capacity')!({ + cudaVersions: ['12.8'], + includeUnavailable: false, + }); + const items = parseText(out).items as Array>; + assert.equal(items.length, 1); + assert.equal(items[0].id, 'NVIDIA GeForce RTX 4090'); + }); + + it('probe mode survives a failed version: allSettled keeps good results and reports probeErrors', async () => { + const good = { + data: { + gpuTypes: [ + { + id: 'NVIDIA GeForce RTX 4090', + displayName: 'RTX 4090', + memoryInGb: 24, + secureCloud: true, + communityCloud: true, + lowestPrice: { stockStatus: 'Low', uninterruptablePrice: 0.34 }, + }, + ], + }, + }; + const bad = { errors: [{ message: 'rate limited' }] }; + const { handlers, outbound } = harness({ jsonBodies: [good, bad] }); + const out = await handlers.get('get-capacity')!({ + cudaVersions: ['12.8', '13.0'], + }); + assert.equal(outbound.length, 2); + const parsed = parseText(out); + const items = parsed.items as Array>; + assert.equal(items.length, 1); + assert.deepEqual(items[0].cudaVersions, { + '12.8': { stock: 'Low', pricePerHr: 0.34 }, + }); + const errs = parsed.probeErrors as Record; + assert.ok(errs['13.0'].includes('rate limited')); + assert.equal(errs['12.8'], undefined); + }); + + it('probe mode dedupes repeated versions before querying', async () => { + const { handlers, outbound } = harness({ + jsonBodies: [{ data: { gpuTypes: [] } }], + }); + const out = await handlers.get('get-capacity')!({ + cudaVersions: ['12.8', '12.8', '12.8'], + }); + assert.equal(outbound.length, 1); + assert.deepEqual(parseText(out).probedCudaVersions, ['12.8']); + }); + + it('gpuCount is runtime-coerced into the wire query (zod bypassed): 9999 → 8, non-numeric → 1', async () => { + const { handlers, outbound } = harness({ + jsonBody: { data: { gpuTypes: [] } }, + }); + await handlers.get('get-capacity')!({ gpuCount: 9999 }); + await handlers.get('get-capacity')!({ gpuCount: '8; }) { x }' }); + const q1 = (JSON.parse(outbound[0].body!) as { query: string }).query; + const q2 = (JSON.parse(outbound[1].body!) as { query: string }).query; + assert.ok(q1.includes('gpuCount: 8')); + assert.ok(q2.includes('gpuCount: 1')); + assert.ok(!q2.includes('8; }')); + }); + + it('matrix mode secureCloudOnly inlines secureCloud: true; blank gpuTypeIds entries mean no filter', async () => { + const { handlers, outbound } = harness({ + jsonBody: { + data: { + gpuTypes: [matrixGpu({}), matrixGpu({ id: 'NVIDIA H200' })], + }, + }, + }); + const out = await handlers.get('get-capacity')!({ + secureCloudOnly: true, + gpuTypeIds: ['', ' '], + }); + const q = (JSON.parse(outbound[0].body!) as { query: string }).query; + assert.ok(q.includes('secureCloud: true')); + // All-blank filter treated as "no filter", not match-everything-by-accident. + const items = parseText(out).items as Array>; + assert.equal(items.length, 2); }); it('probe mode secureCloudOnly inlines secureCloud: true into the query', async () => { From 2e78d5471b900f25ef81917cddb4ebe35c97673b Mon Sep 17 00:00:00 2001 From: Luke Piette Date: Thu, 30 Jul 2026 19:22:48 -0400 Subject: [PATCH 3/3] Correct the v2 REST CUDA claim: it has a minCudaVersion floor filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored v2 spec (and the live API) support minCudaVersion on /catalog/gpus availability — 'no CUDA dimension' was wrong. What remains GraphQL-only, and why this tool doesn't branch on backendFor: the exact per-version breakdown (gpuTypeCudaVersions), per-version graded stock (allowedCudaVersions probes), and per-version pricing. Co-Authored-By: Claude Fable 5 --- .changeset/get-capacity-tool.md | 2 +- src/tools/catalog.ts | 7 +++++-- tests/spec-parity.test.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.changeset/get-capacity-tool.md b/.changeset/get-capacity-tool.md index 353628c..4c87a17 100644 --- a/.changeset/get-capacity-tool.md +++ b/.changeset/get-capacity-tool.md @@ -2,4 +2,4 @@ '@runpod/mcp-server': minor --- -Add `get-capacity`: GPU capacity across host CUDA versions as one matrix call. Agents picking an endpoint's `allowedCudaVersions`/`minCudaVersion` (or diagnosing a capacity-starved endpoint) previously had to reverse-engineer per-version stock by calling the GraphQL `gpuTypes.lowestPrice` query once per CUDA version — an undocumented idiom nobody discovers organically. The default mode returns, per GPU type, overall stock plus AVAILABLE/UNAVAILABLE per host-reported CUDA version in a single credential-free catalog query; passing `cudaVersions` deep-probes those versions instead, returning graded stock (High/Medium/Low/Out) and the lowest on-demand price per version. Nothing is hidden by default — out-of-stock versions appear as explicit `Out` cells (set `includeUnavailable: false` to drop no-stock GPUs), and a transiently failing probe reports a per-version `probeErrors` entry instead of failing the whole call. Works on both v1 and v2 APIs (the v2 REST catalog has no CUDA dimension, so both versions use the public GraphQL catalog). +Add `get-capacity`: GPU capacity across host CUDA versions as one matrix call. Agents picking an endpoint's `allowedCudaVersions`/`minCudaVersion` (or diagnosing a capacity-starved endpoint) previously had to reverse-engineer per-version stock by calling the GraphQL `gpuTypes.lowestPrice` query once per CUDA version — an undocumented idiom nobody discovers organically. The default mode returns, per GPU type, overall stock plus AVAILABLE/UNAVAILABLE per host-reported CUDA version in a single credential-free catalog query; passing `cudaVersions` deep-probes those versions instead, returning graded stock (High/Medium/Low/Out) and the lowest on-demand price per version. Nothing is hidden by default — out-of-stock versions appear as explicit `Out` cells (set `includeUnavailable: false` to drop no-stock GPUs), and a transiently failing probe reports a per-version `probeErrors` entry instead of failing the whole call. Works on both v1 and v2 APIs — both use the public GraphQL catalog, since the v2 REST catalog's CUDA support is a `minCudaVersion` floor filter on availability, not the exact per-version breakdown, graded stock, or per-version pricing this tool returns. diff --git a/src/tools/catalog.ts b/src/tools/catalog.ts index 4c5a38e..558156a 100644 --- a/src/tools/catalog.ts +++ b/src/tools/catalog.ts @@ -349,8 +349,11 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void { ); // Get capacity (GPU × host-CUDA availability). Public GraphQL on both API - // versions — the v2 REST catalog has no CUDA dimension, so unlike the other - // catalog tools this one never branches on backendFor. + // versions — the v2 REST catalog's only CUDA dimension is a minCudaVersion + // floor filter on availability (see MinCudaVersionFilter in the vendored + // spec); the exact per-version breakdown, per-version graded stock, and + // per-version pricing this tool returns are GraphQL-only, so unlike the + // other catalog tools this one never branches on backendFor. server.tool( 'get-capacity', "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 — 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 — check create-endpoint/update-endpoint for the accepted list.", diff --git a/tests/spec-parity.test.ts b/tests/spec-parity.test.ts index 4a6d94b..e2f8484 100644 --- a/tests/spec-parity.test.ts +++ b/tests/spec-parity.test.ts @@ -169,7 +169,7 @@ const ALLOWLIST_PENDING_REST: Record = { 'list-public-endpoints': 'Public Endpoints catalog is served by the public GraphQL endpoint; no v2 REST home yet (revisit when Public Endpoints get REST)', 'get-capacity': - 'Per-CUDA-version capacity is only expressible via the public GraphQL gpuTypes.lowestPrice query (gpuTypeCudaVersions / allowedCudaVersions); the v2 REST catalog has no CUDA dimension (revisit when it does)', + 'Per-CUDA-version capacity is only expressible via the public GraphQL gpuTypes.lowestPrice query (gpuTypeCudaVersions / allowedCudaVersions); the v2 REST catalog offers only a minCudaVersion floor filter on availability, not an exact per-version breakdown (revisit if it gains one)', }; const ALLOWLIST_UNMAPPED_TOOLS: Record = {