computenexa-mcp 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,11 @@
1
+ """ComputeNexa MCP server: let your AI assistant find, launch and stop private GPUs on ComputeNexa.
2
+
3
+ uvx computenexa-mcp # with CNX_API_KEY set: a stdio MCP server named "ComputeNexa"
4
+
5
+ Guide: https://app.computenexa.com/help/mcp
6
+ """
7
+
8
+ from ._version import __version__
9
+ from .server import build_server, main
10
+
11
+ __all__ = ["__version__", "build_server", "main"]
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -0,0 +1,81 @@
1
+ """Small helpers: dollars, durations, and making text people typed safe to show an assistant."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ _CONTROL = re.compile("[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]")
11
+ _SPACES = re.compile(r"\s+")
12
+
13
+
14
+ def clean(value: Any, limit: int = 80) -> str | None:
15
+ """Text a person typed (a machine label, a project or team name): one line, no control or invisible characters,
16
+ at most `limit` characters. Tool results are read by the assistant, so these never carry line breaks or
17
+ formatting that could pass for instructions."""
18
+ if value is None:
19
+ return None
20
+ text = _SPACES.sub(" ", _CONTROL.sub(" ", str(value))).strip()
21
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
22
+
23
+
24
+ def quoted(value: Any, limit: int = 80) -> str:
25
+ """`clean`, in quotation marks, for sentences: 'project “titanic”'."""
26
+ text = clean(value, limit)
27
+ return "“" + (text or "").replace("“", "'").replace("”", "'") + "”"
28
+
29
+
30
+ def usd(cents: int | None) -> float | None:
31
+ return None if cents is None else round(cents / 100, 2)
32
+
33
+
34
+ def money(cents: int | None) -> str:
35
+ return "—" if cents is None else f"${cents / 100:,.2f}"
36
+
37
+
38
+ def charge_cents(price_per_hour_cents: int, minutes: int) -> int:
39
+ """What a block of `minutes` costs at this hourly price, rounded up to the cent (as the API charges)."""
40
+ return -(-price_per_hour_cents * minutes // 60)
41
+
42
+
43
+ def whole_hours(minutes: int) -> int:
44
+ """Hour-billed GPUs take durations in whole hours."""
45
+ return max(1, math.ceil(minutes / 60)) * 60
46
+
47
+
48
+ def duration(minutes: float | None) -> str:
49
+ if minutes is None:
50
+ return "—"
51
+ minutes = round(minutes)
52
+ if minutes < 60:
53
+ return f"{minutes} min"
54
+ hours, rest = divmod(minutes, 60)
55
+ return f"{hours} h" if rest == 0 else f"{hours} h {rest} min"
56
+
57
+
58
+ def when(value: datetime | None) -> str | None:
59
+ """ISO 8601 in UTC, for structured results."""
60
+ if value is None:
61
+ return None
62
+ if value.tzinfo is None:
63
+ value = value.replace(tzinfo=timezone.utc)
64
+ return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
65
+
66
+
67
+ def dollars(obj: Any) -> Any:
68
+ """A copy of an API value with every `*_cents` field turned into `*_usd` (dollars, 2 decimals)."""
69
+ if isinstance(obj, dict):
70
+ out = {}
71
+ for k, v in obj.items():
72
+ if isinstance(k, str) and k.endswith("_cents") and (v is None or isinstance(v, (int, float))):
73
+ out[k[: -len("_cents")] + "_usd"] = None if v is None else round(v / 100, 2)
74
+ else:
75
+ out[k] = dollars(v)
76
+ return out
77
+ if isinstance(obj, list):
78
+ return [dollars(v) for v in obj]
79
+ if isinstance(obj, str):
80
+ return clean(obj, 200)
81
+ return obj
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,202 @@
1
+ """What each tool returns as structured data (its `outputSchema`). Money is in US dollars; times are ISO 8601 UTC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class KeyInfo(BaseModel):
11
+ name: str | None = Field(description="The API key's name in the dashboard")
12
+ prefix: str | None = Field(description="Its first characters, e.g. cnxk_a1b2c3d4 (never the whole key)")
13
+ scope: Literal["read", "write"] | None = Field(description="read = look only; write = also launch and stop")
14
+
15
+
16
+ class Account(BaseModel):
17
+ email: str
18
+ team: str | None
19
+ role: str | None = Field(description="Your role in the team: owner, admin or member")
20
+ team_members: int | None
21
+ balance_usd: float = Field(description="The team's prepaid balance")
22
+ gpu_limit: int = Field(description="GPUs the team may run at the same time")
23
+ api_key: KeyInfo | None = Field(description="The key this server uses; null if the platform doesn't report it")
24
+ can_launch: bool | None = Field(description="Whether this server's key may launch, extend and stop machines")
25
+
26
+
27
+ class GpuInfo(BaseModel):
28
+ slug: str = Field(description="What to launch with, e.g. rtx-4090")
29
+ name: str
30
+ memory_gb: int
31
+ available: bool
32
+ price_per_hour_usd: float | None = Field(description="Per GPU; null when not for sale right now")
33
+ billing: Literal["minute", "hour"] = Field(description="minute = billed per minute; hour = per started hour")
34
+ apps: list[str]
35
+ regions: list[str]
36
+
37
+
38
+ class GpuList(BaseModel):
39
+ region: str
40
+ gpus: list[GpuInfo]
41
+
42
+
43
+ class ModelInfo(BaseModel):
44
+ name: str
45
+ label: str | None = None
46
+ size_gb: float | None = None
47
+ min_memory_gb: int | None = None
48
+ licence: str | None = None
49
+ gated: bool | None = None
50
+
51
+
52
+ class AppInfo(BaseModel):
53
+ name: str = Field(description="The value for launch_machine's app")
54
+ display_name: str
55
+ summary: str
56
+ needs_ssh_key: bool
57
+ models: list[ModelInfo]
58
+ model_required: bool
59
+ recommended: list[tuple[int, str]] = Field(description="[min GPU memory in GB, model]; the first match wins")
60
+
61
+
62
+ class Software(BaseModel):
63
+ image: str
64
+ cuda: str | None = None
65
+ ubuntu: str | None = None
66
+ python: str | None = None
67
+ headline: list[dict[str, Any]] = Field(default_factory=list, description="Main packages: [{name, version}]")
68
+ tools: list[dict[str, Any]] = Field(default_factory=list)
69
+ package_count: int = 0
70
+ matching_packages: list[dict[str, Any]] = Field(default_factory=list,
71
+ description="Packages matching the `package` filter")
72
+
73
+
74
+ class AppList(BaseModel):
75
+ apps: list[AppInfo]
76
+ software: list[Software] = Field(default_factory=list, description="What's installed (when one app was asked for)")
77
+
78
+
79
+ class Machine(BaseModel):
80
+ id: str = Field(description="e.g. cnx-7f3a9c")
81
+ state: str = Field(description="requested | provisioning | running | stopping | terminated | failed")
82
+ gpu: str
83
+ gpu_count: int
84
+ app: str
85
+ label: str | None
86
+ price_per_hour_usd: float = Field(description="For the whole machine (all its GPUs)")
87
+ billing: str | None = None
88
+ expires_at: str | None
89
+ time_left_minutes: int | None
90
+ charged_usd: float
91
+ refunded_usd: float
92
+ cost_so_far_usd: float = Field(description="What it costs if it ended now")
93
+ keep_running_hours: int | None = Field(description="Renews this many hours at a time; null = off")
94
+ next_renewal_at: str | None = None
95
+ renew_failed: bool = False
96
+ end_reason: str | None = None
97
+ startup_progress: str | None = None
98
+ launched_by: str | None = None
99
+
100
+
101
+ class MachineDetail(Machine):
102
+ ssh_command: str | None = Field(description="While running, if the machine has an SSH key")
103
+ browser_apps: list[str] = Field(default_factory=list,
104
+ description="Apps to open from the dashboard's Open button while running")
105
+ api_base_url: str | None = Field(default=None, description="Model API / Voice Agent machines: their API address")
106
+ endpoint_url: str | None = Field(default=None, description="Your own server on the machine's port 8000")
107
+ endpoint_name: str | None = None
108
+ model: str | None = None
109
+ project: str | None = None
110
+ region: str | None = None
111
+
112
+
113
+ class MachineList(BaseModel):
114
+ machines: list[Machine]
115
+
116
+
117
+ class Usage(BaseModel):
118
+ days: int
119
+ balance_usd: float
120
+ spend_usd: float
121
+ topups_usd: float
122
+ sessions: int
123
+ gpu_hours: float
124
+ by_gpu: list[dict[str, Any]]
125
+ by_app: list[dict[str, Any]]
126
+ daily: list[dict[str, Any]]
127
+ projects: list[dict[str, Any]]
128
+
129
+
130
+ class Receipt(BaseModel):
131
+ machine: str
132
+ label: str | None
133
+ gpu: str
134
+ gpu_count: int
135
+ app: str
136
+ model: str | None
137
+ started_at: str | None
138
+ ended_at: str | None
139
+ used_minutes: float
140
+ booked_minutes: int
141
+ charged_usd: float
142
+ refunded_usd: float
143
+ net_usd: float = Field(description="What it cost in the end")
144
+ project: str | None
145
+ outcome: str
146
+
147
+
148
+ class ReceiptList(BaseModel):
149
+ receipts: list[Receipt]
150
+ total_net_usd: float
151
+
152
+
153
+ class Project(BaseModel):
154
+ id: str
155
+ name: str
156
+ size_mb: float
157
+ files: int
158
+ in_use_by: str | None
159
+ last_used_at: str | None
160
+
161
+
162
+ class ProjectList(BaseModel):
163
+ projects: list[Project]
164
+
165
+
166
+ class Endpoint(BaseModel):
167
+ id: str
168
+ name: str
169
+ url: str
170
+ machine: str | None = Field(description="The machine it points at now, if any")
171
+ key: str | None = Field(default=None, description="Only when just created, or from get_endpoint_key")
172
+
173
+
174
+ class EndpointList(BaseModel):
175
+ endpoints: list[Endpoint]
176
+
177
+
178
+ class Quote(BaseModel):
179
+ description: str
180
+ price_per_hour_usd: float
181
+ minutes: int
182
+ total_usd: float = Field(description="Charged now; unused time is refunded if it stops early")
183
+ renewal_usd: float | None = Field(default=None, description="Keep running: charged at each renewal")
184
+ renewal_hours: int | None = None
185
+ limit_usd: float = Field(description="This server's limit per call (CNX_MCP_MAX_LAUNCH_USD)")
186
+
187
+
188
+ class SpendResult(BaseModel):
189
+ status: Literal["quote", "done"] = Field(description="quote = nothing happened yet; done = it was carried out")
190
+ quote: Quote | None = None
191
+ machine: MachineDetail | None = None
192
+
193
+
194
+ class StopResult(BaseModel):
195
+ machine: MachineDetail
196
+ refunded_usd: float
197
+
198
+
199
+ class EndpointKey(BaseModel):
200
+ endpoint: str
201
+ url: str
202
+ key: str
File without changes
@@ -0,0 +1,673 @@
1
+ """The ComputeNexa MCP server: an AI assistant (Claude, Cursor, VS Code…) finds, launches and stops GPUs with the
2
+ customer's own API key. It runs on their computer over stdio and calls the public API through the `computenexa` SDK.
3
+
4
+ Safety (docs/design/mcp-server.md in the platform repo):
5
+ - A read-only key gets the read tools only. A "launch and stop" key also gets the tools that spend money or change
6
+ things; those are annotated so assistants ask the person first.
7
+ - Anything that spends money takes two calls: the first (confirm=false) returns a quote and does nothing; the second
8
+ (confirm=true, with the quoted total) does it. A total above CNX_MCP_MAX_LAUNCH_USD (default $25) is refused.
9
+ - No tool returns the API key this server uses. Endpoint keys appear only from create_endpoint (once) and
10
+ get_endpoint_key. Text people typed (labels, names) is cleaned before an assistant reads it.
11
+ """
12
+
13
+ import functools
14
+ import os
15
+ import sys
16
+ from collections.abc import Callable
17
+ from typing import Annotated, Any, Literal, TypeVar
18
+
19
+ import computenexa
20
+ from computenexa import (
21
+ APIConnectionError,
22
+ AuthenticationError,
23
+ Client,
24
+ ComputeNexaError,
25
+ GpuLimitReachedError,
26
+ InsufficientCreditsError,
27
+ InvalidRequestError,
28
+ NoCapacityError,
29
+ NotFoundError,
30
+ PermissionDeniedError,
31
+ ProjectInUseError,
32
+ RateLimitError,
33
+ ServerError,
34
+ )
35
+ from mcp.server import MCPServer
36
+ from mcp.server.mcpserver.exceptions import ToolError
37
+ from mcp.types import CallToolResult, TextContent, ToolAnnotations
38
+ from pydantic import BaseModel, Field
39
+
40
+ from . import models as m
41
+ from ._text import charge_cents, clean, dollars, duration, money, quoted, usd, when, whole_hours
42
+ from ._version import __version__
43
+
44
+ DEFAULT_MAX_LAUNCH_USD = 25.0
45
+ LIMIT_ENV = "CNX_MCP_MAX_LAUNCH_USD"
46
+
47
+ READ_TOOLS = ("whoami", "list_gpus", "list_apps", "list_machines", "get_machine", "get_usage", "list_receipts",
48
+ "list_projects", "list_endpoints")
49
+ WRITE_TOOLS = ("launch_machine", "extend_machine", "set_keep_running", "stop_machine", "create_endpoint",
50
+ "get_endpoint_key")
51
+
52
+ READ = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=True)
53
+ SPENDS = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False, open_world_hint=True)
54
+ CHANGES = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True)
55
+ ENDS = ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=True, open_world_hint=True)
56
+ SECRET = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=True)
57
+
58
+ INSTRUCTIONS = """\
59
+ ComputeNexa rents private GPUs by the minute from the team's prepaid balance. Prices are in US dollars.
60
+ - Use list_gpus to find GPUs (live prices and stock), list_apps for what each app runs, list_machines and
61
+ get_machine for machines and how to connect.
62
+ - Spending money takes two calls. launch_machine, extend_machine and set_keep_running first return a quote and do
63
+ nothing. Show the quote to the person; only after they agree, call again with the same arguments, confirm=true
64
+ and the quoted total. Never confirm on your own.
65
+ - stop_machine ends a machine at once: files outside a project are deleted, unused time is refunded.
66
+ - Never show an endpoint key unless the person asks for it."""
67
+
68
+ F = TypeVar("F", bound=Callable[..., Any])
69
+
70
+ _NEXT_STEP: list[tuple[type[ComputeNexaError], str]] = [
71
+ (AuthenticationError, ("Check CNX_API_KEY in this server's settings, or create a new key in the ComputeNexa "
72
+ "dashboard under API keys.")),
73
+ (PermissionDeniedError, ("This API key may only look, or this needs the dashboard. To launch and stop, create "
74
+ "a “Launch and stop” key under API keys and use it here.")),
75
+ (InsufficientCreditsError, "Add credits in the ComputeNexa dashboard, or choose fewer hours."),
76
+ (NoCapacityError, "Try another GPU or region: list_gpus shows what's in stock."),
77
+ (GpuLimitReachedError, "Stop a machine first (list_machines), or ask a team owner to raise the GPU limit."),
78
+ (ProjectInUseError, "Another machine is using that project: stop it first, or use another project."),
79
+ (NotFoundError, "Check the id or name: list_machines, list_projects and list_endpoints show them."),
80
+ (InvalidRequestError, "Fix the value the message names and try again."),
81
+ (RateLimitError, "Too many requests: wait a minute and try again."),
82
+ (APIConnectionError, "Couldn't reach ComputeNexa: check the internet connection and try again."),
83
+ (ServerError, ("Something went wrong at ComputeNexa: try again in a minute. If it keeps happening, email "
84
+ "support@computenexa.com.")),
85
+ ]
86
+
87
+
88
+ def explain(e: ComputeNexaError) -> str:
89
+ """An API refusal as a tool error: its message, what to do next, and the request id for support."""
90
+ step = next((s for cls, s in _NEXT_STEP if isinstance(e, cls)), "")
91
+ text = (clean(e.message, 300) or "The request was refused.").rstrip(".") + "."
92
+ if step:
93
+ text += " " + step
94
+ if e.request_id:
95
+ text += f" (request id {clean(e.request_id, 64)})"
96
+ return text
97
+
98
+
99
+ def result(text: str, data: BaseModel) -> CallToolResult:
100
+ """Readable text for the assistant and the same facts as structured data."""
101
+ return CallToolResult(content=[TextContent(type="text", text=text)],
102
+ structured_content=data.model_dump(mode="json"))
103
+
104
+
105
+ def max_launch_usd(environ: dict[str, str] | None = None) -> float:
106
+ """The per-call spending limit from CNX_MCP_MAX_LAUNCH_USD (dollars), default $25."""
107
+ raw = (environ if environ is not None else os.environ).get(LIMIT_ENV, "").strip()
108
+ if not raw:
109
+ return DEFAULT_MAX_LAUNCH_USD
110
+ try:
111
+ value = float(raw.removeprefix("$"))
112
+ except ValueError:
113
+ raise ValueError(f"{LIMIT_ENV} must be a number of US dollars, e.g. 25 (got {raw!r})") from None
114
+ if not 0 < value <= 100_000:
115
+ raise ValueError(f"{LIMIT_ENV} must be more than 0 and at most 100000 (got {raw!r})")
116
+ return value
117
+
118
+
119
+ # ---------------------------------------------------------------- converting API objects
120
+
121
+
122
+ def _machine(i: computenexa.Instance) -> m.MachineDetail:
123
+ raw = i.raw
124
+ project = raw.get("project")
125
+ return m.MachineDetail(
126
+ id=i.id, state=i.state, gpu=i.gpu, gpu_count=i.gpu_count, app=i.app, label=clean(i.label),
127
+ price_per_hour_usd=usd(i.price_per_hour_cents) or 0.0, billing=raw.get("billing_unit"),
128
+ expires_at=when(i.expires_at),
129
+ time_left_minutes=None if i.time_left_seconds is None else i.time_left_seconds // 60,
130
+ charged_usd=usd(i.charged_cents) or 0.0, refunded_usd=usd(i.refunded_cents) or 0.0,
131
+ cost_so_far_usd=usd(i.cost_so_far_cents) or 0.0, keep_running_hours=i.keep_running_hours,
132
+ next_renewal_at=when(i.next_renewal_at), renew_failed=i.renew_failed, end_reason=i.end_reason,
133
+ startup_progress=clean(i.startup_progress, 200), launched_by=clean(raw.get("launched_by"), 200),
134
+ ssh_command=i.ssh_command,
135
+ browser_apps=[clean(a.get("label") or a.get("app"), 40) or "" for a in raw.get("apps") or []],
136
+ api_base_url=i.api_base_url, endpoint_url=i.endpoint_url, endpoint_name=clean(i.endpoint_name),
137
+ model=clean(raw.get("model"), 200), project=clean(project.get("name")) if isinstance(project, dict) else None,
138
+ region=raw.get("region"),
139
+ )
140
+
141
+
142
+ def _summary(i: computenexa.Instance) -> m.Machine:
143
+ d = _machine(i)
144
+ return m.Machine(**{k: getattr(d, k) for k in m.Machine.model_fields})
145
+
146
+
147
+ def _machine_line(d: m.Machine) -> str:
148
+ name = f" {quoted(d.label)}" if d.label else ""
149
+ parts = [f"{d.id}{name}: {d.state}", f"{d.gpu_count} × {d.gpu}" if d.gpu_count > 1 else d.gpu, d.app,
150
+ f"${d.price_per_hour_usd:.2f}/h"]
151
+ if d.state == "running" and d.time_left_minutes is not None:
152
+ parts.append(f"{duration(d.time_left_minutes)} left")
153
+ if d.keep_running_hours:
154
+ parts.append(f"keep running every {d.keep_running_hours} h" + (" (renewal FAILED: top up)"
155
+ if d.renew_failed else ""))
156
+ if d.startup_progress and d.state in ("requested", "provisioning"):
157
+ parts.append(d.startup_progress)
158
+ if d.end_reason:
159
+ parts.append(d.end_reason)
160
+ return " · ".join(parts)
161
+
162
+
163
+ def _machine_text(d: m.MachineDetail) -> str:
164
+ lines = [_machine_line(d)]
165
+ if d.ssh_command:
166
+ lines.append(f"SSH: {d.ssh_command}")
167
+ if d.browser_apps and d.state == "running":
168
+ lines.append("In the browser (Open button in the dashboard): " + ", ".join(d.browser_apps))
169
+ if d.api_base_url:
170
+ lines.append(f"API: {d.api_base_url}")
171
+ if d.endpoint_url:
172
+ lines.append(f"Your server on port 8000: {d.endpoint_url}"
173
+ + (f" (Endpoint {quoted(d.endpoint_name)})" if d.endpoint_name else ""))
174
+ if d.project:
175
+ lines.append(f"Project {quoted(d.project)} is restored into ~/work and saved back automatically.")
176
+ lines.append(f"Charged ${d.charged_usd:.2f}, refunded ${d.refunded_usd:.2f}, cost so far ${d.cost_so_far_usd:.2f}.")
177
+ return "\n".join(lines)
178
+
179
+
180
+ # ---------------------------------------------------------------- the server
181
+
182
+
183
+ def build_server(client: Client | None, *, scope: str | None = None, limit_usd: float | None = DEFAULT_MAX_LAUNCH_USD,
184
+ setup_error: str | None = None) -> MCPServer:
185
+ """The MCP server for one API key.
186
+
187
+ `scope`: the key's scope as GET /v1/me reports it ("read" leaves out the tools that spend or change; None =
188
+ unknown, so every tool is offered and the API refuses what the key may not do). `limit_usd`: the most one call may
189
+ spend; None = the limit setting was invalid, so spending tools refuse with `setup_error`. `client=None`: no
190
+ usable API key; every tool explains `setup_error`."""
191
+ server = MCPServer("ComputeNexa", title="ComputeNexa", version=__version__, instructions=INSTRUCTIONS,
192
+ website_url="https://computenexa.com")
193
+
194
+ def guard(fn: F) -> F:
195
+ @functools.wraps(fn)
196
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
197
+ if client is None:
198
+ raise ToolError(setup_error or "No API key: set CNX_API_KEY in this server's settings.")
199
+ try:
200
+ return fn(*args, **kwargs)
201
+ except ComputeNexaError as e:
202
+ raise ToolError(explain(e)) from None
203
+ except ValueError as e: # the SDK's own argument checks
204
+ raise ToolError(str(e)) from None
205
+ return wrapper # type: ignore[return-value]
206
+
207
+ def c() -> Client:
208
+ assert client is not None
209
+ return client
210
+
211
+ def check_limit(total_cents: int, what: str) -> float:
212
+ if limit_usd is None:
213
+ raise ToolError(setup_error or f"{LIMIT_ENV} is invalid: fix it in this server's settings.")
214
+ if total_cents > round(limit_usd * 100):
215
+ raise ToolError(f"{what} costs {money(total_cents)}, above this server's limit of ${limit_usd:,.2f} per "
216
+ f"call ({LIMIT_ENV}). Choose fewer hours, or raise {LIMIT_ENV} in the MCP settings.")
217
+ return limit_usd
218
+
219
+ def check_approved(total_cents: int, expected_usd: float | None, field: str) -> None:
220
+ if expected_usd is None:
221
+ raise ToolError(f"confirm=true needs {field}: the total from the quote the person agreed to. Call with "
222
+ "confirm=false first to get the quote.")
223
+ if total_cents > round(expected_usd * 100):
224
+ raise ToolError(f"The price changed: it's now {money(total_cents)}, more than the ${expected_usd:,.2f} "
225
+ "the person agreed to. Nothing was charged. Show them the new quote (confirm=false) "
226
+ "before trying again.")
227
+
228
+ # ------------------------------------------------------------ read tools
229
+
230
+ @server.tool(annotations=READ, title="Who am I")
231
+ @guard
232
+ def whoami() -> Annotated[CallToolResult, m.Account]:
233
+ """Who this server's API key acts as: email, team and role, the team's balance and GPU limit, and whether
234
+ the key may launch and stop machines."""
235
+ a = c().account()
236
+ key = a.raw.get("api_key")
237
+ info = m.KeyInfo(name=clean(key.get("name")), prefix=key.get("prefix"), scope=key.get("scope")) \
238
+ if isinstance(key, dict) else None
239
+ out = m.Account(email=a.email, team=clean(a.organization.name), role=a.organization.role or None,
240
+ team_members=a.organization.members, balance_usd=usd(a.balance_cents) or 0.0,
241
+ gpu_limit=a.max_concurrent_gpus, api_key=info,
242
+ can_launch=None if info is None else info.scope == "write")
243
+ text = (f"{out.email} in team {quoted(out.team)} ({out.role}). Balance {money(a.balance_cents)}. "
244
+ f"Up to {out.gpu_limit} GPU(s) at a time.")
245
+ if info is not None:
246
+ text += f" API key {quoted(info.name)} ({info.prefix}…): " + \
247
+ ("launch and stop." if info.scope == "write" else "look only.")
248
+ return result(text, out)
249
+
250
+ @server.tool(annotations=READ, title="List GPUs")
251
+ @guard
252
+ def list_gpus(
253
+ app: Annotated[str | None, Field(description="Only GPUs that run this app (see list_apps), e.g. chat")] = None,
254
+ region: Annotated[Literal["any", "us", "ca"], Field(description="any = anywhere; us; ca = Canada")] = "any",
255
+ min_memory_gb: Annotated[int | None, Field(ge=1, le=2000, description="At least this much GPU memory")] = None,
256
+ include_sold_out: Annotated[bool, Field(description="Also list GPUs not for sale right now")] = False,
257
+ ) -> Annotated[CallToolResult, m.GpuList]:
258
+ """GPUs with live prices (US dollars per GPU per hour) and stock, cheapest first."""
259
+ cat = c().catalog(None if region == "any" else region)
260
+ gpus = [g for g in cat.gpus
261
+ if (include_sold_out or (g.available and g.price_per_hour_cents is not None))
262
+ and (app is None or app in g.apps) and (min_memory_gb is None or g.vram_gb >= min_memory_gb)]
263
+ gpus.sort(key=lambda g: (g.price_per_hour_cents is None, g.price_per_hour_cents or 0, g.slug))
264
+ out = m.GpuList(region=region, gpus=[
265
+ m.GpuInfo(slug=g.slug, name=g.name, memory_gb=g.vram_gb, available=g.available,
266
+ price_per_hour_usd=usd(g.price_per_hour_cents),
267
+ billing="hour" if g.billing_unit == "hour" else "minute", apps=g.apps, regions=g.regions)
268
+ for g in gpus])
269
+ if not out.gpus:
270
+ return result("No GPUs match right now. Try without filters, another region, or include_sold_out=true.",
271
+ out)
272
+ lines = [f"{g.slug} · {g.name} · {g.memory_gb} GB · "
273
+ + (f"${g.price_per_hour_usd:.2f}/h" if g.price_per_hour_usd is not None else "not for sale now")
274
+ + (" (billed per started hour)" if g.billing == "hour" else "")
275
+ + f" · apps: {', '.join(g.apps) or '—'}"
276
+ + (f" · regions: {', '.join(g.regions)}" if g.regions else "")
277
+ for g in out.gpus]
278
+ return result("\n".join(lines), out)
279
+
280
+ @server.tool(annotations=READ, title="List apps")
281
+ @guard
282
+ def list_apps(
283
+ app: Annotated[str | None, Field(description="One app, to also see what's installed in it")] = None,
284
+ package: Annotated[str | None, Field(max_length=60, description="With app: find this package's version, "
285
+ "e.g. torch")] = None,
286
+ ) -> Annotated[CallToolResult, m.AppList]:
287
+ """The apps a GPU can start with (basic, datascience, chat, imagegen, modelapi, voice): what each is for, the
288
+ models it offers and which model we recommend per GPU memory. Give `app` for the software installed."""
289
+ cat = c().catalog()
290
+ picked = [a for a in cat.apps.values() if app is None or a.name == app]
291
+ if app is not None and not picked:
292
+ raise ToolError(f"No app {quoted(app, 40)}. Apps: {', '.join(cat.apps) or '—'}.")
293
+ out = m.AppList(apps=[
294
+ m.AppInfo(name=a.name, display_name=clean(a.display_name) or a.name, summary=clean(a.summary, 300) or "",
295
+ needs_ssh_key=a.needs_ssh_key, model_required=a.model_required, recommended=a.recommended,
296
+ models=[m.ModelInfo(name=str(x.get("name")), label=clean(x.get("label")),
297
+ size_gb=x.get("size_gb"), min_memory_gb=x.get("min_vram_gb"),
298
+ licence=clean(x.get("licence")), gated=x.get("gated"))
299
+ for x in a.models])
300
+ for a in picked])
301
+ lines = [f"{a.name} ({a.display_name}): {a.summary}" + (f" Models: {', '.join(x.name for x in a.models)}."
302
+ if a.models else "") for a in out.apps]
303
+ if app is not None:
304
+ contents = c().image_contents(app)
305
+ for img in contents.get("images") or []:
306
+ man = img.get("manifest") or {}
307
+ packages = man.get("packages") or []
308
+ want = (package or "").lower().strip()
309
+ sw = m.Software(
310
+ image=str(img.get("slug")), cuda=img.get("cuda_version") or man.get("cuda"),
311
+ ubuntu=man.get("ubuntu"), python=man.get("python"), headline=list(man.get("headline") or []),
312
+ tools=list(man.get("tools") or []), package_count=len(packages),
313
+ matching_packages=[p for p in packages if want and want in str(p.get("name", "")).lower()][:50])
314
+ out.software.append(sw)
315
+ head = ", ".join(f"{h.get('name')} {h.get('version')}" for h in sw.headline)
316
+ lines.append(f"Image {sw.image}: Ubuntu {sw.ubuntu or '?'}, CUDA {sw.cuda or '?'}, "
317
+ f"Python {sw.python or '?'}" + (f"; {head}" if head else "")
318
+ + f"; {sw.package_count} Python packages.")
319
+ if want:
320
+ found = ", ".join(f"{p.get('name')} {p.get('version')}" for p in sw.matching_packages)
321
+ lines.append(f" Matching {quoted(package, 60)}: {found or 'none'}")
322
+ return result("\n".join(lines), out)
323
+
324
+ @server.tool(annotations=READ, title="List machines")
325
+ @guard
326
+ def list_machines(
327
+ active_only: Annotated[bool, Field(description="Only starting or running machines")] = True,
328
+ whole_team: Annotated[bool, Field(description="The whole team's machines (owners and admins)")] = False,
329
+ limit: Annotated[int, Field(ge=1, le=100)] = 20,
330
+ ) -> Annotated[CallToolResult, m.MachineList]:
331
+ """Your machines, newest first: state, GPU, app, price, time left and cost so far."""
332
+ found = c().list_instances(active=True if active_only else None, everyone=whole_team, limit=limit)
333
+ out = m.MachineList(machines=[_summary(i) for i in found])
334
+ if not out.machines:
335
+ return result("No machines are running." if active_only else "No machines yet.", out)
336
+ return result("\n".join(_machine_line(d) for d in out.machines), out)
337
+
338
+ @server.tool(annotations=READ, title="Get a machine")
339
+ @guard
340
+ def get_machine(
341
+ machine_id: Annotated[str, Field(min_length=3, max_length=40, description="e.g. cnx-7f3a9c")],
342
+ ) -> Annotated[CallToolResult, m.MachineDetail]:
343
+ """One machine: its state, how to connect (SSH command, browser apps, API or endpoint address), time left,
344
+ keep-running status and costs. Never its keys."""
345
+ d = _machine(c().get_instance(machine_id.strip()))
346
+ return result(_machine_text(d), d)
347
+
348
+ @server.tool(annotations=READ, title="Get usage")
349
+ @guard
350
+ def get_usage(
351
+ days: Annotated[int, Field(ge=1, le=365, description="The last N days")] = 30,
352
+ ) -> Annotated[CallToolResult, m.Usage]:
353
+ """Spending and GPU-hours over the last N days, by GPU, app, day and project."""
354
+ u = c().usage(days)
355
+ out = m.Usage(days=u.days, balance_usd=usd(u.balance_cents) or 0.0, spend_usd=usd(u.spend_cents) or 0.0,
356
+ topups_usd=usd(u.topups_cents) or 0.0, sessions=u.sessions, gpu_hours=u.gpu_hours,
357
+ by_gpu=dollars(u.by_gpu), by_app=dollars(u.by_image), daily=dollars(u.daily),
358
+ projects=dollars(u.projects))
359
+ return result(f"Last {out.days} days: spent ${out.spend_usd:,.2f} on {out.sessions} session(s), "
360
+ f"{out.gpu_hours:g} GPU-hours. Added ${out.topups_usd:,.2f}. Balance now "
361
+ f"${out.balance_usd:,.2f}.", out)
362
+
363
+ @server.tool(annotations=READ, title="List receipts")
364
+ @guard
365
+ def list_receipts(
366
+ days: Annotated[int, Field(ge=1, le=365, description="Sessions that ended in the last N days")] = 30,
367
+ limit: Annotated[int, Field(ge=1, le=200)] = 50,
368
+ ) -> Annotated[CallToolResult, m.ReceiptList]:
369
+ """Finished sessions and what each cost in the end (charged minus refunded)."""
370
+ found = c().receipts(days)[:limit]
371
+ out = m.ReceiptList(total_net_usd=usd(sum(r.net_cents for r in found)) or 0.0, receipts=[
372
+ m.Receipt(machine=r.instance, label=clean(r.label), gpu=r.gpu, gpu_count=r.gpu_count, app=r.app,
373
+ model=clean(r.model, 200), started_at=when(r.started_at), ended_at=when(r.ended_at),
374
+ used_minutes=r.used_minutes, booked_minutes=r.booked_minutes,
375
+ charged_usd=usd(r.charged_cents) or 0.0, refunded_usd=usd(r.refunded_cents) or 0.0,
376
+ net_usd=usd(r.net_cents) or 0.0, project=clean(r.project), outcome=r.outcome)
377
+ for r in found])
378
+ if not out.receipts:
379
+ return result(f"No finished sessions in the last {days} days.", out)
380
+ lines = [f"{r.machine} · {r.gpu} · {r.app} · {duration(r.used_minutes)} used · ${r.net_usd:.2f}"
381
+ for r in out.receipts]
382
+ return result("\n".join(lines) + f"\nTotal ${out.total_net_usd:,.2f}.", out)
383
+
384
+ @server.tool(annotations=READ, title="List projects")
385
+ @guard
386
+ def list_projects() -> Annotated[CallToolResult, m.ProjectList]:
387
+ """Saved projects (files restored into ~/work on machines launched with them) and their size."""
388
+ out = m.ProjectList(projects=[
389
+ m.Project(id=p.id, name=clean(p.name) or "", size_mb=round(p.bytes_used / 1_000_000, 1), files=p.files,
390
+ in_use_by=p.in_use_by, last_used_at=when(p.last_used_at))
391
+ for p in c().list_projects()])
392
+ if not out.projects:
393
+ return result("No projects yet. Create one in the dashboard, or with `cnx projects add NAME`.", out)
394
+ return result("\n".join(f"{quoted(p.name)} ({p.id}) · {p.size_mb:g} MB, {p.files} files"
395
+ + (f" · in use by {p.in_use_by}" if p.in_use_by else "") for p in out.projects), out)
396
+
397
+ @server.tool(annotations=READ, title="List endpoints")
398
+ @guard
399
+ def list_endpoints() -> Annotated[CallToolResult, m.EndpointList]:
400
+ """Endpoints: stable HTTPS addresses for your own server on a machine's port 8000, and the machine each
401
+ points at now. Never their keys."""
402
+ out = m.EndpointList(endpoints=[m.Endpoint(id=e.id, name=clean(e.name) or "", url=e.url, machine=e.instance)
403
+ for e in c().list_endpoints()])
404
+ if not out.endpoints:
405
+ return result("No endpoints yet.", out)
406
+ return result("\n".join(f"{quoted(e.name)} ({e.id}) · {e.url} · "
407
+ + (f"→ {e.machine}" if e.machine else "no machine") for e in out.endpoints), out)
408
+
409
+ if scope == "read":
410
+ return server
411
+
412
+ # ------------------------------------------------------------ tools that spend or change
413
+
414
+ def find_project(ref: str) -> str:
415
+ for p in c().list_projects():
416
+ if p.id == ref or p.name.lower() == ref.strip().lower():
417
+ return p.id
418
+ raise ToolError(f"No project {quoted(ref)}. list_projects shows them; create one in the dashboard.")
419
+
420
+ def find_endpoint(ref: str) -> computenexa.Endpoint:
421
+ for e in c().list_endpoints():
422
+ if ref in (e.id, e.handle) or e.name == ref.strip():
423
+ return e
424
+ raise ToolError(f"No endpoint {quoted(ref)}. list_endpoints shows them.")
425
+
426
+ def launch_quote(gpu: str, app: str, hours: float, gpu_count: int, region: str,
427
+ keep_running_hours: int | None) -> tuple[m.Quote, int]:
428
+ cat = c().catalog(None if region == "any" else region)
429
+ try:
430
+ g = cat.gpu(gpu)
431
+ except KeyError:
432
+ raise ToolError(f"No GPU {quoted(gpu, 40)}. list_gpus shows the GPUs for sale.") from None
433
+ if not g.available or g.price_per_hour_cents is None:
434
+ raise ToolError(f"{g.name} isn't for sale right now" + (f" in {region}" if region != "any" else "")
435
+ + ". list_gpus shows what's in stock.")
436
+ if app not in g.apps:
437
+ raise ToolError(f"{g.name} doesn't run the {quoted(app, 40)} app. It runs: {', '.join(g.apps) or '—'}.")
438
+ minutes = round(hours * 60)
439
+ if minutes < 1:
440
+ raise ToolError("Book at least 1 minute.")
441
+ if g.billing_unit == "hour":
442
+ minutes = whole_hours(minutes)
443
+ price = g.price_per_hour_cents * gpu_count
444
+ total = charge_cents(price, minutes)
445
+ renewal = charge_cents(price, keep_running_hours * 60) if keep_running_hours else None
446
+ what = f"{gpu_count} × {g.name}" if gpu_count > 1 else g.name
447
+ limit = check_limit(total, f"{what} for {duration(minutes)}")
448
+ if renewal is not None:
449
+ check_limit(renewal, f"Each keep-running renewal of {keep_running_hours} h")
450
+ desc = (f"{what} with the {app} app for {duration(minutes)}"
451
+ + (" (billed per started hour)" if g.billing_unit == "hour" else "") + f": {money(total)} charged now "
452
+ f"({money(price)}/h); unused time is refunded if it stops early.")
453
+ if renewal is not None:
454
+ desc += (f" Keep running: then {money(renewal)} every {keep_running_hours} h from the team's balance "
455
+ "until it's stopped.")
456
+ return m.Quote(description=desc, price_per_hour_usd=usd(price) or 0.0, minutes=minutes,
457
+ total_usd=usd(total) or 0.0, renewal_usd=usd(renewal), renewal_hours=keep_running_hours,
458
+ limit_usd=limit), total
459
+
460
+ @server.tool(annotations=SPENDS, title="Launch a machine")
461
+ @guard
462
+ def launch_machine(
463
+ gpu: Annotated[str, Field(min_length=2, max_length=40, description="GPU slug from list_gpus, e.g. rtx-a6000")],
464
+ app: Annotated[str, Field(min_length=2, max_length=40, description="App from list_apps, e.g. datascience")],
465
+ hours: Annotated[float, Field(gt=0, le=720, description="How long to book; unused time is refunded")],
466
+ gpu_count: Annotated[int, Field(ge=1, le=8)] = 1,
467
+ region: Annotated[Literal["any", "us", "ca"], Field(description="Where it must run")] = "any",
468
+ model: Annotated[str | None, Field(max_length=200, description="For apps with models; left out = our "
469
+ "recommendation for this GPU")] = None,
470
+ project: Annotated[str | None, Field(max_length=80, description="Project name or id to restore into "
471
+ "~/work and save back")] = None,
472
+ endpoint: Annotated[str | None, Field(max_length=80, description="Endpoint name or id this machine "
473
+ "serves")] = None,
474
+ label: Annotated[str | None, Field(max_length=60, description="A name for the machine")] = None,
475
+ keep_running_hours: Annotated[int | None, Field(ge=1, le=720, description="Renew this many hours at a "
476
+ "time until stopped")] = None,
477
+ confirm: Annotated[bool, Field(description="false = only quote (default); true = launch, after the person "
478
+ "agreed to the quote")] = False,
479
+ expected_total_usd: Annotated[float | None, Field(ge=0, description="With confirm=true: the quote's "
480
+ "total_usd the person agreed to")] = None,
481
+ ) -> Annotated[CallToolResult, m.SpendResult]:
482
+ """Launch a GPU machine. Costs money: call first with confirm=false to get a quote (nothing happens), show it
483
+ to the person, and only after they agree call again with the same arguments, confirm=true and
484
+ expected_total_usd. The booked time is charged up front from the team's balance."""
485
+ quote, total = launch_quote(gpu.strip(), app.strip(), hours, gpu_count, region, keep_running_hours)
486
+ if not confirm:
487
+ return result(f"Quote (nothing launched yet): {quote.description} To launch, call launch_machine again "
488
+ f"with confirm=true and expected_total_usd={quote.total_usd}.",
489
+ m.SpendResult(status="quote", quote=quote))
490
+ check_approved(total, expected_total_usd, "expected_total_usd")
491
+ launched = c().launch(gpu.strip(), app=app.strip(), minutes=quote.minutes, gpu_count=gpu_count, region=region,
492
+ label=clean(label, 60), model=model,
493
+ project=find_project(project) if project else None,
494
+ endpoint=find_endpoint(endpoint).id if endpoint else None,
495
+ keep_running=keep_running_hours)
496
+ d = _machine(launched)
497
+ return result(f"Launched {d.id} ({d.state}): ${d.charged_usd:.2f} charged. It takes a few minutes to start; "
498
+ "get_machine shows its progress and, once running, how to connect.",
499
+ m.SpendResult(status="done", quote=quote, machine=d))
500
+
501
+ def extension(machine_id: str, hours: float) -> tuple[computenexa.Instance, m.Quote, int]:
502
+ i = c().get_instance(machine_id.strip())
503
+ if i.state not in ("requested", "provisioning", "running"):
504
+ raise ToolError(f"{i.id} has ended ({i.state}): launch a new machine instead.")
505
+ minutes = round(hours * 60)
506
+ if minutes < 1:
507
+ raise ToolError("Add at least 1 minute.")
508
+ if i.raw.get("billing_unit") == "hour":
509
+ minutes = whole_hours(minutes)
510
+ total = charge_cents(i.price_per_hour_cents, minutes)
511
+ limit = check_limit(total, f"{duration(minutes)} more on {i.id}")
512
+ desc = (f"{duration(minutes)} more on {i.id} ({i.gpu}): {money(total)} charged now "
513
+ f"({money(i.price_per_hour_cents)}/h).")
514
+ return i, m.Quote(description=desc, price_per_hour_usd=usd(i.price_per_hour_cents) or 0.0, minutes=minutes,
515
+ total_usd=usd(total) or 0.0, limit_usd=limit), total
516
+
517
+ @server.tool(annotations=SPENDS, title="Extend a machine")
518
+ @guard
519
+ def extend_machine(
520
+ machine_id: Annotated[str, Field(min_length=3, max_length=40)],
521
+ hours: Annotated[float, Field(gt=0, le=720, description="Time to add")],
522
+ confirm: Annotated[bool, Field(description="false = only quote; true = extend, after the person "
523
+ "agreed")] = False,
524
+ expected_total_usd: Annotated[float | None, Field(ge=0, description="With confirm=true: the quoted "
525
+ "total_usd")] = None,
526
+ ) -> Annotated[CallToolResult, m.SpendResult]:
527
+ """Add time to a machine at the price it launched with. Costs money: quote first (confirm=false), then
528
+ confirm=true with expected_total_usd once the person agrees."""
529
+ i, quote, total = extension(machine_id, hours)
530
+ if not confirm:
531
+ return result(f"Quote (nothing changed yet): {quote.description} To extend, call again with "
532
+ f"confirm=true and expected_total_usd={quote.total_usd}.",
533
+ m.SpendResult(status="quote", quote=quote))
534
+ check_approved(total, expected_total_usd, "expected_total_usd")
535
+ d = _machine(c().extend_instance(i.id, minutes=quote.minutes))
536
+ return result(f"Extended {d.id} by {duration(quote.minutes)}: it now runs until {d.expires_at or '—'}.",
537
+ m.SpendResult(status="done", quote=quote, machine=d))
538
+
539
+ @server.tool(annotations=SPENDS, title="Keep a machine running")
540
+ @guard
541
+ def set_keep_running(
542
+ machine_id: Annotated[str, Field(min_length=3, max_length=40)],
543
+ hours: Annotated[int | None, Field(ge=1, le=720, description="Renew this many hours at a time; null = turn "
544
+ "off (it stops when its paid time ends)")],
545
+ confirm: Annotated[bool, Field(description="Turning on: false = only quote; true = turn on, after the "
546
+ "person agreed")] = False,
547
+ expected_renewal_usd: Annotated[float | None, Field(ge=0, description="With confirm=true: the quoted "
548
+ "renewal_usd")] = None,
549
+ ) -> Annotated[CallToolResult, m.SpendResult]:
550
+ """Keep running: the machine renews itself `hours` at a time from the team's balance, at its launch price,
551
+ until it's stopped. Turning it on spends money at every renewal: quote first, then confirm=true with
552
+ expected_renewal_usd. Turning it off (hours=null) needs no confirmation."""
553
+ i = c().get_instance(machine_id.strip())
554
+ if hours is None:
555
+ d = _machine(c().set_keep_running(i.id, None))
556
+ return result(f"Keep running is off for {d.id}: it stops at {d.expires_at or 'the end of its paid time'}.",
557
+ m.SpendResult(status="done", machine=d))
558
+ if i.state not in ("requested", "provisioning", "running"):
559
+ raise ToolError(f"{i.id} has ended ({i.state}).")
560
+ renewal = charge_cents(i.price_per_hour_cents, hours * 60)
561
+ limit = check_limit(renewal, f"Each renewal of {hours} h on {i.id}")
562
+ quote = m.Quote(description=f"Keep {i.id} ({i.gpu}) running: {money(renewal)} every {hours} h from the team's "
563
+ "balance until it's stopped. If the balance runs short it stops at the end of the "
564
+ "paid time.",
565
+ price_per_hour_usd=usd(i.price_per_hour_cents) or 0.0, minutes=hours * 60, total_usd=0.0,
566
+ renewal_usd=usd(renewal), renewal_hours=hours, limit_usd=limit)
567
+ if not confirm:
568
+ return result(f"Quote (nothing changed yet): {quote.description} To turn it on, call again with "
569
+ f"confirm=true and expected_renewal_usd={quote.renewal_usd}.",
570
+ m.SpendResult(status="quote", quote=quote))
571
+ check_approved(renewal, expected_renewal_usd, "expected_renewal_usd")
572
+ d = _machine(c().set_keep_running(i.id, hours))
573
+ return result(f"Keep running is on for {d.id}: {money(renewal)} every {hours} h"
574
+ + (f", next at {d.next_renewal_at}" if d.next_renewal_at else "") + ".",
575
+ m.SpendResult(status="done", quote=quote, machine=d))
576
+
577
+ @server.tool(annotations=ENDS, title="Stop a machine")
578
+ @guard
579
+ def stop_machine(
580
+ machine_id: Annotated[str, Field(min_length=3, max_length=40)],
581
+ ) -> Annotated[CallToolResult, m.StopResult]:
582
+ """End a machine now: stopped if running, cancelled with a full refund if still starting. Files outside a
583
+ project are deleted; a project is saved first. Unused time is refunded. Can't be undone."""
584
+ before = c().get_instance(machine_id.strip())
585
+ if before.state in ("terminated", "failed"):
586
+ d = _machine(before)
587
+ return result(f"{d.id} had already ended ({d.state}).", m.StopResult(machine=d, refunded_usd=0.0))
588
+ after = c().stop_instance(before.id)
589
+ d = _machine(after)
590
+ refunded = max(0, after.refunded_cents - before.refunded_cents)
591
+ text = f"{d.id} is {d.state}. " + (f"Refunded {money(refunded)}." if refunded
592
+ else "Unused time is refunded once it has stopped.")
593
+ text += (f" Project {quoted(d.project)} is saved before it ends." if d.project
594
+ else " Files on it are deleted.")
595
+ return result(text, m.StopResult(machine=d, refunded_usd=usd(refunded) or 0.0))
596
+
597
+ @server.tool(annotations=SPENDS, title="Create an endpoint")
598
+ @guard
599
+ def create_endpoint(
600
+ name: Annotated[str, Field(min_length=1, max_length=60, description="e.g. the customer it serves")],
601
+ ) -> Annotated[CallToolResult, m.Endpoint]:
602
+ """A new Endpoint (owners and admins): a stable HTTPS address and key for your own server on port 8000, e.g.
603
+ one per customer you serve. Launch machines for it with launch_machine(endpoint=...). Free. Its key is shown
604
+ once here; get_endpoint_key shows it again."""
605
+ e = c().create_endpoint(clean(name, 60) or name)
606
+ out = m.Endpoint(id=e.id, name=clean(e.name) or "", url=e.url, machine=e.instance, key=e.key)
607
+ return result(f"Created endpoint {quoted(out.name)} at {out.url}. Its key is in this result: share it only "
608
+ "with whoever calls the endpoint.", out)
609
+
610
+ @server.tool(annotations=SECRET, title="Get an endpoint's key")
611
+ @guard
612
+ def get_endpoint_key(
613
+ endpoint: Annotated[str, Field(min_length=1, max_length=80, description="Endpoint name or id")],
614
+ ) -> Annotated[CallToolResult, m.EndpointKey]:
615
+ """An Endpoint's key (owners and admins). Only when the person asks for it: it gives access to their
616
+ server."""
617
+ e = find_endpoint(endpoint)
618
+ key = c().endpoint_key(e.id)
619
+ return result(f"Key for endpoint {quoted(e.name)} ({e.url}) is in this result.",
620
+ m.EndpointKey(endpoint=clean(e.name) or "", url=e.url, key=key))
621
+
622
+ return server
623
+
624
+
625
+ # ---------------------------------------------------------------- start-up
626
+
627
+
628
+ def detect_scope(client: Client) -> tuple[str | None, str | None]:
629
+ """(the key's scope from GET /v1/me, a setup error). Scope None = unknown (an older platform, or the API couldn't
630
+ be reached): every tool is offered and the API refuses what the key may not do."""
631
+ try:
632
+ raw = client.account().raw
633
+ except AuthenticationError as e:
634
+ return None, explain(e)
635
+ except ComputeNexaError:
636
+ return None, None
637
+ key = raw.get("api_key")
638
+ scope = key.get("scope") if isinstance(key, dict) else None
639
+ return (scope if scope in ("read", "write") else None), None
640
+
641
+
642
+ def create(environ: dict[str, str] | None = None, http_client: Any = None) -> MCPServer:
643
+ """The server from the environment: CNX_API_KEY, optional CNX_API_URL and CNX_MCP_MAX_LAUNCH_USD."""
644
+ env = environ if environ is not None else dict(os.environ)
645
+ try:
646
+ limit: float | None = max_launch_usd(env)
647
+ limit_error = None
648
+ except ValueError as e:
649
+ limit, limit_error = None, str(e)
650
+ key = env.get("CNX_API_KEY", "").strip()
651
+ if not key:
652
+ return build_server(None, setup_error="No API key: set CNX_API_KEY in this server's settings to a ComputeNexa "
653
+ "API key (dashboard → API keys).")
654
+ try:
655
+ client = Client(key, base_url=env.get("CNX_API_URL") or None, http_client=http_client)
656
+ except ComputeNexaError as e:
657
+ return build_server(None, setup_error=f"{e.message} Check CNX_API_KEY in this server's settings.")
658
+ scope, error = detect_scope(client)
659
+ if error is not None:
660
+ return build_server(None, setup_error=error)
661
+ return build_server(client, scope=scope, limit_usd=limit, setup_error=limit_error)
662
+
663
+
664
+ def main() -> None:
665
+ if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
666
+ print(f"computenexa-mcp {__version__}")
667
+ return
668
+ if len(sys.argv) > 1 and sys.argv[1] in ("--help", "-h"):
669
+ print("computenexa-mcp: the ComputeNexa MCP server (stdio).\n\n"
670
+ "Environment: CNX_API_KEY (required), CNX_MCP_MAX_LAUNCH_USD (default 25), CNX_API_URL (optional).\n"
671
+ "Add it to your assistant's MCP settings: https://app.computenexa.com/help/mcp")
672
+ return
673
+ create().run("stdio")
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.5
2
+ Name: computenexa-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for ComputeNexa: let your AI assistant find, launch and stop private GPUs.
5
+ Project-URL: Homepage, https://computenexa.com
6
+ Project-URL: Documentation, https://app.computenexa.com/help/mcp
7
+ Project-URL: Source, https://github.com/computenexa-gpuInfrastructure/computenexa-mcp
8
+ Project-URL: Support, https://app.computenexa.com/help
9
+ Author-email: ComputeNexa <support@computenexa.com>
10
+ Maintainer-email: ComputeNexa <support@computenexa.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: ai agents,claude,cloud,computenexa,cursor,gpu,mcp,model context protocol
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: computenexa<1,>=0.3.0
22
+ Requires-Dist: mcp<3,>=2.3
23
+ Requires-Dist: pydantic<3,>=2.12
24
+ Description-Content-Type: text/markdown
25
+
26
+ # ComputeNexa MCP server
27
+
28
+ <!-- mcp-name: io.github.computenexa-gpuInfrastructure/computenexa-mcp -->
29
+
30
+ Let your AI assistant find, launch and stop private GPUs on [ComputeNexa](https://computenexa.com). Ask it
31
+ *"Which GPUs with 48 GB or more are in stock?"*, *"Launch the cheapest one with Data Science for two hours"* or
32
+ *"Stop it"*: it uses your team's API key, shows you what each launch costs, and only spends money after you agree.
33
+
34
+ Works with Claude Desktop, Claude Code, Cursor, VS Code and other assistants that support the
35
+ [Model Context Protocol](https://modelcontextprotocol.io). It runs on your computer and talks to the ComputeNexa API;
36
+ your key stays in your own settings.
37
+
38
+ ## Set up
39
+
40
+ 1. In the ComputeNexa dashboard, open **API keys** and create a key:
41
+ - **Look only** to browse GPUs, machines and spending;
42
+ - **Launch and stop** to also launch, extend and stop machines.
43
+ 2. Install [uv](https://docs.astral.sh/uv/) (it runs the server with `uvx`, no separate install needed).
44
+ 3. Add the server to your assistant (below), with your key as `CNX_API_KEY`.
45
+
46
+ ### Claude Code
47
+
48
+ ```bash
49
+ claude mcp add --env CNX_API_KEY=cnxk_... --transport stdio computenexa -- uvx computenexa-mcp
50
+ ```
51
+
52
+ Add `--scope user` to use it in every project.
53
+
54
+ ### Claude Desktop
55
+
56
+ Settings → Developer → Edit config (`claude_desktop_config.json`):
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "computenexa": {
62
+ "command": "uvx",
63
+ "args": ["computenexa-mcp"],
64
+ "env": { "CNX_API_KEY": "cnxk_..." }
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ### Cursor
71
+
72
+ `~/.cursor/mcp.json` (every project) or `.cursor/mcp.json` (one project): the same `mcpServers` entry as Claude
73
+ Desktop.
74
+
75
+ ### VS Code
76
+
77
+ `.vscode/mcp.json`; VS Code asks for the key once and keeps it out of the file:
78
+
79
+ ```json
80
+ {
81
+ "inputs": [
82
+ { "type": "promptString", "id": "cnx-key", "description": "ComputeNexa API key", "password": true }
83
+ ],
84
+ "servers": {
85
+ "computenexa": {
86
+ "type": "stdio",
87
+ "command": "uvx",
88
+ "args": ["computenexa-mcp"],
89
+ "env": { "CNX_API_KEY": "${input:cnx-key}" }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ ## What it can do
96
+
97
+ | Tool | What it does | Key |
98
+ |---|---|---|
99
+ | `whoami` | Your email, team, balance, GPU limit and the key's permissions | any |
100
+ | `list_gpus` | GPUs with live prices and stock, cheapest first; filter by app, region, memory | any |
101
+ | `list_apps` | The apps a GPU starts with, their models, and what's installed | any |
102
+ | `list_machines` · `get_machine` | Your machines; how to connect (SSH, browser apps, API or endpoint address) | any |
103
+ | `get_usage` · `list_receipts` | Spending and GPU-hours; finished sessions and their cost | any |
104
+ | `list_projects` · `list_endpoints` | Saved projects; Endpoints and the machine each points at | any |
105
+ | `launch_machine` | Launch a GPU (quote first, then launch when you agree) | launch and stop |
106
+ | `extend_machine` | Add time (quote first) | launch and stop |
107
+ | `set_keep_running` | Renew a machine automatically until stopped (quote first) | launch and stop |
108
+ | `stop_machine` | End a machine now; unused time is refunded | launch and stop |
109
+ | `create_endpoint` · `get_endpoint_key` | A stable HTTPS address for your own server; its key | launch and stop |
110
+
111
+ A look-only key only gets the read tools (the first six rows).
112
+
113
+ ## Safety
114
+
115
+ - **Nothing is charged without you.** `launch_machine`, `extend_machine` and `set_keep_running` first return a quote,
116
+ e.g. *"NVIDIA RTX A6000 with the datascience app for 2 h: $1.44 charged now ($0.72/h); unused time is refunded if
117
+ it stops early."* Only a second call with `confirm=true` and the quoted total goes ahead; if the price went up in
118
+ between, it's refused.
119
+ - **A limit per call:** a launch, extension or keep-running renewal costing more than **$25** is refused. Change it
120
+ with `CNX_MCP_MAX_LAUNCH_USD` (in dollars) in the server's `env`. Your team's GPU limit and prepaid balance still
121
+ apply on top.
122
+ - **Your API key never appears in a tool result.** Endpoint keys are shown only when you create an endpoint or ask
123
+ for its key.
124
+ - Tools that spend money or end a machine are marked as such, so your assistant asks before using them.
125
+
126
+ ## Settings
127
+
128
+ | Variable | |
129
+ |---|---|
130
+ | `CNX_API_KEY` | Required. A ComputeNexa API key (`cnxk_…`) |
131
+ | `CNX_MCP_MAX_LAUNCH_USD` | Most one call may spend, in US dollars. Default `25` |
132
+ | `CNX_API_URL` | Only for testing against another ComputeNexa API |
133
+
134
+ ## Develop
135
+
136
+ ```bash
137
+ uv sync
138
+ uv run pytest
139
+ uv run ruff check src tests
140
+ ```
141
+
142
+ Built on the [ComputeNexa Python SDK](https://pypi.org/project/computenexa/) and the official
143
+ [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).
144
+
145
+ Questions: [support@computenexa.com](mailto:support@computenexa.com) · Security issues: see [SECURITY.md](SECURITY.md).
@@ -0,0 +1,12 @@
1
+ computenexa_mcp/__init__.py,sha256=eifw1CTykkxQqrgdOPSqdkqll28cSz-rE_b5Aj6W4yc,373
2
+ computenexa_mcp/__main__.py,sha256=3dYKHfmWsrdExFlTFlcR5a_icR9fAkn06Yh14TQkEd8,33
3
+ computenexa_mcp/_text.py,sha256=DMkET8q7Uh1Oolu8smT6tBnLw7wRKO-B98bgiR35KbQ,2892
4
+ computenexa_mcp/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
5
+ computenexa_mcp/models.py,sha256=mQqZPHJxpl3cgaSWOcNSUnibTH-F66xuAgXmzvKCFlc,6463
6
+ computenexa_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ computenexa_mcp/server.py,sha256=IpgxTTYmcYiFVcGZHvQznIPTRUYD3FVZoSfOySnYOc0,41029
8
+ computenexa_mcp-0.1.0.dist-info/METADATA,sha256=YSwuPXIqTiy2GKFu-etDeFLkj4J2sRHG8SsWXtNftek,5815
9
+ computenexa_mcp-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ computenexa_mcp-0.1.0.dist-info/entry_points.txt,sha256=TFgvgsCpSiMGoVwYo5b_rj0QZnkwagtaCyYalVxj6-A,64
11
+ computenexa_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=2QFwVb5OF20CkVUdITA4oVlgRJFDfLGpSr7UjCAxV1s,1068
12
+ computenexa_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ computenexa-mcp = computenexa_mcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ComputeNexa
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.