Skip to content

Commit 572fe53

Browse files
authored
Merge branch 'main' into feature/reply-to-review-comments
2 parents 68546f8 + 3e1fca0 commit 572fe53

File tree

6 files changed

+235
-25
lines changed

6 files changed

+235
-25
lines changed

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,38 @@ To specify toolsets you want available to the LLM, you can pass an allow-list in
345345

346346
The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided.
347347

348+
#### Specifying Individual Tools
349+
350+
You can also configure specific tools using the `--tools` flag. Tools can be used independently or combined with toolsets and dynamic toolsets discovery for fine-grained control.
351+
352+
1. **Using Command Line Argument**:
353+
354+
```bash
355+
github-mcp-server --tools get_file_contents,issue_read,create_pull_request
356+
```
357+
358+
2. **Using Environment Variable**:
359+
```bash
360+
GITHUB_TOOLS="get_file_contents,issue_read,create_pull_request" ./github-mcp-server
361+
```
362+
363+
3. **Combining with Toolsets** (additive):
364+
```bash
365+
github-mcp-server --toolsets repos,issues --tools get_gist
366+
```
367+
This registers all tools from `repos` and `issues` toolsets, plus `get_gist`.
368+
369+
4. **Combining with Dynamic Toolsets** (additive):
370+
```bash
371+
github-mcp-server --tools get_file_contents --dynamic-toolsets
372+
```
373+
This registers `get_file_contents` plus the dynamic toolset tools (`enable_toolset`, `list_available_toolsets`, `get_toolset_tools`).
374+
375+
**Important Notes:**
376+
- Tools, toolsets, and dynamic toolsets can all be used together
377+
- Read-only mode takes priority: write tools are skipped if `--read-only` is set, even if explicitly requested via `--tools`
378+
- Tool names must match exactly (e.g., `get_file_contents`, not `getFileContents`). Invalid tool names will cause the server to fail at startup with an error message
379+
348380
### Using Toolsets With Docker
349381

