commit d686f4e2d7e63bca846773a0aa17488a7af6c2dd Author: simple Date: Tue Feb 11 10:36:57 2025 +0900 초기 설정 파일 추가: .gitignore, tsconfig.json, package.json 및 README.md 생성 Generated by Copilot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28f1ba7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9f7737 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# @zereight/mcp-gitlab + +GitLab MCP(Model Context Protocol) Server. + +## Installation and Execution + +```bash +npx @zereight/mcp-gitlab +``` + +## Environment Variable Configuration + +Before running the server, you need to set the following environment variables: + +```bash +GITLAB_PERSONAL_ACCESS_TOKEN=your_gitlab_token +GITLAB_API_URL=your_gitlab_api_url # Default: https://gitlab.com/api/v4 +``` + +## License + +MIT License + +## How to use + +## Using with Claude App + +When using with the Claude App, you need to set up your API key and URLs directly. + +```json +{ + "mcpServers": { + "GitLab communication server": { + "command": "npx @zereight/mcp-gitlab", + "args": [], + "env": { + "GITLAB_PERSONAL_ACCESS_TOKEN": "your_gitlab_token", + "GITLAB_API_URL": "your_gitlab_api_url" + } + } + } +} +``` + +## Using with Cursor + +When using with Cursor, you can set up environment variables and run the server as follows: + +```bash +env GITLAB_PERSONAL_ACCESS_TOKEN=your_gitlab_token GITLAB_API_URL=your_gitlab_api_url npx @zereight/mcp-gitlab +``` + +- `GITLAB_PERSONAL_ACCESS_TOKEN`: Your GitLab personal access token. +- `GITLAB_API_URL`: Your GitLab API URL. (Default: `https://gitlab.com/api/v4`) diff --git a/build/index.js b/build/index.js new file mode 100755 index 0000000..e7e57dd --- /dev/null +++ b/build/index.js @@ -0,0 +1,539 @@ +#!/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 { GitLabForkSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabIssueSchema, GitLabMergeRequestSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabSearchResponseSchema, GitLabTreeSchema, GitLabCommitSchema, CreateOrUpdateFileSchema, SearchRepositoriesSchema, CreateRepositorySchema, GetFileContentsSchema, PushFilesSchema, CreateIssueSchema, CreateMergeRequestSchema, ForkRepositorySchema, CreateBranchSchema, GitLabMergeRequestDiffSchema, GetMergeRequestSchema, GetMergeRequestDiffsSchema, UpdateMergeRequestSchema, } from "./schemas.js"; +const server = new Server({ + name: "gitlab-mcp-server", + version: "0.0.1", +}, { + capabilities: { + tools: {}, + }, +}); +const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN; +const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com/api/v4"; +if (!GITLAB_PERSONAL_ACCESS_TOKEN) { + console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"); + process.exit(1); +} +// GitLab API 공통 헤더 +const DEFAULT_HEADERS = { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`, +}; +// API 에러 처리를 위한 유틸리티 함수 +async function handleGitLabError(response) { + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } +} +// 프로젝트 포크 생성 +async function forkProject(projectId, namespace) { + // API 엔드포인트 URL 생성 + const url = new URL(`${GITLAB_API_URL}/api/v4/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); +} +// 새로운 브랜치 생성 +async function createBranch(projectId, options) { + const url = new URL(`${GITLAB_API_URL}/api/v4/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()); +} +// 프로젝트의 기본 브랜치 조회 +async function getDefaultBranchRef(projectId) { + const url = new URL(`${GITLAB_API_URL}/api/v4/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"; +} +// 파일 내용 조회 +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}/api/v4/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; +} +// 이슈 생성 +async function createIssue(projectId, options) { + const url = new URL(`${GITLAB_API_URL}/api/v4/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); +} +async function createMergeRequest(projectId, options) { + const url = new URL(`${GITLAB_API_URL}/api/v4/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); +} +async function createOrUpdateFile(projectId, filePath, content, commitMessage, branch, previousPath) { + const encodedPath = encodeURIComponent(filePath); + const url = new URL(`${GITLAB_API_URL}/api/v4/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); +} +async function createTree(projectId, files, ref) { + const url = new URL(`${GITLAB_API_URL}/api/v4/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); +} +async function createCommit(projectId, message, branch, actions) { + const url = new URL(`${GITLAB_API_URL}/api/v4/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); +} +async function searchProjects(query, page = 1, perPage = 20) { + const url = new URL(`${GITLAB_API_URL}/api/v4/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, + }); +} +async function createRepository(options) { + const response = await fetch(`${GITLAB_API_URL}/api/v4/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); +} +// MR 조회 함수 +async function getMergeRequest(projectId, mergeRequestIid) { + const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(projectId)}/merge_requests/${mergeRequestIid}`); + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + await handleGitLabError(response); + return GitLabMergeRequestSchema.parse(await response.json()); +} +// MR 변경사항 조회 함수 +async function getMergeRequestDiffs(projectId, mergeRequestIid, view) { + const url = new URL(`${GITLAB_API_URL}/api/v4/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); +} +// MR 업데이트 함수 +async function updateMergeRequest(projectId, mergeRequestIid, options) { + const url = new URL(`${GITLAB_API_URL}/api/v4/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()); +} +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), + }, + ], + }; +}); +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) }, + ], + }; + } + 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; + } +}); +async function runServer() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("GitLab MCP Server running on stdio"); +} +runServer().catch((error) => { + console.error("Fatal error in main():", error); + process.exit(1); +}); diff --git a/build/schemas.js b/build/schemas.js new file mode 100644 index 0000000..d1eb081 --- /dev/null +++ b/build/schemas.js @@ -0,0 +1,348 @@ +import { z } from "zod"; +// Base schemas for common types +export const GitLabAuthorSchema = z.object({ + name: z.string(), + email: z.string(), + date: z.string(), +}); +// Repository related schemas +export const GitLabOwnerSchema = z.object({ + username: z.string(), // Changed from login to match GitLab API + id: z.number(), + avatar_url: z.string(), + web_url: z.string(), // Changed from html_url to match GitLab API + name: z.string(), // Added as GitLab includes full name + state: z.string(), // Added as GitLab includes user state +}); +export const GitLabRepositorySchema = z.object({ + id: z.number(), + name: z.string(), + path_with_namespace: z.string(), + visibility: z.string().optional(), + owner: GitLabOwnerSchema.optional(), + web_url: z.string().optional(), + description: z.string().nullable(), + fork: z.boolean().optional(), + ssh_url_to_repo: z.string().optional(), + http_url_to_repo: z.string().optional(), + created_at: z.string().optional(), + last_activity_at: z.string().optional(), + default_branch: z.string().optional(), +}); +// File content schemas +export const GitLabFileContentSchema = z.object({ + file_name: z.string(), // Changed from name to match GitLab API + file_path: z.string(), // Changed from path to match GitLab API + size: z.number(), + encoding: z.string(), + content: z.string(), + content_sha256: z.string(), // Changed from sha to match GitLab API + ref: z.string(), // Added as GitLab requires branch reference + blob_id: z.string(), // Added to match GitLab API + last_commit_id: z.string(), // Added to match GitLab API +}); +export const GitLabDirectoryContentSchema = z.object({ + name: z.string(), + path: z.string(), + type: z.string(), + mode: z.string(), + id: z.string(), // Changed from sha to match GitLab API + web_url: z.string(), // Changed from html_url to match GitLab API +}); +export const GitLabContentSchema = z.union([ + GitLabFileContentSchema, + z.array(GitLabDirectoryContentSchema), +]); +// Operation schemas +export const FileOperationSchema = z.object({ + path: z.string(), + content: z.string(), +}); +// Tree and commit schemas +export const GitLabTreeEntrySchema = z.object({ + id: z.string(), // Changed from sha to match GitLab API + name: z.string(), + type: z.enum(["blob", "tree"]), + path: z.string(), + mode: z.string(), +}); +export const GitLabTreeSchema = z.object({ + id: z.string(), // Changed from sha to match GitLab API + tree: z.array(GitLabTreeEntrySchema), +}); +export const GitLabCommitSchema = z.object({ + id: z.string(), // Changed from sha to match GitLab API + short_id: z.string(), // Added to match GitLab API + title: z.string(), // Changed from message to match GitLab API + author_name: z.string(), + author_email: z.string(), + authored_date: z.string(), + committer_name: z.string(), + committer_email: z.string(), + committed_date: z.string(), + web_url: z.string(), // Changed from html_url to match GitLab API + parent_ids: z.array(z.string()), // Changed from parents to match GitLab API +}); +// Reference schema +export const GitLabReferenceSchema = z.object({ + name: z.string(), // Changed from ref to match GitLab API + commit: z.object({ + id: z.string(), // Changed from sha to match GitLab API + web_url: z.string(), // Changed from url to match GitLab API + }), +}); +// Input schemas for operations +export const CreateRepositoryOptionsSchema = z.object({ + name: z.string(), + description: z.string().optional(), + visibility: z.enum(["private", "internal", "public"]).optional(), // Changed from private to match GitLab API + initialize_with_readme: z.boolean().optional(), // Changed from auto_init to match GitLab API +}); +export const CreateIssueOptionsSchema = z.object({ + title: z.string(), + description: z.string().optional(), // Changed from body to match GitLab API + assignee_ids: z.array(z.number()).optional(), // Changed from assignees to match GitLab API + milestone_id: z.number().optional(), // Changed from milestone to match GitLab API + labels: z.array(z.string()).optional(), +}); +export const CreateMergeRequestOptionsSchema = z.object({ + // Changed from CreatePullRequestOptionsSchema + title: z.string(), + description: z.string().optional(), // Changed from body to match GitLab API + source_branch: z.string(), // Changed from head to match GitLab API + target_branch: z.string(), // Changed from base to match GitLab API + allow_collaboration: z.boolean().optional(), // Changed from maintainer_can_modify to match GitLab API + draft: z.boolean().optional(), +}); +export const CreateBranchOptionsSchema = z.object({ + name: z.string(), // Changed from ref to match GitLab API + ref: z.string(), // The source branch/commit for the new branch +}); +// Response schemas for operations +export const GitLabCreateUpdateFileResponseSchema = z.object({ + file_path: z.string(), + branch: z.string(), + commit_id: z.string(), // Changed from sha to match GitLab API + content: GitLabFileContentSchema.optional(), +}); +export const GitLabSearchResponseSchema = z.object({ + count: z.number().optional(), + total_pages: z.number().optional(), + current_page: z.number().optional(), + items: z.array(GitLabRepositorySchema), +}); +// Fork related schemas +export const GitLabForkParentSchema = z.object({ + name: z.string(), + path_with_namespace: z.string(), // Changed from full_name to match GitLab API + owner: z.object({ + username: z.string(), // Changed from login to match GitLab API + id: z.number(), + avatar_url: z.string(), + }), + web_url: z.string(), // Changed from html_url to match GitLab API +}); +export const GitLabForkSchema = GitLabRepositorySchema.extend({ + forked_from_project: GitLabForkParentSchema, // Changed from parent to match GitLab API +}); +// Issue related schemas +export const GitLabLabelSchema = z.object({ + id: z.number(), + name: z.string(), + color: z.string(), + description: z.string().optional(), +}); +export const GitLabUserSchema = z.object({ + username: z.string(), // Changed from login to match GitLab API + id: z.number(), + name: z.string(), + avatar_url: z.string(), + web_url: z.string(), // Changed from html_url to match GitLab API +}); +export const GitLabMilestoneSchema = z.object({ + id: z.number(), + iid: z.number(), // Added to match GitLab API + title: z.string(), + description: z.string(), + state: z.string(), + web_url: z.string(), // Changed from html_url to match GitLab API +}); +export const GitLabIssueSchema = z.object({ + id: z.number(), + iid: z.number(), // Added to match GitLab API + project_id: z.number(), // Added to match GitLab API + title: z.string(), + description: z.string(), // Changed from body to match GitLab API + state: z.string(), + author: GitLabUserSchema, + assignees: z.array(GitLabUserSchema), + labels: z.array(GitLabLabelSchema), + milestone: GitLabMilestoneSchema.nullable(), + created_at: z.string(), + updated_at: z.string(), + closed_at: z.string().nullable(), + web_url: z.string(), // Changed from html_url to match GitLab API +}); +// Merge Request related schemas (equivalent to Pull Request) +export const GitLabMergeRequestDiffRefSchema = z.object({ + base_sha: z.string(), + head_sha: z.string(), + start_sha: z.string(), +}); +export const GitLabMergeRequestSchema = z.object({ + id: z.number(), + iid: z.number(), + project_id: z.number(), + title: z.string(), + description: z.string().nullable(), + state: z.string(), + merged: z.boolean().optional(), + draft: z.boolean().optional(), + author: GitLabUserSchema, + assignees: z.array(GitLabUserSchema).optional(), + source_branch: z.string(), + target_branch: z.string(), + diff_refs: GitLabMergeRequestDiffRefSchema.optional(), + web_url: z.string(), + created_at: z.string(), + updated_at: z.string(), + merged_at: z.string().nullable(), + closed_at: z.string().nullable(), + merge_commit_sha: z.string().nullable(), + detailed_merge_status: z.string().optional(), + merge_status: z.string().optional(), + merge_error: z.string().nullable().optional(), + work_in_progress: z.boolean().optional(), + blocking_discussions_resolved: z.boolean().optional(), + should_remove_source_branch: z.boolean().nullable().optional(), + force_remove_source_branch: z.boolean().optional(), + allow_collaboration: z.boolean().optional(), + allow_maintainer_to_push: z.boolean().optional(), + changes_count: z.string().optional(), + merge_when_pipeline_succeeds: z.boolean().optional(), + squash: z.boolean().optional(), + labels: z.array(z.string()).optional(), +}); +// API Operation Parameter Schemas +const ProjectParamsSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), // Changed from owner/repo to match GitLab API +}); +export const CreateOrUpdateFileSchema = ProjectParamsSchema.extend({ + file_path: z.string().describe("Path where to create/update the file"), + content: z.string().describe("Content of the file"), + commit_message: z.string().describe("Commit message"), + branch: z.string().describe("Branch to create/update the file in"), + previous_path: z + .string() + .optional() + .describe("Path of the file to move/rename"), +}); +export const SearchRepositoriesSchema = z.object({ + search: z.string().describe("Search query"), // Changed from query to match GitLab API + page: z + .number() + .optional() + .describe("Page number for pagination (default: 1)"), + per_page: z + .number() + .optional() + .describe("Number of results per page (default: 20)"), +}); +export const CreateRepositorySchema = z.object({ + name: z.string().describe("Repository name"), + description: z.string().optional().describe("Repository description"), + visibility: z + .enum(["private", "internal", "public"]) + .optional() + .describe("Repository visibility level"), + initialize_with_readme: z + .boolean() + .optional() + .describe("Initialize with README.md"), +}); +export const GetFileContentsSchema = ProjectParamsSchema.extend({ + file_path: z.string().describe("Path to the file or directory"), + ref: z.string().optional().describe("Branch/tag/commit to get contents from"), +}); +export const PushFilesSchema = ProjectParamsSchema.extend({ + branch: z.string().describe("Branch to push to"), + files: z + .array(z.object({ + file_path: z.string().describe("Path where to create the file"), + content: z.string().describe("Content of the file"), + })) + .describe("Array of files to push"), + commit_message: z.string().describe("Commit message"), +}); +export const CreateIssueSchema = ProjectParamsSchema.extend({ + title: z.string().describe("Issue title"), + description: z.string().optional().describe("Issue description"), + assignee_ids: z + .array(z.number()) + .optional() + .describe("Array of user IDs to assign"), + labels: z.array(z.string()).optional().describe("Array of label names"), + milestone_id: z.number().optional().describe("Milestone ID to assign"), +}); +export const CreateMergeRequestSchema = ProjectParamsSchema.extend({ + title: z.string().describe("Merge request title"), + description: z.string().optional().describe("Merge request description"), + source_branch: z.string().describe("Branch containing changes"), + target_branch: z.string().describe("Branch to merge into"), + draft: z.boolean().optional().describe("Create as draft merge request"), + allow_collaboration: z + .boolean() + .optional() + .describe("Allow commits from upstream members"), +}); +export const ForkRepositorySchema = ProjectParamsSchema.extend({ + namespace: z.string().optional().describe("Namespace to fork to (full path)"), +}); +export const CreateBranchSchema = ProjectParamsSchema.extend({ + branch: z.string().describe("Name for the new branch"), + ref: z.string().optional().describe("Source branch/commit for new branch"), +}); +export const GitLabMergeRequestDiffSchema = z.object({ + old_path: z.string(), + new_path: z.string(), + a_mode: z.string(), + b_mode: z.string(), + diff: z.string(), + new_file: z.boolean(), + renamed_file: z.boolean(), + deleted_file: z.boolean(), +}); +export const GetMergeRequestSchema = ProjectParamsSchema.extend({ + merge_request_iid: z + .number() + .describe("The internal ID of the merge request"), +}); +export const UpdateMergeRequestSchema = GetMergeRequestSchema.extend({ + title: z.string().optional().describe("The title of the merge request"), + description: z + .string() + .optional() + .describe("The description of the merge request"), + target_branch: z.string().optional().describe("The target branch"), + assignee_ids: z + .array(z.number()) + .optional() + .describe("The ID of the users to assign the MR to"), + labels: z.array(z.string()).optional().describe("Labels for the MR"), + state_event: z + .enum(["close", "reopen"]) + .optional() + .describe("New state (close/reopen) for the MR"), + remove_source_branch: z + .boolean() + .optional() + .describe("Flag indicating if the source branch should be removed"), + squash: z + .boolean() + .optional() + .describe("Squash commits into a single commit when merging"), + draft: z.boolean().optional().describe("Work in progress merge request"), +}); +export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({ + view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"), +}); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..7d4eecf --- /dev/null +++ b/index.ts @@ -0,0 +1,824 @@ +#!/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 { + 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, +} from "./schemas.js"; + +const server = new Server( + { + name: "gitlab-mcp-server", + version: "0.0.1", + }, + { + capabilities: { + tools: {}, + }, + } +); + +const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN; +const GITLAB_API_URL = + process.env.GITLAB_API_URL || "https://gitlab.com/api/v4"; + +if (!GITLAB_PERSONAL_ACCESS_TOKEN) { + console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"); + process.exit(1); +} + +// GitLab API 공통 헤더 +const DEFAULT_HEADERS = { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`, +}; + +// API 에러 처리를 위한 유틸리티 함수 +async function handleGitLabError( + response: import("node-fetch").Response +): Promise { + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `GitLab API error: ${response.status} ${response.statusText}\n${errorBody}` + ); + } +} + +// 프로젝트 포크 생성 +async function forkProject( + projectId: string, + namespace?: string +): Promise { + // API 엔드포인트 URL 생성 + const url = new URL( + `${GITLAB_API_URL}/api/v4/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); +} + +// 새로운 브랜치 생성 +async function createBranch( + projectId: string, + options: z.infer +): Promise { + const url = new URL( + `${GITLAB_API_URL}/api/v4/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()); +} + +// 프로젝트의 기본 브랜치 조회 +async function getDefaultBranchRef(projectId: string): Promise { + const url = new URL( + `${GITLAB_API_URL}/api/v4/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"; +} + +// 파일 내용 조회 +async function getFileContents( + projectId: string, + filePath: string, + ref?: string +): Promise { + const encodedPath = encodeURIComponent(filePath); + + // ref가 없는 경우 default branch를 가져옴 + if (!ref) { + ref = await getDefaultBranchRef(projectId); + } + + const url = new URL( + `${GITLAB_API_URL}/api/v4/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; +} + +// 이슈 생성 +async function createIssue( + projectId: string, + options: z.infer +): Promise { + const url = new URL( + `${GITLAB_API_URL}/api/v4/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); +} + +async function createMergeRequest( + projectId: string, + options: z.infer +): Promise { + const url = new URL( + `${GITLAB_API_URL}/api/v4/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); +} + +async function createOrUpdateFile( + projectId: string, + filePath: string, + content: string, + commitMessage: string, + branch: string, + previousPath?: string +): Promise { + const encodedPath = encodeURIComponent(filePath); + const url = new URL( + `${GITLAB_API_URL}/api/v4/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); +} + +async function createTree( + projectId: string, + files: FileOperation[], + ref?: string +): Promise { + const url = new URL( + `${GITLAB_API_URL}/api/v4/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); +} + +async function createCommit( + projectId: string, + message: string, + branch: string, + actions: FileOperation[] +): Promise { + const url = new URL( + `${GITLAB_API_URL}/api/v4/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); +} + +async function searchProjects( + query: string, + page: number = 1, + perPage: number = 20 +): Promise { + const url = new URL(`${GITLAB_API_URL}/api/v4/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, + }); +} + +async function createRepository( + options: z.infer +): Promise { + const response = await fetch(`${GITLAB_API_URL}/api/v4/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); +} + +// MR 조회 함수 +async function getMergeRequest( + projectId: string, + mergeRequestIid: number +): Promise { + const url = new URL( + `${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( + projectId + )}/merge_requests/${mergeRequestIid}` + ); + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + return GitLabMergeRequestSchema.parse(await response.json()); +} + +// MR 변경사항 조회 함수 +async function getMergeRequestDiffs( + projectId: string, + mergeRequestIid: number, + view?: "inline" | "parallel" +): Promise { + const url = new URL( + `${GITLAB_API_URL}/api/v4/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); +} + +// MR 업데이트 함수 +async function updateMergeRequest( + projectId: string, + mergeRequestIid: number, + options: Omit< + z.infer, + "project_id" | "merge_request_iid" + > +): Promise { + const url = new URL( + `${GITLAB_API_URL}/api/v4/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()); +} + +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), + }, + ], + }; +}); + +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) }, + ], + }; + } + + 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; + } +}); + +async function runServer() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("GitLab MCP Server running on stdio"); +} + +runServer().catch((error) => { + console.error("Fatal error in main():", error); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f743abd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,627 @@ +{ + "name": "@modelcontextprotocol/server-gitlab", + "version": "0.6.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@modelcontextprotocol/server-gitlab", + "version": "0.6.2", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "1.0.1", + "@types/node-fetch": "^2.6.12", + "dotenv": "^16.4.7", + "node-fetch": "^3.3.2", + "zod-to-json-schema": "^3.23.5" + }, + "bin": { + "mcp-server-gitlab": "build/index.js" + }, + "devDependencies": { + "shx": "^0.3.4", + "typescript": "^5.6.2" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.1.tgz", + "integrity": "sha512-slLdFaxQJ9AlRg+hw28iiTtGvShAOgOKXcD0F91nUcRYiOMuS9ZBYjcdNZRXW9G5JQ511GRTdUy1zQVZDpJ+4w==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "raw-body": "^3.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/@types/node": { + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dev": true, + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/shx": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", + "integrity": "sha512-N6A9MLVqjxZYcVn8hLmtneQWIJtp8IKzMP4eMnx+nqkvXoqinUPCbUFLp2UcWTEIUONhlk0ewxr/jaVGlc+J+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.3", + "shelljs": "^0.8.5" + }, + "bin": { + "shx": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.1.tgz", + "integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dacb51f --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@zereight/mcp-gitlab", + "version": "1.0.2", + "description": "MCP server for using the GitLab API", + "license": "MIT", + "author": "zereight", + "type": "module", + "bin": "./build/index.js", + "files": [ + "build" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=14" + }, + "scripts": { + "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", + "prepare": "npm run build", + "watch": "tsc --watch" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.0.1", + "@types/node-fetch": "^2.6.12", + "node-fetch": "^3.3.2", + "zod-to-json-schema": "^3.23.5" + }, + "devDependencies": { + "typescript": "^5.6.2" + } +} diff --git a/schemas.ts b/schemas.ts new file mode 100644 index 0000000..b014291 --- /dev/null +++ b/schemas.ts @@ -0,0 +1,420 @@ +import { z } from "zod"; + +// Base schemas for common types +export const GitLabAuthorSchema = z.object({ + name: z.string(), + email: z.string(), + date: z.string(), +}); + +// Repository related schemas +export const GitLabOwnerSchema = z.object({ + username: z.string(), // Changed from login to match GitLab API + id: z.number(), + avatar_url: z.string(), + web_url: z.string(), // Changed from html_url to match GitLab API + name: z.string(), // Added as GitLab includes full name + state: z.string(), // Added as GitLab includes user state +}); + +export const GitLabRepositorySchema = z.object({ + id: z.number(), + name: z.string(), + path_with_namespace: z.string(), + visibility: z.string().optional(), + owner: GitLabOwnerSchema.optional(), + web_url: z.string().optional(), + description: z.string().nullable(), + fork: z.boolean().optional(), + ssh_url_to_repo: z.string().optional(), + http_url_to_repo: z.string().optional(), + created_at: z.string().optional(), + last_activity_at: z.string().optional(), + default_branch: z.string().optional(), +}); + +// File content schemas +export const GitLabFileContentSchema = z.object({ + file_name: z.string(), // Changed from name to match GitLab API + file_path: z.string(), // Changed from path to match GitLab API + size: z.number(), + encoding: z.string(), + content: z.string(), + content_sha256: z.string(), // Changed from sha to match GitLab API + ref: z.string(), // Added as GitLab requires branch reference + blob_id: z.string(), // Added to match GitLab API + last_commit_id: z.string(), // Added to match GitLab API +}); + +export const GitLabDirectoryContentSchema = z.object({ + name: z.string(), + path: z.string(), + type: z.string(), + mode: z.string(), + id: z.string(), // Changed from sha to match GitLab API + web_url: z.string(), // Changed from html_url to match GitLab API +}); + +export const GitLabContentSchema = z.union([ + GitLabFileContentSchema, + z.array(GitLabDirectoryContentSchema), +]); + +// Operation schemas +export const FileOperationSchema = z.object({ + path: z.string(), + content: z.string(), +}); + +// Tree and commit schemas +export const GitLabTreeEntrySchema = z.object({ + id: z.string(), // Changed from sha to match GitLab API + name: z.string(), + type: z.enum(["blob", "tree"]), + path: z.string(), + mode: z.string(), +}); + +export const GitLabTreeSchema = z.object({ + id: z.string(), // Changed from sha to match GitLab API + tree: z.array(GitLabTreeEntrySchema), +}); + +export const GitLabCommitSchema = z.object({ + id: z.string(), // Changed from sha to match GitLab API + short_id: z.string(), // Added to match GitLab API + title: z.string(), // Changed from message to match GitLab API + author_name: z.string(), + author_email: z.string(), + authored_date: z.string(), + committer_name: z.string(), + committer_email: z.string(), + committed_date: z.string(), + web_url: z.string(), // Changed from html_url to match GitLab API + parent_ids: z.array(z.string()), // Changed from parents to match GitLab API +}); + +// Reference schema +export const GitLabReferenceSchema = z.object({ + name: z.string(), // Changed from ref to match GitLab API + commit: z.object({ + id: z.string(), // Changed from sha to match GitLab API + web_url: z.string(), // Changed from url to match GitLab API + }), +}); + +// Input schemas for operations +export const CreateRepositoryOptionsSchema = z.object({ + name: z.string(), + description: z.string().optional(), + visibility: z.enum(["private", "internal", "public"]).optional(), // Changed from private to match GitLab API + initialize_with_readme: z.boolean().optional(), // Changed from auto_init to match GitLab API +}); + +export const CreateIssueOptionsSchema = z.object({ + title: z.string(), + description: z.string().optional(), // Changed from body to match GitLab API + assignee_ids: z.array(z.number()).optional(), // Changed from assignees to match GitLab API + milestone_id: z.number().optional(), // Changed from milestone to match GitLab API + labels: z.array(z.string()).optional(), +}); + +export const CreateMergeRequestOptionsSchema = z.object({ + // Changed from CreatePullRequestOptionsSchema + title: z.string(), + description: z.string().optional(), // Changed from body to match GitLab API + source_branch: z.string(), // Changed from head to match GitLab API + target_branch: z.string(), // Changed from base to match GitLab API + allow_collaboration: z.boolean().optional(), // Changed from maintainer_can_modify to match GitLab API + draft: z.boolean().optional(), +}); + +export const CreateBranchOptionsSchema = z.object({ + name: z.string(), // Changed from ref to match GitLab API + ref: z.string(), // The source branch/commit for the new branch +}); + +// Response schemas for operations +export const GitLabCreateUpdateFileResponseSchema = z.object({ + file_path: z.string(), + branch: z.string(), + commit_id: z.string(), // Changed from sha to match GitLab API + content: GitLabFileContentSchema.optional(), +}); + +export const GitLabSearchResponseSchema = z.object({ + count: z.number().optional(), + total_pages: z.number().optional(), + current_page: z.number().optional(), + items: z.array(GitLabRepositorySchema), +}); + +// Fork related schemas +export const GitLabForkParentSchema = z.object({ + name: z.string(), + path_with_namespace: z.string(), // Changed from full_name to match GitLab API + owner: z.object({ + username: z.string(), // Changed from login to match GitLab API + id: z.number(), + avatar_url: z.string(), + }), + web_url: z.string(), // Changed from html_url to match GitLab API +}); + +export const GitLabForkSchema = GitLabRepositorySchema.extend({ + forked_from_project: GitLabForkParentSchema, // Changed from parent to match GitLab API +}); + +// Issue related schemas +export const GitLabLabelSchema = z.object({ + id: z.number(), + name: z.string(), + color: z.string(), + description: z.string().optional(), +}); + +export const GitLabUserSchema = z.object({ + username: z.string(), // Changed from login to match GitLab API + id: z.number(), + name: z.string(), + avatar_url: z.string(), + web_url: z.string(), // Changed from html_url to match GitLab API +}); + +export const GitLabMilestoneSchema = z.object({ + id: z.number(), + iid: z.number(), // Added to match GitLab API + title: z.string(), + description: z.string(), + state: z.string(), + web_url: z.string(), // Changed from html_url to match GitLab API +}); + +export const GitLabIssueSchema = z.object({ + id: z.number(), + iid: z.number(), // Added to match GitLab API + project_id: z.number(), // Added to match GitLab API + title: z.string(), + description: z.string(), // Changed from body to match GitLab API + state: z.string(), + author: GitLabUserSchema, + assignees: z.array(GitLabUserSchema), + labels: z.array(GitLabLabelSchema), + milestone: GitLabMilestoneSchema.nullable(), + created_at: z.string(), + updated_at: z.string(), + closed_at: z.string().nullable(), + web_url: z.string(), // Changed from html_url to match GitLab API +}); + +// Merge Request related schemas (equivalent to Pull Request) +export const GitLabMergeRequestDiffRefSchema = z.object({ + base_sha: z.string(), + head_sha: z.string(), + start_sha: z.string(), +}); + +export const GitLabMergeRequestSchema = z.object({ + id: z.number(), + iid: z.number(), + project_id: z.number(), + title: z.string(), + description: z.string().nullable(), + state: z.string(), + merged: z.boolean().optional(), + draft: z.boolean().optional(), + author: GitLabUserSchema, + assignees: z.array(GitLabUserSchema).optional(), + source_branch: z.string(), + target_branch: z.string(), + diff_refs: GitLabMergeRequestDiffRefSchema.optional(), + web_url: z.string(), + created_at: z.string(), + updated_at: z.string(), + merged_at: z.string().nullable(), + closed_at: z.string().nullable(), + merge_commit_sha: z.string().nullable(), + detailed_merge_status: z.string().optional(), + merge_status: z.string().optional(), + merge_error: z.string().nullable().optional(), + work_in_progress: z.boolean().optional(), + blocking_discussions_resolved: z.boolean().optional(), + should_remove_source_branch: z.boolean().nullable().optional(), + force_remove_source_branch: z.boolean().optional(), + allow_collaboration: z.boolean().optional(), + allow_maintainer_to_push: z.boolean().optional(), + changes_count: z.string().optional(), + merge_when_pipeline_succeeds: z.boolean().optional(), + squash: z.boolean().optional(), + labels: z.array(z.string()).optional(), +}); + +// API Operation Parameter Schemas +const ProjectParamsSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), // Changed from owner/repo to match GitLab API +}); + +export const CreateOrUpdateFileSchema = ProjectParamsSchema.extend({ + file_path: z.string().describe("Path where to create/update the file"), + content: z.string().describe("Content of the file"), + commit_message: z.string().describe("Commit message"), + branch: z.string().describe("Branch to create/update the file in"), + previous_path: z + .string() + .optional() + .describe("Path of the file to move/rename"), +}); + +export const SearchRepositoriesSchema = z.object({ + search: z.string().describe("Search query"), // Changed from query to match GitLab API + page: z + .number() + .optional() + .describe("Page number for pagination (default: 1)"), + per_page: z + .number() + .optional() + .describe("Number of results per page (default: 20)"), +}); + +export const CreateRepositorySchema = z.object({ + name: z.string().describe("Repository name"), + description: z.string().optional().describe("Repository description"), + visibility: z + .enum(["private", "internal", "public"]) + .optional() + .describe("Repository visibility level"), + initialize_with_readme: z + .boolean() + .optional() + .describe("Initialize with README.md"), +}); + +export const GetFileContentsSchema = ProjectParamsSchema.extend({ + file_path: z.string().describe("Path to the file or directory"), + ref: z.string().optional().describe("Branch/tag/commit to get contents from"), +}); + +export const PushFilesSchema = ProjectParamsSchema.extend({ + branch: z.string().describe("Branch to push to"), + files: z + .array( + z.object({ + file_path: z.string().describe("Path where to create the file"), + content: z.string().describe("Content of the file"), + }) + ) + .describe("Array of files to push"), + commit_message: z.string().describe("Commit message"), +}); + +export const CreateIssueSchema = ProjectParamsSchema.extend({ + title: z.string().describe("Issue title"), + description: z.string().optional().describe("Issue description"), + assignee_ids: z + .array(z.number()) + .optional() + .describe("Array of user IDs to assign"), + labels: z.array(z.string()).optional().describe("Array of label names"), + milestone_id: z.number().optional().describe("Milestone ID to assign"), +}); + +export const CreateMergeRequestSchema = ProjectParamsSchema.extend({ + title: z.string().describe("Merge request title"), + description: z.string().optional().describe("Merge request description"), + source_branch: z.string().describe("Branch containing changes"), + target_branch: z.string().describe("Branch to merge into"), + draft: z.boolean().optional().describe("Create as draft merge request"), + allow_collaboration: z + .boolean() + .optional() + .describe("Allow commits from upstream members"), +}); + +export const ForkRepositorySchema = ProjectParamsSchema.extend({ + namespace: z.string().optional().describe("Namespace to fork to (full path)"), +}); + +export const CreateBranchSchema = ProjectParamsSchema.extend({ + branch: z.string().describe("Name for the new branch"), + ref: z.string().optional().describe("Source branch/commit for new branch"), +}); + +export const GitLabMergeRequestDiffSchema = z.object({ + old_path: z.string(), + new_path: z.string(), + a_mode: z.string(), + b_mode: z.string(), + diff: z.string(), + new_file: z.boolean(), + renamed_file: z.boolean(), + deleted_file: z.boolean(), +}); + +export const GetMergeRequestSchema = ProjectParamsSchema.extend({ + merge_request_iid: z + .number() + .describe("The internal ID of the merge request"), +}); + +export const UpdateMergeRequestSchema = GetMergeRequestSchema.extend({ + title: z.string().optional().describe("The title of the merge request"), + description: z + .string() + .optional() + .describe("The description of the merge request"), + target_branch: z.string().optional().describe("The target branch"), + assignee_ids: z + .array(z.number()) + .optional() + .describe("The ID of the users to assign the MR to"), + labels: z.array(z.string()).optional().describe("Labels for the MR"), + state_event: z + .enum(["close", "reopen"]) + .optional() + .describe("New state (close/reopen) for the MR"), + remove_source_branch: z + .boolean() + .optional() + .describe("Flag indicating if the source branch should be removed"), + squash: z + .boolean() + .optional() + .describe("Squash commits into a single commit when merging"), + draft: z.boolean().optional().describe("Work in progress merge request"), +}); + +export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({ + view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"), +}); + +// Export types +export type GitLabAuthor = z.infer; +export type GitLabFork = z.infer; +export type GitLabIssue = z.infer; +export type GitLabMergeRequest = z.infer; +export type GitLabRepository = z.infer; +export type GitLabFileContent = z.infer; +export type GitLabDirectoryContent = z.infer< + typeof GitLabDirectoryContentSchema +>; +export type GitLabContent = z.infer; +export type FileOperation = z.infer; +export type GitLabTree = z.infer; +export type GitLabCommit = z.infer; +export type GitLabReference = z.infer; +export type CreateRepositoryOptions = z.infer< + typeof CreateRepositoryOptionsSchema +>; +export type CreateIssueOptions = z.infer; +export type CreateMergeRequestOptions = z.infer< + typeof CreateMergeRequestOptionsSchema +>; +export type CreateBranchOptions = z.infer; +export type GitLabCreateUpdateFileResponse = z.infer< + typeof GitLabCreateUpdateFileResponseSchema +>; +export type GitLabSearchResponse = z.infer; +export type GitLabMergeRequestDiff = z.infer< + typeof GitLabMergeRequestDiffSchema +>; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c541a24 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["./**/*"], + "exclude": ["node_modules", "build"] +}