Skip to content

Commit 0927cc3

Browse files
Merge branch 'main' into dependabot/npm_and_yarn/ui/npm_and_yarn-1b56638fe1
2 parents 768f80c + 24dc8b0 commit 0927cc3

83 files changed

Lines changed: 7448 additions & 514 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,7 @@ The following sets of tools are available:
894894
- **add_issue_comment** - Add comment to issue or pull request
895895
- **Required OAuth Scopes**: `repo`
896896
- `body`: Comment content. Required unless reaction is provided. (string, optional)
897-
- `comment_id`: The numeric ID of the issue or pull request comment to react to. Use this for reactions to comments; omit it to react to the issue or pull request itself. Cannot be combined with body. (number, optional)
897+
- `comment_id`: The numeric ID of the issue or pull request comment to react to. Use this for reactions to comments; omit it to react to the issue or pull request itself. Cannot be combined with body. (integer, optional)
898898
- `issue_number`: Issue or pull request number to comment on or react to. (number, required)
899899
- `owner`: Repository owner (string, required)
900900
- `reaction`: Emoji reaction to add. Required unless body is provided. (string, optional)
@@ -1284,6 +1284,7 @@ The following sets of tools are available:
12841284

12851285
- **create_or_update_file** - Create or update file
12861286
- **Required OAuth Scopes**: `repo`
1287+
- `allow_symlink_write`: Set true to update a symbolic link itself; content must be its new target path. (boolean, optional)
12871288
- `branch`: Branch to create/update the file in (string, required)
12881289
- `content`: Content of the file, exactly as it should appear once written. Do not base64-encode it; this server does that before calling the REST API. (string, required)
12891290
- `message`: Commit message (string, required)
@@ -1308,6 +1309,11 @@ The following sets of tools are available:
13081309
- `path`: Path to the file to delete (string, required)
13091310
- `repo`: Repository name (string, required)
13101311

1312+
- **delete_repository** - Delete repository
1313+
- **Required OAuth Scopes (all required)**: `delete_repo`, `repo`
1314+
- `owner`: Repository owner (username or organization) (string, required)
1315+
- `repo`: Repository name (string, required)
1316+
13111317
- **fork_repository** - Fork repository
13121318
- **Required OAuth Scopes**: `repo`
13131319
- `organization`: Organization to fork to (string, optional)
@@ -1596,6 +1602,10 @@ docker run -i --rm \
15961602

15971603
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.
15981604

1605+
Lockdown mode is a best-effort content filter intended to reduce the risk of prompt injection from untrusted repository content (issues, pull requests, comments, commits, etc.). It is **not** an authorization boundary: it does not change what the underlying GitHub credential can read or write, and content withheld from a filtered tool response may still be reachable through other tools or direct GitHub API access with the same credential.
1606+
1607+
As an intentional exception, content authored by a small set of trusted bot accounts (currently `github-actions[bot]` and `copilot`) is always treated as safe, regardless of push access. This avoids filtering routine automation output (e.g. CI-generated commits or comments) that would otherwise be withheld under lockdown mode.
1608+
15991609
```bash
16001610
./github-mcp-server --lockdown-mode
16011611
```
@@ -1609,12 +1619,17 @@ docker run -i --rm \
16091619
ghcr.io/github/github-mcp-server
16101620
```
16111621

1622+
In HTTP mode, this flag (or `GITHUB_LOCKDOWN_MODE`) is an upper bound: the `X-MCP-Lockdown` request header can enable lockdown mode when the operator has not, but it cannot disable lockdown mode the operator has already enabled. See the [Server Configuration Guide](docs/server-configuration.md#lockdown-mode) for details.
1623+
16121624
The behavior of lockdown mode depends on the tool invoked.
16131625

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

16161628
- `issue_read:get`
16171629
- `pull_request_read:get`
1630+
- `pull_request_read:get_diff`
1631+
- `pull_request_read:get_files`
1632+
- `pull_request_read:get_commits`
16181633

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

cmd/github-mcp-server/generate_docs.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,13 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
221221

222222
// OAuth scopes if present
223223
if len(tool.RequiredScopes) > 0 {
224-
// Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes),
225-
// so when multiple required scopes are listed, render them as alternatives
226-
// rather than implying all are required.
227224
scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`"
228-
if len(tool.RequiredScopes) > 1 {
225+
switch {
226+
case len(tool.RequiredScopeGroups) > 1:
227+
fmt.Fprintf(buf, " - **Required OAuth Scopes (all required)**: %s\n", scopeList)
228+
case len(tool.RequiredScopes) > 1:
229229
fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList)
230-
} else {
230+
default:
231231
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)
232232
}
233233

cmd/github-mcp-server/main.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,11 @@ var (
128128
}
129129

