Skip to content

Commit cf6bab9

Browse files
committed
WIP
1 parent 3956a97 commit cf6bab9

File tree

6 files changed

+246
-25
lines changed

6 files changed

+246
-25
lines changed

cmd/github-mcp-server/main.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ 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 JSON-RPC messages.`,
96+
RunE: func(_ *cobra.Command, _ []string) error {
97+
config := ghmcp.HTTPServerConfig{
98+
Version: version,
99+
Host: viper.GetString("host"),
100+
}
101+
102+
return ghmcp.RunHTTPServer(config)
103+
},
104+
}
91105
)
92106

93107
func init() {
@@ -126,6 +140,7 @@ func init() {
126140

127141
// Add subcommands
128142
rootCmd.AddCommand(stdioCmd)
143+
rootCmd.AddCommand(httpCmd)
129144
}
130145

131146
func initConfig() {

internal/ghmcp/http.go

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import (
88
"net/http"
99
"os"
1010
"os/signal"
11-
"strings"
1211
"syscall"
1312
"time"
1413

1514
"github.com/github/github-mcp-server/pkg/github"
15+
httpMiddleware "github.com/github/github-mcp-server/pkg/http/middleware"
1616
"github.com/github/github-mcp-server/pkg/translations"
17+
"github.com/modelcontextprotocol/go-sdk/mcp"
1718
)
1819

1920
type HTTPServerConfig struct {
@@ -23,9 +24,6 @@ type HTTPServerConfig struct {
2324
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
2425
Host string
2526

26-
// GitHub Token to authenticate with the GitHub API
27-
Token string
28-
2927
// EnabledToolsets is a list of toolsets to enable
3028
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
3129
EnabledToolsets []string
@@ -90,26 +88,9 @@ func RunHTTPServer(cfg HTTPServerConfig) error {
9088
logger := slog.New(slogHandler)
9189
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
9290

93-
// Fetch token scopes for scope-based tool filtering (PAT tokens only)
94-
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
95-
// Fine-grained PATs and other token types don't support this, so we skip filtering.
96-
var tokenScopes []string
97-
if strings.HasPrefix(cfg.Token, "ghp_") {
98-
fetchedScopes, err := github.FetchTokenScopesForHost(ctx, cfg.Token, cfg.Host)
99-
if err != nil {
100-
logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err)
101-
} else {
102-
tokenScopes = fetchedScopes
103-
logger.Info("token scopes fetched for filtering", "scopes", tokenScopes)
104-
}
105-
} else {
106-
logger.Debug("skipping scope filtering for non-PAT token")
107-
}
108-
10991
ghServer, err := github.NewMCPServer(github.MCPServerConfig{
11092
Version: cfg.Version,
11193
Host: cfg.Host,
112-
Token: cfg.Token,
11394
EnabledToolsets: cfg.EnabledToolsets,
11495
EnabledTools: cfg.EnabledTools,
11596
EnabledFeatures: cfg.EnabledFeatures,
@@ -120,10 +101,40 @@ func RunHTTPServer(cfg HTTPServerConfig) error {
120101
LockdownMode: cfg.LockdownMode,
121102
Logger: logger,
122103
RepoAccessTTL: cfg.RepoAccessCacheTTL,
123-
TokenScopes: tokenScopes,
124104
})
125105
if err != nil {
126106
return fmt.Errorf("failed to create MCP server: %w", err)
127107
}
128108

109+
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
110+
return ghServer
111+
}, &mcp.StreamableHTTPOptions{})
112+
113+
httpSvr := http.Server{
114+
Addr: ":8082",
115+
Handler: httpMiddleware.ExtractUserToken()(handler),
116+
}
117+
118+
go func() {
119+
<-ctx.Done()
120+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
121+
defer cancel()
122+
logger.Info("shutting down server")
123+
if err := httpSvr.Shutdown(shutdownCtx); err != nil {
124+
logger.Error("error during server shutdown", "error", err)
125+
}
126+
}()
127+
128+
if cfg.ExportTranslations {
129+
// Once server is initialized, all translations are loaded
130+
dumpTranslations()
131+
}
132+
133+
logger.Info("HTTP server listening on :8082")
134+
if err := httpSvr.ListenAndServe(); err != nil && err != http.ErrServerClosed {
135+
return fmt.Errorf("HTTP server error: %w", err)
136+
}
137+
138+
logger.Info("server stopped gracefully")
139+
return nil
129140
}

pkg/github/dependencies.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66

7+
"github.com/github/github-mcp-server/pkg/http/middleware"
78
"github.com/github/github-mcp-server/pkg/inventory"
89
"github.com/github/github-mcp-server/pkg/lockdown"
910
"github.com/github/github-mcp-server/pkg/raw"
@@ -190,3 +191,44 @@ func NewToolFromHandler(
190191
st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...)
191192
return st
192193
}
194+
195+
type RequestDep struct {
196+
// Pre-created clients
197+
Client *gogithub.Client
198+
GQLClient *githubv4.Client
199+
RawClient *raw.Client
200+
201+
// Static dependencies
202+
RepoAccessCache *lockdown.RepoAccessCache
203+
T translations.TranslationHelperFunc
204+
Flags FeatureFlags
205+
ContentWindowSize int
206+
}
207+
208+
// GetClient implements ToolDependencies.
209+
func (d RequestDep) GetClient(ctx context.Context) (*gogithub.Client, error) {
210+
token := middleware.Token(ctx)
211+
return d.Client.WithAuthToken(token.Token), nil
212+
}
213+
214+
// GetGQLClient implements ToolDependencies.
215+
func (d RequestDep) GetGQLClient(_ context.Context) (*githubv4.Client, error) {
216+
return d.GQLClient, nil
217+
}
218+
219+
// GetRawClient implements ToolDependencies.
220+
func (d RequestDep) GetRawClient(_ context.Context) (*raw.Client, error) {
221+
return d.RawClient, nil
222+
}
223+
224+
// GetRepoAccessCache implements ToolDependencies.
225+
func (d RequestDep) GetRepoAccessCache() *lockdown.RepoAccessCache { return d.RepoAccessCache }
226+
227+
// GetT implements ToolDependencies.
228+
func (d RequestDep) GetT() translations.TranslationHelperFunc { return d.T }
229+
230+
// GetFlags implements ToolDependencies.
231+
func (d RequestDep) GetFlags() FeatureFlags { return d.Flags }
232+
233+
// GetContentWindowSize implements ToolDependencies.
234+
func (d RequestDep) GetContentWindowSize() int { return d.ContentWindowSize }

pkg/github/server.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients,
124124
}, nil
125125
}
126126

127-
// ResolveEnabledToolsets determines which toolsets should be enabled based on config.
127+
// resolveEnabledToolsets determines which toolsets should be enabled based on config.
128128
// Returns nil for "use defaults", empty slice for "none", or explicit list.
129-
func ResolveEnabledToolsets(cfg MCPServerConfig) []string {
129+
func resolveEnabledToolsets(cfg MCPServerConfig) []string {
130130
enabledToolsets := cfg.EnabledToolsets
131131

132132
// In dynamic mode, remove "all" and "default" since users enable toolsets on demand
@@ -162,7 +162,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
162162
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
163163
}
164164

165-
enabledToolsets := ResolveEnabledToolsets(cfg)
165+
enabledToolsets := resolveEnabledToolsets(cfg)
166166

167167
// For instruction generation, we need actual toolset names (not nil).
168168
// nil means "use defaults" in inventory, so expand it for instructions.

pkg/http/headers/headers.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package headers
2+
3+
const (
4+
// AuthorizationHeader is a standard HTTP Header.
5+
AuthorizationHeader = "Authorization"
6+
// ContentTypeHeader is a standard HTTP Header.
7+
ContentTypeHeader = "Content-Type"
8+
// AcceptHeader is a standard HTTP Header.
9+
AcceptHeader = "Accept"
10+
// UserAgentHeader is a standard HTTP Header.
11+
UserAgentHeader = "User-Agent"
12+
13+
// ContentTypeJSON is the standard MIME type for JSON.
14+
ContentTypeJSON = "application/json"
15+
// ContentTypeEventStream is the standard MIME type for Event Streams.
16+
ContentTypeEventStream = "text/event-stream"
17+
18+
// ForwardedForHeader is a standard HTTP Header used to forward the originating IP address of a client.
19+
ForwardedForHeader = "X-Forwarded-For"
20+
21+
// RealIPHeader is a standard HTTP Header used to indicate the real IP address of the client.
22+
RealIPHeader = "X-Real-IP"
23+
24+
// RequestHmacHeader is used to authenticate requests to the Raw API.
25+
RequestHmacHeader = "Request-Hmac"
26+
)

pkg/http/middleware/token.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package middleware
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"regexp"
8+
"strings"
9+
10+
httpheaders "github.com/github/github-mcp-server/pkg/http/headers"
11+
)
12+
13+
type authType int
14+
15+
const (
16+
authTypeUnknown authType = iota
17+
authTypeIDE
18+
authTypeGhToken
19+
)
20+
21+
var (
22+
errMissingAuthorizationHeader = errors.New("missing Authorization header")
23+
errBadAuthorizationHeader = errors.New("bad Authorization header format")
24+
errUnsupportedAuthorizationHeader = errors.New("unsupported Authorization header format")
25+
)
26+
27+
var supportedThirdPartyTokenPrefixes = []string{
28+
"ghp_", // Personal access token (classic)
29+
"github_pat_", // Fine-grained personal access token
30+
"gho_", // OAuth access token
31+
"ghu_", // User access token for a GitHub App
32+
"ghs_", // Installation access token for a GitHub App (a.k.a. server-to-server token)
33+
}
34+
35+
// oldPatternRegexp is the regular expression for the old pattern of the token.
36+
// Until 2021, GitHub API tokens did not have an identifiable prefix. They
37+
// were 40 characters long and only contained the characters a-f and 0-9.
38+
var oldPatternRegexp = regexp.MustCompile(`\A[a-f0-9]{40}\z`)
39+
40+
type tokenCtxKey string
41+
42+
var tokenContextKey tokenCtxKey = "tokenctx"
43+
44+
type TokenData struct {
45+
Token string
46+
}
47+
48+
// AddToken adds the given token data to the context.
49+
func AddToken(ctx context.Context, data *TokenData) context.Context {
50+
return context.WithValue(ctx, tokenContextKey, data)
51+
}
52+
53+
// ReqData returns the request data from the context. It will panic if there is
54+
// no data in the context (which should never happen in production).
55+
func Token(ctx context.Context) *TokenData {
56+
d, ok := ctx.Value(tokenContextKey).(*TokenData)
57+
if !ok || d == nil {
58+
// This should never happen in production, so making it a panic saves us a lot of unnecessary error handling.
59+
panic(errors.New("context does not contain request context token data"))
60+
}
61+
return d
62+
}
63+
64+
// ExtractUserToken is a middleware that extracts the user token from the request
65+
// and adds it to the request context. It also validates the token format.
66+
func ExtractUserToken() func(next http.Handler) http.Handler {
67+
return func(next http.Handler) http.Handler {
68+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
69+
_, token, err := parseAuthorizationHeader(r)
70+
if err != nil {
71+
// For missing Authorization header, return 401 with WWW-Authenticate header per MCP spec
72+
if errors.Is(err, errMissingAuthorizationHeader) {
73+
// sendAuthChallenge(w, r, cfg, obsv)
74+
return
75+
}
76+
// For other auth errors (bad format, unsupported), return 400
77+
http.Error(w, err.Error(), http.StatusBadRequest)
78+
return
79+
}
80+
81+
// Add token info to context
82+
ctx := r.Context()
83+
ctx = AddToken(ctx, &TokenData{Token: token})
84+
85+
next.ServeHTTP(w, r.WithContext(ctx))
86+
})
87+
}
88+
}
89+
90+
func parseAuthorizationHeader(req *http.Request) (authType authType, token string, _ error) {
91+
authHeader := req.Header.Get(httpheaders.AuthorizationHeader)
92+
if authHeader == "" {
93+
return 0, "", errMissingAuthorizationHeader
94+
}
95+
96+
switch {
97+
// decrypt dotcom token and set it as token
98+
case strings.HasPrefix(authHeader, "GitHub-Bearer "):
99+
return 0, "", errUnsupportedAuthorizationHeader
100+
default:
101+
// support both "Bearer" and "bearer" to conform to api.github.com
102+
if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") {
103+
token = authHeader[7:]
104+
} else {
105+
token = authHeader
106+
}
107+
}
108+
109+
// Do a naïve check for a colon in the token - currently, only the IDE token has a colon in it.
110+
// ex: tid=1;exp=25145314523;chat=1:<hmac>
111+
if strings.Contains(token, ":") {
112+
return authTypeIDE, token, nil
113+
}
114+
115+
for _, prefix := range supportedThirdPartyTokenPrefixes {
116+
if strings.HasPrefix(token, prefix) {
117+
return authTypeGhToken, token, nil
118+
}
119+
}
120+
121+
matchesOldTokenPattern := oldPatternRegexp.MatchString(token)
122+
if matchesOldTokenPattern {
123+
return authTypeGhToken, token, nil
124+
}
125+
126+
return authTypeUnknown, "", errBadAuthorizationHeader
127+
}

0 commit comments

Comments
 (0)