350382
When using Docker, you can pass the toolsets as environment variables:
@@ -356,6 +388,25 @@ docker run -i --rm \
356388
ghcr.io/github/github-mcp-server
357389
```
358390

391+
### Using Tools With Docker
392+
393+
When using Docker, you can pass specific tools as environment variables. You can also combine tools with toolsets:
394+
395+
```bash
396+
# Tools only
397+
docker run -i --rm \
398+
-e GITHUB_PERSONAL_ACCESS_TOKEN=<your-token> \
399+
-e GITHUB_TOOLS="get_file_contents,issue_read,create_pull_request" \
400+
ghcr.io/github/github-mcp-server
401+
402+
# Tools combined with toolsets (additive)
403+
docker run -i --rm \
404+
-e GITHUB_PERSONAL_ACCESS_TOKEN=<your-token> \
405+
-e GITHUB_TOOLSETS="repos,issues" \
406+
-e GITHUB_TOOLS="get_gist" \
407+
ghcr.io/github/github-mcp-server
408+
```
409+
359410
### Special toolsets
360411

361412
#### "all" toolset

cmd/github-mcp-server/main.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@ var (
4646
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
4747
}
4848

49-
// No passed toolsets configuration means we enable the default toolset
50-
if len(enabledToolsets) == 0 {
49+
// Parse tools (similar to toolsets)
50+
var enabledTools []string
51+
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
52+
return fmt.Errorf("failed to unmarshal tools: %w", err)
53+
}
54+
55+
// If neither toolset config nor tools config is passed we enable the default toolset
56+
if len(enabledToolsets) == 0 && len(enabledTools) == 0 {
5157
enabledToolsets = []string{github.ToolsetMetadataDefault.ID}
5258
}
5359

@@ -57,6 +63,7 @@ var (
5763
Host: viper.GetString("host"),
5864
Token: token,
5965
EnabledToolsets: enabledToolsets,
66+
EnabledTools: enabledTools,
6067
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
6168
ReadOnly: viper.GetBool("read-only"),
6269
ExportTranslations: viper.GetBool("export-translations"),
@@ -79,6 +86,7 @@ func init() {
7986

8087
// Add global flags that will be shared by all commands
8188
rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp())
89+
rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable")
8290
rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets")
8391
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
8492
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
@@ -91,6 +99,7 @@ func init() {
9199

92100
// Bind flag to viper
93101
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
102+
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
94103
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
95104
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
96105
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))

internal/ghmcp/server.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ type MCPServerConfig struct {
4040
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
4141
EnabledToolsets []string
4242

43+
// EnabledTools is a list of specific tools to enable (additive to toolsets)
44+
// When specified, these tools are registered in addition to any specified toolset tools
45+
EnabledTools []string
46+
4347
// Whether to enable dynamic toolsets
4448
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
4549
DynamicToolsets bool
@@ -62,7 +66,7 @@ type MCPServerConfig struct {
6266

6367
const stdioServerLogPrefix = "stdioserver"
6468

65-
func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
69+
func NewMCPServer(cfg MCPServerConfig, logger *slog.Logger) (*server.MCPServer, error) {
6670
apiHost, err := parseAPIHost(cfg.Host)
6771
if err != nil {
6872
return nil, fmt.Errorf("failed to parse API host: %w", err)
@@ -88,6 +92,9 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
8892
if cfg.RepoAccessTTL != nil {
8993
repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessTTL))
9094
}
95+
96+
repoAccessLogger := logger.With("component", "lockdown")
97+
repoAccessOpts = append(repoAccessOpts, lockdown.WithLogger(repoAccessLogger))
9198
var repoAccessCache *lockdown.RepoAccessCache
9299
if cfg.LockdownMode {
93100
repoAccessCache = lockdown.GetInstance(gqlClient, repoAccessOpts...)
@@ -179,15 +186,32 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
179186
github.FeatureFlags{LockdownMode: cfg.LockdownMode},
180187
repoAccessCache,
181188
)
182-
err = tsg.EnableToolsets(enabledToolsets, nil)
183189

184-
if err != nil {
185-
return nil, fmt.Errorf("failed to enable toolsets: %w", err)
190+
// Enable and register toolsets if configured
191+
// This always happens if toolsets are specified, regardless of whether tools are also specified
192+
if len(enabledToolsets) > 0 {
193+
err = tsg.EnableToolsets(enabledToolsets, nil)
194+
if err != nil {
195+
return nil, fmt.Errorf("failed to enable toolsets: %w", err)
196+
}
197+
198+
// Register all mcp functionality with the server
199+
tsg.RegisterAll(ghServer)
186200
}
187201

188-
// Register all mcp functionality with the server
189-
tsg.RegisterAll(ghServer)
202+
// Register specific tools if configured
203+
if len(cfg.EnabledTools) > 0 {
204+
// Clean and validate tool names
205+
enabledTools := github.CleanTools(cfg.EnabledTools)
190206

207+
// Register the specified tools (additive to any toolsets already enabled)
208+
err = tsg.RegisterSpecificTools(ghServer, enabledTools, cfg.ReadOnly)
209+
if err != nil {
210+
return nil, fmt.Errorf("failed to register tools: %w", err)
211+
}
212+
}
213+
214+
// Register dynamic toolsets if configured (additive to toolsets and tools)
191215
if cfg.DynamicToolsets {
192216
dynamic := github.InitDynamicToolset(ghServer, tsg, cfg.Translator)
193217
dynamic.RegisterTools(ghServer)
@@ -210,6 +234,10 @@ type StdioServerConfig struct {
210234
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
211235
EnabledToolsets []string
212236

237+
// EnabledTools is a list of specific tools to enable (additive to toolsets)
238+
// When specified, these tools are registered in addition to any specified toolset tools
239+
EnabledTools []string
240+
213241
// Whether to enable dynamic toolsets
214242
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
215243
DynamicToolsets bool
@@ -267,13 +295,14 @@ func RunStdioServer(cfg StdioServerConfig) error {
267295
Host: cfg.Host,
268296
Token: cfg.Token,
269297
EnabledToolsets: cfg.EnabledToolsets,
298+
EnabledTools: cfg.EnabledTools,
270299
DynamicToolsets: cfg.DynamicToolsets,
271300
ReadOnly: cfg.ReadOnly,
272301
Translator: t,
273302
ContentWindowSize: cfg.ContentWindowSize,
274303
LockdownMode: cfg.LockdownMode,
275304
RepoAccessTTL: cfg.RepoAccessCacheTTL,
276-
})
305+
}, logger)
277306
if err != nil {
278307
return fmt.Errorf("failed to create MCP server: %w", err)
279308
}

pkg/github/tools.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,3 +525,24 @@ func ContainsToolset(tools []string, toCheck string) bool {
525525
}
526526
return false
527527
}
528+
529+
// CleanTools cleans tool names by removing duplicates and trimming whitespace.
530+
// Validation of tool existence is done during registration.
531+
func CleanTools(toolNames []string) []string {
532+
seen := make(map[string]bool)
533+
result := make([]string, 0, len(toolNames))
534+
535+
// Remove duplicates and trim whitespace
536+
for _, tool := range toolNames {
537+
trimmed := strings.TrimSpace(tool)
538+
if trimmed == "" {
539+
continue
540+
}
541+
if !seen[trimmed] {
542+
seen[trimmed] = true
543+
result = append(result, trimmed)
544+
}
545+
}
546+
547+
return result
548+
}

pkg/lockdown/lockdown.go

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ import (
1515
// RepoAccessCache caches repository metadata related to lockdown checks so that
1616
// multiple tools can reuse the same access information safely across goroutines.
1717
type RepoAccessCache struct {
18-
client *githubv4.Client
19-
mu sync.Mutex
20-
cache *cache2go.CacheTable
21-
ttl time.Duration
22-
logger *slog.Logger
18+
client *githubv4.Client
19+
mu sync.Mutex
20+
cache *cache2go.CacheTable
21+
ttl time.Duration
22+
logger *slog.Logger
23+
trustedBotLogins map[string]struct{}
2324
}
2425

2526
type repoAccessCacheEntry struct {
@@ -85,6 +86,9 @@ func GetInstance(client *githubv4.Client, opts ...RepoAccessOption) *RepoAccessC
8586
client: client,
8687
cache: cache2go.Cache(defaultRepoAccessCacheKey),
8788
ttl: defaultRepoAccessTTL,
89+
trustedBotLogins: map[string]struct{}{
90+
"copilot": {},
91+
},
8892
}
8993
for _, opt := range opts {
9094
if opt != nil {
@@ -109,13 +113,22 @@ type CacheStats struct {
109113
Evictions int64
110114
}
111115

116+
// IsSafeContent determines if the specified user can safely access the requested repository content.
117+
// Safe access applies when any of the following is true:
118+
// - the content was created by a trusted bot;
119+
// - the author currently has push access to the repository;
120+
// - the repository is private;
121+
// - the content was created by the viewer.
112122
func (c *RepoAccessCache) IsSafeContent(ctx context.Context, username, owner, repo string) (bool, error) {
113123
repoInfo, err := c.getRepoAccessInfo(ctx, username, owner, repo)
114124
if err != nil {
115-
c.logDebug("error checking repo access info for content filtering", "owner", owner, "repo", repo, "user", username, "error", err)
116125
return false, err
117126
}
118-
if repoInfo.IsPrivate || repoInfo.ViewerLogin == username {
127+
128+
c.logDebug(ctx, fmt.Sprintf("evaluated repo access for user %s to %s/%s for content filtering, result: hasPushAccess=%t, isPrivate=%t",
129+
username, owner, repo, repoInfo.HasPushAccess, repoInfo.IsPrivate))
130+
131+
if c.isTrustedBot(username) || repoInfo.IsPrivate || repoInfo.ViewerLogin == strings.ToLower(username) {
119132
return true, nil
120133
}
121134
return repoInfo.HasPushAccess, nil
@@ -136,30 +149,34 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner
136149
if err == nil {
137150
entry := cacheItem.Data().(*repoAccessCacheEntry)
138151
if cachedHasPush, known := entry.knownUsers[userKey]; known {
139-
c.logDebug("repo access cache hit", "owner", owner, "repo", repo, "user", username)
152+
c.logDebug(ctx, fmt.Sprintf("repo access cache hit for user %s to %s/%s", username, owner, repo))
140153
return RepoAccessInfo{
141154
IsPrivate: entry.isPrivate,
142155
HasPushAccess: cachedHasPush,
143156
ViewerLogin: entry.viewerLogin,
144157
}, nil
145158
}
146-
c.logDebug("known users cache miss", "owner", owner, "repo", repo, "user", username)
159+
160+
c.logDebug(ctx, "known users cache miss, fetching from graphql API")
161+
147162
info, queryErr := c.queryRepoAccessInfo(ctx, username, owner, repo)
148163
if queryErr != nil {
149164
return RepoAccessInfo{}, queryErr
150165
}
166+
151167
entry.knownUsers[userKey] = info.HasPushAccess
152168
entry.viewerLogin = info.ViewerLogin
153169
entry.isPrivate = info.IsPrivate
154170
c.cache.Add(key, c.ttl, entry)
171+
155172
return RepoAccessInfo{
156173
IsPrivate: entry.isPrivate,
157174
HasPushAccess: entry.knownUsers[userKey],
158175
ViewerLogin: entry.viewerLogin,
159176
}, nil
160177
}
161178

162-
c.logDebug("repo access cache miss", "owner", owner, "repo", repo, "user", username)
179+
c.logDebug(ctx, fmt.Sprintf("repo access cache miss for user %s to %s/%s", username, owner, repo))
163180

164181
info, queryErr := c.queryRepoAccessInfo(ctx, username, owner, repo)
165182
if queryErr != nil {
@@ -223,19 +240,35 @@ func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, username, own
223240
}
224241
}
225242

243+
c.logDebug(ctx, fmt.Sprintf("queried repo access info for user %s to %s/%s: isPrivate=%t, hasPushAccess=%t, viewerLogin=%s",
244+
username, owner, repo, bool(query.Repository.IsPrivate), hasPush, query.Viewer.Login))
245+
226246
return RepoAccessInfo{
227247
IsPrivate: bool(query.Repository.IsPrivate),
228248
HasPushAccess: hasPush,
229249
ViewerLogin: string(query.Viewer.Login),
230250
}, nil
231251
}
232252

233-
func cacheKey(owner, repo string) string {
234-
return fmt.Sprintf("%s/%s", strings.ToLower(owner), strings.ToLower(repo))
253+
func (c *RepoAccessCache) log(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) {
254+
if c == nil || c.logger == nil {
255+
return
256+
}
257+
if !c.logger.Enabled(ctx, level) {
258+
return
259+
}
260+
c.logger.LogAttrs(ctx, level, msg, attrs...)
235261
}
236262

237-
func (c *RepoAccessCache) logDebug(msg string, args ...any) {
238-
if c != nil && c.logger != nil {
239-
c.logger.Debug(msg, args...)
240-
}
263+
func (c *RepoAccessCache) logDebug(ctx context.Context, msg string, attrs ...slog.Attr) {
264+
c.log(ctx, slog.LevelDebug, msg, attrs...)
265+
}
266+
267+
func (c *RepoAccessCache) isTrustedBot(username string) bool {
268+
_, ok := c.trustedBotLogins[strings.ToLower(username)]
269+
return ok
270+
}
271+
272+
func cacheKey(owner, repo string) string {
273+
return fmt.Sprintf("%s/%s", strings.ToLower(owner), strings.ToLower(repo))
241274
}

0 commit comments

Comments
 (0)