Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/mcpb-bundle.yml
Original file line number Diff line number Diff line change
@@ -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' }}
41 changes: 41 additions & 0 deletions .mcpbignore
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
152 changes: 152 additions & 0 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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}"
}
}
}
}
Loading