Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/catalog-availability-product.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@runpod/mcp-server': minor
---

`list-gpu-types` and `get-gpu-type` now always send `product` with their `include=AVAILABILITY` catalog requests, via a new optional `product` parameter (`POD` | `CLUSTER` | `SERVERLESS`, default `POD`). Availability is product-specific — the same GPU can be scarce for Pods and plentiful for Serverless — and the next v2 API release makes `product` required with availability (400 without it), so this keeps the availability lookups working and lets agents ask for the context they actually deploy to: pass `SERVERLESS` when picking a GPU for an endpoint, `CLUSTER` for Instant Clusters. When `includeAvailability` is false, `product` is not sent (the API also rejects `product` without `include=AVAILABILITY`). The CPU catalog tools never request availability, so they are unaffected.
47 changes: 39 additions & 8 deletions src/tools/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { READ_ONLY, type ToolRuntime } from './runtime.js';
// catalog (GET /v2/catalog/*). The adapter picks the backend; v2-only entries
// return a 501 notice on v1.

// Availability product contexts accepted by the v2 GPU catalog. Availability is
// product-specific, so the API requires `product` with include=AVAILABILITY —
// and rejects it without (400 either way).
const GPU_PRODUCTS = new Set(['POD', 'CLUSTER', 'SERVERLESS']);

export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void {
const { jsonReply, graphql, callRestUrl, backendFor } = rt;

Expand Down Expand Up @@ -47,20 +52,33 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void {
.describe(
'Request realtime stock and annotate each GPU with an availability summary (HIGH/MEDIUM/LOW/NONE). Default true. Set false to skip the availability lookup — then out-of-stock GPUs cannot be filtered.'
),
product: z
.enum(['POD', 'CLUSTER', 'SERVERLESS'])
.optional()
.describe(
'Product context for the availability lookup — the same GPU can be scarce for Pods and plentiful for Serverless. Default POD. Use SERVERLESS when picking a GPU for an endpoint, CLUSTER for Instant Clusters. Ignored when includeAvailability is false.'
),
},
{ title: 'List GPU types', ...READ_ONLY },
async (params) => {
const backend = backendFor('gpus');
if (backend.version === 'v2') {
// v2 REST: GET /v2/catalog/gpus?include=AVAILABILITY → { gpus: [...] },
// each with an `availability` summary (HIGH/MEDIUM/LOW/NONE) and a
// per-datacenter `dataCenters` breakdown. Filters re-applied against v2
// field names. Opt out with includeAvailability:false (then the
// filter/sort below no-op, since there's no data).
// v2 REST: GET /v2/catalog/gpus?include=AVAILABILITY&product=… →
// { gpus: [...] }, each with an `availability` summary
// (HIGH/MEDIUM/LOW/NONE) and a per-datacenter `dataCenters` breakdown.
// Filters re-applied against v2 field names. Opt out with
// includeAvailability:false (then the filter/sort below no-op, since
// there's no data). `product` rides along exactly when availability is
// requested — the API requires it with include=AVAILABILITY and
// rejects it without. Re-validated here because direct handler calls
// can bypass zod (same stance as get-capacity).
const wantAvailability = params.includeAvailability !== false;
const product = GPU_PRODUCTS.has(String(params.product))
? String(params.product)
: 'POD';
const raw = await callRestUrl(
`${backend.base}${backend.list}${
wantAvailability ? '?include=AVAILABILITY' : ''
wantAvailability ? `?include=AVAILABILITY&product=${product}` : ''
}`
);
let gpus = backend.unwrap(raw) as Array<Record<string, unknown>>;
Expand Down Expand Up @@ -303,6 +321,12 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void {
.describe(
'Include realtime per-datacenter availability (HIGH/MEDIUM/LOW/NONE). Default true.'
),
product: z
.enum(['POD', 'CLUSTER', 'SERVERLESS'])
.optional()
.describe(
'Product context for the availability lookup — stock differs by product. Default POD. Use SERVERLESS when picking a GPU for an endpoint, CLUSTER for Instant Clusters. Ignored when includeAvailability is false.'
),
},
{ title: 'Get GPU type', ...READ_ONLY },
async (params) => {
Expand All @@ -315,10 +339,17 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void {
});
}
// GPU ids contain spaces (e.g. "NVIDIA GeForce RTX 4090"), so encode the
// path segment. Availability on by default — it's the point of a single GPU.
// path segment. Availability on by default — it's the point of a single
// GPU. `product` goes with it (required with include=AVAILABILITY,
// rejected without); re-validated for zod-bypassed direct calls.
const path = backend.get!(encodeURIComponent(params.gpuTypeId));
const product = GPU_PRODUCTS.has(String(params.product))
? String(params.product)
: 'POD';
const query =
params.includeAvailability === false ? '' : '?include=AVAILABILITY';
params.includeAvailability === false
? ''
: `?include=AVAILABILITY&product=${product}`;
const result = await callRestUrl(`${backend.base}${path}${query}`);
return jsonReply(result);
}
Expand Down
53 changes: 50 additions & 3 deletions tests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1426,14 +1426,47 @@ describe('catalog routing (B5)', () => {
})) as { content: Array<{ text: string }> };
assert.equal(
outbound[0].url,
'https://api.runpod.io/v2/catalog/gpus?include=AVAILABILITY'
'https://api.runpod.io/v2/catalog/gpus?include=AVAILABILITY&product=POD'
);
const payload = JSON.parse(out.content[0].text).items;
assert.equal(payload.length, 1);
assert.equal(payload[0].id, 'a100');
});
});

