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 image 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 @@ - diff --git a/src/kilntainers/server.py b/src/kilntainers/server.py index c22d7a7..b6aa92b 100644 --- a/src/kilntainers/server.py +++ b/src/kilntainers/server.py @@ -9,15 +9,21 @@ from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from typing import Annotated, Any, AsyncContextManager, cast +from urllib.parse import urlsplit import certifi -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.types import CallToolResult, ImageContent, TextContent +from mcp.server import MCPServer +from mcp.server.apps import Apps +from mcp.server.mcpserver import Context +from mcp.server.mcpserver.resources import FunctionResource +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import CallToolResult, ImageContent, Resource, TextContent, Tool +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import Field +from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse -from starlette.routing import WebSocketRoute +from starlette.routing import BaseRoute, WebSocketRoute from starlette.websockets import WebSocket, WebSocketDisconnect from websockets.asyncio.client import connect as connect_websocket from websockets.typing import Subprotocol @@ -61,7 +67,7 @@ class SessionContext: - """Per-session state, available to tool handlers via Context. + """Application-lifetime state for the configured persistent computer. Supports lazy sandbox creation — the sandbox is only created on the first call to get_or_create_sandbox(). This allows the MCP @@ -295,8 +301,8 @@ def _result( """Create an MCP result with JSON fallback and structured app data.""" return CallToolResult( content=[TextContent(type="text", text=json.dumps(payload))], - isError=is_error, - structuredContent=payload, + is_error=is_error, + structured_content=payload, ) @@ -315,7 +321,7 @@ def _computer_ui_url(config: ServerConfig) -> str: host = "127.0.0.1" elif ":" in host and not host.startswith("["): host = f"[{host}]" - return f"http://{host}:{config.port}/dashboard.html" + return config.companion_access.url(f"http://{host}:{config.port}/dashboard.html") def _computer_desktop_proxy_url(config: ServerConfig) -> str: @@ -325,7 +331,7 @@ def _computer_desktop_proxy_url(config: ServerConfig) -> str: host = "127.0.0.1" elif ":" in host and not host.startswith("["): host = f"[{host}]" - return f"ws://{host}:{config.port}/desktop/websockify" + return config.companion_access.url(f"ws://{host}:{config.port}/desktop/websockify") def _connect_desktop_websocket( @@ -347,7 +353,7 @@ def _connect_desktop_websocket( def _session_from_context( - ctx: Context[ServerSession, SessionContext] | None, + ctx: Context[SessionContext, Any] | None, ) -> SessionContext | None: if ctx is None: return None @@ -420,7 +426,7 @@ def create_lifespan( death_callback: Callable[[], None] | None = None, registry: ComputerRegistry | None = None, computer_id: str = "virtual-computer", -) -> Callable[[FastMCP], AsyncContextManager[SessionContext]]: +) -> Callable[[MCPServer], AsyncContextManager[SessionContext]]: """Create a lifespan context manager for the given transport. The returned context manager creates a SessionContext that supports @@ -435,12 +441,12 @@ def create_lifespan( a custom callback to capture death notifications. Returns: - An async context manager function compatible with FastMCP. + An async context manager function compatible with MCPServer. """ @asynccontextmanager - async def lifespan(server: FastMCP) -> AsyncIterator[SessionContext]: - """Create a SessionContext for this session and clean up on exit.""" + async def lifespan(server: MCPServer) -> AsyncIterator[SessionContext]: + """Share one computer context for the serving lifetime and release on exit.""" ctx = SessionContext( backend=backend, transport=transport, @@ -524,7 +530,7 @@ async def terminal_execute_handler( stdin: str | None = None, working_directory: str | None = None, timeout: int | None = None, - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Handle a terminal_execute tool call. @@ -534,11 +540,12 @@ async def terminal_execute_handler( stdin: Content to pipe to stdin. working_directory: Working directory for the command (must be absolute). timeout: Timeout in seconds (defaults to server config). - ctx: FastMCP context object (injected automatically). + ctx: MCPServer context object (injected automatically). Returns: A CallToolResult with the execution result or error. """ + def error_result(message: str) -> CallToolResult: return _result( { @@ -566,7 +573,7 @@ def error_result(message: str) -> CallToolResult: return error_result(error) # --- Get sandbox from context --- - # ctx should always be provided by FastMCP, but handle None for safety + # ctx should always be provided by MCPServer, but handle None for safety if ctx is None: return error_result("Internal error: no context provided") @@ -592,16 +599,16 @@ async def report_runtime(update: DockerRuntimeProgress) -> None: # --- Construct ExecRequest --- # --- Execute --- try: - resolved_timeout = timeout if timeout is not None else config.default_timeout + resolved_timeout = ( + timeout if timeout is not None else config.default_timeout + ) if sandbox.desktop_environment and sandbox.desktop_url is not None: result = await visible_terminal_execute( sandbox, command=command, args=args, stdin=stdin, - working_directory=( - working_directory or config.workspace_directory - ), + working_directory=(working_directory or config.workspace_directory), timeout=resolved_timeout, output_limit=config.output_limit, ) @@ -635,8 +642,8 @@ async def report_runtime(update: DockerRuntimeProgress) -> None: return CallToolResult( content=[TextContent(type="text", text=response_json)], - isError=False, - structuredContent=response, + is_error=False, + structured_content=response, ) return terminal_execute_handler @@ -667,7 +674,7 @@ def _lifecycle_meta(*, model_visible: bool) -> dict[str, Any]: } -def _register_computer_tools(mcp: FastMCP, config: ServerConfig) -> None: +def _register_computer_tools(mcp: MCPServer, config: ServerConfig) -> None: """Register provider-neutral lifecycle tools used by models and the App.""" async def inventory(session: SessionContext) -> dict[str, Any]: @@ -678,7 +685,7 @@ async def inventory(session: SessionContext) -> dict[str, Any]: } async def computer_dashboard( - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Open the interactive sandbox computer dashboard.""" session = _session_from_context(ctx) @@ -692,7 +699,7 @@ async def computer_dashboard( return _result({"error": str(error)}, is_error=True) async def computer_list( - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """List temporary and permanent computers managed by this backend.""" session = _session_from_context(ctx) @@ -707,13 +714,13 @@ async def computer_list( async def computer_create( computer_id: Annotated[ - str, # noqa: RUF013 + str | None, Field( description=( "Optional lowercase slug. Omit to generate a readable random ID." ) ), - ] = None, # type: ignore + ] = None, temporary: Annotated[ bool, Field( @@ -723,7 +730,7 @@ async def computer_create( ) ), ] = True, - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Create or attach to a named sandbox computer.""" session = _session_from_context(ctx) @@ -750,7 +757,7 @@ async def computer_create( async def computer_restart( computer_id: Annotated[str, Field(description="Computer slug to restart")], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Restart a computer while preserving its writable filesystem.""" session = _session_from_context(ctx) @@ -776,7 +783,7 @@ async def computer_factory_reset( str, Field(description="Computer slug whose writable state will be erased"), ], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Erase a computer's writable state and recreate it from its base image.""" session = _session_from_context(ctx) @@ -802,7 +809,7 @@ async def computer_delete( str, Field(description="Computer slug to permanently delete"), ], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Permanently delete a computer and its writable filesystem.""" session = _session_from_context(ctx) @@ -874,25 +881,138 @@ def computer_dashboard_resource() -> str: return dashboard_html() -def _enable_mcp_apps_capability(mcp: FastMCP) -> None: - """Advertise the stable MCP Apps extension missing from MCP SDK 1.x types.""" - from mcp.types import ServerCapabilities +class VirtualComputerServer(MCPServer[SessionContext]): + """Keep application routes and desktop visibility outside SDK internals.""" - low_level_server = mcp._mcp_server - original = low_level_server.get_capabilities + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.companion_routes: list[BaseRoute] = [] + self.desktop_capabilities_enabled = True + super().__init__(*args, **kwargs) - def get_capabilities_with_apps( - notification_options: Any, - experimental_capabilities: dict[str, dict[str, Any]], - ) -> ServerCapabilities: - capabilities = original(notification_options, experimental_capabilities) - payload = capabilities.model_dump(by_alias=True, exclude_none=True) - payload["extensions"] = { - "io.modelcontextprotocol/ui": {"mimeTypes": [DASHBOARD_MIME_TYPE]} - } - return ServerCapabilities.model_validate(payload) + async def list_tools(self) -> list[Tool]: + return sorted(await super().list_tools(), key=lambda tool: tool.name) + + async def list_resources(self) -> list[Resource]: + resources = await super().list_resources() + if not self.desktop_capabilities_enabled: + resources = [ + resource + for resource in resources + if str(resource.uri) not in {SCREEN_IMAGE_URI, SCREEN_ACCESSIBILITY_URI} + ] + return sorted(resources, key=lambda resource: str(resource.uri)) + + async def read_resource( + self, + uri: Any, + context: Context[SessionContext, Any] | None = None, + ) -> Any: + if not self.desktop_capabilities_enabled and str(uri) in { + SCREEN_IMAGE_URI, + SCREEN_ACCESSIBILITY_URI, + }: + from mcp.server.mcpserver.exceptions import ResourceNotFoundError - setattr(low_level_server, "get_capabilities", get_capabilities_with_apps) + raise ResourceNotFoundError( + "Desktop resources are unavailable in headless mode." + ) + return await super().read_resource(uri, context) + + +def create_http_app(mcp: VirtualComputerServer, config: ServerConfig) -> Starlette: + """Build the dual-era SDK app; the caller wraps all routes with authorization.""" + loopback = config.host in {"127.0.0.1", "localhost", "::1", "0.0.0.0", "::"} + bind_hosts = ( + ["127.0.0.1", "localhost", "[::1]"] + if loopback + else [ + f"[{config.host}]" + if ":" in config.host and not config.host.startswith("[") + else config.host + ] + ) + hosts = [f"{host}:{config.port}" for host in bind_hosts] + if config.port == 80: + hosts.extend(bind_hosts) + origins = [ + *(f"http://{host}:{config.port}" for host in bind_hosts), + *config.allowed_http_origins, + ] + for origin in list(origins): + parsed = urlsplit(origin) + default_port = {"http": 80, "https": 443}.get(parsed.scheme) + if ( + default_port is not None + and parsed.hostname is not None + and parsed.port == default_port + and not (parsed.path or parsed.query or parsed.fragment) + and parsed.username is None + and parsed.password is None + ): + hostname = parsed.hostname + authority = f"[{hostname}]" if ":" in hostname else hostname + origins.append(f"{parsed.scheme}://{authority}") + security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=list(dict.fromkeys([*hosts, *config.allowed_http_hosts])), + allowed_origins=list(dict.fromkeys(origins)), + ) + app = mcp.streamable_http_app( + host=config.host, + streamable_http_path="/mcp", + transport_security=security, + ) + app.router.routes.extend(mcp.companion_routes) + return app + + +async def _notify_catalog_changed(ctx: Context[SessionContext, Any]) -> None: + """Use the appropriate public notification channel for the request's era.""" + if ctx.protocol_version in MODERN_PROTOCOL_VERSIONS: + await ctx.notify_tools_changed() + await ctx.notify_resources_changed() + else: + await ctx.session.send_tool_list_changed() + await ctx.session.send_resource_list_changed() + + +def _activity_snapshot( + phase: str, + operation: str, + arguments: dict[str, Any], + payload: dict[str, Any] | None, +) -> dict[str, Any]: + """Retain execution metadata, never commands, file text, outputs or URLs.""" + argument_summary = { + key: value + for key, value in arguments.items() + if key in {"path", "working_directory", "timeout"} + and isinstance(value, (str, int)) + } + payload_summary: dict[str, Any] = {} + if payload is not None: + for key in ( + "path", + "size_bytes", + "exit_code", + "exec_duration_ms", + "replacements", + ): + value = payload.get(key) + if isinstance(value, (str, int)): + payload_summary[key] = value + for key in ("stdout", "stderr", "content"): + value = payload.get(key) + if isinstance(value, str): + payload_summary[f"{key}_bytes"] = len(value.encode("utf-8")) + payload_summary["status"] = "error" if payload.get("error") else "complete" + return { + "phase": phase, + "operation": operation, + "arguments": argument_summary, + "payload": payload_summary if payload is not None else None, + "summary": f"{operation} {phase}", + } # --- Server Factory --- @@ -901,7 +1021,7 @@ def get_capabilities_with_apps( def create_server( backend: Backend, config: ServerConfig, -) -> FastMCP: +) -> VirtualComputerServer: """Create and configure the MCP server. Args: @@ -909,7 +1029,7 @@ def create_server( config: Server configuration (transport, host, port, timeouts, etc.). Returns: - Configured FastMCP instance ready to run. + Configured MCPServer instance ready to run. Raises: BackendError: If tool description assembly fails. @@ -931,18 +1051,20 @@ def create_server( ) # Create server - mcp = FastMCP( + from importlib.metadata import version + + mcp = VirtualComputerServer( name="MCP Virtual Computer", + version=version("mcp-virtual-computer"), lifespan=lifespan, - host=config.host, - port=config.port, + extensions=[Apps()], ) + dashboard_resource: FunctionResource | None = None def sync_dashboard_resource_meta(desktop_url: str | None = None) -> None: """Publish exact loopback origins for hosts that reject wildcard ports.""" - resource = mcp._resource_manager._resources.get(DASHBOARD_URI) - if resource is not None: - resource.meta = dashboard_resource_meta( + if dashboard_resource is not None: + dashboard_resource.meta = dashboard_resource_meta( _computer_ui_url(config), _computer_desktop_proxy_url(config), desktop_url, @@ -968,10 +1090,7 @@ def publish_activity( activity_events.append( { "revision": activity_revision, - "phase": phase, - "operation": operation, - "arguments": arguments, - "payload": payload, + **_activity_snapshot(phase, operation, arguments, payload), } ) del activity_events[:-64] @@ -989,7 +1108,9 @@ async def activity(request: Request) -> JSONResponse: return JSONResponse( { "revision": activity_revision, - "events": [event for event in activity_events if event["revision"] > after], + "events": [ + event for event in activity_events if event["revision"] > after + ], }, headers={"Cache-Control": "no-store"}, ) @@ -1087,7 +1208,7 @@ async def desktop_to_browser() -> None: except RuntimeError: pass - mcp._custom_starlette_routes.append( + mcp.companion_routes.append( cast( Any, WebSocketRoute( @@ -1101,28 +1222,30 @@ async def desktop_to_browser() -> None: handler = _create_handler(config) # Wrapper closure for better MCP type hinting - # type ignore and noqa needed to get the right type hints. Type hinting doesn't work for Optional[str] so str but assign None as default. + # SDK v2 validates optional inputs against their nullable annotations. async def terminal_execute( command: Annotated[ - str, # noqa: RUF013 + str | None, Field(description="Shell command string (mutually exclusive with args)."), - ] = None, # type: ignore + ] = None, args: Annotated[ - list[str], # noqa: RUF013 + list[str] | None, Field( description="List of arguments for direct execution (mutually exclusive with command)." ), - ] = None, # type: ignore - stdin: Annotated[str, Field(description="Content to pipe to stdin.")] = None, # type: ignore # noqa: RUF013 + ] = None, + stdin: Annotated[ + str | None, Field(description="Content to pipe to stdin.") + ] = None, working_directory: Annotated[ - str, # noqa: RUF013 + str | None, Field(description="Working directory for the command (must be absolute)."), - ] = None, # type: ignore + ] = None, timeout: Annotated[ - int, # noqa: RUF013 + int | None, Field(description="Timeout in seconds (defaults to server config)."), - ] = None, # type: ignore - ctx: Context[ServerSession, SessionContext] | None = None, + ] = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: arguments = { key: value @@ -1144,12 +1267,12 @@ async def terminal_execute( timeout=timeout, ctx=ctx, ) - payload = dict(result.structuredContent or {}) + payload = dict(result.structured_content or {}) if payload.get("desktop_url"): payload["desktop_url"] = public_desktop_url( registry.peek(config.computer_id) ) - result = _result(payload, is_error=bool(result.isError)) + result = _result(payload, is_error=bool(result.is_error)) publish_activity("result", "terminal_execute", arguments, payload) return result @@ -1160,7 +1283,7 @@ async def terminal_execute( ) async def computer_ui( - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Open the Three.js virtual computer.""" computer_url = _computer_ui_url(config) @@ -1208,12 +1331,9 @@ async def computer_ui( ) capabilities_changed = False if desktop_capability_sync is not None: - capabilities_changed = desktop_capability_sync( - sandbox.desktop_environment - ) + capabilities_changed = desktop_capability_sync(sandbox.desktop_environment) if capabilities_changed and ctx is not None: - await ctx.session.send_tool_list_changed() - await ctx.session.send_resource_list_changed() + await _notify_catalog_changed(ctx) sync_dashboard_resource_meta(sandbox.desktop_url) return _result( { @@ -1241,7 +1361,7 @@ async def computer_ui( ) async def runtime_status( - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Return lazy host-runtime setup state for the computer App.""" session = _session_from_context(ctx) @@ -1272,12 +1392,9 @@ async def runtime_status( ) capabilities_changed = False if sandbox is not None and desktop_capability_sync is not None: - capabilities_changed = desktop_capability_sync( - sandbox.desktop_environment - ) + capabilities_changed = desktop_capability_sync(sandbox.desktop_environment) if capabilities_changed and ctx is not None: - await ctx.session.send_tool_list_changed() - await ctx.session.send_resource_list_changed() + await _notify_catalog_changed(ctx) return _result( { "operation": "idle", @@ -1308,12 +1425,14 @@ async def set_network_access( bool, Field(description="Whether the computer may access the network."), ], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Plug in or unplug the virtual computer's real network connection.""" session = _session_from_context(ctx) if session is None: - return _result({"error": "Internal error: no context provided"}, is_error=True) + return _result( + {"error": "Internal error: no context provided"}, is_error=True + ) try: async with runtime_switch_lock: await session.get_or_create_sandbox() @@ -1335,14 +1454,18 @@ async def set_network_access( async def set_desktop_environment( enabled: Annotated[ bool, - Field(description="Use a real Xfce desktop instead of the virtual desktop."), + Field( + description="Use a real Xfce desktop instead of the virtual desktop." + ), ], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Switch the same computer between virtual and real desktops.""" session = _session_from_context(ctx) if session is None: - return _result({"error": "Internal error: no context provided"}, is_error=True) + return _result( + {"error": "Internal error: no context provided"}, is_error=True + ) try: async with runtime_switch_lock: await session.get_or_create_sandbox() @@ -1353,8 +1476,7 @@ async def set_desktop_environment( if desktop_capability_sync is not None: desktop_capability_sync(sandbox.desktop_environment) if ctx is not None: - await ctx.session.send_tool_list_changed() - await ctx.session.send_resource_list_changed() + await _notify_catalog_changed(ctx) return _result( { "operation": "idle", @@ -1390,7 +1512,7 @@ async def set_desktop_environment( ) async def _sandbox_for_file_tool( - ctx: Context[ServerSession, SessionContext] | None, + ctx: Context[SessionContext, Any] | None, ) -> tuple[SessionContext, Sandbox]: session = _session_from_context(ctx) if session is None: @@ -1411,7 +1533,7 @@ async def list_directory( str, Field(description="Directory path, absolute or relative to /workspace."), ] = ".", - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """List a real Docker directory for manual Explorer interaction.""" try: @@ -1476,9 +1598,11 @@ def file_payload( async def read_file( path: Annotated[ str, - Field(description="UTF-8 text file path, absolute or relative to /workspace."), + Field( + description="UTF-8 text file path, absolute or relative to /workspace." + ), ], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Read a UTF-8 text file while the virtual computer opens and scrolls it.""" arguments = {"path": path} @@ -1525,14 +1649,16 @@ async def read_file( async def write_file( path: Annotated[ str, - Field(description="UTF-8 text file path, absolute or relative to /workspace."), + Field( + description="UTF-8 text file path, absolute or relative to /workspace." + ), ], content: Annotated[str, Field(description="Complete UTF-8 text to save.")], create_parent_directories: Annotated[ bool, Field(description="Create missing parent folders before saving."), ] = True, - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Atomically write a UTF-8 file while the virtual editor types it.""" arguments = { @@ -1600,18 +1726,24 @@ async def write_file( async def edit_file( path: Annotated[ str, - Field(description="UTF-8 text file path, absolute or relative to /workspace."), + Field( + description="UTF-8 text file path, absolute or relative to /workspace." + ), ], old_text: Annotated[ str, - Field(description="Exact text to select and replace; include context if ambiguous."), + Field( + description="Exact text to select and replace; include context if ambiguous." + ), ], new_text: Annotated[str, Field(description="Replacement UTF-8 text.")], replace_all: Annotated[ bool, - Field(description="Replace every exact match instead of requiring one match."), + Field( + description="Replace every exact match instead of requiring one match." + ), ] = False, - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Replace exact text while the virtual editor selects and overwrites it.""" arguments = { @@ -1667,9 +1799,21 @@ async def edit_file( return _result(payload, is_error=True) for tool, name, tool_description in ( - (read_file, "read_file", "Read a UTF-8 text file and show it being opened and scrolled on the virtual computer."), - (write_file, "write_file", "Write a UTF-8 text file and show it being typed and saved on the virtual computer."), - (edit_file, "edit_file", "Replace exact UTF-8 text and show it being selected, overwritten, and saved."), + ( + read_file, + "read_file", + "Read a UTF-8 text file and show it being opened and scrolled on the virtual computer.", + ), + ( + write_file, + "write_file", + "Write a UTF-8 text file and show it being typed and saved on the virtual computer.", + ), + ( + edit_file, + "edit_file", + "Replace exact UTF-8 text and show it being selected, overwritten, and saved.", + ), ): mcp.add_tool(tool, name=name, description=tool_description) @@ -1678,7 +1822,7 @@ async def edit_file( if True: async def _live_desktop( - ctx: Context[ServerSession, SessionContext] | None, + ctx: Context[SessionContext, Any] | None, ) -> Sandbox: _session, sandbox = await _sandbox_for_file_tool(ctx) if sandbox.desktop_url is None: @@ -1731,7 +1875,7 @@ async def look_at_screen( bool, Field(description="Include the current AT-SPI accessibility snapshot."), ] = True, - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Look at the live desktop as pixels, accessible elements, or both.""" if not include_image and not include_accessibility: @@ -1762,19 +1906,19 @@ async def look_at_screen( ImageContent( type="image", data=base64.b64encode(image).decode("ascii"), - mimeType="image/png", + mime_type="image/png", ) ) return CallToolResult( content=content, - isError=False, - structuredContent=payload, + is_error=False, + structured_content=payload, ) except (BackendError, DesktopControlError, SandboxDiedError) as error: return _result({"error": str(error)}, is_error=True) async def _run_desktop_tool( - ctx: Context[ServerSession, SessionContext] | None, + ctx: Context[SessionContext, Any] | None, action: str, payload: dict[str, Any], ) -> CallToolResult: @@ -1793,11 +1937,13 @@ async def _run_desktop_tool( async def click( element: Annotated[ - str, # noqa: RUF013 - Field(description="AT-SPI ref from look_at_screen (for example atspi:8/0/2)."), - ] = None, # type: ignore - x: Annotated[int, Field(description="Desktop X coordinate.")] = None, # type: ignore # noqa: RUF013 - y: Annotated[int, Field(description="Desktop Y coordinate.")] = None, # type: ignore # noqa: RUF013 + str | None, + Field( + description="AT-SPI ref from look_at_screen (for example atspi:8/0/2)." + ), + ] = None, + x: Annotated[int | None, Field(description="Desktop X coordinate.")] = None, + y: Annotated[int | None, Field(description="Desktop Y coordinate.")] = None, button: Annotated[ str, Field(description="Mouse button: left, middle, or right."), @@ -1806,21 +1952,29 @@ async def click( int, Field(description="Click count from 1 to 3.", ge=1, le=3), ] = 1, - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Click a live accessibility element or desktop coordinates.""" return await _run_desktop_tool( ctx, "click", - {"element": element, "x": x, "y": y, "button": button, "clicks": clicks}, + { + "element": element, + "x": x, + "y": y, + "button": button, + "clicks": clicks, + }, ) async def type_on_screen( - text: Annotated[str, Field(description="Text to type into the active control.")], + text: Annotated[ + str, Field(description="Text to type into the active control.") + ], element: Annotated[ - str, # noqa: RUF013 + str | None, Field(description="Optional AT-SPI ref to focus before typing."), - ] = None, # type: ignore + ] = None, clear: Annotated[ bool, Field(description="Select existing content with Ctrl+A before typing."), @@ -1831,9 +1985,13 @@ async def type_on_screen( ] = False, delay_ms: Annotated[ int, - Field(description="Delay between keystrokes in milliseconds.", ge=0, le=100), + Field( + description="Delay between keystrokes in milliseconds.", + ge=0, + le=100, + ), ] = 2, - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Type into the active Xfce control, optionally focusing it first.""" return await _run_desktop_tool( @@ -1858,22 +2016,32 @@ async def scroll( Field(description="Number of wheel steps.", ge=1, le=50), ] = 3, element: Annotated[ - str, # noqa: RUF013 + str | None, Field(description="Optional AT-SPI ref to scroll over."), - ] = None, # type: ignore - x: Annotated[int, Field(description="Optional desktop X coordinate.")] = None, # type: ignore # noqa: RUF013 - y: Annotated[int, Field(description="Optional desktop Y coordinate.")] = None, # type: ignore # noqa: RUF013 - ctx: Context[ServerSession, SessionContext] | None = None, + ] = None, + x: Annotated[ + int | None, Field(description="Optional desktop X coordinate.") + ] = None, + y: Annotated[ + int | None, Field(description="Optional desktop Y coordinate.") + ] = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Scroll the live desktop over an element, coordinates, or current pointer.""" return await _run_desktop_tool( ctx, "scroll", - {"direction": direction, "amount": amount, "element": element, "x": x, "y": y}, + { + "direction": direction, + "amount": amount, + "element": element, + "x": x, + "y": y, + }, ) async def list_windows( - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """List live Xfce windows with IDs, geometry, class, title, and state.""" return await _run_desktop_tool(ctx, "list_windows", {}) @@ -1881,9 +2049,11 @@ async def list_windows( async def switch_window( window: Annotated[ str, - Field(description="Window ID, exact title/class, or an unambiguous substring."), + Field( + description="Window ID, exact title/class, or an unambiguous substring." + ), ], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Activate and raise a live Xfce window.""" return await _run_desktop_tool(ctx, "switch_window", {"window": window}) @@ -1892,9 +2062,13 @@ async def move_window( window: Annotated[str, Field(description="Window ID, title, or class.")], x: Annotated[int, Field(description="New desktop X coordinate.")], y: Annotated[int, Field(description="New desktop Y coordinate.")], - width: Annotated[int, Field(description="Optional new width.", ge=1)] = None, # type: ignore # noqa: RUF013 - height: Annotated[int, Field(description="Optional new height.", ge=1)] = None, # type: ignore # noqa: RUF013 - ctx: Context[ServerSession, SessionContext] | None = None, + width: Annotated[ + int | None, Field(description="Optional new width.", ge=1) + ] = None, + height: Annotated[ + int | None, Field(description="Optional new height.", ge=1) + ] = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Move and optionally resize a live Xfce window.""" return await _run_desktop_tool( @@ -1904,7 +2078,7 @@ async def move_window( ) async def _window_tool( - ctx: Context[ServerSession, SessionContext] | None, + ctx: Context[SessionContext, Any] | None, action: str, window: str, ) -> CallToolResult: @@ -1912,28 +2086,28 @@ async def _window_tool( async def maximize_window( window: Annotated[str, Field(description="Window ID, title, or class.")], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Maximize a live Xfce window.""" return await _window_tool(ctx, "maximize_window", window) async def restore_window( window: Annotated[str, Field(description="Window ID, title, or class.")], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Restore and activate a minimized or maximized Xfce window.""" return await _window_tool(ctx, "restore_window", window) async def minimize_window( window: Annotated[str, Field(description="Window ID, title, or class.")], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Minimize a live Xfce window.""" return await _window_tool(ctx, "minimize_window", window) async def close_window( window: Annotated[str, Field(description="Window ID, title, or class.")], - ctx: Context[ServerSession, SessionContext] | None = None, + ctx: Context[SessionContext, Any] | None = None, ) -> CallToolResult: """Close a live Xfce window.""" return await _window_tool(ctx, "close_window", window) @@ -1947,11 +2121,21 @@ async def close_window( "Return the live Xfce screen as PNG pixels and/or an AT-SPI snapshot.", None, ), - (click, "click", "Click an AT-SPI element reference or screen coordinates.", None), + ( + click, + "click", + "Click an AT-SPI element reference or screen coordinates.", + None, + ), (type_on_screen, "type", "Type into the active Xfce control.", None), (scroll, "scroll", "Scroll the live Xfce desktop.", None), (list_windows, "list_windows", "List live Xfce windows.", None), - (switch_window, "switch_window", "Activate and raise an Xfce window.", None), + ( + switch_window, + "switch_window", + "Activate and raise an Xfce window.", + None, + ), (move_window, "move_window", "Move or resize an Xfce window.", None), (maximize_window, "maximize_window", "Maximize an Xfce window.", None), (restore_window, "restore_window", "Restore an Xfce window.", None), @@ -1961,10 +2145,6 @@ async def close_window( for tool, name, tool_description, meta in desktop_tool_specs: mcp.add_tool(tool, name=name, description=tool_description, meta=meta) - desktop_resource_objects = { - uri: mcp._resource_manager._resources[uri] - for uri in (SCREEN_IMAGE_URI, SCREEN_ACCESSIBILITY_URI) - } desktop_capabilities_enabled = True def sync_desktop_capabilities(enabled: bool) -> bool: @@ -1979,37 +2159,34 @@ def sync_desktop_capabilities(enabled: bool) -> bool: description=tool_description, meta=meta, ) - for resource in desktop_resource_objects.values(): - mcp.add_resource(resource) else: for _tool, name, _description, _meta in desktop_tool_specs: mcp.remove_tool(name) - for uri in desktop_resource_objects: - mcp._resource_manager._resources.pop(uri, None) desktop_capabilities_enabled = enabled + mcp.desktop_capabilities_enabled = enabled return True desktop_capability_sync = sync_desktop_capabilities desktop_capability_sync(config.desktop_environment) - @mcp.resource( - DASHBOARD_URI, + def virtual_computer_resource() -> str: + live_sandbox = registry.peek(config.computer_id) + sync_dashboard_resource_meta( + live_sandbox.desktop_url if live_sandbox is not None else None + ) + return dashboard_html() + + dashboard_resource = FunctionResource( + uri=DASHBOARD_URI, name="Virtual Computer", title="Virtual Computer", description="Three.js laptop-on-desk view for terminal and file operations.", mime_type=DASHBOARD_MIME_TYPE, + fn=virtual_computer_resource, meta=dashboard_resource_meta( _computer_ui_url(config), _computer_desktop_proxy_url(config), ), ) - def virtual_computer_resource() -> str: - live_sandbox = registry.peek(config.computer_id) - sync_dashboard_resource_meta( - live_sandbox.desktop_url if live_sandbox is not None else None - ) - return dashboard_html() - - _enable_mcp_apps_capability(mcp) - + mcp.add_resource(dashboard_resource) return mcp diff --git a/src/kilntainers/test_auth_boundaries.py b/src/kilntainers/test_auth_boundaries.py new file mode 100644 index 0000000..740ed5d --- /dev/null +++ b/src/kilntainers/test_auth_boundaries.py @@ -0,0 +1,298 @@ +"""Regression coverage for companion disclosure and cross-site desktop access.""" + +from urllib.parse import parse_qs, urlsplit + +import pytest +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route, WebSocketRoute +from starlette.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from kilntainers.auth import BearerTokenMiddleware, CompanionAccess + +BEARER = {"Authorization": "Bearer test-server-token"} +TRUSTED_ORIGIN = {"Origin": "http://testserver"} + + +def client_for( + *, + access: CompanionAccess | None = None, + allow_mcp_capability: bool = False, + allow_unauthenticated_mcp: bool = False, + allow_opaque_origin: bool = True, +) -> TestClient: + async def sensitive_http(request): + return JSONResponse({"sensitive": "terminal output and file contents"}) + + async def sensitive_socket(websocket): + await websocket.accept() + await websocket.send_text("desktop stream") + await websocket.close() + + app = Starlette( + routes=[ + Route( + "/{path:path}", + sensitive_http, + methods=["GET", "POST", "HEAD", "OPTIONS"], + ), + WebSocketRoute("/desktop/{channel}", sensitive_socket), + ] + ) + protected_app = BearerTokenMiddleware( + app, + token="test-server-token", + companion_access=access, + allowed_origins=["http://testserver", "https://trusted.example"], + allow_mcp_capability=allow_mcp_capability, + allow_unauthenticated_mcp=allow_unauthenticated_mcp, + allow_opaque_origin=allow_opaque_origin, + ) + return TestClient(protected_app) + + +@pytest.mark.parametrize( + "path", ["/activity", "/dashboard.html", "/", "/mcp", "/private-future-route"] +) +def test_sensitive_routes_require_credentials(path: str) -> None: + with client_for() as client: + denied = client.get(path) + assert denied.status_code == 401 + assert "sensitive" not in denied.text + assert denied.headers["WWW-Authenticate"] == "Bearer" + assert client.get(path, headers=BEARER).status_code == 200 + + +def test_only_read_only_health_checks_are_public() -> None: + with client_for() as client: + assert client.get("/healthz").status_code == 200 + assert client.head("/healthz").status_code == 200 + assert client.post("/healthz").status_code == 401 + assert client.get("/healthz/other").status_code == 401 + + +def test_authenticated_responses_cannot_cache_or_leak_capability_as_referrer() -> None: + access = CompanionAccess.generate() + with client_for(access=access) as client: + response = client.get(access.url("/dashboard.html")) + assert response.status_code == 200 + assert response.headers["Cache-Control"] == "no-store" + assert response.headers["Referrer-Policy"] == "no-referrer" + + +@pytest.mark.parametrize("path", ["/", "/activity", "/dashboard.html"]) +def test_browser_capability_can_read_companion_routes(path: str) -> None: + access = CompanionAccess.generate() + with client_for(access=access) as client: + assert client.get(access.url(path)).status_code == 200 + assert ( + client.get(path, headers={"X-Computer-Access": access.token}).status_code + == 200 + ) + + +@pytest.mark.parametrize("path", ["/mcp", "/mcp/tools", "/mcp-other", "/private"]) +def test_browser_capability_does_not_grant_mcp_or_unrelated_access(path: str) -> None: + access = CompanionAccess.generate() + with client_for(access=access) as client: + assert client.post(access.url(path)).status_code == 401 + + +def test_standalone_dashboard_mcp_capability_requires_explicit_opt_in() -> None: + access = CompanionAccess.generate() + with client_for(access=access, allow_mcp_capability=True) as client: + assert ( + client.post( + "/mcp", + headers={**TRUSTED_ORIGIN, "X-Computer-Access": access.token}, + ).status_code + == 200 + ) + assert client.post(access.url("/mcp-other")).status_code == 401 + + +@pytest.mark.parametrize( + "origin", ["https://attacker.example", "null", "not-an-origin"] +) +def test_bearer_does_not_override_untrusted_browser_origin(origin: str) -> None: + with client_for() as client: + assert ( + client.get("/activity", headers={**BEARER, "Origin": origin}).status_code + == 403 + ) + + +@pytest.mark.parametrize( + "origin", + [ + "http://testserver.attacker.example", + "http://testserver:9999", + "http://testserver:0", + " http://testserver", + "http://testserver/path", + "http://testserver@attacker.example", + "https://attacker.example", + ], +) +def test_capability_does_not_override_nonopaque_untrusted_origin(origin: str) -> None: + access = CompanionAccess.generate() + with client_for(access=access) as client: + assert ( + client.get(access.url("/activity"), headers={"Origin": origin}).status_code + == 403 + ) + + +def test_known_origins_and_default_ports_are_allowed() -> None: + with client_for() as client: + assert ( + client.get( + "/activity", headers={**BEARER, "Origin": "http://testserver:80"} + ).status_code + == 200 + ) + assert ( + client.get( + "/activity", headers={**BEARER, "Origin": "https://trusted.example"} + ).status_code + == 200 + ) + + +@pytest.mark.parametrize("origin", [None, "null", "http://testserver"]) +def test_desktop_requires_capability_including_sandboxed_frames( + origin: str | None, +) -> None: + access = CompanionAccess.generate() + headers = {} if origin is None else {"Origin": origin} + with client_for(access=access) as client: + with pytest.raises(WebSocketDisconnect) as denied: + with client.websocket_connect("/desktop/websockify", headers=headers): + pytest.fail("Unauthenticated desktop connection was accepted") + assert denied.value.code == 1008 + with client.websocket_connect( + access.url("/desktop/websockify"), headers=headers + ) as websocket: + assert websocket.receive_text() == "desktop stream" + + +@pytest.mark.parametrize("origin", [None, "null", "https://attacker.example"]) +def test_bearer_alone_cannot_open_untrusted_or_opaque_desktop( + origin: str | None, +) -> None: + headers = BEARER if origin is None else {**BEARER, "Origin": origin} + with client_for() as client: + with pytest.raises(WebSocketDisconnect) as denied: + with client.websocket_connect("/desktop/websockify", headers=headers): + pytest.fail("Cross-site desktop connection was accepted") + assert denied.value.code == 1008 + + +def test_capability_does_not_open_desktop_from_hostile_origin() -> None: + access = CompanionAccess.generate() + with client_for(access=access) as client: + with pytest.raises(WebSocketDisconnect) as denied: + with client.websocket_connect( + access.url("/desktop/audio"), + headers={"Origin": "https://attacker.example"}, + ): + pytest.fail("Cross-site desktop connection was accepted") + assert denied.value.code == 1008 + + +def test_opaque_frame_support_can_be_disabled() -> None: + access = CompanionAccess.generate() + with client_for(access=access, allow_opaque_origin=False) as client: + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect( + access.url("/desktop/audio"), headers={"Origin": "null"} + ): + pytest.fail("Opaque origin was accepted despite explicit policy") + + +def test_anonymous_local_mcp_does_not_expose_companion_or_trust_rebound_host() -> None: + with client_for(allow_unauthenticated_mcp=True) as client: + assert client.post("/mcp").status_code == 200 + assert client.get("/activity").status_code == 401 + assert ( + client.post("/mcp", headers={"Host": "attacker.example"}).status_code == 401 + ) + assert ( + client.post( + "/mcp", headers={"Origin": "https://attacker.example"} + ).status_code + == 403 + ) + + +@pytest.mark.parametrize( + "headers", + [ + [ + ("Authorization", "Bearer wrong"), + ("Authorization", "Bearer test-server-token"), + ], + [ + ("Authorization", "Bearer test-server-token"), + ("Origin", "http://testserver"), + ("Origin", "https://attacker.example"), + ], + [("Authorization", "Bearer \u00e9")], + ], +) +def test_ambiguous_or_malformed_headers_are_rejected( + headers: list[tuple[str, str]], +) -> None: + with client_for() as client: + # Bytes permit deliberately malformed non-ASCII HTTP header values. + response = client.get( + "/activity", + headers=[(key.encode(), value.encode()) for key, value in headers], + ) + assert response.status_code in {401, 403} + + +def test_duplicate_or_oversized_capability_query_is_rejected() -> None: + access = CompanionAccess.generate() + url = access.url("/activity") + with client_for(access=access) as client: + assert client.get(url + "&computer_access=wrong").status_code == 401 + assert ( + client.get(url, headers={"X-Computer-Access": access.token}).status_code + == 401 + ) + assert client.get(url + "&x=1" * 65).status_code == 401 + + +def test_capabilities_are_random_and_urls_replace_stale_values() -> None: + first, second = CompanionAccess.generate(), CompanionAccess.generate() + assert first.token != second.token + assert len(first.token) >= 40 + assert first.token not in repr(first) + url = first.url( + "ws://testserver/desktop/audio?computer_access=old&quality=1#anchor" + ) + parts = urlsplit(url) + assert parts.fragment == "anchor" + assert parse_qs(parts.query) == { + "computer_access": [first.token], + "quality": ["1"], + } + + +def test_invalid_origin_configuration_fails_closed() -> None: + with pytest.raises(ValueError, match="HTTP"): + BearerTokenMiddleware( + Starlette(), allowed_origins=["https://trusted.example/path"] + ) + with pytest.raises(ValueError, match="fixed"): + BearerTokenMiddleware(Starlette(), allow_unauthenticated_mcp=True) + + +def test_bearer_can_open_desktop_from_explicitly_trusted_origin() -> None: + with client_for() as client: + with client.websocket_connect( + "/desktop/audio", headers={**BEARER, **TRUSTED_ORIGIN} + ) as websocket: + assert websocket.receive_text() == "desktop stream" diff --git a/src/kilntainers/test_cli.py b/src/kilntainers/test_cli.py index 26a5b8a..a88a718 100644 --- a/src/kilntainers/test_cli.py +++ b/src/kilntainers/test_cli.py @@ -40,7 +40,6 @@ def test_parser_defaults(): assert args.port is _UNSET assert args.timeout == 120 assert args.output_limit == 2_097_152 - assert args.session_timeout is _UNSET assert args.tool_instruction_override is None assert args.extended_tool_instruction is None assert args.engine == "docker" @@ -67,8 +66,6 @@ def test_parser_core_args(): "300", "--output-limit", "1048576", - "--session-timeout", - "600", ] ) @@ -77,7 +74,6 @@ def test_parser_core_args(): assert args.port == 9090 assert args.timeout == 300 assert args.output_limit == 1_048_576 - assert args.session_timeout == 600 def test_parser_tool_description_args(): @@ -185,7 +181,6 @@ def test_build_configs_default_args(monkeypatch): assert server_config.port == 8435 assert server_config.default_timeout == 120 assert server_config.output_limit == 2_097_152 - assert server_config.session_timeout == 300 assert server_config.tool_instruction_override is None assert server_config.extended_tool_instruction is None assert server_config.computer_id == "test-computer" @@ -218,8 +213,6 @@ def test_build_configs_custom_args(): "300", "--output-limit", "1048576", - "--session-timeout", - "600", "--engine", "podman", "--image", @@ -245,7 +238,6 @@ def test_build_configs_custom_args(): assert server_config.port == 9090 assert server_config.default_timeout == 300 assert server_config.output_limit == 1_048_576 - assert server_config.session_timeout == 600 # Docker config assert docker_config.engine == "podman" @@ -357,8 +349,9 @@ def test_build_configs_network_flag(): assert docker_config.network_enabled is True -def test_build_configs_no_network_flag(): - """Test that --no-network explicitly disables network access.""" +def test_build_configs_no_network_flag(monkeypatch): + """Test the CLI network default without an overriding deployment env value.""" + monkeypatch.delenv("NETWORK_ACCESS", raising=False) parser = build_parser() args = parser.parse_args(["--no-network"]) @@ -394,13 +387,10 @@ def test_validate_config_stdio_mode_accepts_dashboard_port(): validate_config(server_config) -def test_validate_config_stdio_mode_accepts_dashboard_session_timeout(): - """The stdio companion exposes the regular HTTP session timeout.""" - parser = build_parser() - args = parser.parse_args(["--session-timeout", "600"]) - server_config, _docker_config = build_configs(args) - - validate_config(server_config) +def test_removed_session_timeout_is_rejected(): + """Reject the old no-op instead of claiming idle cleanup is configured.""" + with pytest.raises(SystemExit): + build_parser().parse_args(["--session-timeout", "600"]) def test_validate_config_http_mode_no_error(): @@ -631,25 +621,25 @@ async def test_async_main_successful_startup(): @pytest.mark.asyncio async def test_async_main_transport_mapping(): - """Test that CLI transport maps to correct FastMCP transport string.""" + """Test that HTTP startup always uses the protected application wrapper.""" server_config = ServerConfig(transport="http") docker_config = DockerBackendConfig() mock_backend = MagicMock() mock_mcp = MagicMock() - mock_mcp.run_streamable_http_async = AsyncMock() with ( patch("kilntainers.cli.get_backend_class") as mock_get_backend, patch("kilntainers.cli.create_server") as mock_create_server, + patch("kilntainers.cli._run_http", new_callable=AsyncMock) as mock_run_http, ): mock_get_backend.return_value = lambda _: mock_backend mock_create_server.return_value = mock_mcp await _async_main(server_config, docker_config, "docker") - mock_mcp.run_streamable_http_async.assert_awaited_once_with() + mock_run_http.assert_awaited_once_with(mock_mcp, server_config) # ================ @@ -719,3 +709,37 @@ def test_docker_and_fly_backends_are_exposed(): assert get_backend_class("fly").__name__ == "FlyBackend" with pytest.raises(KeyError, match="Available backends: docker, fly"): get_backend_class("modal") + + +@pytest.mark.parametrize( + "origin", + ["https://evil.example/path", "*", "null", "https://user:pass@example.com"], +) +def test_reject_invalid_allowed_origin(origin): + with pytest.raises(SystemExit): + validate_config(ServerConfig(allowed_http_origins=(origin,))) + + +@pytest.mark.parametrize( + "host", ["*.example.com", "evil.example/path", "user@host", "bad host"] +) +def test_reject_invalid_allowed_host(host): + with pytest.raises(SystemExit): + validate_config(ServerConfig(allowed_http_hosts=(host,))) + + +def test_parse_explicit_reverse_proxy_boundaries(): + args = build_parser().parse_args( + [ + "--transport", + "http", + "--allowed-host", + "computer.example.com", + "--allowed-origin", + "https://computer.example.com", + ] + ) + config, _ = build_configs(args) + assert config.allowed_http_hosts == ("computer.example.com",) + assert config.allowed_http_origins == ("https://computer.example.com",) + validate_config(config) diff --git a/src/kilntainers/test_cli_integration.py b/src/kilntainers/test_cli_integration.py index 27bc12a..c80434a 100644 --- a/src/kilntainers/test_cli_integration.py +++ b/src/kilntainers/test_cli_integration.py @@ -55,7 +55,7 @@ def test_cli_help_output_complete(self): # Check for HTTP-only args (present in help even though they error in stdio mode) assert "--host" in help_text assert "--port" in help_text - assert "--session-timeout" in help_text + assert "--session-timeout" not in help_text # Check for tool description args assert "--tool-instruction-override" in help_text diff --git a/src/kilntainers/test_config.py b/src/kilntainers/test_config.py index 6aea841..91857b8 100644 --- a/src/kilntainers/test_config.py +++ b/src/kilntainers/test_config.py @@ -29,7 +29,6 @@ def test_defaults(self, monkeypatch) -> None: assert config.network_access is True assert config.expose_lifecycle_tools is False assert config.workspace_directory == "/workspace" - assert config.session_timeout == 300 def test_custom_values(self) -> None: """Custom values should be stored correctly.""" @@ -45,7 +44,6 @@ def test_custom_values(self) -> None: desktop_environment=True, network_access=False, expose_lifecycle_tools=True, - session_timeout=600, ) assert config.transport == "http" assert config.host == "0.0.0.0" @@ -58,7 +56,6 @@ def test_custom_values(self) -> None: assert config.desktop_environment is True assert config.network_access is False assert config.expose_lifecycle_tools is True - assert config.session_timeout == 600 def test_frozen_immutable(self) -> None: """ServerConfig should be frozen (immutable).""" diff --git a/src/kilntainers/test_dashboard.py b/src/kilntainers/test_dashboard.py index 089ef20..bc505d4 100644 --- a/src/kilntainers/test_dashboard.py +++ b/src/kilntainers/test_dashboard.py @@ -1,5 +1,6 @@ """Three.js MCP App registration and static auth tests.""" +from mcp import Client from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route @@ -17,13 +18,13 @@ from kilntainers.server import create_server -def test_virtual_computer_tools_resource_and_extension_are_registered() -> None: +async def test_virtual_computer_tools_resource_and_extension_are_registered() -> None: server = create_server( MockBackend(BackendConfig()), ServerConfig(desktop_environment=False), ) - tools = {tool.name: tool for tool in server._tool_manager.list_tools()} - resources = server._resource_manager.list_resources() + tools = {tool.name: tool for tool in await server.list_tools()} + resources = await server.list_resources() assert set(tools) == { "terminal_execute", @@ -60,14 +61,10 @@ def test_virtual_computer_tools_resource_and_extension_are_registered() -> None: assert "http://127.0.0.1:8435" in resource_meta["ui"]["csp"]["connectDomains"] assert "ws://127.0.0.1:8435" in resource_meta["ui"]["csp"]["connectDomains"] - capabilities = server._mcp_server.create_initialization_options().capabilities - payload = capabilities.model_dump(by_alias=True, exclude_none=True) - assert payload["extensions"] == { - "io.modelcontextprotocol/ui": {"mimeTypes": [DASHBOARD_MIME_TYPE]} - } - assert DASHBOARD_RESOURCE_META["ui"]["permissions"] == { - "clipboardWrite": {} - } + async with Client(server) as client: + extensions = client.server_capabilities.extensions + assert extensions == {"io.modelcontextprotocol/ui": {}} + assert DASHBOARD_RESOURCE_META["ui"]["permissions"] == {"clipboardWrite": {}} assert DASHBOARD_RESOURCE_META["openai/widgetCSP"]["connect_domains"] == [ "ws://127.0.0.1:*", "http://127.0.0.1:*", @@ -76,47 +73,41 @@ def test_virtual_computer_tools_resource_and_extension_are_registered() -> None: async def test_virtual_computer_html_is_self_contained() -> None: server = create_server(MockBackend(BackendConfig()), ServerConfig()) - resource = next( - resource - for resource in server._resource_manager.list_resources() - if str(resource.uri) == DASHBOARD_URI - ) - html = await resource.read() + contents = await server.read_resource(DASHBOARD_URI) + html = next(iter(contents)).content assert isinstance(html, str) assert "Virtual Computer" in html assert "Interactive 3D laptop on a desk" in html assert "Request Received" not in html assert "No operations will be simulated" not in html - assert 'pathname="/audio"' in html + assert '/websockify$/,"/audio"' in html assert "AudioContext" in html assert "clipboardPasteFrom" in html assert 'addEventListener("clipboard"' in html assert "navigator.clipboard.readText" in html assert "navigator.clipboard.writeText" in html assert '"runtime_status"' in html - assert 'window.parent!==window' in html + assert "window.parent!==window" in html assert "