From 3a196e2363ea766e88b38da2e512709b1e42a362 Mon Sep 17 00:00:00 2001 From: Pedro Soares Date: Fri, 3 Oct 2025 10:17:28 -0300 Subject: [PATCH] feat: add streamable transport There's a new command called streamable the brings up the server with streamable HTTP transport. It also accepts --port option, defaults to 8080. ./github-mcp-server streamable --- cmd/github-mcp-server/main.go | 36 ++++++ docs/local-streamable.md | 175 +++++++++++++++++++++++++++++ internal/ghmcp/server.go | 111 ++++++++++++++++++ scripts/dockerhub-publish | 206 ++++++++++++++++++++++++++++++++++ 4 files changed, 528 insertions(+) create mode 100644 docs/local-streamable.md create mode 100755 scripts/dockerhub-publish diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 0a4545835e..24e9c97527 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -60,6 +60,39 @@ var ( return ghmcp.RunStdioServer(stdioServerConfig) }, } + + streamableCmd = &cobra.Command{ + Use: "streamable", + Short: "Start streamable HTTP server", + Long: `Start a server that communicates via streamable HTTP transport using JSON-RPC messages over HTTP.`, + RunE: func(_ *cobra.Command, _ []string) error { + token := viper.GetString("personal_access_token") + if token == "" { + return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") + } + + // If you're wondering why we're not using viper.GetStringSlice("toolsets"), + // it's because viper doesn't handle comma-separated values correctly for env + // vars when using GetStringSlice. + // https://github.com/spf13/viper/issues/380 + var enabledToolsets []string + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + + streamableServerConfig := ghmcp.StreamableServerConfig{ + Version: version, + Host: viper.GetString("host"), + Token: token, + EnabledToolsets: enabledToolsets, + DynamicToolsets: viper.GetBool("dynamic_toolsets"), + ReadOnly: viper.GetBool("read-only"), + Port: viper.GetInt("port"), + ContentWindowSize: viper.GetInt("content-window-size"), + } + return ghmcp.RunStreamableServer(streamableServerConfig) + }, + } ) func init() { @@ -77,6 +110,7 @@ func init() { rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file") rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)") rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") + rootCmd.PersistentFlags().Int("port", 8080, "Port for streamable HTTP server") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -87,9 +121,11 @@ func init() { _ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations")) _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) + _ = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port")) // Add subcommands rootCmd.AddCommand(stdioCmd) + rootCmd.AddCommand(streamableCmd) } func initConfig() { diff --git a/docs/local-streamable.md b/docs/local-streamable.md new file mode 100644 index 0000000000..a439f50097 --- /dev/null +++ b/docs/local-streamable.md @@ -0,0 +1,175 @@ +# Running GitHub MCP Server with Streamable HTTP Transport + +This document provides instructions for running the GitHub MCP Server using the streamable HTTP transport locally with Docker. + +## Overview + +The streamable HTTP transport allows the MCP server to communicate over HTTP using JSON-RPC messages, making it accessible to clients that prefer HTTP-based communication over stdio. + +## Prerequisites + +- Docker installed on your system +- A GitHub Personal Access Token with appropriate permissions + +## Environment Variables + +The server requires the following environment variable: + +- `GITHUB_PERSONAL_ACCESS_TOKEN`: Your GitHub Personal Access Token + +## Building the Docker Image + +Build the Docker image from the project root: + +```bash +docker build -t github-mcp-server . +``` + +## Running with Streamable HTTP Transport + +### Basic Usage + +Run the server with the streamable HTTP transport: + +```bash +docker run -e GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here -p 8080:8080 github-mcp-server streamable +``` + +### Custom Port + +To run on a different port (e.g., 3000): + +```bash +docker run -e GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here -p 3000:3000 github-mcp-server streamable --port 3000 +``` + +### With Additional Options + +Run with custom toolsets and read-only mode: + +```bash +docker run -e GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here -p 8080:8080 github-mcp-server streamable --toolsets issues,pull_requests --read-only +``` + +## Available Endpoints + +Once the server is running, it will be accessible at: + +- **Base URL**: `http://localhost:8080` (or your custom port) +- **MCP Endpoint**: `http://localhost:8080/mcp` + +The server supports the standard MCP protocol over HTTP with JSON-RPC messages. + +## Configuration Options + +The streamable command supports the same configuration options as the stdio command: + +- `--toolsets`: Comma-separated list of toolsets to enable (default: all) +- `--dynamic-toolsets`: Enable dynamic toolset discovery +- `--read-only`: Restrict server to read-only operations +- `--port`: Port to listen on (default: 8080) +- `--content-window-size`: Content window size (default: 5000) +- `--gh-host`: GitHub hostname for Enterprise installations + +## Connecting from Cursor + +To connect from Cursor, update your `mcp.json` configuration: + +```json +{ + "mcpServers": { + "github": { + "command": "curl", + "args": [ + "-X", "POST", + "-H", "Content-Type: application/json", + "-d", "@-", + "http://localhost:8080/mcp" + ] + } + } +} +``` + +Or use a proper MCP client that supports HTTP transport: + +```json +{ + "mcpServers": { + "github": { + "transport": "http", + "url": "http://localhost:8080/mcp" + } + } +} +``` + +## Testing the Connection + +You can test the server by sending a simple ping request: + +```bash +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "ping", + "params": {} + }' +``` + +Expected response: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": {} +} +``` + +## Troubleshooting + +### Server Won't Start + +1. **Check Token**: Ensure `GITHUB_PERSONAL_ACCESS_TOKEN` is set and valid +2. **Port Conflicts**: Make sure the port isn't already in use +3. **Docker Issues**: Verify Docker is running and you have sufficient permissions + +### Connection Issues + +1. **Firewall**: Ensure the port is open in your firewall +2. **Network**: Verify you can reach `http://localhost:8080` from your browser +3. **Logs**: Check Docker logs for error messages: + ```bash + docker logs + ``` + +### GitHub API Issues + +1. **Token Permissions**: Ensure your token has the required scopes +2. **Rate Limits**: Check if you're hitting GitHub API rate limits +3. **Enterprise**: For GitHub Enterprise, use the `--gh-host` flag + +## Stopping the Server + +To stop the server, use Ctrl+C or: + +```bash +docker stop +``` + +## Development + +For development purposes, you can mount the source code and rebuild: + +```bash +docker run -v $(pwd):/app -w /app -e GITHUB_PERSONAL_ACCESS_TOKEN=your_token_here -p 8080:8080 golang:1.25.1-alpine sh -c "go run cmd/github-mcp-server/main.go streamable" +``` + +## Security Considerations + +- Never commit your GitHub Personal Access Token to version control +- Use environment files or secret management systems in production +- Consider using HTTPS in production environments +- Restrict network access to the server port as needed diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 34e374a246..1c33ebb363 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -12,6 +12,7 @@ import ( "os/signal" "strings" "syscall" + "time" "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" @@ -194,6 +195,34 @@ type StdioServerConfig struct { ContentWindowSize int } +type StreamableServerConfig struct { + // Version of the server + Version string + + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) + Host string + + // GitHub Token to authenticate with the GitHub API + Token string + + // EnabledToolsets is a list of toolsets to enable + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration + EnabledToolsets []string + + // Whether to enable dynamic toolsets + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery + DynamicToolsets bool + + // ReadOnly indicates if we should only register read-only tools + ReadOnly bool + + // Port to listen on for HTTP requests + Port int + + // Content window size + ContentWindowSize int +} + // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { // Create app context @@ -272,6 +301,88 @@ func RunStdioServer(cfg StdioServerConfig) error { return nil } +// RunStreamableServer starts a streamable HTTP server. +func RunStreamableServer(cfg StreamableServerConfig) error { + // Create app context + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + t, _ := translations.TranslationHelper() + + ghServer, err := NewMCPServer(MCPServerConfig{ + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + Translator: t, + ContentWindowSize: cfg.ContentWindowSize, + }) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + // Create streamable HTTP server + streamableServer := server.NewStreamableHTTPServer(ghServer) + + // Configure HTTP server + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: streamableServer, + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + logger.Info("starting streamable HTTP server", "version", cfg.Version, "host", cfg.Host, "port", cfg.Port, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly) + + // Start server in a goroutine + errC := make(chan error, 1) + go func() { + errC <- httpServer.ListenAndServe() + }() + + // Output server startup message + _, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on HTTP port %d\n", cfg.Port) + + // Wait for shutdown signal + select { + case <-ctx.Done(): + logger.Info("shutting down server", "signal", "context done") + + // Attempt graceful shutdown with timeout + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + shutdownDone := make(chan error, 1) + go func() { + shutdownDone <- httpServer.Shutdown(shutdownCtx) + }() + + select { + case err := <-shutdownDone: + if err != nil { + logger.Warn("graceful shutdown completed with error", "error", err) + } else { + logger.Info("graceful shutdown completed successfully") + } + case <-shutdownCtx.Done(): + logger.Warn("graceful shutdown timeout, forcing close") + // Force close if graceful shutdown times out + if closeErr := httpServer.Close(); closeErr != nil { + logger.Error("error during forced server close", "error", closeErr) + } + } + + case err := <-errC: + if err != nil && err != http.ErrServerClosed { + logger.Error("error running server", "error", err) + return fmt.Errorf("error running server: %w", err) + } + } + + return nil +} + type apiHost struct { baseRESTURL *url.URL graphqlURL *url.URL diff --git a/scripts/dockerhub-publish b/scripts/dockerhub-publish new file mode 100755 index 0000000000..a2d8289630 --- /dev/null +++ b/scripts/dockerhub-publish @@ -0,0 +1,206 @@ +#!/bin/bash + +# GitHub MCP Server - DockerHub Multi-Arch Build & Push Script +# Usage: ./scripts/dockerhub-publish [tag] +# Example: ./scripts/dockerhub-publish v1.0.0 +# Example: ./scripts/dockerhub-publish (uses current git branch/commit) + +set -e + +# Configuration +REGISTRY="docker.io" +IMAGE_NAME="hspedro/github-mcp-server" +DOCKERFILE="./Dockerfile" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Helper functions +log_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +log_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +log_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +log_error() { + echo -e "${RED}❌ $1${NC}" +} + +# Check if Docker is running +check_docker() { + if ! docker info >/dev/null 2>&1; then + log_error "Docker is not running. Please start Docker and try again." + exit 1 + fi +} + +# Check if buildx is available +check_buildx() { + if ! docker buildx version >/dev/null 2>&1; then + log_error "Docker buildx is not available. Please install Docker Desktop or enable buildx." + exit 1 + fi +} + +# Login to DockerHub +docker_login() { + log_info "Logging in to DockerHub..." + if [ -z "$DOCKERHUB_USERNAME" ] || [ -z "$DOCKERHUB_TOKEN" ]; then + log_warning "DOCKERHUB_USERNAME and DOCKERHUB_TOKEN environment variables not set." + log_info "Please enter your DockerHub credentials:" + docker login $REGISTRY + else + echo "$DOCKERHUB_TOKEN" | docker login $REGISTRY -u "$DOCKERHUB_USERNAME" --password-stdin + fi + log_success "Successfully logged in to DockerHub" +} + +# Determine tag +determine_tag() { + if [ -n "$1" ]; then + TAG="$1" + log_info "Using provided tag: $TAG" + elif git describe --tags --exact-match HEAD >/dev/null 2>&1; then + TAG=$(git describe --tags --exact-match HEAD) + log_info "Using git tag: $TAG" + else + BRANCH=$(git rev-parse --abbrev-ref HEAD) + COMMIT=$(git rev-parse --short HEAD) + TAG="${BRANCH}-${COMMIT}" + log_info "Using branch-commit tag: $TAG" + fi +} + +# Create buildx builder if it doesn't exist +setup_builder() { + BUILDER_NAME="github-mcp-multiarch" + + if ! docker buildx inspect $BUILDER_NAME >/dev/null 2>&1; then + log_info "Creating multi-arch builder: $BUILDER_NAME" + docker buildx create --name $BUILDER_NAME --driver docker-container --bootstrap + fi + + log_info "Using builder: $BUILDER_NAME" + docker buildx use $BUILDER_NAME +} + +# Build and push multi-arch image +build_and_push() { + local full_image_name="$REGISTRY/$IMAGE_NAME" + local build_time=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + log_info "Building and pushing multi-arch image..." + log_info "Image: $full_image_name" + log_info "Tags: $TAG, latest" + log_info "Platforms: linux/amd64, linux/arm64" + + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="$TAG" \ + --build-arg BUILD_TIME="$build_time" \ + --tag "$full_image_name:$TAG" \ + --tag "$full_image_name:latest" \ + --file "$DOCKERFILE" \ + --push \ + . + + log_success "Successfully built and pushed multi-arch image!" +} + +# Inspect the pushed image +inspect_image() { + local full_image_name="$REGISTRY/$IMAGE_NAME" + + log_info "Inspecting pushed images..." + echo + echo "=== Image: $full_image_name:$TAG ===" + docker buildx imagetools inspect "$full_image_name:$TAG" + echo + echo "=== Image: $full_image_name:latest ===" + docker buildx imagetools inspect "$full_image_name:latest" +} + +# Print usage instructions +print_usage() { + local full_image_name="$REGISTRY/$IMAGE_NAME" + + echo + log_success "🚀 Docker images published successfully!" + echo + echo "📦 Repository: $full_image_name" + echo "🏷️ Tags: $TAG, latest" + echo "🏗️ Platforms: linux/amd64, linux/arm64" + echo + echo "📋 Pull commands:" + echo " docker pull $full_image_name:$TAG" + echo " docker pull $full_image_name:latest" + echo + echo "🏃 Run commands:" + echo " # stdio mode" + echo " docker run --rm -e GITHUB_PERSONAL_ACCESS_TOKEN=\$GITHUB_PERSONAL_ACCESS_TOKEN $full_image_name:$TAG stdio" + echo + echo " # streamable mode" + echo " docker run --rm -e GITHUB_PERSONAL_ACCESS_TOKEN=\$GITHUB_PERSONAL_ACCESS_TOKEN -p 8080:8080 $full_image_name:$TAG streamable --port 8080" + echo +} + +# Main execution +main() { + echo "🐳 GitHub MCP Server - DockerHub Multi-Arch Publisher" + echo "==================================================" + + # Pre-flight checks + check_docker + check_buildx + + # Determine tag + determine_tag "$1" + + # Setup + docker_login + setup_builder + + # Build and push + build_and_push + + # Post-build + inspect_image + print_usage + + log_success "All done! 🎉" +} + +# Handle script arguments +case "${1:-}" in + -h|--help) + echo "Usage: $0 [tag]" + echo + echo "Build and push multi-architecture Docker image to DockerHub" + echo + echo "Arguments:" + echo " tag Optional tag name (default: auto-detected from git)" + echo + echo "Environment variables:" + echo " DOCKERHUB_USERNAME DockerHub username (optional, will prompt if not set)" + echo " DOCKERHUB_TOKEN DockerHub access token (optional, will prompt if not set)" + echo + echo "Examples:" + echo " $0 # Use auto-detected tag" + echo " $0 v1.0.0 # Use specific tag" + echo " $0 latest # Use latest tag" + exit 0 + ;; + *) + main "$1" + ;; +esac