130130
// When no static token is provided, log in via OAuth using the given
131-
// client. The requested scopes default to the full supported set
132-
// (which filters out no tools); an explicit, narrower --oauth-scopes
133-
// both narrows the grant and hides tools needing other scopes.
131+
// client. The requested scopes default to the standard set; high-risk
132+
// scopes such as delete_repo require explicit --oauth-scopes opt-in.
133+
// The requested set also filters tools needing other scopes.
134134
if token == "" && !appAuthRequested {
135-
scopes := ghoauth.SupportedScopes
135+
scopes := ghoauth.DefaultScopes
136136
if viper.IsSet("oauth-scopes") {
137137
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
138138
return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err)
@@ -217,6 +217,7 @@ var (
217217
EnabledFeatures: enabledFeatures,
218218
InsidersMode: viper.GetBool("insiders"),
219219
TrustProxyHeaders: viper.GetBool("trust-proxy-headers"),
220+
MRTRStateKey: os.Getenv(ghhttp.MRTRStateKeyEnv),
220221
}
221222

222223
return ghhttp.RunHTTPServer(httpConfig)

cmd/github-mcp-server/main_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package main
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78

9+
"github.com/github/github-mcp-server/pkg/inventory"
810
"github.com/google/jsonschema-go/jsonschema"
11+
"github.com/modelcontextprotocol/go-sdk/mcp"
912
"github.com/stretchr/testify/assert"
1013
"github.com/stretchr/testify/require"
1114
)
@@ -38,6 +41,40 @@ func TestGitHubAppFlagsAreStdioOnly(t *testing.T) {
3841
assert.Nil(t, httpCmd.Flags().Lookup("app-id"))
3942
}
4043

