diff --git a/src/github/Dockerfile b/src/github/Dockerfile index a3ad36b8d9..0c1142439e 100644 --- a/src/github/Dockerfile +++ b/src/github/Dockerfile @@ -10,6 +10,12 @@ RUN --mount=type=cache,target=/root/.npm npm install FROM node:22.12-alpine AS release +# Install GitHub CLI +RUN apk add --no-cache curl git bash && \ + curl -Ls https://github.com/cli/cli/releases/download/v2.44.1/gh_2.44.1_linux_amd64.tar.gz | tar -xz -C /tmp && \ + mv /tmp/gh_*_linux_amd64/bin/gh /usr/local/bin/ && \ + rm -rf /tmp/gh_*_linux_amd64 + COPY --from=builder /app/dist /app/dist COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package-lock.json /app/package-lock.json diff --git a/src/github/README.md b/src/github/README.md index 026dde9b52..1443edc03f 100644 --- a/src/github/README.md +++ b/src/github/README.md @@ -303,7 +303,34 @@ For detailed search syntax, see [GitHub's searching documentation](https://docs. ## Setup -### Personal Access Token +### Authentication + +This server supports two authentication methods: + +#### 1. GitHub CLI Authentication (Recommended) + +The server will automatically use your GitHub CLI authentication if available: + +1. Install the GitHub CLI: + - MacOS: `brew install gh` + - Other platforms: [GitHub CLI installation instructions](https://github.com/cli/cli#installation) + +2. Authenticate with GitHub: + ```bash + gh auth login + ``` + +3. Follow the prompts to authenticate. Make sure to select the HTTPS protocol. + +4. Verify your authentication: + ```bash + gh auth status + ``` + +#### 2. Personal Access Token + +Alternatively, you can use a Personal Access Token: + [Create a GitHub Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with appropriate permissions: - Go to [Personal access tokens](https://github.com/settings/tokens) (in GitHub Settings > Developer settings) - Select which repositories you'd like this token to have access to (Public, All, or Select) @@ -311,10 +338,12 @@ For detailed search syntax, see [GitHub's searching documentation](https://docs. - Alternatively, if working only with public repositories, select only the `public_repo` scope - Copy the generated token +Then set the environment variable `GITHUB_PERSONAL_ACCESS_TOKEN` with your token. + ### Usage with Claude Desktop To use this with Claude Desktop, add the following to your `claude_desktop_config.json`: -#### Docker +#### Docker with Personal Access Token ```json { "mcpServers": { @@ -336,8 +365,28 @@ To use this with Claude Desktop, add the following to your `claude_desktop_confi } ``` +#### Docker with GitHub CLI Auth +```json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "~/.config/gh:/root/.config/gh", + "mcp/github" + ] + } + } +} +``` + ### NPX +#### NPX with Personal Access Token ```json { "mcpServers": { @@ -355,6 +404,21 @@ To use this with Claude Desktop, add the following to your `claude_desktop_confi } ``` +#### NPX with GitHub CLI Auth +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ] + } + } +} +``` + ## Build Docker build: diff --git a/src/github/TEST.md b/src/github/TEST.md new file mode 100644 index 0000000000..90e59fd074 --- /dev/null +++ b/src/github/TEST.md @@ -0,0 +1,51 @@ +# GitHub MCP Server Test + +This directory contains a test script for the GitHub MCP server, which validates its core functionality. + +## Test Summary + +The test script (`test.js`) validates: + +1. **Authentication**: Checks if GitHub CLI authentication is available +2. **List Tools**: Verifies the server can list all available tools +3. **Repository Search**: Tests searching for GitHub repositories +4. **File Content Retrieval**: Tests retrieving file contents from a repository + +## Running Tests + +Make sure you've built the server first: + +```bash +npm install +npm run build +``` + +Then run the test: + +```bash +node test.js +``` + +## Authentication + +For complete testing, authenticate with GitHub CLI: + +```bash +gh auth login +``` + +## Test Results + +A successful test run will show: + +- ✅ Tools listing test passed +- ✅ Repository search test passed +- ✅ Get file contents test passed (if authenticated) + +## Troubleshooting + +If tests fail: + +1. Make sure the server is built correctly (`npm run build`) +2. Check GitHub CLI authentication status (`gh auth status`) +3. Verify network connectivity to GitHub API \ No newline at end of file diff --git a/src/github/common/utils.ts b/src/github/common/utils.ts index e85691a0be..a0bdcd70e5 100644 --- a/src/github/common/utils.ts +++ b/src/github/common/utils.ts @@ -28,6 +28,48 @@ export function buildUrl(baseUrl: string, params: Record { + // Check if we have a valid cached token + const now = Date.now(); + if (cachedGitHubToken && now < tokenExpiryTime) { + return cachedGitHubToken; + } + + // Try to get token from environment variable first + if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) { + cachedGitHubToken = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + tokenExpiryTime = now + TOKEN_CACHE_DURATION; + return cachedGitHubToken; + } + + // Try to get token from gh CLI + try { + const { execSync } = await import('child_process'); + const output = execSync('gh auth token').toString().trim(); + + if (output) { + cachedGitHubToken = output; + tokenExpiryTime = now + TOKEN_CACHE_DURATION; + return cachedGitHubToken; + } + } catch (error) { + console.error("Failed to get GitHub token from gh CLI:", error); + } + + console.warn( + "No GitHub authentication found. Please either:\n" + + "1. Set GITHUB_PERSONAL_ACCESS_TOKEN environment variable, or\n" + + "2. Authenticate with GitHub CLI using 'gh auth login'" + ); + + return null; +} + export async function githubRequest( url: string, options: RequestOptions = {} @@ -39,8 +81,23 @@ export async function githubRequest( ...options.headers, }; - if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) { - headers["Authorization"] = `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`; + // Get token from gh CLI or environment variable + const token = await getGitHubToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } else { + // For operations requiring authentication, it's better to fail early + // Public GitHub APIs will still work without a token + if ( + url.includes('/repos/') && + (options.method === 'POST' || options.method === 'PUT' || options.method === 'PATCH' || options.method === 'DELETE') + ) { + throw new Error( + "GitHub authentication required for this operation. Please either:\n" + + "1. Set the GITHUB_PERSONAL_ACCESS_TOKEN environment variable, or\n" + + "2. Authenticate with GitHub CLI using 'gh auth login'" + ); + } } const response = await fetch(url, { diff --git a/src/github/package.json b/src/github/package.json index 29f5296b6b..b69fa290bc 100644 --- a/src/github/package.json +++ b/src/github/package.json @@ -16,7 +16,8 @@ "scripts": { "build": "tsc && shx chmod +x dist/*.js", "prepare": "npm run build", - "watch": "tsc --watch" + "watch": "tsc --watch", + "test": "node ./test-github-server.js" }, "dependencies": { "@modelcontextprotocol/sdk": "1.0.1", diff --git a/src/github/test-github-server.js b/src/github/test-github-server.js new file mode 100755 index 0000000000..86028ea2eb --- /dev/null +++ b/src/github/test-github-server.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node +// GitHub MCP Server Test Script +import { spawn } from 'child_process'; +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import path from 'path'; + +// ANSI color codes for terminal output +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + bold: '\x1b[1m' +}; + +// Banner +console.log(`${colors.bold}${colors.blue}╔════════════════════════════════════════╗${colors.reset}`); +console.log(`${colors.bold}${colors.blue}║ GitHub MCP Server Test ║${colors.reset}`); +console.log(`${colors.bold}${colors.blue}╚════════════════════════════════════════╝${colors.reset}`); + +// Check prerequisites +const checkPrerequisites = () => { + console.log(`\n${colors.bold}Checking Prerequisites:${colors.reset}`); + + // Check if dist directory exists + if (!existsSync(path.join(process.cwd(), 'dist'))) { + console.log(`${colors.red}✘ Build files not found. Please run "npm run build" first.${colors.reset}`); + process.exit(1); + } + console.log(`${colors.green}✓ Build files exist${colors.reset}`); + + // Check if GitHub CLI is installed + try { + execSync('gh --version', { stdio: 'ignore' }); + console.log(`${colors.green}✓ GitHub CLI is installed${colors.reset}`); + } catch (error) { + console.log(`${colors.yellow}! GitHub CLI not found. Limited testing will be available.${colors.reset}`); + console.log(` To install GitHub CLI: https://cli.github.com/`); + } + + // Check GitHub CLI auth status + let isAuthenticated = false; + try { + const output = execSync('gh auth status 2>&1').toString(); + if (output.includes('Logged in to')) { + console.log(`${colors.green}✓ GitHub CLI is authenticated${colors.reset}`); + isAuthenticated = true; + } else { + console.log(`${colors.yellow}! GitHub CLI is not authenticated${colors.reset}`); + console.log(` To authenticate: gh auth login`); + } + } catch (error) { + console.log(`${colors.yellow}! GitHub CLI is not authenticated${colors.reset}`); + console.log(` To authenticate: gh auth login`); + } + + return isAuthenticated; +}; + +// Helper function to send requests to the MCP server +const sendMCPRequest = (serverProcess, request) => { + return new Promise((resolve, reject) => { + let responseData = ''; + let timeoutId; + + console.log(`${colors.cyan}→ Sending request: ${colors.reset}${JSON.stringify(request).substring(0, 100)}...`); + + const messageHandler = (data) => { + const chunk = data.toString(); + responseData += chunk; + + try { + const response = JSON.parse(responseData); + clearTimeout(timeoutId); + serverProcess.stdout.removeListener('data', messageHandler); + resolve(response); + } catch (error) { + // Probably incomplete JSON, continue collecting + } + }; + + // Set a timeout to prevent hanging + timeoutId = setTimeout(() => { + serverProcess.stdout.removeListener('data', messageHandler); + reject(new Error(`Timeout waiting for response to ${request.method}`)); + }, 10000); + + serverProcess.stdout.on('data', messageHandler); + serverProcess.stdin.write(JSON.stringify(request) + '\n'); + }); +}; + +// Run tests +const runTests = async (isAuthenticated) => { + console.log(`\n${colors.bold}Starting Tests:${colors.reset}`); + + // Start the server process + console.log(`${colors.cyan}→ Starting server...${colors.reset}`); + const serverProcess = spawn('node', ['dist/index.js'], { + cwd: process.cwd(), + stdio: ['pipe', 'pipe', process.stderr] + }); + + let testsPassed = 0; + let testsFailed = 0; + + try { + // Test 1: List tools + console.log(`\n${colors.bold}Test 1: Listing available tools${colors.reset}`); + const listToolsRequest = { + id: "1", + jsonrpc: "2.0", + method: "tools/list", + params: {} + }; + + try { + const listToolsResponse = await sendMCPRequest(serverProcess, listToolsRequest); + + if (listToolsResponse && listToolsResponse.result && listToolsResponse.result.tools) { + const toolNames = listToolsResponse.result.tools.map(t => t.name); + console.log(`${colors.green}✓ Found ${toolNames.length} tools${colors.reset}`); + console.log(` ${toolNames.slice(0, 5).join(', ')}${toolNames.length > 5 ? '...' : ''}`); + testsPassed++; + } else { + console.log(`${colors.red}✘ Tools listing test failed: Unexpected response format${colors.reset}`); + testsFailed++; + } + } catch (error) { + console.log(`${colors.red}✘ Tools listing test failed: ${error.message}${colors.reset}`); + testsFailed++; + } + + // Test 2: Search repositories + console.log(`\n${colors.bold}Test 2: Searching repositories${colors.reset}`); + const searchRequest = { + id: "2", + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "search_repositories", + arguments: { + query: "modelcontextprotocol" + } + } + }; + + try { + const searchResponse = await sendMCPRequest(serverProcess, searchRequest); + + if (searchResponse && searchResponse.result && searchResponse.result.content && searchResponse.result.content[0]) { + const searchContent = JSON.parse(searchResponse.result.content[0].text); + console.log(`${colors.green}✓ Search found ${searchContent.total_count} repositories${colors.reset}`); + testsPassed++; + } else { + console.log(`${colors.red}✘ Repository search test failed: Unexpected response format${colors.reset}`); + testsFailed++; + } + } catch (error) { + console.log(`${colors.red}✘ Repository search test failed: ${error.message}${colors.reset}`); + testsFailed++; + } + + // Test 3: Getting file contents (only if authenticated) + if (isAuthenticated) { + console.log(`\n${colors.bold}Test 3: Getting file contents${colors.reset}`); + const getFileRequest = { + id: "3", + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "get_file_contents", + arguments: { + owner: "modelcontextprotocol", + repo: "servers", + path: "README.md" + } + } + }; + + try { + const fileResponse = await sendMCPRequest(serverProcess, getFileRequest); + + if (fileResponse && fileResponse.result && fileResponse.result.content && fileResponse.result.content[0]) { + const fileContent = JSON.parse(fileResponse.result.content[0].text); + console.log(`${colors.green}✓ Got file contents, file size: ${fileContent.size || 'unknown'} bytes${colors.reset}`); + testsPassed++; + } else { + console.log(`${colors.red}✘ Get file contents test failed: Unexpected response format${colors.reset}`); + testsFailed++; + } + } catch (error) { + console.log(`${colors.red}✘ Get file contents test failed: ${error.message}${colors.reset}`); + testsFailed++; + } + } + + // Summary + console.log(`\n${colors.bold}Test Summary:${colors.reset}`); + console.log(`${colors.green}✓ Passed: ${testsPassed} tests${colors.reset}`); + if (testsFailed > 0) { + console.log(`${colors.red}✘ Failed: ${testsFailed} tests${colors.reset}`); + } + if (!isAuthenticated) { + console.log(`${colors.yellow}! Note: Authentication tests were skipped${colors.reset}`); + console.log(` To enable all tests: gh auth login`); + } + + } catch (error) { + console.error(`${colors.red}Test execution failed: ${error.message}${colors.reset}`); + } finally { + // Clean up + serverProcess.kill(); + console.log(`\n${colors.bold}Tests completed!${colors.reset}`); + } +}; + +// Main execution +const isAuthenticated = checkPrerequisites(); +runTests(isAuthenticated); \ No newline at end of file diff --git a/src/github/test.js b/src/github/test.js new file mode 100644 index 0000000000..0e754dc9cc --- /dev/null +++ b/src/github/test.js @@ -0,0 +1,182 @@ +// Test script for GitHub MCP server +// +// This script tests the basic functionality of the GitHub MCP server by: +// 1. Checking if GitHub CLI authentication is available +// 2. Testing listing of available tools +// 3. Testing repository search functionality +// 4. Testing file content retrieval (if authenticated) +// +// Usage: node test.js +// +// Note: For complete testing, make sure you're authenticated with GitHub CLI: +// gh auth login +import { spawn } from 'child_process'; +import { execSync } from 'child_process'; + +console.log('Testing GitHub MCP server...'); + +// Helper function to check if GitHub CLI is authenticated +const checkGitHubAuth = () => { + try { + const output = execSync('gh auth status').toString(); + if (output.includes('Logged in to')) { + console.log('GitHub CLI is authenticated'); + return true; + } + } catch (error) { + console.log('GitHub CLI is not authenticated'); + return false; + } + + return false; +}; + +// Helper function to send requests to the MCP server +const sendMCPRequest = (serverProcess, request) => { + return new Promise((resolve, reject) => { + let responseData = ''; + let timeoutId; + + console.log(`Sending request: ${JSON.stringify(request).substring(0, 100)}...`); + + const messageHandler = (data) => { + const chunk = data.toString(); + console.log(`Received chunk: ${chunk.substring(0, 50)}...`); + responseData += chunk; + + try { + const response = JSON.parse(responseData); + console.log('Successfully parsed response'); + clearTimeout(timeoutId); + serverProcess.stdout.removeListener('data', messageHandler); + resolve(response); + } catch (error) { + // Probably incomplete JSON, continue collecting + console.log('Incomplete JSON, waiting for more data...'); + } + }; + + // Set a timeout to prevent hanging + timeoutId = setTimeout(() => { + serverProcess.stdout.removeListener('data', messageHandler); + reject(new Error(`Timeout waiting for response to ${request.method}. Partial response: ${responseData.substring(0, 200)}...`)); + }, 5000); + + serverProcess.stdout.on('data', messageHandler); + serverProcess.stdin.write(JSON.stringify(request) + '\n'); + }); +}; + +// Run tests in sequence +const runTests = async () => { + // Check GitHub CLI auth + const isAuthenticated = checkGitHubAuth(); + if (!isAuthenticated) { + console.log('To fully test the GitHub server, please authenticate with GitHub CLI:'); + console.log(' gh auth login'); + console.log('Continuing with limited tests...'); + } + + // Start the server process + const serverProcess = spawn('node', ['dist/index.js'], { + cwd: process.cwd(), + stdio: ['pipe', 'pipe', process.stderr] + }); + + try { + // Test 1: List tools + console.log('\nTest 1: Listing available tools...'); + const listToolsRequest = { + id: "1", + jsonrpc: "2.0", + method: "tools/list", + params: {} + }; + + const listToolsResponse = await sendMCPRequest(serverProcess, listToolsRequest); + console.log('List tools response:', JSON.stringify(listToolsResponse).substring(0, 200) + '...'); + + if (listToolsResponse && listToolsResponse.result && listToolsResponse.result.tools) { + const toolNames = listToolsResponse.result.tools.map(t => t.name); + console.log(`Found ${toolNames.length} tools: ${toolNames.slice(0, 5).join(', ')}${toolNames.length > 5 ? '...' : ''}`); + console.log('✅ Tools listing test passed'); + } else { + console.log('❌ Tools listing test failed: Unexpected response format'); + console.log(JSON.stringify(listToolsResponse, null, 2)); + } + + // Test 2: Search repositories + console.log('\nTest 2: Searching repositories...'); + const searchRequest = { + id: "2", + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "search_repositories", + arguments: { + query: "modelcontextprotocol/servers" + } + } + }; + + try { + const searchResponse = await sendMCPRequest(serverProcess, searchRequest); + console.log('Search response received:', JSON.stringify(searchResponse).substring(0, 200) + '...'); + + if (searchResponse && searchResponse.result && searchResponse.result.content && searchResponse.result.content[0]) { + const searchContent = JSON.parse(searchResponse.result.content[0].text); + console.log(`Search found ${searchContent.total_count} repositories`); + console.log('✅ Repository search test passed'); + } else { + console.log('❌ Repository search test failed: Unexpected response format'); + console.log(JSON.stringify(searchResponse, null, 2)); + } + } catch (error) { + console.log('❌ Repository search test failed:', error); + } + + // More tests only if authenticated + if (isAuthenticated) { + // Test 3: Getting file contents (public repo) + console.log('\nTest 3: Getting file contents...'); + const getFileRequest = { + id: "3", + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "get_file_contents", + arguments: { + owner: "modelcontextprotocol", + repo: "servers", + path: "README.md" + } + } + }; + + try { + const fileResponse = await sendMCPRequest(serverProcess, getFileRequest); + console.log('File response received:', JSON.stringify(fileResponse).substring(0, 200) + '...'); + + if (fileResponse && fileResponse.result && fileResponse.result.content && fileResponse.result.content[0]) { + const fileContent = JSON.parse(fileResponse.result.content[0].text); + console.log(`Got file contents, file size: ${fileContent.size || 'unknown'} bytes`); + console.log('✅ Get file contents test passed'); + } else { + console.log('❌ Get file contents test failed: Unexpected response format'); + console.log(JSON.stringify(fileResponse, null, 2)); + } + } catch (error) { + console.log('❌ Get file contents test failed:', error); + } + } + + console.log('\nAll tests completed!'); + } catch (error) { + console.error('Test failed:', error); + } finally { + // Clean up + serverProcess.kill(); + } +}; + +runTests(); \ No newline at end of file