@tina_summer/codepocket 2.0.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/bin/codepocket.js +453 -0
- package/bin/run.js +289 -0
- package/package.json +31 -0
- package/src/index.ts +304 -0
- package/src/mcp-client.js +348 -0
- package/src/mcp-server.js +331 -0
- package/vendor/cpkt-hub +0 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple MCP client — connects to the running codepocket MCP server via SSE,
|
|
3
|
+
* sends tool calls, and returns results.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const http = require('http')
|
|
7
|
+
const fs = require('fs')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const readline = require('readline')
|
|
10
|
+
const os = require('os')
|
|
11
|
+
|
|
12
|
+
const MCP_PORTS = Array.from({ length: 10 }, (_, i) => 3007 + i)
|
|
13
|
+
const TRACK_FILE = path.join(os.tmpdir(), 'codepocket-last-track.json')
|
|
14
|
+
|
|
15
|
+
// ── MCP Client ──
|
|
16
|
+
|
|
17
|
+
class McpClient {
|
|
18
|
+
constructor(port) {
|
|
19
|
+
this.port = port
|
|
20
|
+
this.baseUrl = `http://127.0.0.1:${port}`
|
|
21
|
+
this.sseBuffer = ''
|
|
22
|
+
this.pending = new Map() // id -> { resolve, reject }
|
|
23
|
+
this.nextId = 1
|
|
24
|
+
this.connected = false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Discover and connect to a running MCP server */
|
|
28
|
+
static async connect() {
|
|
29
|
+
for (const port of MCP_PORTS) {
|
|
30
|
+
try {
|
|
31
|
+
const res = await new Promise((resolve, reject) => {
|
|
32
|
+
const req = http.get(`http://127.0.0.1:${port}/sse`, (res) => {
|
|
33
|
+
resolve(res)
|
|
34
|
+
})
|
|
35
|
+
req.on('error', reject)
|
|
36
|
+
req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')) })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (res.statusCode === 200) {
|
|
40
|
+
const client = new McpClient(port)
|
|
41
|
+
await client._startSSE(res)
|
|
42
|
+
return client
|
|
43
|
+
}
|
|
44
|
+
res.resume()
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
throw new Error('未找到运行中的 MCP server (tried ports 3007-3016)')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_startSSE(res) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
let buffer = ''
|
|
53
|
+
res.setEncoding('utf-8')
|
|
54
|
+
res.on('data', (chunk) => {
|
|
55
|
+
buffer += chunk
|
|
56
|
+
const lines = buffer.split('\n')
|
|
57
|
+
buffer = lines.pop() || ''
|
|
58
|
+
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
if (line.startsWith('data: ')) {
|
|
61
|
+
const data = line.slice(6).trim()
|
|
62
|
+
if (data.startsWith('http')) {
|
|
63
|
+
// endpoint event — connected
|
|
64
|
+
this.connected = true
|
|
65
|
+
resolve()
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const msg = JSON.parse(data)
|
|
70
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
71
|
+
const { resolve: done } = this.pending.get(msg.id)
|
|
72
|
+
this.pending.delete(msg.id)
|
|
73
|
+
done(msg)
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
res.on('error', reject)
|
|
80
|
+
res.on('end', () => {
|
|
81
|
+
this.connected = false
|
|
82
|
+
// Reject all pending
|
|
83
|
+
for (const [id, { reject }] of this.pending) {
|
|
84
|
+
this.pending.delete(id)
|
|
85
|
+
reject(new Error('SSE connection closed'))
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
// Timeout for initial connection
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
if (!this.connected) reject(new Error('SSE connection timeout'))
|
|
91
|
+
}, 3000)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Call an MCP tool and return the parsed result */
|
|
96
|
+
async call(toolName, args = {}) {
|
|
97
|
+
const id = this.nextId++
|
|
98
|
+
const msg = {
|
|
99
|
+
jsonrpc: '2.0',
|
|
100
|
+
id,
|
|
101
|
+
method: 'tools/call',
|
|
102
|
+
params: { name: toolName, arguments: args },
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
this.pending.delete(id)
|
|
108
|
+
reject(new Error(`Tool call "${toolName}" timed out (60s)`))
|
|
109
|
+
}, 60000)
|
|
110
|
+
|
|
111
|
+
this.pending.set(id, {
|
|
112
|
+
resolve: (msg) => {
|
|
113
|
+
clearTimeout(timer)
|
|
114
|
+
resolve(msg)
|
|
115
|
+
},
|
|
116
|
+
reject: (err) => {
|
|
117
|
+
clearTimeout(timer)
|
|
118
|
+
reject(err)
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const body = JSON.stringify(msg)
|
|
123
|
+
const req = http.request(`${this.baseUrl}/message`, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
126
|
+
}, (res) => {
|
|
127
|
+
res.resume()
|
|
128
|
+
if (res.statusCode !== 202) {
|
|
129
|
+
this.pending.delete(id)
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
reject(new Error(`Unexpected status: ${res.statusCode}`))
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
req.on('error', (err) => {
|
|
135
|
+
this.pending.delete(id)
|
|
136
|
+
clearTimeout(timer)
|
|
137
|
+
reject(err)
|
|
138
|
+
})
|
|
139
|
+
req.write(body)
|
|
140
|
+
req.end()
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Extract tool result from MCP response */
|
|
145
|
+
static extractResult(mcpMsg) {
|
|
146
|
+
try {
|
|
147
|
+
const text = mcpMsg.result?.content?.[0]?.text
|
|
148
|
+
if (text) return JSON.parse(text)
|
|
149
|
+
} catch {}
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Test Commands ──
|
|
155
|
+
|
|
156
|
+
async function cmdLocation() {
|
|
157
|
+
const client = await McpClient.connect()
|
|
158
|
+
console.log('获取位置中...')
|
|
159
|
+
const resp = await client.call('phone_location_get')
|
|
160
|
+
const result = McpClient.extractResult(resp)
|
|
161
|
+
if (result?.accepted && result.result) {
|
|
162
|
+
const loc = result.result
|
|
163
|
+
console.log(` 纬度: ${loc.latitude}`)
|
|
164
|
+
console.log(` 经度: ${loc.longitude}`)
|
|
165
|
+
console.log(` 精度: ${loc.accuracy}m`)
|
|
166
|
+
} else {
|
|
167
|
+
console.error(' 获取失败:', result?.error || '未知错误')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdTrack() {
|
|
172
|
+
const client = await McpClient.connect()
|
|
173
|
+
console.log('开始追踪,手机上确认...')
|
|
174
|
+
const resp = await client.call('phone_location_track')
|
|
175
|
+
const result = McpClient.extractResult(resp)
|
|
176
|
+
if (result?.accepted && result.result?.success) {
|
|
177
|
+
console.log(' 追踪已开始,走动一会儿后运行:')
|
|
178
|
+
console.log(' codepocket test stop')
|
|
179
|
+
} else {
|
|
180
|
+
console.error(' 启动失败:', result?.error || '未知错误')
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function cmdStop() {
|
|
185
|
+
const client = await McpClient.connect()
|
|
186
|
+
console.log('停止追踪...')
|
|
187
|
+
const resp = await client.call('phone_location_stop')
|
|
188
|
+
const result = McpClient.extractResult(resp)
|
|
189
|
+
if (result?.accepted && result.result?.success) {
|
|
190
|
+
const { points, count, debug } = result.result
|
|
191
|
+
const duration = count >= 2
|
|
192
|
+
? ((points[points.length - 1].timestamp - points[0].timestamp) / 1000).toFixed(1) + 's'
|
|
193
|
+
: 'N/A'
|
|
194
|
+
|
|
195
|
+
console.log(` 采集点数: ${count}`)
|
|
196
|
+
console.log(` 时长: ${duration}`)
|
|
197
|
+
|
|
198
|
+
if (debug?.length) {
|
|
199
|
+
console.log(' 调试日志:')
|
|
200
|
+
debug.forEach((d) => console.log(` ${d}`))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Save track data for map command
|
|
204
|
+
if (count >= 2) {
|
|
205
|
+
fs.writeFileSync(TRACK_FILE, JSON.stringify({ points, count, savedAt: Date.now() }))
|
|
206
|
+
console.log(`\n 轨迹已保存,运行下面命令查看地图:`)
|
|
207
|
+
console.log(' codepocket test map')
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
console.error(' 停止失败:', result?.error || '未知错误')
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function cmdMap() {
|
|
215
|
+
// Load saved track data
|
|
216
|
+
if (!fs.existsSync(TRACK_FILE)) {
|
|
217
|
+
console.error(' 未找到轨迹数据,请先运行 codepocket test track → stop')
|
|
218
|
+
process.exit(1)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const track = JSON.parse(fs.readFileSync(TRACK_FILE, 'utf-8'))
|
|
222
|
+
const { points } = track
|
|
223
|
+
|
|
224
|
+
console.log(` 加载 ${points.length} 个轨迹点...`)
|
|
225
|
+
|
|
226
|
+
const polylinePoints = points.map((p) => ({
|
|
227
|
+
latitude: p.latitude,
|
|
228
|
+
longitude: p.longitude,
|
|
229
|
+
timestamp: p.timestamp,
|
|
230
|
+
}))
|
|
231
|
+
|
|
232
|
+
const markers = [
|
|
233
|
+
{ latitude: points[0].latitude, longitude: points[0].longitude, title: '起点' },
|
|
234
|
+
{ latitude: points[points.length - 1].latitude, longitude: points[points.length - 1].longitude, title: '终点' },
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
const client = await McpClient.connect()
|
|
238
|
+
console.log(' 发送地图到手机...')
|
|
239
|
+
const resp = await client.call('phone_map_show', {
|
|
240
|
+
polyline: [{ points: polylinePoints, color: '#00C853', width: 4 }],
|
|
241
|
+
markers,
|
|
242
|
+
title: '轨迹地图',
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const result = McpClient.extractResult(resp)
|
|
246
|
+
if (result?.accepted) {
|
|
247
|
+
console.log(' 地图已显示到手机!')
|
|
248
|
+
} else {
|
|
249
|
+
console.error(' 显示失败:', result?.error || '未知错误')
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function cmdFlow() {
|
|
254
|
+
const client = await McpClient.connect()
|
|
255
|
+
|
|
256
|
+
// Step 1: Track
|
|
257
|
+
console.log('\n[1/3] 开始追踪,手机上确认...')
|
|
258
|
+
const trackResp = await client.call('phone_location_track')
|
|
259
|
+
const trackResult = McpClient.extractResult(trackResp)
|
|
260
|
+
if (!trackResult?.accepted || !trackResult.result?.success) {
|
|
261
|
+
console.error(' 追踪启动失败:', trackResult?.error || '未知错误')
|
|
262
|
+
process.exit(1)
|
|
263
|
+
}
|
|
264
|
+
console.log(' 追踪中...')
|
|
265
|
+
|
|
266
|
+
// Step 2: Wait for user
|
|
267
|
+
console.log('\n 走动一会儿,按回车停止追踪...')
|
|
268
|
+
await new Promise((resolve) => {
|
|
269
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
270
|
+
rl.on('line', () => { rl.close(); resolve() })
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// Step 3: Stop
|
|
274
|
+
console.log('\n[2/3] 停止追踪...')
|
|
275
|
+
const stopResp = await client.call('phone_location_stop')
|
|
276
|
+
const stopResult = McpClient.extractResult(stopResp)
|
|
277
|
+
if (!stopResult?.accepted || !stopResult.result?.success) {
|
|
278
|
+
console.error(' 停止失败:', stopResult?.error || '未知错误')
|
|
279
|
+
process.exit(1)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const { points, count } = stopResult.result
|
|
283
|
+
const duration = count >= 2
|
|
284
|
+
? ((points[points.length - 1].timestamp - points[0].timestamp) / 1000).toFixed(1) + 's'
|
|
285
|
+
: 'N/A'
|
|
286
|
+
console.log(` ${count} 个点, ${duration}`)
|
|
287
|
+
|
|
288
|
+
if (count < 2) {
|
|
289
|
+
console.error(' 点数不足,无法画地图')
|
|
290
|
+
process.exit(1)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Save track
|
|
294
|
+
fs.writeFileSync(TRACK_FILE, JSON.stringify({ points, count, savedAt: Date.now() }))
|
|
295
|
+
|
|
296
|
+
// Step 4: Map
|
|
297
|
+
console.log('\n[3/3] 画地图...')
|
|
298
|
+
const polylinePoints = points.map((p) => ({
|
|
299
|
+
latitude: p.latitude,
|
|
300
|
+
longitude: p.longitude,
|
|
301
|
+
timestamp: p.timestamp,
|
|
302
|
+
}))
|
|
303
|
+
const markers = [
|
|
304
|
+
{ latitude: points[0].latitude, longitude: points[0].longitude, title: '起点' },
|
|
305
|
+
{ latitude: points[points.length - 1].latitude, longitude: points[points.length - 1].longitude, title: '终点' },
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
const mapResp = await client.call('phone_map_show', {
|
|
309
|
+
polyline: [{ points: polylinePoints, color: '#00C853', width: 4 }],
|
|
310
|
+
markers,
|
|
311
|
+
title: '轨迹地图',
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const mapResult = McpClient.extractResult(mapResp)
|
|
315
|
+
if (mapResult?.accepted) {
|
|
316
|
+
console.log(' 地图已显示到手机!')
|
|
317
|
+
} else {
|
|
318
|
+
console.error(' 显示失败:', mapResult?.error || '未知错误')
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── CLI Entry ──
|
|
323
|
+
|
|
324
|
+
const COMMANDS = { location: cmdLocation, track: cmdTrack, stop: cmdStop, map: cmdMap, flow: cmdFlow }
|
|
325
|
+
|
|
326
|
+
async function runTestCli(args) {
|
|
327
|
+
const cmd = args[0]
|
|
328
|
+
if (!cmd || !COMMANDS[cmd]) {
|
|
329
|
+
console.log('用法: codepocket test <command>')
|
|
330
|
+
console.log('')
|
|
331
|
+
console.log('命令:')
|
|
332
|
+
console.log(' location 获取当前位置')
|
|
333
|
+
console.log(' track 开始位置追踪')
|
|
334
|
+
console.log(' stop 停止追踪并保存轨迹')
|
|
335
|
+
console.log(' map 用最近轨迹画地图')
|
|
336
|
+
console.log(' flow 全流程: 追踪 → 停止 → 画地图')
|
|
337
|
+
process.exit(cmd ? 1 : 0)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
await COMMANDS[cmd]()
|
|
342
|
+
} catch (err) {
|
|
343
|
+
console.error(`\n [!] ${err.message}`)
|
|
344
|
+
process.exit(1)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = { runTestCli }
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
const http = require('http')
|
|
2
|
+
|
|
3
|
+
// Tool definitions — each tool's JSON Schema for MCP tools/list
|
|
4
|
+
const TOOLS = [
|
|
5
|
+
{
|
|
6
|
+
name: 'phone_calendar_add',
|
|
7
|
+
description: 'Add an event to the phone system calendar. The user will confirm on their phone before the event is written.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
title: { type: 'string', description: 'Event title' },
|
|
12
|
+
startTime: { type: 'number', description: 'Start time as Unix timestamp in milliseconds' },
|
|
13
|
+
endTime: { type: 'number', description: 'End time as Unix timestamp in milliseconds (optional)' },
|
|
14
|
+
description: { type: 'string', description: 'Event description (optional)' },
|
|
15
|
+
reminderAlarm: { type: 'number', description: 'Minutes before event to trigger reminder (optional, default 0)' },
|
|
16
|
+
},
|
|
17
|
+
required: ['title', 'startTime'],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'phone_calendar_query',
|
|
22
|
+
description: 'Query calendar events. Note: the phone cannot read system calendar events; this returns cached data if available.',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
startDate: { type: 'number', description: 'Start of range as Unix timestamp ms' },
|
|
27
|
+
endDate: { type: 'number', description: 'End of range as Unix timestamp ms' },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'phone_todo_add',
|
|
33
|
+
description: 'Add a todo item. The user will confirm on their phone.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
title: { type: 'string', description: 'Todo title' },
|
|
38
|
+
dueDate: { type: 'number', description: 'Due date as Unix timestamp ms (optional)' },
|
|
39
|
+
priority: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Priority level (optional)' },
|
|
40
|
+
},
|
|
41
|
+
required: ['title'],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'phone_todo_list',
|
|
46
|
+
description: 'List todo items stored on the phone.',
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
filter: { type: 'string', enum: ['all', 'pending', 'completed'], description: 'Filter todos by status (default: pending)' },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'phone_todo_complete',
|
|
56
|
+
description: 'Mark a todo item as completed. The user will confirm on their phone.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
todoId: { type: 'string', description: 'The ID of the todo item to complete' },
|
|
61
|
+
},
|
|
62
|
+
required: ['todoId'],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'phone_location_get',
|
|
67
|
+
description: 'Get the phone current GPS location. The user will be asked for permission.',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'phone_location_track',
|
|
75
|
+
description: 'Start tracking the phone GPS location in the background. Points are collected until phone_location_stop is called. Returns immediately.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'phone_location_stop',
|
|
83
|
+
description: 'Stop GPS location tracking and return all collected coordinate points since tracking started.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'phone_notification_send',
|
|
91
|
+
description: 'Send a notification to the phone. The user will confirm on their phone.',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
title: { type: 'string', description: 'Notification title' },
|
|
96
|
+
body: { type: 'string', description: 'Notification body text' },
|
|
97
|
+
at: { type: 'number', description: 'Scheduled time as Unix timestamp ms (optional, send immediately if omitted)' },
|
|
98
|
+
},
|
|
99
|
+
required: ['title', 'body'],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'phone_clipboard_write',
|
|
104
|
+
description: 'Write text to the phone clipboard. The user will confirm on their phone.',
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
text: { type: 'string', description: 'Text to write to clipboard' },
|
|
109
|
+
},
|
|
110
|
+
required: ['text'],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'phone_map_show',
|
|
115
|
+
description: 'Show a map with markers and/or polyline on the phone screen. The user will see the map and confirm when done.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
markers: {
|
|
120
|
+
type: 'array',
|
|
121
|
+
description: 'Marker points to display on the map',
|
|
122
|
+
items: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
latitude: { type: 'number', description: 'Marker latitude' },
|
|
126
|
+
longitude: { type: 'number', description: 'Marker longitude' },
|
|
127
|
+
title: { type: 'string', description: 'Marker label text' },
|
|
128
|
+
},
|
|
129
|
+
required: ['latitude', 'longitude'],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
polyline: {
|
|
133
|
+
type: 'array',
|
|
134
|
+
description: 'Polylines to draw on the map',
|
|
135
|
+
items: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
points: {
|
|
139
|
+
type: 'array',
|
|
140
|
+
items: {
|
|
141
|
+
type: 'object',
|
|
142
|
+
properties: {
|
|
143
|
+
latitude: { type: 'number' },
|
|
144
|
+
longitude: { type: 'number' },
|
|
145
|
+
timestamp: { type: 'number', description: 'Unix timestamp ms (optional, for speed calc)' },
|
|
146
|
+
},
|
|
147
|
+
required: ['latitude', 'longitude'],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
color: { type: 'string', description: 'Line color, e.g. #FF0000', default: '#1677FF' },
|
|
151
|
+
width: { type: 'number', description: 'Line width in pixels', default: 4 },
|
|
152
|
+
},
|
|
153
|
+
required: ['points'],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
title: { type: 'string', description: 'Map page title', default: '地图' },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
// phone_calendar_query and phone_todo_list are also sent to the mini-program,
|
|
163
|
+
// but the mini-program handles them without showing a confirmation modal.
|
|
164
|
+
|
|
165
|
+
let pendingPhoneActions = new Map() // actionId -> { resolve, timer, rpcId }
|
|
166
|
+
let sendToRelayFn = null
|
|
167
|
+
let sseResponse = null // SSE response object for sending events to Claude Code
|
|
168
|
+
let messageEndpoint = ''
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Start the MCP HTTP server.
|
|
172
|
+
* @param {function} relaySender - function(msg) that sends a message to the relay WebSocket
|
|
173
|
+
* @returns {Promise<number>} the port the server is listening on
|
|
174
|
+
*/
|
|
175
|
+
function startMcpServer(relaySender) {
|
|
176
|
+
sendToRelayFn = relaySender
|
|
177
|
+
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
const server = http.createServer((req, res) => {
|
|
180
|
+
// CORS headers
|
|
181
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
182
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
183
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
184
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return }
|
|
185
|
+
|
|
186
|
+
if (req.url === '/sse' && req.method === 'GET') {
|
|
187
|
+
handleSSE(req, res)
|
|
188
|
+
} else if (req.url === '/message' && req.method === 'POST') {
|
|
189
|
+
handleMessage(req, res)
|
|
190
|
+
} else {
|
|
191
|
+
res.writeHead(404)
|
|
192
|
+
res.end('Not found')
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// Try ports 3007-3016
|
|
197
|
+
let port = 3007
|
|
198
|
+
function tryListen() {
|
|
199
|
+
server.listen(port, '127.0.0.1', () => {
|
|
200
|
+
console.log(` [✓] MCP server listening on http://127.0.0.1:${port}`)
|
|
201
|
+
messageEndpoint = `http://127.0.0.1:${port}/message`
|
|
202
|
+
resolve(port)
|
|
203
|
+
})
|
|
204
|
+
server.on('error', (err) => {
|
|
205
|
+
if (err.code === 'EADDRINUSE' && port < 3017) {
|
|
206
|
+
port++
|
|
207
|
+
server.listen(port, '127.0.0.1')
|
|
208
|
+
} else {
|
|
209
|
+
reject(err)
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
tryListen()
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function handleSSE(req, res) {
|
|
218
|
+
res.writeHead(200, {
|
|
219
|
+
'Content-Type': 'text/event-stream',
|
|
220
|
+
'Cache-Control': 'no-cache',
|
|
221
|
+
'Connection': 'keep-alive',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Send the endpoint URL as the first event
|
|
225
|
+
res.write(`event: endpoint\ndata: ${messageEndpoint}\n\n`)
|
|
226
|
+
|
|
227
|
+
sseResponse = res
|
|
228
|
+
|
|
229
|
+
// Heartbeat every 15s to keep SSE connection alive
|
|
230
|
+
const heartbeat = setInterval(() => {
|
|
231
|
+
if (res.writableEnded) {
|
|
232
|
+
clearInterval(heartbeat)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
res.write(': heartbeat\n\n')
|
|
236
|
+
}, 15000)
|
|
237
|
+
|
|
238
|
+
req.on('close', () => {
|
|
239
|
+
clearInterval(heartbeat)
|
|
240
|
+
if (sseResponse === res) sseResponse = null
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function handleMessage(req, res) {
|
|
245
|
+
let body = ''
|
|
246
|
+
req.on('data', (chunk) => { body += chunk })
|
|
247
|
+
req.on('end', () => {
|
|
248
|
+
res.writeHead(202)
|
|
249
|
+
res.end('Accepted')
|
|
250
|
+
|
|
251
|
+
let rpcMsg
|
|
252
|
+
try { rpcMsg = JSON.parse(body) } catch { return }
|
|
253
|
+
|
|
254
|
+
const { id, method, params } = rpcMsg
|
|
255
|
+
|
|
256
|
+
if (method === 'initialize') {
|
|
257
|
+
sendSSE({
|
|
258
|
+
jsonrpc: '2.0', id,
|
|
259
|
+
result: {
|
|
260
|
+
protocolVersion: '2024-11-05',
|
|
261
|
+
capabilities: { tools: {} },
|
|
262
|
+
serverInfo: { name: 'codepocket-phone', version: '1.0.0' },
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
} else if (method === 'tools/list') {
|
|
266
|
+
sendSSE({ jsonrpc: '2.0', id, result: { tools: TOOLS } })
|
|
267
|
+
} else if (method === 'tools/call') {
|
|
268
|
+
handleToolCall(id, params)
|
|
269
|
+
} else if (method === 'notifications/initialized') {
|
|
270
|
+
// Client notification, no response needed
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function handleToolCall(rpcId, params) {
|
|
276
|
+
const toolName = params?.name
|
|
277
|
+
const toolArgs = params?.arguments || {}
|
|
278
|
+
|
|
279
|
+
// All phone tools are sent to the mini-program
|
|
280
|
+
const actionId = 'act_' + Array.from(
|
|
281
|
+
typeof crypto !== 'undefined' && crypto.getRandomValues
|
|
282
|
+
? crypto.getRandomValues(new Uint8Array(4))
|
|
283
|
+
: require('crypto').randomBytes(4)
|
|
284
|
+
).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
285
|
+
|
|
286
|
+
if (sendToRelayFn) {
|
|
287
|
+
sendToRelayFn({
|
|
288
|
+
type: 'phone-action',
|
|
289
|
+
actionId,
|
|
290
|
+
tool: toolName,
|
|
291
|
+
params: toolArgs,
|
|
292
|
+
timeout: 60000,
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Block and wait for phone-action-response (60s timeout)
|
|
297
|
+
const result = await new Promise((resolve) => {
|
|
298
|
+
const timer = setTimeout(() => {
|
|
299
|
+
pendingPhoneActions.delete(actionId)
|
|
300
|
+
resolve({ success: false, error: '操作超时,手机端未响应' })
|
|
301
|
+
}, 60000)
|
|
302
|
+
|
|
303
|
+
pendingPhoneActions.set(actionId, { resolve, rpcId, timer })
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
sendSSE({ jsonrpc: '2.0', id: rpcId, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } })
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Called when a phone-action-response is received from the relay.
|
|
311
|
+
*/
|
|
312
|
+
function handlePhoneActionResponse(msg) {
|
|
313
|
+
const pending = pendingPhoneActions.get(msg.actionId)
|
|
314
|
+
if (!pending) return
|
|
315
|
+
|
|
316
|
+
clearTimeout(pending.timer)
|
|
317
|
+
pendingPhoneActions.delete(msg.actionId)
|
|
318
|
+
pending.resolve({
|
|
319
|
+
accepted: msg.accepted,
|
|
320
|
+
result: msg.result,
|
|
321
|
+
error: msg.error,
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function sendSSE(data) {
|
|
326
|
+
if (sseResponse && !sseResponse.writableEnded) {
|
|
327
|
+
sseResponse.write(`event: message\ndata: ${JSON.stringify(data)}\n\n`)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = { startMcpServer, handlePhoneActionResponse }
|
package/vendor/cpkt-hub
ADDED
|
Binary file
|