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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1502,6 +1502,18 @@ Following tools will filter out content from users lacking the push access:
- `pull_request_read:get_review_comments`
- `pull_request_read:get_reviews`

## Pull Request Author Allowlist

To restrict mutating pull request tools to bot-authored PRs, use `--allowed-pr-authors` or `GITHUB_ALLOWED_PR_AUTHORS` with a comma-separated list of GitHub logins:

```bash
GITHUB_ALLOWED_PR_AUTHORS='renovate[bot],github-actions[bot]' ./github-mcp-server stdio --toolsets=pull_requests,actions
```

When set, tools such as `merge_pull_request`, `update_pull_request`, review-write tools, and PR branch updates fetch the target PR and reject the call unless `pr.User.Login` is in the allowlist. Read-only PR tools and `create_pull_request` are not restricted. `actions_run_trigger` is not gated by this setting because it targets a ref rather than a PR number.

In HTTP mode, `GITHUB_PERSONAL_ACCESS_TOKEN` can also be used as a server-side default token for trusted local deployments. Requests with an `Authorization` header still use the request token; requests without one fall back to the configured server token. This means the server's GitHub identity is used for any unauthenticated HTTP request, so only enable this when the HTTP endpoint is on a trusted network.

## i18n / Overriding Descriptions

The descriptions of the tools can be overridden by creating a
Expand Down
19 changes: 19 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ var (
}
}

var allowedPRAuthors []string
if viper.IsSet("allowed_pr_authors") {
if err := viper.UnmarshalKey("allowed_pr_authors", &allowedPRAuthors); err != nil {
return fmt.Errorf("failed to unmarshal allowed-pr-authors: %w", err)
}
}