44+
func TestWriteToolDocScopeSemantics(t *testing.T) {
45+
tests := []struct {
46+
name string
47+
tool inventory.ServerTool
48+
want string
49+
}{
50+
{
51+
name: "legacy multi-scope tools use any-of",
52+
tool: inventory.ServerTool{
53+
Tool: mcp.Tool{Name: "legacy", Annotations: &mcp.ToolAnnotations{Title: "Legacy"}},
54+
RequiredScopes: []string{"repo", "read:org"},
55+
},
56+
want: "**Required OAuth Scopes (any of)**",
57+
},
58+
{
59+
name: "conjunctive scope groups use all-required",
60+
tool: inventory.ServerTool{
61+
Tool: mcp.Tool{Name: "conjunctive", Annotations: &mcp.ToolAnnotations{Title: "Conjunctive"}},
62+
RequiredScopes: []string{"delete_repo", "repo"},
63+
RequiredScopeGroups: [][]string{{"delete_repo"}, {"repo"}},
64+
},
65+
want: "**Required OAuth Scopes (all required)**",
66+
},
67+
}
68+
69+
for _, tt := range tests {
70+
t.Run(tt.name, func(t *testing.T) {
71+
var buf strings.Builder
72+
writeToolDoc(&buf, tt.tool)
73+
assert.Contains(t, buf.String(), tt.want)
74+
})
75+
}
76+
}
77+
4178
func TestSchemaTypeString(t *testing.T) {
4279
tests := []struct {
4380
name string

docs/remote-server.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,10 @@ The Remote GitHub MCP server has optional headers equivalent to the Local server
6767
- `X-MCP-Readonly`: Enables only "read" tools.
6868
- Equivalent to `GITHUB_READ_ONLY` env var for Local server.
6969
- If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.
70-
- `X-MCP-Lockdown`: Enables lockdown mode, hiding public issue details created by users without push access.
70+
- `X-MCP-Lockdown`: Enables lockdown mode, hiding public issue details created by users without push access. Lockdown mode is a best-effort content filter, not a security boundary.
7171
- Equivalent to `GITHUB_LOCKDOWN_MODE` env var for Local server.
7272
- If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.
73+
- Server-side lockdown configuration is an upper bound: if the operator has already enabled lockdown mode, this header cannot disable it for a request. The header can only enable (or redundantly re-enable) lockdown mode; it cannot relax lockdown mode below the operator's configuration.
7374
- `X-MCP-Insiders`: Enables insiders mode for early access to new features.
7475
- Equivalent to `GITHUB_INSIDERS` env var or `--insiders` flag for Local server.
7576
- If this header is empty, "false", "f", "no", "n", "0", or "off" (ignoring whitespace and case), it will be interpreted as false. All other values are interpreted as true.

docs/server-configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Note: **read-only** mode acts as a strict security filter that takes precedence
2929

3030
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`.
3131

32+
Note: server-side **lockdown mode** (`--lockdown-mode` / `GITHUB_LOCKDOWN_MODE`) is an upper bound in HTTP mode — once an operator enables it, the `X-MCP-Lockdown` header can no longer disable it for a given request. A request may still use the header to enable lockdown mode for itself when the operator has not already enabled it server-wide, but it can never relax lockdown mode below what the operator configured. Lockdown mode remains a best-effort content filter, not a security boundary.
33+
3234
---
3335

3436
## Configuration Examples
@@ -292,6 +294,12 @@ When active, this mode will disable all tools that are not read-only even if the
292294

293295
Lockdown mode ensures the server only surfaces content in public repositories from users with push access to that repository. Private repositories are unaffected, and collaborators retain full access to their own content.
294296

297+
> In HTTP mode, server-side lockdown mode (`--lockdown-mode` / `GITHUB_LOCKDOWN_MODE`) is an upper bound: the `X-MCP-Lockdown` header can enable lockdown mode for a request when the operator has not enabled it server-wide, but it cannot disable lockdown mode the operator has already enabled.
298+
299+
Lockdown mode is a best-effort content filter meant to reduce prompt-injection risk from untrusted repository content; it is not an authorization boundary. It does not restrict what the underlying credential can otherwise read or write, and content withheld from a filtered tool response may still be reachable through other tools or direct GitHub API access with the same credential.
300+
301+
As an intentional exception, content authored by trusted bot accounts (currently `github-actions[bot]` and `copilot`) is always treated as safe, regardless of push access, so routine automation output isn't filtered.
302+
295303
**Example:**
296304
<table>
297305
<tr><th>Remote Server</th><th>Local Server</th></tr>

docs/streamable-http.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@ github-mcp-server http --scope-challenge
3232

3333
When `--scope-challenge` is enabled, requests with insufficient scopes receive a `403 Forbidden` response with a `WWW-Authenticate` header indicating the required scopes.
3434

35+
### Repository deletion and request-state encryption
36+
37+
The `delete_repository` tool uses multi-round-trip elicitation and carries its
38+
confirmed target through client-held request state. To expose this tool in HTTP
39+
mode, configure a stable 32-byte encryption key encoded with standard Base64:
40+
41+
```bash
42+
export GITHUB_MCP_SERVER_MRTR_STATE_KEY="$(openssl rand -base64 32 | tr -d '\n')"
43+
github-mcp-server http
44+
```
45+
46+
Use the same key on every replica that may handle a retry. Keep it secret and
47+
stable during deployments; changing it invalidates confirmations already in
48+
flight. If the variable is absent, `delete_repository` is not exposed by the
49+
HTTP server. If it is present but malformed, the server refuses to start.
50+
51+
This self-hosted key is independent of keys used by the hosted remote server.
52+
Integrators can provide their own request-state sealer through the exported
53+
`github.RequestStateSealer` interface and expose it from their tool dependencies
54+
through `github.RequestStateSealerProvider` without changing their key format.
55+
3556
### With OAuth Metadata Discovery
3657

3758
For use behind reverse proxies or with custom domains, expose OAuth metadata endpoints:

internal/ghmcp/oauth.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,6 @@ type oauthAuthenticator interface {
103103
// delayed response from an older prompt from affecting a newer flow.
104104
const oauthElicitIDPrefix = "github_authorization:"
105105

106-
// protocolVersionNoServerElicitation is the first MCP protocol version that
107-
// forbids server-initiated JSON-RPC requests (SEP-2322): from this version on
108-
// the server may not send elicitation/create while serving a request and must
109-
// instead return an InputRequests map from the tool call (multi round-trip
110-
// requests). It mirrors the go-sdk's internal constant of the same value, which
111-
// the SDK does not export.
112-
const protocolVersionNoServerElicitation = "2026-07-28"
113-
114106
// serverMayInitiateElicitation reports whether the server is permitted to send
115107
// elicitation requests to the client itself, which the spec allows only before
116108
// protocol version 2026-07-28. A nil or un-negotiated session (only reached in
@@ -120,7 +112,7 @@ func serverMayInitiateElicitation(ss *mcp.ServerSession) bool {
120112
return true
121113
}
122114
params := ss.InitializeParams()
123-
return params == nil || params.ProtocolVersion < protocolVersionNoServerElicitation
115+
return params == nil || params.ProtocolVersion < inventory.ProtocolVersionMultiRoundTrip
124116
}
125117

126118
// createOAuthToolMiddleware returns tool-handler middleware that authorizes the

internal/ghmcp/oauth_test.go

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import (
77
"log/slog"
88
"net/http"
99
"net/http/httptest"
10+
"net/url"
1011
"testing"
1112

1213
"github.com/github/github-mcp-server/internal/oauth"
1314
"github.com/github/github-mcp-server/pkg/github"
1415
"github.com/github/github-mcp-server/pkg/http/headers"
15-
"github.com/github/github-mcp-server/pkg/utils"
1616
"github.com/google/jsonschema-go/jsonschema"
1717
"github.com/modelcontextprotocol/go-sdk/mcp"
1818
"github.com/stretchr/testify/assert"
@@ -23,6 +23,108 @@ func discardLogger() *slog.Logger {
2323
return slog.New(slog.NewTextHandler(io.Discard, nil))
2424
}
2525

26+
func TestCreateGitHubClientsScopesRESTAndRawTokens(t *testing.T) {
27+
t.Parallel()
28+
29+
var foreignAuth string
30+
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+
foreignAuth = r.Header.Get(headers.AuthorizationHeader)
32+
w.WriteHeader(http.StatusOK)
33+
}))
34+
defer foreign.Close()
35+
36+
var sourceAuth string
37+
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+
sourceAuth = r.Header.Get(headers.AuthorizationHeader)
39+
http.Redirect(w, r, foreign.URL, http.StatusFound)
40+
}))
41+
defer source.Close()
42+
43+
tests := []struct {
44+
name string
45+
cfg github.MCPServerConfig
46+
}{
47+
{
48+
name: "static token",
49+
cfg: github.MCPServerConfig{
50+
Version: "test",
51+
Token: "static-token",
52+
},
53+
},
54+
{
55+
name: "token provider",
56+
cfg: github.MCPServerConfig{
57+
Version: "test",
58+
TokenProvider: func() string { return "provider-token" },
59+
},
60+
},
61+
}
62+
63+
for _, tt := range tests {
64+
t.Run(tt.name, func(t *testing.T) {
65+
apiHost := newStaticAPIHostResolver(t, source.URL)
66+
clients, err := createGitHubClients(tt.cfg, apiHost)
67+
require.NoError(t, err)
68+
69+
sourceAuth = ""
70+
foreignAuth = ""
71+
resp, err := clients.rest.Client().Get(source.URL + "/rest")
72+
require.NoError(t, err)
73+
resp.Body.Close()
74+
assert.NotEmpty(t, sourceAuth, "REST request must authenticate to the configured host")
75+
assert.Empty(t, foreignAuth, "REST redirect must not authenticate to a foreign host")
76+
77+
sourceAuth = ""
78+
foreignAuth = ""
79+
resp, err = clients.raw.GetRawContent(context.Background(), "owner", "repo", "file", nil)
80+
require.NoError(t, err)
81+
resp.Body.Close()
82+
assert.NotEmpty(t, sourceAuth, "raw request must authenticate to the configured host")
83+
assert.Empty(t, foreignAuth, "raw redirect must not authenticate to a foreign host")
84+
})
85+
}
86+
}
87+
88+
type staticAPIHostResolver struct {
89+
restURL *url.URL
90+
graphQLURL *url.URL
91+
uploadURL *url.URL
92+
rawURL *url.URL
93+
}
94+
95+
func newStaticAPIHostResolver(t *testing.T, endpoint string) staticAPIHostResolver {
96+
t.Helper()
97+
98+
u, err := url.Parse(endpoint)
99+
require.NoError(t, err)
100+
return staticAPIHostResolver{
101+
restURL: u,
102+
graphQLURL: u,
103+
uploadURL: u,
104+
rawURL: u,
105+
}
106+
}
107+
108+
func (r staticAPIHostResolver) BaseRESTURL(context.Context) (*url.URL, error) {
109+
return r.restURL, nil
110+
}
111+
112+
func (r staticAPIHostResolver) GraphqlURL(context.Context) (*url.URL, error) {
113+
return r.graphQLURL, nil
114+
}
115+
116+
func (r staticAPIHostResolver) UploadURL(context.Context) (*url.URL, error) {
117+
return r.uploadURL, nil
118+
}
119+
120+
func (r staticAPIHostResolver) RawURL(context.Context) (*url.URL, error) {
121+
return r.rawURL, nil
122+
}
123+
124+
func (r staticAPIHostResolver) AuthorizationServerURL(context.Context) (*url.URL, error) {
125+
return r.restURL, nil
126+
}
127+
26128
// probeToolName is the name of the throwaway tool the harness registers; its
27129
// handler runs a probe closure against a sessionPrompter so the adapter can be
28130
// exercised against a real, fully-negotiated server session from the client side.
@@ -583,8 +685,7 @@ func TestCreateGitHubClientsTokenProvider(t *testing.T) {
583685
defer server.Close()
584686

585687
current := ""
586-
apiHost, err := utils.NewAPIHost(server.URL)
587-
require.NoError(t, err)
688+
apiHost := newStaticAPIHostResolver(t, server.URL)
588689

589690
clients, err := createGitHubClients(github.MCPServerConfig{
590691
Version: "test",

0 commit comments

Comments
 (0)