[feat] add CrossDesk MCP with remote desktop panels

This commit is contained in:
dijunkun
2026-11-03 02:44:55 +08:00
parent 2fab61871e
commit f57b4cc4e3
40 changed files with 6325 additions and 28 deletions
+1
View File
@@ -0,0 +1 @@
dist/
+198
View File
@@ -0,0 +1,198 @@
# CrossDesk MCP
让本机 Agent 通过 MCP 观察并操作远端 CrossDesk,同时为用户提供 MCP Apps 或浏览器监看面板。控制端不需要安装 CrossDesk 桌面客户端;被控端仍需运行兼容的 CrossDesk,并连接支持 `cli` 注册的信令服务器。
此目录实现 MCP 包 `@chunkdash/crossdesk-mcp` 0.1.0,绑定原生 CLI 包 0.2.0。**源码实现和本地打包不代表这些版本已经发布到 npm。** 发布后的安装示例需要以实际可用版本为准。
## 从源码运行
仓库根目录构建原生程序(已有工作区可保留原有桌面构建选项):
```sh
sh apps/cli/scripts/prepare-deps.sh
xmake f -y --CROSSDESK_BUILD_DESKTOP=n --CROSSDESK_BUILD_CLI=y --USE_CUDA=n -m release
xmake build crossdesk-cli
cd apps/mcp
npm ci
npm run build
```
Node.js 要求 22 或以上。在 Agent 宿主中配置本地 stdio 进程,例如 macOS arm64 源码构建:
```json
{
"mcpServers": {
"crossdesk": {
"command": "node",
"args": [
"/absolute/path/crossdesk/apps/mcp/dist/main.js",
"serve", "--stdio", "--panel=auto",
"--native-binary", "/absolute/path/crossdesk/build/macosx/arm64/release/crossdesk-cli"
]
}
}
}
```
Windows 和 Linux 使用各自构建产物路径;`--native-binary` 必须指向可信原生可执行程序,不接受 shell 命令。也可以通过 `CROSSDESK_NATIVE_BINARY` 显式指定开发产物。未指定时只从包内的固定版本平台依赖解析,不搜索 PATH。服务启动不连接远端、不打开浏览器;连接时才检查 `doctor` 协议并启动原生子进程。
发布包的配置形式:
```json
{
"mcpServers": {
"crossdesk": {
"command": "npx",
"args": ["-y", "@chunkdash/crossdesk-mcp@<已发布版本>", "serve", "--stdio", "--panel=auto"]
}
}
}
```
平台依赖覆盖 macOS arm64/x64、glibc Linux arm64/x64 和 Windows x64。安装时保留 optional dependencies;不需要另装全局 CLI。原生运行要求沿用 [CLI 分发文档](../cli/docs/npm-distribution.md)。`npm ci` 仅安装此源码目录的开发依赖,开发运行需使用 `--native-binary`;生成的发布包才包含固定平台依赖。
## 用户连接流程
1. 对 Agent 说“打开 CrossDesk”。`crossdesk_open` 创建 `view_id`,返回面板选择和等待用户状态。
2. 在面板选择已保存设备,或输入 9 位设备 ID;密码填写在面板里。可选择仅查看或允许 Agent 控制。
3. “保存设备”“记住密码”“允许以后自动连接”分别控制设备簿、系统凭据库、持久权限。后两项要求保存设备,默认均关闭。保存密码不自动授予控制权限。
4. 有待执行的远程任务时,Agent 在打开面板后立即调用 `crossdesk_get_session({view_id, wait_ms: 25000})` 等待连接。连接就绪后,工具直接返回会话 ID 和权限状态,Agent 继续任务;用户无需回到对话发送连接确认,也无需点击继续按钮。浏览器和 Apps 面板使用同一流程。
5. Agent 获取独立截图,用截图的 `observation_id` 和图像坐标提交动作。面板预览不消耗模型截图序列。
`crossdesk_open` 立即返回,让 Apps 宿主有机会渲染面板;等待连接在后续 `crossdesk_get_session` 调用中完成。按 `view_id` 查询默认最多等待 25 秒,`wait_ms: 0` 则立即查询;未就绪时返回 `wait_timed_out: true`,Agent 在原任务仍待执行时继续等待。按 `session_id` 查询仍立即返回。取消等待只取消本次查询,不断开用户正在建立或使用的连接。用户主动断开后返回 `closed`,Agent 停止等待,不自动重连。
自动继续依赖宿主保持当前 Agent 任务及工具调用流程。它不通过网页伪造用户消息,也不承诺从已经结束的对话中主动唤醒 Agent;此类唤醒由宿主负责。
人工暂停由原生层锁定,模型不能通过 `set_control(true)` 恢复;只有当前授权面板的恢复按钮可以恢复。仅查看连接不能升级为控制连接,需重新连接并授权。关闭/隐藏面板仅停止预览,远程会话保留;断开按钮或 `crossdesk_disconnect` 才结束会话。
同一 MCP 进程默认只保留一个活动远程会话。相同配置目录中的进程用设备锁避免同时连接同一目标;它不协调另一个系统用户、另一份配置目录或独立桌面客户端。
## 面板策略
`--panel` 优先于设备配置中的 `panel`,默认 `auto`。
| 模式 | 行为 |
| --- | --- |
| `auto` | 当前请求声明 MCP Apps MIME 能力时提供 Apps;其他情况打开本机浏览器 |
| `apps` | 只使用 MCP Apps;能力不匹配返回 `PANEL_UNSUPPORTED` |
| `browser` | 只使用本地浏览器,不附加 Apps 工具资源元数据 |
| `off` | 不启动网页服务,保留截图和操作工具,凭据通过独立 `setup` 配置 |
使用 `extensions["io.modelcontextprotocol/ui"].mimeTypes` 中的 `text/html;profile=mcp-app` 判断能力。旧协议读取 initialize 能力;2026-07-28 协议读取当前请求的能力,不按宿主产品名猜测支持情况。Apps 的卡片、侧栏或窗口形式由宿主决定。
Apps 资源为 `ui://crossdesk/viewer.html`,工具只在允许使用 Apps 且当前请求支持时暴露 UI 元数据和面板专用接口。页面回报 `crossdesk_ui_ready` 后才标记就绪;10 秒未回报显示 `not_confirmed`,不自动弹第二个浏览器。用户明确要求回退时调用 `crossdesk_open_browser`;该工具不附带 Apps 元数据,`apps`/`off` 策略拒绝覆盖。
浏览器面板只监听随机端口的 `127.0.0.1`,与 MCP 服务同一进程。它不是 MCP Streamable HTTP,也不向局域网提供 MCP 接入。MCP、CLI、用户浏览器应在同一控制端电脑上运行;SSH 容器/远程宿主的浏览器转发不属于此本地接入模式。
页面标题为 CrossDesk MCP,仅保留正式应用标志,使用文字按钮和简洁状态提示。布局填满浏览器或宿主面板的可用宽高,远程画面保持原比例自动缩放;连接设置在高度不足时独立滚动。连接后侧栏显示设备与授权信息,暂停、恢复和断开按钮位于画面工具栏。「放大画面」收起侧栏,按 Esc 还原;窄屏默认显示连接表单,连接后显示画面,可用「连接信息」展开设备详情。界面资源均可离线使用,保存设备与持久授权仍由用户分别选择。
首次打开使用 URL fragment 中的短期一次性 bootstrap,页面立即清除 fragment,并换取 HttpOnly/SameSite cookie 和 CSRF 令牌。模型只收到无特权的网页地址。复制无凭据地址到另一浏览器不能获得会话控制权。
无法自动打开浏览器时,在自己的终端运行工具结果 `pairing_command` 中的命令和参数。例如:
```sh
crossdesk-mcp setup --open-panel --view <view_id> --config-dir <相同的配置目录>
```
通过源码运行时用 `node /absolute/path/apps/mcp/dist/main.js` 替换 `crossdesk-mcp`。配对通过本用户配置目录中的受限运行描述符和本地 IPC 完成,不允许匿名 HTTP 请求签发控制凭证。`setup` 使用用户终端的桌面环境打开浏览器;仍无法打开时,只在该交互终端显示 60 秒有效的一次性地址,不将它返回 MCP 或发送到聊天。面板最多保留 32 个视图;再次打开时会清理超过 30 分钟未使用的视图。浏览器会话最长 12 小时,失效后重新打开。
## 无面板配置与凭据
在用户自己的交互终端执行(不能在宿主 stdio 通道中交互输入密码):
```sh
crossdesk-mcp setup
```
配置默认存放于 `~/.config/crossdesk/mcp/`,可用 `--config-dir` 或 `CROSSDESK_MCP_CONFIG_DIR` 覆盖。`devices.json` 包含设备、面板/视频选项及凭据引用;密码保存到 macOS Keychain、Windows 凭据库或 Linux Secret Service。凭据库不可用就返回明确错误,没有明文文件降级。面板的临时连接仍可使用用户现场输入的密码。持久保存失败会保留已建立连接,并在面板显示错误。
也可在 `setup` 中指定外部密码环境变量名称,服务读取该变量作为凭据提供器。示例配置(没有密码值):
```json
{
"version": 1,
"panel": "off",
"server": "api.crossdesk.cn",
"port": 9099,
"devices": [{
"alias": "work-pc",
"device_id": "123456789",
"server": "api.crossdesk.cn",
"port": 9099,
"authorization": "control",
"credential": "environment",
"environment_variable": "MY_CROSSDESK_PASSWORD"
}]
}
```
授权范围和可用凭据缺一不可。模型不能提供任意 `device_id` 绕过面板确认;模型侧 `crossdesk_connect` 仅接受已授权设备别名。不要把密码发给 Agent,也不要把包含密码的命令行写进宿主配置。
Apps 依赖宿主遵守 app-only 工具可见性和 `_meta` 的模型不可见约定;服务另检查每个视图的随机 UI 能力令牌和 generation。app-only 标记本身不是可信身份凭据。能够读取同用户进程/文件或执行任意本机代码的 Agent 不受此边界隔离。
## 模型工具
| 工具 | 作用 |
| --- | --- |
| `crossdesk_open` | 打开设备选择/连接面板,返回 `view_id` |
| `crossdesk_open_browser` | 用户显式请求的浏览器入口/回退 |
| `crossdesk_list_devices` | 本机设备簿和授权摘要,不提供云发现 |
| `crossdesk_connect` | 通过别名连接已经授权且有凭据的设备 |
| `crossdesk_get_session` | `session_id`、`view_id` 严格二选一;按 `view_id` 可等待连接,`wait_ms` 范围 0–25000 |
| `crossdesk_screenshot` | 返回 PNG image content 和观测元数据 |
| `crossdesk_action` | 结构化输入,可附带动作之后的截图 |
| `crossdesk_set_control` | 在已授权范围内启停 Agent 输入 |
| `crossdesk_select_display` | 切换显示器,使旧观测失效 |
| `crossdesk_disconnect` | 断开并释放输入状态、子进程和设备锁 |
动作包括 `move`、`click`、`double_click`、`scroll`、`drag`、`key_press`、`hotkey`、`type_text`。不提供 shell、任意键按下保持、剪贴板或文件操作。按键使用 Windows VK 对应的标准名称:`A`–`Z`、`0`–`9`、`F1`–`F12`、`CTRL`、`ALT`、`SHIFT`、`META`、`ENTER`、`ESC`、方向键等;应用快捷键仍需根据远端系统选择。
截图默认最长边 1280,最大 2048;允许指定源帧像素区域。返回 `source_size`、`source_rect`、`image_size`、`generation`、`geometry_epoch`、`frame_id`、`frame_age_ms` 和 `observation_id`。动作坐标使用返回图像坐标,不使用浏览器 CSS 像素。观测保留最多 32 个,30 秒过期;切换显示器、连接代际或图像几何变化立即失效。截图使用不超过 2 秒的帧,PNG 不超过 2 MiB,必要时继续缩小输出并返回真实尺寸。
`operation_id` 在当前原生会话中缓存最近 128 个操作,至少在未被容量淘汰的 5 分钟窗口内支持同参数查询;不同参数复用 ID 返回冲突。`submitted` 仅说明本地发送完成,远端没有执行 ACK。超时、断网、取消或发送失败可能返回 `outcome_unknown`/`side_effect_possible`,禁止自动重放。`observe_after` 默认开启,后续图像只证明取得更新帧,不证明操作执行成功。
## 实时视频与按需截图
实时视频提供两条路径,默认 `browser`,两条路径均由原生 CLI 的本机 WebSocket 直接发送给浏览器。Node 只处理会话、授权票据和 MCP 工具,不读取或转发实时视频字节;不再创建视频管道,不经过 MCP stdio 或模型上下文,不逐帧转 PNG、不重新编码视频。
| `video.decode_path` | 画面路径 |
| --- | --- |
| `browser` | MiniRTC 输出原始 H.264 Annex B → 浏览器 WebCodecs 解码 → canvas |
| `minirtc` | MiniRTC 解码 → 紧凑 NV12 原始帧 → 浏览器 VideoFrame / canvas;浏览器不再解码 H.264 |
`video.decoder` 默认 `auto`:优先使用可用硬件解码器,不支持时使用软件。`software` 禁用本程序的硬件选择与硬件回退。浏览器路径使用 WebCodecs 的 `prefer-hardware` / `prefer-software`;这些值是浏览器 API 的偏好,浏览器可以忽略,不能据此宣称已确认物理硬件或软件后端。原生路径由 MiniRTC 的解码器工厂选择,本机 macOS 使用 VideoToolbox 或 OpenH264;其他平台使用 MiniRTC 已支持的后端。状态中的 `native_video_decoder` 返回 MiniRTC 报告的后端名称(统计可用前为空),不代表浏览器后端。
在 `~/.config/crossdesk/mcp/devices.json` 的根对象中加入:
```json
"video": {
"decode_path": "minirtc",
"decoder": "auto"
}
```
也可在 Agent 宿主的 MCP 启动参数中配置,命令行优先于文件:
```sh
crossdesk-mcp serve --stdio --video-path browser --video-decoder auto
crossdesk-mcp serve --stdio --video-path minirtc --video-decoder software
```
修改后重启 MCP,再建立连接。选项适用于整个 MCP 进程,两条路径均将解码选项传给原生端;浏览器路径中的原生解码用于截图缓存。截图工具或动作后的截图请求才触发 PNG 编码。这里配置的是控制端解码器,不更改远端编码器。原有配置文件无需新增字段即可继续使用。
Agent 模式默认请求高帧率档位,分辨率上限 1920 × 1080;实际分辨率和帧率由远端能力、网络和本机解码性能决定。面板显示实际尺寸与呈现 FPS。浏览器路径需要 WebCodecs H.264 解码能力;MiniRTC 路径仅需浏览器支持从 NV12 创建 VideoFrame 并绘制。能力不足时明确报错,不自动退回 PNG 轮询。NV12 不压缩,1080p / 30 FPS 约需 93 MB/s 本机传输,4K / 30 FPS 约需 373 MB/s;它不增加远端到控制端的 H.264 网络码率。移除 Node 转发没有消除 NV12 自身的本机传输与浏览器像素处理开销;这不是 GPU 零拷贝。
视频 WebSocket 在原生 CLI 的独立 I/O 线程中运行,只监听随机 `127.0.0.1` 端口,与 Node 面板 HTTP 端口不同。浏览器面板通过 cookie/CSRF 授权获取视频票据,Apps 通过 app-only `crossdesk_video_open` 和视图令牌获取。票据 15 秒内一次性有效,绑定视图、会话和代际,通过 WebSocket 第一条消息提交,不写入 URL、日志或模型可见工具结果。WebSocket 只提供画面、收帧 ACK 和关键帧恢复请求,不能执行远端输入操作。最多 8 个活动观看者。H.264 丢帧后等待关键帧恢复;NV12 每帧独立,每个观看者最多一帧未确认,原生发送队列只保留最新画面;H.264 最多 8 帧未确认并受字节阈值限制。10 秒未确认最早一帧则关闭该视频通道,不阻塞其他观看者或 MCP 控制。视图撤销、显示器/连接代际变化、断开和进程退出会关闭对应视频连接;暂停 Agent 控制仍继续播放。页面隐藏或卸载时释放视频订阅,恢复可见时重新订阅。
浏览器页面和 MCP Apps 资源的 CSP 允许原生服务使用的 `ws://127.0.0.1:*`(仅动态端口),HTTP 控制仍限制到 Node 面板的确切地址。原生服务另外校验确切 Host、Origin 和一次性票据。宿主仍可能限制 loopback、WebSocket、WebCodecs 或 iframe 权限;视频失败时提供重试和浏览器入口,`--panel=apps` 的严格策略仍禁止浏览器回退。浏览器和 Apps 共用同一播放器,具体宿主需要独立验收。
## 打包
```sh
npm run build
npm run pack:npm -- --out-dir /absolute/path/artifacts
```
脚本生成可发布 tarball,包含面板、运行代码、源代码、许可和固定版本的五个平台可选依赖。CI 的 `MCP package` 工作流仅构建并上传 `mcp-package`,不发布 npm。原生包继续由 CLI 工作流构建,`doctor` 必须同时声明自动化协议和 `automation_video_protocols: ["h264-annexb/1", "nv12/1"]` 和 `automation_video_decoders: ["auto", "software"]`,以及 `automation_video_transport: "loopback-websocket/1"`;先发布对应 0.2.0 平台包,再发布 MCP tarball。尚未进行真实跨平台发布/远端验收时,不应把构建成功等同于各宿主和设备上的联调完成。
从发布 tarball 重建 TypeScript/面板时,将包中的 `build-package.json` 复制为 `package.json`,运行 `npm ci && npm run build`。原生源码和依赖来源随平台包提供,详见 [源码来源](../cli/docs/source-provenance.md)。
+977
View File
@@ -0,0 +1,977 @@
{
"name": "@chunkdash/crossdesk-mcp",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@chunkdash/crossdesk-mcp",
"version": "0.1.0",
"license": "GPL-3.0-only",
"dependencies": {
"@modelcontextprotocol/client": "2.4.0",
"@modelcontextprotocol/core": "2.4.0",
"@modelcontextprotocol/ext-apps": "2.0.4",
"@modelcontextprotocol/server": "2.4.0",
"@napi-rs/keyring": "2.1.2",
"zod": "4.6.7"
},
"bin": {
"crossdesk-mcp": "dist/main.js"
},
"devDependencies": {
"@types/node": "22.19.15",
"esbuild": "0.28.2",
"typescript": "5.9.3"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@modelcontextprotocol/client": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.4.0.tgz",
"integrity": "sha512-LgrD2OphHuG12vfKFKz6SOnq7bKHQmhGJIAYdD+MZBn2npHEyry6uD8zvktodtl5CQM85MO0anUyD9JwmUmxww==",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/core": "2.4.0",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.8",
"jose": "^6.1.3",
"pkce-challenge": "^5.0.0",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/core": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz",
"integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/ext-apps": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-2.0.4.tgz",
"integrity": "sha512-B+PcbuGiaf0e5I3uy4iO5tS3Abo+5w8JvZFzHMzQdq9yVwP836zJkw2+KCGOQ0r4TM1fuQi4JdnbuWjkS4aBvg==",
"license": "MIT",
"workspaces": [
"examples/*"
],
"dependencies": {
"@standard-schema/spec": "^1.1.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@modelcontextprotocol/client": "^2.0.0",
"@modelcontextprotocol/core": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"zod": "^4.2.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/server": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz",
"integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/core": "2.4.0",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@napi-rs/keyring": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.1.2.tgz",
"integrity": "sha512-9i1SR6tYeQ3o1beLeVMxhN6Fq513m9TZlTs+46+4JgvSNkaFoW9S4WrpV5MjzE+0CzVoC+dZ4RfZDIgW6+afnQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/keyring-darwin-arm64": "2.1.2",
"@napi-rs/keyring-darwin-x64": "2.1.2",
"@napi-rs/keyring-freebsd-x64": "2.1.2",
"@napi-rs/keyring-linux-arm-gnueabihf": "2.1.2",
"@napi-rs/keyring-linux-arm64-gnu": "2.1.2",
"@napi-rs/keyring-linux-arm64-musl": "2.1.2",
"@napi-rs/keyring-linux-riscv64-gnu": "2.1.2",
"@napi-rs/keyring-linux-x64-gnu": "2.1.2",
"@napi-rs/keyring-linux-x64-musl": "2.1.2",
"@napi-rs/keyring-win32-arm64-msvc": "2.1.2",
"@napi-rs/keyring-win32-ia32-msvc": "2.1.2",
"@napi-rs/keyring-win32-x64-msvc": "2.1.2"
}
},
"node_modules/@napi-rs/keyring-darwin-arm64": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-2.1.2.tgz",
"integrity": "sha512-vsInUWUzWS6/WzR/TFNwmdtmwKWjnnR8TOpvZ/pjjxDAkJB/waz37PWzp2b8AFcw1aCcq2aQ7k2vvbLQy8pTrw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-darwin-x64": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-2.1.2.tgz",
"integrity": "sha512-wQihYroaQ3r++R6u5c9Gj0TCw1l1F53LCb5aLOXfVvT4DsnDnpe7p7VGYo4/33bLe0iqVevtAj+ET3yKCtOlJQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-freebsd-x64": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-2.1.2.tgz",
"integrity": "sha512-Ve5ha7MbJsq+4TYyXYiTy4IeZOnYxkBB05uH2+nIrEKBkHmyfHttF814EfWAfoJa63KqBhYRXdYJo0o1WVL2sg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-2.1.2.tgz",
"integrity": "sha512-zxijTnVnTApEyJMjgnLdVQL1g1DAudku/5brrWNDRWj4J0q0nFhUZEtzUA+nY2FvgDjl4s+2YCZYXSTH4TFMUg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-arm64-gnu": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-2.1.2.tgz",
"integrity": "sha512-PM+SAcl/yGzgGbeeCsKrnlG4T5DBIDjmDqtKm5an1bJz0o0gwSzR3BMiXf04JenkcqascZpdcIReoEvLj0cvCw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-arm64-musl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-2.1.2.tgz",
"integrity": "sha512-+ERS9oyInPCALN957cViBDbYJc724/Q+rZ8ZAz4rOHj4B4v0VR4OAWThdYZgZqD54sZVV0MdyX24qva97hYoAQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-2.1.2.tgz",
"integrity": "sha512-pGADtqeT3U1SaFZGjkVsmZVjeNOQx0fr77S+NWszXjZboyaUOLlGKgqG6ssAWzcwxv8uPhKc9Ij17ujNZhSqLw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-x64-gnu": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-2.1.2.tgz",
"integrity": "sha512-Fj5TmTnl2oriuCT5DDEYlhgxYes20uGfgOg10LK9PyPVsI8/PQ1m8I4rq/4A5hkrMtY7wvFb70YmX/yPHDYWvQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-x64-musl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-2.1.2.tgz",
"integrity": "sha512-C+KhAeU6x2A43FV3dOmMvUraJb8zsC9VZfsunjZ1cgtuAb208TALaUuAY0jThOphXqhyhBJOs2xTCQhOrRkJIw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-win32-arm64-msvc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-2.1.2.tgz",
"integrity": "sha512-kBq80ZZfjXoa5wHG9kgAVhYYjaPOLgHh5ngt7r1ftwFXPXDPXKH7oM2r2j2Q64cs0pVtmodVtn5Q9e6xCwzQUQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-win32-ia32-msvc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-2.1.2.tgz",
"integrity": "sha512-bhgKvVbWlZ6iR/tmvb2ht6/o0OzarUX9W7hjg+G+eVdGjR1prwtQ9lYo5ZESdn/+zyF1MIFvHkOCGja9YqDcww==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-win32-x64-msvc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-2.1.2.tgz",
"integrity": "sha512-yQtFdfuwqM0ezKlYO6r2n44in2LGzd4MC679Ddhvojip3t1hn+q/4I+gJU+cIJQVd5FAZSKNrK4tGfew3UTpAQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jose": {
"version": "6.2.15",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz",
"integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"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/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/zod": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@chunkdash/crossdesk-mcp",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Local CrossDesk MCP tools and remote desktop panels",
"license": "GPL-3.0-only",
"author": "DI JUNKUN",
"repository": {
"type": "git",
"url": "git+https://github.com/kunkundi/crossdesk.git",
"directory": "apps/mcp"
},
"engines": {
"node": ">=22"
},
"bin": {
"crossdesk-mcp": "dist/main.js"
},
"scripts": {
"build": "tsc && tsc -p tsconfig.ui.json && node scripts/build-ui.mjs",
"check": "tsc --noEmit && tsc -p tsconfig.ui.json",
"pack:npm": "node scripts/pack.mjs"
},
"dependencies": {
"@modelcontextprotocol/client": "2.4.0",
"@modelcontextprotocol/core": "2.4.0",
"@modelcontextprotocol/ext-apps": "2.0.4",
"@modelcontextprotocol/server": "2.4.0",
"@napi-rs/keyring": "2.1.2",
"zod": "4.6.7"
},
"devDependencies": {
"@types/node": "22.19.15",
"esbuild": "0.28.2",
"typescript": "5.9.3"
}
}
+35
View File
@@ -0,0 +1,35 @@
import { build } from "esbuild";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
// Unmodified apps/desktop/resources/linux/crossdesk_128x128.png.
// Keep a local copy so the npm source package rebuilds without desktop assets.
const logo = "data:image/png;base64," +
(await readFile(resolve(root, "ui/assets/crossdesk.png"))).toString("base64");
await mkdir(resolve(root, "dist"), { recursive: true });
for (const mode of ["apps", "browser"]) {
const result = await build({
entryPoints: [resolve(root, "ui/viewer.ts")],
bundle: true,
minify: true,
platform: "browser",
target: "es2022",
outfile: "viewer.js",
write: false,
define: {
CROSSDESK_APPS: String(mode === "apps"),
CROSSDESK_LOGO: JSON.stringify(logo),
},
});
const script = result.outputFiles
.find((f) => f.path.endsWith(".js"))
.text.replaceAll("</script", "<\\/script");
const css = result.outputFiles.find((f) => f.path.endsWith(".css")).text;
const nonce = mode === "browser" ? ' nonce="__CROSSDESK_NONCE__"' : "";
await writeFile(
resolve(root, `dist/viewer-${mode}.html`),
`<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#ffffff"><link rel="icon" type="image/png" href="${logo}"><title>CrossDesk MCP</title><style${nonce}>${css}</style></head><body><div id="app"></div><script${nonce}>${script}</script></body></html>`,
);
}
+107
View File
@@ -0,0 +1,107 @@
import {
mkdtemp,
readFile,
writeFile,
mkdir,
cp,
chmod,
rm,
access,
} from "node:fs/promises";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const repository = resolve(root, "../..");
const { values } = parseArgs({ options: { "out-dir": { type: "string" } } });
const output = resolve(values["out-dir"] ?? join(repository, "build/npm-mcp"));
const project = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
const cli = JSON.parse(
await readFile(join(root, "../cli/package.json"), "utf8"),
);
const platforms = JSON.parse(
await readFile(join(root, "../cli/npm/platforms.json"), "utf8"),
);
const native = await import(new URL("../dist/native.js", import.meta.url));
if (native.NATIVE_VERSION !== cli.version)
throw new Error("MCP and CLI native versions must match.");
for (const file of ["main.js", "viewer-browser.html", "viewer-apps.html"])
await access(join(root, "dist", file));
const staging = await mkdtemp(join(tmpdir(), "crossdesk-mcp-pack-"));
try {
for (const entry of [
"dist",
"src",
"ui",
"scripts",
"tsconfig.json",
"tsconfig.ui.json",
"package-lock.json",
"README.md",
])
await cp(join(root, entry), join(staging, entry), { recursive: true });
await cp(join(repository, "LICENSE"), join(staging, "LICENSE"));
await writeFile(
join(staging, "build-package.json"),
JSON.stringify(project, null, 2) + "\n",
);
const manifest = {
...project,
private: false,
files: [
"dist",
"src",
"ui",
"scripts/build-ui.mjs",
"tsconfig.json",
"tsconfig.ui.json",
"build-package.json",
"package-lock.json",
"README.md",
"LICENSE",
],
optionalDependencies: Object.fromEntries(
Object.values(platforms).map((p) => [p.packageName, cli.version]),
),
publishConfig: { access: "public" },
};
delete manifest.scripts;
delete manifest.devDependencies;
await writeFile(
join(staging, "package.json"),
JSON.stringify(manifest, null, 2) + "\n",
);
await chmod(join(staging, "dist/main.js"), 0o755);
await mkdir(output, { recursive: true });
const filename = `${project.name.replace(/^@/, "").replace("/", "-")}-${project.version}.tgz`;
try {
await access(join(output, filename));
throw new Error(`Output already exists: ${filename}`);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
const args = [
"pack",
"--json",
"--ignore-scripts",
"--pack-destination",
output,
];
if (process.platform === "win32" && !process.env.npm_execpath)
throw new Error("On Windows use npm run pack:npm.");
const result = execFileSync(
process.env.npm_execpath ? process.execPath : "npm",
process.env.npm_execpath ? [process.env.npm_execpath, ...args] : args,
{ cwd: staging, encoding: "utf8" },
);
const parsed = JSON.parse(result);
const packages = Array.isArray(parsed) ? parsed : Object.values(parsed);
if (packages.length !== 1 || packages[0].filename !== filename)
throw new Error("Unexpected package archive.");
process.stdout.write(join(output, filename) + "\n");
} finally {
await rm(staging, { recursive: true, force: true });
}
+406
View File
@@ -0,0 +1,406 @@
import {
createServer,
type IncomingMessage,
type ServerResponse,
type Server,
} from "node:http";
import {
createServer as createIPC,
createConnection,
type Server as IPCServer,
} from "node:net";
import { spawn } from "node:child_process";
import { readFile, writeFile, unlink, chmod, readdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { Failure, token, equal, failure, type Data } from "./common.js";
import { Sessions, type View } from "./sessions.js";
import { uiAction } from "./ui-actions.js";
const uiFile = fileURLToPath(new URL("./viewer-browser.html", import.meta.url));
interface BrowserSession {
csrf: string;
views: Set<string>;
expires: number;
}
export async function openURL(url: string): Promise<boolean> {
if (
process.platform === "linux" &&
!process.env.DISPLAY &&
!process.env.WAYLAND_DISPLAY
)
return false;
const command =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "rundll32.exe"
: "xdg-open";
const args =
process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
return new Promise((resolve) => {
const child = spawn(command, args, {
stdio: "ignore",
shell: false,
windowsHide: true,
});
const timeout = setTimeout(() => {
child.unref();
resolve(true);
}, 1500);
child.once("error", () => {
clearTimeout(timeout);
resolve(false);
});
child.once("exit", (code) => {
clearTimeout(timeout);
resolve(code === 0);
});
});
}
export class BrowserPanel {
private closed = false;
private server?: Server;
private starting?: Promise<void>;
private ipc?: IPCServer;
private ipcPath?: string;
private descriptor?: string;
private origin = "";
private cookie = "crossdesk_" + token();
private grants = new Map<string, { view: string; expires: number }>();
private clients = new Map<string, BrowserSession>();
private html = "";
constructor(private sessions: Sessions) {}
async connectDomains() {
await this.start();
// Native processes own ephemeral video ports, including after reconnect.
// Restrict the wildcard to ports on the literal loopback address.
return [this.origin, "ws://127.0.0.1:*"];
}
async video(view: View) {
await this.start();
if (this.closed) throw new Failure("PANEL_CLOSED");
return this.sessions.openVideo(view, this.origin);
}
async open(view: View): Promise<Data> {
if (this.closed) throw new Failure("PANEL_CLOSED");
await this.start();
if (this.closed) throw new Failure("PANEL_CLOSED");
const opened = await openURL(this.bootstrapURL(view));
return {
...this.sessions.describe(view),
browser_opened: opened,
browser_url: this.origin,
pairing_command: {
command: "crossdesk-mcp",
args: [
"setup",
"--open-panel",
"--view",
view.id,
"--config-dir",
this.sessions.store.directory,
],
},
};
}
private bootstrapURL(view: View): string {
if (this.closed) throw new Failure("PANEL_CLOSED");
for (const [key, value] of this.grants)
if (value.expires < Date.now()) this.grants.delete(key);
if (this.grants.size >= 32) throw new Failure("VIEW_LIMIT");
const grant = token();
this.grants.set(grant, { view: view.id, expires: Date.now() + 60000 });
return `${this.origin}/#bootstrap=${grant}`;
}
private async start() {
this.starting ??= (async () => {
this.html = await readFile(uiFile, "utf8");
if (this.closed) throw new Failure("PANEL_CLOSED");
this.server = createServer((req, res) => {
void this.handle(req, res).catch(() => {
if (!res.headersSent)
this.respond(res, 500, { error: { code: "INTERNAL_ERROR" } });
else res.end();
});
});
// No upgrade handler or video bytes in the Node HTTP server.
this.server.requestTimeout = 45000;
this.server.headersTimeout = 10000;
this.server.maxHeadersCount = 32;
await new Promise<void>((resolve, reject) => {
this.server!.once("error", reject);
this.server!.listen(0, "127.0.0.1", resolve);
});
this.origin = `http://127.0.0.1:${(this.server.address() as import("node:net").AddressInfo).port}`;
if (this.closed) throw new Failure("PANEL_CLOSED");
await this.startIPC();
if (this.closed) throw new Failure("PANEL_CLOSED");
})().catch(async (error) => {
await this.close(false);
this.starting = undefined;
throw error;
});
return this.starting;
}
private respond(res: ServerResponse, code: number, value: Data) {
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(value));
}
private client(req: IncomingMessage): BrowserSession | undefined {
const value = req.headers.cookie
?.split(";")
.map((s) => s.trim())
.find((s) => s.startsWith(this.cookie + "="))
?.slice(this.cookie.length + 1);
const client = value ? this.clients.get(value) : undefined;
return client && client.expires > Date.now() ? client : undefined;
}
private async body(req: IncomingMessage): Promise<Data> {
if (req.headers["content-type"] !== "application/json")
throw new Failure("CONTENT_TYPE_REQUIRED");
let bytes = 0;
const chunks: Buffer[] = [];
for await (const chunk of req) {
bytes += chunk.length;
if (bytes > 65536) throw new Failure("MESSAGE_TOO_LARGE");
chunks.push(chunk);
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (!value || Array.isArray(value) || typeof value !== "object")
throw new Error();
return value;
} catch {
throw new Failure("INVALID_ARGUMENT");
}
}
private async handle(req: IncomingMessage, res: ServerResponse) {
res.setHeader("Cache-Control", "no-store");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
const authority = this.origin.slice("http://".length);
if (
req.headers.host !== authority ||
(req.headers.origin && req.headers.origin !== this.origin)
)
return this.respond(res, 403, { error: { code: "ORIGIN_DENIED" } });
if (req.url === "/" && req.method === "GET") {
const nonce = token();
res.setHeader(
"Content-Security-Policy",
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src data:; connect-src 'self' ws://127.0.0.1:*; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(this.html.replaceAll("__CROSSDESK_NONCE__", nonce));
return;
}
if (req.url === "/api/context" && req.method === "GET") {
const client = this.client(req);
if (!client)
return this.respond(res, 401, { error: { code: "PAIRING_REQUIRED" } });
for (const id of client.views)
if (!this.sessions.views.has(id)) client.views.delete(id);
if (!client.views.size)
return this.respond(res, 401, { error: { code: "PAIRING_REQUIRED" } });
return this.respond(res, 200, {
csrf: client.csrf,
views: [...client.views].map((v) =>
this.sessions.describe(this.sessions.view(v)),
),
});
}
if (req.method !== "POST" || req.headers.origin !== this.origin)
return this.respond(res, 403, { error: { code: "ORIGIN_DENIED" } });
try {
const body = await this.body(req);
if (req.url === "/api/bootstrap") {
const grant =
typeof body.token === "string"
? this.grants.get(body.token)
: undefined;
if (!grant || grant.expires < Date.now())
throw new Failure("PAIRING_REQUIRED");
this.grants.delete(body.token);
for (const [id, client] of this.clients)
if (client.expires < Date.now()) this.clients.delete(id);
const existing = this.client(req);
if (!existing && this.clients.size >= 32)
throw new Failure("VIEW_LIMIT");
const session = token(),
client = existing ?? {
csrf: token(),
views: new Set<string>(),
expires: 0,
};
client.views.add(grant.view);
client.expires = Date.now() + 12 * 60 * 60 * 1000;
for (const [key, previous] of this.clients)
if (previous === client) this.clients.delete(key);
this.clients.set(session, client);
res.setHeader(
"Set-Cookie",
`${this.cookie}=${session}; HttpOnly; SameSite=Strict; Path=/api`,
);
return this.respond(res, 200, {
csrf: client.csrf,
...this.sessions.describe(this.sessions.view(grant.view)),
});
}
const client = this.client(req);
if (
!client ||
typeof req.headers["x-crossdesk-csrf"] !== "string" ||
!equal(client.csrf, req.headers["x-crossdesk-csrf"])
)
throw new Failure("PANEL_UNAUTHORIZED");
if (typeof body.view_id !== "string" || !client.views.has(body.view_id))
throw new Failure("VIEW_UNAUTHORIZED");
const name = req.url?.match(
/^\/api\/(ready|devices|status|connect|video_open|control)$/,
)?.[1];
if (!name) throw new Failure("UNKNOWN_ACTION");
const { view_id, ...args } = body;
const controller = new AbortController();
res.once("close", () => {
if (!res.writableEnded) controller.abort();
});
const result = name === "video_open" ? await this.video(this.sessions.view(view_id)) : await uiAction(
this.sessions,
this.sessions.view(view_id),
name,
args,
controller.signal,
);
this.respond(res, 200, result);
} catch (error) {
this.respond(res, 400, { error: failure(error) });
}
}
private async startIPC() {
const secret = token();
const suffix = token().slice(0, 20);
this.ipcPath =
process.platform === "win32"
? `\\\\.\\pipe\\crossdesk-mcp-${suffix}`
: join(tmpdir(), `cd-mcp-${suffix}.sock`);
this.ipc = createIPC((socket) => {
socket.setTimeout(5000, () => socket.destroy());
let data = "",
consumed = false;
socket.on("error", () => {});
socket.on("data", (part) => {
data += part.toString("utf8");
if (data.length > 2048) {
socket.destroy();
return;
}
if (consumed || !data.includes("\n")) return;
consumed = true;
void (async () => {
try {
const p = JSON.parse(data);
if (
typeof p.secret !== "string" ||
!equal(p.secret, secret) ||
typeof p.view_id !== "string"
)
throw new Failure("PAIRING_DENIED");
const v = this.sessions.view(p.view_id);
if (
v.panel !== "browser" ||
!["auto", "browser"].includes(this.sessions.policy)
)
throw new Failure("PANEL_POLICY_DENIED");
// Only the authenticated local setup process receives this URL.
// It uses its own desktop environment to open the browser.
socket.end(
JSON.stringify({ ok: true, url: this.bootstrapURL(v) }) + "\n",
);
} catch {
socket.end('{"ok":false}\n');
}
})();
});
});
await new Promise<void>((resolve, reject) => {
this.ipc!.once("error", reject);
this.ipc!.listen(this.ipcPath!, resolve);
});
if (process.platform !== "win32") await chmod(this.ipcPath, 0o600);
this.descriptor = join(
this.sessions.store.directory,
`run-${process.pid}-${suffix}.json`,
);
await writeFile(
this.descriptor,
JSON.stringify({ pid: process.pid, endpoint: this.ipcPath, secret }),
{ mode: 0o600, flag: "wx" },
);
}
async close(final = true) {
if (final) this.closed = true;
await this.sessions.revokeVideo();
this.grants.clear();
this.clients.clear();
this.server?.closeAllConnections();
if (this.server?.listening) this.server.close();
if (this.ipc?.listening) this.ipc.close();
if (this.descriptor) await unlink(this.descriptor).catch(() => {});
if (this.ipcPath && process.platform !== "win32")
await unlink(this.ipcPath).catch(() => {});
}
}
export async function pairBrowser(
directory: string,
view: string,
): Promise<string | undefined> {
for (const file of await readdir(directory)) {
if (!/^run-\d+-[a-zA-Z0-9_-]+\.json$/.test(file)) continue;
try {
const descriptor = JSON.parse(
await readFile(join(directory, file), "utf8"),
);
const url = await new Promise<string | undefined>((resolve) => {
const socket = createConnection(descriptor.endpoint);
let data = "";
socket.setTimeout(5000, () => {
socket.destroy();
resolve(undefined);
});
socket.once("error", () => resolve(undefined));
socket.once("connect", () =>
socket.write(
JSON.stringify({ secret: descriptor.secret, view_id: view }) + "\n",
),
);
socket.on("data", (part) => {
data += part;
if (data.length > 2048) socket.destroy();
});
socket.once("end", () => {
try {
const result = JSON.parse(data);
resolve(
result.ok &&
typeof result.url === "string" &&
/^http:\/\/127\.0\.0\.1:\d+\/#bootstrap=[a-zA-Z0-9_-]+$/.test(
result.url,
)
? result.url
: undefined,
);
} catch {
resolve(undefined);
}
});
});
if (url) return url;
} catch {
/* Another MCP instance may have exited. */
}
}
return undefined;
}
+131
View File
@@ -0,0 +1,131 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { z } from "zod";
export type Data = Record<string, any>;
export const token = () => randomBytes(24).toString("base64url");
export const equal = (a: string, b: string) => {
const left = Buffer.from(a),
right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
};
export class Failure extends Error {
constructor(
public code: string,
public sideEffect = false,
public retryable = false,
) {
super(code);
}
}
export function failure(error: unknown): Data {
const e = error instanceof Failure ? error : new Failure("INTERNAL_ERROR");
return {
code: e.code,
retryable: e.retryable,
side_effect_possible: e.sideEffect,
};
}
export function parse<T extends z.ZodType>(
schema: T,
value: unknown,
): z.output<T> {
const result = schema.safeParse(value);
if (!result.success) throw new Failure("INVALID_ARGUMENT");
return result.data;
}
export const id = z
.string()
.min(1)
.max(128)
.regex(/^[a-zA-Z0-9_-]+$/);
export const alias = z
.string()
.min(1)
.max(80)
.regex(/^[\p{L}\p{N} _.-]+$/u);
export const mode = z.enum(["observe", "control"]);
export const panel = z.enum(["auto", "apps", "browser", "off"]);
export type Panel = z.infer<typeof panel>;
export const videoOptions = z.strictObject({
decode_path: z.enum(["browser", "minirtc"]).default("browser"),
decoder: z.enum(["auto", "software"]).default("auto"),
});
export type VideoOptions = z.infer<typeof videoOptions>;
export const host = z
.string()
.min(1)
.max(253)
.regex(/^[a-zA-Z0-9.:-]+$/);
export const point = z.strictObject({
x: z.number().finite().min(0).max(8192),
y: z.number().finite().min(0).max(8192),
});
const button = z.enum(["left", "right", "middle"]).optional();
const key = z
.string()
.min(1)
.max(16)
.regex(/^[A-Z0-9_]+$/);
export const action = z.discriminatedUnion("type", [
point.extend({ type: z.literal("move") }),
point.extend({ type: z.literal("click"), button }),
point.extend({ type: z.literal("double_click"), button }),
point.extend({
type: z.literal("scroll"),
direction: z.enum(["up", "down", "left", "right"]),
ticks: z.int().min(1).max(10).optional(),
}),
z.strictObject({
type: z.literal("drag"),
path: z.array(point).min(2).max(128),
duration_ms: z.int().min(100).max(2000).optional(),
}),
z.strictObject({ type: z.literal("key_press"), key }),
z.strictObject({
type: z.literal("hotkey"),
keys: z.array(key).min(2).max(6),
}),
z.strictObject({
type: z.literal("type_text"),
text: z
.string()
.min(1)
.max(16384)
.refine((s) => Buffer.byteLength(s) <= 16384 && !s.includes("\0")),
}),
]);
export const screenshot = z.strictObject({
session_id: id,
region: z
.strictObject({
x: z.int().min(0),
y: z.int().min(0),
width: z.int().min(1).max(8192),
height: z.int().min(1).max(8192),
})
.optional(),
max_edge: z.int().min(64).max(2048).optional(),
after_frame_id: z.int().min(0).optional(),
generation: z.int().min(0).optional(),
timeout_ms: z.int().min(0).max(5000).optional(),
});
export const uiConnect = z.strictObject({
device_alias: alias.optional(),
device_id: z
.string()
.regex(/^\d{9}$/)
.optional(),
label: alias.optional(),
password: z
.string()
.max(256)
.refine((s) => Buffer.byteLength(s) <= 256 && !/[\0\r\n]/.test(s))
.optional(),
mode: mode.default("observe"),
display_index: z.int().min(0).max(63).default(0),
server: host.optional(),
port: z.int().min(1).max(65535).optional(),
save_device: z.boolean().default(false),
remember_password: z.boolean().default(false),
persistent_authorization: z.boolean().default(false),
});
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env node
import { parseArgs } from "node:util";
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import {
serveStdio,
StdioServerTransport,
} from "@modelcontextprotocol/server/stdio";
import { DeviceStore, type Device } from "./store.js";
import { Sessions } from "./sessions.js";
import { BrowserPanel, pairBrowser, openURL } from "./browser.js";
import { createServer } from "./server.js";
import {
panel,
parse,
Failure,
failure,
uiConnect,
videoOptions,
type Panel,
} from "./common.js";
async function setup(store: DeviceStore, view?: string) {
if (!process.stdin.isTTY || !process.stdout.isTTY)
throw new Failure("SETUP_REQUIRES_TERMINAL");
if (view) {
const url = await pairBrowser(store.directory, view);
if (!url) throw new Failure("PANEL_NOT_FOUND");
if (await openURL(url)) process.stdout.write("浏览器配对入口已打开。\n");
else
process.stdout.write(
`请在本机浏览器打开下方一次性链接(60 秒有效,不要发送到聊天):\n${url}\n`,
);
return;
}
let hidden = false;
const output = new Writable({
write(chunk, _encoding, done) {
if (!hidden) process.stdout.write(chunk);
done();
},
});
const rl = createInterface({
input: process.stdin,
output,
terminal: true,
historySize: 0,
});
const question = async (label: string) => (await rl.question(label)).trim();
try {
const config = await store.config();
process.stdout.write(
"CrossDesk MCP · 本地设备配置\n密码只保存到系统凭据库,不写入设备配置。\n",
);
const name = await question("设备别名:");
const previous = config.devices.find((d) => d.alias === name);
const device = previous?.device_id ?? (await question("9 位设备 ID:"));
const server =
previous?.server ??
((await question(`服务器 [${config.server}]:`)) || config.server);
const port =
previous?.port ??
Number((await question(`端口 [${config.port}]:`)) || config.port);
const permission = await question(
"允许未来自动连接?[n / observe / control]:",
);
const environment = await question(
"使用现有密码环境变量(留空使用系统凭据库):",
);
let password: string | undefined;
if (!environment) {
hidden = true;
process.stdout.write("访问密码(隐藏输入,可为空):");
try {
password = await rl.question("");
} finally {
hidden = false;
process.stdout.write("\n");
}
}
parse(uiConnect, {
device_id: device,
label: name,
server,
port,
password,
});
const d: Device = {
alias: name,
device_id: device,
server,
port,
...(permission === "observe" || permission === "control"
? { authorization: permission }
: {}),
...(environment
? { credential: "environment", environment_variable: environment }
: {}),
};
await store.save(d, password);
password = undefined;
process.stdout.write(
"已保存。Agent 可调用 crossdesk_list_devices;自动连接仍需有效授权和凭据。\n",
);
} finally {
hidden = false;
rl.close();
}
}
async function main() {
const { values, positionals } = parseArgs({
allowPositionals: true,
strict: true,
options: {
stdio: { type: "boolean" },
panel: { type: "string" },
"video-path": { type: "string" },
"video-decoder": { type: "string" },
"config-dir": { type: "string" },
"native-binary": { type: "string" },
"open-panel": { type: "boolean" },
view: { type: "string" },
help: { type: "boolean" },
version: { type: "boolean" },
},
});
if (values.version) {
process.stdout.write("0.1.0\n");
return;
}
if (values.help || !positionals.length) {
process.stdout.write(
"crossdesk-mcp serve --stdio [--panel auto|apps|browser|off] [--video-path browser|minirtc] [--video-decoder auto|software] [--config-dir directory]\ncrossdesk-mcp setup [--config-dir directory]\ncrossdesk-mcp setup --open-panel --view VIEW_ID [--config-dir directory]\nDevelopment: --native-binary /absolute/path/to/crossdesk-cli\n",
);
return;
}
if (positionals.length !== 1 || !["serve", "setup"].includes(positionals[0]!))
throw new Failure("INVALID_COMMAND");
const store = new DeviceStore(
values["config-dir"] ?? process.env.CROSSDESK_MCP_CONFIG_DIR,
);
await store.initialize();
if (positionals[0] === "setup") {
if (values["open-panel"] && !values.view)
throw new Failure("VIEW_REQUIRED");
await setup(store, values["open-panel"] ? values.view : undefined);
return;
}
if (!values.stdio) throw new Failure("STDIO_REQUIRED");
const config = await store.config();
const policy: Panel = parse(
panel,
values.panel ?? config.panel,
);
const sessions = new Sessions(
store,
policy,
values["native-binary"] ?? process.env.CROSSDESK_NATIVE_BINARY,
parse(videoOptions, {
decode_path: values["video-path"] ?? config.video.decode_path,
decoder: values["video-decoder"] ?? config.video.decoder,
}),
);
const browser = new BrowserPanel(sessions);
const handle = serveStdio(() => createServer(sessions, browser), {
transport: new StdioServerTransport(),
onerror: () => process.stderr.write("crossdesk-mcp: protocol error\n"),
});
let closing = false;
const close = async () => {
if (closing) return;
closing = true;
await browser.close();
await sessions.close();
await handle.close();
};
process.stdin.once("end", () => {
void close();
});
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const)
process.once(signal, () => {
void close().finally(() => {
process.exitCode = 0;
});
});
process.once("uncaughtException", () => {
process.stderr.write("crossdesk-mcp: INTERNAL_ERROR\n");
void close().finally(() => {
process.exitCode = 1;
});
});
}
main().catch((error) => {
process.stderr.write(`crossdesk-mcp: ${failure(error).code}\n`);
process.exitCode = 1;
});
+270
View File
@@ -0,0 +1,270 @@
import { spawn, execFile, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import type { Writable } from "node:stream";
import { Failure, type Data, type VideoOptions } from "./common.js";
export const NATIVE_VERSION = "0.2.0";
const run = promisify(execFile);
const require = createRequire(import.meta.url);
function nativeEnvironment(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const key of [
"PATH",
"HOME",
"USERPROFILE",
"SystemRoot",
"SYSTEMROOT",
"WINDIR",
"TEMP",
"TMP",
"TMPDIR",
"LANG",
"LC_ALL",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
])
if (process.env[key] !== undefined) env[key] = process.env[key];
return env;
}
export async function nativeBinary(override?: string): Promise<string> {
let binary: string;
if (override) binary = resolve(override);
else {
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
if (
![
"darwin-arm64",
"darwin-x64",
"linux-arm64",
"linux-x64",
"windows-x64",
].includes(target)
)
throw new Failure("PLATFORM_UNSUPPORTED");
if (
process.platform === "linux" &&
!(process.report.getReport() as Data).header.glibcVersionRuntime
)
throw new Failure("GLIBC_REQUIRED");
try {
const manifest = require.resolve(
`@chunkdash/crossdesk-cli-${target}/package.json`,
);
if (require(manifest).version !== NATIVE_VERSION)
throw new Failure("NATIVE_VERSION_MISMATCH");
binary = join(
dirname(manifest),
"bin",
process.platform === "win32" ? "crossdesk-cli.exe" : "crossdesk-cli",
);
} catch (error) {
throw error instanceof Failure
? error
: new Failure("NATIVE_PACKAGE_MISSING");
}
}
try {
const { stdout } = await run(binary, ["doctor", "--json"], {
timeout: 5000,
maxBuffer: 65536,
windowsHide: true,
env: nativeEnvironment(),
});
const doctor = JSON.parse(stdout);
if (doctor.automation_protocol !== "crossdesk-automation/1" ||
doctor.automation_video_transport !== "loopback-websocket/1" ||
!["h264-annexb/1", "nv12/1"].every((p) => doctor.automation_video_protocols?.includes(p)) ||
!["auto", "software"].every((p) => doctor.automation_video_decoders?.includes(p)))
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
} catch (error) {
throw error instanceof Failure ? error : new Failure("NATIVE_START_FAILED");
}
return binary;
}
type Pending = {
resolve: (value: Data) => void;
reject: (error: Failure) => void;
clean: () => void;
action: boolean;
};
export class NativeClient {
private child: ChildProcess;
private pending = new Map<number, Pending>();
private sequence = 0;
private buffer = "";
private closed = false;
private closing?: Promise<void>;
constructor(binary: string, server: string, port: number, logs: string,
readonly videoOptions: VideoOptions = { decode_path: "browser", decoder: "auto" }) {
this.child = spawn(
binary,
[
"agent",
"--stdio",
"--password-fd",
"3",
"--video-ws",
"--server",
server,
"--port",
String(port),
"--log-dir",
logs,
],
{
stdio: ["pipe", "pipe", "ignore", "pipe"],
shell: false,
windowsHide: true,
env: nativeEnvironment(),
},
);
this.child.stdout!.setEncoding("utf8");
this.child.stdout!.on("data", (part: string) => this.receive(part));
this.child.stdin!.on("error", () => this.fail());
this.child.stdio[3]!.on("error", () => this.fail());
this.child.once("error", () => this.fail());
this.child.once("exit", () => this.fail());
}
async connect(
device: string,
password: string,
mode: string,
display: number,
signal?: AbortSignal,
): Promise<Data> {
if (Buffer.byteLength(password) > 256 || /[\0\r\n]/.test(password))
throw new Failure("INVALID_CREDENTIAL");
const hello = await this.request(
"hello",
{ protocol: "crossdesk-automation/1" },
5000,
signal,
);
if (hello.protocol !== "crossdesk-automation/1" ||
hello.video_transport !== "loopback-websocket/1" ||
!hello.video_protocols?.includes(this.videoOptions.decode_path === "minirtc" ? "nv12/1" : "h264-annexb/1") ||
!hello.video_decoders?.includes(this.videoOptions.decoder))
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
const secret = Buffer.from(password + "\n");
password = "";
(this.child.stdio[3] as Writable).end(secret, () => secret.fill(0));
return this.request("connect", { device, mode, display, ...this.videoOptions }, 7000, signal);
}
request(
method: string,
args: Data = {},
timeout = 7000,
signal?: AbortSignal,
): Promise<Data> {
if (this.closed)
return Promise.reject(new Failure("SESSION_CLOSED", method === "action"));
if (signal?.aborted) return Promise.reject(new Failure("CANCELLED"));
if (this.pending.size >= 32 || this.child.stdin!.writableLength > 262144)
return Promise.reject(new Failure("BUSY", false, true));
const id = ++this.sequence;
const line = JSON.stringify({ ...args, id, method }) + "\n";
if (Buffer.byteLength(line) > 65536)
return Promise.reject(new Failure("MESSAGE_TOO_LARGE"));
return new Promise((resolve, reject) => {
const cancel = (code: string) => {
const p = this.pending.get(id);
if (!p) return;
this.pending.delete(id);
p.clean();
// Cancellation is best effort; a timed-out action is never replayed.
this.child.stdin?.write(
JSON.stringify({
id: ++this.sequence,
method: "cancel",
request_id: id,
}) + "\n",
);
reject(new Failure(code, method === "action"));
};
const abort = () => cancel("CANCELLED");
const timer = setTimeout(() => cancel("NATIVE_TIMEOUT"), timeout);
signal?.addEventListener("abort", abort, { once: true });
this.pending.set(id, {
resolve,
reject,
action: method === "action",
clean: () => {
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
},
});
this.child.stdin!.write(line);
});
}
private receive(part: string) {
this.buffer += part;
if (Buffer.byteLength(this.buffer) > 8 * 1024 * 1024) {
this.fail();
this.child.kill();
return;
}
for (;;) {
const end = this.buffer.indexOf("\n");
if (end < 0) break;
const line = this.buffer.slice(0, end);
this.buffer = this.buffer.slice(end + 1);
try {
if (Buffer.byteLength(line) > 4 * 1024 * 1024) throw new Error();
const reply = JSON.parse(line);
if (!Number.isSafeInteger(reply.id) || typeof reply.ok !== "boolean")
throw new Error();
const p = this.pending.get(reply.id);
if (!p) continue;
this.pending.delete(reply.id);
p.clean();
if (reply.ok) p.resolve(reply.result);
else
p.reject(
new Failure(
/^[A-Z_]+$/.test(reply.error?.code)
? reply.error.code
: "NATIVE_ERROR",
!!reply.error?.side_effect_possible,
!!reply.error?.retryable,
),
);
} catch {
this.fail();
this.child.kill();
return;
}
}
}
private fail() {
this.closed = true;
for (const p of this.pending.values()) {
p.clean();
p.reject(new Failure("SESSION_CLOSED", p.action));
}
this.pending.clear();
}
close(): Promise<void> {
if (this.closing) return this.closing;
this.closing = (async () => {
if (!this.closed)
await this.request("disconnect", {}, 1000).catch(() => {});
this.child.stdin?.end();
if (this.child.exitCode === null && this.child.signalCode === null)
await new Promise<void>((resolve) => {
const kill = setTimeout(() => {
this.child.kill("SIGKILL");
resolve();
}, 2000);
this.child.once("exit", () => {
clearTimeout(kill);
resolve();
});
});
this.fail();
})();
return this.closing;
}
}
+399
View File
@@ -0,0 +1,399 @@
import { readFile } from "node:fs/promises";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/server";
import type {
CallToolResult,
ClientCapabilities,
Tool,
ServerContext,
} from "@modelcontextprotocol/server";
import {
getUiCapability,
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { Sessions, type View } from "./sessions.js";
import { BrowserPanel } from "./browser.js";
import { uiAction, uiSchemas, type UIAction } from "./ui-actions.js";
import {
id,
alias,
mode,
action,
screenshot,
Failure,
failure,
parse,
token,
type Data,
} from "./common.js";
const resource = "ui://crossdesk/viewer.html";
const uiNames: Record<UIAction, string> = {
ready: "crossdesk_ui_ready",
devices: "crossdesk_ui_list_devices",
status: "crossdesk_ui_get_session",
connect: "crossdesk_ui_connect",
video_open: "crossdesk_video_open",
control: "crossdesk_viewer_control",
};
function output(value: Data, meta?: Data): CallToolResult {
const data = { ...value };
const content: any[] = [];
if (data.mime_type === "image/png" && typeof data.data === "string") {
content.push({ type: "image", mimeType: data.mime_type, data: data.data });
delete data.data;
}
if (data.observation?.data) {
content.push({
type: "image",
mimeType: data.observation.mime_type,
data: data.observation.data,
});
data.observation = { ...data.observation };
delete data.observation.data;
}
content.unshift({ type: "text", text: JSON.stringify(data) });
return { content, structuredContent: data, ...(meta ? { _meta: meta } : {}) };
}
export function createServer(
sessions: Sessions,
browser: BrowserPanel,
): McpServer {
const server = new McpServer(
{ name: "crossdesk-mcp", version: "0.1.0" },
{
capabilities: { tools: {}, resources: {} },
instructions:
"Remote computer control. Open a panel for the user to select and authorize a device. Never request passwords in chat. When a remote task is pending, immediately follow crossdesk_open with crossdesk_get_session(view_id, wait_ms: 25000) in the same turn. Repeat bounded waits on wait_timed_out until ready or the user cancels; do not ask the user to type a connection confirmation or click Continue. On ready, continue the requested task within current authorization. On closed/failed, stop waiting and do not automatically reconnect. Obtain a screenshot before actions and pass its observation_id; coordinates refer to that returned image. Submitted actions are not remote execution acknowledgements. Never replay actions after uncertain outcomes. Human pause can only be resumed in the user panel.",
},
);
type Definition = {
name: string;
schema: z.ZodType;
description: string;
ui?: boolean;
app?: boolean;
readOnly?: boolean;
run: (p: Data, ctx: ServerContext) => Promise<CallToolResult>;
};
const definitions = new Map<string, Definition>();
const supported = (ctx: ServerContext) => {
const envelope = ctx.mcpReq.envelope as Data | undefined;
const caps = envelope
? envelope["io.modelcontextprotocol/clientCapabilities"]
: server.server.getClientCapabilities();
return (
getUiCapability((caps ?? {}) as ClientCapabilities)?.mimeTypes?.includes(
RESOURCE_MIME_TYPE,
) ?? false
);
};
const apps = (ctx: ServerContext) =>
["auto", "apps"].includes(sessions.policy) && supported(ctx);
const viewResult = (v: View, result: Data) =>
output(
result,
v.panel === "apps"
? {
"crossdesk/ui": {
view_id: v.id,
view_generation: v.generation,
ui_token: v.secret,
},
}
: undefined,
);
const add = (
name: string,
schema: z.ZodType,
description: string,
run: Definition["run"],
options: Partial<Definition> = {},
) => {
const d = { name, schema, description, run, ...options };
definitions.set(name, d);
const config = {
description,
inputSchema: schema,
annotations: {
readOnlyHint: !!d.readOnly,
destructiveHint: d.name === "crossdesk_action",
openWorldHint: true,
},
...(d.ui || d.app
? {
_meta: {
ui: {
...(d.ui ? { resourceUri: resource } : {}),
visibility: d.app
? ["app" as const]
: ["model" as const, "app" as const],
},
},
}
: {}),
};
if (d.ui || d.app)
registerAppTool(
server,
name,
{ ...config, _meta: config._meta! },
(p, ctx) => run(p as Data, ctx),
);
else server.registerTool(name, config, (p, ctx) => run(p as Data, ctx));
};
add(
"crossdesk_open",
z.strictObject({ device_alias: alias.optional() }),
"Open CrossDesk so the user can choose a device, enter its password, and authorize this session. Returns immediately so the panel can render. To continue a pending remote task, call crossdesk_get_session with the returned view_id and wait_ms: 25000; no chat confirmation is needed.",
async (p, ctx) => {
if (p.device_alias) await sessions.store.device(p.device_alias);
const v = sessions.create(
sessions.choose(supported(ctx)),
p.device_alias,
);
return viewResult(
v,
v.panel === "browser" ? await browser.open(v) : sessions.describe(v),
);
},
{ ui: true },
);
add(
"crossdesk_open_browser",
z.strictObject({ view_id: id.optional() }),
"Explicitly open the local browser panel after the user requests browser fallback. Does not create another remote connection.",
async (p) => {
if (!["auto", "browser"].includes(sessions.policy))
throw new Failure("PANEL_POLICY_DENIED");
const v = p.view_id
? sessions.view(p.view_id)
: sessions.create("browser");
if (v.panel !== "browser") {
await sessions.revokeVideo(v);
v.panel = "browser";
v.generation++;
v.secret = token();
v.ready = false;
v.created = Date.now();
}
return output(await browser.open(v));
},
);
add(
"crossdesk_list_devices",
z.strictObject({}),
"List the local saved device book and persistent authorization, without credentials. This is not online discovery.",
async () => output({ devices: await sessions.store.list() }),
{ readOnly: true },
);
add(
"crossdesk_connect",
z.strictObject({
device_alias: alias,
mode: mode.default("observe"),
display_index: z.int().min(0).max(63).default(0),
}),
"Connect only a saved, previously authorized device with available credentials. AUTH_REQUIRED means the user must authorize through the panel or setup.",
async (p, ctx) => {
const selected = sessions.choose(supported(ctx));
const result = await sessions.connectSaved(
p.device_alias,
p.mode,
p.display_index,
ctx.mcpReq.signal,
);
const v = sessions.create(selected, p.device_alias);
v.session = result.session_id;
return viewResult(v, {
...result,
...(selected === "browser"
? await browser.open(v)
: sessions.describe(v)),
state: result.state,
});
},
{ ui: true },
);
add(
"crossdesk_get_session",
z.strictObject({
session_id: id.optional(),
view_id: id.optional(),
wait_ms: z.int().min(0).max(25000).optional(),
}),
"Get connection, display geometry, frame age and control state. Supply exactly one of session_id or view_id. With view_id, wait for connection (default 25000 ms; 0 returns immediately). On wait_timed_out, repeat while the remote task is pending. On ready, continue without asking the user to send a message. Waiting never connects or grants control. session_id returns immediately and requires wait_ms to be 0 or omitted.",
async (p, ctx) => {
if (!!p.session_id === !!p.view_id)
throw new Failure("SELECT_ONE_SESSION");
if (p.session_id && p.wait_ms) throw new Failure("INVALID_ARGUMENT");
return output(p.view_id
? await sessions.waitForConnection(p.view_id, p.wait_ms ?? 25000, ctx.mcpReq.signal)
: await sessions.status(p.session_id, undefined, ctx.mcpReq.signal));
},
{ readOnly: true },
);
add(
"crossdesk_screenshot",
screenshot,
"Capture the remote display as a PNG image and immutable observation metadata. Coordinates in subsequent actions use this returned image. after_frame_id requires generation.",
async (p, ctx) => output(await sessions.screenshot(p, ctx.mcpReq.signal)),
{ readOnly: true },
);
add(
"crossdesk_action",
z.strictObject({
session_id: id,
operation_id: id,
observation_id: id,
action,
observe_after: z.boolean().default(true),
}),
"Submit one structured action against a recent observation. Reuse operation_id only to retrieve that same operation, never replay uncertain effects with a new ID. Human pause prevents control. A post-action screenshot is not an execution acknowledgement.",
async (p, ctx) => output(await sessions.action(p, ctx.mcpReq.signal)),
);
add(
"crossdesk_set_control",
z.strictObject({ session_id: id, enabled: z.boolean() }),
"Enable or disable control within the user-authorized scope. Cannot override human pause or upgrade an observe-only connection.",
async (p) => output(await sessions.control(p.session_id, p.enabled)),
);
add(
"crossdesk_select_display",
z.strictObject({ session_id: id, display_index: z.int().min(0).max(63) }),
"Select a remote display. Invalidates previous observations; capture a new screenshot before any further input.",
async (p) => output(await sessions.display(p.session_id, p.display_index)),
);
add(
"crossdesk_disconnect",
z.strictObject({ session_id: id }),
"Disconnect the remote session, release input state and stop associated previews.",
async (p) => output(await sessions.disconnect(p.session_id)),
);
for (const [name, schema] of Object.entries(uiSchemas)) {
add(
uiNames[name as UIAction],
schema.extend({
view_id: id,
view_generation: z.int().min(1),
ui_token: id,
}),
`CrossDesk panel only: ${name}.`,
async (p, ctx) => {
if (!apps(ctx)) throw new Failure("PANEL_UNSUPPORTED");
const { view_id, view_generation, ui_token, ...args } = p;
const v = sessions.authenticate(view_id, view_generation, ui_token);
if (v.panel !== "apps") throw new Failure("VIEW_UNAUTHORIZED");
// Video grants stay in the authenticated app-only response.
const result = name === "video_open" ? await browser.video(v) : await uiAction(
sessions,
v,
name,
args,
ctx.mcpReq.signal,
);
return {
content: [
{
type: "text",
text: name === "video_open" ? "Video viewer connection" : JSON.stringify(result),
},
],
structuredContent: result,
};
},
{ app: true },
);
}
registerAppResource(
server,
"CrossDesk viewer",
resource,
{ mimeType: RESOURCE_MIME_TYPE },
async (uri, ctx) => {
if (!apps(ctx)) throw new Failure("PANEL_UNSUPPORTED");
return {
contents: [
{
uri: uri.href,
mimeType: RESOURCE_MIME_TYPE,
text: await readFile(
new URL("./viewer-apps.html", import.meta.url),
"utf8",
),
_meta: {
ui: {
prefersBorder: true,
csp: { connectDomains: await browser.connectDomains(), resourceDomains: [] },
},
},
},
],
};
},
);
// Request-scoped projection avoids global tool mutations when modern clients
// send concurrent calls with different capability envelopes.
server.server.setRequestHandler("tools/list", async (_req, ctx) => ({
tools: [...definitions.values()]
.filter((d) => !d.app || apps(ctx))
.map((d) => {
const inputSchema = z.toJSONSchema(d.schema, {
io: "input",
}) as Tool["inputSchema"];
if (d.name === "crossdesk_get_session")
inputSchema.oneOf = [
{ required: ["session_id"] },
{ required: ["view_id"] },
];
return {
name: d.name,
description: d.description,
inputSchema,
annotations: {
readOnlyHint: !!d.readOnly,
destructiveHint: d.name === "crossdesk_action",
openWorldHint: true,
},
...((d.ui || d.app) && apps(ctx)
? {
_meta: {
ui: {
...(d.ui ? { resourceUri: resource } : {}),
visibility: d.app ? ["app"] : ["model", "app"],
},
...(d.ui ? { "ui/resourceUri": resource } : {}),
},
}
: {}),
};
}),
}));
server.server.setRequestHandler("tools/call", async (req, ctx) => {
try {
const d = definitions.get(req.params.name);
if (!d || (d.app && !apps(ctx))) throw new Failure("UNKNOWN_TOOL");
if (Buffer.byteLength(JSON.stringify(req.params.arguments ?? {})) > 65536)
throw new Failure("MESSAGE_TOO_LARGE");
const p = parse(d.schema, req.params.arguments ?? {}) as Data;
return await d.run(p, ctx);
} catch (error) {
const value = { error: failure(error) };
return { ...output(value), isError: true };
}
});
server.server.setRequestHandler("resources/list", async (_req, ctx) => ({
resources: apps(ctx)
? [
{
name: "CrossDesk viewer",
uri: resource,
mimeType: RESOURCE_MIME_TYPE,
},
]
: [],
}));
return server;
}
+425
View File
@@ -0,0 +1,425 @@
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import {
Failure,
token,
equal,
parse,
uiConnect,
type Data,
type Panel,
type VideoOptions,
} from "./common.js";
import { DeviceStore, type Device } from "./store.js";
import { nativeBinary, NativeClient } from "./native.js";
export interface View {
id: string;
generation: number;
secret: string;
panel: Exclude<Panel, "auto">;
created: number;
ready: boolean;
lastSeen: number;
alias?: string;
session?: string;
disconnected?: boolean;
}
interface Session {
id: string;
device: Device;
native: NativeClient;
status: Data;
release: () => Promise<void>;
}
export class Sessions {
readonly views = new Map<string, View>();
private current?: Session;
private connecting = false;
private closing = false;
private binary?: Promise<string>;
constructor(
readonly store: DeviceStore,
readonly policy: Panel,
private nativePath?: string,
private videoOptions?: VideoOptions,
) {}
choose(apps: boolean): View["panel"] {
if (this.policy === "apps" && !apps) throw new Failure("PANEL_UNSUPPORTED");
return this.policy === "auto" ? (apps ? "apps" : "browser") : this.policy;
}
create(panel: View["panel"], alias?: string): View {
for (const [id, view] of this.views)
if (Date.now() - view.lastSeen > 30 * 60 * 1000) {
void this.revokeVideo(view);
this.views.delete(id);
}
if (this.views.size >= 32) throw new Failure("VIEW_LIMIT");
const view: View = {
id: token(),
generation: 1,
secret: token(),
panel,
created: Date.now(),
lastSeen: Date.now(),
ready: false,
alias,
session: this.current?.id,
};
this.views.set(view.id, view);
return view;
}
view(id: string): View {
const v = this.views.get(id);
if (!v) throw new Failure("VIEW_NOT_FOUND");
return v;
}
authenticate(id: string, generation: number, secret: string): View {
const v = this.view(id);
if (v.generation !== generation || !equal(v.secret, secret))
throw new Failure("VIEW_UNAUTHORIZED");
return v;
}
describe(v: View): Data {
return {
view_id: v.id,
view_generation: v.generation,
panel: v.panel,
panel_status: v.ready
? "ready"
: Date.now() - v.created > 10000
? "not_confirmed"
: "waiting",
session_id: v.session ?? null,
state: v.session ? "session_available" : v.disconnected ? "closed" : "awaiting_user",
device_alias: v.alias ?? null,
next_step: v.session
? "Call crossdesk_get_session with view_id."
: v.disconnected
? "The session was disconnected. Do not automatically reconnect."
: v.panel === "off"
? "Use setup to configure credentials and authorization, then crossdesk_connect with the saved device alias. Never send passwords in chat."
: "The user selects and authorizes a device in the panel. To continue a pending remote task, immediately call crossdesk_get_session with this view_id and wait_ms: 25000; repeat on wait_timed_out. Do not ask the user to send a connection confirmation message.",
};
}
private session(id: string): Session {
if (!this.current || this.current.id !== id)
throw new Failure("SESSION_NOT_FOUND");
return this.current;
}
async status(sessionId?: string, viewId?: string, signal?: AbortSignal): Promise<Data> {
const view = viewId ? this.view(viewId) : undefined;
const id = sessionId ?? view?.session;
if (!id && view) return this.describe(view);
const s = this.session(id!);
// A panel connection may still be performing the native hello handshake.
// Reading native status here would race initialization and could close it.
if (this.connecting) return {
...(view ? this.describe(view) : {}),
session_id: s.id,
device_alias: s.device.alias,
state: "connecting",
control: false,
};
try {
s.status = await s.native.request("status", {}, 5000, signal);
} catch {
if (signal?.aborted) throw new Failure("CANCELLED");
s.status = { state: "closed", control: false };
}
// The user may have disconnected or replaced the session during the read.
if (this.current !== s || (view && view.session !== id))
return view ? this.describe(view) : { session_id: id, state: "closed", control: false };
if (["closed", "failed"].includes(s.status.state)) {
await s.native.close();
await s.release();
}
return {
...s.status,
session_id: s.id,
device_alias: s.device.alias,
...(view ? this.describe(view) : {}),
state: s.status.state,
...(s.status.state === "ready" ? {
next_step: "Connection ready. Continue the user's pending task within the current authorization. Obtain a screenshot before input; respect human_paused and control. No user confirmation message is needed.",
} : ["closed", "failed", "disconnected"].includes(s.status.state) ? {
next_step: "The connection ended. Stop waiting and do not automatically reconnect or replay actions.",
} : {}),
};
}
async waitForConnection(viewId: string, waitMs: number, signal?: AbortSignal): Promise<Data> {
const deadline = Date.now() + waitMs;
let result = this.describe(this.view(viewId));
const timedOut = () => ({
...result,
wait_timed_out: true,
next_step: "Still waiting for the user to connect in the panel. Call crossdesk_get_session again with the same view_id and wait_ms: 25000 while the remote task is pending. Do not ask the user to type a connection confirmation.",
});
const timeout = new AbortController();
const timer = waitMs > 0 ? setTimeout(() => timeout.abort(), waitMs) : undefined;
const waitSignal = signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal;
try {
for (;;) {
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
const view = this.view(viewId);
view.lastSeen = Date.now();
result = await this.status(undefined, viewId, waitSignal);
if (waitMs === 0 || view.panel === "off" ||
["ready", "closed", "failed", "disconnected"].includes(result.state))
return result;
const remaining = deadline - Date.now();
if (remaining <= 0) return timedOut();
await delay(Math.min(250, remaining), undefined, { signal: waitSignal });
}
} catch (error) {
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
if (timeout.signal.aborted) return timedOut();
throw error;
} finally {
clearTimeout(timer);
}
}
async connectSaved(
name: string,
mode: string,
display: number,
signal?: AbortSignal,
): Promise<Data> {
const d = await this.store.device(name);
if (
!d.authorization ||
(mode === "control" && d.authorization !== "control")
)
throw new Failure("AUTH_REQUIRED");
return this.connect(
d,
await this.store.password(d),
mode,
display,
undefined,
signal,
);
}
async connectUI(v: View, args: unknown, signal?: AbortSignal): Promise<Data> {
const p = parse(uiConnect, args);
if (!!p.device_alias === !!p.device_id)
throw new Failure("SELECT_ONE_DEVICE");
const config = await this.store.config();
const d: Device = p.device_alias
? { ...(await this.store.device(p.device_alias)) }
: {
alias: p.label ?? p.device_id!,
device_id: p.device_id!,
server: p.server ?? config.server,
port: p.port ?? config.port,
};
if (p.device_alias && (p.server || p.port || p.label))
throw new Failure("INVALID_ARGUMENT");
if ((p.remember_password || p.persistent_authorization) && !p.save_device)
throw new Failure("SAVE_DEVICE_REQUIRED");
let password = p.password ?? (await this.store.password(d));
p.password = undefined;
// Persist only after successful authentication, avoiding poisoned saved passwords.
const result = await this.connect(
d,
password,
p.mode,
p.display_index,
v,
signal,
);
if (p.save_device) {
d.authorization = p.persistent_authorization ? p.mode : undefined;
try {
await this.store.save(d, p.remember_password ? password : undefined);
} catch (error) {
result.persistence_error =
error instanceof Failure ? error.code : "CONFIG_WRITE_FAILED";
}
}
password = "";
return result;
}
private async connect(
d: Device,
password: string,
mode: string,
display: number,
view?: View,
signal?: AbortSignal,
): Promise<Data> {
if (this.closing) throw new Failure("SESSION_CLOSED");
if (this.connecting) throw new Failure("BUSY");
if (
this.current &&
!["closed", "failed", "disconnected"].includes(
(await this.status(this.current.id)).state,
)
)
throw new Failure("SESSION_LIMIT");
this.connecting = true;
let release: (() => Promise<void>) | undefined;
let native: NativeClient | undefined;
try {
if (this.current) await this.disconnect(this.current.id);
release = await this.store.lock(
`device:${d.server}:${d.port}:${d.device_id}`,
);
this.binary ??= nativeBinary(this.nativePath).catch((e) => {
this.binary = undefined;
throw e;
});
const binary = await this.binary;
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
native = new NativeClient(
binary,
d.server,
d.port,
join(this.store.directory, "logs"),
this.videoOptions ?? (await this.store.config()).video,
);
const s: Session = {
id: token(),
device: d,
native,
status: {},
release,
};
this.current = s;
if (view) {
view.session = s.id;
view.alias = d.alias;
view.disconnected = false;
}
s.status = await native.connect(
d.device_id,
password,
mode,
display,
signal,
);
password = "";
const deadline = Date.now() + 35000;
while (Date.now() < deadline) {
if (this.closing || signal?.aborted) throw new Failure("CANCELLED");
s.status = await native.request("status", {}, 5000, signal);
if (s.status.state === "ready" && s.status.frame_age_ms <= 2000)
return { ...s.status, session_id: s.id, device_alias: s.device.alias };
if (["failed", "closed", "disconnected"].includes(s.status.state))
throw new Failure(
s.status.error === "incorrect_password"
? "AUTH_FAILED"
: "CONNECTION_FAILED",
);
await delay(100, undefined, { signal });
}
throw new Failure("CONNECTION_TIMEOUT");
} catch (error) {
await native?.close();
await release?.();
if (this.current?.native === native) this.current = undefined;
if (view) view.session = undefined;
throw error;
} finally {
password = "";
this.connecting = false;
}
}
async screenshot(p: Data, signal?: AbortSignal): Promise<Data> {
const { session_id, ...args } = p;
if (args.after_frame_id !== undefined && args.generation === undefined)
throw new Failure("GENERATION_REQUIRED");
return this.session(session_id).native.request(
"screenshot",
args,
(args.timeout_ms ?? 2000) + 3000,
signal,
);
}
async action(p: Data, signal?: AbortSignal): Promise<Data> {
const { session_id, observe_after = true, ...args } = p;
const s = this.session(session_id);
const result = await s.native.request("action", args, 5000, signal);
if (observe_after && result.status === "submitted") {
try {
result.observation = await this.screenshot(
{
session_id,
generation: result.generation,
after_frame_id: result.after_frame_id,
timeout_ms: 2000,
},
signal,
);
} catch (error) {
result.observation_error =
error instanceof Failure ? error.code : "OBSERVATION_FAILED";
}
}
return result;
}
async control(id: string, enabled: boolean) {
return this.session(id).native.request("control", { enabled });
}
async display(id: string, index: number) {
return this.session(id).native.request("display", { index });
}
async disconnect(id: string) {
const s = this.session(id);
await s.native.close();
await s.release();
if (this.current === s) this.current = undefined;
for (const v of this.views.values())
if (v.session === id) {
v.session = undefined;
v.disconnected = true;
}
return { session_id: id, state: "closed" };
}
async viewerControl(v: View, operation: string) {
if (!v.session) throw new Failure("NOT_READY");
if (operation === "disconnect") return this.disconnect(v.session);
return this.session(v.session).native.request("viewer_control", {
operation,
});
}
async openVideo(v: View, origin: string): Promise<Data> {
if (!v.session || this.views.get(v.id) !== v) throw new Failure("NOT_READY");
const s = this.session(v.session);
if (this.closing || this.connecting || ["closed", "failed"].includes(s.status.state))
throw new Failure("NOT_READY");
const generation = v.generation, session = v.session;
const ticket = token();
const result = await s.native.request("video_open", {
ticket, view: v.id, view_generation: generation, session, origin, apps: v.panel === "apps",
});
if (this.current !== s || this.views.get(v.id) !== v ||
v.generation !== generation || v.session !== session) {
await s.native.request("video_revoke", { view: v.id }).catch(() => {});
throw new Failure("SESSION_CHANGED");
}
if (result.transport !== "loopback-websocket/1" ||
typeof result.url !== "string" || !/^ws:\/\/127\.0\.0\.1:\d+\/api\/video$/.test(result.url) ||
result.protocol !== (s.native.videoOptions.decode_path === "minirtc" ? "nv12/1" : "h264-annexb/1") ||
result.decoder !== s.native.videoOptions.decoder)
throw new Failure("NATIVE_PROTOCOL_MISMATCH");
return { ...result, ticket };
}
async revokeVideo(view?: View) {
const s = this.current;
if (!s || (view && view.session !== s.id)) return;
try {
await s.native.request("video_revoke", view ? { view: view.id } : {});
} catch {
// Losing the control channel must not leave an authorized video socket
// alive after its view was removed or replaced.
if (this.current === s) await this.disconnect(s.id);
else await s.native.close();
}
}
async close() {
this.closing = true;
if (this.current) await this.disconnect(this.current.id);
this.views.clear();
}
}
+191
View File
@@ -0,0 +1,191 @@
import { createHash } from "node:crypto";
import {
mkdir,
readFile,
writeFile,
rename,
unlink,
open,
chmod,
} from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { z } from "zod";
import {
alias,
host,
mode,
panel,
videoOptions,
Failure,
parse,
token,
type Data,
} from "./common.js";
const deviceSchema = z.strictObject({
alias,
device_id: z.string().regex(/^\d{9}$/),
server: host,
port: z.int().min(1).max(65535),
authorization: mode.optional(),
credential: z.enum(["keyring", "environment"]).optional(),
environment_variable: z
.string()
.regex(/^[A-Z][A-Z0-9_]{0,127}$/)
.optional(),
});
export type Device = z.infer<typeof deviceSchema>;
const configSchema = z.strictObject({
version: z.literal(1),
panel: panel.default("auto"),
video: videoOptions.prefault({}),
server: host.default("api.crossdesk.cn"),
port: z.int().min(1).max(65535).default(9099),
devices: z.array(deviceSchema).max(256),
});
export type Config = z.infer<typeof configSchema>;
export class DeviceStore {
readonly directory: string;
readonly file: string;
constructor(directory?: string) {
this.directory = resolve(
directory ?? join(homedir(), ".config", "crossdesk", "mcp"),
);
this.file = join(this.directory, "devices.json");
}
async initialize() {
await mkdir(this.directory, { recursive: true, mode: 0o700 });
if (process.platform !== "win32") await chmod(this.directory, 0o700);
await mkdir(join(this.directory, "logs"), { recursive: true, mode: 0o700 });
}
async config(): Promise<Config> {
try {
return parse(configSchema, JSON.parse(await readFile(this.file, "utf8")));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT")
return configSchema.parse({ version: 1, devices: [] });
throw new Failure("CONFIG_INVALID");
}
}
async list(): Promise<Data[]> {
return (await this.config()).devices.map((d) => ({
device_alias: d.alias,
device_id: d.device_id,
server: d.server,
port: d.port,
authorization: d.authorization ?? "none",
credential_provider: d.credential ?? "none",
}));
}
async device(name: string): Promise<Device> {
const d = (await this.config()).devices.find((d) => d.alias === name);
if (!d) throw new Failure("DEVICE_NOT_FOUND");
return d;
}
private account(d: Device) {
return createHash("sha256")
.update(JSON.stringify([this.directory, d.server, d.port, d.device_id]))
.digest("hex");
}
private async entry(d: Device) {
try {
const { AsyncEntry } = await import("@napi-rs/keyring");
return new AsyncEntry("crossdesk-mcp", this.account(d), {
linux: { store: "secret-service" },
});
} catch {
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
}
}
async password(d: Device): Promise<string> {
if (d.credential === "environment") {
const secret =
d.environment_variable && process.env[d.environment_variable];
if (secret !== undefined) return secret;
} else if (d.credential === "keyring") {
try {
const secret = await (await this.entry(d)).getPassword();
if (secret !== null) return secret;
} catch {
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
}
}
throw new Failure("AUTH_REQUIRED");
}
async save(d: Device, password?: string) {
parse(deviceSchema, d);
const lock = await this.lock("config");
try {
const config = await this.config();
const previous = config.devices.find((item) => item.alias === d.alias);
if (
previous &&
(previous.device_id !== d.device_id ||
previous.server !== d.server ||
previous.port !== d.port)
)
throw new Failure("DEVICE_ALIAS_CONFLICT");
if (password !== undefined) {
try {
await (await this.entry(d)).setPassword(password);
d.credential = "keyring";
delete d.environment_variable;
} catch {
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
}
}
config.devices = [
...config.devices.filter((item) => item.alias !== d.alias),
d,
];
parse(configSchema, config);
const temporary = `${this.file}.${token()}.tmp`;
try {
await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", {
mode: 0o600,
flag: "wx",
});
await rename(temporary, this.file);
} finally {
await unlink(temporary).catch(() => {});
}
} finally {
await lock();
}
}
async lock(name: string): Promise<() => Promise<void>> {
const path = join(
this.directory,
`lock-${createHash("sha256").update(name).digest("hex").slice(0, 24)}`,
);
for (let attempt = 0; attempt < 2; ++attempt) {
try {
const handle = await open(path, "wx", 0o600);
await handle.writeFile(String(process.pid));
await handle.close();
let released = false;
return async () => {
if (released) return;
released = true;
await unlink(path).catch(() => {});
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST")
throw new Failure("CONFIG_WRITE_FAILED");
const pid = Number(await readFile(path, "utf8").catch(() => "0"));
if (!Number.isSafeInteger(pid) || pid < 1)
throw new Failure("DEVICE_BUSY");
try {
process.kill(pid, 0);
throw new Failure("DEVICE_BUSY");
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ESRCH")
throw new Failure("DEVICE_BUSY");
}
await unlink(path).catch(() => {});
}
}
throw new Failure("DEVICE_BUSY");
}
}
+46
View File
@@ -0,0 +1,46 @@
import { z } from "zod";
import { parse, uiConnect, Failure, type Data } from "./common.js";
import { Sessions, type View } from "./sessions.js";
export const uiSchemas = {
ready: z.strictObject({}),
devices: z.strictObject({}),
status: z.strictObject({}),
connect: uiConnect,
video_open: z.strictObject({}),
control: z.strictObject({
operation: z.enum(["pause", "resume", "disconnect"]),
}),
};
export type UIAction = keyof typeof uiSchemas;
export async function uiAction(
sessions: Sessions,
view: View,
name: string,
args: unknown,
signal?: AbortSignal,
): Promise<Data> {
if (!Object.hasOwn(uiSchemas, name)) throw new Failure("UNKNOWN_ACTION");
const p = parse(uiSchemas[name as UIAction], args) as Data;
view.lastSeen = Date.now();
switch (name) {
case "ready":
view.ready = true;
return sessions.describe(view);
case "devices": {
const config = await sessions.store.config();
return {
devices: await sessions.store.list(),
defaults: { server: config.server, port: config.port },
};
}
case "status":
return sessions.status(undefined, view.id);
case "connect":
return sessions.connectUI(view, p, signal);
case "control":
return sessions.viewerControl(view, p.operation);
default:
throw new Failure("UNKNOWN_ACTION");
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": []
},
"include": ["ui/**/*.ts"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+230
View File
@@ -0,0 +1,230 @@
type FrameInfo = { width: number; height: number; fps: number; frames: number };
function parameters(data: Uint8Array) {
let codec = "", signature = "";
for (let i = 0; i + 4 < data.length; i++) {
if (data[i] || data[i + 1]) continue;
const prefix = data[i + 2] === 1 ? 3 : data[i + 2] === 0 && data[i + 3] === 1 ? 4 : 0;
if (!prefix) continue;
const start = i + prefix, type = data[start]! & 31;
if (type !== 7 && type !== 8) continue;
let end = start + 1;
while (end + 3 < data.length && !(data[end] === 0 && data[end + 1] === 0 &&
(data[end + 2] === 1 || (data[end + 2] === 0 && data[end + 3] === 1)))) end++;
if (end + 3 >= data.length) end = data.length;
if (end - start > 65536) throw new Error("VIDEO_INVALID");
const hex = [...data.subarray(start, end)].map((n) => n.toString(16).padStart(2, "0")).join("");
signature += hex;
if (type === 7 && end >= start + 4) codec = "avc1." + hex.slice(2, 8);
i = end - 1;
}
return { codec, signature };
}
export class VideoPlayer {
private socket?: WebSocket;
private decoder?: VideoDecoder;
private stopped = false;
private failed = false;
private waitingKey = true;
private generation = -1;
private sequence = -1;
private signature = "";
private queue: ArrayBuffer[] = [];
private queuedBytes = 0;
private pumping = false;
private keyRequested = 0;
private firstFrame?: ReturnType<typeof setTimeout>;
private latest?: VideoFrame;
private drawing = 0;
private count = 0;
private samples = 0;
private sampledAt = performance.now();
private fps = 0;
private raw = false;
private software = false;
private automatic = true;
constructor(private canvas: HTMLCanvasElement,
private onFrame: (info: FrameInfo) => void,
private onError: (error: Error) => void) {}
start(url: string, ticket: string, protocol = "h264-annexb/1", decoder = "auto") {
if (!["h264-annexb/1", "nv12/1"].includes(protocol) ||
!["auto", "software"].includes(decoder)) throw new Error("VIDEO_INVALID");
this.raw = protocol === "nv12/1";
this.automatic = decoder === "auto";
this.software = !this.automatic;
if (typeof VideoFrame === "undefined" || (!this.raw && typeof VideoDecoder === "undefined"))
throw new Error("VIDEO_UNSUPPORTED");
if (!/^ws:\/\/127\.0\.0\.1:\d+\/api\/video$/.test(url)) throw new Error("VIDEO_INVALID");
const socket = this.socket = new WebSocket(url);
socket.binaryType = "arraybuffer";
socket.onopen = () => socket.send(JSON.stringify({ ticket }));
socket.onerror = () => this.fail(new Error("VIDEO_UNAVAILABLE"));
socket.onclose = () => { if (!this.stopped) this.fail(new Error("VIDEO_UNAVAILABLE")); };
this.firstFrame = setTimeout(() => this.fail(new Error("VIDEO_UNAVAILABLE")), 10000);
socket.onmessage = (event) => {
if (this.stopped || !(event.data instanceof ArrayBuffer)) return;
const data = event.data;
if (data.byteLength < (this.raw ? 38 : 25) ||
data.byteLength > (this.raw ? 16777216 * 3 / 2 + 32 : 4 * 1024 * 1024 + 24))
return this.fail(new Error("VIDEO_INVALID"));
const header = new DataView(data);
if (header.getUint32(0) !== (this.raw ? 0x43444e31 : 0x43445631))
return this.fail(new Error("VIDEO_INVALID"));
// Receipt, not presentation: bounds native writes even when the decoder
// or the canvas is slow. This message goes directly to the native CLI.
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({
type: "ack", generation: header.getUint32(4), sequence: header.getUint32(8),
}));
if (this.raw || this.queue.length >= 8 || this.queuedBytes + data.byteLength > 8 * 1024 * 1024) {
this.queue = [];
this.queuedBytes = 0;
this.waitingKey = true;
this.requestKey();
}
this.queue.push(data);
this.queuedBytes += data.byteLength;
void this.pump();
};
}
private requestKey() {
if (this.raw) return;
if (Date.now() - this.keyRequested < 1000 || this.socket?.readyState !== WebSocket.OPEN) return;
this.keyRequested = Date.now();
this.socket.send(JSON.stringify({ type: "keyframe" }));
}
private async pump() {
if (this.pumping) return;
this.pumping = true;
try {
while (!this.stopped && this.queue.length) {
const bytes = this.queue.shift()!;
this.queuedBytes -= bytes.byteLength;
const packet = new DataView(bytes);
if (packet.getUint32(0) !== (this.raw ? 0x43444e31 : 0x43445631) || packet.getUint32(20) > 1)
throw new Error("VIDEO_INVALID");
const generation = packet.getUint32(4), sequence = packet.getUint32(8);
const key = packet.getUint32(20) === 1;
if (generation < this.generation) continue;
const timestamp = Number(packet.getBigUint64(12));
if (!Number.isSafeInteger(timestamp)) throw new Error("VIDEO_INVALID");
if (this.raw) {
const width = packet.getUint32(24), height = packet.getUint32(28);
if (!key || !width || !height || (width & 1) || (height & 1) ||
width > 8192 || height > 8192 || width * height > 16777216 ||
bytes.byteLength !== 32 + width * height * 3 / 2)
throw new Error("VIDEO_INVALID");
if (generation === this.generation && sequence <= this.sequence) continue;
this.generation = generation;
this.sequence = sequence;
// Match the native NV12 screenshot conversion's limited-range BT.601.
this.present(new VideoFrame(new Uint8Array(bytes, 32), {
format: "NV12", codedWidth: width, codedHeight: height, timestamp,
colorSpace: { matrix: "smpte170m", primaries: "smpte170m", transfer: "smpte170m", fullRange: false },
transfer: [bytes],
} as VideoFrameBufferInit & { transfer: ArrayBuffer[] }));
continue;
}
if (generation !== this.generation || (this.sequence >= 0 && sequence !== this.sequence + 1))
this.waitingKey = true;
this.generation = generation;
this.sequence = sequence;
if ((this.decoder?.decodeQueueSize ?? 0) >= 4) this.waitingKey = true;
if (this.waitingKey && !key) { this.requestKey(); continue; }
const data = new Uint8Array(bytes, 24);
if (key) {
const { codec, signature } = parameters(data);
if (!codec) { this.waitingKey = true; this.requestKey(); continue; }
if (!this.decoder || this.waitingKey || signature !== this.signature) {
const config: VideoDecoderConfig = { codec, optimizeForLatency: true,
hardwareAcceleration: this.software ? "prefer-software" : "prefer-hardware" };
if (!(await VideoDecoder.isConfigSupported(config)).supported) {
if (this.software) throw new Error("VIDEO_UNSUPPORTED");
this.software = true;
config.hardwareAcceleration = "prefer-software";
if (!(await VideoDecoder.isConfigSupported(config)).supported) throw new Error("VIDEO_UNSUPPORTED");
}
if (this.stopped) break;
if (this.decoder && this.decoder.state !== "closed") this.decoder.close();
const decoder = new VideoDecoder({
output: (frame) => {
if (this.stopped || this.decoder !== decoder) { frame.close(); return; }
this.present(frame);
},
error: () => {
if (this.stopped || this.decoder !== decoder) return;
this.decoder = undefined;
if (this.software || !this.automatic) {
this.fail(new Error("VIDEO_UNAVAILABLE"));
return;
}
this.software = true;
this.waitingKey = true;
this.requestKey();
},
});
this.decoder = decoder;
decoder.configure(config);
this.signature = signature;
}
this.waitingKey = false;
}
this.decoder?.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp, data }));
}
} catch (error) {
this.fail(error instanceof Error ? error : new Error("VIDEO_INVALID"));
} finally { this.pumping = false; }
}
private present(frame: VideoFrame) {
this.latest?.close();
this.latest = frame;
if (!this.drawing) this.drawing = requestAnimationFrame(() => this.draw());
}
private draw() {
this.drawing = 0;
const frame = this.latest;
this.latest = undefined;
if (!frame) return;
try {
if (this.stopped) return;
const width = frame.displayWidth, height = frame.displayHeight;
if (width > 8192 || height > 8192 || width * height > 16777216) throw new Error("VIDEO_INVALID");
if (this.canvas.width !== width || this.canvas.height !== height) {
this.canvas.width = width;
this.canvas.height = height;
}
this.canvas.getContext("2d", { alpha: false })!.drawImage(frame, 0, 0);
clearTimeout(this.firstFrame);
this.count++;
this.samples++;
const elapsed = performance.now() - this.sampledAt;
if (elapsed >= 1000) {
this.fps = this.samples * 1000 / elapsed;
this.samples = 0;
this.sampledAt = performance.now();
}
this.canvas.dataset.frames = String(this.count);
this.onFrame({ width, height, frames: this.count, fps: this.fps });
} catch { this.fail(new Error("VIDEO_INVALID")); }
finally { frame.close(); }
}
private fail(error: Error) {
if (this.failed || this.stopped) return;
this.failed = true;
this.stop();
this.onError(error);
}
stop() {
this.stopped = true;
clearTimeout(this.firstFrame);
this.socket?.close();
if (this.decoder && this.decoder.state !== "closed") this.decoder.close();
this.decoder = undefined;
cancelAnimationFrame(this.drawing);
this.drawing = 0;
this.latest?.close();
this.latest = undefined;
this.queue = [];
this.queuedBytes = 0;
}
}
+184
View File
@@ -0,0 +1,184 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #292d33;
background: #fff;
font-size: 13px;
color-scheme: light;
--accent: #3269b7;
--muted: #777d85;
--line: #e4e6e9;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; }
body { overflow: hidden; }
[hidden] { display: none !important; }
button, input, select { font: inherit; }
button, summary, input, select { -webkit-tap-highlight-color: transparent; }
:focus-visible { outline: 2px solid #7194c6; outline-offset: 2px; }
#app { width: 100%; height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
.app-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; padding: 12px 20px; border-bottom: 1px solid var(--line); flex: none; }
.brand, .header-right { display: flex; align-items: center; gap: 10px; min-width: 0; }
.brand-logo { display: block; width: 28px; height: 28px; }
h1 { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
h2 { margin: 0; font-size: 13px; font-weight: 600; }
.connection { color: var(--muted); font-size: 12px; white-space: nowrap; }
.connection[data-state="online"] { color: #397557; }
.connection[data-state="connecting"] { color: #8a682d; }
.connection[data-state="offline"] { color: #a15149; }
#details-toggle { display: none; }
main { display: grid; grid-template-columns: 270px minmax(0, 1fr); flex: 1; min-height: 0; }
aside { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 22px 20px; border-right: 1px solid var(--line); }
#sidebar-title { margin-bottom: 20px; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
label { display: block; margin: 16px 0 7px; font-size: 12px; }
fieldset > label:first-child { margin-top: 0; }
.optional { color: var(--muted); margin-left: 4px; font-size: 11px; }
input:not([type="checkbox"]), select { display: block; width: 100%; height: 36px; min-width: 0; border: 1px solid #d7dade; border-radius: 4px; padding: 7px 9px; background: #fff; color: inherit; }
input:not([type="checkbox"]):focus, select:focus { border-color: var(--accent); outline: 1px solid var(--accent); }
input:disabled, select:disabled { background: #f5f5f5; color: #999; }
input::placeholder { color: #92969c; }
#device-id, #session-device-id { font-variant-numeric: tabular-nums; }
.password-field { position: relative; }
.password-field input { padding-right: 48px; }
.password-field button { position: absolute; right: 2px; top: 2px; height: 32px; min-height: 32px; padding: 0 8px; border: 0; background: transparent; color: var(--muted); }
.field-hint, .privacy, .session-note { color: var(--muted); font-size: 11px; line-height: 1.7; margin: 7px 0 0; }
.preferences { border-top: 1px solid var(--line); margin-top: 20px; padding-top: 7px; }
details { font-size: 12px; }
summary { padding: 9px 0; cursor: pointer; color: #666d76; }
summary::marker { font-size: 10px; }
.checks { padding-bottom: 10px; }
.checks label { display: flex; align-items: flex-start; gap: 8px; margin-top: 13px; font-size: 12px; line-height: 1.5; cursor: pointer; }
.checks label:has(input:disabled) { color: #999; cursor: default; }
.checks small { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; }
input[type="checkbox"] { width: 14px; height: 14px; flex: none; margin: 2px 0 0; accent-color: var(--accent); }
.row { display: flex; gap: 12px; }
.row > div { flex: 1; min-width: 0; }
button { display: inline-flex; align-items: center; justify-content: center; min-height: 32px; padding: 6px 10px; border: 1px solid #d9dce0; border-radius: 4px; background: #fff; color: #4d535c; font-size: 12px; cursor: pointer; white-space: nowrap; }
button:not(:disabled):hover { background: #f3f4f5; border-color: #bfc4cb; }
button:not(:disabled):active { background: #e9ebee; }
button:disabled { cursor: default; color: #a3a7ad; background: #fafafa; border-color: #e7e9eb; }
.primary { width: 100%; min-height: 36px; background: var(--accent); color: #fff; border-color: var(--accent); margin-top: 20px; }
.primary:not(:disabled):hover { background: #285b9f; border-color: #285b9f; }
.privacy { margin-top: 12px; }
#error { flex: none; max-height: 25%; overflow: auto; padding: 10px 20px; background: #fff5f2; border-bottom: 1px solid #edddd6; color: #964a37; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.device-card strong { display: block; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }
.device-card > span { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; }
.session-details { margin: 20px 0 0; font-size: 12px; }
.session-details > div { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid #eee; }
.session-details dt { color: var(--muted); flex: none; }
.session-details dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
#session-agent[data-paused="true"] { color: #956e2d; }
.session-note { margin-top: 18px; }
.workspace { display: flex; flex-direction: column; gap: 10px; min-width: 0; min-height: 0; overflow: auto; padding: 16px; background: #f6f7f8; }
.viewer-card { display: flex; flex-direction: column; flex: 1; min-height: 160px; min-width: 0; background: #fff; border: 1px solid var(--line); border-radius: 5px; overflow: hidden; }
.viewer-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px 12px; padding: 9px 12px; flex: none; }
.viewer-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
.viewer-title h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#video-state { color: var(--muted); font-size: 11px; white-space: nowrap; flex: none; }
#video-state[data-state="stale"] { color: #956e2d; }
.viewer-controls { display: flex; gap: 6px; flex-wrap: wrap; }
.danger:not(:disabled) { color: #9a4e47; }
.danger:not(:disabled):hover { border-color: #d6b4ae; background: #fff6f4; }
/* The screen takes the remaining panel height; object-fit preserves the remote aspect ratio. */
.screen { position: relative; flex: 1; min-height: 60px; width: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; isolation: isolate; border-radius: 8px; background: #17191c; }
.screen canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
/* Overlay the control indicator so toggling control never resizes the video. */
.control-halo { display: none; }
#app[data-controlling="true"] .screen::before,
#app[data-controlling="true"] .screen::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; }
#app[data-controlling="true"] .screen::before { z-index: 1; box-shadow: inset 0 0 10px #3976ff40, inset 0 0 3px #8ccaff70; }
#app[data-controlling="true"] .control-halo {
--halo-spread: 14px;
display: block;
position: absolute;
inset: 0;
z-index: 2;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
/* Fade the light inward at the edges, leaving the desktop itself clear. */
mask-image:
linear-gradient(to bottom, #000, #0007 4px, transparent var(--halo-spread), transparent calc(100% - var(--halo-spread)), #0007 calc(100% - 4px), #000),
linear-gradient(to right, #000, #0007 4px, transparent var(--halo-spread), transparent calc(100% - var(--halo-spread)), #0007 calc(100% - 4px), #000);
}
.control-halo::before {
content: "";
position: absolute;
inset: -22%;
background:
radial-gradient(ellipse 32% 38% at 24% 26%, #d5f3ff 0%, #76c4ff 20%, #427bff80 50%, transparent 100%),
radial-gradient(ellipse 38% 40% at 78% 75%, #c4d7ff 0%, #637cff 24%, #436bff70 60%, transparent 100%),
linear-gradient(125deg, #3c8aff70, #687dff50 55%, #4ab5ff80);
filter: blur(8px);
opacity: .9;
/* Move a cached light layer rather than repainting an animated gradient. */
animation: control-halo-drift 9s ease-in-out infinite alternate;
will-change: transform, opacity;
}
#app[data-controlling="true"] .screen::after {
z-index: 3;
padding: 1px;
background: linear-gradient(125deg, #dbf6ffb0, #5e98ff70 30%, #c2e9ffc0 53%, #7e9dff80 75%, #b0e6ffb0);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
}
@keyframes control-halo-drift {
0% { transform: translate3d(-7%, -5%, 0) scale(1); opacity: .7; }
50% { opacity: 1; }
100% { transform: translate3d(7%, 5%, 0) scale(1.05); opacity: .85; }
}
@media (prefers-reduced-motion: reduce) {
.control-halo::before { animation: none; will-change: auto; }
}
#empty { text-align: center; padding: 16px; max-width: 100%; color: #b4b7bd; }
#empty h3 { font-size: 14px; font-weight: 400; margin: 0 0 8px; }
#empty p { color: #7e848e; font-size: 12px; line-height: 1.7; margin: 0; }
.stale { position: absolute; left: 12px; bottom: 12px; padding: 7px 10px; background: #25272cdd; color: #e2c495; border-radius: 3px; font-size: 11px; }
.telemetry { display: flex; align-items: center; flex-wrap: wrap; gap: 7px 18px; padding: 9px 12px; flex: none; color: var(--muted); font-size: 11px; }
.stream-metrics { display: flex; gap: 14px; font-variant-numeric: tabular-nums; }
#permission { margin-left: auto; }
#permission[data-state="paused"] { color: #956e2d; }
.panel-status { flex: none; font-size: 12px; line-height: 1.6; color: var(--muted); margin: 0; }
.video-options { display: flex; flex-wrap: wrap; gap: 8px; flex: none; }
.video-options:not(:has(button:not([hidden]))) { display: none; }
.focus-mode main { grid-template-columns: minmax(0, 1fr); }
.focus-mode .app-header { display: none; }
.focus-mode aside, .focus-mode #details-toggle { display: none !important; }
@media (max-width: 760px) {
.app-header { padding: 10px 14px; }
main { display: flex; flex-direction: column; }
aside { flex: 1; border-right: 0; padding: 20px; }
#app:not([data-connected="true"]) .workspace { display: none; }
#app[data-connected="true"] aside { display: none; flex: none; max-height: 40%; border-bottom: 1px solid var(--line); }
#app[data-connected="true"].details-open aside { display: block; }
#app[data-connected="true"] #details-toggle { display: inline-flex; }
#app[data-connected="true"] .workspace { flex: 1; }
.workspace { padding: 10px; }
#sidebar-title { margin-bottom: 16px; }
.session-details { margin-top: 12px; }
}
@media (max-width: 480px) {
#app[data-controlling="true"] .control-halo { --halo-spread: 10px; }
.app-header { gap: 8px; padding: 10px 12px; }
.brand, .header-right { gap: 7px; }
.brand-logo { width: 24px; height: 24px; }
h1 { font-size: 15px; }
.connection { font-size: 11px; }
#details-toggle { padding: 5px 7px; min-height: 28px; font-size: 11px; }
.workspace { padding: 8px; gap: 8px; }
.viewer-toolbar { padding: 9px; }
.viewer-title, .viewer-controls { width: 100%; flex: auto; }
.viewer-controls button { flex: 1; min-height: 36px; }
.telemetry { padding: 8px 9px; gap: 6px 10px; }
#session-info { display: none; }
.stream-metrics { gap: 10px; }
#permission { font-size: 10px; }
}
@media (max-height: 460px) and (min-width: 481px) {
.app-header { padding-top: 6px; padding-bottom: 6px; }
.workspace { padding: 8px; gap: 6px; }
.viewer-toolbar { padding-top: 5px; padding-bottom: 5px; }
.telemetry { padding-top: 6px; padding-bottom: 6px; }
}
+546
View File
@@ -0,0 +1,546 @@
import type { App } from "@modelcontextprotocol/ext-apps";
import "./viewer.css";
import { VideoPlayer } from "./video-player.js";
declare const CROSSDESK_APPS: boolean;
declare const CROSSDESK_LOGO: string;
type Data = Record<string, any>;
type Invoke = (name: string, args?: Data) => Promise<Data>;
const root = document.querySelector<HTMLDivElement>("#app")!;
root.innerHTML = `
<header class="app-header">
<div class="brand"><img class="brand-logo" src="${CROSSDESK_LOGO}" alt="" width="28" height="28"><h1>CrossDesk MCP</h1></div>
<div class="header-right"><button id="details-toggle" aria-expanded="false" aria-controls="sidebar">连接信息</button><span class="connection" id="connection" role="status"><span id="connection-label">等待连接</span></span></div>
</header>
<div id="error" role="alert" hidden></div>
<main>
<aside id="sidebar" aria-labelledby="sidebar-title">
<h2 id="sidebar-title">连接设备</h2>
<div id="connect-form" role="form" aria-label="连接远程电脑">
<fieldset id="connection-fields">
<label for="device">设备</label><select id="device"><option value="">连接新设备</option></select>
<div id="new-device">
<label for="device-id">设备 ID</label><input id="device-id" inputmode="numeric" autocomplete="off" placeholder="输入 9 位设备 ID" maxlength="11">
</div>
<label for="password">访问密码</label><div class="password-field"><input id="password" type="password" autocomplete="off" maxlength="256" placeholder="输入远端访问密码"><button type="button" id="password-toggle" aria-label="显示密码" aria-pressed="false">显示</button></div>
<label for="mode">授权范围</label><select id="mode" aria-describedby="mode-description"><option value="observe">仅查看画面</option><option value="control">允许 Agent 操作</option></select>
<p class="field-hint" id="mode-description">Agent 可以查看屏幕,无法操作键鼠。</p>
<details class="preferences"><summary>保存与授权</summary>
<div id="new-device-label"><label for="label">设备备注 <span class="optional">可选</span></label><input id="label" placeholder="例如:工作电脑" maxlength="80"></div>
<div class="checks">
<label><input id="save" type="checkbox"><span>保存设备</span></label>
<label><input id="remember" type="checkbox" disabled><span>记住密码<small>保存在本机系统凭据库</small></span></label>
<label><input id="authorize" type="checkbox" disabled><span>允许下次自动连接<small>沿用本次授权范围</small></span></label>
</div>
</details>
<details id="advanced"><summary>高级设置</summary>
<label for="server">服务器</label><input id="server" value="api.crossdesk.cn" autocomplete="off">
<div class="row"><div><label for="port">端口</label><input id="port" type="number" min="1" max="65535" value="9099"></div><div><label for="display">显示器索引</label><input id="display" type="number" min="0" max="63" value="0"></div></div>
</details>
</fieldset>
<button id="connect" type="button" class="primary" disabled><span id="connect-label">连接</span></button>
<p class="privacy">密码不会发送到 Agent 对话中。</p>
</div>
<div id="session-card" hidden>
<div class="device-card"><strong id="session-device">远程电脑</strong><span id="session-platform">已连接</span></div>
<dl class="session-details"><div id="session-device-id-row" hidden><dt>设备 ID</dt><dd id="session-device-id"></dd></div><div><dt>访问权限</dt><dd id="session-access"></dd></div><div><dt>显示器</dt><dd id="session-display"></dd></div><div><dt>Agent 状态</dt><dd id="session-agent"></dd></div></dl>
<p class="session-note">关闭页面不会断开连接。</p>
</div>
</aside>
<section class="workspace" aria-label="远程画面与会话控制">
<div class="viewer-card">
<div class="viewer-toolbar">
<div class="viewer-title"><h2 id="device-name">远程画面</h2><span id="video-state">待连接</span></div>
<div class="viewer-controls"><button id="pause" disabled><span id="pause-label">暂停控制</span></button><button id="disconnect" class="danger" disabled>断开连接</button><button id="focus-view" aria-label="放大画面" aria-pressed="false" disabled>放大画面</button></div>
</div>
<div class="screen" id="screen">
<canvas id="frame" role="img" aria-label="远程桌面实时画面" hidden></canvas>
<div class="control-halo" aria-hidden="true"></div>
<div id="empty"><h3 id="empty-title">未连接设备</h3><p id="empty-description">选择设备并输入访问密码。</p></div>
<div class="stale" id="stale" role="status" hidden>画面暂未更新</div>
</div>
<div class="telemetry"><div class="stream-metrics"><span id="resolution">—</span><span id="fps">—</span></div><span id="session-info">等待设备连接</span><span id="permission">等待授权</span></div>
</div>
<div class="video-options"><button id="video-retry" hidden>重新加载画面</button><button id="video-browser" hidden>在浏览器中观看</button></div>
<p id="panel-status" class="panel-status" role="status" hidden></p>
</section>
</main>`;
const element = <T extends HTMLElement = HTMLElement>(id: string) =>
document.getElementById(id) as T;
const input = (id: string) => element<HTMLInputElement>(id);
const select = (id: string) => element<HTMLSelectElement>(id);
let invoke: Invoke;
let app: App | undefined;
let context: Data | undefined;
let status: Data = {};
let savedDevices: Data[] = [];
let controlBusy = false;
let disconnectedByUser = false;
let frameSize = "", frameRate = "";
let player: VideoPlayer | undefined;
let videoStarting = false, videoFailed = false, videoEpoch = 0;
let videoSession: string | undefined;
let videoGeneration: number | undefined;
let imageAt = 0;
let busy = false,
polling = false,
started = false;
const messages: Record<string, string> = {
VIDEO_UNSUPPORTED: "当前浏览器不支持此视频格式,请使用支持 WebCodecs 的浏览器。",
VIDEO_UNAVAILABLE: "视频通道暂不可用。可重新加载画面;内嵌面板受限时请在浏览器中观看。",
VIDEO_INVALID: "视频格式发生变化,请重新加载画面。",
VIDEO_LIMIT: "观看面板数量已达上限,请关闭其他面板。",
AUTH_FAILED: "访问密码错误,请重新输入。",
AUTH_REQUIRED: "请输入访问密码,或在本机 setup 中配置凭据。",
CONNECTION_FAILED: "连接失败,请检查设备是否在线及服务器设置。",
CONNECTION_TIMEOUT: "等待远端画面超时,请检查远端状态后重试。",
CONNECTION_LOST: "远程连接已中断。请检查远端状态并重新连接。",
SESSION_LIMIT: "已有活动连接,请先断开当前电脑。",
DEVICE_BUSY: "另一实例正在使用此设备。",
CREDENTIAL_STORE_UNAVAILABLE: "系统凭据库不可用,密码未保存。",
PAIRING_REQUIRED:
"请在终端运行 Agent 工具结果中的 setup --open-panel 配对命令,再打开面板。",
PANEL_UNAUTHORIZED: "面板授权已失效,请重新打开。",
VIEW_UNAUTHORIZED: "此面板已失效,请使用最新打开的面板。",
CONTROL_DENIED: "当前会话没有控制权限,或已被人工暂停。",
NATIVE_PACKAGE_MISSING:
"缺少对应平台的 CLI 运行组件,请检查包安装是否启用了 optional dependencies。",
INVALID_ARGUMENT: "请检查设备 ID、服务器和输入内容。",
DEVICE_ALIAS_CONFLICT: "这个备注已属于另一台设备,请换一个备注。",
};
function text(id: string, value: string) {
if (element(id).textContent !== value) element(id).textContent = value;
}
function platformName(platform?: string) {
return ({ macos: "macOS", windows: "Windows", linux: "Linux", android: "Android", ios: "iOS" } as Record<string, string>)[platform ?? ""] ?? "远程电脑";
}
function resetMetrics() {
imageAt = 0;
frameSize = frameRate = "";
text("resolution", "—");
text("fps", "—");
element<HTMLButtonElement>("focus-view").disabled = true;
}
function setFocus(focused: boolean) {
root.classList.toggle("focus-mode", focused);
const button = element<HTMLButtonElement>("focus-view");
const label = focused ? "还原布局" : "放大画面";
button.textContent = label;
if (focused) setDetails(false);
button.setAttribute("aria-pressed", String(focused));
button.setAttribute("aria-label", label);
button.title = label;
}
function setDetails(open: boolean) {
root.classList.toggle("details-open", open);
element("details-toggle").setAttribute("aria-expanded", String(open));
text("details-toggle", open ? "收起信息" : "连接信息");
}
element("details-toggle").onclick = () => setDetails(!root.classList.contains("details-open"));
element("focus-view").onclick = () => setFocus(!root.classList.contains("focus-mode"));
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && root.classList.contains("focus-mode")) setFocus(false);
});
function hidePassword() {
input("password").type = "password";
text("password-toggle", "显示");
element("password-toggle").setAttribute("aria-label", "显示密码");
element("password-toggle").setAttribute("aria-pressed", "false");
}
element("password-toggle").onclick = () => {
if (input("password").type === "text") return hidePassword();
input("password").type = "text";
text("password-toggle", "隐藏");
element("password-toggle").setAttribute("aria-label", "隐藏密码");
element("password-toggle").setAttribute("aria-pressed", "true");
};
select("mode").onchange = () => {
text("mode-description", select("mode").value === "control"
? "Agent 可以操作键鼠,你可以随时暂停。"
: "Agent 可以查看屏幕,无法操作键鼠。");
};
function showError(error: unknown) {
const code = error instanceof Error ? error.message : "INTERNAL_ERROR";
element("error").textContent =
messages[code] ??
`操作未完成(${/^[A-Z_]+$/.test(code) ? code : "CONNECTION_ERROR"})`;
element("error").hidden = false;
}
function check(result: Data): Data {
if (result.error) throw new Error(result.error.code ?? "INTERNAL_ERROR");
return result;
}
async function devices() {
const result = await invoke("devices");
if (!started && result.defaults) {
input("server").value = result.defaults.server;
input("port").value = String(result.defaults.port);
}
const chosen = select("device").value || context?.device_alias || "";
select("device").replaceChildren(new Option("连接新设备", ""));
savedDevices = result.devices ?? [];
for (const d of savedDevices)
select("device").add(
new Option(`${d.device_alias} · ${d.device_id}`, d.device_alias),
);
select("device").value = [...select("device").options].some(
(o) => o.value === chosen,
)
? chosen
: "";
deviceChanged();
}
function deviceChanged() {
const saved = !!select("device").value;
element("new-device").hidden = saved;
element("new-device-label").hidden = saved;
input("server").disabled = input("port").disabled = saved;
input("password").placeholder = saved
? "留空使用已保存的密码"
: "输入远端访问密码";
}
select("device").onchange = deviceChanged;
input("save").onchange = () => {
for (const name of ["remember", "authorize"]) {
input(name).disabled = !input("save").checked;
if (!input("save").checked) input(name).checked = false;
}
};
function renderStatus(s: Data) {
const connected = s.state === "ready" || s.state === "waiting_frame";
const lost = ["closed", "failed"].includes(s.state);
if (lost && !disconnectedByUser && ["ready", "waiting_frame"].includes(status.state)) showError(new Error("CONNECTION_LOST"));
const changedSession = s.session_id !== status.session_id;
status = s;
const connecting = busy || s.state === "waiting_frame";
root.dataset.connected = String(connected);
root.dataset.connecting = String(connecting);
root.dataset.controlling = String(connected && s.control === true && s.control_allowed === true && !s.human_paused);
element("connection").dataset.state = connected ? "online" : connecting ? "connecting" : lost && !disconnectedByUser ? "offline" : "idle";
text("connection-label", connected ? "已连接" : connecting ? "正在连接" : lost ? "未连接" : "等待连接");
element("connect-form").hidden = connected;
element("session-card").hidden = !connected;
text("sidebar-title", connected ? "连接信息" : "连接设备");
text("device-name", connected ? s.device_alias || "远程电脑" : "远程画面");
if (!connected || changedSession) setDetails(false);
text("permission", connected ? s.human_paused ? "控制已暂停" : s.control_allowed ? "允许 Agent 操作" : "仅查看画面" : "等待授权");
element("permission").dataset.state = !connected ? "idle" : s.human_paused ? "paused" : "active";
const platform = platformName(s.platform);
text("session-info", connected ? `${platform} · 显示器 ${(s.display ?? 0) + 1}` : "等待设备连接");
text("session-device", s.device_alias || "远程电脑");
text("session-platform", platform);
const device = savedDevices.find((d) => d.device_alias === s.device_alias);
element("session-device-id-row").hidden = !device;
text("session-device-id", device ? String(device.device_id).replace(/(\d{3})(?=\d)/g, "$1 ") : "");
text("session-access", s.control_allowed ? "查看与操作" : "仅查看");
text("session-display", `显示器 ${(s.display ?? 0) + 1}`);
text("session-agent", s.human_paused ? "已暂停" : s.control_allowed ? "允许操作" : "仅可观察");
element("session-agent").dataset.paused = String(!!s.human_paused);
element<HTMLButtonElement>("pause").disabled = !connected || !s.control_allowed || controlBusy;
const pauseLabel = s.human_paused ? "恢复控制" : "暂停控制";
text("pause-label", pauseLabel);
element<HTMLButtonElement>("disconnect").disabled = !s.session_id || controlBusy || busy;
element<HTMLButtonElement>("connect").disabled = busy || connected || !invoke;
element<HTMLFieldSetElement>("connection-fields").disabled = busy;
element("connect").setAttribute("aria-busy", String(busy));
text("connect-label", busy ? "正在连接…" : "连接");
if (!connected) {
element("frame").hidden = true;
element("empty").hidden = false;
element("stale").hidden = true;
resetMetrics();
if (root.classList.contains("focus-mode")) setFocus(false);
} else if (changedSession) {
resetMetrics();
element("frame").hidden = true;
element("empty").hidden = false;
}
if (!imageAt && !videoFailed) {
text("empty-title", connecting || connected ? "正在建立远程连接" : lost ? "会话已结束" : "未连接设备");
text("empty-description", connecting || connected ? "正在等待远端画面,请稍候。" : lost ? "请重新连接设备。" : "选择设备并输入访问密码。");
text("video-state", connecting || connected ? "等待画面" : "待连接");
element("video-state").dataset.state = "idle";
}
}
async function connect() {
if (!invoke || busy || ["ready", "waiting_frame"].includes(status.state)) return;
element("error").hidden = true;
disconnectedByUser = false;
busy = true;
renderStatus(status);
const saved = select("device").value;
let args: Data = {
mode: select("mode").value,
display_index: Number(input("display").value),
save_device: input("save").checked,
remember_password: input("remember").checked,
persistent_authorization: input("authorize").checked,
...(saved
? { device_alias: saved }
: {
device_id: input("device-id").value.replace(/\s/g, ""),
server: input("server").value,
port: Number(input("port").value),
...(input("label").value ? { label: input("label").value } : {}),
}),
};
if (!saved || input("password").value)
args.password = input("password").value;
input("password").value = "";
hidePassword();
try {
const result = await invoke("connect", args);
args = {};
renderStatus(result);
if (result.persistence_error)
showError(new Error(result.persistence_error));
await devices();
} catch (error) {
args = {};
showError(error);
} finally {
busy = false;
renderStatus(status);
}
}
element("connect").onclick = () => {
void connect();
};
element("connect-form").addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target instanceof HTMLInputElement) {
event.preventDefault();
void connect();
}
});
element("pause").onclick = async () => {
if (controlBusy) return;
controlBusy = true;
renderStatus(status);
try {
await invoke("control", {
operation: status.human_paused ? "resume" : "pause",
});
renderStatus(await invoke("status"));
} catch (e) {
showError(e);
} finally {
controlBusy = false;
renderStatus(status);
}
};
element("disconnect").onclick = async () => {
if (controlBusy) return;
controlBusy = true;
disconnectedByUser = true;
renderStatus(status);
try {
await closePreview();
await invoke("control", { operation: "disconnect" });
element("error").hidden = true;
resetMetrics();
renderStatus(await invoke("status"));
} catch (e) {
disconnectedByUser = false;
showError(e);
} finally {
controlBusy = false;
renderStatus(status);
}
};
async function closePreview() {
++videoEpoch;
player?.stop();
player = undefined;
videoSession = undefined;
videoGeneration = undefined;
videoStarting = videoFailed = false;
element("video-retry").hidden = true;
element("video-browser").hidden = true;
}
async function startPreview() {
if (player || videoStarting || videoFailed || document.hidden) return;
const epoch = ++videoEpoch;
videoSession = status.session_id;
videoGeneration = status.generation;
videoStarting = true;
const failed = (error: Error) => {
if (epoch !== videoEpoch) return;
videoFailed = true;
showError(error);
element("video-retry").hidden = false;
element("video-browser").hidden = !CROSSDESK_APPS;
element("stale").hidden = !imageAt;
text("video-state", "画面不可用");
element("video-state").dataset.state = "stale";
if (!imageAt) {
text("empty-title", "暂时无法显示画面");
text("empty-description", "会话仍然保留。请尝试重新加载画面。");
}
};
try {
const grant = await invoke("video_open");
if (epoch !== videoEpoch || document.hidden) return;
player = new VideoPlayer(element<HTMLCanvasElement>("frame"), (info) => {
if (epoch !== videoEpoch) return;
imageAt = Date.now();
element("frame").hidden = false;
element("empty").hidden = true;
element("stale").hidden = true;
const size = `${info.width} × ${info.height}`;
const rate = info.fps > 0 ? `${info.fps.toFixed(1)} FPS` : "测量中";
if (size !== frameSize) { frameSize = size; text("resolution", size); }
if (rate !== frameRate) { frameRate = rate; text("fps", rate); }
element<HTMLButtonElement>("focus-view").disabled = false;
text("video-state", "实时");
element("video-state").dataset.state = "live";
}, failed);
player.start(grant.url, grant.ticket, grant.protocol, grant.decoder);
} catch (error) {
failed(error instanceof Error ? error : new Error("VIDEO_UNAVAILABLE"));
} finally {
if (epoch === videoEpoch) videoStarting = false;
}
}
element("video-retry").onclick = async () => {
await closePreview();
element("error").hidden = true;
await startPreview();
};
element("video-browser").onclick = async () => {
try {
const result = await app!.callServerTool({name: "crossdesk_open_browser", arguments: {view_id: context!.view_id}});
if (result.isError) throw new Error("VIDEO_UNAVAILABLE");
started = false;
await closePreview();
element("error").hidden = true;
for (const name of ["connect", "pause", "disconnect"])
element<HTMLButtonElement>(name).disabled = true;
element("panel-status").hidden = false;
element("panel-status").textContent = "浏览器观看面板已打开。";
} catch (error) { showError(error); }
};
async function poll() {
if (polling || !invoke || !started || document.hidden || busy || controlBusy) return;
polling = true;
try {
renderStatus(await invoke("status"));
if (videoSession && (videoSession !== status.session_id ||
videoGeneration !== status.generation)) {
if (videoFailed) element("error").hidden = true;
await closePreview();
}
if (status.state !== "ready" || document.hidden) {
await closePreview();
return;
}
await startPreview();
const stale = !!imageAt && Date.now() - imageAt > 2000;
element("stale").hidden = !stale;
if (stale) {
text("video-state", "等待画面");
element("video-state").dataset.state = "stale";
text("fps", "—");
frameRate = "";
}
} catch (error) { showError(error); }
finally { polling = false; }
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) void closePreview();
else void poll();
});
window.addEventListener("pagehide", () => {
void closePreview();
});
setInterval(() => {
void poll();
}, 1000);
async function ready(next: Data) {
if (
started &&
context?.view_id === next.view_id &&
context?.view_generation === next.view_generation
)
return;
if (started) await closePreview();
context = next;
element("panel-status").hidden = true;
started = false;
await invoke("ready");
await devices();
started = true;
element<HTMLButtonElement>("connect").disabled = false;
await poll();
}
async function start() {
if (CROSSDESK_APPS) {
const { App } = await import("@modelcontextprotocol/ext-apps");
app = new App({ name: "CrossDesk MCP", version: "0.1.0" }, {});
const names: Record<string, string> = {
ready: "crossdesk_ui_ready",
devices: "crossdesk_ui_list_devices",
status: "crossdesk_ui_get_session",
connect: "crossdesk_ui_connect",
video_open: "crossdesk_video_open",
control: "crossdesk_viewer_control",
};
let connected = false,
initial: Data | undefined;
invoke = async (name, args = {}) => {
const result = await app!.callServerTool({
name: names[name]!,
arguments: {
...args,
view_id: context!.view_id,
view_generation: context!.view_generation,
ui_token: context!.ui_token,
},
});
const data =
result.structuredContent ??
JSON.parse((result.content?.[0] as Data)?.text ?? "{}");
return check(data);
};
app.ontoolresult = (result) => {
const meta = result._meta?.["crossdesk/ui"] as Data | undefined;
if (!meta) return;
initial = { ...(result.structuredContent ?? {}), ...meta };
if (connected) void ready(initial).catch(showError);
};
app.onteardown = async () => {
await closePreview();
return {};
};
await app.connect();
connected = true;
if (initial) await ready(initial);
} else {
let csrf = "";
const request = async (path: string, body?: Data) =>
check(
await (
await fetch("/api/" + path, {
method: body ? "POST" : "GET",
credentials: "same-origin",
headers: body
? { "Content-Type": "application/json", "X-CrossDesk-CSRF": csrf }
: {},
body: body ? JSON.stringify(body) : undefined,
cache: "no-store",
})
).json(),
);
const bootstrap = new URLSearchParams(location.hash.slice(1)).get(
"bootstrap",
);
history.replaceState(null, "", location.pathname);
const result = bootstrap
? await request("bootstrap", { token: bootstrap })
: await request("context");
csrf = result.csrf;
invoke = (name, args = {}) =>
request(name, { ...args, view_id: context!.view_id });
await ready(bootstrap ? result : result.views.at(-1));
}
}
void start().catch(showError);