mcp-virtual-computer 0.2.3__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.
- kilntainers/__init__.py +3 -0
- kilntainers/__main__.py +6 -0
- kilntainers/auth.py +34 -0
- kilntainers/backends/__init__.py +17 -0
- kilntainers/backends/base.py +380 -0
- kilntainers/backends/docker.py +1498 -0
- kilntainers/backends/test___init__.py +37 -0
- kilntainers/backends/test_base.py +355 -0
- kilntainers/backends/test_docker.py +841 -0
- kilntainers/backends/test_docker_integration.py +310 -0
- kilntainers/backends/test_docker_management.py +122 -0
- kilntainers/backends/test_utils.py +152 -0
- kilntainers/backends/test_virtual_docker.py +387 -0
- kilntainers/cli.py +375 -0
- kilntainers/computers.py +284 -0
- kilntainers/config.py +71 -0
- kilntainers/dashboard.html +4263 -0
- kilntainers/dashboard.py +23 -0
- kilntainers/desktop.py +47 -0
- kilntainers/desktop_control.py +105 -0
- kilntainers/desktop_image/Dockerfile +67 -0
- kilntainers/desktop_image/desktop-control.py +661 -0
- kilntainers/desktop_image/start-desktop.sh +206 -0
- kilntainers/desktop_image/visual-action.py +185 -0
- kilntainers/desktop_image/wsproxy.py +218 -0
- kilntainers/errors.py +36 -0
- kilntainers/file_tools.py +320 -0
- kilntainers/server.py +1760 -0
- kilntainers/test_cli.py +704 -0
- kilntainers/test_cli_integration.py +213 -0
- kilntainers/test_computers.py +122 -0
- kilntainers/test_config.py +135 -0
- kilntainers/test_dashboard.py +113 -0
- kilntainers/test_desktop_control.py +109 -0
- kilntainers/test_e2e_mcp.py +310 -0
- kilntainers/test_errors.py +62 -0
- kilntainers/test_file_tools.py +175 -0
- kilntainers/test_http_lifecycle.py +353 -0
- kilntainers/test_lifecycle_integration.py +389 -0
- kilntainers/test_server.py +849 -0
- kilntainers/test_windows_docker.py +300 -0
- kilntainers/windows_docker.py +565 -0
- mcp_virtual_computer-0.2.3.dist-info/METADATA +179 -0
- mcp_virtual_computer-0.2.3.dist-info/RECORD +48 -0
- mcp_virtual_computer-0.2.3.dist-info/WHEEL +5 -0
- mcp_virtual_computer-0.2.3.dist-info/entry_points.txt +5 -0
- mcp_virtual_computer-0.2.3.dist-info/licenses/LICENSE +7 -0
- mcp_virtual_computer-0.2.3.dist-info/top_level.txt +1 -0
kilntainers/__init__.py
ADDED
kilntainers/__main__.py
ADDED
kilntainers/auth.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Minimal static bearer-token protection for remote Streamable HTTP."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
|
|
5
|
+
from starlette.datastructures import Headers
|
|
6
|
+
from starlette.responses import JSONResponse
|
|
7
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BearerTokenMiddleware:
|
|
11
|
+
"""Require a configured bearer token on the MCP protocol route."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, app: ASGIApp, *, token: str) -> None:
|
|
14
|
+
self.app = app
|
|
15
|
+
self.token = token
|
|
16
|
+
|
|
17
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
18
|
+
if scope["type"] != "http" or not scope.get("path", "").startswith("/mcp"):
|
|
19
|
+
await self.app(scope, receive, send)
|
|
20
|
+
return
|
|
21
|
+
authorization = Headers(scope=scope).get("authorization", "")
|
|
22
|
+
scheme, _, supplied = authorization.partition(" ")
|
|
23
|
+
if scheme.lower() != "bearer" or not hmac.compare_digest(
|
|
24
|
+
supplied,
|
|
25
|
+
self.token,
|
|
26
|
+
):
|
|
27
|
+
response = JSONResponse(
|
|
28
|
+
{"error": "unauthorized"},
|
|
29
|
+
status_code=401,
|
|
30
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
31
|
+
)
|
|
32
|
+
await response(scope, receive, send)
|
|
33
|
+
return
|
|
34
|
+
await self.app(scope, receive, send)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Docker-only backend registry for the first virtual-computer slice."""
|
|
2
|
+
|
|
3
|
+
from kilntainers.backends.base import Backend
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_backend_class(name: str) -> type[Backend]:
|
|
7
|
+
"""Return the only supported backend."""
|
|
8
|
+
if name != "docker":
|
|
9
|
+
raise KeyError(f"Unknown backend {name!r}. Available backends: docker")
|
|
10
|
+
from kilntainers.backends.docker import DockerBackend
|
|
11
|
+
|
|
12
|
+
return DockerBackend
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_available_backend_names() -> list[str]:
|
|
16
|
+
"""Return the deliberately narrow backend surface."""
|
|
17
|
+
return ["docker"]
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Backend abstraction layer — ABCs and shared types."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from inspect import signature
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from kilntainers.config import BackendConfig
|
|
13
|
+
from kilntainers.windows_docker import DockerRuntimeProgress
|
|
14
|
+
|
|
15
|
+
RuntimeProgressReporter = Callable[["DockerRuntimeProgress"], Awaitable[None]]
|
|
16
|
+
|
|
17
|
+
# --- Shared types ---
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ExecResult:
|
|
22
|
+
"""Result of a command execution.
|
|
23
|
+
|
|
24
|
+
The return type from every exec call. Immutable, with no optional
|
|
25
|
+
fields — every execution produces all four values.
|
|
26
|
+
|
|
27
|
+
Maps directly to the MCP response schema (Functional spec §2.2).
|
|
28
|
+
The MCP layer serializes this to JSON for the tool response.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
stdout: str
|
|
32
|
+
stderr: str
|
|
33
|
+
exit_code: int
|
|
34
|
+
exec_duration_ms: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
38
|
+
class ComputerInfo:
|
|
39
|
+
"""Provider-neutral description of a managed sandbox computer."""
|
|
40
|
+
|
|
41
|
+
computer_id: str
|
|
42
|
+
sandbox_id: str
|
|
43
|
+
backend: str
|
|
44
|
+
state: str
|
|
45
|
+
temporary: bool
|
|
46
|
+
image: str | None = None
|
|
47
|
+
created_at: str | None = None
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, str | bool | None]:
|
|
50
|
+
"""Return a JSON-serializable representation for MCP responses."""
|
|
51
|
+
return asdict(self)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
55
|
+
class ExecRequest:
|
|
56
|
+
"""Validated parameters for a command execution.
|
|
57
|
+
|
|
58
|
+
Constructed by the MCP layer after input validation. The MCP layer
|
|
59
|
+
resolves defaults (effective timeout, configured output limit) so
|
|
60
|
+
the backend always receives concrete values.
|
|
61
|
+
|
|
62
|
+
kw_only=True forces callers to use keyword arguments, which is
|
|
63
|
+
clearer for a dataclass with many optional fields.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
# Exactly one of command or args must be provided
|
|
67
|
+
command: str | None = None
|
|
68
|
+
args: list[str] | None = None
|
|
69
|
+
|
|
70
|
+
# Optional parameters
|
|
71
|
+
stdin: str | None = None
|
|
72
|
+
working_directory: str | None = None
|
|
73
|
+
|
|
74
|
+
# Always provided by MCP layer (defaults resolved before reaching backend)
|
|
75
|
+
timeout: int # seconds
|
|
76
|
+
output_limit: int # bytes
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
# Validate mutual exclusivity of command/args
|
|
80
|
+
if self.command is not None and self.args is not None:
|
|
81
|
+
raise ValueError("command and args are mutually exclusive")
|
|
82
|
+
if self.command is None and self.args is None:
|
|
83
|
+
raise ValueError("either command or args must be provided")
|
|
84
|
+
|
|
85
|
+
# Validate working_directory is absolute
|
|
86
|
+
if self.working_directory is not None and not self.working_directory.startswith(
|
|
87
|
+
"/"
|
|
88
|
+
):
|
|
89
|
+
raise ValueError("working_directory must be an absolute path")
|
|
90
|
+
|
|
91
|
+
# Validate timeout
|
|
92
|
+
if self.timeout < 1:
|
|
93
|
+
raise ValueError("timeout must be at least 1 second")
|
|
94
|
+
|
|
95
|
+
# Validate output_limit
|
|
96
|
+
if self.output_limit < 1:
|
|
97
|
+
raise ValueError("output_limit must be positive")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- ABCs ---
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Sandbox(ABC):
|
|
104
|
+
"""An active, isolated sandbox for executing commands.
|
|
105
|
+
|
|
106
|
+
Created by Backend.create_sandbox(). Each Sandbox is independent —
|
|
107
|
+
no shared state between Sandbox instances. Supports async context
|
|
108
|
+
manager for automatic cleanup.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def sandbox_id(self) -> str:
|
|
114
|
+
"""Unique identifier for this sandbox.
|
|
115
|
+
|
|
116
|
+
Used for logging, debugging, and session-to-sandbox mapping in
|
|
117
|
+
HTTP mode. For Docker, this is the container ID (short form).
|
|
118
|
+
"""
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def computer_id(self) -> str:
|
|
123
|
+
"""Stable human-facing ID used to reconnect to this computer.
|
|
124
|
+
|
|
125
|
+
Legacy backends do not expose a separate name, so their provider ID is
|
|
126
|
+
also used as the computer ID. Managed backends override this property.
|
|
127
|
+
"""
|
|
128
|
+
return getattr(self, "_managed_computer_id", self.sandbox_id)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def temporary(self) -> bool:
|
|
132
|
+
"""Whether this computer should be removed when its MCP owner exits."""
|
|
133
|
+
return getattr(self, "_managed_temporary", True)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def desktop_url(self) -> str | None:
|
|
137
|
+
"""Browser-reachable noVNC websocket URL, when a desktop is enabled."""
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def desktop_environment(self) -> bool:
|
|
142
|
+
"""Whether this sandbox is backed by a real desktop environment."""
|
|
143
|
+
return self.desktop_url is not None
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def network_access(self) -> bool:
|
|
147
|
+
"""Whether this sandbox currently permits outbound network traffic."""
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
@abstractmethod
|
|
151
|
+
async def exec(self, request: ExecRequest) -> ExecResult:
|
|
152
|
+
"""Execute a command in the sandbox.
|
|
153
|
+
|
|
154
|
+
Returns an ExecResult for all normal outcomes including timeout
|
|
155
|
+
and output-limit conditions. Raises SandboxDiedError if the
|
|
156
|
+
sandbox has died (before or during execution).
|
|
157
|
+
"""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
async def stop(self) -> None:
|
|
162
|
+
"""Stop the sandbox and release all resources.
|
|
163
|
+
|
|
164
|
+
Idempotent — safe to call on an already-stopped sandbox.
|
|
165
|
+
"""
|
|
166
|
+
...
|
|
167
|
+
|
|
168
|
+
@abstractmethod
|
|
169
|
+
async def wait_for_death(self) -> None:
|
|
170
|
+
"""Block until the sandbox dies unexpectedly.
|
|
171
|
+
|
|
172
|
+
Resolves when the sandbox terminates for reasons other than
|
|
173
|
+
stop() being called (OOM, external kill, daemon crash, etc.).
|
|
174
|
+
|
|
175
|
+
Must NOT resolve when stop() is called. Implementations track
|
|
176
|
+
whether stop was requested and suppress the signal in that case.
|
|
177
|
+
|
|
178
|
+
The MCP layer runs this as a background task to detect sandbox
|
|
179
|
+
death between exec calls. On normal shutdown, the MCP layer
|
|
180
|
+
cancels this task before calling stop().
|
|
181
|
+
"""
|
|
182
|
+
...
|
|
183
|
+
|
|
184
|
+
# --- Context manager support (concrete, not abstract) ---
|
|
185
|
+
|
|
186
|
+
async def __aenter__(self) -> "Sandbox":
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
async def __aexit__(
|
|
190
|
+
self,
|
|
191
|
+
exc_type: type[BaseException] | None,
|
|
192
|
+
exc_val: BaseException | None,
|
|
193
|
+
exc_tb: TracebackType | None,
|
|
194
|
+
) -> None:
|
|
195
|
+
await self.stop()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Backend(ABC):
|
|
199
|
+
"""Factory for creating sandboxes.
|
|
200
|
+
|
|
201
|
+
Configured at startup from CLI arguments. One instance per server
|
|
202
|
+
process. Creates independent Sandbox objects on demand.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(self, config: "BackendConfig") -> None:
|
|
206
|
+
"""Initialize the backend with configuration.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
config: The backend configuration.
|
|
210
|
+
"""
|
|
211
|
+
self._validated: bool = False
|
|
212
|
+
self._config = config
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def prepare_runtime(cls) -> None:
|
|
216
|
+
"""Prepare process-wide state before the server event loop is created.
|
|
217
|
+
|
|
218
|
+
Most backends need no preparation. Backends with SDKs that configure
|
|
219
|
+
global runtime state can override this hook so those changes apply only
|
|
220
|
+
when that backend is selected.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def start_runtime_preparation(self) -> None:
|
|
224
|
+
"""Start optional lazy host-runtime preparation without blocking."""
|
|
225
|
+
|
|
226
|
+
async def ensure_runtime(
|
|
227
|
+
self,
|
|
228
|
+
progress: RuntimeProgressReporter | None = None,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Wait for optional host-runtime preparation to finish."""
|
|
231
|
+
|
|
232
|
+
def runtime_status(self) -> dict[str, object]:
|
|
233
|
+
"""Return optional host-runtime preparation state for an MCP App."""
|
|
234
|
+
return {
|
|
235
|
+
"runtime_state": "ready",
|
|
236
|
+
"runtime_phase": "ready",
|
|
237
|
+
"runtime_message": "Container runtime bootstrap is not required.",
|
|
238
|
+
"runtime_progress": 4.0,
|
|
239
|
+
"runtime_total": 4.0,
|
|
240
|
+
"downloaded_bytes": None,
|
|
241
|
+
"download_total_bytes": None,
|
|
242
|
+
"runtime_error": None,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
@abstractmethod
|
|
247
|
+
def add_cli_arguments(cls, group: argparse._ArgumentGroup) -> None:
|
|
248
|
+
"""Register backend-specific CLI arguments on the given argparse group.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
group: An argparse argument group to add arguments to.
|
|
252
|
+
"""
|
|
253
|
+
...
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
@abstractmethod
|
|
257
|
+
def config_from_args(cls, args: argparse.Namespace) -> "BackendConfig":
|
|
258
|
+
"""Build backend config from parsed CLI arguments.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
args: Parsed command-line arguments from argparse.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
A BackendConfig subclass instance with this backend's configuration.
|
|
265
|
+
"""
|
|
266
|
+
...
|
|
267
|
+
|
|
268
|
+
async def validate(self) -> None:
|
|
269
|
+
"""Check all prerequisites. Raises BackendError on failure.
|
|
270
|
+
|
|
271
|
+
Results are cached — subsequent calls are no-ops after the
|
|
272
|
+
first successful validation.
|
|
273
|
+
"""
|
|
274
|
+
if self._validated:
|
|
275
|
+
return
|
|
276
|
+
await self._validate()
|
|
277
|
+
self._validated = True
|
|
278
|
+
|
|
279
|
+
@abstractmethod
|
|
280
|
+
async def _validate(self) -> None:
|
|
281
|
+
"""Implementation-specific validation. Override this method.
|
|
282
|
+
|
|
283
|
+
Check that the backend's prerequisites are met (e.g., Docker
|
|
284
|
+
daemon is reachable, configured image is valid). Raise
|
|
285
|
+
BackendError with an actionable message on failure.
|
|
286
|
+
"""
|
|
287
|
+
...
|
|
288
|
+
|
|
289
|
+
async def create_sandbox(
|
|
290
|
+
self,
|
|
291
|
+
*,
|
|
292
|
+
computer_id: str | None = None,
|
|
293
|
+
temporary: bool = True,
|
|
294
|
+
) -> Sandbox:
|
|
295
|
+
"""Create a new sandbox and return it ready to use.
|
|
296
|
+
|
|
297
|
+
Auto-validates if validate() has not been called. Returns a
|
|
298
|
+
ready-to-use Sandbox (readiness check already passed).
|
|
299
|
+
|
|
300
|
+
Managed backends use ``computer_id`` as a stable provider name and
|
|
301
|
+
``temporary`` to select cleanup behavior. Legacy backends may ignore
|
|
302
|
+
both values while still participating in the in-process registry.
|
|
303
|
+
"""
|
|
304
|
+
await self.validate()
|
|
305
|
+
parameters = signature(self._create_sandbox).parameters
|
|
306
|
+
if "computer_id" not in parameters:
|
|
307
|
+
# Compatibility for third-party backends built against the original
|
|
308
|
+
# no-argument _create_sandbox contract.
|
|
309
|
+
return await self._create_sandbox()
|
|
310
|
+
return await self._create_sandbox(
|
|
311
|
+
computer_id=computer_id,
|
|
312
|
+
temporary=temporary,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
@abstractmethod
|
|
316
|
+
async def _create_sandbox(
|
|
317
|
+
self,
|
|
318
|
+
*,
|
|
319
|
+
computer_id: str | None = None,
|
|
320
|
+
temporary: bool = True,
|
|
321
|
+
) -> Sandbox:
|
|
322
|
+
"""Implementation-specific sandbox creation. Override this method.
|
|
323
|
+
|
|
324
|
+
Must perform the full startup sequence:
|
|
325
|
+
1. Create the sandbox (e.g., docker run)
|
|
326
|
+
2. Verify readiness (e.g., trivial exec)
|
|
327
|
+
3. Return the ready Sandbox object
|
|
328
|
+
|
|
329
|
+
Raise BackendError if startup fails at any step.
|
|
330
|
+
"""
|
|
331
|
+
...
|
|
332
|
+
|
|
333
|
+
async def attach_sandbox(self, computer_id: str) -> Sandbox | None:
|
|
334
|
+
"""Attach to a provider computer created by an earlier process.
|
|
335
|
+
|
|
336
|
+
Backends with provider-side discovery should override this. Returning
|
|
337
|
+
``None`` means no matching computer exists.
|
|
338
|
+
"""
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
async def list_computers(self) -> list[ComputerInfo]:
|
|
342
|
+
"""List provider-side managed computers visible to this backend."""
|
|
343
|
+
return []
|
|
344
|
+
|
|
345
|
+
async def restart_computer(self, computer_id: str) -> Sandbox | None:
|
|
346
|
+
"""Restart and reattach a managed computer, if supported."""
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
async def delete_computer(self, computer_id: str) -> bool:
|
|
350
|
+
"""Permanently delete a provider-side computer, if supported."""
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
async def factory_reset_computer(self, computer_id: str) -> Sandbox | None:
|
|
354
|
+
"""Recreate a managed computer from its configured base image."""
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
async def set_network_access(
|
|
358
|
+
self, computer_id: str, enabled: bool
|
|
359
|
+
) -> Sandbox | None:
|
|
360
|
+
"""Change outbound network access for a managed computer, if supported."""
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
async def switch_desktop_environment(
|
|
364
|
+
self, computer_id: str, enabled: bool
|
|
365
|
+
) -> Sandbox | None:
|
|
366
|
+
"""Switch a managed computer's UI mode without losing its state."""
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
@abstractmethod
|
|
370
|
+
def tool_instructions(self) -> str | None:
|
|
371
|
+
"""Return tool description text for the terminal_execute tool.
|
|
372
|
+
|
|
373
|
+
Returns a string describing this backend's sandbox capabilities,
|
|
374
|
+
or None if the backend cannot provide a description (e.g.,
|
|
375
|
+
custom Docker image where the baked-in description doesn't apply).
|
|
376
|
+
|
|
377
|
+
The MCP layer uses this in tool description assembly. When None,
|
|
378
|
+
the server requires --tool-instruction-override.
|
|
379
|
+
"""
|
|
380
|
+
...
|