Skip to content

Commit 86bff54

Browse files
committed
Merge branch 'main' into tommy/tool-specific-config-support
2 parents 671b179 + 2941e87 commit 86bff54

File tree

18 files changed

+847
-106
lines changed

18 files changed

+847
-106
lines changed

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
[![Go Report Card](https://goreportcard.com/badge/github.com/github/github-mcp-server)](https://goreportcard.com/report/github.com/github/github-mcp-server)
2+
13
# GitHub MCP Server
24

35
The GitHub MCP Server connects AI tools directly to GitHub's platform. This gives AI agents, assistants, and chatbots the ability to read repositories and code files, manage issues and PRs, analyze code, and automate workflows. All through natural language interactions.
@@ -1315,7 +1317,7 @@ docker run -i --rm \
13151317

13161318
## Lockdown Mode
13171319

1318-
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.
1320+
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.
13191321

13201322
```bash
13211323
./github-mcp-server --lockdown-mode
@@ -1330,7 +1332,20 @@ docker run -i --rm \
13301332
ghcr.io/github/github-mcp-server
13311333
```
13321334

1333-
At the moment lockdown mode applies to the issue read toolset, but it is designed to extend to additional data surfaces over time.
1335+
The behavior of lockdown mode depends on the tool invoked.
1336+
1337+
Following tools will return an error when the author lacks the push access:
1338+
1339+
- `issue_read:get`
1340+
- `pull_request_read:get`
1341+
1342+
Following tools will filter out content from users lacking the push access:
1343+
1344+
- `issue_read:get_comments`
1345+
- `issue_read:get_sub_issues`
1346+
- `pull_request_read:get_comments`
1347+
- `pull_request_read:get_review_comments`
1348+
- `pull_request_read:get_reviews`
13341349

13351350
## i18n / Overriding Descriptions
13361351

cmd/github-mcp-server/generate_docs.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111

1212
"github.com/github/github-mcp-server/pkg/github"
13+
"github.com/github/github-mcp-server/pkg/lockdown"
1314
"github.com/github/github-mcp-server/pkg/raw"
1415
"github.com/github/github-mcp-server/pkg/toolsets"
1516
"github.com/github/github-mcp-server/pkg/translations"
@@ -64,7 +65,8 @@ func generateReadmeDocs(readmePath string) error {
6465
t, _ := translations.TranslationHelper()
6566

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

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

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

307310
// Generate table header
308311
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")

cmd/github-mcp-server/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"strings"
8+
"time"
89

910
"github.com/github/github-mcp-server/internal/ghmcp"
1011
"github.com/github/github-mcp-server/pkg/github"
@@ -56,6 +57,7 @@ var (
5657
enabledToolsets = []string{github.ToolsetMetadataDefault.ID}
5758
}
5859

60+
ttl := viper.GetDuration("repo-access-cache-ttl")
5961
stdioServerConfig := ghmcp.StdioServerConfig{
6062
Version: version,
6163
Host: viper.GetString("host"),
@@ -69,6 +71,7 @@ var (
6971
LogFilePath: viper.GetString("log-file"),
7072
ContentWindowSize: viper.GetInt("content-window-size"),
7173
LockdownMode: viper.GetBool("lockdown-mode"),
74+
RepoAccessCacheTTL: &ttl,
7275
}
7376
return ghmcp.RunStdioServer(stdioServerConfig)
7477
},
@@ -92,6 +95,7 @@ func init() {
9295
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
9396
rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size")
9497
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
98+
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
9599

96100
// Bind flag to viper
97101
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -104,6 +108,7 @@ func init() {
104108
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
105109
_ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size"))
106110
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
111+
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
107112

108113
// Add subcommands
109114
rootCmd.AddCommand(stdioCmd)

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/mark3labs/mcp-go v0.36.0
99
github.com/microcosm-cc/bluemonday v1.0.27
1010
github.com/migueleliasweb/go-github-mock v1.3.0
11+
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021
1112
github.com/spf13/cobra v1.10.1
1213
github.com/spf13/viper v1.21.0
1314
github.com/stretchr/testify v1.11.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
6363
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
6464
github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88=
6565
github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY=
66+
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g=
67+
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc=
6668
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
6769
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
6870
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=

internal/ghmcp/server.go

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"github.com/github/github-mcp-server/pkg/errors"
1818
"github.com/github/github-mcp-server/pkg/github"
19+
"github.com/github/github-mcp-server/pkg/lockdown"
1920
mcplog "github.com/github/github-mcp-server/pkg/log"
2021
"github.com/github/github-mcp-server/pkg/raw"
2122
"github.com/github/github-mcp-server/pkg/translations"
@@ -58,6 +59,9 @@ type MCPServerConfig struct {
5859

5960
// LockdownMode indicates if we should enable lockdown mode
6061
LockdownMode bool
62+
63+
// RepoAccessTTL overrides the default TTL for repository access cache entries.
64+
RepoAccessTTL *time.Duration
6165
}
6266

6367
const stdioServerLogPrefix = "stdioserver"
@@ -84,6 +88,14 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
8488
},
8589
} // We're going to wrap the Transport later in beforeInit
8690
gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
91+
repoAccessOpts := []lockdown.RepoAccessOption{}
92+
if cfg.RepoAccessTTL != nil {
93+
repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessTTL))
94+
}
95+
var repoAccessCache *lockdown.RepoAccessCache
96+
if cfg.LockdownMode {
97+
repoAccessCache = lockdown.GetInstance(gqlClient, repoAccessOpts...)
98+
}
8799

88100
// When a client send an initialize request, update the user agent to include the client info.
89101
beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) {
@@ -169,6 +181,7 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
169181
cfg.Translator,
170182
cfg.ContentWindowSize,
171183
github.FeatureFlags{LockdownMode: cfg.LockdownMode},
184+
repoAccessCache,
172185
)
173186

