diff --git a/src/cli/command-tree.ts b/src/cli/command-tree.ts index 1da7f129..5b604bb3 100644 --- a/src/cli/command-tree.ts +++ b/src/cli/command-tree.ts @@ -139,7 +139,7 @@ integrateCommand .description( 'Setup SonarQube integration for Claude Code. This will install secrets scanning hooks, configure SonarQube Agentic Analysis and MCP Server.', ) - .option('-p, --project ', 'Project key') + .option('-p, --project ', 'Project key. Ignored when --global is used.') .option('--non-interactive', 'Non-interactive mode (no prompts)') .option( '-g, --global', @@ -169,10 +169,14 @@ integrateCommand .description( 'Setup SonarQube integration for Copilot. This will install secrets scanning hooks, configure SonarQube Agentic Analysis and MCP Server.', ) - .authenticatedAction((_auth, _options: IntegrateCopilotOptions): Promise => { - integrateCopilot(_auth, _options); - return Promise.resolve(); - }); + .option( + '-g, --global', + 'Install hooks and config globally to ~/.copilot instead of project directory', + ) + .option('-p, --project ', 'Project key. Ignored when --global is used.') + .authenticatedAction((_auth, options: IntegrateCopilotOptions) => + integrateCopilot(_auth, options), + ); // List Sonar resources const list = COMMAND_TREE.command('list').description('List issues and projects from SonarQube'); diff --git a/src/cli/commands/integrate/claude/index.ts b/src/cli/commands/integrate/claude/index.ts index 78ded1d6..c3ee2b1a 100644 --- a/src/cli/commands/integrate/claude/index.ts +++ b/src/cli/commands/integrate/claude/index.ts @@ -140,7 +140,7 @@ export async function integrateClaude( }); reportHookInstallationOutcome(isGlobal, globalSecretsHookPath); - await setupMcpServer('claude', project.rootDir, isGlobal, auth, project.projectKey); + await setupMcpServer(project, isGlobal, options.project || project.projectKey); blank(); text('Phase 3/3: Final Verification'); diff --git a/src/cli/commands/integrate/claude/mcp.ts b/src/cli/commands/integrate/claude/mcp.ts index 9aa8c563..982e2910 100644 --- a/src/cli/commands/integrate/claude/mcp.ts +++ b/src/cli/commands/integrate/claude/mcp.ts @@ -17,90 +17,22 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ - -// MCP Server setup for Claude Code integration - -import { existsSync, mkdirSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; - -import type { ResolvedAuth } from '../../../../lib/auth-resolver'; -import { normalizePath } from '../../../../lib/fs-utils'; -import { getMcpServerConfig } from '../../../../lib/mcp/server-config'; -import { detectContainerRuntime } from '../../../../lib/tool-detector'; -import { error, info, success, warn } from '../../../../ui'; +import { setupMcpServerForAgent } from '../../../../lib/mcp/mcp-helper'; +import { type DiscoveredProject } from '../../../../lib/project-workspace'; +import { info, success, warn } from '../../../../ui'; export async function setupMcpServer( - agent: string, - projectRoot: string, + project: DiscoveredProject, isGlobal: boolean, - auth: ResolvedAuth, - discoveredProjectKey: string | undefined, + projectKey: string | undefined, ): Promise { - info('Setting up SonarQube MCP Server...'); - const runtime = await detectContainerRuntime(); - if (!runtime) { - error( - 'A container runtime (Docker/Podman/Nerdctl) is required to configure the SonarQube MCP Server. Please ensure one of them is installed and the daemon is running.', - ); - warn('Skipping SonarQube MCP Server configuration.'); - return; - } - - const targetFile = getMcpConfigFilePath(agent); - const serverConfig = getMcpServerConfig( - auth, - runtime, - isGlobal ? { withFsMount: false } : { withFsMount: true, projectRoot, discoveredProjectKey }, - ); - + info(`Setting up SonarQube MCP Server...`); try { - await writeMcpServerEntry(targetFile, serverConfig, isGlobal, projectRoot); - } catch (e: unknown) { - if (e instanceof Error) { - error(`Failed to configure SonarQube MCP Server in ${targetFile}: ${e.message}`); - } - return; - } - success(`SonarQube MCP Server configured in ${targetFile}`); -} - -export async function writeMcpServerEntry( - filePath: string, - serverConfig: object, - isGlobal: boolean, - projectRoot: string, -): Promise { - let existing: Record = {}; - if (existsSync(filePath)) { - try { - existing = JSON.parse(await readFile(filePath, 'utf-8')) as Record; - } catch { - throw new Error(`${filePath} contains invalid JSON. Please fix or delete it and re-run.`); + await setupMcpServerForAgent('claude', project.rootDir, isGlobal, projectKey); + success(`SonarQube MCP Server configured`); + } catch (error) { + if (error instanceof Error) { + warn(`Failed to setup MCP server: ${error.message}`); } } - - if (isGlobal) { - const mcpServers = (existing.mcpServers as Record | undefined) ?? {}; - existing.mcpServers = { ...mcpServers, sonarqube: serverConfig }; - } else { - const projectKey = normalizePath(projectRoot); - const projects = (existing.projects as Record | undefined) ?? {}; - const projectEntry = (projects[projectKey] as Record | undefined) ?? {}; - const mcpServers = (projectEntry.mcpServers as Record | undefined) ?? {}; - projectEntry.mcpServers = { ...mcpServers, sonarqube: serverConfig }; - projects[projectKey] = projectEntry; - existing.projects = projects; - } - - mkdirSync(dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify(existing, null, 2), 'utf-8'); -} - -export function getMcpConfigFilePath(agent: string): string { - if (agent === 'claude') { - return join(homedir(), '.claude.json'); - } - throw new Error(`Unsupported agent: ${agent}`); } diff --git a/src/cli/commands/integrate/copilot/index.ts b/src/cli/commands/integrate/copilot/index.ts index e8c53234..e2ef438c 100644 --- a/src/cli/commands/integrate/copilot/index.ts +++ b/src/cli/commands/integrate/copilot/index.ts @@ -18,7 +18,10 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import type { ResolvedAuth } from '../../../../lib/auth-resolver'; -import { intro } from '../../../../ui'; +import { discoverProject } from '../../../../lib/project-workspace'; +import { intro, print } from '../../../../ui'; +import { InvalidOptionError } from '../../_common/error'; +import { setupMcpServer } from './mcp'; /* * SonarQube CLI @@ -45,10 +48,21 @@ export interface IntegrateCopilotOptions { global?: boolean; } -export function integrateCopilot(_auth: ResolvedAuth, _options: IntegrateCopilotOptions) { - intro('SonarQube Copilot integration - coming soon'); +export async function integrateCopilot(_auth: ResolvedAuth, options: IntegrateCopilotOptions) { + if (options.global && options.project) { + throw new InvalidOptionError( + '--global and --project are mutually exclusive; please specify only one scope.', + ); + } + + intro('SonarQube integration for Copilot'); + + const project = await discoverProject(process.cwd()); + for (const configSource of project.configSources) { + print(`Found ${configSource}`); + } // TODO setup hooks - // TODO setup MCP Server + await setupMcpServer(project, options.global ?? false, options.project || project.projectKey); } diff --git a/src/cli/commands/integrate/copilot/mcp.ts b/src/cli/commands/integrate/copilot/mcp.ts new file mode 100644 index 00000000..7942e7ff --- /dev/null +++ b/src/cli/commands/integrate/copilot/mcp.ts @@ -0,0 +1,38 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { setupMcpServerForAgent } from '../../../../lib/mcp/mcp-helper'; +import { type DiscoveredProject } from '../../../../lib/project-workspace'; +import { info, success, warn } from '../../../../ui'; + +export async function setupMcpServer( + project: DiscoveredProject, + isGlobal: boolean, + projectKey: string | undefined, +): Promise { + info(`Setting up SonarQube MCP Server...`); + try { + await setupMcpServerForAgent('copilot', project.rootDir, isGlobal, projectKey); + success(`SonarQube MCP Server configured`); + } catch (error) { + if (error instanceof Error) { + warn(`Failed to setup MCP server: ${error.message}`); + } + } +} diff --git a/src/cli/commands/run/mcp.ts b/src/cli/commands/run/mcp.ts index f73c0978..07e67230 100644 --- a/src/cli/commands/run/mcp.ts +++ b/src/cli/commands/run/mcp.ts @@ -25,7 +25,7 @@ import { homedir } from 'node:os'; import type { ResolvedAuth } from '../../../lib/auth-resolver.js'; import { canonicalizePath } from '../../../lib/fs-utils.js'; -import { getMcpServerConfig, type McpServerContext } from '../../../lib/mcp/server-config.js'; +import { getMcpContainerCommand, type McpServerContext } from '../../../lib/mcp/mcp-helper.js'; import { discoverProject } from '../../../lib/project-workspace/project-info.js'; import { detectContainerRuntime } from '../../../lib/tool-detector.js'; import { CommandFailedError } from '../_common/error.js'; @@ -48,16 +48,16 @@ export async function runMcp(auth: ResolvedAuth, options: McpRunOptions = {}): P const cwd = process.cwd(); const cwdIsHomeDir = canonicalizePath(cwd) === canonicalizePath(homedir()); const discovered = cwdIsHomeDir ? undefined : await discoverProject(cwd); - const projectKey = options.project ?? discovered?.projectKey; + const projectKey = options.project || discovered?.projectKey; const discoveredRootIsHomeDir = discovered && canonicalizePath(discovered.rootDir) === canonicalizePath(homedir()); const projectRoot = discoveredRootIsHomeDir ? undefined : discovered?.rootDir; const context: McpServerContext = projectRoot - ? { withFsMount: true, projectRoot, discoveredProjectKey: projectKey } - : { withFsMount: false, discoveredProjectKey: projectKey }; + ? { withFsMount: true, projectRoot, projectKey } + : { withFsMount: false, projectKey }; - const config = getMcpServerConfig(auth, runtime, context, options); + const config = getMcpContainerCommand(auth, runtime, context, options); await new Promise((resolve, reject) => { const child = spawn(config.command, config.args, { diff --git a/src/lib/config-constants.ts b/src/lib/config-constants.ts index d0952251..2981cac0 100644 --- a/src/lib/config-constants.ts +++ b/src/lib/config-constants.ts @@ -34,6 +34,9 @@ import { join } from 'node:path'; export const APP_NAME = 'sonarqube-cli'; +/** The CLI command name as it appears on PATH after installation. */ +export const CLI_COMMAND = process.platform === 'win32' ? 'sonar.exe' : 'sonar'; + // --------------------------------------------------------------------------- // CLI data directory // --------------------------------------------------------------------------- diff --git a/src/lib/mcp/mcp-helper.ts b/src/lib/mcp/mcp-helper.ts new file mode 100644 index 00000000..bc5ec9d2 --- /dev/null +++ b/src/lib/mcp/mcp-helper.ts @@ -0,0 +1,182 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import type { ResolvedAuth } from '../auth-resolver'; +import { CLI_COMMAND } from '../config-constants'; +import { normalizePath } from '../fs-utils'; +import type { ContainerRuntime } from '../tool-detector'; + +export interface McpServerConfig { + command: string; + args: string[]; +} + +export interface McpContainerCommand { + command: string; + args: string[]; + env: Record; +} +export interface McpServerOptions { + debug?: boolean; + readOnly?: boolean; + toolsets?: string; +} + +export type McpServerContext = + | { withFsMount: false; projectKey?: string } + | { + withFsMount: true; + projectRoot: string; + projectKey?: string; + }; + +export function getMcpConfig( + cliPath: string, + context: McpServerContext, + options: McpServerOptions = {}, +): McpServerConfig { + const args = ['run', 'mcp']; + + if (context.projectKey) { + args.push('--project', context.projectKey); + } + + if (options.debug) { + args.push('--debug'); + } + + if (options.readOnly) { + args.push('--read-only'); + } + + if (options.toolsets) { + args.push('--toolsets', options.toolsets); + } + + return { command: cliPath, args }; +} + +export function getMcpContainerCommand( + auth: ResolvedAuth, + runtime: ContainerRuntime, + context: McpServerContext, + options: McpServerOptions = {}, +): McpContainerCommand { + const { token, orgKey: org, serverUrl } = auth; + + const args = [ + 'run', + '--init', + '--pull=always', + '-i', + '--rm', + '-e', + 'SONARQUBE_TOKEN', + '-e', + 'SONARQUBE_URL', + ]; + const env: Record = { SONARQUBE_TOKEN: token, SONARQUBE_URL: serverUrl }; + + if (auth.connectionType === 'cloud') { + args.push('-e', 'SONARQUBE_ORG'); + env.SONARQUBE_ORG = org ?? ''; + } + + if (context.projectKey) { + args.push('-e', 'SONARQUBE_PROJECT_KEY'); + env.SONARQUBE_PROJECT_KEY = context.projectKey; + } + + if (context.withFsMount) { + const hostPath = normalizePath(context.projectRoot); + args.push('-v', `${hostPath}:/app/mcp-workspace:ro`); + } + + if (options.debug) { + args.push('-e', 'SONARQUBE_DEBUG_ENABLED'); + env.SONARQUBE_DEBUG_ENABLED = 'true'; + } + + if (options.readOnly) { + args.push('-e', 'SONARQUBE_READ_ONLY'); + env.SONARQUBE_READ_ONLY = 'true'; + } + + if (options.toolsets) { + args.push('-e', 'SONARQUBE_TOOLSETS'); + env.SONARQUBE_TOOLSETS = options.toolsets; + } + + args.push('mcp/sonarqube'); + + return { command: runtime, args, env }; +} + +export async function writeMcpServerEntry(filePath: string, serverConfig: object): Promise { + let existing: Record = {}; + if (existsSync(filePath)) { + try { + existing = JSON.parse(await readFile(filePath, 'utf-8')) as Record; + } catch { + throw new Error(`${filePath} contains invalid JSON. Please fix or delete it and re-run.`); + } + } + + const mcpServers = (existing.mcpServers as Record | undefined) ?? {}; + existing.mcpServers = { ...mcpServers, sonarqube: serverConfig }; + + mkdirSync(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify(existing, null, 2), 'utf-8'); +} + +export function getMcpConfigFilePath( + agent: string, + isGlobal: boolean, + projectRoot: string, +): string { + if (agent === 'claude') { + return isGlobal ? join(homedir(), '.claude.json') : join(projectRoot, '.mcp.json'); + } else if (agent === 'copilot') { + return isGlobal + ? join(homedir(), '.copilot', 'mcp-config.json') + : join(projectRoot, '.mcp.json'); + } + throw new Error(`Unsupported agent: ${agent}`); +} + +export async function setupMcpServerForAgent( + agent: 'claude' | 'copilot', + projectRoot: string, + isGlobal: boolean, + projectKey: string | undefined, +): Promise { + const targetFile = getMcpConfigFilePath(agent, isGlobal, projectRoot); + const serverConfig = getMcpConfig( + CLI_COMMAND, + isGlobal ? { withFsMount: false } : { withFsMount: true, projectRoot, projectKey }, + ); + + await writeMcpServerEntry(targetFile, serverConfig); +} diff --git a/src/lib/mcp/server-config.ts b/src/lib/mcp/server-config.ts deleted file mode 100644 index 2184ae6a..00000000 --- a/src/lib/mcp/server-config.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * SonarQube CLI - * Copyright (C) SonarSource Sàrl - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ - -import type { ResolvedAuth } from '../auth-resolver'; -import { normalizePath } from '../fs-utils'; -import type { ContainerRuntime } from '../tool-detector'; - -export interface McpServerConfig { - command: string; - args: string[]; - env: Record; -} -export interface McpServerOptions { - debug?: boolean; - readOnly?: boolean; - toolsets?: string; -} - -export type McpServerContext = - | { withFsMount: false; discoveredProjectKey?: string } - | { - withFsMount: true; - projectRoot: string; - discoveredProjectKey?: string; - }; - -export function getMcpServerConfig( - auth: ResolvedAuth, - runtime: ContainerRuntime, - context: McpServerContext, - options: McpServerOptions = {}, -): McpServerConfig { - const { token, orgKey: org, serverUrl } = auth; - - const args = [ - 'run', - '--init', - '--pull=always', - '-i', - '--rm', - '-e', - 'SONARQUBE_TOKEN', - '-e', - 'SONARQUBE_URL', - ]; - const env: Record = { SONARQUBE_TOKEN: token, SONARQUBE_URL: serverUrl }; - - if (auth.connectionType === 'cloud') { - args.push('-e', 'SONARQUBE_ORG'); - env.SONARQUBE_ORG = org ?? ''; - } - - if (context.discoveredProjectKey) { - args.push('-e', 'SONARQUBE_PROJECT_KEY'); - env.SONARQUBE_PROJECT_KEY = context.discoveredProjectKey; - } - - if (context.withFsMount) { - const hostPath = normalizePath(context.projectRoot); - args.push('-v', `${hostPath}:/app/mcp-workspace:ro`); - } - - if (options.debug) { - args.push('-e', 'SONARQUBE_DEBUG_ENABLED'); - env.SONARQUBE_DEBUG_ENABLED = 'true'; - } - - if (options.readOnly) { - args.push('-e', 'SONARQUBE_READ_ONLY'); - env.SONARQUBE_READ_ONLY = 'true'; - } - - if (options.toolsets) { - args.push('-e', 'SONARQUBE_TOOLSETS'); - env.SONARQUBE_TOOLSETS = options.toolsets; - } - - args.push('mcp/sonarqube'); - - return { command: runtime, args, env }; -} diff --git a/tests/unit/cli/commands/integrate/claude/integrate.test.ts b/tests/unit/cli/commands/integrate/claude/integrate.test.ts index b20c8694..d54379c9 100644 --- a/tests/unit/cli/commands/integrate/claude/integrate.test.ts +++ b/tests/unit/cli/commands/integrate/claude/integrate.test.ts @@ -28,11 +28,11 @@ import { integrateClaude } from '../../../../../../src/cli/commands/integrate/cl import * as health from '../../../../../../src/cli/commands/integrate/claude/health'; import { HealthCheckResult } from '../../../../../../src/cli/commands/integrate/claude/health'; import * as hooks from '../../../../../../src/cli/commands/integrate/claude/hooks'; -import * as mcp from '../../../../../../src/cli/commands/integrate/claude/mcp'; import * as repair from '../../../../../../src/cli/commands/integrate/claude/repair'; import * as state from '../../../../../../src/cli/commands/integrate/claude/state'; import type { ResolvedAuth } from '../../../../../../src/lib/auth-resolver'; import * as authResolver from '../../../../../../src/lib/auth-resolver'; +import * as mcpHelper from '../../../../../../src/lib/mcp/mcp-helper'; import * as migration from '../../../../../../src/lib/migration'; import type { DiscoveredProject } from '../../../../../../src/lib/project-workspace'; import * as discovery from '../../../../../../src/lib/project-workspace'; @@ -91,14 +91,18 @@ describe('integrateCommand', () => { let resolveSecretsBinarySpy: Mock< Extract<(typeof installSecrets)['resolveSecretsBinary'], (...args: any[]) => any> >; - let setupMcpServerSpy: Mock any>>; + let setupMcpServerForAgentSpy: Mock< + Extract<(typeof mcpHelper)['setupMcpServerForAgent'], (...args: any[]) => any> + >; beforeEach(() => { setMockUi(true); hasSqaaEntitlementSpy = spyOn(SonarQubeClient.prototype, 'hasSqaaEntitlement'); hasSqaaEntitlementSpy.mockResolvedValue(false); - setupMcpServerSpy = spyOn(mcp, 'setupMcpServer').mockResolvedValue(undefined); + setupMcpServerForAgentSpy = spyOn(mcpHelper, 'setupMcpServerForAgent').mockResolvedValue( + undefined, + ); loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(getDefaultState('test')); saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => {}); @@ -136,7 +140,7 @@ describe('integrateCommand', () => { runMigrationsSpy.mockRestore(); updateStateAfterConfigurationSpy.mockRestore(); resolveSecretsBinarySpy.mockRestore(); - setupMcpServerSpy.mockRestore(); + setupMcpServerForAgentSpy.mockRestore(); }); it('shows intro message', async () => { diff --git a/tests/unit/cli/commands/integrate/claude/mcp.test.ts b/tests/unit/cli/commands/integrate/claude/mcp.test.ts index 46d80252..a1e03d6c 100644 --- a/tests/unit/cli/commands/integrate/claude/mcp.test.ts +++ b/tests/unit/cli/commands/integrate/claude/mcp.test.ts @@ -24,14 +24,15 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it, spyOn } from 'bun:test'; +import { setupMcpServer } from '../../../../../../src/cli/commands/integrate/claude/mcp'; +import type { ResolvedAuth } from '../../../../../../src/lib/auth-resolver'; +import { CLI_COMMAND } from '../../../../../../src/lib/config-constants'; import { getMcpConfigFilePath, - setupMcpServer, + getMcpContainerCommand, writeMcpServerEntry, -} from '../../../../../../src/cli/commands/integrate/claude/mcp'; -import type { ResolvedAuth } from '../../../../../../src/lib/auth-resolver'; -import { getMcpServerConfig } from '../../../../../../src/lib/mcp/server-config'; -import * as toolDetector from '../../../../../../src/lib/tool-detector'; +} from '../../../../../../src/lib/mcp/mcp-helper'; +import { DiscoveredProject } from '../../../../../../src/lib/project-workspace'; import { getMockUiCalls, setMockUi } from '../../../../../../src/ui'; const ON_PREMISE_AUTH: ResolvedAuth = { @@ -52,9 +53,18 @@ const CLOUD_US_AUTH: ResolvedAuth = { connectionType: 'cloud', }; -describe('getMcpServerConfig', () => { +const FAKE_PROJECT: DiscoveredProject = { + rootDir: '/fake/project', + isGitRepo: false, + serverUrl: 'https://sonarqube.example.com', + organization: 'my-org', + projectKey: 'my-project', + configSources: [], +}; + +describe('getMcpContainerConfig', () => { it('returns a docker command with SONARQUBE_TOKEN and SONARQUBE_URL for on-premise', () => { - const config = getMcpServerConfig(ON_PREMISE_AUTH, 'docker', { withFsMount: false }); + const config = getMcpContainerCommand(ON_PREMISE_AUTH, 'docker', { withFsMount: false }); expect(config).toEqual({ command: 'docker', args: [ @@ -74,7 +84,7 @@ describe('getMcpServerConfig', () => { }); it('returns a podman command with SONARQUBE_TOKEN and SONARQUBE_URL for on-premise', () => { - const config = getMcpServerConfig(ON_PREMISE_AUTH, 'podman', { withFsMount: false }); + const config = getMcpContainerCommand(ON_PREMISE_AUTH, 'podman', { withFsMount: false }); expect(config).toEqual({ command: 'podman', args: [ @@ -95,7 +105,7 @@ describe('getMcpServerConfig', () => { it('returns a docker command with SONARQUBE_ORG for cloud (sonarcloud.io)', () => { const auth: ResolvedAuth = { ...CLOUD_AUTH, orgKey: 'my-org' }; - const config = getMcpServerConfig(auth, 'docker', { withFsMount: false }); + const config = getMcpContainerCommand(auth, 'docker', { withFsMount: false }); expect(config).toEqual({ command: 'docker', args: [ @@ -122,7 +132,7 @@ describe('getMcpServerConfig', () => { it('returns a docker command with SONARQUBE_ORG for cloud US (sonarqube.us)', () => { const auth: ResolvedAuth = { ...CLOUD_US_AUTH, orgKey: 'my-org' }; - const config = getMcpServerConfig(auth, 'docker', { withFsMount: false }); + const config = getMcpContainerCommand(auth, 'docker', { withFsMount: false }); expect(config).toEqual({ command: 'docker', args: [ @@ -148,7 +158,7 @@ describe('getMcpServerConfig', () => { }); it('uses forward slashes in the -v host path on Windows-style roots', () => { - const config = getMcpServerConfig(ON_PREMISE_AUTH, 'docker', { + const config = getMcpContainerCommand(ON_PREMISE_AUTH, 'docker', { withFsMount: true, projectRoot: String.raw`C:\Users\tdd\source\repos\sonarlint-core`, }); @@ -159,7 +169,7 @@ describe('getMcpServerConfig', () => { }); it('returns a docker command with -v ${projectRoot}:/app/mcp-workspace:ro for non-global config', () => { - const config = getMcpServerConfig(ON_PREMISE_AUTH, 'docker', { + const config = getMcpContainerCommand(ON_PREMISE_AUTH, 'docker', { withFsMount: true, projectRoot: '/fake/project', }); @@ -184,7 +194,7 @@ describe('getMcpServerConfig', () => { }); it('returns a podman command with -v ${projectRoot}:/app/mcp-workspace:ro for non-global config', () => { - const config = getMcpServerConfig(ON_PREMISE_AUTH, 'podman', { + const config = getMcpContainerCommand(ON_PREMISE_AUTH, 'podman', { withFsMount: true, projectRoot: '/fake/project', }); @@ -209,10 +219,10 @@ describe('getMcpServerConfig', () => { }); it('returns a docker command with SONARQUBE_PROJECT_KEY for non-global config with project key', () => { - const config = getMcpServerConfig(ON_PREMISE_AUTH, 'docker', { + const config = getMcpContainerCommand(ON_PREMISE_AUTH, 'docker', { withFsMount: true, projectRoot: '/fake/project', - discoveredProjectKey: 'my-project', + projectKey: 'my-project', }); expect(config).toEqual({ command: 'docker', @@ -242,12 +252,22 @@ describe('getMcpServerConfig', () => { }); describe('getMcpConfigFilePath', () => { - it('returns ~/.claude.json for the claude agent', () => { - expect(getMcpConfigFilePath('claude')).toBe(join(homedir(), '.claude.json')); + it('returns ~/.claude.json for the global claude case', () => { + expect(getMcpConfigFilePath('claude', true, '/fake/project')).toBe( + join(homedir(), '.claude.json'), + ); + }); + + it('returns /.mcp.json for the project-level claude case', () => { + expect(getMcpConfigFilePath('claude', false, '/fake/project')).toBe( + join('/fake/project', '.mcp.json'), + ); }); it('throws for an unsupported agent', () => { - expect(() => getMcpConfigFilePath('cursor')).toThrow('Unsupported agent: cursor'); + expect(() => getMcpConfigFilePath('cursor', false, '/fake/project')).toThrow( + 'Unsupported agent: cursor', + ); }); }); @@ -260,49 +280,17 @@ describe('writeMcpServerEntry', () => { it('throws when the existing file contains invalid JSON', () => { writeFileSync(tmpFile, 'not valid json', 'utf-8'); - expect( - writeMcpServerEntry(tmpFile, { command: 'docker' }, true, '/fake/project'), - ).rejects.toThrow('contains invalid JSON'); - }); - - it('merges sonarqube entry into existing project-specific mcpServers without overwriting other entries', async () => { - const projectRoot = '/fake/project'; - const existing = { - projects: { - [projectRoot]: { mcpServers: { other: { command: 'npx', args: ['other-mcp'] } } }, - }, - }; - writeFileSync(tmpFile, JSON.stringify(existing), 'utf-8'); - - const serverConfig = { command: 'docker', args: ['run', 'mcp/sonarqube'] }; - await writeMcpServerEntry(tmpFile, serverConfig, false, projectRoot); - - const written = JSON.parse(readFileSync(tmpFile, 'utf-8')) as Record; - const projects = written.projects as Record; - const mcpServers = (projects[projectRoot] as Record).mcpServers as Record< - string, - unknown - >; - expect(mcpServers['other']).toEqual({ command: 'npx', args: ['other-mcp'] }); - expect(mcpServers['sonarqube']).toEqual(serverConfig); - }); - - it('writes projects keys with forward slashes when projectRoot uses backslashes', async () => { - const winRoot = String.raw`C:\Users\tdd\source\repos\sonarlint-core`; - const serverConfig = { command: 'docker', args: ['run', 'mcp/sonarqube'] }; - await writeMcpServerEntry(tmpFile, serverConfig, false, winRoot); - - const written = JSON.parse(readFileSync(tmpFile, 'utf-8')) as Record; - const projects = written.projects as Record; - expect(Object.keys(projects)).toEqual(['C:/Users/tdd/source/repos/sonarlint-core']); + expect(writeMcpServerEntry(tmpFile, { command: 'sonar' })).rejects.toThrow( + 'contains invalid JSON', + ); }); - it('merges sonarqube entry into existing global mcpServers without overwriting other entries', async () => { + it('merges sonarqube entry into existing mcpServers without overwriting other entries', async () => { const existing = { mcpServers: { other: { command: 'npx', args: ['other-mcp'] } } }; writeFileSync(tmpFile, JSON.stringify(existing), 'utf-8'); - const serverConfig = { command: 'docker', args: ['run', 'mcp/sonarqube'] }; - await writeMcpServerEntry(tmpFile, serverConfig, true, '/fake/project'); + const serverConfig = { command: 'sonar', args: ['run', 'mcp'] }; + await writeMcpServerEntry(tmpFile, serverConfig); const written = JSON.parse(readFileSync(tmpFile, 'utf-8')) as Record; const mcpServers = written.mcpServers as Record; @@ -311,86 +299,80 @@ describe('writeMcpServerEntry', () => { }); }); -describe('setupMcpServer', () => { - let runtimeSpy: ReturnType; +describe('setupMcpServerForAgent (claude)', () => { let writeSpy: ReturnType; afterEach(() => { - runtimeSpy.mockRestore(); writeSpy?.mockRestore(); setMockUi(false); }); - it('skips MCP configuration and prints an error when no container runtime is available', async () => { + it('writes a sonar CLI config with the platform CLI command', async () => { setMockUi(true); - runtimeSpy = spyOn(toolDetector, 'detectContainerRuntime').mockResolvedValue(null); + writeSpy = spyOn( + await import('../../../../../../src/lib/mcp/mcp-helper'), + 'writeMcpServerEntry', + ).mockResolvedValue(undefined); - await setupMcpServer('claude', '/fake/project', false, CLOUD_AUTH, undefined); + await setupMcpServer(FAKE_PROJECT, true, undefined); - const messages = getMockUiCalls().map((c) => String(c.args[0])); - expect(messages.some((m) => m.includes('container runtime (Docker/Podman/Nerdctl)'))).toBe( - true, - ); - expect(messages.some((m) => m.includes('Skipping SonarQube MCP Server configuration'))).toBe( - true, - ); + const config = (writeSpy.mock.calls[0] as unknown[])[1] as { command: string; args: string[] }; + expect(config.command).toBe(CLI_COMMAND); + expect(config.args).toEqual(['run', 'mcp']); }); - it('uses docker command when docker runtime is detected', async () => { + it('writes to ~/.claude.json for the global case', async () => { setMockUi(true); - runtimeSpy = spyOn(toolDetector, 'detectContainerRuntime').mockResolvedValue('docker'); writeSpy = spyOn( - await import('../../../../../../src/cli/commands/integrate/claude/mcp'), + await import('../../../../../../src/lib/mcp/mcp-helper'), 'writeMcpServerEntry', ).mockResolvedValue(undefined); - await setupMcpServer('claude', '/fake/project', true, ON_PREMISE_AUTH, undefined); + await setupMcpServer(FAKE_PROJECT, true, undefined); - const config = (writeSpy.mock.calls[0] as unknown[])[1] as { command: string }; - expect(config.command).toBe('docker'); + const filePath = (writeSpy.mock.calls[0] as unknown[])[0] as string; + expect(filePath).toBe(join(homedir(), '.claude.json')); }); - it('uses podman command when podman runtime is detected', async () => { + it('writes to /.mcp.json for the non-global case', async () => { setMockUi(true); - runtimeSpy = spyOn(toolDetector, 'detectContainerRuntime').mockResolvedValue('podman'); writeSpy = spyOn( - await import('../../../../../../src/cli/commands/integrate/claude/mcp'), + await import('../../../../../../src/lib/mcp/mcp-helper'), 'writeMcpServerEntry', ).mockResolvedValue(undefined); - await setupMcpServer('claude', '/fake/project', true, ON_PREMISE_AUTH, undefined); + await setupMcpServer(FAKE_PROJECT, false, undefined); - const config = (writeSpy.mock.calls[0] as unknown[])[1] as { command: string }; - expect(config.command).toBe('podman'); + const filePath = (writeSpy.mock.calls[0] as unknown[])[0] as string; + expect(filePath).toBe(join('/fake/project', '.mcp.json')); }); - it('uses nerdctl command when nerdctl runtime is detected', async () => { + it('includes --project flag when a project key is provided', async () => { setMockUi(true); - runtimeSpy = spyOn(toolDetector, 'detectContainerRuntime').mockResolvedValue('nerdctl'); writeSpy = spyOn( - await import('../../../../../../src/cli/commands/integrate/claude/mcp'), + await import('../../../../../../src/lib/mcp/mcp-helper'), 'writeMcpServerEntry', ).mockResolvedValue(undefined); - await setupMcpServer('claude', '/fake/project', true, ON_PREMISE_AUTH, undefined); + await setupMcpServer(FAKE_PROJECT, false, 'my-project'); - const config = (writeSpy.mock.calls[0] as unknown[])[1] as { command: string }; - expect(config.command).toBe('nerdctl'); + const config = (writeSpy.mock.calls[0] as unknown[])[1] as { args: string[] }; + expect(config.args).toContain('--project'); + expect(config.args).toContain('my-project'); }); - it('logs an error when writing the MCP entry fails', async () => { + it('warns when writing the MCP entry fails', async () => { setMockUi(true); - runtimeSpy = spyOn(toolDetector, 'detectContainerRuntime').mockResolvedValue('docker'); writeSpy = spyOn( - await import('../../../../../../src/cli/commands/integrate/claude/mcp'), + await import('../../../../../../src/lib/mcp/mcp-helper'), 'writeMcpServerEntry', ).mockRejectedValue(new Error('disk full')); - await setupMcpServer('claude', '/fake/project', false, ON_PREMISE_AUTH, undefined); + await setupMcpServer(FAKE_PROJECT, false, undefined); - const errors = getMockUiCalls() - .filter((c) => c.method === 'error') + const warns = getMockUiCalls() + .filter((c) => c.method === 'warn') .map((c) => String(c.args[0])); - expect(errors.some((m) => m.includes('disk full'))).toBe(true); + expect(warns.some((m) => m.includes('disk full'))).toBe(true); }); }); diff --git a/tests/unit/cli/commands/integrate/copilot/integrate.test.ts b/tests/unit/cli/commands/integrate/copilot/integrate.test.ts new file mode 100644 index 00000000..ca3c2674 --- /dev/null +++ b/tests/unit/cli/commands/integrate/copilot/integrate.test.ts @@ -0,0 +1,127 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; + +import { InvalidOptionError } from '../../../../../../src/cli/commands/_common/error'; +import { integrateCopilot } from '../../../../../../src/cli/commands/integrate/copilot'; +import type { ResolvedAuth } from '../../../../../../src/lib/auth-resolver'; +import * as mcpHelper from '../../../../../../src/lib/mcp/mcp-helper'; +import type { DiscoveredProject } from '../../../../../../src/lib/project-workspace'; +import * as discovery from '../../../../../../src/lib/project-workspace'; +import { clearMockUiCalls, setMockUi } from '../../../../../../src/ui'; + +const SERVER_AUTH: ResolvedAuth = { + token: 'test-token', + serverUrl: 'https://sonar.example.com', + connectionType: 'on-premise', +}; + +describe('integrateCopilot', () => { + let discoverProjectSpy: ReturnType; + let setupMcpServerForAgentSpy: ReturnType; + + beforeEach(() => { + setMockUi(true); + discoverProjectSpy = spyOn(discovery, 'discoverProject'); + setupMcpServerForAgentSpy = spyOn(mcpHelper, 'setupMcpServerForAgent').mockResolvedValue( + undefined, + ); + mockDiscoveredProject({}); + }); + + afterEach(() => { + clearMockUiCalls(); + setMockUi(false); + discoverProjectSpy.mockRestore(); + setupMcpServerForAgentSpy.mockRestore(); + }); + + it('calls setupMcpServerForAgent with copilot, discovered rootDir, non-global, and discovered projectKey', async () => { + mockDiscoveredProject({ rootDir: '/project/root', projectKey: 'discovered-key' }); + + await integrateCopilot(SERVER_AUTH, {}); + + expect(setupMcpServerForAgentSpy).toHaveBeenCalledWith( + 'copilot', + '/project/root', + false, + 'discovered-key', + ); + }); + + it('uses --project override instead of discovered projectKey', async () => { + mockDiscoveredProject({ rootDir: '/project/root', projectKey: 'discovered-key' }); + + await integrateCopilot(SERVER_AUTH, { project: 'override-key' }); + + expect(setupMcpServerForAgentSpy).toHaveBeenCalledWith( + 'copilot', + '/project/root', + false, + 'override-key', + ); + }); + + it('passes isGlobal=true when --global is set', async () => { + mockDiscoveredProject({ rootDir: '/project/root' }); + + await integrateCopilot(SERVER_AUTH, { global: true }); + + expect(setupMcpServerForAgentSpy).toHaveBeenCalledWith( + 'copilot', + '/project/root', + true, + undefined, + ); + }); + + it('throws InvalidOptionError when both --global and --project are provided', () => { + expect(integrateCopilot(SERVER_AUTH, { global: true, project: 'my-project' })).rejects.toThrow( + new InvalidOptionError( + '--global and --project are mutually exclusive; please specify only one scope.', + ), + ); + }); + + it('still calls setupMcpServerForAgent when discoverProject finds no config (non-git, unconfigured dir)', async () => { + mockDiscoveredProject({ rootDir: '/no-config-dir', isGitRepo: false, configSources: [] }); + + await integrateCopilot(SERVER_AUTH, {}); + + expect(setupMcpServerForAgentSpy).toHaveBeenCalledWith( + 'copilot', + '/no-config-dir', + false, + undefined, + ); + }); + + function mockDiscoveredProject(project: Partial) { + discoverProjectSpy.mockResolvedValue({ + rootDir: project.rootDir ?? process.cwd(), + isGitRepo: project.isGitRepo ?? false, + serverUrl: project.serverUrl, + organization: project.organization, + projectKey: project.projectKey, + configSources: project.configSources ?? [], + }); + } +}); diff --git a/tests/unit/cli/commands/integrate/copilot/mcp.test.ts b/tests/unit/cli/commands/integrate/copilot/mcp.test.ts new file mode 100644 index 00000000..34529c0e --- /dev/null +++ b/tests/unit/cli/commands/integrate/copilot/mcp.test.ts @@ -0,0 +1,116 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, spyOn } from 'bun:test'; + +import { setupMcpServer } from '../../../../../../src/cli/commands/integrate/copilot/mcp'; +import { CLI_COMMAND } from '../../../../../../src/lib/config-constants'; +import { DiscoveredProject } from '../../../../../../src/lib/project-workspace'; +import { getMockUiCalls, setMockUi } from '../../../../../../src/ui'; + +const FAKE_PROJECT: DiscoveredProject = { + rootDir: '/fake/project', + isGitRepo: false, + serverUrl: 'https://sonarqube.example.com', + organization: 'my-org', + projectKey: 'my-project', + configSources: [], +}; + +describe('setupMcpServerForAgent (copilot)', () => { + let writeSpy: ReturnType; + + afterEach(() => { + writeSpy?.mockRestore(); + setMockUi(false); + }); + + it('writes a sonar CLI config with the platform CLI command', async () => { + setMockUi(true); + writeSpy = spyOn( + await import('../../../../../../src/lib/mcp/mcp-helper'), + 'writeMcpServerEntry', + ).mockResolvedValue(undefined); + + await setupMcpServer(FAKE_PROJECT, true, undefined); + + const config = (writeSpy.mock.calls[0] as unknown[])[1] as { command: string; args: string[] }; + expect(config.command).toBe(CLI_COMMAND); + expect(config.args).toEqual(['run', 'mcp']); + }); + + it('writes to ~/.copilot/mcp-config.json for the global case', async () => { + setMockUi(true); + writeSpy = spyOn( + await import('../../../../../../src/lib/mcp/mcp-helper'), + 'writeMcpServerEntry', + ).mockResolvedValue(undefined); + + await setupMcpServer(FAKE_PROJECT, true, undefined); + + const filePath = (writeSpy.mock.calls[0] as unknown[])[0] as string; + expect(filePath).toBe(join(homedir(), '.copilot', 'mcp-config.json')); + }); + + it('writes to /.mcp.json for the non-global case', async () => { + setMockUi(true); + writeSpy = spyOn( + await import('../../../../../../src/lib/mcp/mcp-helper'), + 'writeMcpServerEntry', + ).mockResolvedValue(undefined); + + await setupMcpServer(FAKE_PROJECT, false, undefined); + + const filePath = (writeSpy.mock.calls[0] as unknown[])[0] as string; + expect(filePath).toBe(join('/fake/project', '.mcp.json')); + }); + + it('includes --project flag when a project key is provided', async () => { + setMockUi(true); + writeSpy = spyOn( + await import('../../../../../../src/lib/mcp/mcp-helper'), + 'writeMcpServerEntry', + ).mockResolvedValue(undefined); + + await setupMcpServer(FAKE_PROJECT, false, 'my-project'); + + const config = (writeSpy.mock.calls[0] as unknown[])[1] as { args: string[] }; + expect(config.args).toContain('--project'); + expect(config.args).toContain('my-project'); + }); + + it('warns when writing the MCP entry fails', async () => { + setMockUi(true); + writeSpy = spyOn( + await import('../../../../../../src/lib/mcp/mcp-helper'), + 'writeMcpServerEntry', + ).mockRejectedValue(new Error('disk full')); + + await setupMcpServer(FAKE_PROJECT, false, undefined); + + const warns = getMockUiCalls() + .filter((c) => c.method === 'warn') + .map((c) => String(c.args[0])); + expect(warns.some((m) => m.includes('disk full'))).toBe(true); + }); +});