[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
+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");
}
}