174187
// Enable and register toolsets if configured
@@ -244,6 +257,9 @@ type StdioServerConfig struct {
244257

245258
// LockdownMode indicates if we should enable lockdown mode
246259
LockdownMode bool
260+
261+
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
262+
RepoAccessCacheTTL *time.Duration
247263
}
248264

249265
// RunStdioServer is not concurrent safe.
@@ -254,6 +270,23 @@ func RunStdioServer(cfg StdioServerConfig) error {
254270

255271
t, dumpTranslations := translations.TranslationHelper()
256272

273+
var slogHandler slog.Handler
274+
var logOutput io.Writer
275+
if cfg.LogFilePath != "" {
276+
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
277+
if err != nil {
278+
return fmt.Errorf("failed to open log file: %w", err)
279+
}
280+
logOutput = file
281+
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
282+
} else {
283+
logOutput = os.Stderr
284+
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
285+
}
286+
logger := slog.New(slogHandler)
287+
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
288+
stdLogger := log.New(logOutput, stdioServerLogPrefix, 0)
289+
257290
ghServer, err := NewMCPServer(MCPServerConfig{
258291
Version: cfg.Version,
259292
Host: cfg.Host,
@@ -265,29 +298,13 @@ func RunStdioServer(cfg StdioServerConfig) error {
265298
Translator: t,
266299
ContentWindowSize: cfg.ContentWindowSize,
267300
LockdownMode: cfg.LockdownMode,
301+
RepoAccessTTL: cfg.RepoAccessCacheTTL,
268302
})
269303
if err != nil {
270304
return fmt.Errorf("failed to create MCP server: %w", err)
271305
}
272306

273307
stdioServer := server.NewStdioServer(ghServer)
274-
275-
var slogHandler slog.Handler
276-
var logOutput io.Writer
277-
if cfg.LogFilePath != "" {
278-
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
279-
if err != nil {
280-
return fmt.Errorf("failed to open log file: %w", err)
281-
}
282-
logOutput = file
283-
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
284-
} else {
285-
logOutput = os.Stderr
286-
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
287-
}
288-
logger := slog.New(slogHandler)
289-
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
290-
stdLogger := log.New(logOutput, stdioServerLogPrefix, 0)
291308
stdioServer.SetErrorLogger(stdLogger)
292309

293310
if cfg.ExportTranslations {

pkg/github/issues.go

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue {
228228
}
229229

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

298298
switch method {
299299
case "get":
300-
return GetIssue(ctx, client, gqlClient, owner, repo, issueNumber, flags)
300+
return GetIssue(ctx, client, cache, owner, repo, issueNumber, flags)
301301
case "get_comments":
302-
return GetIssueComments(ctx, client, owner, repo, issueNumber, pagination, flags)
302+
return GetIssueComments(ctx, client, cache, owner, repo, issueNumber, pagination, flags)
303303
case "get_sub_issues":
304-
return GetSubIssues(ctx, client, owner, repo, issueNumber, pagination, flags)
304+
return GetSubIssues(ctx, client, cache, owner, repo, issueNumber, pagination, flags)
305305
case "get_labels":
306-
return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber, flags)
306+
return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber)
307307
default:
308308
return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil
309309
}
310310
}
311311
}
312312

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

