Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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/v2-cuda-constraints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@runpod/mcp-server': minor
---

Expose the REST v2 CUDA host constraints on create-pod, create-endpoint, and update-endpoint (`allowedCudaVersions`, `minCudaVersion`, nested under `gpu.*` on the wire per rphttp2 2.9.0), support CUDA-only endpoint patches without resending `gpuPoolIds`, warn when a `gpuPoolIds` update clears GPU-type exclusions set elsewhere, and add the `minCudaVersion` availability filter to list-gpu-types. Requires a server running rphttp2 2.9.0 or later for the new fields.
85 changes: 72 additions & 13 deletions src/_shared/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ interface V1PodParams {
env?: Record<string, string>;
dataCenterIds?: string[];
containerRegistryAuthId?: string;
allowedCudaVersions?: string[];
minCudaVersion?: string;
Comment thread
brodykellish marked this conversation as resolved.
}

// Drop undefined entries so we never emit explicit `undefined`/`null` the API
Expand Down Expand Up @@ -93,11 +95,20 @@ function containerConfigToV2(p: {
// always emit the documented default explicitly.
function gpuConfigToV2(
gpuTypeIds?: string[],
gpuCount?: number
gpuCount?: number,
allowedCudaVersions?: string[],
minCudaVersion?: string
): Record<string, unknown> | undefined {
const id = gpuTypeIds?.[0];
if (id === undefined) return undefined;
return { id, count: gpuCount ?? 1 };
// CUDA host constraints are gpu-nested since 2.9.0 (never body top-level).
// Passed through as given: an explicit [] is a spec-legal "no constraint".
return compact({
id,
count: gpuCount ?? 1,
allowedCudaVersions,
minCudaVersion,
});
}

export function mapPodCreateToV2(params: V1PodParams): Record<string, unknown> {
Expand All @@ -124,7 +135,12 @@ export function mapPodCreateToV2(params: V1PodParams): Record<string, unknown> {
dataCenterIds: params.dataCenterIds?.length
? params.dataCenterIds
: undefined,
gpu: gpuConfigToV2(params.gpuTypeIds, params.gpuCount),
gpu: gpuConfigToV2(
params.gpuTypeIds,
params.gpuCount,
params.allowedCudaVersions,
params.minCudaVersion
),
});
}

Expand Down Expand Up @@ -169,6 +185,8 @@ interface V2EndpointParams {
ports?: string[];
env?: Record<string, string>;
containerRegistryAuthId?: string;
allowedCudaVersions?: string[];
minCudaVersion?: string;
}

type EndpointType = 'QUEUE' | 'LOAD_BALANCER';
Expand Down Expand Up @@ -202,14 +220,24 @@ function endpointScaling(
: { type: 'QUEUE_DELAY', queueDelay: value };
}

// gpu requires `pools` (minItems 1) — return undefined when no pools so the
// handler's guard, not the API, reports the omission.
// CREATE requires `pools` (minItems 1): without them, return undefined so the
// handler's guard, not the API, reports the omission — CUDA/count fields never
// ride without a pool list on create. UPDATE (2.9.0) makes every gpu field
// optional, so a pools-less gpu (CUDA-only or count-only patch) is emitted as
// given; an empty object is still dropped. CUDA clear sentinels pass through:
// [] clears the set, "" clears the floor.
function endpointGpuConfig(
pools?: string[],
count?: number
params: V2EndpointParams,
mode: 'create' | 'update'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: comment around typing etc below.

): Record<string, unknown> | undefined {
if (!pools?.length) return undefined;
return compact({ pools, count });
const gpu = compact({
pools: params.gpuPoolIds?.length ? params.gpuPoolIds : undefined,
count: params.gpuCount,
allowedCudaVersions: params.allowedCudaVersions,
minCudaVersion: params.minCudaVersion,
});
if (mode === 'create' && !('pools' in gpu)) return undefined;
return Object.keys(gpu).length ? gpu : undefined;
}

// `workers` absorbed `idleTimeout` from `scaling`. Returns undefined when the caller
Expand All @@ -227,7 +255,10 @@ function endpointWorkers(

// The half of the body shared by create and update: container config, compute,
// placement and workers. Only `type` and `scaling` differ between the two.
function endpointCommonToV2(params: V2EndpointParams): Record<string, unknown> {
function endpointCommonToV2(
params: V2EndpointParams,
mode: 'create' | 'update'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here as well :)

): Record<string, unknown> {
return compact({
name: params.name,
image: params.imageName,
Expand All @@ -236,7 +267,7 @@ function endpointCommonToV2(params: V2EndpointParams): Record<string, unknown> {
ports: params.ports,
env: params.env,
registry: params.containerRegistryAuthId,
gpu: endpointGpuConfig(params.gpuPoolIds, params.gpuCount),
gpu: endpointGpuConfig(params, mode),
workers: endpointWorkers(params),
dataCenterIds: params.dataCenterIds?.length
? params.dataCenterIds
Expand All @@ -255,7 +286,7 @@ export function mapEndpointCreateToV2(
): Record<string, unknown> {
const endpointType = params.endpointType ?? DEFAULT_ENDPOINT_TYPE;
return compact({
...endpointCommonToV2(params),
...endpointCommonToV2(params, 'create'),
type: endpointType,
scaling: endpointScaling(
params.scalerType ?? defaultScalerType(endpointType),
Expand All @@ -273,7 +304,7 @@ export function mapEndpointUpdateToV2(
params: V2EndpointParams
): Record<string, unknown> {
return compact({
...endpointCommonToV2(params),
...endpointCommonToV2(params, 'update'),
scaling: params.scalerType
? endpointScaling(
params.scalerType,
Expand All @@ -283,6 +314,34 @@ export function mapEndpointUpdateToV2(
});
}

// ---- CUDA constraint validation shared by the pod/endpoint tools ----
// The API answers a raw 422/400; validating here names the field and the fix.
// major.minor only — the REST body fields reject a bare major like "12" (the
// catalog's minCudaVersion QUERY filter is the one place a bare major is legal).
const CUDA_MAJOR_MINOR = /^\d+\.\d+$/;

export function cudaConstraintError(
params: { allowedCudaVersions?: string[]; minCudaVersion?: string },
opts: { allowClear?: boolean } = {}
): string | undefined {
const { allowedCudaVersions, minCudaVersion } = params;
if (allowedCudaVersions?.length && minCudaVersion) {
return 'allowedCudaVersions and minCudaVersion are mutually exclusive — pass either an exact set or an open-ended floor, not both.';
}
if (
minCudaVersion !== undefined &&
!CUDA_MAJOR_MINOR.test(minCudaVersion) &&
!(opts.allowClear && minCudaVersion === '')
) {
return `minCudaVersion must be major.minor (e.g. "12.0", not "12")${opts.allowClear ? '; an empty string clears the floor' : ''}.`;
}
const bad = allowedCudaVersions?.find((v) => !CUDA_MAJOR_MINOR.test(v));
if (bad !== undefined) {
return `allowedCudaVersions entries must be major.minor (e.g. "12.8"); got "${bad}". Discover valid values via get-capacity or list-gpu-types.`;
}
return undefined;
}

// ---- Network volume: dataCenterId → dataCenter (only field change) ----
interface V1NetworkVolumeCreate {
name?: string;
Expand Down
41 changes: 40 additions & 1 deletion src/tools/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,23 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void {
.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.'
),
minCudaVersion: z
.string()
.optional()
.describe(
'Scope the availability lookup to hosts at or above this CUDA version (v2 only). Integer major or major.minor — "12" means any 12.x release; unlike the create-pod/create-endpoint body fields, a bare major is accepted here because the filter only widens a read. Requires includeAvailability (the default).'
),
},
{ title: 'List GPU types', ...READ_ONLY },
async (params) => {
const backend = backendFor('gpus');
if (params.minCudaVersion !== undefined && backend.version !== 'v2') {
return jsonReply({
error:
'The minCudaVersion filter is only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2, or use get-capacity for per-CUDA-version availability.',
status: 501,
});
}
if (backend.version === 'v2') {
// v2 REST: GET /v2/catalog/gpus?include=AVAILABILITY&product=… →
// { gpus: [...] }, each with an `availability` summary
Expand All @@ -76,9 +89,35 @@ export function registerCatalogTools(server: McpServer, rt: ToolRuntime): void {
const product = GPU_PRODUCTS.has(String(params.product))
? String(params.product)
: 'POD';
// The filter scopes the availability lookup, so it is meaningless (and
// rejected by the API) without include=AVAILABILITY. Bare major is
// legal here — this filter only widens a read. Re-validated like
// `product` because direct handler calls can bypass zod.
if (params.minCudaVersion !== undefined) {
if (!wantAvailability) {
return jsonReply({
error:
'minCudaVersion filters the availability lookup, so it requires includeAvailability (the default). Drop includeAvailability:false or the filter.',
status: 400,
});
}
if (!/^\d+(\.\d+)?$/.test(params.minCudaVersion)) {
return jsonReply({
error:
'minCudaVersion must be an integer major ("12") or major.minor ("12.1").',
status: 400,
});
}
}
const raw = await callRestUrl(
`${backend.base}${backend.list}${
wantAvailability ? `?include=AVAILABILITY&product=${product}` : ''
wantAvailability
? `?include=AVAILABILITY&product=${product}${
params.minCudaVersion !== undefined
? `&minCudaVersion=${encodeURIComponent(params.minCudaVersion)}`
: ''
}`
: ''
Comment on lines +115 to +120

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we simplify this nested ternary for readability?

}`
);
let gpus = backend.unwrap(raw) as Array<Record<string, unknown>>;
Expand Down
97 changes: 89 additions & 8 deletions src/tools/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from 'zod';
import { capListResult, listPaginationParams } from '../pagination.js';
import { READ_ONLY, WRITE, DESTRUCTIVE, type ToolRuntime } from './runtime.js';
import { logStreamParams, streamLogsReply } from './logs.js';
import { cudaConstraintError } from '../_shared/mappers.js';

// ============== ENDPOINT MANAGEMENT TOOLS ==============
// Serverless endpoint CRUD, version-aware via the backend adapter.
Expand Down Expand Up @@ -163,6 +164,18 @@ export function registerEndpointTools(
'GPU pool names (v2, required). The `pool` field from list-gpu-types, e.g. ["AMPERE_80"]. NOT GPU type ids.'
),
gpuCount: z.number().optional().describe('GPUs per worker (v2)'),
allowedCudaVersions: z
.array(z.string())
.optional()
.describe(
'Acceptable host CUDA versions for worker placement as major.minor, e.g. ["12.8"] (v2). Exact match — discover valid values with get-capacity (product SERVERLESS). Mutually exclusive with minCudaVersion.'
),
Comment thread
brodykellish marked this conversation as resolved.
minCudaVersion: z
.string()
.optional()
.describe(
'Lowest acceptable host CUDA version as major.minor, e.g. "12.4" (v2). A bare major like "12" is rejected. Mutually exclusive with a non-empty allowedCudaVersions.'
),
args: z.string().optional().describe('Container start command/args (v2)'),
containerDiskInGb: z
.number()
Expand Down Expand Up @@ -239,6 +252,17 @@ export function registerEndpointTools(
async (params) => {
const backend = backendFor('endpoints');

const hasCudaParams =
params.allowedCudaVersions !== undefined ||
params.minCudaVersion !== undefined;
if (hasCudaParams && backend.version !== 'v2') {
return jsonReply({
error:
'allowedCudaVersions/minCudaVersion are only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2.',
status: 501,
});
}

if (backend.version === 'v2') {
// Guard the v2-required fields before calling, so the caller gets a
// clean 400 rather than a raw 422 from the API (mirrors create-pod).
Expand Down Expand Up @@ -290,6 +314,10 @@ export function registerEndpointTools(
if (scalerError) {
return jsonReply({ error: scalerError, status: 400 });
}
if (hasCudaParams) {
const cudaError = cudaConstraintError(params);
if (cudaError) return jsonReply({ error: cudaError, status: 400 });
}
const body = backend.mapCreate(params) as Record<string, unknown>;
const result = await callRestUrl(
`${backend.base}${backend.list}`,
Expand Down Expand Up @@ -337,7 +365,7 @@ export function registerEndpointTools(
// /v2/serverless body; v1 passes the flat fields through.
server.tool(
'update-endpoint',
"Update a Serverless endpoint's config. On v2 you can change image/disk/env/ports/registry/workers/scaling/networkVolumes/timeout/flashboot; on v1, scaling fields (worker min/max, idle timeout, scaler type/value, name). Only provided fields change. An endpoint's request routing (queue vs load balancer) is fixed at creation and cannot be changed here — recreate the endpoint instead.",
"Update a Serverless endpoint's config. On v2 you can change image/disk/env/ports/registry/workers/scaling/networkVolumes/timeout/flashboot; on v1, scaling fields (worker min/max, idle timeout, scaler type/value, name). Only provided fields change. An endpoint's request routing (queue vs load balancer) is fixed at creation and cannot be changed here — recreate the endpoint instead. Note: passing gpuPoolIds replaces the GPU selection wholesale, which clears any GPU-type exclusions set elsewhere (console or set-endpoint-gpus) — the reply carries a _warning when that happens.",
{
endpointId: z.string().describe('ID of the endpoint to update'),
name: z.string().optional().describe('New name for the endpoint'),
Expand Down Expand Up @@ -378,6 +406,18 @@ export function registerEndpointTools(
.optional()
.describe('New GPU pool names (v2), e.g. ["AMPERE_80"]'),
gpuCount: z.number().optional().describe('New GPUs per worker (v2)'),
allowedCudaVersions: z
.array(z.string())
.optional()
.describe(
'New acceptable host CUDA versions as major.minor (v2). Changing only this does not require resending gpuPoolIds. An explicit [] clears the constraint. Mutually exclusive with minCudaVersion.'
),
minCudaVersion: z
.string()
.optional()
.describe(
'New lowest acceptable host CUDA version as major.minor (v2). An empty string clears the floor; a bare major like "12" is rejected. Mutually exclusive with a non-empty allowedCudaVersions.'
),
args: z.string().optional().describe('New container args (v2)'),
containerDiskInGb: z
.number()
Expand Down Expand Up @@ -411,24 +451,55 @@ export function registerEndpointTools(
const backend = backendFor('endpoints');
const url = `${backend.base}${backend.get!(endpointId)}`;

const hasCudaParams =
updateParams.allowedCudaVersions !== undefined ||
updateParams.minCudaVersion !== undefined;
if (backend.version !== 'v2') {
if (hasCudaParams) {
return jsonReply({
error:
'allowedCudaVersions/minCudaVersion are only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2.',
status: 501,
});
}
const body = backend.mapUpdate(updateParams) as Record<string, unknown>;
return jsonReply(await callRestUrl(url, 'PATCH', body));
}

if (hasCudaParams) {
// allowClear: [] clears the set, "" clears the floor (update-only
// sentinels — the create schema rejects both).
const cudaError = cudaConstraintError(updateParams, {
allowClear: true,
});
if (cudaError) return jsonReply({ error: cudaError, status: 400 });
}

// `scaling` is a union keyed on the scaler type, so a bare "change the target
// to N" has no expressible form without knowing which scaler is in effect.
// Rather than reject the call, read the endpoint's current scaler and keep
// it — which is what such a request has always meant.
let scalerType = updateParams.scalerType;
if (scalerType === undefined && updateParams.scalerValue !== undefined) {
const needScalerRead =
scalerType === undefined && updateParams.scalerValue !== undefined;
// Since 2.9.0, sending pools replaces the GPU selection wholesale, which
// clears any excludedTypes pinned out-of-band (console/set-endpoint-gpus).
// Read them first so the wipe is loud, not silent.
const needExclusionRead = (updateParams.gpuPoolIds?.length ?? 0) > 0;
let clearedExclusions: string[] = [];
Comment thread
brodykellish marked this conversation as resolved.
if (needScalerRead || needExclusionRead) {
const current = (await callRestUrl(url)) as
| { scaling?: { type?: string } }
| { scaling?: { type?: string }; gpu?: { excludedTypes?: string[] } }
| undefined;
scalerType =
current?.scaling?.type === 'REQUEST_COUNT'
? 'REQUEST_COUNT'
: 'QUEUE_DELAY';
if (needScalerRead) {
scalerType =
current?.scaling?.type === 'REQUEST_COUNT'
? 'REQUEST_COUNT'
: 'QUEUE_DELAY';
Comment on lines +510 to +512

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: minor concern around usage of magic strings. Seems like there isnt a project convention, but ideally could be simplified by usaing a const as object pattern?

}
if (needExclusionRead && current?.gpu?.excludedTypes?.length) {
clearedExclusions = current.gpu.excludedTypes;
}
}

// Checked against the resolved scaler, so `scalerValue` alone on an endpoint
Expand All @@ -445,7 +516,17 @@ export function registerEndpointTools(
...updateParams,
scalerType,
}) as Record<string, unknown>;
return jsonReply(await callRestUrl(url, 'PATCH', body));
const result = (await callRestUrl(url, 'PATCH', body)) as Record<
string,
unknown
>;
if (clearedExclusions.length) {
return jsonReply({
...result,
_warning: `Supplying gpuPoolIds replaced the GPU selection wholesale, clearing ${clearedExclusions.length} GPU-type exclusion(s) previously set on this endpoint: ${clearedExclusions.join(', ')}. Re-apply them with set-endpoint-gpus if still wanted.`,
});
}
return jsonReply(result);
}
);

Expand Down
Loading
Loading