1071 lines
31 KiB
JavaScript
1071 lines
31 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListToolsRequestSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import fetch from "node-fetch";
|
|
import { z } from "zod";
|
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
import { fileURLToPath } from "url";
|
|
import { dirname, resolve } from "path";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import {
|
|
GitLabForkSchema,
|
|
GitLabReferenceSchema,
|
|
GitLabRepositorySchema,
|
|
GitLabIssueSchema,
|
|
GitLabMergeRequestSchema,
|
|
GitLabContentSchema,
|
|
GitLabCreateUpdateFileResponseSchema,
|
|
GitLabSearchResponseSchema,
|
|
GitLabTreeSchema,
|
|
GitLabCommitSchema,
|
|
CreateRepositoryOptionsSchema,
|
|
CreateIssueOptionsSchema,
|
|
CreateMergeRequestOptionsSchema,
|
|
CreateBranchOptionsSchema,
|
|
CreateOrUpdateFileSchema,
|
|
SearchRepositoriesSchema,
|
|
CreateRepositorySchema,
|
|
GetFileContentsSchema,
|
|
PushFilesSchema,
|
|
CreateIssueSchema,
|
|
CreateMergeRequestSchema,
|
|
ForkRepositorySchema,
|
|
CreateBranchSchema,
|
|
GitLabMergeRequestDiffSchema,
|
|
GetMergeRequestSchema,
|
|
GetMergeRequestDiffsSchema,
|
|
UpdateMergeRequestSchema,
|
|
type GitLabFork,
|
|
type GitLabReference,
|
|
type GitLabRepository,
|
|
type GitLabIssue,
|
|
type GitLabMergeRequest,
|
|
type GitLabContent,
|
|
type GitLabCreateUpdateFileResponse,
|
|
type GitLabSearchResponse,
|
|
type GitLabTree,
|
|
type GitLabCommit,
|
|
type FileOperation,
|
|
type GitLabMergeRequestDiff,
|
|
CreateNoteSchema,
|
|
} from "./schemas.js";
|
|
|
|
/**
|
|
* Read version from package.json
|
|
*/
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const packageJsonPath = path.resolve(__dirname, '../package.json');
|
|
let SERVER_VERSION = "unknown";
|
|
try {
|
|
if (fs.existsSync(packageJsonPath)) {
|
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
SERVER_VERSION = packageJson.version || SERVER_VERSION;
|
|
}
|
|
} catch (error) {
|
|
console.error("Warning: Could not read version from package.json:", error);
|
|
}
|
|
|
|
const server = new Server(
|
|
{
|
|
name: "better-gitlab-mcp-server",
|
|
version: SERVER_VERSION,
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {},
|
|
},
|
|
}
|
|
);
|
|
|
|
const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
|
|
/**
|
|
* Smart URL handling for GitLab API
|
|
*
|
|
* @param {string | undefined} url - Input GitLab API URL
|
|
* @returns {string} Normalized GitLab API URL with /api/v4 path
|
|
*/
|
|
function normalizeGitLabApiUrl(url?: string): string {
|
|
if (!url) {
|
|
return "https://gitlab.com/api/v4";
|
|
}
|
|
|
|
// Remove trailing slash if present
|
|
let normalizedUrl = url.endsWith('/') ? url.slice(0, -1) : url;
|
|
|
|
// Check if URL already has /api/v4
|
|
if (!normalizedUrl.endsWith('/api/v4') && !normalizedUrl.endsWith('/api/v4/')) {
|
|
// Append /api/v4 if not already present
|
|
normalizedUrl = `${normalizedUrl}/api/v4`;
|
|
}
|
|
|
|
return normalizedUrl;
|
|
}
|
|
|
|
// Use the normalizeGitLabApiUrl function to handle various URL formats
|
|
const GITLAB_API_URL = normalizeGitLabApiUrl(process.env.GITLAB_API_URL || "");
|
|
|
|
// Add debug logging for API URL construction
|
|
console.log("=== MCP Server Configuration ===");
|
|
console.log(`GITLAB_API_URL = "${GITLAB_API_URL}"`);
|
|
console.log(`Example project API URL = "${GITLAB_API_URL}/projects/123"`);
|
|
console.log(`Example Notes API URL = "${GITLAB_API_URL}/projects/123/issues/1/notes"`);
|
|
console.log("===============================");
|
|
|
|
if (!GITLAB_PERSONAL_ACCESS_TOKEN) {
|
|
console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Common headers for GitLab API requests
|
|
* GitLab API 공통 헤더 (Common headers for GitLab API)
|
|
*/
|
|
const DEFAULT_HEADERS = {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
};
|
|
|
|
/**
|
|
* Utility function for handling GitLab API errors
|
|
* API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
|
|
*
|
|
* @param {import("node-fetch").Response} response - The response from GitLab API
|
|
* @throws {Error} Throws an error with response details if the request failed
|
|
*/
|
|
async function handleGitLabError(
|
|
response: import("node-fetch").Response
|
|
): Promise<void> {
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
throw new Error(
|
|
`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a fork of a GitLab project
|
|
* 프로젝트 포크 생성 (Create a project fork)
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {string} [namespace] - The namespace to fork the project to
|
|
* @returns {Promise<GitLabFork>} The created fork
|
|
*/
|
|
async function forkProject(
|
|
projectId: string,
|
|
namespace?: string
|
|
): Promise<GitLabFork> {
|
|
// API 엔드포인트 URL 생성
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/fork`
|
|
);
|
|
|
|
if (namespace) {
|
|
url.searchParams.append("namespace", namespace);
|
|
}
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: "POST",
|
|
headers: DEFAULT_HEADERS,
|
|
});
|
|
|
|
// 이미 존재하는 프로젝트인 경우 처리
|
|
if (response.status === 409) {
|
|
throw new Error("Project already exists in the target namespace");
|
|
}
|
|
|
|
await handleGitLabError(response);
|
|
const data = await response.json();
|
|
return GitLabForkSchema.parse(data);
|
|
}
|
|
|
|
/**
|
|
* Create a new branch in a GitLab project
|
|
* 새로운 브랜치 생성 (Create a new branch)
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {z.infer<typeof CreateBranchOptionsSchema>} options - Branch creation options
|
|
* @returns {Promise<GitLabReference>} The created branch reference
|
|
*/
|
|
async function createBranch(
|
|
projectId: string,
|
|
options: z.infer<typeof CreateBranchOptionsSchema>
|
|
): Promise<GitLabReference> {
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/repository/branches`
|
|
);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: "POST",
|
|
headers: DEFAULT_HEADERS,
|
|
body: JSON.stringify({
|
|
branch: options.name,
|
|
ref: options.ref,
|
|
}),
|
|
});
|
|
|
|
await handleGitLabError(response);
|
|
return GitLabReferenceSchema.parse(await response.json());
|
|
}
|
|
|
|
/**
|
|
* Get the default branch for a GitLab project
|
|
* 프로젝트의 기본 브랜치 조회 (Get the default branch of a project)
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @returns {Promise<string>} The name of the default branch
|
|
*/
|
|
async function getDefaultBranchRef(projectId: string): Promise<string> {
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}`
|
|
);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
headers: DEFAULT_HEADERS,
|
|
});
|
|
|
|
await handleGitLabError(response);
|
|
const project = GitLabRepositorySchema.parse(await response.json());
|
|
return project.default_branch ?? "main";
|
|
}
|
|
|
|
/**
|
|
* Get the contents of a file from a GitLab project
|
|
* 파일 내용 조회 (Get file contents)
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {string} filePath - The path of the file to get
|
|
* @param {string} [ref] - The name of the branch, tag or commit
|
|
* @returns {Promise<GitLabContent>} The file content
|
|
*/
|
|
async function getFileContents(
|
|
projectId: string,
|
|
filePath: string,
|
|
ref?: string
|
|
): Promise<GitLabContent> {
|
|
const encodedPath = encodeURIComponent(filePath);
|
|
|
|
// ref가 없는 경우 default branch를 가져옴
|
|
if (!ref) {
|
|
ref = await getDefaultBranchRef(projectId);
|
|
}
|
|
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/repository/files/${encodedPath}`
|
|
);
|
|
|
|
url.searchParams.append("ref", ref);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
headers: DEFAULT_HEADERS,
|
|
});
|
|
|
|
// 파일을 찾을 수 없는 경우 처리
|
|
if (response.status === 404) {
|
|
throw new Error(`File not found: ${filePath}`);
|
|
}
|
|
|
|
await handleGitLabError(response);
|
|
const data = await response.json();
|
|
const parsedData = GitLabContentSchema.parse(data);
|
|
|
|
// Base64로 인코딩된 파일 내용을 UTF-8로 디코딩
|
|
if (!Array.isArray(parsedData) && parsedData.content) {
|
|
parsedData.content = Buffer.from(parsedData.content, "base64").toString(
|
|
"utf8"
|
|
);
|
|
parsedData.encoding = "utf8";
|
|
}
|
|
|
|
return parsedData;
|
|
}
|
|
|
|
/**
|
|
* Create a new issue in a GitLab project
|
|
* 이슈 생성 (Create an issue)
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {z.infer<typeof CreateIssueOptionsSchema>} options - Issue creation options
|
|
* @returns {Promise<GitLabIssue>} The created issue
|
|
*/
|
|
async function createIssue(
|
|
projectId: string,
|
|
options: z.infer<typeof CreateIssueOptionsSchema>
|
|
): Promise<GitLabIssue> {
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues`
|
|
);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: "POST",
|
|
headers: DEFAULT_HEADERS,
|
|
body: JSON.stringify({
|
|
title: options.title,
|
|
description: options.description,
|
|
assignee_ids: options.assignee_ids,
|
|
milestone_id: options.milestone_id,
|
|
labels: options.labels?.join(","),
|
|
}),
|
|
});
|
|
|
|
// 잘못된 요청 처리
|
|
if (response.status === 400) {
|
|
const errorBody = await response.text();
|
|
throw new Error(`Invalid request: ${errorBody}`);
|
|
}
|
|
|
|
await handleGitLabError(response);
|
|
const data = await response.json();
|
|
return GitLabIssueSchema.parse(data);
|
|
}
|
|
|
|
/**
|
|
* Create a new merge request in a GitLab project
|
|
* 병합 요청 생성
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {z.infer<typeof CreateMergeRequestOptionsSchema>} options - Merge request creation options
|
|
* @returns {Promise<GitLabMergeRequest>} The created merge request
|
|
*/
|
|
async function createMergeRequest(
|
|
projectId: string,
|
|
options: z.infer<typeof CreateMergeRequestOptionsSchema>
|
|
): Promise<GitLabMergeRequest> {
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/merge_requests`
|
|
);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
},
|
|
body: JSON.stringify({
|
|
title: options.title,
|
|
description: options.description,
|
|
source_branch: options.source_branch,
|
|
target_branch: options.target_branch,
|
|
allow_collaboration: options.allow_collaboration,
|
|
draft: options.draft,
|
|
}),
|
|
});
|
|
|
|
if (response.status === 400) {
|
|
const errorBody = await response.text();
|
|
throw new Error(`Invalid request: ${errorBody}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
throw new Error(
|
|
`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return GitLabMergeRequestSchema.parse(data);
|
|
}
|
|
|
|
/**
|
|
* Create or update a file in a GitLab project
|
|
* 파일 생성 또는 업데이트
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {string} filePath - The path of the file to create or update
|
|
* @param {string} content - The content of the file
|
|
* @param {string} commitMessage - The commit message
|
|
* @param {string} branch - The branch name
|
|
* @param {string} [previousPath] - The previous path of the file in case of rename
|
|
* @returns {Promise<GitLabCreateUpdateFileResponse>} The file update response
|
|
*/
|
|
async function createOrUpdateFile(
|
|
projectId: string,
|
|
filePath: string,
|
|
content: string,
|
|
commitMessage: string,
|
|
branch: string,
|
|
previousPath?: string
|
|
): Promise<GitLabCreateUpdateFileResponse> {
|
|
const encodedPath = encodeURIComponent(filePath);
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/repository/files/${encodedPath}`
|
|
);
|
|
|
|
const body = {
|
|
branch,
|
|
content,
|
|
commit_message: commitMessage,
|
|
encoding: "text",
|
|
...(previousPath ? { previous_path: previousPath } : {}),
|
|
};
|
|
|
|
// Check if file exists
|
|
let method = "POST";
|
|
try {
|
|
await getFileContents(projectId, filePath, branch);
|
|
method = "PUT";
|
|
} catch (error) {
|
|
if (!(error instanceof Error && error.message.includes("File not found"))) {
|
|
throw error;
|
|
}
|
|
// File doesn't exist, use POST
|
|
}
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method,
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
throw new Error(
|
|
`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return GitLabCreateUpdateFileResponseSchema.parse(data);
|
|
}
|
|
|
|
/**
|
|
* Create a tree structure in a GitLab project repository
|
|
* 저장소에 트리 구조 생성
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {FileOperation[]} files - Array of file operations
|
|
* @param {string} [ref] - The name of the branch, tag or commit
|
|
* @returns {Promise<GitLabTree>} The created tree
|
|
*/
|
|
async function createTree(
|
|
projectId: string,
|
|
files: FileOperation[],
|
|
ref?: string
|
|
): Promise<GitLabTree> {
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/repository/tree`
|
|
);
|
|
|
|
if (ref) {
|
|
url.searchParams.append("ref", ref);
|
|
}
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
},
|
|
body: JSON.stringify({
|
|
files: files.map((file) => ({
|
|
file_path: file.path,
|
|
content: file.content,
|
|
encoding: "text",
|
|
})),
|
|
}),
|
|
});
|
|
|
|
if (response.status === 400) {
|
|
const errorBody = await response.text();
|
|
throw new Error(`Invalid request: ${errorBody}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
throw new Error(
|
|
`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return GitLabTreeSchema.parse(data);
|
|
}
|
|
|
|
/**
|
|
* Create a commit in a GitLab project repository
|
|
* 저장소에 커밋 생성
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {string} message - The commit message
|
|
* @param {string} branch - The branch name
|
|
* @param {FileOperation[]} actions - Array of file operations for the commit
|
|
* @returns {Promise<GitLabCommit>} The created commit
|
|
*/
|
|
async function createCommit(
|
|
projectId: string,
|
|
message: string,
|
|
branch: string,
|
|
actions: FileOperation[]
|
|
): Promise<GitLabCommit> {
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/repository/commits`
|
|
);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
},
|
|
body: JSON.stringify({
|
|
branch,
|
|
commit_message: message,
|
|
actions: actions.map((action) => ({
|
|
action: "create",
|
|
file_path: action.path,
|
|
content: action.content,
|
|
encoding: "text",
|
|
})),
|
|
}),
|
|
});
|
|
|
|
if (response.status === 400) {
|
|
const errorBody = await response.text();
|
|
throw new Error(`Invalid request: ${errorBody}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
throw new Error(
|
|
`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return GitLabCommitSchema.parse(data);
|
|
}
|
|
|
|
/**
|
|
* Search for GitLab projects
|
|
* 프로젝트 검색
|
|
*
|
|
* @param {string} query - The search query
|
|
* @param {number} [page=1] - The page number
|
|
* @param {number} [perPage=20] - Number of items per page
|
|
* @returns {Promise<GitLabSearchResponse>} The search results
|
|
*/
|
|
async function searchProjects(
|
|
query: string,
|
|
page: number = 1,
|
|
perPage: number = 20
|
|
): Promise<GitLabSearchResponse> {
|
|
const url = new URL(`${GITLAB_API_URL}/projects`);
|
|
url.searchParams.append("search", query);
|
|
url.searchParams.append("page", page.toString());
|
|
url.searchParams.append("per_page", perPage.toString());
|
|
url.searchParams.append("order_by", "id");
|
|
url.searchParams.append("sort", "desc");
|
|
|
|
const response = await fetch(url.toString(), {
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
throw new Error(
|
|
`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`
|
|
);
|
|
}
|
|
|
|
const projects = (await response.json()) as GitLabRepository[];
|
|
const totalCount = response.headers.get("x-total");
|
|
const totalPages = response.headers.get("x-total-pages");
|
|
|
|
// GitLab API doesn't return these headers for results > 10,000
|
|
const count = totalCount ? parseInt(totalCount) : projects.length;
|
|
|
|
return GitLabSearchResponseSchema.parse({
|
|
count,
|
|
total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage),
|
|
current_page: page,
|
|
items: projects,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a new GitLab repository
|
|
* 새 저장소 생성
|
|
*
|
|
* @param {z.infer<typeof CreateRepositoryOptionsSchema>} options - Repository creation options
|
|
* @returns {Promise<GitLabRepository>} The created repository
|
|
*/
|
|
async function createRepository(
|
|
options: z.infer<typeof CreateRepositoryOptionsSchema>
|
|
): Promise<GitLabRepository> {
|
|
const response = await fetch(`${GITLAB_API_URL}/projects`, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
|
|
},
|
|
body: JSON.stringify({
|
|
name: options.name,
|
|
description: options.description,
|
|
visibility: options.visibility,
|
|
initialize_with_readme: options.initialize_with_readme,
|
|
default_branch: "main",
|
|
path: options.name.toLowerCase().replace(/\s+/g, "-"),
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
throw new Error(
|
|
`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return GitLabRepositorySchema.parse(data);
|
|
}
|
|
|
|
/**
|
|
* Get merge request details
|
|
* MR 조회 함수 (Function to retrieve merge request)
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {number} mergeRequestIid - The internal ID of the merge request
|
|
* @returns {Promise<GitLabMergeRequest>} The merge request details
|
|
*/
|
|
async function getMergeRequest(
|
|
projectId: string,
|
|
mergeRequestIid: number
|
|
): Promise<GitLabMergeRequest> {
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/merge_requests/${mergeRequestIid}`
|
|
);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
headers: DEFAULT_HEADERS,
|
|
});
|
|
|
|
await handleGitLabError(response);
|
|
return GitLabMergeRequestSchema.parse(await response.json());
|
|
}
|
|
|
|
/**
|
|
* Get merge request changes/diffs
|
|
* MR 변경사항 조회 함수 (Function to retrieve merge request changes)
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {number} mergeRequestIid - The internal ID of the merge request
|
|
* @param {string} [view] - The view type for the diff (inline or parallel)
|
|
* @returns {Promise<GitLabMergeRequestDiff[]>} The merge request diffs
|
|
*/
|
|
async function getMergeRequestDiffs(
|
|
projectId: string,
|
|
mergeRequestIid: number,
|
|
view?: "inline" | "parallel"
|
|
): Promise<GitLabMergeRequestDiff[]> {
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/merge_requests/${mergeRequestIid}/changes`
|
|
);
|
|
|
|
if (view) {
|
|
url.searchParams.append("view", view);
|
|
}
|
|
|
|
const response = await fetch(url.toString(), {
|
|
headers: DEFAULT_HEADERS,
|
|
});
|
|
|
|
await handleGitLabError(response);
|
|
const data = (await response.json()) as { changes: unknown };
|
|
return z.array(GitLabMergeRequestDiffSchema).parse(data.changes);
|
|
}
|
|
|
|
/**
|
|
* Update a merge request
|
|
* MR 업데이트 함수 (Function to update merge request)
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {number} mergeRequestIid - The internal ID of the merge request
|
|
* @param {Object} options - The update options
|
|
* @returns {Promise<GitLabMergeRequest>} The updated merge request
|
|
*/
|
|
async function updateMergeRequest(
|
|
projectId: string,
|
|
mergeRequestIid: number,
|
|
options: Omit<
|
|
z.infer<typeof UpdateMergeRequestSchema>,
|
|
"project_id" | "merge_request_iid"
|
|
>
|
|
): Promise<GitLabMergeRequest> {
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/merge_requests/${mergeRequestIid}`
|
|
);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: "PUT",
|
|
headers: DEFAULT_HEADERS,
|
|
body: JSON.stringify(options),
|
|
});
|
|
|
|
await handleGitLabError(response);
|
|
return GitLabMergeRequestSchema.parse(await response.json());
|
|
}
|
|
|
|
/**
|
|
* Create a new note (comment) on an issue or merge request
|
|
* 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수
|
|
* (New function: createNote - Function to add a note (comment) to an issue or merge request)
|
|
*
|
|
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
* @param {"issue" | "merge_request"} noteableType - The type of the item to add a note to (issue or merge_request)
|
|
* @param {number} noteableIid - The internal ID of the issue or merge request
|
|
* @param {string} body - The content of the note
|
|
* @returns {Promise<any>} The created note
|
|
*/
|
|
async function createNote(
|
|
projectId: string,
|
|
noteableType: "issue" | "merge_request", // 'issue' 또는 'merge_request' 타입 명시
|
|
noteableIid: number,
|
|
body: string
|
|
): Promise<any> {
|
|
// ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능
|
|
const url = new URL(
|
|
`${GITLAB_API_URL}/projects/${encodeURIComponent(
|
|
projectId
|
|
)}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
|
|
);
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: "POST",
|
|
headers: DEFAULT_HEADERS,
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(
|
|
`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`
|
|
);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
return {
|
|
tools: [
|
|
{
|
|
name: "create_or_update_file",
|
|
description: "Create or update a single file in a GitLab project",
|
|
inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema),
|
|
},
|
|
{
|
|
name: "search_repositories",
|
|
description: "Search for GitLab projects",
|
|
inputSchema: zodToJsonSchema(SearchRepositoriesSchema),
|
|
},
|
|
{
|
|
name: "create_repository",
|
|
description: "Create a new GitLab project",
|
|
inputSchema: zodToJsonSchema(CreateRepositorySchema),
|
|
},
|
|
{
|
|
name: "get_file_contents",
|
|
description:
|
|
"Get the contents of a file or directory from a GitLab project",
|
|
inputSchema: zodToJsonSchema(GetFileContentsSchema),
|
|
},
|
|
{
|
|
name: "push_files",
|
|
description:
|
|
"Push multiple files to a GitLab project in a single commit",
|
|
inputSchema: zodToJsonSchema(PushFilesSchema),
|
|
},
|
|
{
|
|
name: "create_issue",
|
|
description: "Create a new issue in a GitLab project",
|
|
inputSchema: zodToJsonSchema(CreateIssueSchema),
|
|
},
|
|
{
|
|
name: "create_merge_request",
|
|
description: "Create a new merge request in a GitLab project",
|
|
inputSchema: zodToJsonSchema(CreateMergeRequestSchema),
|
|
},
|
|
{
|
|
name: "fork_repository",
|
|
description:
|
|
"Fork a GitLab project to your account or specified namespace",
|
|
inputSchema: zodToJsonSchema(ForkRepositorySchema),
|
|
},
|
|
{
|
|
name: "create_branch",
|
|
description: "Create a new branch in a GitLab project",
|
|
inputSchema: zodToJsonSchema(CreateBranchSchema),
|
|
},
|
|
{
|
|
name: "get_merge_request",
|
|
description: "Get details of a merge request",
|
|
inputSchema: zodToJsonSchema(GetMergeRequestSchema),
|
|
},
|
|
{
|
|
name: "get_merge_request_diffs",
|
|
description: "Get the changes/diffs of a merge request",
|
|
inputSchema: zodToJsonSchema(GetMergeRequestDiffsSchema),
|
|
},
|
|
{
|
|
name: "update_merge_request",
|
|
description: "Update a merge request",
|
|
inputSchema: zodToJsonSchema(UpdateMergeRequestSchema),
|
|
},
|
|
{
|
|
name: "create_note",
|
|
description: "Create a new note (comment) to an issue or merge request",
|
|
inputSchema: zodToJsonSchema(CreateNoteSchema),
|
|
},
|
|
],
|
|
};
|
|
});
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
try {
|
|
if (!request.params.arguments) {
|
|
throw new Error("Arguments are required");
|
|
}
|
|
|
|
switch (request.params.name) {
|
|
case "fork_repository": {
|
|
const args = ForkRepositorySchema.parse(request.params.arguments);
|
|
const fork = await forkProject(args.project_id, args.namespace);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(fork, null, 2) }],
|
|
};
|
|
}
|
|
|
|
case "create_branch": {
|
|
const args = CreateBranchSchema.parse(request.params.arguments);
|
|
let ref = args.ref;
|
|
if (!ref) {
|
|
ref = await getDefaultBranchRef(args.project_id);
|
|
}
|
|
|
|
const branch = await createBranch(args.project_id, {
|
|
name: args.branch,
|
|
ref,
|
|
});
|
|
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(branch, null, 2) }],
|
|
};
|
|
}
|
|
|
|
case "search_repositories": {
|
|
const args = SearchRepositoriesSchema.parse(request.params.arguments);
|
|
const results = await searchProjects(
|
|
args.search,
|
|
args.page,
|
|
args.per_page
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
};
|
|
}
|
|
|
|
case "create_repository": {
|
|
const args = CreateRepositorySchema.parse(request.params.arguments);
|
|
const repository = await createRepository(args);
|
|
return {
|
|
content: [
|
|
{ type: "text", text: JSON.stringify(repository, null, 2) },
|
|
],
|
|
};
|
|
}
|
|
|
|
case "get_file_contents": {
|
|
const args = GetFileContentsSchema.parse(request.params.arguments);
|
|
const contents = await getFileContents(
|
|
args.project_id,
|
|
args.file_path,
|
|
args.ref
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(contents, null, 2) }],
|
|
};
|
|
}
|
|
|
|
case "create_or_update_file": {
|
|
const args = CreateOrUpdateFileSchema.parse(request.params.arguments);
|
|
const result = await createOrUpdateFile(
|
|
args.project_id,
|
|
args.file_path,
|
|
args.content,
|
|
args.commit_message,
|
|
args.branch,
|
|
args.previous_path
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
};
|
|
}
|
|
|
|
case "push_files": {
|
|
const args = PushFilesSchema.parse(request.params.arguments);
|
|
const result = await createCommit(
|
|
args.project_id,
|
|
args.commit_message,
|
|
args.branch,
|
|
args.files.map((f) => ({ path: f.file_path, content: f.content }))
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
};
|
|
}
|
|
|
|
case "create_issue": {
|
|
const args = CreateIssueSchema.parse(request.params.arguments);
|
|
const { project_id, ...options } = args;
|
|
const issue = await createIssue(project_id, options);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(issue, null, 2) }],
|
|
};
|
|
}
|
|
|
|
case "create_merge_request": {
|
|
const args = CreateMergeRequestSchema.parse(request.params.arguments);
|
|
const { project_id, ...options } = args;
|
|
const mergeRequest = await createMergeRequest(project_id, options);
|
|
return {
|
|
content: [
|
|
{ type: "text", text: JSON.stringify(mergeRequest, null, 2) },
|
|
],
|
|
};
|
|
}
|
|
|
|
case "get_merge_request": {
|
|
const args = GetMergeRequestSchema.parse(request.params.arguments);
|
|
const mergeRequest = await getMergeRequest(
|
|
args.project_id,
|
|
args.merge_request_iid
|
|
);
|
|
return {
|
|
content: [
|
|
{ type: "text", text: JSON.stringify(mergeRequest, null, 2) },
|
|
],
|
|
};
|
|
}
|
|
|
|
case "get_merge_request_diffs": {
|
|
const args = GetMergeRequestDiffsSchema.parse(request.params.arguments);
|
|
const diffs = await getMergeRequestDiffs(
|
|
args.project_id,
|
|
args.merge_request_iid,
|
|
args.view
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }],
|
|
};
|
|
}
|
|
|
|
case "update_merge_request": {
|
|
const args = UpdateMergeRequestSchema.parse(request.params.arguments);
|
|
const { project_id, merge_request_iid, ...options } = args;
|
|
const mergeRequest = await updateMergeRequest(
|
|
project_id,
|
|
merge_request_iid,
|
|
options
|
|
);
|
|
return {
|
|
content: [
|
|
{ type: "text", text: JSON.stringify(mergeRequest, null, 2) },
|
|
],
|
|
};
|
|
}
|
|
|
|
case "create_note": {
|
|
const args = CreateNoteSchema.parse(request.params.arguments);
|
|
const { project_id, noteable_type, noteable_iid, body } = args;
|
|
|
|
const note = await createNote(
|
|
project_id,
|
|
noteable_type,
|
|
noteable_iid,
|
|
body
|
|
);
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
|
|
};
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
throw new Error(
|
|
`Invalid arguments: ${error.errors
|
|
.map((e) => `${e.path.join(".")}: ${e.message}`)
|
|
.join(", ")}`
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Initialize and run the server
|
|
* 서버 초기화 및 실행
|
|
*/
|
|
async function runServer() {
|
|
try {
|
|
console.error("========================");
|
|
console.error(`GitLab MCP Server v${SERVER_VERSION}`);
|
|
console.error(`API URL: ${GITLAB_API_URL}`);
|
|
console.error("========================");
|
|
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
console.error("GitLab MCP Server running on stdio");
|
|
} catch (error) {
|
|
console.error("Error initializing server:", error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
runServer().catch((error) => {
|
|
console.error("Fatal error in main():", error);
|
|
process.exit(1);
|
|
});
|