pluglayer-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.
- pluglayer_mcp/__init__.py +2 -0
- pluglayer_mcp/client.py +59 -0
- pluglayer_mcp/server.py +69 -0
- pluglayer_mcp/settings.py +27 -0
- pluglayer_mcp/tools/__init__.py +1 -0
- pluglayer_mcp/tools/cicd_health.py +42 -0
- pluglayer_mcp/tools/compute.py +92 -0
- pluglayer_mcp/tools/deployments.py +180 -0
- pluglayer_mcp/tools/domains.py +81 -0
- pluglayer_mcp/tools/identity_projects.py +95 -0
- pluglayer_mcp/tools/shared.py +53 -0
- pluglayer_mcp/tools/tasks_admin.py +122 -0
- pluglayer_mcp-0.1.0.dist-info/METADATA +144 -0
- pluglayer_mcp-0.1.0.dist-info/RECORD +16 -0
- pluglayer_mcp-0.1.0.dist-info/WHEEL +4 -0
- pluglayer_mcp-0.1.0.dist-info/entry_points.txt +2 -0
pluglayer_mcp/client.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Optional, Any
|
|
3
|
+
from pluglayer_mcp.settings import settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PlugLayerClient:
|
|
7
|
+
"""HTTP client for the PlugLayer API."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
|
|
10
|
+
self.api_key = api_key or settings.PLUGLAYER_API_KEY
|
|
11
|
+
self.base_url = (base_url or settings.resolved_api_base_url).rstrip("/")
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def headers(self) -> dict:
|
|
15
|
+
return {
|
|
16
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
"User-Agent": "pluglayer-mcp/0.1.0",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async def _request(self, method: str, path: str, *, params: dict = None, data: dict = None, timeout: float = 30.0) -> Any:
|
|
22
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
23
|
+
resp = await client.request(
|
|
24
|
+
method,
|
|
25
|
+
f"{self.base_url}{path}",
|
|
26
|
+
headers=self.headers,
|
|
27
|
+
params=params,
|
|
28
|
+
json=data,
|
|
29
|
+
)
|
|
30
|
+
try:
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
except httpx.HTTPStatusError as exc:
|
|
33
|
+
detail = resp.text[:500]
|
|
34
|
+
raise RuntimeError(f"{resp.status_code} {resp.reason_phrase}: {detail}") from exc
|
|
35
|
+
if resp.status_code == 204 or not resp.content:
|
|
36
|
+
return {}
|
|
37
|
+
data = resp.json()
|
|
38
|
+
if isinstance(data, dict) and data.get("ok") is True and "data" in data:
|
|
39
|
+
return data["data"]
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
async def get(self, path: str, params: dict = None) -> Any:
|
|
43
|
+
return await self._request("GET", path, params=params, timeout=30.0)
|
|
44
|
+
|
|
45
|
+
async def post(self, path: str, data: dict = None, params: dict = None) -> Any:
|
|
46
|
+
return await self._request("POST", path, params=params, data=data or {}, timeout=60.0)
|
|
47
|
+
|
|
48
|
+
async def delete(self, path: str) -> Any:
|
|
49
|
+
return await self._request("DELETE", path, timeout=30.0)
|
|
50
|
+
|
|
51
|
+
async def patch(self, path: str, data: dict) -> Any:
|
|
52
|
+
return await self._request("PATCH", path, data=data, timeout=30.0)
|
|
53
|
+
|
|
54
|
+
async def put(self, path: str, data: dict) -> Any:
|
|
55
|
+
return await self._request("PUT", path, data=data, timeout=30.0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_client(api_key: Optional[str] = None) -> PlugLayerClient:
|
|
59
|
+
return PlugLayerClient(api_key=api_key)
|
pluglayer_mcp/server.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PlugLayer MCP Server
|
|
3
|
+
|
|
4
|
+
Exposes PlugLayer project, compute, deployment, CI/CD, and admin tools to AI
|
|
5
|
+
assistants through the Model Context Protocol (MCP). The MCP intentionally goes
|
|
6
|
+
through the FastAPI backend endpoints so auth, roles, ownership, quotas, compute
|
|
7
|
+
checks, k3s orchestration, and admin guards stay in one backend implementation.
|
|
8
|
+
"""
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from pluglayer_mcp.settings import settings
|
|
14
|
+
|
|
15
|
+
mcp = FastMCP(
|
|
16
|
+
"PlugLayer",
|
|
17
|
+
instructions="""You are the PlugLayer infrastructure operator.
|
|
18
|
+
You help users deploy, manage, and monitor applications on PlugLayer.
|
|
19
|
+
|
|
20
|
+
Current PlugLayer rules:
|
|
21
|
+
- Authentik groups are exposed by PlugLayer as user.roles. Do not use groups/permissions fields.
|
|
22
|
+
- Admin tools require the user to have pluglayer-admin or pluglayer-superadmin in roles.
|
|
23
|
+
- Compute is account-level: personal SSH nodes and shared PlugLayer nodes can be used by all projects the user owns.
|
|
24
|
+
- A project is a k3s namespace. A deployment is an app inside a project.
|
|
25
|
+
- Custom domains are verified and routed by backend v1 domain endpoints; do not invent DNS or Traefik state.
|
|
26
|
+
- Async operations return task IDs; always poll get_task_status until completion.
|
|
27
|
+
|
|
28
|
+
Deployment workflow:
|
|
29
|
+
1. Run get_current_user and get_compute_summary.
|
|
30
|
+
2. List or create a project.
|
|
31
|
+
3. Ensure can_deploy is true. If false, add a personal SSH node or ask an admin to assign shared compute.
|
|
32
|
+
4. Deploy from image or docker-compose.
|
|
33
|
+
5. Poll the returned task and report the public URL.
|
|
34
|
+
|
|
35
|
+
Confirm destructive actions such as delete and rollback before executing them.
|
|
36
|
+
""",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from pluglayer_mcp.tools.cicd_health import register_cicd_health_tools
|
|
40
|
+
from pluglayer_mcp.tools.compute import register_compute_tools
|
|
41
|
+
from pluglayer_mcp.tools.deployments import register_deployment_tools
|
|
42
|
+
from pluglayer_mcp.tools.domains import register_domain_tools
|
|
43
|
+
from pluglayer_mcp.tools.identity_projects import register_identity_project_tools
|
|
44
|
+
from pluglayer_mcp.tools.tasks_admin import register_task_admin_tools
|
|
45
|
+
|
|
46
|
+
register_identity_project_tools(mcp)
|
|
47
|
+
register_compute_tools(mcp)
|
|
48
|
+
register_deployment_tools(mcp)
|
|
49
|
+
register_domain_tools(mcp)
|
|
50
|
+
register_task_admin_tools(mcp)
|
|
51
|
+
register_cicd_health_tools(mcp)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
"""Entry point for `pluglayer-mcp` command."""
|
|
56
|
+
if not settings.PLUGLAYER_API_KEY:
|
|
57
|
+
print(
|
|
58
|
+
"WARNING: PLUGLAYER_API_KEY not set!\n"
|
|
59
|
+
"Set it as an environment variable:\n"
|
|
60
|
+
" PLUGLAYER_API_KEY=your-token pluglayer-mcp\n\n"
|
|
61
|
+
"Get your token from: https://portal.pluglayer.com/settings",
|
|
62
|
+
file=sys.stderr,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
mcp.run(transport="streamable-http")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings(BaseSettings):
|
|
7
|
+
PLUGLAYER_API_BASE_URL: str = "https://api.pluglayer.com"
|
|
8
|
+
PLUGLAYER_API_URL: str = Field(default="") # legacy fallback
|
|
9
|
+
PLUGLAYER_API_KEY: str = "" # Set by user via env var
|
|
10
|
+
MCP_HOST: str = "0.0.0.0"
|
|
11
|
+
MCP_PORT: int = 8001
|
|
12
|
+
DEBUG: bool = False
|
|
13
|
+
|
|
14
|
+
model_config = SettingsConfigDict(env_file=".env")
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def resolved_api_base_url(self) -> str:
|
|
18
|
+
candidate = (self.PLUGLAYER_API_BASE_URL or "").strip() or (self.PLUGLAYER_API_URL or "").strip()
|
|
19
|
+
return candidate or "https://api.pluglayer.com"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@lru_cache()
|
|
23
|
+
def get_settings() -> Settings:
|
|
24
|
+
return Settings()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
settings = get_settings()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP tool registration modules."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Cicd Health MCP tools."""
|
|
2
|
+
|
|
3
|
+
from pluglayer_mcp.tools.shared import _client, _compact_error
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register_cicd_health_tools(mcp):
|
|
7
|
+
@mcp.tool()
|
|
8
|
+
async def generate_github_actions(project_id: str, deployment_id: str, github_org: str = "your-org") -> str:
|
|
9
|
+
"""Generate a GitHub Actions workflow YAML for PlugLayer CI/CD."""
|
|
10
|
+
try:
|
|
11
|
+
data = await _client().get("/v1/plugin/cicd/generate/github-actions", params={
|
|
12
|
+
"project_id": project_id,
|
|
13
|
+
"deployment_id": deployment_id,
|
|
14
|
+
"repo": github_org,
|
|
15
|
+
})
|
|
16
|
+
workflow = data.get("workflow_yaml", "")
|
|
17
|
+
filename = data.get("filename", ".github/workflows/deploy-pluglayer.yml")
|
|
18
|
+
return (
|
|
19
|
+
f"📋 **GitHub Actions Workflow**\n"
|
|
20
|
+
f"Save as: `{filename}`\n\n"
|
|
21
|
+
f"```yaml\n{workflow}\n```\n\n"
|
|
22
|
+
"Setup steps:\n"
|
|
23
|
+
"1. Create this file in your repo.\n"
|
|
24
|
+
"2. Add `PLUGLAYER_API_KEY` as a GitHub secret.\n"
|
|
25
|
+
"3. Push to main/master to trigger deploys."
|
|
26
|
+
)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return _compact_error("Error generating pipeline", e)
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
async def get_cluster_health() -> str:
|
|
32
|
+
"""Check PlugLayer API and k3s cluster health."""
|
|
33
|
+
try:
|
|
34
|
+
health = await _client().get("/v1/plugin/health")
|
|
35
|
+
k3s = await _client().get("/v1/plugin/health/k3s")
|
|
36
|
+
return (
|
|
37
|
+
"🩺 **PlugLayer Health**\n"
|
|
38
|
+
f"API: {health.get('api', 'unknown')}\n"
|
|
39
|
+
f"k3s: {'healthy' if k3s.get('ok') else 'unavailable'} — {k3s.get('message', '')}"
|
|
40
|
+
)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
return _compact_error("Error checking health", e)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Compute MCP tools."""
|
|
2
|
+
|
|
3
|
+
from pluglayer_mcp.tools.shared import _client, _compact_error, _fmt_compute, _fmt_node, _fmt_task_hint, _get_compute_summary
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register_compute_tools(mcp):
|
|
7
|
+
# ── Compute / nodes ───────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
async def get_compute_summary() -> str:
|
|
12
|
+
"""Show accessible account-level compute: personal SSH nodes plus shared PlugLayer nodes."""
|
|
13
|
+
try:
|
|
14
|
+
data = await _get_compute_summary()
|
|
15
|
+
counts = data.get("counts", {})
|
|
16
|
+
lines = [
|
|
17
|
+
"🧮 **Compute Summary**",
|
|
18
|
+
f"Can deploy: {'yes' if data.get('can_deploy') else 'no'}",
|
|
19
|
+
f"Message: {data.get('message')}",
|
|
20
|
+
f"Accessible nodes: {counts.get('accessible', 0)} total, {counts.get('ready', 0)} ready",
|
|
21
|
+
f"Personal nodes: {counts.get('personal', 0)} total, {counts.get('personal_ready', 0)} ready",
|
|
22
|
+
f"PlugLayer shared nodes: {counts.get('pluglayer', 0)} total, {counts.get('pluglayer_ready', 0)} ready",
|
|
23
|
+
f"Total ready compute: {_fmt_compute(data.get('total_compute'))}",
|
|
24
|
+
f"Personal ready compute: {_fmt_compute(data.get('personal_compute'))}",
|
|
25
|
+
f"Shared ready compute: {_fmt_compute(data.get('pluglayer_compute'))}",
|
|
26
|
+
]
|
|
27
|
+
purchase = data.get("purchase") or {}
|
|
28
|
+
if purchase.get("message"):
|
|
29
|
+
lines.append(f"Purchase: {purchase['message']}")
|
|
30
|
+
return "\n".join(lines)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
return _compact_error("Error loading compute summary", e)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
async def list_nodes(project_id: str = "") -> str:
|
|
37
|
+
"""
|
|
38
|
+
List compute nodes accessible to the authenticated user.
|
|
39
|
+
Compute is account-level; project_id is accepted only for backwards compatibility.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
params = {"project_id": project_id} if project_id else {}
|
|
43
|
+
data = await _client().get("/v1/plugin/compute/nodes", params=params)
|
|
44
|
+
nodes = data.get("nodes", [])
|
|
45
|
+
if not nodes:
|
|
46
|
+
return "No accessible compute nodes found. Add one with add_node_ssh(), or ask an admin to assign shared compute."
|
|
47
|
+
lines = ["Accessible compute nodes:\n"]
|
|
48
|
+
lines.extend(_fmt_node(n) for n in nodes)
|
|
49
|
+
return "\n".join(lines)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
return _compact_error("Error listing compute nodes", e)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
async def add_node_ssh(
|
|
56
|
+
project_id: str,
|
|
57
|
+
name: str,
|
|
58
|
+
host: str,
|
|
59
|
+
ssh_private_key: str,
|
|
60
|
+
user: str = "root",
|
|
61
|
+
port: int = 22,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Add a personal SSH node/VM as account-level compute.
|
|
65
|
+
|
|
66
|
+
project_id is optional/backwards-compatible setup context. The node belongs to the authenticated
|
|
67
|
+
user and can be used by all of that user's projects. Pass an empty string when no project context is needed.
|
|
68
|
+
"""
|
|
69
|
+
if not name or not host or not ssh_private_key:
|
|
70
|
+
return "Missing required fields: name, host, and ssh_private_key are required."
|
|
71
|
+
try:
|
|
72
|
+
payload = {
|
|
73
|
+
"name": name,
|
|
74
|
+
"provider": "ssh",
|
|
75
|
+
"ssh_host": host,
|
|
76
|
+
"ssh_port": port,
|
|
77
|
+
"ssh_user": user,
|
|
78
|
+
"ssh_private_key": ssh_private_key,
|
|
79
|
+
}
|
|
80
|
+
if project_id:
|
|
81
|
+
payload["project_id"] = project_id
|
|
82
|
+
data = await _client().post("/v1/plugin/compute/nodes", payload)
|
|
83
|
+
task_id = data.get("task_id")
|
|
84
|
+
node = data.get("node", {})
|
|
85
|
+
return (
|
|
86
|
+
f"✅ SSH node queued as personal account compute.\n"
|
|
87
|
+
f"Node: **{node.get('name', name)}** (id: `{node.get('id')}`)\n"
|
|
88
|
+
f"Task ID: `{task_id}`\n\n"
|
|
89
|
+
f"⚙️ Installing k3s agent and detecting CPU/RAM/storage/GPU. {_fmt_task_hint(task_id)}"
|
|
90
|
+
)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
return _compact_error("Failed to add SSH node", e)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Deployment/app MCP tools backed by PlugLayer v1 apps API."""
|
|
2
|
+
|
|
3
|
+
from pluglayer_mcp.tools.shared import _client, _compact_error, _fmt_task_hint, _get_compute_summary, _status_emoji
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register_deployment_tools(mcp):
|
|
7
|
+
@mcp.tool()
|
|
8
|
+
async def list_registries() -> str:
|
|
9
|
+
"""List registry destinations available to the current user."""
|
|
10
|
+
try:
|
|
11
|
+
data = await _client().get("/v1/plugin/registries")
|
|
12
|
+
registries = data.get("registries", [])
|
|
13
|
+
if not registries:
|
|
14
|
+
return "No registries are available to you yet. Ask an admin to configure a system or personal registry first."
|
|
15
|
+
lines = ["Available registries:\n"]
|
|
16
|
+
for registry in registries:
|
|
17
|
+
lines.append(
|
|
18
|
+
f"- **{registry.get('name')}** (`{registry.get('id')}`)\n"
|
|
19
|
+
f" Provider: {registry.get('provider')} | Scope: {registry.get('scope')} | Namespace: {registry.get('namespace')}\n"
|
|
20
|
+
f" Last test: {registry.get('last_test', {}).get('message') or 'unknown'}\n"
|
|
21
|
+
)
|
|
22
|
+
return "\n".join(lines)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
return _compact_error("Error listing registries", e)
|
|
25
|
+
|
|
26
|
+
@mcp.tool()
|
|
27
|
+
async def list_deployments(project_id: str = "") -> str:
|
|
28
|
+
"""List apps. Optionally filter by project_id."""
|
|
29
|
+
try:
|
|
30
|
+
params = {"project_id": project_id} if project_id else {}
|
|
31
|
+
data = await _client().get("/v1/plugin/apps", params=params)
|
|
32
|
+
apps = data.get("apps", [])
|
|
33
|
+
if not apps:
|
|
34
|
+
return "No apps found. Deploy one with deploy_image() or deploy_compose()."
|
|
35
|
+
lines = ["Your apps:\n"]
|
|
36
|
+
for app in apps:
|
|
37
|
+
status = app.get("status", "unknown")
|
|
38
|
+
image = app.get("image") or "compose"
|
|
39
|
+
tag = app.get("tag") or ""
|
|
40
|
+
lines.append(
|
|
41
|
+
f"{_status_emoji(status)} **{app.get('name')}** (id: `{app.get('id')}`)\n"
|
|
42
|
+
f" Status: {status} | Source: {app.get('source_type', 'image')} | Image: {image}:{tag}\n"
|
|
43
|
+
f" URL: {app.get('primary_url') or 'not yet available'}\n"
|
|
44
|
+
)
|
|
45
|
+
return "\n".join(lines)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return _compact_error("Error listing apps", e)
|
|
48
|
+
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
async def deploy_image(
|
|
51
|
+
project_id: str,
|
|
52
|
+
name: str,
|
|
53
|
+
image: str,
|
|
54
|
+
tag: str = "latest",
|
|
55
|
+
ports: list[int] | None = None,
|
|
56
|
+
env_vars: dict[str, str] | None = None,
|
|
57
|
+
replicas: int = 1,
|
|
58
|
+
route_slug: str = "",
|
|
59
|
+
cpu_limit: str = "500m",
|
|
60
|
+
memory_limit: str = "512Mi",
|
|
61
|
+
compute_placement: str = "auto",
|
|
62
|
+
push_to_pluglayer_registry: bool = True,
|
|
63
|
+
registry_id: str = "",
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Deploy a Docker image into a project. By default, mirror it into PlugLayer's managed registry first."""
|
|
66
|
+
try:
|
|
67
|
+
compute = await _get_compute_summary()
|
|
68
|
+
if not compute.get("can_deploy"):
|
|
69
|
+
return f"Cannot deploy yet: {compute.get('message')}"
|
|
70
|
+
payload = {
|
|
71
|
+
"name": name,
|
|
72
|
+
"route_slug": route_slug or None,
|
|
73
|
+
"compute_placement": compute_placement,
|
|
74
|
+
"registry_id": registry_id or None,
|
|
75
|
+
"source": {
|
|
76
|
+
"type": "image",
|
|
77
|
+
"image": image,
|
|
78
|
+
"tag": tag,
|
|
79
|
+
"ports": ports or [],
|
|
80
|
+
"env_vars": env_vars or {},
|
|
81
|
+
"replicas": replicas,
|
|
82
|
+
"cpu_limit": cpu_limit,
|
|
83
|
+
"memory_limit": memory_limit,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
endpoint = (
|
|
87
|
+
f"/v1/plugin/projects/{project_id}/apps/push-image"
|
|
88
|
+
if push_to_pluglayer_registry
|
|
89
|
+
else f"/v1/plugin/projects/{project_id}/apps"
|
|
90
|
+
)
|
|
91
|
+
data = await _client().post(endpoint, payload)
|
|
92
|
+
task_id = data.get("task_id")
|
|
93
|
+
app = data.get("app", {})
|
|
94
|
+
mirrored = data.get("mirrored_image")
|
|
95
|
+
lines = [f"🚀 App queued: **{name}** (id: `{app.get('id')}`). Task ID: `{task_id}`"]
|
|
96
|
+
if mirrored:
|
|
97
|
+
lines.append(f"Mirrored image: `{mirrored}`")
|
|
98
|
+
lines.append(_fmt_task_hint(task_id))
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return _compact_error("Deployment failed", e)
|
|
102
|
+
|
|
103
|
+
@mcp.tool()
|
|
104
|
+
async def deploy_compose(
|
|
105
|
+
project_id: str,
|
|
106
|
+
compose_yaml: str,
|
|
107
|
+
app_name: str = "",
|
|
108
|
+
route_slug: str = "",
|
|
109
|
+
compute_placement: str = "auto",
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Deploy docker-compose.yml into a project."""
|
|
112
|
+
try:
|
|
113
|
+
compute = await _get_compute_summary()
|
|
114
|
+
if not compute.get("can_deploy"):
|
|
115
|
+
return f"Cannot deploy yet: {compute.get('message')}"
|
|
116
|
+
data = await _client().post(f"/v1/plugin/projects/{project_id}/apps", {
|
|
117
|
+
"name": app_name or "compose-app",
|
|
118
|
+
"route_slug": route_slug or None,
|
|
119
|
+
"compute_placement": compute_placement,
|
|
120
|
+
"source": {"type": "compose", "compose_yaml": compose_yaml},
|
|
121
|
+
})
|
|
122
|
+
task_id = data.get("task_id")
|
|
123
|
+
app = data.get("app", {})
|
|
124
|
+
return f"🚀 Compose app queued (id: `{app.get('id')}`). Task ID: `{task_id}`\n{_fmt_task_hint(task_id)}"
|
|
125
|
+
except Exception as e:
|
|
126
|
+
return _compact_error("Compose deployment failed", e)
|
|
127
|
+
|
|
128
|
+
@mcp.tool()
|
|
129
|
+
async def get_deployment_status(deployment_id: str) -> str:
|
|
130
|
+
"""Get current app/deployment status and public URL."""
|
|
131
|
+
try:
|
|
132
|
+
data = await _client().get(f"/v1/plugin/apps/{deployment_id}/status")
|
|
133
|
+
app = data.get("app", {})
|
|
134
|
+
k8s = (data.get("runtime") or {}).get("k8s_status") or {}
|
|
135
|
+
status = app.get("status", "unknown")
|
|
136
|
+
result = f"{_status_emoji(status)} **App Status**\nStatus: {status}\nURL: {app.get('primary_url') or 'not yet available'}\n"
|
|
137
|
+
if k8s:
|
|
138
|
+
result += f"Replicas: {k8s.get('ready_replicas', 0)}/{k8s.get('replicas', 0)} ready\n"
|
|
139
|
+
return result
|
|
140
|
+
except Exception as e:
|
|
141
|
+
return _compact_error("Error getting app status", e)
|
|
142
|
+
|
|
143
|
+
@mcp.tool()
|
|
144
|
+
async def get_logs(deployment_id: str, lines: int = 100) -> str:
|
|
145
|
+
"""Get recent logs from an app."""
|
|
146
|
+
try:
|
|
147
|
+
data = await _client().get(f"/v1/plugin/apps/{deployment_id}/logs", params={"tail": lines})
|
|
148
|
+
return f"📋 **Logs** (last {lines} lines):\n\n```\n{data.get('logs', 'No logs available')}\n```"
|
|
149
|
+
except Exception as e:
|
|
150
|
+
return _compact_error("Error getting logs", e)
|
|
151
|
+
|
|
152
|
+
@mcp.tool()
|
|
153
|
+
async def redeploy(deployment_id: str) -> str:
|
|
154
|
+
"""Redeploy an existing app."""
|
|
155
|
+
try:
|
|
156
|
+
data = await _client().post(f"/v1/plugin/apps/{deployment_id}/redeploy")
|
|
157
|
+
task_id = data.get("task_id")
|
|
158
|
+
return f"🔄 Redeployment queued. Task ID: `{task_id}`\n{_fmt_task_hint(task_id)}"
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return _compact_error("Error triggering redeploy", e)
|
|
161
|
+
|
|
162
|
+
@mcp.tool()
|
|
163
|
+
async def rollback(deployment_id: str, revision: int | None = None) -> str:
|
|
164
|
+
"""Roll back an app to a previous revision."""
|
|
165
|
+
try:
|
|
166
|
+
params = {"revision": revision} if revision else {}
|
|
167
|
+
data = await _client().post(f"/v1/plugin/apps/{deployment_id}/rollback", params=params)
|
|
168
|
+
task_id = data.get("task_id")
|
|
169
|
+
return f"⏪ Rollback queued. Task ID: `{task_id}`\n{_fmt_task_hint(task_id)}"
|
|
170
|
+
except Exception as e:
|
|
171
|
+
return _compact_error("Error triggering rollback", e)
|
|
172
|
+
|
|
173
|
+
@mcp.tool()
|
|
174
|
+
async def delete_deployment(deployment_id: str) -> str:
|
|
175
|
+
"""DESTRUCTIVE: delete an app and remove it from k3s."""
|
|
176
|
+
try:
|
|
177
|
+
await _client().delete(f"/v1/plugin/apps/{deployment_id}")
|
|
178
|
+
return f"🗑️ App `{deployment_id}` deleted and removed from cluster."
|
|
179
|
+
except Exception as e:
|
|
180
|
+
return _compact_error("Error deleting app", e)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Custom-domain MCP tools backed by PlugLayer v1 domain APIs."""
|
|
2
|
+
|
|
3
|
+
from pluglayer_mcp.tools.shared import _client, _compact_error, _status_emoji
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _fmt_domain(domain: dict) -> str:
|
|
7
|
+
status = domain.get("status", "unknown")
|
|
8
|
+
app = domain.get("app_id") or "not attached"
|
|
9
|
+
return (
|
|
10
|
+
f"{_status_emoji(status)} **{domain.get('domain')}** (id: `{domain.get('id')}`)\n"
|
|
11
|
+
f" Status: {status} | Mode: {domain.get('mode')} | App: {app}\n"
|
|
12
|
+
f" TXT: {domain.get('verification', {}).get('name')} = {domain.get('verification', {}).get('value')}\n"
|
|
13
|
+
f" DNS: {domain.get('dns', {}).get('expected_type')} -> {domain.get('dns', {}).get('expected_value')}\n"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register_domain_tools(mcp):
|
|
18
|
+
@mcp.tool()
|
|
19
|
+
async def list_project_domains(project_id: str) -> str:
|
|
20
|
+
"""List custom domains for a project."""
|
|
21
|
+
try:
|
|
22
|
+
data = await _client().get(f"/v1/plugin/projects/{project_id}/domains")
|
|
23
|
+
domains = data.get("domains", [])
|
|
24
|
+
if not domains:
|
|
25
|
+
return "No custom domains are configured for this project."
|
|
26
|
+
return "Project domains:\n\n" + "\n".join(_fmt_domain(domain) for domain in domains)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return _compact_error("Error listing domains", e)
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
async def add_custom_domain(project_id: str, domain: str, mode: str = "single", app_id: str = "") -> str:
|
|
32
|
+
"""Add a custom domain. mode is 'single' or 'wildcard'."""
|
|
33
|
+
try:
|
|
34
|
+
data = await _client().post(f"/v1/plugin/projects/{project_id}/domains", {
|
|
35
|
+
"domain": domain,
|
|
36
|
+
"mode": mode,
|
|
37
|
+
"app_id": app_id or None,
|
|
38
|
+
})
|
|
39
|
+
item = data.get("domain", {})
|
|
40
|
+
return "Domain added. Create these DNS records, then run verify_custom_domain():\n\n" + _fmt_domain(item)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
return _compact_error("Error adding domain", e)
|
|
43
|
+
|
|
44
|
+
@mcp.tool()
|
|
45
|
+
async def verify_custom_domain(domain_id: str) -> str:
|
|
46
|
+
"""Verify TXT/CNAME DNS for a custom domain and activate it if attached."""
|
|
47
|
+
try:
|
|
48
|
+
data = await _client().post(f"/v1/plugin/domains/{domain_id}/verify")
|
|
49
|
+
return _fmt_domain(data.get("domain", {}))
|
|
50
|
+
except Exception as e:
|
|
51
|
+
return _compact_error("Error verifying domain", e)
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
async def attach_custom_domain(domain_id: str, app_id: str, make_primary: bool = False) -> str:
|
|
55
|
+
"""Attach a verified custom domain to an app."""
|
|
56
|
+
try:
|
|
57
|
+
data = await _client().post(f"/v1/plugin/domains/{domain_id}/attach", {
|
|
58
|
+
"app_id": app_id,
|
|
59
|
+
"make_primary": make_primary,
|
|
60
|
+
})
|
|
61
|
+
return _fmt_domain(data.get("domain", {}))
|
|
62
|
+
except Exception as e:
|
|
63
|
+
return _compact_error("Error attaching domain", e)
|
|
64
|
+
|
|
65
|
+
@mcp.tool()
|
|
66
|
+
async def detach_custom_domain(domain_id: str) -> str:
|
|
67
|
+
"""Detach a custom domain from its app while keeping verification."""
|
|
68
|
+
try:
|
|
69
|
+
data = await _client().post(f"/v1/plugin/domains/{domain_id}/detach")
|
|
70
|
+
return _fmt_domain(data.get("domain", {}))
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return _compact_error("Error detaching domain", e)
|
|
73
|
+
|
|
74
|
+
@mcp.tool()
|
|
75
|
+
async def remove_custom_domain(domain_id: str) -> str:
|
|
76
|
+
"""Remove a custom domain and its Traefik route."""
|
|
77
|
+
try:
|
|
78
|
+
await _client().delete(f"/v1/plugin/domains/{domain_id}")
|
|
79
|
+
return f"Custom domain `{domain_id}` removed."
|
|
80
|
+
except Exception as e:
|
|
81
|
+
return _compact_error("Error removing domain", e)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Identity Projects MCP tools."""
|
|
2
|
+
|
|
3
|
+
from pluglayer_mcp.tools.shared import _client, _compact_error, _fmt_task_hint, _status_emoji
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register_identity_project_tools(mcp):
|
|
7
|
+
# ── Identity / roles ─────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
async def get_current_user() -> str:
|
|
12
|
+
"""Show the authenticated PlugLayer user and roles from Authentik."""
|
|
13
|
+
try:
|
|
14
|
+
payload = await _client().get("/v1/plugin/me")
|
|
15
|
+
user = payload.get("user", payload)
|
|
16
|
+
roles = user.get("roles") or []
|
|
17
|
+
return (
|
|
18
|
+
"👤 **Current PlugLayer user**\n"
|
|
19
|
+
f"Email: {user.get('email')}\n"
|
|
20
|
+
f"Username: {user.get('username')}\n"
|
|
21
|
+
f"Roles: {', '.join(roles) if roles else 'none'}\n"
|
|
22
|
+
f"Superadmin: {'yes' if user.get('is_superuser') else 'no'}"
|
|
23
|
+
)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
return _compact_error("Error loading current user", e)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Projects ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
async def list_projects() -> str:
|
|
33
|
+
"""List authenticated user's projects. Normal users see their projects; admins may see admin data via admin tools."""
|
|
34
|
+
try:
|
|
35
|
+
data = await _client().get("/v1/plugin/projects")
|
|
36
|
+
projects = data.get("projects", [])
|
|
37
|
+
if not projects:
|
|
38
|
+
return "No projects found. Create one with create_project()."
|
|
39
|
+
lines = ["Your projects:\n"]
|
|
40
|
+
for p in projects:
|
|
41
|
+
status = p.get("status", "unknown")
|
|
42
|
+
lines.append(
|
|
43
|
+
f"{_status_emoji(status)} **{p.get('name')}** (id: `{p.get('id')}`)\n"
|
|
44
|
+
f" Status: {status} | Apps: {p.get('deployment_count', 0)}\n"
|
|
45
|
+
f" Namespace: `{p.get('namespace')}`\n"
|
|
46
|
+
f" URL pattern: {p.get('base_url', 'N/A')}\n"
|
|
47
|
+
)
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
return _compact_error("Error listing projects", e)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
async def create_project(name: str, description: str = "", domain_type: str = "pluglayer") -> str:
|
|
55
|
+
"""
|
|
56
|
+
Create a PlugLayer project namespace. Project creation only requires authentication.
|
|
57
|
+
Deployment still requires account-level compute; check get_compute_summary before deploying.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
data = await _client().post("/v1/plugin/projects", {
|
|
61
|
+
"name": name,
|
|
62
|
+
"description": description,
|
|
63
|
+
"domain_type": domain_type,
|
|
64
|
+
})
|
|
65
|
+
project = data.get("project", {})
|
|
66
|
+
task_id = data.get("task_id")
|
|
67
|
+
return (
|
|
68
|
+
f"✅ Project **{project.get('name', name)}** created.\n"
|
|
69
|
+
f"Project ID: `{project.get('id')}`\n"
|
|
70
|
+
f"Namespace: `{project.get('namespace')}`\n"
|
|
71
|
+
f"Task ID: `{task_id}`\n\n"
|
|
72
|
+
f"⏳ Setting up namespace. {_fmt_task_hint(task_id)}"
|
|
73
|
+
)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return _compact_error("Error creating project", e)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@mcp.tool()
|
|
79
|
+
async def get_project(project_id: str) -> str:
|
|
80
|
+
"""Get project details. Accessible to the project owner or a PlugLayer admin role."""
|
|
81
|
+
try:
|
|
82
|
+
p = await _client().get(f"/v1/plugin/projects/{project_id}")
|
|
83
|
+
p = p.get("project", p)
|
|
84
|
+
status = p.get("status", "unknown")
|
|
85
|
+
return (
|
|
86
|
+
f"{_status_emoji(status)} **{p.get('name')}**\n"
|
|
87
|
+
f"ID: `{p.get('id')}`\n"
|
|
88
|
+
f"Status: {status}\n"
|
|
89
|
+
f"Namespace: `{p.get('namespace')}`\n"
|
|
90
|
+
f"URL pattern: {p.get('base_url', 'N/A')}\n"
|
|
91
|
+
f"Apps: {p.get('deployment_count', 0)}\n"
|
|
92
|
+
"Compute is account-level; use get_compute_summary() for available capacity."
|
|
93
|
+
)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return _compact_error("Error getting project", e)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shared helpers for PlugLayer MCP tools."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pluglayer_mcp.client import PlugLayerClient
|
|
6
|
+
from pluglayer_mcp.settings import settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _client() -> PlugLayerClient:
|
|
10
|
+
return PlugLayerClient(api_key=settings.PLUGLAYER_API_KEY)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _status_emoji(status: str) -> str:
|
|
14
|
+
return {
|
|
15
|
+
"active": "✅", "ready": "✅", "running": "🕺", "completed": "✅",
|
|
16
|
+
"provisioning": "⏳", "pending": "😴", "queued": "⏳", "deploying": "🚀", "joining": "🔗",
|
|
17
|
+
"in_progress": "⚙️", "scaling": "⚙️",
|
|
18
|
+
"error": "❌", "failed": "💀", "crash_loop": "🥴", "offline": "💤",
|
|
19
|
+
"terminated": "🪦", "terminating": "👻", "cancelled": "🚫", "suspended": "⏸️",
|
|
20
|
+
"deleting": "🧹",
|
|
21
|
+
}.get(str(status or ""), "❓")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fmt_compute(compute: dict[str, Any] | None) -> str:
|
|
25
|
+
c = compute or {}
|
|
26
|
+
return (
|
|
27
|
+
f"{c.get('cpu_cores', 0)} CPU, "
|
|
28
|
+
f"{c.get('ram_gb', 0)}GB RAM, "
|
|
29
|
+
f"{c.get('storage_gb', 0)}GB disk, "
|
|
30
|
+
f"{c.get('gpu_gb', 0)}GB GPU"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fmt_node(node: dict[str, Any]) -> str:
|
|
35
|
+
status = node.get("status", "unknown")
|
|
36
|
+
owner = "shared PlugLayer" if node.get("is_shared") else "personal"
|
|
37
|
+
return (
|
|
38
|
+
f"{_status_emoji(status)} **{node.get('name', 'unnamed')}** (id: `{node.get('id')}`)\n"
|
|
39
|
+
f" Provider: {node.get('provider')} | Status: {status} | Scope: {owner}\n"
|
|
40
|
+
f" Compute: {_fmt_compute(node.get('hardware'))}\n"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fmt_task_hint(task_id: str | None) -> str:
|
|
45
|
+
return f"Poll: `get_task_status('{task_id}')`" if task_id else "No task id returned."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _compact_error(prefix: str, exc: Exception) -> str:
|
|
49
|
+
return f"{prefix}: {exc}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _get_compute_summary() -> dict[str, Any]:
|
|
53
|
+
return await _client().get("/v1/plugin/compute")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Tasks Admin MCP tools."""
|
|
2
|
+
|
|
3
|
+
from pluglayer_mcp.tools.shared import _client, _compact_error, _fmt_compute, _fmt_task_hint, _status_emoji
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register_task_admin_tools(mcp):
|
|
7
|
+
# ── Tasks ─────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
async def get_task_status(task_id: str) -> str:
|
|
12
|
+
"""Check async operation status, progress, result URL, or error."""
|
|
13
|
+
try:
|
|
14
|
+
data = await _client().get(f"/v1/plugin/tasks/{task_id}")
|
|
15
|
+
t = data.get("task", data)
|
|
16
|
+
status = t.get("status", "unknown")
|
|
17
|
+
progress = t.get("progress", {}) or {}
|
|
18
|
+
result = t.get("result") or {}
|
|
19
|
+
result_str = ""
|
|
20
|
+
if result.get("primary_url"):
|
|
21
|
+
result_str = f"\n🌐 App URL: {result['primary_url']}"
|
|
22
|
+
elif result.get("k3s_node_name"):
|
|
23
|
+
result_str = f"\n🖥️ Node joined as: {result['k3s_node_name']}"
|
|
24
|
+
elif result:
|
|
25
|
+
result_str = f"\nResult: {result}"
|
|
26
|
+
error = t.get("error_message") or t.get("error")
|
|
27
|
+
error_str = f"\n❌ Error: {error}" if error else ""
|
|
28
|
+
return (
|
|
29
|
+
f"{_status_emoji(status)} **Task {t.get('type', '')}**\n"
|
|
30
|
+
f"Status: {status}\n"
|
|
31
|
+
f"Progress: {round(progress.get('percentage', 0))}% — {progress.get('message', '')}\n"
|
|
32
|
+
f"Steps: {progress.get('step', 0)}/{progress.get('total_steps', 0)}"
|
|
33
|
+
f"{result_str}{error_str}"
|
|
34
|
+
)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
return _compact_error("Error getting task", e)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Admin tools ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@mcp.tool()
|
|
43
|
+
async def admin_get_overview() -> str:
|
|
44
|
+
"""Admin only: summarize platform tasks, capacity events, nodes, and compute defaults."""
|
|
45
|
+
try:
|
|
46
|
+
overview = await _client().get("/v1/plugin/admin/overview")
|
|
47
|
+
compute = await _client().get("/v1/plugin/admin/compute/settings")
|
|
48
|
+
stats = overview.get("stats", {})
|
|
49
|
+
return (
|
|
50
|
+
"🛡️ **Admin Overview**\n"
|
|
51
|
+
f"Projects: {stats.get('projects', 0)} | Deployments: {stats.get('deployments', 0)} | Nodes: {stats.get('nodes', 0)} | Tasks today: {stats.get('tasks_today', 0)}\n"
|
|
52
|
+
f"Registered nodes: {stats.get('nodes', 0)}\n"
|
|
53
|
+
f"Default quota: {_fmt_compute(compute.get('default_quota'))}\n"
|
|
54
|
+
)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
return _compact_error("Admin overview failed (requires pluglayer-admin or pluglayer-superadmin role)", e)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
async def admin_set_compute_defaults(
|
|
61
|
+
cpu_cores: float,
|
|
62
|
+
ram_gb: float,
|
|
63
|
+
storage_gb: int,
|
|
64
|
+
gpu_gb: float = 0,
|
|
65
|
+
allow_shared_compute: bool = True,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Admin only: update default compute quota metadata shown to new users."""
|
|
68
|
+
try:
|
|
69
|
+
await _client().put("/v1/plugin/admin/compute/settings", {
|
|
70
|
+
"allow_shared_compute": allow_shared_compute,
|
|
71
|
+
"default_quota": {
|
|
72
|
+
"cpu_cores": cpu_cores,
|
|
73
|
+
"ram_gb": ram_gb,
|
|
74
|
+
"storage_gb": storage_gb,
|
|
75
|
+
"gpu_gb": gpu_gb,
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
return f"✅ Default compute saved: {_fmt_compute({'cpu_cores': cpu_cores, 'ram_gb': ram_gb, 'storage_gb': storage_gb, 'gpu_gb': gpu_gb})}"
|
|
79
|
+
except Exception as e:
|
|
80
|
+
return _compact_error("Failed to update admin compute defaults", e)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
async def admin_set_node_shared(node_id: str, is_shared: bool = True) -> str:
|
|
85
|
+
"""Admin only: mark an existing node as shared PlugLayer compute or private."""
|
|
86
|
+
try:
|
|
87
|
+
data = await _client().patch(f"/v1/plugin/admin/nodes/{node_id}/sharing", {"is_shared": is_shared})
|
|
88
|
+
warning = f"\nWarning: {data['warning']}" if data.get("warning") else ""
|
|
89
|
+
return f"✅ Node `{node_id}` shared={data.get('is_shared', is_shared)}.{warning}"
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return _compact_error("Failed to update node sharing", e)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
async def admin_add_shared_ssh_node(
|
|
96
|
+
name: str,
|
|
97
|
+
host: str,
|
|
98
|
+
ssh_private_key: str,
|
|
99
|
+
user: str = "root",
|
|
100
|
+
port: int = 22,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Admin only: add an SSH node as PlugLayer-owned shared compute for all users."""
|
|
103
|
+
if not name or not host or not ssh_private_key:
|
|
104
|
+
return "Missing required fields: name, host, and ssh_private_key are required."
|
|
105
|
+
try:
|
|
106
|
+
data = await _client().post("/v1/plugin/admin/nodes/ssh", {
|
|
107
|
+
"name": name,
|
|
108
|
+
"provider": "ssh",
|
|
109
|
+
"ssh_host": host,
|
|
110
|
+
"ssh_port": port,
|
|
111
|
+
"ssh_user": user,
|
|
112
|
+
"ssh_private_key": ssh_private_key,
|
|
113
|
+
})
|
|
114
|
+
task_id = data.get("task_id")
|
|
115
|
+
node = data.get("node", {})
|
|
116
|
+
return (
|
|
117
|
+
f"✅ Shared PlugLayer SSH node queued.\n"
|
|
118
|
+
f"Node: **{node.get('name', name)}** (id: `{node.get('id')}`)\n"
|
|
119
|
+
f"Task ID: `{task_id}`\n{_fmt_task_hint(task_id)}"
|
|
120
|
+
)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
return _compact_error("Failed to add shared SSH node", e)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pluglayer-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PlugLayer MCP server — deploy and manage infrastructure via AI
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: deployment,infrastructure,kubernetes,mcp,pluglayer
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Requires-Dist: mcp[cli]>=1.3.0
|
|
10
|
+
Requires-Dist: pydantic-settings>=2.4.0
|
|
11
|
+
Requires-Dist: pydantic>=2.8.0
|
|
12
|
+
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# PlugLayer MCP Server
|
|
16
|
+
|
|
17
|
+
Deploy and manage your infrastructure through natural language with any MCP-compatible AI assistant.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
### Option 1: uvx (recommended — no install needed)
|
|
22
|
+
```bash
|
|
23
|
+
PLUGLAYER_API_KEY=your-pluglayer-api-token uvx pluglayer-mcp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Optional:
|
|
27
|
+
```bash
|
|
28
|
+
PLUGLAYER_API_BASE_URL=https://api.pluglayer.com
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Option 2: pip
|
|
32
|
+
```bash
|
|
33
|
+
pip install pluglayer-mcp
|
|
34
|
+
PLUGLAYER_API_KEY=your-pluglayer-api-token pluglayer-mcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
### Claude Desktop
|
|
40
|
+
Add to `~/.config/Claude/claude_desktop_config.json`:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"pluglayer": {
|
|
46
|
+
"command": "uvx",
|
|
47
|
+
"args": ["pluglayer-mcp"],
|
|
48
|
+
"env": {
|
|
49
|
+
"PLUGLAYER_API_KEY": "your-pluglayer-api-token",
|
|
50
|
+
"PLUGLAYER_API_BASE_URL": "https://api.pluglayer.com"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Cursor
|
|
58
|
+
Add to `~/.cursor/mcp.json`:
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"pluglayer": {
|
|
62
|
+
"command": "uvx",
|
|
63
|
+
"args": ["pluglayer-mcp"],
|
|
64
|
+
"env": {
|
|
65
|
+
"PLUGLAYER_API_KEY": "your-pluglayer-api-token",
|
|
66
|
+
"PLUGLAYER_API_BASE_URL": "https://api.pluglayer.com"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Remote HTTP (hosted)
|
|
73
|
+
The remote MCP server runs at `mcp.pluglayer.com`. Pass your token as:
|
|
74
|
+
```
|
|
75
|
+
Authorization: Bearer your-pluglayer-api-token
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### API base URL behavior
|
|
79
|
+
|
|
80
|
+
- `PLUGLAYER_API_BASE_URL` is the preferred environment variable for the backend API origin.
|
|
81
|
+
- If it is unset or empty, the MCP defaults to `https://api.pluglayer.com`.
|
|
82
|
+
- `PLUGLAYER_API_URL` is still accepted as a legacy fallback during migration.
|
|
83
|
+
|
|
84
|
+
## Available Tools
|
|
85
|
+
|
|
86
|
+
The MCP calls the PlugLayer FastAPI backend instead of re-implementing backend business logic. Auth, roles, ownership, compute guards, k3s orchestration, and admin checks remain in the backend. MCP and editor plugins should authenticate with a **PlugLayer API token** created in the PlugLayer Settings page, not the browser/session auth token.
|
|
87
|
+
|
|
88
|
+
Managed registries are configured by PlugLayer admins in the platform UI/API. When `deploy_image` uses mirroring, the backend picks a registry the current user is allowed to use and keeps Kubernetes pull secrets in sync automatically.
|
|
89
|
+
|
|
90
|
+
| Tool | Description |
|
|
91
|
+
|------|-------------|
|
|
92
|
+
| `get_current_user` | Show the Authentik-backed user and `roles` |
|
|
93
|
+
| `list_projects` | List authenticated user's projects |
|
|
94
|
+
| `create_project` | Create a new project namespace |
|
|
95
|
+
| `get_project` | Get project details |
|
|
96
|
+
| `get_compute_summary` | Show account-level personal + shared compute capacity |
|
|
97
|
+
| `list_nodes` | List accessible compute nodes |
|
|
98
|
+
| `add_node_ssh` | Add a personal SSH node usable by all of the user's projects |
|
|
99
|
+
| `list_registries` | List the registries currently available to the user |
|
|
100
|
+
| `deploy_image` | Mirror a Docker image into PlugLayer's managed Docker Hub namespace, then deploy it after backend compute checks |
|
|
101
|
+
| `deploy_compose` | Deploy from docker-compose.yml after backend compute checks |
|
|
102
|
+
| `list_deployments` | List running apps/deployments |
|
|
103
|
+
| `get_deployment_status` | Check app status and URL |
|
|
104
|
+
| `get_logs` | Get app logs |
|
|
105
|
+
| `redeploy` | Redeploy an app |
|
|
106
|
+
| `rollback` | Roll back to previous version |
|
|
107
|
+
| `delete_deployment` | Delete an app |
|
|
108
|
+
| `list_project_domains` | List custom domains for a project |
|
|
109
|
+
| `add_custom_domain` | Add a single or wildcard custom domain and return DNS records |
|
|
110
|
+
| `verify_custom_domain` | Verify TXT/CNAME DNS and activate if attached |
|
|
111
|
+
| `attach_custom_domain` | Attach a verified custom domain to an app |
|
|
112
|
+
| `detach_custom_domain` | Detach a domain while keeping verification |
|
|
113
|
+
| `remove_custom_domain` | Remove a domain and its route |
|
|
114
|
+
| `get_task_status` | Poll async operation progress |
|
|
115
|
+
| `admin_get_overview` | Admin-only platform summary |
|
|
116
|
+
| `admin_set_compute_defaults` | Admin-only default compute quota metadata |
|
|
117
|
+
| `admin_set_node_shared` | Admin-only mark node shared/private |
|
|
118
|
+
| `admin_add_shared_ssh_node` | Admin-only add shared PlugLayer SSH compute |
|
|
119
|
+
| `generate_github_actions` | Get CI/CD pipeline YAML |
|
|
120
|
+
| `get_cluster_health` | Check cluster status |
|
|
121
|
+
|
|
122
|
+
## Example Conversations
|
|
123
|
+
|
|
124
|
+
**Deploy your first app:**
|
|
125
|
+
> "I have a FastAPI app at `ghcr.io/myorg/api:latest` that runs on port 8000. Deploy it to my `production` project."
|
|
126
|
+
|
|
127
|
+
**Convert docker-compose:**
|
|
128
|
+
> "Here's my docker-compose.yml: [paste]. Deploy this to PlugLayer."
|
|
129
|
+
|
|
130
|
+
**Add a node:**
|
|
131
|
+
> "Add my server at 192.168.1.100 as personal compute. Here's my SSH key: [paste]"
|
|
132
|
+
|
|
133
|
+
**CI/CD setup:**
|
|
134
|
+
> "Generate a GitHub Actions workflow for my `api` deployment so it auto-deploys on push to main."
|
|
135
|
+
|
|
136
|
+
**Add a custom domain:**
|
|
137
|
+
> "Add `api.example.com` to my production project, show me the DNS records, then verify it and attach it to my API app."
|
|
138
|
+
|
|
139
|
+
## Getting Your API Key
|
|
140
|
+
|
|
141
|
+
1. Go to PlugLayer Settings
|
|
142
|
+
2. Create a **PlugLayer API token**
|
|
143
|
+
3. Copy it once and store it safely
|
|
144
|
+
4. Use it as `PLUGLAYER_API_KEY` for MCP, editor plugins, and CI/CD webhook deploys
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pluglayer_mcp/__init__.py,sha256=i-UC56Dp9hMpJ8y0JBpoaVdI_1NkS2FMfrKwe4QXURU,88
|
|
2
|
+
pluglayer_mcp/client.py,sha256=yGaxDoIbvmP4W60oKDERZhuU8pag1kS1NY8bL43yhvQ,2339
|
|
3
|
+
pluglayer_mcp/server.py,sha256=3_QJWNOBTZw6vKuedCX7CN8LZNfctX1Z-js1nCD_5zc,2679
|
|
4
|
+
pluglayer_mcp/settings.py,sha256=sQahmaggkZjlELkm9qo9t0_4tEdxnUKUgzLGp5yA25Q,794
|
|
5
|
+
pluglayer_mcp/tools/__init__.py,sha256=lZq6Dz0fyb8gnvUcEoKyjdYR11lCMLypgQFh3v0ykIA,37
|
|
6
|
+
pluglayer_mcp/tools/cicd_health.py,sha256=oGOVOzOURlmnx9JPUI0wh28UASVcGhVgXPjRbuE5SZc,1802
|
|
7
|
+
pluglayer_mcp/tools/compute.py,sha256=feY6EggpMOQThq_PHPyP-gAVQLiMt_qCaCVLoxzUj_g,4217
|
|
8
|
+
pluglayer_mcp/tools/deployments.py,sha256=8DKjx9l6wh0dibEhCrYb_0yn0mOoQqdR8H7HDf6ib54,8204
|
|
9
|
+
pluglayer_mcp/tools/domains.py,sha256=ugZ2JpfaGmrCbEzFUUk3UHWTWP3zrgGPO6r4zpwqR9Y,3643
|
|
10
|
+
pluglayer_mcp/tools/identity_projects.py,sha256=e2vPdbG13jiJKlAgVg2coq_EGJ7Cipa1MMLPAiOM3sA,4296
|
|
11
|
+
pluglayer_mcp/tools/shared.py,sha256=MnvmxKSiDzpo8cdydzXpQHT1_FT1H8XVTr34bEdEajk,1824
|
|
12
|
+
pluglayer_mcp/tools/tasks_admin.py,sha256=wm8a9wAE0RNfXNKQZhZY6NiZuUR573CxjRG0-J2Do1w,5618
|
|
13
|
+
pluglayer_mcp-0.1.0.dist-info/METADATA,sha256=DJsr9jhivB4PTSpOGs4-ZtRWwU4er6tHVFsCs5SDRLc,5284
|
|
14
|
+
pluglayer_mcp-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
pluglayer_mcp-0.1.0.dist-info/entry_points.txt,sha256=YpPpYKkwCdSPqJ2VH4I58_0YJQHUPXosNoc4YfFzaCQ,60
|
|
16
|
+
pluglayer_mcp-0.1.0.dist-info/RECORD,,
|