gitlab mcp

#!/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 } from "path"; import fs from "fs"; import path from "path"; import { GitLabForkSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabIssueSchema, GitLabMergeRequestSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabSearchResponseSchema, GitLabTreeSchema, GitLabCommitSchema, GitLabNamespaceSchema, GitLabNamespaceExistsResponseSchema, GitLabProjectSchema, CreateOrUpdateFileSchema, SearchRepositoriesSchema, CreateRepositorySchema, GetFileContentsSchema, PushFilesSchema, CreateIssueSchema, CreateMergeRequestSchema, ForkRepositorySchema, CreateBranchSchema, GitLabMergeRequestDiffSchema, GetMergeRequestSchema, GetMergeRequestDiffsSchema, UpdateMergeRequestSchema, ListIssuesSchema, GetIssueSchema, UpdateIssueSchema, DeleteIssueSchema, GitLabIssueLinkSchema, GitLabIssueWithLinkDetailsSchema, ListIssueLinksSchema, GetIssueLinkSchema, CreateIssueLinkSchema, DeleteIssueLinkSchema, ListNamespacesSchema, GetNamespaceSchema, VerifyNamespaceSchema, GetProjectSchema, ListProjectsSchema, ListLabelsSchema, GetLabelSchema, CreateLabelSchema, UpdateLabelSchema, DeleteLabelSchema, 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) { 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) { 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, namespace) { // 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, options) { 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) { 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, filePath, ref) { 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, options) { 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); } /** * List issues in a GitLab project * 프로젝트의 이슈 목록 조회 * * @param {string} projectId - The ID or URL-encoded path of the project * @param {Object} options - Options for listing issues * @returns {Promise<GitLabIssue[]>} List of issues */ async function listIssues(projectId, options = {}) { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues`); // Add all query parameters Object.entries(options).forEach(([key, value]) => { if (value !== undefined) { if (key === 'label_name' && Array.isArray(value)) { // Handle array of labels url.searchParams.append(key, value.join(',')); } else { url.searchParams.append(key, value.toString()); } } }); const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); return z.array(GitLabIssueSchema).parse(data); } /** * Get a single issue from a GitLab project * 단일 이슈 조회 * * @param {string} projectId - The ID or URL-encoded path of the project * @param {number} issueIid - The internal ID of the project issue * @returns {Promise<GitLabIssue>} The issue */ async function getIssue(projectId, issueIid) { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}`); const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); return GitLabIssueSchema.parse(data); } /** * Update an issue in a GitLab project * 이슈 업데이트 * * @param {string} projectId - The ID or URL-encoded path of the project * @param {number} issueIid - The internal ID of the project issue * @param {Object} options - Update options for the issue * @returns {Promise<GitLabIssue>} The updated issue */ async function updateIssue(projectId, issueIid, options) { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}`); // Convert labels array to comma-separated string if present const body = { ...options }; if (body.labels && Array.isArray(body.labels)) { body.labels = body.labels.join(','); } const response = await fetch(url.toString(), { method: "PUT", headers: DEFAULT_HEADERS, body: JSON.stringify(body), }); await handleGitLabError(response); const data = await response.json(); return GitLabIssueSchema.parse(data); } /** * Delete an issue from a GitLab project * 이슈 삭제 * * @param {string} projectId - The ID or URL-encoded path of the project * @param {number} issueIid - The internal ID of the project issue * @returns {Promise<void>} */ async function deleteIssue(projectId, issueIid) { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}`); const response = await fetch(url.toString(), { method: "DELETE", headers: DEFAULT_HEADERS, }); await handleGitLabError(response); } /** * List all issue links for a specific issue * 이슈 관계 목록 조회 * * @param {string} projectId - The ID or URL-encoded path of the project * @param {number} issueIid - The internal ID of the project issue * @returns {Promise<GitLabIssueWithLinkDetails[]>} List of issues with link details */ async function listIssueLinks(projectId, issueIid) { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}/links`); const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); return z.array(GitLabIssueWithLinkDetailsSchema).parse(data); } /** * Get a specific issue link * 특정 이슈 관계 조회 * * @param {string} projectId - The ID or URL-encoded path of the project * @param {number} issueIid - The internal ID of the project issue * @param {number} issueLinkId - The ID of the issue link * @returns {Promise<GitLabIssueLink>} The issue link */ async function getIssueLink(projectId, issueIid, issueLinkId) { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}/links/${issueLinkId}`); const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); return GitLabIssueLinkSchema.parse(data); } /** * Create an issue link between two issues * 이슈 관계 생성 * * @param {string} projectId - The ID or URL-encoded path of the project * @param {number} issueIid - The internal ID of the project issue * @param {string} targetProjectId - The ID or URL-encoded path of the target project * @param {number} targetIssueIid - The internal ID of the target project issue * @param {string} linkType - The type of the relation (relates_to, blocks, is_blocked_by) * @returns {Promise<GitLabIssueLink>} The created issue link */ async function createIssueLink(projectId, issueIid, targetProjectId, targetIssueIid, linkType = 'relates_to') { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}/links`); const response = await fetch(url.toString(), { method: "POST", headers: DEFAULT_HEADERS, body: JSON.stringify({ target_project_id: targetProjectId, target_issue_iid: targetIssueIid, link_type: linkType }), }); await handleGitLabError(response); const data = await response.json(); return GitLabIssueLinkSchema.parse(data); } /** * Delete an issue link * 이슈 관계 삭제 * * @param {string} projectId - The ID or URL-encoded path of the project * @param {number} issueIid - The internal ID of the project issue * @param {number} issueLinkId - The ID of the issue link * @returns {Promise<void>} */ async function deleteIssueLink(projectId, issueIid, issueLinkId) { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues/${issueIid}/links/${issueLinkId}`); const response = await fetch(url.toString(), { method: "DELETE", headers: DEFAULT_HEADERS, }); await handleGitLabError(response); } /** * 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, options) { 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, filePath, content, commitMessage, branch, previousPath, last_commit_id, commit_id) { 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 { // Get file contents to check existence and retrieve commit IDs const fileData = await getFileContents(projectId, filePath, branch); method = "PUT"; // If fileData is not an array, it's a file content object with commit IDs if (!Array.isArray(fileData)) { // Use commit IDs from the file data if not provided in parameters if (!commit_id && fileData.commit_id) { body.commit_id = fileData.commit_id; } else if (commit_id) { body.commit_id = commit_id; } if (!last_commit_id && fileData.last_commit_id) { body.last_commit_id = fileData.last_commit_id; } else if (last_commit_id) { body.last_commit_id = last_commit_id; } } } catch (error) { if (!(error instanceof Error && error.message.includes("File not found"))) { throw error; } // File doesn't exist, use POST - no need for commit IDs for new files // But still use any provided as parameters if they exist if (commit_id) { body.commit_id = commit_id; } if (last_commit_id) { body.last_commit_id = last_commit_id; } } 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, files, ref) { 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, message, branch, actions) { 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, page = 1, perPage = 20) { 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()); 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) { 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, mergeRequestIid) { 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, mergeRequestIid, view) { 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()); 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, mergeRequestIid, options) { 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, noteableType, // 'issue' 또는 'merge_request' 타입 명시 noteableIid, body) { // ⚙️ 응답 타입은 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(); } /** * List all namespaces * 사용 가능한 모든 네임스페이스 목록 조회 * * @param {Object} options - Options for listing namespaces * @param {string} [options.search] - Search query to filter namespaces * @param {boolean} [options.owned_only] - Only return namespaces owned by the authenticated user * @param {boolean} [options.top_level_only] - Only return top-level namespaces * @returns {Promise<GitLabNamespace[]>} List of namespaces */ async function listNamespaces(options) { const url = new URL(`${GITLAB_API_URL}/namespaces`); if (options.search) { url.searchParams.append("search", options.search); } if (options.owned_only) { url.searchParams.append("owned_only", "true"); } if (options.top_level_only) { url.searchParams.append("top_level_only", "true"); } const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); return z.array(GitLabNamespaceSchema).parse(data); } /** * Get details on a namespace * 네임스페이스 상세 정보 조회 * * @param {string} id - The ID or URL-encoded path of the namespace * @returns {Promise<GitLabNamespace>} The namespace details */ async function getNamespace(id) { const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`); const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); return GitLabNamespaceSchema.parse(data); } /** * Verify if a namespace exists * 네임스페이스 존재 여부 확인 * * @param {string} namespacePath - The path of the namespace to check * @param {number} [parentId] - The ID of the parent namespace * @returns {Promise<GitLabNamespaceExistsResponse>} The verification result */ async function verifyNamespaceExistence(namespacePath, parentId) { const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(namespacePath)}/exists`); if (parentId) { url.searchParams.append("parent_id", parentId.toString()); } const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); return GitLabNamespaceExistsResponseSchema.parse(data); } /** * Get a single project * 단일 프로젝트 조회 * * @param {string} projectId - The ID or URL-encoded path of the project * @param {Object} options - Options for getting project details * @param {boolean} [options.license] - Include project license data * @param {boolean} [options.statistics] - Include project statistics * @param {boolean} [options.with_custom_attributes] - Include custom attributes in response * @returns {Promise<GitLabProject>} Project details */ async function getProject(projectId, options = {}) { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}`); if (options.license) { url.searchParams.append("license", "true"); } if (options.statistics) { url.searchParams.append("statistics", "true"); } if (options.with_custom_attributes) { url.searchParams.append("with_custom_attributes", "true"); } const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); return GitLabRepositorySchema.parse(data); } /** * List projects * 프로젝트 목록 조회 * * @param {Object} options - Options for listing projects * @returns {Promise<GitLabProject[]>} List of projects */ async function listProjects(options = {}) { // Construct the query parameters const params = new URLSearchParams(); for (const [key, value] of Object.entries(options)) { if (value !== undefined && value !== null) { if (typeof value === "boolean") { params.append(key, value ? "true" : "false"); } else { params.append(key, String(value)); } } } // Make the API request const response = await fetch(`${GITLAB_API_URL}/projects?${params.toString()}`, { method: "GET", headers: DEFAULT_HEADERS, }); // Handle errors await handleGitLabError(response); // Parse and return the data const data = await response.json(); return z.array(GitLabProjectSchema).parse(data); } /** * List labels for a project * * @param projectId The ID or URL-encoded path of the project * @param options Optional parameters for listing labels * @returns Array of GitLab labels */ async function listLabels(projectId, options = {}) { // Construct the URL with project path const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels`); // Add query parameters Object.entries(options).forEach(([key, value]) => { if (value !== undefined) { if (typeof value === "boolean") { url.searchParams.append(key, value ? "true" : "false"); } else { url.searchParams.append(key, String(value)); } } }); // Make the API request const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); // Handle errors await handleGitLabError(response); // Parse and return the data const data = await response.json(); return data; } /** * Get a single label from a project * * @param projectId The ID or URL-encoded path of the project * @param labelId The ID or name of the label * @param includeAncestorGroups Whether to include ancestor groups * @returns GitLab label */ async function getLabel(projectId, labelId, includeAncestorGroups) { const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels/${encodeURIComponent(String(labelId))}`); // Add query parameters if (includeAncestorGroups !== undefined) { url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false"); } // Make the API request const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); // Handle errors await handleGitLabError(response); // Parse and return the data const data = await response.json(); return data; } /** * Create a new label in a project * * @param projectId The ID or URL-encoded path of the project * @param options Options for creating the label * @returns Created GitLab label */ async function createLabel(projectId, options) { // Make the API request const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels`, { method: "POST", headers: DEFAULT_HEADERS, body: JSON.stringify(options), }); // Handle errors await handleGitLabError(response); // Parse and return the data const data = await response.json(); return data; } /** * Update an existing label in a project * * @param projectId The ID or URL-encoded path of the project * @param labelId The ID or name of the label to update * @param options Options for updating the label * @returns Updated GitLab label */ async function updateLabel(projectId, labelId, options) { // Make the API request const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels/${encodeURIComponent(String(labelId))}`, { method: "PUT", headers: DEFAULT_HEADERS, body: JSON.stringify(options), }); // Handle errors await handleGitLabError(response); // Parse and return the data const data = await response.json(); return data; } /** * Delete a label from a project * * @param projectId The ID or URL-encoded path of the project * @param labelId The ID or name of the label to delete */ async function deleteLabel(projectId, labelId) { // Make the API request const response = await fetch(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/labels/${encodeURIComponent(String(labelId))}`, { method: "DELETE", headers: DEFAULT_HEADERS, }); // Handle errors await handleGitLabError(response); } 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), }, { name: "list_issues", description: "List issues in a GitLab project with filtering options", inputSchema: zodToJsonSchema(ListIssuesSchema), }, { name: "get_issue", description: "Get details of a specific issue in a GitLab project", inputSchema: zodToJsonSchema(GetIssueSchema), }, { name: "update_issue", description: "Update an issue in a GitLab project", inputSchema: zodToJsonSchema(UpdateIssueSchema), }, { name: "delete_issue", description: "Delete an issue from a GitLab project", inputSchema: zodToJsonSchema(DeleteIssueSchema), }, { name: "list_issue_links", description: "List all issue links for a specific issue", inputSchema: zodToJsonSchema(ListIssueLinksSchema), }, { name: "get_issue_link", description: "Get a specific issue link", inputSchema: zodToJsonSchema(GetIssueLinkSchema), }, { name: "create_issue_link", description: "Create an issue link between two issues", inputSchema: zodToJsonSchema(CreateIssueLinkSchema), }, { name: "delete_issue_link", description: "Delete an issue link", inputSchema: zodToJsonSchema(DeleteIssueLinkSchema), }, { name: "list_namespaces", description: "List all namespaces available to the current user", inputSchema: zodToJsonSchema(ListNamespacesSchema), }, { name: "get_namespace", description: "Get details of a namespace by ID or path", inputSchema: zodToJsonSchema(GetNamespaceSchema), }, { name: "verify_namespace", description: "Verify if a namespace path exists", inputSchema: zodToJsonSchema(VerifyNamespaceSchema), }, { name: "get_project", description: "Get details of a specific project", inputSchema: zodToJsonSchema(GetProjectSchema), }, { name: "list_projects", description: "List projects accessible by the current user", inputSchema: zodToJsonSchema(ListProjectsSchema), }, { name: "list_labels", description: "List labels for a project", inputSchema: zodToJsonSchema(ListLabelsSchema), }, { name: "get_label", description: "Get a single label from a project", inputSchema: zodToJsonSchema(GetLabelSchema), }, { name: "create_label", description: "Create a new label in a project", inputSchema: zodToJsonSchema(CreateLabelSchema), }, { name: "update_label", description: "Update an existing label in a project", inputSchema: zodToJsonSchema(UpdateLabelSchema), }, { name: "delete_label", description: "Delete a label from a project", inputSchema: zodToJsonSchema(DeleteLabelSchema), }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (!request.params.arguments) { throw new Error("Arguments are required"); } switch (request.params.name) { case "fork_repository": { const forkArgs = ForkRepositorySchema.parse(request.params.arguments); try { const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace); return { content: [{ type: "text", text: JSON.stringify(forkedProject, null, 2) }], }; } catch (forkError) { console.error("Error forking repository:", forkError); let forkErrorMessage = "Failed to fork repository"; if (forkError instanceof Error) { forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`; } return { content: [{ type: "text", text: JSON.stringify({ error: forkErrorMessage }, 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, args.last_commit_id, args.commit_id); 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 "list_namespaces": { const args = ListNamespacesSchema.parse(request.params.arguments); const url = new URL(`${GITLAB_API_URL}/namespaces`); if (args.search) { url.searchParams.append("search", args.search); } if (args.page) { url.searchParams.append("page", args.page.toString()); } if (args.per_page) { url.searchParams.append("per_page", args.per_page.toString()); } if (args.owned) { url.searchParams.append("owned", args.owned.toString()); } const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); const namespaces = z.array(GitLabNamespaceSchema).parse(data); return { content: [{ type: "text", text: JSON.stringify(namespaces, null, 2) }], }; } case "get_namespace": { const args = GetNamespaceSchema.parse(request.params.arguments); const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.namespace_id)}`); const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); const namespace = GitLabNamespaceSchema.parse(data); return { content: [{ type: "text", text: JSON.stringify(namespace, null, 2) }], }; } case "verify_namespace": { const args = VerifyNamespaceSchema.parse(request.params.arguments); const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.path)}/exists`); const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); const namespaceExists = GitLabNamespaceExistsResponseSchema.parse(data); return { content: [{ type: "text", text: JSON.stringify(namespaceExists, null, 2) }], }; } case "get_project": { const args = GetProjectSchema.parse(request.params.arguments); const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(args.project_id)}`); const response = await fetch(url.toString(), { headers: DEFAULT_HEADERS, }); await handleGitLabError(response); const data = await response.json(); const project = GitLabProjectSchema.parse(data); return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }], }; } case "list_projects": { const args = ListProjectsSchema.parse(request.params.arguments); const projects = await listProjects(args); return { content: [{ type: "text", text: JSON.stringify(projects, 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) }], }; } case "list_issues": { const args = ListIssuesSchema.parse(request.params.arguments); const { project_id, ...options } = args; const issues = await listIssues(project_id, options); return { content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], }; } case "get_issue": { const args = GetIssueSchema.parse(request.params.arguments); const issue = await getIssue(args.project_id, args.issue_iid); return { content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], }; } case "update_issue": { const args = UpdateIssueSchema.parse(request.params.arguments); const { project_id, issue_iid, ...options } = args; const issue = await updateIssue(project_id, issue_iid, options); return { content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], }; } case "delete_issue": { const args = DeleteIssueSchema.parse(request.params.arguments); await deleteIssue(args.project_id, args.issue_iid); return { content: [{ type: "text", text: JSON.stringify({ status: "success", message: "Issue deleted successfully" }, null, 2) }], }; } case "list_issue_links": { const args = ListIssueLinksSchema.parse(request.params.arguments); const links = await listIssueLinks(args.project_id, args.issue_iid); return { content: [{ type: "text", text: JSON.stringify(links, null, 2) }], }; } case "get_issue_link": { const args = GetIssueLinkSchema.parse(request.params.arguments); const link = await getIssueLink(args.project_id, args.issue_iid, args.issue_link_id); return { content: [{ type: "text", text: JSON.stringify(link, null, 2) }], }; } case "create_issue_link": { const args = CreateIssueLinkSchema.parse(request.params.arguments); const link = await createIssueLink(args.project_id, args.issue_iid, args.target_project_id, args.target_issue_iid, args.link_type); return { content: [{ type: "text", text: JSON.stringify(link, null, 2) }], }; } case "delete_issue_link": { const args = DeleteIssueLinkSchema.parse(request.params.arguments); await deleteIssueLink(args.project_id, args.issue_iid, args.issue_link_id); return { content: [{ type: "text", text: JSON.stringify({ status: "success", message: "Issue link deleted successfully" }, null, 2) }], }; } case "list_labels": { const args = ListLabelsSchema.parse(request.params.arguments); const labels = await listLabels(args.project_id, args); return { content: [{ type: "text", text: JSON.stringify(labels, null, 2) }], }; } case "get_label": { const args = GetLabelSchema.parse(request.params.arguments); const label = await getLabel(args.project_id, args.label_id, args.include_ancestor_groups); return { content: [{ type: "text", text: JSON.stringify(label, null, 2) }], }; } case "create_label": { const args = CreateLabelSchema.parse(request.params.arguments); const label = await createLabel(args.project_id, args); return { content: [{ type: "text", text: JSON.stringify(label, null, 2) }], }; } case "update_label": { const args = UpdateLabelSchema.parse(request.params.arguments); const { project_id, label_id, ...options } = args; const label = await updateLabel(project_id, label_id, options); return { content: [{ type: "text", text: JSON.stringify(label, null, 2) }], }; } case "delete_label": { const args = DeleteLabelSchema.parse(request.params.arguments); await deleteLabel(args.project_id, args.label_id); return { content: [{ type: "text", text: JSON.stringify({ status: "success", message: "Label deleted successfully" }, 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); });