@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.
- package/README.md +97 -72
- package/bin/zhihand +175 -15
- package/dist/cli/detect.js +4 -2
- package/dist/cli/mcp-config.d.ts +9 -0
- package/dist/cli/mcp-config.js +71 -0
- package/dist/cli/spawn.d.ts +22 -1
- package/dist/cli/spawn.js +86 -21
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +17 -2
- package/dist/daemon/dispatcher.d.ts +13 -0
- package/dist/daemon/dispatcher.js +145 -0
- package/dist/daemon/heartbeat.d.ts +5 -0
- package/dist/daemon/heartbeat.js +65 -0
- package/dist/daemon/index.d.ts +6 -0
- package/dist/daemon/index.js +283 -0
- package/dist/daemon/prompt-listener.d.ts +32 -0
- package/dist/daemon/prompt-listener.js +152 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
8
|
+
const PACKAGE_VERSION = "0.16.0";
|
|
9
9
|
export function createServer(deviceName) {
|
|
10
10
|
const server = new McpServer({
|
|
11
11
|
name: "zhihand",
|