Skip to main content
Glama
lininn
by lininn
index.ts80.8 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { Command } from 'commander'; import { config } from 'dotenv'; import axios, { AxiosResponse, AxiosError } from 'axios'; import { join } from 'path'; import { readFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { execSync } from 'child_process'; import { CodeAnalyzer } from './code-analyzer.js'; import { ApiClient } from './api-client.js'; import { CodeAnalysisResult, ApiRequestOptions, CreateMergeRequestOptions, MergeRequestResult, GitBranchInfo, GitRemoteInfo, ProjectInfo } from './types.js'; // Load environment variables config(); // Get current file directory const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Read package.json to get version let packageVersion = '1.0.16'; try { const packagePath = join(__dirname, '../package.json'); const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')); packageVersion = packageJson.version; } catch (error) { console.warn('Could not read package.json version, using default:', packageVersion); } // Configuration interface interface ServerConfig { apiBaseUrl?: string; apiToken?: string; timeout?: number; maxRetries?: number; } type ProjectIdResolutionSource = 'input' | 'git_remote' | 'search'; interface ProjectIdCandidate { rawProjectId: string; normalizedProjectId: string; source: ProjectIdResolutionSource; metadata?: Record<string, any>; } interface ProjectAttemptRecord { candidate: ProjectIdCandidate; success: boolean; status?: number; errorMessage?: string; projectData?: { id?: number; name?: string; path_with_namespace?: string; web_url?: string; visibility?: string; }; } interface ProjectResolutionResult { success: boolean; rawProjectId?: string; normalizedProjectId?: string; source?: ProjectIdResolutionSource; projectData?: any; attempts: ProjectAttemptRecord[]; providedProjectId?: string; reason?: 'invalid_format' | 'not_found' | 'not_detected'; } // Default configuration const defaultConfig: ServerConfig = { apiBaseUrl: process.env.API_BASE_URL || 'https://api.github.com', apiToken: process.env.API_TOKEN || '', timeout: parseInt(process.env.TIMEOUT || '30000'), maxRetries: parseInt(process.env.MAX_RETRIES || '3'), }; class CodeReviewMCPServer { private server: Server; private config: ServerConfig; private codeAnalyzer: CodeAnalyzer; private apiClient: ApiClient; constructor(config: ServerConfig = {}) { this.config = { ...defaultConfig, ...config }; this.codeAnalyzer = new CodeAnalyzer(); this.apiClient = new ApiClient( this.config.apiBaseUrl!, this.config.apiToken!, this.config.timeout, this.config.maxRetries ); this.server = new Server( { name: 'node-code-review-mcp', version: packageVersion, }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); this.setupErrorHandling(); } private setupErrorHandling(): void { this.server.onerror = (error) => { console.error('[MCP Error]', error); }; process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers(): void { this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'fetch_pull_request', description: 'Fetch pull request details from GitHub/GitLab', inputSchema: { type: 'object', properties: { repository: { type: 'string', description: 'Repository in format "owner/repo"', }, pullRequestNumber: { type: 'number', description: 'Pull request number', }, provider: { type: 'string', enum: ['github', 'gitlab'], description: 'Git provider (github or gitlab)', default: 'gitlab', }, }, required: ['repository', 'pullRequestNumber'], }, }, { name: 'fetch_code_diff', description: 'Fetch code diff for a pull request or commit', inputSchema: { type: 'object', properties: { repository: { type: 'string', description: 'Repository in format "owner/repo"', }, pullRequestNumber: { type: 'number', description: 'Pull request number (optional if commitSha is provided)', }, commitSha: { type: 'string', description: 'Commit SHA (optional if pullRequestNumber is provided)', }, filePath: { type: 'string', description: 'Specific file path to get diff for (optional)', }, provider: { type: 'string', enum: ['github', 'gitlab'], description: 'Git provider (github or gitlab)', default: 'gitlab', }, }, required: ['repository'], }, }, { name: 'add_review_comment', description: 'Add a review comment to a pull request', inputSchema: { type: 'object', properties: { repository: { type: 'string', description: 'Repository in format "owner/repo"', }, pullRequestNumber: { type: 'number', description: 'Pull request number', }, body: { type: 'string', description: 'Comment body', }, filePath: { type: 'string', description: 'File path for line comment (optional)', }, line: { type: 'number', description: 'Line number for line comment (optional)', }, provider: { type: 'string', enum: ['github', 'gitlab'], description: 'Git provider (github or gitlab)', default: 'gitlab', }, }, required: ['repository', 'pullRequestNumber', 'body'], }, }, { name: 'analyze_code_quality', description: 'Analyze code quality and provide suggestions with detailed metrics', inputSchema: { type: 'object', properties: { code: { type: 'string', description: 'Code content to analyze', }, language: { type: 'string', description: 'Programming language (javascript, typescript, python, java, go, etc.)', }, rules: { type: 'array', items: { type: 'string' }, description: 'Specific rules to check (optional)', }, }, required: ['code', 'language'], }, }, { name: 'get_supported_languages', description: 'Get list of supported programming languages for code analysis', inputSchema: { type: 'object', properties: {}, }, }, { name: 'get_language_rules', description: 'Get available analysis rules for a specific language', inputSchema: { type: 'object', properties: { language: { type: 'string', description: 'Programming language', }, }, required: ['language'], }, }, { name: 'get_repository_info', description: 'Get repository information', inputSchema: { type: 'object', properties: { repository: { type: 'string', description: 'Repository in format "owner/repo"', }, provider: { type: 'string', enum: ['github', 'gitlab'], description: 'Git provider (github or gitlab)', default: 'gitlab', }, }, required: ['repository'], }, }, { name: 'analyze_files_batch', description: 'Analyze multiple files for code quality issues', inputSchema: { type: 'object', properties: { files: { type: 'array', items: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' }, language: { type: 'string' }, }, required: ['path', 'content', 'language'], }, description: 'Array of files to analyze', }, rules: { type: 'array', items: { type: 'string' }, description: 'Specific rules to apply to all files (optional)', }, }, required: ['files'], }, }, { name: 'get_pull_request_files', description: 'Get list of files changed in a pull request or merge request', inputSchema: { type: 'object', properties: { repository: { type: 'string', description: 'Repository/project identifier (e.g., "owner/repo" or "group/project"). Optional if workingDirectory is provided.', }, pullRequestNumber: { type: ['number', 'string'], description: 'Pull request number (IID). Optional if mergeRequestIid is provided.', }, mergeRequestIid: { type: ['number', 'string'], description: 'Merge request IID (GitLab). Optional if pullRequestNumber is provided.', }, provider: { type: 'string', enum: ['github', 'gitlab'], description: 'Git provider (github or gitlab)', default: 'gitlab', }, workingDirectory: { type: 'string', description: 'Local repository path for auto-detecting project ID (aliases: working_directory, cwd)', }, remoteName: { type: 'string', description: 'Git remote name used for auto-detecting the project ID', default: 'origin', }, }, required: [], }, }, { name: 'get_merge_request_changes', description: 'Get merge request changes (GitLab compatibility alias)', inputSchema: { type: 'object', properties: { repository: { type: 'string', description: 'Repository/project identifier (e.g., "group/project"). Optional if workingDirectory is provided.', }, pullRequestNumber: { type: ['number', 'string'], description: 'Pull/MR number (IID). Optional if mergeRequestIid is provided.', }, mergeRequestIid: { type: ['number', 'string'], description: 'Merge request IID (GitLab). Optional if pullRequestNumber is provided.', }, provider: { type: 'string', enum: ['github', 'gitlab'], description: 'Git provider (default gitlab)', default: 'gitlab', }, workingDirectory: { type: 'string', description: 'Local repository path for auto-detecting project ID (aliases: working_directory, cwd)', }, remoteName: { type: 'string', description: 'Git remote name used for auto-detecting the project ID', default: 'origin', }, }, required: [], }, }, { name: 'get_server_config', description: 'Get current server configuration', inputSchema: { type: 'object', properties: {}, }, }, { name: 'get_merge_request', description: 'Get information about a specific GitLab merge request', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'GitLab project ID or path (aliases: project_id, project_path; e.g., "12345" or "group/project")', }, mergeRequestIid: { type: 'string', description: 'Merge request IID (aliases: merge_request_iid; the number in the MR URL, not the database ID)', }, workingDirectory: { type: 'string', description: 'Local repository path for auto-detecting project ID (aliases: working_directory, cwd)', }, remoteName: { type: 'string', description: 'Git remote name used for auto-detecting the project ID', default: 'origin', }, }, required: ['mergeRequestIid'], }, }, { name: 'create_merge_request', description: 'Create a new GitLab merge request from a source branch', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'GitLab project ID or path (aliases: project_id, project_path; e.g., "12345" or "group/project")', }, sourceBranch: { type: 'string', description: 'Source branch name (aliases: source_branch; e.g., "feature/new-feature")', }, targetBranch: { type: 'string', description: 'Target branch name (aliases: target_branch; defaults to "main")', default: 'main', }, title: { type: 'string', description: 'Merge request title (auto-generated from branch name if not provided)', }, description: { type: 'string', description: 'Merge request description', }, assigneeId: { type: 'number', description: 'User ID to assign the merge request to', }, reviewerIds: { type: 'array', items: { type: 'number' }, description: 'Array of user IDs to request reviews from', }, deleteSourceBranch: { type: 'boolean', description: 'Whether to delete source branch when MR is merged', default: false, }, squash: { type: 'boolean', description: 'Whether to squash commits when merging', default: false, }, workingDirectory: { type: 'string', description: 'Local repository path for auto-detecting project ID (aliases: working_directory, cwd)', }, remoteName: { type: 'string', description: 'Git remote name used for auto-detecting the project ID', default: 'origin', }, }, required: ['sourceBranch'], }, }, { name: 'get_current_branch', description: 'Get current Git branch and repository information', inputSchema: { type: 'object', properties: { workingDirectory: { type: 'string', description: 'Working directory path (defaults to current directory)', }, }, }, }, { name: 'get_project_info', description: 'Get current GitLab project information from Git remotes', inputSchema: { type: 'object', properties: { workingDirectory: { type: 'string', description: 'Working directory path (defaults to current directory)', }, remoteName: { type: 'string', description: 'Git remote name (defaults to "origin")', default: 'origin', }, }, }, }, ], }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; switch (name) { case 'fetch_pull_request': return await this.fetchPullRequest(args); case 'fetch_code_diff': return await this.fetchCodeDiff(args); case 'add_review_comment': return await this.addReviewComment(args); case 'analyze_code_quality': return await this.analyzeCodeQuality(args); case 'get_supported_languages': return await this.getSupportedLanguages(); case 'get_language_rules': return await this.getLanguageRules(args); case 'get_repository_info': return await this.getRepositoryInfo(args); case 'analyze_files_batch': return await this.analyzeFilesBatch(args); case 'get_pull_request_files': return await this.getPullRequestFiles(args); case 'get_merge_request_changes': return await this.getMergeRequestChanges(args); case 'get_server_config': return await this.getServerConfig(); case 'get_merge_request': return await this.getMergeRequest(args); case 'create_merge_request': return await this.createMergeRequest(args); case 'get_current_branch': return await this.getCurrentBranch(args); case 'get_project_info': return await this.getProjectInfo(args); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${name}` ); } } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}` ); } }); } private async makeApiRequest(endpoint: string, options: ApiRequestOptions = {}): Promise<AxiosResponse> { try { return await this.apiClient.requestWithRateLimit(endpoint, options); } catch (error) { console.error(`API request failed for endpoint ${endpoint}:`, error instanceof Error ? error.message : String(error)); throw error; } } private async fetchPullRequest(args: any) { const provider = this.coerceToString(this.getArgumentValue(args, ['provider'])) ?? 'gitlab'; let repository = this.coerceToString( this.getArgumentValue(args, [ 'repository', 'project', 'projectId', 'project_id', 'project_path', 'projectPath', ]) ); let pullRequestIdentifier = this.coerceToString( this.getArgumentValue(args, [ 'pullRequestNumber', 'pull_request_number', 'pullRequestId', 'pull_request_id', 'mergeRequestIid', 'merge_request_iid', 'mergeRequestId', 'merge_request_id', ]) ); if (provider === 'gitlab') { const enhancedContext = this.enhanceGitlabMergeRequestContext(args, { projectId: repository, mergeRequestIid: pullRequestIdentifier, }); repository = enhancedContext.projectId ?? repository; pullRequestIdentifier = enhancedContext.mergeRequestIid ?? pullRequestIdentifier; } if (!pullRequestIdentifier) { throw new Error( 'Pull request number (pullRequestNumber or mergeRequestIid) is required' ); } let endpoint: string; if (provider === 'github') { if (!repository) { throw new Error('Repository is required for GitHub provider'); } endpoint = `repos/${repository}/pulls/${pullRequestIdentifier}`; } else if (provider === 'gitlab') { const projectResolution = await this.resolveGitlabProjectId( args, repository ); if (!projectResolution.success) { const resolutionDetails = this.formatProjectResolutionDetails(projectResolution); const { errorTitle, message, suggestions } = this.buildProjectResolutionError(projectResolution, 'fetch'); return { content: [ { type: 'text', text: JSON.stringify( { success: false, error: errorTitle, message, details: resolutionDetails, suggestions, }, null, 2 ), }, ], }; } const normalizedProjectId = projectResolution.normalizedProjectId!; endpoint = `projects/${normalizedProjectId}/merge_requests/${pullRequestIdentifier}`; } else { throw new Error(`Unsupported provider: ${provider}`); } const response = await this.makeApiRequest(endpoint); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } private async fetchCodeDiff(args: any) { const provider = this.coerceToString(this.getArgumentValue(args, ['provider'])) ?? 'gitlab'; let repository = this.coerceToString( this.getArgumentValue(args, [ 'repository', 'project', 'projectId', 'project_id', 'project_path', 'projectPath', ]) ); let pullRequestNumber = this.coerceToString( this.getArgumentValue(args, [ 'pullRequestNumber', 'pull_request_number', 'mergeRequestIid', 'merge_request_iid', 'mergeRequestId', 'merge_request_id', ]) ); const commitSha = this.coerceToString( this.getArgumentValue(args, ['commitSha', 'commit_sha', 'sha']) ); const filePath = this.coerceToString( this.getArgumentValue(args, ['filePath', 'file_path', 'path']) ); if (provider === 'gitlab') { const enhancedContext = this.enhanceGitlabMergeRequestContext(args, { projectId: repository, mergeRequestIid: pullRequestNumber, }); repository = enhancedContext.projectId ?? repository; pullRequestNumber = enhancedContext.mergeRequestIid ?? pullRequestNumber; } let endpoint: string; if (provider === 'github') { if (!repository) { throw new Error('Repository is required for GitHub requests'); } if (pullRequestNumber) { endpoint = `repos/${repository}/pulls/${pullRequestNumber}/files`; } else if (commitSha) { endpoint = `repos/${repository}/commits/${commitSha}`; } else { throw new Error('Either pullRequestNumber or commitSha must be provided'); } } else if (provider === 'gitlab') { if (!repository) { throw new Error('Project ID or path is required for GitLab requests'); } if (pullRequestNumber) { endpoint = `projects/${encodeURIComponent( repository )}/merge_requests/${pullRequestNumber}/changes`; } else if (commitSha) { endpoint = `projects/${encodeURIComponent( repository )}/repository/commits/${commitSha}/diff`; } else { throw new Error('Either pullRequestNumber or commitSha must be provided'); } } else { throw new Error(`Unsupported provider: ${provider}`); } const response = await this.makeApiRequest(endpoint); let data = response.data; // Filter by file path if specified if (filePath && Array.isArray(data)) { data = data.filter( (file: any) => file.filename === filePath || file.new_path === filePath || file.old_path === filePath ); } return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } private async addReviewComment(args: any) { const provider = this.coerceToString(this.getArgumentValue(args, ['provider'])) ?? 'gitlab'; let repository = this.coerceToString( this.getArgumentValue(args, [ 'repository', 'project', 'projectId', 'project_id', 'project_path', 'projectPath', ]) ); let pullRequestNumber = this.coerceToString( this.getArgumentValue(args, [ 'pullRequestNumber', 'pull_request_number', 'pullRequestId', 'pull_request_id', 'mergeRequestIid', 'merge_request_iid', 'mergeRequestId', 'merge_request_id', 'iid', ]) ); const body = this.coerceToString(this.getArgumentValue(args, ['body'])); const filePath = this.coerceToString( this.getArgumentValue(args, ['filePath', 'file_path']) ); const line = this.coerceToNumber( this.getArgumentValue(args, ['line', 'lineNumber', 'line_number']) ); if (!body) { throw new Error('Comment body is required'); } if (provider === 'gitlab') { const enhancedContext = this.enhanceGitlabMergeRequestContext(args, { projectId: repository, mergeRequestIid: pullRequestNumber, }); repository = enhancedContext.projectId ?? repository; pullRequestNumber = enhancedContext.mergeRequestIid ?? pullRequestNumber; } if (!pullRequestNumber) { throw new Error( 'Pull request or merge request identifier is required to add a comment' ); } let endpoint: string; let requestData: any; if (provider === 'github') { if (!repository) { throw new Error('Repository is required for GitHub requests'); } if (filePath && line !== undefined) { endpoint = `repos/${repository}/pulls/${pullRequestNumber}/reviews`; requestData = { body, event: 'COMMENT', comments: [ { path: filePath, line, body, }, ], }; } else { endpoint = `repos/${repository}/issues/${pullRequestNumber}/comments`; requestData = { body }; } } else if (provider === 'gitlab') { if (!repository) { throw new Error('Project ID or path is required for GitLab requests'); } endpoint = `projects/${encodeURIComponent( repository )}/merge_requests/${pullRequestNumber}/notes`; requestData = { body }; if (filePath && line !== undefined) { requestData.position = { position_type: 'text', new_path: filePath, new_line: line, }; } } else { throw new Error(`Unsupported provider: ${provider}`); } const response = await this.makeApiRequest(endpoint, { method: 'POST', data: requestData, }); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } private async analyzeCodeQuality(args: any) { const { code, language, rules = [] } = args; const analysis: CodeAnalysisResult = this.codeAnalyzer.analyze(code, language, rules); return { content: [ { type: 'text', text: JSON.stringify(analysis, null, 2), }, ], }; } private async getSupportedLanguages() { const languages = this.codeAnalyzer.getSupportedLanguages(); return { content: [ { type: 'text', text: JSON.stringify({ supportedLanguages: languages, total: languages.length, }, null, 2), }, ], }; } private async getLanguageRules(args: any) { const { language } = args; const rules = this.codeAnalyzer.getLanguageRules(language); return { content: [ { type: 'text', text: JSON.stringify({ language, rules: rules.map(rule => ({ name: rule.name, message: rule.message, severity: rule.severity, type: rule.type, })), total: rules.length, }, null, 2), }, ], }; } private async getRepositoryInfo(args: any) { const { repository, provider = 'gitlab' } = args; let endpoint: string; if (provider === 'github') { endpoint = `repos/${repository}`; } else if (provider === 'gitlab') { endpoint = `projects/${encodeURIComponent(repository)}`; } else { throw new Error(`Unsupported provider: ${provider}`); } const response = await this.makeApiRequest(endpoint); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } private async analyzeFilesBatch(args: any) { const { files, rules = [] } = args; const results = files.map((file: any) => { const analysis = this.codeAnalyzer.analyze(file.content, file.language, rules); return { filePath: file.path, language: file.language, analysis, }; }); // Calculate overall statistics const totalIssues = results.reduce((sum: number, result: any) => sum + result.analysis.issues.length, 0); const totalLines = results.reduce((sum: number, result: any) => sum + result.analysis.lineCount, 0); const avgComplexity = results.reduce((sum: number, result: any) => sum + result.analysis.metrics.cyclomaticComplexity, 0) / results.length; const summary = { totalFiles: files.length, totalLines, totalIssues, averageComplexity: Math.round(avgComplexity * 100) / 100, issuesByType: { error: results.reduce((sum: number, result: any) => sum + result.analysis.metrics.issueCount.error, 0), warning: results.reduce((sum: number, result: any) => sum + result.analysis.metrics.issueCount.warning, 0), info: results.reduce((sum: number, result: any) => sum + result.analysis.metrics.issueCount.info, 0), }, results, }; return { content: [ { type: 'text', text: JSON.stringify(summary, null, 2), }, ], }; } private async getPullRequestFiles(args: any) { const provider = this.coerceToString(this.getArgumentValue(args, ['provider'])) ?? 'gitlab'; let repository = this.coerceToString( this.getArgumentValue(args, [ 'repository', 'project', 'projectId', 'project_id', 'project_path', 'projectPath', ]) ); let pullRequestNumber = this.coerceToString( this.getArgumentValue(args, [ 'pullRequestNumber', 'pull_request_number', 'pullRequestId', 'pull_request_id', 'mergeRequestIid', 'merge_request_iid', 'mergeRequestId', 'merge_request_id', ]) ); if (provider === 'gitlab') { const enhancedContext = this.enhanceGitlabMergeRequestContext(args, { projectId: repository, mergeRequestIid: pullRequestNumber, }); repository = enhancedContext.projectId ?? repository; pullRequestNumber = enhancedContext.mergeRequestIid ?? pullRequestNumber; } if (!pullRequestNumber) { throw new Error( 'Pull request or merge request identifier is required to list files' ); } let endpoint: string; if (provider === 'github') { if (!repository) { throw new Error('Repository is required for GitHub requests'); } endpoint = `repos/${repository}/pulls/${pullRequestNumber}/files`; } else if (provider === 'gitlab') { if (!repository) { throw new Error('Project ID or path is required for GitLab requests'); } endpoint = `projects/${encodeURIComponent( repository )}/merge_requests/${pullRequestNumber}/changes`; } else { throw new Error(`Unsupported provider: ${provider}`); } const response = await this.makeApiRequest(endpoint); let files = response.data; // Normalize the response format if (provider === 'gitlab' && files.changes) { files = files.changes; } // Extract file information const fileList = Array.isArray(files) ? files.map((file: any) => { if (provider === 'github') { return { filename: file.filename, status: file.status, additions: file.additions, deletions: file.deletions, changes: file.changes, patch: file.patch?.substring(0, 1000) + (file.patch?.length > 1000 ? '...' : ''), // Truncate large patches }; } else { return { filename: file.new_path || file.old_path, status: file.new_file ? 'added' : file.deleted_file ? 'deleted' : 'modified', oldPath: file.old_path, newPath: file.new_path, diff: file.diff?.substring(0, 1000) + (file.diff?.length > 1000 ? '...' : ''), // Truncate large diffs }; } }) : []; return { content: [ { type: 'text', text: JSON.stringify({ pullRequestNumber, repository, provider, totalFiles: fileList.length, files: fileList, }, null, 2), }, ], }; } private async getMergeRequestChanges(args: any) { return this.getPullRequestFiles(args); } private async getServerConfig() { const apiInfo = this.apiClient.getApiInfo(); const isHealthy = await this.apiClient.healthCheck(); const safeConfig = { ...apiInfo, supportedLanguages: this.codeAnalyzer.getSupportedLanguages(), healthStatus: isHealthy ? 'healthy' : 'unhealthy', version: packageVersion, }; return { content: [ { type: 'text', text: JSON.stringify(safeConfig, null, 2), }, ], }; } private async getMergeRequest(args: any) { let rawProjectIdInput = this.coerceToString( this.getArgumentValue(args, [ 'projectId', 'project_id', 'project', 'projectPath', 'project_path', 'repository', 'repository_path', ]) ); let mergeRequestIid = this.coerceToString( this.getArgumentValue(args, [ 'mergeRequestIid', 'merge_request_iid', 'mergeRequestIID', 'merge_request_IID', 'iid', 'pullRequestNumber', 'pull_request_number', 'mergeRequestId', 'merge_request_id', ]) ); const enhancedContext = this.enhanceGitlabMergeRequestContext(args, { projectId: rawProjectIdInput, mergeRequestIid, }); rawProjectIdInput = enhancedContext.projectId ?? rawProjectIdInput; mergeRequestIid = enhancedContext.mergeRequestIid ?? mergeRequestIid; if (!mergeRequestIid) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: 'Missing merge request IID', message: '❌ Please provide mergeRequestIid (e.g., "42")', suggestions: [ 'The IID is the number visible in the merge request URL', 'Pass mergeRequestIid or merge_request_iid in the tool arguments' ] }, null, 2), }, ], }; } const projectResolution = await this.resolveGitlabProjectId( args, rawProjectIdInput ); if (!projectResolution.success) { const resolutionDetails = this.formatProjectResolutionDetails(projectResolution); const { errorTitle, message, suggestions } = this.buildProjectResolutionError(projectResolution, 'fetch'); return { content: [ { type: 'text', text: JSON.stringify( { success: false, error: errorTitle, message, details: resolutionDetails, suggestions, }, null, 2 ), }, ], }; } const normalizedProjectId = projectResolution.normalizedProjectId!; const resolvedRawProjectId = projectResolution.rawProjectId!; const projectInfoDetails = this.formatProjectResolutionDetails(projectResolution); if (projectResolution.source && projectResolution.source !== 'input') { console.warn( `ℹ️ Resolved project ID via ${projectResolution.source}: ${resolvedRawProjectId}` ); } try { // Fetch merge request details from GitLab API const endpoint = `projects/${normalizedProjectId}/merge_requests/${mergeRequestIid}`; console.warn(`🔍 Fetching merge request at endpoint: ${endpoint}`); const response = await this.makeApiRequest(endpoint); const mrData = response.data; return { content: [ { type: 'text', text: JSON.stringify({ success: true, mergeRequest: { id: mrData.id, iid: mrData.iid, title: mrData.title, description: mrData.description, state: mrData.state, source_branch: mrData.source_branch, target_branch: mrData.target_branch, author: mrData.author, web_url: mrData.web_url, created_at: mrData.created_at, updated_at: mrData.updated_at, notes_count: mrData.notes_count, commits_count: mrData.commits_count, changes_count: mrData.changes_count, merge_status: mrData.merge_status, approvals_before_merge: mrData.approvals_before_merge, approved_by: mrData.approved_by, reviewers: mrData.reviewers, assignees: mrData.assignees, labels: mrData.labels, milestone: mrData.milestone, user: mrData.user, }, message: `📋 Merge request fetched successfully!`, projectInfo: projectInfoDetails, }, null, 2), }, ], }; } catch (error) { console.error('Failed to fetch merge request:', error); // Enhanced error reporting let errorDetails: any = { success: false, error: error instanceof Error ? error.message : String(error), message: `❌ Failed to fetch merge request`, projectInfo: projectInfoDetails, requestDetails: { endpoint: `projects/${normalizedProjectId}/merge_requests/${mergeRequestIid}`, method: 'GET' } }; // Add specific error handling for common GitLab API errors if (this.isAxiosError(error)) { const status = error.response?.status; const responseData = error.response?.data; errorDetails.httpStatus = status; errorDetails.responseData = responseData; switch (status) { case 401: errorDetails.troubleshooting = [ 'Check API token validity', 'Verify token has not expired', 'Ensure token has correct permissions' ]; break; case 403: errorDetails.troubleshooting = [ 'Check if you have read access to the project', 'Verify you have permission to view merge requests', 'Check if the merge request is private' ]; break; case 404: errorDetails.troubleshooting = [ 'Verify project exists and you have access', 'Check if merge request IID is correct', 'Verify the merge request has not been deleted', 'Try using different project ID format' ]; break; } } return { content: [ { type: 'text', text: JSON.stringify(errorDetails, null, 2), }, ], }; } } private async createMergeRequest(args: any) { const rawProjectIdInput = this.coerceToString( this.getArgumentValue(args, [ 'projectId', 'project_id', 'project', 'projectPath', 'project_path', ]) ); const sourceBranchInput = this.coerceToString( this.getArgumentValue(args, [ 'sourceBranch', 'source_branch', 'source', ]) ); const targetBranch = this.coerceToString( this.getArgumentValue(args, [ 'targetBranch', 'target_branch', 'destinationBranch', 'destination_branch', ]) ) ?? 'main'; const title = this.coerceToString( this.getArgumentValue(args, [ 'title', 'mr_title', 'mergeRequestTitle', ]) ); const description = this.coerceToString( this.getArgumentValue(args, [ 'description', 'body', 'mergeRequestDescription', ]) ); const assigneeId = this.coerceToNumber( this.getArgumentValue(args, [ 'assigneeId', 'assignee_id', 'assignee', ]) ); const reviewerIds = this.coerceToNumberArray( this.getArgumentValue(args, [ 'reviewerIds', 'reviewer_ids', 'reviewers', ]) ); const deleteSourceBranch = this.coerceToBoolean( this.getArgumentValue(args, [ 'deleteSourceBranch', 'delete_source_branch', 'removeSourceBranch', 'remove_source_branch', ]), false ); const squash = this.coerceToBoolean( this.getArgumentValue(args, [ 'squash', 'shouldSquash', 'squash_commits', ]), false ); const sourceBranch = sourceBranchInput?.trim(); if (!sourceBranch) { return { content: [ { type: 'text', text: JSON.stringify( { success: false, error: 'Missing source branch', message: '❌ Please provide sourceBranch (e.g., "feature/my-feature")', suggestions: [ 'Pass sourceBranch or source_branch in the tool arguments', 'Ensure the branch exists in the GitLab project', ], }, null, 2 ), }, ], }; } const projectResolution = await this.resolveGitlabProjectId( args, rawProjectIdInput ); if (!projectResolution.success) { const resolutionDetails = this.formatProjectResolutionDetails(projectResolution); const { errorTitle, message, suggestions } = this.buildProjectResolutionError(projectResolution, 'create'); return { content: [ { type: 'text', text: JSON.stringify( { success: false, error: errorTitle, message, details: resolutionDetails, suggestions, }, null, 2 ), }, ], }; } const normalizedProjectId = projectResolution.normalizedProjectId!; const resolvedRawProjectId = projectResolution.rawProjectId!; const projectInfoDetails = this.formatProjectResolutionDetails(projectResolution); if (projectResolution.source && projectResolution.source !== 'input') { console.warn( `ℹ️ Resolved project ID via ${projectResolution.source}: ${resolvedRawProjectId}` ); } // Auto-generate title from branch name if not provided const mrTitle = title || this.generateMRTitle(sourceBranch); // Prepare the request data const requestData: any = { source_branch: sourceBranch, target_branch: targetBranch, title: mrTitle, }; if (description) { requestData.description = description; } if (assigneeId !== undefined) { requestData.assignee_id = assigneeId; } if (reviewerIds && reviewerIds.length > 0) { requestData.reviewer_ids = reviewerIds; } if (deleteSourceBranch) { requestData.remove_source_branch = true; } if (squash) { requestData.squash = true; } try { const endpoint = `projects/${normalizedProjectId}/merge_requests`; console.warn(`🚀 Creating merge request at endpoint: ${endpoint}`); const response = await this.makeApiRequest(endpoint, { method: 'POST', data: requestData, }); const mrData: MergeRequestResult = response.data; return { content: [ { type: 'text', text: JSON.stringify( { success: true, mergeRequest: { id: mrData.id, web_url: mrData.web_url, title: mrData.title, state: mrData.state, source_branch: mrData.source_branch, target_branch: mrData.target_branch, author: mrData.author, }, message: `🎉 Merge request created successfully!`, projectInfo: projectInfoDetails, requestData, responseData: response.data, }, null, 2 ), }, ], }; } catch (error) { console.error('Failed to create merge request:', error); // Enhanced error reporting const errorDetails: any = { success: false, error: error instanceof Error ? error.message : String(error), message: `❌ Failed to create merge request`, projectInfo: projectInfoDetails, requestDetails: { endpoint: `projects/${normalizedProjectId}/merge_requests`, method: 'POST', sourceBranch, targetBranch, title: mrTitle, deleteSourceBranch, squash, }, }; // Add specific error handling for common GitLab API errors if (this.isAxiosError(error)) { const status = error.response?.status; const responseData = error.response?.data; errorDetails.httpStatus = status; errorDetails.responseData = responseData; switch (status) { case 400: errorDetails.troubleshooting = [ 'Check if source branch exists', 'Verify branch names are valid', 'Ensure target branch exists', 'Check if MR already exists for this branch', ]; break; case 401: errorDetails.troubleshooting = [ 'Check API token validity', 'Verify token has not expired', 'Ensure token has correct permissions', ]; break; case 403: errorDetails.troubleshooting = [ 'Check if you have push access to the project', 'Verify you have permission to create merge requests', 'Check if branch protection rules allow MR creation', ]; break; case 404: errorDetails.troubleshooting = [ 'Verify project exists and you have access', 'Check if source branch exists', 'Try using different project ID format', 'Verify GitLab API base URL is correct', ]; break; case 409: errorDetails.troubleshooting = [ 'A merge request may already exist for this branch', 'Check for conflicting branch names', 'Verify source and target branches are different', ]; break; } } return { content: [ { type: 'text', text: JSON.stringify(errorDetails, null, 2), }, ], }; } } private generateMRTitle(sourceBranch: string): string { // Extract feature name from branch name (similar to the script logic) let title = sourceBranch; // Remove common prefixes if (title.startsWith('feature/')) { title = title.replace('feature/', ''); } else if (title.startsWith('feat/')) { title = title.replace('feat/', ''); } else if (title.startsWith('bugfix/')) { title = title.replace('bugfix/', ''); return `fix: ${title}`; } else if (title.startsWith('hotfix/')) { title = title.replace('hotfix/', ''); return `fix: ${title}`; } else if (title.startsWith('docs/')) { title = title.replace('docs/', ''); return `docs: ${title}`; } else if (title.startsWith('refactor/')) { title = title.replace('refactor/', ''); return `refactor: ${title}`; } // Convert kebab-case or snake_case to readable title title = title .replace(/[-_]/g, ' ') .replace(/\b\w/g, l => l.toUpperCase()); return `feat: ${title}`; } private async getCurrentBranch(args: any) { const { workingDirectory = process.cwd() } = args; try { const branchInfo = this.executeGitCommand('get_current_branch', workingDirectory); return { content: [ { type: 'text', text: JSON.stringify(branchInfo, null, 2), }, ], }; } catch (error) { console.error('Failed to get current branch:', error); return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error), isGitRepository: false, }, null, 2), }, ], }; } } private async getProjectInfo(args: any) { const { workingDirectory = process.cwd(), remoteName = 'origin' } = args; try { const projectInfo = this.executeGitCommand('get_project_info', workingDirectory, remoteName); return { content: [ { type: 'text', text: JSON.stringify(projectInfo, null, 2), }, ], }; } catch (error) { console.error('Failed to get project info:', error); return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error), isGitlabProject: false, remotes: [], }, null, 2), }, ], }; } } private executeGitCommand(command: string, workingDirectory: string, ...args: string[]): any { const originalCwd = process.cwd(); try { // Change to the working directory process.chdir(workingDirectory); switch (command) { case 'get_current_branch': return this.getBranchInfo(); case 'get_project_info': return this.getGitProjectInfo(args[0] || 'origin'); default: throw new Error(`Unknown git command: ${command}`); } } finally { // Always restore original working directory process.chdir(originalCwd); } } private getBranchInfo(): GitBranchInfo { try { // Check if it's a git repository execSync('git rev-parse --git-dir', { stdio: 'pipe' }); // Get current branch const currentBranch = execSync('git branch --show-current', { encoding: 'utf8', stdio: 'pipe' }).trim(); // Get all branches const allBranchesOutput = execSync('git branch -a', { encoding: 'utf8', stdio: 'pipe' }); const allBranches = allBranchesOutput .split('\n') .map(branch => branch.replace(/^\s*\*?\s*/, '').trim()) .filter(branch => branch && !branch.startsWith('remotes/origin/HEAD')) .map(branch => branch.replace(/^remotes\/origin\//, '')); // Remove duplicates and current branch marker const uniqueBranches = [...new Set(allBranches)].filter(Boolean); // Get repository root const repositoryRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8', stdio: 'pipe' }).trim(); return { currentBranch, allBranches: uniqueBranches, isGitRepository: true, repositoryRoot, }; } catch (error) { return { currentBranch: '', allBranches: [], isGitRepository: false, }; } } private getGitProjectInfo(remoteName: string): ProjectInfo { try { // Check if it's a git repository execSync('git rev-parse --git-dir', { stdio: 'pipe' }); // Get remote URLs const remotesOutput = execSync('git remote -v', { encoding: 'utf8', stdio: 'pipe' }); const remotes: GitRemoteInfo[] = []; const remoteLines = remotesOutput.split('\n').filter(line => line.trim()); for (const line of remoteLines) { const match = line.match(/^(\w+)\s+(.+?)\s+\((\w+)\)$/); if (match) { const [, name, url, type] = match; let remote = remotes.find(r => r.name === name); if (!remote) { remote = { name, url }; remotes.push(remote); } if (type === 'fetch') { remote.fetch = url; } else if (type === 'push') { remote.push = url; } } } // Find the specified remote const targetRemote = remotes.find(r => r.name === remoteName); if (!targetRemote) { return { remotes, isGitlabProject: false, }; } // Parse GitLab project info from remote URL const gitlabInfo = this.parseGitlabRemoteUrl(targetRemote.url); return { ...gitlabInfo, remotes, isGitlabProject: gitlabInfo.projectId !== undefined, }; } catch (error) { return { remotes: [], isGitlabProject: false, }; } } private parseGitlabRemoteUrl(url: string): Partial<ProjectInfo> { try { // Common GitLab URL patterns: // https://gitlab.com/group/project.git // git@gitlab.com:group/project.git // https://gitlab.example.com/group/subgroup/project.git let cleanUrl = url.replace(/\.git$/, ''); let gitlabUrl = ''; let projectPath = ''; // Handle SSH format (git@hostname:path) const sshMatch = cleanUrl.match(/^git@([^:]+):(.+)$/); if (sshMatch) { const [, hostname, path] = sshMatch; gitlabUrl = `https://${hostname}`; projectPath = path; } else { // Handle HTTPS format const httpsMatch = cleanUrl.match(/^https?:\/\/([^\/]+)\/(.+)$/); if (httpsMatch) { const [, hostname, path] = httpsMatch; gitlabUrl = `https://${hostname}`; projectPath = path; } } if (!projectPath) { return {}; } // For GitLab API, we can use the path directly as project ID // or try to determine the numeric ID if needed return { projectId: encodeURIComponent(projectPath), projectPath, gitlabUrl, }; } catch (error) { return {}; } } private enhanceGitlabMergeRequestContext( args: any, context: { projectId?: string | null; mergeRequestIid?: string | null } ): { projectId?: string; mergeRequestIid?: string } { const mergeRequestUrl = this.coerceToString( this.getArgumentValue(args, [ 'mergeRequestUrl', 'merge_request_url', 'mergeRequestLink', 'merge_request_link', 'mrUrl', 'mr_url', 'pullRequestUrl', 'pull_request_url', 'prUrl', 'pr_url', 'url', ]) ); const projectUrl = this.coerceToString( this.getArgumentValue(args, [ 'projectUrl', 'project_url', 'repositoryUrl', 'repository_url', 'repoUrl', 'repo_url', 'gitlabUrl', 'gitlab_url', ]) ); const candidates = [ context.projectId, mergeRequestUrl, projectUrl, ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); let projectId = context.projectId ?? undefined; let mergeRequestIid = context.mergeRequestIid ?? undefined; for (const candidate of candidates) { const parsed = this.parseGitlabReferenceString(candidate); if (!parsed) { continue; } if (parsed.projectPath && !projectId) { projectId = parsed.projectPath; } if (parsed.mergeRequestIid && !mergeRequestIid) { mergeRequestIid = parsed.mergeRequestIid; } if (projectId && mergeRequestIid) { break; } } return { projectId, mergeRequestIid }; } private parseGitlabReferenceString( value?: string | null ): { projectPath?: string; mergeRequestIid?: string } | null { if (!value) { return null; } let trimmed = value.trim(); if (!trimmed) { return null; } // Remove surrounding quotes that sometimes sneak in from CLI tools if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { trimmed = trimmed.slice(1, -1); } let mergeRequestIid: string | undefined; const normalizePath = (input: string) => { let cleaned = input.replace(/^\/+/, '').replace(/[?#].*$/, ''); cleaned = cleaned.replace(/\.git$/i, ''); if (!cleaned) { return ''; } const segments = cleaned .split('/') .filter(Boolean) .map((segment) => { try { return decodeURIComponent(segment); } catch { return segment; } }); return segments.join('/'); }; const parsePotentialUrl = (input: string): string => { try { const parsedUrl = new URL(input); return parsedUrl.pathname || ''; } catch { return input; } }; const parseSshPath = (input: string): string => { const sshMatch = input.match(/^git@[^:]+:(.+)$/); return sshMatch ? sshMatch[1] : input; }; const extractMergeRequestFromPath = (input: string) => { const mrMatch = input.match(/(.+?)\/-\/merge_requests\/(\d+)(?:\/.*)?$/i); if (mrMatch) { return { path: mrMatch[1], iid: mrMatch[2] }; } const fallbackMatch = input.match(/(.+?)\/merge_requests\/(\d+)(?:\/.*)?$/i); if (fallbackMatch) { return { path: fallbackMatch[1], iid: fallbackMatch[2] }; } return { path: input }; }; let pathCandidate = parsePotentialUrl(trimmed); pathCandidate = parseSshPath(pathCandidate); const extracted = extractMergeRequestFromPath(pathCandidate); if (extracted.iid) { mergeRequestIid = extracted.iid; } const normalizedPath = normalizePath(extracted.path); if (!normalizedPath && !mergeRequestIid) { return null; } return { projectPath: normalizedPath || undefined, mergeRequestIid, }; } private getArgumentValue<T = any>(args: Record<string, any> | undefined, keys: string[], defaultValue?: T): T | undefined { if (!args) { return defaultValue; } for (const key of keys) { if (Object.prototype.hasOwnProperty.call(args, key) && args[key] !== undefined) { return args[key]; } } return defaultValue; } private coerceToString(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined; } if (typeof value === 'string') { return value; } if (typeof value === 'number' || typeof value === 'boolean') { return String(value); } return undefined; } private coerceToBoolean(value: unknown, defaultValue = false): boolean { if (value === undefined || value === null) { return defaultValue; } if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value !== 0; } if (typeof value === 'string') { const normalized = value.trim().toLowerCase(); if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) { return true; } if (['false', '0', 'no', 'n', 'off'].includes(normalized)) { return false; } } return defaultValue; } private coerceToNumber(value: unknown): number | undefined { if (value === undefined || value === null) { return undefined; } if (typeof value === 'number') { return Number.isNaN(value) ? undefined : value; } if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); return Number.isNaN(parsed) ? undefined : parsed; } return undefined; } private coerceToNumberArray(value: unknown): number[] | undefined { if (value === undefined || value === null) { return undefined; } if (Array.isArray(value)) { const parsed = value .map((item) => this.coerceToNumber(item)) .filter((item): item is number => item !== undefined); return parsed.length > 0 ? parsed : undefined; } if (typeof value === 'string') { const parts = value .split(',') .map((part) => part.trim()) .filter(Boolean); const parsed = parts .map((part) => this.coerceToNumber(part)) .filter((item): item is number => item !== undefined); return parsed.length > 0 ? parsed : undefined; } const single = this.coerceToNumber(value); return single !== undefined ? [single] : undefined; } private getWorkingDirectoryArg(args: any): string | undefined { const candidate = this.coerceToString( this.getArgumentValue(args, [ 'workingDirectory', 'working_directory', 'cwd', ]) ); if (candidate && existsSync(candidate)) { return candidate; } return undefined; } private getRemoteNameArg(args: any): string { return ( this.coerceToString( this.getArgumentValue(args, ['remoteName', 'remote_name', 'remote']) ) ?? 'origin' ); } private getApiHost(): string | null { try { if (!this.config.apiBaseUrl) { return null; } return new URL(this.config.apiBaseUrl).host; } catch { return null; } } private getProjectCandidateFromGit(args: any): ProjectIdCandidate | null { const workingDirectory = this.getWorkingDirectoryArg(args) ?? process.cwd(); const remoteName = this.getRemoteNameArg(args); try { const projectInfo = this.executeGitCommand( 'get_project_info', workingDirectory, remoteName ) as ProjectInfo; if (!projectInfo?.isGitlabProject || !projectInfo.projectId) { return null; } const apiHost = this.getApiHost(); if (apiHost && projectInfo.gitlabUrl) { try { const remoteHost = new URL(projectInfo.gitlabUrl).host; if (remoteHost !== apiHost) { return null; } } catch { // Ignore parsing errors and fall through } } return { rawProjectId: projectInfo.projectPath ?? decodeURIComponent(projectInfo.projectId), normalizedProjectId: projectInfo.projectId, source: 'git_remote', metadata: { workingDirectory, remoteName, }, }; } catch (error) { console.warn( 'Failed to detect project from git remote:', error instanceof Error ? error.message : String(error) ); return null; } } private async getProjectCandidateFromSearch( projectIdentifier?: string ): Promise<ProjectIdCandidate | null> { if (!projectIdentifier) { return null; } const trimmed = projectIdentifier.trim(); if (!trimmed) { return null; } const decodedIdentifier = decodeURIComponent(trimmed); const segments = decodedIdentifier.split('/').filter(Boolean); const searchTerm = segments.length > 0 ? segments[segments.length - 1] : ''; if (!searchTerm) { return null; } const endpoint = `projects?search=${encodeURIComponent( searchTerm )}&simple=true&per_page=20`; try { const response = await this.makeApiRequest(endpoint); const projects = Array.isArray(response.data) ? response.data : []; if (projects.length === 0) { return null; } const providedLower = decodedIdentifier.toLowerCase(); const searchLower = searchTerm.toLowerCase(); const exactMatch = projects.find( (project: any) => typeof project?.path_with_namespace === 'string' && project.path_with_namespace.toLowerCase() === providedLower ); const pathMatch = projects.find( (project: any) => typeof project?.path === 'string' && project.path.toLowerCase() === searchLower ); const nameMatch = projects.find( (project: any) => typeof project?.name === 'string' && project.name.toLowerCase() === searchLower ); const candidateProject = exactMatch ?? pathMatch ?? nameMatch ?? projects[0]; if ( !candidateProject || typeof candidateProject?.path_with_namespace !== 'string' ) { return null; } return { rawProjectId: candidateProject.path_with_namespace, normalizedProjectId: encodeURIComponent( candidateProject.path_with_namespace ), source: 'search', metadata: { searchTerm, projectId: candidateProject.id, resultCount: projects.length, }, }; } catch (error) { console.warn( 'GitLab project search failed:', error instanceof Error ? error.message : String(error) ); return null; } } private async verifyProjectCandidate( candidate: ProjectIdCandidate, attempts: ProjectAttemptRecord[] ): Promise<{ verified: boolean; status?: number; projectData?: any }> { try { const response = await this.makeApiRequest( `projects/${candidate.normalizedProjectId}` ); const projectData = response.data; attempts.push({ candidate, success: true, status: response.status, projectData: projectData ? { id: projectData.id, name: projectData.name, path_with_namespace: projectData.path_with_namespace, web_url: projectData.web_url, visibility: projectData.visibility, } : undefined, }); return { verified: true, status: response.status, projectData, }; } catch (error) { let status: number | undefined; let errorMessage: string | undefined; if (this.isAxiosError(error)) { status = error.response?.status; const responseData: any = error.response?.data; errorMessage = (responseData && (responseData.message || responseData.error)) || error.message; } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } attempts.push({ candidate, success: false, status, errorMessage, }); if (status && status !== 404) { throw error; } return { verified: false, status, }; } } private async getFallbackProjectCandidates( args: any, options: { allowSearch: boolean; providedProjectId?: string; excludeNormalized?: string; } ): Promise<ProjectIdCandidate[]> { const candidates: ProjectIdCandidate[] = []; const gitCandidate = this.getProjectCandidateFromGit(args); if ( gitCandidate && gitCandidate.normalizedProjectId !== options.excludeNormalized ) { candidates.push(gitCandidate); } const searchIdentifier = options.providedProjectId ?? gitCandidate?.rawProjectId; if (options.allowSearch && searchIdentifier) { const searchCandidate = await this.getProjectCandidateFromSearch( searchIdentifier ); if ( searchCandidate && searchCandidate.normalizedProjectId !== options.excludeNormalized && !candidates.some( (candidate) => candidate.normalizedProjectId === searchCandidate.normalizedProjectId ) ) { candidates.push(searchCandidate); } } return candidates; } private async resolveGitlabProjectId( args: any, rawProjectIdInput?: string | null, options: { allowSearch?: boolean } = {} ): Promise<ProjectResolutionResult> { const allowSearch = options.allowSearch !== false; const attempts: ProjectAttemptRecord[] = []; const seenNormalized = new Set<string>(); const queue: ProjectIdCandidate[] = []; const providedProjectId = rawProjectIdInput ?? undefined; const normalizedProvidedProjectId = rawProjectIdInput ? this.normalizeProjectId(rawProjectIdInput) : null; let fallbackCandidatesQueued = false; const enqueueCandidate = ( candidate: ProjectIdCandidate | null | undefined ) => { if ( !candidate || !candidate.normalizedProjectId || seenNormalized.has(candidate.normalizedProjectId) ) { return; } seenNormalized.add(candidate.normalizedProjectId); queue.push(candidate); }; if (normalizedProvidedProjectId) { enqueueCandidate({ rawProjectId: rawProjectIdInput!, normalizedProjectId: normalizedProvidedProjectId, source: 'input', metadata: { decodedProjectPath: decodeURIComponent(normalizedProvidedProjectId), }, }); } if (queue.length === 0) { const fallbackCandidates = await this.getFallbackProjectCandidates(args, { allowSearch, providedProjectId, }); fallbackCandidates.forEach(enqueueCandidate); if (fallbackCandidates.length > 0) { fallbackCandidatesQueued = true; } } while (queue.length > 0) { const candidate = queue.shift()!; const verification = await this.verifyProjectCandidate( candidate, attempts ); if (verification.verified) { return { success: true, rawProjectId: candidate.rawProjectId, normalizedProjectId: candidate.normalizedProjectId, source: candidate.source, projectData: verification.projectData, attempts, providedProjectId, }; } if (verification.status === 404 && !fallbackCandidatesQueued) { const fallbackCandidates = await this.getFallbackProjectCandidates( args, { allowSearch, providedProjectId, excludeNormalized: candidate.normalizedProjectId, } ); if (fallbackCandidates.length > 0) { fallbackCandidates.forEach(enqueueCandidate); fallbackCandidatesQueued = true; } } } let reason: ProjectResolutionResult['reason'] = undefined; if (providedProjectId && !normalizedProvidedProjectId) { reason = 'invalid_format'; } else if (attempts.some((attempt) => attempt.status === 404)) { reason = 'not_found'; } else if (attempts.length === 0) { reason = 'not_detected'; } return { success: false, attempts, providedProjectId, reason, }; } private formatProjectResolutionDetails( resolution: ProjectResolutionResult ) { return { providedProjectId: resolution.providedProjectId, resolvedProjectId: resolution.rawProjectId, normalizedProjectId: resolution.normalizedProjectId, resolutionSource: resolution.source, reason: resolution.reason, attempts: resolution.attempts.map((attempt) => ({ source: attempt.candidate.source, rawProjectId: attempt.candidate.rawProjectId, normalizedProjectId: attempt.candidate.normalizedProjectId, success: attempt.success, status: attempt.status, errorMessage: attempt.errorMessage, projectId: attempt.projectData?.id, projectPath: attempt.projectData?.path_with_namespace, projectName: attempt.projectData?.name, projectVisibility: attempt.projectData?.visibility, })), }; } private buildProjectResolutionError( resolution: ProjectResolutionResult, context: 'fetch' | 'create' ) { const baseMessage = context === 'fetch' ? '❌ Unable to resolve project ID from provided parameters or git remotes' : '❌ Unable to resolve project ID needed to create merge request'; let errorTitle = 'Project Resolution Failed'; let message = baseMessage; let suggestions: string[] = [ 'Verify the project exists and you have access', 'Provide projectId in format "group/project"', 'Try using numeric project ID (e.g., "12345")', ]; switch (resolution.reason) { case 'invalid_format': errorTitle = 'Invalid project ID format'; message = '❌ Project ID should be in format "group/project" or numeric ID like "12345"'; suggestions = [ 'Use project path format: "mygroup/myproject"', 'Run tool with workingDirectory so the project can be auto-detected from git remote', 'Use numeric project ID: "12345"', ]; break; case 'not_detected': errorTitle = 'Project auto-detection failed'; message = '❌ Could not detect GitLab project automatically from git remotes'; suggestions = [ 'Provide projectId explicitly (e.g., "group/project")', 'Ensure workingDirectory points to the GitLab repository', 'If remote is not "origin", pass remoteName (e.g., "upstream")', ]; break; case 'not_found': errorTitle = 'Project not found'; message = '❌ GitLab API returned 404 for provided project ID candidates'; suggestions = [ 'Verify the project exists and you have access', 'Check git remote path (git remote -v) to confirm namespace', 'Try using numeric project ID from GitLab project settings', ]; break; } return { errorTitle, message, suggestions }; } private normalizeProjectId(projectId: string): string | null { if (!projectId || typeof projectId !== 'string') { return null; } const trimmedId = projectId.trim(); // Check if it's a numeric ID if (/^\d+$/.test(trimmedId)) { return trimmedId; } // If it looks like it might already be encoded, try to use it as-is first if (trimmedId.includes('%')) { return trimmedId; } // Check if it's a valid project path format (更宽松的正则表达式以支持更多GitLab项目格式) // 支持: group/project, group/subgroup/project, 以及包含连字符、下划线、点的项目名 // 也支持中文字符和其他Unicode字符,这在某些GitLab实例中是允许的 if (/^[a-zA-Z0-9_.\u4e00-\u9fff-]+\/[a-zA-Z0-9_.\u4e00-\u9fff-]+(?:\/[a-zA-Z0-9_.\u4e00-\u9fff-]+)*$/.test(trimmedId)) { // For project paths, use URL encoding // GitLab API expects URL encoding for paths but not for numeric IDs return encodeURIComponent(trimmedId); } // 更宽松的验证:如果包含斜杠,很可能是项目路径格式,尝试编码 if (trimmedId.includes('/') && trimmedId.split('/').length >= 2) { // 简单验证:至少包含 group/project 格式 const parts = trimmedId.split('/'); if (parts.every(part => part.length > 0 && !/^[\s]*$/.test(part))) { return encodeURIComponent(trimmedId); } } // 作为最后尝试,如果看起来像一个合理的标识符,返回编码后的版本 // 这提供了更好的向后兼容性 if (trimmedId.length > 0 && !/^[\s]*$/.test(trimmedId)) { console.warn(`⚠️ Using fallback encoding for project ID: ${trimmedId}`); return encodeURIComponent(trimmedId); } return null; } private isAxiosError(error: any): error is AxiosError { return error && error.isAxiosError === true; } async run(): Promise<void> { const transport = new StdioServerTransport(); await this.server.connect(transport); } } // CLI setup async function main() { const program = new Command(); program .name('node-code-review-mcp') .description('Node.js MCP server for code review operations') .version(packageVersion) .option('--api-base-url <url>', 'API base URL') .option('--api-token <token>', 'API token for authentication') .option('--timeout <ms>', 'Request timeout in milliseconds', '30000') .option('--max-retries <count>', 'Maximum retry attempts', '3'); program.parse(); const options = program.opts(); // Create server config from CLI options const config: ServerConfig = {}; if (options.apiBaseUrl) config.apiBaseUrl = options.apiBaseUrl; if (options.apiToken) config.apiToken = options.apiToken; if (options.timeout) config.timeout = parseInt(options.timeout); if (options.maxRetries) config.maxRetries = parseInt(options.maxRetries); const server = new CodeReviewMCPServer(config); await server.run(); } // Run the server main().catch((error) => { console.error('Failed to start server:', error); process.exit(1); }); export { CodeReviewMCPServer, ServerConfig };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/lininn/gitlab-review-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server