328328
if flags.LockdownMode {
329-
if issue.User != nil {
330-
shouldRemoveContent, err := lockdown.ShouldRemoveContent(ctx, gqlClient, *issue.User.Login, owner, repo)
329+
if cache == nil {
330+
return nil, fmt.Errorf("lockdown cache is not configured")
331+
}
332+
login := issue.GetUser().GetLogin()
333+
if login != "" {
334+
isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo)
331335
if err != nil {
332336
return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil
333337
}
334-
if shouldRemoveContent {
338+
if !isSafeContent {
335339
return mcp.NewToolResultError("access to issue details is restricted by lockdown mode"), nil
336340
}
337341
}
@@ -355,7 +359,7 @@ func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Cl
355359
return mcp.NewToolResultText(string(r)), nil
356360
}
357361

358-
func GetIssueComments(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, pagination PaginationParams, _ FeatureFlags) (*mcp.CallToolResult, error) {
362+
func GetIssueComments(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner string, repo string, issueNumber int, pagination PaginationParams, flags FeatureFlags) (*mcp.CallToolResult, error) {
359363
opts := &github.IssueListCommentsOptions{
360364
ListOptions: github.ListOptions{
361365
Page: pagination.Page,
@@ -376,6 +380,30 @@ func GetIssueComments(ctx context.Context, client *github.Client, owner string,
376380
}
377381
return mcp.NewToolResultError(fmt.Sprintf("failed to get issue comments: %s", string(body))), nil
378382
}
383+
if flags.LockdownMode {
384+
if cache == nil {
385+
return nil, fmt.Errorf("lockdown cache is not configured")
386+
}
387+
filteredComments := make([]*github.IssueComment, 0, len(comments))
388+
for _, comment := range comments {
389+
user := comment.User
390+
if user == nil {
391+
continue
392+
}
393+
login := user.GetLogin()
394+
if login == "" {
395+
continue
396+
}
397+
isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo)
398+
if err != nil {
399+
return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil
400+
}
401+
if isSafeContent {
402+
filteredComments = append(filteredComments, comment)
403+
}
404+
}
405+
comments = filteredComments
406+
}
379407

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

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

443+
if featureFlags.LockdownMode {
444+
if cache == nil {
445+
return nil, fmt.Errorf("lockdown cache is not configured")
446+
}
447+
filteredSubIssues := make([]*github.SubIssue, 0, len(subIssues))
448+
for _, subIssue := range subIssues {
449+
user := subIssue.User
450+
if user == nil {
451+
continue
452+
}
453+
login := user.GetLogin()
454+
if login == "" {
455+
continue
456+
}
457+
isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo)
458+
if err != nil {
459+
return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil
460+
}
461+
if isSafeContent {
462+
filteredSubIssues = append(filteredSubIssues, subIssue)
463+
}
464+
}
465+
subIssues = filteredSubIssues
466+
}
467+
415468
r, err := json.Marshal(subIssues)
416469
if err != nil {
417470
return nil, fmt.Errorf("failed to marshal response: %w", err)
@@ -420,7 +473,7 @@ func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo
420473
return mcp.NewToolResultText(string(r)), nil
421474
}
422475

423-
func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int, _ FeatureFlags) (*mcp.CallToolResult, error) {
476+
func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
424477
// Get current labels on the issue using GraphQL
425478
var query struct {
426479
Repository struct {

0 commit comments

Comments
 (0)