mirror of
https://github.com/modelcontextprotocol/servers.git
synced 2026-04-18 16:13:22 +02:00
534 lines
15 KiB
JavaScript
534 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListToolsRequestSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import fetch from "node-fetch";
|
|
import { z } from 'zod';
|
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
import {
|
|
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);
|
|
}); |