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.
- iotamine_mcp-0.1.0/.github/workflows/publish.yml +54 -0
- iotamine_mcp-0.1.0/.gitignore +8 -0
- iotamine_mcp-0.1.0/PKG-INFO +113 -0
- iotamine_mcp-0.1.0/README.md +100 -0
- iotamine_mcp-0.1.0/pyproject.toml +30 -0
- iotamine_mcp-0.1.0/src/iotamine_mcp/__init__.py +1 -0
- iotamine_mcp-0.1.0/src/iotamine_mcp/__main__.py +25 -0
- iotamine_mcp-0.1.0/src/iotamine_mcp/client.py +99 -0
- iotamine_mcp-0.1.0/src/iotamine_mcp/server.py +24 -0
- iotamine_mcp-0.1.0/src/iotamine_mcp/tools/__init__.py +0 -0
- iotamine_mcp-0.1.0/src/iotamine_mcp/tools/billing.py +34 -0
- iotamine_mcp-0.1.0/src/iotamine_mcp/tools/infra.py +39 -0
- iotamine_mcp-0.1.0/src/iotamine_mcp/tools/vps.py +24 -0
- iotamine_mcp-0.1.0/tests/conftest.py +19 -0
- iotamine_mcp-0.1.0/tests/test_client.py +92 -0
- iotamine_mcp-0.1.0/tests/test_tools.py +87 -0
|
@@ -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,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
|