UNPKG

@hicho-lge/gerrit-mcp

Version:

Gerrit Code Review system을 위한 Model Context Protocol (MCP) 서버 - 23개 도구로 변경사항, 프로젝트, 계정 관리 기능 제공

1,118 lines (1,117 loc) 50.7 kB
#!/usr/bin/env node // MCP SDK 모듈들 - Model Context Protocol 서버 구성용 import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; // CLI 명령어 처리용 import { Command } from 'commander'; import { readFileSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; // Gerrit API 클라이언트와 타입 정의 import { GerritClient } from './gerrit-client.js'; // package.json에서 버전 읽기 const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')); const VERSION = packageJson.version; /** * Gerrit MCP Server 클래스 * - Gerrit 코드 리뷰 시스템과 상호작용하는 MCP 서버 * - 23개의 도구를 제공하여 변경사항, 프로젝트, 계정 관리 기능 지원 */ class GerritMCPServer { /** * 생성자 - 서버 초기화 및 설정 */ constructor() { // Gerrit API 클라이언트 (연결되기 전까지는 null) this.gerritClient = null; // MCP 서버 초기화 this.server = new Server({ name: 'gerrit-mcp', version: VERSION, }, { capabilities: { tools: {}, // 도구 기능 활성화 }, }); // 도구 핸들러 설정 this.setupToolHandlers(); // 환경변수가 설정되어 있으면 자동 연결 시도 (비동기로 실행하되 오류 무시) this.autoConnectIfConfigured().catch(error => { console.error('자동 연결 초기화 실패:', error); }); } /** * 도구 핸들러 설정 * - 사용 가능한 도구 목록 정의 * - 각 도구의 호출 처리 로직 설정 */ setupToolHandlers() { // 사용 가능한 도구 목록을 반환하는 핸들러 this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ // === 연결 관리 도구 === { name: 'gerrit_connect', description: 'Gerrit 서버에 연결합니다', inputSchema: { type: 'object', properties: { baseUrl: { type: 'string', description: 'Gerrit 서버 기본 URL (예: https://gerrit.example.com)' }, username: { type: 'string', description: '인증용 사용자명 (액세스 토큰 사용시 선택사항)' }, password: { type: 'string', description: '인증용 비밀번호 (액세스 토큰 사용시 선택사항)' }, accessToken: { type: 'string', description: '인증용 액세스 토큰 (사용자명/비밀번호 사용시 선택사항)' } }, required: ['baseUrl'] } }, // === 변경사항 관리 도구 === { name: 'gerrit_query_changes', description: 'Gerrit에서 변경사항을 검색합니다', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Gerrit query string (e.g., "status:open project:myproject")' }, limit: { type: 'number', description: 'Maximum number of results to return' }, options: { type: 'array', items: { type: 'string' }, description: 'Additional options like DETAILED_LABELS, CURRENT_REVISION, etc.' } }, required: ['query'] } }, { name: 'gerrit_get_change', description: 'Get detailed information about a specific change', inputSchema: { type: 'object', properties: { changeId: { type: 'string', description: 'Change ID (e.g., "I1234..." or change number)' }, detailed: { type: 'boolean', description: 'Whether to fetch detailed information' } }, required: ['changeId'] } }, { name: 'gerrit_review_change', description: 'Add a review to a change', inputSchema: { type: 'object', properties: { changeId: { type: 'string', description: 'Change ID' }, revisionId: { type: 'string', description: 'Revision ID (use "current" for latest)' }, message: { type: 'string', description: 'Review message' }, labels: { type: 'object', description: 'Labels to set (e.g., {"Code-Review": 1, "Verified": 1})' }, ready: { type: 'boolean', description: 'Mark change as ready for review' } }, required: ['changeId', 'revisionId'] } }, { name: 'gerrit_abandon_change', description: 'Abandon a change', inputSchema: { type: 'object', properties: { changeId: { type: 'string', description: 'Change ID' }, message: { type: 'string', description: 'Abandon message' } }, required: ['changeId'] } }, // === 프로젝트 관리 도구 === { name: 'gerrit_list_projects', description: 'Gerrit의 프로젝트 목록을 조회합니다', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of projects to return' }, prefix: { type: 'string', description: 'Project name prefix filter' }, regex: { type: 'string', description: 'Project name regex filter' }, description: { type: 'boolean', description: 'Include project descriptions' } } } }, { name: 'gerrit_get_project', description: 'Get information about a specific project', inputSchema: { type: 'object', properties: { projectName: { type: 'string', description: 'Project name' } }, required: ['projectName'] } }, // === 계정 관리 도구 === { name: 'gerrit_query_accounts', description: '사용자 계정을 검색합니다', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Account query string (e.g., username, email, or name)' }, limit: { type: 'number', description: 'Maximum number of results' }, suggest: { type: 'boolean', description: 'Use suggest mode for autocomplete' } }, required: ['query'] } }, { name: 'gerrit_get_account', description: 'Get account information', inputSchema: { type: 'object', properties: { accountId: { type: 'string', description: 'Account ID, username, or email' } }, required: ['accountId'] } }, { name: 'gerrit_test_connection', description: 'Gerrit 서버 연결 상태를 테스트합니다', inputSchema: { type: 'object', properties: {} } }, { name: 'gerrit_get_project_description', description: 'Get project description', inputSchema: { type: 'object', properties: { projectName: { type: 'string', description: 'Project name' } }, required: ['projectName'] } }, { name: 'gerrit_get_project_head', description: 'Get project HEAD branch', inputSchema: { type: 'object', properties: { projectName: { type: 'string', description: 'Project name' } }, required: ['projectName'] } }, { name: 'gerrit_list_project_branches', description: 'List project branches', inputSchema: { type: 'object', properties: { projectName: { type: 'string', description: 'Project name' }, limit: { type: 'number', description: 'Maximum number of branches to return' }, substring: { type: 'string', description: 'Substring to match in branch names' }, regex: { type: 'string', description: 'Regex pattern to match branch names' } }, required: ['projectName'] } }, { name: 'gerrit_list_project_tags', description: 'List project tags', inputSchema: { type: 'object', properties: { projectName: { type: 'string', description: 'Project name' }, limit: { type: 'number', description: 'Maximum number of tags to return' }, substring: { type: 'string', description: 'Substring to match in tag names' }, regex: { type: 'string', description: 'Regex pattern to match tag names' } }, required: ['projectName'] } }, { name: 'gerrit_get_change_comments', description: 'Get change comments', inputSchema: { type: 'object', properties: { changeId: { type: 'string', description: 'Change ID' } }, required: ['changeId'] } }, { name: 'gerrit_reply_to_inline_comment', description: 'Reply to inline comment', inputSchema: { type: 'object', properties: { changeId: { type: 'string', description: 'Change ID' }, revisionId: { type: 'string', description: 'Revision ID (use "current" for latest)' }, path: { type: 'string', description: 'File path' }, line: { type: 'number', description: 'Line number' }, message: { type: 'string', description: 'Reply message' }, inReplyTo: { type: 'string', description: 'ID of comment being replied to' } }, required: ['changeId', 'revisionId', 'message'] } }, // === 커밋 작업 도구 === { name: 'gerrit_get_commit', description: '커밋 상세 정보를 조회합니다', inputSchema: { type: 'object', properties: { projectName: { type: 'string', description: 'Project name' }, commitId: { type: 'string', description: 'Commit SHA' } }, required: ['projectName', 'commitId'] } }, { name: 'gerrit_get_included_in', description: 'Get branches/tags that include a commit', inputSchema: { type: 'object', properties: { changeId: { type: 'string', description: 'Change ID' }, revisionId: { type: 'string', description: 'Revision ID (use "current" for latest)' } }, required: ['changeId', 'revisionId'] } }, // === 파일 작업 도구 === { name: 'gerrit_list_files', description: '변경사항의 파일 목록을 조회합니다', inputSchema: { type: 'object', properties: { changeId: { type: 'string', description: 'Change ID' }, revisionId: { type: 'string', description: 'Revision ID (use "current" for latest)' }, base: { type: 'string', description: 'Base revision for comparison' }, parent: { type: 'number', description: 'Parent number for merge commits' } }, required: ['changeId', 'revisionId'] } }, { name: 'gerrit_get_content', description: 'Get file content', inputSchema: { type: 'object', properties: { changeId: { type: 'string', description: 'Change ID' }, revisionId: { type: 'string', description: 'Revision ID (use "current" for latest)' }, filePath: { type: 'string', description: 'File path' }, base: { type: 'string', description: 'Base revision for comparison' }, parent: { type: 'number', description: 'Parent number for merge commits' } }, required: ['changeId', 'revisionId', 'filePath'] } }, { name: 'gerrit_list_access_rights', description: 'List project access rights', inputSchema: { type: 'object', properties: { projectName: { type: 'string', description: 'Project name' } }, required: ['projectName'] } }, { name: 'gerrit_get_project_clone_info', description: 'Get project clone information for git MCP server', inputSchema: { type: 'object', properties: { projectName: { type: 'string', description: 'Project name' } }, required: ['projectName'] } }, { name: 'gerrit_get_change_fetch_info', description: 'Get change fetch information for git MCP server cherry-pick', inputSchema: { type: 'object', properties: { changeId: { type: 'string', description: 'Change ID' }, patchSetNumber: { type: 'number', description: 'Specific patch-set number (optional, defaults to current revision)' } }, required: ['changeId'] } } ], }; }); // 도구 호출 요청을 처리하는 핸들러 this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { // 요청된 도구에 따라 적절한 핸들러 호출 switch (name) { // 연결 관리 case 'gerrit_connect': return await this.handleConnect(args); case 'gerrit_test_connection': return await this.handleTestConnection(); // 변경사항 관리 case 'gerrit_query_changes': return await this.handleQueryChanges(args); case 'gerrit_get_change': return await this.handleGetChange(args); case 'gerrit_review_change': return await this.handleReviewChange(args); case 'gerrit_abandon_change': return await this.handleAbandonChange(args); case 'gerrit_get_change_comments': return await this.handleGetChangeComments(args); case 'gerrit_reply_to_inline_comment': return await this.handleReplyToInlineComment(args); case 'gerrit_get_change_fetch_info': return await this.handleGetChangeFetchInfo(args); // 프로젝트 관리 case 'gerrit_list_projects': return await this.handleListProjects(args); case 'gerrit_get_project': return await this.handleGetProject(args); case 'gerrit_get_project_description': return await this.handleGetProjectDescription(args); case 'gerrit_get_project_head': return await this.handleGetProjectHead(args); case 'gerrit_list_project_branches': return await this.handleListProjectBranches(args); case 'gerrit_list_project_tags': return await this.handleListProjectTags(args); case 'gerrit_list_access_rights': return await this.handleListAccessRights(args); case 'gerrit_get_project_clone_info': return await this.handleGetProjectCloneInfo(args); // 계정 관리 case 'gerrit_query_accounts': return await this.handleQueryAccounts(args); case 'gerrit_get_account': return await this.handleGetAccount(args); // 파일 작업 case 'gerrit_list_files': return await this.handleListFiles(args); case 'gerrit_get_content': return await this.handleGetContent(args); // 커밋 작업 case 'gerrit_get_commit': return await this.handleGetCommit(args); case 'gerrit_get_included_in': return await this.handleGetIncludedIn(args); default: throw new Error(`알 수 없는 도구: ${name}`); } } catch (error) { // 오류 발생 시 사용자에게 쳤절한 메시지 반환 return { content: [ { type: 'text', text: `오류 발생: ${error.message}`, }, ], isError: true, // 오류 플래그 추가 }; } }); } /** * Gerrit 클라이언트 필수 검증 * - 연결되지 않았으면 오류 발생 * @returns 연결된 Gerrit 클라이언트 */ requireClient() { if (!this.gerritClient) { throw new Error('Gerrit에 연결되지 않았습니다. 먼저 gerrit_connect를 사용하세요.'); } return this.gerritClient; } /** * 환경변수로 설정된 경우 자동 연결 시도 * - GERRIT_BASE_URL 환경변수가 있으면 자동으로 연결 시도 */ async autoConnectIfConfigured() { // 환경변수에 Gerrit URL이 설정되어 있는지 확인 if (process.env.GERRIT_BASE_URL) { try { // 환경변수로 Gerrit 클라이언트 생성 this.gerritClient = new GerritClient({ baseUrl: process.env.GERRIT_BASE_URL, username: process.env.GERRIT_USERNAME, password: process.env.GERRIT_HTTP_CREDENTIALS }); // 연결 테스트 const testResult = await this.gerritClient.testConnection(); if (!testResult.success) { console.error('자동 연결 실패:', testResult.error); this.gerritClient = null; } } catch (error) { console.error('자동 연결 실패:', error); this.gerritClient = null; } } } async handleConnect(args) { try { // 환경변수를 우선적으로 사용, args로 전달된 값이 있으면 덮어씀 const config = { baseUrl: process.env.GERRIT_BASE_URL || args.baseUrl, username: process.env.GERRIT_USERNAME || args.username, password: process.env.GERRIT_HTTP_CREDENTIALS || args.password, accessToken: args.accessToken // accessToken은 환경변수 사용하지 않음 }; if (!config.baseUrl) { throw new Error('Gerrit base URL is required. Set GERRIT_BASE_URL environment variable or provide baseUrl parameter.'); } this.gerritClient = new GerritClient(config); const testResult = await this.gerritClient.testConnection(); if (testResult.success) { const sourceInfo = process.env.GERRIT_BASE_URL ? '(from environment variables)' : '(from parameters)'; return { content: [ { type: 'text', text: `Successfully connected to Gerrit at ${config.baseUrl} ${sourceInfo}\\nAuthenticated as: ${testResult.account?.name || testResult.account?.username || 'Unknown'}`, }, ], }; } else { this.gerritClient = null; throw new Error(`Connection failed: ${testResult.error}`); } } catch (error) { this.gerritClient = null; throw error; } } async handleQueryChanges(args) { const client = this.requireClient(); const changes = await client.queryChanges({ q: args.query, n: args.limit, o: args.options }); return { content: [ { type: 'text', text: `Found ${changes.length} changes:\\n\\n` + changes.map(change => `${change._number}: ${change.subject}\\n` + ` Project: ${change.project}\\n` + ` Status: ${change.status}\\n` + ` Owner: ${change.owner.name || change.owner.username}\\n` + ` Updated: ${change.updated}`).join('\\n\\n'), }, ], }; } async handleGetChange(args) { const client = this.requireClient(); const change = args.detailed ? await client.getChangeDetail(args.changeId) : await client.getChange(args.changeId); return { content: [ { type: 'text', text: `Change ${change._number}: ${change.subject}\\n\\n` + `Project: ${change.project}\\n` + `Branch: ${change.branch}\\n` + `Status: ${change.status}\\n` + `Owner: ${change.owner.name || change.owner.username}\\n` + `Created: ${change.created}\\n` + `Updated: ${change.updated}\\n` + (change.topic ? `Topic: ${change.topic}\\n` : '') + (change.hashtags && change.hashtags.length > 0 ? `Hashtags: ${change.hashtags.join(', ')}\\n` : '') + `Insertions: ${change.insertions || 0}\\n` + `Deletions: ${change.deletions || 0}\\n` + `Comments: ${change.totalCommentCount || 0}` + (change.unresolvedCommentCount ? ` (${change.unresolvedCommentCount} unresolved)` : ''), }, ], }; } async handleReviewChange(args) { const client = this.requireClient(); const review = {}; if (args.message) review.message = args.message; if (args.labels) review.labels = args.labels; if (args.ready !== undefined) review.ready = args.ready; const result = await client.reviewChange(args.changeId, args.revisionId, review); return { content: [ { type: 'text', text: `Successfully reviewed change ${args.changeId}`, }, ], }; } async handleAbandonChange(args) { const client = this.requireClient(); const change = await client.abandonChange(args.changeId, args.message); return { content: [ { type: 'text', text: `Successfully abandoned change ${change._number}: ${change.subject}`, }, ], }; } async handleListProjects(args) { const client = this.requireClient(); const projects = await client.listProjects(args); const projectList = Object.entries(projects); return { content: [ { type: 'text', text: `Found ${projectList.length} projects:\\n\\n` + projectList.map(([name, info]) => `${name}\\n` + (info.description ? ` Description: ${info.description}\\n` : '') + (info.state ? ` State: ${info.state}\\n` : '') + (info.parent ? ` Parent: ${info.parent}` : '')).join('\\n\\n'), }, ], }; } async handleGetProject(args) { const client = this.requireClient(); const project = await client.getProject(args.projectName); return { content: [ { type: 'text', text: `Project: ${project.name}\\n` + (project.description ? `Description: ${project.description}\\n` : '') + (project.state ? `State: ${project.state}\\n` : '') + (project.parent ? `Parent: ${project.parent}\\n` : '') + (project.branches ? `Branches: ${Object.keys(project.branches).join(', ')}` : ''), }, ], }; } async handleQueryAccounts(args) { const client = this.requireClient(); const accounts = await client.queryAccounts(args.query, { limit: args.limit, suggest: args.suggest }); return { content: [ { type: 'text', text: `Found ${accounts.length} accounts:\\n\\n` + accounts.map(account => `${account.name || account.username || 'Unknown'}\\n` + (account.email ? ` Email: ${account.email}\\n` : '') + (account.username ? ` Username: ${account.username}\\n` : '') + ` Account ID: ${account._accountId}`).join('\\n\\n'), }, ], }; } async handleGetAccount(args) { const client = this.requireClient(); const account = await client.getAccount(args.accountId); return { content: [ { type: 'text', text: `Account: ${account.name || account.username || 'Unknown'}\\n` + (account.email ? `Email: ${account.email}\\n` : '') + (account.username ? `Username: ${account.username}\\n` : '') + `Account ID: ${account._accountId}\\n` + (account.status ? `Status: ${account.status}\\n` : '') + `Active: ${!account.inactive}`, }, ], }; } async handleTestConnection() { const client = this.requireClient(); const result = await client.testConnection(); const version = await client.getServerVersion(); return { content: [ { type: 'text', text: result.success ? `Connection successful!\\nServer version: ${version}\\n` + `Authenticated as: ${result.account?.name || result.account?.username || 'Unknown'}` : `Connection failed: ${result.error}`, }, ], }; } // New handler methods async handleGetProjectDescription(args) { const client = this.requireClient(); const description = await client.getProjectDescription(args.projectName); return { content: [ { type: 'text', text: `Project Description for ${args.projectName}:\\n${description || 'No description available'}`, }, ], }; } async handleGetProjectHead(args) { const client = this.requireClient(); const headBranch = await client.getProjectHead(args.projectName); return { content: [ { type: 'text', text: `HEAD branch for ${args.projectName}: ${headBranch}`, }, ], }; } async handleListProjectBranches(args) { const client = this.requireClient(); const branches = await client.listProjectBranches(args.projectName, { limit: args.limit, substring: args.substring, regex: args.regex }); return { content: [ { type: 'text', text: `Found ${branches.length} branches in ${args.projectName}:\\n\\n` + branches.map(branch => `${branch.ref}\\n` + ` Revision: ${branch.revision.substring(0, 8)}...\\n` + ` Can Delete: ${branch.canDelete ? 'Yes' : 'No'}`).join('\\n\\n'), }, ], }; } async handleListProjectTags(args) { const client = this.requireClient(); const tags = await client.listProjectTags(args.projectName, { limit: args.limit, substring: args.substring, regex: args.regex }); return { content: [ { type: 'text', text: `Found ${tags.length} tags in ${args.projectName}:\\n\\n` + tags.map(tag => `${tag.ref}\\n` + ` Revision: ${tag.revision.substring(0, 8)}...\\n` + (tag.message ? ` Message: ${tag.message}\\n` : '') + (tag.tagger ? ` Tagger: ${tag.tagger.name}\\n` : '') + (tag.created ? ` Created: ${tag.created}` : '')).join('\\n\\n'), }, ], }; } async handleGetChangeComments(args) { const client = this.requireClient(); const comments = await client.getChangeComments(args.changeId); const fileCount = Object.keys(comments).length; const totalComments = Object.values(comments).reduce((sum, fileComments) => sum + fileComments.length, 0); return { content: [ { type: 'text', text: `Found ${totalComments} comments across ${fileCount} files in change ${args.changeId}:\\n\\n` + Object.entries(comments).map(([filePath, fileComments]) => `${filePath} (${fileComments.length} comments):\\n` + fileComments.map(comment => ` Line ${comment.line || 'file'}: ${comment.message}\\n` + ` Author: ${comment.author?.name || 'Unknown'}\\n` + ` Updated: ${comment.updated}`).join('\\n')).join('\\n\\n'), }, ], }; } async handleReplyToInlineComment(args) { const client = this.requireClient(); const draft = await client.replyToInlineComment(args.changeId, args.revisionId, { message: args.message, path: args.path, line: args.line, inReplyTo: args.inReplyTo }); return { content: [ { type: 'text', text: `Successfully created reply comment:\\n` + `ID: ${draft.id}\\n` + `Path: ${draft.path || 'N/A'}\\n` + `Line: ${draft.line || 'N/A'}\\n` + `Message: ${draft.message}`, }, ], }; } async handleGetCommit(args) { const client = this.requireClient(); const commit = await client.getCommit(args.projectName, args.commitId); return { content: [ { type: 'text', text: `Commit ${commit.commit?.substring(0, 8)}... in ${args.projectName}:\\n\\n` + `Subject: ${commit.subject}\\n` + `Author: ${commit.author.name} <${commit.author.email}>\\n` + `Committer: ${commit.committer.name} <${commit.committer.email}>\\n` + `Date: ${commit.author.date}\\n` + `Parents: ${commit.parents.map(p => p.commit.substring(0, 8)).join(', ')}\\n\\n` + `Message:\\n${commit.message}`, }, ], }; } async handleGetIncludedIn(args) { const client = this.requireClient(); const includedIn = await client.getIncludedIn(args.changeId, args.revisionId); return { content: [ { type: 'text', text: `Commit is included in:\\n\\n` + (includedIn.branches?.length ? `Branches (${includedIn.branches.length}):\\n${includedIn.branches.join('\\n')}\\n\\n` : 'No branches\\n\\n') + (includedIn.tags?.length ? `Tags (${includedIn.tags.length}):\\n${includedIn.tags.join('\\n')}\\n\\n` : 'No tags\\n\\n') + (includedIn.external && Object.keys(includedIn.external).length ? `External:\\n${Object.entries(includedIn.external).map(([key, values]) => `${key}: ${values.join(', ')}`).join('\\n')}` : 'No external references'), }, ], }; } async handleListFiles(args) { const client = this.requireClient(); const files = await client.listFiles(args.changeId, args.revisionId, { base: args.base, parent: args.parent }); const fileList = Object.entries(files); return { content: [ { type: 'text', text: `Found ${fileList.length} files in change ${args.changeId}:\\n\\n` + fileList.map(([path, fileInfo]) => `${path}\\n` + (fileInfo.status ? ` Status: ${fileInfo.status}\\n` : '') + (fileInfo.linesInserted ? ` Lines Inserted: ${fileInfo.linesInserted}\\n` : '') + (fileInfo.linesDeleted ? ` Lines Deleted: ${fileInfo.linesDeleted}\\n` : '') + (fileInfo.sizeDelta ? ` Size Delta: ${fileInfo.sizeDelta}\\n` : '') + ` Binary: ${fileInfo.binary ? 'Yes' : 'No'}`).join('\\n\\n'), }, ], }; } async handleGetContent(args) { const client = this.requireClient(); const content = await client.getFileContent(args.changeId, args.revisionId, args.filePath, { base: args.base, parent: args.parent }); return { content: [ { type: 'text', text: `File content for ${args.filePath} in change ${args.changeId}:\\n\\n${content}`, }, ], }; } async handleListAccessRights(args) { const client = this.requireClient(); const access = await client.listAccessRights(args.projectName); return { content: [ { type: 'text', text: `Access rights for ${args.projectName}:\\n\\n` + `Revision: ${access.revision}\\n` + `Is Owner: ${access.isOwner ? 'Yes' : 'No'}\\n` + `Can Upload: ${access.canUpload ? 'Yes' : 'No'}\\n` + `Can Add: ${access.canAdd ? 'Yes' : 'No'}\\n` + `Can Add Tags: ${access.canAddTags ? 'Yes' : 'No'}\\n\\n` + `Local Permissions:\\n` + Object.entries(access.local).map(([ref, section]) => `${ref}:\\n` + Object.entries(section.permissions).map(([perm, permInfo]) => ` ${perm}: ${permInfo.label || perm}\\n` + Object.entries(permInfo.rules).map(([group, rule]) => ` ${group}: ${rule.action}${rule.min !== undefined ? ` (${rule.min}..${rule.max})` : ''}`).join('\\n')).join('\\n')).join('\\n\\n'), }, ], }; } async handleGetProjectCloneInfo(args) { const client = this.requireClient(); const cloneInfo = await client.getProjectCloneInfo(args.projectName); return { content: [ { type: 'text', text: `Clone information for ${cloneInfo.project}:\\n\\n` + `Description: ${cloneInfo.description || 'No description'}\\n` + `Default branch: ${cloneInfo.defaultBranch}\\n` + `Available branches: ${cloneInfo.branches?.join(', ') || 'None'}\\n\\n` + `Clone URLs:\\n` + (cloneInfo.cloneUrls.http ? ` HTTP: ${cloneInfo.cloneUrls.http}\\n` : '') + (cloneInfo.cloneUrls.ssh ? ` SSH: ${cloneInfo.cloneUrls.ssh}\\n` : '') + (cloneInfo.cloneUrls.anonymous ? ` Anonymous: ${cloneInfo.cloneUrls.anonymous}\\n` : ''), }, ], }; } async handleGetChangeFetchInfo(args) { const client = this.requireClient(); const fetchInfo = await client.getChangeFetchInfo(args.changeId, args.patchSetNumber); return { content: [ { type: 'text', text: `Fetch information for change ${fetchInfo.changeId} (Patch-set ${fetchInfo.patchSetNumber}):\\n\\n` + `Project: ${fetchInfo.project}\\n` + `Branch: ${fetchInfo.branch}\\n` + `Ref: ${fetchInfo.ref}\\n` + `Commit ID: ${fetchInfo.commitId}\\n` + `Patch-set: ${fetchInfo.patchSetNumber}\\n\\n` + `Clone URLs:\\n` + (fetchInfo.cloneUrls.http ? ` HTTP: ${fetchInfo.cloneUrls.http}\\n` : '') + (fetchInfo.cloneUrls.ssh ? ` SSH: ${fetchInfo.cloneUrls.ssh}\\n` : '') + `\\nFetch Commands:\\n` + (fetchInfo.fetchCommands.http ? ` HTTP: ${fetchInfo.fetchCommands.http}\\n` : '') + (fetchInfo.fetchCommands.ssh ? ` SSH: ${fetchInfo.fetchCommands.ssh}\\n` : ''), }, ], }; } /** * MCP 서버 실행 * - 표준 입출력을 통해 클라이언트와 통신 */ async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); } } // === CLI 설정 === // 명령줄 인터페이스 설정 const program = new Command(); program .name('gerrit-mcp') .description('Gerrit 코드 리뷰를 위한 MCP 서버') .version(VERSION) .action(async () => { try { const server = new GerritMCPServer(); await server.run(); } catch (error) { console.error('서버 시작 실패:', error); process.exit(1); } }); // 직접 실행된 경우에만 CLI 파싱 const currentFile = fileURLToPath(import.meta.url); const isMainModule = process.argv[1] === currentFile || process.argv[1].endsWith('/gerrit-mcp') || process.argv[1].includes('gerrit-mcp'); if (isMainModule) { program.parse(); }