@hicho-lge/gerrit-mcp
Version:
Gerrit Code Review system을 위한 Model Context Protocol (MCP) 서버 - 23개 도구로 변경사항, 프로젝트, 계정 관리 기능 제공
1,118 lines (1,117 loc) • 50.7 kB
JavaScript
#!/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();
}