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 ADDED
@@ -0,0 +1,2 @@
1
+ """Control Kasm Workspaces desktops from any MCP client."""
2
+ __version__ = "0.1.0"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kasm-use = kasm_use.server:main
@@ -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.