Files
servers-modelcontextprotocol/src/github/index.ts
2024-12-04 14:58:47 +05:30

692 lines
20 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 {
GitHubForkSchema,
GitHubReferenceSchema,
GitHubRepositorySchema,
GitHubIssueSchema,
GitHubPullRequestSchema,
GitHubContentSchema,
GitHubCreateUpdateFileResponseSchema,
GitHubSearchResponseSchema,
GitHubTreeSchema,
GitHubCommitSchema,
CreateRepositoryOptionsSchema,
CreateIssueOptionsSchema,
CreatePullRequestOptionsSchema,
CreateBranchOptionsSchema,
type GitHubFork,
type GitHubReference,
type GitHubRepository,
type GitHubIssue,
type GitHubPullRequest,
type GitHubContent,
type GitHubCreateUpdateFileResponse,
type GitHubSearchResponse,
type GitHubTree,
type GitHubCommit,
type FileOperation,
CreateOrUpdateFileSchema,
SearchRepositoriesSchema,
CreateRepositorySchema,
GetFileContentsSchema,
PushFilesSchema,
CreateIssueSchema,
CreatePullRequestSchema,
ForkRepositorySchema,
CreateBranchSchema,
ListCommitsSchema
} from './schemas.js';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const server = new Server({
name: "github-mcp-server",
version: "0.1.0",
}, {
capabilities: {
tools: {}
}
});
const GITHUB_PERSONAL_ACCESS_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
if (!GITHUB_PERSONAL_ACCESS_TOKEN) {
console.error("GITHUB_PERSONAL_ACCESS_TOKEN environment variable is not set");
process.exit(1);
}
async function forkRepository(
owner: string,
repo: string,
organization?: string
): Promise<GitHubFork> {
const url = organization
? `https://api.github.com/repos/${owner}/${repo}/forks?organization=${organization}`
: `https://api.github.com/repos/${owner}/${repo}/forks`;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server"
}
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubForkSchema.parse(await response.json());
}
async function createBranch(
owner: string,
repo: string,
options: z.infer<typeof CreateBranchOptionsSchema>
): Promise<GitHubReference> {
const fullRef = `refs/heads/${options.ref}`;
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/refs`,
{
method: "POST",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server",
"Content-Type": "application/json"
},
body: JSON.stringify({
ref: fullRef,
sha: options.sha
})
}
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubReferenceSchema.parse(await response.json());
}
async function getDefaultBranchSHA(
owner: string,
repo: string
): Promise<string> {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/main`,
{
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server"
}
}
);
if (!response.ok) {
const masterResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/master`,
{
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server"
}
}
);
if (!masterResponse.ok) {
throw new Error("Could not find default branch (tried 'main' and 'master')");
}
const data = GitHubReferenceSchema.parse(await masterResponse.json());
return data.object.sha;
}
const data = GitHubReferenceSchema.parse(await response.json());
return data.object.sha;
}
async function getFileContents(
owner: string,
repo: string,
path: string,
branch?: string
): Promise<GitHubContent> {
let url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`;
if (branch) {
url += `?ref=${branch}`;
}
const response = await fetch(url, {
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server"
}
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
const data = GitHubContentSchema.parse(await response.json());
// If it's a file, decode the content
if (!Array.isArray(data) && data.content) {
data.content = Buffer.from(data.content, 'base64').toString('utf8');
}
return data;
}
async function createIssue(
owner: string,
repo: string,
options: z.infer<typeof CreateIssueOptionsSchema>
): Promise<GitHubIssue> {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues`,
{
method: "POST",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server",
"Content-Type": "application/json"
},
body: JSON.stringify(options)
}
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubIssueSchema.parse(await response.json());
}
async function createPullRequest(
owner: string,
repo: string,
options: z.infer<typeof CreatePullRequestOptionsSchema>
): Promise<GitHubPullRequest> {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/pulls`,
{
method: "POST",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server",
"Content-Type": "application/json"
},
body: JSON.stringify(options)
}
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubPullRequestSchema.parse(await response.json());
}
async function createOrUpdateFile(
owner: string,
repo: string,
path: string,
content: string,
message: string,
branch: string,
sha?: string
): Promise<GitHubCreateUpdateFileResponse> {
const encodedContent = Buffer.from(content).toString('base64');
let currentSha = sha;
if (!currentSha) {
try {
const existingFile = await getFileContents(owner, repo, path, branch);
if (!Array.isArray(existingFile)) {
currentSha = existingFile.sha;
}
} catch (error) {
console.error('Note: File does not exist in branch, will create new file');
}
}
const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`;
const body = {
message,
content: encodedContent,
branch,
...(currentSha ? { sha: currentSha } : {})
};
const response = await fetch(url, {
method: "PUT",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server",
"Content-Type": "application/json"
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubCreateUpdateFileResponseSchema.parse(await response.json());
}
async function createTree(
owner: string,
repo: string,
files: FileOperation[],
baseTree?: string
): Promise<GitHubTree> {
const tree = files.map(file => ({
path: file.path,
mode: '100644' as const,
type: 'blob' as const,
content: file.content
}));
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/trees`,
{
method: "POST",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server",
"Content-Type": "application/json"
},
body: JSON.stringify({
tree,
base_tree: baseTree
})
}
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubTreeSchema.parse(await response.json());
}
async function createCommit(
owner: string,
repo: string,
message: string,
tree: string,
parents: string[]
): Promise<GitHubCommit> {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/commits`,
{
method: "POST",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server",
"Content-Type": "application/json"
},
body: JSON.stringify({
message,
tree,
parents
})
}
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubCommitSchema.parse(await response.json());
}
async function updateReference(
owner: string,
repo: string,
ref: string,
sha: string
): Promise<GitHubReference> {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/refs/${ref}`,
{
method: "PATCH",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server",
"Content-Type": "application/json"
},
body: JSON.stringify({
sha,
force: true
})
}
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubReferenceSchema.parse(await response.json());
}
async function pushFiles(
owner: string,
repo: string,
branch: string,
files: FileOperation[],
message: string
): Promise<GitHubReference> {
const refResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
{
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server"
}
}
);
if (!refResponse.ok) {
throw new Error(`GitHub API error: ${refResponse.statusText}`);
}
const ref = GitHubReferenceSchema.parse(await refResponse.json());
const commitSha = ref.object.sha;
const tree = await createTree(owner, repo, files, commitSha);
const commit = await createCommit(owner, repo, message, tree.sha, [commitSha]);
return await updateReference(owner, repo, `heads/${branch}`, commit.sha);
}
async function searchRepositories(
query: string,
page: number = 1,
perPage: number = 30
): Promise<GitHubSearchResponse> {
const url = new URL("https://api.github.com/search/repositories");
url.searchParams.append("q", query);
url.searchParams.append("page", page.toString());
url.searchParams.append("per_page", perPage.toString());
const response = await fetch(url.toString(), {
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server"
}
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubSearchResponseSchema.parse(await response.json());
}
async function createRepository(
options: z.infer<typeof CreateRepositoryOptionsSchema>
): Promise<GitHubRepository> {
const response = await fetch("https://api.github.com/user/repos", {
method: "POST",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server",
"Content-Type": "application/json"
},
body: JSON.stringify(options)
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubRepositorySchema.parse(await response.json());
}
async function listCommits(
owner: string,
repo: string,
page: number = 1,
perPage: number = 30,
sha?: string,
): Promise<GitHubCommit[]> {
const url = new URL(`https://api.github.com/repos/${owner}/${repo}/commits`);
url.searchParams.append("page", page.toString());
url.searchParams.append("per_page", perPage.toString());
if (sha) {
url.searchParams.append("sha", sha);
}
const response = await fetch(
url.toString(),
{
method: "GET",
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server",
"Content-Type": "application/json"
},
}
);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return GitHubCommitSchema.array().parse(await response.json());
}
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "create_or_update_file",
description: "Create or update a single file in a GitHub repository",
inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema)
},
{
name: "search_repositories",
description: "Search for GitHub repositories",
inputSchema: zodToJsonSchema(SearchRepositoriesSchema)
},
{
name: "create_repository",
description: "Create a new GitHub repository in your account",
inputSchema: zodToJsonSchema(CreateRepositorySchema)
},
{
name: "get_file_contents",
description: "Get the contents of a file or directory from a GitHub repository",
inputSchema: zodToJsonSchema(GetFileContentsSchema)
},
{
name: "push_files",
description: "Push multiple files to a GitHub repository in a single commit",
inputSchema: zodToJsonSchema(PushFilesSchema)
},
{
name: "create_issue",
description: "Create a new issue in a GitHub repository",
inputSchema: zodToJsonSchema(CreateIssueSchema)
},
{
name: "create_pull_request",
description: "Create a new pull request in a GitHub repository",
inputSchema: zodToJsonSchema(CreatePullRequestSchema)
},
{
name: "fork_repository",
description: "Fork a GitHub repository to your account or specified organization",
inputSchema: zodToJsonSchema(ForkRepositorySchema)
},
{
name: "create_branch",
description: "Create a new branch in a GitHub repository",
inputSchema: zodToJsonSchema(CreateBranchSchema)
},
{
name: "list_commits",
description: "Get list of commits of a branch in a GitHub repository",
inputSchema: zodToJsonSchema(ListCommitsSchema)
}
]
};
});
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 forkRepository(args.owner, args.repo, args.organization);
return { content: [{ type: "text", text: JSON.stringify(fork, null, 2) }] };
}
case "create_branch": {
const args = CreateBranchSchema.parse(request.params.arguments);
let sha: string;
if (args.from_branch) {
const response = await fetch(
`https://api.github.com/repos/${args.owner}/${args.repo}/git/refs/heads/${args.from_branch}`,
{
headers: {
"Authorization": `token ${GITHUB_PERSONAL_ACCESS_TOKEN}`,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "github-mcp-server"
}
}
);
if (!response.ok) {
throw new Error(`Source branch '${args.from_branch}' not found`);
}
const data = GitHubReferenceSchema.parse(await response.json());
sha = data.object.sha;
} else {
sha = await getDefaultBranchSHA(args.owner, args.repo);
}
const branch = await createBranch(args.owner, args.repo, {
ref: args.branch,
sha
});
return { content: [{ type: "text", text: JSON.stringify(branch, null, 2) }] };
}
case "search_repositories": {
const args = SearchRepositoriesSchema.parse(request.params.arguments);
const results = await searchRepositories(args.query, args.page, args.perPage);
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.owner, args.repo, args.path, args.branch);
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.owner,
args.repo,
args.path,
args.content,
args.message,
args.branch,
args.sha
);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "push_files": {
const args = PushFilesSchema.parse(request.params.arguments);
const result = await pushFiles(
args.owner,
args.repo,
args.branch,
args.files,
args.message
);
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
case "create_issue": {
const args = CreateIssueSchema.parse(request.params.arguments);
const { owner, repo, ...options } = args;
const issue = await createIssue(owner, repo, options);
return { content: [{ type: "text", text: JSON.stringify(issue, null, 2) }] };
}
case "create_pull_request": {
const args = CreatePullRequestSchema.parse(request.params.arguments);
const { owner, repo, ...options } = args;
const pullRequest = await createPullRequest(owner, repo, options);
return { content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }] };
}
case "list_commits": {
const args = ListCommitsSchema.parse(request.params.arguments);
const results = await listCommits(args.owner, args.repo, args.page, args.perPage, args.sha);
return { content: [{ type: "text", text: JSON.stringify(results, 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("GitHub MCP Server running on stdio");
}
runServer().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});