@vibe-lark/larkpal 0.1.41 → 0.1.42
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/dist/main.mjs +936 -795
- package/dist/mcp-server.mjs +367 -0
- package/package.json +2 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/mcp-server/host-bridge.ts
|
|
6
|
+
/**
|
|
7
|
+
* Host Bridge — 与宿主层的 HTTP 通信桥
|
|
8
|
+
*
|
|
9
|
+
* 负责通过 HTTP 回调与 LarkPal 宿主层通信:
|
|
10
|
+
* - 发送 tool 调用回调到宿主层
|
|
11
|
+
* - 轮询等待宿主层 resolve 结果(用于阻塞式 tool)
|
|
12
|
+
* - fire-and-forget 通知(用于非阻塞式 tool)
|
|
13
|
+
*/
|
|
14
|
+
/** 日志输出到 stderr(stdout 被 MCP 协议占用) */
|
|
15
|
+
function log$2(level, message, meta) {
|
|
16
|
+
const entry = {
|
|
17
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
18
|
+
level,
|
|
19
|
+
component: "mcp-server/host-bridge",
|
|
20
|
+
message,
|
|
21
|
+
...meta
|
|
22
|
+
};
|
|
23
|
+
process.stderr.write(JSON.stringify(entry) + "\n");
|
|
24
|
+
}
|
|
25
|
+
/** 轮询配置 */
|
|
26
|
+
const POLL_INTERVAL_MS = 2e3;
|
|
27
|
+
const POLL_TIMEOUT_MS = 1800 * 1e3;
|
|
28
|
+
/** 获取宿主层基础 URL */
|
|
29
|
+
function getGatewayBaseUrl() {
|
|
30
|
+
return `http://localhost:${process.env["LARKPAL_GATEWAY_PORT"] || "3000"}`;
|
|
31
|
+
}
|
|
32
|
+
/** 获取当前 session ID */
|
|
33
|
+
function getSessionId() {
|
|
34
|
+
const sessionId = process.env["LARKPAL_MCP_SESSION_ID"];
|
|
35
|
+
if (!sessionId) throw new Error("环境变量 LARKPAL_MCP_SESSION_ID 未设置");
|
|
36
|
+
return sessionId;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 发送回调请求到宿主层
|
|
40
|
+
*
|
|
41
|
+
* @param tool - tool 名称
|
|
42
|
+
* @param params - tool 参数
|
|
43
|
+
* @returns requestId 用于后续轮询
|
|
44
|
+
*/
|
|
45
|
+
async function sendCallback(tool, params) {
|
|
46
|
+
const requestId = randomUUID();
|
|
47
|
+
const sessionId = getSessionId();
|
|
48
|
+
const url = `${getGatewayBaseUrl()}/api/mcp/callback`;
|
|
49
|
+
const body = {
|
|
50
|
+
requestId,
|
|
51
|
+
tool,
|
|
52
|
+
params,
|
|
53
|
+
sessionId
|
|
54
|
+
};
|
|
55
|
+
log$2("info", `发送回调请求到宿主层`, {
|
|
56
|
+
url,
|
|
57
|
+
requestId,
|
|
58
|
+
tool,
|
|
59
|
+
sessionId
|
|
60
|
+
});
|
|
61
|
+
const response = await fetch(url, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify(body)
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const text = await response.text().catch(() => "<读取失败>");
|
|
68
|
+
log$2("error", `回调请求失败`, {
|
|
69
|
+
requestId,
|
|
70
|
+
status: response.status,
|
|
71
|
+
responseBody: text
|
|
72
|
+
});
|
|
73
|
+
throw new Error(`宿主层回调请求失败: HTTP ${response.status} - ${text}`);
|
|
74
|
+
}
|
|
75
|
+
log$2("info", `回调请求成功`, {
|
|
76
|
+
requestId,
|
|
77
|
+
status: response.status
|
|
78
|
+
});
|
|
79
|
+
return requestId;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 轮询等待宿主层 resolve 结果
|
|
83
|
+
*
|
|
84
|
+
* @param requestId - 请求 ID
|
|
85
|
+
* @returns resolve 后的结果
|
|
86
|
+
*/
|
|
87
|
+
async function pollForResult(requestId) {
|
|
88
|
+
const url = `${getGatewayBaseUrl()}/api/mcp/resolve/${requestId}`;
|
|
89
|
+
const startTime = Date.now();
|
|
90
|
+
log$2("info", `开始轮询等待结果`, {
|
|
91
|
+
requestId,
|
|
92
|
+
url,
|
|
93
|
+
timeoutMs: POLL_TIMEOUT_MS
|
|
94
|
+
});
|
|
95
|
+
while (true) {
|
|
96
|
+
const elapsed = Date.now() - startTime;
|
|
97
|
+
if (elapsed >= POLL_TIMEOUT_MS) {
|
|
98
|
+
log$2("error", `轮询超时`, {
|
|
99
|
+
requestId,
|
|
100
|
+
elapsedMs: elapsed
|
|
101
|
+
});
|
|
102
|
+
throw new Error(`等待宿主层响应超时 (${POLL_TIMEOUT_MS / 1e3}s): requestId=${requestId}`);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(url, { method: "GET" });
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
const text = await response.text().catch(() => "<读取失败>");
|
|
108
|
+
log$2("warn", `轮询请求返回非 2xx`, {
|
|
109
|
+
requestId,
|
|
110
|
+
status: response.status,
|
|
111
|
+
responseBody: text
|
|
112
|
+
});
|
|
113
|
+
await sleep(POLL_INTERVAL_MS);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const data = await response.json();
|
|
117
|
+
if (data.status === "resolved") {
|
|
118
|
+
log$2("info", `轮询获得结果`, {
|
|
119
|
+
requestId,
|
|
120
|
+
status: data.status
|
|
121
|
+
});
|
|
122
|
+
return data.result;
|
|
123
|
+
}
|
|
124
|
+
if (data.status === "rejected") {
|
|
125
|
+
log$2("error", `请求被拒绝`, {
|
|
126
|
+
requestId,
|
|
127
|
+
error: data.error
|
|
128
|
+
});
|
|
129
|
+
throw new Error(`宿主层拒绝请求: ${data.error || "未知原因"}`);
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err instanceof Error && err.message.startsWith("宿主层拒绝请求")) throw err;
|
|
133
|
+
log$2("warn", `轮询发生网络错误,将重试`, {
|
|
134
|
+
requestId,
|
|
135
|
+
error: err instanceof Error ? err.message : String(err)
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
await sleep(POLL_INTERVAL_MS);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 发送 fire-and-forget 通知(不等待响应结果)
|
|
143
|
+
*
|
|
144
|
+
* @param tool - tool 名称
|
|
145
|
+
* @param params - tool 参数
|
|
146
|
+
*/
|
|
147
|
+
async function sendNotification(tool, params) {
|
|
148
|
+
const requestId = randomUUID();
|
|
149
|
+
const sessionId = getSessionId();
|
|
150
|
+
const url = `${getGatewayBaseUrl()}/api/mcp/callback`;
|
|
151
|
+
const body = {
|
|
152
|
+
requestId,
|
|
153
|
+
tool,
|
|
154
|
+
params,
|
|
155
|
+
sessionId
|
|
156
|
+
};
|
|
157
|
+
log$2("info", `发送 fire-and-forget 通知`, {
|
|
158
|
+
url,
|
|
159
|
+
requestId,
|
|
160
|
+
tool,
|
|
161
|
+
sessionId
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
log$2("info", `通知发送完成`, {
|
|
165
|
+
requestId,
|
|
166
|
+
status: (await fetch(url, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify(body)
|
|
170
|
+
})).status
|
|
171
|
+
});
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log$2("warn", `通知发送失败(已忽略)`, {
|
|
174
|
+
requestId,
|
|
175
|
+
error: err instanceof Error ? err.message : String(err)
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function sleep(ms) {
|
|
180
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
181
|
+
}
|
|
182
|
+
const hostBridge = {
|
|
183
|
+
sendCallback,
|
|
184
|
+
pollForResult,
|
|
185
|
+
sendNotification
|
|
186
|
+
};
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/mcp-server/tools.ts
|
|
189
|
+
/** 日志输出到 stderr */
|
|
190
|
+
function log$1(level, message, meta) {
|
|
191
|
+
const entry = {
|
|
192
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
193
|
+
level,
|
|
194
|
+
component: "mcp-server/tools",
|
|
195
|
+
message,
|
|
196
|
+
...meta
|
|
197
|
+
};
|
|
198
|
+
process.stderr.write(JSON.stringify(entry) + "\n");
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 注册所有 tool 到 MCP Server
|
|
202
|
+
*/
|
|
203
|
+
function registerTools(server) {
|
|
204
|
+
server.tool("ask_user", "向用户提问,等待用户回答。支持多阶段提问,每个阶段可包含选项或自由文本输入。", {
|
|
205
|
+
question: z.string().describe("主问题描述"),
|
|
206
|
+
phases: z.array(z.object({
|
|
207
|
+
id: z.string().describe("阶段唯一标识"),
|
|
208
|
+
title: z.string().describe("阶段标题"),
|
|
209
|
+
description: z.string().optional().describe("阶段详细描述"),
|
|
210
|
+
options: z.array(z.object({
|
|
211
|
+
label: z.string().describe("选项显示文本"),
|
|
212
|
+
value: z.string().describe("选项值")
|
|
213
|
+
})).optional().describe("可选项列表"),
|
|
214
|
+
allowFreeText: z.boolean().optional().describe("是否允许自由文本输入"),
|
|
215
|
+
required: z.boolean().optional().describe("是否必填")
|
|
216
|
+
})).describe("提问阶段列表")
|
|
217
|
+
}, async (params) => {
|
|
218
|
+
log$1("info", `ask_user 被调用`, {
|
|
219
|
+
question: params.question,
|
|
220
|
+
phaseCount: params.phases.length
|
|
221
|
+
});
|
|
222
|
+
try {
|
|
223
|
+
const toolParams = {
|
|
224
|
+
question: params.question,
|
|
225
|
+
phases: params.phases
|
|
226
|
+
};
|
|
227
|
+
const requestId = await hostBridge.sendCallback("ask_user", toolParams);
|
|
228
|
+
log$1("info", `等待用户回答`, { requestId });
|
|
229
|
+
const result = await hostBridge.pollForResult(requestId);
|
|
230
|
+
log$1("info", `用户已回答`, {
|
|
231
|
+
requestId,
|
|
232
|
+
answers: result
|
|
233
|
+
});
|
|
234
|
+
return { content: [{
|
|
235
|
+
type: "text",
|
|
236
|
+
text: JSON.stringify(result)
|
|
237
|
+
}] };
|
|
238
|
+
} catch (err) {
|
|
239
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
240
|
+
log$1("error", `ask_user 执行失败`, { error: errorMsg });
|
|
241
|
+
return {
|
|
242
|
+
content: [{
|
|
243
|
+
type: "text",
|
|
244
|
+
text: JSON.stringify({ error: errorMsg })
|
|
245
|
+
}],
|
|
246
|
+
isError: true
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
server.tool("request_permission", "请求飞书 API 权限。发送权限申请后等待用户授权。", {
|
|
251
|
+
scopes: z.array(z.string()).describe("需要的权限列表(如 \"contact:user.base:readonly\")"),
|
|
252
|
+
reason: z.string().optional().describe("申请权限的原因说明")
|
|
253
|
+
}, async (params) => {
|
|
254
|
+
log$1("info", `request_permission 被调用`, {
|
|
255
|
+
scopes: params.scopes,
|
|
256
|
+
reason: params.reason
|
|
257
|
+
});
|
|
258
|
+
try {
|
|
259
|
+
const toolParams = {
|
|
260
|
+
scopes: params.scopes,
|
|
261
|
+
reason: params.reason
|
|
262
|
+
};
|
|
263
|
+
const requestId = await hostBridge.sendCallback("request_permission", toolParams);
|
|
264
|
+
log$1("info", `等待权限审批`, { requestId });
|
|
265
|
+
const result = await hostBridge.pollForResult(requestId);
|
|
266
|
+
log$1("info", `权限审批完成`, {
|
|
267
|
+
requestId,
|
|
268
|
+
granted: result.granted
|
|
269
|
+
});
|
|
270
|
+
return { content: [{
|
|
271
|
+
type: "text",
|
|
272
|
+
text: JSON.stringify(result)
|
|
273
|
+
}] };
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
276
|
+
log$1("error", `request_permission 执行失败`, { error: errorMsg });
|
|
277
|
+
return {
|
|
278
|
+
content: [{
|
|
279
|
+
type: "text",
|
|
280
|
+
text: JSON.stringify({ error: errorMsg })
|
|
281
|
+
}],
|
|
282
|
+
isError: true
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
server.tool("signal_no_reply", "标记本次不需要回复用户。仅用于通知宿主层,立即返回。", { reason: z.string().describe("不回复的原因(仅用于日志记录)") }, async (params) => {
|
|
287
|
+
log$1("info", `signal_no_reply 被调用`, { reason: params.reason });
|
|
288
|
+
hostBridge.sendNotification("signal_no_reply", { reason: params.reason }).catch((err) => {
|
|
289
|
+
log$1("warn", `signal_no_reply 通知发送失败(已忽略)`, { error: err instanceof Error ? err.message : String(err) });
|
|
290
|
+
});
|
|
291
|
+
return { content: [{
|
|
292
|
+
type: "text",
|
|
293
|
+
text: JSON.stringify({ acknowledged: true })
|
|
294
|
+
}] };
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/mcp-server/index.ts
|
|
299
|
+
/**
|
|
300
|
+
* MCP Server 入口 — stdio transport
|
|
301
|
+
*
|
|
302
|
+
* LarkPal MCP Server 通过 stdio 与 Claude Code 通信,
|
|
303
|
+
* 提供 ask_user、request_permission、signal_no_reply 三个 tool。
|
|
304
|
+
*
|
|
305
|
+
* 启动方式:由 CC 通过 --mcp-config 参数 spawn 本进程。
|
|
306
|
+
* 环境变量:
|
|
307
|
+
* - LARKPAL_GATEWAY_PORT: 宿主层 HTTP 端口(默认 3000)
|
|
308
|
+
* - LARKPAL_MCP_SESSION_ID: 当前 CC session 的 ID
|
|
309
|
+
*/
|
|
310
|
+
/** 日志输出到 stderr(stdout 被 MCP 协议占用) */
|
|
311
|
+
function log(level, message, meta) {
|
|
312
|
+
const entry = {
|
|
313
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
314
|
+
level,
|
|
315
|
+
component: "mcp-server",
|
|
316
|
+
message,
|
|
317
|
+
...meta
|
|
318
|
+
};
|
|
319
|
+
process.stderr.write(JSON.stringify(entry) + "\n");
|
|
320
|
+
}
|
|
321
|
+
async function main() {
|
|
322
|
+
log("info", "MCP Server 启动", {
|
|
323
|
+
sessionId: process.env["LARKPAL_MCP_SESSION_ID"] || "<未设置>",
|
|
324
|
+
gatewayPort: process.env["LARKPAL_GATEWAY_PORT"] || "3000",
|
|
325
|
+
pid: process.pid
|
|
326
|
+
});
|
|
327
|
+
const server = new McpServer({
|
|
328
|
+
name: "larkpal",
|
|
329
|
+
version: "1.0.0"
|
|
330
|
+
});
|
|
331
|
+
registerTools(server);
|
|
332
|
+
const transport = new StdioServerTransport();
|
|
333
|
+
await server.connect(transport);
|
|
334
|
+
log("info", "MCP Server 已连接,等待 tool 调用");
|
|
335
|
+
process.stdin.on("close", () => {
|
|
336
|
+
log("info", "stdin 已关闭,MCP Server 准备退出");
|
|
337
|
+
process.exit(0);
|
|
338
|
+
});
|
|
339
|
+
process.on("SIGTERM", () => {
|
|
340
|
+
log("info", "收到 SIGTERM,MCP Server 退出");
|
|
341
|
+
process.exit(0);
|
|
342
|
+
});
|
|
343
|
+
process.on("SIGINT", () => {
|
|
344
|
+
log("info", "收到 SIGINT,MCP Server 退出");
|
|
345
|
+
process.exit(0);
|
|
346
|
+
});
|
|
347
|
+
process.on("uncaughtException", (err) => {
|
|
348
|
+
log("error", "未捕获的异常", {
|
|
349
|
+
error: err.message,
|
|
350
|
+
stack: err.stack
|
|
351
|
+
});
|
|
352
|
+
process.exit(1);
|
|
353
|
+
});
|
|
354
|
+
process.on("unhandledRejection", (reason) => {
|
|
355
|
+
log("error", "未处理的 Promise rejection", { reason: reason instanceof Error ? reason.message : String(reason) });
|
|
356
|
+
process.exit(1);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
main().catch((err) => {
|
|
360
|
+
log("error", "MCP Server 启动失败", {
|
|
361
|
+
error: err instanceof Error ? err.message : String(err),
|
|
362
|
+
stack: err instanceof Error ? err.stack : void 0
|
|
363
|
+
});
|
|
364
|
+
process.exit(1);
|
|
365
|
+
});
|
|
366
|
+
//#endregion
|
|
367
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibe-lark/larkpal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
4
4
|
"description": "LarkPal - Lark/Feishu bot service",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/main.mjs",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@larksuiteoapi/node-sdk": "^1.60.0",
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39
40
|
"@sinclair/typebox": "0.34.48",
|
|
40
41
|
"dotenv": "^17.4.2",
|
|
41
42
|
"express": "^5.1.0",
|