cursor-feedback 1.1.1 → 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.
@@ -56,6 +56,17 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
56
56
  const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
57
57
  const http = __importStar(require("http"));
58
58
  const os = __importStar(require("os"));
59
+ const fs = __importStar(require("fs"));
60
+ const path = __importStar(require("path"));
61
+ const feishu_js_1 = require("./feishu.js");
62
+ // ⚠️ MCP stdio 协议要求 stdout 只承载 JSON-RPC 消息。第三方库(尤其飞书 SDK 的内置 logger,
63
+ // 输出形如 "[info]: [...]")会用 console.log/info/debug 往 stdout 打日志,一旦混入就会让
64
+ // Cursor 端 JSON 解析失败、连接进入 failed(表现为 "Not connected")。这里在进程最早期把这三个
65
+ // 统一重定向到 stderr,保证 stdout 纯净。
66
+ // 注:warn/error 在 Node 中本就走 stderr;MCP SDK 用 process.stdout.write 直接发消息,不经 console,故不受影响。
67
+ console.log = (...args) => console.error(...args);
68
+ console.info = (...args) => console.error(...args);
69
+ console.debug = (...args) => console.error(...args);
59
70
  // 调试日志输出到 stderr(不影响 stdio 通信)
60
71
  function debugLog(message) {
61
72
  const timestamp = new Date().toISOString();
@@ -71,17 +82,46 @@ class McpFeedbackServer {
71
82
  this.pendingRequests = new Map();
72
83
  // 当前反馈请求
73
84
  this.currentRequest = null;
85
+ // 飞书桥接(可选;未配置时不加载 SDK、零开销)
86
+ this.feishu = new feishu_js_1.FeishuBridge();
87
+ this.feishuRoutingSetup = false;
88
+ /**
89
+ * 抢跑暂存:用户在「上一轮反馈刚结束、下一轮卡片还没注册」的空窗里直接发来的「无主」消息。
90
+ * 暂存后等下一轮 pending 一注册立即兑现,避免回复石沉大海(修复用户反馈的竞态 bug)。
91
+ */
92
+ this.stashedInbound = null;
93
+ // 最近一次被飞书回复 resolve 的请求:供插件端精确区分「飞书回复」与「超时」,
94
+ // 避免超时续期(request 也会短暂为 null)被误判成飞书回复而错误重置面板。
95
+ this.lastFeishuResolved = null;
96
+ // 超时续期开关真相源:磁盘(UI 改过、跨窗口共享、重启保留)> env MCP_AUTO_RETRY > 默认开。
97
+ // 由 POST /api/settings/autoRetry 广播写入,不再从轮询 query 同步(曾致多窗口互相覆盖抖动)。
98
+ // null = 没被磁盘/UI 覆盖,poll 时回退环境变量 / 默认值。
99
+ this.autoRetryOverride = null;
100
+ // server 级设置的磁盘持久化路径(与飞书凭证同目录,跨进程/重启共享真相源)
101
+ this.settingsStorePath = path.join(os.homedir(), '.cursor-feedback', 'settings.json');
74
102
  // 所属工作区(只在 AI 调用 feedback 时设置)
75
103
  // 只有来自同一工作区的轮询才会更新活动时间
76
104
  this.ownerWorkspace = null;
105
+ // Cursor 在 spawn 本 MCP server 时注入的「真实所属工作区」,精确指向发起对话的那个窗口。
106
+ // 这是可靠的归属信号:AI 传的 project_directory 可能被填成对话里聊到的另一个项目,
107
+ // 用它路由会把反馈发到错误窗口、当前窗口收不到(用户反馈的 bug)。拿不到时回退到 AI 传参。
108
+ this.realWorkspace = (process.env.WORKSPACE_FOLDER_PATHS || '').split(',')[0].trim() || null;
77
109
  // Server 启动时间
78
110
  this.startTime = Date.now();
79
111
  // 最近一次活动时间(任意 HTTP 轮询 / MCP 调用都会刷新)
80
112
  // 用于 watchdog 判定是否所有 Cursor 窗口都已关闭
81
113
  this.lastActivityTime = Date.now();
114
+ // 「我的窗口」最近一次轮询时间:仅被「workspace 匹配本实例归属」的插件轮询刷新。
115
+ // 关键:lastActivityTime 会被任意 HTTP(含别的活跃窗口对全端口的扫描)刷新,无法识别僵尸——
116
+ // 别的窗口的端口扫描会给已关窗口的残留 server 续命。改用本字段判定「我的窗口是否还开着」。
117
+ this.lastOwnerPollTime = Date.now();
118
+ // 是否曾收到过「我的窗口」的插件轮询:区分「Cursor 插件环境」与「无插件 host(如 Claude Desktop)」。
119
+ // 只有曾被自己窗口插件轮询过的实例,才用 lastOwnerPollTime 判定僵尸 / 退出,避免误杀无插件 host。
120
+ this.everOwnerPolled = false;
82
121
  // 看门狗定时器(兜底退出,防止进程残留 / CPU 占满)
83
122
  this.watchdogTimer = null;
84
123
  this.port = port;
124
+ this.basePort = port;
85
125
  this.server = new index_js_1.Server({
86
126
  name: 'cursor-feedback-server',
87
127
  version: '0.0.1',
@@ -91,6 +131,7 @@ class McpFeedbackServer {
91
131
  },
92
132
  });
93
133
  this.setupHandlers();
134
+ this.setupFeishuRouting();
94
135
  }
95
136
  setupHandlers() {
96
137
  // 列出可用工具
@@ -114,18 +155,20 @@ class McpFeedbackServer {
114
155
  用户消息 → 你工作 → 调用此工具(附带摘要)→ 用户反馈 → 你工作 → 再次调用此工具 → ...
115
156
 
116
157
  参数:
117
- project_directory: 【必填】项目目录的绝对路径,用于正确匹配项目窗口
158
+ project_directory: 【必填】当前所在项目空间的根目录绝对路径(指你打开的工作区,不是对话里聊到的项目)
118
159
  summary: AI 工作摘要,供用户查看(支持 Markdown)
119
160
  timeout: 等待用户反馈的超时时间(秒),默认 300 秒(5 分钟)
120
161
 
121
162
  返回:
122
- 用户反馈内容(文字/图片/文件路径),或 timeout/cancelled 状态`,
163
+ - 正常:用户反馈内容(文字/图片/文件路径)
164
+ - 超时续期信号:返回内容以 [TIMEOUT_KEEP_WAITING] 开头时,表示用户暂未回复(注意:这是超时,不是取消),你必须立即用相同 project_directory 再次调用本工具继续等待,不要结束对话
165
+ - 超时结束:以 [TIMEOUT_END] 开头时(用户已关闭续期),可以结束本轮`,
123
166
  inputSchema: {
124
167
  type: 'object',
125
168
  properties: {
126
169
  project_directory: {
127
170
  type: 'string',
128
- description: 'Project directory absolute path (REQUIRED - must be the absolute path of current workspace for correct project matching)',
171
+ description: 'Absolute path of the root of the project workspace you are currently in (REQUIRED) — the workspace that is open, not a project merely discussed in the conversation. 【当前所在项目空间的根目录,指你打开的工作区】',
129
172
  },
130
173
  summary: {
131
174
  type: 'string',
@@ -194,7 +237,9 @@ class McpFeedbackServer {
194
237
  isError: true,
195
238
  };
196
239
  }
197
- const projectDir = args.project_directory;
240
+ // 归属以 Cursor 注入的真实工作区为准(修复:AI 把 project_directory 传成对话里聊到的
241
+ // 另一个项目时,反馈会发到错误窗口、当前窗口收不到)。拿不到 env 时才回退到 AI 传参。
242
+ const projectDir = this.realWorkspace || args.project_directory;
198
243
  // summary 支持别名 message
199
244
  const summary = args?.summary || args?.message || '我已完成您的请求。';
200
245
  // 超时时间优先级:环境变量 > 工具参数 > 默认值(300秒)
@@ -202,9 +247,13 @@ class McpFeedbackServer {
202
247
  const envTimeout = process.env.MCP_FEEDBACK_TIMEOUT ? parseInt(process.env.MCP_FEEDBACK_TIMEOUT, 10) : null;
203
248
  const timeout = envTimeout || args?.timeout || 300;
204
249
  const requestId = this.generateRequestId();
250
+ // 作废上一轮残留的「僵尸」请求:单实例单窗口同时只应有一个活跃反馈请求。
251
+ // 旧请求多半是对话被压缩 / 客户端取消后还卡在 await 的残留(要等 timeout 才自然结束),
252
+ // 不清理会让 pendingCount 虚高、全局视角误判「多个窗口在等」
253
+ //(即你看到的两个一模一样的 cursor-feedback-extension)。
254
+ this.cancelStalePending();
205
255
  // AI 调用 feedback 时设置 ownerWorkspace(这是唯一正确的时机)
206
- const normalize = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
207
- this.ownerWorkspace = normalize(projectDir);
256
+ this.ownerWorkspace = this.normalizePath(projectDir);
208
257
  debugLog(`Owner workspace set to: ${this.ownerWorkspace}`);
209
258
  // 创建反馈请求
210
259
  this.currentRequest = {
@@ -219,16 +268,25 @@ class McpFeedbackServer {
219
268
  debugLog(`Project: ${projectDir}`);
220
269
  debugLog(`Timeout: ${timeout}s`);
221
270
  debugLog(`Waiting for VS Code extension to collect feedback...`);
271
+ // 飞书:已配置则推送一张反馈请求卡片(失败不影响插件主流程)
272
+ if (this.feishu.isConfigured()) {
273
+ this.feishu.sendFeedbackCard(requestId, summary, projectDir).catch(() => { });
274
+ }
222
275
  try {
223
276
  // 等待用户反馈
224
277
  const result = await this.waitForFeedback(requestId, timeout * 1000);
225
278
  if (!result) {
226
- debugLog('Feedback request timed out or cancelled');
227
- // MCP_AUTO_RETRY 环境变量控制是否显示重试指示,默认 true
228
- const autoRetry = process.env.MCP_AUTO_RETRY !== 'false';
279
+ debugLog('Feedback wait window elapsed without user input');
280
+ // 超时续期开关:MCP_AUTO_RETRY=false 时关闭(超时即结束),默认开启(超时返回续期提醒)。
281
+ // 关键:这里【绝不能】说成 "cancelled"——那会让 AI 误以为用户主动取消而结束对话,
282
+ // 这正是“超时后 AI 直接收尾、不再续命”的根因。改为明确区分“超时 ≠ 取消”。
283
+ // 优先用 UI 开关(autoRetryOverride),未设置时回退到环境变量 / 默认开启
284
+ const autoRetry = this.autoRetryOverride !== null
285
+ ? this.autoRetryOverride
286
+ : (process.env.MCP_AUTO_RETRY !== 'false');
229
287
  const timeoutMessage = autoRetry
230
- ? 'User cancelled the feedback or timeout. 【必须立即重新调用此工具继续等待用户反馈】'
231
- : 'User cancelled the feedback or timeout.';
288
+ ? '[TIMEOUT_KEEP_WAITING] No feedback was submitted within the wait window. This is a TIMEOUT, NOT a cancellation — the user is most likely still away from the keyboard. You MUST immediately call the interactive_feedback tool again with the same project_directory to keep waiting. Do NOT end the conversation, do NOT summarize the task as finished. Only stop calling it when the user EXPLICITLY tells you to stop. 【这是超时续期,并非用户取消:请立即用相同 project_directory 再次调用 interactive_feedback 继续等待,不要结束对话、不要当作任务完成。】'
289
+ : '[TIMEOUT_END] No feedback was submitted within the wait window and timeout auto-continue is disabled (MCP_AUTO_RETRY=false). You may end this turn now. 【超时未收到反馈,且已关闭超时续期(MCP_AUTO_RETRY=false),可以结束本轮。】';
232
290
  return {
233
291
  content: [
234
292
  {
@@ -292,8 +350,200 @@ class McpFeedbackServer {
292
350
  };
293
351
  }
294
352
  finally {
295
- this.currentRequest = null;
353
+ // 只清自己的 currentRequest:被 cancelStalePending 提前作废的旧(僵尸)请求,其 finally 触发时
354
+ // currentRequest 可能已是新一轮请求,绝不能误清空(否则刚发起的请求会被旧请求的收尾抹掉)。
355
+ if (this.currentRequest?.id === requestId)
356
+ this.currentRequest = null;
357
+ // 仅清「待回复」计数,保留卡片 message_id 映射:用户回复一张已结束(超时/已提交/已回复)的
358
+ // 旧卡片时仍能被 resolveParent 命中、回执「已结束」,不会因映射丢失被当成陌生卡片广播、石沉大海。
359
+ this.feishu.clearPending(requestId);
360
+ }
361
+ }
362
+ /**
363
+ * 配置飞书消息路由:用户在飞书回复 → 匹配 requestId → resolve 本地 pending 或跨实例转发。
364
+ */
365
+ setupFeishuRouting() {
366
+ if (this.feishuRoutingSetup)
367
+ return;
368
+ this.feishuRoutingSetup = true;
369
+ this.feishu.setOnReply(async (reply) => {
370
+ const { parentId, text, chatId } = reply;
371
+ const messageId = reply.messageId || '';
372
+ const images = reply.images || [];
373
+ const files = reply.files || [];
374
+ // 文本、图片、文件任一非空都算有效反馈(此前只认文本,导致纯图片/文件被丢弃)
375
+ if (!text && images.length === 0 && files.length === 0)
376
+ return;
377
+ if (parentId) {
378
+ // 用户「回复」了某条卡片 → 用 parent_id 精确路由
379
+ const reqId = this.feishu.resolveParent(parentId);
380
+ if (reqId) {
381
+ // 这张卡片是本实例发出的
382
+ if (this.pendingRequests.has(reqId)) {
383
+ this.submitFromFeishu(reqId, text, chatId, images, files, messageId);
384
+ }
385
+ else {
386
+ // 卡片是本实例发的,但请求已结束(超时 / 已回复)→ 明确告知,不必广播
387
+ this.feishu.replyText(chatId, '这条反馈已经结束了(可能已超时或已被回复)。');
388
+ }
389
+ }
390
+ else {
391
+ // 不是本实例发出的卡片 → 广播给其他窗口的 server
392
+ this.broadcastFeishuInbound(parentId, text, chatId, images, files, messageId);
393
+ }
394
+ return;
395
+ }
396
+ // 无 parent_id(用户直接发消息,未指明卡片)→ 需要「全局视角」:
397
+ // 飞书消息只会推给某一个窗口的 server,而各窗口各自维护 pending;只看本实例会误判
398
+ // (每个窗口都以为自己唯一),导致多窗口时被随机一个窗口抢走。故先跨实例汇总全局再决策。
399
+ const localPending = this.feishu.listPending();
400
+ const remote = await this.queryRemotePending();
401
+ const remoteCount = remote.reduce((n, r) => n + r.list.length, 0);
402
+ const globalCount = localPending.length + remoteCount;
403
+ if (globalCount === 0) {
404
+ // 抢跑兜底:此刻全局无人等待,但很可能 AI 正要发起下一轮(卡片还没注册)。
405
+ // 暂存到收到消息的本实例,等下一轮 pending 注册时立即兑现。
406
+ this.stashedInbound = { text, chatId, images, files, at: Date.now(), messageId };
407
+ // 轻回执:先给消息加 ✅ 表情(无未读);兑现到具体任务时不再重复回执
408
+ this.feishu.reactDone(messageId || undefined, chatId);
409
+ }
410
+ else if (globalCount === 1) {
411
+ if (localPending.length === 1) {
412
+ // 唯一的等待就在本窗口 → 直接给
413
+ this.submitFromFeishu(localPending[0].requestId, text, chatId, images, files, messageId);
414
+ }
415
+ else {
416
+ // 唯一的等待在另一个窗口 → 转发让它认领
417
+ const target = remote.find((r) => r.list.length === 1);
418
+ if (target)
419
+ this.forwardOrphan(target.port, text, chatId, images, files, messageId);
420
+ }
421
+ }
422
+ else {
423
+ // 多个窗口在等 → 不猜。项目名可能重复(同一项目开多窗口),逐项列出也无法区分,
424
+ // 故不逐项列,直接引导用户去点想回复的那张卡片——回复哪张就精确回到哪个窗口。
425
+ this.feishu.replyText(chatId, `当前有 ${globalCount} 个窗口在等反馈,没法自动判断你要回复哪个。\n请直接在你想回复的那张卡片上点「回复」再发,回复哪张就回到哪个窗口。`);
426
+ }
427
+ });
428
+ }
429
+ projectName(dir) {
430
+ return dir.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || dir;
431
+ }
432
+ /** 归一化路径用于跨进程/跨平台比对(统一斜杠、去尾斜杠、小写) */
433
+ normalizePath(p) {
434
+ return (p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
435
+ }
436
+ /** 飞书回复命中某 requestId → resolve 该 pending,并回执用户 */
437
+ submitFromFeishu(requestId, text, chatId, images = [], attachedFiles = [], ackMessageId) {
438
+ const pending = this.pendingRequests.get(requestId);
439
+ if (!pending)
440
+ return;
441
+ clearTimeout(pending.timeout);
442
+ pending.resolve({
443
+ interactive_feedback: text,
444
+ images,
445
+ attachedFiles,
446
+ project_directory: this.currentRequest?.projectDir || '',
447
+ });
448
+ this.pendingRequests.delete(requestId);
449
+ this.feishu.clearPending(requestId);
450
+ this.lastFeishuResolved = { id: requestId, at: Date.now() };
451
+ debugLog(`Feedback resolved from Feishu for request: ${requestId} ` +
452
+ `(images=${images.length}, files=${attachedFiles.length})`);
453
+ // 轻回执:给用户消息加 ✅ 表情,不产生新的未读消息(方案B)。
454
+ // ackMessageId 为 undefined 表示回执已在别处完成(如抢跑暂存时已 react),跳过。
455
+ if (ackMessageId !== undefined && chatId) {
456
+ this.feishu.reactDone(ackMessageId || undefined, chatId);
457
+ }
458
+ }
459
+ /** 把飞书回复广播给其他窗口的 server(跨实例路由兜底) */
460
+ broadcastFeishuInbound(parentId, text, chatId, images = [], attachedFiles = [], messageId) {
461
+ const body = JSON.stringify({ parentId, text, chatId, images, attachedFiles, messageId });
462
+ for (let p = this.basePort; p < this.basePort + McpFeedbackServer.PORT_SCAN_RANGE; p++) {
463
+ if (p === this.port)
464
+ continue;
465
+ const req = http.request({
466
+ hostname: '127.0.0.1',
467
+ port: p,
468
+ path: '/api/feishu/inbound',
469
+ method: 'POST',
470
+ timeout: 2000,
471
+ headers: {
472
+ 'Content-Type': 'application/json',
473
+ 'Content-Length': Buffer.byteLength(body),
474
+ },
475
+ }, (res) => {
476
+ res.on('data', () => { });
477
+ res.on('end', () => { });
478
+ });
479
+ req.on('error', () => { });
480
+ req.on('timeout', () => req.destroy());
481
+ req.write(body);
482
+ req.end();
483
+ }
484
+ }
485
+ /**
486
+ * 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案)。
487
+ * 用于无 parent_id 的「无主消息」:飞书只推给一个窗口,需汇总全局才能正确决策。
488
+ */
489
+ queryRemotePending() {
490
+ const ports = [];
491
+ for (let p = this.basePort; p < this.basePort + McpFeedbackServer.PORT_SCAN_RANGE; p++) {
492
+ if (p !== this.port)
493
+ ports.push(p);
494
+ }
495
+ return Promise.all(ports.map((port) => this.fetchRemotePending(port)));
496
+ }
497
+ fetchRemotePending(port) {
498
+ return new Promise((resolve) => {
499
+ const req = http.request({ hostname: '127.0.0.1', port, path: '/api/feishu/pending', method: 'GET', timeout: 1500 }, (res) => {
500
+ let body = '';
501
+ res.on('data', (chunk) => { body += chunk.toString(); });
502
+ res.on('end', () => {
503
+ try {
504
+ const j = JSON.parse(body);
505
+ resolve({ port, list: Array.isArray(j.list) ? j.list : [] });
506
+ }
507
+ catch {
508
+ resolve({ port, list: [] });
509
+ }
510
+ });
511
+ });
512
+ req.on('error', () => resolve({ port, list: [] }));
513
+ req.on('timeout', () => { req.destroy(); resolve({ port, list: [] }); });
514
+ req.end();
515
+ });
516
+ }
517
+ /** 把「无主消息」转发给全局唯一持有 pending 的那个远程窗口,让它认领提交 */
518
+ forwardOrphan(port, text, chatId, images = [], attachedFiles = [], messageId) {
519
+ const body = JSON.stringify({ text, chatId, images, attachedFiles, messageId });
520
+ const req = http.request({
521
+ hostname: '127.0.0.1',
522
+ port,
523
+ path: '/api/feishu/inbound-orphan',
524
+ method: 'POST',
525
+ timeout: 2000,
526
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
527
+ }, (res) => { res.on('data', () => { }); res.on('end', () => { }); });
528
+ req.on('error', () => { });
529
+ req.on('timeout', () => req.destroy());
530
+ req.write(body);
531
+ req.end();
532
+ }
533
+ /**
534
+ * 作废本实例所有未决的反馈请求(在每次新请求进来时调用)。
535
+ * 单个 server 实例服务单个 Cursor 窗口的单个对话,同时只应有一个活跃反馈请求;
536
+ * resolve(null) 让旧的 await 自然返回走超时分支,其 finally 因 id 不匹配不会误清新请求。
537
+ */
538
+ cancelStalePending() {
539
+ if (this.pendingRequests.size === 0)
540
+ return;
541
+ for (const [reqId, pending] of this.pendingRequests) {
542
+ clearTimeout(pending.timeout);
543
+ pending.resolve(null);
544
+ this.feishu.clearPending(reqId);
296
545
  }
546
+ this.pendingRequests.clear();
297
547
  }
298
548
  /**
299
549
  * 等待用户反馈
@@ -303,6 +553,7 @@ class McpFeedbackServer {
303
553
  const timeout = setTimeout(() => {
304
554
  debugLog(`Request ${requestId} timed out`);
305
555
  this.pendingRequests.delete(requestId);
556
+ // 飞书侧的清理统一交给 handleInteractiveFeedback 的 finally(clearPending),这里不再重复。
306
557
  resolve(null);
307
558
  }, timeoutMs);
308
559
  this.pendingRequests.set(requestId, {
@@ -310,8 +561,26 @@ class McpFeedbackServer {
310
561
  reject: () => resolve(null),
311
562
  timeout
312
563
  });
564
+ // 抢跑兑现:用户在空窗期「无主」发来的消息已被暂存,本轮 pending 一注册立即作为回复提交
565
+ this.tryConsumeStash(requestId);
313
566
  });
314
567
  }
568
+ /**
569
+ * 抢跑暂存兑现:若存在近期「无主」飞书消息,立即作为指定请求的回复提交。
570
+ * 超过 STASH_TTL_MS 的暂存视为与当前任务无关,直接丢弃。
571
+ */
572
+ tryConsumeStash(requestId) {
573
+ const stash = this.stashedInbound;
574
+ if (!stash)
575
+ return;
576
+ this.stashedInbound = null;
577
+ if (Date.now() - stash.at > McpFeedbackServer.STASH_TTL_MS) {
578
+ debugLog('Stashed inbound expired, dropped');
579
+ return;
580
+ }
581
+ debugLog(`Consuming stashed inbound for request: ${requestId}`);
582
+ this.submitFromFeishu(requestId, stash.text, stash.chatId, stash.images, stash.files);
583
+ }
315
584
  /**
316
585
  * 处理获取系统信息请求
317
586
  */
@@ -357,7 +626,7 @@ class McpFeedbackServer {
357
626
  * 生成唯一的请求 ID
358
627
  */
359
628
  generateRequestId() {
360
- return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
629
+ return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
361
630
  }
362
631
  /**
363
632
  * 检查端口是否被我们的 MCP Server 占用,如果是则请求关闭
@@ -442,23 +711,41 @@ class McpFeedbackServer {
442
711
  try {
443
712
  // 任何来自插件的 HTTP 请求都视为 Cursor 仍存活的心跳(供 watchdog 判定)
444
713
  this.lastActivityTime = Date.now();
445
- // 设置 CORS
446
- res.setHeader('Access-Control-Allow-Origin', '*');
447
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
448
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
714
+ // 不设置 CORS 头:本地 API 仅供本插件(Node http 客户端,不受同源策略限制)调用。
715
+ // 去掉 Access-Control-Allow-Origin: * 后,同机浏览器里的恶意网页无法跨域读取
716
+ // /api/feedback/current(可能含 AI summary 中的代码),靠浏览器同源策略兜底。
449
717
  if (req.method === 'OPTIONS') {
450
- res.writeHead(200);
718
+ res.writeHead(204);
451
719
  res.end();
452
720
  return;
453
721
  }
454
722
  // 获取当前反馈请求
455
723
  if (req.method === 'GET' && req.url?.startsWith('/api/feedback/current')) {
724
+ // autoRetry 不再从轮询 query 同步(曾致多窗口互相覆盖、抖动):
725
+ // 改由 POST /api/settings/autoRetry 广播 + 磁盘真相源,poll 只回读做 UI 回显。
726
+ try {
727
+ const u = new URL(req.url, 'http://127.0.0.1');
728
+ // 「我的窗口」心跳:仅当轮询带的 workspace 匹配本实例归属时刷新。
729
+ // 别的活跃窗口对全端口的扫描虽刷 lastActivityTime,但 workspace 不匹配、不刷此字段,
730
+ // 故已关窗口的残留 server 不会被别人续命,可被 watchdog / 僵尸自检识别。
731
+ const ws = this.normalizePath(u.searchParams.get('workspace') || '');
732
+ if (ws && (ws === this.ownerWorkspace || ws === this.normalizePath(this.realWorkspace || ''))) {
733
+ this.lastOwnerPollTime = Date.now();
734
+ this.everOwnerPolled = true;
735
+ }
736
+ }
737
+ catch {
738
+ // 忽略解析错误
739
+ }
456
740
  res.writeHead(200, { 'Content-Type': 'application/json' });
457
- // 返回当前请求、ownerWorkspace startTime
741
+ // 返回当前请求、ownerWorkspace、startTime,以及当前生效的 autoRetry(供 UI 初始同步)
458
742
  res.end(JSON.stringify({
459
743
  request: this.currentRequest || null,
460
744
  ownerWorkspace: this.ownerWorkspace,
461
745
  startTime: this.startTime,
746
+ autoRetry: this.autoRetryOverride !== null ? this.autoRetryOverride : (process.env.MCP_AUTO_RETRY !== 'false'),
747
+ feishu: this.feishu.getStatus(),
748
+ feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
462
749
  }));
463
750
  return;
464
751
  }
@@ -478,6 +765,7 @@ class McpFeedbackServer {
478
765
  clearTimeout(pending.timeout);
479
766
  pending.resolve(feedback);
480
767
  this.pendingRequests.delete(requestId);
768
+ // 飞书侧清理同样交给 finally(clearPending)统一处理,这里不再重复。
481
769
  res.writeHead(200, { 'Content-Type': 'application/json' });
482
770
  res.end(JSON.stringify({ success: true }));
483
771
  }
@@ -495,6 +783,128 @@ class McpFeedbackServer {
495
783
  });
496
784
  return;
497
785
  }
786
+ // 配置飞书(来自插件 UI;凭证未变只补 chatId,变了则重建长连接)
787
+ if (req.method === 'POST' && req.url === '/api/feishu/config') {
788
+ let body = '';
789
+ req.on('data', (chunk) => { body += chunk.toString(); });
790
+ req.on('end', () => {
791
+ (async () => {
792
+ try {
793
+ const cfg = JSON.parse(body);
794
+ // 凭证以磁盘为全局真相源:用户改/删 → 持久化(touched=true)+ configure。
795
+ // 多窗口下插件会把同一份 POST 给所有 server,各自写同一磁盘文件、行为一致;
796
+ // 不再回退 env——用户主动清空就是空(touched 标记让重启后也尊重,不被 env 复活)。
797
+ this.feishu.writePersistedConfig({
798
+ appId: cfg.appId || '',
799
+ appSecret: cfg.appSecret || '',
800
+ enabled: cfg.enabled,
801
+ ackReaction: cfg.ackReaction,
802
+ touched: true,
803
+ });
804
+ await this.feishu.configure(cfg);
805
+ res.writeHead(200, { 'Content-Type': 'application/json' });
806
+ res.end(JSON.stringify({ success: true, status: this.feishu.getStatus() }));
807
+ }
808
+ catch {
809
+ res.writeHead(400, { 'Content-Type': 'application/json' });
810
+ res.end(JSON.stringify({ error: 'Invalid config' }));
811
+ }
812
+ })();
813
+ });
814
+ return;
815
+ }
816
+ // 超时续期开关(来自插件 UI;磁盘做真相源,跨窗口/重启一致;不再走轮询 query)
817
+ if (req.method === 'POST' && req.url === '/api/settings/autoRetry') {
818
+ let body = '';
819
+ req.on('data', (chunk) => { body += chunk.toString(); });
820
+ req.on('end', () => {
821
+ try {
822
+ const { autoRetry } = JSON.parse(body);
823
+ if (typeof autoRetry === 'boolean') {
824
+ this.autoRetryOverride = autoRetry;
825
+ this.writePersistedSettings({ autoRetry });
826
+ }
827
+ res.writeHead(200, { 'Content-Type': 'application/json' });
828
+ res.end(JSON.stringify({ success: true, autoRetry: this.autoRetryOverride }));
829
+ }
830
+ catch {
831
+ res.writeHead(400, { 'Content-Type': 'application/json' });
832
+ res.end(JSON.stringify({ error: 'Invalid body' }));
833
+ }
834
+ });
835
+ return;
836
+ }
837
+ // 解除飞书绑定(用户「删绑定」后重新发消息即可重新绑定)
838
+ if (req.method === 'POST' && req.url === '/api/feishu/unbind') {
839
+ this.feishu.unbind();
840
+ res.writeHead(200, { 'Content-Type': 'application/json' });
841
+ res.end(JSON.stringify({ success: true, status: this.feishu.getStatus() }));
842
+ return;
843
+ }
844
+ // 跨实例转发的飞书回复(由其他窗口的 server 广播过来)
845
+ if (req.method === 'POST' && req.url === '/api/feishu/inbound') {
846
+ let body = '';
847
+ req.on('data', (chunk) => { body += chunk.toString(); });
848
+ req.on('end', () => {
849
+ try {
850
+ const { parentId, text, chatId, images, attachedFiles, messageId } = JSON.parse(body);
851
+ const reqId = this.feishu.resolveParent(parentId);
852
+ if (reqId && this.pendingRequests.has(reqId)) {
853
+ this.submitFromFeishu(reqId, text, chatId, images || [], attachedFiles || [], messageId || '');
854
+ res.writeHead(200, { 'Content-Type': 'application/json' });
855
+ res.end(JSON.stringify({ handled: true }));
856
+ }
857
+ else {
858
+ res.writeHead(200, { 'Content-Type': 'application/json' });
859
+ res.end(JSON.stringify({ handled: false }));
860
+ }
861
+ }
862
+ catch {
863
+ res.writeHead(400);
864
+ res.end('{}');
865
+ }
866
+ });
867
+ return;
868
+ }
869
+ // 查询本实例当前 pending 列表(供其他窗口做「全局视角」汇总;只暴露项目名)
870
+ if (req.method === 'GET' && req.url === '/api/feishu/pending') {
871
+ // 僵尸自检:本实例曾被自己窗口插件轮询,但已久未收到(只剩别的窗口在扫端口)→ 我的窗口已关,
872
+ // 不再上报 pending,避免污染全局计数(用户反馈的「没开两个窗口却显示 2 个同名」即源于此)。
873
+ const ownerGone = this.everOwnerPolled &&
874
+ Date.now() - this.lastOwnerPollTime > McpFeedbackServer.OWNER_IDLE_MS;
875
+ const list = ownerGone
876
+ ? []
877
+ : this.feishu.listPending().map((p) => ({ projectName: this.projectName(p.projectDir) }));
878
+ res.writeHead(200, { 'Content-Type': 'application/json' });
879
+ res.end(JSON.stringify({ count: list.length, list }));
880
+ return;
881
+ }
882
+ // 接收「无主消息」转发:仅当本实例恰好持有唯一 pending 时认领提交
883
+ if (req.method === 'POST' && req.url === '/api/feishu/inbound-orphan') {
884
+ let body = '';
885
+ req.on('data', (chunk) => { body += chunk.toString(); });
886
+ req.on('end', () => {
887
+ try {
888
+ const { text, chatId, images, attachedFiles, messageId } = JSON.parse(body);
889
+ if (this.feishu.pendingCount() === 1) {
890
+ const only = this.feishu.theOnlyPendingId();
891
+ if (only) {
892
+ this.submitFromFeishu(only, text, chatId, images || [], attachedFiles || [], messageId || '');
893
+ res.writeHead(200, { 'Content-Type': 'application/json' });
894
+ res.end(JSON.stringify({ claimed: true }));
895
+ return;
896
+ }
897
+ }
898
+ res.writeHead(200, { 'Content-Type': 'application/json' });
899
+ res.end(JSON.stringify({ claimed: false }));
900
+ }
901
+ catch {
902
+ res.writeHead(400);
903
+ res.end('{}');
904
+ }
905
+ });
906
+ return;
907
+ }
498
908
  // 健康检查
499
909
  if (req.method === 'GET' && req.url === '/api/health') {
500
910
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -534,7 +944,12 @@ class McpFeedbackServer {
534
944
  });
535
945
  this.httpServer.on('error', async (err) => {
536
946
  if (err.code === 'EADDRINUSE') {
537
- // 端口被占用,使用下一个端口(不关闭旧的 MCP Server,支持多项目独立运行)
947
+ // 端口被占用,使用下一个端口(不关闭旧的 MCP Server,支持多项目独立运行)。
948
+ // 限制在扫描范围内:超出则放弃,避免 server 端口跑到插件扫描不到的区间导致失联。
949
+ if (this.port - this.basePort >= McpFeedbackServer.PORT_SCAN_RANGE - 1) {
950
+ reject(new Error(`No free port in scan range ${this.basePort}-${this.basePort + McpFeedbackServer.PORT_SCAN_RANGE - 1}`));
951
+ return;
952
+ }
538
953
  debugLog(`Port ${this.port} is already in use, trying next port...`);
539
954
  this.httpServer?.close();
540
955
  this.port++;
@@ -550,6 +965,45 @@ class McpFeedbackServer {
550
965
  });
551
966
  });
552
967
  }
968
+ /**
969
+ * 从 mcp.json 的 env 读取飞书默认配置。
970
+ * 服务于「没有 webview 面板」的 MCP host(如 Claude Desktop / CLI):它们无法用插件 UI 配飞书,
971
+ * 只能在 mcp.json 里配 FEISHU_*。优先级:插件 UI(非空凭证)> 这里的 env > 代码默认(开关全开)。
972
+ */
973
+ /** 读取持久化的 server 设置(无文件返回 null)。 */
974
+ readPersistedSettings() {
975
+ try {
976
+ const raw = JSON.parse(fs.readFileSync(this.settingsStorePath, 'utf-8'));
977
+ return raw && typeof raw === 'object' ? raw : null;
978
+ }
979
+ catch {
980
+ return null;
981
+ }
982
+ }
983
+ /** 持久化 server 设置到磁盘(merge 写入,跨进程共享、重启不丢)。 */
984
+ writePersistedSettings(patch) {
985
+ try {
986
+ const cur = this.readPersistedSettings() || {};
987
+ const next = { ...cur, ...patch };
988
+ fs.mkdirSync(path.dirname(this.settingsStorePath), { recursive: true });
989
+ fs.writeFileSync(this.settingsStorePath, JSON.stringify(next), 'utf-8');
990
+ }
991
+ catch {
992
+ // 持久化失败不影响运行
993
+ }
994
+ }
995
+ readFeishuEnvConfig() {
996
+ const appId = (process.env.FEISHU_APP_ID || '').trim();
997
+ const appSecret = (process.env.FEISHU_APP_SECRET || '').trim();
998
+ if (!appId || !appSecret)
999
+ return null;
1000
+ return {
1001
+ appId,
1002
+ appSecret,
1003
+ enabled: process.env.FEISHU_ENABLED !== 'false',
1004
+ ackReaction: process.env.FEISHU_ACK !== 'false',
1005
+ };
1006
+ }
553
1007
  /**
554
1008
  * 启动服务器
555
1009
  */
@@ -558,9 +1012,33 @@ class McpFeedbackServer {
558
1012
  debugLog('Starting MCP Feedback Server...');
559
1013
  // 启动 HTTP 服务器
560
1014
  await this.startHttpServer();
1015
+ // 飞书凭证优先级:用户在面板配过(磁盘持久化、跨窗口共享、含主动清空)> mcp.json env > 不启用。
1016
+ // 磁盘有 touched 记录就尊重它(哪怕凭证为空 = 用户主动清空,也不被 env 复活);
1017
+ // 没碰过才回退 env(无面板的 host 靠 env 启用)。
1018
+ const persisted = this.feishu.readPersistedConfig();
1019
+ if (persisted && persisted.touched) {
1020
+ this.feishu.configure(persisted).catch(() => { });
1021
+ }
1022
+ else {
1023
+ const envFeishu = this.readFeishuEnvConfig();
1024
+ if (envFeishu) {
1025
+ this.feishu.configure(envFeishu).catch(() => { });
1026
+ }
1027
+ }
1028
+ // 超时续期开关同款优先级:磁盘(UI 改过、跨窗口共享)> env MCP_AUTO_RETRY > 默认开。
1029
+ const settings = this.readPersistedSettings();
1030
+ if (settings && typeof settings.autoRetry === 'boolean') {
1031
+ this.autoRetryOverride = settings.autoRetry;
1032
+ }
561
1033
  // 启动 MCP stdio 传输
562
1034
  const transport = new stdio_js_1.StdioServerTransport();
563
1035
  await this.server.connect(transport);
1036
+ // stdio 关闭(Cursor reload / 关窗 / 重启该 MCP)→ 立即退出,避免旧进程残留堆积
1037
+ transport.onclose = () => {
1038
+ debugLog('stdio transport closed, exiting...');
1039
+ this.stop();
1040
+ setTimeout(() => process.exit(0), 100);
1041
+ };
564
1042
  // 启动看门狗兜底退出(防止 Cursor 关闭后进程残留 / CPU 占满)
565
1043
  this.startWatchdog();
566
1044
  debugLog('MCP Server started successfully');
@@ -601,6 +1079,14 @@ class McpFeedbackServer {
601
1079
  this.stop();
602
1080
  process.exit(0);
603
1081
  }
1082
+ // 防线 3:本实例曾被自己窗口插件轮询,但久未再收到(只剩别的活跃窗口在扫端口刷 lastActivityTime)
1083
+ // → 我的窗口已关、本实例是僵尸 → 退出,避免持续被全局 pending 计数误算成「另一个在等的窗口」。
1084
+ const ownerIdle = Date.now() - this.lastOwnerPollTime;
1085
+ if (this.everOwnerPolled && ownerIdle > IDLE_TIMEOUT) {
1086
+ debugLog(`Owner window idle for ${ownerIdle}ms, stale instance, exiting...`);
1087
+ this.stop();
1088
+ process.exit(0);
1089
+ }
604
1090
  }, 5000);
605
1091
  // 不让 watchdog 自身阻止进程的自然退出
606
1092
  this.watchdogTimer.unref();
@@ -631,6 +1117,14 @@ class McpFeedbackServer {
631
1117
  debugLog('Server stopped');
632
1118
  }
633
1119
  }
1120
+ McpFeedbackServer.PORT_SCAN_RANGE = 20; // 与插件端扫描范围保持一致
1121
+ /** 抢跑暂存有效期:超过则视为与当前任务无关,丢弃 */
1122
+ McpFeedbackServer.STASH_TTL_MS = 8000;
1123
+ /**
1124
+ * 「我的窗口」判定为已关闭的空闲阈值:本实例曾被自己窗口插件轮询,但超过此时长没再收到
1125
+ *(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
1126
+ */
1127
+ McpFeedbackServer.OWNER_IDLE_MS = 12000;
634
1128
  // 主函数
635
1129
  async function main() {
636
1130
  const port = 61927;