// Parse enabled features (similar to toolsets)
var enabledFeatures []string
if viper.IsSet("features") {
Expand All @@ -92,6 +99,7 @@ var (
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
AllowedPRAuthors: allowedPRAuthors,
InsidersMode: viper.GetBool("insiders"),
ExcludeTools: excludeTools,
RepoAccessCacheTTL: &ttl,
Expand Down Expand Up @@ -127,10 +135,18 @@ var (
}
}

var allowedPRAuthors []string
if viper.IsSet("allowed_pr_authors") {
if err := viper.UnmarshalKey("allowed_pr_authors", &allowedPRAuthors); err != nil {
return fmt.Errorf("failed to unmarshal allowed-pr-authors: %w", err)
}
}

ttl := viper.GetDuration("repo-access-cache-ttl")
httpConfig := ghhttp.ServerConfig{
Version: version,
Host: viper.GetString("host"),
Token: viper.GetString("personal_access_token"),
Port: viper.GetInt("port"),
BaseURL: viper.GetString("base-url"),
ResourcePath: viper.GetString("base-path"),
Expand All @@ -139,6 +155,7 @@ var (
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
AllowedPRAuthors: allowedPRAuthors,
RepoAccessCacheTTL: &ttl,
ScopeChallenge: viper.GetBool("scope-challenge"),
ReadOnly: viper.GetBool("read-only"),
Expand Down Expand Up @@ -173,6 +190,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().StringSlice("allowed-pr-authors", nil, "Comma-separated list of pull request author logins allowed for mutating pull request tools")
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")

Expand All @@ -195,6 +213,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("allowed_pr_authors", rootCmd.PersistentFlags().Lookup("allowed-pr-authors"))
_ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
Expand Down
30 changes: 30 additions & 0 deletions docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ We currently support the following ways in which the GitHub MCP Server can be co
| Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var |
| Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var |
| Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var |
| PR Author Allowlist | Server `--allowed-pr-authors` flag or `GITHUB_ALLOWED_PR_AUTHORS` env var | `--allowed-pr-authors` flag or `GITHUB_ALLOWED_PR_AUTHORS` env var |
| Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var |
| Feature Flags | `X-MCP-Features` header | `--features` flag |
| Scope Filtering | Always enabled | Always enabled |
Expand All @@ -30,6 +31,8 @@ Note: **read-only** mode acts as a strict security filter that takes precedence

Note: **excluded tools** takes precedence over toolsets and individual tools — listed tools are always excluded, even if their toolset is enabled or they are explicitly added via `--tools` / `X-MCP-Tools`.

Note: **PR author allowlist** restricts mutating pull request tools to existing pull requests authored by the configured GitHub logins. Read-only PR tools and `create_pull_request` are not restricted. `actions_run_trigger` is not restricted by this setting because it targets a ref rather than a pull request number.

---

## Configuration Examples
Expand Down Expand Up @@ -387,6 +390,33 @@ Lockdown mode ensures the server only surfaces content in public repositories fr

---

### PR Author Allowlist

**Best for:** Automation workflows that may mutate bot-authored pull requests but should never mutate human-authored pull requests.

When set, mutating pull request tools first fetch the target pull request and check `pr.User.Login`. If the author is not in the allowlist, the tool returns an error before making the mutation. Empty or unset means unrestricted behavior.

```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--toolsets=pull_requests,actions",
"--allowed-pr-authors=renovate[bot],github-actions[bot]"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```

Known limitations: `actions_run_trigger` operates on refs, not pull request numbers, so it is not gated by this setting. Review-thread resolve and unresolve tools take only opaque thread IDs and are not gated by the PR author allowlist. The allowlist checks `pr.User.Login`; PRs from forks authored by allowed bots still pass. Enabling the allowlist adds one API call before a mutating PR operation when the handler does not already have the pull request.

---

### Insiders Mode

**Best for:** Users who want early access to experimental features and new tools before they reach general availability.
Expand Down
12 changes: 12 additions & 0 deletions docs/streamable-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,15 @@ To provide PAT credentials, or to customize server behavior preferences, you can
```

See [Remote Server](./remote-server.md) documentation for more details on client configuration options.

### Using a Server-Side Default Token

For trusted local deployments, HTTP mode can use `GITHUB_PERSONAL_ACCESS_TOKEN` as a fallback when a request does not include an `Authorization` header:

```bash
GITHUB_PERSONAL_ACCESS_TOKEN=ghp_yourtokenhere github-mcp-server http
```

If a request includes `Authorization: Bearer ...`, that request token takes precedence. If no request token is provided and no server-side token is configured, the server returns `401 Unauthorized`.

When this fallback is enabled, the server's GitHub identity is used for every HTTP request without an `Authorization` header. Only expose the endpoint on a trusted network.
9 changes: 9 additions & 0 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
cfg.ContentWindowSize,
featureChecker,
obs,
cfg.AllowedPRAuthors,
)
// Build and register the tool/resource/prompt inventory
inventoryBuilder := github.NewInventory(cfg.Translator).
Expand Down Expand Up @@ -220,6 +221,10 @@ type StdioServerConfig struct {
// LockdownMode indicates if we should enable lockdown mode
LockdownMode bool

// AllowedPRAuthors restricts mutating pull request tools to PRs authored by
// one of these GitHub logins. Empty means unrestricted.
AllowedPRAuthors []string

// InsidersMode indicates if we should enable experimental features
InsidersMode bool

Expand Down Expand Up @@ -255,6 +260,9 @@ 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)
if len(cfg.AllowedPRAuthors) > 0 {
logger.Info("PR author allowlist enforced", "authors", cfg.AllowedPRAuthors)
}

// Fetch token scopes for scope-based tool filtering (PAT tokens only)
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
Expand Down Expand Up @@ -284,6 +292,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
AllowedPRAuthors: cfg.AllowedPRAuthors,
InsidersMode: cfg.InsidersMode,
ExcludeTools: cfg.ExcludeTools,
Logger: logger,
Expand Down
4 changes: 4 additions & 0 deletions pkg/github/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,10 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server
return utils.NewToolResultError(err.Error()), nil, nil
}

if result, err := enforcePRAuthorAllowlist(ctx, deps, owner, repo, pullNumber, nil); result != nil || err != nil {
return result, nil, err
}

client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
Expand Down
28 changes: 28 additions & 0 deletions pkg/github/copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -961,3 +961,31 @@ func Test_RequestCopilotReview(t *testing.T) {
})
}
}

func Test_RequestCopilotReview_PRAuthorDenied(t *testing.T) {
serverTool := RequestCopilotReview(translations.NullTranslationHelper)
client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{
User: &github.User{Login: github.Ptr("alice")},
}),
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("reviewer request endpoint should not be called when PR author is denied")
},
}))
deps := BaseDeps{
Client: client,
allowedPRAuthors: buildPRAuthorAllowlist([]string{"renovate[bot]"}),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
})

result, err := handler(ContextWithDeps(context.Background(), deps), &request)

require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getErrorResult(t, result).Text, `pull request author "alice" is not in --allowed-pr-authors`)
}
22 changes: 22 additions & 0 deletions pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ type ToolDependencies interface {

// Metrics returns the metrics client
Metrics(ctx context.Context) metrics.Metrics

// IsPRAuthorAllowed checks whether a pull request author is allowed for
// mutating pull request tools. enforced is false when no allowlist is set.
IsPRAuthorAllowed(login string) (allowed bool, enforced bool)
}

// BaseDeps is the standard implementation of ToolDependencies for the local server.
Expand All @@ -127,6 +131,8 @@ type BaseDeps struct {

// Observability exporters (includes logger)
Obsv observability.Exporters

allowedPRAuthors map[string]struct{}
}

// Compile-time assertion to verify that BaseDeps implements the ToolDependencies interface.
Expand All @@ -143,6 +149,7 @@ func NewBaseDeps(
contentWindowSize int,
featureChecker inventory.FeatureFlagChecker,
obsv observability.Exporters,
allowedPRAuthors ...[]string,
) *BaseDeps {
return &BaseDeps{
Client: client,
Expand All @@ -154,6 +161,7 @@ func NewBaseDeps(
ContentWindowSize: contentWindowSize,
featureChecker: featureChecker,
Obsv: obsv,
allowedPRAuthors: buildPRAuthorAllowlist(firstStringSlice(allowedPRAuthors)),
}
}

Expand Down Expand Up @@ -196,6 +204,11 @@ func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics {
return d.Obsv.Metrics(ctx)
}

// IsPRAuthorAllowed implements ToolDependencies.
func (d BaseDeps) IsPRAuthorAllowed(login string) (bool, bool) {
return isPRAuthorAllowed(d.allowedPRAuthors, login)
}

// IsFeatureEnabled checks if a feature flag is enabled.
// Returns false if the feature checker is nil, flag name is empty, or an error occurs.
// This allows tools to conditionally change behavior based on feature flags.
Expand Down Expand Up @@ -276,6 +289,8 @@ type RequestDeps struct {

// Observability exporters (includes logger)
obsv observability.Exporters

allowedPRAuthors map[string]struct{}
}

// NewRequestDeps creates a RequestDeps with the provided clients and configuration.
Expand All @@ -288,6 +303,7 @@ func NewRequestDeps(
contentWindowSize int,
featureChecker inventory.FeatureFlagChecker,
obsv observability.Exporters,
allowedPRAuthors ...[]string,
) *RequestDeps {
return &RequestDeps{
apiHosts: apiHosts,
Expand All @@ -298,6 +314,7 @@ func NewRequestDeps(
ContentWindowSize: contentWindowSize,
featureChecker: featureChecker,
obsv: obsv,
allowedPRAuthors: buildPRAuthorAllowlist(firstStringSlice(allowedPRAuthors)),
}
}

Expand Down Expand Up @@ -420,6 +437,11 @@ func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics {
return d.obsv.Metrics(ctx)
}

// IsPRAuthorAllowed implements ToolDependencies.
func (d *RequestDeps) IsPRAuthorAllowed(login string) (bool, bool) {
return isPRAuthorAllowed(d.allowedPRAuthors, login)
}

// IsFeatureEnabled checks if a feature flag is enabled.
func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool {
if d.featureChecker == nil || flagName == "" {
Expand Down
4 changes: 4 additions & 0 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,10 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
if result, err := enforceIssueCommentPRAuthorAllowlist(ctx, deps, client, owner, repo, issueNumber); result != nil || err != nil {
return result, nil, err
}

createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to create comment", err), nil, nil
Expand Down
35 changes: 35 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,41 @@ func Test_AddIssueComment(t *testing.T) {
}
}

func Test_AddIssueComment_PRAuthorDenied(t *testing.T) {
serverTool := AddIssueComment(translations.NullTranslationHelper)
client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, &github.Issue{
Number: github.Ptr(42),
PullRequestLinks: &github.PullRequestLinks{
URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"),
},
}),
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{
User: &github.User{Login: github.Ptr("alice")},
}),
PostReposIssuesCommentsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("issue comment endpoint should not be called when PR author is denied")
},
}))
deps := BaseDeps{
Client: client,
allowedPRAuthors: buildPRAuthorAllowlist([]string{"renovate[bot]"}),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"body": "comment",
})

result, err := handler(ContextWithDeps(context.Background(), deps), &request)

require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, getErrorResult(t, result).Text, `pull request author "alice" is not in --allowed-pr-authors`)
}

func Test_SearchIssues(t *testing.T) {
// Verify tool definition once
serverTool := SearchIssues(translations.NullTranslationHelper)
Expand Down
Loading