it('list-gpu-types v2 passes product through; omits it (and include) when availability is off', async () => {
await withV2(async () => {
const serverless = harness({ jsonBody: { gpus: [] } });
await serverless.handlers.get('list-gpu-types')!({
product: 'SERVERLESS',
});
assert.equal(
serverless.outbound[0].url,
'https://api.runpod.io/v2/catalog/gpus?include=AVAILABILITY&product=SERVERLESS'
);
// product without include=AVAILABILITY is a 400 upstream, so it must
// not be sent when the availability lookup is skipped
const off = harness({ jsonBody: { gpus: [] } });
await off.handlers.get('list-gpu-types')!({
product: 'SERVERLESS',
includeAvailability: false,
});
assert.equal(
off.outbound[0].url,
'https://api.runpod.io/v2/catalog/gpus'
);
// a zod-bypassed junk product falls back to POD rather than 400ing
const junk = harness({ jsonBody: { gpus: [] } });
await junk.handlers.get('list-gpu-types')!({
product: 'BOGUS' as never,
});
assert.equal(
junk.outbound[0].url,
'https://api.runpod.io/v2/catalog/gpus?include=AVAILABILITY&product=POD'
);
});
});

it('list-gpu-types v2 communityCloudOnly filters on g.community', async () => {
await withV2(async () => {
const gpus = [
Expand Down Expand Up @@ -1617,10 +1650,10 @@ describe('catalog routing (B5)', () => {
const out = (await handlers.get('get-gpu-type')!({
gpuTypeId: 'a100',
})) as { content: Array<{ text: string }> };
// availability is requested by default
// availability is requested by default, with its required product context
assert.equal(
outbound[0].url,
'https://api.runpod.io/v2/catalog/gpus/a100?include=AVAILABILITY'
'https://api.runpod.io/v2/catalog/gpus/a100?include=AVAILABILITY&product=POD'
);
// raw passthrough (no unwrap) — body preserved
assert.deepEqual(JSON.parse(out.content[0].text), {
Expand All @@ -1644,6 +1677,20 @@ describe('catalog routing (B5)', () => {
});
});

it('get-gpu-type v2 passes product through with the availability request', async () => {
await withV2(async () => {
const { handlers, outbound } = harness({ jsonBody: { id: 'a100' } });
await handlers.get('get-gpu-type')!({
gpuTypeId: 'a100',
product: 'SERVERLESS',
});
assert.equal(
outbound[0].url,
'https://api.runpod.io/v2/catalog/gpus/a100?include=AVAILABILITY&product=SERVERLESS'
);
});
});

it('list-gpu-types v2 caps to limit + returns a working pagination cursor', async () => {
await withV2(async () => {
const gpus = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
Expand Down
Loading