agent-deploy-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,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-deploy-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Agent Deploy — deploy and manage apps from any AI client
|
|
5
|
+
Project-URL: Homepage, https://agent-deploy.org
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
Requires-Dist: mcp>=1.6.0
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thin httpx wrapper for the Agent Deploy REST API.
|
|
3
|
+
Token and base URL are read from environment variables.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
BASE_URL = os.environ.get("AGENT_DEPLOY_URL", "https://agent-deploy.org").rstrip("/")
|
|
12
|
+
_TOKEN = os.environ.get("AGENT_DEPLOY_TOKEN", "")
|
|
13
|
+
|
|
14
|
+
TIMEOUT = 120.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _headers() -> dict:
|
|
18
|
+
return {"Authorization": f"Bearer {_TOKEN}"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _log(method: str, path: str, res: httpx.Response) -> None:
|
|
22
|
+
print(f"[agent-deploy-mcp] {method} {path} → {res.status_code}", file=sys.stderr, flush=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get(path: str, **kwargs) -> httpx.Response:
|
|
26
|
+
res = httpx.get(f"{BASE_URL}{path}", headers=_headers(), timeout=TIMEOUT, **kwargs)
|
|
27
|
+
_log("GET", path, res)
|
|
28
|
+
return res
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def post(path: str, **kwargs) -> httpx.Response:
|
|
32
|
+
res = httpx.post(f"{BASE_URL}{path}", headers=_headers(), timeout=TIMEOUT, **kwargs)
|
|
33
|
+
_log("POST", path, res)
|
|
34
|
+
return res
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def patch(path: str, **kwargs) -> httpx.Response:
|
|
38
|
+
res = httpx.patch(f"{BASE_URL}{path}", headers=_headers(), timeout=TIMEOUT, **kwargs)
|
|
39
|
+
_log("PATCH", path, res)
|
|
40
|
+
return res
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def delete(path: str, **kwargs) -> httpx.Response:
|
|
44
|
+
res = httpx.delete(f"{BASE_URL}{path}", headers=_headers(), timeout=TIMEOUT, **kwargs)
|
|
45
|
+
_log("DELETE", path, res)
|
|
46
|
+
return res
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Deploy MCP Server
|
|
3
|
+
|
|
4
|
+
Exposes the Agent Deploy platform as MCP tools so any compatible AI client
|
|
5
|
+
(Claude Code, Claude Desktop, Cursor, etc.) can deploy and manage apps via
|
|
6
|
+
natural language.
|
|
7
|
+
|
|
8
|
+
Environment variables:
|
|
9
|
+
AGENT_DEPLOY_URL Base URL of the Agent Deploy API (default: https://agent-deploy.org)
|
|
10
|
+
AGENT_DEPLOY_TOKEN Bearer token — copy from Settings → Auth token
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
|
|
18
|
+
from agent_deploy_mcp import client
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP("Agent Deploy")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Helpers
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def _ok(data) -> str:
|
|
28
|
+
return json.dumps(data, indent=2, default=str)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _err(res) -> str:
|
|
32
|
+
try:
|
|
33
|
+
detail = res.json().get("detail", res.text)
|
|
34
|
+
except Exception:
|
|
35
|
+
detail = res.text
|
|
36
|
+
return f"Error {res.status_code}: {detail}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Tools
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
@mcp.tool()
|
|
44
|
+
def list_apps() -> str:
|
|
45
|
+
"""List all apps belonging to the authenticated user, including live container statuses."""
|
|
46
|
+
res = client.get("/apps")
|
|
47
|
+
if not res.is_success:
|
|
48
|
+
return _err(res)
|
|
49
|
+
return _ok(res.json())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@mcp.tool()
|
|
53
|
+
def get_app(app_id: int) -> str:
|
|
54
|
+
"""Get full details for a single app including its Docker images.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
app_id: Numeric ID of the app (visible in list_apps).
|
|
58
|
+
"""
|
|
59
|
+
res = client.get(f"/apps/{app_id}")
|
|
60
|
+
if not res.is_success:
|
|
61
|
+
return _err(res)
|
|
62
|
+
return _ok(res.json())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@mcp.tool()
|
|
66
|
+
def create_app(name: str, slug: str, app_type: str) -> str:
|
|
67
|
+
"""Create a new app record. Does not deploy anything — use deploy_app afterwards.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: Human-readable display name, e.g. "My Portfolio".
|
|
71
|
+
slug: URL-safe identifier, e.g. "my-portfolio". Must be unique per user.
|
|
72
|
+
app_type: One of: web, api, fullstack, static.
|
|
73
|
+
"""
|
|
74
|
+
res = client.post("/apps", json={"name": name, "slug": slug, "type": app_type})
|
|
75
|
+
if not res.is_success:
|
|
76
|
+
return _err(res)
|
|
77
|
+
return _ok(res.json())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def deploy_app(external_id: str, project_path: str) -> str:
|
|
82
|
+
"""Deploy a project directory to an existing app.
|
|
83
|
+
|
|
84
|
+
BEFORE calling this tool you MUST ensure:
|
|
85
|
+
|
|
86
|
+
1. A Dockerfile or docker-compose.yml exists in the project root.
|
|
87
|
+
The Dockerfile must handle ALL build steps internally — do not pre-build
|
|
88
|
+
on the host. Use multi-stage builds where compilation is needed, e.g.:
|
|
89
|
+
|
|
90
|
+
TypeScript / React / Vite / Next.js:
|
|
91
|
+
FROM node:20-alpine AS build
|
|
92
|
+
WORKDIR /app
|
|
93
|
+
COPY package*.json ./
|
|
94
|
+
RUN npm ci
|
|
95
|
+
COPY . .
|
|
96
|
+
RUN npm run build
|
|
97
|
+
FROM nginx:alpine
|
|
98
|
+
COPY --from=build /app/dist /usr/share/nginx/html
|
|
99
|
+
EXPOSE 80
|
|
100
|
+
|
|
101
|
+
Python / FastAPI / Flask:
|
|
102
|
+
FROM python:3.12-slim
|
|
103
|
+
WORKDIR /app
|
|
104
|
+
COPY requirements.txt .
|
|
105
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
106
|
+
COPY . .
|
|
107
|
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
108
|
+
|
|
109
|
+
Full-stack (frontend + backend + db): use docker-compose.yml with a
|
|
110
|
+
separate service per tier. Every service that serves custom content
|
|
111
|
+
MUST use a 'build:' key pointing to a Dockerfile — never a bare
|
|
112
|
+
'image:' key alone. A bare 'image: nginx:alpine' with no 'build:'
|
|
113
|
+
will deploy an empty nginx container (default welcome page). Example:
|
|
114
|
+
|
|
115
|
+
services:
|
|
116
|
+
frontend:
|
|
117
|
+
build:
|
|
118
|
+
context: ./frontend
|
|
119
|
+
dockerfile: Dockerfile
|
|
120
|
+
backend:
|
|
121
|
+
build: ./backend
|
|
122
|
+
db:
|
|
123
|
+
image: postgres:16
|
|
124
|
+
environment:
|
|
125
|
+
POSTGRES_PASSWORD: secret
|
|
126
|
+
|
|
127
|
+
2. The Dockerfile output port matches the service — nginx uses 80, FastAPI
|
|
128
|
+
defaults to 8000. Expose the correct port with EXPOSE.
|
|
129
|
+
|
|
130
|
+
This tool returns a shell script — run it with the Bash tool. Do NOT zip
|
|
131
|
+
or upload the file yourself.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
external_id: The app's external_id from list_apps or create_app.
|
|
135
|
+
project_path: Absolute path to the project directory on the host.
|
|
136
|
+
"""
|
|
137
|
+
token = os.environ.get("AGENT_DEPLOY_TOKEN", "")
|
|
138
|
+
base_url = os.environ.get("AGENT_DEPLOY_URL", "https://agent-deploy.org").rstrip("/")
|
|
139
|
+
auth = f'-H "Authorization: Bearer {token}"' if token else ""
|
|
140
|
+
zip_path = f"/tmp/agent-deploy-{external_id}.zip"
|
|
141
|
+
|
|
142
|
+
script = f"""
|
|
143
|
+
# 1. Validate Docker configuration is present
|
|
144
|
+
if [ ! -f "{project_path}/Dockerfile" ] && [ ! -f "{project_path}/docker-compose.yml" ] && [ ! -f "{project_path}/docker-compose.yaml" ]; then
|
|
145
|
+
echo "ERROR: No Dockerfile or docker-compose.yml found in {project_path}."
|
|
146
|
+
echo "The Dockerfile must include all build steps (e.g. npm run build for JS/TS apps)."
|
|
147
|
+
exit 1
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
# 2. Warn if a JS/TS project has no build step in Dockerfile
|
|
151
|
+
if [ -f "{project_path}/package.json" ] && [ -f "{project_path}/Dockerfile" ]; then
|
|
152
|
+
if ! grep -q "npm run build\\|yarn build\\|pnpm build" "{project_path}/Dockerfile"; then
|
|
153
|
+
echo "WARNING: package.json detected but no build step found in Dockerfile."
|
|
154
|
+
echo "For TypeScript/JSX apps, add 'RUN npm run build' to your Dockerfile."
|
|
155
|
+
fi
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
# 3. Warn if a compose service uses a bare image without a build context
|
|
159
|
+
for compose_file in "{project_path}/docker-compose.yml" "{project_path}/docker-compose.yaml"; do
|
|
160
|
+
if [ -f "$compose_file" ]; then
|
|
161
|
+
if grep -q "image:\\s*nginx" "$compose_file" && ! grep -B5 "image:\\s*nginx" "$compose_file" | grep -q "build:"; then
|
|
162
|
+
echo "WARNING: docker-compose.yml has a service using 'image: nginx' without a 'build:' section."
|
|
163
|
+
echo "This will deploy an empty nginx container (default welcome page)."
|
|
164
|
+
echo "Add a 'build:' key pointing to a Dockerfile with a multi-stage build."
|
|
165
|
+
fi
|
|
166
|
+
fi
|
|
167
|
+
done
|
|
168
|
+
|
|
169
|
+
# 4. Zip the project (excluding build artefacts and hidden files)
|
|
170
|
+
cd "{project_path}" && zip -r "{zip_path}" . \\
|
|
171
|
+
--exclude "*.git*" \\
|
|
172
|
+
--exclude "node_modules/*" \\
|
|
173
|
+
--exclude "__pycache__/*" \\
|
|
174
|
+
--exclude "*.pyc" \\
|
|
175
|
+
--exclude ".env" \\
|
|
176
|
+
--exclude "*.DS_Store"
|
|
177
|
+
|
|
178
|
+
# 5. Upload and deploy
|
|
179
|
+
curl -s -X POST "{base_url}/apps/upload" \\
|
|
180
|
+
{auth} \\
|
|
181
|
+
-F "external_id={external_id}" \\
|
|
182
|
+
-F "file=@{zip_path}"
|
|
183
|
+
|
|
184
|
+
# 6. Clean up
|
|
185
|
+
rm -f "{zip_path}"
|
|
186
|
+
""".strip()
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
f"Run the following script using the Bash tool:\n\n{script}\n\n"
|
|
190
|
+
"Then call get_app_status to monitor the deployment."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@mcp.tool()
|
|
195
|
+
def get_app_status(app_id: int) -> str:
|
|
196
|
+
"""Get live container statuses for every service in an app.
|
|
197
|
+
|
|
198
|
+
Returns a mapping of image_type → {status, started_at}.
|
|
199
|
+
Status values: running, exited, restarting, paused, dead, not_started.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
app_id: Numeric ID of the app.
|
|
203
|
+
"""
|
|
204
|
+
res = client.get(f"/apps/{app_id}/status")
|
|
205
|
+
if not res.is_success:
|
|
206
|
+
return _err(res)
|
|
207
|
+
return _ok(res.json())
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@mcp.tool()
|
|
211
|
+
def get_logs(app_id: int, image_type: str, limit: int = 30) -> str:
|
|
212
|
+
"""Fetch the most recent log lines for a container.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
app_id: Numeric ID of the app.
|
|
216
|
+
image_type: Service name, e.g. frontend, backend, db, worker.
|
|
217
|
+
limit: Number of log lines to return (default 30, max 200).
|
|
218
|
+
"""
|
|
219
|
+
res = client.get(
|
|
220
|
+
f"/apps/{app_id}/images/{image_type}/logs",
|
|
221
|
+
params={"limit": limit},
|
|
222
|
+
)
|
|
223
|
+
if not res.is_success:
|
|
224
|
+
return _err(res)
|
|
225
|
+
entries = res.json()
|
|
226
|
+
if not entries:
|
|
227
|
+
return "No logs found for this container."
|
|
228
|
+
lines = [f"[{e['timestamp']}] {e['log_str']}" for e in entries]
|
|
229
|
+
return "\n".join(lines)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@mcp.tool()
|
|
233
|
+
def analyze_logs(app_id: int, image_type: str) -> str:
|
|
234
|
+
"""Get a plain-English AI summary of the last 20 log lines for a container.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
app_id: Numeric ID of the app.
|
|
238
|
+
image_type: Service name, e.g. frontend, backend, db, worker.
|
|
239
|
+
"""
|
|
240
|
+
res = client.get(f"/apps/{app_id}/images/{image_type}/logs/analyze")
|
|
241
|
+
if not res.is_success:
|
|
242
|
+
return _err(res)
|
|
243
|
+
analysis = res.json().get("analysis")
|
|
244
|
+
if not analysis:
|
|
245
|
+
return "Analysis unavailable."
|
|
246
|
+
return analysis
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@mcp.tool()
|
|
250
|
+
def restart_app(app_id: int) -> str:
|
|
251
|
+
"""Restart all containers for an app.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
app_id: Numeric ID of the app.
|
|
255
|
+
"""
|
|
256
|
+
res = client.post(f"/apps/{app_id}/restart")
|
|
257
|
+
if not res.is_success:
|
|
258
|
+
return _err(res)
|
|
259
|
+
return _ok(res.json())
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@mcp.tool()
|
|
263
|
+
def set_app_visibility(app_id: int, public: bool) -> str:
|
|
264
|
+
"""Make an app public or private.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
app_id: Numeric ID of the app.
|
|
268
|
+
public: True to make public, False to make private.
|
|
269
|
+
"""
|
|
270
|
+
res = client.patch(f"/apps/{app_id}", json={"is_public": public})
|
|
271
|
+
if not res.is_success:
|
|
272
|
+
return _err(res)
|
|
273
|
+
return f"App {app_id} is now {'public' if public else 'private'}."
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@mcp.tool()
|
|
277
|
+
def update_app(app_id: int, name: str | None = None, slug: str | None = None, app_type: str | None = None) -> str:
|
|
278
|
+
"""Update an app's name, slug, or type. Only provided fields are changed.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
app_id: Numeric ID of the app.
|
|
282
|
+
name: New display name (optional).
|
|
283
|
+
slug: New URL slug (optional). Must be unique per user.
|
|
284
|
+
app_type: New type: web, api, fullstack, static (optional).
|
|
285
|
+
"""
|
|
286
|
+
payload = {k: v for k, v in {"name": name, "slug": slug, "type": app_type}.items() if v is not None}
|
|
287
|
+
if not payload:
|
|
288
|
+
return "No fields provided — nothing to update."
|
|
289
|
+
res = client.patch(f"/apps/{app_id}", json=payload)
|
|
290
|
+
if not res.is_success:
|
|
291
|
+
return _err(res)
|
|
292
|
+
return _ok(res.json())
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@mcp.tool()
|
|
296
|
+
def delete_app(app_id: int) -> str:
|
|
297
|
+
"""Soft-delete an app. Containers are stopped and the app is removed from the dashboard.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
app_id: Numeric ID of the app to delete.
|
|
301
|
+
"""
|
|
302
|
+
res = client.delete(f"/apps/{app_id}")
|
|
303
|
+
if not res.is_success:
|
|
304
|
+
return _err(res)
|
|
305
|
+
return f"App {app_id} deleted successfully."
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Entry point
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def main() -> None:
|
|
313
|
+
mcp.run(transport="stdio")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agent-deploy-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for Agent Deploy — deploy and manage apps from any AI client"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp>=1.6.0",
|
|
12
|
+
"httpx>=0.27.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
agent-deploy-mcp = "agent_deploy_mcp.server:main"
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://agent-deploy.org"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["agent_deploy_mcp"]
|