kasm-use 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.
- kasm_use/__init__.py +2 -0
- kasm_use/server.py +115 -0
- kasm_use/tools.py +312 -0
- kasm_use-0.1.0.dist-info/METADATA +140 -0
- kasm_use-0.1.0.dist-info/RECORD +8 -0
- kasm_use-0.1.0.dist-info/WHEEL +4 -0
- kasm_use-0.1.0.dist-info/entry_points.txt +2 -0
- kasm_use-0.1.0.dist-info/licenses/LICENSE +21 -0
kasm_use/__init__.py
ADDED
kasm_use/server.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""kasm-use: Kasm Workspaces desktop control over MCP.
|
|
2
|
+
|
|
3
|
+
kasm-use stdio (the client launches it; the usual way)
|
|
4
|
+
kasm-use --transport http [--host 0.0.0.0] [--port 8000]
|
|
5
|
+
streamable HTTP at /mcp; requires KASM_USE_TOKEN,
|
|
6
|
+
sent by clients as `Authorization: Bearer <token>`
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from mcp import types
|
|
17
|
+
from mcp.server.lowlevel import Server
|
|
18
|
+
|
|
19
|
+
from . import __version__, tools
|
|
20
|
+
|
|
21
|
+
HANDLERS = {name: fn for name, fn, _, _ in tools._TOOLS}
|
|
22
|
+
|
|
23
|
+
server = Server("kasm", version=__version__, instructions=(
|
|
24
|
+
"Operate Kasm Workspaces desktops. Typical loop: kasm_start (or reuse a running session "
|
|
25
|
+
"from kasm_list) -> kasm_look -> act (click/type/key/scroll) -> kasm_look to confirm. "
|
|
26
|
+
"Never type passwords, MFA codes or payment details; stop and ask the owner to take over. "
|
|
27
|
+
"Stop at CAPTCHAs and hand them to the owner. Call kasm_stop when done."))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@server.list_tools()
|
|
31
|
+
async def list_tools() -> list[types.Tool]:
|
|
32
|
+
return [types.Tool(name=n, description=d, inputSchema=p) for n, _, d, p in tools._TOOLS]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@server.call_tool()
|
|
36
|
+
async def call_tool(name: str, arguments: dict):
|
|
37
|
+
if name not in HANDLERS:
|
|
38
|
+
raise ValueError(f"Unknown tool {name}")
|
|
39
|
+
# The tools are blocking (urllib + sleeps); keep the event loop free.
|
|
40
|
+
result = await asyncio.to_thread(HANDLERS[name], arguments or {})
|
|
41
|
+
if isinstance(result, dict) and result.get("_multimodal"):
|
|
42
|
+
out = []
|
|
43
|
+
for part in result["content"]:
|
|
44
|
+
if part["type"] == "text":
|
|
45
|
+
out.append(types.TextContent(type="text", text=part["text"]))
|
|
46
|
+
elif part["type"] == "image_url":
|
|
47
|
+
header, b64 = part["image_url"]["url"].split(",", 1)
|
|
48
|
+
out.append(types.ImageContent(type="image", data=b64, mimeType=header[5:].split(";")[0]))
|
|
49
|
+
return out
|
|
50
|
+
parsed = json.loads(result)
|
|
51
|
+
if not parsed.get("ok", True):
|
|
52
|
+
# Surface tool errors as MCP tool errors so clients show them as failures.
|
|
53
|
+
raise RuntimeError(parsed["error"])
|
|
54
|
+
return [types.TextContent(type="text", text=result)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _check_env() -> None:
|
|
58
|
+
missing = [v for v in tools._REQUIRED_ENV if not os.environ.get(v)]
|
|
59
|
+
if missing:
|
|
60
|
+
sys.exit(f"kasm-use: missing environment variables: {', '.join(missing)}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _stdio() -> None:
|
|
64
|
+
from mcp.server.stdio import stdio_server
|
|
65
|
+
async with stdio_server() as (read, write):
|
|
66
|
+
await server.run(read, write, server.create_initialization_options())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _http(host: str, port: int) -> None:
|
|
70
|
+
import uvicorn
|
|
71
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
72
|
+
from starlette.applications import Starlette
|
|
73
|
+
from starlette.responses import JSONResponse
|
|
74
|
+
from starlette.routing import Mount, Route
|
|
75
|
+
|
|
76
|
+
token = os.environ.get("KASM_USE_TOKEN")
|
|
77
|
+
if not token:
|
|
78
|
+
sys.exit("kasm-use: KASM_USE_TOKEN is required for --transport http")
|
|
79
|
+
manager = StreamableHTTPSessionManager(app=server, stateless=True)
|
|
80
|
+
|
|
81
|
+
async def mcp_app(scope, receive, send):
|
|
82
|
+
given = dict(scope.get("headers") or []).get(b"authorization", b"").decode()
|
|
83
|
+
if not hmac.compare_digest(given, f"Bearer {token}"):
|
|
84
|
+
await JSONResponse({"error": "unauthorized"}, status_code=401)(scope, receive, send)
|
|
85
|
+
return
|
|
86
|
+
await manager.handle_request(scope, receive, send)
|
|
87
|
+
|
|
88
|
+
async def health(_request):
|
|
89
|
+
return JSONResponse({"ok": True, "version": __version__, "tools": len(HANDLERS)})
|
|
90
|
+
|
|
91
|
+
@contextlib.asynccontextmanager
|
|
92
|
+
async def lifespan(_app):
|
|
93
|
+
async with manager.run():
|
|
94
|
+
yield
|
|
95
|
+
|
|
96
|
+
app = Starlette(routes=[Route("/healthz", health), Mount("/mcp", app=mcp_app)], lifespan=lifespan)
|
|
97
|
+
uvicorn.run(app, host=host, port=port, log_level="info")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
ap = argparse.ArgumentParser(prog="kasm-use", description="Kasm Workspaces desktop control over MCP")
|
|
102
|
+
ap.add_argument("--transport", choices=["stdio", "http"], default=os.environ.get("KASM_USE_TRANSPORT", "stdio"))
|
|
103
|
+
ap.add_argument("--host", default=os.environ.get("KASM_USE_HOST", "127.0.0.1"))
|
|
104
|
+
ap.add_argument("--port", type=int, default=int(os.environ.get("KASM_USE_PORT", "8000")))
|
|
105
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
106
|
+
args = ap.parse_args()
|
|
107
|
+
_check_env()
|
|
108
|
+
if args.transport == "stdio":
|
|
109
|
+
asyncio.run(_stdio())
|
|
110
|
+
else:
|
|
111
|
+
_http(args.host, args.port)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
kasm_use/tools.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Kasm Workspaces desktop control tools — work with any stock Kasm image.
|
|
2
|
+
|
|
3
|
+
Shared by the MCP server (kasm_use.server) and the Hermes Agent plugin (register() below).
|
|
4
|
+
|
|
5
|
+
Eyes : POST /api/public/get_kasm_screenshot (KasmVNC in the session renders a JPEG)
|
|
6
|
+
Hands : POST /api/public/exec_command_kasm + xdotool (installed into the session at start;
|
|
7
|
+
stock images don't ship it)
|
|
8
|
+
|
|
9
|
+
Configuration comes from the environment only:
|
|
10
|
+
KASM_API_URL, KASM_API_KEY, KASM_API_KEY_SECRET - a Kasm API key (session permissions only)
|
|
11
|
+
KASM_USER_ID - the Kasm user sessions are created for, so the owner can watch/take over
|
|
12
|
+
KASM_VERIFY_TLS - "false" to accept a self-signed Kasm certificate (default: verify)
|
|
13
|
+
|
|
14
|
+
Coordinates: every click/scroll uses the coordinate space of the most recent kasm_look image for
|
|
15
|
+
that session. The real desktop is usually much larger (and changes when the owner connects and
|
|
16
|
+
KasmVNC resizes it), so we scale inside the session using `xdotool getdisplaygeometry`.
|
|
17
|
+
|
|
18
|
+
Lessons baked in:
|
|
19
|
+
- exec_command_kasm is fire-and-forget: it never returns command output.
|
|
20
|
+
- The screenshot keeps the desktop's aspect ratio, so the returned JPEG is NOT the requested
|
|
21
|
+
size — always read its real dimensions (a 1280x720 request on a 3840x2008 desktop gives
|
|
22
|
+
1280x669; scaling Y by 720 put clicks ~35 real px too high).
|
|
23
|
+
- Screenshots right after an action can be stale for a few seconds.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import ssl
|
|
32
|
+
import struct
|
|
33
|
+
import time
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.request
|
|
36
|
+
|
|
37
|
+
TOOLSET = "kasm"
|
|
38
|
+
_REQUIRED_ENV = ["KASM_API_URL", "KASM_API_KEY", "KASM_API_KEY_SECRET", "KASM_USER_ID"]
|
|
39
|
+
_SHOT_W, _SHOT_H = 1280, 800
|
|
40
|
+
_INSTALL_WAIT_S = 90
|
|
41
|
+
_CTX = (ssl.create_default_context() if os.environ.get("KASM_VERIFY_TLS", "true").lower() not in ("0", "false", "no")
|
|
42
|
+
else ssl._create_unverified_context())
|
|
43
|
+
|
|
44
|
+
# kasm_id -> (image_width, image_height) of the last screenshot shown to the model.
|
|
45
|
+
# Per process: one server instance serves one Kasm user.
|
|
46
|
+
_LAST_SHOT: dict[str, tuple[int, int]] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ----------------------------------------------------------------------------------- API
|
|
50
|
+
def _call(endpoint: str, raw: bool = False, **payload):
|
|
51
|
+
body = dict(api_key=os.environ["KASM_API_KEY"], api_key_secret=os.environ["KASM_API_KEY_SECRET"], **payload)
|
|
52
|
+
req = urllib.request.Request(
|
|
53
|
+
f"{os.environ['KASM_API_URL'].rstrip('/')}/api/public/{endpoint}",
|
|
54
|
+
data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
|
|
55
|
+
try:
|
|
56
|
+
resp = urllib.request.urlopen(req, timeout=120, context=_CTX)
|
|
57
|
+
data = resp.read()
|
|
58
|
+
except urllib.error.HTTPError as e:
|
|
59
|
+
raise RuntimeError(f"Kasm {endpoint} HTTP {e.code}: {e.read()[:200]!r}") from None
|
|
60
|
+
if raw:
|
|
61
|
+
return data
|
|
62
|
+
parsed = json.loads(data or b"{}")
|
|
63
|
+
if isinstance(parsed, dict) and parsed.get("error_message"):
|
|
64
|
+
raise RuntimeError(f"Kasm {endpoint}: {parsed['error_message']}")
|
|
65
|
+
return parsed
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _user() -> str:
|
|
69
|
+
return os.environ["KASM_USER_ID"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _exec(kasm_id: str, cmd: str, env: dict | None = None, root: bool = False) -> None:
|
|
73
|
+
cfg = {"cmd": cmd, "environment": {"DISPLAY": ":1", **(env or {})}}
|
|
74
|
+
if root:
|
|
75
|
+
cfg["user"] = "root"
|
|
76
|
+
_call("exec_command_kasm", kasm_id=kasm_id, user_id=_user(), exec_config=cfg)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _jpeg_size(data: bytes) -> tuple[int, int] | None:
|
|
80
|
+
"""(width, height) from a JPEG's SOF marker."""
|
|
81
|
+
i = 2
|
|
82
|
+
while i < len(data) - 9:
|
|
83
|
+
if data[i] != 0xFF:
|
|
84
|
+
i += 1
|
|
85
|
+
continue
|
|
86
|
+
marker = data[i + 1]
|
|
87
|
+
if marker in (0xC0, 0xC1, 0xC2):
|
|
88
|
+
h, w = struct.unpack(">HH", data[i + 5:i + 9])
|
|
89
|
+
return w, h
|
|
90
|
+
i += 2 + struct.unpack(">H", data[i + 2:i + 4])[0]
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _ok(**fields) -> str:
|
|
95
|
+
return json.dumps({"ok": True, **fields})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _err(msg: str) -> str:
|
|
99
|
+
return json.dumps({"ok": False, "error": msg})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_session(args: dict) -> str:
|
|
103
|
+
kid = (args.get("session_id") or "").strip()
|
|
104
|
+
if kid:
|
|
105
|
+
return kid
|
|
106
|
+
mine = [k for k in _call("get_kasms").get("kasms", [])
|
|
107
|
+
if str(k.get("user_id", "")).replace("-", "") == _user().replace("-", "")]
|
|
108
|
+
if not mine:
|
|
109
|
+
raise RuntimeError("No running Kasm session. Start one with kasm_start first.")
|
|
110
|
+
return mine[-1]["kasm_id"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------------- tools
|
|
114
|
+
def kasm_list(args, **kw):
|
|
115
|
+
try:
|
|
116
|
+
images = [{"workspace": i.get("friendly_name"), "image": i.get("name")}
|
|
117
|
+
for i in _call("get_images").get("images", []) if i.get("enabled")]
|
|
118
|
+
sessions = [{"session_id": k["kasm_id"], "workspace": (k.get("image") or {}).get("friendly_name"),
|
|
119
|
+
"status": k.get("operational_status")}
|
|
120
|
+
for k in _call("get_kasms").get("kasms", [])
|
|
121
|
+
if str(k.get("user_id", "")).replace("-", "") == _user().replace("-", "")]
|
|
122
|
+
return _ok(workspaces=images, sessions=sessions)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
return _err(str(e))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def kasm_start(args, **kw):
|
|
128
|
+
workspace = (args.get("workspace") or "Chrome").strip().lower()
|
|
129
|
+
try:
|
|
130
|
+
images = [i for i in _call("get_images").get("images", []) if i.get("enabled")]
|
|
131
|
+
matches = [i for i in images if workspace in (i.get("friendly_name") or "").lower()]
|
|
132
|
+
if not matches:
|
|
133
|
+
return _err(f"No workspace matching {workspace!r}. Available: "
|
|
134
|
+
+ ", ".join(sorted({i.get('friendly_name') for i in images})))
|
|
135
|
+
# Prefer the newest image tag when the same workspace exists more than once.
|
|
136
|
+
img = sorted(matches, key=lambda i: i.get("name") or "")[-1]
|
|
137
|
+
started = _call("request_kasm", image_id=img["image_id"], user_id=_user(), enable_sharing=False)
|
|
138
|
+
kid = started["kasm_id"]
|
|
139
|
+
for _ in range(60):
|
|
140
|
+
status = (_call("get_kasm_status", kasm_id=kid, user_id=_user()).get("kasm") or {}).get("operational_status")
|
|
141
|
+
if status == "running":
|
|
142
|
+
break
|
|
143
|
+
time.sleep(5)
|
|
144
|
+
else:
|
|
145
|
+
return _err(f"Session {kid} did not reach 'running'.")
|
|
146
|
+
# Hands: stock images lack xdotool; install it (Debian/Ubuntu images). Fire-and-forget,
|
|
147
|
+
# so wait for apt to finish.
|
|
148
|
+
_exec(kid, "sh -c 'command -v xdotool >/dev/null || { apt-get update -qq && "
|
|
149
|
+
"DEBIAN_FRONTEND=noninteractive apt-get install -y -qq xdotool; } >/tmp/.kasm-use-xdotool.log 2>&1'",
|
|
150
|
+
root=True)
|
|
151
|
+
time.sleep(_INSTALL_WAIT_S)
|
|
152
|
+
return _ok(session_id=kid, workspace=img.get("friendly_name"), image=img.get("name"),
|
|
153
|
+
note="Session is running and visible in the owner's Kasm dashboard. Call kasm_look next.")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
return _err(str(e))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def kasm_look(args, **kw):
|
|
159
|
+
try:
|
|
160
|
+
kid = _resolve_session(args)
|
|
161
|
+
time.sleep(float(args.get("wait_seconds", 2)))
|
|
162
|
+
img = b""
|
|
163
|
+
for _ in range(8):
|
|
164
|
+
img = _call("get_kasm_screenshot", raw=True, kasm_id=kid, user_id=_user(), width=_SHOT_W, height=_SHOT_H)
|
|
165
|
+
if img[:2] == b"\xff\xd8":
|
|
166
|
+
break
|
|
167
|
+
time.sleep(3)
|
|
168
|
+
if img[:2] != b"\xff\xd8":
|
|
169
|
+
return _err("Screenshot not available yet (the desktop may still be starting). Try again.")
|
|
170
|
+
size = _jpeg_size(img) or (_SHOT_W, _SHOT_H)
|
|
171
|
+
_LAST_SHOT[kid] = size
|
|
172
|
+
summary = (f"Screenshot of Kasm session {kid[:8]} ({size[0]}x{size[1]}). Give kasm_click / kasm_scroll "
|
|
173
|
+
f"coordinates in THIS image's pixels. The view can lag a few seconds behind actions.")
|
|
174
|
+
return {
|
|
175
|
+
"_multimodal": True,
|
|
176
|
+
"content": [
|
|
177
|
+
{"type": "text", "text": summary},
|
|
178
|
+
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + base64.b64encode(img).decode()}},
|
|
179
|
+
],
|
|
180
|
+
"text_summary": summary,
|
|
181
|
+
}
|
|
182
|
+
except Exception as e:
|
|
183
|
+
return _err(str(e))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _scaled_xy(kid: str, x: int, y: int) -> tuple[str, str]:
|
|
187
|
+
w, h = _LAST_SHOT.get(kid, (0, 0))
|
|
188
|
+
if not w:
|
|
189
|
+
raise RuntimeError("Call kasm_look first so coordinates can be mapped to the screen.")
|
|
190
|
+
x, y = max(0, min(int(x), w - 1)), max(0, min(int(y), h - 1))
|
|
191
|
+
return f"$(( {x} * WIDTH / {w} ))", f"$(( {y} * HEIGHT / {h} ))"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def kasm_click(args, **kw):
|
|
195
|
+
try:
|
|
196
|
+
kid = _resolve_session(args)
|
|
197
|
+
sx, sy = _scaled_xy(kid, args["x"], args["y"])
|
|
198
|
+
button = {"left": 1, "middle": 2, "right": 3}.get(args.get("button", "left"), 1)
|
|
199
|
+
repeat = "--repeat 2 " if args.get("double") else ""
|
|
200
|
+
_exec(kid, f"sh -c 'eval $(xdotool getdisplaygeometry --shell); "
|
|
201
|
+
f"xdotool mousemove {sx} {sy} sleep 0.1 click {repeat}{button}'")
|
|
202
|
+
return _ok(session_id=kid, clicked=[args["x"], args["y"]])
|
|
203
|
+
except Exception as e:
|
|
204
|
+
return _err(str(e))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def kasm_type(args, **kw):
|
|
208
|
+
text = args.get("text", "")
|
|
209
|
+
if not text:
|
|
210
|
+
return _err("text is required")
|
|
211
|
+
try:
|
|
212
|
+
kid = _resolve_session(args)
|
|
213
|
+
# Text travels as an env var, never inside the shell string: no quoting/injection issues.
|
|
214
|
+
_exec(kid, "sh -c 'xdotool type --delay 35 -- \"$KASM_USE_TEXT\"'", env={"KASM_USE_TEXT": text})
|
|
215
|
+
if args.get("press_enter"):
|
|
216
|
+
time.sleep(min(0.05 * len(text) + 0.5, 15))
|
|
217
|
+
_exec(kid, "sh -c 'xdotool key Return'")
|
|
218
|
+
return _ok(session_id=kid, typed_chars=len(text), pressed_enter=bool(args.get("press_enter")))
|
|
219
|
+
except Exception as e:
|
|
220
|
+
return _err(str(e))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
_KEY_RE = re.compile(r"^[A-Za-z0-9_+]+$")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def kasm_key(args, **kw):
|
|
227
|
+
keys = [k for k in re.split(r"[\s,]+", args.get("keys", "")) if k]
|
|
228
|
+
bad = [k for k in keys if not _KEY_RE.match(k)]
|
|
229
|
+
if not keys or bad:
|
|
230
|
+
return _err(f"keys must be xdotool key names like ctrl+l, Return, Tab, Escape (invalid: {bad})")
|
|
231
|
+
try:
|
|
232
|
+
kid = _resolve_session(args)
|
|
233
|
+
_exec(kid, "sh -c 'xdotool key " + " ".join(keys) + "'")
|
|
234
|
+
return _ok(session_id=kid, keys=keys)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
return _err(str(e))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def kasm_scroll(args, **kw):
|
|
240
|
+
try:
|
|
241
|
+
kid = _resolve_session(args)
|
|
242
|
+
button = {"up": 4, "down": 5, "left": 6, "right": 7}.get(args.get("direction", "down"), 5)
|
|
243
|
+
amount = max(1, min(int(args.get("amount", 5)), 30))
|
|
244
|
+
move = ""
|
|
245
|
+
if "x" in args and "y" in args:
|
|
246
|
+
sx, sy = _scaled_xy(kid, args["x"], args["y"])
|
|
247
|
+
move = f"eval $(xdotool getdisplaygeometry --shell); xdotool mousemove {sx} {sy}; "
|
|
248
|
+
_exec(kid, f"sh -c '{move}xdotool click --repeat {amount} {button}'")
|
|
249
|
+
return _ok(session_id=kid, direction=args.get("direction", "down"), amount=amount)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
return _err(str(e))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def kasm_stop(args, **kw):
|
|
255
|
+
try:
|
|
256
|
+
kid = _resolve_session(args)
|
|
257
|
+
_call("destroy_kasm", kasm_id=kid, user_id=_user())
|
|
258
|
+
_LAST_SHOT.pop(kid, None)
|
|
259
|
+
return _ok(session_id=kid, stopped=True)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
return _err(str(e))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------------------ schemas
|
|
265
|
+
_SID = {"session_id": {"type": "string", "description": "Kasm session id. Omit to use your newest running session."}}
|
|
266
|
+
|
|
267
|
+
_TOOLS = [
|
|
268
|
+
("kasm_list", kasm_list, "List Kasm workspaces you can start and your running sessions.",
|
|
269
|
+
{"type": "object", "properties": {}}),
|
|
270
|
+
("kasm_start", kasm_start,
|
|
271
|
+
"Start a Kasm workspace session (e.g. 'Chrome', 'Terminal') as the owner, so they can watch it. "
|
|
272
|
+
"Takes ~2 minutes (it installs the input tool). Then call kasm_look.",
|
|
273
|
+
{"type": "object", "properties": {"workspace": {"type": "string", "description": "Workspace name, e.g. Chrome"}}}),
|
|
274
|
+
("kasm_look", kasm_look,
|
|
275
|
+
"Screenshot the Kasm session so you can see it. Always look before clicking, and look again after "
|
|
276
|
+
"each action to confirm it worked.",
|
|
277
|
+
{"type": "object", "properties": {**_SID, "wait_seconds": {"type": "number", "description": "Pause before capturing (default 2)."}}}),
|
|
278
|
+
("kasm_click", kasm_click, "Click at x,y in the coordinate space of the latest kasm_look image.",
|
|
279
|
+
{"type": "object", "required": ["x", "y"], "properties": {
|
|
280
|
+
**_SID, "x": {"type": "integer"}, "y": {"type": "integer"},
|
|
281
|
+
"button": {"type": "string", "enum": ["left", "right", "middle"]},
|
|
282
|
+
"double": {"type": "boolean"}}}),
|
|
283
|
+
("kasm_type", kasm_type,
|
|
284
|
+
"Type text into the focused field of the Kasm session. NEVER type passwords, MFA codes, or payment "
|
|
285
|
+
"details — stop and ask the owner to take over the session for those.",
|
|
286
|
+
{"type": "object", "required": ["text"], "properties": {
|
|
287
|
+
**_SID, "text": {"type": "string"}, "press_enter": {"type": "boolean"}}}),
|
|
288
|
+
("kasm_key", kasm_key, "Press keys/shortcuts in the Kasm session, e.g. 'ctrl+l', 'Return', 'Tab', 'Escape'.",
|
|
289
|
+
{"type": "object", "required": ["keys"], "properties": {**_SID, "keys": {"type": "string"}}}),
|
|
290
|
+
("kasm_scroll", kasm_scroll, "Scroll the Kasm session, optionally at x,y from the latest kasm_look image.",
|
|
291
|
+
{"type": "object", "properties": {
|
|
292
|
+
**_SID, "direction": {"type": "string", "enum": ["up", "down", "left", "right"]},
|
|
293
|
+
"amount": {"type": "integer", "description": "Wheel clicks, 1-30 (default 5)"},
|
|
294
|
+
"x": {"type": "integer"}, "y": {"type": "integer"}}}),
|
|
295
|
+
("kasm_stop", kasm_stop, "Stop (destroy) a Kasm session when the task is finished or the owner asks.",
|
|
296
|
+
{"type": "object", "properties": {**_SID}}),
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _available() -> bool:
|
|
301
|
+
return all(os.environ.get(v) for v in _REQUIRED_ENV)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def register(ctx) -> None:
|
|
305
|
+
"""Hermes Agent plugin entry point (see hermes-plugin/)."""
|
|
306
|
+
for name, handler, description, params in _TOOLS:
|
|
307
|
+
ctx.register_tool(
|
|
308
|
+
name=name, toolset=TOOLSET,
|
|
309
|
+
schema={"name": name, "description": description, "parameters": params},
|
|
310
|
+
handler=handler, check_fn=_available, requires_env=_REQUIRED_ENV,
|
|
311
|
+
description=description, emoji="🖥️",
|
|
312
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: kasm-use
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Let AI agents drive Kasm Workspaces desktops (look, click, type) over MCP — works with stock Kasm images
|
|
5
|
+
Project-URL: Homepage, https://github.com/rchurro/kasm-use
|
|
6
|
+
Project-URL: Issues, https://github.com/rchurro/kasm-use/issues
|
|
7
|
+
Author: rchurro
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agent,computer-use,hermes,kasm,kasm-workspaces,mcp
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Desktop Environment
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: mcp<2,>=1.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# kasm-use
|
|
19
|
+
|
|
20
|
+
Let an AI agent use a [Kasm Workspaces](https://kasmweb.com) desktop: start a session, look at the
|
|
21
|
+
screen, click, type, press keys, scroll, and stop it — in plain language, from any MCP client
|
|
22
|
+
(Claude Code, Claude Desktop, OpenCode, Cursor, …) or as a native [Hermes Agent](#hermes-agent) plugin.
|
|
23
|
+
|
|
24
|
+
Other Kasm MCP servers manage sessions (start, list, stop). **kasm-use actually uses them**: the agent
|
|
25
|
+
sees the desktop and operates it, like computer-use or browser-use, inside your Kasm.
|
|
26
|
+
|
|
27
|
+
- **Works with stock Kasm images.** Nothing is baked into the workspace; it drives the desktop
|
|
28
|
+
through Kasm's own API.
|
|
29
|
+
- **You can watch and take over.** Sessions are created as *your* Kasm user, so they show up in
|
|
30
|
+
your Kasm dashboard. Open one to watch the agent work, or to handle a login or CAPTCHA yourself.
|
|
31
|
+
- **Your Kasm, your key.** You run the server next to your own Kasm. Nothing goes through a third party.
|
|
32
|
+
|
|
33
|
+
## Tools
|
|
34
|
+
|
|
35
|
+
| Tool | What it does |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `kasm_list` | Workspaces you can start, and your running sessions |
|
|
38
|
+
| `kasm_start` | Start a workspace (e.g. `Chrome`) — takes ~2 min, see [Limitations](#limitations) |
|
|
39
|
+
| `kasm_look` | Screenshot of the session, returned as an image |
|
|
40
|
+
| `kasm_click` | Click at x,y in the latest screenshot's coordinates |
|
|
41
|
+
| `kasm_type` | Type text into the focused field |
|
|
42
|
+
| `kasm_key` | Keys/shortcuts, e.g. `ctrl+l`, `Return`, `Tab` |
|
|
43
|
+
| `kasm_scroll` | Scroll, optionally at a point |
|
|
44
|
+
| `kasm_stop` | Destroy the session |
|
|
45
|
+
|
|
46
|
+
## Requirements
|
|
47
|
+
|
|
48
|
+
- Kasm Workspaces **1.19 or newer** (the screenshot API needs 1.19 images).
|
|
49
|
+
- A Kasm **API key**: Admin → Settings → Developers → API Keys → Add. Grant it only the session
|
|
50
|
+
permissions (create/list/destroy sessions, screenshot, exec). It does not need user or admin rights.
|
|
51
|
+
- Your Kasm **user ID**, so sessions belong to you: Admin → Access Management → Users → open your user;
|
|
52
|
+
the ID is in the page URL.
|
|
53
|
+
- A workspace image based on Debian/Ubuntu (all official `kasmweb/*` images are).
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
| Variable | Required | Meaning |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `KASM_API_URL` | yes | e.g. `https://kasm.example.com` |
|
|
60
|
+
| `KASM_API_KEY` / `KASM_API_KEY_SECRET` | yes | the API key pair |
|
|
61
|
+
| `KASM_USER_ID` | yes | the Kasm user sessions are created for |
|
|
62
|
+
| `KASM_VERIFY_TLS` | no | `false` to accept a self-signed Kasm certificate (default `true`) |
|
|
63
|
+
| `KASM_USE_TOKEN` | HTTP only | bearer token clients must send |
|
|
64
|
+
|
|
65
|
+
## Run it
|
|
66
|
+
|
|
67
|
+
### Locally (stdio) — the usual way
|
|
68
|
+
|
|
69
|
+
Your MCP client starts the server itself. With [uv](https://docs.astral.sh/uv/) installed:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"kasm": {
|
|
75
|
+
"command": "uvx",
|
|
76
|
+
"args": ["kasm-use"],
|
|
77
|
+
"env": {
|
|
78
|
+
"KASM_API_URL": "https://kasm.example.com",
|
|
79
|
+
"KASM_API_KEY": "…",
|
|
80
|
+
"KASM_API_KEY_SECRET": "…",
|
|
81
|
+
"KASM_USER_ID": "…"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Claude Code:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
claude mcp add kasm -e KASM_API_URL=https://kasm.example.com -e KASM_API_KEY=… -e KASM_API_KEY_SECRET=… -e KASM_USER_ID=… -- uvx kasm-use
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### As a service (streamable HTTP)
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
docker run -d -p 8000:8000 \
|
|
98
|
+
-e KASM_API_URL=https://kasm.example.com -e KASM_API_KEY=… -e KASM_API_KEY_SECRET=… \
|
|
99
|
+
-e KASM_USER_ID=… -e KASM_USE_TOKEN="$(openssl rand -hex 32)" \
|
|
100
|
+
ghcr.io/rchurro/kasm-use:latest
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Clients connect to `http://host:8000/mcp` with the header `Authorization: Bearer <KASM_USE_TOKEN>`.
|
|
104
|
+
Health check: `GET /healthz`. Run **one replica** per Kasm user: the server remembers each
|
|
105
|
+
session's last screenshot size in memory to map click coordinates.
|
|
106
|
+
|
|
107
|
+
Keep it on a private network (LAN, VPN, Tailscale). Anyone with the token can drive your desktops.
|
|
108
|
+
|
|
109
|
+
### Hermes Agent
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install kasm-use # into Hermes's Python environment
|
|
113
|
+
cp -r hermes-plugin/kasm ~/.hermes/plugins/kasm
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Enable `kasm` under `plugins.enabled`, set the environment variables above, and start a new
|
|
117
|
+
Hermes session (`/new`) so the tools load.
|
|
118
|
+
|
|
119
|
+
## Limitations
|
|
120
|
+
|
|
121
|
+
- **First start is slow (~90 s extra).** Stock images don't include `xdotool`, so `kasm_start`
|
|
122
|
+
installs it in the session as root. Reusing a running session skips this.
|
|
123
|
+
- **Screenshots can lag** a few seconds behind actions; the agent is told to look again to confirm.
|
|
124
|
+
- **Kasm's exec API returns no output**, so actions report "sent", not "succeeded". The next
|
|
125
|
+
screenshot is the confirmation.
|
|
126
|
+
- **CAPTCHAs, passwords, MFA and payments are handed to you.** The tool descriptions tell the agent
|
|
127
|
+
never to type them; open the session in Kasm and take over.
|
|
128
|
+
- It's slow compared to a local browser tool: every step is a screenshot round trip.
|
|
129
|
+
|
|
130
|
+
## How it works
|
|
131
|
+
|
|
132
|
+
- **Eyes:** `POST /api/public/get_kasm_screenshot` (KasmVNC renders a JPEG).
|
|
133
|
+
- **Hands:** `POST /api/public/exec_command_kasm` running `xdotool` inside the session.
|
|
134
|
+
- Clicks are given in screenshot pixels and scaled to the real desktop size inside the session
|
|
135
|
+
(`xdotool getdisplaygeometry`), using the JPEG's actual dimensions — Kasm keeps the desktop's
|
|
136
|
+
aspect ratio, so the image is often not the size requested.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
kasm_use/__init__.py,sha256=1-gsZGuiR4Du8w0Nom8p0Y_uaPk76ErkhtxexE5_p-Y,82
|
|
2
|
+
kasm_use/server.py,sha256=XwOnUXq1EJ4cOiW_KXWe22Xfds1BZL228N5z32ZR3YE,4601
|
|
3
|
+
kasm_use/tools.py,sha256=CNiBv_WeQ8O3cjtUb0SIdDoZT9bEWXGBAdmMzQ9-0SI,14144
|
|
4
|
+
kasm_use-0.1.0.dist-info/METADATA,sha256=UX_-2X06xdku9sws11pt8ZmnTxylKy2fEK7ZchhED8Y,5719
|
|
5
|
+
kasm_use-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
kasm_use-0.1.0.dist-info/entry_points.txt,sha256=3AeHBUbYDuAExun8JMZcM73tIqQAy21kjcshycLa3BI,50
|
|
7
|
+
kasm_use-0.1.0.dist-info/licenses/LICENSE,sha256=1xRbCUam2PldiO0CleiBfnPtHrd0mejKz-7aPzKQVn0,1064
|
|
8
|
+
kasm_use-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rchurro
|
|
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.
|