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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1264,7 +1264,7 @@ docker run -i --rm \

## Lockdown Mode

Lockdown mode limits the content that the server will surface from public repositories. When enabled, requests that fetch issue details will return an error if the issue was created by someone who does not have push access to the repository. Private repositories are unaffected, and collaborators can still access their own issues.
Lockdown mode limits the content that the server will surface from public repositories. When enabled, the server checks whether the author of each item has push access to the repository. Private repositories are unaffected, and collaborators keep full access to their own content.

```bash
./github-mcp-server --lockdown-mode
Expand All @@ -1279,7 +1279,20 @@ docker run -i --rm \
ghcr.io/github/github-mcp-server
```

At the moment lockdown mode applies to the issue read toolset, but it is designed to extend to additional data surfaces over time.
The behavior of lockdown mode depends on the tool invoked.

Following tools will return an error when the author lacks the push access:

- `issue_read:get`
- `pull_request_read:get`

Following tools will filter out content from users lacking the push access:

- `issue_read:get_comments`
- `issue_read:get_sub_issues`
- `pull_request_read:get_comments`
- `pull_request_read:get_review_comments`
- `pull_request_read:get_reviews`

## i18n / Overriding Descriptions

Expand Down
7 changes: 5 additions & 2 deletions cmd/github-mcp-server/generate_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/toolsets"
"github.com/github/github-mcp-server/pkg/translations"
Expand Down Expand Up @@ -64,7 +65,8 @@ func generateReadmeDocs(readmePath string) error {
t, _ := translations.TranslationHelper()

// Create toolset group with mock clients
tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000, github.FeatureFlags{})
repoAccessCache := lockdown.GetInstance(nil)
tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000, github.FeatureFlags{}, repoAccessCache)

// Generate toolsets documentation
toolsetsDoc := generateToolsetsDoc(tsg)
Expand Down Expand Up @@ -302,7 +304,8 @@ func generateRemoteToolsetsDoc() string {
t, _ := translations.TranslationHelper()

// Create toolset group with mock clients
tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000, github.FeatureFlags{})
repoAccessCache := lockdown.GetInstance(nil)
tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000, github.FeatureFlags{}, repoAccessCache)

// Generate table header
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")
Expand Down
5 changes: 5 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strings"
"time"

"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/github"
Expand Down Expand Up @@ -50,6 +51,7 @@ var (
enabledToolsets = []string{github.ToolsetMetadataDefault.ID}
}

ttl := viper.GetDuration("repo-access-cache-ttl")
stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Host: viper.GetString("host"),
Expand All @@ -62,6 +64,7 @@ var (
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
RepoAccessCacheTTL: &ttl,
}
return ghmcp.RunStdioServer(stdioServerConfig)
},
Expand All @@ -84,6 +87,7 @@ func init() {
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size")
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")

// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
Expand All @@ -95,6 +99,7 @@ func init() {
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
_ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size"))
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))

// Add subcommands
rootCmd.AddCommand(stdioCmd)
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/mark3labs/mcp-go v0.36.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/migueleliasweb/go-github-mock v1.3.0
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
Expand Down Expand Up @@ -37,7 +38,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/google/go-querystring v1.1.0
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88=
github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY=
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g=
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
Expand Down
51 changes: 34 additions & 17 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/github"
"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/translations"
Expand Down Expand Up @@ -54,6 +55,9 @@ type MCPServerConfig struct {

// LockdownMode indicates if we should enable lockdown mode
LockdownMode bool

// RepoAccessTTL overrides the default TTL for repository access cache entries.
RepoAccessTTL *time.Duration
}

const stdioServerLogPrefix = "stdioserver"
Expand All @@ -80,6 +84,14 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
},
} // We're going to wrap the Transport later in beforeInit
gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
repoAccessOpts := []lockdown.RepoAccessOption{}
if cfg.RepoAccessTTL != nil {
repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessTTL))
}
var repoAccessCache *lockdown.RepoAccessCache
if cfg.LockdownMode {
repoAccessCache = lockdown.GetInstance(gqlClient, repoAccessOpts...)
}

// When a client send an initialize request, update the user agent to include the client info.
beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) {
Expand Down Expand Up @@ -165,6 +177,7 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
cfg.Translator,
cfg.ContentWindowSize,
github.FeatureFlags{LockdownMode: cfg.LockdownMode},
repoAccessCache,
)
err = tsg.EnableToolsets(enabledToolsets, nil)

Expand Down Expand Up @@ -219,6 +232,9 @@ type StdioServerConfig struct {

// LockdownMode indicates if we should enable lockdown mode
LockdownMode bool

// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
RepoAccessCacheTTL *time.Duration
}

// RunStdioServer is not concurrent safe.
Expand All @@ -229,23 +245,6 @@ func RunStdioServer(cfg StdioServerConfig) error {

t, dumpTranslations := translations.TranslationHelper()

ghServer, err := NewMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}

stdioServer := server.NewStdioServer(ghServer)

