diff --git a/package.json b/package.json index 97c85a2..e5da91f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "lint": "pnpm -r run lint", "typecheck": "pnpm -r run typecheck", "qa": "pnpm typecheck && pnpm lint && pnpm test", - "prepare": "husky" + "prepare": "husky", + "clear": "./scripts/clean-dev-env.sh" }, "devDependencies": { "@commitlint/cli": "^21.2.3", diff --git a/packages/cli/src/features/mcp/status.ts b/packages/cli/src/features/mcp/status.ts index dee55c6..819061a 100644 --- a/packages/cli/src/features/mcp/status.ts +++ b/packages/cli/src/features/mcp/status.ts @@ -1,10 +1,7 @@ import type { IdeId } from '@spotify-confidence/shared-kernel'; -import { getIntegration, type McpServerName, type McpServerStatus } from '@spotify-confidence/core'; +import { getIntegration, type McpStatusMap } from '@spotify-confidence/core'; -export async function getMcpStatuses( - ideId: IdeId, - projectDir: string, -): Promise> { +export async function getMcpStatuses(ideId: IdeId, projectDir: string): Promise { const integration = getIntegration(ideId); return integration.detectMcpStatuses(projectDir); } diff --git a/packages/core/__tests__/integrations/mcp-disconnect.test.ts b/packages/core/__tests__/integrations/mcp-disconnect.test.ts index c01f2da..60c2d3b 100644 --- a/packages/core/__tests__/integrations/mcp-disconnect.test.ts +++ b/packages/core/__tests__/integrations/mcp-disconnect.test.ts @@ -21,27 +21,29 @@ beforeEach(() => { describe('claude disconnectMcpServer', () => { async function loadDisconnect() { - const mod = await import('@integrations/claude/mcp.js'); + const mod = await import('@integrations/claude/mcp/index.js'); return mod.disconnectMcpServer; } - it('calls claude mcp remove with the server name', async () => { - const sut = await loadDisconnect(); + it.each(['local', 'project', 'user'] as const)( + 'calls claude mcp remove with %s scope', + async (scope) => { + const sut = await loadDisconnect(); - await sut({ serverName: 'confidence-flags', projectDir: '/project' }); + await sut({ serverName: 'confidence-flags', projectDir: '/project' }); - expect(execFile).toHaveBeenCalledWith( - 'claude', - ['mcp', 'remove', '--scope', 'project', 'confidence-flags'], - { cwd: '/project' }, - ); - }); + expect(execFile).toHaveBeenCalledWith( + 'claude', + ['mcp', 'remove', '--scope', scope, 'confidence-flags'], + { cwd: '/project' }, + ); + }, + ); it('removes the tool permission from settings.local.json', async () => { using project = createProjectDir('empty'); writeClaudeSettings(project.path, { permissions: { allow: ['mcp__confidence-flags__*', 'mcp__other__*'] }, - enabledMcpjsonServers: ['confidence-flags', 'other-server'], }); const sut = await loadDisconnect(); @@ -51,7 +53,6 @@ describe('claude disconnectMcpServer', () => { readFileSync(join(project.path, '.claude', 'settings.local.json'), 'utf-8'), ); expect(settings.permissions.allow).toEqual(['mcp__other__*']); - expect(settings.enabledMcpjsonServers).toEqual(['other-server']); }); it('does not fail when settings.local.json does not exist', async () => { @@ -67,32 +68,30 @@ describe('claude disconnectMcpServer', () => { using project = createProjectDir('empty'); writeClaudeSettings(project.path, { permissions: { allow: ['mcp__confidence-flags__*'] }, - enabledMcpjsonServers: ['confidence-flags'], }); - execFile.mockRejectedValueOnce(new Error('claude not found')); + execFile.mockRejectedValue(new Error('claude not found')); const sut = await loadDisconnect(); - await expect(sut({ serverName: 'confidence-flags', projectDir: project.path })).rejects.toThrow( - 'claude not found', - ); + await sut({ serverName: 'confidence-flags', projectDir: project.path }); const settings = JSON.parse( readFileSync(join(project.path, '.claude', 'settings.local.json'), 'utf-8'), ); expect(settings.permissions.allow).toEqual([]); - expect(settings.enabledMcpjsonServers).toEqual([]); }); }); describe('cursor disconnectMcpServer', () => { async function loadDisconnect() { - const mod = await import('@integrations/cursor/mcp.js'); + const mod = await import('@integrations/cursor/mcp/index.js'); return mod.disconnectMcpServer; } - it('removes the server entry from project config and CLI permissions', async () => { + it('removes the server from global config and CLI permissions', async () => { using project = createProjectDir('empty'); - writeCursorMcpConfig(project.path, { + using home = createProjectDir('empty'); + vi.stubEnv('HOME', home.path); + writeCursorMcpConfig(home.path, { mcpServers: { 'confidence-flags': { type: 'http', url: 'https://example.com' }, 'other-server': { type: 'http', url: 'https://other.com' }, @@ -106,33 +105,14 @@ describe('cursor disconnectMcpServer', () => { await sut({ serverName: 'confidence-flags', projectDir: project.path }); - const updatedMcp = JSON.parse(readFileSync(join(project.path, '.cursor', 'mcp.json'), 'utf-8')); - expect(updatedMcp.mcpServers).not.toHaveProperty('confidence-flags'); - expect(updatedMcp.mcpServers).toHaveProperty('other-server'); + const globalMcp = JSON.parse(readFileSync(join(home.path, '.cursor', 'mcp.json'), 'utf-8')); + expect(globalMcp.mcpServers).not.toHaveProperty('confidence-flags'); + expect(globalMcp.mcpServers).toHaveProperty('other-server'); const updatedCli = JSON.parse(readFileSync(join(project.path, '.cursor', 'cli.json'), 'utf-8')); expect(updatedCli.permissions.allow).toEqual(['Mcp(other:*)']); }); - it('does not remove the server from global config', async () => { - using project = createProjectDir('empty'); - using home = createProjectDir('empty'); - vi.stubEnv('HOME', home.path); - writeCursorMcpConfig(project.path, { - mcpServers: { 'confidence-flags': { type: 'http', url: 'https://example.com' } }, - }); - writeCursorMcpConfig(home.path, { - mcpServers: { 'confidence-flags': { type: 'http', url: 'https://example.com' } }, - }); - - const sut = await loadDisconnect(); - - await sut({ serverName: 'confidence-flags', projectDir: project.path }); - - const globalMcp = JSON.parse(readFileSync(join(home.path, '.cursor', 'mcp.json'), 'utf-8')); - expect(globalMcp.mcpServers).toHaveProperty('confidence-flags'); - }); - it('does not fail when config files do not exist', async () => { using project = createProjectDir('empty'); const sut = await loadDisconnect(); @@ -145,7 +125,7 @@ describe('cursor disconnectMcpServer', () => { describe('codex disconnectMcpServer', () => { async function loadDisconnect() { - const mod = await import('@integrations/codex/mcp.js'); + const mod = await import('@integrations/codex/mcp/index.js'); return mod.disconnectMcpServer; } diff --git a/packages/core/src/integrations/claude/index.ts b/packages/core/src/integrations/claude/index.ts index a992bef..01749dc 100644 --- a/packages/core/src/integrations/claude/index.ts +++ b/packages/core/src/integrations/claude/index.ts @@ -2,7 +2,7 @@ import type { IdeIntegration } from '../types.js'; import { launchChat } from './chat.js'; import { detectPlugin, installPlugin, updatePlugin, uninstallPlugin } from './plugins.js'; import { skillsDir } from './paths.js'; -import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp.js'; +import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp/index.js'; import { runOnboarding } from './onboarding.js'; import { prepare } from './prepare.js'; diff --git a/packages/core/src/integrations/claude/mcp.ts b/packages/core/src/integrations/claude/mcp.ts deleted file mode 100644 index 155ef73..0000000 --- a/packages/core/src/integrations/claude/mcp.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { execFile } from '../../exec/exec.js'; -import type { McpConnectOpts, McpDisconnectOpts } from '../types.js'; -import { - type McpServerName, - type McpServerStatus, - detectMcpStatuses as detectShared, -} from '../mcp/servers.js'; -import { getRegisteredMcpNames, getStoredAuthToken } from '../mcp/config.js'; -import { projectConfigPath } from './paths.js'; - -export function detectMcpStatuses( - projectDir: string, -): Promise> { - const configPath = projectConfigPath(projectDir); - return detectShared({ - getRegisteredNames: () => getRegisteredMcpNames(configPath), - getAuthToken: (name) => getStoredAuthToken(configPath, name), - }); -} - -export async function connectMcpServer(opts: McpConnectOpts): Promise { - try { - await execFile('claude', ['mcp', 'remove', '--scope', 'project', opts.serverName], { - cwd: opts.projectDir, - }); - } catch { - // Server may not be registered yet; the subsequent `mcp add` is idempotent - } - - const headers: Record = { ...opts.serverHeaders }; - if (opts.accessToken) { - headers['Authorization'] = `Bearer ${opts.accessToken}`; - } - - const args = [ - 'mcp', - 'add', - '--transport', - 'http', - '--scope', - 'project', - opts.serverName, - opts.serverUrl, - ]; - for (const [key, value] of Object.entries(headers)) { - args.push('--header', `${key}: ${value}`); - } - - await execFile('claude', args, { cwd: opts.projectDir }); - - allowMcpToolsInSettings(opts.serverName, opts.projectDir); -} - -export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { - removeMcpToolsFromSettings(opts.serverName, opts.projectDir); - - await execFile('claude', ['mcp', 'remove', '--scope', 'project', opts.serverName], { - cwd: opts.projectDir, - }); -} - -function removeMcpToolsFromSettings(serverName: string, projectDir: string): void { - const settingsPath = join(projectDir, '.claude', 'settings.local.json'); - if (!existsSync(settingsPath)) return; - - let settings: ClaudeSettings; - try { - settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as ClaudeSettings; - } catch { - return; - } - - const toolPattern = `mcp__${serverName}__*`; - if (settings.permissions?.allow) { - settings.permissions.allow = settings.permissions.allow.filter((p) => p !== toolPattern); - } - - if (settings.enabledMcpjsonServers) { - settings.enabledMcpjsonServers = settings.enabledMcpjsonServers.filter((s) => s !== serverName); - } - - writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); -} - -type ClaudeSettings = { - permissions?: { - allow?: string[]; - [key: string]: unknown; - }; - enabledMcpjsonServers?: string[]; - [key: string]: unknown; -}; - -function allowMcpToolsInSettings(serverName: string, projectDir: string): void { - const settingsDir = join(projectDir, '.claude'); - const settingsPath = join(settingsDir, 'settings.local.json'); - - if (!existsSync(settingsDir)) { - mkdirSync(settingsDir, { recursive: true }); - } - - let settings: ClaudeSettings = {}; - if (existsSync(settingsPath)) { - try { - settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as ClaudeSettings; - } catch { - // Corrupt JSON — fall through to overwrite with valid settings - } - } - - const permissions = settings.permissions ?? {}; - const allow = permissions.allow ?? []; - const toolPattern = `mcp__${serverName}__*`; - if (!allow.includes(toolPattern)) { - allow.push(toolPattern); - } - settings.permissions = { ...permissions, allow }; - - const enabled = settings.enabledMcpjsonServers ?? []; - if (!enabled.includes(serverName)) { - enabled.push(serverName); - } - settings.enabledMcpjsonServers = enabled; - - writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); -} diff --git a/packages/core/src/integrations/claude/mcp/connect.ts b/packages/core/src/integrations/claude/mcp/connect.ts new file mode 100644 index 0000000..80d1ccd --- /dev/null +++ b/packages/core/src/integrations/claude/mcp/connect.ts @@ -0,0 +1,48 @@ +import { execFile } from '../../../exec/exec.js'; +import type { McpConnectOpts, McpDisconnectOpts } from '../../types.js'; +import { allowMcpToolsInSettings, removeMcpToolsFromSettings } from './settings.js'; + +export async function connectMcpServer(opts: McpConnectOpts): Promise { + await unregisterServer(opts); + await registerServer(opts); + allowMcpToolsInSettings(opts); +} + +export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { + removeMcpToolsFromSettings(opts); + await unregisterServer(opts); +} + +async function registerServer(opts: McpConnectOpts): Promise { + const headers = { ...opts.serverHeaders }; + const args = [ + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + 'local', + opts.serverName, + opts.serverUrl, + ]; + + if (opts.accessToken) { + headers['Authorization'] = `Bearer ${opts.accessToken}`; + } + + for (const [key, value] of Object.entries(headers)) { + args.push('--header', `${key}: ${value}`); + } + + await execFile('claude', args, { cwd: opts.projectDir }); +} + +async function unregisterServer(opts: McpDisconnectOpts): Promise { + await Promise.allSettled( + (['local', 'project', 'user'] as const).map((scope) => + execFile('claude', ['mcp', 'remove', '--scope', scope, opts.serverName], { + cwd: opts.projectDir, + }), + ), + ); +} diff --git a/packages/core/src/integrations/claude/mcp/detect.ts b/packages/core/src/integrations/claude/mcp/detect.ts new file mode 100644 index 0000000..089d61a --- /dev/null +++ b/packages/core/src/integrations/claude/mcp/detect.ts @@ -0,0 +1,45 @@ +import { realpathSync, readFileSync } from 'node:fs'; +import type { McpStatusMap } from '../../mcp/servers.js'; +import { type McpServerName, detectMcpStatuses as detectShared } from '../../mcp/servers.js'; +import { onlyKnownServerNames } from '../../mcp/config.js'; +import { globalConfigPath } from '../paths.js'; + +type McpServerEntry = { + headers?: Record; +}; + +type ClaudeProjectConfig = { + mcpServers?: Record; +}; + +type ClaudeGlobalConfig = { + projects?: Record; +}; + +function readProjectMcpServers(projectDir: string): Record { + try { + const resolved = realpathSync(projectDir); + const config = JSON.parse(readFileSync(globalConfigPath(), 'utf-8')) as ClaudeGlobalConfig; + return ( + config.projects?.[resolved]?.mcpServers ?? config.projects?.[projectDir]?.mcpServers ?? {} + ); + } catch { + return {}; + } +} + +function getLocalScopeMcpNames(projectDir: string): string[] { + return onlyKnownServerNames(Object.keys(readProjectMcpServers(projectDir))); +} + +function getLocalScopeAuthToken(projectDir: string, serverName: McpServerName): string | null { + const bearer = readProjectMcpServers(projectDir)[serverName]?.headers?.['Authorization']; + return bearer?.startsWith('Bearer ') ? bearer.slice(7) : null; +} + +export function detectMcpStatuses(projectDir: string): Promise { + return detectShared({ + getRegisteredNames: () => getLocalScopeMcpNames(projectDir), + getAuthToken: (name) => getLocalScopeAuthToken(projectDir, name), + }); +} diff --git a/packages/core/src/integrations/claude/mcp/index.ts b/packages/core/src/integrations/claude/mcp/index.ts new file mode 100644 index 0000000..1bb4692 --- /dev/null +++ b/packages/core/src/integrations/claude/mcp/index.ts @@ -0,0 +1,2 @@ +export { detectMcpStatuses } from './detect.js'; +export { connectMcpServer, disconnectMcpServer } from './connect.js'; diff --git a/packages/core/src/integrations/claude/mcp/settings.ts b/packages/core/src/integrations/claude/mcp/settings.ts new file mode 100644 index 0000000..68a63b0 --- /dev/null +++ b/packages/core/src/integrations/claude/mcp/settings.ts @@ -0,0 +1,72 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { McpConnectOpts, McpDisconnectOpts } from '../../types.js'; + +type ClaudePermissions = { + allow?: string[]; + [key: string]: unknown; +}; + +type ClaudeSettings = { + permissions?: ClaudePermissions; + [key: string]: unknown; +}; + +export function allowMcpToolsInSettings(opts: McpConnectOpts): void { + const settingsPath = settingsFilePath(opts.projectDir); + const settings = readOrCreateSettings(settingsPath); + + const permissions = settings.permissions ?? {}; + const allow = permissions.allow ?? []; + const toolPattern = `mcp__${opts.serverName}__*`; + + if (!allow.includes(toolPattern)) { + allow.push(toolPattern); + } + + settings.permissions = { ...permissions, allow }; + + writeSettings(settingsPath, settings); +} + +export function removeMcpToolsFromSettings(opts: McpDisconnectOpts): void { + const settingsPath = settingsFilePath(opts.projectDir); + const settings = readSettings(settingsPath); + if (!settings) return; + + const toolPattern = `mcp__${opts.serverName}__*`; + if (settings.permissions?.allow) { + settings.permissions.allow = settings.permissions.allow.filter((p) => p !== toolPattern); + } + + writeSettings(settingsPath, settings); +} + +function settingsFilePath(projectDir: string): string { + return join(projectDir, '.claude', 'settings.local.json'); +} + +function readSettings(settingsPath: string): ClaudeSettings | null { + if (!existsSync(settingsPath)) return null; + try { + return JSON.parse(readFileSync(settingsPath, 'utf-8')) as ClaudeSettings; + } catch { + return null; + } +} + +function readOrCreateSettings(settingsPath: string): ClaudeSettings { + const existing = readSettings(settingsPath); + if (existing) return existing; + + const settingsDir = join(settingsPath, '..'); + if (!existsSync(settingsDir)) { + mkdirSync(settingsDir, { recursive: true }); + } + + return {}; +} + +function writeSettings(settingsPath: string, settings: ClaudeSettings): void { + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); +} diff --git a/packages/core/src/integrations/codex/index.ts b/packages/core/src/integrations/codex/index.ts index 50410a4..95d42c6 100644 --- a/packages/core/src/integrations/codex/index.ts +++ b/packages/core/src/integrations/codex/index.ts @@ -2,7 +2,7 @@ import type { IdeIntegration } from '../types.js'; import { launchChat } from './chat.js'; import { detectPlugin, installPlugin, updatePlugin, uninstallPlugin } from './plugins.js'; import { skillsDir } from './paths.js'; -import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp.js'; +import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp/index.js'; import { runOnboarding } from './onboarding.js'; import { prepare } from './prepare.js'; diff --git a/packages/core/src/integrations/codex/mcp.ts b/packages/core/src/integrations/codex/mcp.ts deleted file mode 100644 index 9ffddbc..0000000 --- a/packages/core/src/integrations/codex/mcp.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { execFile } from '../../exec/exec.js'; -import type { McpConnectOpts, McpDisconnectOpts } from '../types.js'; -import { - MCP_SERVERS, - type McpServerName, - type McpServerStatus, - detectMcpStatuses as detectShared, -} from '../mcp/servers.js'; -import { globalConfigPath, projectConfigPath } from './paths.js'; - -export function detectMcpStatuses( - projectDir: string, -): Promise> { - return detectShared({ - getRegisteredNames: () => getRegisteredMcpNames(projectDir), - getAuthToken: (name) => getStoredAuthToken(name), - }); -} - -export async function connectMcpServer(opts: McpConnectOpts): Promise { - try { - await execFile('codex', ['mcp', 'remove', opts.serverName]); - } catch { - // Server may not be registered yet; the subsequent `mcp add` is idempotent - } - - await execFile('codex', ['mcp', 'add', opts.serverName, '--url', opts.serverUrl]); - - const headers: Record = { ...opts.serverHeaders }; - if (opts.accessToken) { - headers['Authorization'] = `Bearer ${opts.accessToken}`; - } - - patchHttpHeaders(opts.serverName, headers); -} - -export function disconnectMcpServer(opts: McpDisconnectOpts): Promise { - removeTomlSection(projectConfigPath(opts.projectDir), opts.serverName); - removeTomlSection(globalConfigPath(), opts.serverName); - return Promise.resolve(); -} - -function getRegisteredMcpNames(projectDir: string): McpServerName[] { - const names = Object.keys(MCP_SERVERS) as McpServerName[]; - const paths = [globalConfigPath(), projectConfigPath(projectDir)]; - - return names.filter((name) => - paths.some((configPath) => { - try { - const content = readFileSync(configPath, 'utf-8'); - return content.includes(`[mcp_servers.${name}]`) || content.includes(`"${name}"`); - } catch { - // Config file doesn't exist — server is not registered in this scope - return false; - } - }), - ); -} - -function getStoredAuthToken(serverName: McpServerName): string | null { - try { - const content = readFileSync(globalConfigPath(), 'utf-8'); - const sectionHeader = `[mcp_servers.${serverName}]`; - const idx = content.indexOf(sectionHeader); - if (idx === -1) return null; - - const nextSection = content.indexOf('\n[', idx + sectionHeader.length); - const section = content.slice(idx, nextSection === -1 ? undefined : nextSection); - const match = section.match(/"Authorization"\s*=\s*"Bearer\s+([^"]+)"/); - return match?.[1] ?? null; - } catch { - // Config file missing or unreadable — treat as no stored token - return null; - } -} - -function removeTomlSection(configPath: string, serverName: string): void { - if (!existsSync(configPath)) return; - try { - const content = readFileSync(configPath, 'utf-8'); - const sectionHeader = `[mcp_servers.${serverName}]`; - const idx = content.indexOf(sectionHeader); - if (idx === -1) return; - - const nextSection = content.indexOf('\n[', idx + sectionHeader.length); - const before = content.slice(0, idx).replace(/\n+$/, ''); - const after = nextSection === -1 ? '' : content.slice(nextSection); - const result = (before + after).trim(); - - writeFileSync(configPath, result ? result + '\n' : '', 'utf-8'); - } catch { - // Corrupt or unreadable config — server is effectively unregistered already - } -} - -export function patchHttpHeaders( - serverName: string, - headers: Readonly>, -): void { - if (Object.keys(headers).length === 0) return; - - const configPath = globalConfigPath(); - try { - let content = readFileSync(configPath, 'utf-8'); - - const sectionHeader = `[mcp_servers.${serverName}]`; - const idx = content.indexOf(sectionHeader); - if (idx === -1) return; - - const nextSection = content.indexOf('\n[', idx + sectionHeader.length); - const sectionEnd = nextSection === -1 ? content.length : nextSection; - const sectionSlice = content.slice(idx, sectionEnd); - - const entries = Object.entries(headers) - .map(([k, v]) => `"${k}" = "${v}"`) - .join(', '); - const headerLine = `http_headers = { ${entries} }`; - - const existingMatch = sectionSlice.match(/^http_headers\s*=.*$/m); - if (existingMatch) { - const lineStart = idx + sectionSlice.indexOf(existingMatch[0]); - content = - content.slice(0, lineStart) + - headerLine + - content.slice(lineStart + existingMatch[0].length); - } else { - const before = content.slice(0, sectionEnd).trimEnd(); - const after = content.slice(sectionEnd); - content = before + '\n' + headerLine + '\n' + after; - } - - writeFileSync(configPath, content, 'utf-8'); - } catch { - // Config may not exist yet if `codex mcp add` failed; MCP still works without custom headers - } -} diff --git a/packages/core/src/integrations/codex/mcp/connect.ts b/packages/core/src/integrations/codex/mcp/connect.ts new file mode 100644 index 0000000..1dba810 --- /dev/null +++ b/packages/core/src/integrations/codex/mcp/connect.ts @@ -0,0 +1,29 @@ +import { execFile } from '../../../exec/exec.js'; +import type { McpConnectOpts, McpDisconnectOpts } from '../../types.js'; +import { removeTomlSection, ensureTomlSection, patchHttpHeaders } from './toml.js'; +import { globalConfigPath, projectConfigPath } from '../paths.js'; + +export async function connectMcpServer(opts: McpConnectOpts): Promise { + try { + await execFile('codex', ['mcp', 'remove', opts.serverName]); + } catch { + // Server may not be registered yet; the subsequent `mcp add` is idempotent + } + + await execFile('codex', ['mcp', 'add', opts.serverName, '--url', opts.serverUrl]); + + const headers: Record = { ...opts.serverHeaders }; + if (opts.accessToken) { + headers['Authorization'] = `Bearer ${opts.accessToken}`; + } + + const configPath = globalConfigPath(); + ensureTomlSection(opts.serverName, opts.serverUrl, configPath); + patchHttpHeaders(opts.serverName, headers, configPath); +} + +export function disconnectMcpServer(opts: McpDisconnectOpts): Promise { + removeTomlSection(projectConfigPath(opts.projectDir), opts.serverName); + removeTomlSection(globalConfigPath(), opts.serverName); + return Promise.resolve(); +} diff --git a/packages/core/src/integrations/codex/mcp/detect.ts b/packages/core/src/integrations/codex/mcp/detect.ts new file mode 100644 index 0000000..3bd9741 --- /dev/null +++ b/packages/core/src/integrations/codex/mcp/detect.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs'; +import type { McpServerName, McpStatusMap } from '../../mcp/servers.js'; +import { detectMcpStatuses as detectShared } from '../../mcp/servers.js'; +import { onlyKnownServerNames } from '../../mcp/config.js'; +import { globalConfigPath, projectConfigPath } from '../paths.js'; + +export function detectMcpStatuses(projectDir: string): Promise { + return detectShared({ + getRegisteredNames: () => getRegisteredMcpNames(projectDir), + getAuthToken: (name) => getStoredAuthToken(name), + }); +} + +function getRegisteredMcpNames(projectDir: string): string[] { + const paths = [globalConfigPath(), projectConfigPath(projectDir)]; + + const names = paths.flatMap((configPath) => { + try { + const content = readFileSync(configPath, 'utf-8'); + const matches = [...content.matchAll(/\[mcp_servers\.([^\]]+)\]/g)].map((m) => m[1]); + return matches; + } catch { + return []; + } + }); + + return onlyKnownServerNames(names); +} + +function getStoredAuthToken(serverName: McpServerName): string | null { + try { + const content = readFileSync(globalConfigPath(), 'utf-8'); + const sectionHeader = `[mcp_servers.${serverName}]`; + const idx = content.indexOf(sectionHeader); + if (idx === -1) return null; + + const nextSection = content.indexOf('\n[', idx + sectionHeader.length); + const section = content.slice(idx, nextSection === -1 ? undefined : nextSection); + const match = section.match(/"Authorization"\s*=\s*"Bearer\s+([^"]+)"/); + return match?.[1] ?? null; + } catch { + return null; + } +} diff --git a/packages/core/src/integrations/codex/mcp/index.ts b/packages/core/src/integrations/codex/mcp/index.ts new file mode 100644 index 0000000..1bb4692 --- /dev/null +++ b/packages/core/src/integrations/codex/mcp/index.ts @@ -0,0 +1,2 @@ +export { detectMcpStatuses } from './detect.js'; +export { connectMcpServer, disconnectMcpServer } from './connect.js'; diff --git a/packages/core/src/integrations/codex/mcp/toml.ts b/packages/core/src/integrations/codex/mcp/toml.ts new file mode 100644 index 0000000..9e6b417 --- /dev/null +++ b/packages/core/src/integrations/codex/mcp/toml.ts @@ -0,0 +1,82 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + +type TomlSection = { + start: number; + end: number; + body: string; +}; + +function findTomlSection(content: string, serverName: string): TomlSection | null { + const header = `[mcp_servers.${serverName}]`; + const start = content.indexOf(header); + if (start === -1) return null; + + const nextSection = content.indexOf('\n[', start + header.length); + const end = nextSection === -1 ? content.length : nextSection; + return { start, end, body: content.slice(start, end) }; +} + +export function removeTomlSection(configPath: string, serverName: string): void { + if (!existsSync(configPath)) return; + try { + const content = readFileSync(configPath, 'utf-8'); + const section = findTomlSection(content, serverName); + if (!section) return; + + const before = content.slice(0, section.start).replace(/\n+$/, ''); + const after = content.slice(section.end); + const result = (before + after).trim(); + + writeFileSync(configPath, result ? result + '\n' : '', 'utf-8'); + } catch { + // Corrupt or unreadable config — server is effectively unregistered already + } +} + +export function ensureTomlSection(serverName: string, url: string, configPath: string): void { + try { + const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : ''; + if (findTomlSection(content, serverName)) return; + + const section = `\n[mcp_servers.${serverName}]\nurl = "${url}"\n`; + writeFileSync(configPath, content.trimEnd() + section, 'utf-8'); + } catch { + // Config may not be writable; patchHttpHeaders will handle the fallout + } +} + +export function patchHttpHeaders( + serverName: string, + headers: Readonly>, + configPath: string, +): void { + if (Object.keys(headers).length === 0) return; + + try { + let content = readFileSync(configPath, 'utf-8'); + const section = findTomlSection(content, serverName); + if (!section) return; + + const entries = Object.entries(headers) + .map(([k, v]) => `"${k}" = "${v}"`) + .join(', '); + const headerLine = `http_headers = { ${entries} }`; + + const existingMatch = section.body.match(/^http_headers\s*=.*$/m); + if (existingMatch) { + const lineStart = section.start + section.body.indexOf(existingMatch[0]); + content = + content.slice(0, lineStart) + + headerLine + + content.slice(lineStart + existingMatch[0].length); + } else { + const before = content.slice(0, section.end).trimEnd(); + const after = content.slice(section.end); + content = before + '\n' + headerLine + '\n' + after; + } + + writeFileSync(configPath, content, 'utf-8'); + } catch { + // Config may not exist yet if `codex mcp add` failed; MCP still works without custom headers + } +} diff --git a/packages/core/src/integrations/cursor/index.ts b/packages/core/src/integrations/cursor/index.ts index 9fea7b0..d3a30ea 100644 --- a/packages/core/src/integrations/cursor/index.ts +++ b/packages/core/src/integrations/cursor/index.ts @@ -1,6 +1,6 @@ import type { IdeIntegration } from '../types.js'; import { launchChat } from './chat.js'; -import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp.js'; +import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp/index.js'; import { runOnboarding } from './onboarding.js'; import { detectPlugin, installPlugin, updatePlugin, uninstallPlugin } from './plugins.js'; import { skillsDir } from './paths.js'; diff --git a/packages/core/src/integrations/cursor/mcp.ts b/packages/core/src/integrations/cursor/mcp.ts deleted file mode 100644 index 56aeb60..0000000 --- a/packages/core/src/integrations/cursor/mcp.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { execFile } from '../../exec/exec.js'; -import type { McpConnectOpts, McpDisconnectOpts } from '../types.js'; -import { - type McpServerName, - type McpServerStatus, - detectMcpStatuses as detectShared, -} from '../mcp/servers.js'; -import { getRegisteredMcpNames, getStoredAuthToken } from '../mcp/config.js'; -import { cliConfigPath, globalConfigPath, mcpConfigPath } from './paths.js'; - -export function detectMcpStatuses( - projectDir: string, -): Promise> { - const configPath = mcpConfigPath(projectDir); - return detectShared({ - getRegisteredNames: () => getRegisteredMcpNames(configPath), - getAuthToken: (name) => getStoredAuthToken(configPath, name), - }); -} - -export async function connectMcpServer(opts: McpConnectOpts): Promise { - const headers: Record = { ...opts.serverHeaders }; - if (opts.accessToken) { - headers['Authorization'] = `Bearer ${opts.accessToken}`; - } - - const entry = { type: opts.serverType, url: opts.serverUrl, headers }; - - writeMcpEntry(mcpConfigPath(opts.projectDir), opts.serverName, entry); - writeMcpEntry(globalConfigPath(), opts.serverName, entry); - writeCliPermission(cliConfigPath(opts.projectDir), opts.serverName); - - try { - await execFile('cursor', ['agent', 'mcp', 'enable', opts.serverName]); - } catch { - // `cursor` CLI is not always installed; config files were already written above - } -} - -export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { - removeMcpEntry(mcpConfigPath(opts.projectDir), opts.serverName); - removeCliPermission(cliConfigPath(opts.projectDir), opts.serverName); -} - -function removeMcpEntry(configPath: string, serverName: string): void { - if (!existsSync(configPath)) return; - try { - const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; - const mcpServers = (config.mcpServers ?? {}) as Record; - delete mcpServers[serverName]; - config.mcpServers = mcpServers; - writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); - } catch { - // Corrupt or unreadable config — server is effectively unregistered already - } -} - -function removeCliPermission(configPath: string, serverName: string): void { - if (!existsSync(configPath)) return; - try { - const config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; - const permissions = (config.permissions ?? {}) as Record; - const allow = (permissions.allow ?? []) as string[]; - const rule = `Mcp(${serverName}:*)`; - permissions.allow = allow.filter((r) => r !== rule); - config.permissions = permissions; - writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); - } catch { - // Corrupt or unreadable config — stale permission rules are harmless - } -} - -function writeMcpEntry(configPath: string, serverName: string, entry: unknown): void { - let config: Record = {}; - if (existsSync(configPath)) { - try { - config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; - } catch { - // Corrupt JSON — fall through to overwrite with valid config - } - } else { - mkdirSync(join(configPath, '..'), { recursive: true }); - } - - const mcpServers = (config.mcpServers ?? {}) as Record; - mcpServers[serverName] = entry; - config.mcpServers = mcpServers; - - writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); -} - -function writeCliPermission(configPath: string, serverName: string): void { - let config: Record = {}; - if (existsSync(configPath)) { - try { - config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record; - } catch { - // Corrupt JSON — fall through to overwrite with valid config - } - } else { - mkdirSync(join(configPath, '..'), { recursive: true }); - } - - const permissions = (config.permissions ?? {}) as Record; - const allow = (permissions.allow ?? []) as string[]; - const rule = `Mcp(${serverName}:*)`; - - if (!allow.includes(rule)) { - allow.push(rule); - } - - permissions.allow = allow; - permissions.deny ??= []; - config.permissions = permissions; - - writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); -} diff --git a/packages/core/src/integrations/cursor/mcp/config.ts b/packages/core/src/integrations/cursor/mcp/config.ts new file mode 100644 index 0000000..b5ce0f5 --- /dev/null +++ b/packages/core/src/integrations/cursor/mcp/config.ts @@ -0,0 +1,22 @@ +import { readJsonConfig, readOrCreateJsonConfig, writeJsonConfig } from './json-config.js'; + +export function writeMcpEntry(configPath: string, serverName: string, entry: unknown): void { + const config = readOrCreateJsonConfig(configPath); + + const mcpServers = (config.mcpServers ?? {}) as Record; + mcpServers[serverName] = entry; + config.mcpServers = mcpServers; + + writeJsonConfig(configPath, config); +} + +export function removeMcpEntry(configPath: string, serverName: string): void { + const config = readJsonConfig(configPath); + if (!config) return; + + const mcpServers = (config.mcpServers ?? {}) as Record; + delete mcpServers[serverName]; + config.mcpServers = mcpServers; + + writeJsonConfig(configPath, config); +} diff --git a/packages/core/src/integrations/cursor/mcp/connect.ts b/packages/core/src/integrations/cursor/mcp/connect.ts new file mode 100644 index 0000000..20b60b1 --- /dev/null +++ b/packages/core/src/integrations/cursor/mcp/connect.ts @@ -0,0 +1,29 @@ +import { execFile } from '../../../exec/exec.js'; +import type { McpConnectOpts, McpDisconnectOpts } from '../../types.js'; +import { writeMcpEntry, removeMcpEntry } from './config.js'; +import { writeCliPermission, removeCliPermission } from './permissions.js'; +import { cliConfigPath, globalConfigPath } from '../paths.js'; + +export async function connectMcpServer(opts: McpConnectOpts): Promise { + const headers: Record = { ...opts.serverHeaders }; + + if (opts.accessToken) { + headers['Authorization'] = `Bearer ${opts.accessToken}`; + } + + const entry = { type: opts.serverType, url: opts.serverUrl, headers }; + + writeMcpEntry(globalConfigPath(), opts.serverName, entry); + writeCliPermission(cliConfigPath(opts.projectDir), opts.serverName); + + try { + await execFile('cursor', ['agent', 'mcp', 'enable', opts.serverName]); + } catch { + // `cursor` CLI is not always installed; config files were already written above + } +} + +export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise { + removeMcpEntry(globalConfigPath(), opts.serverName); + removeCliPermission(cliConfigPath(opts.projectDir), opts.serverName); +} diff --git a/packages/core/src/integrations/cursor/mcp/detect.ts b/packages/core/src/integrations/cursor/mcp/detect.ts new file mode 100644 index 0000000..790dbfe --- /dev/null +++ b/packages/core/src/integrations/cursor/mcp/detect.ts @@ -0,0 +1,11 @@ +import { type McpStatusMap, detectMcpStatuses as detectShared } from '../../mcp/servers.js'; +import { getRegisteredMcpNames, getStoredAuthToken } from '../../mcp/config.js'; +import { globalConfigPath } from '../paths.js'; + +export function detectMcpStatuses(_projectDir: string): Promise { + const configPath = globalConfigPath(); + return detectShared({ + getRegisteredNames: () => getRegisteredMcpNames(configPath), + getAuthToken: (name) => getStoredAuthToken(configPath, name), + }); +} diff --git a/packages/core/src/integrations/cursor/mcp/index.ts b/packages/core/src/integrations/cursor/mcp/index.ts new file mode 100644 index 0000000..1bb4692 --- /dev/null +++ b/packages/core/src/integrations/cursor/mcp/index.ts @@ -0,0 +1,2 @@ +export { detectMcpStatuses } from './detect.js'; +export { connectMcpServer, disconnectMcpServer } from './connect.js'; diff --git a/packages/core/src/integrations/cursor/mcp/json-config.ts b/packages/core/src/integrations/cursor/mcp/json-config.ts new file mode 100644 index 0000000..efe274e --- /dev/null +++ b/packages/core/src/integrations/cursor/mcp/json-config.ts @@ -0,0 +1,26 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +export function readJsonConfig(configPath: string): Record | null { + if (!existsSync(configPath)) return null; + try { + return JSON.parse(readFileSync(configPath, 'utf-8')) as Record; + } catch { + return null; + } +} + +export function readOrCreateJsonConfig(configPath: string): Record { + const existing = readJsonConfig(configPath); + if (existing) return existing; + + if (!existsSync(configPath)) { + mkdirSync(join(configPath, '..'), { recursive: true }); + } + + return {}; +} + +export function writeJsonConfig(configPath: string, config: Record): void { + writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); +} diff --git a/packages/core/src/integrations/cursor/mcp/permissions.ts b/packages/core/src/integrations/cursor/mcp/permissions.ts new file mode 100644 index 0000000..8afa446 --- /dev/null +++ b/packages/core/src/integrations/cursor/mcp/permissions.ts @@ -0,0 +1,33 @@ +import { readJsonConfig, readOrCreateJsonConfig, writeJsonConfig } from './json-config.js'; + +export function writeCliPermission(configPath: string, serverName: string): void { + const config = readOrCreateJsonConfig(configPath); + + const permissions = (config.permissions ?? {}) as Record; + const allow = (permissions.allow ?? []) as string[]; + const rule = `Mcp(${serverName}:*)`; + + if (!allow.includes(rule)) { + allow.push(rule); + } + + permissions.allow = allow; + permissions.deny ??= []; + config.permissions = permissions; + + writeJsonConfig(configPath, config); +} + +export function removeCliPermission(configPath: string, serverName: string): void { + const config = readJsonConfig(configPath); + if (!config) return; + + const permissions = (config.permissions ?? {}) as Record; + const allow = (permissions.allow ?? []) as string[]; + const rule = `Mcp(${serverName}:*)`; + + permissions.allow = allow.filter((r) => r !== rule); + config.permissions = permissions; + + writeJsonConfig(configPath, config); +} diff --git a/packages/core/src/integrations/cursor/paths.ts b/packages/core/src/integrations/cursor/paths.ts index 6c77445..d812619 100644 --- a/packages/core/src/integrations/cursor/paths.ts +++ b/packages/core/src/integrations/cursor/paths.ts @@ -5,14 +5,6 @@ export function globalConfigPath(): string { return join(homedir(), '.cursor', 'mcp.json'); } -export function projectConfigPath(projectDir: string): string { - return join(projectDir, '.cursor', 'mcp.json'); -} - -export function mcpConfigPath(projectDir: string): string { - return join(projectDir, '.cursor', 'mcp.json'); -} - export function cliConfigPath(projectDir: string): string { return join(projectDir, '.cursor', 'cli.json'); } diff --git a/packages/core/src/integrations/index.ts b/packages/core/src/integrations/index.ts index f09eb3d..97688ac 100644 --- a/packages/core/src/integrations/index.ts +++ b/packages/core/src/integrations/index.ts @@ -15,6 +15,7 @@ export { type McpServer, type McpServerName, type McpServerStatus, + type McpStatusMap, MCP_SERVERS, allServersConnected, getAvailableMcpServers, diff --git a/packages/core/src/integrations/mcp/config.ts b/packages/core/src/integrations/mcp/config.ts index 96df45d..51fb5b6 100644 --- a/packages/core/src/integrations/mcp/config.ts +++ b/packages/core/src/integrations/mcp/config.ts @@ -13,12 +13,16 @@ function readMcpJsonConfig(configPath: string): McpJsonConfig | null { } } -export function getRegisteredMcpNames(configPath: string): string[] { +export function onlyKnownServerNames(names: string[]): McpServerName[] { + const known = Object.keys(MCP_SERVERS) as McpServerName[]; + return known.filter((name) => names.includes(name)); +} + +export function getRegisteredMcpNames(configPath: string): McpServerName[] { const config = readMcpJsonConfig(configPath); if (!config) return []; - const mcpServers = config.mcpServers ?? {}; - return (Object.keys(MCP_SERVERS) as McpServerName[]).filter((name) => name in mcpServers); + return onlyKnownServerNames(Object.keys(config.mcpServers ?? {})); } export function getStoredAuthToken(configPath: string, serverName: McpServerName): string | null { diff --git a/packages/core/src/integrations/mcp/index.ts b/packages/core/src/integrations/mcp/index.ts index bb53462..ca565e3 100644 --- a/packages/core/src/integrations/mcp/index.ts +++ b/packages/core/src/integrations/mcp/index.ts @@ -2,6 +2,7 @@ export { type McpServer, type McpServerName, type McpServerStatus, + type McpStatusMap, MCP_SERVERS, allServersConnected, getAvailableMcpServers, diff --git a/packages/core/src/integrations/mcp/preference.ts b/packages/core/src/integrations/mcp/preference.ts index 2720358..a6a267a 100644 --- a/packages/core/src/integrations/mcp/preference.ts +++ b/packages/core/src/integrations/mcp/preference.ts @@ -1,24 +1,24 @@ -import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { getConfigDir } from '../../auth/credentials/paths.js'; type McpPreference = 'connected' | 'skipped'; -const PREFERENCE_FILE = join(tmpdir(), 'confidence_mcp_preference'); +const PREFERENCE_FILE = join(getConfigDir(), 'mcp_preference'); export function loadMcpPreference(): McpPreference | null { if (!existsSync(PREFERENCE_FILE)) return null; try { const value = readFileSync(PREFERENCE_FILE, 'utf-8').trim(); - if (value === 'connected' || value === 'skipped') return value; - return null; + return ['connected', 'skipped'].includes(value) ? (value as McpPreference) : null; } catch { return null; } } export function persistMcpPreference(preference: McpPreference): void { + mkdirSync(getConfigDir(), { recursive: true }); writeFileSync(PREFERENCE_FILE, preference, 'utf-8'); } diff --git a/packages/core/src/integrations/mcp/servers.ts b/packages/core/src/integrations/mcp/servers.ts index c5ee921..5b2394c 100644 --- a/packages/core/src/integrations/mcp/servers.ts +++ b/packages/core/src/integrations/mcp/servers.ts @@ -23,6 +23,7 @@ export type McpServer = { }; export type McpServerStatus = 'not-installed' | 'installed' | 'auth-expired' | 'connected'; +export type McpStatusMap = Record; export function allServersConnected(statuses: Record): boolean { return Object.values(statuses).every((s) => s === 'connected'); @@ -35,7 +36,7 @@ export function getAvailableMcpServers(): McpServer[] { export async function detectMcpStatuses(deps: { getRegisteredNames: () => string[]; getAuthToken: (name: McpServerName) => string | null; -}): Promise> { +}): Promise { const registered = deps.getRegisteredNames(); const names = Object.keys(MCP_SERVERS) as McpServerName[]; @@ -51,7 +52,7 @@ export async function detectMcpStatuses(deps: { }), ); - return Object.fromEntries(statuses) as Record; + return Object.fromEntries(statuses) as McpStatusMap; } export type McpVerifyOpts = { diff --git a/packages/core/src/integrations/types.ts b/packages/core/src/integrations/types.ts index 6378a9b..ac4301a 100644 --- a/packages/core/src/integrations/types.ts +++ b/packages/core/src/integrations/types.ts @@ -4,7 +4,7 @@ import type { PluginInstallationMethod, PluginScope, } from '@spotify-confidence/shared-kernel'; -import type { McpServerName, McpServerStatus } from './mcp/servers.js'; +import type { McpServerName, McpStatusMap } from './mcp/servers.js'; export type InstalledPlugin = { ide: IdeId; @@ -58,7 +58,7 @@ export type IdeIntegration = { updatePlugin: (projectDir: string, scope?: PluginScope) => Promise; uninstallPlugin: (projectDir: string, scope?: PluginScope) => Promise; - detectMcpStatuses: (projectDir: string) => Promise>; + detectMcpStatuses: (projectDir: string) => Promise; connectMcpServer: (opts: McpConnectOpts) => Promise; disconnectMcpServer: (opts: McpDisconnectOpts) => Promise; diff --git a/packages/quickstart/__tests__/e2e/stale-mcp-auth.e2e.ts b/packages/quickstart/__tests__/e2e/stale-mcp-auth.e2e.ts index 7c09ef8..6edcccb 100644 --- a/packages/quickstart/__tests__/e2e/stale-mcp-auth.e2e.ts +++ b/packages/quickstart/__tests__/e2e/stale-mcp-auth.e2e.ts @@ -1,5 +1,3 @@ -import { writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { createSession, navigatePastWelcome, @@ -7,34 +5,17 @@ import { navigatePastAuth, buildTestJwt, } from '@spotify-confidence/testing/e2e'; - -function buildExpiredJwt(): string { - return buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 3600 }); -} - -function writeExpiredMcpConfig(projectDir: string): void { - const expired = buildExpiredJwt(); - const config = { - mcpServers: { - 'confidence-flags': { - type: 'http', - url: 'https://mcp.confidence.dev/mcp/flags', - headers: { Authorization: `Bearer ${expired}` }, - }, - 'confidence-docs': { - type: 'http', - url: 'https://mcp.confidence.dev/mcp/docs', - headers: { Authorization: `Bearer ${expired}` }, - }, - }, - }; - writeFileSync(join(projectDir, '.mcp.json'), JSON.stringify(config)); -} +import { createProjectDir, writeClaudeGlobalConfig } from '@spotify-confidence/testing/scaffold'; describe('when MCP config has expired auth tokens', () => { it('shows auth-expired status and reconnects successfully', async () => { - using session = createSession(); - writeExpiredMcpConfig(session.cwd); + const expiredToken = buildExpiredJwt(); + using home = createProjectDir('empty'); + using session = createSession({ + token: expiredToken, + env: { HOME: home.path }, + }); + writeClaudeGlobalConfig(home.path, session.cwd, buildMcpServers(expiredToken)); // Welcome await session.waitForText('Start setup'); @@ -68,8 +49,13 @@ describe('when MCP config has expired auth tokens', () => { }); it('allows skipping when auth is expired', async () => { - using session = createSession(); - writeExpiredMcpConfig(session.cwd); + const expiredToken = buildExpiredJwt(); + using home = createProjectDir('empty'); + using session = createSession({ + token: expiredToken, + env: { HOME: home.path }, + }); + writeClaudeGlobalConfig(home.path, session.cwd, buildMcpServers(expiredToken)); await navigatePastWelcome(session); await navigatePastGoalSelection(session); @@ -92,3 +78,15 @@ describe('when MCP config has expired auth tokens', () => { expect(session.snapshot()).toMatchSnapshot('mcp-auth-skipped'); }); }); + +function buildExpiredJwt(): string { + return buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 3600 }); +} + +function buildMcpServers(token: string) { + const headers = { Authorization: `Bearer ${token}` }; + return { + 'confidence-flags': { type: 'http', url: 'https://mcp.confidence.dev/mcp/flags', headers }, + 'confidence-docs': { type: 'http', url: 'https://mcp.confidence.dev/mcp/docs', headers }, + }; +} diff --git a/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx b/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx index 45586c4..1bdd3c5 100644 --- a/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx +++ b/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.auth.test.tsx @@ -1,25 +1,28 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { http, HttpResponse } from 'msw'; import { act, renderScreen, createProjectDir, + prepareAuthTokens, waitFor, buildExpiredJwt, buildAuthState, ENTER, } from '../testing-framework/index.js'; import { ConnectToolsScreen } from '@ui/screens/connect-tools/index.js'; -import { - ScreenId, - persistMcpPreference, - clearMcpPreference, - MCP_SERVERS, - type McpServerName, -} from '@spotify-confidence/core'; +import { ScreenId, persistMcpPreference, clearMcpPreference } from '@spotify-confidence/core'; import type { IdeId } from '@spotify-confidence/shared-kernel'; import { server } from '@spotify-confidence/testing'; +import { + writeClaudeGlobalConfig, + writeCursorMcpConfig, + writeCodexConfig, +} from '@spotify-confidence/testing/scaffold'; + +vi.mock('../../../../core/src/exec/exec.js', () => ({ + execFile: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + spawn: vi.fn(), +})); type IntegrationTestCase = { ide: IdeId; @@ -35,8 +38,8 @@ describe('ConnectToolsScreen', () => { ); using _pref = createMcpPreference('connected'); + using _auth = prepareAuthTokens('none'); using project = createProjectDir(); - writeMcpConfig(project.path, 'cursor'); // Act using sut = renderScreen(, { @@ -58,8 +61,10 @@ describe('ConnectToolsScreen', () => { 'shows auth-expired status for $ide', async ({ ide }) => { // Arrange + const token = buildExpiredJwt(); using project = createProjectDir(); - writeMcpConfig(project.path, ide, { token: buildExpiredJwt() }); + using _mcp = seedMcpRegistration(project.path, { token }); + using _creds = prepareAuthTokens('expired'); // Act using sut = renderScreen(, { @@ -79,7 +84,8 @@ describe('ConnectToolsScreen', () => { it('shows expired auth warning message', async () => { using project = createProjectDir(); - writeMcpConfig(project.path, 'cursor', { token: buildExpiredJwt() }); + using _mcp = seedMcpRegistration(project.path, { token: buildExpiredJwt() }); + using _creds = prepareAuthTokens('expired'); using sut = renderScreen(, { screen: ScreenId.ConnectTools, @@ -98,7 +104,8 @@ describe('ConnectToolsScreen', () => { it('reconnects successfully when user selects reconnect', async () => { // Arrange using project = createProjectDir(); - writeMcpConfig(project.path, 'cursor', { token: buildExpiredJwt() }); + using _mcp = seedMcpRegistration(project.path, { token: buildExpiredJwt() }); + using _creds = prepareAuthTokens('expired'); using sut = renderScreen(, { screen: ScreenId.ConnectTools, @@ -122,8 +129,8 @@ describe('ConnectToolsScreen', () => { }); describe('when server returns 401 during detection', () => { - // Arrange it('shows auth-expired for codex', async () => { + // Arrange server.use( http.post( 'https://mcp.confidence.dev/mcp/flags', @@ -136,7 +143,8 @@ describe('ConnectToolsScreen', () => { ); using project = createProjectDir(); - writeMcpConfig(project.path, 'codex'); + using _mcp = seedMcpRegistration(project.path); + using _auth = prepareAuthTokens(); // Act using sut = renderScreen(, { @@ -154,102 +162,78 @@ describe('ConnectToolsScreen', () => { }); describe('when server returns 401 after connecting', () => { - it.each([{ ide: 'claude' }, { ide: 'cursor' }])( - 'shows auth-expired for $ide', - async ({ ide }) => { - // Arrange - server.use( - http.post( - 'https://mcp.confidence.dev/mcp/flags', - () => new HttpResponse(null, { status: 401 }), - ), - http.post( - 'https://mcp.confidence.dev/mcp/docs', - () => new HttpResponse(null, { status: 401 }), - ), - ); + it('shows auth-expired after connect attempt', async () => { + // Arrange + server.use( + http.post( + 'https://mcp.confidence.dev/mcp/flags', + () => new HttpResponse(null, { status: 401 }), + ), + http.post( + 'https://mcp.confidence.dev/mcp/docs', + () => new HttpResponse(null, { status: 401 }), + ), + ); - using project = createProjectDir(); + using _auth = prepareAuthTokens('none'); + using project = createProjectDir(); - using sut = renderScreen(, { - screen: ScreenId.ConnectTools, - dir: project.path, - ide, - }); + using sut = renderScreen(, { + screen: ScreenId.ConnectTools, + dir: project.path, + }); - await waitFor(() => { - expect(sut.lastFrame()).toContain('Connect all tools'); - }); + await waitFor(() => { + expect(sut.lastFrame()).toContain('Connect all tools'); + }); - // Act - await act(() => sut.stdin.write(ENTER)); + // Act + await act(() => sut.stdin.write(ENTER)); - // Assert - await waitFor(() => { - expect(sut.lastFrame()).toContain('auth expired'); - }); - }, - 10000, - ); + // Assert + await waitFor(() => { + expect(sut.lastFrame()).toContain('auth expired'); + }); + }, 10000); }); }); -function createMcpPreference(value: 'connected' | 'skipped') { - persistMcpPreference(value); - return { - [Symbol.dispose]() { - clearMcpPreference(); - }, - }; -} - -type McpConfigOpts = { +type SeedMcpOpts = { token?: string; }; -function writeMcpConfig(projectDir: string, ide: IdeId, opts?: McpConfigOpts): void { - switch (ide) { - case 'claude': - return writeJsonMcpConfig(join(projectDir, '.mcp.json'), opts); - - case 'cursor': - mkdirSync(join(projectDir, '.cursor'), { recursive: true }); - return writeJsonMcpConfig(join(projectDir, '.cursor', 'mcp.json'), opts); - - case 'codex': - return writeCodexMcpConfig(projectDir); - - default: { - const _exhaustive: never = ide satisfies never; - throw new Error(`Unhandled IDE: ${_exhaustive}`); - } - } -} +function seedMcpRegistration(projectDir: string, opts?: SeedMcpOpts) { + vi.stubEnv('HOME', projectDir); -function writeJsonMcpConfig(configPath: string, opts?: McpConfigOpts): void { - writeFileSync( - configPath, - JSON.stringify({ - mcpServers: Object.fromEntries( - Object.entries(MCP_SERVERS).map(([name, { type, url }]) => { - const server: Record = { type, url }; - - if (opts?.token) { - server.headers = { Authorization: `Bearer ${opts.token}` }; - } - - return [name, server]; - }), - ), - }), + const headers = opts?.token ? { Authorization: `Bearer ${opts.token}` } : undefined; + const servers = { + 'confidence-flags': { type: 'http', url: 'https://mcp.confidence.dev/mcp/flags', headers }, + 'confidence-docs': { type: 'http', url: 'https://mcp.confidence.dev/mcp/docs', headers }, + }; + const codexHeaders = opts?.token + ? `\nhttp_headers = { "Authorization" = "Bearer ${opts.token}" }` + : ''; + + writeClaudeGlobalConfig(projectDir, projectDir, servers); + writeCursorMcpConfig(projectDir, { mcpServers: servers }); + writeCodexConfig( + projectDir, + `[mcp_servers.confidence-flags]\nurl = "https://mcp.confidence.dev/mcp/flags"${codexHeaders}\n\n[mcp_servers.confidence-docs]\nurl = "https://mcp.confidence.dev/mcp/docs"${codexHeaders}\n`, ); + + return { + [Symbol.dispose]() { + vi.unstubAllEnvs(); + }, + }; } -function writeCodexMcpConfig(projectDir: string): void { - const content = (Object.keys(MCP_SERVERS) as McpServerName[]) - .map((name) => `[mcp_servers.${name}]\nurl = "${MCP_SERVERS[name].url}"`) - .join('\n\n'); +function createMcpPreference(value: 'connected' | 'skipped') { + persistMcpPreference(value); - mkdirSync(join(projectDir, '.codex'), { recursive: true }); - writeFileSync(join(projectDir, '.codex', 'config.toml'), content); + return { + [Symbol.dispose]() { + clearMcpPreference(); + }, + }; } diff --git a/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.test.tsx b/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.test.tsx index 815cdef..226e330 100644 --- a/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.test.tsx +++ b/packages/quickstart/__tests__/ui/screens/ConnectToolsScreen.test.tsx @@ -4,6 +4,7 @@ import { renderScreen, renderApp, createProjectDir, + prepareAuthTokens, ENTER, ARROW_DOWN, waitFor, @@ -12,8 +13,14 @@ import { ConnectToolsScreen } from '@ui/screens/connect-tools/index.js'; import { ScreenId } from '@spotify-confidence/core'; import { server } from '@spotify-confidence/testing'; +vi.mock('../../../../core/src/exec/exec.js', () => ({ + execFile: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + spawn: vi.fn(), +})); + describe('ConnectToolsScreen', () => { it('renders title', async () => { + using _auth = prepareAuthTokens('none'); using project = createProjectDir(); using sut = renderScreen(, { screen: ScreenId.ConnectTools, @@ -25,6 +32,7 @@ describe('ConnectToolsScreen', () => { }); it('shows tool list after detection', async () => { + using _auth = prepareAuthTokens('none'); using project = createProjectDir(); using sut = renderScreen(, { screen: ScreenId.ConnectTools, @@ -37,6 +45,7 @@ describe('ConnectToolsScreen', () => { }); it('shows connect options', async () => { + using _auth = prepareAuthTokens('none'); using project = createProjectDir(); using sut = renderScreen(, { screen: ScreenId.ConnectTools, @@ -49,11 +58,11 @@ describe('ConnectToolsScreen', () => { describe('when connection succeeds', () => { it('connects and shows success message', async () => { + using _auth = prepareAuthTokens('none'); using project = createProjectDir(); using sut = renderScreen(, { screen: ScreenId.ConnectTools, dir: project.path, - ide: 'cursor', }); await waitFor(() => { @@ -75,11 +84,11 @@ describe('ConnectToolsScreen', () => { http.post('https://mcp.confidence.dev/mcp/docs', () => HttpResponse.error()), ); + using _auth = prepareAuthTokens('none'); using project = createProjectDir(); using sut = renderScreen(, { screen: ScreenId.ConnectTools, dir: project.path, - ide: 'cursor', }); await waitFor(() => { @@ -96,11 +105,11 @@ describe('ConnectToolsScreen', () => { it('shows partial failure status', async () => { server.use(http.post('https://mcp.confidence.dev/mcp/docs', () => HttpResponse.error())); + using _auth = prepareAuthTokens('none'); using project = createProjectDir(); using sut = renderScreen(, { screen: ScreenId.ConnectTools, dir: project.path, - ide: 'cursor', }); await waitFor(() => { @@ -118,6 +127,7 @@ describe('ConnectToolsScreen', () => { describe('when user skips', () => { it('shows skip confirmation and auto-advances', async () => { + using _auth = prepareAuthTokens('none'); using project = createProjectDir(); using sut = renderApp({ screen: ScreenId.ConnectTools, dir: project.path }); diff --git a/packages/quickstart/src/ui/screens/connect-tools/useMcpConnect.ts b/packages/quickstart/src/ui/screens/connect-tools/useMcpConnect.ts index d046bb0..31d8432 100644 --- a/packages/quickstart/src/ui/screens/connect-tools/useMcpConnect.ts +++ b/packages/quickstart/src/ui/screens/connect-tools/useMcpConnect.ts @@ -122,6 +122,7 @@ export function useMcpConnect(): McpConnectState { async function run() { const statuses = await integration.detectMcpStatuses(session.projectDir); + const preference = loadMcpPreference(); for (const [name, status] of Object.entries(statuses)) { const server = available.find((s) => s.name === name); @@ -130,13 +131,14 @@ export function useMcpConnect(): McpConnectState { setServerStatuses(statuses); const allOk = allServersConnected(statuses); - if (allOk) { + const hasExplicitlyConnected = preference === 'connected'; + + if (allOk && hasExplicitlyConnected) { setPhase('already-connected'); return; } - const preference = loadMcpPreference(); - if (preference !== 'connected') { + if (!hasExplicitlyConnected) { setPhase(resolvePhaseFromStatuses(statuses)); return; } diff --git a/packages/testing/src/auth/tokens.ts b/packages/testing/src/auth/tokens.ts index 116383c..af871b3 100644 --- a/packages/testing/src/auth/tokens.ts +++ b/packages/testing/src/auth/tokens.ts @@ -16,6 +16,10 @@ const SCAFFOLDS: Record void> = { accessToken: buildTestJwt({ email: DEFAULT_EMAIL }), refreshToken: 'test-refresh-token', }), + expired: (dir) => + writeCredentials(dir, { + accessToken: buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 3600 }), + }), }; type Credentials = { diff --git a/packages/testing/src/auth/types.ts b/packages/testing/src/auth/types.ts index f8118ed..24d2aa4 100644 --- a/packages/testing/src/auth/types.ts +++ b/packages/testing/src/auth/types.ts @@ -7,5 +7,6 @@ * - `'none'` — no tokens (clears any existing files) * - `'valid'` — valid JWT for `existing@example.com`, no refresh token * - `'with-refresh'` — valid JWT + refresh token (enables token refresh flow) + * - `'expired'` — expired JWT (triggers auth-expired detection) */ -export type TokenType = 'none' | 'valid' | 'with-refresh'; +export type TokenType = 'none' | 'valid' | 'with-refresh' | 'expired'; diff --git a/packages/testing/src/e2e/session-factory.ts b/packages/testing/src/e2e/session-factory.ts index 8845a3d..7d300d5 100644 --- a/packages/testing/src/e2e/session-factory.ts +++ b/packages/testing/src/e2e/session-factory.ts @@ -70,6 +70,10 @@ export function createSession({ sessionEnv.CONFIDENCE_CONFIG_DIR = configDir; } + if (token) { + sessionEnv.CONFIDENCE_TOKEN ??= token; + } + const session = new TerminalSession({ cliPath: process.env.E2E_CLI_PATH!, args: ['--debug', '--dir', projectDir, ...extraArgs], diff --git a/packages/testing/src/e2e/terminal/session.ts b/packages/testing/src/e2e/terminal/session.ts index 39e5326..9b31e8b 100644 --- a/packages/testing/src/e2e/terminal/session.ts +++ b/packages/testing/src/e2e/terminal/session.ts @@ -84,18 +84,24 @@ export class TerminalSession { this.cwd = cwd ?? process.cwd(); this.tempDirs.push(isolatedTmpDir); + const isolationDefaults: Record = { + HOME: isolatedTmpDir, + TMPDIR: isolatedTmpDir, + ...(isWindows + ? { + USERPROFILE: isolatedTmpDir, + TEMP: isolatedTmpDir, + TMP: isolatedTmpDir, + } + : {}), + }; + this.pty = ptySpawn(process.execPath, [cliPath, ...args], { name: 'xterm-256color', cols, rows, cwd: this.cwd, - env: overlayEnv(process.env, E2E_BASE_ENV, env, { - HOME: isolatedTmpDir, - TMPDIR: isolatedTmpDir, - ...(isWindows - ? { USERPROFILE: isolatedTmpDir, TEMP: isolatedTmpDir, TMP: isolatedTmpDir } - : {}), - }), + env: overlayEnv(process.env, E2E_BASE_ENV, isolationDefaults, env), }); this.pty.onData((data) => { diff --git a/packages/testing/src/scaffold/ide-scaffold.ts b/packages/testing/src/scaffold/ide-scaffold.ts index d2518a4..c2adaaf 100644 --- a/packages/testing/src/scaffold/ide-scaffold.ts +++ b/packages/testing/src/scaffold/ide-scaffold.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; type ClaudeSettings = { permissions?: { allow?: string[] }; - enabledMcpjsonServers?: string[]; + mcpServers?: Record; }; type CursorMcpConfig = { @@ -20,6 +20,18 @@ export function writeClaudeSettings(projectDir: string, settings: ClaudeSettings writeFileSync(join(dir, 'settings.local.json'), JSON.stringify(settings, null, 2)); } +export function writeClaudeGlobalConfig( + homeDir: string, + projectDir: string, + mcpServers: Record, +): void { + const configPath = join(homeDir, '.claude.json'); + writeFileSync( + configPath, + JSON.stringify({ projects: { [projectDir]: { mcpServers } } }, null, 2), + ); +} + export function writeCursorMcpConfig(projectDir: string, config: CursorMcpConfig): void { const dir = join(projectDir, '.cursor'); mkdirSync(dir, { recursive: true }); diff --git a/packages/testing/src/scaffold/index.ts b/packages/testing/src/scaffold/index.ts index ded47cf..ee94c6e 100644 --- a/packages/testing/src/scaffold/index.ts +++ b/packages/testing/src/scaffold/index.ts @@ -2,6 +2,7 @@ export { createConfigDir } from './config-scaffold.js'; export { createProjectDir } from './project-scaffold.js'; export { writeClaudeSettings, + writeClaudeGlobalConfig, writeCursorMcpConfig, writeCursorCliConfig, writeCodexConfig, diff --git a/scripts/clean-dev-env.sh b/scripts/clean-dev-env.sh index 889827b..f213225 100755 --- a/scripts/clean-dev-env.sh +++ b/scripts/clean-dev-env.sh @@ -78,7 +78,7 @@ if $clean_mcp; then # --- Confidence MCP preference --- - mcp_pref_file="${TMPDIR:-/tmp}/confidence_mcp_preference" + mcp_pref_file="$config_dir/mcp_preference" if [[ -f "$mcp_pref_file" ]]; then rm "$mcp_pref_file" echo "Removed $mcp_pref_file" @@ -122,32 +122,26 @@ if $clean_mcp; then # Claude Code: uninstall plugin (may be from official or custom marketplace) for scope in local project user; do - if (cd "$PROJECT_DIR" && claude plugin uninstall "$plugin_name" --scope "$scope") 2>/dev/null; then + if (cd "$PROJECT_DIR" && claude plugin uninstall "$plugin_name" --scope "$scope") >/dev/null 2>&1; then echo "Uninstalled Claude plugin ($scope scope)" ((removed++)) || true fi done - if (cd "$PROJECT_DIR" && claude plugin marketplace remove "$marketplace_name") 2>/dev/null; then + if (cd "$PROJECT_DIR" && claude plugin marketplace remove "$marketplace_name") >/dev/null 2>&1; then echo "Removed Claude marketplace $marketplace_name" ((removed++)) || true fi # Codex: remove plugin and marketplace - if codex plugin remove "$plugin_name@$marketplace_name" 2>/dev/null; then + if codex plugin remove "$plugin_name@$marketplace_name" >/dev/null 2>&1; then echo "Removed Codex plugin" ((removed++)) || true fi - if codex plugin marketplace remove "$marketplace_name" 2>/dev/null; then + if codex plugin marketplace remove "$marketplace_name" >/dev/null 2>&1; then echo "Removed Codex marketplace $marketplace_name" ((removed++)) || true fi - # Cursor: remove marketplace (no CLI plugin install to undo) - if cursor agent plugin marketplace remove "$marketplace_repo" 2>/dev/null; then - echo "Removed Cursor marketplace $marketplace_repo" - ((removed++)) || true - fi - # --- MCP server entries from config files --- remove_all_mcp_entries() { @@ -155,7 +149,7 @@ if $clean_mcp; then [[ -f "$config_path" ]] || return 0 local result - result=$(node "$SCRIPT_DIR/remove-mcp-entries.js" "$config_path" 2>/dev/null) || return 0 + result=$(node "$SCRIPT_DIR/remove-mcp-entries.cjs" "$config_path" 2>/dev/null) || return 0 local action="${result%%:*}" local names="${result#*:}" @@ -183,14 +177,14 @@ if $clean_mcp; then # Use `claude mcp remove` to clear each server's entries and approval state (all scopes) for server in ${mcp_servers[@]+"${mcp_servers[@]}"}; do for scope in local project user; do - if (cd "$PROJECT_DIR" && claude mcp remove --scope "$scope" "$server") 2>/dev/null; then + if (cd "$PROJECT_DIR" && claude mcp remove --scope "$scope" "$server") >/dev/null 2>&1; then echo "Removed MCP server $server from $scope scope" ((removed++)) || true fi done done - # Remove .mcp.json entirely + # Remove legacy .mcp.json (Claude used --scope project before switching to --scope local) if [[ -f "$PROJECT_DIR/.mcp.json" ]]; then rm "$PROJECT_DIR/.mcp.json" echo "Removed $PROJECT_DIR/.mcp.json" @@ -198,44 +192,41 @@ if $clean_mcp; then fi # Clean Cursor MCP configs, CLI permissions, and agent state - remove_all_mcp_entries "$PROJECT_DIR/.cursor/mcp.json" remove_all_mcp_entries "$HOME/.cursor/mcp.json" if [[ -f "$PROJECT_DIR/.cursor/cli.json" ]]; then rm "$PROJECT_DIR/.cursor/cli.json" echo "Removed $PROJECT_DIR/.cursor/cli.json" ((removed++)) || true fi + # Clean Claude local-scope MCP entries (stored in global config keyed by project path) + remove_all_mcp_entries "$HOME/.claude.json" + + # Clean Codex MCP config (TOML format) — both project-level and global + # Use CLI for global config; direct file manipulation covers both scopes for server in confidence-flags confidence-docs; do - if cursor agent mcp disable "$server" 2>/dev/null; then - echo "Disabled Cursor agent MCP server $server" - ((removed++)) || true - fi + codex mcp remove "$server" >/dev/null 2>&1 || true done - # Clean legacy Claude MCP config - remove_all_mcp_entries "$HOME/.claude.json" + remove_toml_mcp_entries() { + local config_path="$1" + [[ -f "$config_path" ]] || return 0 - # Clean Codex MCP config (TOML format) — both project-level and global - codex_configs=("$PROJECT_DIR/.codex/config.toml" "$HOME/.codex/config.toml") - for codex_config in "${codex_configs[@]}"; do - if [[ -f "$codex_config" ]]; then - for server in confidence-flags confidence-docs; do - codex mcp remove "$server" 2>/dev/null || true - done - # Remove the config if it only contained our MCP entries - if [[ -f "$codex_config" ]]; then - remaining=$(grep -c '^\[' "$codex_config" 2>/dev/null || echo "0") - if [[ "$remaining" -eq 0 ]]; then - rm "$codex_config" - echo "Deleted $codex_config (empty after cleanup)" - ((removed++)) || true - else - echo "Cleaned Codex MCP entries from $codex_config" - ((removed++)) || true - fi - fi + local result + result=$(node "$SCRIPT_DIR/remove-toml-mcp-entries.cjs" "$config_path" 2>/dev/null) || return 0 + + local action="${result%%:*}" + local names="${result#*:}" + if [[ "$action" == "deleted" ]]; then + echo "Deleted $config_path (empty after removing MCP servers: $names)" + ((removed++)) || true + elif [[ "$action" == "cleaned" ]]; then + echo "Removed MCP entries from $config_path ($names)" + ((removed++)) || true fi - done + } + + remove_toml_mcp_entries "$PROJECT_DIR/.codex/config.toml" + remove_toml_mcp_entries "$HOME/.codex/config.toml" # --- MCP tool permissions from .claude/settings*.json --- @@ -244,7 +235,7 @@ if $clean_mcp; then [[ -f "$settings_path" ]] || return 0 local settings_result - settings_result=$(node "$SCRIPT_DIR/remove-mcp-settings.js" "$settings_path" 2>/dev/null) || return 0 + settings_result=$(node "$SCRIPT_DIR/remove-mcp-settings.cjs" "$settings_path" 2>/dev/null) || return 0 if [[ "$settings_result" == "deleted" ]]; then echo "Deleted $settings_path (empty after cleanup)" diff --git a/scripts/remove-mcp-entries.js b/scripts/remove-mcp-entries.cjs similarity index 100% rename from scripts/remove-mcp-entries.js rename to scripts/remove-mcp-entries.cjs diff --git a/scripts/remove-mcp-settings.js b/scripts/remove-mcp-settings.cjs similarity index 71% rename from scripts/remove-mcp-settings.js rename to scripts/remove-mcp-settings.cjs index 88fcce6..5841d58 100755 --- a/scripts/remove-mcp-settings.js +++ b/scripts/remove-mcp-settings.cjs @@ -17,9 +17,15 @@ if (Array.isArray(allow)) { } } -if (Array.isArray(config.enabledMcpjsonServers) && config.enabledMcpjsonServers.length > 0) { - delete config.enabledMcpjsonServers; - changed = true; +const mcpServers = config.mcpServers; +if (mcpServers && typeof mcpServers === 'object') { + for (const name of ['confidence-flags', 'confidence-docs']) { + if (name in mcpServers) { + delete mcpServers[name]; + changed = true; + } + } + if (Object.keys(mcpServers).length === 0) delete config.mcpServers; } if (!changed) { diff --git a/scripts/remove-toml-mcp-entries.cjs b/scripts/remove-toml-mcp-entries.cjs new file mode 100755 index 0000000..2994849 --- /dev/null +++ b/scripts/remove-toml-mcp-entries.cjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = process.argv[2]; + +let content = fs.readFileSync(path, 'utf-8'); +const servers = ['confidence-flags', 'confidence-docs']; +let changed = false; +const removed = []; + +for (const server of servers) { + const header = `[mcp_servers.${server}]`; + const idx = content.indexOf(header); + if (idx === -1) continue; + + const nextSection = content.indexOf('\n[', idx + header.length); + const before = content.slice(0, idx).replace(/\n+$/, ''); + const after = nextSection === -1 ? '' : content.slice(nextSection); + content = (before + after).trim(); + changed = true; + removed.push(server); +} + +if (!changed) process.exit(0); + +if (!content) { + fs.unlinkSync(path); + console.log('deleted:' + removed.join(',')); +} else { + fs.writeFileSync(path, content + '\n'); + console.log('cleaned:' + removed.join(',')); +}