@codedroid/mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +11 -0
- package/.mcp.json +8 -0
- package/LICENSE.aura-mcp-connect +21 -0
- package/README.md +263 -2
- package/gemini-extension.json +14 -0
- package/package.json +44 -4
- package/skills/codedroid-phone/SKILL.md +92 -0
- package/skills/test-on-device/SKILL.md +73 -0
- package/src/adb-tool.js +200 -0
- package/src/daemon-transport.js +30 -0
- package/src/daemon.js +422 -0
- package/src/host.js +110 -0
- package/src/index.js +261 -0
- package/src/proxy-core.js +294 -0
- package/src/relay-transport.js +190 -0
- package/src/skill.js +39 -0
- package/src/status-tool.js +49 -0
- package/src/stdio-shim.js +146 -0
- package/src/version.js +2 -0
package/src/host.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This computer's pairing with a phone, kept in ~/.codedroid/host.json (dir 0700, file 0600):
|
|
3
|
+
* {server, host_id, token, device: {id, model}}.
|
|
4
|
+
*
|
|
5
|
+
* The phone shows a one-time code (Settings > Computers > Pair a computer); `pair` redeems it at
|
|
6
|
+
* the CodeDroid server for a host token. The token is the key to the relay: anyone holding it
|
|
7
|
+
* reaches the phone (still behind its Allow sheet), so the file is the user's alone.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_SERVER = "https://orchestrator.codedroid.com";
|
|
14
|
+
|
|
15
|
+
export const PAIRING_STEPS =
|
|
16
|
+
"On the phone, open CodeDroid > Settings > Computers > Pair a computer, then run the command it shows " +
|
|
17
|
+
"on this computer (npx -y @codedroid/mcp pair XXXX-XXXX).";
|
|
18
|
+
|
|
19
|
+
export function hostFilePath(home = os.homedir()) {
|
|
20
|
+
return path.join(home, ".codedroid", "host.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The saved pairing, or null when this computer is not paired. */
|
|
24
|
+
export function readHost({ home = os.homedir() } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
const host = JSON.parse(fs.readFileSync(hostFilePath(home), "utf8"));
|
|
27
|
+
return host?.token && host.server ? { ...host, server: normalizeServer(host.server) } : null;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveHost(host, { home = os.homedir() } = {}) {
|
|
34
|
+
const file = hostFilePath(home);
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
36
|
+
// A new file, renamed over the old: 0600 from its first byte, and never half-written.
|
|
37
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
38
|
+
fs.writeFileSync(tmp, JSON.stringify(host, null, 2) + "\n", { mode: 0o600 });
|
|
39
|
+
fs.renameSync(tmp, file);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The phone a pairing points at, as status and /health show it. */
|
|
43
|
+
export function pairedDevice(host) {
|
|
44
|
+
return host ? { id: host.device?.id ?? null, model: host.device?.model ?? null, server: host.server } : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** [server] without a trailing slash, or an error saying what is wrong with it. */
|
|
48
|
+
export function normalizeServer(server) {
|
|
49
|
+
let url;
|
|
50
|
+
try {
|
|
51
|
+
url = new URL(server);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
throw new Error(`Not a server URL: ${server}`);
|
|
54
|
+
}
|
|
55
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`Not an http(s) server URL: ${server}`);
|
|
56
|
+
url.search = "";
|
|
57
|
+
url.hash = "";
|
|
58
|
+
return url.href.replace(/\/+$/, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const NEW_CODE = "On the phone, open CodeDroid > Settings > Computers > Pair a computer for a new one.";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Redeems [code] at [server] and saves the pairing, replacing any earlier one; resolves what was
|
|
65
|
+
* saved. Throws a message for the user, and leaves the earlier pairing as it was, on any failure.
|
|
66
|
+
*/
|
|
67
|
+
export async function pair(
|
|
68
|
+
code,
|
|
69
|
+
{ server = DEFAULT_SERVER, fetch = globalThis.fetch, home = os.homedir(), hostname = os.hostname(), platform = os.platform() } = {},
|
|
70
|
+
) {
|
|
71
|
+
// The server reads the code the forgiving way (any case, with or without the dash); this only
|
|
72
|
+
// catches what cannot be one before a round-trip.
|
|
73
|
+
const typed = String(code).replace(/\s+/g, "");
|
|
74
|
+
if (!/^[0-9a-z]{8}$/i.test(typed.replace(/-/g, ""))) {
|
|
75
|
+
throw new Error(`${code} is not a pairing code: it is 8 letters and digits, like ABCD-EFGH. ${NEW_CODE}`);
|
|
76
|
+
}
|
|
77
|
+
const base = normalizeServer(server);
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await fetch(`${base}/v1/mcp/pairings/redeem`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
code: typed,
|
|
85
|
+
name: hostname.replace(/\.local$/, "").slice(0, 64) || "computer",
|
|
86
|
+
platform: platform.slice(0, 32),
|
|
87
|
+
}),
|
|
88
|
+
signal: AbortSignal.timeout(30_000),
|
|
89
|
+
});
|
|
90
|
+
} catch (e) {
|
|
91
|
+
throw new Error(`Could not reach the CodeDroid server at ${base}: ${e.cause?.message ?? e.message}`);
|
|
92
|
+
}
|
|
93
|
+
const body = await res.json().catch(() => null);
|
|
94
|
+
if (res.status === 404) throw new Error(`The code ${code} is not valid: codes work once, for 10 minutes. ${NEW_CODE}`);
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const detail = typeof body?.detail === "string" ? `: ${body.detail}` : "";
|
|
97
|
+
throw new Error(`The CodeDroid server at ${base} refused the pairing (HTTP ${res.status}${detail}).`);
|
|
98
|
+
}
|
|
99
|
+
if (typeof body?.token !== "string" || !body.host_id) {
|
|
100
|
+
throw new Error(`The CodeDroid server at ${base} answered the pairing without a host token; update codedroid-mcp.`);
|
|
101
|
+
}
|
|
102
|
+
const host = {
|
|
103
|
+
server: base,
|
|
104
|
+
host_id: body.host_id,
|
|
105
|
+
token: body.token,
|
|
106
|
+
device: { id: body.device?.id ?? null, model: body.device?.model ?? null },
|
|
107
|
+
};
|
|
108
|
+
saveHost(host, { home });
|
|
109
|
+
return host;
|
|
110
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* codedroid-mcp — connect any MCP client (Claude Code, Codex, Cursor, ...) to CodeDroid on an
|
|
4
|
+
* Android phone paired with this computer.
|
|
5
|
+
*
|
|
6
|
+
* ONE daemon per computer owns the connection to the phone (through the CodeDroid server's relay)
|
|
7
|
+
* and serves MCP over Streamable HTTP on localhost; clients either point at its URL or launch this
|
|
8
|
+
* stdio shim, which auto-starts it.
|
|
9
|
+
*
|
|
10
|
+
* Modes:
|
|
11
|
+
* codedroid-mcp stdio shim → daemon (auto-starts it) [default]
|
|
12
|
+
* codedroid-mcp daemon run the daemon in the foreground
|
|
13
|
+
* codedroid-mcp pair <code> pair with the phone showing <code>, then connect
|
|
14
|
+
* codedroid-mcp connect connect to the phone now (and get it allowed there), then exit
|
|
15
|
+
* codedroid-mcp status what the running daemon sees
|
|
16
|
+
* codedroid-mcp stop stop the running daemon
|
|
17
|
+
* codedroid-mcp call <tool> [json] call one tool and print the result (scripts, CI)
|
|
18
|
+
* codedroid-mcp skill install|path the agent skills, for clients without the plugin
|
|
19
|
+
*
|
|
20
|
+
* Options: --port=<n> (or CODEDROID_MCP_PORT), default 4826. --server=<url> for pair. CODEDROID_MCP_ADB=0
|
|
21
|
+
* leaves out the adb tool. CODEDROID_SERIAL picks the adb tool's device when several are attached.
|
|
22
|
+
*
|
|
23
|
+
* All log output goes to stderr — stdout is reserved for MCP JSON-RPC frames.
|
|
24
|
+
*
|
|
25
|
+
* Forked from aura-mcp-connect (MIT, Copyright (c) 2026 Dinesh Kumar C; see LICENSE.aura-mcp-connect).
|
|
26
|
+
*/
|
|
27
|
+
import { VERSION } from "./version.js";
|
|
28
|
+
|
|
29
|
+
function log(...parts) {
|
|
30
|
+
process.stderr.write(`[codedroid-mcp] ${parts.join(" ")}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fatal(message, code = 1) {
|
|
34
|
+
process.stderr.write(`[codedroid-mcp] ${message}\n`);
|
|
35
|
+
process.exit(code);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const out = { _: [] };
|
|
40
|
+
for (const raw of argv) {
|
|
41
|
+
if (!raw.startsWith("--")) {
|
|
42
|
+
out._.push(raw);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const eq = raw.indexOf("=");
|
|
46
|
+
if (eq === -1) out[raw.slice(2)] = "true";
|
|
47
|
+
else out[raw.slice(2, eq)] = raw.slice(eq + 1);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolvePort(args) {
|
|
53
|
+
const raw = args.port ?? process.env.CODEDROID_MCP_PORT;
|
|
54
|
+
if (raw === undefined) return undefined;
|
|
55
|
+
const port = Number(raw);
|
|
56
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) fatal(`Invalid port: ${raw}`);
|
|
57
|
+
return port;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The adb tool is on unless CODEDROID_MCP_ADB=0 (or --no-adb). The env var reaches a daemon the shim starts. */
|
|
61
|
+
function resolveEnableAdb(args) {
|
|
62
|
+
if (args["no-adb"]) return false;
|
|
63
|
+
const raw = process.env.CODEDROID_MCP_ADB;
|
|
64
|
+
return raw === undefined || (raw !== "0" && raw.toLowerCase() !== "false");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const HELP = `codedroid-mcp ${VERSION}
|
|
68
|
+
|
|
69
|
+
Gives an MCP client (Claude Code, Codex, Cursor...) your Android phone through CodeDroid: see the
|
|
70
|
+
screen, tap and type, hand CodeDroid a task, and run adb.
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
codedroid-mcp MCP stdio server (auto-starts the shared daemon)
|
|
74
|
+
codedroid-mcp pair <code> Pair with the phone showing <code>, then connect
|
|
75
|
+
(--server=<url> when the phone's command has it)
|
|
76
|
+
codedroid-mcp connect Connect to the phone now; the first time, tap Allow on it
|
|
77
|
+
codedroid-mcp status What the running daemon sees
|
|
78
|
+
codedroid-mcp stop Stop the running daemon
|
|
79
|
+
codedroid-mcp call <tool> [json] Call one tool and print the result
|
|
80
|
+
codedroid-mcp daemon Run the shared daemon in the foreground
|
|
81
|
+
codedroid-mcp skill install Install the agent skills for Claude Code
|
|
82
|
+
(--codex for Codex, --dir=<path> anywhere else)
|
|
83
|
+
codedroid-mcp skill path Print where the skill files are
|
|
84
|
+
|
|
85
|
+
Pair first: on the phone, open CodeDroid > Settings > Computers > Pair a computer, and run the
|
|
86
|
+
command it shows here. The phone needs CodeDroid signed in, online, with its accessibility
|
|
87
|
+
service on. The adb tool, alone, needs the phone on this computer's adb.
|
|
88
|
+
|
|
89
|
+
Options:
|
|
90
|
+
--port=<n> Daemon port (default 4826; env CODEDROID_MCP_PORT)
|
|
91
|
+
--no-adb Leave out the adb tool (env CODEDROID_MCP_ADB=0)
|
|
92
|
+
--version Print version
|
|
93
|
+
--help This text
|
|
94
|
+
|
|
95
|
+
Env: CODEDROID_SERIAL picks the adb tool's device when several are attached; ADB is the adb binary.
|
|
96
|
+
|
|
97
|
+
Clients:
|
|
98
|
+
claude mcp add codedroid -- npx -y @codedroid/mcp
|
|
99
|
+
codex mcp add codedroid -- npx -y @codedroid/mcp
|
|
100
|
+
{ "mcpServers": { "codedroid": { "command": "npx", "args": ["-y", "@codedroid/mcp"] } } }
|
|
101
|
+
or by URL, with the daemon running: http://127.0.0.1:4826/mcp (streamable HTTP)
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
/** Logger for daemon mode: stderr + ~/.codedroid/daemon.log (it is usually spawned with stdio ignored). */
|
|
105
|
+
async function makeDaemonLogger() {
|
|
106
|
+
const { default: fs } = await import("fs");
|
|
107
|
+
const { default: path } = await import("path");
|
|
108
|
+
const { default: os } = await import("os");
|
|
109
|
+
const logFile = path.join(os.homedir(), ".codedroid", "daemon.log");
|
|
110
|
+
try {
|
|
111
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true, mode: 0o700 });
|
|
112
|
+
} catch (e) {
|
|
113
|
+
/* stderr still works */
|
|
114
|
+
}
|
|
115
|
+
return (...parts) => {
|
|
116
|
+
const line = `${new Date().toISOString()} ${parts.join(" ")}`;
|
|
117
|
+
process.stderr.write(`[codedroid-mcp] ${line}\n`);
|
|
118
|
+
try {
|
|
119
|
+
fs.appendFileSync(logFile, line + "\n");
|
|
120
|
+
} catch (e) {
|
|
121
|
+
/* best-effort */
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A client of the daemon, starting it if needed: for pair, connect, status and call. */
|
|
127
|
+
async function daemonClient(port, enableAdbTool) {
|
|
128
|
+
const { ensureDaemonRunning } = await import("./stdio-shim.js");
|
|
129
|
+
const { DEFAULT_PORT } = await import("./daemon.js");
|
|
130
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
131
|
+
const { daemonTransport } = await import("./daemon-transport.js");
|
|
132
|
+
const effective = port ?? DEFAULT_PORT;
|
|
133
|
+
await ensureDaemonRunning(effective, log, { enableAdbTool });
|
|
134
|
+
const client = new Client({ name: "codedroid-mcp-cli", version: VERSION });
|
|
135
|
+
await client.connect(daemonTransport(effective), { timeout: 150_000 });
|
|
136
|
+
return client;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The running daemon's /health, or null when there is none. */
|
|
140
|
+
async function daemonHealth(port) {
|
|
141
|
+
const { DEFAULT_PORT } = await import("./daemon.js");
|
|
142
|
+
const { fetchHealth } = await import("./stdio-shim.js");
|
|
143
|
+
return fetchHealth(port ?? DEFAULT_PORT);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Stops a running daemon and waits until it is gone; false when none was running. */
|
|
147
|
+
async function stopDaemon(port) {
|
|
148
|
+
const { DEFAULT_PORT } = await import("./daemon.js");
|
|
149
|
+
if (!(await daemonHealth(port))) return false;
|
|
150
|
+
await fetch(`http://127.0.0.1:${port ?? DEFAULT_PORT}/shutdown`, { method: "POST" }).catch(() => null);
|
|
151
|
+
for (let i = 0; i < 50 && (await daemonHealth(port)); i++) await new Promise((r) => setTimeout(r, 100));
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function printResult(result) {
|
|
156
|
+
for (const item of result.content ?? []) {
|
|
157
|
+
if (item.type === "text") process.stdout.write(item.text + "\n");
|
|
158
|
+
else if (item.type === "image") process.stdout.write(`[image ${item.mimeType}, ${item.data.length} base64 chars]\n`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function main() {
|
|
163
|
+
const args = parseArgs(process.argv.slice(2));
|
|
164
|
+
if (args.version) return void process.stdout.write(`codedroid-mcp ${VERSION}\n`);
|
|
165
|
+
if (args.help || args._[0] === "help") return void process.stdout.write(HELP);
|
|
166
|
+
|
|
167
|
+
const command = args._[0];
|
|
168
|
+
const port = resolvePort(args);
|
|
169
|
+
const enableAdbTool = resolveEnableAdb(args);
|
|
170
|
+
|
|
171
|
+
if (command === "skill") {
|
|
172
|
+
const { installSkills, SKILLS_SOURCE, defaultSkillsDir } = await import("./skill.js");
|
|
173
|
+
if (args._[1] === "path") return void process.stdout.write(`${SKILLS_SOURCE}\n`);
|
|
174
|
+
if (args._[1] !== "install") fatal("Usage: codedroid-mcp skill install [--codex | --dir=<path>] | codedroid-mcp skill path");
|
|
175
|
+
const written = installSkills({ dir: args.dir ?? defaultSkillsDir({ codex: !!args.codex }) });
|
|
176
|
+
process.stdout.write(`Installed:\n${written.map((p) => ` ${p}`).join("\n")}\nNew sessions pick them up.\n`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (command === "daemon") {
|
|
181
|
+
const { startDaemon, DEFAULT_PORT } = await import("./daemon.js");
|
|
182
|
+
const daemonLog = await makeDaemonLogger();
|
|
183
|
+
daemonLog(`v${VERSION} · daemon`);
|
|
184
|
+
let daemon;
|
|
185
|
+
try {
|
|
186
|
+
daemon = await startDaemon({ port, log: daemonLog, enableAdbTool });
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (e.code === "EADDRINUSE") {
|
|
189
|
+
const effectivePort = port ?? DEFAULT_PORT;
|
|
190
|
+
if (await daemonHealth(effectivePort)) {
|
|
191
|
+
daemonLog(`A CodeDroid daemon is already running on port ${effectivePort}.`);
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
fatal(`Port ${effectivePort} is in use by something else. Pick another: codedroid-mcp daemon --port=<n> (clients must use the same).`);
|
|
195
|
+
}
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
const shutdown = async () => {
|
|
199
|
+
await daemon.close();
|
|
200
|
+
process.exit(0);
|
|
201
|
+
};
|
|
202
|
+
process.on("SIGINT", shutdown);
|
|
203
|
+
process.on("SIGTERM", shutdown);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (command === "stop") {
|
|
208
|
+
process.stdout.write((await stopDaemon(port)) ? "Stopped the daemon.\n" : "No daemon was running.\n");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (command === "pair") {
|
|
213
|
+
const code = args._.slice(1).join(" ");
|
|
214
|
+
if (!code) fatal("Usage: codedroid-mcp pair <code> [--server=<url>]. The phone shows the command: CodeDroid > Settings > Computers > Pair a computer.");
|
|
215
|
+
const { DEFAULT_SERVER, pair } = await import("./host.js");
|
|
216
|
+
const host = await pair(code, { server: args.server ?? DEFAULT_SERVER });
|
|
217
|
+
log(`Paired with ${host.device.model ?? "the phone"} through ${host.server}.`);
|
|
218
|
+
// A daemon connected, or connecting, holds the old pairing's link; a disconnected one reads the new one.
|
|
219
|
+
const phone = (await daemonHealth(port))?.phone;
|
|
220
|
+
if (phone && phone !== "disconnected" && (await stopDaemon(port))) log("Restarted the daemon for the new pairing.");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (command === "status" || command === "connect" || command === "pair") {
|
|
224
|
+
const { PAIRING_STEPS, readHost } = await import("./host.js");
|
|
225
|
+
if (!readHost()) fatal(`This computer is not paired with a phone. ${PAIRING_STEPS}`);
|
|
226
|
+
if (command !== "status") log("Connecting to the phone. The first time, it asks whether to allow this computer: tap Allow.");
|
|
227
|
+
const client = await daemonClient(port, enableAdbTool);
|
|
228
|
+
const result = await client.callTool({ name: "codedroid_status", arguments: {} }, undefined, { timeout: 150_000 });
|
|
229
|
+
printResult(result);
|
|
230
|
+
await client.close();
|
|
231
|
+
const status = JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
232
|
+
process.exit(status.connected ? 0 : 1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (command === "call") {
|
|
236
|
+
const name = args._[1];
|
|
237
|
+
if (!name) fatal("Usage: codedroid-mcp call <tool> ['{\"arg\": 1}']");
|
|
238
|
+
let toolArgs = {};
|
|
239
|
+
if (args._[2]) {
|
|
240
|
+
try {
|
|
241
|
+
toolArgs = JSON.parse(args._[2]);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
fatal(`Arguments must be JSON: ${e.message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const client = await daemonClient(port, enableAdbTool);
|
|
247
|
+
const result = await client.callTool({ name, arguments: toolArgs }, undefined, { timeout: 600_000 });
|
|
248
|
+
printResult(result);
|
|
249
|
+
await client.close();
|
|
250
|
+
process.exit(result.isError ? 1 : 0);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (command) fatal(`Unknown command: ${command}. See codedroid-mcp --help.`);
|
|
254
|
+
|
|
255
|
+
const { runStdioShim } = await import("./stdio-shim.js");
|
|
256
|
+
log(`v${VERSION} · stdio → shared daemon`);
|
|
257
|
+
await runStdioShim({ port, log, enableAdbTool });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Expected failures carry a message written for the user; CODEDROID_DEBUG=1 shows the stack.
|
|
261
|
+
main().catch((err) => fatal(process.env.CODEDROID_DEBUG ? err?.stack || String(err) : err?.message || String(err)));
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy-core — builds an MCP Server that transparently forwards requests to a
|
|
3
|
+
* backend MCP Client (the single phone connection).
|
|
4
|
+
*
|
|
5
|
+
* Forked from aura-mcp-connect (MIT, Copyright (c) 2026 Dinesh Kumar C; see
|
|
6
|
+
* LICENSE.aura-mcp-connect). Changed: a session still opens when the phone cannot be
|
|
7
|
+
* reached, serving the local tools (adb, codedroid_status) and saying why in its
|
|
8
|
+
* instructions, and tools/list keeps trying the phone.
|
|
9
|
+
*
|
|
10
|
+
* Used twice:
|
|
11
|
+
* - daemon: one proxy Server per HTTP client session → phone client
|
|
12
|
+
* - stdio shim: one proxy Server on stdio → daemon client
|
|
13
|
+
*
|
|
14
|
+
* The proxy advertises exactly the backend's capabilities and instructions,
|
|
15
|
+
* so downstream agents get the same tool context at handshake as a direct
|
|
16
|
+
* connection would give them.
|
|
17
|
+
*/
|
|
18
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
19
|
+
import {
|
|
20
|
+
ListToolsRequestSchema,
|
|
21
|
+
CallToolRequestSchema,
|
|
22
|
+
ListPromptsRequestSchema,
|
|
23
|
+
GetPromptRequestSchema,
|
|
24
|
+
ListResourcesRequestSchema,
|
|
25
|
+
ListResourceTemplatesRequestSchema,
|
|
26
|
+
ReadResourceRequestSchema,
|
|
27
|
+
CompleteRequestSchema,
|
|
28
|
+
SetLevelRequestSchema,
|
|
29
|
+
ListToolsResultSchema,
|
|
30
|
+
CallToolResultSchema,
|
|
31
|
+
ListPromptsResultSchema,
|
|
32
|
+
GetPromptResultSchema,
|
|
33
|
+
ListResourcesResultSchema,
|
|
34
|
+
ListResourceTemplatesResultSchema,
|
|
35
|
+
ReadResourceResultSchema,
|
|
36
|
+
CompleteResultSchema,
|
|
37
|
+
EmptyResultSchema,
|
|
38
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
39
|
+
|
|
40
|
+
/** Device tools drive a real screen — give them a generous budget and let
|
|
41
|
+
* progress notifications keep the clock alive. */
|
|
42
|
+
const CALL_TOOL_TIMEOUT_MS = 120_000;
|
|
43
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Phone tools that never touch the screen, so need not wait behind the calls that do: get_task
|
|
47
|
+
* alone can wait 55 s for a task, and holding cancel_task behind it would defeat the point.
|
|
48
|
+
*/
|
|
49
|
+
const UNQUEUED_TOOLS = new Set(["get_task", "cancel_task", "device_info"]);
|
|
50
|
+
|
|
51
|
+
/** The request never reached the phone: the link was already down when it was sent. */
|
|
52
|
+
const NEVER_SENT = /not open|not connected/i;
|
|
53
|
+
/** The link dropped with the request in flight: it may or may not have run. */
|
|
54
|
+
const DROPPED = /connection closed/i;
|
|
55
|
+
|
|
56
|
+
/** [promise]'s value, or null if it takes longer than [ms]; its rejection still rejects. */
|
|
57
|
+
function within(promise, ms) {
|
|
58
|
+
if (!Number.isFinite(ms)) return promise;
|
|
59
|
+
let timer;
|
|
60
|
+
return Promise.race([promise, new Promise((resolve) => (timer = setTimeout(() => resolve(null), ms)))]).finally(() =>
|
|
61
|
+
clearTimeout(timer),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toolError(text) {
|
|
66
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} options
|
|
71
|
+
* @param {() => Promise<import("@modelcontextprotocol/sdk/client/index.js").Client>} options.getClient
|
|
72
|
+
* Resolves the live backend client (reconnecting if needed). Called per
|
|
73
|
+
* request so a phone reconnect is picked up transparently.
|
|
74
|
+
* @param {(fn: () => Promise<any>) => Promise<any>} [options.runExclusive]
|
|
75
|
+
* Serializer for tool calls — there is one physical screen, so concurrent
|
|
76
|
+
* tool calls from different sessions must queue, not interleave.
|
|
77
|
+
* @param {() => void} [options.onBackendAlive]
|
|
78
|
+
* Called after every successful backend round-trip — lets the connection
|
|
79
|
+
* manager treat real traffic as proof of liveness (fewer health pings).
|
|
80
|
+
* @param {string} options.name
|
|
81
|
+
* @param {string} options.version
|
|
82
|
+
* @param {Array<{definition: object, handler: (args: object) => Promise<object>, instructions?: string}>} [options.localTools]
|
|
83
|
+
* Tools served entirely by THIS process — never forwarded to the phone.
|
|
84
|
+
* Merged into tools/list alongside the backend's tools; tools/call for one
|
|
85
|
+
* of these names short-circuits before touching the phone connection at
|
|
86
|
+
* all. Used for machine-local capabilities (e.g. `adb`) that make no
|
|
87
|
+
* sense to relay over the WebRTC link. Pass only where the tool should
|
|
88
|
+
* actually execute (the daemon — see daemon.js); the stdio shim leaves this
|
|
89
|
+
* empty and transparently forwards such calls through to the daemon.
|
|
90
|
+
* An optional `instructions` string on a local tool is appended to the
|
|
91
|
+
* backend's handshake instructions, since the backend cannot describe a tool
|
|
92
|
+
* it never sees.
|
|
93
|
+
* @param {number} [options.connectWaitMs] how long a new session waits for the backend before
|
|
94
|
+
* opening with the local tools alone
|
|
95
|
+
* @param {number} [options.listWaitMs] in such a session, how long tools/list waits for it
|
|
96
|
+
*/
|
|
97
|
+
export async function buildProxyServer({
|
|
98
|
+
getClient,
|
|
99
|
+
runExclusive = (fn) => fn(),
|
|
100
|
+
onBackendAlive = () => {},
|
|
101
|
+
name,
|
|
102
|
+
version,
|
|
103
|
+
localTools = [],
|
|
104
|
+
connectWaitMs = Infinity,
|
|
105
|
+
listWaitMs = connectWaitMs,
|
|
106
|
+
}) {
|
|
107
|
+
const localToolsByName = new Map(localTools.map((t) => [t.definition.name, t]));
|
|
108
|
+
let backend = null;
|
|
109
|
+
let backendError = null;
|
|
110
|
+
try {
|
|
111
|
+
// The first connect can wait on a person tapping Allow on the phone, longer than clients
|
|
112
|
+
// wait for a server to start: past connectWaitMs the session opens without the phone, and
|
|
113
|
+
// the daemon announces its tools (tools/list_changed) once it connects.
|
|
114
|
+
backend = await within(getClient(), connectWaitMs);
|
|
115
|
+
if (!backend) throw new Error("Waiting for the phone: if it asks whether to allow this computer, tap Allow.");
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// With nothing of our own to serve, a session without the phone is no session.
|
|
118
|
+
if (localToolsByName.size === 0) throw e;
|
|
119
|
+
backendError = e;
|
|
120
|
+
}
|
|
121
|
+
const backendCaps = backend?.getServerCapabilities() ?? {};
|
|
122
|
+
|
|
123
|
+
// Local tools are invisible to the phone, so the phone's instructions can't
|
|
124
|
+
// mention them. Append their own guidance after it — a tool description is
|
|
125
|
+
// only read once a model is already picking a tool, whereas instructions
|
|
126
|
+
// shape strategy before the first call. Phone text stays first and unedited:
|
|
127
|
+
// it owns the safety doctrine, and these sections defer to it.
|
|
128
|
+
const unreachable = backendError
|
|
129
|
+
? `CodeDroid on the phone is not connected yet: ${String(backendError.message).split("\n")[0]} ` +
|
|
130
|
+
"Call codedroid_status for what to do; once it connects, its tools appear and codedroid_status " +
|
|
131
|
+
"returns the phone's own instructions: read them before driving the phone."
|
|
132
|
+
: null;
|
|
133
|
+
const instructions = [
|
|
134
|
+
backend?.getInstructions() ?? unreachable,
|
|
135
|
+
...[...localToolsByName.values()].map((t) => t.instructions).filter(Boolean),
|
|
136
|
+
]
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.join("\n\n")
|
|
139
|
+
.trim() || undefined;
|
|
140
|
+
|
|
141
|
+
// Only advertise what we actually forward (and what the phone declared).
|
|
142
|
+
const capabilities = {};
|
|
143
|
+
if (backendCaps.tools || localToolsByName.size > 0) {
|
|
144
|
+
// listChanged: the phone's tools can arrive after the session opened (see connectWaitMs).
|
|
145
|
+
capabilities.tools = { ...(backendCaps.tools ?? {}), listChanged: localToolsByName.size > 0 || !!backendCaps.tools?.listChanged };
|
|
146
|
+
}
|
|
147
|
+
if (backendCaps.prompts) capabilities.prompts = backendCaps.prompts;
|
|
148
|
+
if (backendCaps.resources) capabilities.resources = backendCaps.resources;
|
|
149
|
+
if (backendCaps.logging) capabilities.logging = backendCaps.logging;
|
|
150
|
+
if (backendCaps.completions) capabilities.completions = backendCaps.completions;
|
|
151
|
+
|
|
152
|
+
const server = new Server({ name, version }, { capabilities, instructions });
|
|
153
|
+
|
|
154
|
+
const forward = (requestSchema, resultSchema, options = {}) => {
|
|
155
|
+
server.setRequestHandler(requestSchema, async (request) => {
|
|
156
|
+
const call = async () => {
|
|
157
|
+
const client = await getClient();
|
|
158
|
+
return client.request(
|
|
159
|
+
{ method: request.method, params: request.params },
|
|
160
|
+
resultSchema,
|
|
161
|
+
{
|
|
162
|
+
timeout: options.timeout ?? DEFAULT_TIMEOUT_MS,
|
|
163
|
+
resetTimeoutOnProgress: true,
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
const run = () => (options.exclusive ? runExclusive(call) : call());
|
|
168
|
+
let result;
|
|
169
|
+
try {
|
|
170
|
+
result = await run();
|
|
171
|
+
} catch (e) {
|
|
172
|
+
// "Connection closed" = the request never completed against a link
|
|
173
|
+
// that is now provably dead; getClient() will reconnect. Retry once —
|
|
174
|
+
// but never for tool calls: a lost RESPONSE still moved real pixels,
|
|
175
|
+
// and replaying a tap is worse than surfacing the error.
|
|
176
|
+
const message = e?.message ?? "";
|
|
177
|
+
if (!(NEVER_SENT.test(message) || DROPPED.test(message)) || options.exclusive) throw e;
|
|
178
|
+
result = await run();
|
|
179
|
+
}
|
|
180
|
+
onBackendAlive();
|
|
181
|
+
return result;
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (backendCaps.tools || localToolsByName.size > 0) {
|
|
186
|
+
server.setRequestHandler(ListToolsRequestSchema, async (request) => {
|
|
187
|
+
const backendResult = backendCaps.tools || backendError
|
|
188
|
+
? await (async () => {
|
|
189
|
+
// A session that opened without the phone does not wait on it here either: the
|
|
190
|
+
// phone's tools follow in a tools/list_changed once it connects.
|
|
191
|
+
const client = backendError ? await within(getClient(), listWaitMs) : await getClient();
|
|
192
|
+
if (!client) return { tools: [] };
|
|
193
|
+
const result = await client.request(
|
|
194
|
+
{ method: request.method, params: request.params },
|
|
195
|
+
ListToolsResultSchema,
|
|
196
|
+
{ timeout: DEFAULT_TIMEOUT_MS, resetTimeoutOnProgress: true },
|
|
197
|
+
);
|
|
198
|
+
onBackendAlive();
|
|
199
|
+
return result;
|
|
200
|
+
})().catch((e) => {
|
|
201
|
+
// Without local tools a failed list is the client's to see.
|
|
202
|
+
if (localToolsByName.size === 0) throw e;
|
|
203
|
+
return { tools: [] };
|
|
204
|
+
})
|
|
205
|
+
: { tools: [] };
|
|
206
|
+
return {
|
|
207
|
+
...backendResult,
|
|
208
|
+
tools: [
|
|
209
|
+
...backendResult.tools,
|
|
210
|
+
...[...localToolsByName.values()].map((t) => t.definition),
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
216
|
+
// Local tools never touch the phone connection — no need for the
|
|
217
|
+
// exclusive queue, which exists only to serialize against the one
|
|
218
|
+
// physical screen.
|
|
219
|
+
const local = localToolsByName.get(request.params.name);
|
|
220
|
+
if (local) return local.handler(request.params.arguments ?? {});
|
|
221
|
+
|
|
222
|
+
// A failure comes back as a tool error, not a protocol one, so the model reads why. Only a
|
|
223
|
+
// call that never left is tried again: a tap whose answer was lost still happened, and
|
|
224
|
+
// replaying it is worse than saying so.
|
|
225
|
+
const call = async () => {
|
|
226
|
+
let client;
|
|
227
|
+
try {
|
|
228
|
+
client = await getClient();
|
|
229
|
+
} catch (e) {
|
|
230
|
+
return { unreachable: e };
|
|
231
|
+
}
|
|
232
|
+
return client.request(
|
|
233
|
+
{ method: request.method, params: request.params },
|
|
234
|
+
CallToolResultSchema,
|
|
235
|
+
{ timeout: CALL_TOOL_TIMEOUT_MS, resetTimeoutOnProgress: true },
|
|
236
|
+
);
|
|
237
|
+
};
|
|
238
|
+
const name = request.params.name;
|
|
239
|
+
const run = UNQUEUED_TOOLS.has(name) ? call : () => runExclusive(call);
|
|
240
|
+
let result;
|
|
241
|
+
for (let attempt = 0; ; attempt++) {
|
|
242
|
+
try {
|
|
243
|
+
result = await run();
|
|
244
|
+
break;
|
|
245
|
+
} catch (e) {
|
|
246
|
+
const message = e?.message ?? String(e);
|
|
247
|
+
if (NEVER_SENT.test(message) && attempt === 0) continue;
|
|
248
|
+
if (NEVER_SENT.test(message)) return toolError(`CodeDroid on the phone is not connected: ${message} Call codedroid_status.`);
|
|
249
|
+
return toolError(
|
|
250
|
+
`The link to the phone failed during ${name} (${message}). It may or may not have happened on the ` +
|
|
251
|
+
"phone: read the screen with get_screen before trying again. If calls keep failing, call codedroid_status.",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (result.unreachable) {
|
|
256
|
+
return toolError(`CodeDroid on the phone is not connected: ${result.unreachable.message}\nCall codedroid_status once it is fixed.`);
|
|
257
|
+
}
|
|
258
|
+
onBackendAlive();
|
|
259
|
+
return result;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (backendCaps.prompts) {
|
|
263
|
+
forward(ListPromptsRequestSchema, ListPromptsResultSchema);
|
|
264
|
+
forward(GetPromptRequestSchema, GetPromptResultSchema);
|
|
265
|
+
}
|
|
266
|
+
if (backendCaps.resources) {
|
|
267
|
+
forward(ListResourcesRequestSchema, ListResourcesResultSchema);
|
|
268
|
+
forward(ListResourceTemplatesRequestSchema, ListResourceTemplatesResultSchema);
|
|
269
|
+
forward(ReadResourceRequestSchema, ReadResourceResultSchema);
|
|
270
|
+
}
|
|
271
|
+
if (backendCaps.completions) {
|
|
272
|
+
forward(CompleteRequestSchema, CompleteResultSchema);
|
|
273
|
+
}
|
|
274
|
+
if (backendCaps.logging) {
|
|
275
|
+
forward(SetLevelRequestSchema, EmptyResultSchema);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return server;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Forward a notification from the backend to a set of live proxy servers.
|
|
283
|
+
* Sessions that can't take it (closing, capability mismatch) are skipped —
|
|
284
|
+
* a notification must never take a session down.
|
|
285
|
+
*/
|
|
286
|
+
export async function broadcastNotification(servers, notification) {
|
|
287
|
+
for (const server of servers) {
|
|
288
|
+
try {
|
|
289
|
+
await server.notification(notification);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
// Best-effort fan-out.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|