import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import dotenv from "dotenv";
import express from "express";
import { env } from "./env.js";
// Load environment variables
dotenv.config();
const app = express();
const GITLAB_PAT = env.GITLAB_PAT;
const GITLAB_API_URL = env.GITLAB_API_URL;
const GITLAB_PROJECT_ID = env.GITLAB_PROJECT_ID;
// Create a server instance
const server = new McpServer({
name: env.SERVER_NAME,
version: env.SERVER_VERSION,
capabilities: {
resources: {},
tools: {},
prompts: {},
},
});
// Helper function for making GitLab API requests
async function makeGitLabRequest<T>(
url: string,
method: 'GET' | 'POST' = 'GET',
body?: any
): Promise<T | null> {
const headers = new Headers();
headers.append("PRIVATE-TOKEN", GITLAB_PAT);
headers.append("Accept", "*/*");
headers.append("Connection", "keep-alive");
if (method === 'POST' && body) {
headers.append("Content-Type", "application/json");
}
const requestOptions: RequestInit = {
method: method,
headers: headers,
};
if (method === 'POST' && body) {
requestOptions.body = JSON.stringify(body);
}
try {
const response = await fetch(url, requestOptions);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return (await response.json()) as T;
} catch (error) {
console.error("Error making GitLab request:", error);
return null;
}
}
interface GitLabMergeRequest {
id: number;
iid: number;
title: string;
description?: string;
state: string;
created_at: string;
updated_at: string;
target_branch: string;
source_branch: string;
author: {
name: string;
username: string;
};
assignee?: {
name: string;
username: string;
};
web_url: string;
}
interface GitLabDiff {
old_path: string;
new_path: string;
a_mode?: string;
b_mode?: string;
new_file: boolean;
renamed_file: boolean;
deleted_file: boolean;
diff: string;
}
interface GitLabProject {
id: number;
name: string;
name_with_namespace: string;
path: string;
path_with_namespace: string;
description?: string;
default_branch: string;
created_at: string;
last_activity_at: string;
web_url: string;
visibility: string;
namespace: {
id: number;
name: string;
path: string;
kind: string;
};
owner?: {
id: number;
name: string;
username: string;
};
}
interface GitLabDraftNote {
id: number;
author: {
id: number;
name: string;
username: string;
};
note: string;
created_at: string;
updated_at: string;
position?: {
base_sha: string;
start_sha: string;
head_sha: string;
old_path: string;
new_path: string;
position_type: string;
old_line?: number;
new_line?: number;
};
}
// Register GitLab tools
server.tool(
"get-merge-requests",
"Get list of merge requests from GitLab project",
{
state: z.string().optional().describe("Filter by state: 'opened', 'closed', 'merged', or 'all' (default: 'opened')"),
per_page: z.number().optional().describe("Number of merge requests per page (default: 20, max: 100)"),
},
async ({ state = "opened", per_page = 20 }) => {
const validStates = ["opened", "closed", "merged", "all"];
if (!validStates.includes(state)) {
return {
content: [
{
type: "text",
text: `Invalid state '${state}'. Valid states are: ${validStates.join(", ")}`,
},
],
};
}
const clampedPerPage = Math.min(Math.max(per_page, 1), 100);
const url = `${GITLAB_API_URL}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests?state=${state}&per_page=${clampedPerPage}`;
const mergeRequests = await makeGitLabRequest<GitLabMergeRequest[]>(url);
if (!mergeRequests) {
return {
content: [
{
type: "text",
text: "Failed to retrieve merge requests from GitLab",
},
],
};
}
if (mergeRequests.length === 0) {
return {
content: [
{
type: "text",
text: `No merge requests found with state '${state}'`,
},
],
};
}
const formattedMRs = mergeRequests.map((mr) => [
`#${mr.iid}: ${mr.title}`,
`State: ${mr.state}`,
`Author: ${mr.author.name} (@${mr.author.username})`,
`Source → Target: ${mr.source_branch} → ${mr.target_branch}`,
`Created: ${new Date(mr.created_at).toLocaleDateString()}`,
`Updated: ${new Date(mr.updated_at).toLocaleDateString()}`,
`URL: ${mr.web_url}`,
mr.assignee ? `Assignee: ${mr.assignee.name} (@${mr.assignee.username})` : "Assignee: None",
"---",
].join("\n"));
const mrText = `Merge Requests (${state}):\n\n${formattedMRs.join("\n")}`;
return {
content: [
{
type: "text",
text: mrText,
},
],
};
},
);
server.tool(
"get-merge-request-diffs",
"Get diffs for a specific merge request from GitLab project",
{
mr_iid: z.number().optional().describe("Internal ID (iid) of the merge request"),
source_branch: z.string().optional().describe("Source branch name to search for the merge request"),
mrTitle: z.string().optional().describe("Title or partial title to search for the merge request"),
},
async ({ mr_iid, source_branch, mrTitle }) => {
let targetMrIid = mr_iid;
// If no direct MR ID provided, search for it
if (!targetMrIid && (source_branch || mrTitle)) {
const searchUrl = `${GITLAB_API_URL}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests?state=all&per_page=100`;
const allMergeRequests = await makeGitLabRequest<GitLabMergeRequest[]>(searchUrl);
if (!allMergeRequests) {
return {
content: [
{
type: "text",
text: "Failed to retrieve merge requests from GitLab for search",
},
],
};
}
// Search by source branch or title
const matchingMRs = allMergeRequests.filter((mr) => {
const branchMatch = source_branch ? mr.source_branch.includes(source_branch) : true;
const titleMatch = mrTitle ? mr.title.toLowerCase().includes(mrTitle.toLowerCase()) : true;
return branchMatch && titleMatch;
});
if (matchingMRs.length === 0) {
const searchCriteria = [];
if (source_branch) searchCriteria.push(`source branch containing "${source_branch}"`);
if (mrTitle) searchCriteria.push(`title containing "${mrTitle}"`);
return {
content: [
{
type: "text",
text: `No merge requests found matching: ${searchCriteria.join(" and ")}`,
},
],
};
}
if (matchingMRs.length > 1) {
const mrList = matchingMRs.map(mr => `#${mr.iid}: ${mr.title} (${mr.source_branch} → ${mr.target_branch})`).join("\n");
return {
content: [
{
type: "text",
text: `Multiple merge requests found. Please specify the exact MR ID:\n\n${mrList}`,
},
],
};
}
targetMrIid = matchingMRs[0].iid;
}
if (!targetMrIid) {
return {
content: [
{
type: "text",
text: "Please provide either mr_iid, source_branch, or title to identify the merge request",
},
],
};
}
// Get the diffs for the merge request
const diffsUrl = `${GITLAB_API_URL}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests/${targetMrIid}/diffs`;
const diffs = await makeGitLabRequest<GitLabDiff[]>(diffsUrl);
if (!diffs) {
return {
content: [
{
type: "text",
text: `Failed to retrieve diffs for merge request #${targetMrIid}`,
},
],
};
}
if (diffs.length === 0) {
return {
content: [
{
type: "text",
text: `No diffs found for merge request #${targetMrIid}`,
},
],
};
}
// Format the diffs
const formattedDiffs = diffs.map((diff) => {
const fileStatus = [];
if (diff.new_file) fileStatus.push("NEW FILE");
if (diff.deleted_file) fileStatus.push("DELETED");
if (diff.renamed_file) fileStatus.push("RENAMED");
const statusText = fileStatus.length > 0 ? ` [${fileStatus.join(", ")}]` : "";
const filePath = diff.new_path !== diff.old_path ? `${diff.old_path} → ${diff.new_path}` : diff.new_path;
return [
`File: ${filePath}${statusText}`,
"=" .repeat(80),
diff.diff,
"",
].join("\n");
});
const diffsText = `Diffs for Merge Request #${targetMrIid}:\n\n${formattedDiffs.join("\n")}`;
return {
content: [
{
type: "text",
text: diffsText,
},
],
};
},
);
server.tool(
"get-projects",
"Get list of projects from GitLab",
{
search: z.string().optional().describe("Search term to filter projects by name or path"),
per_page: z.number().optional().describe("Number of projects per page (default: 20, max: 100)"),
visibility: z.string().optional().describe("Filter by visibility: 'private', 'internal', or 'public'"),
owned: z.boolean().optional().describe("Limit to projects owned by the authenticated user (default: false)"),
},
async ({ search, per_page = 20, visibility, owned = false }) => {
const clampedPerPage = Math.min(Math.max(per_page, 1), 100);
// Build the URL with query parameters
const params = new URLSearchParams();
params.append('per_page', clampedPerPage.toString());
if (search) {
params.append('search', search);
}
if (visibility && ['private', 'internal', 'public'].includes(visibility)) {
params.append('visibility', visibility);
}
if (owned) {
params.append('owned', 'true');
}
// Sort by last activity to show most recently active projects first
params.append('order_by', 'last_activity_at');
params.append('sort', 'desc');
const url = `${GITLAB_API_URL}/api/v4/projects?${params.toString()}`;
const projects = await makeGitLabRequest<GitLabProject[]>(url);
if (!projects) {
return {
content: [
{
type: "text",
text: "Failed to retrieve projects from GitLab",
},
],
};
}
if (projects.length === 0) {
const searchInfo = search ? ` matching "${search}"` : "";
return {
content: [
{
type: "text",
text: `No projects found${searchInfo}`,
},
],
};
}
const formattedProjects = projects.map((project) => [
`${project.name_with_namespace}`,
`ID: ${project.id}`,
`Path: ${project.path_with_namespace}`,
`Description: ${project.description || 'No description'}`,
`Visibility: ${project.visibility}`,
`Default Branch: ${project.default_branch}`,
`Created: ${new Date(project.created_at).toLocaleDateString()}`,
`Last Activity: ${new Date(project.last_activity_at).toLocaleDateString()}`,
`URL: ${project.web_url}`,
project.owner ? `Owner: ${project.owner.name} (@${project.owner.username})` : `Namespace: ${project.namespace.name}`,
"---",
].join("\n"));
const searchInfo = search ? ` (search: "${search}")` : "";
const projectsText = `GitLab Projects${searchInfo}:\n\n${formattedProjects.join("\n")}`;
return {
content: [
{
type: "text",
text: projectsText,
},
],
};
},
);
server.tool(
"create-draft-note",
"Create a draft note for a merge request in GitLab",
{
project_id: z.number().optional().describe("Project ID (uses default project if not provided)"),
mr_iid: z.number().describe("Internal ID (iid) of the merge request"),
note: z.string().describe("Content of the draft note"),
position_type: z.string().optional().describe("Type of position: 'text' for line comments, omit for general comments"),
old_path: z.string().optional().describe("Path to the file in the old version (required for line comments)"),
new_path: z.string().optional().describe("Path to the file in the new version (required for line comments)"),
old_line: z.number().optional().describe("Line number in the old file (for line comments)"),
new_line: z.number().optional().describe("Line number in the new file (for line comments)"),
base_sha: z.string().optional().describe("Base SHA for the position (required for line comments)"),
start_sha: z.string().optional().describe("Start SHA for the position (required for line comments)"),
head_sha: z.string().optional().describe("Head SHA for the position (required for line comments)"),
},
async ({
project_id,
mr_iid,
note,
position_type,
old_path,
new_path,
old_line,
new_line,
base_sha,
start_sha,
head_sha
}) => {
const targetProjectId = project_id || GITLAB_PROJECT_ID;
if (!note.trim()) {
return {
content: [
{
type: "text",
text: "Note content cannot be empty",
},
],
};
}
// Build the request body
const requestBody: any = {
note: note.trim(),
};
// Add position data if this is a line comment
if (position_type === 'text') {
if (!old_path || !new_path || !base_sha || !start_sha || !head_sha) {
return {
content: [
{
type: "text",
text: "For line comments, you must provide: old_path, new_path, base_sha, start_sha, and head_sha",
},
],
};
}
requestBody.position = {
position_type: 'text',
old_path,
new_path,
base_sha,
start_sha,
head_sha,
};
if (old_line !== undefined) {
requestBody.position.old_line = old_line;
}
if (new_line !== undefined) {
requestBody.position.new_line = new_line;
}
}
const url = `${GITLAB_API_URL}/api/v4/projects/${targetProjectId}/merge_requests/${mr_iid}/draft_notes`;
const draftNote = await makeGitLabRequest<GitLabDraftNote>(url, 'POST', requestBody);
if (!draftNote) {
return {
content: [
{
type: "text",
text: `Failed to create draft note for merge request #${mr_iid}`,
},
],
};
}
const positionInfo = draftNote.position
? `\nPosition: ${draftNote.position.new_path}${draftNote.position.new_line ? ` (line ${draftNote.position.new_line})` : ''}`
: '';
const responseText = [
`Draft note created successfully!`,
`ID: ${draftNote.id}`,
`Author: ${draftNote.author.name} (@${draftNote.author.username})`,
`Created: ${new Date(draftNote.created_at).toLocaleString()}`,
`Note: ${draftNote.note}${positionInfo}`,
].join('\n');
return {
content: [
{
type: "text",
text: responseText,
},
],
};
},
);
// Define a prompt for getting GitLab project information
server.prompt(
"gitlab-project-info",
"Get comprehensive information about GitLab projects",
{
project_search: z.string().optional().describe("Search term to find specific projects (optional)"),
detail_level: z.string().optional().describe("Level of detail: 'full' for detailed info, 'summary' for brief info (default: 'full')"),
},
async ({ project_search, detail_level = "full" }) => {
// Build the URL with query parameters
const params = new URLSearchParams();
params.append('per_page', '10'); // Limit to 10 projects for prompt
if (project_search) {
params.append('search', project_search);
}
// Sort by last activity to show most recently active projects first
params.append('order_by', 'last_activity_at');
params.append('sort', 'desc');
const url = `${GITLAB_API_URL}/api/v4/projects?${params.toString()}`;
const projects = await makeGitLabRequest<GitLabProject[]>(url);
if (!projects) {
return {
description: "Failed to retrieve GitLab project information",
messages: [
{
role: "user",
content: {
type: "text",
text: "Unable to connect to GitLab API. Please check your configuration and try again.",
},
},
],
};
}
if (projects.length === 0) {
const searchInfo = project_search ? ` matching "${project_search}"` : "";
return {
description: `No GitLab projects found${searchInfo}`,
messages: [
{
role: "user",
content: {
type: "text",
text: `No projects were found${searchInfo}. Try adjusting your search criteria or check if you have access to any projects.`,
},
},
],
};
}
// Format project information
let promptText = "Here is information about your GitLab projects:\n\n";
if (detail_level === "full") {
projects.forEach((project, index) => {
promptText += `## ${index + 1}. ${project.name_with_namespace}\n`;
promptText += `- **Project ID**: ${project.id}\n`;
promptText += `- **Path**: ${project.path_with_namespace}\n`;
promptText += `- **Description**: ${project.description || 'No description provided'}\n`;
promptText += `- **Visibility**: ${project.visibility}\n`;
promptText += `- **Default Branch**: ${project.default_branch}\n`;
promptText += `- **Created**: ${new Date(project.created_at).toLocaleDateString()}\n`;
promptText += `- **Last Activity**: ${new Date(project.last_activity_at).toLocaleDateString()}\n`;
promptText += `- **Web URL**: ${project.web_url}\n`;
if (project.owner) {
promptText += `- **Owner**: ${project.owner.name} (@${project.owner.username})\n`;
} else {
promptText += `- **Namespace**: ${project.namespace.name} (${project.namespace.kind})\n`;
}
promptText += "\n";
});
} else {
promptText += projects.map((project, index) =>
`${index + 1}. **${project.name_with_namespace}** (ID: ${project.id}) - ${project.description || 'No description'}`
).join('\n');
}
promptText += "\nYou can use the project IDs with other GitLab tools to get merge requests, diffs, or create draft notes.";
return {
description: `GitLab project information${project_search ? ` for "${project_search}"` : ''}`,
messages: [
{
role: "user",
content: {
type: "text",
text: promptText,
},
},
],
};
},
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Code Review MCP Server running on stdio");
}
app.get("/mcp", (req, res) => {
// Optional: Support server-initiated SSE streams
res.setHeader("Content-Type", "text/event-stream");
// Send server notifications/requests...
res.send('Hello World!')
});
app.listen(3000);
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});