cloudgpu-mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +163 -2
- package/package.json +52 -4
- package/rules/cloudgpu.md +45 -0
- package/src/api.js +99 -0
- package/src/guards.js +118 -0
- package/src/index.js +571 -0
- package/src/ssh.js +233 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CloudGPU
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,164 @@
|
|
|
1
|
-
#
|
|
1
|
+
# CloudGPU MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[CloudGPU](https://cloudgpu.app) MCP server: let an AI agent rent a GPU by the minute, run a job, bring the results back and shut the GPU down. The same API key and balance also cover CloudGPU's OpenAI-compatible model API, so an agent can call a hosted model without renting anything.
|
|
4
|
+
|
|
5
|
+
> "Fine-tune a 7B base model with LoRA on data/train.jsonl and put the adapter in ./outputs."
|
|
6
|
+
|
|
7
|
+
The agent checks live prices and quotes you, rents the GPU once you say yes, uploads your code and data, starts training in the background, watches the logs, downloads the adapter and destroys the instance. You get the files and the final cost.
|
|
8
|
+
|
|
9
|
+
It is a local (stdio) MCP server. It works with any MCP client that can start one: Claude Code, Cursor, Cline, opencode, goose, VS Code, Windsurf and others.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
1. Create an API key at [cloudgpu.app/api](https://cloudgpu.app/api) and add a prepaid balance (card or USDT).
|
|
14
|
+
2. Add the server to your client. Node.js 18 or newer is required. The key goes in the environment variable `CLOUDGPU_API_KEY`.
|
|
15
|
+
|
|
16
|
+
<details open>
|
|
17
|
+
<summary><b>Claude Code</b></summary>
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude mcp add cloudgpu --env CLOUDGPU_API_KEY=cgw-sk-... -- npx -y cloudgpu-mcp
|
|
21
|
+
```
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<details>
|
|
25
|
+
<summary><b>Cursor</b> (<code>~/.cursor/mcp.json</code> or <code>.cursor/mcp.json</code>)</summary>
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"cloudgpu": {
|
|
31
|
+
"command": "npx",
|
|
32
|
+
"args": ["-y", "cloudgpu-mcp"],
|
|
33
|
+
"env": { "CLOUDGPU_API_KEY": "cgw-sk-..." }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
</details>
|
|
39
|
+
|
|
40
|
+
<details>
|
|
41
|
+
<summary><b>Cline</b> (MCP Servers → Configure MCP Servers → <code>cline_mcp_settings.json</code>)</summary>
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"cloudgpu": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["-y", "cloudgpu-mcp"],
|
|
49
|
+
"env": { "CLOUDGPU_API_KEY": "cgw-sk-..." }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
</details>
|
|
55
|
+
|
|
56
|
+
<details>
|
|
57
|
+
<summary><b>opencode</b> (<code>opencode.json</code>)</summary>
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"$schema": "https://opencode.ai/config.json",
|
|
62
|
+
"mcp": {
|
|
63
|
+
"cloudgpu": {
|
|
64
|
+
"type": "local",
|
|
65
|
+
"command": ["npx", "-y", "cloudgpu-mcp"],
|
|
66
|
+
"environment": { "CLOUDGPU_API_KEY": "cgw-sk-..." },
|
|
67
|
+
"enabled": true
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
</details>
|
|
73
|
+
|
|
74
|
+
<details>
|
|
75
|
+
<summary><b>goose</b> (<code>~/.config/goose/config.yaml</code>, or <code>goose configure</code> → Add Extension → Command-line Extension)</summary>
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
extensions:
|
|
79
|
+
cloudgpu:
|
|
80
|
+
name: cloudgpu
|
|
81
|
+
type: stdio
|
|
82
|
+
cmd: npx
|
|
83
|
+
args: ["-y", "cloudgpu-mcp"]
|
|
84
|
+
envs:
|
|
85
|
+
CLOUDGPU_API_KEY: "cgw-sk-..."
|
|
86
|
+
enabled: true
|
|
87
|
+
timeout: 300
|
|
88
|
+
```
|
|
89
|
+
</details>
|
|
90
|
+
|
|
91
|
+
3. Optional but recommended: copy [`rules/cloudgpu.md`](rules/cloudgpu.md) into your project as `AGENTS.md`, `.cursor/rules/cloudgpu.md` or `.clinerules/cloudgpu.md`. It tells the agent to quote before renting, download before shutting down, and never leave a GPU running. The server also exposes the same text as the resource `cloudgpu://rules`.
|
|
92
|
+
|
|
93
|
+
To check the install, ask your agent: "List the GPUs available, then estimate the cost for a 24 GB GPU for one hour. Do not create anything." You should see `list_gpus` and `estimate_cost` calls and a quote, and nothing is rented.
|
|
94
|
+
|
|
95
|
+
## Tools
|
|
96
|
+
|
|
97
|
+
| Tool | What it does |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `get_account` | Balance, and how many instances the account may run at once |
|
|
100
|
+
| `list_gpus` | GPU models with VRAM, live price per GPU-hour and availability |
|
|
101
|
+
| `list_templates` | Ready-made images: JupyterLab, Ollama, ComfyUI, LLaMA-Factory, … |
|
|
102
|
+
| `estimate_cost` | Live quote for a GPU and a number of hours, plus your balance afterwards; returns a `quote_id` |
|
|
103
|
+
| `create_instance` | Rents from an approved quote, with a budget, a time limit and an idle limit |
|
|
104
|
+
| `get_status` / `list_instances` | Status, minutes run, cost so far, auto-shutdown limits |
|
|
105
|
+
| `run_command` | Runs a command on the instance; `background=true` for long jobs |
|
|
106
|
+
| `tail_logs` | Latest log lines, whether the job finished, GPU utilisation |
|
|
107
|
+
| `upload` / `download` | Files and folders between your computer and the instance (SFTP) |
|
|
108
|
+
| `stop_instance` | Destroys the instance and stops billing |
|
|
109
|
+
| `list_models` | Models on the OpenAI-compatible API, for inference without renting a GPU |
|
|
110
|
+
|
|
111
|
+
The server also provides a `train_on_cloudgpu` prompt.
|
|
112
|
+
|
|
113
|
+
## Guardrails
|
|
114
|
+
|
|
115
|
+
- **A quote first.** `create_instance` only takes a `quote_id` from `estimate_cost` and `user_confirmed=true`.
|
|
116
|
+
- **Server-side limits.** Every instance is created with `max_spend_usd`, `max_hours` and an idle limit (60 minutes by default). CloudGPU's servers check them and destroy the instance when one is hit, even if your client has disconnected or your laptop is asleep.
|
|
117
|
+
- **Your own ceiling.** `CLOUDGPU_MAX_SPEND_USD` and `CLOUDGPU_MAX_HOURS` cap what the agent may set.
|
|
118
|
+
- **Results before shutdown.** After commands have run, `stop_instance` refuses until something has been downloaded, unless the user confirms `force=true`.
|
|
119
|
+
- **Secrets stay out of the chat.** The instance's SSH login is used inside this process and never returned to the model. Tool output is scrubbed of the API key and login details. Uploading `.ssh`, `.env`, key files or your whole home directory needs `allow_sensitive=true`.
|
|
120
|
+
- **The instance stays manageable.** Commands that change the root password, SSH configuration, firewall or the platform's monitoring agent are refused. CloudGPU shuts down instances that it can no longer monitor.
|
|
121
|
+
- **Read-only mode.** `CLOUDGPU_READ_ONLY=1` registers only the look-up tools.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
| Env | Default | |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `CLOUDGPU_API_KEY` | (required) | From https://cloudgpu.app/api |
|
|
128
|
+
| `CLOUDGPU_MAX_SPEND_USD` | `50` | Highest budget the agent may set for one instance |
|
|
129
|
+
| `CLOUDGPU_MAX_HOURS` | `24` | Longest time limit the agent may set |
|
|
130
|
+
| `CLOUDGPU_READ_ONLY` | off | `1` = look-up tools only |
|
|
131
|
+
| `CLOUDGPU_BASE_URL` | `https://cloudgpu.app` | Override the API host |
|
|
132
|
+
|
|
133
|
+
## Model API on the same balance
|
|
134
|
+
|
|
135
|
+
The key also works with the OpenAI-compatible API at `https://cloudgpu.app/v1`. Call `list_models` (or `GET /v1/models`) to see the available model IDs. Model prices are on [cloudgpu.app/api](https://cloudgpu.app/api).
|
|
136
|
+
|
|
137
|
+
## Without MCP
|
|
138
|
+
|
|
139
|
+
The server wraps CloudGPU's public REST API, which any agent or script can call with the same key:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
curl https://cloudgpu.app/v1/gpu/offers -H "Authorization: Bearer $CLOUDGPU_API_KEY"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Links
|
|
146
|
+
|
|
147
|
+
- MCP docs: https://cloudgpu.app/docs/mcp
|
|
148
|
+
- GPU pricing: https://cloudgpu.app/pricing
|
|
149
|
+
- Model API and prices: https://cloudgpu.app/api
|
|
150
|
+
- API reference: https://cloudgpu.app/api/docs · OpenAPI: https://cloudgpu.app/openapi.json · For LLMs: https://cloudgpu.app/llms.txt
|
|
151
|
+
|
|
152
|
+
CloudGPU here means cloudgpu.app. Prepaid balance, billed per minute; when the balance runs out, running instances are destroyed and their disks erased, so keep enough balance for the job and download results as you go.
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm install
|
|
158
|
+
npm test
|
|
159
|
+
node src/index.js # stdio server; set CLOUDGPU_API_KEY first
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT, see [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloudgpu-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"mcpName": "io.github.mrhy7176/cloudgpu-mcp",
|
|
5
|
+
"description": "MCP server for cloudgpu.app: let Claude Code, Cursor, Cline, opencode, goose and other MCP clients rent a GPU per minute, run a job, fetch the results and shut it down, with spending limits the platform enforces.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cloudgpu-mcp": "src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "src/index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"rules",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/*.test.js"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"model-context-protocol",
|
|
26
|
+
"gpu",
|
|
27
|
+
"cloud-gpu",
|
|
28
|
+
"gpu-rental",
|
|
29
|
+
"model-api",
|
|
30
|
+
"ai-agent",
|
|
31
|
+
"claude-code",
|
|
32
|
+
"cursor",
|
|
33
|
+
"cline",
|
|
34
|
+
"opencode",
|
|
35
|
+
"goose",
|
|
36
|
+
"fine-tuning",
|
|
37
|
+
"cloudgpu"
|
|
38
|
+
],
|
|
39
|
+
"homepage": "https://cloudgpu.app/docs/mcp",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/mrhy7176/cloudgpu-mcp.git"
|
|
43
|
+
},
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/mrhy7176/cloudgpu-mcp/issues"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"author": "CloudGPU <support@cloudgpu.app>",
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
51
|
+
"ssh2": "^1.17.0",
|
|
52
|
+
"zod": "^3.25.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# CloudGPU rules for AI assistants
|
|
2
|
+
|
|
3
|
+
Use these rules whenever you rent or use a GPU on CloudGPU (cloudgpu.app) through the `cloudgpu` MCP tools.
|
|
4
|
+
Copy this file into your project as `AGENTS.md`, `.cursor/rules/cloudgpu.md` or `.clinerules/cloudgpu.md`, or wherever your assistant reads project instructions.
|
|
5
|
+
|
|
6
|
+
## Before renting
|
|
7
|
+
|
|
8
|
+
1. Call `get_account` and `list_gpus`. Pick a GPU with enough VRAM for the job, and don't rent more GPU than the job needs. As a rough guide, LoRA on a 7–8B model fits in 24 GB, and a full fine-tune of the same model needs 80 GB or more.
|
|
9
|
+
2. Call `estimate_cost` with a realistic number of hours.
|
|
10
|
+
3. Show the user a short quote before you rent anything:
|
|
11
|
+
- the GPU and how many,
|
|
12
|
+
- the price per hour,
|
|
13
|
+
- the estimated hours and cost,
|
|
14
|
+
- the budget and time limit you will set,
|
|
15
|
+
- their balance after the job.
|
|
16
|
+
4. Rent only after the user says yes. Pass `user_confirmed=true` only in that case.
|
|
17
|
+
|
|
18
|
+
## Renting
|
|
19
|
+
|
|
20
|
+
- Always set `max_spend_usd` and `max_hours`. CloudGPU's servers destroy the instance when either is reached, even if you have disconnected.
|
|
21
|
+
- Keep `idle_minutes` on (default 60). Raise it if setup will take long, because installing packages and downloading models leave the GPU idle.
|
|
22
|
+
- Call `get_status` at intervals, not in a tight loop, until the instance is `running`.
|
|
23
|
+
- `failed` instances are not charged. Tell the user, then offer another GPU.
|
|
24
|
+
|
|
25
|
+
## Working on the instance
|
|
26
|
+
|
|
27
|
+
- Put code and data on the instance with `upload`. Do not upload credentials.
|
|
28
|
+
- Use `run_command` for short steps. For training, long installs or big downloads, use `background=true` and follow progress with `tail_logs`.
|
|
29
|
+
- Instances come with Python and CUDA, but not always PyTorch. Check first: `nvidia-smi -L` and `python -c "import torch; print(torch.__version__, torch.cuda.is_available())"`.
|
|
30
|
+
- If PyTorch is missing, plain `pip install torch` installs a current CUDA build. It is a few GB, so run it with `background=true`. Index URLs for older CUDA versions (for example `.../whl/cu124`) may have no wheel for the machine's Python, which can be as new as 3.14. Only use them if the project needs that CUDA version, and then create an environment with an older Python first (`conda create -y -n work python=3.12`, then `conda activate work && ...` in each command).
|
|
31
|
+
- If `nvidia-smi` shows no GPU once the instance is running, stop and tell the user. CloudGPU detects such machines, shuts them down and refunds them automatically.
|
|
32
|
+
- **Do not** change the root password, edit the SSH configuration, change the firewall, or stop the platform's monitoring agent (`cgpu-*` processes). The platform must keep its access to the instance; if that access is lost, the platform shuts the instance down. The MCP server refuses such commands.
|
|
33
|
+
- Never use the GPU for cryptocurrency mining or anything the [acceptable use policy](https://cloudgpu.app/aup) forbids. Instances that do so are shut down.
|
|
34
|
+
|
|
35
|
+
## Finishing (always)
|
|
36
|
+
|
|
37
|
+
1. Download every result: model weights, adapters, checkpoints, logs, metrics. Destroying an instance erases its disk, and CloudGPU has no persistent storage.
|
|
38
|
+
2. Call `stop_instance`.
|
|
39
|
+
3. Tell the user what ran, where the results are on their computer, and the final cost from `stop_instance`.
|
|
40
|
+
|
|
41
|
+
If anything goes wrong mid-way, do not leave the instance running. Download whatever exists, stop the instance, then explain what happened.
|
|
42
|
+
|
|
43
|
+
## Only need a model, not a GPU?
|
|
44
|
+
|
|
45
|
+
The same API key works for CloudGPU's OpenAI-compatible model API at `https://cloudgpu.app/v1`. Call `list_models` to see the models. If you only need inference on a hosted model, use the API instead of renting a GPU.
|
package/src/api.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// CloudGPU REST client. Talks only to the public API (https://cloudgpu.app/v1/...),
|
|
2
|
+
// with the user's own API key. Nothing here knows or needs anything that is not public.
|
|
3
|
+
|
|
4
|
+
export class ApiError extends Error {
|
|
5
|
+
constructor(status, code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class CloudGpuApi {
|
|
13
|
+
constructor({ apiKey, baseUrl = "https://cloudgpu.app", fetchImpl = globalThis.fetch } = {}) {
|
|
14
|
+
this.apiKey = apiKey;
|
|
15
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
16
|
+
this.fetch = fetchImpl;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async request(method, path, body) {
|
|
20
|
+
if (!this.apiKey) {
|
|
21
|
+
throw new ApiError(
|
|
22
|
+
401,
|
|
23
|
+
"missing_api_key",
|
|
24
|
+
"CLOUDGPU_API_KEY is not set. Create a key at https://cloudgpu.app/api and add it to this MCP server's env.",
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
let lastErr;
|
|
28
|
+
// Retry only on network errors and 502/503/504; never on 4xx (a create must not run twice).
|
|
29
|
+
const attempts = method === "GET" ? 3 : 1;
|
|
30
|
+
for (let i = 0; i < attempts; i++) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await this.fetch(this.baseUrl + path, {
|
|
33
|
+
method,
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
"User-Agent": "cloudgpu-mcp/0.1",
|
|
38
|
+
},
|
|
39
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let json;
|
|
43
|
+
try {
|
|
44
|
+
json = text ? JSON.parse(text) : {};
|
|
45
|
+
} catch {
|
|
46
|
+
json = null;
|
|
47
|
+
}
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const e = json && json.error ? json.error : {};
|
|
50
|
+
const err = new ApiError(res.status, e.code || `http_${res.status}`, e.message || `HTTP ${res.status}`);
|
|
51
|
+
if (method === "GET" && res.status >= 502 && res.status <= 504 && i < attempts - 1) {
|
|
52
|
+
lastErr = err;
|
|
53
|
+
await sleep(1000 * (i + 1));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
if (json === null) {
|
|
59
|
+
throw new ApiError(res.status, "bad_response", "The server returned something that is not JSON.");
|
|
60
|
+
}
|
|
61
|
+
return json;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err instanceof ApiError) throw err;
|
|
64
|
+
lastErr = new ApiError(0, "network_error", `Could not reach ${this.baseUrl}: ${err.message}`);
|
|
65
|
+
if (i < attempts - 1) await sleep(1000 * (i + 1));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw lastErr;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
account() {
|
|
72
|
+
return this.request("GET", "/v1/gpu/account");
|
|
73
|
+
}
|
|
74
|
+
offers() {
|
|
75
|
+
return this.request("GET", "/v1/gpu/offers").then((r) => r.data || []);
|
|
76
|
+
}
|
|
77
|
+
templates() {
|
|
78
|
+
return this.request("GET", "/v1/gpu/templates").then((r) => r.data || []);
|
|
79
|
+
}
|
|
80
|
+
instances() {
|
|
81
|
+
return this.request("GET", "/v1/gpu/instances").then((r) => r.data || []);
|
|
82
|
+
}
|
|
83
|
+
instance(id) {
|
|
84
|
+
return this.request("GET", `/v1/gpu/instances/${encodeURIComponent(id)}`);
|
|
85
|
+
}
|
|
86
|
+
create(body) {
|
|
87
|
+
return this.request("POST", "/v1/gpu/instances", body);
|
|
88
|
+
}
|
|
89
|
+
destroy(id) {
|
|
90
|
+
return this.request("DELETE", `/v1/gpu/instances/${encodeURIComponent(id)}`);
|
|
91
|
+
}
|
|
92
|
+
models() {
|
|
93
|
+
return this.request("GET", "/v1/models").then((r) => r.data || []);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function sleep(ms) {
|
|
98
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
99
|
+
}
|
package/src/guards.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Guardrails that run on this machine, before anything reaches the API or the instance.
|
|
2
|
+
// The hard limits (auto-shutdown on time, budget and idle GPU) are enforced by CloudGPU's servers;
|
|
3
|
+
// these catch mistakes early and keep secrets out of the model's context.
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
|
|
8
|
+
// ── Commands that would break the instance ───────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// Every CloudGPU instance runs a small platform monitoring agent, and the platform checks on it
|
|
11
|
+
// over SSH. Changing the root password, rewriting the SSH config or killing the agent locks that
|
|
12
|
+
// check out, and the platform then shuts the instance down. These patterns stop an assistant from
|
|
13
|
+
// doing that by accident (for example while "hardening" SSH or "cleaning up" processes).
|
|
14
|
+
const BLOCKED = [
|
|
15
|
+
{ re: /\b(passwd|chpasswd)\b/, why: "changes a login password" },
|
|
16
|
+
{ re: /\busermod\b[^\n]*\s-p\b/, why: "changes a login password" },
|
|
17
|
+
{ re: /sshd_config|\/etc\/ssh\//, why: "edits the SSH server configuration" },
|
|
18
|
+
{ re: /\b(systemctl|service)\s+(stop|disable|mask|restart)\s+(ssh|sshd)\b/, why: "stops the SSH server" },
|
|
19
|
+
{ re: /cgpu-(watchdog|agent)/, why: "touches the platform monitoring agent" },
|
|
20
|
+
{ re: /\bkill\s+(-9\s+|-KILL\s+|-s\s+KILL\s+)?-1\b/, why: "kills every process on the machine" },
|
|
21
|
+
{ re: /\bpkill\s+(-9\s+)?(-f\s+)?(bash|sh|sshd)\b/, why: "kills the shells the platform relies on" },
|
|
22
|
+
{ re: /\bkillall\s+(-9\s+)?(bash|sh|sshd)\b/, why: "kills the shells the platform relies on" },
|
|
23
|
+
{ re: /\b(iptables|nft|ufw)\b/, why: "changes the firewall" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function checkCommand(command) {
|
|
27
|
+
for (const b of BLOCKED) {
|
|
28
|
+
if (b.re.test(command)) {
|
|
29
|
+
return (
|
|
30
|
+
`Refused: this command ${b.why}. CloudGPU instances must stay reachable for the platform's ` +
|
|
31
|
+
`monitoring, otherwise the instance is shut down. Do the task another way.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Local files that should not be shipped to a rented machine by default ─────
|
|
39
|
+
const SENSITIVE = [
|
|
40
|
+
/(^|\/)\.ssh(\/|$)/,
|
|
41
|
+
/(^|\/)\.aws(\/|$)/,
|
|
42
|
+
/(^|\/)\.gnupg(\/|$)/,
|
|
43
|
+
/(^|\/)\.config\/gcloud(\/|$)/,
|
|
44
|
+
/(^|\/)\.kube(\/|$)/,
|
|
45
|
+
/(^|\/)\.docker\/config\.json$/,
|
|
46
|
+
/(^|\/)\.netrc$/,
|
|
47
|
+
/(^|\/)\.npmrc$/,
|
|
48
|
+
/(^|\/)\.pypirc$/,
|
|
49
|
+
/(^|\/)\.env(\..*)?$/,
|
|
50
|
+
/\.(pem|key|p12|pfx)$/,
|
|
51
|
+
/(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export function sensitiveReason(localPath) {
|
|
55
|
+
const abs = path.resolve(expandHome(localPath)).replace(/\\/g, "/");
|
|
56
|
+
if (abs === os.homedir().replace(/\\/g, "/")) return "it is your whole home directory";
|
|
57
|
+
for (const re of SENSITIVE) if (re.test(abs)) return "it looks like credentials or a secrets file";
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function expandHome(p) {
|
|
62
|
+
if (!p) return p;
|
|
63
|
+
return p === "~" ? os.homedir() : p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Redaction ─────────────────────────────────────────────────────────────
|
|
67
|
+
/** Remove secrets (API key, SSH password, SSH host) from any text going back to the model. */
|
|
68
|
+
export function redact(text, secrets) {
|
|
69
|
+
if (text == null) return text;
|
|
70
|
+
let out = String(text);
|
|
71
|
+
for (const s of secrets) {
|
|
72
|
+
if (s && String(s).length >= 4) out = out.split(String(s)).join("[redacted]");
|
|
73
|
+
}
|
|
74
|
+
out = out.replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "sk-[redacted]");
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Instance object as the model sees it: no SSH host, user or password. */
|
|
79
|
+
export function publicInstance(inst) {
|
|
80
|
+
if (!inst) return inst;
|
|
81
|
+
const { ssh, ...rest } = inst;
|
|
82
|
+
return { ...rest, ssh_ready: !!(ssh && ssh.host && inst.status === "running") };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Quotes ────────────────────────────────────────────────────────────────
|
|
86
|
+
//
|
|
87
|
+
// create_instance only accepts a quote_id from estimate_cost, so the assistant always has the
|
|
88
|
+
// real hourly price, the estimate and the balance in hand (and shows them) before renting.
|
|
89
|
+
export class Quotes {
|
|
90
|
+
constructor(ttlMs = 15 * 60 * 1000) {
|
|
91
|
+
this.ttlMs = ttlMs;
|
|
92
|
+
this.map = new Map();
|
|
93
|
+
}
|
|
94
|
+
put(q) {
|
|
95
|
+
const id = "q_" + crypto.randomBytes(6).toString("hex");
|
|
96
|
+
this.map.set(id, { ...q, id, at: Date.now() });
|
|
97
|
+
return id;
|
|
98
|
+
}
|
|
99
|
+
take(id) {
|
|
100
|
+
const q = this.map.get(id);
|
|
101
|
+
if (!q) return { error: "Unknown quote_id. Call estimate_cost first and show the quote to the user." };
|
|
102
|
+
if (Date.now() - q.at > this.ttlMs) {
|
|
103
|
+
this.map.delete(id);
|
|
104
|
+
return { error: "This quote expired (quotes last 15 minutes). Call estimate_cost again; prices are live." };
|
|
105
|
+
}
|
|
106
|
+
this.map.delete(id);
|
|
107
|
+
return { quote: q };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function round2(n) {
|
|
112
|
+
return Math.round(n * 100) / 100;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Shell-quote one argument for bash. */
|
|
116
|
+
export function shq(s) {
|
|
117
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
118
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CloudGPU MCP server (stdio). Lets an AI coding assistant rent a GPU on cloudgpu.app, run a job,
|
|
3
|
+
// fetch the results and shut the GPU down.
|
|
4
|
+
//
|
|
5
|
+
// Env:
|
|
6
|
+
// CLOUDGPU_API_KEY required; create one at https://cloudgpu.app/api
|
|
7
|
+
// CLOUDGPU_MAX_SPEND_USD optional; highest budget the assistant may set for one instance (default 50)
|
|
8
|
+
// CLOUDGPU_MAX_HOURS optional; longest run the assistant may set for one instance (default 24)
|
|
9
|
+
// CLOUDGPU_READ_ONLY optional; "1" registers only the tools that look things up
|
|
10
|
+
// CLOUDGPU_BASE_URL optional; default https://cloudgpu.app
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { CloudGpuApi, ApiError } from "./api.js";
|
|
18
|
+
import { SshPool, exec, upload, download, writeFile } from "./ssh.js";
|
|
19
|
+
import {
|
|
20
|
+
checkCommand,
|
|
21
|
+
sensitiveReason,
|
|
22
|
+
expandHome,
|
|
23
|
+
redact,
|
|
24
|
+
publicInstance,
|
|
25
|
+
Quotes,
|
|
26
|
+
round2,
|
|
27
|
+
shq,
|
|
28
|
+
} from "./guards.js";
|
|
29
|
+
|
|
30
|
+
const VERSION = "0.1.1";
|
|
31
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const RULES = fs.readFileSync(path.join(HERE, "..", "rules", "cloudgpu.md"), "utf8");
|
|
33
|
+
|
|
34
|
+
const cfg = {
|
|
35
|
+
apiKey: (process.env.CLOUDGPU_API_KEY || "").trim(),
|
|
36
|
+
baseUrl: process.env.CLOUDGPU_BASE_URL || "https://cloudgpu.app",
|
|
37
|
+
maxSpend: num(process.env.CLOUDGPU_MAX_SPEND_USD, 50),
|
|
38
|
+
maxHours: num(process.env.CLOUDGPU_MAX_HOURS, 24),
|
|
39
|
+
readOnly: /^(1|true|yes)$/i.test(process.env.CLOUDGPU_READ_ONLY || ""),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function num(v, d) {
|
|
43
|
+
const n = Number(v);
|
|
44
|
+
return Number.isFinite(n) && n > 0 ? n : d;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const api = new CloudGpuApi({ apiKey: cfg.apiKey, baseUrl: cfg.baseUrl });
|
|
48
|
+
const pool = new SshPool();
|
|
49
|
+
const quotes = new Quotes();
|
|
50
|
+
// Instances this session touched: did we run anything, did we download anything.
|
|
51
|
+
const session = new Map();
|
|
52
|
+
|
|
53
|
+
const INSTRUCTIONS = `CloudGPU (cloudgpu.app) rents GPUs billed per minute from a prepaid balance.
|
|
54
|
+
Workflow: get_account and list_gpus -> estimate_cost -> show the quote to the user and get an explicit yes -> create_instance -> get_status until running -> upload -> run_command (background for long jobs) -> tail_logs -> download results -> stop_instance -> report the actual cost.
|
|
55
|
+
Rules: never rent without the user's yes on a quote; always set a budget and time limit (the platform shuts the instance down when either is hit); download results before stop_instance because destroying an instance erases its disk; never change passwords, SSH config, the firewall or the platform's monitoring agent on the instance; if anything fails, download what exists and stop the instance rather than leaving it running.
|
|
56
|
+
The same API key also works for the OpenAI-compatible model API at ${cfg.baseUrl}/v1 (see list_models).`;
|
|
57
|
+
|
|
58
|
+
const server = new McpServer({ name: "cloudgpu", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
59
|
+
|
|
60
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function ok(obj) {
|
|
63
|
+
const text = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2);
|
|
64
|
+
return { content: [{ type: "text", text: redact(text, [cfg.apiKey]) }] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fail(msg) {
|
|
68
|
+
return { content: [{ type: "text", text: redact(msg, [cfg.apiKey]) }], isError: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function wrap(fn) {
|
|
72
|
+
return async (args) => {
|
|
73
|
+
try {
|
|
74
|
+
return await fn(args || {});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err instanceof ApiError) {
|
|
77
|
+
let hint = "";
|
|
78
|
+
if (err.status === 401) hint = " Check CLOUDGPU_API_KEY (create one at https://cloudgpu.app/api).";
|
|
79
|
+
if (err.status === 402) hint = " Top up at https://cloudgpu.app/billing.";
|
|
80
|
+
return fail(`CloudGPU API error ${err.status} ${err.code}: ${err.message}${hint}`);
|
|
81
|
+
}
|
|
82
|
+
return fail(`Error: ${err && err.message ? err.message : String(err)}`);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function track(id) {
|
|
88
|
+
if (!session.has(id)) session.set(id, { ran: false, downloaded: false });
|
|
89
|
+
return session.get(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Running instance + SSH client; throws a readable error otherwise. */
|
|
93
|
+
async function connectTo(id) {
|
|
94
|
+
const inst = await api.instance(id);
|
|
95
|
+
if (inst.status !== "running") {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Instance ${id} is ${inst.status}` +
|
|
98
|
+
(inst.status === "creating"
|
|
99
|
+
? "; wait for it with get_status until it is running."
|
|
100
|
+
: inst.failure_reason
|
|
101
|
+
? `: ${inst.failure_reason}`
|
|
102
|
+
: inst.limits && inst.limits.stopped_by
|
|
103
|
+
? ` (stopped by its ${inst.limits.stopped_by} limit).`
|
|
104
|
+
: "."),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (!inst.ssh || !inst.ssh.host) throw new Error(`Instance ${id} has no SSH access yet; try get_status again shortly.`);
|
|
108
|
+
const client = await pool.get(id, inst.ssh);
|
|
109
|
+
const secrets = [cfg.apiKey, inst.ssh.password, inst.ssh.host];
|
|
110
|
+
return { inst, client, secrets };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const JOBS = ".cloudgpu/jobs";
|
|
114
|
+
// bash -i so conda / venv set up in ~/.bashrc are active, the same as an interactive login.
|
|
115
|
+
const NOISE = /^bash: (cannot set terminal process group|no job control in this shell).*\n?/gm;
|
|
116
|
+
|
|
117
|
+
function cleanOut(s, secrets) {
|
|
118
|
+
return redact((s || "").replace(NOISE, ""), secrets);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── account and catalogue ────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
server.registerTool(
|
|
124
|
+
"get_account",
|
|
125
|
+
{
|
|
126
|
+
title: "CloudGPU account",
|
|
127
|
+
description: "Balance in USD, how many instances this account may run at once, and how many are running now.",
|
|
128
|
+
inputSchema: {},
|
|
129
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
130
|
+
},
|
|
131
|
+
wrap(async () => ok(await api.account())),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
server.registerTool(
|
|
135
|
+
"list_gpus",
|
|
136
|
+
{
|
|
137
|
+
title: "List GPUs",
|
|
138
|
+
description:
|
|
139
|
+
"GPU models you can rent now, with VRAM, live price per GPU-hour in USD and availability (ok / low / none). Prices are live; never assume them.",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
min_vram_gb: z.number().optional().describe("Only GPUs with at least this much VRAM per card"),
|
|
142
|
+
gpu_model: z.string().optional().describe("Filter by name, e.g. 4090, A100, H20"),
|
|
143
|
+
},
|
|
144
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
145
|
+
},
|
|
146
|
+
wrap(async ({ min_vram_gb, gpu_model }) => {
|
|
147
|
+
let rows = await api.offers();
|
|
148
|
+
if (min_vram_gb) rows = rows.filter((r) => (r.vram_gb || 0) >= min_vram_gb);
|
|
149
|
+
if (gpu_model) {
|
|
150
|
+
const q = gpu_model.toLowerCase().replace(/\s+/g, "");
|
|
151
|
+
rows = rows.filter((r) => String(r.gpu_model).toLowerCase().replace(/\s+/g, "").includes(q));
|
|
152
|
+
}
|
|
153
|
+
return ok({
|
|
154
|
+
gpus: rows.map((r) => ({
|
|
155
|
+
gpu_model: r.gpu_model,
|
|
156
|
+
vram_gb: r.vram_gb,
|
|
157
|
+
usd_per_gpu_hour: r.hourly_usd,
|
|
158
|
+
available: r.available,
|
|
159
|
+
min_gpu_count: r.min_gpu_count,
|
|
160
|
+
})),
|
|
161
|
+
note: "Billed per minute. 'available' is live and can change before you rent.",
|
|
162
|
+
});
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
server.registerTool(
|
|
167
|
+
"list_templates",
|
|
168
|
+
{
|
|
169
|
+
title: "List templates",
|
|
170
|
+
description:
|
|
171
|
+
"Ready-made instance images (e.g. JupyterLab, Ollama, ComfyUI, LLaMA-Factory) with the GPU each recommends. Templates with has_api_endpoint get a public HTTPS api_url.",
|
|
172
|
+
inputSchema: {},
|
|
173
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
174
|
+
},
|
|
175
|
+
wrap(async () => ok({ templates: await api.templates() })),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
server.registerTool(
|
|
179
|
+
"estimate_cost",
|
|
180
|
+
{
|
|
181
|
+
title: "Quote a rental",
|
|
182
|
+
description:
|
|
183
|
+
"Live price and estimated cost for a GPU, plus your balance after it. Returns a quote_id that create_instance needs. Show the quote to the user and get an explicit yes before renting.",
|
|
184
|
+
inputSchema: {
|
|
185
|
+
gpu_model: z.string().describe("GPU model exactly as list_gpus returns it"),
|
|
186
|
+
gpu_count: z.number().int().min(1).max(8).default(1),
|
|
187
|
+
hours: z.number().positive().describe("Expected run time in hours"),
|
|
188
|
+
template: z.string().optional().describe("Template id from list_templates; omit for a plain GPU with Python and CUDA"),
|
|
189
|
+
},
|
|
190
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
191
|
+
},
|
|
192
|
+
wrap(async ({ gpu_model, gpu_count = 1, hours, template }) => {
|
|
193
|
+
const [offers, account] = await Promise.all([api.offers(), api.account().catch(() => null)]);
|
|
194
|
+
const offer = offers.find((o) => String(o.gpu_model).toLowerCase() === gpu_model.toLowerCase());
|
|
195
|
+
if (!offer) {
|
|
196
|
+
return fail(`No GPU named "${gpu_model}". Available: ${offers.map((o) => o.gpu_model).join(", ")}`);
|
|
197
|
+
}
|
|
198
|
+
if (gpu_count < (offer.min_gpu_count || 1)) {
|
|
199
|
+
return fail(`${offer.gpu_model} is rented in groups of at least ${offer.min_gpu_count} GPUs.`);
|
|
200
|
+
}
|
|
201
|
+
const hourly = round2(Number(offer.hourly_usd) * gpu_count);
|
|
202
|
+
const estimate = round2(hourly * hours);
|
|
203
|
+
const balance = account ? Number(account.balance_usd) : null;
|
|
204
|
+
const warnings = [];
|
|
205
|
+
if (offer.available === "none") warnings.push("This GPU is out of stock right now; renting will likely fail. Pick another.");
|
|
206
|
+
if (offer.available === "low") warnings.push("Only a few of these are free; renting may fail.");
|
|
207
|
+
if (balance != null && balance < estimate * 1.1) {
|
|
208
|
+
warnings.push(
|
|
209
|
+
`Balance $${balance} does not cover the estimate plus a 10% buffer. When the balance runs out the instance is destroyed and its disk erased. Top up at https://cloudgpu.app/billing.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const quote_id = quotes.put({ gpu_model: offer.gpu_model, gpu_count, template, hourly, hours, estimate });
|
|
213
|
+
return ok({
|
|
214
|
+
quote_id,
|
|
215
|
+
gpu_model: offer.gpu_model,
|
|
216
|
+
gpu_count,
|
|
217
|
+
template: template || null,
|
|
218
|
+
usd_per_hour: hourly,
|
|
219
|
+
hours,
|
|
220
|
+
estimated_cost_usd: estimate,
|
|
221
|
+
balance_usd: balance,
|
|
222
|
+
balance_after_usd: balance == null ? null : round2(balance - estimate),
|
|
223
|
+
suggested_max_spend_usd: round2(Math.min(cfg.maxSpend, Math.max(1, estimate * 1.25))),
|
|
224
|
+
warnings,
|
|
225
|
+
next: "Show this quote to the user. Only after they say yes, call create_instance with this quote_id.",
|
|
226
|
+
});
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
server.registerTool(
|
|
231
|
+
"list_models",
|
|
232
|
+
{
|
|
233
|
+
title: "List hosted models",
|
|
234
|
+
description:
|
|
235
|
+
"Models on CloudGPU's OpenAI-compatible API (same key, same balance). Use them from code with base_url " +
|
|
236
|
+
`${cfg.baseUrl}/v1 instead of renting a GPU when you only need inference.`,
|
|
237
|
+
inputSchema: {},
|
|
238
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
239
|
+
},
|
|
240
|
+
wrap(async () => {
|
|
241
|
+
const models = await api.models();
|
|
242
|
+
return ok({
|
|
243
|
+
base_url: `${cfg.baseUrl}/v1`,
|
|
244
|
+
auth: "Authorization: Bearer <the same CLOUDGPU_API_KEY>",
|
|
245
|
+
models: models.map((m) => m.id),
|
|
246
|
+
pricing: `${cfg.baseUrl}/pricing`,
|
|
247
|
+
});
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// ── instances (read) ─────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
server.registerTool(
|
|
254
|
+
"get_status",
|
|
255
|
+
{
|
|
256
|
+
title: "Instance status",
|
|
257
|
+
description:
|
|
258
|
+
"Status (creating / running / stopped / failed), minutes run, cost so far, whether SSH is ready, the public api_url if the template has one, and the auto-shutdown limits.",
|
|
259
|
+
inputSchema: { instance_id: z.string() },
|
|
260
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
261
|
+
},
|
|
262
|
+
wrap(async ({ instance_id }) => ok(publicInstance(await api.instance(instance_id)))),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
server.registerTool(
|
|
266
|
+
"list_instances",
|
|
267
|
+
{
|
|
268
|
+
title: "List instances",
|
|
269
|
+
description: "Instances on this account: running, starting, and those stopped in the last 7 days, with cost.",
|
|
270
|
+
inputSchema: {},
|
|
271
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
272
|
+
},
|
|
273
|
+
wrap(async () => ok({ instances: (await api.instances()).map(publicInstance) })),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// ── instances (act) ──────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
if (!cfg.readOnly) {
|
|
279
|
+
server.registerTool(
|
|
280
|
+
"create_instance",
|
|
281
|
+
{
|
|
282
|
+
title: "Rent a GPU",
|
|
283
|
+
description:
|
|
284
|
+
"Rents the GPU from a quote. Costs money. Requires a quote_id from estimate_cost that the user has approved. " +
|
|
285
|
+
"max_spend_usd and max_hours are enforced by CloudGPU's servers: the instance is destroyed when either is reached, even if this assistant has disconnected. " +
|
|
286
|
+
"idle_minutes destroys it after the GPU sits idle that long (installing packages and downloading data count as idle, so allow for setup).",
|
|
287
|
+
inputSchema: {
|
|
288
|
+
quote_id: z.string(),
|
|
289
|
+
user_confirmed: z.boolean().describe("true only if the user explicitly approved this quote in the conversation"),
|
|
290
|
+
max_spend_usd: z.number().positive().describe(`Budget for this instance in USD (at most ${cfg.maxSpend})`),
|
|
291
|
+
max_hours: z.number().positive().describe(`Hard time limit in hours (at most ${cfg.maxHours})`),
|
|
292
|
+
idle_minutes: z
|
|
293
|
+
.number()
|
|
294
|
+
.int()
|
|
295
|
+
.min(0)
|
|
296
|
+
.max(1440)
|
|
297
|
+
.default(60)
|
|
298
|
+
.describe("Destroy after the GPU has been idle this long; 0 turns it off; otherwise at least 10"),
|
|
299
|
+
},
|
|
300
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
301
|
+
},
|
|
302
|
+
wrap(async ({ quote_id, user_confirmed, max_spend_usd, max_hours, idle_minutes = 60 }) => {
|
|
303
|
+
if (user_confirmed !== true) {
|
|
304
|
+
return fail("Not rented: show the quote to the user and wait for an explicit yes, then call again with user_confirmed=true.");
|
|
305
|
+
}
|
|
306
|
+
if (max_spend_usd > cfg.maxSpend) {
|
|
307
|
+
return fail(
|
|
308
|
+
`max_spend_usd ${max_spend_usd} is above this server's cap of ${cfg.maxSpend} (CLOUDGPU_MAX_SPEND_USD). Ask the user to raise the cap in the MCP config if they want more.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (max_hours > cfg.maxHours) {
|
|
312
|
+
return fail(`max_hours ${max_hours} is above this server's cap of ${cfg.maxHours} (CLOUDGPU_MAX_HOURS).`);
|
|
313
|
+
}
|
|
314
|
+
if (idle_minutes > 0 && idle_minutes < 10) return fail("idle_minutes must be 0 (off) or at least 10.");
|
|
315
|
+
const { quote, error } = quotes.take(quote_id);
|
|
316
|
+
if (error) return fail(error);
|
|
317
|
+
const account = await api.account();
|
|
318
|
+
const balance = Number(account.balance_usd);
|
|
319
|
+
const needed = round2(Math.min(max_spend_usd, quote.estimate) * 1.1);
|
|
320
|
+
if (balance < needed) {
|
|
321
|
+
return fail(
|
|
322
|
+
`Not rented: balance $${balance} is below $${needed} (the estimate or budget plus 10%). If the balance runs out the instance is destroyed and its disk erased. Top up at https://cloudgpu.app/billing.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const body = {
|
|
326
|
+
gpu_model: quote.gpu_model,
|
|
327
|
+
gpu_count: quote.gpu_count,
|
|
328
|
+
max_minutes: Math.ceil(max_hours * 60),
|
|
329
|
+
max_spend_usd,
|
|
330
|
+
idle_minutes,
|
|
331
|
+
};
|
|
332
|
+
if (quote.template) body.template = quote.template;
|
|
333
|
+
const inst = await api.create(body);
|
|
334
|
+
track(inst.id);
|
|
335
|
+
return ok({
|
|
336
|
+
...publicInstance(inst),
|
|
337
|
+
next: "Call get_status at intervals (not in a tight loop) until status is running, then upload and run_command.",
|
|
338
|
+
});
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
server.registerTool(
|
|
343
|
+
"run_command",
|
|
344
|
+
{
|
|
345
|
+
title: "Run a command on the instance",
|
|
346
|
+
description:
|
|
347
|
+
"Runs a bash command on the instance as root, in an interactive-style shell so conda/venv from ~/.bashrc are active. " +
|
|
348
|
+
"Foreground returns the exit code and the last 20k characters of output. For anything longer than a few minutes (training, big downloads) use background=true, then tail_logs.",
|
|
349
|
+
inputSchema: {
|
|
350
|
+
instance_id: z.string(),
|
|
351
|
+
command: z.string().describe("bash command or script"),
|
|
352
|
+
background: z.boolean().default(false),
|
|
353
|
+
timeout_sec: z.number().int().min(5).max(3600).default(600).describe("Foreground only"),
|
|
354
|
+
workdir: z.string().default("~").describe("Directory to run in"),
|
|
355
|
+
},
|
|
356
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
357
|
+
},
|
|
358
|
+
wrap(async ({ instance_id, command, background = false, timeout_sec = 600, workdir = "~" }) => {
|
|
359
|
+
const blocked = checkCommand(command);
|
|
360
|
+
if (blocked) return fail(blocked);
|
|
361
|
+
const { client, secrets } = await connectTo(instance_id);
|
|
362
|
+
track(instance_id).ran = true;
|
|
363
|
+
const job = "j" + Date.now().toString(36);
|
|
364
|
+
const cd = workdir === "~" ? "cd ~" : workdir.startsWith("~/") ? `cd ~/${shq(workdir.slice(2))}` : `cd ${shq(workdir)}`;
|
|
365
|
+
await writeFile(client, `${JOBS}/${job}.sh`, `${cd} || exit 97\n${command}\n`);
|
|
366
|
+
if (background) {
|
|
367
|
+
const r = await exec(
|
|
368
|
+
client,
|
|
369
|
+
`cd ~ && setsid nohup bash -c 'bash -i ${JOBS}/${job}.sh > ${JOBS}/${job}.log 2>&1 < /dev/null; echo $? > ${JOBS}/${job}.exit' > /dev/null 2>&1 < /dev/null & echo started`,
|
|
370
|
+
{ timeoutSec: 30 },
|
|
371
|
+
);
|
|
372
|
+
if (!/started/.test(r.stdout)) return fail(`Could not start the job: ${cleanOut(r.stderr, secrets)}`);
|
|
373
|
+
return ok({
|
|
374
|
+
job_id: job,
|
|
375
|
+
log: `~/${JOBS}/${job}.log`,
|
|
376
|
+
next: "Check progress with tail_logs (job_id). The job keeps running if this assistant disconnects; the instance's limits still apply.",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const r = await exec(client, `cd ~ && timeout ${timeout_sec} bash -i ${JOBS}/${job}.sh 2>&1 < /dev/null`, {
|
|
380
|
+
timeoutSec: timeout_sec + 15,
|
|
381
|
+
});
|
|
382
|
+
const out = cleanOut(r.stdout + (r.stderr ? "\n" + r.stderr : ""), secrets);
|
|
383
|
+
return ok({
|
|
384
|
+
exit_code: r.timedOut || r.code === 124 ? null : r.code,
|
|
385
|
+
timed_out: r.timedOut || r.code === 124,
|
|
386
|
+
output: out,
|
|
387
|
+
...(r.timedOut || r.code === 124 ? { hint: "Timed out. Re-run long work with background=true." } : {}),
|
|
388
|
+
});
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
server.registerTool(
|
|
393
|
+
"upload",
|
|
394
|
+
{
|
|
395
|
+
title: "Upload to the instance",
|
|
396
|
+
description:
|
|
397
|
+
"Copies a local file or directory (this computer) to the instance over SFTP. node_modules, .git and __pycache__ are skipped. Credentials files (.ssh, .env, keys) are refused unless allow_sensitive is true and the user asked for it.",
|
|
398
|
+
inputSchema: {
|
|
399
|
+
instance_id: z.string(),
|
|
400
|
+
local_path: z.string(),
|
|
401
|
+
remote_path: z.string().describe("Destination on the instance, e.g. /root/project or /root/data/"),
|
|
402
|
+
allow_sensitive: z.boolean().default(false),
|
|
403
|
+
},
|
|
404
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
405
|
+
},
|
|
406
|
+
wrap(async ({ instance_id, local_path, remote_path, allow_sensitive = false }) => {
|
|
407
|
+
const local = path.resolve(expandHome(local_path));
|
|
408
|
+
if (!fs.existsSync(local)) return fail(`No such local path: ${local}`);
|
|
409
|
+
const why = sensitiveReason(local);
|
|
410
|
+
if (why && !allow_sensitive) {
|
|
411
|
+
return fail(`Refused to upload ${local}: ${why}. If the user explicitly wants it on the instance, call again with allow_sensitive=true.`);
|
|
412
|
+
}
|
|
413
|
+
const { client } = await connectTo(instance_id);
|
|
414
|
+
const remote = remote_path.startsWith("~/") ? remote_path.slice(2) : remote_path;
|
|
415
|
+
const r = await upload(client, local, remote);
|
|
416
|
+
return ok({ uploaded_files: r.files, bytes: r.bytes, remote_path });
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
server.registerTool(
|
|
421
|
+
"download",
|
|
422
|
+
{
|
|
423
|
+
title: "Download from the instance",
|
|
424
|
+
description:
|
|
425
|
+
"Copies a file or directory from the instance to this computer. Do this for every result before stop_instance: destroying an instance erases its disk.",
|
|
426
|
+
inputSchema: {
|
|
427
|
+
instance_id: z.string(),
|
|
428
|
+
remote_path: z.string(),
|
|
429
|
+
local_path: z.string().describe("Local destination file or directory, e.g. ./outputs/"),
|
|
430
|
+
},
|
|
431
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
432
|
+
},
|
|
433
|
+
wrap(async ({ instance_id, remote_path, local_path }) => {
|
|
434
|
+
const { client } = await connectTo(instance_id);
|
|
435
|
+
const remote = remote_path.startsWith("~/") ? remote_path.slice(2) : remote_path;
|
|
436
|
+
const r = await download(client, remote, path.resolve(expandHome(local_path)));
|
|
437
|
+
track(instance_id).downloaded = true;
|
|
438
|
+
return ok({
|
|
439
|
+
files: r.files.slice(0, 200),
|
|
440
|
+
file_count: r.files.length,
|
|
441
|
+
bytes: r.bytes,
|
|
442
|
+
});
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
server.registerTool(
|
|
447
|
+
"tail_logs",
|
|
448
|
+
{
|
|
449
|
+
title: "Job logs and GPU use",
|
|
450
|
+
description:
|
|
451
|
+
"Last lines of a background job's log (job_id) or any log file (path), whether the job is still running and its exit code, plus current GPU utilisation and memory.",
|
|
452
|
+
inputSchema: {
|
|
453
|
+
instance_id: z.string(),
|
|
454
|
+
job_id: z.string().optional(),
|
|
455
|
+
path: z.string().optional(),
|
|
456
|
+
lines: z.number().int().min(1).max(2000).default(100),
|
|
457
|
+
},
|
|
458
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
459
|
+
},
|
|
460
|
+
wrap(async ({ instance_id, job_id, path: logPath, lines = 100 }) => {
|
|
461
|
+
if (!job_id && !logPath) return fail("Give job_id (from run_command background=true) or path.");
|
|
462
|
+
if (job_id && !/^[a-z0-9]+$/i.test(job_id)) return fail("Bad job_id.");
|
|
463
|
+
const { client, secrets } = await connectTo(instance_id);
|
|
464
|
+
const file = job_id ? `${JOBS}/${job_id}.log` : logPath.startsWith("~/") ? logPath.slice(2) : logPath;
|
|
465
|
+
const exitFile = job_id ? `${JOBS}/${job_id}.exit` : "/nonexistent";
|
|
466
|
+
const r = await exec(
|
|
467
|
+
client,
|
|
468
|
+
`cd ~; echo '<<LOG'; tail -n ${lines} ${shq(file)} 2>&1; echo 'LOG>>'; ` +
|
|
469
|
+
`if [ -f ${shq(exitFile)} ]; then echo "EXIT=$(cat ${shq(exitFile)})"; fi; ` +
|
|
470
|
+
`nvidia-smi --query-gpu=index,utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits 2>/dev/null | sed 's/^/GPU=/'`,
|
|
471
|
+
{ timeoutSec: 60 },
|
|
472
|
+
);
|
|
473
|
+
const s = r.stdout;
|
|
474
|
+
const log = s.includes("<<LOG") ? s.slice(s.indexOf("<<LOG") + 6, s.lastIndexOf("LOG>>")) : s;
|
|
475
|
+
const exitM = s.match(/EXIT=(\d+)/);
|
|
476
|
+
const gpus = [...s.matchAll(/GPU=(\d+), (\d+), (\d+), (\d+)/g)].map((m) => ({
|
|
477
|
+
gpu: Number(m[1]),
|
|
478
|
+
utilization_pct: Number(m[2]),
|
|
479
|
+
memory_used_mb: Number(m[3]),
|
|
480
|
+
memory_total_mb: Number(m[4]),
|
|
481
|
+
}));
|
|
482
|
+
return ok({
|
|
483
|
+
log: cleanOut(log, secrets),
|
|
484
|
+
...(job_id ? { running: !exitM, exit_code: exitM ? Number(exitM[1]) : null } : {}),
|
|
485
|
+
gpus,
|
|
486
|
+
});
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
server.registerTool(
|
|
491
|
+
"stop_instance",
|
|
492
|
+
{
|
|
493
|
+
title: "Destroy an instance",
|
|
494
|
+
description:
|
|
495
|
+
"Destroys the instance and stops billing. Its disk is erased. If commands were run on it in this session and nothing was downloaded, this refuses unless force=true (only after the user confirms the results are not needed).",
|
|
496
|
+
inputSchema: {
|
|
497
|
+
instance_id: z.string(),
|
|
498
|
+
force: z.boolean().default(false),
|
|
499
|
+
},
|
|
500
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
501
|
+
},
|
|
502
|
+
wrap(async ({ instance_id, force = false }) => {
|
|
503
|
+
const s = session.get(instance_id);
|
|
504
|
+
if (s && s.ran && !s.downloaded && !force) {
|
|
505
|
+
return fail(
|
|
506
|
+
"Not stopped: nothing has been downloaded from this instance yet, and stopping erases its disk. Download the results first, or ask the user and call again with force=true.",
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
pool.close(instance_id);
|
|
510
|
+
try {
|
|
511
|
+
const inst = await api.destroy(instance_id);
|
|
512
|
+
session.delete(instance_id);
|
|
513
|
+
return ok({ ...publicInstance(inst), note: "Destroyed. Billing has stopped." });
|
|
514
|
+
} catch (err) {
|
|
515
|
+
if (err instanceof ApiError && err.code === "cannot_destroy") {
|
|
516
|
+
// 已经停了(限额到线、余额用完、开出来没 GPU 被平台退款):告诉助手结果,而不是报错
|
|
517
|
+
const inst = await api.instance(instance_id).catch(() => null);
|
|
518
|
+
if (inst && inst.status !== "running") {
|
|
519
|
+
session.delete(instance_id);
|
|
520
|
+
return ok({ ...publicInstance(inst), note: "Already stopped; nothing more to do. Billing has stopped." });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (err instanceof ApiError && err.code === "instance_provisioning") {
|
|
524
|
+
return fail("The instance is still starting and cannot be destroyed yet. Wait for get_status to show running (or failed; failed instances are not charged), then call stop_instance again.");
|
|
525
|
+
}
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── rules for the assistant ──────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
server.registerResource(
|
|
535
|
+
"rules",
|
|
536
|
+
"cloudgpu://rules",
|
|
537
|
+
{ title: "CloudGPU agent rules", description: "How to rent, run and shut down safely", mimeType: "text/markdown" },
|
|
538
|
+
async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: RULES }] }),
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
server.registerPrompt(
|
|
542
|
+
"train_on_cloudgpu",
|
|
543
|
+
{
|
|
544
|
+
title: "Run a job on a CloudGPU GPU",
|
|
545
|
+
description: "Rent a GPU, run a training or fine-tuning job, bring the results back and shut down",
|
|
546
|
+
argsSchema: { task: z.string().describe("What to run, e.g. 'LoRA fine-tune a 7B base model on data/train.jsonl'") },
|
|
547
|
+
},
|
|
548
|
+
({ task }) => ({
|
|
549
|
+
messages: [{ role: "user", content: { type: "text", text: `${RULES}\n\n---\n\nTask: ${task}` } }],
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// ── start ────────────────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
if (process.argv.includes("--version")) {
|
|
556
|
+
console.log(VERSION);
|
|
557
|
+
process.exit(0);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const transport = new StdioServerTransport();
|
|
561
|
+
await server.connect(transport);
|
|
562
|
+
const bye = () => {
|
|
563
|
+
pool.closeAll();
|
|
564
|
+
process.exit(0);
|
|
565
|
+
};
|
|
566
|
+
process.on("SIGINT", bye);
|
|
567
|
+
process.on("SIGTERM", bye);
|
|
568
|
+
process.stdin.on("close", bye);
|
|
569
|
+
if (!cfg.apiKey) {
|
|
570
|
+
console.error("cloudgpu-mcp: CLOUDGPU_API_KEY is not set; tools will return an error until it is. Create a key at https://cloudgpu.app/api");
|
|
571
|
+
}
|
package/src/ssh.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// SSH to a rented instance: run commands, upload, download.
|
|
2
|
+
//
|
|
3
|
+
// The SSH password comes from the API and stays inside this process. It is never put in a tool
|
|
4
|
+
// result, so it never reaches the model's context or the chat log.
|
|
5
|
+
import { Client } from "ssh2";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { sleep } from "./api.js";
|
|
9
|
+
|
|
10
|
+
const MAX_OUTPUT = 20000; // characters of command output returned to the model
|
|
11
|
+
|
|
12
|
+
export class SshPool {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.conns = new Map(); // instanceId -> { client, key }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async get(instanceId, ssh) {
|
|
18
|
+
const key = `${ssh.host}:${ssh.port}:${ssh.user}`;
|
|
19
|
+
const cur = this.conns.get(instanceId);
|
|
20
|
+
if (cur && cur.key === key && cur.ready) return cur.client;
|
|
21
|
+
if (cur) this.close(instanceId);
|
|
22
|
+
// Some instance gateways drop several connections opened back to back, so retry with backoff.
|
|
23
|
+
let lastErr;
|
|
24
|
+
for (let i = 0; i < 4; i++) {
|
|
25
|
+
try {
|
|
26
|
+
const client = await connect(ssh);
|
|
27
|
+
const entry = { client, key, ready: true };
|
|
28
|
+
client.on("close", () => {
|
|
29
|
+
entry.ready = false;
|
|
30
|
+
});
|
|
31
|
+
client.on("error", () => {
|
|
32
|
+
entry.ready = false;
|
|
33
|
+
});
|
|
34
|
+
this.conns.set(instanceId, entry);
|
|
35
|
+
return client;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
lastErr = err;
|
|
38
|
+
await sleep(2000 * (i + 1));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`SSH connection failed: ${lastErr && lastErr.message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
close(instanceId) {
|
|
45
|
+
const cur = this.conns.get(instanceId);
|
|
46
|
+
if (cur) {
|
|
47
|
+
try {
|
|
48
|
+
cur.client.end();
|
|
49
|
+
} catch {
|
|
50
|
+
/* already closed */
|
|
51
|
+
}
|
|
52
|
+
this.conns.delete(instanceId);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
closeAll() {
|
|
57
|
+
for (const id of [...this.conns.keys()]) this.close(id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function connect(ssh) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const c = new Client();
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
c.end();
|
|
66
|
+
reject(new Error("timed out"));
|
|
67
|
+
}, 25000);
|
|
68
|
+
c.on("ready", () => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
resolve(c);
|
|
71
|
+
});
|
|
72
|
+
c.on("error", (e) => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
reject(e);
|
|
75
|
+
});
|
|
76
|
+
c.connect({
|
|
77
|
+
host: ssh.host,
|
|
78
|
+
port: ssh.port || 22,
|
|
79
|
+
username: ssh.user || "root",
|
|
80
|
+
password: ssh.password,
|
|
81
|
+
readyTimeout: 20000,
|
|
82
|
+
keepaliveInterval: 15000,
|
|
83
|
+
keepaliveCountMax: 4,
|
|
84
|
+
tryKeyboard: true,
|
|
85
|
+
});
|
|
86
|
+
c.on("keyboard-interactive", (_n, _i, _l, prompts, finish) => finish(prompts.map(() => ssh.password)));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Run a command; resolves { code, stdout, stderr } (both truncated to the tail). */
|
|
91
|
+
export function exec(client, command, { timeoutSec = 600 } = {}) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
client.exec(command, (err, stream) => {
|
|
94
|
+
if (err) return reject(err);
|
|
95
|
+
let out = "";
|
|
96
|
+
let errOut = "";
|
|
97
|
+
let done = false;
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
if (done) return;
|
|
100
|
+
done = true;
|
|
101
|
+
try {
|
|
102
|
+
stream.close();
|
|
103
|
+
} catch {
|
|
104
|
+
/* ignore */
|
|
105
|
+
}
|
|
106
|
+
resolve({ code: null, timedOut: true, stdout: tail(out), stderr: tail(errOut) });
|
|
107
|
+
}, timeoutSec * 1000);
|
|
108
|
+
stream.on("data", (d) => {
|
|
109
|
+
out = keepTail(out + d.toString("utf8"));
|
|
110
|
+
});
|
|
111
|
+
stream.stderr.on("data", (d) => {
|
|
112
|
+
errOut = keepTail(errOut + d.toString("utf8"));
|
|
113
|
+
});
|
|
114
|
+
stream.on("close", (code) => {
|
|
115
|
+
if (done) return;
|
|
116
|
+
done = true;
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
resolve({ code, timedOut: false, stdout: tail(out), stderr: tail(errOut) });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function keepTail(s) {
|
|
125
|
+
return s.length > MAX_OUTPUT * 2 ? s.slice(-MAX_OUTPUT * 2) : s;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function tail(s) {
|
|
129
|
+
return s.length > MAX_OUTPUT ? "…(earlier output cut)…\n" + s.slice(-MAX_OUTPUT) : s;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sftpOf(client) {
|
|
133
|
+
return new Promise((resolve, reject) => client.sftp((err, sftp) => (err ? reject(err) : resolve(sftp))));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const p = (fn) =>
|
|
137
|
+
new Promise((resolve, reject) => fn((err, v) => (err ? reject(err) : resolve(v))));
|
|
138
|
+
|
|
139
|
+
async function mkdirp(sftp, dir) {
|
|
140
|
+
const parts = dir.split("/").filter(Boolean);
|
|
141
|
+
let cur = dir.startsWith("/") ? "" : ".";
|
|
142
|
+
for (const part of parts) {
|
|
143
|
+
cur = `${cur}/${part}`;
|
|
144
|
+
try {
|
|
145
|
+
await p((cb) => sftp.stat(cur, cb));
|
|
146
|
+
} catch {
|
|
147
|
+
await p((cb) => sftp.mkdir(cur, cb)).catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Upload a local file or directory. Returns { files, bytes }. */
|
|
153
|
+
export async function upload(client, localPath, remotePath) {
|
|
154
|
+
const sftp = await sftpOf(client);
|
|
155
|
+
try {
|
|
156
|
+
const st = fs.statSync(localPath);
|
|
157
|
+
let files = 0;
|
|
158
|
+
let bytes = 0;
|
|
159
|
+
const put = async (src, dst) => {
|
|
160
|
+
await mkdirp(sftp, path.posix.dirname(dst));
|
|
161
|
+
await p((cb) => sftp.fastPut(src, dst, cb));
|
|
162
|
+
files += 1;
|
|
163
|
+
bytes += fs.statSync(src).size;
|
|
164
|
+
};
|
|
165
|
+
if (st.isDirectory()) {
|
|
166
|
+
const walk = async (dir, rel) => {
|
|
167
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
168
|
+
if (ent.name === "node_modules" || ent.name === ".git" || ent.name === "__pycache__") continue;
|
|
169
|
+
const src = path.join(dir, ent.name);
|
|
170
|
+
const dst = path.posix.join(remotePath, rel, ent.name);
|
|
171
|
+
if (ent.isDirectory()) await walk(src, path.posix.join(rel, ent.name));
|
|
172
|
+
else if (ent.isFile()) await put(src, dst);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
await mkdirp(sftp, remotePath);
|
|
176
|
+
await walk(localPath, "");
|
|
177
|
+
} else {
|
|
178
|
+
await put(localPath, remotePath.endsWith("/") ? path.posix.join(remotePath, path.basename(localPath)) : remotePath);
|
|
179
|
+
}
|
|
180
|
+
return { files, bytes };
|
|
181
|
+
} finally {
|
|
182
|
+
sftp.end();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Download a remote file or directory. Returns { files: [{path, bytes}], bytes }. */
|
|
187
|
+
export async function download(client, remotePath, localPath) {
|
|
188
|
+
const sftp = await sftpOf(client);
|
|
189
|
+
try {
|
|
190
|
+
const st = await p((cb) => sftp.stat(remotePath, cb));
|
|
191
|
+
const files = [];
|
|
192
|
+
let bytes = 0;
|
|
193
|
+
const get = async (src, dst, size) => {
|
|
194
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
195
|
+
await p((cb) => sftp.fastGet(src, dst, cb));
|
|
196
|
+
files.push({ path: dst, bytes: size });
|
|
197
|
+
bytes += size;
|
|
198
|
+
};
|
|
199
|
+
if (st.isDirectory()) {
|
|
200
|
+
const walk = async (dir, rel) => {
|
|
201
|
+
const list = await p((cb) => sftp.readdir(dir, cb));
|
|
202
|
+
for (const ent of list) {
|
|
203
|
+
const src = path.posix.join(dir, ent.filename);
|
|
204
|
+
const dst = path.join(localPath, rel, ent.filename);
|
|
205
|
+
if (ent.attrs.isDirectory()) await walk(src, path.join(rel, ent.filename));
|
|
206
|
+
else if (ent.attrs.isFile()) await get(src, dst, ent.attrs.size);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
fs.mkdirSync(localPath, { recursive: true });
|
|
210
|
+
await walk(remotePath, "");
|
|
211
|
+
} else {
|
|
212
|
+
let dst = localPath;
|
|
213
|
+
if (fs.existsSync(localPath) && fs.statSync(localPath).isDirectory()) {
|
|
214
|
+
dst = path.join(localPath, path.posix.basename(remotePath));
|
|
215
|
+
}
|
|
216
|
+
await get(remotePath, dst, st.size);
|
|
217
|
+
}
|
|
218
|
+
return { files, bytes };
|
|
219
|
+
} finally {
|
|
220
|
+
sftp.end();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Write a small text file on the instance. */
|
|
225
|
+
export async function writeFile(client, remotePath, content) {
|
|
226
|
+
const sftp = await sftpOf(client);
|
|
227
|
+
try {
|
|
228
|
+
await mkdirp(sftp, path.posix.dirname(remotePath));
|
|
229
|
+
await p((cb) => sftp.writeFile(remotePath, content, { mode: 0o700 }, cb));
|
|
230
|
+
} finally {
|
|
231
|
+
sftp.end();
|
|
232
|
+
}
|
|
233
|
+
}
|