@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.
@@ -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.41",
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",