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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Shared session utilities for all hooks.
|
|
3
|
+
// Manages per-session file structure under ~/.cursor-mcp-feedback/sessions/
|
|
4
|
+
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const crypto = require("crypto");
|
|
9
|
+
|
|
10
|
+
const BASE_DIR = path.join(os.homedir(), ".cursor-mcp-feedback");
|
|
11
|
+
const SESSIONS_DIR = path.join(BASE_DIR, "sessions");
|
|
12
|
+
const ACTIVE_FILE = path.join(BASE_DIR, "active-sessions.json");
|
|
13
|
+
const SUBAGENT_FILE = path.join(BASE_DIR, "subagent-ids.json");
|
|
14
|
+
|
|
15
|
+
// Legacy global pending file (for backward compat with CLI)
|
|
16
|
+
const GLOBAL_PENDING = path.join(BASE_DIR, "pending.json");
|
|
17
|
+
|
|
18
|
+
function ensureDir(dir) {
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sessionDir(convId) {
|
|
23
|
+
return path.join(SESSIONS_DIR, convId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readJson(filePath, fallback) {
|
|
27
|
+
try { return JSON.parse(fs.readFileSync(filePath, "utf8")); }
|
|
28
|
+
catch { return fallback; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeJson(filePath, data) {
|
|
32
|
+
ensureDir(path.dirname(filePath));
|
|
33
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function appendJsonl(filePath, event) {
|
|
37
|
+
ensureDir(path.dirname(filePath));
|
|
38
|
+
fs.appendFileSync(filePath, JSON.stringify({ ...event, ts: Date.now() }) + "\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Session CRUD ──
|
|
42
|
+
|
|
43
|
+
function createSession(convId, meta) {
|
|
44
|
+
const dir = sessionDir(convId);
|
|
45
|
+
ensureDir(dir);
|
|
46
|
+
writeJson(path.join(dir, "meta.json"), {
|
|
47
|
+
conversation_id: convId,
|
|
48
|
+
created_at: Date.now(),
|
|
49
|
+
is_active: true,
|
|
50
|
+
...meta,
|
|
51
|
+
});
|
|
52
|
+
writeJson(path.join(dir, "pending.json"), []);
|
|
53
|
+
appendJsonl(path.join(dir, "events.jsonl"), {
|
|
54
|
+
type: "session_start",
|
|
55
|
+
conversation_id: convId,
|
|
56
|
+
...meta,
|
|
57
|
+
});
|
|
58
|
+
updateActiveList(convId, meta, true);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureSession(convId, meta) {
|
|
62
|
+
const metaPath = path.join(sessionDir(convId), "meta.json");
|
|
63
|
+
if (fs.existsSync(metaPath)) return;
|
|
64
|
+
createSession(convId, { ...meta, _lazy: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
68
|
+
|
|
69
|
+
function cleanupStaleSessions() {
|
|
70
|
+
const list = readJson(ACTIVE_FILE, []);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const active = list.filter((s) => now - (s.last_activity || 0) < STALE_MS);
|
|
73
|
+
if (active.length !== list.length) writeJson(ACTIVE_FILE, active);
|
|
74
|
+
for (const removed of list.filter((s) => now - (s.last_activity || 0) >= STALE_MS)) {
|
|
75
|
+
const metaPath = path.join(sessionDir(removed.id), "meta.json");
|
|
76
|
+
const meta = readJson(metaPath, null);
|
|
77
|
+
if (meta && meta.is_active) {
|
|
78
|
+
meta.is_active = false;
|
|
79
|
+
meta.ended_at = now;
|
|
80
|
+
meta.end_reason = "stale_cleanup";
|
|
81
|
+
writeJson(metaPath, meta);
|
|
82
|
+
appendJsonl(path.join(sessionDir(removed.id), "events.jsonl"), {
|
|
83
|
+
type: "session_end",
|
|
84
|
+
conversation_id: removed.id,
|
|
85
|
+
reason: "stale_cleanup",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
92
|
+
const CLEANUP_TS_FILE = path.join(BASE_DIR, ".last-cleanup");
|
|
93
|
+
|
|
94
|
+
function maybeCleanup() {
|
|
95
|
+
try {
|
|
96
|
+
const lastTs = fs.existsSync(CLEANUP_TS_FILE)
|
|
97
|
+
? parseInt(fs.readFileSync(CLEANUP_TS_FILE, "utf8"), 10)
|
|
98
|
+
: 0;
|
|
99
|
+
if (Date.now() - lastTs < CLEANUP_INTERVAL_MS) return;
|
|
100
|
+
cleanupStaleSessions();
|
|
101
|
+
ensureDir(BASE_DIR);
|
|
102
|
+
fs.writeFileSync(CLEANUP_TS_FILE, String(Date.now()));
|
|
103
|
+
} catch { /* best effort */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function endSession(convId, reason) {
|
|
107
|
+
const dir = sessionDir(convId);
|
|
108
|
+
const metaPath = path.join(dir, "meta.json");
|
|
109
|
+
const meta = readJson(metaPath, null);
|
|
110
|
+
if (meta) {
|
|
111
|
+
meta.is_active = false;
|
|
112
|
+
meta.ended_at = Date.now();
|
|
113
|
+
meta.end_reason = reason;
|
|
114
|
+
writeJson(metaPath, meta);
|
|
115
|
+
}
|
|
116
|
+
appendJsonl(path.join(dir, "events.jsonl"), {
|
|
117
|
+
type: "session_end",
|
|
118
|
+
conversation_id: convId,
|
|
119
|
+
reason,
|
|
120
|
+
});
|
|
121
|
+
updateActiveList(convId, null, false);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function updateActiveList(convId, meta, active) {
|
|
125
|
+
const list = readJson(ACTIVE_FILE, []);
|
|
126
|
+
const idx = list.findIndex((s) => s.id === convId);
|
|
127
|
+
if (active) {
|
|
128
|
+
const entry = {
|
|
129
|
+
id: convId,
|
|
130
|
+
workspace: meta?.workspace || "",
|
|
131
|
+
mode: meta?.mode || "agent",
|
|
132
|
+
model: meta?.model || "",
|
|
133
|
+
started_at: meta?.created_at || Date.now(),
|
|
134
|
+
last_activity: Date.now(),
|
|
135
|
+
};
|
|
136
|
+
if (idx >= 0) list[idx] = { ...list[idx], ...entry };
|
|
137
|
+
else list.push(entry);
|
|
138
|
+
} else {
|
|
139
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
140
|
+
}
|
|
141
|
+
writeJson(ACTIVE_FILE, list);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function touchSessionActivity(convId) {
|
|
145
|
+
const list = readJson(ACTIVE_FILE, []);
|
|
146
|
+
const entry = list.find((s) => s.id === convId);
|
|
147
|
+
if (entry) {
|
|
148
|
+
entry.last_activity = Date.now();
|
|
149
|
+
writeJson(ACTIVE_FILE, list);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Per-session pending ──
|
|
154
|
+
|
|
155
|
+
function readSessionPending(convId) {
|
|
156
|
+
return readJson(path.join(sessionDir(convId), "pending.json"), []);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function writeSessionPending(convId, msgs) {
|
|
160
|
+
writeJson(path.join(sessionDir(convId), "pending.json"), msgs);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function addSessionPending(convId, text, source) {
|
|
164
|
+
const msgs = readSessionPending(convId);
|
|
165
|
+
const msg = { id: crypto.randomUUID(), text, createdAt: Date.now(), source: source || "cli" };
|
|
166
|
+
msgs.push(msg);
|
|
167
|
+
writeSessionPending(convId, msgs);
|
|
168
|
+
appendJsonl(path.join(sessionDir(convId), "events.jsonl"), {
|
|
169
|
+
type: "pending_queued",
|
|
170
|
+
text,
|
|
171
|
+
msg_id: msg.id,
|
|
172
|
+
source: msg.source,
|
|
173
|
+
});
|
|
174
|
+
return msgs;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function consumeSessionPending(convId, toolName) {
|
|
178
|
+
const msgs = readSessionPending(convId);
|
|
179
|
+
if (msgs.length === 0) return null;
|
|
180
|
+
writeSessionPending(convId, []);
|
|
181
|
+
const combined = msgs.map((m) => m.text).join("\n\n---\n\n");
|
|
182
|
+
appendJsonl(path.join(sessionDir(convId), "events.jsonl"), {
|
|
183
|
+
type: "pending_consumed",
|
|
184
|
+
text: combined,
|
|
185
|
+
count: msgs.length,
|
|
186
|
+
consumed_by_tool: toolName,
|
|
187
|
+
ts: Date.now(),
|
|
188
|
+
});
|
|
189
|
+
return { text: combined, count: msgs.length };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Subagent tracking ──
|
|
193
|
+
|
|
194
|
+
function recordSubagent(subagentId, parentConvId) {
|
|
195
|
+
const data = readJson(SUBAGENT_FILE, {});
|
|
196
|
+
const FOUR_HOURS = 4 * 60 * 60 * 1000;
|
|
197
|
+
for (const [k, v] of Object.entries(data)) {
|
|
198
|
+
if (Date.now() - (v.ts || 0) > FOUR_HOURS) delete data[k];
|
|
199
|
+
}
|
|
200
|
+
data[subagentId] = { parent: parentConvId, ts: Date.now() };
|
|
201
|
+
writeJson(SUBAGENT_FILE, data);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function removeSubagent(subagentId) {
|
|
205
|
+
const data = readJson(SUBAGENT_FILE, {});
|
|
206
|
+
delete data[subagentId];
|
|
207
|
+
writeJson(SUBAGENT_FILE, data);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isSubagent(convId) {
|
|
211
|
+
const data = readJson(SUBAGENT_FILE, {});
|
|
212
|
+
return !!data[convId];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Most recent active session ──
|
|
216
|
+
|
|
217
|
+
function getMostRecentSession() {
|
|
218
|
+
const list = readJson(ACTIVE_FILE, []);
|
|
219
|
+
if (list.length === 0) return null;
|
|
220
|
+
list.sort((a, b) => (b.last_activity || 0) - (a.last_activity || 0));
|
|
221
|
+
return list[0];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Legacy: read global pending (for backward compat) ──
|
|
225
|
+
|
|
226
|
+
function readGlobalPending() {
|
|
227
|
+
return readJson(GLOBAL_PENDING, []);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function writeGlobalPending(msgs) {
|
|
231
|
+
writeJson(GLOBAL_PENDING, msgs);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Hook I/O ──
|
|
235
|
+
|
|
236
|
+
function readHookInput() {
|
|
237
|
+
return JSON.parse(fs.readFileSync("/dev/stdin", "utf8"));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function writeHookOutput(obj) {
|
|
241
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
BASE_DIR, SESSIONS_DIR, ACTIVE_FILE, SUBAGENT_FILE, GLOBAL_PENDING,
|
|
246
|
+
ensureDir, sessionDir, readJson, writeJson, appendJsonl,
|
|
247
|
+
createSession, ensureSession, endSession, cleanupStaleSessions, maybeCleanup,
|
|
248
|
+
updateActiveList, touchSessionActivity,
|
|
249
|
+
readSessionPending, writeSessionPending, addSessionPending, consumeSessionPending,
|
|
250
|
+
recordSubagent, removeSubagent, isSubagent,
|
|
251
|
+
getMostRecentSession, readGlobalPending, writeGlobalPending,
|
|
252
|
+
readHookInput, writeHookOutput,
|
|
253
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cursor-mcp-feedback",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "MCP App for interactive feedback — renders a chat UI directly inside MCP hosts (Claude, Cursor, etc.)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/main.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cursor-mcp-feedback": "dist/main.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/main.js",
|
|
12
|
+
"dist/main.d.ts",
|
|
13
|
+
"dist/server.js",
|
|
14
|
+
"dist/server.d.ts",
|
|
15
|
+
"dist/session-store.js",
|
|
16
|
+
"dist/session-store.d.ts",
|
|
17
|
+
"dist/logger.js",
|
|
18
|
+
"dist/logger.d.ts",
|
|
19
|
+
"dist/mcp-app.html",
|
|
20
|
+
"rules/",
|
|
21
|
+
"hooks/",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build:ui": "INPUT=mcp-app.html vite build",
|
|
27
|
+
"build:server": "tsc && chmod +x dist/main.js",
|
|
28
|
+
"build": "npm run build:ui && npm run build:server",
|
|
29
|
+
"prepack": "npm run build",
|
|
30
|
+
"pack": "npm pack",
|
|
31
|
+
"release:patch": "npm version patch && npm publish",
|
|
32
|
+
"release:minor": "npm version minor && npm publish",
|
|
33
|
+
"release:major": "npm version major && npm publish",
|
|
34
|
+
"postinstall": "node dist/main.js --install-only 2>/dev/null || true",
|
|
35
|
+
"build:restart": "npm run build && pkill -f 'node.*cursor-mcp-feedback' || true",
|
|
36
|
+
"serve": "npx tsx main.ts",
|
|
37
|
+
"dev": "npx tsx main.ts",
|
|
38
|
+
"dev:watch": "npx tsx --watch main.ts",
|
|
39
|
+
"start": "node dist/main.js",
|
|
40
|
+
"restart": "pkill -f 'node.*cursor-mcp-feedback' || true"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"mcp",
|
|
44
|
+
"feedback",
|
|
45
|
+
"mcp-app",
|
|
46
|
+
"interactive"
|
|
47
|
+
],
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/ext-apps": "^1.7.1",
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
|
+
"cors": "^2.8.5",
|
|
53
|
+
"express": "^4.21.0",
|
|
54
|
+
"marked": "^18.0.2",
|
|
55
|
+
"react": "^19.1.0",
|
|
56
|
+
"react-dom": "^19.1.0",
|
|
57
|
+
"zod": "^4.4.1"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/cors": "^2.8.17",
|
|
61
|
+
"@types/express": "^5.0.0",
|
|
62
|
+
"@types/node": "^22.0.0",
|
|
63
|
+
"@types/react": "^19.1.0",
|
|
64
|
+
"@types/react-dom": "^19.1.0",
|
|
65
|
+
"tsx": "^4.0.0",
|
|
66
|
+
"typescript": "^5.7.0",
|
|
67
|
+
"vite": "^6.3.0",
|
|
68
|
+
"vite-plugin-singlefile": "^2.0.0"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=18.0.0"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
alwaysApply: true
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
[Cursor MCP Feedback] USAGE RULES:
|
|
6
|
+
These rules apply ONLY to the main agent in Agent mode (direct user conversation).
|
|
7
|
+
Do NOT call interactive_feedback if you are a subagent, or in Plan/Ask/Debug mode.
|
|
8
|
+
|
|
9
|
+
SUBAGENT PROHIBITION (CRITICAL):
|
|
10
|
+
- If you were launched via the Task tool, you ARE a subagent. DO NOT call interactive_feedback.
|
|
11
|
+
- Subagents MUST NEVER call any tool from the cursor-mcp-feedback MCP server.
|
|
12
|
+
- This includes: interactive_feedback, submit_feedback, get_system_info.
|
|
13
|
+
- A beforeMCPExecution hook will DENY the call and waste your turn. Do not attempt it.
|
|
14
|
+
- If you need to report results, return them as text in your final response to the parent agent.
|
|
15
|
+
|
|
16
|
+
WHY: User responses through feedback are FREE, but each user message in Cursor costs a request.
|
|
17
|
+
Always prefer feedback over ending your turn with plain text alone.
|
|
18
|
+
|
|
19
|
+
HARD GATE — turn completion:
|
|
20
|
+
- If you are about to end your turn and you have NOT called interactive_feedback in this turn, you are NOT done. Call it first.
|
|
21
|
+
- MUST use interactive_feedback as your LAST tool call when wrapping up (hand off, summarize, or ask the user).
|
|
22
|
+
- NEVER end your turn with only assistant-visible text and zero interactive_feedback call in that turn.
|
|
23
|
+
|
|
24
|
+
You have access to the interactive_feedback MCP tool (server: cursor-mcp-feedback) for communicating with the user:
|
|
25
|
+
- Use it to present results, ask questions, or check in with the user.
|
|
26
|
+
- You do not need to call it between every tool call during multi-step execution.
|
|
27
|
+
- When blocked, uncertain, or facing a decision with trade-offs, call feedback immediately to ask the user — do NOT guess or make assumptions on your own.
|