GitHub MCP Server

  • src
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { Octokit } from 'octokit'; const GITHUB_TOKEN = process.env.GITHUB_TOKEN; if (!GITHUB_TOKEN) { throw new Error('GITHUB_TOKEN environment variable is required'); } interface RepoArgs { command: string; } const isValidRepoArgs = (args: any): args is RepoArgs => typeof args === 'object' && args !== null && typeof args.command === 'string'; interface ParsedCommand { mode: 'create' | 'update-description' | 'update-tags' | 'update-website'; name?: string; description?: string; tags?: string[]; website?: string; } const parseCommand = (command: string): ParsedCommand => { const lowercaseCommand = command.toLowerCase(); // Check for update commands if (lowercaseCommand.includes('update') || lowercaseCommand.includes('change') || lowercaseCommand.includes('set')) { // Extract repository name - common for all update commands const repoMatch = command.match(/(?:update|change|set|modify)\s+(?:the\s+)?(?:repository\s+)?([a-zA-Z0-9-_/]+)/i); if (!repoMatch) { throw new McpError(ErrorCode.InvalidParams, 'Repository name not found in update command'); } const repoName = repoMatch[1]; // Check for description update if (lowercaseCommand.includes('description')) { const descMatch = command.match(/description\s+(?:to|as)\s+["']?([^"']+)["']?/i); if (!descMatch) { throw new McpError(ErrorCode.InvalidParams, 'New description not found in update command'); } return { mode: 'update-description', name: repoName, description: descMatch[1].trim() }; } // Check for tags update if (lowercaseCommand.includes('tags') || lowercaseCommand.includes('topics')) { return { mode: 'update-tags', name: repoName, tags: extractTags(command) }; } // Check for website update if (lowercaseCommand.includes('website') || lowercaseCommand.includes('homepage') || lowercaseCommand.includes('url')) { const website = extractWebsite(command); if (!website) { throw new McpError(ErrorCode.InvalidParams, 'New website URL not found in update command'); } return { mode: 'update-website', name: repoName, website }; } throw new McpError(ErrorCode.InvalidParams, 'Unknown update type. Use "description", "tags", or "website".'); } // This is a create command const descMatch = command.match(/(?:create|make|new)\s+(?:a\s+)?(?:repository\s+)?(?:for|called|named)?\s+([^,\.]+)/i); if (!descMatch) { throw new McpError(ErrorCode.InvalidParams, 'Repository description not found in create command'); } return { mode: 'create', description: descMatch[1].trim(), tags: extractTags(command), website: extractWebsite(command) }; }; const extractTags = (command: string): string[] => { // Look for tags/topics after keywords, excluding the "to" keyword const tagMatch = command.match(/(?:tags|topics|labeled|tagged)\s+(?:to|as)\s+["']?([^"']+)["']?/i); if (!tagMatch) return []; // Split tags by spaces or commas and clean them up return tagMatch[1] .split(/[\s,]+/) .filter(tag => tag.toLowerCase() !== 'to') // Extra safety to filter out "to" .map(tag => tag.toLowerCase() .trim() .replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphens .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens .substring(0, 50)) // Limit to 50 chars .filter(tag => tag.length > 0 && tag.length <= 50 && /^[a-z0-9]/.test(tag)); // Must start with lowercase letter or number }; const extractWebsite = (command: string): string | undefined => { // Look for website/homepage/url after keywords const websiteMatch = command.match(/(?:website|homepage|url)\s+(?:to|as)\s+["']?([^"'\s]+)["']?/i); return websiteMatch ? websiteMatch[1] : undefined; }; class GitHubServer { private server: Server; private octokit: Octokit; constructor() { this.server = new Server( { name: 'github-server', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); this.octokit = new Octokit({ auth: GITHUB_TOKEN, }); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'create_repo', description: 'Create or update GitHub repositories using natural language commands', inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'Natural language command like "Create a repository for my machine learning project with tags python tensorflow" or "Update repository-name description to New description with tags updated ml"' } }, required: ['command'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name !== 'create_repo') { throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } if (!isValidRepoArgs(request.params.arguments)) { throw new McpError( ErrorCode.InvalidParams, 'Invalid command format' ); } try { const parsed = parseCommand(request.params.arguments.command); let response; if (parsed.mode === 'create') { // Generate a repository name based on the description const repoName = parsed.description! .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); // Create the repository response = await this.octokit.rest.repos.createForAuthenticatedUser({ name: repoName, description: parsed.description, homepage: parsed.website, topics: parsed.tags, auto_init: true, }); } else { // Handle different types of updates const [owner, repo] = parsed.name!.split('/'); switch (parsed.mode) { case 'update-description': response = await this.octokit.rest.repos.update({ owner, repo, description: parsed.description, }); break; case 'update-tags': response = await this.octokit.rest.repos.replaceAllTopics({ owner, repo, names: parsed.tags || [], }); break; case 'update-website': response = await this.octokit.rest.repos.update({ owner, repo, homepage: parsed.website, }); break; } } // Format response based on operation type const formatResponse = () => { switch (parsed.mode) { case 'create': { const repo = response.data as { html_url: string; name: string; description: string; topics: string[]; homepage: string | null; }; return { message: 'Repository created successfully', url: repo.html_url, name: repo.name, description: repo.description, topics: repo.topics, homepage: repo.homepage, }; } case 'update-description': { const repo = response.data as { name: string; description: string; }; return { message: 'Repository description updated', name: repo.name, description: repo.description, }; } case 'update-tags': { const topics = response.data as { names: string[]; }; return { message: 'Repository topics updated', topics: topics.names, }; } case 'update-website': { const repo = response.data as { name: string; homepage: string | null; }; return { message: 'Repository website updated', name: repo.name, homepage: repo.homepage, }; } } }; return { content: [ { type: 'text', text: JSON.stringify(formatResponse(), null, 2), }, ], }; } catch (error) { if (error instanceof Error) { return { content: [ { type: 'text', text: `GitHub API error: ${error.message}`, }, ], isError: true, }; } throw error; } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('GitHub MCP server running on stdio'); } } const server = new GitHubServer(); server.run().catch(console.error);