feat(mcp): add computercraft bridge tool

This commit is contained in:
Guillaume ARM 2026-06-10 20:49:29 +02:00
parent 10c45bbc8f
commit d6e97e49da
10 changed files with 1093 additions and 0 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.craftos
.craftos-vanilla
.env
node_modules/
dist/

601
tools/mcp-bridge/package-lock.json generated Normal file
View File

@ -0,0 +1,601 @@
{
"name": "mcp-bridge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mcp-bridge",
"version": "0.1.0",
"dependencies": {
"ws": "^8.17.1"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/ws": "^8.5.10",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "20.19.42",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
"integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@ -0,0 +1,21 @@
{
"name": "mcp-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc --noEmit false",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"test": "npm run build && node --test dist/test/*.test.js"
},
"dependencies": {
"ws": "^8.17.1"
},
"devDependencies": {
"@types/node": "^20.14.10",
"@types/ws": "^8.5.10",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,26 @@
import { LinkRegistry, startLinkServer } from "./link-server.js";
import { startMcpServer } from "./mcp-server.js";
const config = {
mcpHost: process.env.MCP_HOST ?? "127.0.0.1",
mcpPort: readPort(process.env.MCP_PORT, 3000),
ccLinkHost: process.env.CC_LINK_HOST ?? "0.0.0.0",
ccLinkPort: readPort(process.env.CC_LINK_PORT, 3001),
probeTimeoutMs: readPort(process.env.CC_PROBE_TIMEOUT_MS, 2000),
};
const registry = new LinkRegistry();
startMcpServer({ host: config.mcpHost, port: config.mcpPort, probeTimeoutMs: config.probeTimeoutMs, registry });
startLinkServer({ host: config.ccLinkHost, port: config.ccLinkPort, registry });
console.log(`MCP bridge listening on http://${config.mcpHost}:${config.mcpPort}`);
console.log(`ComputerCraft link listening on ws://${config.ccLinkHost}:${config.ccLinkPort}`);
function readPort(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}

View File

@ -0,0 +1,125 @@
import { randomUUID } from "node:crypto";
import { WebSocketServer, type WebSocket } from "ws";
import { formatComputer, parseJsonFrame, parseLinkMessage, type ResponseMessage } from "./protocol.js";
export type ComputerConnection = {
computerId: number;
label: string | null;
ws: WebSocket;
connectedAt: number;
lastSeenAt: number;
};
type PendingRequest = {
computerId: number;
resolve: (message: ResponseMessage) => void;
};
export class LinkRegistry {
private readonly computers = new Map<number, ComputerConnection>();
private readonly pending = new Map<string, PendingRequest>();
register(connection: ComputerConnection): void {
const existing = this.computers.get(connection.computerId);
if (existing && existing.ws !== connection.ws) {
existing.ws.close();
}
this.computers.set(connection.computerId, connection);
}
unregister(ws: WebSocket): void {
for (const [computerId, connection] of this.computers) {
if (connection.ws === ws) {
this.computers.delete(computerId);
}
}
}
count(): number {
return this.computers.size;
}
snapshot(): ComputerConnection[] {
return [...this.computers.values()];
}
handleFrame(ws: WebSocket, data: unknown): void {
const message = parseLinkMessage(parseJsonFrame(data));
if (!message) {
return;
}
if (message.type === "hello") {
const now = Date.now();
this.register({
computerId: message.computerId,
label: message.computerLabel,
ws,
connectedAt: now,
lastSeenAt: now,
});
ws.send(JSON.stringify({ type: "hello-ok" }));
return;
}
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
const connection = this.computers.get(pending.computerId);
if (connection) {
connection.lastSeenAt = Date.now();
}
this.pending.delete(message.id);
pending.resolve(message);
}
async probeComputers(timeoutMs: number): Promise<string> {
const computers = this.snapshot();
if (computers.length === 0) {
return "No computers connected.";
}
const lines = await Promise.all(computers.map((computer) => this.probeComputer(computer, timeoutMs)));
return lines.join("\n");
}
private async probeComputer(computer: ComputerConnection, timeoutMs: number): Promise<string> {
const id = randomUUID();
const response = new Promise<ResponseMessage>((resolve) => {
this.pending.set(id, { computerId: computer.computerId, resolve });
computer.ws.send(JSON.stringify({ type: "request", id, method: "ping" }));
});
const timeout = new Promise<null>((resolve) => {
setTimeout(() => {
this.pending.delete(id);
resolve(null);
}, timeoutMs);
});
const result = await Promise.race([response, timeout]);
if (!result) {
return `timeout from ${formatComputer(computer.computerId, computer.label)}`;
}
if (result.ok && typeof result.result === "string") {
return result.result;
}
return `error from ${formatComputer(computer.computerId, computer.label)}: ${result.error ?? "unknown error"}`;
}
}
export function startLinkServer(options: { host: string; port: number; registry: LinkRegistry }): WebSocketServer {
const server = new WebSocketServer({ host: options.host, port: options.port });
server.on("connection", (ws) => {
ws.on("message", (data) => options.registry.handleFrame(ws, data));
ws.on("close", () => options.registry.unregister(ws));
ws.on("error", () => options.registry.unregister(ws));
});
return server;
}

View File

@ -0,0 +1,127 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { LinkRegistry } from "./link-server.js";
type JsonRpcRequest = {
jsonrpc?: unknown;
id?: unknown;
method?: unknown;
params?: unknown;
};
export function startMcpServer(options: {
host: string;
port: number;
probeTimeoutMs: number;
registry: LinkRegistry;
}): Server {
const server = createServer((req, res) => {
void handleRequest(req, res, options.registry, options.probeTimeoutMs);
});
server.listen(options.port, options.host);
return server;
}
export async function handleMcpRequest(body: unknown, registry: LinkRegistry, probeTimeoutMs: number): Promise<unknown> {
if (Array.isArray(body)) {
return Promise.all(body.map((item) => handleSingleRequest(item as JsonRpcRequest, registry, probeTimeoutMs)));
}
return handleSingleRequest(body as JsonRpcRequest, registry, probeTimeoutMs);
}
async function handleRequest(
req: IncomingMessage,
res: ServerResponse,
registry: LinkRegistry,
probeTimeoutMs: number,
): Promise<void> {
if (req.method === "GET" && req.url === "/health") {
writeJson(res, 200, { ok: true, computers: registry.count() });
return;
}
if (req.method !== "POST") {
writeJson(res, 404, { error: "not found" });
return;
}
const raw = await readBody(req);
let body: unknown;
try {
body = JSON.parse(raw) as unknown;
} catch {
writeJson(res, 400, jsonRpcError(null, -32700, "Parse error"));
return;
}
writeJson(res, 200, await handleMcpRequest(body, registry, probeTimeoutMs));
}
async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegistry, probeTimeoutMs: number): Promise<unknown> {
const id = request && "id" in request ? request.id : null;
if (!request || request.jsonrpc !== "2.0" || typeof request.method !== "string") {
return jsonRpcError(id, -32600, "Invalid Request");
}
if (request.method === "initialize") {
return jsonRpcResult(id, {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "mcp-bridge", version: "0.1.0" },
});
}
if (request.method === "notifications/initialized") {
return null;
}
if (request.method === "tools/list") {
return jsonRpcResult(id, {
tools: [
{
name: "probe-computers",
description: "Probe all linked ComputerCraft computers.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
],
});
}
if (request.method === "tools/call") {
const params = isRecord(request.params) ? request.params : {};
if (params.name !== "probe-computers") {
return jsonRpcError(id, -32602, "Unknown tool");
}
const text = await registry.probeComputers(probeTimeoutMs);
return jsonRpcResult(id, { content: [{ type: "text", text }] });
}
return jsonRpcError(id, -32601, "Method not found");
}
function jsonRpcResult(id: unknown, result: unknown): unknown {
return { jsonrpc: "2.0", id, result };
}
function jsonRpcError(id: unknown, code: number, message: string): unknown {
return { jsonrpc: "2.0", id, error: { code, message } };
}
function writeJson(res: ServerResponse, statusCode: number, body: unknown): void {
res.writeHead(statusCode, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -0,0 +1,89 @@
export type HelloMessage = {
type: "hello";
computerId: number;
computerLabel: string | null;
};
export type ResponseMessage = {
type: "response";
id: string;
ok: boolean;
result?: unknown;
error?: string;
};
export type LinkMessage = HelloMessage | ResponseMessage;
export function parseJsonFrame(data: unknown): unknown | null {
if (typeof data === "string") {
return parseJson(data);
}
if (Buffer.isBuffer(data)) {
return parseJson(data.toString("utf8"));
}
if (data instanceof ArrayBuffer) {
return parseJson(Buffer.from(data).toString("utf8"));
}
if (Array.isArray(data)) {
return parseJson(Buffer.concat(data).toString("utf8"));
}
return null;
}
export function parseLinkMessage(value: unknown): LinkMessage | null {
if (!isRecord(value) || typeof value.type !== "string") {
return null;
}
if (value.type === "hello") {
if (typeof value.computerId !== "number" || !Number.isFinite(value.computerId)) {
return null;
}
const label = typeof value.computerLabel === "string" && value.computerLabel.trim() !== ""
? value.computerLabel
: null;
return {
type: "hello",
computerId: value.computerId,
computerLabel: label,
};
}
if (value.type === "response") {
if (typeof value.id !== "string" || typeof value.ok !== "boolean") {
return null;
}
return {
type: "response",
id: value.id,
ok: value.ok,
result: value.result,
error: typeof value.error === "string" ? value.error : undefined,
};
}
return null;
}
export function formatComputer(computerId: number, label: string | null): string {
return `${computerId} (Label: ${label ?? "null"})`;
}
function parseJson(text: string): unknown | null {
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import test from "node:test";
import { WebSocket } from "ws";
import { LinkRegistry } from "../src/link-server.js";
import { handleMcpRequest } from "../src/mcp-server.js";
test("probe-computers returns no computers message when registry is empty", async () => {
const registry = new LinkRegistry();
assert.equal(await registry.probeComputers(10), "No computers connected.");
});
test("probe-computers aggregates multiple successful responses", async () => {
const registry = new LinkRegistry();
const computer1 = new FakeSocket();
const computer2 = new FakeSocket();
registry.register({ computerId: 12, label: "base-turtle", ws: computer1 as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
registry.register({ computerId: 13, label: "miner-1", ws: computer2 as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
const promise = registry.probeComputers(50);
computer1.respond(registry, 12, "pong from 12 (Label: base-turtle)");
computer2.respond(registry, 13, "pong from 13 (Label: miner-1)");
assert.equal(await promise, "pong from 12 (Label: base-turtle)\npong from 13 (Label: miner-1)");
});
test("probe-computers reports timeout for a connected computer that does not answer", async () => {
const registry = new LinkRegistry();
registry.register({ computerId: 14, label: "farm-turtle", ws: new FakeSocket() as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
assert.equal(await registry.probeComputers(5), "timeout from 14 (Label: farm-turtle)");
});
test("MCP tool call returns text content", async () => {
const registry = new LinkRegistry();
const response = await handleMcpRequest(
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "probe-computers", arguments: {} } },
registry,
10,
);
assert.deepEqual(response, {
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text: "No computers connected." }] },
});
});
class FakeSocket {
sent: unknown[] = [];
send(data: unknown): void {
this.sent.push(data);
}
close(): void {
return;
}
respond(registry: LinkRegistry, computerId: number, result: string): void {
const last = this.sent.at(-1);
assert.equal(typeof last, "string");
const request = JSON.parse(last as string) as { id: string };
registry.handleFrame(this as unknown as WebSocket, JSON.stringify({ type: "response", id: request.id, ok: true, result }));
assert.equal(computerId > 0, true);
}
}

View File

@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseJsonFrame, parseLinkMessage } from "../src/protocol.js";
test("protocol validation accepts valid hello", () => {
const frame = parseJsonFrame(JSON.stringify({ type: "hello", computerId: 12, computerLabel: "base-turtle" }));
assert.deepEqual(parseLinkMessage(frame), { type: "hello", computerId: 12, computerLabel: "base-turtle" });
});
test("protocol validation normalizes empty label", () => {
const frame = parseJsonFrame(JSON.stringify({ type: "hello", computerId: 12, computerLabel: "" }));
assert.deepEqual(parseLinkMessage(frame), { type: "hello", computerId: 12, computerLabel: null });
});
test("protocol validation rejects invalid frames", () => {
assert.equal(parseJsonFrame("not json"), null);
assert.equal(parseLinkMessage({ type: "hello", computerId: "12" }), null);
assert.equal(parseLinkMessage({ type: "response", id: 1, ok: true }), null);
});

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": ".",
"declaration": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}