@zhihand/mcp 0.16.0 → 0.17.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.
@@ -9,5 +9,5 @@ export interface DispatchResult {
9
9
  * when the child has exited (or immediately if no child).
10
10
  */
11
11
  export declare function killActiveChild(): Promise<void>;
12
- export declare function dispatchToCLI(backend: Exclude<BackendName, "openclaw">, prompt: string, model?: string): Promise<DispatchResult>;
12
+ export declare function dispatchToCLI(backend: Exclude<BackendName, "openclaw">, prompt: string, log: (msg: string) => void, model?: string): Promise<DispatchResult>;
13
13
  export declare function postReply(config: ZhiHandConfig, promptId: string, text: string): Promise<boolean>;
@@ -1,31 +1,310 @@
1
1
  import { spawn } from "node:child_process";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { fileURLToPath } from "node:url";
2
6
  const CLI_TIMEOUT = 120_000; // 120s
3
7
  const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
4
8
  const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB
5
- const BACKENDS = {
6
- gemini: {
7
- command: "gemini",
8
- buildArgs: (prompt, model) => [
9
- "--approval-mode", "yolo",
10
- "--model", model ?? process.env.CLAUDE_GEMINI_MODEL ?? "gemini-3.1-pro-preview",
11
- "-i", prompt,
12
- ],
13
- env: {
14
- GEMINI_SANDBOX: "false",
15
- TERM: "xterm-256color",
16
- COLORTERM: "truecolor",
17
- },
18
- },
19
- claudecode: {
20
- command: "claude",
21
- buildArgs: (prompt) => ["-p", prompt, "--output-format", "json"],
22
- },
23
- codex: {
24
- command: "codex",
25
- buildArgs: (prompt) => ["-q", prompt, "--json"],
26
- },
27
- };
9
+ // Gemini session file polling
10
+ const SESSION_POLL_INTERVAL = 1_000; // 1s
11
+ const SESSION_STABILITY_DELAY = 2_000; // wait 2s after outcome before returning
12
+ // Resolve pty-wrap.py relative to this file (works from both src/ and dist/)
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const PTY_WRAP_SCRIPT = path.resolve(__dirname, "../../scripts/pty-wrap.py");
15
+ // Gemini session directories
16
+ const GEMINI_TMP_DIR = path.join(os.homedir(), ".gemini", "tmp");
28
17
  let activeChild = null;
18
+ // ── Gemini Session File Monitoring ─────────────────────────
19
+ /** Safely read and parse a JSON file (single attempt, async). */
20
+ async function loadJsonFile(filePath) {
21
+ try {
22
+ const raw = await fsp.readFile(filePath, "utf8");
23
+ const parsed = JSON.parse(raw);
24
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
25
+ }
26
+ catch {
27
+ // File locked or partial write — next poll cycle will retry
28
+ return null;
29
+ }
30
+ }
31
+ /** Extract text content from a gemini session message. */
32
+ function extractMessageText(message) {
33
+ const content = message.content;
34
+ if (typeof content === "string")
35
+ return content;
36
+ if (Array.isArray(content)) {
37
+ return content
38
+ .map((item) => {
39
+ if (typeof item === "string")
40
+ return item;
41
+ if (typeof item === "object" && item !== null) {
42
+ const obj = item;
43
+ if (typeof obj.text === "string")
44
+ return obj.text;
45
+ if (typeof obj.output === "string")
46
+ return obj.output;
47
+ }
48
+ return "";
49
+ })
50
+ .join("");
51
+ }
52
+ if (typeof content === "object" && content !== null) {
53
+ const obj = content;
54
+ if (typeof obj.text === "string")
55
+ return obj.text;
56
+ }
57
+ // Fallback to displayContent
58
+ const display = message.displayContent;
59
+ if (typeof display === "string")
60
+ return display;
61
+ return "";
62
+ }
63
+ /** Check if a message has active (non-terminal) tool calls. */
64
+ function hasActiveToolCalls(message) {
65
+ if (String(message.type ?? "").trim() !== "gemini")
66
+ return false;
67
+ const toolCalls = message.toolCalls;
68
+ if (!Array.isArray(toolCalls))
69
+ return false;
70
+ const terminalStatuses = new Set(["completed", "cancelled", "errored", "failed"]);
71
+ for (const tc of toolCalls) {
72
+ if (typeof tc !== "object" || tc === null)
73
+ continue;
74
+ const status = String(tc.status ?? "").trim().toLowerCase();
75
+ if (status && !terminalStatuses.has(status))
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ /**
81
+ * Check session messages for completion.
82
+ * Returns [status, text] or null if still in progress.
83
+ */
84
+ function checkSessionOutcome(messages) {
85
+ if (messages.length === 0)
86
+ return null;
87
+ // Get the latest turn messages (trailing messages from last user input)
88
+ const trailing = [];
89
+ for (let i = messages.length - 1; i >= 0; i--) {
90
+ const msg = messages[i];
91
+ if (String(msg.type ?? "").trim() === "user")
92
+ break;
93
+ trailing.unshift(msg);
94
+ }
95
+ if (trailing.length === 0)
96
+ return null;
97
+ // If any message has active tool calls, still in progress
98
+ for (const msg of trailing) {
99
+ if (hasActiveToolCalls(msg))
100
+ return null;
101
+ }
102
+ // Check from last message backwards for a result
103
+ for (let i = trailing.length - 1; i >= 0; i--) {
104
+ const msg = trailing[i];
105
+ const msgType = String(msg.type ?? "").trim();
106
+ // Error/warning/info messages
107
+ if (["error", "warning", "info"].includes(msgType)) {
108
+ const text = extractMessageText(msg).trim();
109
+ if (text)
110
+ return ["error", text];
111
+ }
112
+ // Gemini response message
113
+ if (msgType === "gemini") {
114
+ const text = extractMessageText(msg).trim();
115
+ if (text)
116
+ return ["success", text];
117
+ if (hasActiveToolCalls(msg))
118
+ return null;
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ /**
124
+ * Find the most recently created session file in the gemini tmp directory
125
+ * that was created after `afterTime`. Validates that the session contains
126
+ * our prompt text to avoid picking up unrelated gemini sessions.
127
+ */
128
+ async function findLatestSessionFile(afterTime, promptText) {
129
+ try {
130
+ const entries = await fsp.readdir(GEMINI_TMP_DIR, { withFileTypes: true });
131
+ const candidates = [];
132
+ for (const entry of entries) {
133
+ if (!entry.isDirectory())
134
+ continue;
135
+ const chatsDir = path.join(GEMINI_TMP_DIR, entry.name, "chats");
136
+ try {
137
+ await fsp.access(chatsDir);
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ const chatFiles = await fsp.readdir(chatsDir);
143
+ for (const f of chatFiles) {
144
+ if (!f.startsWith("session-") || !f.endsWith(".json"))
145
+ continue;
146
+ const fullPath = path.join(chatsDir, f);
147
+ const stat = await fsp.stat(fullPath);
148
+ if (stat.mtimeMs > afterTime) {
149
+ candidates.push({ path: fullPath, mtime: stat.mtimeMs });
150
+ }
151
+ }
152
+ }
153
+ // Sort newest first, then validate content matches our prompt
154
+ candidates.sort((a, b) => b.mtime - a.mtime);
155
+ const promptPrefix = promptText.slice(0, 50);
156
+ for (const candidate of candidates) {
157
+ const data = await loadJsonFile(candidate.path);
158
+ if (!data || !Array.isArray(data.messages))
159
+ continue;
160
+ // Check first user message matches our prompt
161
+ for (const msg of data.messages) {
162
+ if (String(msg.type ?? "").trim() !== "user")
163
+ continue;
164
+ const text = extractMessageText(msg);
165
+ if (text.startsWith(promptPrefix))
166
+ return candidate.path;
167
+ break; // Only check first user message
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ /**
177
+ * Poll gemini session files for the response.
178
+ * Returns the final text when gemini completes, or null on timeout.
179
+ */
180
+ function pollGeminiSession(child, startTime, promptText, log) {
181
+ return new Promise((resolve) => {
182
+ let sessionFile = null;
183
+ let outcomeAt = null;
184
+ let finalResult = null;
185
+ let settled = false;
186
+ let pollTimeout = null;
187
+ function settle(result) {
188
+ if (settled)
189
+ return;
190
+ settled = true;
191
+ if (pollTimeout)
192
+ clearTimeout(pollTimeout);
193
+ // Kill the gemini process now that we have the answer
194
+ closeChild(child);
195
+ resolve(result);
196
+ }
197
+ async function poll() {
198
+ if (settled)
199
+ return;
200
+ const elapsed = Date.now() - startTime;
201
+ // Timeout
202
+ if (elapsed > CLI_TIMEOUT) {
203
+ closeChild(child);
204
+ settle({
205
+ text: "Gemini timed out after 120s.",
206
+ success: false,
207
+ durationMs: elapsed,
208
+ });
209
+ return;
210
+ }
211
+ // Find session file if not yet found
212
+ if (!sessionFile) {
213
+ sessionFile = await findLatestSessionFile(startTime, promptText);
214
+ if (sessionFile) {
215
+ log(`[gemini] Session file found: ${path.basename(sessionFile)}`);
216
+ }
217
+ schedulePoll();
218
+ return;
219
+ }
220
+ // Read session file and check for outcome
221
+ const conversation = await loadJsonFile(sessionFile);
222
+ if (!conversation) {
223
+ schedulePoll();
224
+ return;
225
+ }
226
+ const messages = conversation.messages;
227
+ if (!Array.isArray(messages)) {
228
+ schedulePoll();
229
+ return;
230
+ }
231
+ const outcome = checkSessionOutcome(messages);
232
+ if (!outcome) {
233
+ // Still in progress, reset stability timer
234
+ outcomeAt = null;
235
+ finalResult = null;
236
+ schedulePoll();
237
+ return;
238
+ }
239
+ // Outcome detected — wait for stability (2s) before returning
240
+ if (!outcomeAt) {
241
+ outcomeAt = Date.now();
242
+ finalResult = outcome;
243
+ schedulePoll();
244
+ return;
245
+ }
246
+ if (Date.now() - outcomeAt >= SESSION_STABILITY_DELAY) {
247
+ const [status, text] = finalResult ?? outcome;
248
+ settle({
249
+ text,
250
+ success: status === "success",
251
+ durationMs: Date.now() - startTime,
252
+ });
253
+ }
254
+ else {
255
+ schedulePoll();
256
+ }
257
+ }
258
+ function schedulePoll() {
259
+ if (settled)
260
+ return;
261
+ pollTimeout = setTimeout(() => { poll(); }, SESSION_POLL_INTERVAL);
262
+ }
263
+ // Start polling
264
+ schedulePoll();
265
+ // Also handle process exit (in case it crashes before producing session file)
266
+ child.on("close", (code) => {
267
+ if (settled)
268
+ return;
269
+ // Give a final chance to read the session file
270
+ setTimeout(async () => {
271
+ if (settled)
272
+ return;
273
+ if (sessionFile) {
274
+ const conversation = await loadJsonFile(sessionFile);
275
+ if (conversation && Array.isArray(conversation.messages)) {
276
+ const outcome = checkSessionOutcome(conversation.messages);
277
+ if (outcome) {
278
+ settle({
279
+ text: outcome[1],
280
+ success: outcome[0] === "success",
281
+ durationMs: Date.now() - startTime,
282
+ });
283
+ return;
284
+ }
285
+ }
286
+ }
287
+ settle({
288
+ text: `Gemini process exited with code ${code} before producing a response.`,
289
+ success: false,
290
+ durationMs: Date.now() - startTime,
291
+ });
292
+ }, 500);
293
+ });
294
+ });
295
+ }
296
+ /** Gracefully close a child process: EOF → SIGTERM → SIGKILL. */
297
+ function closeChild(child) {
298
+ if (child.killed || child.exitCode !== null)
299
+ return;
300
+ // Try SIGTERM first
301
+ child.kill("SIGTERM");
302
+ setTimeout(() => {
303
+ if (!child.killed && child.exitCode === null) {
304
+ child.kill("SIGKILL");
305
+ }
306
+ }, SIGKILL_DELAY);
307
+ }
29
308
  /**
30
309
  * Kill the active child process. Returns a promise that resolves
31
310
  * when the child has exited (or immediately if no child).
@@ -37,28 +316,140 @@ export function killActiveChild() {
37
316
  return new Promise((resolve) => {
38
317
  const child = activeChild;
39
318
  child.once("close", () => resolve());
40
- child.kill("SIGTERM");
41
- setTimeout(() => {
42
- if (!child.killed) {
43
- child.kill("SIGKILL");
44
- }
45
- }, SIGKILL_DELAY);
319
+ closeChild(child);
46
320
  // Safety: resolve after SIGKILL_DELAY + 1s even if no close event
47
321
  setTimeout(() => resolve(), SIGKILL_DELAY + 1000);
48
322
  });
49
323
  }
50
- export function dispatchToCLI(backend, prompt, model) {
51
- const config = BACKENDS[backend];
52
- if (!config) {
53
- return Promise.resolve({
54
- text: `Unsupported backend: ${backend}`,
55
- success: false,
56
- durationMs: 0,
57
- });
58
- }
324
+ // ── System Prompt ─────────────────────────────────────────
325
+ /**
326
+ * Wrap the user's raw prompt with system context so the CLI backend
327
+ * knows about the connected phone and how to use zhihand MCP tools.
328
+ */
329
+ function wrapPrompt(userPrompt) {
330
+ return `You are ZhiHand, an AI assistant connected to the user's mobile phone via MCP tools.
331
+
332
+ You have the following MCP tools to interact with the phone:
333
+ - zhihand_screenshot: Take a screenshot of the phone screen. Use this when the user asks to see, check, or look at their screen.
334
+ - zhihand_control: Control the phone — click, type, swipe, scroll, key combos, clipboard, wait. Requires "action" parameter. For clicks, provide xRatio/yRatio (0-1 normalized coordinates).
335
+ - zhihand_pair: Pair a new device (rarely needed).
336
+
337
+ When the user asks you to see their screen, look at something, or check what's on the phone, ALWAYS call zhihand_screenshot first.
338
+ When the user asks you to tap, click, type, swipe, or interact with the phone, use zhihand_control.
339
+
340
+ User message:
341
+ ${userPrompt}`;
342
+ }
343
+ // ── Dispatch Entrypoint ────────────────────────────────────
344
+ export function dispatchToCLI(backend, prompt, log, model) {
59
345
  const startTime = Date.now();
60
- const args = config.buildArgs(prompt, model);
61
- const env = { ...process.env, ...config.env };
346
+ const wrappedPrompt = wrapPrompt(prompt);
347
+ if (backend === "gemini") {
348
+ return dispatchGemini(wrappedPrompt, startTime, log, model);
349
+ }
350
+ if (backend === "codex") {
351
+ return dispatchCodex(wrappedPrompt, startTime, model);
352
+ }
353
+ if (backend === "claudecode") {
354
+ return dispatchClaude(wrappedPrompt, startTime, model);
355
+ }
356
+ return Promise.resolve({
357
+ text: `Unsupported backend: ${backend}`,
358
+ success: false,
359
+ durationMs: 0,
360
+ });
361
+ }
362
+ // ── Gemini Dispatch (PTY + Session File Monitoring) ────────
363
+ function dispatchGemini(prompt, startTime, log, model) {
364
+ const geminiModel = model ?? process.env.CLAUDE_GEMINI_MODEL ?? "gemini-3.1-pro-preview";
365
+ const cliArgs = [
366
+ "--approval-mode", "yolo",
367
+ "--model", geminiModel,
368
+ "-i", prompt,
369
+ ];
370
+ const env = {
371
+ ...process.env,
372
+ GEMINI_SANDBOX: "false",
373
+ TERM: "xterm-256color",
374
+ COLORTERM: "truecolor",
375
+ };
376
+ // Wrap with PTY so gemini sees isatty()==true
377
+ const child = spawn("python3", [PTY_WRAP_SCRIPT, "gemini", ...cliArgs], {
378
+ env,
379
+ stdio: ["ignore", "pipe", "pipe"],
380
+ detached: false,
381
+ });
382
+ activeChild = child;
383
+ // Drain PTY output (discard — we read from session file instead)
384
+ child.stdout?.resume();
385
+ child.stderr?.resume();
386
+ return pollGeminiSession(child, startTime, prompt, log);
387
+ }
388
+ // ── Codex Dispatch ─────────────────────────────────────────
389
+ function dispatchCodex(prompt, startTime, model) {
390
+ // codex exec --full-auto --skip-git-repo-check --json [-m model] <prompt>
391
+ const args = ["exec", "--full-auto", "--skip-git-repo-check", "--json"];
392
+ const codexModel = model ?? process.env.CLAUDE_CODEX_MODEL;
393
+ if (codexModel) {
394
+ args.push("-m", codexModel);
395
+ }
396
+ args.push(prompt);
397
+ const child = spawn("codex", args, {
398
+ env: process.env,
399
+ stdio: ["ignore", "pipe", "pipe"],
400
+ detached: false,
401
+ });
402
+ activeChild = child;
403
+ return collectCodexOutput(child, startTime);
404
+ }
405
+ // ── Claude Dispatch ────────────────────────────────────────
406
+ function dispatchClaude(prompt, startTime, model) {
407
+ const child = spawn("claude", ["-p", prompt, "--output-format", "json"], {
408
+ env: process.env,
409
+ stdio: ["ignore", "pipe", "pipe"],
410
+ detached: false,
411
+ });
412
+ activeChild = child;
413
+ return collectChildOutput(child, startTime);
414
+ }
415
+ // ── Codex JSONL Output Parser ──────────────────────────────
416
+ /** Parse codex JSONL output and extract agent message text. */
417
+ function parseCodexJsonl(raw) {
418
+ const lines = raw.split("\n").filter(Boolean);
419
+ const texts = [];
420
+ let hasError = false;
421
+ for (const line of lines) {
422
+ try {
423
+ const event = JSON.parse(line);
424
+ const type = String(event.type ?? "");
425
+ // Extract text from completed agent messages
426
+ if (type === "item.completed") {
427
+ const item = event.item;
428
+ if (item && typeof item.text === "string" && item.text.trim()) {
429
+ texts.push(item.text.trim());
430
+ }
431
+ }
432
+ // Capture errors
433
+ if (type === "error") {
434
+ const msg = String(event.message ?? "");
435
+ if (msg)
436
+ texts.push(`Error: ${msg}`);
437
+ hasError = true;
438
+ }
439
+ if (type === "turn.failed") {
440
+ hasError = true;
441
+ }
442
+ }
443
+ catch {
444
+ // Not valid JSON — skip (truncated line or stderr mixed in)
445
+ }
446
+ }
447
+ if (texts.length > 0) {
448
+ return { text: texts.join("\n\n"), success: !hasError };
449
+ }
450
+ return { text: raw.trim(), success: false };
451
+ }
452
+ function collectCodexOutput(child, startTime) {
62
453
  return new Promise((resolve) => {
63
454
  const chunks = [];
64
455
  let totalBytes = 0;
@@ -70,19 +461,64 @@ export function dispatchToCLI(backend, prompt, model) {
70
461
  settled = true;
71
462
  resolve(result);
72
463
  }
73
- const child = spawn(config.command, args, {
74
- env,
75
- stdio: ["ignore", "pipe", "pipe"],
76
- detached: false,
464
+ const timer = setTimeout(() => { closeChild(child); }, CLI_TIMEOUT);
465
+ const collectOutput = (data) => {
466
+ if (truncated)
467
+ return;
468
+ totalBytes += data.length;
469
+ if (totalBytes > MAX_OUTPUT_BYTES) {
470
+ truncated = true;
471
+ chunks.push(data.subarray(0, MAX_OUTPUT_BYTES - (totalBytes - data.length)));
472
+ }
473
+ else {
474
+ chunks.push(data);
475
+ }
476
+ };
477
+ child.stdout?.on("data", collectOutput);
478
+ child.stderr?.on("data", collectOutput);
479
+ child.on("close", (code) => {
480
+ clearTimeout(timer);
481
+ activeChild = null;
482
+ const durationMs = Date.now() - startTime;
483
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
484
+ const parsed = parseCodexJsonl(raw);
485
+ let text = parsed.text;
486
+ if (truncated)
487
+ text += "\n\n[Output truncated at 100KB]";
488
+ if (!text) {
489
+ text = code === 0
490
+ ? "Task completed (no output)."
491
+ : `CLI process exited with code ${code}.`;
492
+ }
493
+ settle({ text, success: parsed.success && code === 0, durationMs });
494
+ });
495
+ child.on("error", (err) => {
496
+ clearTimeout(timer);
497
+ activeChild = null;
498
+ settle({
499
+ text: `CLI launch failed: ${err.message}`,
500
+ success: false,
501
+ durationMs: Date.now() - startTime,
502
+ });
77
503
  });
78
- activeChild = child;
504
+ });
505
+ }
506
+ // ── Shared: Collect stdout/stderr from a child process ─────
507
+ function collectChildOutput(child, startTime) {
508
+ return new Promise((resolve) => {
509
+ const chunks = [];
510
+ let totalBytes = 0;
511
+ let truncated = false;
512
+ let settled = false;
513
+ function settle(result) {
514
+ if (settled)
515
+ return;
516
+ settled = true;
517
+ resolve(result);
518
+ }
79
519
  // Timeout with two-stage kill
80
520
  const timer = setTimeout(() => {
81
- child.kill("SIGTERM");
82
- setTimeout(() => {
83
- if (!child.killed)
84
- child.kill("SIGKILL");
85
- }, SIGKILL_DELAY);
521
+ closeChild(child);
86
522
  }, CLI_TIMEOUT);
87
523
  const collectOutput = (data) => {
88
524
  if (truncated)
@@ -124,6 +560,7 @@ export function dispatchToCLI(backend, prompt, model) {
124
560
  });
125
561
  });
126
562
  }
563
+ // ── Reply ──────────────────────────────────────────────────
127
564
  export async function postReply(config, promptId, text) {
128
565
  try {
129
566
  const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/prompts/${encodeURIComponent(promptId)}/reply`;
@@ -28,7 +28,7 @@ async function processPrompt(config, prompt) {
28
28
  }
29
29
  const preview = prompt.text.length > 40 ? prompt.text.slice(0, 40) + "..." : prompt.text;
30
30
  log(`[relay] Prompt: "${preview}" → dispatching to ${activeBackend}...`);
31
- const result = await dispatchToCLI(activeBackend, prompt.text);
31
+ const result = await dispatchToCLI(activeBackend, prompt.text, log);
32
32
  const ok = await postReply(config, prompt.id, result.text);
33
33
  const dur = (result.durationMs / 1000).toFixed(1);
34
34
  if (ok) {
@@ -158,10 +158,22 @@ export async function startDaemon(options) {
158
158
  // Load backend
159
159
  const backendConfig = loadBackendConfig();
160
160
  activeBackend = backendConfig.activeBackend ?? null;
161
- // Create MCP server
162
- const mcpServer = createMcpServer(options?.deviceName);
163
- // Track active transports for cleanup
164
- const activeTransports = new Map();
161
+ // MCP sessions: each client gets its own McpServer + Transport pair
162
+ // because McpServer.connect() can only be called once per instance
163
+ const MAX_MCP_SESSIONS = 20;
164
+ const SESSION_IDLE_TIMEOUT = 30 * 60 * 1000; // 30 minutes
165
+ const mcpSessions = new Map();
166
+ // Evict idle MCP sessions periodically
167
+ const sessionCleanupTimer = setInterval(() => {
168
+ const now = Date.now();
169
+ for (const [sid, session] of mcpSessions) {
170
+ if (session.activeRequests === 0 && now - session.lastActivity > SESSION_IDLE_TIMEOUT) {
171
+ log(`[mcp] Evicting idle session: ${sid.slice(0, 8)}...`);
172
+ session.transport.close().catch(() => { });
173
+ mcpSessions.delete(sid);
174
+ }
175
+ }
176
+ }, 60_000);
165
177
  // Create HTTP server
166
178
  const httpServer = createHTTPServer(async (req, res) => {
167
179
  // Internal API
@@ -172,36 +184,63 @@ export async function startDaemon(options) {
172
184
  res.end();
173
185
  return;
174
186
  }
175
- // MCP endpoint
187
+ // MCP endpoint — per-session server + transport
176
188
  if (req.url === "/mcp" || req.url?.startsWith("/mcp")) {
177
189
  try {
178
- // Check for existing session
179
190
  const sessionId = req.headers["mcp-session-id"];
180
- if (sessionId && activeTransports.has(sessionId)) {
181
- // Route to existing transport
182
- const transport = activeTransports.get(sessionId);
183
- await transport.handleRequest(req, res);
191
+ if (sessionId && mcpSessions.has(sessionId)) {
192
+ // Existing session
193
+ const session = mcpSessions.get(sessionId);
194
+ session.lastActivity = Date.now();
195
+ session.activeRequests++;
196
+ try {
197
+ await session.transport.handleRequest(req, res);
198
+ }
199
+ finally {
200
+ session.activeRequests--;
201
+ }
184
202
  }
185
- else if (req.method === "POST" || !sessionId) {
186
- // New session: create transport, connect to MCP server
203
+ else if (!sessionId) {
204
+ // New session: create dedicated McpServer + Transport
205
+ const server = createMcpServer(options?.deviceName);
187
206
  const transport = new StreamableHTTPServerTransport({
188
207
  sessionIdGenerator: () => randomUUID(),
189
208
  onsessioninitialized: (sid) => {
190
- activeTransports.set(sid, transport);
209
+ // Evict oldest session if at capacity
210
+ if (mcpSessions.size >= MAX_MCP_SESSIONS) {
211
+ let oldestSid = null;
212
+ let oldestTime = Infinity;
213
+ for (const [s, sess] of mcpSessions) {
214
+ if (sess.activeRequests === 0 && sess.lastActivity < oldestTime) {
215
+ oldestTime = sess.lastActivity;
216
+ oldestSid = s;
217
+ }
218
+ }
219
+ if (oldestSid) {
220
+ log(`[mcp] Evicting oldest session (at cap): ${oldestSid.slice(0, 8)}...`);
221
+ mcpSessions.get(oldestSid)?.transport.close().catch(() => { });
222
+ mcpSessions.delete(oldestSid);
223
+ }
224
+ }
225
+ mcpSessions.set(sid, { server, transport, lastActivity: Date.now(), activeRequests: 0 });
191
226
  log(`[mcp] Session started: ${sid.slice(0, 8)}...`);
192
227
  },
193
228
  onsessionclosed: (sid) => {
194
- activeTransports.delete(sid);
229
+ mcpSessions.delete(sid);
195
230
  log(`[mcp] Session closed: ${sid.slice(0, 8)}...`);
196
231
  },
197
232
  });
198
- await mcpServer.connect(transport);
233
+ await server.connect(transport);
199
234
  await transport.handleRequest(req, res);
200
235
  }
201
236
  else {
202
- // Unknown session ID
237
+ // Unknown/expired session ID
203
238
  res.writeHead(400, { "Content-Type": "application/json" });
204
- res.end(JSON.stringify({ error: "Invalid or expired session" }));
239
+ res.end(JSON.stringify({
240
+ jsonrpc: "2.0",
241
+ error: { code: -32000, message: "Invalid or expired session" },
242
+ id: null,
243
+ }));
205
244
  }
206
245
  }
207
246
  catch (err) {
@@ -249,15 +288,17 @@ export async function startDaemon(options) {
249
288
  log("\nShutting down...");
250
289
  promptListener.stop();
251
290
  stopHeartbeatLoop();
291
+ clearInterval(sessionCleanupTimer);
252
292
  await killActiveChild();
253
293
  await sendBrainOffline(config);
254
- // Close all active MCP transports
255
- for (const transport of activeTransports.values()) {
294
+ // Close all MCP sessions
295
+ for (const session of mcpSessions.values()) {
256
296
  try {
257
- await transport.close();
297
+ await session.transport.close();
258
298
  }
259
299
  catch { /* ignore */ }
260
300
  }
301
+ mcpSessions.clear();
261
302
  httpServer.close();
262
303
  removePid();
263
304
  log("Daemon stopped.");
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js"
5
5
  import { executeControl } from "./tools/control.js";
6
6
  import { handleScreenshot } from "./tools/screenshot.js";
7
7
  import { handlePair } from "./tools/pair.js";
8
- const PACKAGE_VERSION = "0.16.0";
8
+ const PACKAGE_VERSION = "0.17.0";
9
9
  export function createServer(deviceName) {
10
10
  const server = new McpServer({
11
11
  name: "zhihand",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhihand/mcp",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ZhiHand MCP Server — phone control tools for Claude Code, Codex, Gemini CLI, and OpenClaw",