Skip to content

Commit 6f1c0ca

Browse files
committed
Add HTTP transport and MCPB packaging support
- Add `http` subcommand with StreamableHTTP transport - Add manifest.json and .mcpbignore for MCPB bundling - Add mcpb-bundle.yml workflow to create bundles on release
1 parent 1decd77 commit 6f1c0ca

5 files changed

Lines changed: 382 additions & 0 deletions

File tree

.github/workflows/mcpb-bundle.yml

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
name: MCPB Bundle
2+
3+
on:
4+
release:
5+
types: [published]
6+
workflow_dispatch:
7+
inputs:
8+
tag:
9+
description: 'Release tag to build bundle for (e.g., v1.0.0)'
10+
required: true
11+
12+
permissions:
13+
contents: write
14+
15+
jobs:
16+
build-bundle:
17+
runs-on: ubuntu-latest
18+
strategy:
19+
matrix:
20+
include:
21+
- arch: x86_64
22+
goarch: amd64
23+
- arch: arm64
24+
goarch: arm64
25+
26+
steps:
27+
- name: Checkout
28+
uses: actions/checkout@v4
29+
30+
- name: Determine version
31+
id: version
32+
run: |
33+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
34+
TAG="${{ github.event.inputs.tag }}"
35+
else
36+
TAG="${{ github.event.release.tag_name }}"
37+
fi
38+
VERSION="${TAG#v}"
39+
echo "tag=$TAG" >> $GITHUB_OUTPUT
40+
echo "version=$VERSION" >> $GITHUB_OUTPUT
41+
echo "Building MCPB bundle for $TAG (version $VERSION)"
42+
43+
- name: Download release binary
44+
env:
45+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+
run: |
47+
TAG="${{ steps.version.outputs.tag }}"
48+
ARCHIVE="github-mcp-server_Linux_${{ matrix.arch }}.tar.gz"
49+
50+
echo "Downloading $ARCHIVE from release $TAG..."
51+
gh release download "$TAG" --pattern "$ARCHIVE" --dir /tmp
52+
53+
# Extract the binary
54+
mkdir -p /tmp/extracted
55+
tar -xzf "/tmp/$ARCHIVE" -C /tmp/extracted
56+
57+
echo "Extracted contents:"
58+
ls -la /tmp/extracted
59+
60+
- name: Update manifest version
61+
run: |
62+
VERSION="${{ steps.version.outputs.version }}"
63+
# Update version in manifest.json
64+
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" manifest.json
65+
echo "Updated manifest.json:"
66+
cat manifest.json
67+
68+
- name: Create MCPB bundle
69+
run: |
70+
VERSION="${{ steps.version.outputs.version }}"
71+
BUNDLE_NAME="mcp-github-v${VERSION}-linux-${{ matrix.goarch }}.mcpb"
72+
BUNDLE_DIR="/tmp/mcpb-bundle"
73+
74+
# Create bundle structure
75+
mkdir -p "$BUNDLE_DIR/bin"
76+
77+
# Copy manifest
78+
cp manifest.json "$BUNDLE_DIR/"
79+
80+
# Copy binary (it's in the root of the extracted archive)
81+
cp /tmp/extracted/github-mcp-server "$BUNDLE_DIR/bin/"
82+
chmod +x "$BUNDLE_DIR/bin/github-mcp-server"
83+
84+
echo "Bundle contents:"
85+
ls -la "$BUNDLE_DIR"
86+
ls -la "$BUNDLE_DIR/bin"
87+
88+
# Create the bundle (tar.gz)
89+
cd "$BUNDLE_DIR"
90+
tar -czvf "/tmp/$BUNDLE_NAME" .
91+
92+
echo "Created bundle: /tmp/$BUNDLE_NAME"
93+
ls -la "/tmp/$BUNDLE_NAME"
94+
95+
# Calculate SHA256
96+
SHA256=$(sha256sum "/tmp/$BUNDLE_NAME" | cut -d' ' -f1)
97+
echo "SHA256: $SHA256"
98+
echo "$SHA256" > "/tmp/${BUNDLE_NAME}.sha256"
99+
100+
# Store for upload step
101+
echo "bundle_name=$BUNDLE_NAME" >> $GITHUB_ENV
102+
echo "bundle_path=/tmp/$BUNDLE_NAME" >> $GITHUB_ENV
103+
echo "sha256_path=/tmp/${BUNDLE_NAME}.sha256" >> $GITHUB_ENV
104+
105+
- name: Upload bundle to release
106+
env:
107+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
108+
run: |
109+
TAG="${{ steps.version.outputs.tag }}"
110+
111+
echo "Uploading ${{ env.bundle_name }} to release $TAG..."
112+
gh release upload "$TAG" "${{ env.bundle_path }}" --clobber
113+
gh release upload "$TAG" "${{ env.sha256_path }}" --clobber
114+
115+
echo "Uploaded successfully!"

