diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index fb716f78d..04e401bde 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/github/github-mcp-server/pkg/ghmcp" "github.com/github/github-mcp-server/pkg/github" "github.com/spf13/cobra" "github.com/spf13/viper" diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index a75a9e0cb..4d88bb9f2 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -22,6 +22,10 @@ import ( "github.com/sirupsen/logrus" ) +// TokenProvider is a function that returns the current GitHub token. +// This allows for dynamic token refresh without restarting the server. +type TokenProvider func() string + type MCPServerConfig struct { // Version of the server Version string @@ -30,8 +34,13 @@ type MCPServerConfig struct { Host string // GitHub Token to authenticate with the GitHub API + // Deprecated: Use TokenProvider for dynamic token support Token string + // TokenProvider is a function that returns the current GitHub token + // If set, this takes precedence over Token + TokenProvider TokenProvider + // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string @@ -53,8 +62,25 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { return nil, fmt.Errorf("failed to parse API host: %w", err) } - // Construct our REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) + // Set up token provider - use the provided one or create one from static token + tokenProvider := cfg.TokenProvider + if tokenProvider == nil { + if cfg.Token == "" { + return nil, fmt.Errorf("either Token or TokenProvider must be set") + } + // Create a token provider that returns the static token + staticToken := cfg.Token + tokenProvider = func() string { return staticToken } + } + + // Construct our REST client with a custom transport + restHTTPClient := &http.Client{ + Transport: &tokenProviderTransport{ + transport: http.DefaultTransport, + tokenProvider: tokenProvider, + }, + } + restClient := gogithub.NewClient(restHTTPClient) restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) restClient.BaseURL = apiHost.baseRESTURL restClient.UploadURL = apiHost.uploadURL @@ -63,9 +89,9 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { // We're using NewEnterpriseClient here unconditionally as opposed to NewClient because we already // did the necessary API host parsing so that github.com will return the correct URL anyway. gqlHTTPClient := &http.Client{ - Transport: &bearerAuthTransport{ - transport: http.DefaultTransport, - token: cfg.Token, + Transport: &bearerAuthProviderTransport{ + transport: http.DefaultTransport, + tokenProvider: tokenProvider, }, } // We're going to wrap the Transport later in beforeInit gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) @@ -147,8 +173,13 @@ type StdioServerConfig struct { Host string // GitHub Token to authenticate with the GitHub API + // Deprecated: Use TokenProvider for dynamic token support Token string + // TokenProvider is a function that returns the current GitHub token + // If set, this takes precedence over Token + TokenProvider TokenProvider + // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string @@ -183,6 +214,7 @@ func RunStdioServer(cfg StdioServerConfig) error { Version: cfg.Version, Host: cfg.Host, Token: cfg.Token, + TokenProvider: cfg.TokenProvider, EnabledToolsets: cfg.EnabledToolsets, DynamicToolsets: cfg.DynamicToolsets, ReadOnly: cfg.ReadOnly, @@ -368,6 +400,7 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error return t.transport.RoundTrip(req) } +// bearerAuthTransport is deprecated - use bearerAuthProviderTransport type bearerAuthTransport struct { transport http.RoundTripper token string @@ -378,3 +411,29 @@ func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro req.Header.Set("Authorization", "Bearer "+t.token) return t.transport.RoundTrip(req) } + +// bearerAuthProviderTransport uses a token provider for dynamic token support +type bearerAuthProviderTransport struct { + transport http.RoundTripper + tokenProvider TokenProvider +} + +func (t *bearerAuthProviderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + token := t.tokenProvider() + req.Header.Set("Authorization", "Bearer "+token) + return t.transport.RoundTrip(req) +} + +// tokenProviderTransport adds GitHub authentication using a token provider +type tokenProviderTransport struct { + transport http.RoundTripper + tokenProvider TokenProvider +} + +func (t *tokenProviderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + token := t.tokenProvider() + req.Header.Set("Authorization", "Bearer "+token) + return t.transport.RoundTrip(req) +} diff --git a/pkg/ghmcp/README.md b/pkg/ghmcp/README.md new file mode 100644 index 000000000..ca8b210cc --- /dev/null +++ b/pkg/ghmcp/README.md @@ -0,0 +1,230 @@ +# GitHub MCP Server Go Library + +This package provides a Go library interface to the GitHub MCP Server, allowing you to embed the server functionality in your own Go applications. + +## Installation + +```bash +go get github.com/github/github-mcp-server/pkg/ghmcp +``` + +## Usage + +### Running a Stdio Server with Static Token + +The most common use case is running the MCP server using stdio for communication: + +```go +package main + +import ( + "log" + "os" + + "github.com/github/github-mcp-server/pkg/ghmcp" +) + +func main() { + config := ghmcp.StdioServerConfig{ + Version: "1.0.0", + Host: "https://github.com", // or your GitHub Enterprise URL + Token: os.Getenv("GITHUB_TOKEN"), + EnabledToolsets: []string{"repos", "issues", "pulls"}, + ReadOnly: false, + } + + if err := ghmcp.RunStdioServer(config); err != nil { + log.Fatal(err) + } +} +``` + +### Running a Stdio Server with Dynamic Token Provider + +For applications that need to refresh tokens without restarting the server: + +```go +package main + +import ( + "log" + "sync" + "time" + + "github.com/github/github-mcp-server/pkg/ghmcp" +) + +// TokenManager manages dynamic token updates +type TokenManager struct { + mu sync.RWMutex + currentToken string +} + +func (tm *TokenManager) GetToken() string { + tm.mu.RLock() + defer tm.mu.RUnlock() + return tm.currentToken +} + +func (tm *TokenManager) UpdateToken(newToken string) { + tm.mu.Lock() + defer tm.mu.Unlock() + tm.currentToken = newToken +} + +func main() { + tokenManager := &TokenManager{ + currentToken: getInitialToken(), // Your initial token + } + + // The token provider will be called on each API request + tokenProvider := func() string { + return tokenManager.GetToken() + } + + config := ghmcp.StdioServerConfig{ + Version: "1.0.0", + Host: "https://github.com", + TokenProvider: tokenProvider, // Use TokenProvider instead of Token + EnabledToolsets: []string{"repos", "issues", "pulls"}, + ReadOnly: false, + } + + // Start a goroutine to refresh the token periodically + go func() { + for { + time.Sleep(30 * time.Minute) + newToken := refreshTokenFromAuthService() // Your token refresh logic + tokenManager.UpdateToken(newToken) + } + }() + + if err := ghmcp.RunStdioServer(config); err != nil { + log.Fatal(err) + } +} +``` + +### Creating a Custom MCP Server + +For more advanced use cases, you can create an MCP server instance directly: + +```go +package main + +import ( + "log" + + "github.com/github/github-mcp-server/pkg/ghmcp" + "github.com/github/github-mcp-server/pkg/translations" +) + +func main() { + config := ghmcp.MCPServerConfig{ + Version: "1.0.0", + Host: "https://github.com", + Token: "your-github-token", + EnabledToolsets: []string{"repos", "issues"}, + ReadOnly: true, + Translator: translations.NullTranslationHelper, + } + + server, err := ghmcp.NewMCPServer(config) + if err != nil { + log.Fatal(err) + } + + // Use the server instance as needed + _ = server +} +``` + +## Configuration Options + +### StdioServerConfig + +- `Version`: Version of your server +- `Host`: GitHub API host (e.g., "https://github.com" or "https://github.enterprise.com") +- `Token`: GitHub personal access token (static token, use TokenProvider for dynamic tokens) +- `TokenProvider`: Function that returns the current GitHub token (takes precedence over Token) +- `EnabledToolsets`: List of toolsets to enable (e.g., "repos", "issues", "pulls", "users", "search") +- `DynamicToolsets`: Enable dynamic toolset discovery +- `ReadOnly`: Restrict to read-only operations +- `ExportTranslations`: Export translations to a JSON file +- `EnableCommandLogging`: Log all command requests and responses +- `LogFilePath`: Path to log file (defaults to stderr) + +### MCPServerConfig + +- `Version`: Version of your server +- `Host`: GitHub API host +- `Token`: GitHub personal access token (static token, use TokenProvider for dynamic tokens) +- `TokenProvider`: Function that returns the current GitHub token (takes precedence over Token) +- `EnabledToolsets`: List of toolsets to enable +- `DynamicToolsets`: Enable dynamic toolset discovery +- `ReadOnly`: Restrict to read-only operations +- `Translator`: Translation helper function (use `translations.NullTranslationHelper` for default) + +## Available Toolsets + +- `repos`: Repository management tools +- `issues`: Issue management tools +- `pulls`: Pull request management tools +- `users`: User management tools +- `search`: Search functionality +- `all`: Enable all available toolsets + +## Token Provider Best Practices + +When using a `TokenProvider`: + +1. **Thread Safety**: Ensure your token provider is thread-safe as it will be called concurrently from multiple goroutines. +2. **Performance**: The token provider is called on each API request, so it should be fast. Consider caching the token. +3. **Error Handling**: The token provider should always return a valid token. Handle errors internally and fall back to a cached token if necessary. +4. **Logging**: Be careful not to log the full token. Log only the last few characters for debugging. +5. **Graceful Updates**: When updating tokens, ensure there's no downtime. The old token should remain valid until the new one is ready. + +Example of a production-ready token provider: + +```go +type TokenCache struct { + mu sync.RWMutex + token string + expiry time.Time + refreshFunc func() (string, time.Time, error) +} + +func (tc *TokenCache) GetToken() string { + tc.mu.RLock() + if time.Now().Before(tc.expiry) { + defer tc.mu.RUnlock() + return tc.token + } + tc.mu.RUnlock() + + // Token expired, refresh it + tc.mu.Lock() + defer tc.mu.Unlock() + + // Double-check after acquiring write lock + if time.Now().Before(tc.expiry) { + return tc.token + } + + newToken, newExpiry, err := tc.refreshFunc() + if err != nil { + // Log error and return cached token + log.Printf("Failed to refresh token: %v", err) + return tc.token + } + + tc.token = newToken + tc.expiry = newExpiry + return tc.token +} +``` + +## Requirements + +- Go 1.21 or later +- Valid GitHub personal access token with appropriate permissions \ No newline at end of file diff --git a/pkg/ghmcp/example_test.go b/pkg/ghmcp/example_test.go new file mode 100644 index 000000000..033b78357 --- /dev/null +++ b/pkg/ghmcp/example_test.go @@ -0,0 +1,106 @@ +package ghmcp_test + +import ( + "fmt" + "log" + "sync" + + "github.com/github/github-mcp-server/pkg/ghmcp" + "github.com/github/github-mcp-server/pkg/translations" +) + +func ExampleRunStdioServer() { + // Example of how to use RunStdioServer from an external module + config := ghmcp.StdioServerConfig{ + Version: "1.0.0", + Host: "https://github.com", + Token: "your-github-token", + EnabledToolsets: []string{"repos", "issues"}, + ReadOnly: true, + } + + // This would normally block and run the server + // err := ghmcp.RunStdioServer(config) + // if err != nil { + // log.Fatal(err) + // } + + // Just to use the config variable in the example + _ = config + fmt.Println("Server configured") + // Output: Server configured +} + +func ExampleRunStdioServer_tokenProvider() { + // Example showing how to use a TokenProvider for dynamic token refresh + + // This simulates a token management system + tokenManager := &TokenManager{ + currentToken: "initial-token", + } + + // Create a token provider function + tokenProvider := func() string { + return tokenManager.GetCurrentToken() + } + + config := ghmcp.StdioServerConfig{ + Version: "1.0.0", + Host: "https://github.com", + TokenProvider: tokenProvider, // Use TokenProvider instead of Token + EnabledToolsets: []string{"repos", "issues"}, + ReadOnly: false, + } + + // In your application, you can update the token at any time: + // tokenManager.UpdateToken("new-refreshed-token") + + // This would normally block and run the server + // err := ghmcp.RunStdioServer(config) + // if err != nil { + // log.Fatal(err) + // } + + // Just to use the config variable in the example + _ = config + fmt.Println("Server configured with token provider") + // Output: Server configured with token provider +} + +func ExampleNewMCPServer() { + // Example of how to use NewMCPServer from an external module + config := ghmcp.MCPServerConfig{ + Version: "1.0.0", + Host: "https://github.com", + Token: "your-github-token", + EnabledToolsets: []string{"repos", "issues"}, + ReadOnly: true, + Translator: translations.NullTranslationHelper, + } + + _, err := ghmcp.NewMCPServer(config) + if err != nil { + log.Fatal(err) + } + + fmt.Println("MCP Server created") + // Output: MCP Server created +} + +// TokenManager is an example of a token management system +type TokenManager struct { + mu sync.RWMutex + currentToken string +} + +func (tm *TokenManager) GetCurrentToken() string { + tm.mu.RLock() + defer tm.mu.RUnlock() + return tm.currentToken +} + +func (tm *TokenManager) UpdateToken(newToken string) { + tm.mu.Lock() + defer tm.mu.Unlock() + tm.currentToken = newToken +} diff --git a/pkg/ghmcp/example_tokenrefresh_test.go b/pkg/ghmcp/example_tokenrefresh_test.go new file mode 100644 index 000000000..fdc3e361b --- /dev/null +++ b/pkg/ghmcp/example_tokenrefresh_test.go @@ -0,0 +1,164 @@ +//go:build example +// +build example + +package ghmcp_test + +import ( + "log" + "os" + "sync" + "time" + + "github.com/github/github-mcp-server/pkg/ghmcp" +) + +// This example demonstrates how to implement a wrapper around ghmcp.RunStdioServer +// that supports dynamic token refresh without restarting the server. +func Example_tokenRefreshWrapper() { + // Create a token manager + tokenManager := NewTokenManager() + + // Initialize with the first token + initialToken := os.Getenv("GITHUB_TOKEN") + if initialToken == "" { + log.Fatal("GITHUB_TOKEN environment variable is required") + } + tokenManager.UpdateToken(initialToken) + + // Create the server configuration with a token provider + config := ghmcp.StdioServerConfig{ + Version: "1.0.0", + Host: "https://github.com", + TokenProvider: func() string { + token := tokenManager.GetCurrentToken() + log.Printf("Token provider called, returning token ending with: ...%s", + token[len(token)-4:]) + return token + }, + EnabledToolsets: []string{"repos", "issues", "pulls"}, + ReadOnly: false, + EnableCommandLogging: true, + LogFilePath: "github-mcp-server.log", + } + + // Start a goroutine that refreshes the token periodically + stopRefresh := make(chan struct{}) + go func() { + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // In a real application, this would call your auth service + newToken := refreshTokenFromAuthService() + if newToken != "" { + log.Println("Refreshing GitHub token...") + tokenManager.UpdateToken(newToken) + } + case <-stopRefresh: + return + } + } + }() + + // Run the server (this blocks) + log.Println("Starting GitHub MCP Server with dynamic token support...") + if err := ghmcp.RunStdioServer(config); err != nil { + close(stopRefresh) + log.Fatal(err) + } +} + +// TokenManager provides thread-safe token management +type TokenManager struct { + mu sync.RWMutex + currentToken string + lastUpdated time.Time +} + +func NewTokenManager() *TokenManager { + return &TokenManager{} +} + +func (tm *TokenManager) GetCurrentToken() string { + tm.mu.RLock() + defer tm.mu.RUnlock() + return tm.currentToken +} + +func (tm *TokenManager) UpdateToken(newToken string) { + tm.mu.Lock() + defer tm.mu.Unlock() + tm.currentToken = newToken + tm.lastUpdated = time.Now() + log.Printf("Token updated at %v", tm.lastUpdated) +} + +func (tm *TokenManager) GetLastUpdated() time.Time { + tm.mu.RLock() + defer tm.mu.RUnlock() + return tm.lastUpdated +} + +// refreshTokenFromAuthService simulates fetching a new token from an auth service +func refreshTokenFromAuthService() string { + // In a real implementation, this would: + // 1. Call your authentication service + // 2. Exchange refresh tokens + // 3. Return the new access token + + // For this example, we'll just return the current token from env + // In production, you'd implement actual token refresh logic here + return os.Getenv("GITHUB_TOKEN_REFRESHED") +} + +// Example of a more advanced token provider with caching and validation +type AdvancedTokenProvider struct { + mu sync.RWMutex + currentToken string + tokenExpiry time.Time + refreshToken string + authServiceURL string + minRefreshTime time.Duration +} + +func NewAdvancedTokenProvider(authServiceURL, refreshToken string) *AdvancedTokenProvider { + return &AdvancedTokenProvider{ + authServiceURL: authServiceURL, + refreshToken: refreshToken, + minRefreshTime: 5 * time.Minute, // Don't refresh more often than every 5 minutes + } +} + +func (atp *AdvancedTokenProvider) GetToken() string { + atp.mu.RLock() + + // Check if token needs refresh + if time.Now().After(atp.tokenExpiry.Add(-atp.minRefreshTime)) { + atp.mu.RUnlock() + return atp.refreshTokenIfNeeded() + } + + token := atp.currentToken + atp.mu.RUnlock() + return token +} + +func (atp *AdvancedTokenProvider) refreshTokenIfNeeded() string { + atp.mu.Lock() + defer atp.mu.Unlock() + + // Double-check after acquiring write lock + if time.Now().Before(atp.tokenExpiry.Add(-atp.minRefreshTime)) { + return atp.currentToken + } + + // In a real implementation, you would call your auth service here + // newToken, newExpiry := callAuthService(atp.authServiceURL, atp.refreshToken) + // atp.currentToken = newToken + // atp.tokenExpiry = newExpiry + + log.Println("Token refreshed via advanced provider") + return atp.currentToken +} diff --git a/pkg/ghmcp/ghmcp.go b/pkg/ghmcp/ghmcp.go new file mode 100644 index 000000000..84d111f70 --- /dev/null +++ b/pkg/ghmcp/ghmcp.go @@ -0,0 +1,67 @@ +// Package ghmcp provides a public API wrapper for the GitHub MCP Server functionality. +// This package exposes the necessary types and functions from the internal implementation +// for use by external Go modules. +// +// Usage example with static token: +// +// config := ghmcp.StdioServerConfig{ +// Version: "1.0.0", +// Host: "https://github.com", +// Token: os.Getenv("GITHUB_TOKEN"), +// EnabledToolsets: []string{"repos", "issues"}, +// ReadOnly: false, +// } +// +// if err := ghmcp.RunStdioServer(config); err != nil { +// log.Fatal(err) +// } +// +// Usage example with dynamic token provider: +// +// tokenProvider := func() string { +// // Fetch current token from your token management system +// return fetchCurrentToken() +// } +// +// config := ghmcp.StdioServerConfig{ +// Version: "1.0.0", +// Host: "https://github.com", +// TokenProvider: tokenProvider, +// EnabledToolsets: []string{"repos", "issues"}, +// ReadOnly: false, +// } +// +// if err := ghmcp.RunStdioServer(config); err != nil { +// log.Fatal(err) +// } +package ghmcp + +import ( + "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/mark3labs/mcp-go/server" +) + +// TokenProvider is a function that returns the current GitHub token. +// This allows for dynamic token refresh without restarting the server. +// The function will be called on each API request, so it should be efficient. +type TokenProvider = ghmcp.TokenProvider + +// StdioServerConfig contains configuration for running the GitHub MCP Server +// in stdio mode. This is a re-export of the internal type. +type StdioServerConfig = ghmcp.StdioServerConfig + +// MCPServerConfig contains configuration for creating a new MCP Server instance. +// This is a re-export of the internal type. +type MCPServerConfig = ghmcp.MCPServerConfig + +// RunStdioServer runs the GitHub MCP Server using stdio for communication. +// This function wraps the internal implementation and is not concurrent safe. +func RunStdioServer(cfg StdioServerConfig) error { + return ghmcp.RunStdioServer(cfg) +} + +// NewMCPServer creates a new MCP Server instance with the given configuration. +// This function wraps the internal implementation. +func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { + return ghmcp.NewMCPServer(cfg) +}