cursor-mcp-feedback 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/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +25 -0
- package/dist/main.d.ts +13 -0
- package/dist/main.js +438 -0
- package/dist/mcp-app.html +227 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +241 -0
- package/dist/session-store.d.ts +4 -0
- package/dist/session-store.js +76 -0
- package/hooks/block-cursor-mcp-feedback.js +152 -0
- package/hooks/consume-pending.js +88 -0
- package/hooks/session-utils.js +253 -0
- package/package.json +73 -0
- package/rules/cursor-mcp-feedback.mdc +27 -0
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as fsPromises from "node:fs/promises";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { log, logObj } from "./logger.js";
|
|
9
|
+
import { createFreshSession, resolveSession, SESSIONS_DIR } from "./session-store.js";
|
|
10
|
+
const DIST_DIR = import.meta.filename.endsWith(".ts")
|
|
11
|
+
? path.join(import.meta.dirname, "dist")
|
|
12
|
+
: import.meta.dirname;
|
|
13
|
+
const SETTINGS_FILE = path.join(os.homedir(), ".cursor-mcp-feedback-settings.json");
|
|
14
|
+
const PENDING_DIR = path.join(os.homedir(), ".cursor-mcp-feedback");
|
|
15
|
+
const PENDING_FILE = path.join(PENDING_DIR, "pending.json");
|
|
16
|
+
const DEFAULT_SETTINGS = {
|
|
17
|
+
autoAppend: true,
|
|
18
|
+
autoAppendText: "After completing, call interactive_feedback to check in with me.",
|
|
19
|
+
};
|
|
20
|
+
async function saveSettings(settings) {
|
|
21
|
+
const current = await (async () => {
|
|
22
|
+
try {
|
|
23
|
+
return { ...DEFAULT_SETTINGS, ...JSON.parse(await fsPromises.readFile(SETTINGS_FILE, "utf-8")) };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { ...DEFAULT_SETTINGS };
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
29
|
+
const merged = { ...current, ...settings };
|
|
30
|
+
await fsPromises.writeFile(SETTINGS_FILE, JSON.stringify(merged, null, 2), "utf-8");
|
|
31
|
+
return merged;
|
|
32
|
+
}
|
|
33
|
+
const FEEDBACK_SUFFIX = '\n\n<!-- Read through all your active rules now. For each rule, check: am I following it? If you have forgotten any, Read ~/.cursor/rules/ before continuing. -->';
|
|
34
|
+
const FEEDBACK_TIMEOUT_MS = (parseInt(process.env.MCP_FEEDBACK_TIMEOUT || "86400", 10)) * 1000;
|
|
35
|
+
const CLEANUP_INTERVAL = 3600_000; // 1h
|
|
36
|
+
export function createServer() {
|
|
37
|
+
const pendingResolves = new Map();
|
|
38
|
+
const pendingTimers = new Map();
|
|
39
|
+
const server = new McpServer({
|
|
40
|
+
name: "cursor-mcp-feedback",
|
|
41
|
+
version: "1.0.0",
|
|
42
|
+
});
|
|
43
|
+
const resourceUri = "ui://interactive-feedback/mcp-app.html";
|
|
44
|
+
// ── Global scanner: every 1s, scan for resolved files matching our Promises.
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
catch { /* ignore */ }
|
|
49
|
+
const scanner = setInterval(() => {
|
|
50
|
+
// Log current pendingResolves map
|
|
51
|
+
const mapEntries = [...pendingResolves.entries()].map(([k]) => k.substring(0, 8) + "...");
|
|
52
|
+
// log(`scanner: pendingResolves=[${mapEntries.join(", ")}]`);
|
|
53
|
+
let files;
|
|
54
|
+
try {
|
|
55
|
+
files = fs.readdirSync(SESSIONS_DIR);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const f of files) {
|
|
61
|
+
if (!f.endsWith(".json"))
|
|
62
|
+
continue;
|
|
63
|
+
const fp = path.join(SESSIONS_DIR, f);
|
|
64
|
+
const sid = path.basename(f, ".json");
|
|
65
|
+
const resolve = pendingResolves.get(sid);
|
|
66
|
+
if (!resolve)
|
|
67
|
+
continue; // no matching Promise in this process
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(fp, "utf-8");
|
|
70
|
+
const data = JSON.parse(raw);
|
|
71
|
+
if (data.status !== "resolved")
|
|
72
|
+
continue;
|
|
73
|
+
log(`=== scanner: resolved file matches pending session === sid=${sid} file=${fp} content=${raw}`);
|
|
74
|
+
const t = pendingTimers.get(sid);
|
|
75
|
+
if (t) {
|
|
76
|
+
clearTimeout(t);
|
|
77
|
+
pendingTimers.delete(sid);
|
|
78
|
+
}
|
|
79
|
+
pendingResolves.delete(sid);
|
|
80
|
+
try {
|
|
81
|
+
fs.unlinkSync(fp);
|
|
82
|
+
}
|
|
83
|
+
catch { /* ignore */ }
|
|
84
|
+
resolve({ feedback: data.feedback || "", images: data.images, userInput: data.userInput });
|
|
85
|
+
log(` → Promise resolved for ${sid}`);
|
|
86
|
+
}
|
|
87
|
+
catch { /* skip bad files */ }
|
|
88
|
+
}
|
|
89
|
+
}, 1000);
|
|
90
|
+
scanner.unref();
|
|
91
|
+
// ── Cleanup stale files every hour
|
|
92
|
+
const cleanup = setInterval(() => {
|
|
93
|
+
const cutoff = Date.now() - FEEDBACK_TIMEOUT_MS;
|
|
94
|
+
let files;
|
|
95
|
+
try {
|
|
96
|
+
files = fs.readdirSync(SESSIONS_DIR);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const f of files) {
|
|
102
|
+
if (!f.endsWith(".json"))
|
|
103
|
+
continue;
|
|
104
|
+
try {
|
|
105
|
+
const raw = fs.readFileSync(path.join(SESSIONS_DIR, f), "utf-8");
|
|
106
|
+
const data = JSON.parse(raw);
|
|
107
|
+
if (data.createdAt && data.createdAt < cutoff) {
|
|
108
|
+
try {
|
|
109
|
+
fs.unlinkSync(path.join(SESSIONS_DIR, f));
|
|
110
|
+
}
|
|
111
|
+
catch { /* ignore */ }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
try {
|
|
116
|
+
fs.unlinkSync(path.join(SESSIONS_DIR, f));
|
|
117
|
+
}
|
|
118
|
+
catch { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}, CLEANUP_INTERVAL);
|
|
122
|
+
cleanup.unref();
|
|
123
|
+
// Clean timers on process exit
|
|
124
|
+
process.once("beforeExit", () => { clearInterval(scanner); clearInterval(cleanup); });
|
|
125
|
+
// ── interactive_feedback ──
|
|
126
|
+
registerAppTool(server, "interactive_feedback", {
|
|
127
|
+
title: "Interactive Feedback",
|
|
128
|
+
description: "Request interactive feedback from the user.",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
summary: z.string().describe("Summary of what you have done so far."),
|
|
131
|
+
sessionId: z.string().uuid("sessionId must be a valid UUID v4. Generate one via: node -e \"console.log(crypto.randomUUID())\"").describe("Unique session identifier. MUST generate a UUID v4 before each call to ensure uniqueness. Generate via: node -e \"console.log(crypto.randomUUID())\" | python3 -c \"import uuid; print(uuid.uuid4())\" | uuidgen"),
|
|
132
|
+
},
|
|
133
|
+
_meta: { ui: { resourceUri } },
|
|
134
|
+
}, async ({ summary, sessionId }) => {
|
|
135
|
+
createFreshSession(summary.trim(), sessionId);
|
|
136
|
+
const filePath = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
137
|
+
let fileContent = "(unreadable)";
|
|
138
|
+
try {
|
|
139
|
+
fileContent = fs.readFileSync(filePath, "utf-8");
|
|
140
|
+
}
|
|
141
|
+
catch { }
|
|
142
|
+
log(`=== interactive_feedback === sessionId=${sessionId} file=${filePath} content=${fileContent}`);
|
|
143
|
+
let pollError;
|
|
144
|
+
let result;
|
|
145
|
+
try {
|
|
146
|
+
result = await new Promise((resolve, reject) => {
|
|
147
|
+
pendingResolves.set(sessionId, resolve);
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
pendingResolves.delete(sessionId);
|
|
150
|
+
pendingTimers.delete(sessionId);
|
|
151
|
+
reject(new Error(`Feedback timeout (${FEEDBACK_TIMEOUT_MS / 1000}s)`));
|
|
152
|
+
}, FEEDBACK_TIMEOUT_MS);
|
|
153
|
+
timer.unref();
|
|
154
|
+
pendingTimers.set(sessionId, timer);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
pollError = e;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
pendingTimers.delete(sessionId);
|
|
162
|
+
pendingResolves.delete(sessionId);
|
|
163
|
+
}
|
|
164
|
+
if (pollError)
|
|
165
|
+
throw pollError;
|
|
166
|
+
if (!result)
|
|
167
|
+
throw new Error("Feedback cancelled");
|
|
168
|
+
logObj("interactive_feedback: resolved", { sessionId });
|
|
169
|
+
const content = [
|
|
170
|
+
{ type: "text", text: result.feedback + FEEDBACK_SUFFIX },
|
|
171
|
+
{ type: "text", text: JSON.stringify({ _mcp_meta: true, sessionId, userInput: result.userInput ?? result.feedback }) },
|
|
172
|
+
];
|
|
173
|
+
if (result.images) {
|
|
174
|
+
for (const img of result.images) {
|
|
175
|
+
content.push({ type: "image", data: img, mimeType: "image/png" });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { content };
|
|
179
|
+
});
|
|
180
|
+
// ── submit_feedback: just write the file, nothing more. Scanner picks it up. ──
|
|
181
|
+
registerAppTool(server, "submit_feedback", {
|
|
182
|
+
title: "Submit Feedback",
|
|
183
|
+
description: "Submit user feedback.",
|
|
184
|
+
inputSchema: {
|
|
185
|
+
feedback: z.string().describe("The user's feedback text."),
|
|
186
|
+
images: z.array(z.string()).optional().describe("Optional base64-encoded PNG images."),
|
|
187
|
+
sessionId: z.string().optional().describe("Session ID."),
|
|
188
|
+
},
|
|
189
|
+
_meta: { ui: { resourceUri, visibility: ["app"] } },
|
|
190
|
+
}, async ({ feedback, images, sessionId: sid }) => {
|
|
191
|
+
let userInput = feedback;
|
|
192
|
+
let autoAppend = false;
|
|
193
|
+
let autoAppendText = "";
|
|
194
|
+
let skipAppend = false;
|
|
195
|
+
const settingsMatch = feedback.match(/<!--mcp-settings:(.*?)-->/s);
|
|
196
|
+
if (settingsMatch) {
|
|
197
|
+
try {
|
|
198
|
+
const embedded = JSON.parse(settingsMatch[1]);
|
|
199
|
+
autoAppend = embedded.autoAppend ?? false;
|
|
200
|
+
autoAppendText = embedded.autoAppendText || "";
|
|
201
|
+
skipAppend = embedded.skipAppend ?? false;
|
|
202
|
+
await saveSettings({ autoAppend, autoAppendText });
|
|
203
|
+
}
|
|
204
|
+
catch { /* ignore */ }
|
|
205
|
+
userInput = feedback.replace(/\s*<!--mcp-settings:.*?-->/s, "").trim();
|
|
206
|
+
}
|
|
207
|
+
let fullFeedback = userInput;
|
|
208
|
+
if (!skipAppend && autoAppend && autoAppendText.trim()) {
|
|
209
|
+
fullFeedback += "\n\n" + autoAppendText.trim();
|
|
210
|
+
}
|
|
211
|
+
log(`=== submit_feedback === sessionId=${sid || "(none)"} userInputLen=${userInput.length} fullLen=${fullFeedback.length}`);
|
|
212
|
+
if (sid) {
|
|
213
|
+
resolveSession(sid, fullFeedback, images ?? undefined, userInput);
|
|
214
|
+
const filePath = path.join(SESSIONS_DIR, `${sid}.json`);
|
|
215
|
+
let content = "(unreadable)";
|
|
216
|
+
try {
|
|
217
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
log(` → resolved file=${filePath} content=${content}`);
|
|
221
|
+
}
|
|
222
|
+
return { content: [{ type: "text", text: "Feedback submitted." }] };
|
|
223
|
+
});
|
|
224
|
+
// ── get_system_info ──
|
|
225
|
+
server.tool("get_system_info", "Get system information.", {}, async () => {
|
|
226
|
+
return { content: [{ type: "text", text: JSON.stringify({ platform: process.platform, arch: process.arch, nodeVersion: process.version, homeDir: os.homedir() }, null, 2) }] };
|
|
227
|
+
});
|
|
228
|
+
// ── UI resource ──
|
|
229
|
+
registerAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => {
|
|
230
|
+
let html = await fsPromises.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
|
|
231
|
+
let settings = { autoAppend: true, autoAppendText: DEFAULT_SETTINGS.autoAppendText };
|
|
232
|
+
try {
|
|
233
|
+
const raw = await fsPromises.readFile(SETTINGS_FILE, "utf-8");
|
|
234
|
+
settings = { ...settings, ...JSON.parse(raw) };
|
|
235
|
+
}
|
|
236
|
+
catch { /* use defaults */ }
|
|
237
|
+
html = html.replace("<head>", `<head><script id="mcp-feedback-settings" type="application/json">${JSON.stringify(settings)}</script>`);
|
|
238
|
+
return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] };
|
|
239
|
+
});
|
|
240
|
+
return server;
|
|
241
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
declare const SESSIONS_DIR: string;
|
|
2
|
+
export { SESSIONS_DIR };
|
|
3
|
+
export declare function createFreshSession(summary: string, sessionId: string): string;
|
|
4
|
+
export declare function resolveSession(sessionId: string, feedback: string, images?: string[], userInput?: string): void;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import { logObj } from "./logger.js";
|
|
5
|
+
const SESSIONS_DIR = path.join(os.homedir(), ".cursor-mcp-feedback", "sessions");
|
|
6
|
+
export { SESSIONS_DIR };
|
|
7
|
+
function ensureDir() {
|
|
8
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
function sessionPath(sessionId) {
|
|
11
|
+
return path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
12
|
+
}
|
|
13
|
+
function writeSession(record) {
|
|
14
|
+
ensureDir();
|
|
15
|
+
fs.writeFileSync(sessionPath(record.sessionId), JSON.stringify(record, null, 2), "utf-8");
|
|
16
|
+
}
|
|
17
|
+
function listSessions() {
|
|
18
|
+
ensureDir();
|
|
19
|
+
try {
|
|
20
|
+
const files = fs.readdirSync(SESSIONS_DIR);
|
|
21
|
+
const sessions = [];
|
|
22
|
+
for (const f of files) {
|
|
23
|
+
if (!f.endsWith(".json"))
|
|
24
|
+
continue;
|
|
25
|
+
try {
|
|
26
|
+
const raw = fs.readFileSync(path.join(SESSIONS_DIR, f), "utf-8");
|
|
27
|
+
sessions.push(JSON.parse(raw));
|
|
28
|
+
}
|
|
29
|
+
catch { /* skip corrupt files */ }
|
|
30
|
+
}
|
|
31
|
+
return sessions;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function cleanExpiredSessions() {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
for (const s of listSessions()) {
|
|
40
|
+
if (s.status === "pending" && (now - s.createdAt) > 86400000) {
|
|
41
|
+
s.status = "timedout";
|
|
42
|
+
writeSession(s);
|
|
43
|
+
cleanupSession(s.sessionId);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function cleanupSession(sessionId) {
|
|
48
|
+
try {
|
|
49
|
+
fs.unlinkSync(sessionPath(sessionId));
|
|
50
|
+
}
|
|
51
|
+
catch { /* ignore */ }
|
|
52
|
+
}
|
|
53
|
+
// ── Public API ──
|
|
54
|
+
export function createFreshSession(summary, sessionId) {
|
|
55
|
+
ensureDir();
|
|
56
|
+
cleanExpiredSessions();
|
|
57
|
+
writeSession({ sessionId, summary, createdAt: Date.now(), status: "pending" });
|
|
58
|
+
logObj("session-store: created:fresh", { sessionId, summaryLen: summary.length });
|
|
59
|
+
return sessionId;
|
|
60
|
+
}
|
|
61
|
+
export function resolveSession(sessionId, feedback, images, userInput) {
|
|
62
|
+
const sessions = listSessions();
|
|
63
|
+
const idx = sessions.findIndex(s => s.sessionId === sessionId && s.status === "pending");
|
|
64
|
+
if (idx !== -1) {
|
|
65
|
+
sessions[idx].status = "resolved";
|
|
66
|
+
sessions[idx].feedback = feedback;
|
|
67
|
+
sessions[idx].userInput = userInput;
|
|
68
|
+
if (images?.length)
|
|
69
|
+
sessions[idx].images = images;
|
|
70
|
+
writeSession(sessions[idx]);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
writeSession({ sessionId, summary: "", createdAt: Date.now(), status: "resolved", feedback, images, userInput });
|
|
74
|
+
}
|
|
75
|
+
logObj("session-store: resolved", { sessionId });
|
|
76
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cursor hook: session lifecycle + subagent protection.
|
|
3
|
+
//
|
|
4
|
+
// Events (argv[2]):
|
|
5
|
+
// sessionStart — create per-session directory & meta
|
|
6
|
+
// sessionEnd — mark session inactive
|
|
7
|
+
// subagentStart — record subagent to negative list
|
|
8
|
+
// subagentStop — remove subagent
|
|
9
|
+
// beforeMCPExecution — deny cursor-mcp-feedback calls from subagents
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const su = require('./session-utils');
|
|
14
|
+
|
|
15
|
+
let raw = '';
|
|
16
|
+
process.stdin.setEncoding('utf8');
|
|
17
|
+
process.stdin.on('data', (c) => { raw += c; });
|
|
18
|
+
process.stdin.on('end', () => {
|
|
19
|
+
const event = process.argv[2];
|
|
20
|
+
let input;
|
|
21
|
+
try { input = JSON.parse(raw); } catch { input = {}; }
|
|
22
|
+
|
|
23
|
+
if (event === 'sessionStart') {
|
|
24
|
+
su.cleanupStaleSessions();
|
|
25
|
+
const convId = input.session_id || input.conversation_id;
|
|
26
|
+
if (convId) {
|
|
27
|
+
const workspace = (input.workspace_roots || [])[0] || '';
|
|
28
|
+
su.createSession(convId, {
|
|
29
|
+
workspace,
|
|
30
|
+
mode: input.composer_mode || 'agent',
|
|
31
|
+
model: input.model || '',
|
|
32
|
+
is_background: input.is_background_agent || false,
|
|
33
|
+
transcript_path: input.transcript_path || null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
su.writeHookOutput({});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (event === 'sessionEnd') {
|
|
41
|
+
const convId = input.session_id || input.conversation_id;
|
|
42
|
+
if (convId) {
|
|
43
|
+
su.endSession(convId, input.reason || 'unknown');
|
|
44
|
+
}
|
|
45
|
+
su.writeHookOutput({});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (event === 'subagentStart') {
|
|
50
|
+
const subagentId = input.subagent_id;
|
|
51
|
+
const parentConvId = input.parent_conversation_id || input.conversation_id;
|
|
52
|
+
if (subagentId) {
|
|
53
|
+
su.recordSubagent(subagentId, parentConvId);
|
|
54
|
+
}
|
|
55
|
+
su.writeHookOutput({});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (event === 'subagentStop') {
|
|
60
|
+
const subagentId = input.subagent_id || input.conversation_id;
|
|
61
|
+
if (subagentId) su.removeSubagent(subagentId);
|
|
62
|
+
su.writeHookOutput({});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (event === 'beforeMCPExecution') {
|
|
67
|
+
const command = (input.command || '').toLowerCase();
|
|
68
|
+
const isFeedbackApp =
|
|
69
|
+
command.includes('cursor-mcp-feedback') &&
|
|
70
|
+
!command.includes('mcp-feedback-enhanced');
|
|
71
|
+
|
|
72
|
+
if (!isFeedbackApp) {
|
|
73
|
+
su.writeHookOutput({});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const convId = input.conversation_id;
|
|
78
|
+
if (convId && su.isSubagent(convId)) {
|
|
79
|
+
su.writeHookOutput({
|
|
80
|
+
permission: 'deny',
|
|
81
|
+
user_message: '[Hook] Blocked cursor-mcp-feedback call from subagent',
|
|
82
|
+
agent_message:
|
|
83
|
+
'DENIED by beforeMCPExecution hook. ' +
|
|
84
|
+
'cursor-mcp-feedback tools are for the MAIN agent only. ' +
|
|
85
|
+
'Return your results as text in your final response instead.',
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
su.writeHookOutput({});
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (event === 'afterMCPExecution') {
|
|
94
|
+
const convId = input.conversation_id;
|
|
95
|
+
const toolName = input.tool_name || '';
|
|
96
|
+
|
|
97
|
+
const isFeedback = toolName === 'interactive_feedback' || toolName === 'submit_feedback';
|
|
98
|
+
if (convId && isFeedback) {
|
|
99
|
+
const eventsFile = require('path').join(su.sessionDir(convId), 'events.jsonl');
|
|
100
|
+
let toolInput = {};
|
|
101
|
+
try { toolInput = typeof input.tool_input === 'string' ? JSON.parse(input.tool_input) : (input.tool_input || {}); } catch {}
|
|
102
|
+
|
|
103
|
+
if (toolName === 'interactive_feedback') {
|
|
104
|
+
su.appendJsonl(eventsFile, {
|
|
105
|
+
type: 'feedback_request',
|
|
106
|
+
conversation_id: convId,
|
|
107
|
+
summary: toolInput.summary || '',
|
|
108
|
+
source: 'hook',
|
|
109
|
+
ts: input.start_time_ms || (Date.now() - 5000),
|
|
110
|
+
});
|
|
111
|
+
let feedbackText = '';
|
|
112
|
+
try {
|
|
113
|
+
const resultJson = typeof input.result_json === 'string' ? JSON.parse(input.result_json) : (input.result_json || {});
|
|
114
|
+
if (resultJson.isError) { /* skip error responses */ }
|
|
115
|
+
else {
|
|
116
|
+
const content = resultJson.content || [];
|
|
117
|
+
for (const item of content) {
|
|
118
|
+
if (item.type === 'text' && item.text) {
|
|
119
|
+
feedbackText = item.text
|
|
120
|
+
.split('\n<!-- ')[0]
|
|
121
|
+
.split('\nAfter completing, call interactive_feedback')[0]
|
|
122
|
+
.trim();
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch {}
|
|
128
|
+
if (feedbackText) {
|
|
129
|
+
su.appendJsonl(eventsFile, {
|
|
130
|
+
type: 'feedback_response',
|
|
131
|
+
conversation_id: convId,
|
|
132
|
+
text: feedbackText.slice(0, 2000),
|
|
133
|
+
source: 'hook',
|
|
134
|
+
ts: Date.now(),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
su.appendJsonl(eventsFile, {
|
|
139
|
+
type: 'feedback_response',
|
|
140
|
+
conversation_id: convId,
|
|
141
|
+
text: (toolInput.feedback || '').slice(0, 2000),
|
|
142
|
+
source: 'hook',
|
|
143
|
+
ts: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
su.writeHookOutput({});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
su.writeHookOutput({});
|
|
152
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cursor preToolUse hook: consume pending messages for the current session.
|
|
3
|
+
//
|
|
4
|
+
// 1. Read conversation_id from hook input
|
|
5
|
+
// 2. If subagent → skip
|
|
6
|
+
// 3. Check per-session pending (sessions/<conv_id>/pending.json)
|
|
7
|
+
// 4. Fallback: check global pending (~/.cursor-mcp-feedback/pending.json)
|
|
8
|
+
// 5. If messages found → deny tool, inject messages as agent_message
|
|
9
|
+
// 6. Update session activity timestamp
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const su = require('./session-utils');
|
|
14
|
+
|
|
15
|
+
const ALLOWLIST = new Set([
|
|
16
|
+
'interactive_feedback', 'submit_feedback', 'manage_pending',
|
|
17
|
+
'task', 'read', 'readfile', 'glob', 'grep', 'semanticsearch',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
let raw = '';
|
|
21
|
+
process.stdin.setEncoding('utf8');
|
|
22
|
+
process.stdin.on('data', (c) => { raw += c; });
|
|
23
|
+
process.stdin.on('end', () => {
|
|
24
|
+
let input;
|
|
25
|
+
try { input = JSON.parse(raw); } catch { input = {}; }
|
|
26
|
+
|
|
27
|
+
const toolName = (input.tool_name || '').toLowerCase();
|
|
28
|
+
if (ALLOWLIST.has(toolName)) {
|
|
29
|
+
su.writeHookOutput({});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const convId = input.conversation_id;
|
|
34
|
+
|
|
35
|
+
if (convId && su.isSubagent(convId)) {
|
|
36
|
+
su.writeHookOutput({});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// preToolUse is the primary session lifecycle driver.
|
|
41
|
+
// Lazy creation: if no session dir exists, create it.
|
|
42
|
+
// Activity update: always touch last_activity.
|
|
43
|
+
// Periodic cleanup: run stale session cleanup every ~5 minutes.
|
|
44
|
+
if (convId) {
|
|
45
|
+
su.ensureSession(convId, {
|
|
46
|
+
workspace: (input.workspace_roots || [])[0] || '',
|
|
47
|
+
mode: input.composer_mode || 'agent',
|
|
48
|
+
model: input.model || '',
|
|
49
|
+
transcript_path: input.transcript_path || null,
|
|
50
|
+
});
|
|
51
|
+
su.touchSessionActivity(convId);
|
|
52
|
+
su.maybeCleanup();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Try per-session pending first
|
|
56
|
+
let consumed = null;
|
|
57
|
+
if (convId) {
|
|
58
|
+
consumed = su.consumeSessionPending(convId, toolName);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fallback to global pending
|
|
62
|
+
if (!consumed) {
|
|
63
|
+
const globalMsgs = su.readGlobalPending();
|
|
64
|
+
if (globalMsgs.length > 0) {
|
|
65
|
+
const combined = globalMsgs.map((m) => m.text || m).join('\n\n---\n\n');
|
|
66
|
+
su.writeGlobalPending([]);
|
|
67
|
+
consumed = { text: combined, count: globalMsgs.length };
|
|
68
|
+
|
|
69
|
+
if (convId) {
|
|
70
|
+
su.appendJsonl(
|
|
71
|
+
require('path').join(su.sessionDir(convId), 'events.jsonl'),
|
|
72
|
+
{ type: 'pending_consumed', text: combined, count: consumed.count,
|
|
73
|
+
consumed_by_tool: toolName, source: 'global', ts: Date.now() }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (consumed) {
|
|
80
|
+
su.writeHookOutput({
|
|
81
|
+
permission: 'deny',
|
|
82
|
+
user_message: `Pending message delivered (${consumed.count})`,
|
|
83
|
+
agent_message: `[User Feedback] The user has queued the following message(s) for you:\n\n${consumed.text}\n\nPlease address this feedback before continuing with your current task.`,
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
su.writeHookOutput({});
|
|
87
|
+
}
|
|
88
|
+
});
|