iotamine-mcp 0.1.0__tar.gz

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,54 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ test:
9
+ name: Run tests
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.x"
16
+ - run: python -m pip install -e ".[dev]"
17
+ - run: pytest
18
+
19
+ build:
20
+ name: Build distribution
21
+ needs: test
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.x"
28
+ - run: python -m pip install --upgrade build
29
+ - run: python -m build
30
+ - uses: actions/upload-artifact@v4
31
+ with:
32
+ name: python-package-distributions
33
+ path: dist/
34
+
35
+ publish:
36
+ name: Publish to PyPI
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment:
40
+ name: pypi
41
+ url: https://pypi.org/p/iotamine-mcp
42
+ permissions:
43
+ # The whole point of trusted publishing — this lets GitHub Actions
44
+ # prove its identity to PyPI via OIDC (matching the "pending
45
+ # publisher" configured at pypi.org/manage/account/publishing/:
46
+ # repo=piyushladhar/iotamine-mcp, workflow=publish.yml,
47
+ # environment=pypi) with no API token stored anywhere at all.
48
+ id-token: write
49
+ steps:
50
+ - uses: actions/download-artifact@v4
51
+ with:
52
+ name: python-package-distributions
53
+ path: dist/
54
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: iotamine-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for the Iotamine cloud VPS platform — inspect your VPS instances, billing, and infrastructure from any MCP-compatible client (Claude Desktop, Claude Code, Cursor, ...).
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: mcp<3,>=2.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Requires-Dist: respx>=0.21; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # iotamine-mcp
15
+
16
+ An [MCP](https://modelcontextprotocol.io) server for the [Iotamine](https://iotamine.com) cloud VPS
17
+ platform. Connect it to Claude Desktop, Claude Code, Cursor, or any other MCP-compatible client to
18
+ let it read your VPS instances, billing, quota, and infrastructure directly.
19
+
20
+ This is a thin, read-only wrapper around the real Iotamine REST API — the exact same API your
21
+ [dashboard](https://iotamine.com/control) already uses. It doesn't duplicate any account logic,
22
+ store any of your data, or run any service of its own; it just translates MCP tool calls into
23
+ ordinary API requests on your behalf, using your own API key.
24
+
25
+ **Every tool in this server is read-only.** Nothing here can start, stop, create, resize, or
26
+ destroy a VPS, or spend money. (Write actions are planned for a future release, gated behind a
27
+ separate `read_write`-scoped key — see [Scopes](#scopes) below.)
28
+
29
+ ## Setup
30
+
31
+ ### 1. Get an API key
32
+
33
+ From your [Iotamine dashboard](https://iotamine.com/control/api_keys) → API Keys → Create Key.
34
+ Leave the scope as **Read-only** — that's all this server needs.
35
+
36
+ ### 2. Install and run
37
+
38
+ Using [`uv`](https://docs.astral.sh/uv/) (recommended — no separate install step):
39
+
40
+ ```bash
41
+ uvx iotamine-mcp
42
+ ```
43
+
44
+ Or with `pip`:
45
+
46
+ ```bash
47
+ pip install iotamine-mcp
48
+ iotamine-mcp
49
+ ```
50
+
51
+ ### 3. Add it to your MCP client
52
+
53
+ **Claude Code:**
54
+
55
+ ```bash
56
+ claude mcp add iotamine uvx --args iotamine-mcp --env IOTAMINE_API_KEY="your-key-here"
57
+ ```
58
+
59
+ **Claude Desktop / Cursor** (`claude_desktop_config.json` or your client's equivalent):
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "iotamine": {
65
+ "command": "uvx",
66
+ "args": ["iotamine-mcp"],
67
+ "env": {
68
+ "IOTAMINE_API_KEY": "your-key-here"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ Set `IOTAMINE_API_KEY` as an environment variable in your client's config, not hardcoded anywhere
76
+ else — the same rule any API key deserves.
77
+
78
+ ## Tools
79
+
80
+ | Tool | What it returns |
81
+ |---|---|
82
+ | `list_vps` | Every VPS instance on the account |
83
+ | `get_vps` | Full detail for one VPS by id |
84
+ | `get_quota` | Resource limits, current usage, and tier |
85
+ | `get_account_balance` | Wallet balance and basic profile |
86
+ | `list_invoices` | Every invoice, most recent first |
87
+ | `get_usage_billing` | Current unbilled usage + historical billed cost |
88
+ | `list_os_images` | Available operating systems |
89
+ | `list_regions` | Available Points of Presence |
90
+ | `list_ip_addresses` | Every standalone IP address on the account |
91
+ | `list_volumes` | Every standalone storage volume on the account |
92
+ | `list_ssh_keys` | Every SSH key saved on the account |
93
+
94
+ ## Scopes
95
+
96
+ An Iotamine API key has a `scope` — **read-only** (default) or **read & write**. This server only
97
+ ever needs a read-only key. If you paste a read & write key instead, nothing changes: every tool
98
+ here still only ever sends `GET` requests.
99
+
100
+ ## Configuration
101
+
102
+ | Environment variable | Required | Default |
103
+ |---|---|---|
104
+ | `IOTAMINE_API_KEY` | Yes | — |
105
+ | `IOTAMINE_API_URL` | No | `https://iotamine.com/api/` |
106
+
107
+ ## Development
108
+
109
+ ```bash
110
+ python -m venv .venv && source .venv/bin/activate
111
+ pip install -e ".[dev]"
112
+ pytest
113
+ ```
@@ -0,0 +1,100 @@
1
+ # iotamine-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for the [Iotamine](https://iotamine.com) cloud VPS
4
+ platform. Connect it to Claude Desktop, Claude Code, Cursor, or any other MCP-compatible client to
5
+ let it read your VPS instances, billing, quota, and infrastructure directly.
6
+
7
+ This is a thin, read-only wrapper around the real Iotamine REST API — the exact same API your
8
+ [dashboard](https://iotamine.com/control) already uses. It doesn't duplicate any account logic,
9
+ store any of your data, or run any service of its own; it just translates MCP tool calls into
10
+ ordinary API requests on your behalf, using your own API key.
11
+
12
+ **Every tool in this server is read-only.** Nothing here can start, stop, create, resize, or
13
+ destroy a VPS, or spend money. (Write actions are planned for a future release, gated behind a
14
+ separate `read_write`-scoped key — see [Scopes](#scopes) below.)
15
+
16
+ ## Setup
17
+
18
+ ### 1. Get an API key
19
+
20
+ From your [Iotamine dashboard](https://iotamine.com/control/api_keys) → API Keys → Create Key.
21
+ Leave the scope as **Read-only** — that's all this server needs.
22
+
23
+ ### 2. Install and run
24
+
25
+ Using [`uv`](https://docs.astral.sh/uv/) (recommended — no separate install step):
26
+
27
+ ```bash
28
+ uvx iotamine-mcp
29
+ ```
30
+
31
+ Or with `pip`:
32
+
33
+ ```bash
34
+ pip install iotamine-mcp
35
+ iotamine-mcp
36
+ ```
37
+
38
+ ### 3. Add it to your MCP client
39
+
40
+ **Claude Code:**
41
+
42
+ ```bash
43
+ claude mcp add iotamine uvx --args iotamine-mcp --env IOTAMINE_API_KEY="your-key-here"
44
+ ```
45
+
46
+ **Claude Desktop / Cursor** (`claude_desktop_config.json` or your client's equivalent):
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "iotamine": {
52
+ "command": "uvx",
53
+ "args": ["iotamine-mcp"],
54
+ "env": {
55
+ "IOTAMINE_API_KEY": "your-key-here"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ Set `IOTAMINE_API_KEY` as an environment variable in your client's config, not hardcoded anywhere
63
+ else — the same rule any API key deserves.
64
+
65
+ ## Tools
66
+
67
+ | Tool | What it returns |
68
+ |---|---|
69
+ | `list_vps` | Every VPS instance on the account |
70
+ | `get_vps` | Full detail for one VPS by id |
71
+ | `get_quota` | Resource limits, current usage, and tier |
72
+ | `get_account_balance` | Wallet balance and basic profile |
73
+ | `list_invoices` | Every invoice, most recent first |
74
+ | `get_usage_billing` | Current unbilled usage + historical billed cost |
75
+ | `list_os_images` | Available operating systems |
76
+ | `list_regions` | Available Points of Presence |
77
+ | `list_ip_addresses` | Every standalone IP address on the account |
78
+ | `list_volumes` | Every standalone storage volume on the account |
79
+ | `list_ssh_keys` | Every SSH key saved on the account |
80
+
81
+ ## Scopes
82
+
83
+ An Iotamine API key has a `scope` — **read-only** (default) or **read & write**. This server only
84
+ ever needs a read-only key. If you paste a read & write key instead, nothing changes: every tool
85
+ here still only ever sends `GET` requests.
86
+
87
+ ## Configuration
88
+
89
+ | Environment variable | Required | Default |
90
+ |---|---|---|
91
+ | `IOTAMINE_API_KEY` | Yes | — |
92
+ | `IOTAMINE_API_URL` | No | `https://iotamine.com/api/` |
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ python -m venv .venv && source .venv/bin/activate
98
+ pip install -e ".[dev]"
99
+ pytest
100
+ ```
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "iotamine-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for the Iotamine cloud VPS platform — inspect your VPS instances, billing, and infrastructure from any MCP-compatible client (Claude Desktop, Claude Code, Cursor, ...)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "mcp>=2.0,<3",
9
+ "httpx>=0.27",
10
+ ]
11
+
12
+ [project.scripts]
13
+ iotamine-mcp = "iotamine_mcp.__main__:main"
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.0",
18
+ "pytest-asyncio>=0.24",
19
+ "respx>=0.21",
20
+ ]
21
+
22
+ [tool.pytest.ini_options]
23
+ asyncio_mode = "auto"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/iotamine_mcp"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,25 @@
1
+ import sys
2
+
3
+ from iotamine_mcp.client import IotamineConfigError
4
+ from iotamine_mcp.server import build_server
5
+
6
+
7
+ def main():
8
+ try:
9
+ mcp, client = build_server()
10
+ except IotamineConfigError as exc:
11
+ # A clear, early failure on stderr — most MCP clients don't
12
+ # surface a crashed server's own stderr nicely, but anyone
13
+ # running this by hand to debug it (the whole point of it being
14
+ # a plain local process) still deserves a real message instead
15
+ # of every tool call failing one at a time with a 401.
16
+ print(f"iotamine-mcp: {exc}", file=sys.stderr)
17
+ sys.exit(1)
18
+ try:
19
+ mcp.run(transport="stdio")
20
+ finally:
21
+ client.close()
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
@@ -0,0 +1,99 @@
1
+ """Thin wrapper around the real Iotamine REST API (iotamine_backend_django)
2
+ — every tool in iotamine_mcp.tools goes through this, and nothing here
3
+ duplicates any business logic the backend already owns. Auth is a plain
4
+ Iotamine API key (core.authentications.APIKeyAuthentication), sent the
5
+ same way any other REST client already sends one.
6
+ """
7
+ import os
8
+
9
+ import httpx
10
+
11
+ DEFAULT_BASE_URL = "https://iotamine.com/api/"
12
+
13
+
14
+ class IotamineConfigError(Exception):
15
+ """Raised at startup when IOTAMINE_API_KEY isn't set — a clear,
16
+ early failure instead of every tool call failing with a confusing
17
+ 401 one at a time."""
18
+
19
+
20
+ class IotamineAPIError(Exception):
21
+ """Wraps a non-2xx response from the real API. `message` is the
22
+ backend's own {"message": ...} (or {"error": ...}) body when it sent
23
+ one — always shown to the model as-is rather than a generic
24
+ "something went wrong", since it's already written to be a clear,
25
+ actionable string (see e.g. core.views's own KYC/VPS error
26
+ messages)."""
27
+
28
+ def __init__(self, status_code, message):
29
+ super().__init__(f"{status_code}: {message}")
30
+ self.status_code = status_code
31
+ self.message = message
32
+
33
+
34
+ def _extract_message(response):
35
+ try:
36
+ data = response.json()
37
+ except ValueError:
38
+ return response.text[:500] or f"HTTP {response.status_code}"
39
+ if isinstance(data, dict):
40
+ return data.get("message") or data.get("error") or data.get("detail") or str(data)
41
+ return str(data)
42
+
43
+
44
+ class IotamineClient:
45
+ """One instance per server process — httpx.Client is safe to reuse
46
+ across calls, unlike creating a fresh connection per tool call."""
47
+
48
+ def __init__(self, api_key=None, base_url=None):
49
+ self.api_key = api_key or os.environ.get("IOTAMINE_API_KEY")
50
+ if not self.api_key:
51
+ raise IotamineConfigError(
52
+ "IOTAMINE_API_KEY is not set. Generate an API key from your Iotamine "
53
+ "dashboard (Account -> API Keys) and set it as an environment variable "
54
+ "in your MCP client's config for this server."
55
+ )
56
+ self.base_url = (base_url or os.environ.get("IOTAMINE_API_URL") or DEFAULT_BASE_URL).rstrip("/") + "/"
57
+ self._client = httpx.Client(
58
+ base_url=self.base_url,
59
+ headers={"Authorization": f"Api-Key {self.api_key}", "Accept": "application/json"},
60
+ timeout=httpx.Timeout(connect=10, read=60, write=15, pool=10),
61
+ )
62
+
63
+ def close(self):
64
+ self._client.close()
65
+
66
+ def _request(self, method, path, *, params=None, json=None):
67
+ try:
68
+ response = self._client.request(method, path.lstrip("/"), params=params, json=json)
69
+ except httpx.RequestError as exc:
70
+ raise IotamineAPIError(None, f"Could not reach the Iotamine API: {exc}") from exc
71
+ if response.status_code == 401:
72
+ raise IotamineAPIError(401, "This API key is invalid, inactive, or IP-restricted. Check it on your Iotamine dashboard's API Keys page.")
73
+ if response.status_code == 403:
74
+ raise IotamineAPIError(403, _extract_message(response) or "This API key doesn't have permission for this action — it may be read-only (see APIKey.scope on your dashboard).")
75
+ if not response.is_success:
76
+ raise IotamineAPIError(response.status_code, _extract_message(response))
77
+ if not response.content:
78
+ return None
79
+ return response.json()
80
+
81
+ def get(self, path, params=None):
82
+ return self._request("GET", path, params=params)
83
+
84
+ def post(self, path, json=None):
85
+ return self._request("POST", path, json=json)
86
+
87
+ def get_list(self, path, params=None):
88
+ """Normalizes the two list shapes the real API actually returns
89
+ (see individual view comments — some endpoints always paginate,
90
+ some only conditionally): a plain JSON array, or DRF's
91
+ {count, next, previous, results} envelope. Callers always get a
92
+ plain list back either way — an MCP tool has no use for a
93
+ pagination cursor a model can't act on."""
94
+ data = self.get(path, params=params)
95
+ if isinstance(data, dict) and "results" in data:
96
+ return data["results"]
97
+ if isinstance(data, list):
98
+ return data
99
+ return [] if data is None else [data]
@@ -0,0 +1,24 @@
1
+ """Builds the MCP server: one MCPServer instance, one IotamineClient,
2
+ every tools.* module registers its own tools against both. No tool
3
+ logic lives in this file — see iotamine_mcp/tools/ for that."""
4
+ from mcp.server.mcpserver import MCPServer
5
+
6
+ from iotamine_mcp.client import IotamineClient
7
+ from iotamine_mcp.tools import billing, infra, vps
8
+
9
+ INSTRUCTIONS = (
10
+ "Tools for managing your Iotamine cloud VPS account: listing and inspecting VPS "
11
+ "instances, checking quota/balance/invoices/usage, and viewing OS images, regions, "
12
+ "IP addresses, volumes, and SSH keys. Every tool in this server is read-only — "
13
+ "nothing here can start, stop, create, resize, or destroy anything, or spend money. "
14
+ "Data comes directly from the same API https://iotamine.com's own dashboard uses."
15
+ )
16
+
17
+
18
+ def build_server(client=None):
19
+ mcp = MCPServer(name="iotamine", title="Iotamine Cloud", instructions=INSTRUCTIONS)
20
+ client = client or IotamineClient()
21
+ vps.register(mcp, client)
22
+ billing.register(mcp, client)
23
+ infra.register(mcp, client)
24
+ return mcp, client
File without changes
@@ -0,0 +1,34 @@
1
+ """Billing/account read tools — quota, balance, invoices, usage."""
2
+ from mcp.types import ToolAnnotations
3
+
4
+ READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False)
5
+
6
+
7
+ def register(mcp, client):
8
+ @mcp.tool(annotations=READ_ONLY)
9
+ def get_quota() -> dict:
10
+ """This account's resource limits (VPS count / vCPU / RAM / disk
11
+ / IP caps), current usage against each, and its current tier —
12
+ core.quotas's balance-driven tier system, exactly what the
13
+ dashboard's own Tier page shows."""
14
+ return client.get("account/quota/")
15
+
16
+ @mcp.tool(annotations=READ_ONLY)
17
+ def get_account_balance() -> dict:
18
+ """This account's current wallet balance, currency, and basic
19
+ profile (name, email, country, verification status)."""
20
+ return client.get("users/me/")
21
+
22
+ @mcp.tool(annotations=READ_ONLY)
23
+ def list_invoices() -> list:
24
+ """List every invoice on this account (paid, unpaid, and
25
+ cancelled), most recent first — amount, status, due date, and
26
+ line items."""
27
+ return client.get_list("invoices/", params={"page_size": 100})
28
+
29
+ @mcp.tool(annotations=READ_ONLY)
30
+ def get_usage_billing() -> dict:
31
+ """Current unbilled usage (a live snapshot) plus historical
32
+ already-billed cost broken down by Compute/Storage/Network/Other
33
+ — the same data the dashboard's own Usage Billing page shows."""
34
+ return client.get("usage-billing/overview/")
@@ -0,0 +1,39 @@
1
+ """Infrastructure inventory tools — everything else a VPS is built from
2
+ or attached to: OS images, regions, IPs, volumes, SSH keys.
3
+ """
4
+ from mcp.types import ToolAnnotations
5
+
6
+ READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False)
7
+
8
+
9
+ def register(mcp, client):
10
+ @mcp.tool(annotations=READ_ONLY)
11
+ def list_os_images() -> list:
12
+ """List every operating system image available to deploy a VPS
13
+ with (name, distro, virtualization type)."""
14
+ return client.get_list("os/", params={"page_size": 200})
15
+
16
+ @mcp.tool(annotations=READ_ONLY)
17
+ def list_regions() -> list:
18
+ """List every Point of Presence (region/data-center location)
19
+ VPS instances can be deployed in."""
20
+ return client.get_list("pop/", params={"page_size": 200})
21
+
22
+ @mcp.tool(annotations=READ_ONLY)
23
+ def list_ip_addresses() -> list:
24
+ """List every standalone IP address on this account (attached
25
+ to a VPS or not), independent of any VPS — see how a boot
26
+ volume/IP can be detached and reattached on Iotamine."""
27
+ return client.get_list("ip-addresses/", params={"page_size": 200})
28
+
29
+ @mcp.tool(annotations=READ_ONLY)
30
+ def list_volumes() -> list:
31
+ """List every standalone storage volume on this account
32
+ (attached to a VPS or not, boot or non-boot)."""
33
+ return client.get_list("volumes/", params={"page_size": 200})
34
+
35
+ @mcp.tool(annotations=READ_ONLY)
36
+ def list_ssh_keys() -> list:
37
+ """List every SSH public key saved on this account (title only
38
+ — the public key material itself, never anything private)."""
39
+ return client.get_list("sshkey/", params={"page_size": 200})
@@ -0,0 +1,24 @@
1
+ """VPS read tools — thin passthroughs to vps.views.VPSViewSet. Response
2
+ shapes are whatever VPSSerializer already returns; nothing here
3
+ re-derives or reshapes them, so they never drift out of sync with the
4
+ real API.
5
+ """
6
+ from mcp.types import ToolAnnotations
7
+
8
+ READ_ONLY = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False)
9
+
10
+
11
+ def register(mcp, client):
12
+ @mcp.tool(annotations=READ_ONLY)
13
+ def list_vps() -> list:
14
+ """List every VPS instance on this Iotamine account — hostname,
15
+ status, specs (cores/ram/disk/traffic), IP addresses, OS, node
16
+ location, and machine_status (Running/Stopped/Building/...) for
17
+ each one."""
18
+ return client.get_list("vps/", params={"all": "true"})
19
+
20
+ @mcp.tool(annotations=READ_ONLY)
21
+ def get_vps(vps_id: str) -> dict:
22
+ """Get full detail for one VPS instance by its id (a UUID, as
23
+ returned by list_vps's own "id" field)."""
24
+ return client.get(f"vps/{vps_id}/")
@@ -0,0 +1,19 @@
1
+ from unittest.mock import MagicMock
2
+
3
+ import pytest
4
+
5
+ from iotamine_mcp.server import build_server
6
+
7
+
8
+ @pytest.fixture
9
+ def fake_client():
10
+ """A MagicMock standing in for IotamineClient — every tool test
11
+ asserts against how it was called, not real HTTP (test_client.py's
12
+ respx-based tests already cover the real request/response shapes)."""
13
+ return MagicMock()
14
+
15
+
16
+ @pytest.fixture
17
+ def mcp_server(fake_client):
18
+ mcp, _ = build_server(client=fake_client)
19
+ return mcp
@@ -0,0 +1,92 @@
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from iotamine_mcp.client import IotamineAPIError, IotamineClient, IotamineConfigError
6
+
7
+
8
+ def test_missing_api_key_raises_a_clear_config_error(monkeypatch):
9
+ monkeypatch.delenv("IOTAMINE_API_KEY", raising=False)
10
+ with pytest.raises(IotamineConfigError, match="IOTAMINE_API_KEY"):
11
+ IotamineClient()
12
+
13
+
14
+ def test_default_base_url_is_the_real_production_api():
15
+ client = IotamineClient(api_key="k")
16
+ assert client.base_url == "https://iotamine.com/api/"
17
+ client.close()
18
+
19
+
20
+ def test_base_url_override_via_env(monkeypatch):
21
+ monkeypatch.setenv("IOTAMINE_API_URL", "http://127.0.0.1:8003/api")
22
+ client = IotamineClient(api_key="k")
23
+ assert client.base_url == "http://127.0.0.1:8003/api/"
24
+ client.close()
25
+
26
+
27
+ @respx.mock
28
+ def test_get_sends_the_api_key_header():
29
+ route = respx.get("https://iotamine.com/api/vps/").mock(
30
+ return_value=httpx.Response(200, json=[])
31
+ )
32
+ client = IotamineClient(api_key="my-secret-key")
33
+ client.get("vps/")
34
+ client.close()
35
+ assert route.called
36
+ assert route.calls.last.request.headers["Authorization"] == "Api-Key my-secret-key"
37
+
38
+
39
+ @respx.mock
40
+ def test_401_raises_a_clear_invalid_key_message():
41
+ respx.get("https://iotamine.com/api/vps/").mock(return_value=httpx.Response(401))
42
+ client = IotamineClient(api_key="bad-key")
43
+ with pytest.raises(IotamineAPIError) as exc_info:
44
+ client.get("vps/")
45
+ client.close()
46
+ assert exc_info.value.status_code == 401
47
+ assert "API Keys page" in exc_info.value.message
48
+
49
+
50
+ @respx.mock
51
+ def test_403_surfaces_the_backends_real_message():
52
+ respx.post("https://iotamine.com/api/sshkey/").mock(
53
+ return_value=httpx.Response(403, json={"message": "This API key is read-only. Generate a read & write key to perform this action."})
54
+ )
55
+ client = IotamineClient(api_key="ro-key")
56
+ with pytest.raises(IotamineAPIError) as exc_info:
57
+ client.post("sshkey/", json={"title": "x"})
58
+ client.close()
59
+ assert exc_info.value.status_code == 403
60
+ assert "read-only" in exc_info.value.message
61
+
62
+
63
+ @respx.mock
64
+ def test_network_failure_is_wrapped_not_raised_raw():
65
+ respx.get("https://iotamine.com/api/vps/").mock(side_effect=httpx.ConnectError("refused"))
66
+ client = IotamineClient(api_key="k")
67
+ with pytest.raises(IotamineAPIError) as exc_info:
68
+ client.get("vps/")
69
+ client.close()
70
+ assert "Could not reach" in exc_info.value.message
71
+
72
+
73
+ @respx.mock
74
+ def test_get_list_unwraps_a_paginated_envelope():
75
+ respx.get("https://iotamine.com/api/invoices/").mock(
76
+ return_value=httpx.Response(200, json={"count": 2, "next": None, "previous": None, "results": [{"id": 1}, {"id": 2}]})
77
+ )
78
+ client = IotamineClient(api_key="k")
79
+ result = client.get_list("invoices/")
80
+ client.close()
81
+ assert result == [{"id": 1}, {"id": 2}]
82
+
83
+
84
+ @respx.mock
85
+ def test_get_list_passes_through_a_plain_array():
86
+ respx.get("https://iotamine.com/api/vps/").mock(
87
+ return_value=httpx.Response(200, json=[{"id": "a"}, {"id": "b"}])
88
+ )
89
+ client = IotamineClient(api_key="k")
90
+ result = client.get_list("vps/")
91
+ client.close()
92
+ assert result == [{"id": "a"}, {"id": "b"}]
@@ -0,0 +1,87 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+
6
+ def _texts(result):
7
+ """Every tool test just needs to see what got returned, not fight
8
+ the SDK's own content-block framing (a list-returning tool comes
9
+ back as one TextContent block per item; a dict-returning tool comes
10
+ back as one block holding the whole object)."""
11
+ return [json.loads(block.text) for block in result.content]
12
+
13
+
14
+ @pytest.mark.asyncio
15
+ async def test_list_vps_calls_the_right_endpoint_with_all_true(mcp_server, fake_client):
16
+ fake_client.get_list.return_value = [{"id": "v1"}]
17
+ result = await mcp_server.call_tool("list_vps", {})
18
+ fake_client.get_list.assert_called_once_with("vps/", params={"all": "true"})
19
+ assert _texts(result) == [{"id": "v1"}]
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_get_vps_passes_the_id_into_the_path(mcp_server, fake_client):
24
+ fake_client.get.return_value = {"id": "v1", "hostname": "box"}
25
+ result = await mcp_server.call_tool("get_vps", {"vps_id": "v1"})
26
+ fake_client.get.assert_called_once_with("vps/v1/")
27
+ assert _texts(result) == [{"id": "v1", "hostname": "box"}]
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_get_quota(mcp_server, fake_client):
32
+ fake_client.get.return_value = {"tier": "Tier 2"}
33
+ result = await mcp_server.call_tool("get_quota", {})
34
+ fake_client.get.assert_called_once_with("account/quota/")
35
+ assert _texts(result) == [{"tier": "Tier 2"}]
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_get_account_balance_hits_users_me(mcp_server, fake_client):
40
+ fake_client.get.return_value = {"balance": "42.00"}
41
+ result = await mcp_server.call_tool("get_account_balance", {})
42
+ fake_client.get.assert_called_once_with("users/me/")
43
+ assert _texts(result) == [{"balance": "42.00"}]
44
+
45
+
46
+ @pytest.mark.asyncio
47
+ async def test_list_invoices(mcp_server, fake_client):
48
+ fake_client.get_list.return_value = [{"id": 1}]
49
+ result = await mcp_server.call_tool("list_invoices", {})
50
+ fake_client.get_list.assert_called_once_with("invoices/", params={"page_size": 100})
51
+ assert _texts(result) == [{"id": 1}]
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_get_usage_billing(mcp_server, fake_client):
56
+ fake_client.get.return_value = {"unbilled": 5.0}
57
+ result = await mcp_server.call_tool("get_usage_billing", {})
58
+ fake_client.get.assert_called_once_with("usage-billing/overview/")
59
+ assert _texts(result) == [{"unbilled": 5.0}]
60
+
61
+
62
+ @pytest.mark.parametrize("tool_name,path", [
63
+ ("list_os_images", "os/"),
64
+ ("list_regions", "pop/"),
65
+ ("list_ip_addresses", "ip-addresses/"),
66
+ ("list_volumes", "volumes/"),
67
+ ("list_ssh_keys", "sshkey/"),
68
+ ])
69
+ @pytest.mark.asyncio
70
+ async def test_infra_list_tools_hit_the_right_endpoint(mcp_server, fake_client, tool_name, path):
71
+ fake_client.get_list.return_value = [{"id": 1}]
72
+ result = await mcp_server.call_tool(tool_name, {})
73
+ fake_client.get_list.assert_called_once_with(path, params={"page_size": 200})
74
+ assert _texts(result) == [{"id": 1}]
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_every_registered_tool_is_marked_read_only(mcp_server):
79
+ """The whole point of Phase 1 shipping read-only-only: nothing here
80
+ should ever be annotated otherwise, or a client that trusts
81
+ readOnlyHint could let a model call it without confirmation."""
82
+ tools = await mcp_server.list_tools()
83
+ assert len(tools) == 11
84
+ for tool in tools:
85
+ assert tool.annotations is not None
86
+ assert tool.annotations.read_only_hint is True
87
+ assert not tool.annotations.destructive_hint