deploykar-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.
- deploykar_mcp/__init__.py +3 -0
- deploykar_mcp/__main__.py +4 -0
- deploykar_mcp/cli.py +130 -0
- deploykar_mcp/client.py +106 -0
- deploykar_mcp/config.py +93 -0
- deploykar_mcp/login.py +144 -0
- deploykar_mcp/server.py +465 -0
- deploykar_mcp-0.1.0.dist-info/METADATA +193 -0
- deploykar_mcp-0.1.0.dist-info/RECORD +12 -0
- deploykar_mcp-0.1.0.dist-info/WHEEL +4 -0
- deploykar_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- deploykar_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
deploykar_mcp/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
deploykar-mcp run the MCP server over stdio (what your IDE launches)
|
|
4
|
+
deploykar-mcp login approve access in the browser and save an API token
|
|
5
|
+
deploykar-mcp logout revoke the saved token and delete it
|
|
6
|
+
deploykar-mcp whoami show the account and access level of the current token
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import getpass
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from deploykar_mcp import __version__
|
|
14
|
+
from deploykar_mcp.config import (
|
|
15
|
+
RUN_COMMAND,
|
|
16
|
+
UVX_ARGS,
|
|
17
|
+
dashboard_url,
|
|
18
|
+
delete_credentials,
|
|
19
|
+
load_settings,
|
|
20
|
+
save_credentials,
|
|
21
|
+
)
|
|
22
|
+
from deploykar_mcp.login import LoginError, browser_login, fetch_token_info, revoke_token
|
|
23
|
+
|
|
24
|
+
IDE_CONFIG = {"mcpServers": {"deploykar": {"command": "uvx", "args": UVX_ARGS}}}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _err(message: str) -> None:
|
|
28
|
+
print(message, file=sys.stderr)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _access(scopes: list[str] | None) -> str:
|
|
32
|
+
return "Deploy" if "deploy" in (scopes or []) else "Read-only"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_serve(_args) -> int:
|
|
36
|
+
import logging
|
|
37
|
+
|
|
38
|
+
from deploykar_mcp.server import create_server
|
|
39
|
+
|
|
40
|
+
# stdout carries the MCP protocol: never print to it here. Logs go to stderr.
|
|
41
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
42
|
+
create_server().run("stdio")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_login(args) -> int:
|
|
47
|
+
settings = load_settings()
|
|
48
|
+
api_url = (args.api_url or settings.api_url).rstrip("/")
|
|
49
|
+
auth_url = (args.auth_url or settings.auth_url).rstrip("/")
|
|
50
|
+
print(f"First, make sure you're signed in to DeployKar in your browser: {dashboard_url()}/login\n")
|
|
51
|
+
try:
|
|
52
|
+
if args.token or args.no_browser:
|
|
53
|
+
if args.no_browser and not args.token:
|
|
54
|
+
print(f"Create a token at:\n {auth_url}/api-tokens/authorize\n")
|
|
55
|
+
token = (args.token or getpass.getpass("Paste your DeployKar API token: ")).strip()
|
|
56
|
+
if not token.startswith("dk_live_"):
|
|
57
|
+
_err("That doesn't look like a DeployKar API token (it should start with dk_live_).")
|
|
58
|
+
return 1
|
|
59
|
+
info = fetch_token_info(api_url, token)
|
|
60
|
+
email, scopes = info["user"]["email"], (info.get("token") or {}).get("scopes")
|
|
61
|
+
else:
|
|
62
|
+
result = browser_login(auth_url)
|
|
63
|
+
token, email, scopes = result["token"], result["user"]["email"], result.get("scopes")
|
|
64
|
+
except LoginError as exc:
|
|
65
|
+
_err(f"Login failed: {exc}")
|
|
66
|
+
return 1
|
|
67
|
+
except KeyboardInterrupt:
|
|
68
|
+
_err("\nLogin cancelled.")
|
|
69
|
+
return 130
|
|
70
|
+
|
|
71
|
+
path = save_credentials(token, email, api_url, auth_url)
|
|
72
|
+
print(f"✓ Signed in to DeployKar as {email} ({_access(scopes)} access)")
|
|
73
|
+
print(f" Token saved to {path}")
|
|
74
|
+
if settings.token_source == "env":
|
|
75
|
+
print(" Note: DEPLOYKAR_API_TOKEN is set in your environment and takes precedence over the saved token.")
|
|
76
|
+
print("\nAdd DeployKar to your IDE's MCP settings:\n")
|
|
77
|
+
print(json.dumps(IDE_CONFIG, indent=2))
|
|
78
|
+
print("\nThen restart the MCP server in your IDE and ask: \"deploy my app to DeployKar\".")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_logout(_args) -> int:
|
|
83
|
+
settings = load_settings()
|
|
84
|
+
if settings.token_source != "file":
|
|
85
|
+
if settings.token_source == "env":
|
|
86
|
+
_err("The token comes from DEPLOYKAR_API_TOKEN; remove it from your environment/IDE config instead.")
|
|
87
|
+
else:
|
|
88
|
+
print("Not signed in.")
|
|
89
|
+
return 0
|
|
90
|
+
revoked = revoke_token(settings.auth_url, settings.token)
|
|
91
|
+
delete_credentials()
|
|
92
|
+
print("✓ Signed out. " + ("The token was revoked." if revoked else
|
|
93
|
+
"Could not revoke the token on the server; revoke it from the API tokens page if needed."))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_whoami(_args) -> int:
|
|
98
|
+
settings = load_settings()
|
|
99
|
+
if not settings.token:
|
|
100
|
+
_err(f"Not signed in. Run `{RUN_COMMAND} login`.")
|
|
101
|
+
return 1
|
|
102
|
+
try:
|
|
103
|
+
info = fetch_token_info(settings.api_url, settings.token)
|
|
104
|
+
except LoginError as exc:
|
|
105
|
+
_err(f"Token check failed: {exc}")
|
|
106
|
+
return 1
|
|
107
|
+
token = info.get("token") or {}
|
|
108
|
+
print(f"Account: {info['user']['email']}")
|
|
109
|
+
print(f"Token: {token.get('name')} ({token.get('prefix')}…), {_access(token.get('scopes'))} access")
|
|
110
|
+
print(f"Expires: {token.get('expires_at') or 'never'}")
|
|
111
|
+
print(f"Source: {'DEPLOYKAR_API_TOKEN' if settings.token_source == 'env' else 'saved credentials'}")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main(argv: list[str] | None = None) -> None:
|
|
116
|
+
parser = argparse.ArgumentParser(prog="deploykar-mcp", description="DeployKar MCP server and login helper.")
|
|
117
|
+
parser.add_argument("--version", action="version", version=f"deploykar-mcp {__version__}")
|
|
118
|
+
sub = parser.add_subparsers(dest="command")
|
|
119
|
+
sub.add_parser("serve", help="run the MCP server over stdio (default)")
|
|
120
|
+
login = sub.add_parser("login", help="approve access in the browser and save an API token")
|
|
121
|
+
login.add_argument("--no-browser", action="store_true", help="paste a token created in the dashboard instead")
|
|
122
|
+
login.add_argument("--token", help="save this token (from the API tokens page) without opening a browser")
|
|
123
|
+
login.add_argument("--api-url", help=argparse.SUPPRESS)
|
|
124
|
+
login.add_argument("--auth-url", help=argparse.SUPPRESS)
|
|
125
|
+
sub.add_parser("logout", help="revoke and delete the saved token")
|
|
126
|
+
sub.add_parser("whoami", help="show the current account and token")
|
|
127
|
+
args = parser.parse_args(argv)
|
|
128
|
+
|
|
129
|
+
handlers = {None: cmd_serve, "serve": cmd_serve, "login": cmd_login, "logout": cmd_logout, "whoami": cmd_whoami}
|
|
130
|
+
sys.exit(handlers[args.command](args))
|
deploykar_mcp/client.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Thin async client for the DeployKar deploy-api, authenticated with a personal API token."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import anyio
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from deploykar_mcp import __version__
|
|
8
|
+
from deploykar_mcp.config import RUN_COMMAND
|
|
9
|
+
|
|
10
|
+
LOGIN_HINT = f"Run `{RUN_COMMAND} login` (or set DEPLOYKAR_API_TOKEN) and restart the DeployKar MCP server in your IDE."
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeployKarError(Exception):
|
|
14
|
+
def __init__(self, status: int, code: str | None, message: str):
|
|
15
|
+
self.status = status
|
|
16
|
+
self.code = code
|
|
17
|
+
self.message = message
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
|
|
20
|
+
def user_message(self) -> str:
|
|
21
|
+
"""A message the IDE's assistant can relay and act on."""
|
|
22
|
+
if self.status == 401:
|
|
23
|
+
return f"DeployKar rejected the API token ({self.message}) {LOGIN_HINT}"
|
|
24
|
+
if self.status == 403 and self.code == "ERR_403_TOK_02":
|
|
25
|
+
return (
|
|
26
|
+
"This DeployKar API token is read-only. Create a token with Deploy access "
|
|
27
|
+
f"(`{RUN_COMMAND} login`) to deploy or change settings."
|
|
28
|
+
)
|
|
29
|
+
if self.status == 403 and self.code in ("ERR_403_TOK_01", "ERR_403_TOK_03"):
|
|
30
|
+
return f"{self.message} (Not available through the MCP server.)"
|
|
31
|
+
if self.status == 429:
|
|
32
|
+
return f"DeployKar rate limit reached: {self.message}"
|
|
33
|
+
if self.status >= 500:
|
|
34
|
+
return f"DeployKar had an internal error ({self.status}). Try again shortly."
|
|
35
|
+
return self.message
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _error_from_response(response: httpx.Response) -> DeployKarError:
|
|
39
|
+
code, message = None, None
|
|
40
|
+
try:
|
|
41
|
+
body = response.json()
|
|
42
|
+
if isinstance(body, dict):
|
|
43
|
+
code = body.get("code")
|
|
44
|
+
message = body.get("message") or (body.get("detail") if isinstance(body.get("detail"), str) else None)
|
|
45
|
+
except ValueError:
|
|
46
|
+
pass
|
|
47
|
+
return DeployKarError(response.status_code, code, message or f"HTTP {response.status_code}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DeployKarClient:
|
|
51
|
+
def __init__(self, api_url: str, token: str, transport: httpx.AsyncBaseTransport | None = None, timeout: float = 30.0):
|
|
52
|
+
self.api_url = api_url.rstrip("/")
|
|
53
|
+
self._http = httpx.AsyncClient(
|
|
54
|
+
base_url=self.api_url,
|
|
55
|
+
headers={"Authorization": f"Bearer {token}", "User-Agent": f"deploykar-mcp/{__version__}"},
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
transport=transport,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def aclose(self) -> None:
|
|
61
|
+
await self._http.aclose()
|
|
62
|
+
|
|
63
|
+
async def request(self, method: str, path: str, *, params: dict | None = None, json: Any = None) -> Any:
|
|
64
|
+
try:
|
|
65
|
+
response = await self._http.request(method, path, params=params, json=json)
|
|
66
|
+
except httpx.HTTPError as exc:
|
|
67
|
+
raise DeployKarError(0, None, f"Could not reach DeployKar at {self.api_url}: {exc}") from exc
|
|
68
|
+
if response.status_code >= 400:
|
|
69
|
+
raise _error_from_response(response)
|
|
70
|
+
if not response.content:
|
|
71
|
+
return None
|
|
72
|
+
return response.json()
|
|
73
|
+
|
|
74
|
+
async def get(self, path: str, **kwargs) -> Any:
|
|
75
|
+
return await self.request("GET", path, **kwargs)
|
|
76
|
+
|
|
77
|
+
async def post(self, path: str, **kwargs) -> Any:
|
|
78
|
+
return await self.request("POST", path, **kwargs)
|
|
79
|
+
|
|
80
|
+
async def read_event_stream(self, path: str, *, max_seconds: float, max_events: int = 5000) -> tuple[list[str], bool]:
|
|
81
|
+
"""Collect `data:` events from a server-sent-events endpoint.
|
|
82
|
+
|
|
83
|
+
Returns (events, finished): finished is True when the server sent [DONE] (the stream
|
|
84
|
+
ended on its own) and False when we stopped after max_seconds.
|
|
85
|
+
"""
|
|
86
|
+
events: list[str] = []
|
|
87
|
+
finished = False
|
|
88
|
+
with anyio.move_on_after(max_seconds):
|
|
89
|
+
try:
|
|
90
|
+
async with self._http.stream("GET", path, timeout=httpx.Timeout(30.0, read=max_seconds + 30)) as response:
|
|
91
|
+
if response.status_code >= 400:
|
|
92
|
+
await response.aread()
|
|
93
|
+
raise _error_from_response(response)
|
|
94
|
+
async for line in response.aiter_lines():
|
|
95
|
+
if not line.startswith("data:"):
|
|
96
|
+
continue
|
|
97
|
+
data = line[5:].strip()
|
|
98
|
+
if data == "[DONE]":
|
|
99
|
+
finished = True
|
|
100
|
+
break
|
|
101
|
+
events.append(data)
|
|
102
|
+
if len(events) >= max_events:
|
|
103
|
+
break
|
|
104
|
+
except httpx.HTTPError as exc:
|
|
105
|
+
raise DeployKarError(0, None, f"Could not reach DeployKar at {self.api_url}: {exc}") from exc
|
|
106
|
+
return events, finished
|
deploykar_mcp/config.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Where to reach DeployKar and which API token to use.
|
|
2
|
+
|
|
3
|
+
Order of precedence for the token: DEPLOYKAR_API_TOKEN, then the credentials file
|
|
4
|
+
written by `deploykar-mcp login`.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Current DeployKar environment. Switch all three to deploykar.com when it goes live
|
|
13
|
+
# (in a new release; saved logins follow the new defaults, see save_credentials).
|
|
14
|
+
DEFAULT_API_URL = "https://dev-32786578325.deploykar.com/api/v1"
|
|
15
|
+
DEFAULT_AUTH_URL = "https://dev-32786578325.deploykar.com/auth/api/v1"
|
|
16
|
+
DEFAULT_DASHBOARD_URL = "http://187.127.179.204:7100"
|
|
17
|
+
|
|
18
|
+
# How users run this package from PyPI (shown in hints and in the IDE config that `login` prints).
|
|
19
|
+
RUN_COMMAND = "uvx deploykar-mcp"
|
|
20
|
+
UVX_ARGS = ["deploykar-mcp@latest"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Settings:
|
|
25
|
+
api_url: str
|
|
26
|
+
auth_url: str
|
|
27
|
+
token: str | None
|
|
28
|
+
token_source: str | None # "env" | "file" | None
|
|
29
|
+
email: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dashboard_url() -> str:
|
|
33
|
+
"""The DeployKar website where users sign in (the API lives at DEFAULT_API_URL)."""
|
|
34
|
+
return (os.environ.get("DEPLOYKAR_DASHBOARD_URL") or DEFAULT_DASHBOARD_URL).rstrip("/")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def config_dir() -> Path:
|
|
38
|
+
if sys.platform == "win32" and os.environ.get("APPDATA"):
|
|
39
|
+
return Path(os.environ["APPDATA"]) / "deploykar"
|
|
40
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
41
|
+
return Path(base) / "deploykar"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def credentials_path() -> Path:
|
|
45
|
+
return config_dir() / "credentials.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_credentials() -> dict:
|
|
49
|
+
try:
|
|
50
|
+
return json.loads(credentials_path().read_text(encoding="utf-8"))
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_settings() -> Settings:
|
|
56
|
+
saved = _read_credentials()
|
|
57
|
+
api_url = (os.environ.get("DEPLOYKAR_API_URL") or saved.get("api_url") or DEFAULT_API_URL).rstrip("/")
|
|
58
|
+
auth_url = (os.environ.get("DEPLOYKAR_AUTH_URL") or saved.get("auth_url") or DEFAULT_AUTH_URL).rstrip("/")
|
|
59
|
+
env_token = (os.environ.get("DEPLOYKAR_API_TOKEN") or "").strip()
|
|
60
|
+
if env_token:
|
|
61
|
+
return Settings(api_url, auth_url, env_token, "env")
|
|
62
|
+
if saved.get("token"):
|
|
63
|
+
return Settings(api_url, auth_url, saved["token"], "file", saved.get("email"))
|
|
64
|
+
return Settings(api_url, auth_url, None, None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_credentials(token: str, email: str | None, api_url: str, auth_url: str) -> Path:
|
|
68
|
+
path = credentials_path()
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
data = {"token": token, "email": email}
|
|
71
|
+
# Remember the addresses only when they were customised, so that a later release can
|
|
72
|
+
# move everyone to new default addresses (e.g. deploykar.com) without a new login.
|
|
73
|
+
if api_url.rstrip("/") != DEFAULT_API_URL:
|
|
74
|
+
data["api_url"] = api_url
|
|
75
|
+
if auth_url.rstrip("/") != DEFAULT_AUTH_URL:
|
|
76
|
+
data["auth_url"] = auth_url
|
|
77
|
+
# Create with owner-only permissions before writing the secret.
|
|
78
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
79
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
80
|
+
json.dump(data, f, indent=2)
|
|
81
|
+
try:
|
|
82
|
+
os.chmod(path, 0o600)
|
|
83
|
+
except OSError:
|
|
84
|
+
pass
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def delete_credentials() -> bool:
|
|
89
|
+
try:
|
|
90
|
+
credentials_path().unlink()
|
|
91
|
+
return True
|
|
92
|
+
except FileNotFoundError:
|
|
93
|
+
return False
|
deploykar_mcp/login.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""`deploykar-mcp login`: get an API token by approving access in the browser.
|
|
2
|
+
|
|
3
|
+
1. Start a one-shot HTTP listener on 127.0.0.1 (random port).
|
|
4
|
+
2. Open DeployKar's approval page with that port, a random state and a PKCE challenge.
|
|
5
|
+
3. After the user approves, DeployKar redirects the browser to the listener with a short-lived code.
|
|
6
|
+
4. Exchange the code + PKCE verifier for the token and save it (owner-only file permissions).
|
|
7
|
+
"""
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import html
|
|
11
|
+
import secrets
|
|
12
|
+
import socket
|
|
13
|
+
import threading
|
|
14
|
+
import webbrowser
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
17
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from deploykar_mcp import __version__
|
|
22
|
+
|
|
23
|
+
LOGIN_TIMEOUT_SECONDS = 300
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LoginError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
31
|
+
verifier = secrets.token_urlsafe(64)[:96]
|
|
32
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
|
|
33
|
+
return verifier, challenge
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _result_page(title: str, message: str) -> bytes:
|
|
37
|
+
return (
|
|
38
|
+
"<!doctype html><html><head><meta charset='utf-8'><title>DeployKar</title>"
|
|
39
|
+
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
40
|
+
"<style>body{font:16px/1.5 system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 16px;"
|
|
41
|
+
"color:#17171c;background:#f6f6f8}@media(prefers-color-scheme:dark){body{color:#ececf1;background:#0f1015}}</style>"
|
|
42
|
+
f"</head><body><h1>{html.escape(title)}</h1><p>{html.escape(message)}</p></body></html>"
|
|
43
|
+
).encode("utf-8")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _wait_for_callback(expected_state: str, on_ready: Callable[[int], None]) -> dict[str, str]:
|
|
47
|
+
result: dict[str, str] = {}
|
|
48
|
+
done = threading.Event()
|
|
49
|
+
|
|
50
|
+
class Handler(BaseHTTPRequestHandler):
|
|
51
|
+
def do_GET(self): # noqa: N802
|
|
52
|
+
parsed = urlparse(self.path)
|
|
53
|
+
params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
|
54
|
+
if parsed.path != "/callback" or params.get("state") != expected_state or done.is_set():
|
|
55
|
+
self.send_response(400)
|
|
56
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
57
|
+
self.end_headers()
|
|
58
|
+
self.wfile.write(_result_page("Invalid request", "This sign-in link is not valid. Run the login command again."))
|
|
59
|
+
return
|
|
60
|
+
result.update(params)
|
|
61
|
+
ok = "code" in params
|
|
62
|
+
self.send_response(200)
|
|
63
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
64
|
+
self.send_header("Cache-Control", "no-store")
|
|
65
|
+
self.end_headers()
|
|
66
|
+
if ok:
|
|
67
|
+
self.wfile.write(_result_page("Signed in to DeployKar", "You can close this tab and return to your terminal."))
|
|
68
|
+
else:
|
|
69
|
+
self.wfile.write(_result_page("Sign-in cancelled", "No token was created. You can close this tab."))
|
|
70
|
+
done.set()
|
|
71
|
+
|
|
72
|
+
def log_message(self, *args): # keep the terminal quiet
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
76
|
+
httpd.timeout = 1
|
|
77
|
+
port = httpd.server_address[1]
|
|
78
|
+
thread = threading.Thread(target=lambda: httpd.serve_forever(poll_interval=0.2), daemon=True)
|
|
79
|
+
thread.start()
|
|
80
|
+
try:
|
|
81
|
+
on_ready(port)
|
|
82
|
+
if not done.wait(LOGIN_TIMEOUT_SECONDS):
|
|
83
|
+
raise LoginError("Timed out waiting for approval in the browser.")
|
|
84
|
+
finally:
|
|
85
|
+
httpd.shutdown()
|
|
86
|
+
httpd.server_close()
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def browser_login(auth_url: str, open_browser: Callable[[str], bool] | None = None,
|
|
91
|
+
echo: Callable[[str], None] = print) -> dict:
|
|
92
|
+
"""Run the browser approval flow and return the exchange response ({token, user, ...})."""
|
|
93
|
+
open_browser = open_browser or webbrowser.open
|
|
94
|
+
verifier, challenge = _pkce_pair()
|
|
95
|
+
state = secrets.token_urlsafe(24)
|
|
96
|
+
client_name = f"DeployKar MCP on {socket.gethostname()}"[:100]
|
|
97
|
+
|
|
98
|
+
def on_ready(port: int) -> None:
|
|
99
|
+
url = f"{auth_url}/api-tokens/authorize?" + urlencode({
|
|
100
|
+
"callback_port": port, "state": state, "code_challenge": challenge, "client_name": client_name,
|
|
101
|
+
})
|
|
102
|
+
echo("Opening your browser to approve access to DeployKar...")
|
|
103
|
+
echo(f"If it doesn't open, visit:\n {url}\n")
|
|
104
|
+
try:
|
|
105
|
+
open_browser(url)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
params = _wait_for_callback(state, on_ready)
|
|
110
|
+
if params.get("error"):
|
|
111
|
+
raise LoginError("Access was not approved in the browser.")
|
|
112
|
+
response = httpx.post(
|
|
113
|
+
f"{auth_url}/api-tokens/exchange",
|
|
114
|
+
json={"code": params["code"], "code_verifier": verifier},
|
|
115
|
+
headers={"User-Agent": f"deploykar-mcp/{__version__}"},
|
|
116
|
+
timeout=30,
|
|
117
|
+
)
|
|
118
|
+
if response.status_code != 200:
|
|
119
|
+
try:
|
|
120
|
+
message = response.json().get("message")
|
|
121
|
+
except ValueError:
|
|
122
|
+
message = None
|
|
123
|
+
raise LoginError(message or f"Token exchange failed (HTTP {response.status_code}).")
|
|
124
|
+
return response.json()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fetch_token_info(api_url: str, token: str) -> dict:
|
|
128
|
+
response = httpx.get(f"{api_url}/token-info", headers={"Authorization": f"Bearer {token}",
|
|
129
|
+
"User-Agent": f"deploykar-mcp/{__version__}"}, timeout=30)
|
|
130
|
+
if response.status_code != 200:
|
|
131
|
+
try:
|
|
132
|
+
message = response.json().get("message")
|
|
133
|
+
except ValueError:
|
|
134
|
+
message = None
|
|
135
|
+
raise LoginError(message or f"DeployKar rejected the token (HTTP {response.status_code}).")
|
|
136
|
+
return response.json()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def revoke_token(auth_url: str, token: str) -> bool:
|
|
140
|
+
try:
|
|
141
|
+
response = httpx.post(f"{auth_url}/api-tokens/revoke-current", headers={"Authorization": f"Bearer {token}"}, timeout=30)
|
|
142
|
+
return response.status_code == 200
|
|
143
|
+
except httpx.HTTPError:
|
|
144
|
+
return False
|
deploykar_mcp/server.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""DeployKar MCP server: translates IDE tool calls into DeployKar API requests.
|
|
2
|
+
|
|
3
|
+
The IDE's own assistant does the reasoning; every tool here is a thin, bounded wrapper
|
|
4
|
+
around an existing deploy-api endpoint.
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
from mcp.server.mcpserver import Context, MCPServer
|
|
14
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
15
|
+
from mcp.types import ToolAnnotations
|
|
16
|
+
|
|
17
|
+
from deploykar_mcp import __version__
|
|
18
|
+
from deploykar_mcp.client import LOGIN_HINT, DeployKarClient, DeployKarError
|
|
19
|
+
from deploykar_mcp.config import load_settings
|
|
20
|
+
|
|
21
|
+
INSTRUCTIONS = """\
|
|
22
|
+
DeployKar builds and hosts apps straight from GitHub. It never uploads local files: whatever is
|
|
23
|
+
pushed to the project's branch on GitHub is what gets built and deployed.
|
|
24
|
+
|
|
25
|
+
Typical flow:
|
|
26
|
+
1. If unsure whether the user is set up, call check_setup (it also returns the GitHub App install link).
|
|
27
|
+
2. Find the project with list_projects(repo_url=<output of `git remote get-url origin`>).
|
|
28
|
+
3. Before deploying, make sure local changes are committed and pushed (`git status`, `git push`).
|
|
29
|
+
Ask the user before committing or pushing on their behalf.
|
|
30
|
+
4. Call deploy, then get_status(project_id, deployment_id, wait=true) and report the live URL.
|
|
31
|
+
5. If the project has auto-deploy enabled, a push already starts a deployment: do not call deploy;
|
|
32
|
+
call get_status(project_id, commit_sha=<the pushed commit>, wait=true) instead.
|
|
33
|
+
6. If a deployment fails, read get_logs(deployment_id=..., errors_only=true), explain the cause and
|
|
34
|
+
propose a fix. Ask the user before retrying more than once.
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
- Never repeat environment variable values back to the user. Confirm with the user before sending
|
|
38
|
+
values taken from a local .env file.
|
|
39
|
+
- Environment variable changes apply on the next deploy or restart (set_env_var(apply_now=true)
|
|
40
|
+
applies them immediately).
|
|
41
|
+
- Deleting projects or deployments, removing backends, and managing databases, domains or secret
|
|
42
|
+
groups are not available here: point the user to the DeployKar dashboard.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
TERMINAL_STATUSES = {"ACTIVE", "FAILED", "REMOVED", "ROLLBACK"}
|
|
46
|
+
DEPLOYABLE_TYPES = {"FRONTEND", "BACKEND"}
|
|
47
|
+
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
48
|
+
ERROR_LINE_RE = re.compile(r"error|err!|failed|failure|exception|traceback|fatal|cannot find|not found", re.IGNORECASE)
|
|
49
|
+
POLL_INTERVAL_SECONDS = 5.0
|
|
50
|
+
|
|
51
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=True)
|
|
52
|
+
|
|
53
|
+
ClientProvider = Callable[[], DeployKarClient]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ─── Helpers ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize_repo(url: str | None) -> str | None:
|
|
60
|
+
"""'git@github.com:Owner/Repo.git', 'https://github.com/owner/repo' -> 'owner/repo'."""
|
|
61
|
+
if not url:
|
|
62
|
+
return None
|
|
63
|
+
value = url.strip()
|
|
64
|
+
value = re.sub(r"^(git\+)?(ssh://)?git@github\.com[:/]", "https://github.com/", value)
|
|
65
|
+
value = re.sub(r"^(https?://)?(www\.)?github\.com/", "", value)
|
|
66
|
+
value = value.split("?")[0].split("#")[0].strip("/")
|
|
67
|
+
if value.endswith(".git"):
|
|
68
|
+
value = value[:-4]
|
|
69
|
+
parts = value.split("/")
|
|
70
|
+
if len(parts) < 2 or not parts[0] or not parts[1]:
|
|
71
|
+
return None
|
|
72
|
+
return f"{parts[0]}/{parts[1]}".lower()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def to_https_repo_url(url: str) -> str:
|
|
76
|
+
"""Any GitHub remote form -> 'https://github.com/Owner/Repo' (the form deploy-api accepts)."""
|
|
77
|
+
m = re.search(r"github\.com[:/]+([^/\s]+)/([^/?#\s]+)", url.strip())
|
|
78
|
+
if not m:
|
|
79
|
+
raise ToolError("repo_url must be a GitHub repository URL, e.g. https://github.com/owner/repo")
|
|
80
|
+
owner, repo = m.group(1), m.group(2)
|
|
81
|
+
if repo.endswith(".git"):
|
|
82
|
+
repo = repo[:-4]
|
|
83
|
+
return f"https://github.com/{owner}/{repo}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _project_summary(p: dict) -> dict:
|
|
87
|
+
return {
|
|
88
|
+
"id": p.get("id"),
|
|
89
|
+
"name": p.get("name"),
|
|
90
|
+
"type": (p.get("type") or "").lower(),
|
|
91
|
+
"repo_url": p.get("github_repo_url"),
|
|
92
|
+
"branch": p.get("github_branch"),
|
|
93
|
+
"latest_status": p.get("latest_deployment_status"),
|
|
94
|
+
"url": p.get("deployment_url"),
|
|
95
|
+
"auto_deploy": bool(p.get("auto_deploy_enabled")),
|
|
96
|
+
"env_changes_pending_restart": bool(p.get("secrets_stale")),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _deployment_summary(d: dict) -> dict:
|
|
101
|
+
return {
|
|
102
|
+
"id": d.get("id"),
|
|
103
|
+
"status": d.get("status"),
|
|
104
|
+
"url": d.get("deployment_url"),
|
|
105
|
+
"commit_sha": d.get("github_commit_sha"),
|
|
106
|
+
"created_at": d.get("created_at"),
|
|
107
|
+
"deployed_at": d.get("deployed_at"),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _format_log_entry(raw: str) -> tuple[str, str]:
|
|
112
|
+
"""'level|epoch_ms|message' -> ('error', '12:01:03 [error] message')."""
|
|
113
|
+
parts = raw.split("|", 2)
|
|
114
|
+
if len(parts) == 3 and parts[1].isdigit():
|
|
115
|
+
level, ts, message = parts
|
|
116
|
+
if level == "complete":
|
|
117
|
+
outcome, _, detail = message.partition("|")
|
|
118
|
+
level, message = ("success" if outcome == "success" else "error"), f"Deployment {outcome}: {detail}".strip()
|
|
119
|
+
clock = datetime.fromtimestamp(int(ts) / 1000, tz=timezone.utc).strftime("%H:%M:%S")
|
|
120
|
+
return level, f"{clock} [{level}] {message}"
|
|
121
|
+
return "info", raw
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_error(level: str, line: str) -> bool:
|
|
125
|
+
return level in ("error", "warn") or bool(ERROR_LINE_RE.search(line))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _api_error(exc: DeployKarError) -> ToolError:
|
|
129
|
+
return ToolError(exc.user_message())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _clamp(value: int | float, low: int | float, high: int | float):
|
|
133
|
+
return max(low, min(high, value))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ─── Server ──────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def default_client_provider() -> ClientProvider:
|
|
140
|
+
cache: dict[str, DeployKarClient] = {}
|
|
141
|
+
|
|
142
|
+
def provide() -> DeployKarClient:
|
|
143
|
+
settings = load_settings()
|
|
144
|
+
if not settings.token:
|
|
145
|
+
raise ToolError(f"Not signed in to DeployKar. {LOGIN_HINT}")
|
|
146
|
+
key = f"{settings.api_url}|{settings.token}"
|
|
147
|
+
if key not in cache:
|
|
148
|
+
cache.clear()
|
|
149
|
+
cache[key] = DeployKarClient(settings.api_url, settings.token)
|
|
150
|
+
return cache[key]
|
|
151
|
+
|
|
152
|
+
return provide
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def create_server(client_provider: ClientProvider | None = None) -> MCPServer:
|
|
156
|
+
get_client = client_provider or default_client_provider()
|
|
157
|
+
server = MCPServer("deploykar", instructions=INSTRUCTIONS, version=__version__, website_url="https://deploykar.com")
|
|
158
|
+
|
|
159
|
+
async def call(fn: Callable[[DeployKarClient], Awaitable[Any]]) -> Any:
|
|
160
|
+
try:
|
|
161
|
+
return await fn(get_client())
|
|
162
|
+
except DeployKarError as exc:
|
|
163
|
+
raise _api_error(exc) from exc
|
|
164
|
+
|
|
165
|
+
async def fetch_deployments(project_id: str) -> list[dict]:
|
|
166
|
+
return await call(lambda c: c.get(f"/projects/{project_id}/deployments", params={"fresh": "true"})) or []
|
|
167
|
+
|
|
168
|
+
async def read_build_logs(deployment_id: str, max_wait_seconds: float) -> tuple[list[tuple[str, str]], bool]:
|
|
169
|
+
events, finished = await call(lambda c: c.read_event_stream(
|
|
170
|
+
f"/deployments/{deployment_id}/logs", max_seconds=max_wait_seconds))
|
|
171
|
+
return [_format_log_entry(e) for e in events], finished
|
|
172
|
+
|
|
173
|
+
# ── Setup ──
|
|
174
|
+
|
|
175
|
+
@server.tool(annotations=READ_ONLY)
|
|
176
|
+
async def check_setup() -> dict[str, Any]:
|
|
177
|
+
"""Check the DeployKar connection: signed-in account, API token access level, and whether
|
|
178
|
+
GitHub is connected (DeployKar deploys from GitHub repositories). Returns an install link
|
|
179
|
+
if GitHub still needs to be connected."""
|
|
180
|
+
info = await call(lambda c: c.get("/token-info"))
|
|
181
|
+
token = info.get("token") or {}
|
|
182
|
+
result: dict[str, Any] = {
|
|
183
|
+
"account": info.get("user", {}).get("email"),
|
|
184
|
+
"plan": info.get("user", {}).get("tier"),
|
|
185
|
+
"token": {
|
|
186
|
+
"name": token.get("name"),
|
|
187
|
+
"access": "deploy" if "deploy" in (token.get("scopes") or []) else "read-only",
|
|
188
|
+
"expires_at": token.get("expires_at"),
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
try:
|
|
192
|
+
gh = await call(lambda c: c.get("/github/app/status"))
|
|
193
|
+
except ToolError as exc:
|
|
194
|
+
gh = {"connected": None, "error": str(exc)}
|
|
195
|
+
github = {"connected": gh.get("connected"), "username": gh.get("github_username")}
|
|
196
|
+
if gh.get("connected") is False:
|
|
197
|
+
try:
|
|
198
|
+
install = await call(lambda c: c.get("/github/app/install"))
|
|
199
|
+
github["install_url"] = install.get("install_url")
|
|
200
|
+
except ToolError:
|
|
201
|
+
pass
|
|
202
|
+
github["next_step"] = "Ask the user to open install_url in a browser and install the DeployKar GitHub App on their repositories."
|
|
203
|
+
result["github"] = github
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
# ── Projects ──
|
|
207
|
+
|
|
208
|
+
@server.tool(annotations=READ_ONLY)
|
|
209
|
+
async def list_projects(repo_url: str | None = None, team_id: str | None = None) -> dict[str, Any]:
|
|
210
|
+
"""List the user's DeployKar projects (frontends and backends) with their latest status and live URL.
|
|
211
|
+
|
|
212
|
+
Pass repo_url (e.g. the output of `git remote get-url origin`) to find the project for the
|
|
213
|
+
current repository. Pass team_id to list a team's projects instead of personal ones."""
|
|
214
|
+
params = {"team_id": team_id} if team_id else None
|
|
215
|
+
stack = await call(lambda c: c.get("/stack", params=params))
|
|
216
|
+
projects = [_project_summary(p) for p in stack.get("projects", []) if p.get("type") in DEPLOYABLE_TYPES]
|
|
217
|
+
if repo_url:
|
|
218
|
+
wanted = normalize_repo(repo_url)
|
|
219
|
+
if not wanted:
|
|
220
|
+
raise ToolError("repo_url must be a GitHub repository URL, e.g. https://github.com/owner/repo")
|
|
221
|
+
projects = [p for p in projects if normalize_repo(p["repo_url"]) == wanted]
|
|
222
|
+
if not projects:
|
|
223
|
+
return {
|
|
224
|
+
"projects": [],
|
|
225
|
+
"message": f"No DeployKar project uses {wanted} yet. Call deploy with repo_url and "
|
|
226
|
+
"project_type ('frontend' or 'backend') to create one.",
|
|
227
|
+
}
|
|
228
|
+
return {"projects": projects}
|
|
229
|
+
|
|
230
|
+
# ── Deploy ──
|
|
231
|
+
|
|
232
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False, open_world_hint=True))
|
|
233
|
+
async def deploy(
|
|
234
|
+
project_id: str | None = None,
|
|
235
|
+
repo_url: str | None = None,
|
|
236
|
+
project_type: Literal["frontend", "backend"] | None = None,
|
|
237
|
+
branch: str | None = None,
|
|
238
|
+
build_command: str | None = None,
|
|
239
|
+
output_dir: str | None = None,
|
|
240
|
+
port: int | None = None,
|
|
241
|
+
health_path: str | None = None,
|
|
242
|
+
enable_auto_deploy: bool | None = None,
|
|
243
|
+
) -> dict[str, Any]:
|
|
244
|
+
"""Deploy the latest pushed commit of a GitHub branch to DeployKar.
|
|
245
|
+
|
|
246
|
+
- Existing project: pass project_id (from list_projects).
|
|
247
|
+
- New project: pass repo_url and project_type ('frontend' for static sites/SPAs, 'backend'
|
|
248
|
+
for API servers); DeployKar creates the project.
|
|
249
|
+
DeployKar builds what is on GitHub, so push local commits first. Optional: branch,
|
|
250
|
+
build_command and output_dir (frontends), port and health_path (backends), and
|
|
251
|
+
enable_auto_deploy to redeploy automatically on every push to the branch.
|
|
252
|
+
Returns a deployment_id; then call get_status(..., wait=true)."""
|
|
253
|
+
if not project_id and not repo_url:
|
|
254
|
+
raise ToolError("Pass project_id for an existing project, or repo_url and project_type to create one.")
|
|
255
|
+
if not project_id and not project_type:
|
|
256
|
+
raise ToolError("project_type ('frontend' or 'backend') is required when creating a new project from repo_url.")
|
|
257
|
+
if port is not None and not 1 <= port <= 65535:
|
|
258
|
+
raise ToolError("port must be between 1 and 65535.")
|
|
259
|
+
|
|
260
|
+
body: dict[str, Any] = {}
|
|
261
|
+
if project_id:
|
|
262
|
+
body["project_id"] = project_id
|
|
263
|
+
if repo_url:
|
|
264
|
+
body["github_repo_url"] = to_https_repo_url(repo_url)
|
|
265
|
+
if project_type:
|
|
266
|
+
body["project_type"] = project_type.upper()
|
|
267
|
+
for key, value in (("github_branch", branch), ("build_command", build_command), ("output_dir", output_dir),
|
|
268
|
+
("port", port), ("health_path", health_path)):
|
|
269
|
+
if value is not None:
|
|
270
|
+
body[key] = value
|
|
271
|
+
if enable_auto_deploy is not None:
|
|
272
|
+
body["auto_deploy_enabled"] = enable_auto_deploy
|
|
273
|
+
if enable_auto_deploy and branch:
|
|
274
|
+
body["auto_deploy_branch"] = branch
|
|
275
|
+
|
|
276
|
+
result = await call(lambda c: c.post("/deploy", json=body))
|
|
277
|
+
return {
|
|
278
|
+
"deployment_id": result.get("deployment_id"),
|
|
279
|
+
"project_id": result.get("project_id"),
|
|
280
|
+
"status": result.get("status"),
|
|
281
|
+
"message": result.get("message"),
|
|
282
|
+
"next_step": "Call get_status with this project_id and deployment_id and wait=true to follow the build.",
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@server.tool(annotations=READ_ONLY)
|
|
286
|
+
async def get_status(
|
|
287
|
+
project_id: str,
|
|
288
|
+
deployment_id: str | None = None,
|
|
289
|
+
commit_sha: str | None = None,
|
|
290
|
+
wait: bool = False,
|
|
291
|
+
timeout_seconds: int = 600,
|
|
292
|
+
ctx: Context = None,
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
"""Get the status of a deployment (QUEUED, BUILDING, DEPLOYING, ACTIVE or FAILED) and its live URL.
|
|
295
|
+
|
|
296
|
+
Identify the deployment by deployment_id, or by commit_sha (useful after a git push when
|
|
297
|
+
auto-deploy is on); by default the project's latest deployment is used. With wait=true,
|
|
298
|
+
keeps checking until the deployment finishes or timeout_seconds pass (max 1800). If the
|
|
299
|
+
deployment failed, the result includes the error lines from the build log."""
|
|
300
|
+
timeout_seconds = int(_clamp(timeout_seconds, 10, 1800))
|
|
301
|
+
sha = (commit_sha or "").strip().lower() or None
|
|
302
|
+
if sha and not re.fullmatch(r"[0-9a-f]{7,40}", sha):
|
|
303
|
+
raise ToolError("commit_sha must be a git commit hash (7-40 hex characters).")
|
|
304
|
+
|
|
305
|
+
def pick(deployments: list[dict]) -> dict | None:
|
|
306
|
+
if deployment_id:
|
|
307
|
+
return next((d for d in deployments if d.get("id") == deployment_id), None)
|
|
308
|
+
if sha:
|
|
309
|
+
return next((d for d in deployments if (d.get("github_commit_sha") or "").lower().startswith(sha)), None)
|
|
310
|
+
return deployments[0] if deployments else None
|
|
311
|
+
|
|
312
|
+
started = time.monotonic()
|
|
313
|
+
timed_out = False
|
|
314
|
+
while True:
|
|
315
|
+
target = pick(await fetch_deployments(project_id))
|
|
316
|
+
if not wait or (target and target.get("status") in TERMINAL_STATUSES):
|
|
317
|
+
break
|
|
318
|
+
elapsed = time.monotonic() - started
|
|
319
|
+
if elapsed >= timeout_seconds:
|
|
320
|
+
timed_out = True
|
|
321
|
+
break
|
|
322
|
+
if ctx is not None:
|
|
323
|
+
label = target.get("status") if target else "waiting for the deployment to start"
|
|
324
|
+
await ctx.report_progress(elapsed, timeout_seconds, message=f"Deployment: {label}")
|
|
325
|
+
await anyio.sleep(POLL_INTERVAL_SECONDS)
|
|
326
|
+
|
|
327
|
+
if not target:
|
|
328
|
+
what = f"deployment {deployment_id}" if deployment_id else (f"commit {sha}" if sha else "any deployment")
|
|
329
|
+
hint = (" If auto-deploy is on it normally starts a few seconds after the push; call again with wait=true."
|
|
330
|
+
if sha else "")
|
|
331
|
+
return {"found": False, "message": f"No DeployKar deployment found for {what} in this project.{hint}",
|
|
332
|
+
"timed_out": timed_out}
|
|
333
|
+
|
|
334
|
+
status = target.get("status")
|
|
335
|
+
result: dict[str, Any] = {
|
|
336
|
+
"found": True,
|
|
337
|
+
"deployment": _deployment_summary(target),
|
|
338
|
+
"finished": status in TERMINAL_STATUSES,
|
|
339
|
+
"timed_out": timed_out,
|
|
340
|
+
}
|
|
341
|
+
if status == "ACTIVE":
|
|
342
|
+
result["live_url"] = target.get("deployment_url")
|
|
343
|
+
elif status == "FAILED":
|
|
344
|
+
entries, _ = await read_build_logs(target["id"], max_wait_seconds=8)
|
|
345
|
+
errors = [line for level, line in entries if _is_error(level, line)]
|
|
346
|
+
result["error_log"] = errors[-40:] or ["Build logs are no longer available (DeployKar keeps them for about an hour)."]
|
|
347
|
+
result["next_step"] = "Explain the error to the user and propose a fix; get_logs gives the full build log."
|
|
348
|
+
elif timed_out:
|
|
349
|
+
result["message"] = "Still in progress. Call get_status again with wait=true to keep following it."
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
@server.tool(annotations=READ_ONLY)
|
|
353
|
+
async def get_logs(
|
|
354
|
+
project_id: str | None = None,
|
|
355
|
+
deployment_id: str | None = None,
|
|
356
|
+
source: Literal["build", "runtime"] = "build",
|
|
357
|
+
tail: int = 150,
|
|
358
|
+
errors_only: bool = False,
|
|
359
|
+
max_wait_seconds: int = 10,
|
|
360
|
+
) -> dict[str, Any]:
|
|
361
|
+
"""Read logs.
|
|
362
|
+
|
|
363
|
+
- source='build' (default): the build/deploy log of deployment_id (or the project's latest
|
|
364
|
+
deployment). Build logs are kept for about an hour.
|
|
365
|
+
- source='runtime': recent live output of a running backend service (project_id required;
|
|
366
|
+
backends only).
|
|
367
|
+
Returns the last `tail` lines (max 1000); errors_only keeps error/warning lines. For a build
|
|
368
|
+
that is still running, returns what has been logged within max_wait_seconds (max 60)."""
|
|
369
|
+
tail = int(_clamp(tail, 1, 1000))
|
|
370
|
+
max_wait = float(_clamp(max_wait_seconds, 1, 60))
|
|
371
|
+
|
|
372
|
+
if source == "runtime":
|
|
373
|
+
if not project_id:
|
|
374
|
+
raise ToolError("project_id is required for runtime logs.")
|
|
375
|
+
events, _ = await call(lambda c: c.read_event_stream(
|
|
376
|
+
f"/projects/{project_id}/service-logs", max_seconds=min(max_wait, 15)))
|
|
377
|
+
entries = [_format_log_entry(e) for e in events]
|
|
378
|
+
deployment = None
|
|
379
|
+
else:
|
|
380
|
+
if not deployment_id:
|
|
381
|
+
if not project_id:
|
|
382
|
+
raise ToolError("Pass deployment_id, or project_id to read the latest deployment's build log.")
|
|
383
|
+
deployments = await fetch_deployments(project_id)
|
|
384
|
+
if not deployments:
|
|
385
|
+
return {"lines": [], "message": "This project has no deployments yet."}
|
|
386
|
+
deployment = deployments[0]
|
|
387
|
+
deployment_id = deployment["id"]
|
|
388
|
+
else:
|
|
389
|
+
deployment = None
|
|
390
|
+
entries, finished = await read_build_logs(deployment_id, max_wait)
|
|
391
|
+
if not entries and finished:
|
|
392
|
+
return {"deployment_id": deployment_id, "lines": [],
|
|
393
|
+
"message": "No build logs available. DeployKar keeps build logs for about an hour after a build."}
|
|
394
|
+
|
|
395
|
+
lines = [line for level, line in entries if not errors_only or _is_error(level, line)]
|
|
396
|
+
result: dict[str, Any] = {
|
|
397
|
+
"source": source,
|
|
398
|
+
"lines": lines[-tail:],
|
|
399
|
+
"total_lines": len(lines),
|
|
400
|
+
"truncated": len(lines) > tail,
|
|
401
|
+
}
|
|
402
|
+
if source == "build":
|
|
403
|
+
result["deployment_id"] = deployment_id
|
|
404
|
+
if deployment:
|
|
405
|
+
result["deployment_status"] = deployment.get("status")
|
|
406
|
+
return result
|
|
407
|
+
|
|
408
|
+
# ── Configuration ──
|
|
409
|
+
|
|
410
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True))
|
|
411
|
+
async def list_env_vars(project_id: str) -> dict[str, Any]:
|
|
412
|
+
"""List the names of a project's environment variables (values are never shown)."""
|
|
413
|
+
rows = await call(lambda c: c.get(f"/projects/{project_id}/env-vars")) or []
|
|
414
|
+
return {"variables": [{"key": r.get("key"), "secret": bool(r.get("is_secret"))} for r in rows]}
|
|
415
|
+
|
|
416
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=True, open_world_hint=True))
|
|
417
|
+
async def set_env_var(
|
|
418
|
+
project_id: str,
|
|
419
|
+
variables: dict[str, str],
|
|
420
|
+
secret: bool = True,
|
|
421
|
+
apply_now: bool = False,
|
|
422
|
+
) -> dict[str, Any]:
|
|
423
|
+
"""Set (create or overwrite) environment variables on a project, e.g. {"DATABASE_URL": "postgres://..."}.
|
|
424
|
+
|
|
425
|
+
Changes take effect on the next deploy or restart. With apply_now=true, backends are restarted
|
|
426
|
+
and frontends are rebuilt right away. Values are never returned. Confirm with the user before
|
|
427
|
+
sending values taken from a local .env file."""
|
|
428
|
+
if not variables:
|
|
429
|
+
raise ToolError("Pass at least one variable, e.g. {\"DATABASE_URL\": \"...\"}.")
|
|
430
|
+
if len(variables) > 50:
|
|
431
|
+
raise ToolError("Set at most 50 variables per call.")
|
|
432
|
+
bad = [k for k in variables if not ENV_KEY_RE.match(k)]
|
|
433
|
+
if bad:
|
|
434
|
+
raise ToolError(f"Invalid variable name(s): {', '.join(bad)}. Use letters, digits and underscores, not starting with a digit.")
|
|
435
|
+
|
|
436
|
+
payload = {"env_vars": [{"key": k, "value": str(v), "is_secret": secret} for k, v in variables.items()]}
|
|
437
|
+
await call(lambda c: c.post(f"/projects/{project_id}/env-vars", json=payload))
|
|
438
|
+
result: dict[str, Any] = {"saved": sorted(variables), "applied": False}
|
|
439
|
+
|
|
440
|
+
if not apply_now:
|
|
441
|
+
result["message"] = "Saved. They take effect on the next deploy or restart (call again with apply_now=true to apply now)."
|
|
442
|
+
return result
|
|
443
|
+
client = get_client()
|
|
444
|
+
try:
|
|
445
|
+
restart = await client.post(f"/projects/{project_id}/backend-action", json={"action": "restart"})
|
|
446
|
+
result.update(applied=True, action="restart", deployment_id=restart.get("deployment_id"))
|
|
447
|
+
except DeployKarError as exc:
|
|
448
|
+
if exc.code != "ERR_400_60": # "only available for backend projects" -> it's a frontend: rebuild
|
|
449
|
+
raise _api_error(exc) from exc
|
|
450
|
+
redeploy = await call(lambda c: c.post("/deploy", json={"project_id": project_id}))
|
|
451
|
+
result.update(applied=True, action="redeploy", deployment_id=redeploy.get("deployment_id"))
|
|
452
|
+
result["next_step"] = "Call get_status with this project_id and deployment_id and wait=true."
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True))
|
|
456
|
+
async def scale(project_id: str, replicas: int) -> dict[str, Any]:
|
|
457
|
+
"""Set the number of running instances (replicas, 1-10) of a backend service.
|
|
458
|
+
Applies within a few minutes, when the cluster picks up the change."""
|
|
459
|
+
if not 1 <= replicas <= 10:
|
|
460
|
+
raise ToolError("replicas must be between 1 and 10.")
|
|
461
|
+
result = await call(lambda c: c.post(f"/projects/{project_id}/backend-action",
|
|
462
|
+
json={"action": "scale", "replicas": replicas}))
|
|
463
|
+
return {"project_id": project_id, "replicas": replicas, "message": result.get("message")}
|
|
464
|
+
|
|
465
|
+
return server
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: deploykar-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for DeployKar: deploy, monitor and debug your apps from your IDE.
|
|
5
|
+
Project-URL: Homepage, https://deploykar.com
|
|
6
|
+
Author: DeployKar
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: deploy,deploykar,ide,mcp,model-context-protocol
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: mcp<3,>=2.3
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# DeployKar MCP server
|
|
22
|
+
|
|
23
|
+
Deploy, monitor and debug your [DeployKar](https://deploykar.com) apps from your IDE's AI chat:
|
|
24
|
+
Kiro, Cursor, VS Code (Copilot), Claude Code, Windsurf, or any other MCP client.
|
|
25
|
+
|
|
26
|
+
> "Deploy my app" · "Why did my build fail?" · "Set DATABASE_URL to postgres://…" · "Scale the API to 3 instances"
|
|
27
|
+
|
|
28
|
+
Your IDE's assistant does the thinking. This server only translates its tool calls into DeployKar API
|
|
29
|
+
requests, using a personal API token.
|
|
30
|
+
|
|
31
|
+
## Setup (2 minutes)
|
|
32
|
+
|
|
33
|
+
You need:
|
|
34
|
+
- [uv](https://docs.astral.sh/uv/getting-started/installation/) installed (it provides the `uvx` command)
|
|
35
|
+
- a DeployKar account. Sign in at **http://187.127.179.204:7100**
|
|
36
|
+
- your app's code in a GitHub repository that DeployKar can access (connect GitHub once in the dashboard)
|
|
37
|
+
|
|
38
|
+
**1. Sign in**
|
|
39
|
+
|
|
40
|
+
First sign in to the DeployKar dashboard in your browser, then run:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uvx deploykar-mcp login
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Your browser opens DeployKar's approval page. Choose **Deploy** (recommended) or **Read-only** access
|
|
47
|
+
and click **Approve**. The token is saved on your computer, readable only by you:
|
|
48
|
+
`~/.config/deploykar/credentials.json` (Windows: `%APPDATA%\deploykar\credentials.json`).
|
|
49
|
+
|
|
50
|
+
**2. Add DeployKar to your IDE**
|
|
51
|
+
|
|
52
|
+
For Claude Code, create a file named `.mcp.json` in your project folder (or run
|
|
53
|
+
`claude mcp add --scope user deploykar -- uvx deploykar-mcp@latest` to add it for all projects):
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"deploykar": {
|
|
59
|
+
"command": "uvx",
|
|
60
|
+
"args": ["deploykar-mcp@latest"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The same JSON works in other IDEs:
|
|
67
|
+
|
|
68
|
+
| IDE | Where |
|
|
69
|
+
|---|---|
|
|
70
|
+
| Cursor | `~/.cursor/mcp.json` |
|
|
71
|
+
| Kiro | `~/.kiro/settings/mcp.json` (or `.kiro/settings/mcp.json` in a workspace) |
|
|
72
|
+
| Windsurf | `~/.codeium/windsurf/mcp_config.json` |
|
|
73
|
+
|
|
74
|
+
VS Code (Copilot agent mode) uses `.vscode/mcp.json` with a slightly different shape:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"servers": {
|
|
79
|
+
"deploykar": {
|
|
80
|
+
"type": "stdio",
|
|
81
|
+
"command": "uvx",
|
|
82
|
+
"args": ["deploykar-mcp@latest"]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Open the folder in your IDE, approve the `deploykar` server when asked, and start chatting.
|
|
89
|
+
`@latest` means you get new versions automatically when your IDE restarts the server.
|
|
90
|
+
|
|
91
|
+
**Without the login command** (CI, remote machines, or if you prefer to paste a token): create a token
|
|
92
|
+
on DeployKar's API tokens page (`https://dev-32786578325.deploykar.com/auth/api/v1/api-tokens/authorize`)
|
|
93
|
+
and add it to the config. Don't commit a file that contains a token.
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"mcpServers": {
|
|
98
|
+
"deploykar": {
|
|
99
|
+
"command": "uvx",
|
|
100
|
+
"args": ["deploykar-mcp@latest"],
|
|
101
|
+
"env": { "DEPLOYKAR_API_TOKEN": "dk_live_..." }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## What you can ask
|
|
108
|
+
|
|
109
|
+
- "Deploy this repo to DeployKar as a frontend"
|
|
110
|
+
- "Deploy my latest changes" (the assistant pushes first, then deploys and waits for the live URL)
|
|
111
|
+
- "Turn on auto-deploy for main"
|
|
112
|
+
- "Why did my last deploy fail? Fix it."
|
|
113
|
+
- "Show me the API's logs from the last few minutes"
|
|
114
|
+
- "Set DATABASE_URL to … and restart"
|
|
115
|
+
- "Scale the API to 3 instances"
|
|
116
|
+
|
|
117
|
+
## Tools
|
|
118
|
+
|
|
119
|
+
| Tool | What it does |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `check_setup` | Signed-in account, token access level, GitHub connection (with an install link if missing) |
|
|
122
|
+
| `list_projects` | Your projects with status and live URL. Pass `repo_url` to find the project for the current repo |
|
|
123
|
+
| `deploy` | Deploy an existing project, or create one from a GitHub repo URL (frontend or backend) |
|
|
124
|
+
| `get_status` | Deployment status and live URL. `wait=true` follows the build; failures include the error lines |
|
|
125
|
+
| `get_logs` | Build logs (`source="build"`) or live backend logs (`source="runtime"`), with `errors_only` |
|
|
126
|
+
| `list_env_vars` | Environment variable names (never values) |
|
|
127
|
+
| `set_env_var` | Create or update environment variables; `apply_now=true` restarts/redeploys |
|
|
128
|
+
| `scale` | Set the number of instances (1-10) of a backend service |
|
|
129
|
+
|
|
130
|
+
## How deployments work
|
|
131
|
+
|
|
132
|
+
DeployKar builds from GitHub, not from your local files, exactly like deploying from the dashboard.
|
|
133
|
+
Whatever is pushed to the project's branch is what gets deployed, so the assistant commits and pushes
|
|
134
|
+
your changes (after asking you) before deploying. With auto-deploy enabled, every push to the branch
|
|
135
|
+
deploys automatically and the assistant just follows the deployment for the commit it pushed.
|
|
136
|
+
|
|
137
|
+
## Security
|
|
138
|
+
|
|
139
|
+
- API tokens are stored hashed by DeployKar, shown once, expire after 30, 90 or 365 days, and can be
|
|
140
|
+
revoked at any time: `uvx deploykar-mcp logout`, or the API tokens page
|
|
141
|
+
(`https://dev-32786578325.deploykar.com/auth/api/v1/api-tokens/manage`).
|
|
142
|
+
- Tokens can only reach a fixed set of endpoints. They can **never** delete projects or deployments,
|
|
143
|
+
remove backends, or manage databases, domains, secret groups or other tokens.
|
|
144
|
+
- Read-only tokens can view projects, statuses and logs, but cannot deploy or read environment variables.
|
|
145
|
+
- Environment variable values are never returned to the assistant.
|
|
146
|
+
- Per-token rate limits apply (120 requests/minute, 30 deploy or scale actions/hour).
|
|
147
|
+
|
|
148
|
+
## Commands
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
uvx deploykar-mcp run the MCP server (your IDE does this)
|
|
152
|
+
uvx deploykar-mcp login sign in via the browser (--no-browser to paste a token instead)
|
|
153
|
+
uvx deploykar-mcp whoami show the current account and token
|
|
154
|
+
uvx deploykar-mcp logout revoke and delete the saved token
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Environment variables (all optional):
|
|
158
|
+
|
|
159
|
+
| Variable | Default |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `DEPLOYKAR_API_TOKEN` | the token saved by `login` |
|
|
162
|
+
| `DEPLOYKAR_API_URL` | `https://dev-32786578325.deploykar.com/api/v1` |
|
|
163
|
+
| `DEPLOYKAR_AUTH_URL` | `https://dev-32786578325.deploykar.com/auth/api/v1` |
|
|
164
|
+
| `DEPLOYKAR_DASHBOARD_URL` | `http://187.127.179.204:7100` |
|
|
165
|
+
|
|
166
|
+
## Troubleshooting
|
|
167
|
+
|
|
168
|
+
- **"Not signed in" / "rejected the API token"**: run `uvx deploykar-mcp login` again, then restart
|
|
169
|
+
the MCP server in your IDE.
|
|
170
|
+
- **The approval page asks you to sign in**: sign in at http://187.127.179.204:7100 in the same
|
|
171
|
+
browser, then reload the approval page.
|
|
172
|
+
- **The server shows "Failed" or "Connection closed", or the IDE says `uvx` was not found**: your
|
|
173
|
+
IDE can't find `uvx`. Restart the IDE completely after installing uv. If that doesn't help, put the
|
|
174
|
+
full path to `uvx` in `"command"`:
|
|
175
|
+
- macOS/Linux: run `which uvx` and use what it prints, e.g. `/Users/you/.local/bin/uvx`
|
|
176
|
+
- Windows: run `where uvx` in PowerShell and use it with forward slashes, e.g.
|
|
177
|
+
`C:/Users/you/.local/bin/uvx.exe`. (Git Bash's `which uvx` prints `/c/Users/...`, which
|
|
178
|
+
Windows doesn't understand: write it as `C:/Users/...`.)
|
|
179
|
+
- **"This API token is read-only"**: sign in again and choose Deploy access.
|
|
180
|
+
- **Deployment built old code**: push your commits; DeployKar builds the latest commit on GitHub.
|
|
181
|
+
- **No build logs**: DeployKar keeps build logs for about an hour after a build.
|
|
182
|
+
|
|
183
|
+
## Development
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
uv run --group dev pytest
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Releases are published to PyPI from GitHub (see `RELEASING.md` in the repository).
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
deploykar_mcp/__init__.py,sha256=p064QZCoDXZRVZtRjpVpSGRqoNlxlXrDPcpdqYM23VE,107
|
|
2
|
+
deploykar_mcp/__main__.py,sha256=X_MyDO9k2oyoXZajaLaAPfjFOPvd7C89XqLi7XPtM5I,74
|
|
3
|
+
deploykar_mcp/cli.py,sha256=wRv1VFA5M-ff0BNzf1Ykd2qJJkaKKSiCmq1s51iXPxU,5490
|
|
4
|
+
deploykar_mcp/client.py,sha256=OmKAzXXFqvbccedFJhNVOxUs-nefMYjEwzVDkzEf9-U,4617
|
|
5
|
+
deploykar_mcp/config.py,sha256=CvAogmxyiyjZX6whKM4GXcARK2Y2S--lOzoNgmhyWBk,3309
|
|
6
|
+
deploykar_mcp/login.py,sha256=kBo8IRneBJKz7QaVJluOX6V9joVZKy7aXoKhAxL2jAU,5890
|
|
7
|
+
deploykar_mcp/server.py,sha256=kkSw_muFQ9MFDZRGuhBHBGBEZqVlHAYVrhjcaaQMVuk,22785
|
|
8
|
+
deploykar_mcp-0.1.0.dist-info/METADATA,sha256=gv8A__qcUdUk5_Twgk7M4gAv7Gg76MmHBweV7Tg6cnA,7568
|
|
9
|
+
deploykar_mcp-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
deploykar_mcp-0.1.0.dist-info/entry_points.txt,sha256=Yx9soBIyc8fGGDF2svzaqwFt9ilbRnDa6Vr1NCIhITE,57
|
|
11
|
+
deploykar_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=iHeGY9VEmi-yCCmJ3s_B-lW16vHgJczwcetNFGFmQc8,1066
|
|
12
|
+
deploykar_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeployKar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|