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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Give any AI agent the ability to provision GPUs, launch pods, deploy models, and

| Layer | What it does | Count |
|-------|-------------|-------|
| **Tools** | CRUD operations for VMs, Pods, Serverless endpoints, and Registry credentials | 31 |
| **Tools** | CRUD operations for VMs, Pods, Serverless endpoints, Volumes, and Registry credentials | 37 |
| **Resources** | GPU catalog with specs, pricing, and availability | 2 |
| **Prompts** | Guided workflows for GPU selection, pod launch, and model serving | 3 |
| **Skills** | Agent skill definitions for Claude Code and compatible agents | 3 |
Expand Down Expand Up @@ -118,6 +118,19 @@ Full GPU virtual machines.
| `vm_rename` | Rename a VM |
| `vm_terminate` | Terminate a VM (irreversible) |

### Volumes

Persistent and object storage for pods and VMs.

| Tool | Description |
|------|-------------|
| `volume_create` | Create a storage volume (S3, R2, CEPH, VENDOR) |
| `volume_list` | List volumes by storage type (paginated) |
| `volume_get` | Get volume details by ID |
| `volume_delete` | Delete a volume (must be unmounted) |
| `volume_rename` | Rename a volume |
| `volume_resize` | Resize a CEPH or VENDOR volume |

### Container Registry

Manage credentials for pulling private Docker images.
Expand Down Expand Up @@ -197,7 +210,7 @@ Compatible with Claude Code and any agent framework that supports skill files.
| Environment Variable | Required | Default | Description |
|---------------------|----------|---------|-------------|
| `YOTTA_API_KEY` | Yes | — | Yotta Platform API key |
| `YOTTA_API_BASE_URL` | No | `https://api.test.yottalabs.ai` | API base URL |
| `YOTTA_API_BASE_URL` | No | `https://api.yottalabs.ai` | API base URL |

## Development

Expand All @@ -222,6 +235,7 @@ src/
│ ├── vms.ts # VM tools (6)
│ ├── pods.ts # Pod tools (6)
│ ├── serverless.ts # Serverless tools (14)
│ ├── volumes.ts # Volume tools (6)
│ └── registry.ts # Registry tools (5)
├── resources/
│ ├── index.ts # GPU catalog resources
Expand Down
6 changes: 3 additions & 3 deletions src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ describe("YottaClient", () => {
});

