X MCP Server
by DataWhisker
- src
- gitlab
#!/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,
CreateRepositoryOptionsSchema,
CreateIssueOptionsSchema,
CreateMergeRequestOptionsSchema,
CreateBranchOptionsSchema,
CreateOrUpdateFileSchema,
SearchRepositoriesSchema,
CreateRepositorySchema,
GetFileContentsSchema,
PushFilesSchema,
CreateIssueSchema,
CreateMergeRequestSchema,
ForkRepositorySchema,
CreateBranchSchema,
type GitLabFork,
type GitLabReference,
type GitLabRepository,
type GitLabIssue,
type GitLabMergeRequest,
type GitLabContent,
type GitLabCreateUpdateFileResponse,
type GitLabSearchResponse,
type GitLabTree,
type GitLabCommit,
type FileOperation,
} from './schemas.js';
const server = new Server({
name: "gitlab-mcp-server",
version: "0.5.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);
}
async function forkProject(
projectId: string,
namespace?: string
): Promise<GitLabFork> {
const url = `${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/fork`;
const queryParams = namespace ? `?namespace=${encodeURIComponent(namespace)}` : '';
const response = await fetch(url + queryParams, {
method: "POST",
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
}
});
if (!response.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
return GitLabForkSchema.parse(await response.json());
}
async function createBranch(
projectId: string,
options: z.infer<typeof CreateBranchOptionsSchema>
): Promise<GitLabReference> {
const response = await fetch(
`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/branches`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
branch: options.name,
ref: options.ref
})
}
);
if (!response.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
return GitLabReferenceSchema.parse(await response.json());
}
async function getDefaultBranchRef(projectId: string): Promise<string> {
const response = await fetch(
`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}`,
{
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`
}
}
);
if (!response.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
const project = GitLabRepositorySchema.parse(await response.json());
return project.default_branch;
}
async function getFileContents(
projectId: string,
filePath: string,
ref?: string
): Promise<GitLabContent> {
const encodedPath = encodeURIComponent(filePath);
let url = `${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/files/${encodedPath}`;
if (ref) {
url += `?ref=${encodeURIComponent(ref)}`;
}
const response = await fetch(url, {
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`
}
});
if (!response.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
const data = GitLabContentSchema.parse(await response.json());
if (!Array.isArray(data) && data.content) {
data.content = Buffer.from(data.content, 'base64').toString('utf8');
}
return data;
}
async function createIssue(
projectId: string,
options: z.infer<typeof CreateIssueOptionsSchema>
): Promise<GitLabIssue> {
const response = await fetch(
`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
},
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.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
return GitLabIssueSchema.parse(await response.json());
}
async function createMergeRequest(
projectId: string,
options: z.infer<typeof CreateMergeRequestOptionsSchema>
): Promise<GitLabMergeRequest> {
const response = await fetch(
`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/merge_requests`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
},
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.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
return GitLabMergeRequestSchema.parse(await response.json());
}
async function createOrUpdateFile(
projectId: string,
filePath: string,
content: string,
commitMessage: string,
branch: string,
previousPath?: string
): Promise<GitLabCreateUpdateFileResponse> {
const encodedPath = encodeURIComponent(filePath);
const url = `${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/files/${encodedPath}`;
const body = {
branch,
content,
commit_message: commitMessage,
...(previousPath ? { previous_path: previousPath } : {})
};
// Check if file exists
let method = "POST";
try {
await getFileContents(projectId, filePath, branch);
method = "PUT";
} catch (error) {
// File doesn't exist, use POST
}
const response = await fetch(url, {
method,
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
return GitLabCreateUpdateFileResponseSchema.parse(await response.json());
}
async function createTree(
projectId: string,
files: FileOperation[],
ref?: string
): Promise<GitLabTree> {
const response = await fetch(
`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/tree`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
files: files.map(file => ({
file_path: file.path,
content: file.content
})),
...(ref ? { ref } : {})
})
}
);
if (!response.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
return GitLabTreeSchema.parse(await response.json());
}
async function createCommit(
projectId: string,
message: string,
branch: string,
actions: FileOperation[]
): Promise<GitLabCommit> {
const response = await fetch(
`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/repository/commits`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
branch,
commit_message: message,
actions: actions.map(action => ({
action: "create",
file_path: action.path,
content: action.content
}))
})
}
);
if (!response.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
return GitLabCommitSchema.parse(await response.json());
}
async function searchProjects(
query: string,
page: number = 1,
perPage: number = 20
): Promise<GitLabSearchResponse> {
const url = new URL(`${GITLAB_API_URL}/projects`);
url.searchParams.append("search", query);
url.searchParams.append("page", page.toString());
url.searchParams.append("per_page", perPage.toString());
const response = await fetch(url.toString(), {
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`
}
});
if (!response.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
const projects = await response.json();
return GitLabSearchResponseSchema.parse({
count: parseInt(response.headers.get("X-Total") || "0"),
items: projects
});
}
async function createRepository(
options: z.infer<typeof CreateRepositoryOptionsSchema>
): Promise<GitLabRepository> {
const response = await fetch(`${GITLAB_API_URL}/projects`, {
method: "POST",
headers: {
"Authorization": `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
name: options.name,
description: options.description,
visibility: options.visibility,
initialize_with_readme: options.initialize_with_readme
})
});
if (!response.ok) {
throw new Error(`GitLab API error: ${response.statusText}`);
}
return GitLabRepositorySchema.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)
}
]
};
});
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) }] };
}
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);
});