Initial HTTP stack

This commit is contained in:
Adam Holt
2026-01-13 17:56:15 +01:00
parent 31b541ea08
commit 3e30fc87cc
18 changed files with 1959 additions and 1322 deletions
+19
View File
@@ -88,6 +88,24 @@ var (
return ghmcp.RunStdioServer(stdioServerConfig)
},
}
httpCmd = &cobra.Command{
Use: "http",
Short: "Start HTTP server",
Long: `Start an HTTP server that listens for MCP requests over HTTP.`,
RunE: func(_ *cobra.Command, _ []string) error {
httpConfig := ghmcp.HTTPServerConfig{
Version: version,
Host: viper.GetString("host"),
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
}
return ghmcp.RunHTTPServer(httpConfig)
},
}
)
func init() {
@@ -126,6 +144,7 @@ func init() {
// Add subcommands
rootCmd.AddCommand(stdioCmd)
rootCmd.AddCommand(httpCmd)
}
func initConfig() {
+188
View File
@@ -0,0 +1,188 @@
package ghmcp
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/http/middleware"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
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
// EnabledToolsets is a list of toolsets to enable
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
EnabledToolsets []string
// EnabledTools is a list of specific tools to enable (additive to toolsets)
// When specified, these tools are registered in addition to any specified toolset tools
EnabledTools []string
// EnabledFeatures is a list of feature flags that are enabled
// Items with FeatureFlagEnable matching an entry in this list will be available
EnabledFeatures []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
// ExportTranslations indicates if we should export translations
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
ExportTranslations bool
// EnableCommandLogging indicates if we should log commands
EnableCommandLogging 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
}
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 server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
// Set up repo access cache for lockdown mode
var opts []lockdown.RepoAccessOption
if cfg.LockdownMode {
opts = []lockdown.RepoAccessOption{
lockdown.WithLogger(logger.With("component", "lockdown")),
}
if cfg.RepoAccessCacheTTL != nil {
opts = append(opts, lockdown.WithTTL(*cfg.RepoAccessCacheTTL))
}
}
apiHost, err := utils.ParseAPIHost(cfg.Host)
if err != nil {
return fmt.Errorf("failed to parse API host: %w", err)
}
deps := github.NewRequestDeps(
&apiHost,
cfg.Version,
cfg.LockdownMode,
opts,
t,
github.FeatureFlags{
LockdownMode: cfg.LockdownMode,
},
cfg.ContentWindowSize,
)
ghServer, err := github.NewMcpServer(&github.MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
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,
}, deps)
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}
handler := NewHttpMcpHandler(&cfg, ghServer)
httpSvr := http.Server{
Addr: ":8082",
Handler: handler,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger.Info("shutting down server")
if err := httpSvr.Shutdown(shutdownCtx); err != nil {
logger.Error("error during server shutdown", "error", err)
}
}()
if cfg.ExportTranslations {
// Once server is initialized, all translations are loaded
dumpTranslations()
}
logger.Info("HTTP server listening on :8082")
if err := httpSvr.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("HTTP server error: %w", err)
}
logger.Info("server stopped gracefully")
return nil
}
type HttpMcpHandler struct {
config *HTTPServerConfig
ghServer *mcp.Server
}
func NewHttpMcpHandler(cfg *HTTPServerConfig, mcpServer *mcp.Server) *HttpMcpHandler {
return &HttpMcpHandler{
config: cfg,
ghServer: mcpServer,
}
}
func (s *HttpMcpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
mcpHandler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
return s.ghServer
}, &mcp.StreamableHTTPOptions{
Stateless: true,
})
middleware.ExtractUserToken()(mcpHandler).ServeHTTP(w, r)
}
+23 -404
View File
@@ -6,7 +6,6 @@ import (
"io"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
@@ -15,66 +14,18 @@ import (
"github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/lockdown"
mcplog "github.com/github/github-mcp-server/pkg/log"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
gogithub "github.com/google/go-github/v79/github"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
type MCPServerConfig 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
// EnabledTools is a list of specific tools to enable (additive to toolsets)
// When specified, these tools are registered in addition to any specified toolset tools
EnabledTools []string
// EnabledFeatures is a list of feature flags that are enabled
// Items with FeatureFlagEnable matching an entry in this list will be available
EnabledFeatures []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 offer read-only tools
ReadOnly bool
// Translator provides translated text for the server tooling
Translator translations.TranslationHelperFunc
// Content window size
ContentWindowSize int
// LockdownMode indicates if we should enable lockdown mode
LockdownMode bool
// Logger is used for logging within the server
Logger *slog.Logger
// RepoAccessTTL overrides the default TTL for repository access cache entries.
RepoAccessTTL *time.Duration
// TokenScopes contains the OAuth scopes available to the token.
// When non-nil, tools requiring scopes not in this list will be hidden.
// This is used for PAT scope filtering where we can't issue scope challenges.
TokenScopes []string
}
// githubClients holds all the GitHub API clients created for a server instance.
type githubClients struct {
rest *gogithub.Client
@@ -85,25 +36,25 @@ type githubClients struct {
}
// createGitHubClients creates all the GitHub API clients needed by the server.
func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, error) {
func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.ApiHost) (*githubClients, error) {
// Construct REST client
restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token)
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
restClient.BaseURL = apiHost.baseRESTURL
restClient.UploadURL = apiHost.uploadURL
restClient.BaseURL = apiHost.BaseRESTURL
restClient.UploadURL = apiHost.UploadURL
// Construct GraphQL client
// We use NewEnterpriseClient unconditionally since we already parsed the API host
gqlHTTPClient := &http.Client{
Transport: &bearerAuthTransport{
transport: http.DefaultTransport,
token: cfg.Token,
Transport: &transport.BearerAuthTransport{
Transport: http.DefaultTransport,
Token: cfg.Token,
},
}
gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
gqlClient := githubv4.NewEnterpriseClient(apiHost.GraphqlURL.String(), gqlHTTPClient)
// Create raw content client (shares REST client's HTTP transport)
rawClient := raw.NewClient(restClient, apiHost.rawURL)
rawClient := raw.NewClient(restClient, apiHost.RawURL)
// Set up repo access cache for lockdown mode
var repoAccessCache *lockdown.RepoAccessCache
@@ -126,35 +77,8 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients,
}, nil
}
// resolveEnabledToolsets determines which toolsets should be enabled based on config.
// Returns nil for "use defaults", empty slice for "none", or explicit list.
func resolveEnabledToolsets(cfg MCPServerConfig) []string {
enabledToolsets := cfg.EnabledToolsets
// In dynamic mode, remove "all" and "default" since users enable toolsets on demand
if cfg.DynamicToolsets && enabledToolsets != nil {
enabledToolsets = github.RemoveToolset(enabledToolsets, string(github.ToolsetMetadataAll.ID))
enabledToolsets = github.RemoveToolset(enabledToolsets, string(github.ToolsetMetadataDefault.ID))
}
if enabledToolsets != nil {
return enabledToolsets
}
if cfg.DynamicToolsets {
// Dynamic mode with no toolsets specified: start empty so users enable on demand
return []string{}
}
if len(cfg.EnabledTools) > 0 {
// When specific tools are requested but no toolsets, don't use default toolsets
// This matches the original behavior: --tools=X alone registers only X
return []string{}
}
// nil means "use defaults" in WithToolsets
return nil
}
func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
apiHost, err := parseAPIHost(cfg.Host)
func NewStdioMCPServer(cfg github.MCPServerConfig) (*mcp.Server, error) {
apiHost, err := utils.ParseAPIHost(cfg.Host)
if err != nil {
return nil, fmt.Errorf("failed to parse API host: %w", err)
}
@@ -164,40 +88,6 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
}
enabledToolsets := resolveEnabledToolsets(cfg)
// For instruction generation, we need actual toolset names (not nil).
// nil means "use defaults" in inventory, so expand it for instructions.
instructionToolsets := enabledToolsets
if instructionToolsets == nil {
instructionToolsets = github.GetDefaultToolsetIDs()
}
// Create the MCP server
serverOpts := &mcp.ServerOptions{
Instructions: github.GenerateInstructions(instructionToolsets),
Logger: cfg.Logger,
CompletionHandler: github.CompletionsHandler(func(_ context.Context) (*gogithub.Client, error) {
return clients.rest, nil
}),
}
// In dynamic mode, explicitly advertise capabilities since tools/resources/prompts
// may be enabled at runtime even if none are registered initially.
if cfg.DynamicToolsets {
serverOpts.Capabilities = &mcp.ServerCapabilities{
Tools: &mcp.ToolCapabilities{},
Resources: &mcp.ResourceCapabilities{},
Prompts: &mcp.PromptCapabilities{},
}
}
ghServer := github.NewServer(cfg.Version, serverOpts)
// Add middlewares
ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP))
// Create dependencies for tool handlers
deps := github.NewBaseDeps(
clients.rest,
@@ -209,74 +99,16 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
cfg.ContentWindowSize,
)
// Inject dependencies into context for all tool handlers
ghServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
return next(github.ContextWithDeps(ctx, deps), method, req)
}
})
// Build and register the tool/resource/prompt inventory
inventoryBuilder := github.NewInventory(cfg.Translator).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(enabledToolsets).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures))
// Apply token scope filtering if scopes are known (for PAT filtering)
if cfg.TokenScopes != nil {
inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes))
ghServer, err := github.NewMcpServer(&cfg, deps)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err)
}
inventory := inventoryBuilder.Build()
if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 {
fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", "))
}
// Register GitHub tools/resources/prompts from the inventory.
// In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets
// is empty - users enable toolsets at runtime via the dynamic tools below (but can
// enable toolsets or tools explicitly that do need registration).
inventory.RegisterAll(context.Background(), ghServer, deps)
// Register dynamic toolset management tools (enable/disable) - these are separate
// meta-tools that control the inventory, not part of the inventory itself
if cfg.DynamicToolsets {
registerDynamicTools(ghServer, inventory, deps, cfg.Translator)
}
ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP))
return ghServer, nil
}
// registerDynamicTools adds the dynamic toolset enable/disable tools to the server.
func registerDynamicTools(server *mcp.Server, inventory *inventory.Inventory, deps *github.BaseDeps, t translations.TranslationHelperFunc) {
dynamicDeps := github.DynamicToolDependencies{
Server: server,
Inventory: inventory,
ToolDeps: deps,
T: t,
}
for _, tool := range github.DynamicTools(inventory) {
tool.RegisterFunc(server, dynamicDeps)
}
}
// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name
// is present in the provided list of enabled features. For the local server,
// this is populated from the --features CLI flag.
func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker {
// Build a set for O(1) lookup
featureSet := make(map[string]bool, len(enabledFeatures))
for _, f := range enabledFeatures {
featureSet[f] = true
}
return func(_ context.Context, flagName string) (bool, error) {
return featureSet[flagName], nil
}
}
type StdioServerConfig struct {
// Version of the server
Version string
@@ -366,7 +198,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
logger.Debug("skipping scope filtering for non-PAT token")
}
ghServer, err := NewMCPServer(MCPServerConfig{
ghServer, err := NewStdioMCPServer(github.MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
@@ -427,220 +259,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
return nil
}
type apiHost struct {
baseRESTURL *url.URL
graphqlURL *url.URL
uploadURL *url.URL
rawURL *url.URL
}
func newDotcomHost() (apiHost, error) {
baseRestURL, err := url.Parse("https://api.github.com/")
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err)
}
gqlURL, err := url.Parse("https://api.github.com/graphql")
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err)
}
uploadURL, err := url.Parse("https://uploads.github.com")
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
}
rawURL, err := url.Parse("https://raw.githubusercontent.com/")
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
}
return apiHost{
baseRESTURL: baseRestURL,
graphqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
}, nil
}
func newGHECHost(hostname string) (apiHost, error) {
u, err := url.Parse(hostname)
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err)
}
// Unsecured GHEC would be an error
if u.Scheme == "http" {
return apiHost{}, fmt.Errorf("GHEC URL must be HTTPS")
}
restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err)
}
gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err)
}
uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s", u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
}
rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
}
return apiHost{
baseRESTURL: restURL,
graphqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
}, nil
}
func newGHESHost(hostname string) (apiHost, error) {
u, err := url.Parse(hostname)
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
}
restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err)
}
gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err)
}
// Check if subdomain isolation is enabled
// See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation
hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname())
var uploadURL *url.URL
if hasSubdomainIsolation {
// With subdomain isolation: https://uploads.hostname/
uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname()))
} else {
// Without subdomain isolation: https://hostname/api/uploads/
uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname()))
}
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
}
var rawURL *url.URL
if hasSubdomainIsolation {
// With subdomain isolation: https://raw.hostname/
rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname()))
} else {
// Without subdomain isolation: https://hostname/raw/
rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname()))
}
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err)
}
return apiHost{
baseRESTURL: restURL,
graphqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
}, nil
}
// checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled
// by attempting to ping the raw.<host>/_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation.
func checkSubdomainIsolation(scheme, hostname string) bool {
subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname)
client := &http.Client{
Timeout: 5 * time.Second,
// Don't follow redirects - we just want to check if the endpoint exists
//nolint:revive // parameters are required by http.Client.CheckRedirect signature
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(subdomainURL)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// Note that this does not handle ports yet, so development environments are out.
func parseAPIHost(s string) (apiHost, error) {
if s == "" {
return newDotcomHost()
}
u, err := url.Parse(s)
if err != nil {
return apiHost{}, fmt.Errorf("could not parse host as URL: %s", s)
}
if u.Scheme == "" {
return apiHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s)
}
if strings.HasSuffix(u.Hostname(), "github.com") {
return newDotcomHost()
}
if strings.HasSuffix(u.Hostname(), "ghe.com") {
return newGHECHost(s)
}
return newGHESHost(s)
}
type userAgentTransport struct {
transport http.RoundTripper
agent string
}
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("User-Agent", t.agent)
return t.transport.RoundTrip(req)
}
type bearerAuthTransport struct {
transport http.RoundTripper
token string
}
func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer "+t.token)
// Check for GraphQL-Features in context and add header if present
if features := github.GetGraphQLFeatures(req.Context()); len(features) > 0 {
req.Header.Set("GraphQL-Features", strings.Join(features, ", "))
}
return t.transport.RoundTrip(req)
}
func addGitHubAPIErrorToContext(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) {
// Ensure the context is cleared of any previous errors
// as context isn't propagated through middleware
ctx = errors.ContextWithGitHubErrors(ctx)
return next(ctx, method, req)
}
}
func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler {
func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Client, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) {
if method != "initialize" {
@@ -662,9 +281,9 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g
restClient.UserAgent = userAgent
gqlHTTPClient.Transport = &userAgentTransport{
transport: gqlHTTPClient.Transport,
agent: userAgent,
gqlHTTPClient.Transport = &transport.UserAgentTransport{
Transport: gqlHTTPClient.Transport,
Agent: userAgent,
}
return next(ctx, method, request)
@@ -675,13 +294,13 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g
// fetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API.
// It constructs the appropriate API host URL based on the configured host.
func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) {
apiHost, err := parseAPIHost(host)
apiHost, err := utils.ParseAPIHost(host)
if err != nil {
return nil, fmt.Errorf("failed to parse API host: %w", err)
}
fetcher := scopes.NewFetcher(scopes.FetcherOptions{
APIHost: apiHost.baseRESTURL.String(),
APIHost: apiHost.BaseRESTURL.String(),
})
return fetcher.FetchTokenScopes(ctx, token)
+19
View File
@@ -0,0 +1,19 @@
package context
import "context"
// graphQLFeaturesKey is a context key for GraphQL feature flags
type graphQLFeaturesKey struct{}
// withGraphQLFeatures adds GraphQL feature flags to the context
func WithGraphQLFeatures(ctx context.Context, features ...string) context.Context {
return context.WithValue(ctx, graphQLFeaturesKey{}, features)
}
// GetGraphQLFeatures retrieves GraphQL feature flags from the context
func GetGraphQLFeatures(ctx context.Context) []string {
if features, ok := ctx.Value(graphQLFeaturesKey{}).([]string); ok {
return features
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package context
import "context"
// tokenCtxKey is a context key for authentication token information
type tokenCtxKey struct{}
// WithTokenInfo adds TokenInfo to the context
func WithTokenInfo(ctx context.Context, token string) context.Context {
return context.WithValue(ctx, tokenCtxKey{}, token)
}
// GetTokenInfo retrieves the authentication token from the context
func GetTokenInfo(ctx context.Context) (string, bool) {
if token, ok := ctx.Value(tokenCtxKey{}).(string); ok {
return token, true
}
return "", false
}
+140 -2
View File
@@ -3,12 +3,17 @@ package github
import (
"context"
"errors"
"fmt"
"net/http"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
gogithub "github.com/google/go-github/v79/github"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
@@ -21,6 +26,14 @@ type depsContextKey struct{}
// ErrDepsNotInContext is returned when ToolDependencies is not found in context.
var ErrDepsNotInContext = errors.New("ToolDependencies not found in context; use ContextWithDeps to inject")
func InjectDepsMiddleware(deps ToolDependencies) mcp.Middleware {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) {
return next(ContextWithDeps(ctx, deps), method, req)
}
}
}
// ContextWithDeps returns a new context with the ToolDependencies stored in it.
// This is used to inject dependencies at request time rather than at registration time,
// avoiding expensive closure creation during server initialization.
@@ -67,7 +80,7 @@ type ToolDependencies interface {
GetRawClient(ctx context.Context) (*raw.Client, error)
// GetRepoAccessCache returns the lockdown mode repo access cache
GetRepoAccessCache() *lockdown.RepoAccessCache
GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAccessCache, error)
// GetT returns the translation helper function
GetT() translations.TranslationHelperFunc
@@ -132,7 +145,9 @@ func (d BaseDeps) GetRawClient(_ context.Context) (*raw.Client, error) {
}
// GetRepoAccessCache implements ToolDependencies.
func (d BaseDeps) GetRepoAccessCache() *lockdown.RepoAccessCache { return d.RepoAccessCache }
func (d BaseDeps) GetRepoAccessCache(_ context.Context) (*lockdown.RepoAccessCache, error) {
return d.RepoAccessCache, nil
}
// GetT implements ToolDependencies.
func (d BaseDeps) GetT() translations.TranslationHelperFunc { return d.T }
@@ -190,3 +205,126 @@ func NewToolFromHandler(
st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...)
return st
}
type RequestDeps struct {
Client *gogithub.Client
GQLClient *githubv4.Client
RawClient *raw.Client
RepoAccessCache *lockdown.RepoAccessCache
LockdownMode bool
// Static dependencies
apiHosts *utils.ApiHost
version string
RepoAccessOpts []lockdown.RepoAccessOption
T translations.TranslationHelperFunc
Flags FeatureFlags
ContentWindowSize int
}
// NewRequestDeps creates a RequestDeps with the provided clients and configuration.
func NewRequestDeps(
apiHosts *utils.ApiHost,
version string,
lockdownMode bool,
repoAccessOpts []lockdown.RepoAccessOption,
t translations.TranslationHelperFunc,
flags FeatureFlags,
contentWindowSize int,
) *RequestDeps {
return &RequestDeps{
apiHosts: apiHosts,
version: version,
LockdownMode: lockdownMode,
RepoAccessOpts: repoAccessOpts,
T: t,
Flags: flags,
ContentWindowSize: contentWindowSize,
}
}
// GetClient implements ToolDependencies.
func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {
if d.Client != nil {
return d.Client, nil
}
// extract the token from the context
token, _ := ghcontext.GetTokenInfo(ctx)
// Construct REST client
restClient := gogithub.NewClient(nil).WithAuthToken(token)
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", d.version)
restClient.BaseURL = d.apiHosts.BaseRESTURL
restClient.UploadURL = d.apiHosts.UploadURL
return restClient, nil
}
// GetGQLClient implements ToolDependencies.
func (d *RequestDeps) GetGQLClient(ctx context.Context) (*githubv4.Client, error) {
if d.GQLClient != nil {
return d.GQLClient, nil
}
// extract the token from the context
token, _ := ghcontext.GetTokenInfo(ctx)
// Construct GraphQL client
// We use NewEnterpriseClient unconditionally since we already parsed the API host
gqlHTTPClient := &http.Client{
Transport: &transport.BearerAuthTransport{
Transport: http.DefaultTransport,
Token: token,
},
}
gqlClient := githubv4.NewEnterpriseClient(d.apiHosts.GraphqlURL.String(), gqlHTTPClient)
d.GQLClient = gqlClient
return gqlClient, nil
}
// GetRawClient implements ToolDependencies.
func (d *RequestDeps) GetRawClient(ctx context.Context) (*raw.Client, error) {
if d.RawClient != nil {
return d.RawClient, nil
}
client, err := d.GetClient(ctx)
if err != nil {
return nil, err
}
rawClient := raw.NewClient(client, d.apiHosts.RawURL)
d.RawClient = rawClient
return rawClient, nil
}
// GetRepoAccessCache implements ToolDependencies.
func (d *RequestDeps) GetRepoAccessCache(ctx context.Context) (*lockdown.RepoAccessCache, error) {
if d.LockdownMode == false {
return nil, nil
}
if d.RepoAccessCache != nil {
return d.RepoAccessCache, nil
}
gqlClient, err := d.GetGQLClient(ctx)
if err != nil {
return nil, err
}
// Create repo access cache
instance := lockdown.GetInstance(gqlClient, d.RepoAccessOpts...)
d.RepoAccessCache = instance
return instance, nil
}
// GetT implements ToolDependencies.
func (d *RequestDeps) GetT() translations.TranslationHelperFunc { return d.T }
// GetFlags implements ToolDependencies.
func (d *RequestDeps) GetFlags() FeatureFlags { return d.Flags }
// GetContentWindowSize implements ToolDependencies.
func (d *RequestDeps) GetContentWindowSize() int { return d.ContentWindowSize }
+26 -24
View File
@@ -9,9 +9,9 @@ import (
"strings"
"time"
ghcontext "github.com/github/github-mcp-server/pkg/context"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/octicons"
"github.com/github/github-mcp-server/pkg/sanitize"
"github.com/github/github-mcp-server/pkg/scopes"
@@ -312,13 +312,13 @@ Options are:
switch method {
case "get":
result, err := GetIssue(ctx, client, deps.GetRepoAccessCache(), owner, repo, issueNumber, deps.GetFlags())
result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber)
return result, nil, err
case "get_comments":
result, err := GetIssueComments(ctx, client, deps.GetRepoAccessCache(), owner, repo, issueNumber, pagination, deps.GetFlags())
result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination)
return result, nil, err
case "get_sub_issues":
result, err := GetSubIssues(ctx, client, deps.GetRepoAccessCache(), owner, repo, issueNumber, pagination, deps.GetFlags())
result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination)
return result, nil, err
case "get_labels":
result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber)
@@ -329,7 +329,13 @@ Options are:
})
}
func GetIssue(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, flags FeatureFlags) (*mcp.CallToolResult, error) {
func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
cache, err := deps.GetRepoAccessCache(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get repo access cache: %w", err)
}
flags := deps.GetFlags()
issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
if err != nil {
return nil, fmt.Errorf("failed to get issue: %w", err)
@@ -378,7 +384,13 @@ func GetIssue(ctx context.Context, client *github.Client, cache *lockdown.RepoAc
return utils.NewToolResultText(string(r)), nil
}
func GetIssueComments(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, pagination PaginationParams, flags FeatureFlags) (*mcp.CallToolResult, error) {
func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
cache, err := deps.GetRepoAccessCache(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get repo access cache: %w", err)
}
flags := deps.GetFlags()
opts := &github.IssueListCommentsOptions{
ListOptions: github.ListOptions{
Page: pagination.Page,
@@ -432,7 +444,13 @@ func GetIssueComments(ctx context.Context, client *github.Client, cache *lockdow
return utils.NewToolResultText(string(r)), nil
}
func GetSubIssues(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, pagination PaginationParams, featureFlags FeatureFlags) (*mcp.CallToolResult, error) {
func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
cache, err := deps.GetRepoAccessCache(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get repo access cache: %w", err)
}
featureFlags := deps.GetFlags()
opts := &github.IssueListOptions{
ListOptions: github.ListOptions{
Page: pagination.Page,
@@ -1789,7 +1807,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server
// Add the GraphQL-Features header for the agent assignment API
// The header will be read by the HTTP transport if it's configured to do so
ctxWithFeatures := withGraphQLFeatures(ctx, "issues_copilot_assignment_api_support")
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issues_copilot_assignment_api_support")
if err := client.Mutate(
ctxWithFeatures,
@@ -1913,19 +1931,3 @@ func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) inventory.Ser
},
)
}
// graphQLFeaturesKey is a context key for GraphQL feature flags
type graphQLFeaturesKey struct{}
// withGraphQLFeatures adds GraphQL feature flags to the context
func withGraphQLFeatures(ctx context.Context, features ...string) context.Context {
return context.WithValue(ctx, graphQLFeaturesKey{}, features)
}
// GetGraphQLFeatures retrieves GraphQL feature flags from the context
func GetGraphQLFeatures(ctx context.Context) []string {
if features, ok := ctx.Value(graphQLFeaturesKey{}).([]string); ok {
return features
}
return nil
}
+393
View File
@@ -0,0 +1,393 @@
package github
import (
"errors"
"fmt"
"strconv"
"github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
)
// OptionalParamOK is a helper function that can be used to fetch a requested parameter from the request.
// It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong.
func OptionalParamOK[T any, A map[string]any](args A, p string) (value T, ok bool, err error) {
// Check if the parameter is present in the request
val, exists := args[p]
if !exists {
// Not present, return zero value, false, no error
return
}
// Check if the parameter is of the expected type
value, ok = val.(T)
if !ok {
// Present but wrong type
err = fmt.Errorf("parameter %s is not of type %T, is %T", p, value, val)
ok = true // Set ok to true because the parameter *was* present, even if wrong type
return
}
// Present and correct type
ok = true
return
}
// isAcceptedError checks if the error is an accepted error.
func isAcceptedError(err error) bool {
var acceptedError *github.AcceptedError
return errors.As(err, &acceptedError)
}
// RequiredParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func RequiredParam[T comparable](args map[string]any, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return zero, fmt.Errorf("missing required parameter: %s", p)
}
// Check if the parameter is of the expected type
val, ok := args[p].(T)
if !ok {
return zero, fmt.Errorf("parameter %s is not of type %T", p, zero)
}
if val == zero {
return zero, fmt.Errorf("missing required parameter: %s", p)
}
return val, nil
}
// RequiredInt is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func RequiredInt(args map[string]any, p string) (int, error) {
v, err := RequiredParam[float64](args, p)
if err != nil {
return 0, err
}
return int(v), nil
}
// RequiredBigInt is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type (float64).
// 3. Checks if the parameter is not empty, i.e: non-zero value.
// 4. Validates that the float64 value can be safely converted to int64 without truncation.
func RequiredBigInt(args map[string]any, p string) (int64, error) {
v, err := RequiredParam[float64](args, p)
if err != nil {
return 0, err
}
result := int64(v)
// Check if converting back produces the same value to avoid silent truncation
if float64(result) != v {
return 0, fmt.Errorf("parameter %s value %f is too large to fit in int64", p, v)
}
return result, nil
}
// OptionalParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, it checks if the parameter is of the expected type and returns it
func OptionalParam[T any](args map[string]any, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return zero, nil
}
// Check if the parameter is of the expected type
if _, ok := args[p].(T); !ok {
return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, args[p])
}
return args[p].(T), nil
}
// OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, it checks if the parameter is of the expected type and returns it
func OptionalIntParam(args map[string]any, p string) (int, error) {
v, err := OptionalParam[float64](args, p)
if err != nil {
return 0, err
}
return int(v), nil
}
// OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
// similar to optionalIntParam, but it also takes a default value.
func OptionalIntParamWithDefault(args map[string]any, p string, d int) (int, error) {
v, err := OptionalIntParam(args, p)
if err != nil {
return 0, err
}
if v == 0 {
return d, nil
}
return v, nil
}
// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
// similar to optionalBoolParam, but it also takes a default value.
func OptionalBoolParamWithDefault(args map[string]any, p string, d bool) (bool, error) {
_, ok := args[p]
v, err := OptionalParam[bool](args, p)
if err != nil {
return false, err
}
if !ok {
return d, nil
}
return v, nil
}
// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, iterates the elements and checks each is a string
func OptionalStringArrayParam(args map[string]any, p string) ([]string, error) {
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return []string{}, nil
}
switch v := args[p].(type) {
case nil:
return []string{}, nil
case []string:
return v, nil
case []any:
strSlice := make([]string, len(v))
for i, v := range v {
s, ok := v.(string)
if !ok {
return []string{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
}
strSlice[i] = s
}
return strSlice, nil
default:
return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, args[p])
}
}
func convertStringSliceToBigIntSlice(s []string) ([]int64, error) {
int64Slice := make([]int64, len(s))
for i, str := range s {
val, err := convertStringToBigInt(str, 0)
if err != nil {
return nil, fmt.Errorf("failed to convert element %d (%s) to int64: %w", i, str, err)
}
int64Slice[i] = val
}
return int64Slice, nil
}
func convertStringToBigInt(s string, def int64) (int64, error) {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return def, fmt.Errorf("failed to convert string %s to int64: %w", s, err)
}
return v, nil
}
// OptionalBigIntArrayParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns an empty slice
// 2. If it is present, iterates the elements, checks each is a string, and converts them to int64 values
func OptionalBigIntArrayParam(args map[string]any, p string) ([]int64, error) {
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return []int64{}, nil
}
switch v := args[p].(type) {
case nil:
return []int64{}, nil
case []string:
return convertStringSliceToBigIntSlice(v)
case []any:
int64Slice := make([]int64, len(v))
for i, v := range v {
s, ok := v.(string)
if !ok {
return []int64{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
}
val, err := convertStringToBigInt(s, 0)
if err != nil {
return []int64{}, fmt.Errorf("parameter %s: failed to convert element %d (%s) to int64: %w", p, i, s, err)
}
int64Slice[i] = val
}
return int64Slice, nil
default:
return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, args[p])
}
}
// WithPagination adds REST API pagination parameters to a tool.
// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["page"] = &jsonschema.Schema{
Type: "number",
Description: "Page number for pagination (min 1)",
Minimum: jsonschema.Ptr(1.0),
}
schema.Properties["perPage"] = &jsonschema.Schema{
Type: "number",
Description: "Results per page for pagination (min 1, max 100)",
Minimum: jsonschema.Ptr(1.0),
Maximum: jsonschema.Ptr(100.0),
}
return schema
}
// WithUnifiedPagination adds REST API pagination parameters to a tool.
// GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally.
func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["page"] = &jsonschema.Schema{
Type: "number",
Description: "Page number for pagination (min 1)",
Minimum: jsonschema.Ptr(1.0),
}
schema.Properties["perPage"] = &jsonschema.Schema{
Type: "number",
Description: "Results per page for pagination (min 1, max 100)",
Minimum: jsonschema.Ptr(1.0),
Maximum: jsonschema.Ptr(100.0),
}
schema.Properties["after"] = &jsonschema.Schema{
Type: "string",
Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
}
return schema
}
// WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter).
func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["perPage"] = &jsonschema.Schema{
Type: "number",
Description: "Results per page for pagination (min 1, max 100)",
Minimum: jsonschema.Ptr(1.0),
Maximum: jsonschema.Ptr(100.0),
}
schema.Properties["after"] = &jsonschema.Schema{
Type: "string",
Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
}
return schema
}
type PaginationParams struct {
Page int
PerPage int
After string
}
// OptionalPaginationParams returns the "page", "perPage", and "after" parameters from the request,
// or their default values if not present, "page" default is 1, "perPage" default is 30.
// In future, we may want to make the default values configurable, or even have this
// function returned from `withPagination`, where the defaults are provided alongside
// the min/max values.
func OptionalPaginationParams(args map[string]any) (PaginationParams, error) {
page, err := OptionalIntParamWithDefault(args, "page", 1)
if err != nil {
return PaginationParams{}, err
}
perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
if err != nil {
return PaginationParams{}, err
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return PaginationParams{}, err
}
return PaginationParams{
Page: page,
PerPage: perPage,
After: after,
}, nil
}
// OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request,
// without the "page" parameter, suitable for cursor-based pagination only.
func OptionalCursorPaginationParams(args map[string]any) (CursorPaginationParams, error) {
perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
if err != nil {
return CursorPaginationParams{}, err
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return CursorPaginationParams{}, err
}
return CursorPaginationParams{
PerPage: perPage,
After: after,
}, nil
}
type CursorPaginationParams struct {
PerPage int
After string
}
// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters.
func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
if p.PerPage > 100 {
return nil, fmt.Errorf("perPage value %d exceeds maximum of 100", p.PerPage)
}
if p.PerPage < 0 {
return nil, fmt.Errorf("perPage value %d cannot be negative", p.PerPage)
}
first := int32(p.PerPage)
var after *string
if p.After != "" {
after = &p.After
}
return &GraphQLPaginationParams{
First: &first,
After: after,
}, nil
}
type GraphQLPaginationParams struct {
First *int32
After *string
}
// ToGraphQLParams converts REST API pagination parameters to GraphQL-specific parameters.
// This converts page/perPage to first parameter for GraphQL queries.
// If After is provided, it takes precedence over page-based pagination.
func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
// Convert to CursorPaginationParams and delegate to avoid duplication
cursor := CursorPaginationParams{
PerPage: p.PerPage,
After: p.After,
}
return cursor.ToGraphQLParams()
}
+503
View File
@@ -0,0 +1,503 @@
package github
import (
"fmt"
"testing"
"github.com/google/go-github/v79/github"
"github.com/stretchr/testify/assert"
)
func Test_IsAcceptedError(t *testing.T) {
tests := []struct {
name string
err error
expectAccepted bool
}{
{
name: "github AcceptedError",
err: &github.AcceptedError{},
expectAccepted: true,
},
{
name: "regular error",
err: fmt.Errorf("some other error"),
expectAccepted: false,
},
{
name: "nil error",
err: nil,
expectAccepted: false,
},
{
name: "wrapped AcceptedError",
err: fmt.Errorf("wrapped: %w", &github.AcceptedError{}),
expectAccepted: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := isAcceptedError(tc.err)
assert.Equal(t, tc.expectAccepted, result)
})
}
}
func Test_RequiredStringParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected string
expectError bool
}{
{
name: "valid string parameter",
params: map[string]interface{}{"name": "test-value"},
paramName: "name",
expected: "test-value",
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "name",
expected: "",
expectError: true,
},
{
name: "empty string parameter",
params: map[string]interface{}{"name": ""},
paramName: "name",
expected: "",
expectError: true,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"name": 123},
paramName: "name",
expected: "",
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := RequiredParam[string](tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_OptionalStringParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected string
expectError bool
}{
{
name: "valid string parameter",
params: map[string]interface{}{"name": "test-value"},
paramName: "name",
expected: "test-value",
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "name",
expected: "",
expectError: false,
},
{
name: "empty string parameter",
params: map[string]interface{}{"name": ""},
paramName: "name",
expected: "",
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"name": 123},
paramName: "name",
expected: "",
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalParam[string](tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_RequiredInt(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected int
expectError bool
}{
{
name: "valid number parameter",
params: map[string]interface{}{"count": float64(42)},
paramName: "count",
expected: 42,
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "count",
expected: 0,
expectError: true,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"count": "not-a-number"},
paramName: "count",
expected: 0,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := RequiredInt(tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_OptionalIntParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected int
expectError bool
}{
{
name: "valid number parameter",
params: map[string]interface{}{"count": float64(42)},
paramName: "count",
expected: 42,
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "count",
expected: 0,
expectError: false,
},
{
name: "zero value",
params: map[string]interface{}{"count": float64(0)},
paramName: "count",
expected: 0,
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"count": "not-a-number"},
paramName: "count",
expected: 0,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalIntParam(tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_OptionalNumberParamWithDefault(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
defaultVal int
expected int
expectError bool
}{
{
name: "valid number parameter",
params: map[string]interface{}{"count": float64(42)},
paramName: "count",
defaultVal: 10,
expected: 42,
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "count",
defaultVal: 10,
expected: 10,
expectError: false,
},
{
name: "zero value",
params: map[string]interface{}{"count": float64(0)},
paramName: "count",
defaultVal: 10,
expected: 10,
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"count": "not-a-number"},
paramName: "count",
defaultVal: 10,
expected: 0,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalIntParamWithDefault(tc.params, tc.paramName, tc.defaultVal)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_OptionalBooleanParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected bool
expectError bool
}{
{
name: "true value",
params: map[string]interface{}{"flag": true},
paramName: "flag",
expected: true,
expectError: false,
},
{
name: "false value",
params: map[string]interface{}{"flag": false},
paramName: "flag",
expected: false,
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "flag",
expected: false,
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"flag": "not-a-boolean"},
paramName: "flag",
expected: false,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalParam[bool](tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func TestOptionalStringArrayParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected []string
expectError bool
}{
{
name: "parameter not in request",
params: map[string]any{},
paramName: "flag",
expected: []string{},
expectError: false,
},
{
name: "valid any array parameter",
params: map[string]any{
"flag": []any{"v1", "v2"},
},
paramName: "flag",
expected: []string{"v1", "v2"},
expectError: false,
},
{
name: "valid string array parameter",
params: map[string]any{
"flag": []string{"v1", "v2"},
},
paramName: "flag",
expected: []string{"v1", "v2"},
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]any{
"flag": 1,
},
paramName: "flag",
expected: []string{},
expectError: true,
},
{
name: "wrong slice type parameter",
params: map[string]any{
"flag": []any{"foo", 2},
},
paramName: "flag",
expected: []string{},
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalStringArrayParam(tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func TestOptionalPaginationParams(t *testing.T) {
tests := []struct {
name string
params map[string]any
expected PaginationParams
expectError bool
}{
{
name: "no pagination parameters, default values",
params: map[string]any{},
expected: PaginationParams{
Page: 1,
PerPage: 30,
},
expectError: false,
},
{
name: "page parameter, default perPage",
params: map[string]any{
"page": float64(2),
},
expected: PaginationParams{
Page: 2,
PerPage: 30,
},
expectError: false,
},
{
name: "perPage parameter, default page",
params: map[string]any{
"perPage": float64(50),
},
expected: PaginationParams{
Page: 1,
PerPage: 50,
},
expectError: false,
},
{
name: "page and perPage parameters",
params: map[string]any{
"page": float64(2),
"perPage": float64(50),
},
expected: PaginationParams{
Page: 2,
PerPage: 50,
},
expectError: false,
},
{
name: "invalid page parameter",
params: map[string]any{
"page": "not-a-number",
},
expected: PaginationParams{},
expectError: true,
},
{
name: "invalid perPage parameter",
params: map[string]any{
"perPage": "not-a-number",
},
expected: PaginationParams{},
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalPaginationParams(tc.params)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
+25 -8
View File
@@ -15,7 +15,6 @@ import (
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/octicons"
"github.com/github/github-mcp-server/pkg/sanitize"
"github.com/github/github-mcp-server/pkg/scopes"
@@ -101,7 +100,7 @@ Possible options:
switch method {
case "get":
result, err := GetPullRequest(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, deps.GetFlags())
result, err := GetPullRequest(ctx, client, deps, owner, repo, pullNumber)
return result, nil, err
case "get_diff":
result, err := GetPullRequestDiff(ctx, client, owner, repo, pullNumber)
@@ -121,13 +120,13 @@ Possible options:
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, err := GetPullRequestReviewComments(ctx, gqlClient, deps.GetRepoAccessCache(), owner, repo, pullNumber, cursorPagination, deps.GetFlags())
result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination)
return result, nil, err
case "get_reviews":
result, err := GetPullRequestReviews(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, deps.GetFlags())
result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber)
return result, nil, err
case "get_comments":
result, err := GetIssueComments(ctx, client, deps.GetRepoAccessCache(), owner, repo, pullNumber, pagination, deps.GetFlags())
result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination)
return result, nil, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
@@ -135,7 +134,13 @@ Possible options:
})
}
func GetPullRequest(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, ff FeatureFlags) (*mcp.CallToolResult, error) {
func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) {
cache, err := deps.GetRepoAccessCache(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get repo access cache: %w", err)
}
ff := deps.GetFlags()
pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
@@ -340,7 +345,13 @@ type pageInfoFragment struct {
EndCursor githubv4.String
}
func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, pagination CursorPaginationParams, ff FeatureFlags) (*mcp.CallToolResult, error) {
func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination CursorPaginationParams) (*mcp.CallToolResult, error) {
cache, err := deps.GetRepoAccessCache(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get repo access cache: %w", err)
}
ff := deps.GetFlags()
// Convert pagination parameters to GraphQL format
gqlParams, err := pagination.ToGraphQLParams()
if err != nil {
@@ -421,7 +432,13 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien
return utils.NewToolResultText(string(r)), nil
}
func GetPullRequestReviews(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, ff FeatureFlags) (*mcp.CallToolResult, error) {
func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) {
cache, err := deps.GetRepoAccessCache(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get repo access cache: %w", err)
}
ff := deps.GetFlags()
reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
+186 -388
View File
@@ -3,20 +3,201 @@ package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"log/slog"
"os"
"strings"
"time"
gherrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/octicons"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// NewServer creates a new GitHub MCP server with the specified GH client and logger.
type MCPServerConfig 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
// EnabledTools is a list of specific tools to enable (additive to toolsets)
// When specified, these tools are registered in addition to any specified toolset tools
EnabledTools []string
// EnabledFeatures is a list of feature flags that are enabled
// Items with FeatureFlagEnable matching an entry in this list will be available
EnabledFeatures []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 offer read-only tools
ReadOnly bool
// Translator provides translated text for the server tooling
Translator translations.TranslationHelperFunc
// Content window size
ContentWindowSize int
// LockdownMode indicates if we should enable lockdown mode
LockdownMode bool
// Logger is used for logging within the server
Logger *slog.Logger
// RepoAccessTTL overrides the default TTL for repository access cache entries.
RepoAccessTTL *time.Duration
// TokenScopes contains the OAuth scopes available to the token.
// When non-nil, tools requiring scopes not in this list will be hidden.
// This is used for PAT scope filtering where we can't issue scope challenges.
TokenScopes []string
}
func NewMcpServer(cfg *MCPServerConfig, deps ToolDependencies) (*mcp.Server, error) {
enabledToolsets := resolveEnabledToolsets(cfg)
// For instruction generation, we need actual toolset names (not nil).
// nil means "use defaults" in inventory, so expand it for instructions.
instructionToolsets := enabledToolsets
if instructionToolsets == nil {
instructionToolsets = GetDefaultToolsetIDs()
}
// Create the MCP server
serverOpts := &mcp.ServerOptions{
Instructions: GenerateInstructions(instructionToolsets),
Logger: cfg.Logger,
CompletionHandler: CompletionsHandler(deps.GetClient),
}
// In dynamic mode, explicitly advertise capabilities since tools/resources/prompts
// may be enabled at runtime even if none are registered initially.
if cfg.DynamicToolsets {
serverOpts.Capabilities = &mcp.ServerCapabilities{
Tools: &mcp.ToolCapabilities{},
Resources: &mcp.ResourceCapabilities{},
Prompts: &mcp.PromptCapabilities{},
}
}
ghServer := NewServer(cfg.Version, serverOpts)
// Add middlewares
ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps))
// Build and register the tool/resource/prompt inventory
inventoryBuilder := NewInventory(cfg.Translator).
WithDeprecatedAliases(DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(enabledToolsets).
WithTools(CleanTools(cfg.EnabledTools)).
WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures))
// Apply token scope filtering if scopes are known (for PAT filtering)
if cfg.TokenScopes != nil {
inventoryBuilder = inventoryBuilder.WithFilter(CreateToolScopeFilter(cfg.TokenScopes))
}
inventory := inventoryBuilder.Build()
if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 {
fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", "))
}
// Register GitHub tools/resources/prompts from the inventory.
// In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets
// is empty - users enable toolsets at runtime via the dynamic tools below (but can
// enable toolsets or tools explicitly that do need registration).
inventory.RegisterAll(context.Background(), ghServer, deps)
// Register dynamic toolset management tools (enable/disable) - these are separate
// meta-tools that control the inventory, not part of the inventory itself
if cfg.DynamicToolsets {
registerDynamicTools(ghServer, inventory, deps, cfg.Translator)
}
return ghServer, nil
}
// registerDynamicTools adds the dynamic toolset enable/disable tools to the server.
func registerDynamicTools(server *mcp.Server, inventory *inventory.Inventory, deps ToolDependencies, t translations.TranslationHelperFunc) {
dynamicDeps := DynamicToolDependencies{
Server: server,
Inventory: inventory,
ToolDeps: deps,
T: t,
}
for _, tool := range DynamicTools(inventory) {
tool.RegisterFunc(server, dynamicDeps)
}
}
// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name
// is present in the provided list of enabled features. For the local server,
// this is populated from the --features CLI flag.
func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker {
// Build a set for O(1) lookup
featureSet := make(map[string]bool, len(enabledFeatures))
for _, f := range enabledFeatures {
featureSet[f] = true
}
return func(_ context.Context, flagName string) (bool, error) {
return featureSet[flagName], nil
}
}
// resolveEnabledToolsets determines which toolsets should be enabled based on config.
// Returns nil for "use defaults", empty slice for "none", or explicit list.
func resolveEnabledToolsets(cfg *MCPServerConfig) []string {
enabledToolsets := cfg.EnabledToolsets
// In dynamic mode, remove "all" and "default" since users enable toolsets on demand
if cfg.DynamicToolsets && enabledToolsets != nil {
enabledToolsets = RemoveToolset(enabledToolsets, string(ToolsetMetadataAll.ID))
enabledToolsets = RemoveToolset(enabledToolsets, string(ToolsetMetadataDefault.ID))
}
if enabledToolsets != nil {
return enabledToolsets
}
if cfg.DynamicToolsets {
// Dynamic mode with no toolsets specified: start empty so users enable on demand
return []string{}
}
if len(cfg.EnabledTools) > 0 {
// When specific tools are requested but no toolsets, don't use default toolsets
// This matches the original behavior: --tools=X alone registers only X
return []string{}
}
// nil means "use defaults" in WithToolsets
return nil
}
func addGitHubAPIErrorToContext(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) {
// Ensure the context is cleared of any previous errors
// as context isn't propagated through middleware
ctx = gherrors.ContextWithGitHubErrors(ctx)
return next(ctx, method, req)
}
}
// NewServer creates a new GitHub MCP server with the specified GH client and logger.
func NewServer(version string, opts *mcp.ServerOptions) *mcp.Server {
if opts == nil {
opts = &mcp.ServerOptions{}
@@ -49,389 +230,6 @@ func CompletionsHandler(getClient GetClientFn) func(ctx context.Context, req *mc
}
}
// OptionalParamOK is a helper function that can be used to fetch a requested parameter from the request.
// It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong.
func OptionalParamOK[T any, A map[string]any](args A, p string) (value T, ok bool, err error) {
// Check if the parameter is present in the request
val, exists := args[p]
if !exists {
// Not present, return zero value, false, no error
return
}
// Check if the parameter is of the expected type
value, ok = val.(T)
if !ok {
// Present but wrong type
err = fmt.Errorf("parameter %s is not of type %T, is %T", p, value, val)
ok = true // Set ok to true because the parameter *was* present, even if wrong type
return
}
// Present and correct type
ok = true
return
}
// isAcceptedError checks if the error is an accepted error.
func isAcceptedError(err error) bool {
var acceptedError *github.AcceptedError
return errors.As(err, &acceptedError)
}
// RequiredParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func RequiredParam[T comparable](args map[string]any, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return zero, fmt.Errorf("missing required parameter: %s", p)
}
// Check if the parameter is of the expected type
val, ok := args[p].(T)
if !ok {
return zero, fmt.Errorf("parameter %s is not of type %T", p, zero)
}
if val == zero {
return zero, fmt.Errorf("missing required parameter: %s", p)
}
return val, nil
}
// RequiredInt is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func RequiredInt(args map[string]any, p string) (int, error) {
v, err := RequiredParam[float64](args, p)
if err != nil {
return 0, err
}
return int(v), nil
}
// RequiredBigInt is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type (float64).
// 3. Checks if the parameter is not empty, i.e: non-zero value.
// 4. Validates that the float64 value can be safely converted to int64 without truncation.
func RequiredBigInt(args map[string]any, p string) (int64, error) {
v, err := RequiredParam[float64](args, p)
if err != nil {
return 0, err
}
result := int64(v)
// Check if converting back produces the same value to avoid silent truncation
if float64(result) != v {
return 0, fmt.Errorf("parameter %s value %f is too large to fit in int64", p, v)
}
return result, nil
}
// OptionalParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, it checks if the parameter is of the expected type and returns it
func OptionalParam[T any](args map[string]any, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return zero, nil
}
// Check if the parameter is of the expected type
if _, ok := args[p].(T); !ok {
return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, args[p])
}
return args[p].(T), nil
}
// OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, it checks if the parameter is of the expected type and returns it
func OptionalIntParam(args map[string]any, p string) (int, error) {
v, err := OptionalParam[float64](args, p)
if err != nil {
return 0, err
}
return int(v), nil
}
// OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
// similar to optionalIntParam, but it also takes a default value.
func OptionalIntParamWithDefault(args map[string]any, p string, d int) (int, error) {
v, err := OptionalIntParam(args, p)
if err != nil {
return 0, err
}
if v == 0 {
return d, nil
}
return v, nil
}
// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
// similar to optionalBoolParam, but it also takes a default value.
func OptionalBoolParamWithDefault(args map[string]any, p string, d bool) (bool, error) {
_, ok := args[p]
v, err := OptionalParam[bool](args, p)
if err != nil {
return false, err
}
if !ok {
return d, nil
}
return v, nil
}
// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, iterates the elements and checks each is a string
func OptionalStringArrayParam(args map[string]any, p string) ([]string, error) {
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return []string{}, nil
}
switch v := args[p].(type) {
case nil:
return []string{}, nil
case []string:
return v, nil
case []any:
strSlice := make([]string, len(v))
for i, v := range v {
s, ok := v.(string)
if !ok {
return []string{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
}
strSlice[i] = s
}
return strSlice, nil
default:
return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, args[p])
}
}
func convertStringSliceToBigIntSlice(s []string) ([]int64, error) {
int64Slice := make([]int64, len(s))
for i, str := range s {
val, err := convertStringToBigInt(str, 0)
if err != nil {
return nil, fmt.Errorf("failed to convert element %d (%s) to int64: %w", i, str, err)
}
int64Slice[i] = val
}
return int64Slice, nil
}
func convertStringToBigInt(s string, def int64) (int64, error) {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return def, fmt.Errorf("failed to convert string %s to int64: %w", s, err)
}
return v, nil
}
// OptionalBigIntArrayParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns an empty slice
// 2. If it is present, iterates the elements, checks each is a string, and converts them to int64 values
func OptionalBigIntArrayParam(args map[string]any, p string) ([]int64, error) {
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return []int64{}, nil
}
switch v := args[p].(type) {
case nil:
return []int64{}, nil
case []string:
return convertStringSliceToBigIntSlice(v)
case []any:
int64Slice := make([]int64, len(v))
for i, v := range v {
s, ok := v.(string)
if !ok {
return []int64{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
}
val, err := convertStringToBigInt(s, 0)
if err != nil {
return []int64{}, fmt.Errorf("parameter %s: failed to convert element %d (%s) to int64: %w", p, i, s, err)
}
int64Slice[i] = val
}
return int64Slice, nil
default:
return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, args[p])
}
}
// WithPagination adds REST API pagination parameters to a tool.
// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["page"] = &jsonschema.Schema{
Type: "number",
Description: "Page number for pagination (min 1)",
Minimum: jsonschema.Ptr(1.0),
}
schema.Properties["perPage"] = &jsonschema.Schema{
Type: "number",
Description: "Results per page for pagination (min 1, max 100)",
Minimum: jsonschema.Ptr(1.0),
Maximum: jsonschema.Ptr(100.0),
}
return schema
}
// WithUnifiedPagination adds REST API pagination parameters to a tool.
// GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally.
func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["page"] = &jsonschema.Schema{
Type: "number",
Description: "Page number for pagination (min 1)",
Minimum: jsonschema.Ptr(1.0),
}
schema.Properties["perPage"] = &jsonschema.Schema{
Type: "number",
Description: "Results per page for pagination (min 1, max 100)",
Minimum: jsonschema.Ptr(1.0),
Maximum: jsonschema.Ptr(100.0),
}
schema.Properties["after"] = &jsonschema.Schema{
Type: "string",
Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
}
return schema
}
// WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter).
func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["perPage"] = &jsonschema.Schema{
Type: "number",
Description: "Results per page for pagination (min 1, max 100)",
Minimum: jsonschema.Ptr(1.0),
Maximum: jsonschema.Ptr(100.0),
}
schema.Properties["after"] = &jsonschema.Schema{
Type: "string",
Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
}
return schema
}
type PaginationParams struct {
Page int
PerPage int
After string
}
// OptionalPaginationParams returns the "page", "perPage", and "after" parameters from the request,
// or their default values if not present, "page" default is 1, "perPage" default is 30.
// In future, we may want to make the default values configurable, or even have this
// function returned from `withPagination`, where the defaults are provided alongside
// the min/max values.
func OptionalPaginationParams(args map[string]any) (PaginationParams, error) {
page, err := OptionalIntParamWithDefault(args, "page", 1)
if err != nil {
return PaginationParams{}, err
}
perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
if err != nil {
return PaginationParams{}, err
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return PaginationParams{}, err
}
return PaginationParams{
Page: page,
PerPage: perPage,
After: after,
}, nil
}
// OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request,
// without the "page" parameter, suitable for cursor-based pagination only.
func OptionalCursorPaginationParams(args map[string]any) (CursorPaginationParams, error) {
perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
if err != nil {
return CursorPaginationParams{}, err
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return CursorPaginationParams{}, err
}
return CursorPaginationParams{
PerPage: perPage,
After: after,
}, nil
}
type CursorPaginationParams struct {
PerPage int
After string
}
// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters.
func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
if p.PerPage > 100 {
return nil, fmt.Errorf("perPage value %d exceeds maximum of 100", p.PerPage)
}
if p.PerPage < 0 {
return nil, fmt.Errorf("perPage value %d cannot be negative", p.PerPage)
}
first := int32(p.PerPage)
var after *string
if p.After != "" {
after = &p.After
}
return &GraphQLPaginationParams{
First: &first,
After: after,
}, nil
}
type GraphQLPaginationParams struct {
First *int32
After *string
}
// ToGraphQLParams converts REST API pagination parameters to GraphQL-specific parameters.
// This converts page/perPage to first parameter for GraphQL queries.
// If After is provided, it takes precedence over page-based pagination.
func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
// Convert to CursorPaginationParams and delegate to avoid duplication
cursor := CursorPaginationParams{
PerPage: p.PerPage,
After: p.After,
}
return cursor.ToGraphQLParams()
}
func MarshalledTextResult(v any) *mcp.CallToolResult {
data, err := json.Marshal(v)
if err != nil {
-496
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"net/http"
"testing"
"time"
"github.com/github/github-mcp-server/pkg/lockdown"
@@ -14,7 +13,6 @@ import (
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v79/github"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
)
// stubDeps is a test helper that implements ToolDependencies with configurable behavior.
@@ -100,497 +98,3 @@ func badRequestHandler(msg string) http.HandlerFunc {
http.Error(w, string(b), http.StatusBadRequest)
}
}
func Test_IsAcceptedError(t *testing.T) {
tests := []struct {
name string
err error
expectAccepted bool
}{
{
name: "github AcceptedError",
err: &github.AcceptedError{},
expectAccepted: true,
},
{
name: "regular error",
err: fmt.Errorf("some other error"),
expectAccepted: false,
},
{
name: "nil error",
err: nil,
expectAccepted: false,
},
{
name: "wrapped AcceptedError",
err: fmt.Errorf("wrapped: %w", &github.AcceptedError{}),
expectAccepted: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := isAcceptedError(tc.err)
assert.Equal(t, tc.expectAccepted, result)
})
}
}
func Test_RequiredStringParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected string
expectError bool
}{
{
name: "valid string parameter",
params: map[string]interface{}{"name": "test-value"},
paramName: "name",
expected: "test-value",
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "name",
expected: "",
expectError: true,
},
{
name: "empty string parameter",
params: map[string]interface{}{"name": ""},
paramName: "name",
expected: "",
expectError: true,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"name": 123},
paramName: "name",
expected: "",
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := RequiredParam[string](tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_OptionalStringParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected string
expectError bool
}{
{
name: "valid string parameter",
params: map[string]interface{}{"name": "test-value"},
paramName: "name",
expected: "test-value",
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "name",
expected: "",
expectError: false,
},
{
name: "empty string parameter",
params: map[string]interface{}{"name": ""},
paramName: "name",
expected: "",
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"name": 123},
paramName: "name",
expected: "",
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalParam[string](tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_RequiredInt(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected int
expectError bool
}{
{
name: "valid number parameter",
params: map[string]interface{}{"count": float64(42)},
paramName: "count",
expected: 42,
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "count",
expected: 0,
expectError: true,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"count": "not-a-number"},
paramName: "count",
expected: 0,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := RequiredInt(tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_OptionalIntParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected int
expectError bool
}{
{
name: "valid number parameter",
params: map[string]interface{}{"count": float64(42)},
paramName: "count",
expected: 42,
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "count",
expected: 0,
expectError: false,
},
{
name: "zero value",
params: map[string]interface{}{"count": float64(0)},
paramName: "count",
expected: 0,
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"count": "not-a-number"},
paramName: "count",
expected: 0,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalIntParam(tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_OptionalNumberParamWithDefault(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
defaultVal int
expected int
expectError bool
}{
{
name: "valid number parameter",
params: map[string]interface{}{"count": float64(42)},
paramName: "count",
defaultVal: 10,
expected: 42,
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "count",
defaultVal: 10,
expected: 10,
expectError: false,
},
{
name: "zero value",
params: map[string]interface{}{"count": float64(0)},
paramName: "count",
defaultVal: 10,
expected: 10,
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"count": "not-a-number"},
paramName: "count",
defaultVal: 10,
expected: 0,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalIntParamWithDefault(tc.params, tc.paramName, tc.defaultVal)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func Test_OptionalBooleanParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected bool
expectError bool
}{
{
name: "true value",
params: map[string]interface{}{"flag": true},
paramName: "flag",
expected: true,
expectError: false,
},
{
name: "false value",
params: map[string]interface{}{"flag": false},
paramName: "flag",
expected: false,
expectError: false,
},
{
name: "missing parameter",
params: map[string]interface{}{},
paramName: "flag",
expected: false,
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]interface{}{"flag": "not-a-boolean"},
paramName: "flag",
expected: false,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalParam[bool](tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func TestOptionalStringArrayParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
paramName string
expected []string
expectError bool
}{
{
name: "parameter not in request",
params: map[string]any{},
paramName: "flag",
expected: []string{},
expectError: false,
},
{
name: "valid any array parameter",
params: map[string]any{
"flag": []any{"v1", "v2"},
},
paramName: "flag",
expected: []string{"v1", "v2"},
expectError: false,
},
{
name: "valid string array parameter",
params: map[string]any{
"flag": []string{"v1", "v2"},
},
paramName: "flag",
expected: []string{"v1", "v2"},
expectError: false,
},
{
name: "wrong type parameter",
params: map[string]any{
"flag": 1,
},
paramName: "flag",
expected: []string{},
expectError: true,
},
{
name: "wrong slice type parameter",
params: map[string]any{
"flag": []any{"foo", 2},
},
paramName: "flag",
expected: []string{},
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalStringArrayParam(tc.params, tc.paramName)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
func TestOptionalPaginationParams(t *testing.T) {
tests := []struct {
name string
params map[string]any
expected PaginationParams
expectError bool
}{
{
name: "no pagination parameters, default values",
params: map[string]any{},
expected: PaginationParams{
Page: 1,
PerPage: 30,
},
expectError: false,
},
{
name: "page parameter, default perPage",
params: map[string]any{
"page": float64(2),
},
expected: PaginationParams{
Page: 2,
PerPage: 30,
},
expectError: false,
},
{
name: "perPage parameter, default page",
params: map[string]any{
"perPage": float64(50),
},
expected: PaginationParams{
Page: 1,
PerPage: 50,
},
expectError: false,
},
{
name: "page and perPage parameters",
params: map[string]any{
"page": float64(2),
"perPage": float64(50),
},
expected: PaginationParams{
Page: 2,
PerPage: 50,
},
expectError: false,
},
{
name: "invalid page parameter",
params: map[string]any{
"page": "not-a-number",
},
expected: PaginationParams{},
expectError: true,
},
{
name: "invalid perPage parameter",
params: map[string]any{
"perPage": "not-a-number",
},
expected: PaginationParams{},
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := OptionalPaginationParams(tc.params)
if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expected, result)
}
})
}
}
+26
View File
@@ -0,0 +1,26 @@
package headers
const (
// AuthorizationHeader is a standard HTTP Header.
AuthorizationHeader = "Authorization"
// ContentTypeHeader is a standard HTTP Header.
ContentTypeHeader = "Content-Type"
// AcceptHeader is a standard HTTP Header.
AcceptHeader = "Accept"
// UserAgentHeader is a standard HTTP Header.
UserAgentHeader = "User-Agent"
// ContentTypeJSON is the standard MIME type for JSON.
ContentTypeJSON = "application/json"
// ContentTypeEventStream is the standard MIME type for Event Streams.
ContentTypeEventStream = "text/event-stream"
// ForwardedForHeader is a standard HTTP Header used to forward the originating IP address of a client.
ForwardedForHeader = "X-Forwarded-For"
// RealIPHeader is a standard HTTP Header used to indicate the real IP address of the client.
RealIPHeader = "X-Real-IP"
// RequestHmacHeader is used to authenticate requests to the Raw API.
RequestHmacHeader = "Request-Hmac"
)
+65
View File
@@ -0,0 +1,65 @@
// Package mark provides a mechnanism for tagging errors with a well-known error value.
package mark
import "errors"
// This list of errors is not exhaustive, but is a good starting point for most
// applications. Feel free to add more as needed, but don't go overboard.
// Remember, the specific types of errors are only important so far as someone
// calling your code might want to write logic to handle each type of error
// differently.
//
// Do not add application-specific errors to this list. Instead, just define
// your own package with your own application-specific errors, and use this
// package to mark errors with them. The errors in this package are not special,
// they're just plain old errors.
//
// Not all errors need to be marked. An error that is not marked should be
// treated as an unexpected error that cannot be handled by calling code. This
// is often the case for network errors or logic errors.
var (
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrBadRequest = errors.New("bad request")
ErrUnauthorized = errors.New("unauthorized")
ErrCancelled = errors.New("request cancelled")
ErrUnavailable = errors.New("unavailable")
ErrTimedout = errors.New("request timed out")
ErrTooLarge = errors.New("request is too large")
ErrTooManyRequests = errors.New("too many requests")
ErrForbidden = errors.New("forbidden")
)
// With wraps err with another error that will return true from errors.Is and
// errors.As for both err and markErr, and anything either may wrap.
func With(err, markErr error) error {
if err == nil {
return nil
}
return marked{wrapped: err, mark: markErr}
}
type marked struct {
wrapped error
mark error
}
func (f marked) Is(target error) bool {
// if this is false, errors.Is will call unwrap and retry on the wrapped
// error.
return errors.Is(f.mark, target)
}
func (f marked) As(target any) bool {
// if this is false, errors.As will call unwrap and retry on the wrapped
// error.
return errors.As(f.mark, target)
}
func (f marked) Unwrap() error {
return f.wrapped
}
func (f marked) Error() string {
return f.mark.Error() + ": " + f.wrapped.Error()
}
+103
View File
@@ -0,0 +1,103 @@
package middleware
import (
"errors"
"fmt"
"net/http"
"regexp"
"strings"
ghcontext "github.com/github/github-mcp-server/pkg/context"
httpheaders "github.com/github/github-mcp-server/pkg/http/headers"
"github.com/github/github-mcp-server/pkg/http/mark"
)
type authType int
const (
authTypeUnknown authType = iota
authTypeIDE
authTypeGhToken
)
var (
errMissingAuthorizationHeader = fmt.Errorf("%w: missing required Authorization header", mark.ErrBadRequest)
errBadAuthorizationHeader = fmt.Errorf("%w: Authorization header is badly formatted", mark.ErrBadRequest)
errUnsupportedAuthorizationHeader = fmt.Errorf("%w: unsupported Authorization header", mark.ErrBadRequest)
errMissingTokenInfoHeader = fmt.Errorf("%w: missing required token info header", mark.ErrBadRequest)
)
var supportedThirdPartyTokenPrefixes = []string{
"ghp_", // Personal access token (classic)
"github_pat_", // Fine-grained personal access token
"gho_", // OAuth access token
"ghu_", // User access token for a GitHub App
"ghs_", // Installation access token for a GitHub App (a.k.a. server-to-server token)
}
// oldPatternRegexp is the regular expression for the old pattern of the token.
// Until 2021, GitHub API tokens did not have an identifiable prefix. They
// were 40 characters long and only contained the characters a-f and 0-9.
var oldPatternRegexp = regexp.MustCompile(`\A[a-f0-9]{40}\z`)
func ExtractUserToken() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, token, err := parseAuthorizationHeader(r)
if err != nil {
// For missing Authorization header, return 401 with WWW-Authenticate header per MCP spec
if errors.Is(err, errMissingAuthorizationHeader) {
// sendAuthChallenge(w, r, cfg, obsv)
return
}
// For other auth errors (bad format, unsupported), return 400
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
ctx = ghcontext.WithTokenInfo(ctx, token)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
}
func parseAuthorizationHeader(req *http.Request) (authType authType, token string, _ error) {
authHeader := req.Header.Get(httpheaders.AuthorizationHeader)
if authHeader == "" {
return 0, "", errMissingAuthorizationHeader
}
switch {
// decrypt dotcom token and set it as token
case strings.HasPrefix(authHeader, "GitHub-Bearer "):
return 0, "", errUnsupportedAuthorizationHeader
default:
// support both "Bearer" and "bearer" to conform to api.github.com
if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") {
token = authHeader[7:]
} else {
token = authHeader
}
}
// Do a naïve check for a colon in the token - currently, only the IDE token has a colon in it.
// ex: tid=1;exp=25145314523;chat=1:<hmac>
if strings.Contains(token, ":") {
return authTypeIDE, token, nil
}
for _, prefix := range supportedThirdPartyTokenPrefixes {
if strings.HasPrefix(token, prefix) {
return authTypeGhToken, token, nil
}
}
matchesOldTokenPattern := oldPatternRegexp.MatchString(token)
if matchesOldTokenPattern {
return authTypeGhToken, token, nil
}
return 0, "", errBadAuthorizationHeader
}
+25
View File
@@ -0,0 +1,25 @@
package transport
import (
"net/http"
"strings"
ghcontext "github.com/github/github-mcp-server/pkg/context"
)
type BearerAuthTransport struct {
Transport http.RoundTripper
Token string
}
func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer "+t.Token)
// Check for GraphQL-Features in context and add header if present
if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 {
req.Header.Set("GraphQL-Features", strings.Join(features, ", "))
}
return t.Transport.RoundTrip(req)
}
+14
View File
@@ -0,0 +1,14 @@
package transport
import "net/http"
type UserAgentTransport struct {
Transport http.RoundTripper
Agent string
}
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("User-Agent", t.Agent)
return t.Transport.RoundTrip(req)
}
+185
View File
@@ -0,0 +1,185 @@
package utils
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
type ApiHost struct {
BaseRESTURL *url.URL
GraphqlURL *url.URL
UploadURL *url.URL
RawURL *url.URL
}
func newDotcomHost() (ApiHost, error) {
baseRestURL, err := url.Parse("https://api.github.com/")
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err)
}
gqlURL, err := url.Parse("https://api.github.com/graphql")
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err)
}
uploadURL, err := url.Parse("https://uploads.github.com")
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
}
rawURL, err := url.Parse("https://raw.githubusercontent.com/")
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
}
return ApiHost{
BaseRESTURL: baseRestURL,
GraphqlURL: gqlURL,
UploadURL: uploadURL,
RawURL: rawURL,
}, nil
}
func newGHECHost(hostname string) (ApiHost, error) {
u, err := url.Parse(hostname)
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err)
}
// Unsecured GHEC would be an error
if u.Scheme == "http" {
return ApiHost{}, fmt.Errorf("GHEC URL must be HTTPS")
}
restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname()))
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err)
}
gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname()))
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err)
}
uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s", u.Hostname()))
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
}
rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
}
return ApiHost{
BaseRESTURL: restURL,
GraphqlURL: gqlURL,
UploadURL: uploadURL,
RawURL: rawURL,
}, nil
}
func newGHESHost(hostname string) (ApiHost, error) {
u, err := url.Parse(hostname)
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
}
restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname()))
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err)
}
gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname()))
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err)
}
// Check if subdomain isolation is enabled
// See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation
hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname())
var uploadURL *url.URL
if hasSubdomainIsolation {
// With subdomain isolation: https://uploads.hostname/
uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname()))
} else {
// Without subdomain isolation: https://hostname/api/uploads/
uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname()))
}
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
}
var rawURL *url.URL
if hasSubdomainIsolation {
// With subdomain isolation: https://raw.hostname/
rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname()))
} else {
// Without subdomain isolation: https://hostname/raw/
rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname()))
}
if err != nil {
return ApiHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err)
}
return ApiHost{
BaseRESTURL: restURL,
GraphqlURL: gqlURL,
UploadURL: uploadURL,
RawURL: rawURL,
}, nil
}
// checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled
// by attempting to ping the raw.<host>/_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation.
func checkSubdomainIsolation(scheme, hostname string) bool {
subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname)
client := &http.Client{
Timeout: 5 * time.Second,
// Don't follow redirects - we just want to check if the endpoint exists
//nolint:revive // parameters are required by http.Client.CheckRedirect signature
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(subdomainURL)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// Note that this does not handle ports yet, so development environments are out.
func ParseAPIHost(s string) (ApiHost, error) {
if s == "" {
return newDotcomHost()
}
u, err := url.Parse(s)
if err != nil {
return ApiHost{}, fmt.Errorf("could not parse host as URL: %s", s)
}
if u.Scheme == "" {
return ApiHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s)
}
if strings.HasSuffix(u.Hostname(), "github.com") {
return newDotcomHost()
}
if strings.HasSuffix(u.Hostname(), "ghe.com") {
return newGHECHost(s)
}
return newGHESHost(s)
}