Skip to content

Commit b8e9dd2

Browse files
heavygeecursoragent
andcommitted
fix(tiann#956): Cursor native MCP overlay; drop user-turn bridge prepend
Cursor ACP ignores session/new mcpServers. Write .cursor/mcp.json and run agent mcp enable hapi instead. Remove HAPI_MCP_BRIDGE_PROMPT from user turns on ACP remotes; enrich MCP tool descriptions for discovery. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f98f104 commit b8e9dd2

11 files changed

Lines changed: 261 additions & 58 deletions

cli/src/claude/utils/startHappyServer.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean
106106
}
107107

108108
mcp.registerTool<any, any>('change_title', {
109-
description: 'Change the title of the current chat session',
109+
description: 'Change the title of the current HAPI chat session. Call once when the user\'s primary objective is clear; use a concise task title.',
110110
title: 'Change Chat Title',
111111
inputSchema: changeTitleInputSchema,
112112
}, async (args: { title: string }) => {
@@ -137,7 +137,7 @@ function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean
137137
});
138138

139139
mcp.registerTool<any, any>('display_image', {
140-
description: 'Display a local image file inline in the current HAPI chat session',
140+
description: 'Display a local image file inline in the current HAPI chat session. Call with the absolute filesystem path when the user should see a screenshot, diagram, or generated image.',
141141
title: 'Display Image',
142142
inputSchema: displayImageInputSchema,
143143
}, async (args: { path: string; title?: string }) => {
@@ -171,7 +171,7 @@ function createHapiMcpServer(client: ApiSessionClient, emitTitleSummary: boolean
171171
});
172172

173173
mcp.registerTool<any, any>('display_video', {
174-
description: 'Display a local mp4 or webm file inline in the current HAPI chat session',
174+
description: 'Display a local mp4 or webm file inline in the current HAPI chat session. Call with the absolute filesystem path when the user should see a screen recording or video artifact.',
175175
title: 'Display Video',
176176
inputSchema: displayVideoInputSchema,
177177
}, async (args: { path: string; title?: string }) => {

cli/src/cursor/cursorAcpRemoteLauncher.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,14 @@ vi.mock('@/agent/permissionAdapter', () => ({
119119
vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({
120120
buildHapiMcpBridge: async () => ({
121121
server: { stop: () => {} },
122-
mcpServers: {}
123-
})
122+
mcpServers: {
123+
hapi: { command: 'hapi', args: ['mcp', '--url', 'http://127.0.0.1:1/'] },
124+
},
125+
}),
126+
}));
127+
128+
vi.mock('./utils/cursorMcpOverlay', () => ({
129+
installCursorMcpOverlay: () => ({ cleanup: () => {} }),
124130
}));
125131

126132
vi.mock('@/ui/ink/OpencodeDisplay', () => ({

cli/src/cursor/cursorAcpRemoteLauncher.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { applyCursorAcpMode, applyCursorAcpModel, wireIdForCursorSessionState }
2020
import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels';
2121
import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache';
2222
import type { AcpSdkBackend } from '@/agent/backends/acp';
23-
import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt';
23+
import { installCursorMcpOverlay, type CursorMcpOverlayHandle } from './utils/cursorMcpOverlay';
2424

2525
class CursorAcpRemoteLauncher extends RemoteLauncherBase {
2626
private readonly session: CursorSession;
@@ -34,7 +34,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
3434
private defaultBackendModel: string | null = null;
3535
private unregisterModelApplyHandler: (() => void) | null = null;
3636
private modelApplySeq = 0;
37-
private instructionsSent = false;
37+
private cursorMcpOverlay: CursorMcpOverlayHandle | null = null;
3838

3939
constructor(session: CursorSession) {
4040
super(process.env.DEBUG ? session.logPath : undefined);
@@ -59,6 +59,14 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
5959
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client);
6060
this.happyServer = happyServer;
6161

62+
const hapiBridge = mcpServers.hapi;
63+
if (hapiBridge) {
64+
this.cursorMcpOverlay = installCursorMcpOverlay(session.path, {
65+
command: hapiBridge.command,
66+
args: hapiBridge.args,
67+
});
68+
}
69+
6270
const backend = createCursorAcpBackend({ cwd: session.path, model: session.model });
6371
this.backend = backend;
6472

@@ -103,7 +111,8 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
103111
);
104112

105113
const resumeSessionId = session.sessionId;
106-
const mcpServerList = toAcpMcpServers(mcpServers);
114+
// Cursor ACP ignores session/new|load mcpServers; native .cursor/mcp.json is wired above.
115+
const mcpServerList: McpServerStdio[] = [];
107116
let acpSessionId: string;
108117

109118
if (resumeSessionId && backend.supportsLoadSession()) {
@@ -113,7 +122,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
113122
acpSessionId = await backend.loadSession({
114123
sessionId: resumeSessionId,
115124
cwd: session.path,
116-
mcpServers: mcpServerList
125+
mcpServers: mcpServerList,
117126
});
118127
} catch (error) {
119128
logger.warn('[cursor-acp] session/load failed', formatAcpLoadError(error));
@@ -213,15 +222,9 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
213222
this.applyDisplayMode(batch.mode.permissionMode as PermissionMode);
214223
messageBuffer.addMessage(batch.message, 'user');
215224

216-
let messageText = batch.message;
217-
if (!this.instructionsSent) {
218-
messageText = `${HAPI_MCP_BRIDGE_PROMPT}\n\n${messageText}`;
219-
this.instructionsSent = true;
220-
}
221-
222225
const promptContent: PromptContent[] = [{
223226
type: 'text',
224-
text: messageText
227+
text: batch.message,
225228
}];
226229

227230
session.onThinkingChange(true);
@@ -275,6 +278,11 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
275278
this.happyServer = null;
276279
}
277280

281+
if (this.cursorMcpOverlay) {
282+
this.cursorMcpOverlay.cleanup();
283+
this.cursorMcpOverlay = null;
284+
}
285+
278286
setCursorAcpModelsSnapshot(null);
279287
}
280288

@@ -518,15 +526,6 @@ function syncCursorModelsFromAcp(backend: AcpSdkBackend, acpSessionId: string):
518526
seedCursorModelsCache(payload);
519527
}
520528

521-
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): McpServerStdio[] {
522-
return Object.entries(config).map(([name, entry]) => ({
523-
name,
524-
command: entry.command,
525-
args: entry.args,
526-
env: []
527-
}));
528-
}
529-
530529
export async function cursorAcpRemoteLauncher(session: CursorSession): Promise<'switch' | 'exit'> {
531530
const launcher = new CursorAcpRemoteLauncher(session);
532531
return launcher.launch();
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { randomUUID } from 'node:crypto';
6+
import { CURSOR_HAPI_MCP_SERVER_ID, installCursorMcpOverlay } from './cursorMcpOverlay';
7+
8+
describe('installCursorMcpOverlay', () => {
9+
const roots: string[] = [];
10+
11+
afterEach(() => {
12+
for (const root of roots.splice(0)) {
13+
rmSync(root, { recursive: true, force: true });
14+
}
15+
});
16+
17+
function makeProjectDir(initialMcpJson?: string): string {
18+
const root = join(tmpdir(), `hapi-cursor-mcp-${randomUUID()}`);
19+
mkdirSync(root, { recursive: true });
20+
roots.push(root);
21+
if (initialMcpJson !== undefined) {
22+
mkdirSync(join(root, '.cursor'), { recursive: true });
23+
writeFileSync(join(root, '.cursor', 'mcp.json'), initialMcpJson, 'utf-8');
24+
}
25+
return root;
26+
}
27+
28+
it('writes hapi bridge into .cursor/mcp.json and restores on cleanup', () => {
29+
const cwd = makeProjectDir(JSON.stringify({
30+
mcpServers: {
31+
other: { command: 'echo', args: ['x'] },
32+
},
33+
}, null, 2));
34+
35+
const mcpPath = join(cwd, '.cursor', 'mcp.json');
36+
const before = readFileSync(mcpPath, 'utf-8');
37+
38+
const handle = installCursorMcpOverlay(cwd, {
39+
command: '/bin/hapi',
40+
args: ['mcp', '--url', 'http://127.0.0.1:12345/'],
41+
});
42+
43+
const merged = JSON.parse(readFileSync(mcpPath, 'utf-8')) as {
44+
mcpServers: Record<string, { command: string; args: string[] }>;
45+
};
46+
expect(merged.mcpServers.other).toEqual({ command: 'echo', args: ['x'] });
47+
expect(merged.mcpServers[CURSOR_HAPI_MCP_SERVER_ID]).toEqual({
48+
command: '/bin/hapi',
49+
args: ['mcp', '--url', 'http://127.0.0.1:12345/'],
50+
});
51+
52+
handle.cleanup();
53+
expect(readFileSync(mcpPath, 'utf-8')).toBe(before);
54+
});
55+
56+
it('creates .cursor/mcp.json when missing and removes hapi entry on cleanup', () => {
57+
const cwd = makeProjectDir();
58+
expect(existsSync(join(cwd, '.cursor', 'mcp.json'))).toBe(false);
59+
60+
const handle = installCursorMcpOverlay(cwd, {
61+
command: 'hapi',
62+
args: ['mcp', '--url', 'http://127.0.0.1:9999/'],
63+
});
64+
65+
const mcpPath = join(cwd, '.cursor', 'mcp.json');
66+
expect(existsSync(mcpPath)).toBe(true);
67+
68+
handle.cleanup();
69+
70+
if (existsSync(mcpPath)) {
71+
const after = JSON.parse(readFileSync(mcpPath, 'utf-8')) as {
72+
mcpServers?: Record<string, unknown>;
73+
};
74+
expect(after.mcpServers?.[CURSOR_HAPI_MCP_SERVER_ID]).toBeUndefined();
75+
}
76+
});
77+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Cursor ACP does not connect MCP servers passed on session/new (upstream limitation).
3+
* The working path is project .cursor/mcp.json + `agent mcp enable <id>`.
4+
* See https://forum.cursor.com/t/acp-agent-silently-ignores-mcpservers-in-session-new/153623
5+
*/
6+
7+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8+
import { join } from 'node:path';
9+
import { spawnSync } from 'node:child_process';
10+
import { logger } from '@/ui/logger';
11+
12+
export const CURSOR_HAPI_MCP_SERVER_ID = 'hapi';
13+
14+
type McpServerEntry = {
15+
command: string;
16+
args: string[];
17+
env?: Record<string, string>;
18+
};
19+
20+
type CursorMcpJson = {
21+
mcpServers?: Record<string, McpServerEntry>;
22+
};
23+
24+
export type CursorMcpOverlayHandle = {
25+
cleanup: () => void;
26+
};
27+
28+
function parseMcpJson(raw: string): CursorMcpJson {
29+
const parsed = JSON.parse(raw) as unknown;
30+
if (parsed === null || typeof parsed !== 'object') {
31+
return { mcpServers: {} };
32+
}
33+
return parsed as CursorMcpJson;
34+
}
35+
36+
function readMcpJson(path: string): CursorMcpJson {
37+
if (!existsSync(path)) {
38+
return { mcpServers: {} };
39+
}
40+
return parseMcpJson(readFileSync(path, 'utf-8'));
41+
}
42+
43+
function writeMcpJson(path: string, config: CursorMcpJson): void {
44+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
45+
}
46+
47+
/**
48+
* Merge the per-session HAPI stdio bridge into `<cwd>/.cursor/mcp.json` and approve it
49+
* for Cursor's native MCP loader. Restores prior file contents on cleanup.
50+
*/
51+
export function installCursorMcpOverlay(
52+
cwd: string,
53+
bridge: { command: string; args: string[] }
54+
): CursorMcpOverlayHandle {
55+
const cursorDir = join(cwd, '.cursor');
56+
const mcpJsonPath = join(cursorDir, 'mcp.json');
57+
mkdirSync(cursorDir, { recursive: true });
58+
59+
const hadFile = existsSync(mcpJsonPath);
60+
const previousContent = hadFile ? readFileSync(mcpJsonPath, 'utf-8') : null;
61+
62+
const config = hadFile && previousContent
63+
? parseMcpJson(previousContent)
64+
: { mcpServers: {} };
65+
config.mcpServers ??= {};
66+
67+
config.mcpServers[CURSOR_HAPI_MCP_SERVER_ID] = {
68+
command: bridge.command,
69+
args: [...bridge.args],
70+
};
71+
72+
writeMcpJson(mcpJsonPath, config);
73+
74+
const enable = spawnSync('agent', ['mcp', 'enable', CURSOR_HAPI_MCP_SERVER_ID], {
75+
cwd,
76+
encoding: 'utf-8',
77+
timeout: 30_000,
78+
});
79+
80+
if (enable.status !== 0) {
81+
const detail = (enable.stderr || enable.stdout || '').trim();
82+
logger.warn(
83+
`[cursor-acp] agent mcp enable ${CURSOR_HAPI_MCP_SERVER_ID} failed (status=${enable.status ?? 'null'}${detail ? `: ${detail}` : ''})`
84+
);
85+
} else {
86+
logger.debug(`[cursor-acp] enabled native MCP server ${CURSOR_HAPI_MCP_SERVER_ID} via .cursor/mcp.json`);
87+
}
88+
89+
return {
90+
cleanup: () => {
91+
try {
92+
if (previousContent !== null) {
93+
writeFileSync(mcpJsonPath, previousContent, 'utf-8');
94+
return;
95+
}
96+
97+
if (!existsSync(mcpJsonPath)) {
98+
return;
99+
}
100+
101+
const current = readMcpJson(mcpJsonPath);
102+
if (current.mcpServers) {
103+
delete current.mcpServers[CURSOR_HAPI_MCP_SERVER_ID];
104+
}
105+
if (!current.mcpServers || Object.keys(current.mcpServers).length === 0) {
106+
rmSync(mcpJsonPath, { force: true });
107+
return;
108+
}
109+
writeMcpJson(mcpJsonPath, current);
110+
} catch (error) {
111+
logger.debug('[cursor-acp] cursor MCP overlay cleanup failed', error);
112+
}
113+
},
114+
};
115+
}

cli/src/gemini/geminiRemoteLauncher.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import type { PermissionMode } from './types';
1010
import { createGeminiBackend } from './utils/geminiBackend';
1111
import { GeminiPermissionHandler } from './utils/permissionHandler';
1212
import { resolveGeminiRuntimeConfig } from './utils/config';
13-
import { HAPI_MCP_BRIDGE_PROMPT } from '@/modules/common/hapiMcpBridgePrompt';
1413

1514
class GeminiRemoteLauncher extends RemoteLauncherBase {
1615
private readonly session: GeminiSession;
@@ -24,7 +23,6 @@ class GeminiRemoteLauncher extends RemoteLauncherBase {
2423
private displayPermissionMode: PermissionMode | null = null;
2524
private currentBackendModel: string | null = null;
2625
private setModelSupported: boolean | undefined = undefined;
27-
private instructionsSent = false;
2826

2927
constructor(session: GeminiSession, opts: { model?: string; hookSettingsPath?: string }) {
3028
super(process.env.DEBUG ? session.logPath : undefined);
@@ -164,15 +162,9 @@ class GeminiRemoteLauncher extends RemoteLauncherBase {
164162
this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model);
165163
messageBuffer.addMessage(batch.message, 'user');
166164

167-
let messageText = batch.message;
168-
if (!this.instructionsSent) {
169-
messageText = `${HAPI_MCP_BRIDGE_PROMPT}\n\n${messageText}`;
170-
this.instructionsSent = true;
171-
}
172-
173165
const promptContent: PromptContent[] = [{
174166
type: 'text',
175-
text: messageText
167+
text: batch.message,
176168
}];
177169

178170
session.onThinkingChange(true);

0 commit comments

Comments
 (0)