mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-04 21:12:42 +08:00
[feat] add CrossDesk MCP with remote desktop panels
This commit is contained in:
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user