var slogHandler slog.Handler
var logOutput io.Writer
if cfg.LogFilePath != "" {
Expand All @@ -262,6 +261,24 @@ func RunStdioServer(cfg StdioServerConfig) error {
logger := slog.New(slogHandler)
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
stdLogger := log.New(logOutput, stdioServerLogPrefix, 0)

ghServer, err := NewMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}

stdioServer := server.NewStdioServer(ghServer)
stdioServer.SetErrorLogger(stdLogger)

if cfg.ExportTranslations {
Expand Down
77 changes: 65 additions & 12 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue {
}

// GetIssue creates a tool to get details of a specific issue in a GitHub repository.
func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (tool mcp.Tool, handler server.ToolHandlerFunc) {
func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, cache *lockdown.RepoAccessCache, t translations.TranslationHelperFunc, flags FeatureFlags) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("issue_read",
mcp.WithDescription(t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Expand Down Expand Up @@ -297,20 +297,20 @@ Options are:

switch method {
case "get":
return GetIssue(ctx, client, gqlClient, owner, repo, issueNumber, flags)
return GetIssue(ctx, client, cache, owner, repo, issueNumber, flags)
case "get_comments":
return GetIssueComments(ctx, client, owner, repo, issueNumber, pagination, flags)
return GetIssueComments(ctx, client, cache, owner, repo, issueNumber, pagination, flags)
case "get_sub_issues":
return GetSubIssues(ctx, client, owner, repo, issueNumber, pagination, flags)
return GetSubIssues(ctx, client, cache, owner, repo, issueNumber, pagination, flags)
case "get_labels":
return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber, flags)
return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber)
default:
return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil
}
}
}

func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, flags FeatureFlags) (*mcp.CallToolResult, error) {
func GetIssue(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, flags FeatureFlags) (*mcp.CallToolResult, error) {
issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
if err != nil {
return nil, fmt.Errorf("failed to get issue: %w", err)
Expand All @@ -326,12 +326,16 @@ func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Cl
}

if flags.LockdownMode {
if issue.User != nil {
shouldRemoveContent, err := lockdown.ShouldRemoveContent(ctx, gqlClient, *issue.User.Login, owner, repo)
if cache == nil {
return nil, fmt.Errorf("lockdown cache is not configured")
}
login := issue.GetUser().GetLogin()
if login != "" {
isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil
}
if shouldRemoveContent {
if !isSafeContent {
return mcp.NewToolResultError("access to issue details is restricted by lockdown mode"), nil
}
}
Expand All @@ -355,7 +359,7 @@ func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Cl
return mcp.NewToolResultText(string(r)), nil
}

func GetIssueComments(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, pagination PaginationParams, _ FeatureFlags) (*mcp.CallToolResult, error) {
func GetIssueComments(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, pagination PaginationParams, flags FeatureFlags) (*mcp.CallToolResult, error) {
opts := &github.IssueListCommentsOptions{
ListOptions: github.ListOptions{
Page: pagination.Page,
Expand All @@ -376,6 +380,30 @@ func GetIssueComments(ctx context.Context, client *github.Client, owner string,
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get issue comments: %s", string(body))), nil
}
if flags.LockdownMode {
if cache == nil {
return nil, fmt.Errorf("lockdown cache is not configured")
}
filteredComments := make([]*github.IssueComment, 0, len(comments))
for _, comment := range comments {
user := comment.User
if user == nil {
continue
}
login := user.GetLogin()
if login == "" {
continue
}
isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil
}
if isSafeContent {
filteredComments = append(filteredComments, comment)
}
}
comments = filteredComments
}

r, err := json.Marshal(comments)
if err != nil {
Expand All @@ -385,7 +413,7 @@ func GetIssueComments(ctx context.Context, client *github.Client, owner string,
return mcp.NewToolResultText(string(r)), nil
}

func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, pagination PaginationParams, _ FeatureFlags) (*mcp.CallToolResult, error) {
func GetSubIssues(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, pagination PaginationParams, featureFlags FeatureFlags) (*mcp.CallToolResult, error) {
opts := &github.IssueListOptions{
ListOptions: github.ListOptions{
Page: pagination.Page,
Expand All @@ -412,6 +440,31 @@ func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo
return mcp.NewToolResultError(fmt.Sprintf("failed to list sub-issues: %s", string(body))), nil
}

if featureFlags.LockdownMode {
if cache == nil {
return nil, fmt.Errorf("lockdown cache is not configured")
}
filteredSubIssues := make([]*github.SubIssue, 0, len(subIssues))
for _, subIssue := range subIssues {
user := subIssue.User
if user == nil {
continue
}
login := user.GetLogin()
if login == "" {
continue
}
isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil
}
if isSafeContent {
filteredSubIssues = append(filteredSubIssues, subIssue)
}
}
subIssues = filteredSubIssues
}

r, err := json.Marshal(subIssues)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
Expand All @@ -420,7 +473,7 @@ func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo
return mcp.NewToolResultText(string(r)), nil
}

func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int, _ FeatureFlags) (*mcp.CallToolResult, error) {
func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
// Get current labels on the issue using GraphQL
var query struct {
Repository struct {
Expand Down
Loading
Loading