@zhihand/mcp 0.12.3 → 0.16.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.
@@ -0,0 +1,283 @@
1
+ import { createServer as createHTTPServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ // Transport type used only for cleanup interface
7
+ import { createServer as createMcpServer } from "../index.js";
8
+ import { resolveConfig, loadBackendConfig, saveBackendConfig, resolveZhiHandDir, ensureZhiHandDir, } from "../core/config.js";
9
+ import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline } from "./heartbeat.js";
10
+ import { PromptListener } from "./prompt-listener.js";
11
+ import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
12
+ const DEFAULT_PORT = 18686;
13
+ const PID_FILE = "daemon.pid";
14
+ // ── State ──────────────────────────────────────────────────
15
+ let activeBackend = null;
16
+ let isProcessing = false;
17
+ const promptQueue = [];
18
+ function log(msg) {
19
+ const ts = new Date().toLocaleTimeString();
20
+ process.stdout.write(`[${ts}] ${msg}\n`);
21
+ }
22
+ // ── Prompt Processing ──────────────────────────────────────
23
+ async function processPrompt(config, prompt) {
24
+ if (!activeBackend) {
25
+ log(`[relay] No backend configured. Replying with error.`);
26
+ await postReply(config, prompt.id, "No AI backend configured. Run: zhihand gemini / zhihand claude / zhihand codex");
27
+ return;
28
+ }
29
+ const preview = prompt.text.length > 40 ? prompt.text.slice(0, 40) + "..." : prompt.text;
30
+ log(`[relay] Prompt: "${preview}" → dispatching to ${activeBackend}...`);
31
+ const result = await dispatchToCLI(activeBackend, prompt.text);
32
+ const ok = await postReply(config, prompt.id, result.text);
33
+ const dur = (result.durationMs / 1000).toFixed(1);
34
+ if (ok) {
35
+ log(`[relay] Reply posted (${dur}s, ${result.success ? "ok" : "error"}).`);
36
+ }
37
+ else {
38
+ log(`[relay] Failed to post reply for prompt ${prompt.id}.`);
39
+ }
40
+ }
41
+ async function processQueue(config) {
42
+ while (promptQueue.length > 0) {
43
+ isProcessing = true;
44
+ const next = promptQueue.shift();
45
+ await processPrompt(config, next);
46
+ }
47
+ isProcessing = false;
48
+ }
49
+ function onPromptReceived(config, prompt) {
50
+ promptQueue.push(prompt);
51
+ if (!isProcessing) {
52
+ processQueue(config);
53
+ }
54
+ }
55
+ // ── Internal API ───────────────────────────────────────────
56
+ function handleInternalAPI(req, res) {
57
+ const url = req.url ?? "";
58
+ if (url === "/internal/backend" && req.method === "POST") {
59
+ let body = "";
60
+ const MAX_BODY = 10 * 1024; // 10KB
61
+ req.on("data", (chunk) => {
62
+ body += chunk.toString();
63
+ if (body.length > MAX_BODY) {
64
+ res.writeHead(413, { "Content-Type": "application/json" });
65
+ res.end(JSON.stringify({ error: "Payload too large" }));
66
+ req.destroy();
67
+ return;
68
+ }
69
+ });
70
+ req.on("end", () => {
71
+ try {
72
+ const { backend } = JSON.parse(body);
73
+ const allowed = ["claudecode", "codex", "gemini"];
74
+ if (!allowed.includes(backend)) {
75
+ res.writeHead(400, { "Content-Type": "application/json" });
76
+ res.end(JSON.stringify({ error: `Invalid backend. Allowed: ${allowed.join(", ")}` }));
77
+ return;
78
+ }
79
+ activeBackend = backend;
80
+ saveBackendConfig({ activeBackend });
81
+ log(`[config] Backend switched to ${activeBackend}.`);
82
+ res.writeHead(200, { "Content-Type": "application/json" });
83
+ res.end(JSON.stringify({ ok: true, backend: activeBackend }));
84
+ }
85
+ catch {
86
+ res.writeHead(400, { "Content-Type": "application/json" });
87
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
88
+ }
89
+ });
90
+ return true;
91
+ }
92
+ if (url === "/internal/status" && req.method === "GET") {
93
+ res.writeHead(200, { "Content-Type": "application/json" });
94
+ res.end(JSON.stringify({
95
+ backend: activeBackend,
96
+ processing: isProcessing,
97
+ queueLength: promptQueue.length,
98
+ pid: process.pid,
99
+ }));
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+ // ── PID Management ─────────────────────────────────────────
105
+ function getPidPath() {
106
+ return path.join(resolveZhiHandDir(), PID_FILE);
107
+ }
108
+ function writePid() {
109
+ ensureZhiHandDir();
110
+ fs.writeFileSync(getPidPath(), String(process.pid), { mode: 0o600 });
111
+ }
112
+ function removePid() {
113
+ try {
114
+ fs.unlinkSync(getPidPath());
115
+ }
116
+ catch { /* ignore */ }
117
+ }
118
+ function readPid() {
119
+ try {
120
+ const pid = parseInt(fs.readFileSync(getPidPath(), "utf8").trim(), 10);
121
+ if (isNaN(pid))
122
+ return null;
123
+ // Check if process is still alive
124
+ try {
125
+ process.kill(pid, 0);
126
+ return pid;
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ export function isAlreadyRunning() {
137
+ return readPid();
138
+ }
139
+ // ── Main Daemon Entry ──────────────────────────────────────
140
+ export async function startDaemon(options) {
141
+ const port = options?.port ?? (parseInt(process.env.ZHIHAND_PORT ?? "", 10) || DEFAULT_PORT);
142
+ // Check if already running
143
+ const existingPid = readPid();
144
+ if (existingPid) {
145
+ log(`Daemon already running (PID ${existingPid}). Use 'zhihand stop' first.`);
146
+ process.exit(1);
147
+ }
148
+ // Load config
149
+ let config;
150
+ try {
151
+ config = resolveConfig(options?.deviceName);
152
+ }
153
+ catch (err) {
154
+ log(`Error: ${err.message}`);
155
+ log("Run 'zhihand setup' to pair a device first.");
156
+ process.exit(1);
157
+ }
158
+ // Load backend
159
+ const backendConfig = loadBackendConfig();
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();
165
+ // Create HTTP server
166
+ const httpServer = createHTTPServer(async (req, res) => {
167
+ // Internal API
168
+ if (req.url?.startsWith("/internal/")) {
169
+ if (handleInternalAPI(req, res))
170
+ return;
171
+ res.writeHead(404);
172
+ res.end();
173
+ return;
174
+ }
175
+ // MCP endpoint
176
+ if (req.url === "/mcp" || req.url?.startsWith("/mcp")) {
177
+ try {
178
+ // Check for existing session
179
+ 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);
184
+ }
185
+ else if (req.method === "POST" || !sessionId) {
186
+ // New session: create transport, connect to MCP server
187
+ const transport = new StreamableHTTPServerTransport({
188
+ sessionIdGenerator: () => randomUUID(),
189
+ onsessioninitialized: (sid) => {
190
+ activeTransports.set(sid, transport);
191
+ log(`[mcp] Session started: ${sid.slice(0, 8)}...`);
192
+ },
193
+ onsessionclosed: (sid) => {
194
+ activeTransports.delete(sid);
195
+ log(`[mcp] Session closed: ${sid.slice(0, 8)}...`);
196
+ },
197
+ });
198
+ await mcpServer.connect(transport);
199
+ await transport.handleRequest(req, res);
200
+ }
201
+ else {
202
+ // Unknown session ID
203
+ res.writeHead(400, { "Content-Type": "application/json" });
204
+ res.end(JSON.stringify({ error: "Invalid or expired session" }));
205
+ }
206
+ }
207
+ catch (err) {
208
+ if (!res.headersSent) {
209
+ res.writeHead(500);
210
+ res.end(`MCP error: ${err.message}`);
211
+ }
212
+ }
213
+ return;
214
+ }
215
+ // Health check
216
+ if (req.url === "/health") {
217
+ res.writeHead(200, { "Content-Type": "application/json" });
218
+ res.end(JSON.stringify({ status: "ok", pid: process.pid }));
219
+ return;
220
+ }
221
+ res.writeHead(404);
222
+ res.end();
223
+ });
224
+ // Start HTTP server on 127.0.0.1 ONLY (security: no 0.0.0.0)
225
+ await new Promise((resolve, reject) => {
226
+ httpServer.once("error", (err) => {
227
+ if (err.code === "EADDRINUSE") {
228
+ log(`Error: Port ${port} is already in use. Set ZHIHAND_PORT to use a different port.`);
229
+ process.exit(1);
230
+ }
231
+ reject(err);
232
+ });
233
+ httpServer.listen(port, "127.0.0.1", () => resolve());
234
+ });
235
+ writePid();
236
+ // Start heartbeat
237
+ startHeartbeatLoop(config, log);
238
+ // Start prompt listener
239
+ const promptListener = new PromptListener(config, (prompt) => onPromptReceived(config, prompt), log);
240
+ promptListener.start();
241
+ log(`ZhiHand daemon started.`);
242
+ log(` PID: ${process.pid}`);
243
+ log(` MCP: http://127.0.0.1:${port}/mcp`);
244
+ log(` Backend: ${activeBackend ?? "(none)"}`);
245
+ log(` Device: ${config.credentialId}`);
246
+ log(`Listening for prompts...`);
247
+ // Graceful shutdown
248
+ const shutdown = async () => {
249
+ log("\nShutting down...");
250
+ promptListener.stop();
251
+ stopHeartbeatLoop();
252
+ await killActiveChild();
253
+ await sendBrainOffline(config);
254
+ // Close all active MCP transports
255
+ for (const transport of activeTransports.values()) {
256
+ try {
257
+ await transport.close();
258
+ }
259
+ catch { /* ignore */ }
260
+ }
261
+ httpServer.close();
262
+ removePid();
263
+ log("Daemon stopped.");
264
+ process.exit(0);
265
+ };
266
+ process.on("SIGINT", shutdown);
267
+ process.on("SIGTERM", shutdown);
268
+ }
269
+ export function stopDaemon() {
270
+ const pid = readPid();
271
+ if (!pid) {
272
+ return false;
273
+ }
274
+ try {
275
+ process.kill(pid, "SIGTERM");
276
+ return true;
277
+ }
278
+ catch {
279
+ // Process already dead, clean up PID file
280
+ removePid();
281
+ return false;
282
+ }
283
+ }
@@ -0,0 +1,32 @@
1
+ import type { ZhiHandConfig } from "../core/config.ts";
2
+ export interface MobilePrompt {
3
+ id: string;
4
+ credential_id: string;
5
+ edge_id: string;
6
+ text: string;
7
+ status: string;
8
+ client_message_id?: string;
9
+ created_at: string;
10
+ attachments?: unknown[];
11
+ }
12
+ export type PromptHandler = (prompt: MobilePrompt) => void;
13
+ export declare class PromptListener {
14
+ private config;
15
+ private handler;
16
+ private log;
17
+ private processedIds;
18
+ private sseAbort;
19
+ private pollTimer;
20
+ private sseConnected;
21
+ private stopped;
22
+ constructor(config: ZhiHandConfig, handler: PromptHandler, log: (msg: string) => void);
23
+ start(): void;
24
+ stop(): void;
25
+ private dispatchPrompt;
26
+ private connectSSE;
27
+ private resetWatchdog;
28
+ private handleSSEEvent;
29
+ private startPolling;
30
+ private stopPolling;
31
+ private poll;
32
+ }
@@ -0,0 +1,152 @@
1
+ const SSE_WATCHDOG_TIMEOUT = 45_000; // 45s no data → reconnect
2
+ const SSE_RECONNECT_DELAY = 3_000;
3
+ const POLL_INTERVAL = 2_000;
4
+ export class PromptListener {
5
+ config;
6
+ handler;
7
+ log;
8
+ processedIds = new Set();
9
+ sseAbort = null;
10
+ pollTimer = null;
11
+ sseConnected = false;
12
+ stopped = false;
13
+ constructor(config, handler, log) {
14
+ this.config = config;
15
+ this.handler = handler;
16
+ this.log = log;
17
+ }
18
+ start() {
19
+ this.stopped = false;
20
+ this.connectSSE();
21
+ }
22
+ stop() {
23
+ this.stopped = true;
24
+ this.sseAbort?.abort();
25
+ this.sseAbort = null;
26
+ if (this.pollTimer) {
27
+ clearInterval(this.pollTimer);
28
+ this.pollTimer = null;
29
+ }
30
+ }
31
+ dispatchPrompt(prompt) {
32
+ if (this.processedIds.has(prompt.id))
33
+ return;
34
+ this.processedIds.add(prompt.id);
35
+ // Prevent unbounded growth
36
+ if (this.processedIds.size > 500) {
37
+ const arr = [...this.processedIds];
38
+ this.processedIds = new Set(arr.slice(-250));
39
+ }
40
+ this.handler(prompt);
41
+ }
42
+ async connectSSE() {
43
+ while (!this.stopped) {
44
+ try {
45
+ this.sseAbort = new AbortController();
46
+ const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/events/stream?topic=prompts`;
47
+ const response = await fetch(url, {
48
+ headers: {
49
+ "Accept": "text/event-stream",
50
+ "x-zhihand-controller-token": this.config.controllerToken,
51
+ },
52
+ signal: this.sseAbort.signal,
53
+ });
54
+ if (!response.ok) {
55
+ throw new Error(`SSE connect failed: ${response.status}`);
56
+ }
57
+ this.sseConnected = true;
58
+ this.stopPolling();
59
+ this.log("[sse] Connected to prompt stream.");
60
+ const reader = response.body?.getReader();
61
+ if (!reader)
62
+ throw new Error("No response body for SSE");
63
+ const decoder = new TextDecoder();
64
+ let buffer = "";
65
+ let watchdog = this.resetWatchdog();
66
+ while (!this.stopped) {
67
+ const { done, value } = await reader.read();
68
+ if (done)
69
+ break;
70
+ // Reset watchdog on any data (including keepalive comments)
71
+ clearTimeout(watchdog);
72
+ watchdog = this.resetWatchdog();
73
+ buffer += decoder.decode(value, { stream: true });
74
+ const lines = buffer.split("\n");
75
+ buffer = lines.pop() ?? "";
76
+ let eventData = "";
77
+ for (const line of lines) {
78
+ if (line.startsWith("data: ")) {
79
+ eventData += (eventData ? "\n" : "") + line.slice(6);
80
+ }
81
+ else if (line === "" && eventData) {
82
+ try {
83
+ const event = JSON.parse(eventData);
84
+ this.handleSSEEvent(event);
85
+ }
86
+ catch {
87
+ // Malformed event
88
+ }
89
+ eventData = "";
90
+ }
91
+ }
92
+ }
93
+ clearTimeout(watchdog);
94
+ }
95
+ catch (err) {
96
+ if (this.stopped)
97
+ break;
98
+ this.sseConnected = false;
99
+ this.log(`[sse] Disconnected. Falling back to polling. (${err.message})`);
100
+ this.startPolling();
101
+ await new Promise((r) => setTimeout(r, SSE_RECONNECT_DELAY));
102
+ }
103
+ }
104
+ }
105
+ resetWatchdog() {
106
+ return setTimeout(() => {
107
+ this.log("[sse] Watchdog timeout (45s no data). Reconnecting...");
108
+ this.sseAbort?.abort();
109
+ }, SSE_WATCHDOG_TIMEOUT);
110
+ }
111
+ handleSSEEvent(event) {
112
+ if (event.kind === "prompt.queued" && event.prompt) {
113
+ this.dispatchPrompt(event.prompt);
114
+ }
115
+ else if (event.kind === "prompt.snapshot" && event.prompts) {
116
+ for (const p of event.prompts) {
117
+ if (p.status === "pending" || p.status === "processing") {
118
+ this.dispatchPrompt(p);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ startPolling() {
124
+ if (this.pollTimer)
125
+ return;
126
+ this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL);
127
+ }
128
+ stopPolling() {
129
+ if (this.pollTimer) {
130
+ clearInterval(this.pollTimer);
131
+ this.pollTimer = null;
132
+ }
133
+ }
134
+ async poll() {
135
+ try {
136
+ const url = `${this.config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(this.config.credentialId)}/prompts?limit=5`;
137
+ const response = await fetch(url, {
138
+ headers: { "x-zhihand-controller-token": this.config.controllerToken },
139
+ signal: AbortSignal.timeout(10_000),
140
+ });
141
+ if (!response.ok)
142
+ return;
143
+ const data = (await response.json());
144
+ for (const prompt of data.items ?? []) {
145
+ this.dispatchPrompt(prompt);
146
+ }
147
+ }
148
+ catch {
149
+ // Polling failure is non-fatal
150
+ }
151
+ }
152
+ }
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.12.3";
8
+ const PACKAGE_VERSION = "0.16.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.12.3",
3
+ "version": "0.16.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",