# @krovacloud/mcp

[![npm version](https://img.shields.io/npm/v/@krovacloud/mcp)](https://www.npmjs.com/package/@krovacloud/mcp)
[![npm downloads](https://img.shields.io/npm/dm/@krovacloud/mcp)](https://www.npmjs.com/package/@krovacloud/mcp)
[![license](https://img.shields.io/npm/l/@krovacloud/mcp)](./LICENSE)
[![Node](https://img.shields.io/node/v/@krovacloud/mcp)](https://nodejs.org)

> **MCP server for [Krova Cloud](https://krova.cloud) — let Claude, Cursor, and any MCP client provision and manage Cubes (Firecracker microVMs) in natural language.**

A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes Krova Cloud as a set of tools an AI agent can call. Ask Claude to "spin up a 2-vCPU Ubuntu cube in us", "list my running cubes", or "power off the idle ones", and it drives the [Krova Cloud API](https://krova.cloud) for you.

It's a thin, fully typed bridge over the official [`@krovacloud/sdk`](https://www.npmjs.com/package/@krovacloud/sdk): each Krova Cloud operation is an MCP tool with a validated input schema, authenticated with your API key.

## Quickstart

The fastest way to try it — no install, no clone:

```bash
KROVA_API_KEY=kro_... npx -y @krovacloud/mcp
```

The server speaks MCP over **stdio**, so you normally don't run it by hand — your MCP client launches it with that command. Pick your client below.

## Client setup

All clients use the same launch command (`npx -y @krovacloud/mcp`) and the same environment variables. Set at least `KROVA_API_KEY`; set `KROVA_SPACE_ID` too if you want the Cube tools to default to one Space.

### Claude Desktop

Edit `claude_desktop_config.json` (**Settings → Developer → Edit Config**, or `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS / `%APPDATA%\Claude\claude_desktop_config.json` on Windows) and add:

```json
{
  "mcpServers": {
    "krova": {
      "command": "npx",
      "args": ["-y", "@krovacloud/mcp"],
      "env": {
        "KROVA_API_KEY": "kro_your_api_key_here",
        "KROVA_SPACE_ID": "space_optional_default"
      }
    }
  }
}
```

Restart Claude Desktop. The Krova Cloud tools appear under the tools (🔨) menu.

### Claude Code

Add the server with one command:

```bash
claude mcp add krova \
  --env KROVA_API_KEY=kro_your_api_key_here \
  --env KROVA_SPACE_ID=space_optional_default \
  -- npx -y @krovacloud/mcp
```

Or add it to `.mcp.json` in your project root (checked in, shared with your team):

```json
{
  "mcpServers": {
    "krova": {
      "command": "npx",
      "args": ["-y", "@krovacloud/mcp"],
      "env": {
        "KROVA_API_KEY": "kro_your_api_key_here",
        "KROVA_SPACE_ID": "space_optional_default"
      }
    }
  }
}
```

Verify with `claude mcp list`.

### Cursor

Create `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for all projects):

```json
{
  "mcpServers": {
    "krova": {
      "command": "npx",
      "args": ["-y", "@krovacloud/mcp"],
      "env": {
        "KROVA_API_KEY": "kro_your_api_key_here",
        "KROVA_SPACE_ID": "space_optional_default"
      }
    }
  }
}
```

Then enable the **krova** server in **Cursor Settings → MCP**.

### Any other MCP client

Any client that speaks MCP over stdio works — launch `npx -y @krovacloud/mcp` with the environment variables set. The config shape above is portable across VS Code (Copilot), Windsurf, Zed, and custom agents built on the MCP SDK.

## Configuration

The server is configured entirely through environment variables:

| Variable | Required | Description |
| --- | --- | --- |
| `KROVA_API_KEY` | **yes** | Your Krova Cloud API key (`kro_...`). Scoped to a Space; inherits the permissions of the membership that created it. |
| `KROVA_SPACE_ID` | no | A default Space id, so Cube tools can omit `spaceId`. |
| `KROVA_BASE_URL` | no | Override the API base URL (defaults to the Krova Cloud production API). |

## Tools

All 37 tools, their parameters, and what they do. Every tool's `spaceId` is optional when `KROVA_SPACE_ID` is set.

| Tool | Parameters | Description |
| --- | --- | --- |
| `list_cubes` | `spaceId?` | List all Cubes (Firecracker microVMs) in a Space. |
| `get_cube` | `spaceId?`, `cubeId` | Get details for a single Cube by id. |
| `get_cube_ssh` | `spaceId?`, `cubeId` | Host, port, **login user** and pinned host keys for SSH. `host` is the Cube's own stable SSH hostname — not the server's IP — and survives migration to another server; it falls back to the server's public IPv4 only when the Cube has no DNS record yet. Call this rather than guessing a username — it is `ubuntu`/`debian` on newer Cubes and `root` on older ones, and cannot be derived from the image id. |
| `create_cube` | `spaceId?`, `name`, `image`, `vcpu`, `ramGb`, `diskGb`, `sshPublicKey`, `region?`, `userData?` | Provision a new Cube. Asynchronous — the returned Cube starts in a pending state. **Billable. Destructive.** |
| `power_off_cube` | `spaceId?`, `cubeId` | Power off a running Cube (releases compute + host RAM, keeps disk). Asynchronous. |
| `wake_cube` | `spaceId?`, `cubeId` | Start a stopped Cube. Asynchronous. |
| `restart_cube` | `spaceId?`, `cubeId` | Restart a **running** Cube. This is a COLD restart — the Cube boots against the host's current kernel, so it is the only way to pick up a refreshed guest kernel (a `reboot` issued inside the Cube cannot, and reports no error). Disk state is preserved. Asynchronous; a concurrent restart is rejected, not queued. |
| `delete_cube` | `spaceId?`, `cubeId` | Delete a Cube. Asynchronous — deletion is enqueued. **Destructive.** |
| `protect_cube` | `spaceId?`, `cubeId` | Turn on termination protection: `delete_cube` is refused with 409 until it is turned off. Power-off, wake, restart, snapshot and restore stay allowed. Idempotent. |
| `unprotect_cube` | `spaceId?`, `cubeId` | Turn termination protection off, so `delete_cube` works again. Idempotent. |
| `list_regions` | — | List regions with available capacity. |
| `list_images` | — | List available OS images for new Cubes. |
| `get_pricing` | — | Get per-resource hourly rates and volume pricing tiers. |
| `list_domains` | `spaceId?`, `cubeId` | List the custom domains attached to a Cube. |
| `get_domain_records` | `spaceId?`, `cubeId`, `mappingId` | The DNS records a domain needs, each checked against live DNS — `missing` means not published yet (expected before the user creates them, never an error), and `summary.complete` turns true once every record is found. Performs real DNS lookups; rate limited. |
| `create_domain` | `spaceId?`, `cubeId`, `domain`, `port`, `originScheme?`, `proxyProtocol?`, `confirmMixedProxyProtocol?` | Attach a custom domain to a Cube. Returns the domain **and** the DNS records to publish — relay them verbatim. |
| `update_domain` | `spaceId?`, `cubeId`, `mappingId`, `originScheme?`, `proxyProtocol?`, `confirmMixedProxyProtocol?` | Change a domain's proxy settings: the origin scheme, and the PROXY protocol header (`v1`, `v2` or `off`) that names the visitor on each connection to the Cube. Pass at least one; what you leave out keeps its value. |
| `delete_domain` | `spaceId?`, `cubeId`, `mappingId` | Detach a custom domain. **Destructive.** |
| `list_snapshots` | `spaceId?`, `cubeId` | List a Cube's disk snapshots. |
| `create_snapshot` | `spaceId?`, `cubeId`, `name?` | Snapshot a Cube's disk. Asynchronous. |
| `delete_snapshot` | `spaceId?`, `cubeId`, `snapshotId` | Delete a snapshot. **Destructive.** |
| `restore_cube` | `spaceId?`, `cubeId`, `snapshotId` | Restore a Cube's disk from a snapshot — **replaces the disk. Destructive.** |
| `list_backups` | `spaceId?` | List the space's backups, newest first. `sharedFromBackupId` is set on a copy another space shared with this one. |
| `get_backup` | `spaceId?`, `backupId` | Get one backup. |
| `share_backup` | `spaceId?`, `backupId`, `destinationSpaceId` | Offer a copy of a backup to another space. Nothing is copied until the destination accepts within 48 hours; the copy is then its own and billed to it. **Destructive** (it hands a copy of the disk to another space). |
| `list_backup_shares` | `spaceId?` | Pending shares into (`incoming`) and out of (`outgoing`) the space. |
| `accept_backup_share` | `spaceId?`, `shareId` | Accept a backup shared with the space. The space pays backup storage for its copy from now. **Destructive** (it adds a bill). |
| `decline_backup_share` | `spaceId?`, `shareId` | Decline a backup shared with the space. Nothing is copied. |
| `cancel_backup_share` | `spaceId?`, `shareId` | Withdraw a pending share the space offered. |
| `list_tcp_mappings` | `spaceId?`, `cubeId` | List a Cube's TCP port mappings. |
| `create_tcp_mapping` | `spaceId?`, `cubeId`, `cubePort`, `whitelistedIps?` | Expose a Cube TCP port on the host. `whitelistIps` is still accepted as a deprecated alias. |
| `delete_tcp_mapping` | `spaceId?`, `cubeId`, `mappingId` | Remove a TCP port mapping. **Destructive.** |
| `list_webhooks` | `spaceId?` | List the webhook endpoints in the space, with their events and whether each is enabled. |
| `get_webhook` | `spaceId?`, `endpointId` | Get one webhook endpoint. The signing secret is never returned here. |
| `create_webhook` | `spaceId?`, `url`, `events`, `description?` | Create a webhook endpoint. The signing secret is returned only in this response; persist it immediately. |
| `delete_webhook` | `spaceId?`, `endpointId` | Delete a webhook endpoint and its delivery history. **Destructive.** |
| `list_webhook_deliveries` | `spaceId?`, `endpointId`, `limit?` | The last 30 days of delivery attempts for an endpoint, newest first (max 100). |

Every tool advertises MCP **annotations** so your client can treat them appropriately: the **sixteen** read tools (`list_cubes`, `get_cube`, `get_cube_ssh`, `list_regions`, `list_images`, `get_pricing`, `list_domains`, `get_domain_records`, `list_snapshots`, `list_backups`, `get_backup`, `list_backup_shares`, `list_tcp_mappings`, `list_webhooks`, `get_webhook`, `list_webhook_deliveries`) are marked read-only, while the **nine** destructive tools (`create_cube`, `delete_cube`, `delete_domain`, `delete_snapshot`, `restore_cube`, `share_backup`, `accept_backup_share`, `delete_tcp_mapping`, `delete_webhook`) are marked **destructive**. `update_domain` mutates but is idempotent and reversible, so it is not. Most MCP clients surface a confirmation prompt before running a destructive tool — keep that confirmation on, since an LLM driven by untrusted content could be induced to call one.

The backup tools need the **Backups** permissions on the API key's member: View Backups to read, Manage Backups to share and answer shares. There is deliberately no download tool: the download link fetches the whole disk with no further authentication, and a model's conversation is the wrong place for it. Use `krova backups download` from the CLI.

### `create_cube` parameters

| Param | Type | Notes |
| --- | --- | --- |
| `name` | string | Human-readable Cube name. |
| `image` | string | OS image slug — see `list_images`. `ubuntu-24.04`, `ubuntu-24.04-docker`, `debian-13` or `debian-13-docker`. |
| `region` | string? | Optional region slug — see `list_regions`. Omit to let Krova Cloud auto-select a region with capacity. |
| `vcpu` | integer | Number of virtual CPUs. Default per-space cap 16 (can be raised for your space). |
| `ramGb` | integer | RAM in whole GiB. Default per-space cap 32 GB (raisable for your space). |
| `diskGb` | integer | Disk in GiB — minimum 10, in steps of 5. Default per-space cap 100 GB (raisable for your space). |
| `sshPublicKey` | string | Written to `/root/.ssh/authorized_keys` at boot (`ssh-ed25519`, `ssh-rsa`, `ecdsa-sha2-*`, …). Required by the API. |
| `userData` | string? | Optional cloud-init script (**max 16 KiB, enforced**). |

Successful calls return the API's JSON response as text; API errors surface as an MCP error result carrying the HTTP status, the API message, and a request id when available.

## Authentication

You need a Krova Cloud API key. Create one from your Space settings in the [Krova Cloud dashboard](https://krova.cloud) — keys are **scoped per Space**, inherit the permissions of the membership that created them, and look like `kro_...`.

Never commit your key or paste it into logs, issues, or chats. Rotate any key you believe has been exposed. See [SECURITY.md](https://github.com/krovacloud/krova-node/blob/main/SECURITY.md).

## Requirements

- **Node.js ≥ 18** (the underlying SDK uses the global `fetch`).
- An MCP-capable client (Claude Desktop, Claude Code, Cursor, or your own agent).

## Related packages

Part of the Krova Cloud developer toolkit:

- [`@krovacloud/sdk`](https://www.npmjs.com/package/@krovacloud/sdk) — the official TypeScript SDK this server is built on.
- [Krova Cloud docs & dashboard](https://krova.cloud) — hosted API docs, the OpenAPI spec, and where you obtain API keys.

## Contributing

See [CONTRIBUTING.md](https://github.com/krovacloud/krova-node/blob/main/CONTRIBUTING.md). The tool registry in `src/tools.ts` is the single source of truth — add a `defineTool(...)`, cover it in `tests/`, and update the table above. Run `pnpm typecheck`, `pnpm test`, and `pnpm build` before opening a PR.

## License

[MIT](./LICENSE) © 2026 Krova Inc.

## Publishing to the official MCP Registry

This server is listed as [`cloud.krova/mcp`](https://registry.modelcontextprotocol.io/v0.1/servers?search=cloud.krova).
CI republishes it automatically on every release — see the *Publish MCP server
to the official registry* step in `.github/workflows/ci-release.yml`.

**It requires one repository secret, `MCP_PRIVATE_KEY`.** Until that exists the
step logs a skip and does nothing, so releases are never blocked by it.

The value is the raw Ed25519 private key as hex, derived from the key whose
public half is published as a TXT record on the `krova.cloud` **apex**:

```bash
openssl pkey -in key.pem -noout -text | grep -A3 'priv:' | tail -n +2 | tr -d ' :\n' | pbcopy
```

⚠️ Pipe it to `pbcopy` rather than reading it off the terminal. The command
emits no trailing newline, so **zsh displays a trailing `%`** — copy that by eye
and it lands in the secret, and the publisher fails with
`invalid hex private key format: ... invalid byte: U+0025 '%'`. CI now strips
non-hex characters and checks the length is 64, so this cannot bite twice.

⛔ **The namespace is the reason a secret exists at all.** `mcp-publisher login
github-oidc` needs no secret, but only grants `io.github.<org>/*` — taking it
would mean abandoning `cloud.krova/mcp`, republishing under a new name and
orphaning the live entry. The branded namespace was chosen deliberately.

### Running it by hand

```bash
pnpm publish:mcp-registry --dry-run   # derives the version, writes and publishes nothing
pnpm publish:mcp-registry
```

The script reads the version from **npm**, never from `server.json`, and exits
early when the registry already serves that version. That is deliberate: the
stored number cannot be trusted, because `release.mjs` patch-bumps from npm's
latest, CI never commits the bump back, and `server.json` is not in the
package's `files` list so it does not ship in the tarball either. Publishing a
stale version fails silently — the registry checks only that the version exists
on npm with the `mcpName` marker, not that it is the latest.