.mcpbignore

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Git
2+
.git/
3+
.github/
4+
5+
# Go build artifacts
6+
*.exe
7+
*.test
8+
e2e.test
9+
10+
# Development
11+
.vscode/
12+
.idea/
13+
*.md
14+
!README.md
15+
16+
# Test files
17+
e2e/
18+
*_test.go
19+
20+
# Source code (we only need the binary)
21+
cmd/
22+
internal/
23+
pkg/
24+
script/
25+
third-party/
26+
docs/
27+
28+
# Go modules (binary is statically linked)
29+
go.mod
30+
go.sum
31+
32+
# Other
33+
*.yaml
34+
*.yml
35+
*.json
36+
!manifest.json
37+
Dockerfile
38+
.dockerignore
39+
.golangci.yml
40+
Makefile
41+
LICENSE

cmd/github-mcp-server/main.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,58 @@ var (
8888
return ghmcp.RunStdioServer(stdioServerConfig)
8989
},
9090
}
91+
92+
httpCmd = &cobra.Command{
93+
Use: "http",
94+
Short: "Start HTTP server",
95+
Long: `Start a server that communicates via HTTP using the Streamable HTTP transport.`,
96+
RunE: func(_ *cobra.Command, _ []string) error {
97+
token := viper.GetString("personal_access_token")
98+
if token == "" {
99+
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
100+
}
101+
102+
var enabledToolsets []string
103+
if viper.IsSet("toolsets") {
104+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
105+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
106+
}
107+
}
108+
109+
var enabledTools []string
110+
if viper.IsSet("tools") {
111+
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
112+
return fmt.Errorf("failed to unmarshal tools: %w", err)
113+
}
114+
}
115+
116+
var enabledFeatures []string
117+
if viper.IsSet("features") {
118+
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
119+
return fmt.Errorf("failed to unmarshal features: %w", err)
120+
}
121+
}
122+
123+
ttl := viper.GetDuration("repo-access-cache-ttl")
124+
httpServerConfig := ghmcp.HTTPServerConfig{
125+
Version: version,
126+
Host: viper.GetString("host"),
127+
Token: token,
128+
EnabledToolsets: enabledToolsets,
129+
EnabledTools: enabledTools,
130+
EnabledFeatures: enabledFeatures,
131+
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
132+
ReadOnly: viper.GetBool("read-only"),
133+
ExportTranslations: viper.GetBool("export-translations"),
134+
LogFilePath: viper.GetString("log-file"),
135+
ContentWindowSize: viper.GetInt("content-window-size"),
136+
LockdownMode: viper.GetBool("lockdown-mode"),
137+
RepoAccessCacheTTL: &ttl,
138+
Port: viper.GetInt("port"),
139+
}
140+
return ghmcp.RunHTTPServer(httpServerConfig)
141+
},
142+
}
91143
)
92144