describe("request basics", () => {
it("sends correct Authorization header", async () => {
it("sends correct X-API-Key header", async () => {
const fetchMock = mockFetch([]);
const client = await freshClient();
await client.listPods();
expect(fetchMock).toHaveBeenCalledOnce();
const [, opts] = fetchMock.mock.calls[0];
expect(opts.headers.Authorization).toBe("Bearer test-api-key");
expect(opts.headers["X-API-Key"]).toBe("test-api-key");
});

it("prefixes paths with /v2", async () => {
Expand Down Expand Up @@ -160,7 +160,7 @@ describe("YottaClient", () => {
});
});

describe("Endpoints", () => {
describe("Serverless", () => {
it("scaleEndpointWorkers sends PUT to /serverless path", async () => {
const fetchMock = mockFetch(null);
const client = await freshClient();
Expand Down
36 changes: 35 additions & 1 deletion src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import type {
SubmitTaskRequest,
SubmitTaskResponse,
WorkerLogsResponse,
Volume,
CreateVolumeRequest,
ListVolumesParams,
} from "./types.js";

class YottaClient {
Expand All @@ -37,7 +40,7 @@ class YottaClient {
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${this.apiKey}`,
"X-API-Key": this.apiKey,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
Expand Down Expand Up @@ -219,6 +222,37 @@ class YottaClient {
const qs = query.toString();
return this.request<WorkerLogsResponse>("GET", `/serverless/${endpointId}/workers/${workerId}/logs${qs ? `?${qs}` : ""}`);
}

// --- Volumes ---

createVolume(req: CreateVolumeRequest) {
return this.request<Volume>("POST", "/volumes", req);
}

listVolumes(params: ListVolumesParams) {
const query = new URLSearchParams();
query.set("storageType", params.storageType);
if (params.page) query.set("page", String(params.page));
if (params.size) query.set("size", String(params.size));
if (params.region) query.set("region", params.region);
return this.request<PaginatedData<Volume>>("GET", `/volumes?${query.toString()}`);
}

getVolume(id: number) {
return this.request<Volume>("GET", `/volumes/${id}`);
}

deleteVolume(id: number) {
return this.request<boolean>("DELETE", `/volumes/${id}`);
}

renameVolume(id: number, name: string) {
return this.request<Volume>("POST", `/volumes/${id}/name`, { name });
}

resizeVolume(id: number, sizeInGb: number) {
return this.request<null>("POST", `/volumes/${id}/resize`, { sizeInGb });
}
}

let _client: YottaClient | null = null;
Expand Down
68 changes: 59 additions & 9 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export interface VmType {

// --- Pods ---

export interface PodExposePortRequest {
port: number;
protocol: string;
}

export interface PodExposePortResponse {
port: number;
proxyPort?: number;
Expand All @@ -107,6 +112,12 @@ export interface PodExposePortResponse {
serviceName?: string;
}

export interface PodPersistentVolume {
volumeId: number;
mountPath: string;
needBackup?: boolean;
}

export interface Pod {
id: number;
name: string;
Expand All @@ -124,29 +135,38 @@ export interface Pod {
singleCardRamInGb?: number;
singleCardVcpu?: number;
singleCardPrice?: number;
envVars?: { key: string; value: string }[];
environmentVars?: { key: string; value: string }[];
expose?: PodExposePortResponse[];
initializationCommand?: string;
sshCmd?: string;
internalIp?: string;
status: string;
createdAt: string;
updatedAt?: string;
createdAt: number;
updatedAt?: number;
}

export interface CreatePodRequest {
name: string;
image: string;
gpuType: string;
gpuCount: number;
regions?: string[];
region?: string;
regionList?: string[];
containerRegistryAuthId?: number;
imageRegistry?: string;
imagePublicType?: string;
resourceType?: string;
containerVolumeInGb?: number;
persistentVolumeInGb?: number;
persistentMountPath?: string;
shmInGb?: number;
minSingleCardVramInGb?: number;
minSingleCardRamInGb?: number;
minSingleCardVcpu?: number;
initializationCommand?: string;
envVars?: { key: string; value: string }[];
ports?: number[];
environmentVars?: { key: string; value: string }[];
expose?: PodExposePortRequest[];
persistentVolumes?: PodPersistentVolume[];
}

// --- Endpoints ---
Expand All @@ -158,7 +178,7 @@ export interface Endpoint {
imageRegistry?: string;
resources?: EndpointResource[];
containerVolumeInGb?: number;
envVars?: { key: string; value: string }[];
environmentVars?: { key: string; value: string }[];
expose?: { port: number; protocol?: string };
totalWorkers: number;
runningWorkers: number;
Expand All @@ -184,18 +204,18 @@ export interface CreateEndpointRequest {
name: string;
imageRegistry?: string;
image: string;
containerRegistryAuthId?: number;
resources: EndpointResource[];
workers: number;
containerVolumeInGb: number;
envVars?: { key: string; value: string }[];
environmentVars?: { key: string; value: string }[];
expose?: {
port: number;
protocol: string;
};
serviceMode: string;
webhook?: string;
initializationCommand?: string;
credentialId?: number;
}

export interface UpdateEndpointRequest {
Expand Down Expand Up @@ -279,3 +299,33 @@ export interface WorkerLogsResponse {
nextSearchAfterTime: string | null;
nextSearchAfterOffset: string | null;
}

// --- Volumes ---

export interface Volume {
id: number;
name: string;
sizeInGb: number | null;
region: string | null;
storageType: string;
status: string;
vendorVolumeType: string | null;
mountCount: number;
cost: number;
createdAt: number;
}

export interface CreateVolumeRequest {
name: string;
storageType: string;
region?: string;
sizeInGb?: number;
vendorVolumeType?: string;
}

export interface ListVolumesParams {
storageType: string;
page?: number;
size?: number;
region?: string;
}
2 changes: 1 addition & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("getConfig", () => {
delete process.env.YOTTA_API_BASE_URL;
const { getConfig } = await import("./config.js");
const config = getConfig();
expect(config.apiBaseUrl).toBe("https://api.test.yottalabs.ai");
expect(config.apiBaseUrl).toBe("https://api.yottalabs.ai");
});

it("uses custom base URL from env", async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function getConfig(): Config {
if (!apiKey) throw new Error("YOTTA_API_KEY environment variable is required");

_config = {
apiBaseUrl: (process.env.YOTTA_API_BASE_URL || "https://api.test.yottalabs.ai").replace(/\/$/, ""),
apiBaseUrl: (process.env.YOTTA_API_BASE_URL || "https://api.yottalabs.ai").replace(/\/$/, ""),
apiKey,
};
return _config;
Expand Down
2 changes: 2 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { registerVmTools } from "./vms.js";
import { registerPodTools } from "./pods.js";
import { registerServerlessTools } from "./serverless.js";
import { registerRegistryTools } from "./registry.js";
import { registerVolumeTools } from "./volumes.js";

export function registerTools(server: McpServer): void {
registerVmTools(server);
registerPodTools(server);
registerServerlessTools(server);
registerRegistryTools(server);
registerVolumeTools(server);
}
18 changes: 15 additions & 3 deletions src/tools/pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const envVarSchema = z.object({
value: z.string(),
});

const exposePortSchema = z.object({
port: z.number(),
protocol: z.string().describe('Protocol (e.g. "http", "tcp")'),
});

export function registerPodTools(server: McpServer): void {
server.tool(
"pod_create",
Expand All @@ -16,11 +21,18 @@ export function registerPodTools(server: McpServer): void {
image: z.string().describe('Docker image (e.g. "pytorch/pytorch:2.0.0-cuda11.7-cudnn8-runtime")'),
gpuType: z.string().describe('GPU type code (e.g. "RTX_4090_24G", "A100_80G")'),
gpuCount: z.number().describe("Number of GPUs (must be power of 2)"),
region: z.string().optional().describe('Region code (e.g. "us-east-1")'),
regions: z.array(z.string()).optional().describe('Acceptable region codes for scheduling (e.g. ["us-east-1"])'),
containerVolumeInGb: z.number().optional().describe("Container volume size in GB"),
persistentVolumeInGb: z.number().optional().describe("Persistent volume size in GB"),
persistentMountPath: z.string().optional().describe("Persistent volume mount path"),
shmInGb: z.number().optional().describe("Shared memory size in GB"),
minSingleCardVramInGb: z.number().optional().describe("Minimum single GPU card VRAM in GB"),
minSingleCardRamInGb: z.number().optional().describe("Minimum single GPU card RAM in GB"),
minSingleCardVcpu: z.number().optional().describe("Minimum single GPU card vCPU count"),
initializationCommand: z.string().optional().describe("Command to run on pod start"),
envVars: z.array(envVarSchema).optional().describe("Environment variables"),
ports: z.array(z.number()).optional().describe("Ports to expose (e.g. [8080, 22])"),
environmentVars: z.array(envVarSchema).optional().describe("Environment variables"),
expose: z.array(exposePortSchema).optional().describe('Ports to expose (e.g. [{port: 8080, protocol: "http"}])'),
containerRegistryAuthId: z.number().optional().describe("Container registry credential ID (for private images)"),
},
async (args) => {
const pod = await getClient().createPod(args);
Expand Down
3 changes: 2 additions & 1 deletion src/tools/serverless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export function registerServerlessTools(server: McpServer): void {
resources: z.array(resourceSchema).describe("GPU resources per worker"),
workers: z.number().describe("Number of workers"),
containerVolumeInGb: z.number().describe("Container volume in GB (min 20)"),
envVars: z.array(envVarSchema).optional().describe("Environment variables"),
containerRegistryAuthId: z.number().optional().describe("Container registry credential ID (for private images)"),
environmentVars: z.array(envVarSchema).optional().describe("Environment variables"),
expose: exposeSchema.optional().describe("Port exposure config"),
serviceMode: z.enum(["ALB", "QUEUE", "CUSTOM"]).describe("Service mode"),
webhook: z.string().optional().describe("Webhook URL for worker status notifications (max 512 chars)"),
Expand Down
23 changes: 21 additions & 2 deletions src/tools/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,31 @@ describe("tool registration", () => {
]);
});

it("registerTools registers all 31 tools", async () => {
it("registers all expected Volume tools", async () => {
const toolNames: string[] = [];
const mockServer = { tool: vi.fn((...args: unknown[]) => { toolNames.push(args[0] as string); }) };

const { registerVolumeTools } = await import("./volumes.js");
registerVolumeTools(mockServer as any);

expect(toolNames).toEqual([
"volume_create",
"volume_list",
"volume_get",
"volume_delete",
"volume_rename",
"volume_resize",
]);
});

it("registerTools registers all 37 tools", async () => {
const toolNames: string[] = [];
const mockServer = { tool: vi.fn((...args: unknown[]) => { toolNames.push(args[0] as string); }) };

const { registerTools } = await import("./index.js");
registerTools(mockServer as any);

expect(toolNames).toHaveLength(31);
expect(toolNames).toHaveLength(37);
// Spot-check one from each category
expect(toolNames).toContain("vm_create");
expect(toolNames).toContain("vm_types");
Expand All @@ -97,5 +114,7 @@ describe("tool registration", () => {
expect(toolNames).toContain("serverless_submit_task");
expect(toolNames).toContain("serverless_worker_logs");
expect(toolNames).toContain("registry_delete");
expect(toolNames).toContain("volume_create");
expect(toolNames).toContain("volume_resize");
});
});
Loading