From d6e97e49da50c5da6ff2af581d7235d8604c7eec Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Wed, 10 Jun 2026 20:49:29 +0200 Subject: [PATCH] feat(mcp): add computercraft bridge tool --- .gitignore | 2 + tools/mcp-bridge/package-lock.json | 601 ++++++++++++++++++ tools/mcp-bridge/package.json | 21 + tools/mcp-bridge/src/index.ts | 26 + tools/mcp-bridge/src/link-server.ts | 125 ++++ tools/mcp-bridge/src/mcp-server.ts | 127 ++++ tools/mcp-bridge/src/protocol.ts | 89 +++ tools/mcp-bridge/test/probe-computers.test.ts | 68 ++ tools/mcp-bridge/test/protocol.test.ts | 19 + tools/mcp-bridge/tsconfig.json | 15 + 10 files changed, 1093 insertions(+) create mode 100644 tools/mcp-bridge/package-lock.json create mode 100644 tools/mcp-bridge/package.json create mode 100644 tools/mcp-bridge/src/index.ts create mode 100644 tools/mcp-bridge/src/link-server.ts create mode 100644 tools/mcp-bridge/src/mcp-server.ts create mode 100644 tools/mcp-bridge/src/protocol.ts create mode 100644 tools/mcp-bridge/test/probe-computers.test.ts create mode 100644 tools/mcp-bridge/test/protocol.test.ts create mode 100644 tools/mcp-bridge/tsconfig.json diff --git a/.gitignore b/.gitignore index de3ea73..a2bb8c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .craftos .craftos-vanilla .env +node_modules/ +dist/ diff --git a/tools/mcp-bridge/package-lock.json b/tools/mcp-bridge/package-lock.json new file mode 100644 index 0000000..689baa4 --- /dev/null +++ b/tools/mcp-bridge/package-lock.json @@ -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 + } + } + } + } +} diff --git a/tools/mcp-bridge/package.json b/tools/mcp-bridge/package.json new file mode 100644 index 0000000..8563928 --- /dev/null +++ b/tools/mcp-bridge/package.json @@ -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" + } +} diff --git a/tools/mcp-bridge/src/index.ts b/tools/mcp-bridge/src/index.ts new file mode 100644 index 0000000..41b50ac --- /dev/null +++ b/tools/mcp-bridge/src/index.ts @@ -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; +} diff --git a/tools/mcp-bridge/src/link-server.ts b/tools/mcp-bridge/src/link-server.ts new file mode 100644 index 0000000..3af8233 --- /dev/null +++ b/tools/mcp-bridge/src/link-server.ts @@ -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(); + private readonly pending = new Map(); + + 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 { + 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 { + const id = randomUUID(); + const response = new Promise((resolve) => { + this.pending.set(id, { computerId: computer.computerId, resolve }); + computer.ws.send(JSON.stringify({ type: "request", id, method: "ping" })); + }); + + const timeout = new Promise((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; +} diff --git a/tools/mcp-bridge/src/mcp-server.ts b/tools/mcp-bridge/src/mcp-server.ts new file mode 100644 index 0000000..38aa999 --- /dev/null +++ b/tools/mcp-bridge/src/mcp-server.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/tools/mcp-bridge/src/protocol.ts b/tools/mcp-bridge/src/protocol.ts new file mode 100644 index 0000000..1ba0c16 --- /dev/null +++ b/tools/mcp-bridge/src/protocol.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/tools/mcp-bridge/test/probe-computers.test.ts b/tools/mcp-bridge/test/probe-computers.test.ts new file mode 100644 index 0000000..334c644 --- /dev/null +++ b/tools/mcp-bridge/test/probe-computers.test.ts @@ -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); + } +} diff --git a/tools/mcp-bridge/test/protocol.test.ts b/tools/mcp-bridge/test/protocol.test.ts new file mode 100644 index 0000000..e102ec2 --- /dev/null +++ b/tools/mcp-bridge/test/protocol.test.ts @@ -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); +}); diff --git a/tools/mcp-bridge/tsconfig.json b/tools/mcp-bridge/tsconfig.json new file mode 100644 index 0000000..005d2e7 --- /dev/null +++ b/tools/mcp-bridge/tsconfig.json @@ -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"] +}