diff --git a/.github/workflows/mcpb-bundle.yml b/.github/workflows/mcpb-bundle.yml new file mode 100644 index 0000000000..98ce93efae --- /dev/null +++ b/.github/workflows/mcpb-bundle.yml @@ -0,0 +1,76 @@ +name: Build MCPB Bundle + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build bundle for (e.g., v1.0.0)' + required: true + +permissions: + contents: write + id-token: write + +jobs: + build: + strategy: + matrix: + include: + - os: linux + arch: amd64 + runner: ubuntu-latest + goreleaser_os: Linux + goreleaser_arch: x86_64 + - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + goreleaser_os: Linux + goreleaser_arch: arm64 + - os: darwin + arch: arm64 + runner: macos-latest + goreleaser_os: Darwin + goreleaser_arch: arm64 + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.tag }}" + else + TAG="${{ github.event.release.tag_name }}" + fi + VERSION="${TAG#v}" + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Download binary from GoReleaser + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.version.outputs.tag }}" + ARCHIVE="github-mcp-server_${{ matrix.goreleaser_os }}_${{ matrix.goreleaser_arch }}.tar.gz" + + gh release download "$TAG" --pattern "$ARCHIVE" --dir /tmp + mkdir -p bin + tar -xzf "/tmp/$ARCHIVE" -C /tmp + cp /tmp/github-mcp-server bin/ + chmod +x bin/github-mcp-server + + - name: Update manifest version + run: | + VERSION="${{ steps.version.outputs.version }}" + jq --arg v "$VERSION" '.version = $v' manifest.json > manifest.tmp.json + mv manifest.tmp.json manifest.json + + - uses: NimbleBrainInc/mcpb-pack@v3 + with: + output: "{name}-{version}-${{ matrix.os }}-${{ matrix.arch }}.mcpb" + upload: ${{ github.event_name == 'release' }} + announce: ${{ github.event_name == 'release' }} diff --git a/.mcpbignore b/.mcpbignore new file mode 100644 index 0000000000..3dea7d11ea --- /dev/null +++ b/.mcpbignore @@ -0,0 +1,41 @@ +# Git +.git/ +.github/ + +# Go build artifacts +*.exe +*.test +e2e.test + +# Development +.vscode/ +.idea/ +*.md +!README.md + +# Test files +e2e/ +*_test.go + +# Source code (we only need the binary) +cmd/ +internal/ +pkg/ +script/ +third-party/ +docs/ + +# Go modules (binary is statically linked) +go.mod +go.sum + +# Other +*.yaml +*.yml +*.json +!manifest.json +Dockerfile +.dockerignore +.golangci.yml +Makefile +LICENSE diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000..3c452fe38e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,5 @@ +# Default code owners for all NimbleBrainInc repositories. +# +# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +* @NimbleBrainInc/core diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cfb68be4eb..0fb9945ee1 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -88,6 +88,58 @@ var ( return ghmcp.RunStdioServer(stdioServerConfig) }, } + + httpCmd = &cobra.Command{ + Use: "http", + Short: "Start HTTP server", + Long: `Start a server that communicates via HTTP using the Streamable HTTP transport.`, + RunE: func(_ *cobra.Command, _ []string) error { + token := viper.GetString("personal_access_token") + if token == "" { + return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") + } + + var enabledToolsets []string + if viper.IsSet("toolsets") { + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + } + + var enabledTools []string + if viper.IsSet("tools") { + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } + } + + var enabledFeatures []string + if viper.IsSet("features") { + if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { + return fmt.Errorf("failed to unmarshal features: %w", err) + } + } + + ttl := viper.GetDuration("repo-access-cache-ttl") + httpServerConfig := ghmcp.HTTPServerConfig{ + Version: version, + Host: viper.GetString("host"), + Token: token, + EnabledToolsets: enabledToolsets, + EnabledTools: enabledTools, + EnabledFeatures: enabledFeatures, + DynamicToolsets: viper.GetBool("dynamic_toolsets"), + ReadOnly: viper.GetBool("read-only"), + ExportTranslations: viper.GetBool("export-translations"), + LogFilePath: viper.GetString("log-file"), + ContentWindowSize: viper.GetInt("content-window-size"), + LockdownMode: viper.GetBool("lockdown-mode"), + RepoAccessCacheTTL: &ttl, + Port: viper.GetInt("port"), + } + return ghmcp.RunHTTPServer(httpServerConfig) + }, + } ) func init() { @@ -124,8 +176,13 @@ func init() { _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + // Add http command flags + httpCmd.Flags().Int("port", 8000, "Port to listen on for HTTP connections") + _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) + // Add subcommands rootCmd.AddCommand(stdioCmd) + rootCmd.AddCommand(httpCmd) } func initConfig() { diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9859e2e9bb..76742f11be 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -314,6 +314,51 @@ type StdioServerConfig struct { RepoAccessCacheTTL *time.Duration } +// HTTPServerConfig contains configuration for running the MCP server over HTTP. +type HTTPServerConfig 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 + EnabledToolsets []string + + // EnabledTools is a list of specific tools to enable (additive to toolsets) + EnabledTools []string + + // EnabledFeatures is a list of feature flags that are enabled + EnabledFeatures []string + + // Whether to enable dynamic toolsets + DynamicToolsets bool + + // ReadOnly indicates if we should only register read-only tools + ReadOnly bool + + // ExportTranslations indicates if we should export translations + ExportTranslations bool + + // Path to the log file if not stderr + LogFilePath string + + // Content window size + ContentWindowSize int + + // LockdownMode indicates if we should enable lockdown mode + LockdownMode bool + + // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. + RepoAccessCacheTTL *time.Duration + + // Port to listen on for HTTP connections + Port int +} + // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { // Create app context @@ -398,6 +443,113 @@ func RunStdioServer(cfg StdioServerConfig) error { return nil } +// RunHTTPServer starts an HTTP server that serves the MCP protocol via Streamable HTTP. +func RunHTTPServer(cfg HTTPServerConfig) error { + // Create app context + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + t, dumpTranslations := translations.TranslationHelper() + + var slogHandler slog.Handler + var logOutput io.Writer + if cfg.LogFilePath != "" { + file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + logOutput = file + slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug}) + } else { + logOutput = os.Stderr + slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) + } + logger := slog.New(slogHandler) + logger.Info("starting HTTP server", "version", cfg.Version, "host", cfg.Host, "port", cfg.Port, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + + ghServer, err := NewMCPServer(MCPServerConfig{ + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + EnabledTools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + Translator: t, + ContentWindowSize: cfg.ContentWindowSize, + LockdownMode: cfg.LockdownMode, + Logger: logger, + RepoAccessTTL: cfg.RepoAccessCacheTTL, + }) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + if cfg.ExportTranslations { + dumpTranslations() + } + + // Create HTTP mux with health and MCP endpoints + mux := http.NewServeMux() + + // Health check endpoint + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }) + + // MCP endpoint using Streamable HTTP transport + mcpHandler := mcp.NewStreamableHTTPHandler( + func(r *http.Request) *mcp.Server { + return ghServer + }, + &mcp.StreamableHTTPOptions{ + Stateless: true, + Logger: logger, + }, + ) + mux.Handle("/mcp", mcpHandler) + + // Create HTTP server + addr := fmt.Sprintf(":%d", cfg.Port) + server := &http.Server{ + Addr: addr, + Handler: mux, + } + + // Start server in goroutine + errC := make(chan error, 1) + go func() { + logger.Info("listening", "addr", addr) + fmt.Fprintf(os.Stderr, "GitHub MCP Server running on http://0.0.0.0%s\n", addr) + fmt.Fprintf(os.Stderr, " MCP endpoint: http://0.0.0.0%s/mcp\n", addr) + fmt.Fprintf(os.Stderr, " Health check: http://0.0.0.0%s/health\n", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errC <- err + } + }() + + // Wait for shutdown signal + select { + case <-ctx.Done(): + logger.Info("shutting down server", "signal", "context done") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Error("error shutting down server", "error", err) + } + case err := <-errC: + if err != nil { + 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/manifest.json b/manifest.json new file mode 100644 index 0000000000..e3c90eb886 --- /dev/null +++ b/manifest.json @@ -0,0 +1,32 @@ +{ + "manifest_version": "0.3", + "name": "@nimblebraininc/github", + "version": "0.26.3-mcpb.3", + "description": "GitHub MCP server for repository management, issues, PRs, and workflow automation", + "author": { + "name": "GitHub (packaged by NimbleBrain Inc)" + }, + "icon": "https://static.nimblebrain.ai/icons/github.png", + "user_config": { + "personal_access_token": { + "type": "string", + "title": "GitHub Personal Access Token", + "description": "Create at https://github.com/settings/tokens with appropriate scopes for repo, issues, PRs", + "sensitive": true, + "required": true + } + }, + "server": { + "type": "binary", + "entry_point": "bin/github-mcp-server", + "mcp_config": { + "command": "${__dirname}/bin/github-mcp-server", + "args": [ + "stdio" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${user_config.personal_access_token}" + } + } + } +} diff --git a/server.json b/server.json deleted file mode 100644 index 83b4e06bec..0000000000 --- a/server.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.github/github-mcp-server", - "description": "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.", - "title": "GitHub", - "repository": { - "url": "https://github.com/github/github-mcp-server", - "source": "github" - }, - "version": "${VERSION}", - "remotes": [ - { - "type": "streamable-http", - "url": "https://api.githubcopilot.com/mcp/", - "headers": [ - { - "name": "Authorization", - "description": "Authentication token (PAT or App token)", - "isRequired": true, - "isSecret": true - } - ] - } - ] -}