@codedroid/mcp 0.0.0-stage → 0.1.0

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/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
+ }