Skip to content

Commit dec66f5

Browse files
committed
fix: preserve existing MCP config entries in init wizard
Avoid overwriting existing Cursor/OpenCode MCP entries by merging only the codebase-context server config, and remove the unused init import in index to clear Quality Checks.
1 parent 5bddc8a commit dec66f5

File tree

3 files changed

+154
-3
lines changed

3 files changed

+154
-3
lines changed

src/cli-init.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export type McpConfigResult =
1919
| { kind: 'file'; path: string; content: string }
2020
| { kind: 'command'; args: string[] };
2121

22+
type JsonObject = Record<string, unknown>;
23+
2224
export function generateMcpConfig(client: Client): McpConfigResult {
2325
switch (client) {
2426
case 'claude-code':
@@ -121,6 +123,70 @@ export async function _appendInstructionBlock(filePath: string): Promise<'writte
121123
return 'written';
122124
}
123125

126+
function isJsonObject(value: unknown): value is JsonObject {
127+
return typeof value === 'object' && value !== null && !Array.isArray(value);
128+
}
129+
130+
/**
131+
* Merge generated MCP config into an existing JSON config file when possible.
132+
* Preserves unrelated keys and only updates the codebase-context server entry.
133+
*/
134+
export async function _buildMergedMcpContent(
135+
filePath: string,
136+
generatedContent: string,
137+
client: Extract<Client, 'cursor' | 'opencode'>
138+
): Promise<{ content: string; mergedFromExisting: boolean }> {
139+
let existing: unknown;
140+
try {
141+
existing = JSON.parse(await fs.readFile(filePath, 'utf8'));
142+
} catch {
143+
return { content: generatedContent, mergedFromExisting: false };
144+
}
145+
146+
const generated = JSON.parse(generatedContent) as unknown;
147+
if (!isJsonObject(existing) || !isJsonObject(generated)) {
148+
return { content: generatedContent, mergedFromExisting: false };
149+
}
150+
151+
if (client === 'cursor') {
152+
const existingServers = isJsonObject(existing.mcpServers) ? existing.mcpServers : {};
153+
const generatedServers = isJsonObject(generated.mcpServers) ? generated.mcpServers : {};
154+
return {
155+
content: JSON.stringify(
156+
{
157+
...existing,
158+
...generated,
159+
mcpServers: {
160+
...existingServers,
161+
...generatedServers
162+
}
163+
},
164+
null,
165+
2
166+
),
167+
mergedFromExisting: true
168+
};
169+
}
170+
171+
const existingMcp = isJsonObject(existing.mcp) ? existing.mcp : {};
172+
const generatedMcp = isJsonObject(generated.mcp) ? generated.mcp : {};
173+
return {
174+
content: JSON.stringify(
175+
{
176+
...existing,
177+
...generated,
178+
mcp: {
179+
...existingMcp,
180+
...generatedMcp
181+
}
182+
},
183+
null,
184+
2
185+
),
186+
mergedFromExisting: true
187+
};
188+
}
189+
124190
export async function handleInitCli(_argv: string[]): Promise<void> {
125191
console.log('\nSet up codebase-context for your AI client\n');
126192

@@ -138,6 +204,12 @@ export async function handleInitCli(_argv: string[]): Promise<void> {
138204

139205
console.log('\n--- MCP Config Preview ---');
140206
if (mcpResult.kind === 'file') {
207+
try {
208+
await fs.access(mcpResult.path);
209+
console.log(`Warning: ${mcpResult.path} already exists; existing entries will be preserved.`);
210+
} catch {
211+
// file does not exist
212+
}
141213
console.log(`File: ${mcpResult.path}\n${mcpResult.content}`);
142214
} else {
143215
console.log(`Command to run: ${mcpResult.args[0]} ${mcpResult.args.slice(1).join(' ')}`);
@@ -181,7 +253,14 @@ export async function handleInitCli(_argv: string[]): Promise<void> {
181253
if (dir && dir !== '.') {
182254
await fs.mkdir(dir, { recursive: true });
183255
}
184-
await fs.writeFile(mcpResult.path, mcpResult.content, 'utf8');
256+
const mergedConfig =
257+
client === 'cursor' || client === 'opencode'
258+
? await _buildMergedMcpContent(mcpResult.path, mcpResult.content, client)
259+
: { content: mcpResult.content, mergedFromExisting: false };
260+
await fs.writeFile(mcpResult.path, mergedConfig.content, 'utf8');
261+
if (mergedConfig.mergedFromExisting) {
262+
console.log(`Merged: ${mcpResult.path} (existing entries preserved)`);
263+
}
185264
console.log(`Written: ${mcpResult.path}`);
186265
} else {
187266
const [cmd, ...rest] = mcpResult.args;

src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import { GenericAnalyzer } from './analyzers/generic/index.js';
3838
import { IndexCorruptedError } from './errors/index.js';
3939
import { appendMemoryFile } from './memory/store.js';
4040
import { handleCliCommand } from './cli.js';
41-
import { handleInitCli } from './cli-init.js';
4241
import { startFileWatcher } from './core/file-watcher.js';
4342
import { parseGitLogLineToMemory } from './memory/git-memory.js';
4443
import {

tests/cli-init.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
generateMcpConfig,
1919
generateInstructionBlock,
2020
resolveInstructionFilePath,
21-
_appendInstructionBlock
21+
_appendInstructionBlock,
22+
_buildMergedMcpContent
2223
} from '../src/cli-init.js';
2324

2425
// --- generateMcpConfig ---
@@ -151,3 +152,75 @@ describe('_appendInstructionBlock', () => {
151152
expect(writeFileMock).not.toHaveBeenCalled();
152153
});
153154
});
155+
156+
describe('_buildMergedMcpContent', () => {
157+
const readFileMock = vi.mocked(fsMod.readFile);
158+
159+
beforeEach(() => {
160+
vi.resetAllMocks();
161+
});
162+
163+
it('preserves existing cursor mcpServers entries and adds codebase-context', async () => {
164+
readFileMock.mockResolvedValue(
165+
JSON.stringify({
166+
mcpServers: {
167+
'existing-server': { type: 'http', url: 'http://127.0.0.1:4000/mcp' }
168+
},
169+
someOtherKey: true
170+
}) as unknown as Buffer
171+
);
172+
173+
const generated = generateMcpConfig('cursor');
174+
if (generated.kind !== 'file') throw new Error('expected file config');
175+
176+
const merged = await _buildMergedMcpContent('/test/.cursor/mcp.json', generated.content, 'cursor');
177+
const parsed = JSON.parse(merged.content) as {
178+
mcpServers: Record<string, { type: string; url: string }>;
179+
someOtherKey: boolean;
180+
};
181+
182+
expect(merged.mergedFromExisting).toBe(true);
183+
expect(parsed.someOtherKey).toBe(true);
184+
expect(parsed.mcpServers['existing-server']).toEqual({
185+
type: 'http',
186+
url: 'http://127.0.0.1:4000/mcp'
187+
});
188+
expect(parsed.mcpServers['codebase-context']).toEqual({
189+
type: 'http',
190+
url: 'http://127.0.0.1:3100/mcp'
191+
});
192+
});
193+
194+
it('preserves existing opencode mcp entries and updates codebase-context deterministically', async () => {
195+
readFileMock.mockResolvedValue(
196+
JSON.stringify({
197+
$schema: 'https://opencode.ai/config.json',
198+
mcp: {
199+
'existing-server': { type: 'remote', url: 'http://127.0.0.1:5000/mcp' },
200+
'codebase-context': { type: 'remote', url: 'http://old-host/mcp' }
201+
},
202+
extra: 'value'
203+
}) as unknown as Buffer
204+
);
205+
206+
const generated = generateMcpConfig('opencode');
207+
if (generated.kind !== 'file') throw new Error('expected file config');
208+
209+
const merged = await _buildMergedMcpContent('/test/opencode.json', generated.content, 'opencode');
210+
const parsed = JSON.parse(merged.content) as {
211+
mcp: Record<string, { type: string; url: string }>;
212+
extra: string;
213+
};
214+
215+
expect(merged.mergedFromExisting).toBe(true);
216+
expect(parsed.extra).toBe('value');
217+
expect(parsed.mcp['existing-server']).toEqual({
218+
type: 'remote',
219+
url: 'http://127.0.0.1:5000/mcp'
220+
});
221+
expect(parsed.mcp['codebase-context']).toEqual({
222+
type: 'remote',
223+
url: 'http://127.0.0.1:3100/mcp'
224+
});
225+
});
226+
});

0 commit comments

Comments
 (0)