Updated Github to Zod

This commit is contained in:
Mahesh Murag
2024-11-21 00:18:09 -05:00
parent c60d4564f2
commit 859c7b8520
4 changed files with 559 additions and 804 deletions

View File

@@ -8,23 +8,43 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import fetch from "node-fetch";
import {
GitHubContent,
GitHubCreateUpdateFileResponse,
GitHubSearchResponse,
GitHubRepository,
GitHubTree,
GitHubCommit,
GitHubReference,
CreateRepositoryOptions,
FileOperation,
CreateTreeParams,
GitHubPullRequest,
CreateIssueOptions,
CreatePullRequestOptions,
GitHubIssue,
GitHubFork,
CreateBranchOptions,
} from './interfaces.js';
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
} from './schemas.js';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const server = new Server({
name: "github-mcp-server",
@@ -64,15 +84,14 @@ async function forkRepository(
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubFork;
return GitHubForkSchema.parse(await response.json());
}
async function createBranch(
owner: string,
repo: string,
options: CreateBranchOptions
options: z.infer<typeof CreateBranchOptionsSchema>
): Promise<GitHubReference> {
// The ref needs to be in the format "refs/heads/branch-name"
const fullRef = `refs/heads/${options.ref}`;
const response = await fetch(
@@ -96,10 +115,9 @@ async function createBranch(
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubReference;
return GitHubReferenceSchema.parse(await response.json());
}
// Helper function to get the default branch SHA
async function getDefaultBranchSHA(
owner: string,
repo: string
@@ -115,7 +133,6 @@ async function getDefaultBranchSHA(
}
);
// If main branch doesn't exist, try master
if (!response.ok) {
const masterResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/master`,
@@ -132,15 +149,20 @@ async function getDefaultBranchSHA(
throw new Error("Could not find default branch (tried 'main' and 'master')");
}
const data = await masterResponse.json() as GitHubReference;
const data = GitHubReferenceSchema.parse(await masterResponse.json());
return data.object.sha;
}
const data = await response.json() as GitHubReference;
const data = GitHubReferenceSchema.parse(await response.json());
return data.object.sha;
}
async function getFileContents(owner: string, repo: string, path: string, branch?: string): Promise<GitHubContent> {
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}`;
@@ -155,28 +177,23 @@ async function getFileContents(owner: string, repo: string, path: string, branch
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`GitHub API error (${response.status}): ${errorData}`);
throw new Error(`GitHub API error: ${response.statusText}`);
}
const data = await response.json() as GitHubContent;
const data = GitHubContentSchema.parse(await response.json());
// If it's a file, decode the content
if (!Array.isArray(data) && data.content) {
return {
...data,
content: Buffer.from(data.content, 'base64').toString('utf8')
};
data.content = Buffer.from(data.content, 'base64').toString('utf8');
}
return data;
}
async function createIssue(
owner: string,
repo: string,
options: CreateIssueOptions
options: z.infer<typeof CreateIssueOptionsSchema>
): Promise<GitHubIssue> {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues`,
@@ -196,13 +213,13 @@ async function createIssue(
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubIssue;
return GitHubIssueSchema.parse(await response.json());
}
async function createPullRequest(
owner: string,
repo: string,
options: CreatePullRequestOptions
options: z.infer<typeof CreatePullRequestOptionsSchema>
): Promise<GitHubPullRequest> {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/pulls`,
@@ -222,7 +239,7 @@ async function createPullRequest(
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubPullRequest;
return GitHubPullRequestSchema.parse(await response.json());
}
async function createOrUpdateFile(
@@ -234,19 +251,16 @@ async function createOrUpdateFile(
branch: string,
sha?: string
): Promise<GitHubCreateUpdateFileResponse> {
// Properly encode content to base64
const encodedContent = Buffer.from(content).toString('base64');
let currentSha = sha;
if (!currentSha) {
// Try to get current file SHA if it exists in the specified branch
try {
const existingFile = await getFileContents(owner, repo, path, branch);
if (!Array.isArray(existingFile)) {
currentSha = existingFile.sha;
}
} catch (error) {
// File doesn't exist in this branch, which is fine for creation
console.error('Note: File does not exist in branch, will create new file');
}
}
@@ -272,11 +286,10 @@ async function createOrUpdateFile(
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`GitHub API error (${response.status}): ${errorData}`);
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubCreateUpdateFileResponse;
return GitHubCreateUpdateFileResponseSchema.parse(await response.json());
}
async function createTree(
@@ -285,10 +298,10 @@ async function createTree(
files: FileOperation[],
baseTree?: string
): Promise<GitHubTree> {
const tree: CreateTreeParams[] = files.map(file => ({
const tree = files.map(file => ({
path: file.path,
mode: '100644',
type: 'blob',
mode: '100644' as const,
type: 'blob' as const,
content: file.content
}));
@@ -313,7 +326,7 @@ async function createTree(
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubTree;
return GitHubTreeSchema.parse(await response.json());
}
async function createCommit(
@@ -345,7 +358,7 @@ async function createCommit(
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubCommit;
return GitHubCommitSchema.parse(await response.json());
}
async function updateReference(
@@ -375,7 +388,7 @@ async function updateReference(
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubReference;
return GitHubReferenceSchema.parse(await response.json());
}
async function pushFiles(
@@ -400,7 +413,7 @@ async function pushFiles(
throw new Error(`GitHub API error: ${refResponse.statusText}`);
}
const ref = await refResponse.json() as GitHubReference;
const ref = GitHubReferenceSchema.parse(await refResponse.json());
const commitSha = ref.object.sha;
const tree = await createTree(owner, repo, files, commitSha);
@@ -430,10 +443,12 @@ async function searchRepositories(
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubSearchResponse;
return GitHubSearchResponseSchema.parse(await response.json());
}
async function createRepository(options: CreateRepositoryOptions): Promise<GitHubRepository> {
async function createRepository(
options: z.infer<typeof CreateRepositoryOptionsSchema>
): Promise<GitHubRepository> {
const response = await fetch("https://api.github.com/user/repos", {
method: "POST",
headers: {
@@ -449,7 +464,7 @@ async function createRepository(options: CreateRepositoryOptions): Promise<GitHu
throw new Error(`GitHub API error: ${response.statusText}`);
}
return await response.json() as GitHubRepository;
return GitHubRepositorySchema.parse(await response.json());
}
server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -458,473 +473,165 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
{
name: "create_or_update_file",
description: "Create or update a single file in a GitHub repository",
inputSchema: {
type: "object",
properties: {
owner: {
type: "string",
description: "Repository owner (username or organization)"
},
repo: {
type: "string",
description: "Repository name"
},
path: {
type: "string",
description: "Path where to create/update the file"
},
content: {
type: "string",
description: "Content of the file"
},
message: {
type: "string",
description: "Commit message"
},
branch: {
type: "string",
description: "Branch to create/update the file in"
},
sha: {
type: "string",
description: "SHA of the file being replaced (required when updating existing files)"
}
},
required: ["owner", "repo", "path", "content", "message", "branch"]
}
},
inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema)
},
{
name: "search_repositories",
description: "Search for GitHub repositories",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query (see GitHub search syntax)"
},
page: {
type: "number",
description: "Page number for pagination (default: 1)"
},
perPage: {
type: "number",
description: "Number of results per page (default: 30, max: 100)"
}
},
required: ["query"]
}
inputSchema: zodToJsonSchema(SearchRepositoriesSchema)
},
{
name: "create_repository",
description: "Create a new GitHub repository in your account",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Repository name"
},
description: {
type: "string",
description: "Repository description"
},
private: {
type: "boolean",
description: "Whether the repository should be private"
},
autoInit: {
type: "boolean",
description: "Initialize with README.md"
}
},
required: ["name"]
}
inputSchema: zodToJsonSchema(CreateRepositorySchema)
},
{
name: "get_file_contents",
description: "Get the contents of a file or directory from a GitHub repository",
inputSchema: {
type: "object",
properties: {
owner: {
type: "string",
description: "Repository owner (username or organization)"
},
repo: {
type: "string",
description: "Repository name"
},
path: {
type: "string",
description: "Path to the file or directory"
}
},
required: ["owner", "repo", "path"]
}
inputSchema: zodToJsonSchema(GetFileContentsSchema)
},
{
name: "push_files",
description: "Push multiple files to a GitHub repository in a single commit",
inputSchema: {
type: "object",
properties: {
owner: {
type: "string",
description: "Repository owner (username or organization)"
},
repo: {
type: "string",
description: "Repository name"
},
branch: {
type: "string",
description: "Branch to push to (e.g., 'main' or 'master')"
},
files: {
type: "array",
description: "Array of files to push",
items: {
type: "object",
properties: {
path: {
type: "string",
description: "Path where to create the file"
},
content: {
type: "string",
description: "Content of the file"
}
},
required: ["path", "content"]
}
},
message: {
type: "string",
description: "Commit message"
}
},
required: ["owner", "repo", "branch", "files", "message"]
}
inputSchema: zodToJsonSchema(PushFilesSchema)
},
{
name: "create_issue",
description: "Create a new issue in a GitHub repository",
inputSchema: {
type: "object",
properties: {
owner: {
type: "string",
description: "Repository owner (username or organization)"
},
repo: {
type: "string",
description: "Repository name"
},
title: {
type: "string",
description: "Issue title"
},
body: {
type: "string",
description: "Issue body/description"
},
assignees: {
type: "array",
items: { type: "string" },
description: "Array of usernames to assign"
},
labels: {
type: "array",
items: { type: "string" },
description: "Array of label names"
},
milestone: {
type: "number",
description: "Milestone number to assign"
}
},
required: ["owner", "repo", "title"]
}
inputSchema: zodToJsonSchema(CreateIssueSchema)
},
{
name: "create_pull_request",
description: "Create a new pull request in a GitHub repository",
inputSchema: {
type: "object",
properties: {
owner: {
type: "string",
description: "Repository owner (username or organization)"
},
repo: {
type: "string",
description: "Repository name"
},
title: {
type: "string",
description: "Pull request title"
},
body: {
type: "string",
description: "Pull request body/description"
},
head: {
type: "string",
description: "The name of the branch where your changes are implemented"
},
base: {
type: "string",
description: "The name of the branch you want the changes pulled into"
},
draft: {
type: "boolean",
description: "Whether to create the pull request as a draft"
},
maintainer_can_modify: {
type: "boolean",
description: "Whether maintainers can modify the pull request"
}
},
required: ["owner", "repo", "title", "head", "base"]
}
inputSchema: zodToJsonSchema(CreatePullRequestSchema)
},
{
name: "fork_repository",
description: "Fork a GitHub repository to your account or specified organization",
inputSchema: {
type: "object",
properties: {
owner: {
type: "string",
description: "Repository owner (username or organization)"
},
repo: {
type: "string",
description: "Repository name"
},
organization: {
type: "string",
description: "Optional: organization to fork to (defaults to your personal account)"
}
},
required: ["owner", "repo"]
}
inputSchema: zodToJsonSchema(ForkRepositorySchema)
},
{
name: "create_branch",
description: "Create a new branch in a GitHub repository",
inputSchema: {
type: "object",
properties: {
owner: {
type: "string",
description: "Repository owner (username or organization)"
},
repo: {
type: "string",
description: "Repository name"
},
branch: {
type: "string",
description: "Name for the new branch"
},
from_branch: {
type: "string",
description: "Optional: source branch to create from (defaults to the repository's default branch)"
}
},
required: ["owner", "repo", "branch"]
}
inputSchema: zodToJsonSchema(CreateBranchSchema)
}
]
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "fork_repository") {
try {
if (!request.params.arguments) {
throw new Error("Arguments are required");
}
const args = request.params.arguments as {
owner: string;
repo: string;
organization?: string;
};
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 { toolResult: fork };
}
const fork = await forkRepository(args.owner, args.repo, args.organization);
return { toolResult: fork };
}
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 (request.params.name === "create_branch") {
if (!request.params.arguments) {
throw new Error("Arguments are required");
}
const args = request.params.arguments as {
owner: string;
repo: string;
branch: string;
from_branch?: string;
};
// If no source branch is specified, use the default branch
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);
}
);
if (!response.ok) {
throw new Error(`Source branch '${args.from_branch}' not found`);
const branch = await createBranch(args.owner, args.repo, {
ref: args.branch,
sha
});
return { toolResult: branch };
}
const data = await response.json() as GitHubReference;
sha = data.object.sha;
} else {
sha = await getDefaultBranchSHA(args.owner, args.repo);
}
const branch = await createBranch(args.owner, args.repo, {
ref: args.branch,
sha: sha
});
return { toolResult: branch };
}
if (request.params.name === "search_repositories") {
const { query, page, perPage } = request.params.arguments as {
query: string;
page?: number;
perPage?: number;
};
const results = await searchRepositories(query, page, perPage);
return { toolResult: results };
}
if (request.params.name === "create_repository") {
const options = request.params.arguments as CreateRepositoryOptions;
const repository = await createRepository(options);
return { toolResult: repository };
}
if (request.params.name === "get_file_contents") {
if (!request.params.arguments) {
throw new Error("Arguments are required");
}
const args = request.params.arguments as {
owner: string;
repo: string;
path: string;
branch?: string;
};
const contents = await getFileContents(args.owner, args.repo, args.path, args.branch);
return { toolResult: contents };
}
if (request.params.name === "create_or_update_file") {
if (!request.params.arguments) {
throw new Error("Arguments are required");
}
const args = request.params.arguments as {
owner: string;
repo: string;
path: string;
content: string;
message: string;
branch: string;
sha?: string;
};
try {
const result = await createOrUpdateFile(
args.owner,
args.repo,
args.path,
args.content,
args.message,
args.branch,
args.sha
);
return { toolResult: result };
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to create/update file: ${error.message}`);
case "search_repositories": {
const args = SearchRepositoriesSchema.parse(request.params.arguments);
const results = await searchRepositories(args.query, args.page, args.perPage);
return { toolResult: results };
}
throw error;
case "create_repository": {
const args = CreateRepositorySchema.parse(request.params.arguments);
const repository = await createRepository(args);
return { toolResult: repository };
}
case "get_file_contents": {
const args = GetFileContentsSchema.parse(request.params.arguments);
const contents = await getFileContents(args.owner, args.repo, args.path, args.branch);
return { toolResult: contents };
}
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 { toolResult: result };
}
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 { toolResult: result };
}
case "create_issue": {
const args = CreateIssueSchema.parse(request.params.arguments);
const { owner, repo, ...options } = args;
const issue = await createIssue(owner, repo, options);
return { toolResult: issue };
}
case "create_pull_request": {
const args = CreatePullRequestSchema.parse(request.params.arguments);
const { owner, repo, ...options } = args;
const pullRequest = await createPullRequest(owner, repo, options);
return { toolResult: pullRequest };
}
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;
}
if (request.params.name === "push_files") {
const { owner, repo, branch, files, message } = request.params.arguments as {
owner: string;
repo: string;
branch: string;
files: FileOperation[];
message: string;
};
const result = await pushFiles(owner, repo, branch, files, message);
return { toolResult: result };
}
if (request.params.name === "create_issue") {
if (!request.params.arguments) {
throw new Error("Arguments are required");
}
const args = request.params.arguments as {
owner: string;
repo: string;
title: string;
body?: string;
assignees?: string[];
milestone?: number;
labels?: string[];
};
const { owner, repo, ...options } = args;
const issue = await createIssue(owner, repo, options);
return { toolResult: issue };
}
if (request.params.name === "create_pull_request") {
if (!request.params.arguments) {
throw new Error("Arguments are required");
}
const args = request.params.arguments as {
owner: string;
repo: string;
title: string;
body?: string;
head: string;
base: string;
maintainer_can_modify?: boolean;
draft?: boolean;
};
const { owner, repo, ...options } = args;
const pullRequest = await createPullRequest(owner, repo, options);
return { toolResult: pullRequest };
}
throw new Error("Tool not found");
});
async function runServer() {