From 6569d9cb88038442ee20471d9e56791ee481d617 Mon Sep 17 00:00:00 2001
From: flujo-app <300233937+flujo-app@users.noreply.github.com>
Date: Sat, 5 Sep 2026 23:53:18 +0000
Subject: [PATCH 1/4] Secure companion access and migrate to MCP v2
---
.github/workflows/build_and_test.yml | 8 +
README.md | 40 ++
package-lock.json | 141 +++++-
package.json | 11 +-
pyproject.toml | 3 +-
scripts/live_fly_mcp_test.py | 82 ++--
scripts/smoke-docker.py | 229 ++++++++++
scripts/test_smoke_docker.py | 96 ++++
specs/architecture/architecture_summary.md | 4 +-
specs/functional_spec.md | 112 ++---
src/kilntainers/auth.py | 220 ++++++++-
src/kilntainers/cli.py | 109 +++--
src/kilntainers/config.py | 13 +-
src/kilntainers/dashboard.html | 472 ++++++++++---------
src/kilntainers/server.py | 503 ++++++++++++++-------
src/kilntainers/test_auth_boundaries.py | 298 ++++++++++++
src/kilntainers/test_cli.py | 64 ++-
src/kilntainers/test_cli_integration.py | 2 +-
src/kilntainers/test_config.py | 3 -
src/kilntainers/test_dashboard.py | 55 +--
src/kilntainers/test_http_lifecycle.py | 1 -
src/kilntainers/test_http_security.py | 319 +++++++++++++
src/kilntainers/test_server.py | 322 +++++++++++--
src/virtual-computer/app.ts | 30 +-
uv.lock | 107 +++--
25 files changed, 2524 insertions(+), 720 deletions(-)
create mode 100644 scripts/smoke-docker.py
create mode 100644 scripts/test_smoke_docker.py
create mode 100644 src/kilntainers/test_auth_boundaries.py
create mode 100644 src/kilntainers/test_http_security.py
diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml
index 48276d0..cd373c5 100644
--- a/.github/workflows/build_and_test.yml
+++ b/.github/workflows/build_and_test.yml
@@ -32,3 +32,11 @@ jobs:
- name: Build, lint, typecheck, test, and package
run: npm run check
+
+ - name: Exercise built wheel against disposable Docker computers
+ env:
+ AUTO_INSTALL_DOCKER: "false"
+ run: |
+ uv run pytest scripts/test_smoke_docker.py -q
+ docker info > /dev/null
+ uv run --no-project --with "$(find dist -maxdepth 1 -name '*.whl' -print -quit)" python scripts/smoke-docker.py
diff --git a/README.md b/README.md
index df5b386..0d7848c 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,46 @@ additionally exposes:
The MCP App can always call `runtime_status`, `set_network_access`, and `set_desktop_environment`. Set `EXPOSE_LIFECYCLE_TOOLS=true` to additionally expose those lifecycle controls to the model; they remain model-hidden by default.
+## Protocol, HTTP access, and persistence
+
+The server uses the official Python MCP SDK 2.1.1 and serves protocol
+2026-07-28 (`server/discover`) as well as SDK-supported legacy clients. Stdio
+remains supported. The standalone browser uses the TypeScript SDK v2 client;
+the embedded MCP App uses its host bridge. MCP Apps support is advertised
+through the SDK's public extension API.
+
+Each server is a trusted, single-computer service selected by `COMPUTER_ID`.
+Clients of the same server share that computer; protocol connections are not
+tenant boundaries. The computer and its files survive MCP disconnects and
+ordinary server shutdown. No connection creates a disposable computer.
+The old `--session-timeout` option has been removed because it never enforced
+idle cleanup. Remove it from existing launch configurations. Command execution
+deadlines still use `--timeout` or the tool's `timeout` argument.
+
+The stdio companion binds to loopback. `computer_ui` returns a dashboard URL
+with a fresh, process-scoped browser capability. Open that complete URL; a bare
+`/dashboard.html` URL is intentionally unauthorized. The page removes the
+capability from its address bar and uses a header for activity/MCP requests.
+Desktop WebSockets use the capability in their URL. Do not share these URLs.
+They expire when the MCP server process exits. Embedded Apps access tools via
+the host bridge; an opaque iframe Origin is accepted on desktop WebSockets
+only with the valid capability.
+
+HTTP mode supports a static bearer configured by `KILNTAINERS_AUTH_TOKEN` or
+`--auth-token`, including protection of sensitive companion routes. A listener
+outside loopback requires that token unless explicitly deployed behind a
+trusted authentication proxy with `--allow-unauthenticated-http`. This is a
+static-token deployment mode, not an OAuth authorization server. Use TLS at
+the reverse proxy for remote access. Supply its exact request Host and browser
+Origin with repeatable `--allowed-host` and `--allowed-origin` options; wildcard
+origins and untrusted browser origins are rejected. Health status at `/healthz`
+remains public and contains no computer state.
+
+Activity history keeps bounded operation metadata and byte counts, not raw
+commands, stdin, file contents, output, or browser capability URLs. HTTP access
+logs are disabled to keep capability query parameters out of request logs;
+protected responses use `Cache-Control: no-store` and `Referrer-Policy: no-referrer`.
+
## Architecture
diff --git a/package-lock.json b/package-lock.json
index 080f1ba..4f8957d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,8 +8,8 @@
"name": "mcp-virtual-computer-build-tools",
"version": "0.2.11",
"dependencies": {
+ "@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/ext-apps": "^1.7.5",
- "@modelcontextprotocol/sdk": "^1.30.0",
"@novnc/novnc": "^1.6.0",
"three": "^0.185.0"
},
@@ -19,7 +19,7 @@
"typescript": "^5.9.3"
},
"engines": {
- "node": ">=20"
+ "node": ">=22.13.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
@@ -476,6 +476,7 @@
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
"integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20"
},
@@ -483,6 +484,36 @@
"hono": "^4"
}
},
+ "node_modules/@modelcontextprotocol/client": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz",
+ "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/core": "2.0.0",
+ "cross-spawn": "^7.0.5",
+ "eventsource": "^3.0.2",
+ "eventsource-parser": "^3.0.0",
+ "jose": "^6.1.3",
+ "pkce-challenge": "^5.0.0",
+ "zod": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@modelcontextprotocol/core": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz",
+ "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==",
+ "license": "MIT",
+ "dependencies": {
+ "zod": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@modelcontextprotocol/ext-apps": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz",
@@ -517,6 +548,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
@@ -605,6 +637,7 @@
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
@@ -618,6 +651,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -634,6 +668,7 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@@ -651,6 +686,7 @@
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
@@ -675,6 +711,7 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -688,6 +725,7 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -697,6 +735,7 @@
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@@ -710,6 +749,7 @@
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -726,6 +766,7 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -739,6 +780,7 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -748,6 +790,7 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -757,6 +800,7 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=6.6.0"
}
@@ -766,6 +810,7 @@
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"object-assign": "^4",
"vary": "^1"
@@ -797,6 +842,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -814,6 +860,7 @@
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -823,6 +870,7 @@
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@@ -836,13 +884,15 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -852,6 +902,7 @@
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -861,6 +912,7 @@
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -870,6 +922,7 @@
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"es-errors": "^1.3.0"
},
@@ -923,13 +976,15 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -960,6 +1015,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -1003,6 +1059,7 @@
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
"integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
@@ -1021,7 +1078,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/fast-uri": {
"version": "3.1.6",
@@ -1037,7 +1095,8 @@
"url": "https://opencollective.com/fastify"
}
],
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "peer": true
},
"node_modules/fflate": {
"version": "0.8.3",
@@ -1051,6 +1110,7 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
@@ -1072,6 +1132,7 @@
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -1081,6 +1142,7 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -1090,6 +1152,7 @@
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -1099,6 +1162,7 @@
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
@@ -1123,6 +1187,7 @@
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@@ -1136,6 +1201,7 @@
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.4"
},
@@ -1148,6 +1214,7 @@
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.4"
},
@@ -1160,6 +1227,7 @@
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -1172,6 +1240,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz",
"integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -1181,6 +1250,7 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
@@ -1201,6 +1271,7 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -1216,13 +1287,15 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "license": "ISC"
+ "license": "ISC",
+ "peer": true
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 12"
}
@@ -1232,6 +1305,7 @@
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.10"
}
@@ -1240,7 +1314,8 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/isexe": {
"version": "2.0.0",
@@ -1261,19 +1336,22 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
- "license": "BSD-2-Clause"
+ "license": "BSD-2-Clause",
+ "peer": true
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -1283,6 +1361,7 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
},
@@ -1296,6 +1375,7 @@
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -1315,6 +1395,7 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -1324,6 +1405,7 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -1339,13 +1421,15 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"content-type": "^2.1.0"
},
@@ -1362,6 +1446,7 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -1375,6 +1460,7 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1384,6 +1470,7 @@
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.4"
},
@@ -1396,6 +1483,7 @@
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"ee-first": "1.1.1"
},
@@ -1408,6 +1496,7 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
+ "peer": true,
"dependencies": {
"wrappy": "1"
}
@@ -1417,6 +1506,7 @@
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -1435,6 +1525,7 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
@@ -1454,6 +1545,7 @@
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
@@ -1467,6 +1559,7 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
+ "peer": true,
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
@@ -1483,6 +1576,7 @@
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
},
@@ -1496,6 +1590,7 @@
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
@@ -1511,6 +1606,7 @@
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1520,6 +1616,7 @@
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
@@ -1535,13 +1632,15 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
@@ -1568,6 +1667,7 @@
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
@@ -1586,7 +1686,8 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
- "license": "ISC"
+ "license": "ISC",
+ "peer": true
},
"node_modules/shebang-command": {
"version": "2.0.0",
@@ -1614,6 +1715,7 @@
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
@@ -1633,6 +1735,7 @@
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
@@ -1649,6 +1752,7 @@
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -1667,6 +1771,7 @@
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -1686,6 +1791,7 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -1701,6 +1807,7 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.6"
}
@@ -1710,6 +1817,7 @@
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
@@ -1728,6 +1836,7 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -1755,6 +1864,7 @@
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -1764,6 +1874,7 @@
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -1787,7 +1898,8 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "license": "ISC"
+ "license": "ISC",
+ "peer": true
},
"node_modules/zod": {
"version": "4.5.4",
@@ -1803,6 +1915,7 @@
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
+ "peer": true,
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
diff --git a/package.json b/package.json
index 1995b76..f13370d 100644
--- a/package.json
+++ b/package.json
@@ -9,19 +9,20 @@
"sync-version": "node scripts/sync-version.mjs",
"version:check": "node scripts/sync-version.mjs --check",
"version": "node scripts/sync-version.mjs && git add pyproject.toml uv.lock server.json src/kilntainers/__init__.py",
- "check": "npm run build:app && node scripts/check-release.mjs",
+ "check": "npm run typecheck:app && npm run build:app && node scripts/check-release.mjs",
"release": "node scripts/release.mjs",
"release:check": "node scripts/release.mjs --check",
"registry:release": "node scripts/publish-mcp.mjs",
"registry:validate": "node scripts/publish-mcp.mjs --dry-run",
"mcp:publish": "node scripts/publish-mcp.mjs",
- "mcp:validate": "node scripts/publish-mcp.mjs --dry-run"
+ "mcp:validate": "node scripts/publish-mcp.mjs --dry-run",
+ "typecheck:app": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --lib DOM,ES2022 --skipLibCheck src/virtual-computer/app.ts"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.7.5",
- "@modelcontextprotocol/sdk": "^1.30.0",
"@novnc/novnc": "^1.6.0",
- "three": "^0.185.0"
+ "three": "^0.185.0",
+ "@modelcontextprotocol/client": "2.0.0"
},
"devDependencies": {
"@types/three": "^0.185.0",
@@ -29,6 +30,6 @@
"typescript": "^5.9.3"
},
"engines": {
- "node": ">=20"
+ "node": ">=22.13.0"
}
}
diff --git a/pyproject.toml b/pyproject.toml
index 0c1c082..e6f7dfc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,7 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"certifi>=2026.1.4",
- "mcp>=1.26.0,<2",
+ "mcp==2.1.1",
"websockets>=15.0.1,<16",
]
@@ -49,6 +49,7 @@ package = true
[dependency-groups]
dev = [
+ "httpx>=0.28.1,<1",
"coverage>=7.13.4",
"diff-cover>=10.2.0",
"dotenv>=0.9.9",
diff --git a/scripts/live_fly_mcp_test.py b/scripts/live_fly_mcp_test.py
index b8e1a3b..7317131 100644
--- a/scripts/live_fly_mcp_test.py
+++ b/scripts/live_fly_mcp_test.py
@@ -9,13 +9,13 @@
from pathlib import Path
from typing import Any
-from mcp import ClientSession, StdioServerParameters
-from mcp.client.stdio import stdio_client
+from mcp.client import Client
+from mcp.client.stdio import StdioServerParameters
from mcp.types import ImageContent
def _structured(result: Any) -> dict[str, Any]:
- payload = result.structuredContent
+ payload = result.structured_content
return payload if isinstance(payload, dict) else {}
@@ -37,51 +37,43 @@ async def run(computer_id: str, output: Path) -> None:
cwd=Path(__file__).resolve().parents[1],
)
- async with stdio_client(server) as (read_stream, write_stream):
- async with ClientSession(read_stream, write_stream) as session:
- await session.initialize()
- ui_result = await session.call_tool("computer_ui", {})
- if ui_result.isError:
- raise RuntimeError(f"computer_ui failed: {ui_result.content}")
- ui = _structured(ui_result)
+ async with Client(server, mode="auto") as session:
+ ui_result = await session.call_tool("computer_ui", {})
+ if ui_result.is_error:
+ raise RuntimeError(f"computer_ui failed: {ui_result.content}")
+ ui = _structured(ui_result)
- screen_result = await session.call_tool(
- "look_at_screen",
- {"include_image": True, "include_accessibility": True},
- )
- if screen_result.isError:
- raise RuntimeError(f"look_at_screen failed: {screen_result.content}")
- image = next(
- (
- item
- for item in screen_result.content
- if isinstance(item, ImageContent)
- ),
- None,
- )
- if image is None:
- raise RuntimeError("look_at_screen returned no image content")
+ screen_result = await session.call_tool(
+ "look_at_screen",
+ {"include_image": True, "include_accessibility": True},
+ )
+ if screen_result.is_error:
+ raise RuntimeError(f"look_at_screen failed: {screen_result.content}")
+ image = next(
+ (item for item in screen_result.content if isinstance(item, ImageContent)),
+ None,
+ )
+ if image is None:
+ raise RuntimeError("look_at_screen returned no image content")
- output.parent.mkdir(parents=True, exist_ok=True)
- image_bytes = base64.b64decode(image.data)
- output.write_bytes(image_bytes)
- accessibility = _structured(screen_result).get("accessibility", {})
- print(
- json.dumps(
- {
- "computer_ui_url": ui.get("url"),
- "desktop_url": ui.get("desktop_url"),
- "computer_id": ui.get("computer_id"),
- "desktop_environment": ui.get("desktop_environment"),
- "screenshot": str(output.resolve()),
- "screenshot_bytes": len(image_bytes),
- "accessibility_applications": accessibility.get(
- "applications", []
- ),
- },
- indent=2,
- )
+ output.parent.mkdir(parents=True, exist_ok=True)
+ image_bytes = base64.b64decode(image.data)
+ output.write_bytes(image_bytes)
+ accessibility = _structured(screen_result).get("accessibility", {})
+ print(
+ json.dumps(
+ {
+ "computer_ui_url": ui.get("url"),
+ "desktop_url": ui.get("desktop_url"),
+ "computer_id": ui.get("computer_id"),
+ "desktop_environment": ui.get("desktop_environment"),
+ "screenshot": str(output.resolve()),
+ "screenshot_bytes": len(image_bytes),
+ "accessibility_applications": accessibility.get("applications", []),
+ },
+ indent=2,
)
+ )
def main() -> None:
diff --git a/scripts/smoke-docker.py b/scripts/smoke-docker.py
new file mode 100644
index 0000000..c555b40
--- /dev/null
+++ b/scripts/smoke-docker.py
@@ -0,0 +1,229 @@
+"""Smoke-test the installed wheel against one disposable headless Docker computer.
+
+CI runs this after building the wheel with:
+uv run --no-project --with dist/.whl python scripts/smoke-docker.py
+
+Every MCP connection is a fresh real CLI subprocess. Cleanup can remove only the
+unique fixture container whose name, immutable ID, and ownership labels match.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import uuid
+from pathlib import Path
+from typing import Any
+
+from mcp.client import Client
+from mcp.client.stdio import StdioServerParameters
+
+import kilntainers
+
+IMAGE = "debian:bookworm-slim"
+SMOKE_LABEL = "kilntainers.smoke-run"
+MODERN_PROTOCOL = "2026-07-28"
+LEGACY_PROTOCOL = "2025-11-25"
+
+
+def docker(*arguments: str, timeout: int = 30) -> str:
+ """Run a bounded Docker command without a shell."""
+ result = subprocess.run(
+ ["docker", *arguments],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ return result.stdout
+
+
+def inspect_fixture(computer_id: str, run_id: str) -> dict[str, Any] | None:
+ """Find only our exact random name and verify ownership before using its ID."""
+ if computer_id != f"mcp-smoke-{run_id}" or not re.fullmatch(
+ r"[0-9a-f]{32}", run_id
+ ):
+ raise RuntimeError("Refusing a non-smoke computer identity")
+ name = f"kilntainer-{computer_id}"
+ ids = docker(
+ "container",
+ "ls",
+ "--all",
+ "--quiet",
+ "--no-trunc",
+ "--filter",
+ f"name=^/{name}$",
+ ).split()
+ if not ids:
+ return None
+ if len(ids) != 1 or not re.fullmatch(r"[0-9a-f]{64}", ids[0]):
+ raise RuntimeError("Unexpected fixture container identity")
+ records = json.loads(docker("container", "inspect", ids[0]))
+ if not isinstance(records, list) or len(records) != 1:
+ raise RuntimeError("Unexpected fixture inspection response")
+ record = records[0]
+ labels = record.get("Config", {}).get("Labels") or {}
+ expected_labels = {
+ "kilntainers": "true",
+ "kilntainers.computer-id": computer_id,
+ "kilntainers.temporary": "false",
+ SMOKE_LABEL: run_id,
+ }
+ if (
+ record.get("Id") != ids[0]
+ or record.get("Name") != f"/{name}"
+ or not record["Name"].startswith("/kilntainer-mcp-smoke-")
+ or any(labels.get(key) != value for key, value in expected_labels.items())
+ or record.get("Config", {}).get("Image") != IMAGE
+ ):
+ raise RuntimeError("Fixture ownership mismatch; refusing container cleanup")
+ return record
+
+
+def cleanup(computer_id: str, run_id: str) -> None:
+ """Remove only the verified immutable ID; never prune or delete by a prefix."""
+ record = inspect_fixture(computer_id, run_id)
+ if record is None:
+ return
+ container_id = record["Id"]
+ docker("container", "rm", "--force", container_id)
+ if inspect_fixture(computer_id, run_id) is not None:
+ raise RuntimeError("Fixture remained after cleanup")
+ print(json.dumps({"cleanup": "removed", "container_id": container_id}))
+
+
+async def call(client: Client, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
+ result = await client.call_tool(name, arguments, read_timeout_seconds=90)
+ if result.is_error or not isinstance(result.structured_content, dict):
+ raise RuntimeError(f"{name} failed: {result.content!r}")
+ return result.structured_content
+
+
+async def exercise(computer_id: str, run_id: str, directory: str) -> None:
+ environment = os.environ.copy()
+ environment.pop("PYTHONPATH", None)
+ environment.update(
+ {
+ "BACKEND": "docker",
+ "COMPUTER_ID": computer_id,
+ "DESKTOP_ENVIRONMENT": "false",
+ "NETWORK_ACCESS": "false",
+ "EXPOSE_LIFECYCLE_TOOLS": "false",
+ }
+ )
+ parameters = StdioServerParameters(
+ command=sys.executable,
+ args=[
+ "-m",
+ "kilntainers",
+ "--backend",
+ "docker",
+ "--image",
+ IMAGE,
+ "--timeout",
+ "30",
+ f"--docker-run-flag=--label={SMOKE_LABEL}={run_id}",
+ ],
+ env=environment,
+ cwd=directory,
+ )
+ first_container_id: str | None = None
+ for mode in ("auto", "legacy"):
+ path = f"/workspace/{mode}-persistence.txt"
+ content = f"persistent {mode} MCP smoke {run_id}\nUTF-8: café\n"
+ expected_protocol = MODERN_PROTOCOL if mode == "auto" else LEGACY_PROTOCOL
+ for reconnect in (False, True):
+ async with Client(parameters, mode=mode, read_timeout_seconds=90) as client:
+ if client.protocol_version != expected_protocol:
+ raise RuntimeError(
+ f"{mode} negotiated unexpected protocol {client.protocol_version}"
+ )
+ catalog = await client.list_tools()
+ names = {tool.name for tool in catalog.tools}
+ if not {"terminal_execute", "write_file", "read_file"} <= names:
+ raise RuntimeError("Installed artifact is missing core tools")
+ terminal = await call(
+ client,
+ "terminal_execute",
+ {"command": "printf 'docker-smoke-ok\\n'; pwd", "timeout": 30},
+ )
+ if terminal.get("exit_code") != 0 or terminal.get("stdout") != (
+ "docker-smoke-ok\n/workspace\n"
+ ):
+ raise RuntimeError(f"Unexpected terminal result: {terminal!r}")
+ if not reconnect:
+ written = await call(
+ client, "write_file", {"path": path, "content": content}
+ )
+ if (
+ written.get("sha256")
+ != hashlib.sha256(content.encode()).hexdigest()
+ ):
+ raise RuntimeError("write_file did not save the expected bytes")
+ read = await call(client, "read_file", {"path": path})
+ if read.get("content") != content:
+ raise RuntimeError("File contents did not persist across processes")
+ record = inspect_fixture(computer_id, run_id)
+ if record is None or not record.get("State", {}).get("Running"):
+ raise RuntimeError("Persistent computer did not survive MCP disconnect")
+ if first_container_id is None:
+ first_container_id = record["Id"]
+ elif record["Id"] != first_container_id:
+ raise RuntimeError("MCP reconnect replaced the persistent container")
+ print(
+ json.dumps(
+ {
+ "protocol": expected_protocol,
+ "reconnect": reconnect,
+ "computer_id": computer_id,
+ "container_id": first_container_id,
+ "terminal_and_files": "passed",
+ }
+ ),
+ flush=True,
+ )
+
+
+async def main() -> None:
+ """Exercise only a generated fixture, with cleanup even after failures."""
+ repository = Path(__file__).resolve().parents[1]
+ module_path = Path(kilntainers.__file__).resolve()
+ if module_path.is_relative_to(repository / "src"):
+ raise RuntimeError("Run with the built wheel, not the editable source tree")
+ run_id = uuid.uuid4().hex
+ computer_id = f"mcp-smoke-{run_id}"
+ # Preflight is read-only and precedes the cleanup scope. An existing name is
+ # never adopted, even in the fantastically unlikely event of a UUID collision.
+ docker("info")
+ existing = docker(
+ "container",
+ "ls",
+ "--all",
+ "--quiet",
+ "--filter",
+ f"name=^/kilntainer-{computer_id}$",
+ ).strip()
+ if existing:
+ raise RuntimeError(
+ "Generated fixture name already exists; refusing to reuse it"
+ )
+ docker("pull", IMAGE, timeout=180)
+ print(
+ json.dumps({"installed_module": str(module_path), "computer_id": computer_id})
+ )
+ try:
+ with tempfile.TemporaryDirectory(prefix="mcp-wheel-smoke-") as directory:
+ async with asyncio.timeout(240):
+ await exercise(computer_id, run_id, directory)
+ finally:
+ cleanup(computer_id, run_id)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/scripts/test_smoke_docker.py b/scripts/test_smoke_docker.py
new file mode 100644
index 0000000..f856665
--- /dev/null
+++ b/scripts/test_smoke_docker.py
@@ -0,0 +1,96 @@
+"""Deletion boundaries for the disposable Docker acceptance fixture."""
+
+import copy
+import importlib.util
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+_spec = importlib.util.spec_from_file_location(
+ "smoke_docker", Path(__file__).with_name("smoke-docker.py")
+)
+assert _spec is not None and _spec.loader is not None
+smoke = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(smoke)
+
+RUN_ID = "a" * 32
+COMPUTER_ID = f"mcp-smoke-{RUN_ID}"
+CONTAINER_ID = "b" * 64
+
+
+def fixture_record() -> dict[str, Any]:
+ return {
+ "Id": CONTAINER_ID,
+ "Name": f"/kilntainer-{COMPUTER_ID}",
+ "Config": {
+ "Image": "debian:bookworm-slim",
+ "Labels": {
+ "kilntainers": "true",
+ "kilntainers.computer-id": COMPUTER_ID,
+ "kilntainers.temporary": "false",
+ "kilntainers.smoke-run": RUN_ID,
+ },
+ },
+ }
+
+
+@pytest.mark.parametrize("mismatch", ["name", "id", "computer", "run", "image"])
+def test_cleanup_refuses_foreign_container(monkeypatch, mismatch):
+ record = copy.deepcopy(fixture_record())
+ if mismatch == "name":
+ record["Name"] = "/kilntainer-agent-workstation"
+ elif mismatch == "id":
+ record["Id"] = "c" * 64
+ elif mismatch == "computer":
+ record["Config"]["Labels"]["kilntainers.computer-id"] = "agent-workstation"
+ elif mismatch == "run":
+ record["Config"]["Labels"]["kilntainers.smoke-run"] = "other-run"
+ else:
+ record["Config"]["Image"] = "another-image"
+ calls = []
+
+ def docker(*args, **kwargs):
+ calls.append(args)
+ if args[:2] == ("container", "ls"):
+ return CONTAINER_ID + "\n"
+ if args[:2] == ("container", "inspect"):
+ return json.dumps([record])
+ raise AssertionError("Must never remove an unverified container")
+
+ monkeypatch.setattr(smoke, "docker", docker)
+ with pytest.raises(RuntimeError, match="ownership mismatch"):
+ smoke.cleanup(COMPUTER_ID, RUN_ID)
+ assert not any("rm" in args for args in calls)
+
+
+def test_cleanup_removes_only_verified_immutable_id(monkeypatch):
+ calls = []
+ removed = False
+
+ def docker(*args, **kwargs):
+ nonlocal removed
+ calls.append(args)
+ if args[:2] == ("container", "ls"):
+ return "" if removed else CONTAINER_ID + "\n"
+ if args[:2] == ("container", "inspect"):
+ return json.dumps([fixture_record()])
+ assert args == ("container", "rm", "--force", CONTAINER_ID)
+ removed = True
+ return CONTAINER_ID + "\n"
+
+ monkeypatch.setattr(smoke, "docker", docker)
+ smoke.cleanup(COMPUTER_ID, RUN_ID)
+ assert [args for args in calls if "rm" in args] == [
+ ("container", "rm", "--force", CONTAINER_ID)
+ ]
+
+
+def test_cleanup_never_queries_non_smoke_identity(monkeypatch):
+ def docker(*args, **kwargs):
+ raise AssertionError("A non-smoke identity must never reach Docker")
+
+ monkeypatch.setattr(smoke, "docker", docker)
+ with pytest.raises(RuntimeError, match="non-smoke"):
+ smoke.cleanup("agent-workstation", RUN_ID)
diff --git a/specs/architecture/architecture_summary.md b/specs/architecture/architecture_summary.md
index 4ffde66..0b419b4 100644
--- a/specs/architecture/architecture_summary.md
+++ b/specs/architecture/architecture_summary.md
@@ -20,7 +20,7 @@ How the Docker backend implements the abstraction layer: subprocess calls to the
### [Phase 4: MCP Server & Tool Layer](mcp_server.md)
-MCP library evaluation (official `mcp` SDK v1.x with built-in FastMCP), server architecture with lifespan context for per-session sandbox management, `terminal_execute` tool registration with dynamic description, the tool handler implementation (input validation, `ExecRequest` construction, `ExecResult` → JSON response formatting, `isError` mapping), tool description assembly rules, transport configuration (stdio and Streamable HTTP), and the `create_server()` factory function.
+MCP library evaluation (official `mcp` SDK v2 with public MCPServer and Extension APIs), server architecture with application lifespan context for ownership of the configured permanent computer, `terminal_execute` tool registration with dynamic description, the tool handler implementation (input validation, `ExecRequest` construction, `ExecResult` → JSON response formatting, `isError` mapping), tool description assembly rules, transport configuration (stdio and Streamable HTTP), and the `create_server()` factory function.
### [Phase 5: CLI, Configuration & Startup](cli_and_startup.md)
@@ -28,7 +28,7 @@ Argument parsing with `argparse` (no third-party CLI libraries), the `ServerConf
### [Phase 6: Connection & Session Lifecycle](connection_lifecycle.md)
-How stdio and Streamable HTTP transports map to sandbox lifecycles: stdio runs one sandbox for the process lifetime, HTTP runs one per `Mcp-Session-Id` session. Covers session creation and request routing, idle session timeout (`--session-timeout`) and its SDK integration, the one-sandbox-per-session ownership model, sandbox death propagation (SIGTERM self-signal for stdio, request-time detection for HTTP), graceful shutdown orchestration (cancel death task → stop sandbox), force-kill timeouts, and edge cases (concurrent death and exec, rapid reconnection, SIGTERM during creation).
+Both transports serve the same configured permanent computer. Modern MCP requests are stateless; supported legacy sessions do not own or destroy a computer. Cleanup releases process ownership and death monitors. See current functional spec section 4 and README; older phase subdocuments record the upstream disposable-sandbox design and are superseded for this fork.
### [Modal Backend Implementation](modal_backend.md)
diff --git a/specs/functional_spec.md b/specs/functional_spec.md
index f37ccf2..f3476a9 100644
--- a/specs/functional_spec.md
+++ b/specs/functional_spec.md
@@ -165,14 +165,13 @@ Kilntainers is configured through CLI parameters at startup. One server instance
| `--output-limit` | integer (bytes) | `2097152` | Max combined stdout+stderr per exec. (D24) |
| `--extended-tool-instruction` | string | — | Appended to backend's tool description. (D16) |
| `--tool-instruction-override` | string | — | Replaces the entire tool description. (D16) |
-| `--session-timeout` | integer (sec) | `300` | Idle session timeout (HTTP mode only). |
-> **Note:** `--session-timeout` only applies to Streamable HTTP mode, where the server manages multiple concurrent sessions. In stdio mode, the session lives as long as the process runs. Passing session-timeout when stdio should error explaining why.
+> **Current behavior:** the no-op `--session-timeout` option was removed. Persistent computer lifetime is independent of protocol connections.
**Constraints:**
- `--extended-tool-instruction` and `--tool-instruction-override` are mutually exclusive. Providing both is a startup error. (D16)
-- `--host`, `--port`, and `--session-timeout` error if passed to stdio mode where they do no apply.
+- Stdio serves its authenticated companion on loopback; `--port` can select its port. A non-loopback `--host` is rejected for stdio.
- `--host` defaults to `127.0.0.1` (localhost only) for security. Set to `0.0.0.0` for remote access — see Section 9 security notes.
### 3.2 Docker Backend Parameters (V1)
@@ -207,88 +206,43 @@ Steps 1–2 are synchronous and complete before the server accepts any MCP conne
## 4. Connection Lifecycle
-### 4.1 stdio Transport
+### 4.1 Configured permanent computer
-One sandbox for the lifetime of the server process. (D8)
+One process owns the computer named by required `COMPUTER_ID`. Both stdio and
+HTTP clients of that process share that computer. There is no per-connection
+tenant isolation and no automatic disposable computer selection.
-```
-Process starts → validate config → accept MCP messages
- → first terminal_execute → start sandbox (validate backend, pull image if needed)
- → execute command → ... → stdin closes or SIGTERM → stop sandbox → exit
-```
-
-**Lazy sandbox creation:** The server accepts MCP connections and responds to non-exec requests (`tools/list`, etc.) immediately after config validation. The sandbox is created on the first `terminal_execute` call. Image pull happens during this first exec and blocks that call until complete. First run with a new image will be slow; subsequent runs use Docker's image cache. (D18)
-
-If no `terminal_execute` is ever called during the session, no sandbox is created and no container resources are consumed.
-
-### 4.2 Streamable HTTP Transport
-
-Multiple concurrent sessions, each with its own independent sandbox. (D8, D28)
-
-```
-Server starts → validate config → listen on host:port
-
-Per session:
- initialize request → return session ID
- → accept tool calls → first terminal_execute → start sandbox
- → execute command → ... → session ends → stop sandbox (if started)
-```
-
-**Lazy sandbox creation:** The `initialize` request completes immediately without creating a sandbox. The sandbox is created on the first `terminal_execute` call within the session. If the session ends without any `terminal_execute` calls, no sandbox resources are consumed.
-
-Sessions are identified by the `Mcp-Session-Id` header per the MCP Streamable HTTP protocol. A session ends when:
-
-- The client explicitly closes it.
-- No requests are received for `--session-timeout` seconds (default: 5 minutes).
-- The sandbox dies (D23).
-
-Multiple sessions can be active simultaneously. Each has an independent sandbox — no shared state between sessions.
-
-### 4.3 Sandbox Startup Sequence
-
-Sandbox creation is **lazy** — it happens on the first `terminal_execute` call, not at connection time. The full startup sequence runs when the first `terminal_execute` is received:
-
-1. **Validate backend prerequisites** (cached after first success) — e.g., verify the Docker daemon is reachable. (This was previously done at server startup.)
-2. **Pull image** if not locally available — blocking. (D18) Progress should be logged to stderr so the user knows something is happening.
-3. **Create and start** the sandbox (e.g., `docker run`).
-4. **Verify readiness** — execute a trivial command (e.g., `echo kilntainers-ready`) to confirm the sandbox accepts exec calls.
-5. **Return the exec result** for the first command.
-
-**Concurrency:** If multiple `terminal_execute` calls arrive before the sandbox is ready, only one sandbox is created. Concurrent calls wait for the same creation to complete.
-
-**Timeout isolation:** The command timeout parameter applies only to the command execution (step 5 conceptually), not to the sandbox startup time (steps 1–4). Sandbox startup has its own internal timeouts defined by the backend.
-
-**Failure handling:** If any startup step fails, the `terminal_execute` call returns an MCP error (`isError: true`) with an actionable message. The session remains alive — subsequent `terminal_execute` calls will retry sandbox creation. Once a sandbox is successfully created, it is used for all future calls in that session. If the sandbox dies after successful creation, the session is dead (see §4.5).
-
-**No sandbox for non-exec requests:** `tools/list`, `initialize`, and other MCP protocol requests never trigger sandbox creation. The server responds to these immediately.
-
-### 4.4 Graceful Shutdown
-
-When a connection ends normally:
-
-- **stdio** — stdin closes or process receives SIGTERM.
-- **HTTP session** — client closes session, or idle timeout expires.
-- **HTTP server** — process receives SIGTERM (all active sessions are torn down).
-
-Shutdown sequence:
+### 4.2 Protocol connections
-1. Any in-flight exec is **killed immediately.** The client is disconnecting — no one will receive the result.
-2. The sandbox is stopped (e.g., `docker stop`).
-3. Sandbox resources are cleaned up. (For Docker, `--rm` handles this automatically when the container stops; other future backends may require explicit cleanup.)
-4. If cleanup takes more than **10 seconds**, force-kill and proceed.
+MCP 2026-07-28 uses stateless requests and `server/discover`; supported legacy
+clients may use initialization and HTTP session IDs. These protocol details do
+not select, create or destroy computers. The no-op `--session-timeout` option
+was removed instead of promising unsupported idle cleanup.
-### 4.5 Sandbox Death
+### 4.3 Lazy startup
-If the sandbox dies unexpectedly (OOM, killed externally, Docker daemon crash): (D6, D23)
+Protocol discovery and tool listing do not provision a computer. The first
+computer operation validates backend prerequisites, creates or reattaches the
+configured computer, and waits for readiness. Concurrent attachment is guarded
+by the registry and application context. Failures return actionable tool errors;
+later calls may retry. Command timeouts bound execution, separately from backend
+provisioning deadlines. No user files are erased as part of attachment.
-- **During an exec call:** Return an MCP error (`isError: true`) for the in-flight call with a message explaining the sandbox terminated unexpectedly, then drop the connection.
-- **Between exec calls:** Drop the connection immediately. The client sees a disconnected server.
+### 4.4 Shutdown and reconnect
-**stdio:** Process exits. Most MCP clients will offer to restart the server, which gives the user a fresh sandbox.
+When stdin closes or the process stops, application cleanup cancels its death
+monitors and releases registry ownership. It does not stop, reset or delete the
+permanent computer. A later process using the same `COMPUTER_ID` reattaches to
+the persisted computer and workspace. Each process creates a new dashboard
+capability; old browser URLs cease to authorize that server.
-**HTTP:** The session is terminated. The client can create a new session and get a new sandbox.
+### 4.5 Unexpected computer death
-No restart is attempted. Sandbox death is unrecoverable in v1. (D6)
+Backend failures surface as tool errors. Stdio's death monitor terminates its
+own MCP process on unexpected computer death; HTTP operations can refresh
+backend state. Reconnecting always targets the configured computer identity,
+never an automatically allocated replacement identity. Destructive provider
+actions require an explicit lifecycle operation outside ordinary disconnects.
---
@@ -549,7 +503,7 @@ kilntainers \
| **Resource exhaustion** — CPU abuse, memory bombs, disk fill, fork bombs | Backend-specific resource limits (`--cpu`, `--memory`, Docker PID limits via `--docker-run-flag`). Exec timeout prevents indefinite CPU use. |
| **Container escape** | Relies on the backend's isolation technology (Docker, WASI). Not a Kilntainers-specific concern — use up-to-date container runtimes. |
| **Host filesystem access** | No mounts by default. Future mapped working directory will be scoped to a single user-specified directory. (D14) |
-| **MCP server abuse** (HTTP mode) | Default bind to `127.0.0.1`. `--session-timeout` reclaims idle resources. No built-in authentication — production HTTP deployments should use a reverse proxy with auth. |
+| **MCP server abuse** (HTTP mode) | Default bind to `127.0.0.1`. Static bearer authentication and scoped companion capabilities protect sensitive routes. Exact Origin/Host checks reject browser rebinding; remote deployments use TLS. |
### 9.3 Operator Responsibilities
@@ -580,8 +534,8 @@ The following open items from [spec_queue.md](spec_queue.md) were resolved in th
| **Container startup flow** | Pull → create/start → verify readiness → accept calls. Pull failure = startup error. | §4.3 |
| **Docker config approach** | Flat CLI args for v1 with `--docker-run-flag` escape hatch for uncovered options. | §3.2 |
| **Tool description text** | Drafted for Docker backend with dynamic shell, timeout, and output limit values. Custom image → no description, requires override. | §7 |
-| **Startup parameters** | Full schema in §3 including transport, host, port, session-timeout. | §3.1, §3.2 |
-| **Connection lifecycle** | stdio: one sandbox per process. Streamable HTTP: one sandbox per session, identified by Mcp-Session-Id. 5-minute idle timeout (configurable). | §4 |
+| **Startup parameters** | Full schema in §3 including transport, host, port, exec deadlines and trusted HTTP origins. | §3.1, §3.2 |
+| **Connection lifecycle** | One permanent configured computer across both transports and reconnects; no automatic idle destruction. | §4 |
| **Security model** | Threat model covering exfiltration, resource abuse, container escape, host access, and HTTP exposure. | §9 |
| **D8 transport correction** | Streamable HTTP, not SSE. These are different transports; SSE is deprecated. D8 updated. | §1 |
| **No additional logging** | No logging system in v1. Focus on great error responses. Standard HTTP logging via reverse proxy if needed. (D31) | — |
diff --git a/src/kilntainers/auth.py b/src/kilntainers/auth.py
index c108664..a7ff366 100644
--- a/src/kilntainers/auth.py
+++ b/src/kilntainers/auth.py
@@ -1,34 +1,218 @@
-"""Minimal static bearer-token protection for remote Streamable HTTP."""
+"""Authentication boundaries for MCP and its browser companion.
+
+The random companion capability is deliberately separate from a configured MCP
+bearer token. It lets sandboxed MCP App frames open desktop WebSockets without
+putting a long-lived server credential in a URL or a browser cookie.
+"""
import hmac
+import secrets
+from collections.abc import Iterable
+from dataclasses import dataclass, field
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
-from starlette.datastructures import Headers
+from starlette.datastructures import Headers, MutableHeaders
from starlette.responses import JSONResponse
-from starlette.types import ASGIApp, Receive, Scope, Send
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
+
+_ACCESS_QUERY = "computer_access"
+_ACCESS_HEADER = "x-computer-access"
+_COMPANION_PATHS = frozenset(
+ {"/", "/activity", "/dashboard.html", "/desktop/websockify", "/desktop/audio"}
+)
+
+
+@dataclass(frozen=True, slots=True)
+class CompanionAccess:
+ """An unguessable capability valid only for this server process."""
+
+ token: str = field(repr=False)
+
+ @classmethod
+ def generate(cls) -> "CompanionAccess":
+ """Create a fresh capability; never reuse the remote MCP bearer token."""
+ return cls(secrets.token_urlsafe(32))
+
+ def url(self, url: str) -> str:
+ """Attach the capability to a companion URL, replacing any old value."""
+ parts = urlsplit(url)
+ query = [
+ (key, value)
+ for key, value in parse_qsl(parts.query, keep_blank_values=True)
+ if key != _ACCESS_QUERY
+ ]
+ query.append((_ACCESS_QUERY, self.token))
+ return urlunsplit(parts._replace(query=urlencode(query)))
+
+
+def _origin(value: str) -> tuple[str, str, int] | None:
+ """Parse a serialized web origin without accepting URLs with path/userinfo."""
+ if value != value.strip() or any(ord(character) < 32 for character in value):
+ return None
+ try:
+ parsed = urlsplit(value)
+ if (
+ parsed.scheme not in {"http", "https"}
+ or not parsed.hostname
+ or parsed.username is not None
+ or parsed.password is not None
+ or parsed.path
+ or parsed.query
+ or parsed.fragment
+ or parsed.port == 0
+ ):
+ return None
+ return (
+ parsed.scheme,
+ parsed.hostname.lower(),
+ parsed.port or (443 if parsed.scheme == "https" else 80),
+ )
+ except ValueError:
+ return None
+
+
+def _matches(supplied: str, expected: str | None) -> bool:
+ """Compare bytes so malformed non-ASCII credentials cannot raise TypeError."""
+ return bool(expected) and hmac.compare_digest(
+ supplied.encode("utf-8"), expected.encode("utf-8")
+ )
class BearerTokenMiddleware:
- """Require a configured bearer token on the MCP protocol route."""
+ """Protect MCP and companion routes with separate, explicit credentials.
- def __init__(self, app: ASGIApp, *, token: str) -> None:
+ Browser origins are compared with a fixed allowlist, never the untrusted
+ Host header. A sandboxed frame's opaque null origin, and a WebSocket
+ without Origin, require a companion capability. A bearer token alone never
+ authorizes those browser contexts.
+
+ allow_mcp_capability is an explicit opt-in for a standalone dashboard
+ that calls /mcp directly. Otherwise the capability cannot call MCP tools.
+ allow_unauthenticated_mcp preserves an explicitly configured local HTTP
+ endpoint; it still validates Origin and Host and does not expose companion
+ routes. Health checks alone are public.
+ """
+
+ def __init__(
+ self,
+ app: ASGIApp,
+ *,
+ token: str | None = None,
+ companion_access: CompanionAccess | None = None,
+ allowed_origins: Iterable[str] = (),
+ allow_mcp_capability: bool = False,
+ allow_unauthenticated_mcp: bool = False,
+ allow_opaque_origin: bool = True,
+ ) -> None:
self.app = app
self.token = token
+ self.companion_access = companion_access
+ origins = [_origin(value) for value in allowed_origins]
+ if any(value is None for value in origins):
+ raise ValueError("allowed_origins must contain HTTP(S) origins")
+ self.allowed_origins = frozenset(value for value in origins if value)
+ self.allow_mcp_capability = allow_mcp_capability
+ self.allow_unauthenticated_mcp = allow_unauthenticated_mcp
+ self.allow_opaque_origin = allow_opaque_origin
+ if allow_unauthenticated_mcp and not self.allowed_origins:
+ raise ValueError("Unauthenticated MCP requires fixed allowed_origins")
+
+ def _capability(self, scope: Scope, headers: Headers) -> bool:
+ if self.companion_access is None:
+ return False
+ values = headers.getlist(_ACCESS_HEADER)
+ try:
+ query = parse_qsl(
+ scope.get("query_string", b"").decode("ascii"),
+ keep_blank_values=True,
+ max_num_fields=64,
+ )
+ except (UnicodeDecodeError, ValueError):
+ return False
+ values.extend(value for key, value in query if key == _ACCESS_QUERY)
+ return len(values) == 1 and _matches(values[0], self.companion_access.token)
+
+ def _bearer(self, headers: Headers) -> bool:
+ values = headers.getlist("authorization")
+ if len(values) != 1:
+ return False
+ scheme, _, supplied = values[0].partition(" ")
+ return scheme.lower() == "bearer" and _matches(supplied, self.token)
+
+ def _allowed_origin(
+ self, scope: Scope, headers: Headers, *, capability: bool
+ ) -> bool:
+ values = headers.getlist("origin")
+ if not values:
+ return scope["type"] != "websocket" or capability
+ if len(values) != 1:
+ return False
+ if values[0] == "null":
+ return capability and self.allow_opaque_origin
+ origin = _origin(values[0])
+ return origin is not None and origin in self.allowed_origins
+
+ def _allowed_host(self, scope: Scope, headers: Headers) -> bool:
+ """Bind optional anonymous MCP to configured addresses, not DNS rebinding."""
+ hosts = headers.getlist("host")
+ if len(hosts) != 1:
+ return False
+ scheme = "https" if scope.get("scheme") == "https" else "http"
+ return _origin(f"{scheme}://{hosts[0]}") in self.allowed_origins
+
+ async def _reject(
+ self, scope: Scope, receive: Receive, send: Send, *, forbidden: bool = False
+ ) -> None:
+ if scope["type"] == "websocket":
+ await send({"type": "websocket.close", "code": 1008})
+ return
+ headers = {"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"}
+ if not forbidden:
+ headers["WWW-Authenticate"] = "Bearer"
+ response = JSONResponse(
+ {"error": "forbidden origin" if forbidden else "unauthorized"},
+ status_code=403 if forbidden else 401,
+ headers=headers,
+ )
+ await response(scope, receive, send)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
- if scope["type"] != "http" or not scope.get("path", "").startswith("/mcp"):
+ if scope["type"] not in {"http", "websocket"}:
await self.app(scope, receive, send)
return
- authorization = Headers(scope=scope).get("authorization", "")
- scheme, _, supplied = authorization.partition(" ")
- if scheme.lower() != "bearer" or not hmac.compare_digest(
- supplied,
- self.token,
+ path = scope.get("path", "")
+ if (
+ scope["type"] == "http"
+ and path == "/healthz"
+ and scope.get("method") in {"GET", "HEAD"}
):
- response = JSONResponse(
- {"error": "unauthorized"},
- status_code=401,
- headers={"WWW-Authenticate": "Bearer"},
- )
- await response(scope, receive, send)
+ await self.app(scope, receive, send)
+ return
+
+ headers = Headers(scope=scope)
+ is_mcp = path == "/mcp" or path.startswith("/mcp/")
+ capability = (
+ path in _COMPANION_PATHS or (is_mcp and self.allow_mcp_capability)
+ ) and self._capability(scope, headers)
+ if not self._allowed_origin(scope, headers, capability=capability):
+ await self._reject(scope, receive, send, forbidden=True)
return
- await self.app(scope, receive, send)
+
+ anonymous_mcp = (
+ scope["type"] == "http"
+ and is_mcp
+ and self.allow_unauthenticated_mcp
+ and self._allowed_host(scope, headers)
+ )
+ if not (self._bearer(headers) or capability or anonymous_mcp):
+ await self._reject(scope, receive, send)
+ return
+
+ async def private_response(message: Message) -> None:
+ if message["type"] == "http.response.start":
+ response_headers = MutableHeaders(scope=message)
+ response_headers["Cache-Control"] = "no-store"
+ response_headers["Referrer-Policy"] = "no-referrer"
+ await send(message)
+
+ await self.app(scope, receive, private_response)
diff --git a/src/kilntainers/cli.py b/src/kilntainers/cli.py
index e223bee..98d1b44 100644
--- a/src/kilntainers/cli.py
+++ b/src/kilntainers/cli.py
@@ -9,6 +9,7 @@
import threading
from dataclasses import replace
from typing import NoReturn
+from urllib.parse import urlsplit
from kilntainers.auth import BearerTokenMiddleware
from kilntainers.backends import (
@@ -18,7 +19,7 @@
from kilntainers.computers import validate_computer_id
from kilntainers.config import BackendConfig, ServerConfig, env_flag
from kilntainers.errors import BackendError
-from kilntainers.server import create_server
+from kilntainers.server import create_http_app, create_server
# Sentinel for detecting unset HTTP-only arguments
_UNSET = object()
@@ -83,16 +84,22 @@ def build_parser() -> argparse.ArgumentParser:
help="Max combined stdout+stderr bytes per exec (default: 2097152 = 2 MiB)",
)
core.add_argument(
- "--session-timeout",
- type=int,
- default=_UNSET,
- help="Idle session timeout in seconds (default: 300, HTTP mode only)",
+ "--allowed-host",
+ action="append",
+ default=[],
+ help="Additional trusted HTTP Host value (host[:port]); repeat for a proxy.",
+ )
+ core.add_argument(
+ "--allowed-origin",
+ action="append",
+ default=[],
+ help="Additional exact trusted HTTP(S) browser origin; repeat as needed.",
)
core.add_argument(
"--auth-token",
default=os.getenv("KILNTAINERS_AUTH_TOKEN"),
help=(
- "Static bearer token for the /mcp HTTP route "
+ "Static bearer token for MCP and sensitive companion HTTP routes "
"(default: KILNTAINERS_AUTH_TOKEN)"
),
)
@@ -161,7 +168,6 @@ def build_configs(
# Handle HTTP-only args that may be _UNSET
host = "127.0.0.1" if args.host is _UNSET else args.host
port = 8435 if args.port is _UNSET else args.port
- session_timeout = 300 if args.session_timeout is _UNSET else args.session_timeout
server_config = ServerConfig(
transport=args.transport,
@@ -178,7 +184,8 @@ def build_configs(
),
tool_instruction_override=args.tool_instruction_override,
extended_tool_instruction=args.extended_tool_instruction,
- session_timeout=session_timeout,
+ allowed_http_hosts=tuple(args.allowed_host),
+ allowed_http_origins=tuple(args.allowed_origin),
auth_token=args.auth_token,
allow_unauthenticated_http=args.allow_unauthenticated_http,
)
@@ -238,6 +245,27 @@ def validate_config(server_config: ServerConfig) -> None:
"the backend default."
)
+ for origin in server_config.allowed_http_origins:
+ parsed = urlsplit(origin)
+ if (
+ parsed.scheme not in {"http", "https"}
+ or not parsed.netloc
+ or parsed.username
+ or parsed.password
+ or parsed.path
+ or parsed.query
+ or parsed.fragment
+ or "*" in origin
+ ):
+ _startup_error(
+ "--allowed-origin must be an exact HTTP(S) origin without a path."
+ )
+ for host in server_config.allowed_http_hosts:
+ if not host or any(char in host for char in "/@?#* \t\r\n"):
+ _startup_error(
+ "--allowed-host must be a literal host[:port], without wildcards."
+ )
+
# Timeout must be positive
if server_config.default_timeout < 1:
_startup_error("--timeout must be at least 1 second.")
@@ -247,7 +275,9 @@ def validate_config(server_config: ServerConfig) -> None:
_startup_error("--output-limit must be at least 1 byte.")
if not server_config.computer_id:
- _startup_error("COMPUTER_ID is required (example: COMPUTER_ID=agent-workstation).")
+ _startup_error(
+ "COMPUTER_ID is required (example: COMPUTER_ID=agent-workstation)."
+ )
try:
validate_computer_id(server_config.computer_id)
except BackendError as error:
@@ -299,7 +329,7 @@ async def _async_main(
if server_config.transport == "stdio":
await _run_stdio_with_dashboard(mcp, server_config)
else:
- await mcp.run_streamable_http_async()
+ await _run_http(mcp, server_config)
def _available_loopback_port() -> int:
@@ -309,11 +339,48 @@ def _available_loopback_port() -> int:
return int(listener.getsockname()[1])
+def _protected_http_app(mcp, server_config: ServerConfig):
+ """Apply the same security boundary to HTTP and the stdio companion."""
+ app = create_http_app(mcp, server_config)
+ host = server_config.host
+ if host in {"127.0.0.1", "localhost", "::1", "0.0.0.0", "::"}:
+ hosts = ("127.0.0.1", "localhost", "[::1]")
+ else:
+ hosts = (f"[{host}]" if ":" in host else host,)
+ origins = tuple(f"http://{item}:{server_config.port}" for item in hosts)
+ app.add_middleware(
+ BearerTokenMiddleware, # ty: ignore[invalid-argument-type]
+ token=server_config.auth_token,
+ companion_access=server_config.companion_access,
+ allowed_origins=(*origins, *server_config.allowed_http_origins),
+ allow_mcp_capability=True,
+ allow_unauthenticated_mcp=(
+ server_config.transport == "http" and not server_config.auth_token
+ ),
+ )
+ return app
+
+
+async def _run_http(mcp, server_config: ServerConfig) -> None:
+ import uvicorn
+
+ server = uvicorn.Server(
+ uvicorn.Config(
+ _protected_http_app(mcp, server_config),
+ host=server_config.host,
+ port=server_config.port,
+ log_level="info",
+ access_log=False,
+ )
+ )
+ await server.serve()
+
+
async def _run_stdio_with_dashboard(mcp, server_config: ServerConfig) -> None:
"""Serve stdio MCP and a standalone loopback dashboard on one event loop."""
import uvicorn
- app = mcp.streamable_http_app()
+ app = _protected_http_app(mcp, server_config)
uvicorn_config = uvicorn.Config(
app,
host="127.0.0.1",
@@ -327,7 +394,9 @@ async def _run_stdio_with_dashboard(mcp, server_config: ServerConfig) -> None:
while not dashboard_server.started:
if dashboard_task.done():
await dashboard_task
- raise RuntimeError("The standalone dashboard server stopped during startup.")
+ raise RuntimeError(
+ "The standalone dashboard server stopped during startup."
+ )
await asyncio.sleep(0.01)
await mcp.run_stdio_async()
finally:
@@ -391,21 +460,7 @@ def _handle_sigterm(signum: int, frame: object) -> None:
try:
if server_config.transport == "stdio":
asyncio.run(_run_stdio_with_dashboard(mcp, server_config))
- elif server_config.auth_token:
- import uvicorn
-
- app = mcp.streamable_http_app()
- app.add_middleware(
- BearerTokenMiddleware, # ty: ignore[invalid-argument-type]
- token=server_config.auth_token,
- )
- uvicorn.run(
- app,
- host=server_config.host,
- port=server_config.port,
- log_level="info",
- )
else:
- mcp.run(transport="streamable-http")
+ asyncio.run(_run_http(mcp, server_config))
except KeyboardInterrupt:
pass # Clean exit on Ctrl+C
diff --git a/src/kilntainers/config.py b/src/kilntainers/config.py
index fd2abae..d4bf521 100644
--- a/src/kilntainers/config.py
+++ b/src/kilntainers/config.py
@@ -1,9 +1,11 @@
"""Configuration dataclasses for the single-computer Docker server."""
import os
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Literal
+from kilntainers.auth import CompanionAccess
+
Transport = Literal["stdio", "http"]
@@ -64,8 +66,13 @@ class ServerConfig:
tool_instruction_override: str | None = None
extended_tool_instruction: str | None = None
- # Session management (HTTP only)
- session_timeout: int = 300 # seconds (5 minutes)
+ # A fresh browser capability is scoped to this server process. Exclude it
+ # from repr/equality so diagnostics never reveal it.
+ companion_access: CompanionAccess = field(
+ default_factory=CompanionAccess.generate, repr=False, compare=False
+ )
+ allowed_http_hosts: tuple[str, ...] = ()
+ allowed_http_origins: tuple[str, ...] = ()
# Remote HTTP protection
auth_token: str | None = None
diff --git a/src/kilntainers/dashboard.html b/src/kilntainers/dashboard.html
index c06be6d..a8a1298 100644
--- a/src/kilntainers/dashboard.html
+++ b/src/kilntainers/dashboard.html
@@ -22,76 +22,69 @@
-