From 8a6accbbe18db0bde95647841f92bf4ff1628c3c Mon Sep 17 00:00:00 2001 From: Tony Truong Date: Tue, 25 Nov 2025 19:18:44 +0100 Subject: [PATCH 1/4] replacing all with default (#1489) --- docs/remote-server.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/remote-server.md b/docs/remote-server.md index 5ee6aea64..ec6d2302d 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| Default | ["Default" toolset](../README.md#default-toolset) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | @@ -99,4 +99,4 @@ Example: "type": "http", "url": "https://api.githubcopilot.com/mcp/x/issues/readonly" } -``` \ No newline at end of file +``` From 781a95f5bce75afe42b34e546e9a970c0355314b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 19:19:25 +0100 Subject: [PATCH 2/4] build(deps): bump actions/checkout from 5 to 6 (#1480) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/code-scanning.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- .github/workflows/docs-check.yml | 2 +- .github/workflows/go.yml | 2 +- .github/workflows/goreleaser.yml | 2 +- .github/workflows/license-check.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/moderator.yml | 2 +- .github/workflows/registry-releaser.yml | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 2dcb43003..7dda8c9bd 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -35,7 +35,7 @@ jobs: runner: '["ubuntu-22.04"]' steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index baaf6c2f0..af5fd5bbf 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Install the cosign tool except on PR # https://github.com/sigstore/cosign-installer diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index a9227d702..5084a78a1 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 2c6204e59..9fca37208 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 0de25c770..167760cba 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index ac74dd15c..d9cb59fb7 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a69d9a569..a1647446f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version: stable diff --git a/.github/workflows/moderator.yml b/.github/workflows/moderator.yml index a7a1d22da..0805a0840 100644 --- a/.github/workflows/moderator.yml +++ b/.github/workflows/moderator.yml @@ -16,7 +16,7 @@ jobs: models: read contents: read steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: github/ai-moderator@v1 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/registry-releaser.yml b/.github/workflows/registry-releaser.yml index 7b793785d..5e76f2dc6 100644 --- a/.github/workflows/registry-releaser.yml +++ b/.github/workflows/registry-releaser.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Go uses: actions/setup-go@v6 From 7cfb3547285cd42b13123557154b8d8f5f97058b Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 26 Nov 2025 11:24:23 +0100 Subject: [PATCH 3/4] Dont filter content from Copilot (#1464) * Dont filter content from trusted bots * Final changes * Use only debug level * Add logs and comments --- internal/ghmcp/server.go | 7 +++-- pkg/lockdown/lockdown.go | 65 ++++++++++++++++++++++++++++++---------- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 15b1efc10..970d230ab 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -62,7 +62,7 @@ type MCPServerConfig struct { const stdioServerLogPrefix = "stdioserver" -func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { +func NewMCPServer(cfg MCPServerConfig, logger *slog.Logger) (*server.MCPServer, error) { apiHost, err := parseAPIHost(cfg.Host) if err != nil { return nil, fmt.Errorf("failed to parse API host: %w", err) @@ -88,6 +88,9 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { if cfg.RepoAccessTTL != nil { repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessTTL)) } + + repoAccessLogger := logger.With("component", "lockdown") + repoAccessOpts = append(repoAccessOpts, lockdown.WithLogger(repoAccessLogger)) var repoAccessCache *lockdown.RepoAccessCache if cfg.LockdownMode { repoAccessCache = lockdown.GetInstance(gqlClient, repoAccessOpts...) @@ -273,7 +276,7 @@ func RunStdioServer(cfg StdioServerConfig) error { ContentWindowSize: cfg.ContentWindowSize, LockdownMode: cfg.LockdownMode, RepoAccessTTL: cfg.RepoAccessCacheTTL, - }) + }, logger) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/pkg/lockdown/lockdown.go b/pkg/lockdown/lockdown.go index 4c3500440..80eca07f8 100644 --- a/pkg/lockdown/lockdown.go +++ b/pkg/lockdown/lockdown.go @@ -15,11 +15,12 @@ import ( // RepoAccessCache caches repository metadata related to lockdown checks so that // multiple tools can reuse the same access information safely across goroutines. type RepoAccessCache struct { - client *githubv4.Client - mu sync.Mutex - cache *cache2go.CacheTable - ttl time.Duration - logger *slog.Logger + client *githubv4.Client + mu sync.Mutex + cache *cache2go.CacheTable + ttl time.Duration + logger *slog.Logger + trustedBotLogins map[string]struct{} } type repoAccessCacheEntry struct { @@ -85,6 +86,9 @@ func GetInstance(client *githubv4.Client, opts ...RepoAccessOption) *RepoAccessC client: client, cache: cache2go.Cache(defaultRepoAccessCacheKey), ttl: defaultRepoAccessTTL, + trustedBotLogins: map[string]struct{}{ + "copilot": {}, + }, } for _, opt := range opts { if opt != nil { @@ -109,13 +113,22 @@ type CacheStats struct { Evictions int64 } +// IsSafeContent determines if the specified user can safely access the requested repository content. +// Safe access applies when any of the following is true: +// - the content was created by a trusted bot; +// - the author currently has push access to the repository; +// - the repository is private; +// - the content was created by the viewer. func (c *RepoAccessCache) IsSafeContent(ctx context.Context, username, owner, repo string) (bool, error) { repoInfo, err := c.getRepoAccessInfo(ctx, username, owner, repo) if err != nil { - c.logDebug("error checking repo access info for content filtering", "owner", owner, "repo", repo, "user", username, "error", err) return false, err } - if repoInfo.IsPrivate || repoInfo.ViewerLogin == username { + + c.logDebug(ctx, fmt.Sprintf("evaluated repo access for user %s to %s/%s for content filtering, result: hasPushAccess=%t, isPrivate=%t", + username, owner, repo, repoInfo.HasPushAccess, repoInfo.IsPrivate)) + + if c.isTrustedBot(username) || repoInfo.IsPrivate || repoInfo.ViewerLogin == strings.ToLower(username) { return true, nil } return repoInfo.HasPushAccess, nil @@ -136,22 +149,26 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner if err == nil { entry := cacheItem.Data().(*repoAccessCacheEntry) if cachedHasPush, known := entry.knownUsers[userKey]; known { - c.logDebug("repo access cache hit", "owner", owner, "repo", repo, "user", username) + c.logDebug(ctx, fmt.Sprintf("repo access cache hit for user %s to %s/%s", username, owner, repo)) return RepoAccessInfo{ IsPrivate: entry.isPrivate, HasPushAccess: cachedHasPush, ViewerLogin: entry.viewerLogin, }, nil } - c.logDebug("known users cache miss", "owner", owner, "repo", repo, "user", username) + + c.logDebug(ctx, "known users cache miss, fetching from graphql API") + info, queryErr := c.queryRepoAccessInfo(ctx, username, owner, repo) if queryErr != nil { return RepoAccessInfo{}, queryErr } + entry.knownUsers[userKey] = info.HasPushAccess entry.viewerLogin = info.ViewerLogin entry.isPrivate = info.IsPrivate c.cache.Add(key, c.ttl, entry) + return RepoAccessInfo{ IsPrivate: entry.isPrivate, HasPushAccess: entry.knownUsers[userKey], @@ -159,7 +176,7 @@ func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner }, nil } - c.logDebug("repo access cache miss", "owner", owner, "repo", repo, "user", username) + c.logDebug(ctx, fmt.Sprintf("repo access cache miss for user %s to %s/%s", username, owner, repo)) info, queryErr := c.queryRepoAccessInfo(ctx, username, owner, repo) if queryErr != nil { @@ -223,6 +240,9 @@ func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, username, own } } + c.logDebug(ctx, fmt.Sprintf("queried repo access info for user %s to %s/%s: isPrivate=%t, hasPushAccess=%t, viewerLogin=%s", + username, owner, repo, bool(query.Repository.IsPrivate), hasPush, query.Viewer.Login)) + return RepoAccessInfo{ IsPrivate: bool(query.Repository.IsPrivate), HasPushAccess: hasPush, @@ -230,12 +250,25 @@ func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, username, own }, nil } -func cacheKey(owner, repo string) string { - return fmt.Sprintf("%s/%s", strings.ToLower(owner), strings.ToLower(repo)) +func (c *RepoAccessCache) log(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) { + if c == nil || c.logger == nil { + return + } + if !c.logger.Enabled(ctx, level) { + return + } + c.logger.LogAttrs(ctx, level, msg, attrs...) } -func (c *RepoAccessCache) logDebug(msg string, args ...any) { - if c != nil && c.logger != nil { - c.logger.Debug(msg, args...) - } +func (c *RepoAccessCache) logDebug(ctx context.Context, msg string, attrs ...slog.Attr) { + c.log(ctx, slog.LevelDebug, msg, attrs...) +} + +func (c *RepoAccessCache) isTrustedBot(username string) bool { + _, ok := c.trustedBotLogins[strings.ToLower(username)] + return ok +} + +func cacheKey(owner, repo string) string { + return fmt.Sprintf("%s/%s", strings.ToLower(owner), strings.ToLower(repo)) } From 3e1fca0cc55ca62bc4676687e38970453a0ac066 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:20:01 +0000 Subject: [PATCH 4/4] Tommy/tool-specific-config-support (#1394) * add enabledTools to StdioServerConfig * add EnabledTools to MCPServerConfig, and logic to bypass toolset config if present * add logic to register specific tools * update readme * Update to be consistent with: https://docs.google.com/document/d/1tOOBJ4y9xY61QVrO18ymuVt4SO9nV-z2B4ckaL2f9IU/edit?tab=t.0#heading=h.ffto4e5dwzlf specifically - allow for --tools and dynamic toolset mode together - allow for --tools and --toolsets together * go mod tidy * update * clean up comment * fix * fix * updte * update * clean up --- README.md | 51 ++++++++++++++++++++++++++ cmd/github-mcp-server/main.go | 13 +++++-- internal/ghmcp/server.go | 36 ++++++++++++++++--- pkg/github/tools.go | 21 +++++++++++ pkg/toolsets/toolsets.go | 67 +++++++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c9a1fd70b..d6925d8ab 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,38 @@ To specify toolsets you want available to the LLM, you can pass an allow-list in The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided. +#### Specifying Individual Tools + +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. + +1. **Using Command Line Argument**: + + ```bash + github-mcp-server --tools get_file_contents,issue_read,create_pull_request + ``` + +2. **Using Environment Variable**: + ```bash + GITHUB_TOOLS="get_file_contents,issue_read,create_pull_request" ./github-mcp-server + ``` + +3. **Combining with Toolsets** (additive): + ```bash + github-mcp-server --toolsets repos,issues --tools get_gist + ``` + This registers all tools from `repos` and `issues` toolsets, plus `get_gist`. + +4. **Combining with Dynamic Toolsets** (additive): + ```bash + github-mcp-server --tools get_file_contents --dynamic-toolsets + ``` + This registers `get_file_contents` plus the dynamic toolset tools (`enable_toolset`, `list_available_toolsets`, `get_toolset_tools`). + +**Important Notes:** +- Tools, toolsets, and dynamic toolsets can all be used together +- Read-only mode takes priority: write tools are skipped if `--read-only` is set, even if explicitly requested via `--tools` +- 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 + ### Using Toolsets With Docker When using Docker, you can pass the toolsets as environment variables: @@ -356,6 +388,25 @@ docker run -i --rm \ ghcr.io/github/github-mcp-server ``` +### Using Tools With Docker + +When using Docker, you can pass specific tools as environment variables. You can also combine tools with toolsets: + +```bash +# Tools only +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_TOOLS="get_file_contents,issue_read,create_pull_request" \ + ghcr.io/github/github-mcp-server + +# Tools combined with toolsets (additive) +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_TOOLSETS="repos,issues" \ + -e GITHUB_TOOLS="get_gist" \ + ghcr.io/github/github-mcp-server +``` + ### Special toolsets #### "all" toolset diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 3d4113644..87eeedd2e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -46,8 +46,14 @@ var ( return fmt.Errorf("failed to unmarshal toolsets: %w", err) } - // No passed toolsets configuration means we enable the default toolset - if len(enabledToolsets) == 0 { + // Parse tools (similar to toolsets) + var enabledTools []string + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } + + // If neither toolset config nor tools config is passed we enable the default toolset + if len(enabledToolsets) == 0 && len(enabledTools) == 0 { enabledToolsets = []string{github.ToolsetMetadataDefault.ID} } @@ -57,6 +63,7 @@ var ( Host: viper.GetString("host"), Token: token, EnabledToolsets: enabledToolsets, + EnabledTools: enabledTools, DynamicToolsets: viper.GetBool("dynamic_toolsets"), ReadOnly: viper.GetBool("read-only"), ExportTranslations: viper.GetBool("export-translations"), @@ -79,6 +86,7 @@ func init() { // Add global flags that will be shared by all commands rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp()) + rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable") rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") rootCmd.PersistentFlags().String("log-file", "", "Path to log file") @@ -91,6 +99,7 @@ func init() { // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) + _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) _ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) _ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file")) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 970d230ab..26b0024c9 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -40,6 +40,10 @@ type MCPServerConfig struct { // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string + // EnabledTools is a list of specific tools to enable (additive to toolsets) + // When specified, these tools are registered in addition to any specified toolset tools + EnabledTools []string + // Whether to enable dynamic toolsets // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery DynamicToolsets bool @@ -182,15 +186,32 @@ func NewMCPServer(cfg MCPServerConfig, logger *slog.Logger) (*server.MCPServer, github.FeatureFlags{LockdownMode: cfg.LockdownMode}, repoAccessCache, ) - err = tsg.EnableToolsets(enabledToolsets, nil) - if err != nil { - return nil, fmt.Errorf("failed to enable toolsets: %w", err) + // Enable and register toolsets if configured + // This always happens if toolsets are specified, regardless of whether tools are also specified + if len(enabledToolsets) > 0 { + err = tsg.EnableToolsets(enabledToolsets, nil) + if err != nil { + return nil, fmt.Errorf("failed to enable toolsets: %w", err) + } + + // Register all mcp functionality with the server + tsg.RegisterAll(ghServer) } - // Register all mcp functionality with the server - tsg.RegisterAll(ghServer) + // Register specific tools if configured + if len(cfg.EnabledTools) > 0 { + // Clean and validate tool names + enabledTools := github.CleanTools(cfg.EnabledTools) + // Register the specified tools (additive to any toolsets already enabled) + err = tsg.RegisterSpecificTools(ghServer, enabledTools, cfg.ReadOnly) + if err != nil { + return nil, fmt.Errorf("failed to register tools: %w", err) + } + } + + // Register dynamic toolsets if configured (additive to toolsets and tools) if cfg.DynamicToolsets { dynamic := github.InitDynamicToolset(ghServer, tsg, cfg.Translator) dynamic.RegisterTools(ghServer) @@ -213,6 +234,10 @@ type StdioServerConfig struct { // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string + // EnabledTools is a list of specific tools to enable (additive to toolsets) + // When specified, these tools are registered in addition to any specified toolset tools + EnabledTools []string + // Whether to enable dynamic toolsets // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery DynamicToolsets bool @@ -270,6 +295,7 @@ func RunStdioServer(cfg StdioServerConfig) error { Host: cfg.Host, Token: cfg.Token, EnabledToolsets: cfg.EnabledToolsets, + EnabledTools: cfg.EnabledTools, DynamicToolsets: cfg.DynamicToolsets, ReadOnly: cfg.ReadOnly, Translator: t, diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 74f3d52f2..a5605ec04 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -524,3 +524,24 @@ func ContainsToolset(tools []string, toCheck string) bool { } return false } + +// CleanTools cleans tool names by removing duplicates and trimming whitespace. +// Validation of tool existence is done during registration. +func CleanTools(toolNames []string) []string { + seen := make(map[string]bool) + result := make([]string, 0, len(toolNames)) + + // Remove duplicates and trim whitespace + for _, tool := range toolNames { + trimmed := strings.TrimSpace(tool) + if trimmed == "" { + continue + } + if !seen[trimmed] { + seen[trimmed] = true + result = append(result, trimmed) + } + } + + return result +} diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 96f1fc3ca..ba68649e3 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -2,6 +2,8 @@ package toolsets import ( "fmt" + "os" + "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -263,3 +265,68 @@ func (tg *ToolsetGroup) GetToolset(name string) (*Toolset, error) { } return toolset, nil } + +type ToolDoesNotExistError struct { + Name string +} + +func (e *ToolDoesNotExistError) Error() string { + return fmt.Sprintf("tool %s does not exist", e.Name) +} + +func NewToolDoesNotExistError(name string) *ToolDoesNotExistError { + return &ToolDoesNotExistError{Name: name} +} + +// FindToolByName searches all toolsets (enabled or disabled) for a tool by name. +// Returns the tool, its parent toolset name, and an error if not found. +func (tg *ToolsetGroup) FindToolByName(toolName string) (*server.ServerTool, string, error) { + for toolsetName, toolset := range tg.Toolsets { + // Check read tools + for _, tool := range toolset.readTools { + if tool.Tool.Name == toolName { + return &tool, toolsetName, nil + } + } + // Check write tools + for _, tool := range toolset.writeTools { + if tool.Tool.Name == toolName { + return &tool, toolsetName, nil + } + } + } + return nil, "", NewToolDoesNotExistError(toolName) +} + +// RegisterSpecificTools registers only the specified tools. +// Respects read-only mode (skips write tools if readOnly=true). +// Returns error if any tool is not found. +func (tg *ToolsetGroup) RegisterSpecificTools(s *server.MCPServer, toolNames []string, readOnly bool) error { + var skippedTools []string + for _, toolName := range toolNames { + tool, _, err := tg.FindToolByName(toolName) + if err != nil { + return fmt.Errorf("tool %s not found: %w", toolName, err) + } + + // Check if it's a write tool and we're in read-only mode + if tool.Tool.Annotations.ReadOnlyHint != nil { + isWriteTool := !*tool.Tool.Annotations.ReadOnlyHint + if isWriteTool && readOnly { + // Skip write tools in read-only mode + skippedTools = append(skippedTools, toolName) + continue + } + } + + // Register the tool + s.AddTool(tool.Tool, tool.Handler) + } + + // Log skipped write tools if any + if len(skippedTools) > 0 { + fmt.Fprintf(os.Stderr, "Write tools skipped due to read-only mode: %s\n", strings.Join(skippedTools, ", ")) + } + + return nil +}