93145
func init() {
@@ -124,8 +176,13 @@ func init() {
124176
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
125177
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
126178

179+
// Add http command flags
180+
httpCmd.Flags().Int("port", 8000, "Port to listen on for HTTP connections")
181+
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
182+
127183
// Add subcommands
128184
rootCmd.AddCommand(stdioCmd)
185+
rootCmd.AddCommand(httpCmd)
129186
}
130187

131188
func initConfig() {

internal/ghmcp/server.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,51 @@ type StdioServerConfig struct {
314314
RepoAccessCacheTTL *time.Duration
315315
}
316316

317+
// HTTPServerConfig contains configuration for running the MCP server over HTTP.
318+
type HTTPServerConfig struct {
319+
// Version of the server
320+
Version string
321+
322+
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
323+
Host string
324+
325+
// GitHub Token to authenticate with the GitHub API
326+
Token string
327+
328+
// EnabledToolsets is a list of toolsets to enable
329+
EnabledToolsets []string
330+
331+
// EnabledTools is a list of specific tools to enable (additive to toolsets)
332+
EnabledTools []string
333+
334+
// EnabledFeatures is a list of feature flags that are enabled
335+
EnabledFeatures []string
336+
337+
// Whether to enable dynamic toolsets
338+
DynamicToolsets bool
339+
340+
// ReadOnly indicates if we should only register read-only tools
341+
ReadOnly bool
342+
343+
// ExportTranslations indicates if we should export translations
344+
ExportTranslations bool
345+
346+
// Path to the log file if not stderr
347+
LogFilePath string
348+
349+
// Content window size
350+
ContentWindowSize int
351+
352+
// LockdownMode indicates if we should enable lockdown mode
353+
LockdownMode bool
354+
355+
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
356+
RepoAccessCacheTTL *time.Duration
357+
358+
// Port to listen on for HTTP connections
359+
Port int
360+
}
361+
317362
// RunStdioServer is not concurrent safe.
318363
func RunStdioServer(cfg StdioServerConfig) error {
319364
// Create app context
@@ -398,6 +443,113 @@ func RunStdioServer(cfg StdioServerConfig) error {
398443
return nil
399444
}
400445

446+
// RunHTTPServer starts an HTTP server that serves the MCP protocol via Streamable HTTP.
447+
func RunHTTPServer(cfg HTTPServerConfig) error {
448+
// Create app context
449+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
450+
defer stop()
451+
452+
t, dumpTranslations := translations.TranslationHelper()
453+
454+
var slogHandler slog.Handler
455+
var logOutput io.Writer
456+
if cfg.LogFilePath != "" {
457+
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
458+
if err != nil {
459+
return fmt.Errorf("failed to open log file: %w", err)
460+
}
461+
logOutput = file
462+
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
463+
} else {
464+
logOutput = os.Stderr
465+
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
466+
}
467+
logger := slog.New(slogHandler)
468+
logger.Info("starting HTTP server", "version", cfg.Version, "host", cfg.Host, "port", cfg.Port, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
469+
470+
ghServer, err := NewMCPServer(MCPServerConfig{
471+
Version: cfg.Version,
472+
Host: cfg.Host,
473+
Token: cfg.Token,
474+
EnabledToolsets: cfg.EnabledToolsets,
475+
EnabledTools: cfg.EnabledTools,
476+
EnabledFeatures: cfg.EnabledFeatures,
477+
DynamicToolsets: cfg.DynamicToolsets,
478+
ReadOnly: cfg.ReadOnly,
479+
Translator: t,
480+
ContentWindowSize: cfg.ContentWindowSize,
481+
LockdownMode: cfg.LockdownMode,
482+
Logger: logger,
483+
RepoAccessTTL: cfg.RepoAccessCacheTTL,
484+
})
485+
if err != nil {
486+
return fmt.Errorf("failed to create MCP server: %w", err)
487+
}
488+
489+
if cfg.ExportTranslations {
490+
dumpTranslations()
491+
}
492+
493+
// Create HTTP mux with health and MCP endpoints
494+
mux := http.NewServeMux()
495+
496+
// Health check endpoint
497+
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
498+
w.Header().Set("Content-Type", "application/json")
499+
w.WriteHeader(http.StatusOK)
500+
_, _ = w.Write([]byte(`{"status":"ok"}`))
501+
})
502+
503+
// MCP endpoint using Streamable HTTP transport
504+
mcpHandler := mcp.NewStreamableHTTPHandler(
505+
func(r *http.Request) *mcp.Server {
506+
return ghServer
507+
},
508+
&mcp.StreamableHTTPOptions{
509+
Stateless: true,
510+
Logger: logger,
511+
},
512+
)
513+
mux.Handle("/mcp", mcpHandler)
514+
515+
// Create HTTP server
516+
addr := fmt.Sprintf(":%d", cfg.Port)
517+
server := &http.Server{
518+
Addr: addr,
519+
Handler: mux,
520+
}
521+
522+
// Start server in goroutine
523+
errC := make(chan error, 1)
524+
go func() {
525+
logger.Info("listening", "addr", addr)
526+
fmt.Fprintf(os.Stderr, "GitHub MCP Server running on http://0.0.0.0%s\n", addr)
527+
fmt.Fprintf(os.Stderr, " MCP endpoint: http://0.0.0.0%s/mcp\n", addr)
528+
fmt.Fprintf(os.Stderr, " Health check: http://0.0.0.0%s/health\n", addr)
529+
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
530+
errC <- err
531+
}
532+
}()
533+
534+
// Wait for shutdown signal
535+
select {
536+
case <-ctx.Done():
537+
logger.Info("shutting down server", "signal", "context done")
538+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
539+
defer cancel()
540+
if err := server.Shutdown(shutdownCtx); err != nil {
541+
logger.Error("error shutting down server", "error", err)
542+
}
543+
case err := <-errC:
544+
if err != nil {
545+
logger.Error("error running server", "error", err)
546+
return fmt.Errorf("error running server: %w", err)
547+
}
548+
}
549+
550+
return nil
551+
}
552+
401553
type apiHost struct {
402554
baseRESTURL *url.URL
403555
graphqlURL *url.URL

0 commit comments

Comments
 (0)