@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.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * adb — a single generic MCP tool that runs an adb command against a phone
3
+ * attached to THIS computer (USB, or Wireless Debugging).
4
+ *
5
+ * Forked from aura-mcp-connect's `aura-adb` (MIT, Copyright (c) 2026 Dinesh Kumar C;
6
+ * see LICENSE.aura-mcp-connect). Changed: on by default, since the user is a developer
7
+ * (CODEDROID_MCP_ADB=0 turns it off); marked destructive so clients ask before running
8
+ * it; a per-call timeout.
9
+ *
10
+ * It never touches the connection to CodeDroid: it shells out to this machine's
11
+ * own `adb`, exactly like a developer typing `adb install app.apk` themselves.
12
+ */
13
+ import { execFile } from "child_process";
14
+ import { promisify } from "util";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ export const MAX_OUTPUT_CHARS = 20_000;
19
+ const DEFAULT_TIMEOUT_S = 60;
20
+ const MAX_TIMEOUT_S = 900;
21
+
22
+ // adb's global options that take a value: `-s SERIAL`, `-t ID`, `-H host`, `-P port`, `-L socket`.
23
+ const VALUE_OPTIONS = new Set(["-s", "-t", "-H", "-P", "-L"]);
24
+ // Global options that pick the device, after which CODEDROID_SERIAL must not be added.
25
+ const DEVICE_OPTIONS = new Set(["-s", "-t", "-d", "-e"]);
26
+ // Subcommands whose rest is a command line for the phone's shell.
27
+ const SHELLS = new Set(["shell", "exec-out"]);
28
+
29
+ /**
30
+ * [command] as adb's argv. Up to the subcommand, and for host-side commands (install, push, pull),
31
+ * words split on whitespace and quotes group them, so a path with spaces stays one argument. After
32
+ * `shell` or `exec-out` (and its own -x style flags) the rest goes to the phone's shell as written,
33
+ * quotes, pipes and all, which is what that shell expects.
34
+ */
35
+ export function adbArgv(command) {
36
+ const text = command.trim();
37
+ const argv = [];
38
+ let subcommand = null;
39
+ let i = 0;
40
+ while (i < text.length) {
41
+ while (i < text.length && /\s/.test(text[i])) i++;
42
+ if (i >= text.length) break;
43
+ if (subcommand && SHELLS.has(subcommand) && !/^-[a-zA-Z]\s/.test(text.slice(i) + " ")) {
44
+ argv.push(text.slice(i).trim());
45
+ break;
46
+ }
47
+ let word = "";
48
+ let quote = null;
49
+ for (; i < text.length; i++) {
50
+ const c = text[i];
51
+ if (quote) {
52
+ if (c === quote) quote = null;
53
+ else word += c;
54
+ } else if (c === '"' || c === "'") {
55
+ quote = c;
56
+ } else if (/\s/.test(c)) {
57
+ break;
58
+ } else {
59
+ word += c;
60
+ }
61
+ }
62
+ if (!subcommand && !word.startsWith("-") && !VALUE_OPTIONS.has(argv[argv.length - 1])) subcommand = word;
63
+ argv.push(word);
64
+ }
65
+ return argv;
66
+ }
67
+
68
+ /** Whether [argv]'s own global options, before the subcommand, already pick the device. */
69
+ function namesDevice(argv) {
70
+ for (let i = 0; i < argv.length && argv[i].startsWith("-"); i++) {
71
+ if (DEVICE_OPTIONS.has(argv[i])) return true;
72
+ if (VALUE_OPTIONS.has(argv[i])) i++;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ function truncate(text) {
78
+ if (text.length <= MAX_OUTPUT_CHARS) return text;
79
+ const cut = text.length - MAX_OUTPUT_CHARS;
80
+ return `${text.slice(0, MAX_OUTPUT_CHARS)}\n…(truncated, ${cut} more chars)`;
81
+ }
82
+
83
+ /**
84
+ * What the handshake says about adb, appended to the phone's instructions. At most 400 bytes:
85
+ * Claude Code keeps about 2 KB of a server's instructions and the phone's take up to 1,600.
86
+ */
87
+ const INSTRUCTIONS =
88
+ "You also have `adb`: this computer's adb, for the phone when it is on USB or Wireless debugging. Use " +
89
+ "it to install builds, read logs (logcat -d), inspect state (dumpsys), start activities, and run " +
90
+ "instrumentation tests; use CodeDroid's tools to see and drive the screen. Never force-stop or " +
91
+ "instrument com.codedroid.app.";
92
+
93
+ /** The tool's full guidance: read right before calling it. */
94
+ const DESCRIPTION = `
95
+ Run an adb command against the phone attached to this computer. Pass what you would type after \`adb\`, e.g. \`install -r -g app/build/outputs/apk/debug/app-debug.apk\`, \`logcat -d -t 300 *:E\`, \`shell dumpsys activity activities\`. Runs this machine's adb, so the phone must be on it (USB or Wireless debugging): CODEDROID_SERIAL or ANDROID_SERIAL, or -s in the command, picks it when several devices are; otherwise adb's own default.
96
+
97
+ USEFUL:
98
+ - Install and start: \`install -r -g <apk>\`, \`shell am start -W -n <pkg>/<activity>\`, \`shell am force-stop <pkg>\`, \`shell pm clear <pkg>\`.
99
+ - Logs: clear with \`logcat -c\` before acting, then \`logcat -d -t 300\`; crashes only: \`logcat -d -b crash\`; one app: \`shell pidof <pkg>\` then \`logcat -d --pid=<pid>\`.
100
+ - Tests: \`shell am instrument -w -r <test-pkg>/androidx.test.runner.AndroidJUnitRunner\` (add \`-e class <Class>\`; set timeout_s); read the INSTRUMENTATION_STATUS lines.
101
+ - State: \`shell dumpsys window | grep mCurrentFocus\`, \`shell dumpsys package <pkg>\`, \`shell settings get global <key>\`.
102
+
103
+ RULES:
104
+ - On a phone with several users, \`settings\`, \`content\`, \`pm grant\` and \`appops\` need \`--user $(adb shell am get-current-user)\`; adb defaults to user 0.
105
+ - Do not force-stop, clear or instrument com.codedroid.app: it switches CodeDroid's accessibility off and its tools stop working.
106
+ - \`uiautomator dump\` and UiAutomator-based tests suspend CodeDroid's accessibility while they run; use get_screen to read the screen.
107
+ - Never stream: \`logcat\` without \`-d\`, \`screenrecord\` without \`--time-limit\` run until timeout_s.
108
+ - Ask the user before uninstalling, clearing data, rebooting or deleting files.
109
+ - Quotes group words for adb itself (\`install "my app.apk"\`); everything after \`shell\` goes to the phone's shell as written, which does quote, pipe and chain.
110
+ - Output is cut at 20,000 characters, keeping the start: filter on the phone with grep or head.
111
+ `.trim();
112
+
113
+ /**
114
+ * Build the tool.
115
+ *
116
+ * The `run`/`env` seams exist so tests can drive the handler without a real
117
+ * adb on PATH: `promisify(execFile)` is captured at module load, so an ESM
118
+ * test can never monkeypatch it after the fact. Mirrors the injection style
119
+ * of `PhoneManager({ transportFactory })` elsewhere in this package.
120
+ *
121
+ * @param {{
122
+ * run?: (file: string, args: string[], opts: object) => Promise<{stdout: string, stderr: string}>,
123
+ * env?: Record<string, string | undefined>,
124
+ * }} [deps]
125
+ */
126
+ export function createAdbTool({ run = execFileAsync, env = process.env } = {}) {
127
+ return {
128
+ /** Merged into the handshake instructions by buildProxyServer. */
129
+ instructions: INSTRUCTIONS,
130
+ definition: {
131
+ name: "adb",
132
+ title: "Run adb",
133
+ description: DESCRIPTION,
134
+ inputSchema: {
135
+ type: "object",
136
+ properties: {
137
+ command: {
138
+ type: "string",
139
+ description: "Arguments to pass to adb, e.g. 'shell pm list packages -3'",
140
+ },
141
+ timeout_s: {
142
+ type: "number",
143
+ description: `Seconds before it is stopped (default ${DEFAULT_TIMEOUT_S}, max ${MAX_TIMEOUT_S})`,
144
+ },
145
+ },
146
+ required: ["command"],
147
+ },
148
+ annotations: { title: "Run adb", readOnlyHint: false, destructiveHint: true, openWorldHint: false },
149
+ },
150
+
151
+ /** @param {{command?: string, timeout_s?: number}} args */
152
+ async handler(args) {
153
+ const command = args?.command;
154
+ if (typeof command !== "string" || !command.trim()) {
155
+ return {
156
+ content: [{ type: "text", text: "adb: 'command' must be a non-empty string" }],
157
+ isError: true,
158
+ };
159
+ }
160
+
161
+ // execFile — NOT exec()/shell:true — so metacharacters in `command`
162
+ // (`;`, `&&`, backticks, `|`) reach adb as literal argv entries instead of
163
+ // being interpreted by a shell ON THIS MACHINE. This is a generic *adb*
164
+ // passthrough, deliberately not a generic host-shell passthrough.
165
+ //
166
+ // It does NOT follow that the phone sees them literally: `adb shell X; Y`
167
+ // hands "X; Y" to the *device's* shell, which does chain (verified on
168
+ // device 2026-08-06 — `shell echo hi; touch /sdcard/PWNED` created the
169
+ // file). That is inherent to `adb shell` and expected for a dev tool; the
170
+ // boundary this protects is the host, not the phone.
171
+ const argv = adbArgv(command);
172
+ const serial = namesDevice(argv) ? null : env.CODEDROID_SERIAL || env.ANDROID_SERIAL;
173
+ const timeoutS = Math.min(Math.max(Number(args?.timeout_s) || DEFAULT_TIMEOUT_S, 1), MAX_TIMEOUT_S);
174
+ const fullArgv = serial ? ["-s", serial, ...argv] : argv;
175
+
176
+ try {
177
+ const { stdout, stderr } = await run(env.ADB || "adb", fullArgv, {
178
+ timeout: timeoutS * 1000,
179
+ maxBuffer: 10 * 1024 * 1024,
180
+ });
181
+ const text = [stdout, stderr].filter(Boolean).join("\n").trim() || "(no output)";
182
+ return { content: [{ type: "text", text: truncate(text) }] };
183
+ } catch (e) {
184
+ // Never let a failed adb invocation escape past the MCP boundary — the
185
+ // model can act on "device unauthorized" text, but not on a dead session.
186
+ const text =
187
+ e?.code === "ENOENT"
188
+ ? "adb was not found on this computer. Install Android platform-tools " +
189
+ "(https://developer.android.com/tools/releases/platform-tools) or set ADB to its path."
190
+ : [e.stdout, e.stderr, e.message].filter(Boolean).join("\n").trim();
191
+ return {
192
+ content: [{ type: "text", text: truncate(text || String(e)) }],
193
+ isError: true,
194
+ };
195
+ }
196
+ },
197
+ };
198
+ }
199
+
200
+ export const adbTool = createAdbTool();
@@ -0,0 +1,30 @@
1
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2
+
3
+ class DaemonTransport extends StreamableHTTPClientTransport {
4
+ async close() {
5
+ // The SDK's close() only aborts HTTP streams. DELETE is what releases the daemon's
6
+ // session, including its proxy server, when a CLI call or stdio client is finished.
7
+ let timer;
8
+ try {
9
+ await Promise.race([
10
+ this.terminateSession(),
11
+ new Promise((resolve) => { timer = setTimeout(resolve, 1500); }),
12
+ ]);
13
+ } catch {
14
+ // The daemon may already be gone; close the local transport either way.
15
+ } finally {
16
+ clearTimeout(timer);
17
+ await super.close();
18
+ }
19
+ }
20
+ }
21
+
22
+ /** A request owns its localhost connection: a long get_task must not hold cancel_task or
23
+ * another session behind it in the HTTP connection pool (reproduced with Node 26's fetch).
24
+ * MCP sessions still live across requests; only the underlying TCP connection is released.
25
+ */
26
+ export function daemonTransport(port) {
27
+ return new DaemonTransport(new URL(`http://127.0.0.1:${port}/mcp`), {
28
+ requestInit: { headers: { Connection: "close" } },
29
+ });
30
+ }
package/src/daemon.js ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * daemon — ONE process on this computer that owns the single link to
3
+ * CodeDroid on the phone (through the CodeDroid server's relay) and serves MCP over
4
+ * Streamable HTTP on localhost.
5
+ *
6
+ * Forked from aura-mcp-connect (MIT, Copyright (c) 2026 Dinesh Kumar C; see
7
+ * LICENSE.aura-mcp-connect): the link is a WebSocket relay instead of WebRTC, and the adb tool is
8
+ * on by default.
9
+ *
10
+ * Every MCP client (Claude Code, Claude Desktop, Cursor, VS Code windows…)
11
+ * connects to http://127.0.0.1:<port>/mcp and gets its own MCP session; tool
12
+ * calls are serialized onto the one phone connection. This replaces the old
13
+ * model where each client spawned its own bridge and stole the phone from the
14
+ * previous one ("newest wins" tug-of-war).
15
+ *
16
+ * Endpoints:
17
+ * POST/GET/DELETE /mcp Streamable HTTP MCP (per-session)
18
+ * GET /health {ok, version, phone, device, error, sessions, adbToolEnabled}
19
+ */
20
+ import http from "http";
21
+ import { randomUUID } from "crypto";
22
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
23
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
25
+ import { adbTool } from "./adb-tool.js";
26
+ import { buildProxyServer, broadcastNotification } from "./proxy-core.js";
27
+ import { RelayPhoneTransport } from "./relay-transport.js";
28
+ import { createStatusTool } from "./status-tool.js";
29
+ import { VERSION } from "./version.js";
30
+
31
+ export const DEFAULT_PORT = 4826;
32
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
33
+
34
+ /** How long a link stays trusted after its last proven round-trip. Past
35
+ * this, getClient() pings before reuse (a phone app restart kills the link
36
+ * without any close event — see 2026-07-16 stale-connection incident). */
37
+ const LIVENESS_MAX_AGE_MS = 20_000;
38
+ const PING_TIMEOUT_MS = 5_000;
39
+ /**
40
+ * How long a new session waits for the phone before opening without it (see proxy-core). A phone
41
+ * that already allowed this computer connects in about a second; Codex gives a server 10 s to start.
42
+ */
43
+ const CONNECT_WAIT_MS = 5_000;
44
+ /** In a session that opened without the phone, how long tools/list waits for it: a ping's worth. */
45
+ const LIST_WAIT_MS = 2_000;
46
+
47
+ /** Owns the single connection to the phone: single-flight connect, exclusive
48
+ * tool-call queue, reconnect-on-demand after a drop. */
49
+ export class PhoneManager {
50
+ constructor({
51
+ transportFactory,
52
+ log = () => {},
53
+ livenessMaxAgeMs = LIVENESS_MAX_AGE_MS,
54
+ pingTimeoutMs = PING_TIMEOUT_MS,
55
+ } = {}) {
56
+ this._transportFactory =
57
+ transportFactory ?? (() => new RelayPhoneTransport({ log }));
58
+ this._log = log;
59
+ this._livenessMaxAgeMs = livenessMaxAgeMs;
60
+ this._pingTimeoutMs = pingTimeoutMs;
61
+ this._client = null;
62
+ this._connectPromise = null;
63
+ this._queueTail = Promise.resolve();
64
+ this._closed = false;
65
+ this._lastAliveAt = 0;
66
+ this.state = "disconnected";
67
+ /** Why the last connect failed, in words for the user; null once connected. */
68
+ this.lastError = null;
69
+ /** {id, model, server} of the phone, once connected. */
70
+ this.device = null;
71
+ /** Set by the daemon to fan phone notifications out to sessions. */
72
+ this.onNotification = null;
73
+ /** Set by the daemon: the phone (re)connected, so its tools may be new to a session. */
74
+ this.onConnected = null;
75
+ }
76
+
77
+ async getClient() {
78
+ if (this._closed) throw new Error("Daemon is shutting down");
79
+ if (this._client) {
80
+ // A phone app restart or network flip can kill the link without any
81
+ // close event — the cached client then looks healthy while every
82
+ // forwarded request hangs. Verify liveness with a cheap MCP ping
83
+ // when the link hasn't been proven alive recently.
84
+ if (Date.now() - this._lastAliveAt <= this._livenessMaxAgeMs) return this._client;
85
+ try {
86
+ await this._client.ping({ timeout: this._pingTimeoutMs });
87
+ this._lastAliveAt = Date.now();
88
+ return this._client;
89
+ } catch (e) {
90
+ this._log("Cached phone connection is dead (ping failed) — reconnecting");
91
+ const dead = this._client;
92
+ this._client = null;
93
+ this.state = "disconnected";
94
+ try {
95
+ await dead.close();
96
+ } catch (closeErr) {
97
+ /* it's already gone */
98
+ }
99
+ }
100
+ }
101
+ if (!this._connectPromise) {
102
+ this._connectPromise = this._connect().finally(() => {
103
+ this._connectPromise = null;
104
+ });
105
+ }
106
+ return this._connectPromise;
107
+ }
108
+
109
+ /** Every successful round-trip proves the link — cheaper than extra pings. */
110
+ markAlive() {
111
+ this._lastAliveAt = Date.now();
112
+ }
113
+
114
+ async _connect() {
115
+ this.state = "connecting";
116
+ this._log("Connecting to phone...");
117
+ let transport;
118
+ try {
119
+ transport = await this._transportFactory();
120
+ } catch (e) {
121
+ this.state = "disconnected";
122
+ this.lastError = e.message;
123
+ throw e;
124
+ }
125
+ const client = new Client({ name: "codedroid-mcp-daemon", version: VERSION });
126
+ client.fallbackNotificationHandler = async (notification) => {
127
+ await this.onNotification?.(notification);
128
+ };
129
+ try {
130
+ // Generous initialize budget: the very first connect can include a
131
+ // human tapping Approve on the phone.
132
+ await client.connect(transport, { timeout: 130_000 });
133
+ } catch (e) {
134
+ this.state = "disconnected";
135
+ this.lastError = e.message;
136
+ throw e;
137
+ }
138
+ client.onclose = () => {
139
+ if (this._client === client) {
140
+ this._client = null;
141
+ this.state = "disconnected";
142
+ this._log("Phone connection closed — will reconnect on next request");
143
+ }
144
+ };
145
+ this._client = client;
146
+ this._lastAliveAt = Date.now();
147
+ this.state = "connected";
148
+ this.lastError = null;
149
+ this.device = transport.device ?? null;
150
+ this._log("Phone connected");
151
+ this.onConnected?.();
152
+ return client;
153
+ }
154
+
155
+ /** Serialize work against the single physical screen. */
156
+ runExclusive(fn) {
157
+ const run = this._queueTail.then(fn, fn);
158
+ // Keep the chain alive whether fn resolved or rejected.
159
+ this._queueTail = run.then(
160
+ () => undefined,
161
+ () => undefined,
162
+ );
163
+ return run;
164
+ }
165
+
166
+ async close() {
167
+ this._closed = true;
168
+ const client = this._client;
169
+ this._client = null;
170
+ this.state = "disconnected";
171
+ if (client) {
172
+ try {
173
+ await client.close();
174
+ } catch (e) {
175
+ /* already gone */
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ function isLoopbackHost(hostHeader) {
182
+ if (!hostHeader) return false;
183
+ // Strip port; tolerate IPv6 brackets.
184
+ const host = hostHeader.replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
185
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
186
+ }
187
+
188
+ function isAllowedOrigin(origin) {
189
+ if (origin === undefined || origin === "null") return true; // non-browser clients
190
+ try {
191
+ const url = new URL(origin);
192
+ return isLoopbackHost(url.host);
193
+ } catch (e) {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ function readJsonBody(req) {
199
+ return new Promise((resolve, reject) => {
200
+ const chunks = [];
201
+ let size = 0;
202
+ req.on("data", (chunk) => {
203
+ size += chunk.length;
204
+ if (size > MAX_BODY_BYTES) {
205
+ reject(new Error("Request body too large"));
206
+ req.destroy();
207
+ return;
208
+ }
209
+ chunks.push(chunk);
210
+ });
211
+ req.on("end", () => {
212
+ try {
213
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
214
+ } catch (e) {
215
+ reject(new Error("Invalid JSON body"));
216
+ }
217
+ });
218
+ req.on("error", reject);
219
+ });
220
+ }
221
+
222
+ function sendJson(res, status, body) {
223
+ res.writeHead(status, { "Content-Type": "application/json" });
224
+ res.end(JSON.stringify(body));
225
+ }
226
+
227
+ function jsonRpcError(res, status, id, code, message) {
228
+ sendJson(res, status, {
229
+ jsonrpc: "2.0",
230
+ error: { code, message },
231
+ id: id ?? null,
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Start the daemon.
237
+ *
238
+ * @param {object} [options]
239
+ * @param {number} [options.port] 0 = ephemeral (tests)
240
+ * @param {string} [options.host]
241
+ * @param {() => Promise<import("@modelcontextprotocol/sdk/shared/transport.js").Transport> | import("@modelcontextprotocol/sdk/shared/transport.js").Transport} [options.phoneTransportFactory]
242
+ * @param {(msg: string) => void} [options.log]
243
+ * @param {boolean} [options.enableAdbTool] Serve the host-side `adb` tool. On by
244
+ * default: the user is a developer. CODEDROID_MCP_ADB=0 turns it off (index.js).
245
+ * @returns {Promise<{port: number, close: () => Promise<void>, phoneManager: PhoneManager}>}
246
+ */
247
+ export async function startDaemon({
248
+ port = DEFAULT_PORT,
249
+ host = "127.0.0.1",
250
+ phoneTransportFactory,
251
+ log = () => {},
252
+ enableAdbTool = true,
253
+ } = {}) {
254
+ const phoneManager = new PhoneManager({
255
+ transportFactory: phoneTransportFactory,
256
+ log,
257
+ });
258
+ /** sessionId → { transport, server } */
259
+ const sessions = new Map();
260
+
261
+ phoneManager.onNotification = (notification) =>
262
+ broadcastNotification(
263
+ [...sessions.values()].map((s) => s.server),
264
+ notification,
265
+ );
266
+ phoneManager.onConnected = () =>
267
+ broadcastNotification(
268
+ [...sessions.values()].map((s) => s.server),
269
+ { method: "notifications/tools/list_changed" },
270
+ );
271
+
272
+ const httpServer = http.createServer(async (req, res) => {
273
+ try {
274
+ // Local-only surface: reject DNS-rebinding / cross-origin browser abuse.
275
+ if (!isLoopbackHost(req.headers.host) || !isAllowedOrigin(req.headers.origin)) {
276
+ sendJson(res, 403, { error: "Forbidden: localhost access only" });
277
+ return;
278
+ }
279
+
280
+ const url = new URL(req.url, `http://${req.headers.host}`);
281
+
282
+ if (url.pathname === "/health" && req.method === "GET") {
283
+ sendJson(res, 200, {
284
+ ok: true,
285
+ service: "codedroid-mcp-daemon",
286
+ version: VERSION,
287
+ phone: phoneManager.state,
288
+ device: phoneManager.device,
289
+ error: phoneManager.lastError,
290
+ sessions: sessions.size,
291
+ adbToolEnabled: enableAdbTool,
292
+ });
293
+ return;
294
+ }
295
+
296
+ if (url.pathname === "/shutdown" && req.method === "POST") {
297
+ sendJson(res, 200, { ok: true });
298
+ setImmediate(async () => {
299
+ await close();
300
+ process.exit(0);
301
+ });
302
+ return;
303
+ }
304
+
305
+ if (url.pathname !== "/mcp") {
306
+ sendJson(res, 404, { error: "Not found" });
307
+ return;
308
+ }
309
+
310
+ const sessionId = req.headers["mcp-session-id"];
311
+
312
+ if (req.method === "POST") {
313
+ let body;
314
+ try {
315
+ body = await readJsonBody(req);
316
+ } catch (e) {
317
+ jsonRpcError(res, 400, null, -32700, e.message);
318
+ return;
319
+ }
320
+
321
+ // Existing session → route to its transport.
322
+ if (sessionId && sessions.has(sessionId)) {
323
+ await sessions.get(sessionId).transport.handleRequest(req, res, body);
324
+ return;
325
+ }
326
+
327
+ // New session — must be an initialize request.
328
+ if (!sessionId && isInitializeRequest(body)) {
329
+ let server;
330
+ try {
331
+ server = await buildProxyServer({
332
+ getClient: () => phoneManager.getClient(),
333
+ runExclusive: (fn) => phoneManager.runExclusive(fn),
334
+ onBackendAlive: () => phoneManager.markAlive(),
335
+ name: "codedroid (via daemon)",
336
+ version: VERSION,
337
+ // Executes on THIS machine (wherever the daemon runs), not the
338
+ // phone — see adb-tool.js. The stdio shim builds its own proxy
339
+ // server without these, so it transparently forwards their
340
+ // calls to here rather than double-registering them.
341
+ localTools: [createStatusTool(phoneManager), ...(enableAdbTool ? [adbTool] : [])],
342
+ connectWaitMs: CONNECT_WAIT_MS,
343
+ listWaitMs: LIST_WAIT_MS,
344
+ });
345
+ } catch (e) {
346
+ log(`Session init failed: ${e.message}`);
347
+ jsonRpcError(res, 503, body.id, -32001, e.message);
348
+ return;
349
+ }
350
+
351
+ const transport = new StreamableHTTPServerTransport({
352
+ sessionIdGenerator: () => randomUUID(),
353
+ onsessioninitialized: (id) => {
354
+ sessions.set(id, { transport, server });
355
+ log(`Session ${id.slice(0, 8)}… opened (${sessions.size} active)`);
356
+ },
357
+ });
358
+ transport.onclose = () => {
359
+ const id = transport.sessionId;
360
+ if (id && sessions.delete(id)) {
361
+ log(`Session ${id.slice(0, 8)}… closed (${sessions.size} active)`);
362
+ }
363
+ };
364
+
365
+ await server.connect(transport);
366
+ await transport.handleRequest(req, res, body);
367
+ return;
368
+ }
369
+
370
+ jsonRpcError(
371
+ res,
372
+ 400,
373
+ body?.id,
374
+ -32000,
375
+ "Bad Request: unknown session (send initialize without mcp-session-id to start one)",
376
+ );
377
+ return;
378
+ }
379
+
380
+ if (req.method === "GET" || req.method === "DELETE") {
381
+ if (!sessionId || !sessions.has(sessionId)) {
382
+ jsonRpcError(res, 400, null, -32000, "Bad Request: no valid session");
383
+ return;
384
+ }
385
+ await sessions.get(sessionId).transport.handleRequest(req, res);
386
+ return;
387
+ }
388
+
389
+ sendJson(res, 405, { error: "Method not allowed" });
390
+ } catch (e) {
391
+ log(`HTTP handler error: ${e.stack || e.message}`);
392
+ if (!res.headersSent) {
393
+ jsonRpcError(res, 500, null, -32603, `Internal error: ${e.message}`);
394
+ } else {
395
+ res.end();
396
+ }
397
+ }
398
+ });
399
+
400
+ await new Promise((resolve, reject) => {
401
+ httpServer.once("error", reject);
402
+ httpServer.listen(port, host, resolve);
403
+ });
404
+ const boundPort = httpServer.address().port;
405
+ log(`CodeDroid MCP daemon listening on http://${host}:${boundPort}/mcp`);
406
+ if (enableAdbTool) log("The adb tool is on: connected MCP clients can run adb commands on this machine.");
407
+
408
+ const close = async () => {
409
+ for (const { transport } of sessions.values()) {
410
+ try {
411
+ await transport.close();
412
+ } catch (e) {
413
+ /* best-effort */
414
+ }
415
+ }
416
+ sessions.clear();
417
+ await phoneManager.close();
418
+ await new Promise((resolve) => httpServer.close(resolve));
419
+ };
420
+
421
+ return { port: boundPort, close, phoneManager };
422
+ }